From a380a822342c4e399b200f40204edf705a00e906 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:46:56 +0100 Subject: [PATCH 001/262] build: raise Gradle daemon heap to avoid intermittent CI OOM (#7151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The `build (25, saas)` CI job intermittently fails with: ``` The Daemon will expire immediately since the JVM garbage collector is thrashing. The currently configured max heap space is '512 MiB' and the configured max metaspace is '384 MiB'. FAILURE: Build failed with an exception. * What went wrong: Gradle build daemon has been stopped: since the JVM garbage collector is thrashing ``` `gradle.properties` never set `org.gradle.jvmargs`, so the daemon runs on Gradle's 512 MiB default heap. The larger builds — the `saas` flavor in particular, which compiles core + proprietary + saas — exhaust it under `org.gradle.parallel=true`, and the daemon dies mid-build. It's flaky (passes on re-run), which makes it a recurring, noisy CI failure. ## Fix ```properties org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g ``` 2 GiB heap + 1 GiB metaspace gives comfortable headroom on GitHub-hosted runners and typical dev machines, well clear of the thrash point. One-line, repo-wide config change. ## Verification - `./gradlew help` starts the daemon cleanly with the new args (no malformed-arg failure). - The real signal is CI: this branch's `build (25, saas)` should stop OOM-ing. Split out from #7048 (Plan & Usage) since it's unrelated build infrastructure. --- gradle.properties | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gradle.properties b/gradle.properties index e99fbdc918..0e8306a444 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,9 @@ +# Gradle daemon heap. The default (512 MiB) is exhausted by the larger builds +# (notably the saas flavor) under parallel execution, causing intermittent +# "JVM garbage collector is thrashing" daemon deaths in CI. 2 GiB heap + 1 GiB +# metaspace gives comfortable headroom on CI runners and typical dev machines. +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g + # Enables parallel execution of tasks, allowing multiple tasks to run simultaneously org.gradle.parallel=true From ba404d3f903e82e446a57a50dcc86a219e8df2fb Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:05:20 +0100 Subject: [PATCH 002/262] PAYG bundle: server-authoritative price (inline amount_off coupon) + #7032 review nits (#7156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Follow-up to #7032. Makes the prepaid-bundle price **server-authoritative** and removes the percent-coupon rounding drift, by switching the 12-for-10 discount from a pre-made `percent_off` Stripe coupon to an **edge-function-computed inline `amount_off` coupon**. Also folds in Ethan's #7032 review nits. This is a money-mechanism change, so it was verified against the Deno tests and is ready for a V2-preview check before rollout. ## SaaS side — already on `v3` (purely additive) The edge fn + migration were pushed **directly to `v3`** (commit `4534ff1c1`), since the DB change is purely additive (a backward-compatible function replacement — no table/column/data changes): - `create-payg-bundle-quote`: retrieves the Stripe Price for the bundle, computes `subtotal = unit_amount x pool_credits` (falls back to `round(unit_amount_decimal x pool_credits)`), `discount = round(subtotal x 2 / 12)`, `total = subtotal - discount`; mints a single-use fixed-amount coupon (`amount_off`, `duration: once`, `max_redemptions: 1`, `redeem_by = valid_until`) and applies it instead of the stored percent coupon; persists `total` via `p_price_minor`. - Migration `20260803000000_payg_bundle_quote_stripe_price_minor.sql`: `payg_set_bundle_quote_stripe` gains `p_price_minor BIGINT DEFAULT NULL` → `price_minor = COALESCE(p_price_minor, price_minor)`. **Deploy choreography (important):** the migration must apply **before** the edge fn is deployed — the fn now calls the 4-arg `payg_set_bundle_quote_stripe`. #7032's own Supabase migration is already on `main`/`v3`. ## This PR (FE) - **Server-authoritative price:** `bundlePriceMinor` now computes `subtotal - round(subtotal x (granted-paid)/granted)` (round the discount, then subtract) — identical to the edge fn — so the pre-mint estimate matches the `amount_off` charged, and the persisted/frozen total, to the penny (they previously diverged by a minor unit on exact-half ties). Tie-case test added. ### Ethan's #7032 review nits - **1** — comments in `ActivationChoiceModal` / `FreePlanView` no longer assert the metered subscription is auto-provisioned off the saved card; they describe it as a known, not-yet-wired follow-up. - **2** — corrected the price-authority narrative (`stripe.ts`, `BundleCheckoutModal`): the client-sent `p_price_minor` is a pre-mint **display estimate only**; the edge fn overwrites `price_minor` with the server total once the quote is minted. **Verified** the edge fn builds the Stripe line from `bundle_price_id x pool_credits` with `amount_off` from the retrieved Price — it never uses the client price. - **4** — `ensureStripeQuote`'s reuse key now includes the posture/size/pipeline ids (`buildStripeQuoteSig`), not just pool+PO, so a same-pool sizing edit re-mints and re-persists instead of leaving stale sizing on the row. - **5** — `SpendLimitPicker`: a cleared field (maps to `0`) can no longer proceed as a `$0` cap — the cap-step Continue is disabled and `handleContinue` guards on it (empty = incomplete, distinct from the explicit `null` "No limit"). - **6** — `"prepaid PDFs"` code fallbacks aligned to the `"prepaid credits"` TOML (`usageMeters`, `PrepaidCapacityCard`). ## Testing - SaaS Deno: **25/25** (coupon `amount_off == round(subtotal*2/12)`, `p_price_minor == total` persisted, `unit_amount_decimal` fallback, exact-half tie, zero-discount path, price/coupon failure paths). - FE vitest: **50** billing/format tests pass; prettier + eslint clean; tsc clean for all changed files. - Pending: manual V2-preview check that the invoice shows a concrete `-$X.00` discount line (labelled "12 months for the price of 10") equal to the in-app total. Closes the residual half of #7032 review finding #2 — once merged/deployed, the in-app total, the persisted value, and the Stripe invoice all agree. --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../saas/payg/api/PaygWalletController.java | 13 ++++- .../saas/payg/api/WalletSnapshotResponse.java | 8 ++- .../saas/payg/billing/TeamBillingService.java | 25 +++++++++ .../saas/payg/policy/PricingPolicy.java | 8 +-- .../payg/api/WalletSnapshotResponseTest.java | 7 ++- .../config/configSections/usageMeters.tsx | 2 +- frontend/editor/src/cloud/hooks/useWallet.ts | 1 + frontend/editor/src/portal/billing/stripe.ts | 7 ++- .../billing/ActivationChoiceModal.tsx | 9 ++-- .../billing/BundleCheckoutModal.tsx | 51 +++++++++++++++---- .../components/billing/FreePlanView.tsx | 8 +-- .../billing/PrepaidCapacityCard.tsx | 2 +- .../billing/StripeCheckoutModal.tsx | 11 ++++ .../components/billing/walletFixtures.ts | 2 + .../src/proprietary/billing/format.test.ts | 15 ++++-- .../editor/src/proprietary/billing/format.ts | 21 +++++--- .../editor/src/proprietary/billing/types.ts | 6 +++ .../shared/FreeLimitReachedModal.test.tsx | 1 + .../editor/src/saas/hooks/walletDevPreview.ts | 1 + 19 files changed, 161 insertions(+), 37 deletions(-) diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 2ab19ad4cb..0f18f13284 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -1,5 +1,6 @@ package stirling.software.saas.payg.api; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -206,6 +207,12 @@ public class PaygWalletController { // Prepaid while pools still have units to draw; once exhausted the meter is live again. String billingMode = prepaidRemaining > 0 ? BILLING_MODE_PREPAID : BILLING_MODE_PAYG; + // Per-credit rate for the bundle calculator — the bundle:processor price, NOT the metered + // per-doc rate. Resolved in the team's currency (USD fallback), null when the price is + // unsynced. + BigDecimal bundleRatePerCreditMinor = + billingService.resolveBundleRatePerCreditMinor(billing.currency()); + WalletSnapshotResponse body = new WalletSnapshotResponse( teamId, @@ -234,7 +241,8 @@ public class PaygWalletController { prepaidRemaining, prepaidTotal, prepaidExpiresAt, - billingMode); + billingMode, + bundleRatePerCreditMinor); return ResponseEntity.ok(body); } @@ -512,6 +520,7 @@ public class PaygWalletController { 0L, 0L, null, - BILLING_MODE_PAYG); + BILLING_MODE_PAYG, + null); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java index 3d381ef165..8bf887a0ea 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java @@ -55,6 +55,11 @@ import java.util.List; * @param members leader-only roster of team members + their per-member sub-caps. Empty for member * callers. * @param recent latest wallet-ledger entries (newest first) for the activity feed. + * @param bundleRatePerCreditMinor per-credit rate of the prepaid-bundle Stripe Price (lookup key + * {@code bundle:processor}) in minor units of {@code currency} (may be fractional); {@code + * null} when unresolved. The in-app bundle calculator multiplies its pool by this so its + * estimate matches the checkout edge fn's charge. Distinct from {@code pricePerDocMinor} (the + * metered per-document rate) — the two must not be conflated. */ public record WalletSnapshotResponse( Long teamId, @@ -83,7 +88,8 @@ public record WalletSnapshotResponse( long prepaidUnitsRemaining, long prepaidUnitsTotal, String prepaidExpiresAt, - String billingMode) { + String billingMode, + BigDecimal bundleRatePerCreditMinor) { // Prepaid usage bundles, aggregated across the team's in-term pools (drawn ahead of the meter, // outside the spend cap): diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java index 260efbd9ab..8bca25758a 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java @@ -68,6 +68,14 @@ public class TeamBillingService { */ private static final String PAYG_LOOKUP_KEY = "plan:processor"; + /** + * Stripe Price {@code lookup_key} for the prepaid-bundle price — the per-credit rate the bundle + * calculator prices its pool at. A DIFFERENT price from {@link #PAYG_LOOKUP_KEY} (the metered + * per-document rate); the two must not be conflated, or the in-app estimate diverges from the + * amount the checkout edge fn actually charges (which bills against this same price). + */ + private static final String BUNDLE_LOOKUP_KEY = "bundle:processor"; + private final PaygTeamExtensionsRepository extensionsRepository; private final WalletPolicyRepository walletPolicyRepository; private final PricingPolicyService pricingPolicyService; @@ -254,6 +262,23 @@ public class TeamBillingService { .longValue()); } + /** + * Per-credit rate of the prepaid-bundle Stripe Price (lookup key {@code bundle:processor}) in + * {@code currency} (USD fallback) — the rate the in-app bundle calculator multiplies its pool + * by, so its estimate matches the amount the checkout edge fn charges (which bills the pool + * against this same price). Distinct from the metered {@code perDocMinor}; a bundle credit is + * one size-scaled run, priced per {@code unit_amount} of the bundle price. {@code null} when + * the rate can't be resolved (stripe schema absent, price unsynced) — the calculator then hides + * the figure and defers to the server total. + */ + public BigDecimal resolveBundleRatePerCreditMinor(String currency) { + return subscriptionDao + .findRateByLookupKey( + BUNDLE_LOOKUP_KEY, currency != null ? currency : DISPLAY_CURRENCY) + .map(StripeSubscriptionDao.PriceRate::perDocMinor) + .orElse(null); + } + /** * Inclusive-start / exclusive-end window for the calendar month — the monthly billing window * used when there's no Stripe subscription period to anchor on. diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java index d28b0fd09d..c9cda11831 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java @@ -123,9 +123,11 @@ public class PricingPolicy implements Serializable { @Column(name = "bundle_stripe_price_id", length = 128) private String bundleStripePriceId; - /** Stripe coupon id applying the 12-for-10 prepaid discount. Null = bundles not offered. */ - @Column(name = "bundle_coupon_id", length = 128) - private String bundleCouponId; + // No bundle_coupon_id field: the 12-for-10 discount is minted per-quote as an inline amount_off + // coupon by the create-payg-bundle-quote edge fn (computed from the bundle Price), so the + // pre-made percent coupon this policy used to carry is no longer consulted by anything. The + // column still exists (payg_get_bundle_pricing returns it) and is dropped in a later cleanup; + // ddl-auto=update never drops columns, so removing the mapping here is safe. /** * Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx. diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java index 55dfdb40e3..7960def2a9 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java @@ -43,7 +43,8 @@ class WalletSnapshotResponseTest { /* prepaidUnitsRemaining= */ 40_000L, /* prepaidUnitsTotal= */ 120_000L, /* prepaidExpiresAt= */ "2027-06-01", - /* billingMode= */ "prepaid"); + /* billingMode= */ "prepaid", + /* bundleRatePerCreditMinor= */ new BigDecimal("1")); } @Test @@ -123,10 +124,12 @@ class WalletSnapshotResponseTest { 0L, 0L, null, - "payg"); + "payg", + null); assertThat(free.billableLimit()).isNull(); assertThat(free.pricePerDocMinor()).isNull(); + assertThat(free.bundleRatePerCreditMinor()).isNull(); assertThat(free.currency()).isNull(); assertThat(free.estimatedBillMinor()).isNull(); assertThat(free.capUsd()).isNull(); diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index cb6eb511c9..abfb0110ca 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -196,7 +196,7 @@ export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) { figure={snap.remaining.toLocaleString()} capSuffix={t( "payg.prepaid.meter.capSuffix", - "of {{total}} prepaid PDFs", + "of {{total}} prepaid credits", { total: snap.total.toLocaleString(), }, diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index ccef26a8bc..0a3f78b3ce 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -128,6 +128,7 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { prev.freeAllowance !== next.freeAllowance || prev.freeRemaining !== next.freeRemaining || prev.pricePerDocMinor !== next.pricePerDocMinor || + prev.bundleRatePerCreditMinor !== next.bundleRatePerCreditMinor || prev.currency !== next.currency || prev.estimatedBillMinor !== next.estimatedBillMinor || prev.capUsd !== next.capUsd || diff --git a/frontend/editor/src/portal/billing/stripe.ts b/frontend/editor/src/portal/billing/stripe.ts index 8b9690d3f6..9efcc9b0a1 100644 --- a/frontend/editor/src/portal/billing/stripe.ts +++ b/frontend/editor/src/portal/billing/stripe.ts @@ -117,7 +117,12 @@ export interface BundleQuoteInput { provisionedMonthlyVolume: number; /** Size-folded run-credits = the Stripe line quantity when this quote is paid. */ poolCredits: number; - /** Discounted total in minor units; null when the per-run rate is unknown. */ + /** + * Client-estimated discounted total in minor units, persisted for the pre-mint display only; null + * when the per-run rate is unknown. NOT authoritative: once the Stripe quote is minted, + * create-payg-bundle-quote overwrites the row's price_minor with the server-derived total + * (Price x qty - amount_off), and the Stripe quote/invoice amount is server-derived regardless. + */ priceMinor: number | null; currency: string; /** Affirmative consent to the prepaid→metered auto-transition (ARL/EULA §7.2). */ diff --git a/frontend/editor/src/portal/components/billing/ActivationChoiceModal.tsx b/frontend/editor/src/portal/components/billing/ActivationChoiceModal.tsx index 3b110fe187..0130751bea 100644 --- a/frontend/editor/src/portal/components/billing/ActivationChoiceModal.tsx +++ b/frontend/editor/src/portal/components/billing/ActivationChoiceModal.tsx @@ -50,11 +50,14 @@ function DoorCard({ * before any card is entered. Two door-cards, matching the demo — * * - Pay as you go → the metered subscription checkout (spend limit + card). - * - Prepay a year → the discounted bundle (calculator + one-time payment); the - * backend silently stands up the metered subscription off the saved card so - * metering resumes when the pool empties, so no spend-limit step is needed. + * - Prepay a year → the discounted bundle (calculator + one-time payment); no + * spend-limit step, since the buyer commits to a fixed pool up front. * * Same per-PDF rate on both paths — prepay just front-loads two free months. + * + * Note: auto-standing-up the metered subscription off the saved card so metering + * resumes once a prepaid pool empties is a known follow-up, NOT yet wired — a + * prepay-only team isn't metered past its pool today. */ export function ActivationChoiceModal({ open, diff --git a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx index 6bb442f6ed..b5d0d58a3b 100644 --- a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx @@ -100,6 +100,23 @@ function pipelineIdFor(mult: number): string { ); } +/** + * Reuse key for the minted Stripe quote. Includes the sizing MULTIPLIER ids alongside the pool + PO, + * not just the pool: different posture/size/pipeline combos can yield the same poolCredits (identical + * Stripe amount), and keying on the pool alone would skip the re-mint on such an edit and leave the + * persisted quote row with stale sizing fields. Keying on the ids re-mints (and re-persists) whenever + * the buyer actually changes the config. + */ +function buildStripeQuoteSig( + poolCredits: number, + postureId: string, + sizeId: string, + pipelineId: string, + poNumber: string, +): string { + return `${poolCredits}|${postureId}|${sizeId}|${pipelineId}|${poNumber.trim()}`; +} + /** * Pre-quote calculator progress, persisted per team so closing the modal / reloading doesn't lose the * buyer's place. Once a real quote exists it's the source of truth (loaded server-side), so this is @@ -189,7 +206,9 @@ export function BundleCheckoutModal({ const { t } = useTranslation(); const teamId = wallet.teamId; const currency = wallet.currency ?? "usd"; - const pricePerDocMinor = wallet.pricePerDocMinor; + // The pool is priced per size-scaled RUN at the prepaid-bundle rate (bundle:processor), NOT the + // metered per-document rate — so the estimate matches the amount the checkout edge fn charges. + const ratePerRunMinor = wallet.bundleRatePerCreditMinor; const [phase, setPhase] = useState("calc"); const [users, setUsers] = useState(DEFAULT_USERS); @@ -218,10 +237,12 @@ export function BundleCheckoutModal({ const [stripeQuoteSig, setStripeQuoteSig] = useState(null); // The invoice generated when the quote is accepted (awaiting payment); null when simulated. const [invoice, setInvoice] = useState(null); - // On resume, the total the quote was persisted at (server value), frozen so the receipt shows what - // the buyer actually quoted rather than a figure recomputed from a since-changed rate. Paired with - // the pool size it was persisted at — once the buyer edits the sizing (pool changes) we drop back to - // the live estimate, since editing re-mints and re-persists anyway. + // On resume, the total the quote was persisted at, frozen so the receipt shows what the buyer + // actually quoted rather than a figure recomputed from a since-changed rate. Once the Stripe quote + // has been minted this is the server-derived total (create-payg-bundle-quote overwrites price_minor + // with Price x qty - amount_off); before that it's the client estimate persisted at upsert. Paired + // with the pool size it was persisted at — once the buyer edits the sizing (pool changes) we drop + // back to the live estimate, since editing re-mints and re-persists anyway. const [persistedPriceMinor, setPersistedPriceMinor] = useState( null, ); @@ -302,7 +323,13 @@ export function BundleCheckoutModal({ }); // Match the reuse signature so resuming doesn't immediately re-mint the Stripe quote. setStripeQuoteSig( - `${latest.poolCredits}|${(saved?.poNumber ?? "").trim()}`, + buildStripeQuoteSig( + latest.poolCredits, + postureIdFor(latest.posturePolicies), + sizeIdFor(latest.sizeMult), + pipelineIdFor(latest.pipelineMult), + saved?.poNumber ?? "", + ), ); } // Already accepted (an invoice exists) → resume straight to the payment step rather than the @@ -374,9 +401,9 @@ export function BundleCheckoutModal({ posturePolicies: policiesFor(postureId), sizeMult: sizeMultFor(sizeId), pipelineMult: pipelineMultFor(pipelineId), - ratePerRunMinor: pricePerDocMinor, + ratePerRunMinor, }), - [users, postureId, sizeId, pipelineId, pricePerDocMinor], + [users, postureId, sizeId, pipelineId, ratePerRunMinor], ); // The receipt shows the persisted (server) total on resume so it matches the quote the buyer @@ -459,7 +486,13 @@ export function BundleCheckoutModal({ stripeQuote: BundleStripeQuote; } | null> { if (teamId == null) return null; - const sig = `${quote.poolCredits}|${poNumber.trim()}`; + const sig = buildStripeQuoteSig( + quote.poolCredits, + postureId, + sizeId, + pipelineId, + poNumber, + ); if (stripeQuote && stripeQuoteSig === sig && quoteId != null) { return { quoteId, stripeQuote }; // unchanged since last mint — reuse, no new quote } diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index 5b704f3860..d3a1baf036 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -184,9 +184,11 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { )} {/* Prepay reuses the bundle modal (free team → first-purchase copy, no cap - step). On completion the webhook credits the pool AND silently creates - the metered subscription off the saved card, so we poll like the payg - path to flip the wallet to subscribed. */} + step). On completion the webhook credits the pool; we still poll onSubscribed + like the payg path, but flipping the wallet to subscribed depends on the + metered-subscription auto-provisioning off the saved card, a known follow-up + that's NOT yet wired — so for a prepay-only team this poll can just time out + until then. */} {wallet.teamId != null && ( { const cleaned = raw.replace(/[^0-9]/g, ""); setText(cleaned); + // Empty maps to 0 — the "nothing entered yet" sentinel (distinct from the explicit null "No limit"). + // The parent treats 0 as incomplete and blocks Continue, so a cleared field can't become a $0 cap. onChange(cleaned === "" ? 0 : parseInt(cleaned, 10)); }; @@ -326,6 +328,10 @@ export function StripeCheckoutModal({ // Apply the chosen ceiling, then advance to payment. Applying it up front keeps // "you're never billed past it" true from the first processed PDF. async function handleContinue() { + // Guard the empty-field sentinel: clearing the input maps to 0, which is neither a real cap nor the + // explicit "No limit" (null). Proceeding would set a $0 ceiling (processing immediately paused), so + // treat it as incomplete and stay put — the Continue button is disabled in this state too. + if (capUsd !== null && capUsd <= 0) return; setCapBusy(true); setCapError(null); try { @@ -357,6 +363,10 @@ export function StripeCheckoutModal({ if (dismissable) onClose(); }; + // Valid to continue when a positive cap is set OR "No limit" (null) was explicitly picked. A cleared + // field maps to 0 (incomplete) — block Continue rather than let it become an accidental $0 ceiling. + const capValid = capUsd === null || capUsd > 0; + // The chosen cap, formatted for the payment-step recap (null = "No cap" was picked on step 1). const capLabel = capUsd != null @@ -443,6 +453,7 @@ export function StripeCheckoutModal({ + } + /> + ); +} + +/** Single-select dropdown with a disabled item. */ +export const Default: Story = { render: () => }; + +/** Multi-select with search box and a footer action. */ +export const MultiSelectWithFooter: Story = { + render: () => , +}; + +/** No items available — empty state message inside the dropdown. */ +export const Empty: Story = { + render: () => { + return ( + {}} + /> + ); + }, +}; diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx new file mode 100644 index 0000000000..b49fbd2806 --- /dev/null +++ b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditableSecretField from "@app/components/shared/EditableSecretField"; + +const meta: Meta = { + title: "Shared/EditableSecretField", + component: EditableSecretField, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SecretFieldDemo({ + initialValue = "", + ...rest +}: { + initialValue?: string; + label?: string; + description?: string; + placeholder?: string; + disabled?: boolean; + error?: string; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +/** Empty value: renders a normal password input. */ +export const Default: Story = { + render: () => , +}; + +/** Backend returned a masked value (********): shows a read-only display + Edit button. */ +export const Masked: Story = { + render: () => , +}; + +/** Disabled state — the Edit button on a masked value must still read as inert. */ +export const MaskedDisabled: Story = { + render: () => , +}; + +/** Validation error surfaced under the password input. */ +export const WithError: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx new file mode 100644 index 0000000000..dd83b6eece --- /dev/null +++ b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx @@ -0,0 +1,60 @@ +import { useState, type ComponentProps } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; + +const meta = { + title: "Shared/EncryptedPdfUnlockModal", + component: EncryptedPdfUnlockModal, + args: { + opened: true, + password: "", + isProcessing: false, + remainingCount: 0, + onPasswordChange: () => {}, + onUnlock: () => {}, + onUnlockAll: () => {}, + onSkip: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function UnlockDemo( + props: Partial>, +) { + const [password, setPassword] = useState(""); + return ( + {}} + onUnlockAll={() => {}} + onSkip={() => {}} + {...props} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const MultipleFilesRemaining: Story = { + render: () => , +}; + +export const IncorrectPassword: Story = { + render: () => ( + + ), +}; + +export const Processing: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..22a5f06feb --- /dev/null +++ b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Text } from "@mantine/core"; +import ErrorBoundary from "@app/components/shared/ErrorBoundary"; + +const meta: Meta = { + title: "Shared/ErrorBoundary", + component: ErrorBoundary, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ThrowingChild(): never { + throw new Error("Simulated render error for Storybook"); +} + +/** Normal path — children render untouched when nothing throws. */ +export const Default: Story = { + args: { + children: Protected content renders normally., + }, +}; + +/** A child throwing during render is caught, showing the default fallback with a retry button. */ +export const CaughtError: Story = { + args: { + children: , + }, +}; + +/** A custom fallback component receives the error and a retry callback. */ +export const CustomFallback: Story = { + args: { + children: , + fallback: ({ error, retry }) => ( + + Custom fallback: {error?.message} + + + ), + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileCard.stories.tsx b/frontend/editor/src/core/components/shared/FileCard.stories.tsx new file mode 100644 index 0000000000..48edd5854e --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileCard.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileCard from "@app/components/shared/FileCard"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { StirlingFileStub, FileId } from "@app/types/fileContext"; + +function makeFile(name: string, type = "application/pdf"): File { + return new File(["%PDF-1.4 storybook fixture"], name, { + type, + lastModified: Date.now(), + }); +} + +function makeStub(id: string): StirlingFileStub { + return { + id: id as FileId, + name: "Annual-Report-2026.pdf", + type: "application/pdf", + size: 245_760, + lastModified: Date.now(), + isLeaf: true, + originalFileId: id, + versionNumber: 1, + }; +} + +/** FileCard reads/writes files via FileContext + IndexedDB, so it needs a real provider tree. */ +const meta = { + title: "Shared/FileCard", + component: FileCard, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: makeFile("Annual-Report-2026.pdf"), + fileStub: makeStub("story-file-1"), + onRemove: () => {}, + onView: () => {}, + onEdit: () => {}, + }, +}; + +export const Selected: Story = { + args: { + ...Default.args, + isSelected: true, + onSelect: () => {}, + }, +}; + +export const Unsupported: Story = { + args: { + ...Default.args, + isSupported: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx new file mode 100644 index 0000000000..b42f7085a2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileDocIcon } from "@app/components/shared/FileDocIcon"; + +const meta = { + title: "Shared/FileDocIcon", + component: FileDocIcon, + parameters: { layout: "padded" }, + args: { variant: "pdf" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { variant: "pdf" }, +}; + +/** All file-type variants, each using its own default accent color. */ +export const AllVariants: Story = { + render: () => ( +
+ + + + + + + +
+ ), +}; + +/** Explicit `color` overrides the variant's default accent. */ +export const CustomColor: Story = { + args: { variant: "pdf", color: "#e64980" }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx new file mode 100644 index 0000000000..bb6b7d2670 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx @@ -0,0 +1,49 @@ +import type { CSSProperties } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileDropdownMenu } from "@app/components/shared/FileDropdownMenu"; + +const viewOptionStyle: CSSProperties = { + display: "flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.25rem 0.5rem", +}; + +const activeFiles = [ + { fileId: "file-1", name: "Contract-Draft-v1.pdf" }, + { fileId: "file-2", name: "Invoice-2026-04.pdf", versionNumber: 2 }, + { fileId: "file-3", name: "Scanned-Document-With-A-Very-Long-Name.pdf" }, +]; + +const meta: Meta = { + title: "Shared/FileDropdownMenu", + component: FileDropdownMenu, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + displayName: "Contract-Draft-v1.pdf", + activeFiles, + currentFileIndex: 0, + viewOptionStyle, + onFileSelect: () => {}, + onFileRemove: () => {}, + }, +}; + +export const Switching: Story = { + args: { + ...Default.args, + switchingTo: "viewer", + }, +}; + +export const NoRemove: Story = { + args: { + ...Default.args, + onFileRemove: undefined, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileGrid.stories.tsx b/frontend/editor/src/core/components/shared/FileGrid.stories.tsx new file mode 100644 index 0000000000..aea431852a --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileGrid.stories.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileGrid from "@app/components/shared/FileGrid"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * FileGrid renders FileCard entries, which call useFileThumbnail -> + * useIndexedDBThumbnail. That hook reads IndexedDBContext + FileContext, + * neither of which is part of the shared preview decorators, so + * FileContextProvider (which wraps IndexedDBProvider internally) is stood up + * here. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const buildFile = (name: string, size: number, type: string): File => { + return new File([new Uint8Array(size)], name, { + type, + lastModified: Date.now(), + }); +}; + +const buildRecord = ( + id: string, + overrides: Partial = {}, +): StirlingFileStub => ({ + id: id as FileId, + name: overrides.name ?? "report.pdf", + type: overrides.type ?? "application/pdf", + size: overrides.size ?? 1_240_000, + lastModified: overrides.lastModified ?? Date.now(), + isLeaf: true, + originalFileId: id, + versionNumber: 1, + // Set so useLazyThumbnail short-circuits on the stored thumbnail instead of + // trying to read file bytes out of IndexedDB. + thumbnailUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E", + ...overrides, +}); + +const files = [ + { + file: buildFile("report.pdf", 1_240_000, "application/pdf"), + record: buildRecord("file-1", { name: "report.pdf" }), + }, + { + file: buildFile("invoice.pdf", 540_000, "application/pdf"), + record: buildRecord("file-2", { + name: "invoice.pdf", + size: 540_000, + }), + }, + { + file: buildFile( + "budget.xlsx", + 82_000, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + record: buildRecord("file-3", { + name: "budget.xlsx", + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + size: 82_000, + thumbnailUrl: undefined, + }), + }, +]; + +const meta = { + title: "Shared/FileGrid", + component: FileGrid, + decorators: [withFileContext], + args: { + files, + onRemove: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const SearchAndSort: Story = { + args: { + showSearch: true, + showSort: true, + onDeleteAll: () => {}, + }, +}; + +export const Empty: Story = { + args: { + files: [], + showSearch: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx new file mode 100644 index 0000000000..51eac29632 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FilePickerModal from "@app/components/shared/FilePickerModal"; + +const mockStoredFiles = [ + { id: "file-1", name: "invoice.pdf", size: 245_000, thumbnail: null }, + { + id: "file-2", + name: "contract-draft.pdf", + size: 1_240_000, + thumbnail: null, + }, + { id: "file-3", name: "scanned-form.pdf", size: 3_400_000, thumbnail: null }, +]; + +const meta = { + title: "Shared/FilePickerModal", + component: FilePickerModal, + parameters: { layout: "padded" }, + args: { + opened: true, + onClose: () => {}, + onSelectFiles: () => {}, + storedFiles: mockStoredFiles, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Storage populated with a few files available to pick from. */ +export const Default: Story = {}; + +/** No files exist in storage yet — shows the empty-state message. */ +export const Empty: Story = { + args: { + storedFiles: [], + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePreview.stories.tsx b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx new file mode 100644 index 0000000000..30e2fec884 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FilePreview from "@app/components/shared/FilePreview"; +import { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "file-1" as FileId, + name: "annual-report.pdf", + type: "application/pdf", + size: 245_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, +}; + +const meta = { + title: "Shared/FilePreview", + component: FilePreview, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: mockFile, + thumbnail: null, + }, +}; + +export const Empty: Story = { + args: { + file: null, + }, +}; + +export const WithNavigation: Story = { + args: { + file: mockFile, + thumbnail: null, + showStacking: true, + showHoverOverlay: true, + showNavigation: true, + totalFiles: 3, + onFileClick: () => {}, + onPrevious: () => {}, + onNext: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx new file mode 100644 index 0000000000..3d01996b99 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileSelectorPicker } from "@app/components/shared/FileSelectorPicker"; +import { FileContextProvider } from "@app/contexts/FileContext"; + +/** + * Reads from FileContext (workbench files) and IndexedDBContext (persisted + * saved files) further up the tree — neither is part of the shared preview + * decorators, so FileContextProvider (which also wraps IndexedDBProvider) is + * stood up here. The popover starts closed, so no IndexedDB read happens + * until a story interacts with it. + */ +function withFileContext(Story: () => ReactElement) { + return ( + +
+ +
+
+ ); +} + +const meta = { + title: "Shared/FileSelectorPicker", + component: FileSelectorPicker, + parameters: { layout: "padded" }, + args: { + onSelect: () => {}, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a comparison file", + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx new file mode 100644 index 0000000000..d0411f1be9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileUploadButton from "@app/components/shared/FileUploadButton"; + +const meta: Meta = { + title: "Shared/FileUploadButton", + component: FileUploadButton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function UploadDemo({ + initialFile, + ...rest +}: { + initialFile?: File; + disabled?: boolean; + accept?: string; + placeholder?: string; +}) { + const [file, setFile] = useState(initialFile); + return ( + setFile(next ?? undefined)} + {...rest} + /> + ); +} + +/** No file chosen yet — shows the default "Choose File" placeholder. */ +export const Default: Story = { render: () => }; + +/** A file has already been selected — the button shows its name. */ +export const WithFileSelected: Story = { + render: () => ( + + ), +}; + +/** Disabled state — should still be legible but non-interactive. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx new file mode 100644 index 0000000000..30941579f4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FirstLoginModal from "@app/components/shared/FirstLoginModal"; + +const meta = { + title: "Shared/FirstLoginModal", + component: FirstLoginModal, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + username: "jane.doe", + onPasswordChanged: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FitText.stories.tsx b/frontend/editor/src/core/components/shared/FitText.stories.tsx new file mode 100644 index 0000000000..63b93d0ed7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FitText.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FitText from "@app/components/shared/FitText"; + +const meta: Meta = { + title: "Shared/FitText", + component: FitText, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Single-line text that shrinks its font size to fit the available width. */ +export const Default: Story = { + args: { + text: "Invoice_2026_Quarterly_Report.pdf", + }, +}; + +/** Multi-line clamp with soft-break hints inserted after '/', '-' and '_'. */ +export const MultiLine: Story = { + args: { + text: "path/to/some-very/long_document/name_that_needs_multiple_lines.pdf", + lines: 3, + }, +}; + +/** Explicit font size (rem) with a lower minimum shrink scale. */ +export const CustomFontSize: Story = { + args: { + text: "Custom Sized Label", + fontSize: 1.5, + minimumFontScale: 0.5, + }, +}; diff --git a/frontend/editor/src/core/components/shared/Footer.stories.tsx b/frontend/editor/src/core/components/shared/Footer.stories.tsx new file mode 100644 index 0000000000..af1d4a5805 --- /dev/null +++ b/frontend/editor/src/core/components/shared/Footer.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Footer from "@app/components/shared/Footer"; + +const meta = { + title: "Shared/Footer", + component: Footer, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Defaults: no overrides supplied, only the always-present links render. */ +export const Default: Story = { + args: {}, +}; + +/** All optional legal links populated, plus the cookie preferences button. */ +export const AllLinksAndCookieBanner: Story = { + args: { + privacyPolicy: "https://example.com/privacy", + termsAndConditions: "https://example.com/terms", + accessibilityStatement: "https://example.com/accessibility", + cookiePolicy: "https://example.com/cookies", + impressum: "https://example.com/impressum", + analyticsEnabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx new file mode 100644 index 0000000000..dc391d0e9a --- /dev/null +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HoverActionMenu, { + type HoverAction, +} from "@app/components/shared/HoverActionMenu"; +import { iconMap } from "@app/components/tools/automate/iconMap"; + +const { EditIcon, DeleteIcon, DownloadIcon } = iconMap; + +const actions: HoverAction[] = [ + { + id: "edit", + icon: , + label: "Edit", + onClick: () => {}, + }, + { + id: "download", + icon: , + label: "Download", + onClick: () => {}, + }, + { + id: "delete", + icon: , + label: "Delete", + onClick: () => {}, + color: "var(--text-error)", + }, +]; + +const meta: Meta = { + title: "Shared/HoverActionMenu", + component: HoverActionMenu, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Visible menu with the standard edit/download/delete action set. */ +export const Default: Story = { + args: { + show: true, + actions, + }, +}; + +/** Hidden state (`show: false`) — menu stays mounted but faded/non-interactive. */ +export const Hidden: Story = { + args: { + show: false, + actions, + }, +}; + +/** One action disabled with a custom tooltip explaining why. */ +export const WithDisabledAction: Story = { + args: { + show: true, + actions: [ + actions[0], + actions[1], + { + ...actions[2], + disabled: true, + tooltip: "Deletion is restricted by policy", + }, + ], + }, +}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx new file mode 100644 index 0000000000..5fad071d05 --- /dev/null +++ b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx @@ -0,0 +1,38 @@ +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; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx b/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx new file mode 100644 index 0000000000..e51e068ff9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack"; + +/** Decorative stack only: window dots + grey bars — no props, no i18n. */ +const meta: Meta = { + title: "Shared/LandingDocumentStack", + component: LandingDocumentStack, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx b/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx new file mode 100644 index 0000000000..2ffcba96c1 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LanguageSelector from "@app/components/shared/LanguageSelector"; + +const meta = { + title: "Shared/LanguageSelector", + component: LanguageSelector, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Compact: Story = { + args: { + compact: true, + tooltip: "Change language", + }, +}; + +export const TopStartPosition: Story = { + args: { + position: "top-start", + offset: 4, + }, +}; diff --git a/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx b/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx new file mode 100644 index 0000000000..1ce948a671 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; + +/** Full-screen splash shown while i18next Suspense is loading translations. */ +const meta: Meta = { + title: "Shared/LoadingFallback", + component: LoadingFallback, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx b/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx new file mode 100644 index 0000000000..5f38ef168b --- /dev/null +++ b/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LocalIcon from "@app/components/shared/LocalIcon"; + +const meta: Meta = { + title: "Shared/LocalIcon", + component: LocalIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: "description", + width: "2rem", + height: "2rem", + }, +}; + +export const NumericSize: Story = { + args: { + icon: "download", + width: 32, + }, +}; + +export const WithFullCollectionPrefix: Story = { + args: { + icon: "material-symbols:error-rounded", + width: "1.5rem", + }, +}; diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx new file mode 100644 index 0000000000..d8b160692a --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LoginAgreementModal from "@app/components/shared/LoginAgreementModal"; + +/** + * Renders nothing by default: the modal only opens after fetching + * `/api/v1/config/login-disclaimer` and finding it enabled, which requires a + * live AppConfigProvider/backend. In Storybook (no providers configured) the + * config stays null, so the effect bails out and the component stays hidden. + */ +const meta: Meta = { + title: "Shared/LoginAgreementModal", + component: LoginAgreementModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx b/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx new file mode 100644 index 0000000000..58e7e86aa4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MobileUploadModal from "@app/components/shared/MobileUploadModal"; + +const meta: Meta = { + title: "Shared/MobileUploadModal", + component: MobileUploadModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onFilesReceived: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** QR code + instructions for scanning a file upload session from a phone. */ +export const Default: Story = {}; + +/** Closed state — modal renders nothing visible. */ +export const Closed: Story = { + args: { + opened: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx b/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx new file mode 100644 index 0000000000..cb8281399b --- /dev/null +++ b/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MultiSelectControls from "@app/components/shared/MultiSelectControls"; + +const meta: Meta = { + title: "Shared/MultiSelectControls", + component: MultiSelectControls, + parameters: { layout: "padded" }, + args: { + selectedCount: 3, + onClearSelection: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Only the always-present "Clear files" action, since none of the optional handlers are passed. */ +export const Default: Story = {}; + +/** All optional actions supplied — every button in the group renders. */ +export const AllActions: Story = { + args: { + onAddToUpload: () => {}, + onOpenInFileEditor: () => {}, + onOpenInPageEditor: () => {}, + onDeleteAll: () => {}, + }, +}; + +/** Renders nothing when no files are selected. */ +export const NoSelection: Story = { + args: { + selectedCount: 0, + }, +}; diff --git a/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx b/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx new file mode 100644 index 0000000000..ac6636125e --- /dev/null +++ b/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx @@ -0,0 +1,103 @@ +import { useEffect, type ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationWarningModal from "@app/components/shared/NavigationWarningModal"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { + NavigationProvider, + useNavigationGuard, + type NavigationWarningHandlers, +} from "@app/contexts/NavigationContext"; + +/** + * The modal renders nothing until NavigationContext has unsaved changes AND a + * pending navigation to warn about, so this drives both into place on mount — + * mirroring what a real editor does when it calls requestNavigation() while + * hasUnsavedChanges is true. It also registers any warning handlers the story + * supplies, since the modal only shows the "Apply & Leave"/"Export & Leave" + * buttons when a handler for them is present. + */ +function TriggerWarning({ + children, + handlers, +}: { + children: React.ReactNode; + handlers?: NavigationWarningHandlers; +}) { + const { + hasUnsavedChanges, + setHasUnsavedChanges, + requestNavigation, + registerNavigationWarningHandlers, + } = useNavigationGuard(); + + useEffect(() => { + setHasUnsavedChanges(true); + }, [setHasUnsavedChanges]); + + useEffect(() => { + if (handlers) { + registerNavigationWarningHandlers(handlers); + } + }, [handlers, registerNavigationWarningHandlers]); + + useEffect(() => { + if (hasUnsavedChanges) { + requestNavigation(() => {}); + } + }, [hasUnsavedChanges, requestNavigation]); + + return <>{children}; +} + +function withProviders( + Story: () => ReactElement, + context: { parameters: { navigationHandlers?: NavigationWarningHandlers } }, +) { + return ( + + + + + + + + ); +} + +const meta = { + title: "Shared/NavigationWarningModal", + component: NavigationWarningModal, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Unsaved changes plus a pending navigation trigger the confirmation dialog. */ +export const Default: Story = {}; + +/** + * When the active tool registers an "apply and continue" handler (e.g. a + * pending edit that can be committed before leaving), the modal adds a third + * action alongside "Keep Working" and "Discard Changes". + */ +export const WithApplyAndContinue: Story = { + parameters: { + navigationHandlers: { + onApplyAndContinue: async () => {}, + } satisfies NavigationWarningHandlers, + }, +}; + +/** + * When the active tool registers an "export and continue" handler (e.g. a + * conversion tool that can export its result before leaving), the modal adds + * an "Export & Leave" action instead. + */ +export const WithExportAndContinue: Story = { + parameters: { + navigationHandlers: { + onExportAndContinue: async () => {}, + } satisfies NavigationWarningHandlers, + }, +}; diff --git a/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx b/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx new file mode 100644 index 0000000000..4b8d936cc5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ObscuredOverlay from "@app/components/shared/ObscuredOverlay"; + +const meta: Meta = { + title: "Shared/ObscuredOverlay", + component: ObscuredOverlay, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const Content = () => ( +
+ Underlying content that gets obscured. +
+); + +export const Unobscured: Story = { + args: { + obscured: false, + children: , + }, +}; + +export const Obscured: Story = { + args: { + obscured: true, + overlayMessage: "This feature requires an upgrade", + buttonText: "Upgrade", + onButtonClick: () => {}, + children: , + }, +}; + +export const RoundedCorners: Story = { + args: { + obscured: true, + overlayMessage: "Locked", + borderRadius: "0.5rem", + children: , + }, +}; diff --git a/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx new file mode 100644 index 0000000000..e14d9dc030 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint"; + +const meta = { + title: "Shared/PageSelectionSyntaxHint", + component: PageSelectionSyntaxHint, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Valid syntax ("1-3,5") renders nothing — no hint shown. */ +export const Default: Story = { + args: { + input: "1-3,5", + maxPages: 10, + }, +}; + +/** Malformed expression falls back to CSV parsing and shows the panel-style hint. */ +export const SyntaxError: Story = { + args: { + input: "abc", + maxPages: 10, + }, +}; + +/** Same malformed input, compact variant used inline within a tool panel. */ +export const CompactSyntaxError: Story = { + args: { + input: "abc", + maxPages: 10, + variant: "compact", + }, +}; diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx new file mode 100644 index 0000000000..81969bf615 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyBadges } from "@app/components/shared/PolicyBadges"; +import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; + +const mockPolicies: FileItemPolicyRef[] = [ + { id: "policy-1", name: "Redact PII", accentColor: "#e03131", recent: true }, + { id: "policy-2", name: "Sanitize", accentColor: "#2f9e44", recent: false }, + { id: "policy-3", name: "Watermark", accentColor: "#4263eb", recent: false }, +]; + +const meta = { + title: "Shared/PolicyBadges", + component: PolicyBadges, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + policies: mockPolicies, + }, +}; + +export const Enforcing: Story = { + args: { + policies: [ + { + id: "policy-1", + name: "Redact PII", + accentColor: "#e03131", + recent: false, + enforcing: true, + }, + ...mockPolicies.slice(1), + ], + }, +}; + +export const Empty: Story = { + args: { + policies: [], + }, +}; diff --git a/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx b/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx new file mode 100644 index 0000000000..3103eb3962 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; + +/** Layout-invisible wrapper that tags sensitive content with 'ph-no-capture' to exclude it from analytics. */ +const meta: Meta = { + title: "Shared/PrivateContent", + component: PrivateContent, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "sensitive-filename.pdf", + }, +}; + +export const WithCustomClassName: Story = { + args: { + children: "sensitive-filename.pdf", + className: "custom-class", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx new file mode 100644 index 0000000000..a616480d36 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShareFileModal from "@app/components/shared/ShareFileModal"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "story-file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-1", + versionNumber: 1, +}; + +/** + * ShareFileModal reads useFileActions() from FileContext (stood up here since + * it isn't part of the shared preview decorators) and useAppConfig() to gate + * share links on `storageShareLinksEnabled` — wrapped per-story to show both + * the disabled and enabled states. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/ShareFileModal", + component: ShareFileModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + file: mockFile, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Share links disabled by server config — default when no config is loaded. */ +export const Default: Story = {}; + +/** Share links enabled — the role selector and "Generate Link" action are active. */ +export const LinksEnabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx b/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx new file mode 100644 index 0000000000..3fff68cbf3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "story-file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-1", + versionNumber: 1, +}; + +/** + * ShareManagementModal reads useFileActions() from FileContext (stood up here + * since it isn't part of the shared preview decorators) and useAppConfig() to + * gate share links on `storageShareLinksEnabled` — wrapped per-story to show + * both the disabled and enabled states. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/ShareManagementModal", + component: ShareManagementModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + file: mockFile, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Share links disabled by server config — default when no config is loaded. */ +export const Default: Story = {}; + +/** Share links enabled — the role selector, link generation and activity panel are active. */ +export const LinksEnabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx b/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx new file mode 100644 index 0000000000..50c754a3cf --- /dev/null +++ b/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SkeletonLoader from "@app/components/shared/SkeletonLoader"; + +const meta: Meta = { + title: "Shared/SkeletonLoader", + component: SkeletonLoader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const PageGrid: Story = { + args: { type: "pageGrid", count: 4 }, +}; + +export const FileGrid: Story = { + args: { type: "fileGrid", count: 4 }, +}; + +export const Controls: Story = { + args: { type: "controls" }, +}; + +export const Viewer: Story = { + args: { type: "viewer" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; + +export const Block: Story = { + args: { type: "block", width: 120, height: 20 }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolChain.stories.tsx b/frontend/editor/src/core/components/shared/ToolChain.stories.tsx new file mode 100644 index 0000000000..b8ef29f9b5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolChain.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolChain from "@app/components/shared/ToolChain"; +import { ToolOperation } from "@app/types/file"; + +function op(toolId: ToolOperation["toolId"], timestamp: number): ToolOperation { + return { toolId, timestamp }; +} + +const shortChain: ToolOperation[] = [op("watermark", 1), op("ocr", 2)]; + +const longChain: ToolOperation[] = [ + op("split", 1), + op("merge", 2), + op("watermark", 3), + op("ocr", 4), + op("rotate", 5), +]; + +const meta = { + title: "Shared/ToolChain", + component: ToolChain, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Default text style, short chain (no truncation needed). */ +export const Default: Story = { + args: { + toolChain: shortChain, + }, +}; + +/** Text style with a long chain — truncates to first → +N → last, with a tooltip for the full chain. */ +export const TextTruncated: Story = { + args: { + toolChain: longChain, + displayStyle: "text", + }, +}; + +/** Badge style — shows up to 3 badges, with "..." + final badge and a tooltip when longer. */ +export const Badges: Story = { + args: { + toolChain: longChain, + displayStyle: "badges", + }, +}; + +/** Compact style — collapses to a tool count once more than one tool is present. */ +export const Compact: Story = { + args: { + toolChain: longChain, + displayStyle: "compact", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx b/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx new file mode 100644 index 0000000000..8994f34157 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { iconMap } from "@app/components/tools/automate/iconMap"; +import { ToolIcon } from "@app/components/shared/ToolIcon"; + +const { PictureAsPdfIcon } = iconMap; + +const meta = { + title: "Shared/ToolIcon", + component: ToolIcon, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + }, +}; + +/** Visually unavailable state, for tools the user can't run. */ +export const ReducedOpacity: Story = { + args: { + icon: , + opacity: 0.25, + }, +}; + +/** No right margin, for inline placement. */ +export const NoMargin: Story = { + args: { + icon: , + marginRight: "0", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx b/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx new file mode 100644 index 0000000000..bfcaedba4f --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader"; + +const meta: Meta = { + title: "Shared/ToolPanelHeader", + component: ToolPanelHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + title: "Split", + }, +}; + +/** Trailing close button only renders when `onClose` is supplied. */ +export const WithCloseButton: Story = { + args: { + icon: , + title: "Split", + onClose: () => {}, + closeLabel: "Close tool panel", + }, +}; diff --git a/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx b/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx new file mode 100644 index 0000000000..9d7626edbf --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UpdateModal from "@app/components/shared/UpdateModal"; +import type { UpdateSummary, MachineInfo } from "@app/services/updateService"; + +const UPDATE_SUMMARY: UpdateSummary = { + latest_version: "2.5.0", + latest_stable_version: "2.5.0", + max_priority: "normal", + recommended_action: "This update contains important fixes and improvements.", + any_breaking: false, + migration_guides: [ + { + version: "2.5.0", + notes: "Config file format changed for custom watermark presets.", + url: "https://docs.stirlingpdf.com/migration/2.5.0", + }, + ], +}; + +const MACHINE_INFO: MachineInfo = { + machineType: "Client-win", + activeSecurity: false, + licenseType: "NORMAL", +}; + +const meta = { + title: "Shared/UpdateModal", + component: UpdateModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + currentVersion: "2.4.0", + updateSummary: UPDATE_SUMMARY, + machineInfo: MACHINE_INFO, + downloadSizeBytes: 235_000_000, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Web/server build: no Tauri updater, so the footer offers a plain download link. */ +export const Default: Story = {}; + +/** Desktop app: an update has finished installing and is waiting for a restart. */ +export const DesktopInstallReadyToRestart: Story = { + args: { + desktopInstall: { + state: "ready-to-restart", + progress: null, + errorMessage: null, + actions: { + startInstall: async () => true, + restartApp: async () => {}, + }, + }, + }, +}; + +/** Desktop app on a non-admin machine: install probe reported it can't write to + * the install directory, so Install Now is disabled and the docs alert shows. */ +export const DesktopInstallBlocked: Story = { + args: { + desktopInstall: { + state: "idle", + progress: null, + errorMessage: null, + actions: { + startInstall: async () => true, + restartApp: async () => {}, + }, + canInstall: { + canInstall: false, + reason: "Install directory is not writable without elevation.", + }, + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx b/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx new file mode 100644 index 0000000000..4abfdece22 --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +/** + * Startup update-check popup — renders null until an update is detected, so + * every story here shows an empty canvas. The stories exercise the gating + * logic (`isUpdatePopupAllowed`) rather than the (invisible) update-found UI, + * which additionally requires a real startup delay + network round trip. + */ +const meta = { + title: "Shared/UpdateStartupPopup", + component: UpdateStartupPopup, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** No app config resolved yet — gate is closed, renders nothing. */ +export const Default: Story = {}; + +/** Config resolved but `shouldShowUpdate` is false — gate stays closed. */ +export const UpdatesDisabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** + * Gate is open (`shouldShowUpdate: true`), so the startup timer would fire and + * check for an update — but the modal itself only appears once that check + * resolves with a newer version, well after the 15s startup delay. + */ +export const UpdatesEnabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx b/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx new file mode 100644 index 0000000000..50f1e9e0ad --- /dev/null +++ b/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UploadToServerModal from "@app/components/shared/UploadToServerModal"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1" as FileId, + versionNumber: 1, +}; + +const mockUploadedFile: StirlingFileStub = { + ...mockFile, + id: "file-2" as FileId, + originalFileId: "file-2" as FileId, + remoteStorageId: 2, +}; + +/** + * The modal dispatches updateStirlingFileStub on upload, so it needs + * FileContext (also supplies IndexedDBContext) mounted above it. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/UploadToServerModal", + component: UploadToServerModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + file: mockFile, + }, +}; + +export const AlreadyUploaded: Story = { + args: { + opened: true, + file: mockUploadedFile, + }, +}; diff --git a/frontend/editor/src/core/components/shared/UserSelector.stories.tsx b/frontend/editor/src/core/components/shared/UserSelector.stories.tsx new file mode 100644 index 0000000000..760eb0f5ee --- /dev/null +++ b/frontend/editor/src/core/components/shared/UserSelector.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UserSelector from "@app/components/shared/UserSelector"; + +/** + * Fetches `/api/v1/user/users` on mount — unmocked here, so stories render + * whatever the fetch settles to (loader, then the "no users" empty state). + */ +const meta = { + title: "Shared/UserSelector", + component: UserSelector, + parameters: { layout: "padded" }, + args: { + value: [], + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { disabled: true }, +}; + +export const CustomPlaceholder: Story = { + args: { placeholder: "Add collaborators..." }, +}; diff --git a/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx b/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx new file mode 100644 index 0000000000..4f43ee145c --- /dev/null +++ b/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ZipWarningModal from "@app/components/shared/ZipWarningModal"; + +const meta: Meta = { + title: "Shared/ZipWarningModal", + component: ZipWarningModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + fileCount: 42, + zipFileName: "large-archive.zip", + onConfirm: () => {}, + onCancel: () => {}, + }, +}; + +export const SingleFile: Story = { + args: { + opened: true, + fileCount: 1, + zipFileName: "small-archive.zip", + onConfirm: () => {}, + onCancel: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx b/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx new file mode 100644 index 0000000000..7544729824 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; + +const meta = { + title: "Shared/Config/LoginRequiredBanner", + component: LoginRequiredBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + show: true, + }, +}; + +export const Hidden: Story = { + args: { + show: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx b/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx new file mode 100644 index 0000000000..0dc2b09db3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OverviewHeader } from "@app/components/shared/config/OverviewHeader"; + +const meta = { + title: "Shared/Config/OverviewHeader", + component: OverviewHeader, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx b/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx new file mode 100644 index 0000000000..85df5f4243 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PendingBadge from "@app/components/shared/config/PendingBadge"; + +const meta = { + title: "Shared/Config/PendingBadge", + component: PendingBadge, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + show: true, + }, +}; + +export const Hidden: Story = { + args: { + show: false, + }, +}; + +export const LargeSize: Story = { + args: { + show: true, + size: "lg", + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx new file mode 100644 index 0000000000..d9f250da6e --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal"; + +const meta = { + title: "Shared/Config/RestartConfirmationModal", + component: RestartConfirmationModal, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + onClose: () => {}, + onRestart: () => {}, + }, +}; + +export const Closed: Story = { + args: { + opened: false, + onClose: () => {}, + onRestart: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx new file mode 100644 index 0000000000..28d5a1c3e0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; +import type { ConfigNavSection } from "@app/components/shared/config/configNavSections"; + +const mockConfigNavSections: ConfigNavSection[] = [ + { + title: "Preferences", + items: [ + { + key: "general", + label: "General", + icon: "settings-rounded", + component: null, + }, + { + key: "hotkeys", + label: "Keyboard Shortcuts", + icon: "keyboard-rounded", + component: null, + }, + ], + }, + { + title: "Workspace", + items: [ + { + key: "people", + label: "People", + icon: "group-rounded", + component: null, + }, + { + key: "teams", + label: "Teams", + icon: "groups-rounded", + component: null, + disabled: true, + }, + ], + }, +]; + +const meta = { + title: "Shared/Config/SettingsSearchBar", + component: SettingsSearchBar, + parameters: { layout: "padded" }, + args: { + configNavSections: mockConfigNavSections, + onNavigate: async () => {}, + isMobile: false, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Mobile: Story = { + args: { + isMobile: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx new file mode 100644 index 0000000000..ae94dd7117 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter"; + +const meta = { + title: "Shared/Config/SettingsStickyFooter", + component: SettingsStickyFooter, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isDirty: true, + saving: false, + loginEnabled: true, + onSave: () => {}, + onDiscard: () => {}, + }, +}; + +export const Saving: Story = { + args: { + ...Default.args, + saving: true, + }, +}; + +export const Hidden: Story = { + args: { + ...Default.args, + isDirty: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx new file mode 100644 index 0000000000..08047afb17 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ThemeProvider } from "@app/components/shared/ThemeProvider"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads theme/tool-panel preferences via usePreferences()/useTheme() and server +// config via useAppConfig() — none of which the Storybook preview's own provider +// tree supplies (those are the portal contexts), so wrap here. AppConfigProvider +// uses autoFetch off so stories render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/ConfigSections/GeneralSection", + component: GeneralSection, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Login enabled, no backend version known yet — Software Updates section and admin banner stay hidden. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Backend version known — shows the Software Updates section with version info. */ +export const WithBackendVersion: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Login disabled — the "For System Administrators" banner shows, prompting the env vars to enable it. */ +export const AdminBanner: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx new file mode 100644 index 0000000000..d7ad738458 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HelpSection from "@app/components/shared/config/configSections/HelpSection"; + +const meta = { + title: "Shared/Config/ConfigSections/HelpSection", + component: HelpSection, + parameters: { layout: "padded" }, + args: { + onRequestClose: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isAdmin: false, + }, +}; + +export const Admin: Story = { + args: { + isAdmin: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx new file mode 100644 index 0000000000..94420ad5cd --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; + +/** + * HotkeyContext reads the tool registry and selection state off + * ToolWorkflowContext, so both providers must wrap the story. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + + + ); +} + +const meta = { + title: "Shared/Config/ConfigSections/HotkeysSection", + component: HotkeysSection, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Full tool list with default keyboard shortcuts assigned. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx new file mode 100644 index 0000000000..6b0c958725 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LegalSection from "@app/components/shared/config/configSections/LegalSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads legal document links and the analytics flag via useAppConfig() (the +// preview's own provider tree doesn't supply this — that's the portal +// context), so wrap here. AppConfigProvider uses autoFetch off so stories +// render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/ConfigSections/LegalSection", + component: LegalSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** All optional legal documents configured, analytics disabled — no Cookie Preferences card. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Analytics enabled — adds the Cookie Preferences card with its "Manage" button. */ +export const WithAnalyticsEnabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** No legal documents configured — only Privacy Policy and Terms show, using the stirling.com fallback links. */ +export const MinimalLinks: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx new file mode 100644 index 0000000000..9b04d088e9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Overview from "@app/components/shared/config/configSections/Overview"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads config via useAppConfig() — no props. Wrap in AppConfigProvider with +// autoFetch off so stories render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/Overview", + component: Overview, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No config resolved yet (default context state) — shows the loading spinner. */ +export const Default: Story = {}; + +/** Config loaded — renders the basic/security/system/integration sections. */ +export const Loaded: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Config resolved but carrying a server-reported warning. */ +export const WithWarning: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx new file mode 100644 index 0000000000..0d7a44c38f --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ProviderCard from "@app/components/shared/config/configSections/ProviderCard"; +import type { Provider } from "@app/components/shared/config/configSections/providerDefinitions"; + +const mockProvider: Provider = { + id: "google", + name: "Google", + icon: "key-rounded", + type: "oauth2", + scope: "Sign-in authentication", + documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth", + fields: [ + { + key: "clientId", + type: "text", + label: "Client ID", + description: "The OAuth2 client ID from Google Cloud Console", + placeholder: "your-client-id.apps.googleusercontent.com", + }, + { + key: "clientSecret", + type: "password", + label: "Client Secret", + description: "The OAuth2 client secret from Google Cloud Console", + }, + { + key: "scopes", + type: "tags", + label: "Scopes", + description: "OAuth2 scopes to request", + defaultValue: ["email", "profile"], + }, + { + key: "autoProvision", + type: "switch", + label: "Auto Provision Users", + description: "Automatically create accounts for new sign-ins", + defaultValue: false, + }, + ], +}; + +const meta = { + title: "Shared/Config/ConfigSections/ProviderCard", + component: ProviderCard, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + provider: mockProvider, + isConfigured: false, + }, +}; + +export const Configured: Story = { + args: { + provider: mockProvider, + isConfigured: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + autoProvision: true, + }, + }, +}; + +export const ReadOnly: Story = { + args: { + provider: mockProvider, + isConfigured: true, + readOnly: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx index 93999ce438..c24178635c 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -32,10 +32,16 @@ interface ProviderCardProps { readOnly?: boolean; } +// Shared default so an omitted `settings` prop keeps the same identity across +// renders. An inline `settings = {}` would allocate a new object every render, +// and the sync effect below lists `settings` as a dependency — so it would +// re-run and setState on every render, looping until React bails out. +const NO_SETTINGS: Record = {}; + export default function ProviderCard({ provider, isConfigured, - settings = {}, + settings = NO_SETTINGS, onSave, onDisconnect, onChange, diff --git a/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx new file mode 100644 index 0000000000..7d149b2b3b --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ThirdPartyLicensesSection, { + FrontendThirdPartyLicensesSection, +} from "@app/components/shared/config/configSections/ThirdPartyLicensesSection"; + +const meta = { + title: "Shared/Config/ConfigSections/ThirdPartyLicensesSection", + component: ThirdPartyLicensesSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Frontend: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx new file mode 100644 index 0000000000..f76bc47831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box } from "@mantine/core"; +import DocumentStack from "@app/components/shared/filePreview/DocumentStack"; + +const meta = { + title: "Shared/FilePreview/DocumentStack", + component: DocumentStack, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const previewContent = ( + +); + +export const SingleFile: Story = { + args: { + totalFiles: 1, + children: previewContent, + }, +}; + +export const TwoFiles: Story = { + args: { + totalFiles: 2, + children: previewContent, + }, +}; + +export const ManyFiles: Story = { + args: { + totalFiles: 5, + children: previewContent, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx new file mode 100644 index 0000000000..d410c3af78 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; + +const mockFile = new File(["dummy content"], "sample-report.pdf", { + type: "application/pdf", +}); + +const meta = { + title: "Shared/FilePreview/DocumentThumbnail", + component: DocumentThumbnail, + parameters: { layout: "padded" }, + args: { + file: mockFile, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithThumbnail: Story = { + args: { + thumbnail: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +export const Encrypted: Story = { + args: { + isEncrypted: true, + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx new file mode 100644 index 0000000000..560d129843 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, Text } from "@mantine/core"; +import HoverOverlay from "@app/components/shared/filePreview/HoverOverlay"; + +const meta = { + title: "Shared/FilePreview/HoverOverlay", + component: HoverOverlay, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + + Page thumbnail + + ), + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx new file mode 100644 index 0000000000..76a6fbc99e --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationArrows from "@app/components/shared/filePreview/NavigationArrows"; + +const meta = { + title: "Shared/FilePreview/NavigationArrows", + component: NavigationArrows, + parameters: { layout: "padded" }, + args: { + onPrevious: () => {}, + onNext: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children:
Page 1 of 5
, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + children:
Page 1 of 1
, + }, +}; diff --git a/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx new file mode 100644 index 0000000000..cc984b007f --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HomeIcon from "@mui/icons-material/HomeRounded"; +import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; + +const meta = { + title: "Shared/QuickAccessBar/QuickAccessButton", + component: QuickAccessButton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + label: "Home", + isActive: false, + ariaLabel: "Home", + }, +}; + +export const Active: Story = { + args: { + icon: , + label: "Home", + isActive: true, + ariaLabel: "Home", + }, +}; + +export const Disabled: Story = { + args: { + icon: , + label: "Home", + isActive: false, + ariaLabel: "Home", + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx new file mode 100644 index 0000000000..9de692e597 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreateSessionFlow } from "@app/components/shared/signing/CreateSessionFlow"; +import type { FileState } from "@app/types/file"; + +const mockFile: FileState = { + name: "contract.pdf", + size: 245_760, +}; + +function CreateSessionFlowDemo({ + initialFiles, +}: { + initialFiles: FileState[]; +}) { + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [dueDate, setDueDate] = useState(""); + + return ( + {}} + /> + ); +} + +const meta = { + title: "Shared/Signing/CreateSessionFlow", + component: CreateSessionFlow, + parameters: { layout: "padded" }, + args: { + selectedFiles: [mockFile], + selectedUserIds: [], + onSelectedUserIdsChange: () => {}, + dueDate: "", + onDueDateChange: () => {}, + creating: false, + onSubmit: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** A single file is selected, so step 1 shows its picker instead of the "no file" message. */ +export const Default: Story = { + render: () => , +}; + +/** No file selected yet: step 1 shows the empty-state prompt instead of the document picker. */ +export const NoFileSelected: Story = { + render: () => , +}; + +/** Session creation in flight: the review step's submit action is disabled. */ +export const Creating: Story = { + args: { + selectedFiles: [mockFile], + selectedUserIds: [1, 2], + onSelectedUserIdsChange: () => {}, + dueDate: "2026-08-01", + onDueDateChange: () => {}, + creating: true, + onSubmit: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx new file mode 100644 index 0000000000..ae08d23771 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx @@ -0,0 +1,104 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import SharedSigningLauncher from "@app/components/shared/signing/SharedSigningLauncher"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import type { SignRequestSummary } from "@app/types/signingSession"; + +/** + * SharedSigningLauncher reads server config via AppConfigContext (whether + * group signing is enabled) and tool selection via ToolWorkflowContext (the + * "Open shared signing" click target), so both must wrap it here. + */ +function withProviders(groupSigningEnabled: boolean) { + return function Decorator(Story: () => React.JSX.Element) { + return ( + + + + + + + + + + + + ); + }; +} + +const meta = { + title: "Shared/Signing/SharedSigningLauncher", + component: SharedSigningLauncher, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const noSignRequests: SignRequestSummary[] = []; + +const pendingSignRequests: SignRequestSummary[] = [ + { + sessionId: "session-1", + documentName: "NDA-acme-corp.pdf", + ownerUsername: "alex", + createdAt: "2026-07-01T09:00:00Z", + dueDate: "2026-07-20T00:00:00Z", + myStatus: "PENDING", + }, + { + sessionId: "session-2", + documentName: "vendor-agreement.pdf", + ownerUsername: "jordan", + createdAt: "2026-07-05T14:30:00Z", + dueDate: "2026-07-22T00:00:00Z", + myStatus: "VIEWED", + }, +]; + +/** Group signing enabled, no sign requests awaiting the user's action. */ +export const Default: Story = { + decorators: [withProviders(true)], + parameters: { + msw: { + handlers: [ + http.get("/api/v1/security/cert-sign/sign-requests", () => + HttpResponse.json(noSignRequests), + ), + http.get("/api/v1/security/cert-sign/sessions", () => + HttpResponse.json([]), + ), + ], + }, + }, +}; + +/** Two sign requests awaiting this user — the count badge appears on the button. */ +export const PendingRequests: Story = { + decorators: [withProviders(true)], + parameters: { + msw: { + handlers: [ + http.get("/api/v1/security/cert-sign/sign-requests", () => + HttpResponse.json(pendingSignRequests), + ), + http.get("/api/v1/security/cert-sign/sessions", () => + HttpResponse.json([]), + ), + ], + }, + }, +}; + +/** Group signing disabled on the server — the component renders nothing. */ +export const Disabled: Story = { + decorators: [withProviders(false)], +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx new file mode 100644 index 0000000000..b33c1e37b5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConfigureSignatureDefaultsStep } from "@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep"; +import { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput"; + +const meta = { + title: "Shared/Signing/Steps/ConfigureSignatureDefaultsStep", + component: ConfigureSignatureDefaultsStep, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + args: { + settings: {}, + onSettingsChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ConfigureDefaultsDemo({ + disabled, + initial, +}: { + disabled?: boolean; + initial: SignatureSettings; +}) { + const [settings, setSettings] = useState(initial); + return ( + {}} + onNext={() => {}} + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => ( + + ), +}; + +export const InvisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx new file mode 100644 index 0000000000..60cacfd9ea --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReviewSessionStep } from "@app/components/shared/signing/steps/ReviewSessionStep"; +import type { FileState } from "@app/types/file"; +import type { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput"; + +const selectedFile: FileState = { + name: "contract-agreement.pdf", + size: 2.4 * 1024 * 1024, +}; + +const signatureSettings: SignatureSettings = { + showSignature: true, + pageNumber: 1, + reason: "Approval of contract terms", + location: "London, UK", + showLogo: true, +}; + +const meta = { + title: "Shared/Signing/Steps/ReviewSessionStep", + component: ReviewSessionStep, + parameters: { layout: "padded" }, + args: { + selectedFile, + participantCount: 3, + signatureSettings, + dueDate: "2026-08-01", + onDueDateChange: () => {}, + onBack: () => {}, + onSubmit: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const InvisibleSignature: Story = { + args: { + signatureSettings: { + showSignature: false, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx new file mode 100644 index 0000000000..f8fbfacc1d --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectDocumentStep } from "@app/components/shared/signing/steps/SelectDocumentStep"; +import type { FileState } from "@app/types/file"; + +const meta = { + title: "Shared/Signing/SelectDocumentStep", + component: SelectDocumentStep, + parameters: { layout: "padded" }, + args: { + selectedFiles: [] as FileState[], + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const mockFile: FileState = { + name: "contract-agreement.pdf", + size: 2.4 * 1024 * 1024, +}; + +export const NoFileSelected: Story = {}; + +export const Default: Story = { + args: { + selectedFiles: [mockFile], + }, +}; + +export const MultipleFilesSelected: Story = { + args: { + selectedFiles: [mockFile, { name: "addendum.pdf", size: 512 * 1024 }], + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx new file mode 100644 index 0000000000..7e74abb11c --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectParticipantsStep } from "@app/components/shared/signing/steps/SelectParticipantsStep"; + +const meta = { + title: "Shared/Signing/Steps/SelectParticipantsStep", + component: SelectParticipantsStep, + parameters: { layout: "padded" }, + args: { + selectedUserIds: [], + onSelectedUserIdsChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithSelection: Story = { + args: { selectedUserIds: [1, 2] }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx b/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx new file mode 100644 index 0000000000..78f1710e69 --- /dev/null +++ b/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SliderWithInput from "@app/components/shared/sliderWithInput/SliderWithInput"; + +/** Reproduces the compression "Quality" slider used in tool settings panels. */ +const meta: Meta = { + title: "Shared/SliderWithInput", + component: SliderWithInput, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SliderDemo({ + disabled, + initial = 60, +}: { + disabled?: boolean; + initial?: number; +}) { + const [value, setValue] = useState(initial); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx new file mode 100644 index 0000000000..8f06789bac --- /dev/null +++ b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; +import type { TooltipTip } from "@app/types/tips"; + +const meta = { + title: "Shared/Tooltip/TooltipContent", + component: TooltipContent, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const singleTip: TooltipTip[] = [ + { + title: "Tip", + description: + "Choose a page range before running the split to keep sections in order.", + bullets: [ + "Ranges use commas, e.g. 1-3,5", + "Leave blank to include all pages", + ], + }, +]; + +const multipleTips: TooltipTip[] = [ + { + title: "Step 1", + description: "Select the pages you want to extract.", + }, + { + title: "Step 2", + description: "Confirm the output order matches your expectations.", + bullets: ["Drag to reorder", "Remove any page with the trash icon"], + }, +]; + +/** Plain text content with no structured tips. */ +export const Default: Story = { + args: { + content: "Drag and drop files here, or click to browse your computer.", + }, +}; + +/** A single tip with a title, description, and bullet list. */ +export const SingleTip: Story = { + args: { + tips: singleTip, + }, +}; + +/** Multiple tips rendered as separate sections, each with its own spacing. */ +export const MultipleTips: Story = { + args: { + tips: multipleTips, + }, +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx new file mode 100644 index 0000000000..1a4d260d08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DrawSignatureCanvas } from "@app/components/shared/wetSignature/DrawSignatureCanvas"; + +const meta = { + title: "Shared/WetSignature/DrawSignatureCanvas", + component: DrawSignatureCanvas, + parameters: { layout: "padded" }, + args: { + signature: null, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +function DrawSignatureCanvasDemo( + props: Partial>, +) { + const [signature, setSignature] = useState(null); + + return ( + + ); +} + +/** Empty canvas ready for the user to draw a signature. */ +export const Default: Story = { render: () => }; + +/** Disabled state — drawing and clearing are both blocked. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx new file mode 100644 index 0000000000..0c6cf99dcf --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + SignatureTypeSelector, + type SignatureType, +} from "@app/components/shared/wetSignature/SignatureTypeSelector"; + +const meta: Meta = { + title: "Shared/WetSignature/SignatureTypeSelector", + component: SignatureTypeSelector, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function SignatureTypeSelectorDemo({ + initialValue = "draw", + disabled, +}: { + initialValue?: SignatureType; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx new file mode 100644 index 0000000000..b3996cbaa4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TypeSignatureText } from "@app/components/shared/wetSignature/TypeSignatureText"; + +const meta = { + title: "Shared/WetSignature/TypeSignatureText", + component: TypeSignatureText, + parameters: { layout: "padded" }, + args: { + text: "Jane Doe", + fontFamily: "Arial", + fontSize: 40, + color: "#000000", + onTextChange: () => {}, + onFontFamilyChange: () => {}, + onFontSizeChange: () => {}, + onColorChange: () => {}, + onSignatureChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function TypeSignatureTextDemo( + props: Partial>, +) { + const [text, setText] = useState(props.text ?? "Jane Doe"); + const [fontFamily, setFontFamily] = useState(props.fontFamily ?? "Arial"); + const [fontSize, setFontSize] = useState(props.fontSize ?? 40); + const [color, setColor] = useState(props.color ?? "#000000"); + + return ( + {})} + /> + ); +} + +/** Typed signature with text, font, size and colour controls plus a live preview. */ +export const Default: Story = { + render: () => , +}; + +/** No text entered yet, so the preview is hidden. */ +export const Empty: Story = { + render: () => , +}; + +/** All controls disabled, e.g. while the signature is being submitted. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx new file mode 100644 index 0000000000..4612e37442 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UploadSignatureImage } from "@app/components/shared/wetSignature/UploadSignatureImage"; + +const meta = { + title: "Shared/WetSignature/UploadSignatureImage", + component: UploadSignatureImage, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + signature: null, + onChange: () => {}, + }, +}; + +export const WithSignature: Story = { + args: { + signature: + "data:image/svg+xml;base64," + + btoa( + 'Jane Doe', + ), + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + signature: null, + onChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx b/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx new file mode 100644 index 0000000000..ffed473833 --- /dev/null +++ b/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx @@ -0,0 +1,102 @@ +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToastRenderer from "@app/components/toast/ToastRenderer"; +import { ToastProvider, useToast } from "@app/components/toast/ToastContext"; +import type { ToastOptions } from "@app/components/toast/types"; + +// ToastRenderer takes no props — it renders whatever is in ToastContext. The +// only way to exercise it is to seed toasts through the same provider/show() +// API the app uses, then let the renderer subscribe to that context. +function SeedToasts({ + toasts, + children, +}: { + toasts: ToastOptions[]; + children: React.ReactNode; +}) { + const { show } = useToast(); + useEffect(() => { + toasts.forEach((toast) => show(toast)); + }, []); + return <>{children}; +} + +const meta = { + title: "Toast/ToastRenderer", + component: ToastRenderer, + parameters: { layout: "fullscreen" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const WithProgress: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const WithActionButton: Story = { + decorators: [ + (Story) => ( + {}, + }, + ]} + > + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx b/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx new file mode 100644 index 0000000000..4135be2c79 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback"; + +const meta: Meta = { + title: "Tools/ToolLoadingFallback", + component: ToolLoadingFallback, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const WithToolName: Story = { + args: { + toolName: "Merge PDF", + }, +}; diff --git a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx new file mode 100644 index 0000000000..2290784b00 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * ToolWorkflowProvider reads navigation and tool-registry state on mount, so + * NavigationProvider and ToolRegistryProvider must wrap it; PreferencesProvider + * backs the persisted tool-panel-mode choice. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Tools/ToolPanelModePrompt", + component: ToolPanelModePrompt, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Orchestrator controls visibility directly via `forceOpen`. */ +export const Default: Story = { + args: { + forceOpen: true, + onComplete: () => {}, + }, +}; + +/** Closed — nothing renders on top of the story canvas. */ +export const Closed: Story = { + args: { + forceOpen: false, + onComplete: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx b/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx new file mode 100644 index 0000000000..fbca2dfc4f --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import ToolRenderer from "@app/components/tools/ToolRenderer"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * ToolWorkflowContext reads the tool registry, preferences, and navigation + * state, so all three providers must be present above it for ToolRenderer + * to resolve a tool. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Tools/ToolRenderer", + component: ToolRenderer, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** A registered tool with a component renders its lazy-loaded settings UI. */ +export const Default: Story = { + args: { + selectedToolKey: "compress", + onPreviewFile: () => {}, + onComplete: () => {}, + onError: () => {}, + }, +}; + +/** An unknown tool key falls back to the "Tool not found" message. */ +export const ToolNotFound: Story = { + args: { + selectedToolKey: "not-a-real-tool" as Story["args"]["selectedToolKey"], + onPreviewFile: () => {}, + onComplete: () => {}, + onError: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx new file mode 100644 index 0000000000..c4dcc8a202 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageNumberPreview from "@app/components/tools/addPageNumbers/PageNumberPreview"; +import { defaultParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; + +const meta = { + title: "Tools/AddPageNumbers/PageNumberPreview", + component: PageNumberPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithQuickGrid: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + showQuickGrid: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx b/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx new file mode 100644 index 0000000000..be16c2037c --- /dev/null +++ b/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; +import { AddPasswordParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters"; + +const meta = { + title: "Tools/AddPassword/AddPasswordSettings", + component: AddPasswordSettings, + args: { + parameters: { password: "", ownerPassword: "", keyLength: 128 }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the password/key-length inputs interactive in the canvas. +const AddPasswordSettingsDemo = (props: { + initialParameters: AddPasswordParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Filled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx new file mode 100644 index 0000000000..74a8c95690 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StampPreview from "@app/components/tools/addStamp/StampPreview"; +import { defaultParameters } from "@app/components/tools/addStamp/useAddStampParameters"; + +const meta = { + title: "Tools/AddStamp/StampPreview", + component: StampPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithText: Story = { + args: { + parameters: { + ...defaultParameters, + stampText: "CONFIDENTIAL", + }, + onParameterChange: () => {}, + }, +}; + +export const WithQuickGrid: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + showQuickGrid: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx new file mode 100644 index 0000000000..b50df8c70f --- /dev/null +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings"; +import { + AddStampParameters, + defaultParameters, +} from "@app/components/tools/addStamp/useAddStampParameters"; + +const meta = { + title: "Tools/AddStamp/StampSetupSettings", + component: StampSetupSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, + filename, +}: { + initialParameters?: AddStampParameters; + disabled?: boolean; + filename?: string; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + filename={filename} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const TextStampWithPreview: Story = { + render: () => ( + + ), +}; + +export const ImageStamp: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..1476e951e5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/AddWatermarkSingleStepSettings"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/AddWatermarkSingleStepSettings", + component: AddWatermarkSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, + showFlatten, + textOnly, +}: { + initialParameters?: AddWatermarkParameters; + disabled?: boolean; + showFlatten?: boolean; + textOnly?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + showFlatten={showFlatten} + textOnly={textOnly} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const TextWatermark: Story = { + render: () => ( + + ), +}; + +export const TextOnly: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx new file mode 100644 index 0000000000..6e9e099c5d --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkFormatting from "@app/components/tools/addWatermark/WatermarkFormatting"; +import { defaultParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/WatermarkFormatting", + component: WatermarkFormatting, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: { ...defaultParameters, watermarkType: "text" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; + +export const WithoutFlattenOption: Story = { + args: { + ...Default.args, + showFlatten: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx new file mode 100644 index 0000000000..b5568e1779 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkImageFile from "@app/components/tools/addWatermark/WatermarkImageFile"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/WatermarkImageFile", + component: WatermarkImageFile, + args: { + parameters: { ...defaultParameters, watermarkType: "image" }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const makeImageFile = (name: string, sizeBytes: number): File => + new File([new Uint8Array(sizeBytes)], name, { type: "image/png" }); + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the file picker interaction working in the canvas. +const WatermarkImageFileDemo = (props: { + initialParameters: AddWatermarkParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const WithSelectedImage: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx new file mode 100644 index 0000000000..316e377511 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkStyleSettings from "@app/components/tools/addWatermark/WatermarkStyleSettings"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "Tools/AddWatermark/WatermarkStyleSettings", + component: WatermarkStyleSettings, +}; +export default meta; +type Story = StoryObj; + +function WatermarkStyleSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + watermarkType: "text", + watermarkText: "CONFIDENTIAL", + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx new file mode 100644 index 0000000000..5bee6d393a --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkTextStyle from "@app/components/tools/addWatermark/WatermarkTextStyle"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "AddWatermark/WatermarkTextStyle", + component: WatermarkTextStyle, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function WatermarkTextStyleDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AddWatermarkParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx new file mode 100644 index 0000000000..9ce7f4ff4b --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkTypeSettings from "@app/components/tools/addWatermark/WatermarkTypeSettings"; + +const meta: Meta = { + title: "Tools/AddWatermark/WatermarkTypeSettings", + component: WatermarkTypeSettings, +}; +export default meta; +type Story = StoryObj; + +function WatermarkTypeSettingsDemo({ disabled }: { disabled?: boolean }) { + const [watermarkType, setWatermarkType] = useState<"text" | "image">("text"); + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx new file mode 100644 index 0000000000..c95c2f37f1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkWording from "@app/components/tools/addWatermark/WatermarkWording"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "AddWatermark/WatermarkWording", + component: WatermarkWording, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function WatermarkWordingDemo({ + initialText = "", + disabled, +}: { + initialText?: string; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + watermarkText: initialText, + }); + + const handleParameterChange = ( + key: K, + value: AddWatermarkParameters[K], + ) => { + setParameters((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Filled: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx new file mode 100644 index 0000000000..c4b4ecef9b --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastBasicSettings from "@app/components/tools/adjustContrast/AdjustContrastBasicSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastBasicSettings", + component: AdjustContrastBasicSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx new file mode 100644 index 0000000000..13155ae524 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastColorSettings from "@app/components/tools/adjustContrast/AdjustContrastColorSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastColorSettings", + component: AdjustContrastColorSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx new file mode 100644 index 0000000000..e1d605188f --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastPreview from "@app/components/tools/adjustContrast/AdjustContrastPreview"; +import { defaultParameters } from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastPreview", + component: AdjustContrastPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// No file selected yet: the component shows the obscured "select a PDF" state +// without attempting any thumbnail generation, which needs a live PDF worker. +export const Default: Story = { + args: { + file: null, + parameters: defaultParameters, + }, +}; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..6352b0fe15 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastSingleStepSettings from "@app/components/tools/adjustContrast/AdjustContrastSingleStepSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastSingleStepSettings", + component: AdjustContrastSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx new file mode 100644 index 0000000000..00d3aaba85 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustPageScaleSettings from "@app/components/tools/adjustPageScale/AdjustPageScaleSettings"; +import { + AdjustPageScaleParameters, + PageSize, +} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; + +const meta = { + title: "Tools/AdjustPageScale/AdjustPageScaleSettings", + component: AdjustPageScaleSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const defaultParameters: AdjustPageScaleParameters = { + scaleFactor: 1.0, + pageSize: PageSize.KEEP, + orientation: "PORTRAIT", +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const CustomPageSize: Story = { + args: { + parameters: { + scaleFactor: 2.5, + pageSize: PageSize.A4, + orientation: "LANDSCAPE", + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx b/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx new file mode 100644 index 0000000000..6e632564c1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutoRenameSettings from "@app/components/tools/autoRename/AutoRenameSettings"; +import { AutoRenameParameters } from "@app/hooks/tools/autoRename/useAutoRenameParameters"; + +const meta = { + title: "Tools/AutoRename/AutoRenameSettings", + component: AutoRenameSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const baseParameters: AutoRenameParameters = { + useFirstTextAsFallback: false, +}; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx new file mode 100644 index 0000000000..8299f7222c --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutomationCreation from "@app/components/tools/automate/AutomationCreation"; +import { AutomationMode } from "@app/types/automation"; +import type { AutomationConfig } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +const emptyToolRegistry: Partial = {}; + +const existingAutomation: AutomationConfig = { + id: "automation-1", + name: "Weekly Cleanup", + description: "Compress and flatten incoming PDFs.", + icon: "CompressIcon", + operations: [ + { operation: "compress", parameters: {} }, + { operation: "flatten", parameters: {} }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const meta = { + title: "Tools/Automate/AutomationCreation", + component: AutomationCreation, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + mode: AutomationMode.CREATE, + onBack: () => {}, + onComplete: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; + +export const EditExisting: Story = { + args: { + mode: AutomationMode.EDIT, + existingAutomation, + onBack: () => {}, + onComplete: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; + +export const EmbeddedHideMetadata: Story = { + args: { + mode: AutomationMode.CREATE, + hideMetadata: true, + nameOverride: "Watched Folder Automation", + onBack: () => {}, + onComplete: () => {}, + onSaveFailed: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx new file mode 100644 index 0000000000..b3fea378df --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutomationImportModal from "@app/components/tools/automate/AutomationImportModal"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +const emptyToolRegistry: Partial = {}; + +const meta = { + title: "Tools/Automate/AutomationImportModal", + component: AutomationImportModal, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + toolRegistry: emptyToolRegistry, + onCancel: () => {}, + onImport: () => {}, + }, +}; + +export const Closed: Story = { + args: { + opened: false, + toolRegistry: emptyToolRegistry, + onCancel: () => {}, + onImport: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx b/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx new file mode 100644 index 0000000000..1a8fdadcd1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import IconSelector from "@app/components/tools/automate/IconSelector"; + +const meta = { + title: "Automate/IconSelector", + component: IconSelector, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function IconSelectorDemo({ size }: { size?: "sm" | "md" | "lg" }) { + const [value, setValue] = useState("SettingsIcon"); + return ; +} + +export const Default: Story = { + render: () => , +}; + +export const Medium: Story = { + render: () => , +}; + +export const Large: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx new file mode 100644 index 0000000000..81aa48e300 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextInput } from "@mantine/core"; +import ToolConfigurationModal from "@app/components/tools/automate/ToolConfigurationModal"; +import { + ToolRegistry, + ToolCategoryId, + SubcategoryId, +} from "@app/data/toolsTaxonomy"; +import { + ToolAutomationSettingsProps, + ErasedToolParams, +} from "@app/hooks/tools/shared/toolOperationTypes"; + +const meta = { + title: "Tools/Automate/ToolConfigurationModal", + component: ToolConfigurationModal, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Registry entry with no automationSettings: the modal falls back to a "no settings" message. */ +export const Default: Story = { + args: { + opened: true, + tool: { + id: "1", + operation: "autoRename", + name: "Auto Rename", + }, + onSave: () => {}, + onCancel: () => {}, + toolRegistry: {}, + }, +}; + +function DemoSettings({ + parameters, + onParameterChange, + disabled, +}: ToolAutomationSettingsProps) { + return ( + + onParameterChange("prefix", event.currentTarget.value) + } + disabled={disabled} + /> + ); +} + +const registryWithSettings: Partial = { + autoRename: { + icon: null, + name: "Auto Rename", + component: null, + description: "Automatically rename files.", + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: DemoSettings, + }, +}; + +/** Registry entry with a settings component: renders the tool's own configuration fields. */ +export const WithSettings: Story = { + args: { + opened: true, + tool: { + id: "1", + operation: "autoRename", + name: "Auto Rename", + parameters: { prefix: "invoice-" }, + }, + onSave: () => {}, + onCancel: () => {}, + toolRegistry: registryWithSettings, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx new file mode 100644 index 0000000000..f9c83419c8 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolList from "@app/components/tools/automate/ToolList"; +import type { AutomationTool } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +// Kept empty: ToolSelector resolves `tool.operation` against this registry, and an +// unmatched operation falls back to the search-input display rather than ToolButton +// (which needs Hotkey/ToolWorkflow context this story doesn't mount). +const emptyToolRegistry: Partial = {}; + +const configuredTools: AutomationTool[] = [ + { + id: "step-1", + operation: "compress", + name: "Compress", + configured: true, + parameters: {}, + }, + { + id: "step-2", + operation: "flatten", + name: "Flatten", + configured: false, + parameters: {}, + }, +]; + +const meta = { + title: "Tools/Automate/ToolList", + component: ToolList, + args: { + toolRegistry: emptyToolRegistry, + onToolUpdate: () => {}, + onToolRemove: () => {}, + onToolConfigure: () => {}, + onToolAdd: () => {}, + getToolName: (operation: string) => operation, + getToolDefaultParameters: () => ({}), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + tools: configuredTools, + }, +}; + +export const Empty: Story = { + args: { + tools: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx new file mode 100644 index 0000000000..504fed0b1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolSelector from "@app/components/tools/automate/ToolSelector"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +// ToolSelector only mounts ToolButton once a tool is selected or the dropdown +// is opened with matches, and ToolButton needs Hotkey/ToolWorkflow/AppConfig +// context the shared preview doesn't mount. Leaving `selectedValue` unset and +// the registry empty keeps the story on the closed search-input display. +const emptyToolRegistry: Partial = {}; + +const meta = { + title: "Tools/Automate/ToolSelector", + component: ToolSelector, + args: { + onSelect: () => {}, + toolRegistry: emptyToolRegistry, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Closed search input, showing the default "Add a tool..." placeholder. */ +export const Default: Story = {}; + +/** Custom placeholder text, e.g. when embedded in a different flow. */ +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a step...", + }, +}; diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx new file mode 100644 index 0000000000..7d1dc55e4d --- /dev/null +++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import BookletImpositionSettings from "@app/components/tools/bookletImposition/BookletImpositionSettings"; +import { + BookletImpositionParameters, + defaultParameters, +} from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters"; + +const meta = { + title: "Tools/BookletImposition/BookletImpositionSettings", + component: BookletImpositionSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const manualDuplexParameters: BookletImpositionParameters = { + ...defaultParameters, + doubleSided: false, + duplexPass: "FIRST", +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const ManualDuplex: Story = { + args: { + parameters: manualDuplexParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx new file mode 100644 index 0000000000..449ca5bf6a --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertSignAutomationSettings from "@app/components/tools/certSign/CertSignAutomationSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertSignAutomationSettings", + component: CertSignAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const AutoSignMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx new file mode 100644 index 0000000000..137fc8272a --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateFilesSettings", + component: CertificateFilesSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Pkcs12: Story = { + render: () => ( + + ), +}; + +export const Jks: Story = { + render: () => ( + + ), +}; + +export const AutoSignMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx new file mode 100644 index 0000000000..02a30d6382 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings"; +import { defaultParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateFormatSettings", + component: CertificateFormatSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Selected: Story = { + args: { + parameters: { ...defaultParameters, certType: "PKCS12" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx new file mode 100644 index 0000000000..8caa8726f5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + CertificateSelector, + CertificateType, + UploadFormat, +} from "@app/components/tools/certSign/CertificateSelector"; + +const meta = { + title: "Tools/CertSign/CertificateSelector", + component: CertificateSelector, + parameters: { layout: "padded" }, + args: { + certType: "UPLOAD", + onCertTypeChange: () => {}, + uploadFormat: "PKCS12", + onUploadFormatChange: () => {}, + p12File: null, + onP12FileChange: () => {}, + privateKeyFile: null, + onPrivateKeyFileChange: () => {}, + certFile: null, + onCertFileChange: () => {}, + jksFile: null, + onJksFileChange: () => {}, + password: "", + onPasswordChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SelectorDemo({ + initialCertType = "UPLOAD", + initialUploadFormat = "PKCS12", + disabled, +}: { + initialCertType?: CertificateType; + initialUploadFormat?: UploadFormat; + disabled?: boolean; +}) { + const [certType, setCertType] = useState(initialCertType); + const [uploadFormat, setUploadFormat] = + useState(initialUploadFormat); + const [p12File, setP12File] = useState(null); + const [privateKeyFile, setPrivateKeyFile] = useState(null); + const [certFile, setCertFile] = useState(null); + const [jksFile, setJksFile] = useState(null); + const [password, setPassword] = useState(""); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const PemFormat: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx new file mode 100644 index 0000000000..2ad8f660a0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateTypeSettings", + component: CertificateTypeSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, + serverCertificateEnabled = false, + hardwareSigningAvailable = false, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; + serverCertificateEnabled?: boolean; + hardwareSigningAvailable?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + + ); +} + +/** No server certificate or hardware signing available — just the informational message. */ +export const Default: Story = { + render: () => , +}; + +/** Server certificate and on-device signing both available alongside upload. */ +export const AllSourcesAvailable: Story = { + render: () => ( + + ), +}; + +/** Server certificate selected as the active source. */ +export const ServerSelected: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx new file mode 100644 index 0000000000..2e34f1e2a5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HardwareCertificateSettings from "@app/components/tools/certSign/HardwareCertificateSettings"; +import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; + +const baseParameters: CertSignParameters = { + signMode: "DEVICE", + certType: "WINDOWS_STORE", + password: "", + showSignature: false, + reason: "", + location: "", + name: "", + pageNumber: 1, + showLogo: true, +}; + +const meta = { + title: "Tools/CertSign/HardwareCertificateSettings", + component: HardwareCertificateSettings, + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the kind toggle / driver / PIN inputs interactive +// in the canvas. Capability + certificate lookups hit the backend and are +// expected to fail in Storybook - the component treats that as best-effort +// and still renders the picker. +const HardwareCertificateSettingsDemo = (props: { + initialParameters: CertSignParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Pkcs11: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx new file mode 100644 index 0000000000..13c1e333f6 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureAppearanceSettings from "@app/components/tools/certSign/SignatureAppearanceSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/SignatureAppearanceSettings", + component: SignatureAppearanceSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const VisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx new file mode 100644 index 0000000000..cd41aa7ca1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSettingsDisplay from "@app/components/tools/certSign/SignatureSettingsDisplay"; + +const meta = { + title: "CertSign/SignatureSettingsDisplay", + component: SignatureSettingsDisplay, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + showSignature: true, + pageNumber: 1, + reason: "Document approval", + location: "New York, USA", + showLogo: true, + }, +}; + +export const Invisible: Story = { + args: { + showSignature: false, + pageNumber: null, + reason: null, + location: null, + showLogo: false, + }, +}; + +export const MinimalDetails: Story = { + args: { + showSignature: true, + pageNumber: null, + reason: null, + location: null, + showLogo: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx new file mode 100644 index 0000000000..87b4127647 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSettingsInput, { + SignatureSettings, +} from "@app/components/tools/certSign/SignatureSettingsInput"; + +const meta = { + title: "Tools/CertSign/SignatureSettingsInput", + component: SignatureSettingsInput, + parameters: { layout: "padded" }, + args: { + value: {}, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialValue = {}, + disabled, +}: { + initialValue?: SignatureSettings; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const VisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx new file mode 100644 index 0000000000..12a1ef08fe --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WetSignatureInput from "@app/components/tools/certSign/WetSignatureInput"; + +const meta = { + title: "Tools/CertSign/WetSignatureInput", + component: WetSignatureInput, + parameters: { layout: "padded" }, + args: { + onSignatureDataChange: () => {}, + onSignatureTypeChange: () => {}, + onCertTypeChange: () => {}, + onP12FileChange: () => {}, + onPasswordChange: () => {}, + certType: "USER_CERT", + p12File: null, + password: "", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function WetSignatureInputDemo( + props: Partial>, +) { + const [certType, setCertType] = useState<"SERVER" | "USER_CERT" | "UPLOAD">( + props.certType ?? "USER_CERT", + ); + const [p12File, setP12File] = useState(props.p12File ?? null); + const [password, setPassword] = useState(props.password ?? ""); + + return ( + {}} + onSignatureTypeChange={() => {}} + onP12FileChange={setP12File} + onPasswordChange={setPassword} + {...props} + certType={certType} + onCertTypeChange={setCertType} + p12File={p12File} + password={password} + /> + ); +} + +/** Default state: personal certificate selected, canvas signature type. */ +export const Default: Story = { + render: () => , +}; + +/** Upload-certificate flow, revealing the P12 file and password fields. */ +export const UploadCertificate: Story = { + render: () => , +}; + +/** All controls disabled. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx new file mode 100644 index 0000000000..316ad9d8b0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AddParticipantsFlow } from "@app/components/tools/certSign/modals/AddParticipantsFlow"; + +const meta = { + title: "Tools/CertSign/Modals/AddParticipantsFlow", + component: AddParticipantsFlow, + parameters: { layout: "padded" }, + args: { + opened: true, + onClose: () => {}, + onSubmit: async () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Closed: Story = { + args: { opened: false }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx new file mode 100644 index 0000000000..2962196a74 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CertificateConfigModal } from "@app/components/tools/certSign/modals/CertificateConfigModal"; + +const meta = { + title: "Tools/CertSign/Modals/CertificateConfigModal", + component: CertificateConfigModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onSign: async () => {}, + signatureCount: 1, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const MultipleSignatures: Story = { + args: { signatureCount: 3 }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx new file mode 100644 index 0000000000..28f2948249 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectSignatureModal } from "@app/components/tools/certSign/modals/SelectSignatureModal"; + +const meta = { + title: "Tools/CertSign/Modals/SelectSignatureModal", + component: SelectSignatureModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onSignatureSelected: () => {}, + onCreateNew: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx new file mode 100644 index 0000000000..fc4df3c7c8 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ParticipantListPanel } from "@app/components/tools/certSign/panels/ParticipantListPanel"; +import type { ParticipantInfo } from "@app/types/signingSession"; + +const meta = { + title: "CertSign/ParticipantListPanel", + component: ParticipantListPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const participants: ParticipantInfo[] = [ + { + id: 1, + userId: 101, + email: "alice@example.com", + name: "Alice Anderson", + status: "SIGNED", + lastUpdated: "2026-07-10T12:00:00Z", + }, + { + id: 2, + userId: 102, + email: "bob@example.com", + name: "Bob Brown", + status: "PENDING", + lastUpdated: "2026-07-11T09:00:00Z", + }, + { + id: 3, + userId: 103, + email: "carol@example.com", + name: "Carol Clark", + status: "DECLINED", + lastUpdated: "2026-07-12T15:30:00Z", + }, +]; + +export const Default: Story = { + args: { + participants, + finalized: false, + onRemove: () => {}, + }, +}; + +export const Finalized: Story = { + args: { + participants, + finalized: true, + onRemove: () => {}, + }, +}; + +export const Empty: Story = { + args: { + participants: [], + finalized: false, + onRemove: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx new file mode 100644 index 0000000000..95575dab64 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SessionActionsPanel } from "@app/components/tools/certSign/panels/SessionActionsPanel"; +import type { SessionDetail } from "@app/types/signingSession"; + +const baseSession: SessionDetail = { + sessionId: "session-1", + documentName: "Contract.pdf", + ownerEmail: "owner@example.com", + message: "Please review and sign by end of week.", + dueDate: "2026-08-01", + createdAt: "2026-07-01T09:00:00Z", + updatedAt: "2026-07-10T09:00:00Z", + finalized: false, + participants: [ + { + id: 1, + userId: 1, + email: "alice@example.com", + name: "Alice", + status: "SIGNED", + lastUpdated: "2026-07-05T09:00:00Z", + }, + { + id: 2, + userId: 2, + email: "bob@example.com", + name: "Bob", + status: "PENDING", + lastUpdated: "2026-07-01T09:00:00Z", + }, + ], +}; + +const meta = { + title: "Tools/CertSign/Panels/SessionActionsPanel", + component: SessionActionsPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + session: baseSession, + onAddParticipants: () => {}, + onFinalize: () => {}, + onLoadSignedPdf: () => {}, + finalizing: false, + loadingPdf: false, + }, +}; + +export const AllSigned: Story = { + args: { + ...Default.args, + session: { + ...baseSession, + participants: baseSession.participants.map((p) => ({ + ...p, + status: "SIGNED", + })), + }, + }, +}; + +export const Finalized: Story = { + args: { + ...Default.args, + session: { ...baseSession, finalized: true }, + loadingPdf: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx new file mode 100644 index 0000000000..fcc489a6b1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SessionDetailPanel } from "@app/components/tools/certSign/panels/SessionDetailPanel"; +import type { SigningDetailData } from "@app/hooks/signing/useSigningSessionController"; +import type { SessionDetail } from "@app/types/signingSession"; + +const baseSession: SessionDetail = { + sessionId: "session-1", + documentName: "Employment-Contract.pdf", + ownerEmail: "owner@example.com", + message: "Please review and sign by the due date.", + dueDate: "2026-08-01T00:00:00Z", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-10T00:00:00Z", + finalized: false, + participants: [ + { + id: 1, + userId: 101, + email: "alice@example.com", + name: "Alice Johnson", + status: "SIGNED", + lastUpdated: "2026-07-05T00:00:00Z", + }, + { + id: 2, + userId: 102, + email: "bob@example.com", + name: "Bob Smith", + status: "PENDING", + lastUpdated: "2026-07-01T00:00:00Z", + }, + ], +}; + +function buildData(session: SessionDetail): SigningDetailData { + return { + session, + pdfFile: null, + onFinalize: async () => {}, + onLoadSignedPdf: async () => {}, + onAddParticipants: async () => {}, + onRemoveParticipant: async () => {}, + onDelete: async () => {}, + onBack: () => {}, + onRefresh: async () => {}, + }; +} + +const meta = { + title: "CertSign/SessionDetailPanel", + component: SessionDetailPanel, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: buildData(baseSession), + }, +}; + +export const Finalized: Story = { + args: { + data: buildData({ + ...baseSession, + finalized: true, + participants: baseSession.participants.map((p) => ({ + ...p, + status: "SIGNED", + })), + }), + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx new file mode 100644 index 0000000000..2a1792b5c3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignControlsPanel from "@app/components/tools/certSign/panels/SignControlsPanel"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +const meta = { + title: "CertSign/SignControlsPanel", + component: SignControlsPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const textSignatureConfig: SignParameters = { + signatureType: "text", + signerName: "Alice Anderson", + fontFamily: "Helvetica", + fontSize: 16, + textColor: "#000000", + signatureData: "Alice Anderson", +}; + +export const Default: Story = { + args: { + placementMode: false, + onPlacementModeChange: () => {}, + onSignatureSelected: () => {}, + onComplete: () => {}, + canComplete: true, + signatureConfig: textSignatureConfig, + hasSelectedAnnotation: true, + onDeleteSelected: () => {}, + }, +}; + +export const PlacingNoSelection: Story = { + args: { + ...Default.args, + placementMode: true, + canComplete: false, + hasSelectedAnnotation: false, + }, +}; + +export const NoSignatureChosen: Story = { + args: { + ...Default.args, + signatureConfig: { signatureType: "canvas" }, + hasSelectedAnnotation: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx new file mode 100644 index 0000000000..5da3dbd5ea --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AddSignaturesStep } from "@app/components/tools/certSign/steps/AddSignaturesStep"; + +const meta = { + title: "Tools/CertSign/Steps/AddSignaturesStep", + component: AddSignaturesStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onRequestPlacement: () => {}, + placementMode: false, + }, +}; + +export const PlacementMode: Story = { + args: { + onRequestPlacement: () => {}, + onCancelPlacement: () => {}, + placementMode: true, + }, +}; + +export const Disabled: Story = { + args: { + onRequestPlacement: () => {}, + placementMode: false, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx new file mode 100644 index 0000000000..025ea7d574 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CertificateSelectionStep } from "@app/components/tools/certSign/steps/CertificateSelectionStep"; +import { + CertificateType, + UploadFormat, +} from "@app/components/tools/certSign/CertificateSelector"; + +const meta = { + title: "Tools/CertSign/CertificateSelectionStep", + component: CertificateSelectionStep, + parameters: { layout: "padded" }, + args: { + certType: "UPLOAD", + onCertTypeChange: () => {}, + uploadFormat: "PKCS12", + onUploadFormatChange: () => {}, + p12File: null, + onP12FileChange: () => {}, + privateKeyFile: null, + onPrivateKeyFileChange: () => {}, + certFile: null, + onCertFileChange: () => {}, + jksFile: null, + onJksFileChange: () => {}, + password: "", + onPasswordChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function StepDemo({ + initialCertType = "UPLOAD", + initialUploadFormat = "PKCS12", + withUploadedFile = false, + disabled, +}: { + initialCertType?: CertificateType; + initialUploadFormat?: UploadFormat; + withUploadedFile?: boolean; + disabled?: boolean; +}) { + const [certType, setCertType] = useState(initialCertType); + const [uploadFormat, setUploadFormat] = + useState(initialUploadFormat); + const [p12File, setP12File] = useState( + withUploadedFile + ? new File(["mock"], "certificate.p12", { + type: "application/x-pkcs12", + }) + : null, + ); + const [privateKeyFile, setPrivateKeyFile] = useState(null); + const [certFile, setCertFile] = useState(null); + const [jksFile, setJksFile] = useState(null); + const [password, setPassword] = useState(withUploadedFile ? "secret" : ""); + + return ( + {}} + onNext={() => {}} + disabled={disabled} + /> + ); +} + +/** Upload flow with no file/password yet — "Continue" stays disabled. */ +export const Default: Story = { + render: () => , +}; + +/** Upload flow with a certificate + password already provided — "Continue" is enabled. */ +export const UploadReady: Story = { + render: () => , +}; + +/** Pre-installed user certificate — always valid, no upload fields required. */ +export const UserCertificate: Story = { + render: () => , +}; + +/** Whole step disabled (e.g. while a request is in flight). */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx new file mode 100644 index 0000000000..76e920b0db --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReviewSignatureStep } from "@app/components/tools/certSign/steps/ReviewSignatureStep"; +import type { SignRequestDetail } from "@app/types/signingSession"; + +const signRequest: SignRequestDetail = { + sessionId: "session-1", + documentName: "Contract.pdf", + ownerUsername: "owner@example.com", + message: "Please sign the attached contract.", + dueDate: "2026-08-01T00:00:00Z", + createdAt: "2026-07-15T00:00:00Z", + myStatus: "VIEWED", + showSignature: true, + pageNumber: 1, + reason: "Contract approval", + location: "London, UK", +}; + +const meta = { + title: "CertSign/ReviewSignatureStep", + component: ReviewSignatureStep, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatureCount: 1, + certType: "USER_CERT", + uploadFormat: "PKCS12", + p12File: null, + signRequest, + onBack: () => {}, + onSign: () => {}, + onDecline: () => {}, + }, +}; + +export const MultipleSignatures: Story = { + args: { + ...Default.args, + signatureCount: 3, + certType: "SERVER", + }, +}; + +export const UploadedCertificateDisabled: Story = { + args: { + ...Default.args, + certType: "UPLOAD", + uploadFormat: "PFX", + p12File: new File(["dummy"], "my-cert.pfx"), + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx new file mode 100644 index 0000000000..c99f9a0fa9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignatureCreationStep } from "@app/components/tools/certSign/steps/SignatureCreationStep"; + +const meta = { + title: "CertSign/Steps/SignatureCreationStep", + component: SignatureCreationStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatureType: "draw", + onSignatureTypeChange: () => {}, + signature: null, + onSignatureChange: () => {}, + signatureText: "", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#000000", + onSignatureTextChange: () => {}, + onFontFamilyChange: () => {}, + onFontSizeChange: () => {}, + onTextColorChange: () => {}, + onNext: () => {}, + }, +}; + +export const WithSignature: Story = { + args: { + ...Default.args, + signature: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, +}; + +export const TypeMode: Story = { + args: { + ...Default.args, + signatureType: "type", + signatureText: "Jane Doe", + signature: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx new file mode 100644 index 0000000000..685d06b89f --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePlacementStep } from "@app/components/tools/certSign/steps/SignaturePlacementStep"; + +const meta = { + title: "Tools/CertSign/SignaturePlacementStep", + component: SignaturePlacementStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isPlaced: false, + placementInfo: null, + onBack: () => {}, + onNext: () => {}, + children: ( +
+ ), + }, +}; + +export const Placed: Story = { + args: { + isPlaced: true, + placementInfo: { page: 2, x: 120, y: 340 }, + onBack: () => {}, + onNext: () => {}, + children: ( +
+ ), + }, +}; + +export const Disabled: Story = { + args: { + isPlaced: true, + placementInfo: { page: 1, x: 50, y: 50 }, + onBack: () => {}, + onNext: () => {}, + disabled: true, + children: ( +
+ ), + }, +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx new file mode 100644 index 0000000000..ce15dd5922 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedOptionsStep from "@app/components/tools/changeMetadata/steps/AdvancedOptionsStep"; +import { defaultParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { TrappedStatus } from "@app/types/metadata"; + +const meta = { + title: "Tools/ChangeMetadata/Steps/AdvancedOptionsStep", + component: AdvancedOptionsStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + addCustomMetadata: () => {}, + removeCustomMetadata: () => {}, + updateCustomMetadata: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithCustomMetadata: Story = { + args: { + parameters: { + ...defaultParameters, + trapped: TrappedStatus.TRUE, + customMetadata: [{ id: "1", key: "CustomField", value: "CustomValue" }], + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx new file mode 100644 index 0000000000..4ba366f972 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CustomMetadataStep from "@app/components/tools/changeMetadata/steps/CustomMetadataStep"; +import { + ChangeMetadataParameters, + defaultParameters, + createCustomMetadataFunctions, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { CustomMetadataEntry } from "@app/types/metadata"; + +const meta = { + title: "Tools/ChangeMetadata/CustomMetadataStep", + component: CustomMetadataStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + addCustomMetadata: () => {}, + removeCustomMetadata: () => {}, + updateCustomMetadata: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function CustomMetadataStepDemo({ + disabled, + customMetadata = [], +}: { + disabled?: boolean; + customMetadata?: CustomMetadataEntry[]; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + customMetadata, + }); + + const onParameterChange = ( + key: K, + value: ChangeMetadataParameters[K], + ) => setParameters((prev) => ({ ...prev, [key]: value })); + + const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = + createCustomMetadataFunctions(parameters, onParameterChange); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const WithEntries: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx new file mode 100644 index 0000000000..d03e977157 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DeleteAllStep from "@app/components/tools/changeMetadata/steps/DeleteAllStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/DeleteAllStep", + component: DeleteAllStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function DeleteAllStepDemo({ + disabled, + deleteAll = false, +}: { + disabled?: boolean; + deleteAll?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + deleteAll, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Checked: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx new file mode 100644 index 0000000000..d6b8de2337 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DocumentDatesStep from "@app/components/tools/changeMetadata/steps/DocumentDatesStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/DocumentDatesStep", + component: DocumentDatesStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function DocumentDatesStepDemo({ + disabled, + creationDate = null, + modificationDate = null, +}: { + disabled?: boolean; + creationDate?: Date | null; + modificationDate?: Date | null; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + creationDate, + modificationDate, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Filled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx new file mode 100644 index 0000000000..34dacc7d41 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StandardMetadataStep from "@app/components/tools/changeMetadata/steps/StandardMetadataStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/StandardMetadataStep", + component: StandardMetadataStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function StandardMetadataStepDemo({ + disabled, + filled = false, +}: { + disabled?: boolean; + filled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + ...(filled + ? { + title: "Annual Report 2026", + author: "Jane Doe", + subject: "Financial Summary", + keywords: "finance, report, annual", + creator: "Stirling PDF", + producer: "Stirling PDF", + } + : {}), + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Filled: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx b/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx new file mode 100644 index 0000000000..8a05186886 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ChangePermissionsSettings from "@app/components/tools/changePermissions/ChangePermissionsSettings"; +import { ChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters"; + +const mockParameters: ChangePermissionsParameters = { + preventAssembly: false, + preventExtractContent: false, + preventExtractForAccessibility: false, + preventFillInForm: false, + preventModify: false, + preventModifyAnnotations: false, + preventPrinting: false, + preventPrintingFaithful: false, +}; + +const meta: Meta = { + title: "Tools/ChangePermissions/ChangePermissionsSettings", + component: ChangePermissionsSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: mockParameters, + onParameterChange: () => {}, + }, +}; + +export const SomeRestricted: Story = { + args: { + parameters: { + ...mockParameters, + preventPrinting: true, + preventModify: true, + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: mockParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx b/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx new file mode 100644 index 0000000000..09d27d5ad5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane"; +import type { PagePreview } from "@app/types/compare"; + +// 1x1 transparent PNG so the pane's resolves without a network request. +const PLACEHOLDER_PAGE_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const buildPages = (count: number): PagePreview[] => + Array.from({ length: count }, (_, index) => ({ + pageNumber: index + 1, + width: 612, + height: 792, + rotation: 0, + url: PLACEHOLDER_PAGE_IMAGE, + })); + +const meta = { + title: "Tools/Compare/CompareDocumentPane", + component: CompareDocumentPane, + // Excluded from the automated (Vitest browser) test run: mounting several of + // these panes in one page exhausts the headless browser's memory and the page + // is dropped mid-run, which fails the whole file rather than this component. + // It still renders in the Storybook UI. + tags: ["!test"], + args: { + pane: "base", + layout: "side-by-side", + scrollRef: { current: null }, + peerScrollRef: { current: null }, + handleScrollSync: () => {}, + handleWheelZoom: () => {}, + handleWheelOverscroll: () => {}, + onTouchStart: () => {}, + onTouchMove: () => {}, + onTouchEnd: () => {}, + isPanMode: false, + zoom: 1, + title: "original-document.pdf", + changes: [ + { value: "change-1", label: "Paragraph 1 change", pageNumber: 1 }, + { value: "change-2", label: "Paragraph 2 change", pageNumber: 2 }, + ], + onNavigateChange: () => {}, + isLoading: false, + processingMessage: "Processing...", + pages: buildPages(2), + pairedPages: buildPages(2), + getRowHeightPx: () => 792, + wordHighlightMap: new Map(), + metaIndexToGroupId: new Map(), + documentLabel: "Original", + pageLabel: "Page", + altLabel: "Document page preview", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { + isLoading: true, + pages: [], + pairedPages: [], + }, +}; + +export const NoChanges: Story = { + args: { + changes: [], + dropdownPlaceholder: "No changes", + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx b/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx new file mode 100644 index 0000000000..0e8f59f40e --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown"; + +const meta = { + title: "Compare/CompareNavigationDropdown", + component: CompareNavigationDropdown, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const sampleChanges = [ + { + value: "change-1", + label: "Introduction paragraph reworded", + pageNumber: 1, + }, + { value: "change-2", label: "Budget table updated", pageNumber: 1 }, + { value: "change-3", label: "New clause added", pageNumber: 2 }, + { value: "change-4", label: "Signature date changed", pageNumber: 3 }, +]; + +export const Default: Story = { + args: { + changes: sampleChanges, + placeholder: "Jump to change", + onNavigate: (value, pageNumber) => { + console.log("navigate", value, pageNumber); + }, + renderedPageNumbers: new Set([1, 2, 3]), + }, +}; + +export const Empty: Story = { + args: { + changes: [], + placeholder: "Jump to change", + onNavigate: () => {}, + }, +}; + +export const RenderingInProgress: Story = { + args: { + changes: sampleChanges, + placeholder: "Jump to change", + onNavigate: () => {}, + renderedPageNumbers: new Set([1]), + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx new file mode 100644 index 0000000000..14d47c6761 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView"; +import type { CompareResultPixelData } from "@app/types/compare"; + +// A tiny transparent PNG data URI so the elements have something valid to +// load without reaching out to a real file or network resource. +const PLACEHOLDER_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const baseResult: CompareResultPixelData = { + mode: "pixel", + base: { fileId: "base-file", fileName: "contract-v1.pdf" }, + comparison: { fileId: "comparison-file", fileName: "contract-v2.pdf" }, + pages: [ + { + pageNumber: 1, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 1200, + totalPixels: 484704, + diffRatio: 0.0025, + sizeMismatch: false, + }, + { + pageNumber: 2, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 0, + totalPixels: 484704, + diffRatio: 0, + sizeMismatch: false, + }, + { + pageNumber: 3, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 96940, + totalPixels: 484704, + diffRatio: 0.2, + sizeMismatch: true, + missingComparison: true, + }, + ], + totals: { + diffPixels: 98140, + totalPixels: 1454112, + diffRatio: 0.0675, + pagesWithChanges: 2, + durationMs: 842, + processedAt: 1752300000000, + }, + warnings: [], + settings: { + dpi: 150, + threshold: 10, + }, +}; + +const meta = { + title: "Tools/Compare/ComparePixelWorkbenchView", + component: ComparePixelWorkbenchView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + result: baseResult, + }, +}; + +export const NoDifferences: Story = { + args: { + result: { + ...baseResult, + pages: baseResult.pages.map((page) => ({ + ...page, + diffPixels: 0, + diffRatio: 0, + sizeMismatch: false, + missingBase: undefined, + missingComparison: undefined, + })), + totals: { + ...baseResult.totals, + diffPixels: 0, + diffRatio: 0, + pagesWithChanges: 0, + }, + }, + }, +}; + +export const WithWarnings: Story = { + args: { + result: { + ...baseResult, + warnings: [ + "Page 3 could not be rendered at the requested DPI and was downscaled.", + ], + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx b/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx new file mode 100644 index 0000000000..672f436e56 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompressSettings from "@app/components/tools/compress/CompressSettings"; +import { + CompressParameters, + defaultParameters, +} from "@app/hooks/tools/compress/useCompressParameters"; + +const meta = { + title: "Tools/Compress/CompressSettings", + component: CompressSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the sliders/inputs interactive in the canvas. +const CompressSettingsDemo = (props: { + initialParameters: CompressParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const FileSizeMethod: Story = { + render: () => ( + + ), +}; + +export const LineArtEnabled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx new file mode 100644 index 0000000000..0f9f386add --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromCbrSettings", + component: ConvertFromCbrSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the checkbox interactive in the canvas. +const ConvertFromCbrSettingsDemo = (props: { + initialParameters: ConvertParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const OptimizeForEbookEnabled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx new file mode 100644 index 0000000000..abec19fcc2 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings"; +import { + defaultParameters, + ConvertParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/ConvertFromCbzSettings", + component: ConvertFromCbzSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function CbzSettingsDemo({ + disabled, + initialOptimize = false, +}: { + disabled?: boolean; + initialOptimize?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + cbzOptions: { optimizeForEbook: initialOptimize }, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const OptimizedForEbook: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx new file mode 100644 index 0000000000..e0eafa1835 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromEbookSettings", + component: ConvertFromEbookSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +function ConvertFromEbookSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx new file mode 100644 index 0000000000..4e860948f9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromEmailSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromEmailSettings", + component: ConvertFromEmailSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ConvertFromEmailSettingsDemo( + props: Partial>, +) { + const [parameters, setParameters] = useState( + props.parameters ?? defaultParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + /> + ); +} + +/** Default state: attachments included, so the max-size input is visible. */ +export const Default: Story = { + render: () => , +}; + +/** Attachments excluded, hiding the max-attachment-size input. */ +export const WithoutAttachments: Story = { + render: () => ( + + ), +}; + +/** All controls disabled, e.g. while a conversion is running. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx new file mode 100644 index 0000000000..2392e13ec3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromImageSettings", + component: ConvertFromImageSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromImageSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx new file mode 100644 index 0000000000..8ca41ba2bb --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromSvgSettings from "@app/components/tools/convert/ConvertFromSvgSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromSvgSettings", + component: ConvertFromSvgSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromSvgSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx new file mode 100644 index 0000000000..ddc756f723 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromWebSettings from "@app/components/tools/convert/ConvertFromWebSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromWebSettings", + component: ConvertFromWebSettings, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromWebSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + fromExtension: "html", + toExtension: "pdf", + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx new file mode 100644 index 0000000000..3a202e95fa --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToCbrSettings", + component: ConvertToCbrSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the DPI input interactive in the canvas. +const ConvertToCbrSettingsDemo = (props: { + initialParameters: ConvertParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx new file mode 100644 index 0000000000..0d31069b86 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings"; +import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToCbzSettings", + component: ConvertToCbzSettings, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx new file mode 100644 index 0000000000..c2087cf00f --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToEpubSettings from "@app/components/tools/convert/ConvertToEpubSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertToEpubSettings", + component: ConvertToEpubSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertToEpubSettingsDemo({ + toExtension = "epub", + disabled, +}: { + toExtension?: string; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + fromExtension: "docx", + toExtension, + }); + + const handleParameterChange = ( + key: K, + value: ConvertParameters[K], + ) => { + setParameters((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Azw3Output: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx new file mode 100644 index 0000000000..242c0c631d --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertToImageSettings", + component: ConvertToImageSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertToImageSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx new file mode 100644 index 0000000000..2bffbe1391 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings"; +import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToPdfaSettings", + component: ConvertToPdfaSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + selectedFiles: [], + disabled: false, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const StrictMode: Story = { + args: { + parameters: { + ...defaultParameters, + pdfaOptions: { outputFormat: "pdfa-1", strict: true }, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx new file mode 100644 index 0000000000..30d59e42fb --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToPdfxSettings from "@app/components/tools/convert/ConvertToPdfxSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; +import { StirlingFile } from "@app/types/fileContext"; + +const meta = { + title: "Tools/Convert/ConvertToPdfxSettings", + component: ConvertToPdfxSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + selectedFiles: [], + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component renders no UI — it only reconciles the "outputFormat" field +// on mount — so the shim just proves it mounts and updates parameters without +// throwing. +const ConvertToPdfxSettingsDemo = (props: { + initialParameters: ConvertParameters; + selectedFiles?: StirlingFile[]; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + selectedFiles={props.selectedFiles ?? []} + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx new file mode 100644 index 0000000000..4d1c029da0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown"; + +const documentOptions = [ + { value: "pdf", label: "PDF", group: "Document" }, + { value: "docx", label: "Word", group: "Document" }, + { value: "odt", label: "OpenDocument", group: "Document" }, + { value: "png", label: "PNG", group: "Image" }, + { value: "jpg", label: "JPEG", group: "Image" }, + { value: "epub", label: "EPUB", group: "Ebook", usesCloud: true }, + { value: "mobi", label: "MOBI", group: "Ebook", usesCloud: true }, + { value: "cbr", label: "CBR", group: "Comic", enabled: false }, +]; + +const meta = { + title: "Tools/Convert/GroupedFormatDropdown", + component: GroupedFormatDropdown, + args: { + options: documentOptions, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the selected format interactive in the canvas. +const GroupedFormatDropdownDemo = ( + props: Partial>, +) => { + const [value, setValue] = useState(props.value); + + return ( + + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const Selected: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx new file mode 100644 index 0000000000..fbe30e65f5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, MantineProvider } from "@mantine/core"; +import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector"; +import { Rectangle, PDFBounds } from "@app/utils/cropCoordinates"; +import { mantineTheme } from "@app/theme/mantineTheme"; + +// CropAreaSelector reads theme.other.crop (overlay/handle colors), which only +// the core app's Mantine theme defines. Nesting it here supplies that value +// without disturbing whatever theme wraps the story globally, since Mantine +// merges nested providers with their parent. +const pdfBounds: PDFBounds = { + actualWidth: 595.28, + actualHeight: 841.89, + thumbnailWidth: 300, + thumbnailHeight: 424, + offsetX: 0, + offsetY: 0, + scale: 300 / 595.28, +}; + +const meta = { + title: "Tools/Crop/CropAreaSelector", + component: CropAreaSelector, + parameters: { layout: "padded" }, + // Every story below supplies its own `render`, which ignores these args, but + // Storybook's types still require `args` to satisfy CropAreaSelector's + // required props. + args: { + pdfBounds, + cropArea: { x: 50, y: 50, width: 300, height: 400 }, + onCropAreaChange: () => {}, + children: null, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialCropArea = { x: 50, y: 50, width: 300, height: 400 }, + disabled, +}: { + initialCropArea?: Rectangle; + disabled?: boolean; +}) { + const [cropArea, setCropArea] = useState(initialCropArea); + + return ( + + + + + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx new file mode 100644 index 0000000000..dfb1aede62 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CropAutomationSettings from "@app/components/tools/crop/CropAutomationSettings"; +import { + CropParameters, + defaultParameters, +} from "@app/hooks/tools/crop/useCropParameters"; + +const meta = { + title: "Tools/Crop/CropAutomationSettings", + component: CropAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CropParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const CustomArea: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx new file mode 100644 index 0000000000..fbf034b7e7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs"; +import { Rectangle, PDFBounds } from "@app/utils/cropCoordinates"; + +const meta = { + title: "Tools/Crop/CropCoordinateInputs", + component: CropCoordinateInputs, + parameters: { layout: "padded" }, + args: { + cropArea: { x: 50, y: 50, width: 300, height: 400 }, + onCoordinateChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const pdfBounds: PDFBounds = { + actualWidth: 595.28, + actualHeight: 841.89, + thumbnailWidth: 300, + thumbnailHeight: 424, + offsetX: 0, + offsetY: 0, + scale: 300 / 595.28, +}; + +function Demo({ + initialCropArea = { x: 50, y: 50, width: 300, height: 400 }, + disabled, + showAutomationInfo, + withBounds = true, +}: { + initialCropArea?: Rectangle; + disabled?: boolean; + showAutomationInfo?: boolean; + withBounds?: boolean; +}) { + const [cropArea, setCropArea] = useState(initialCropArea); + + return ( + + setCropArea((prev) => ({ + ...prev, + [field]: typeof value === "number" ? value : Number(value) || 0, + })) + } + disabled={disabled} + pdfBounds={withBounds ? pdfBounds : undefined} + showAutomationInfo={showAutomationInfo} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const AutomationInfo: Story = { + render: () => , +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx new file mode 100644 index 0000000000..945bd29745 --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import BookmarkEditor from "@app/components/tools/editTableOfContents/BookmarkEditor"; +import { createBookmarkNode } from "@app/utils/editTableOfContents"; + +const meta = { + title: "Tools/EditTableOfContents/BookmarkEditor", + component: BookmarkEditor, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + bookmarks: [ + createBookmarkNode({ + title: "Chapter 1: Introduction", + pageNumber: 1, + children: [ + createBookmarkNode({ + title: "Section 1.1: Background", + pageNumber: 2, + }), + createBookmarkNode({ title: "Section 1.2: Scope", pageNumber: 4 }), + ], + }), + createBookmarkNode({ title: "Chapter 2: Methodology", pageNumber: 8 }), + ], + onChange: () => {}, + }, +}; + +export const Empty: Story = { + args: { + bookmarks: [], + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx new file mode 100644 index 0000000000..a9daf70b4e --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditTableOfContentsSettings from "@app/components/tools/editTableOfContents/EditTableOfContentsSettings"; +import { BookmarkNode } from "@app/utils/editTableOfContents"; + +const sampleBookmarks: BookmarkNode[] = [ + { + id: "1", + title: "Chapter 1", + pageNumber: 1, + expanded: true, + children: [ + { + id: "1.1", + title: "Section 1.1", + pageNumber: 2, + expanded: false, + children: [], + }, + ], + }, + { + id: "2", + title: "Chapter 2", + pageNumber: 5, + expanded: true, + children: [], + }, +]; + +const meta = { + title: "Tools/EditTableOfContents/EditTableOfContentsSettings", + component: EditTableOfContentsSettings, + args: { + bookmarks: sampleBookmarks, + replaceExisting: true, + onReplaceExistingChange: () => {}, + onSelectFiles: () => {}, + onLoadFromPdf: () => {}, + onImportJson: () => {}, + onImportClipboard: () => {}, + onExportJson: () => {}, + onExportClipboard: () => {}, + isLoading: false, + loadError: null, + canReadClipboard: true, + canWriteClipboard: true, + disabled: false, + selectedFileName: "document.pdf", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoFileSelected: Story = { + args: { + selectedFileName: undefined, + bookmarks: [], + }, +}; + +export const LoadingWithError: Story = { + args: { + isLoading: true, + loadError: "Failed to read bookmarks from the selected PDF.", + }, +}; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx new file mode 100644 index 0000000000..265d7c0786 --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditTableOfContentsWorkbenchView, { + type EditTableOfContentsWorkbenchViewData, +} from "@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView"; +import { createBookmarkNode } from "@app/utils/editTableOfContents"; + +const meta = { + title: "Tools/EditTableOfContents/EditTableOfContentsWorkbenchView", + component: EditTableOfContentsWorkbenchView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const sampleFile = new File(["%PDF-1.4"], "annual-report.pdf", { + type: "application/pdf", +}); + +const sampleBookmarks = [ + createBookmarkNode({ + title: "Introduction", + pageNumber: 1, + }), + createBookmarkNode({ + title: "Chapter 1: Overview", + pageNumber: 3, + children: [ + createBookmarkNode({ title: "Background", pageNumber: 4 }), + createBookmarkNode({ title: "Scope", pageNumber: 6 }), + ], + }), + createBookmarkNode({ + title: "Conclusion", + pageNumber: 20, + }), +]; + +const baseData: EditTableOfContentsWorkbenchViewData = { + bookmarks: sampleBookmarks, + selectedFileName: sampleFile.name, + disabled: false, + files: [sampleFile], + thumbnails: [undefined], + downloadUrl: null, + downloadFilename: null, + errorMessage: null, + isGeneratingThumbnails: false, + isExecuteDisabled: false, + isExecuting: false, + onClearError: () => {}, + onBookmarksChange: () => {}, + onExecute: () => {}, + onUndo: () => {}, + onFileClick: () => {}, +}; + +export const Default: Story = { + args: { + data: baseData, + }, +}; + +export const Empty: Story = { + args: { + data: null, + }, +}; + +export const WithResults: Story = { + args: { + data: { + ...baseData, + downloadUrl: "blob:https://example.com/annual-report-toc.pdf", + downloadFilename: "annual-report-toc.pdf", + }, + }, +}; + +export const WithError: Story = { + args: { + data: { + ...baseData, + errorMessage: "Failed to apply the table of contents.", + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx new file mode 100644 index 0000000000..e0f7c0a014 --- /dev/null +++ b/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractImagesSettings"; +import { + ExtractImagesParameters, + defaultParameters, +} from "@app/hooks/tools/extractImages/useExtractImagesParameters"; + +const meta = { + title: "Tools/ExtractImages/ExtractImagesSettings", + component: ExtractImagesSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the dropdown interactive in the canvas. +const ExtractImagesSettingsDemo = (props: { + initialParameters: ExtractImagesParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const JpgFormat: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx new file mode 100644 index 0000000000..0ebf63c149 --- /dev/null +++ b/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings"; +import { ExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters"; + +const meta = { + title: "Tools/ExtractPages/ExtractPagesSettings", + component: ExtractPagesSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const buildParameters = ( + overrides: Partial = {}, +): ExtractPagesParameters => ({ + pageNumbers: "", + ...overrides, +}); + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const Filled: Story = { + args: { + parameters: buildParameters({ pageNumbers: "1,3,5-8" }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters({ pageNumbers: "1-10" }), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx b/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx new file mode 100644 index 0000000000..9f5e84b3b4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FlattenSettings from "@app/components/tools/flatten/FlattenSettings"; +import { FlattenParameters } from "@app/hooks/tools/flatten/useFlattenParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): FlattenParameters => ({ + flattenOnlyForms: false, + renderDpi: undefined, + ...overrides, +}); + +const meta = { + title: "Tools/Flatten/FlattenSettings", + component: FlattenSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const FlattenOnlyForms: Story = { + args: { + parameters: buildParameters({ flattenOnlyForms: true }), + onParameterChange: () => {}, + }, +}; + +export const CustomRenderDpi: Story = { + args: { + parameters: buildParameters({ renderDpi: 300 }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx new file mode 100644 index 0000000000..cd443e248a --- /dev/null +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx @@ -0,0 +1,103 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DetailedToolItem from "@app/components/tools/fullscreen/DetailedToolItem"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { + ToolWorkflowProvider, + useToolWorkflow, +} from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import type { ToolId } from "@app/types/toolId"; +import { + ToolCategoryId, + SubcategoryId, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; + +// DetailedToolItem reads hotkeys/favourites/availability via useToolMeta, which +// pulls from HotkeyContext, ToolWorkflowContext and AppConfigContext, so every +// provider here must be present or those reads fail. AppConfigProvider uses +// autoFetch={false} to skip the network fetch and render synchronously instead +// of showing a loading state. +function withProviders(Story: () => React.JSX.Element) { + return ( + + + + + + + + + + + + + + ); +} + +// Pulls a real entry out of the same registry the component reads internally +// (via useToolMeta), so the icon/description/availability match a real render. +function ToolItemDemo({ + toolId, + isSelected = false, +}: { + toolId: ToolId; + isSelected?: boolean; +}) { + const { toolRegistry } = useToolWorkflow(); + const tool = toolRegistry[toolId]; + if (!tool) return null; + + return ( + {}} + /> + ); +} + +// Meta-level args are inherited by every story below. The stories themselves +// use `render` to swap in ToolItemDemo (which pulls a real registry entry), +// so these values never actually reach DetailedToolItem — they only exist to +// satisfy the required-props type on StoryAnnotations. +const mockTool: ToolRegistryEntry = { + icon: null, + name: "Split", + component: null, + description: "Split a PDF into multiple files", + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: null, +}; + +const meta = { + title: "Tools/Fullscreen/DetailedToolItem", + component: DetailedToolItem, + decorators: [withProviders], + args: { + id: "split", + tool: mockTool, + isSelected: false, + onClick: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** An available tool rendered in its default, unselected state. */ +export const Default: Story = { + render: () => , +}; + +/** The active tool in the panel — highlighted selected state. */ +export const Selected: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx new file mode 100644 index 0000000000..08352aaab6 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GetPdfInfoReportView from "@app/components/tools/getPdfInfo/GetPdfInfoReportView"; +import type { PdfInfoReportData } from "@app/types/getPdfInfo"; + +const filledData: PdfInfoReportData = { + generatedAt: Date.now(), + entries: [ + { + fileId: "file-1", + fileName: "annual-report.pdf", + fileSize: 245_760, + lastModified: Date.now(), + thumbnailUrl: null, + error: null, + data: { + Metadata: { + Title: "Annual Report 2025", + Author: "Stirling PDF", + Subject: "Financials", + Keywords: "annual, report, finance", + Creator: "Stirling PDF", + Producer: "Stirling PDF", + CreationDate: "2025-01-15T10:00:00Z", + ModificationDate: "2025-02-01T08:30:00Z", + }, + BasicInfo: { + FileSizeInBytes: 245_760, + WordCount: 12_400, + ParagraphCount: 320, + CharacterCount: 78_000, + Compression: true, + CompressionType: "Flate", + Language: "en-US", + "Number of pages": 24, + TotalImages: 6, + }, + DocumentInfo: { + "PDF version": "1.7", + Trapped: "False", + "Page Mode": "UseOutlines", + }, + Encryption: { + IsEncrypted: false, + }, + Permissions: { + Printing: "Allowed", + Modifying: "Not Allowed", + "Extracting Content": "Allowed", + }, + Compliancy: { + "IsPDF/ACompliant": true, + "PDF/AConformanceLevel": "2B", + }, + "Bookmarks/Outline/TOC": [ + { Title: "Introduction" }, + { Title: "Financial Summary" }, + { Title: "Appendix" }, + ], + Other: { + Attachments: [], + EmbeddedFiles: [], + JavaScript: [], + }, + PerPageInfo: { + "Page 1": { + Rotation: 0, + "Page Orientation": "Portrait", + }, + }, + SummaryData: { + encrypted: false, + restrictedPermissions: ["Modifying"], + restrictedPermissionsCount: 1, + Compliance: [ + { + Standard: "PDF/A", + Compliant: true, + Summary: "Fully compliant with PDF/A-2B.", + }, + ], + }, + }, + summaryGeneratedAt: Date.now(), + }, + ], +}; + +const emptyData: PdfInfoReportData = { + generatedAt: Date.now(), + entries: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/GetPdfInfoReportView", + component: GetPdfInfoReportView, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: filledData, + }, +}; + +export const NoData: Story = { + args: { + data: emptyData, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx new file mode 100644 index 0000000000..92d8f82377 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GetPdfInfoResults from "@app/components/tools/getPdfInfo/GetPdfInfoResults"; +import type { GetPdfInfoOperationHook } from "@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation"; +import type { PdfInfoReportEntry } from "@app/types/getPdfInfo"; + +const mockEntry: PdfInfoReportEntry = { + fileId: "file-1", + fileName: "sample.pdf", + fileSize: 245_760, + lastModified: Date.now(), + thumbnailUrl: null, + data: {}, + error: null, + summaryGeneratedAt: Date.now(), +}; + +const baseOperation: GetPdfInfoOperationHook = { + files: [], + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl: null, + downloadFilename: "", + isLoading: false, + status: "", + errorMessage: null, + progress: null, + executeOperation: async () => {}, + resetResults: () => {}, + clearError: () => {}, + cancelOperation: () => {}, + undoOperation: async () => {}, + results: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/GetPdfInfoResults", + component: GetPdfInfoResults, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + operation: { + ...baseOperation, + results: [mockEntry], + files: [ + new File([JSON.stringify(mockEntry.data)], "response.json", { + type: "application/json", + }), + ], + }, + isLoading: false, + errorMessage: null, + }, +}; + +export const Loading: Story = { + args: { + operation: { + ...baseOperation, + results: [], + }, + isLoading: true, + errorMessage: null, + }, +}; + +export const Empty: Story = { + args: { + operation: { + ...baseOperation, + results: [], + }, + isLoading: false, + errorMessage: null, + }, +}; + +export const PartialError: Story = { + args: { + operation: { + ...baseOperation, + results: [ + mockEntry, + { + ...mockEntry, + fileId: "file-2", + fileName: "broken.pdf", + error: "Could not read file", + }, + ], + files: [ + new File([JSON.stringify(mockEntry.data)], "response.json", { + type: "application/json", + }), + ], + }, + isLoading: false, + errorMessage: "Some files could not be processed.", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx new file mode 100644 index 0000000000..b2cb29b81d --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ComplianceSection from "@app/components/tools/getPdfInfo/sections/ComplianceSection"; + +const meta = { + title: "Tools/GetPdfInfo/ComplianceSection", + component: ComplianceSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + anchorId: "compliance", + complianceSummary: [ + { + Standard: "pdfa-2b", + Compliant: true, + Summary: "Document conforms to PDF/A-2B requirements", + }, + { + Standard: "pdfua-1", + Compliant: false, + Summary: + "Document is missing required tagging structure for accessibility", + }, + ], + legacyCompliance: { + "IsPDF/SECCompliant": true, + }, + }, +}; + +export const AllPassed: Story = { + args: { + anchorId: "compliance-passed", + complianceSummary: [ + { + Standard: "pdfa-3b", + Compliant: true, + Summary: "Document conforms to PDF/A-3B requirements", + }, + { + Standard: "pdfua-1", + Compliant: true, + Summary: "Document meets PDF/UA-1 accessibility requirements", + }, + ], + legacyCompliance: null, + }, +}; + +export const Empty: Story = { + args: { + anchorId: "compliance-empty", + complianceSummary: [], + legacyCompliance: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx new file mode 100644 index 0000000000..75b2dd679d --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import KeyValueSection from "@app/components/tools/getPdfInfo/sections/KeyValueSection"; + +const meta = { + title: "Tools/GetPdfInfo/KeyValueSection", + component: KeyValueSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Document Info", + anchorId: "document-info", + obj: { + Title: "Sample Document", + Author: "Jane Doe", + Producer: "Stirling-PDF", + CreationDate: "2026-01-15", + }, + }, +}; + +export const Empty: Story = { + args: { + title: "Custom Metadata", + anchorId: "custom-metadata", + obj: {}, + emptyLabel: "No custom metadata found", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx new file mode 100644 index 0000000000..f087e0e8f7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import OtherSection from "@app/components/tools/getPdfInfo/sections/OtherSection"; +import type { PdfOtherInfo } from "@app/types/getPdfInfo"; + +const populatedOther: PdfOtherInfo = { + Attachments: [ + { Name: "invoice.xlsx", Description: "Original invoice", FileSize: 24576 }, + ], + EmbeddedFiles: [ + { + Name: "font-data.bin", + FileSize: 10240, + MimeType: "application/octet-stream", + CreationDate: "2026-01-01", + ModificationDate: "2026-02-01", + }, + ], + JavaScript: [{ "JS Name": "AutoPrint", "JS Script Length": 42 }], + Layers: [{ Name: "Watermark" }], + StructureTree: [{ Type: "Document" }], + XMPMetadata: "...", +}; + +const meta = { + title: "Tools/GetPdfInfo/Sections/OtherSection", + component: OtherSection, + parameters: { layout: "padded" }, + args: { + anchorId: "other", + other: populatedOther, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { + other: {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx new file mode 100644 index 0000000000..3cf0bbc9d7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PerPageSection from "@app/components/tools/getPdfInfo/sections/PerPageSection"; +import type { PdfPerPageInfo } from "@app/types/getPdfInfo"; + +const perPage: PdfPerPageInfo = { + "Page 1": { + Size: { + "Width (px)": "612", + "Height (px)": "792", + "Width (in)": "8.5", + "Height (in)": "11", + "Standard Page": "Letter", + }, + Rotation: 0, + "Page Orientation": "Portrait", + MediaBox: "[0.0, 0.0, 612.0, 792.0]", + CropBox: "[0.0, 0.0, 612.0, 792.0]", + "Text Characters Count": 1284, + Annotations: { + AnnotationsCount: 2, + SubtypeCount: 1, + ContentsCount: 1, + }, + Images: [{ Name: "Im0", Width: 400, Height: 300, ColorSpace: "DeviceRGB" }], + Links: [{ URI: "https://stirlingpdf.com" }], + Fonts: [ + { Name: "Helvetica", IsEmbedded: true, Subtype: "Type1" }, + { Name: "Times-Roman", IsEmbedded: false, Subtype: "Type1" }, + ], + XObjectCounts: { Image: 1, Form: 0, Other: 0 }, + Multimedia: [], + }, + "Page 2": { + Size: { + "Width (px)": "612", + "Height (px)": "792", + "Standard Page": "Letter", + }, + Rotation: 90, + "Page Orientation": "Landscape", + MediaBox: "[0.0, 0.0, 612.0, 792.0]", + "Text Characters Count": 0, + Images: [], + Links: [], + Fonts: [], + Multimedia: [], + }, +}; + +const meta = { + title: "GetPdfInfo/PerPageSection", + component: PerPageSection, + parameters: { layout: "padded" }, + args: { + anchorId: "per-page-info", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + perPage, + }, +}; + +export const Empty: Story = { + args: { + perPage: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx new file mode 100644 index 0000000000..464ef92491 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SummarySection from "@app/components/tools/getPdfInfo/sections/SummarySection"; +import type { ParsedPdfSections } from "@app/types/getPdfInfo"; + +const fullSections: ParsedPdfSections = { + basicInfo: { + "Number of pages": 12, + FileSizeInBytes: 245_760, + TotalImages: 4, + Language: "en-US", + }, + documentInfo: { + "PDF version": "1.7", + }, + metadata: { + Title: "Annual Report 2026", + Author: "Jane Doe", + CreationDate: "2026-01-10", + ModificationDate: "2026-02-01", + }, + encryption: { + IsEncrypted: false, + }, + permissions: { + Printing: "Allowed", + Modifying: "Allowed", + "Extracting Content": "Allowed", + "Document Assembly": "Allowed", + }, + summaryData: { + restrictedPermissionsCount: 0, + Compliance: [ + { Standard: "pdfa-2b", Compliant: true, Summary: "Passed" }, + { Standard: "pdfua-1", Compliant: false, Summary: "Failed" }, + ], + }, + other: { + EmbeddedFiles: [], + JavaScript: [], + Layers: [], + }, + perPage: { + "Page 1": { + Fonts: [ + { Name: "Helvetica", IsEmbedded: true }, + { Name: "Arial", IsEmbedded: false }, + ], + Multimedia: [], + }, + }, + formFields: {}, + toc: [], +}; + +const emptySections: ParsedPdfSections = { + basicInfo: {}, + documentInfo: {}, + metadata: {}, + encryption: {}, + permissions: {}, + summaryData: {}, + other: {}, + perPage: {}, + formFields: {}, + toc: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/SummarySection", + component: SummarySection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + sections: fullSections, + }, +}; + +export const Empty: Story = { + args: { + sections: emptySections, + }, +}; + +export const HiddenTitle: Story = { + args: { + sections: fullSections, + hideSectionTitle: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx new file mode 100644 index 0000000000..9645e7d1ec --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TableOfContentsSection from "@app/components/tools/getPdfInfo/sections/TableOfContentsSection"; +import type { PdfTocEntry } from "@app/types/getPdfInfo"; + +const tocArray: PdfTocEntry[] = [ + { Title: "Chapter 1: Introduction" }, + { Title: "Chapter 2: Getting Started" }, + { Title: "Chapter 3: Advanced Topics" }, +]; + +const meta = { + title: "Tools/GetPdfInfo/TableOfContentsSection", + component: TableOfContentsSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + anchorId: "table-of-contents", + tocArray, + }, +}; + +export const Empty: Story = { + args: { + anchorId: "table-of-contents-empty", + tocArray: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx new file mode 100644 index 0000000000..6c3bed6e0f --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import KeyValueList from "@app/components/tools/getPdfInfo/shared/KeyValueList"; + +const meta = { + title: "Tools/GetPdfInfo/KeyValueList", + component: KeyValueList, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + obj: { + Title: "Sample Document", + Author: "Jane Doe", + Producer: "Stirling-PDF", + CreationDate: "2026-01-15", + }, + }, +}; + +export const Empty: Story = { + args: { + obj: {}, + emptyLabel: "No custom metadata found", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx new file mode 100644 index 0000000000..30ff2ccb19 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ScrollableCodeBlock from "@app/components/tools/getPdfInfo/shared/ScrollableCodeBlock"; + +const meta = { + title: "Tools/GetPdfInfo/Shared/ScrollableCodeBlock", + component: ScrollableCodeBlock, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: ` + + + + Sample Document + Stirling PDF + + + +`, + }, +}; + +export const Empty: Story = { + args: { + content: null, + }, +}; + +export const CustomEmptyMessage: Story = { + args: { + content: undefined, + emptyMessage: "No structure tree found in this document", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx new file mode 100644 index 0000000000..efd32402a9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SectionBlock from "@app/components/tools/getPdfInfo/shared/SectionBlock"; +import KeyValueList from "@app/components/tools/getPdfInfo/shared/KeyValueList"; + +const meta = { + title: "Tools/GetPdfInfo/Shared/SectionBlock", + component: SectionBlock, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Document Info", + anchorId: "document-info", + children: ( + + ), + }, +}; diff --git a/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx new file mode 100644 index 0000000000..d919d3ef3a --- /dev/null +++ b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter"; + +const meta = { + title: "Tools/Merge/MergeFileSorter", + component: MergeFileSorter, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onSortFiles: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onSortFiles: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx b/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx new file mode 100644 index 0000000000..689e210612 --- /dev/null +++ b/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MergeSettings from "@app/components/tools/merge/MergeSettings"; +import { MergeParameters } from "@app/hooks/tools/merge/useMergeParameters"; + +const meta = { + title: "tools/merge/MergeSettings", + component: MergeSettings, + args: { + parameters: { + removeDigitalSignature: false, + generateTableOfContents: false, + }, + onParameterChange: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function MergeSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + removeDigitalSignature: false, + generateTableOfContents: false, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx b/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx new file mode 100644 index 0000000000..863f730d7a --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedOCRSettings from "@app/components/tools/ocr/AdvancedOCRSettings"; + +const meta = { + title: "Tools/Ocr/AdvancedOCRSettings", + component: AdvancedOCRSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + advancedOptions: [], + ocrRenderType: "hocr", + onParameterChange: () => {}, + }, +}; + +export const OptionsSelected: Story = { + args: { + advancedOptions: ["sidecar", "deskew"], + ocrRenderType: "hocr", + onParameterChange: () => {}, + }, +}; + +export const CompatibilityMode: Story = { + args: { + advancedOptions: [], + ocrRenderType: "sandwich", + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + advancedOptions: ["clean"], + ocrRenderType: "hocr", + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx new file mode 100644 index 0000000000..3ca16b9aba --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import LanguagePicker from "@app/components/tools/ocr/LanguagePicker"; + +const meta = { + title: "Tools/OCR/LanguagePicker", + component: LanguagePicker, + args: { + value: [], + onChange: () => {}, + }, + parameters: { + msw: { + handlers: [ + http.get("/api/v1/ui-data/ocr-pdf", () => + HttpResponse.json({ + languages: ["eng", "fra", "deu", "spa", "ita", "por"], + }), + ), + ], + }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Controlled wrapper so selecting/removing languages actually updates the picker. */ +function LanguagePickerDemo({ + initialValue = [], + disabled, +}: { + initialValue?: string[]; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +/** No languages selected yet, backend list loaded via MSW. */ +export const Default: Story = { + render: () => , +}; + +/** One language already selected. */ +export const WithSelection: Story = { + render: () => , +}; + +/** Disabled — e.g. while OCR is running elsewhere in the tool panel. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx b/frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx new file mode 100644 index 0000000000..8839cfda29 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import OCRSettings from "@app/components/tools/ocr/OCRSettings"; +import { OCRParameters } from "@app/hooks/tools/ocr/useOCRParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): OCRParameters => ({ + languages: [], + ocrType: "skip-text", + ocrRenderType: "hocr", + additionalOptions: [], + ...overrides, +}); + +const meta = { + title: "Tools/OCR/OCRSettings", + component: OCRSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const LanguagesSelected: Story = { + args: { + parameters: buildParameters({ languages: ["eng", "fra"] }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx new file mode 100644 index 0000000000..fdac5554d2 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LayoutPreview from "@app/components/tools/pageLayout/LayoutPreview"; +import { defaultParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/LayoutPreview", + component: LayoutPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + }, +}; + +export const CustomGridLandscape: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 3, + orientation: "LANDSCAPE", + addBorder: true, + }, + }, +}; + +export const RightToLeftReading: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 2, + arrangement: "BY_ROWS", + readingDirection: "RTL", + addBorder: true, + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx new file mode 100644 index 0000000000..712ca94741 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutAdvancedSettings from "@app/components/tools/pageLayout/PageLayoutAdvancedSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutAdvancedSettings", + component: PageLayoutAdvancedSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function AdvancedSettingsDemo({ + disabled, + initialParameters, +}: { + disabled?: boolean; + initialParameters?: PageLayoutParameters; +}) { + const [parameters, setParameters] = useState( + initialParameters ?? defaultParameters, + ); + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; + +export const LandscapeRTL: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx new file mode 100644 index 0000000000..67d9f06388 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutMarginsBordersSettings from "@app/components/tools/pageLayout/PageLayoutMarginsBordersSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutMarginsBordersSettings", + component: PageLayoutMarginsBordersSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const withMarginsParameters: PageLayoutParameters = { + ...defaultParameters, + topMargin: 20, + bottomMargin: 20, + leftMargin: 15, + rightMargin: 15, + innerMargin: 5, +}; + +const withBorderParameters: PageLayoutParameters = { + ...defaultParameters, + addBorder: true, + borderWidth: 2, +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithMargins: Story = { + args: { + parameters: withMarginsParameters, + onParameterChange: () => {}, + }, +}; + +export const WithBorder: Story = { + args: { + parameters: withBorderParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx new file mode 100644 index 0000000000..e3c2a66183 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutPreview from "@app/components/tools/pageLayout/PageLayoutPreview"; +import { defaultParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutPreview", + component: PageLayoutPreview, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + }, +}; + +export const CustomGridWithBorder: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 3, + addBorder: true, + borderWidth: 2, + }, + }, +}; + +export const Landscape: Story = { + args: { + parameters: { + ...defaultParameters, + orientation: "LANDSCAPE", + pagesPerSheet: 2, + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx new file mode 100644 index 0000000000..4ed2a6219f --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta: Meta = { + title: "Tools/PageLayout/PageLayoutSettings", + component: PageLayoutSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initial, + disabled, +}: { + initial: PageLayoutParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState(initial); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** Default mode: pages-per-sheet select plus its description banner. */ +export const Default: Story = { + render: () => , +}; + +/** Custom mode: rows/columns number inputs instead of the sheet-count select. */ +export const CustomMode: Story = { + render: () => ( + + ), +}; + +/** Disabled: all controls locked, e.g. while no file is selected. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx new file mode 100644 index 0000000000..70c858cb65 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings"; +import { + RedactParameters, + defaultParameters, +} from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactAdvancedSettings", + component: RedactAdvancedSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function AdvancedSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx new file mode 100644 index 0000000000..03ca1abe12 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector"; +import type { RedactMode } from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactModeSelector", + component: RedactModeSelector, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + args: { + mode: "automatic", + onModeChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ModeDemo({ + disabled, + hasAnyFiles, +}: { + disabled?: boolean; + hasAnyFiles?: boolean; +}) { + const [mode, setMode] = useState("automatic"); + return ( + + ); +} + +/** Files present: both Automatic and Manual are selectable. */ +export const Default: Story = { render: () => }; + +/** No files uploaded yet: both options disabled with a tooltip on Automatic. */ +export const NoFiles: Story = { + render: () => , +}; + +/** Selector disabled entirely (e.g. while an operation is running). */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..e34cc33924 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactSingleStepSettings from "@app/components/tools/redact/RedactSingleStepSettings"; +import { + RedactParameters, + defaultParameters, +} from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactSingleStepSettings", + component: RedactSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: RedactParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const AutomaticWithWords: Story = { + render: () => ( + + ), +}; + +export const ManualMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx new file mode 100644 index 0000000000..ec6d2b8492 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput"; + +const meta = { + title: "Tools/Redact/WordsToRedactInput", + component: WordsToRedactInput, + args: { + wordsToRedact: [], + onWordsChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function WordsToRedactInputDemo({ + initialWords = [], + disabled, +}: { + initialWords?: string[]; + disabled?: boolean; +}) { + const [words, setWords] = useState(initialWords); + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const WithWords: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx new file mode 100644 index 0000000000..d0461743ad --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveAnnotationsSettings from "@app/components/tools/removeAnnotations/RemoveAnnotationsSettings"; + +const meta = { + title: "Tools/RemoveAnnotations/RemoveAnnotationsSettings", + component: RemoveAnnotationsSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx new file mode 100644 index 0000000000..946dc5074b --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlanksSettings"; +import { RemoveBlanksParameters } from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): RemoveBlanksParameters => ({ + threshold: 10, + whitePercent: 99.9, + includeBlankPages: false, + ...overrides, +}); + +const meta = { + title: "Tools/RemoveBlanks/RemoveBlanksSettings", + component: RemoveBlanksSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const IncludeBlankPages: Story = { + args: { + parameters: buildParameters({ includeBlankPages: true }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx new file mode 100644 index 0000000000..4af89e165f --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveCertificateSignSettings from "@app/components/tools/removeCertificateSign/RemoveCertificateSignSettings"; +import { RemoveCertificateSignParameters } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters"; + +const baseParameters: RemoveCertificateSignParameters = {}; + +const meta = { + title: "Tools/RemoveCertificateSign/RemoveCertificateSignSettings", + component: RemoveCertificateSignSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx new file mode 100644 index 0000000000..92db55641e --- /dev/null +++ b/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemovePagesSettings from "@app/components/tools/removePages/RemovePagesSettings"; +import { RemovePagesParameters } from "@app/hooks/tools/removePages/useRemovePagesParameters"; + +const meta = { + title: "Tools/RemovePages/RemovePagesSettings", + component: RemovePagesSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const baseParameters: RemovePagesParameters = { + pageNumbers: "", +}; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const FilledValid: Story = { + args: { + parameters: { pageNumbers: "1,3,5-8,10" }, + onParameterChange: () => {}, + }, +}; + +export const InvalidInput: Story = { + args: { + parameters: { pageNumbers: "abc" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx b/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx new file mode 100644 index 0000000000..5997482e5d --- /dev/null +++ b/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; + +import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; +import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters"; + +const parameters: RemovePasswordParameters = { + password: "", +}; + +const meta = { + title: "Tools/RemovePassword/RemovePasswordSettings", + component: RemovePasswordSettings, + parameters: { layout: "padded" }, + args: { + parameters, + onParameterChange: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Filled: Story = { + args: { + parameters: { + ...parameters, + password: "correct-horse-battery-staple", + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx new file mode 100644 index 0000000000..07586069b9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings"; +import { + defaultReorganizePagesParameters, + ReorganizePagesParameters, +} from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters"; + +const meta: Meta = { + title: "Tools/ReorganizePages/ReorganizePagesSettings", + component: ReorganizePagesSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initial, + disabled, +}: { + initial?: Partial; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultReorganizePagesParameters, + ...initial, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** Custom order mode (default): the page order text input is shown. */ +export const Default: Story = { render: () => }; + +/** A preset mode (e.g. reverse) that doesn't require a page order input. */ +export const PresetMode: Story = { + render: () => , +}; + +/** Disabled state, e.g. while no files are loaded or processing is in progress. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx b/frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx new file mode 100644 index 0000000000..4111f5882b --- /dev/null +++ b/frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RepairSettings from "@app/components/tools/repair/RepairSettings"; +import { RepairParameters } from "@app/hooks/tools/repair/useRepairParameters"; + +const parameters: RepairParameters = {}; + +const meta = { + title: "Tools/Repair/RepairSettings", + component: RepairSettings, + args: { + parameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx new file mode 100644 index 0000000000..ff11addc1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings"; +import { + ReplaceColorParameters, + defaultParameters, +} from "@app/hooks/tools/replaceColor/useReplaceColorParameters"; + +const meta = { + title: "Tools/ReplaceColor/ReplaceColorSettings", + component: ReplaceColorSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomColor: Story = { + args: { + parameters: { + ...defaultParameters, + replaceAndInvertOption: "CUSTOM_COLOR", + } satisfies ReplaceColorParameters, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx new file mode 100644 index 0000000000..e967671741 --- /dev/null +++ b/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RotateAutomationSettings from "@app/components/tools/rotate/RotateAutomationSettings"; +import { RotateParameters } from "@app/hooks/tools/rotate/useRotateParameters"; + +const meta = { + title: "Tools/Rotate/RotateAutomationSettings", + component: RotateAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: { angle: 0 }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function RotateDemo({ + disabled, + initialAngle = 0, +}: { + disabled?: boolean; + initialAngle?: number; +}) { + const [parameters, setParameters] = useState({ + angle: initialAngle, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Rotated90: Story = { + render: () => , +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx b/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx new file mode 100644 index 0000000000..c5463cbfc0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SanitizeSettings from "@app/components/tools/sanitize/SanitizeSettings"; +import { defaultParameters } from "@app/hooks/tools/sanitize/useSanitizeParameters"; + +const meta = { + title: "Tools/Sanitize/SanitizeSettings", + component: SanitizeSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const AllSelected: Story = { + args: { + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: true, + removeMetadata: true, + removeLinks: true, + removeFonts: true, + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx b/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx new file mode 100644 index 0000000000..05f4a41cf4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ScannerImageSplitSettings from "@app/components/tools/scannerImageSplit/ScannerImageSplitSettings"; +import { ScannerImageSplitParameters } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): ScannerImageSplitParameters => ({ + angle_threshold: 10, + tolerance: 30, + min_area: 10000, + min_contour_area: 500, + border_size: 1, + ...overrides, +}); + +const meta = { + title: "Tools/ScannerImageSplit/ScannerImageSplitSettings", + component: ScannerImageSplitSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx b/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx new file mode 100644 index 0000000000..81f6c658bc --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ErrorNotification from "@app/components/tools/shared/ErrorNotification"; + +const meta = { + title: "ToolsShared/ErrorNotification", + component: ErrorNotification, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + error: "Something went wrong while processing the file.", + onClose: () => {}, + }, +}; + +export const CustomTitle: Story = { + args: { + error: "The uploaded file could not be read.", + onClose: () => {}, + title: "Upload failed", + color: "orange", + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx b/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx new file mode 100644 index 0000000000..750d751e6d --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileMetadata from "@app/components/tools/shared/FileMetadata"; + +const buildFile = (name = "report.pdf", type = "application/pdf"): File => + new File(["%PDF-1.4 mock content"], name, { + type, + lastModified: new Date("2026-01-15T10:30:00Z").getTime(), + }); + +const meta = { + title: "ToolsShared/FileMetadata", + component: FileMetadata, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: buildFile(), + }, +}; + +export const UnknownType: Story = { + args: { + file: buildFile("data.bin", ""), + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx b/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx new file mode 100644 index 0000000000..e34b1cb329 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationControls from "@app/components/tools/shared/NavigationControls"; + +const meta = { + title: "ToolsShared/NavigationControls", + component: NavigationControls, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + currentIndex: 0, + totalFiles: 5, + onPrevious: () => {}, + onNext: () => {}, + }, +}; + +export const LastFile: Story = { + args: { + currentIndex: 4, + totalFiles: 5, + onPrevious: () => {}, + onNext: () => {}, + }, +}; + +export const SingleFile: Story = { + args: { + currentIndex: 0, + totalFiles: 1, + onPrevious: () => {}, + onNext: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx b/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx new file mode 100644 index 0000000000..6ac9f9a3b0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx @@ -0,0 +1,11 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NoToolsFound from "@app/components/tools/shared/NoToolsFound"; + +const meta = { + title: "Tools/Shared/NoToolsFound", + component: NoToolsFound, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx new file mode 100644 index 0000000000..b6e1d8280f --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NumberInputWithUnit from "@app/components/tools/shared/NumberInputWithUnit"; + +const meta = { + title: "Tools/Shared/NumberInputWithUnit", + component: NumberInputWithUnit, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Margin", + value: 10, + onChange: () => {}, + unit: "px", + }, +}; + +export const WithMinMax: Story = { + args: { + ...Default.args, + label: "Opacity", + value: 50, + unit: "%", + min: 0, + max: 100, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx b/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx new file mode 100644 index 0000000000..174a192613 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ResultsPreview, { + ReviewFile, +} from "@app/components/tools/shared/ResultsPreview"; + +function makeFile(name: string, type: string, size: number): File { + return new File([new Uint8Array(size)], name, { type }); +} + +const files: ReviewFile[] = [ + { file: makeFile("contract-final.pdf", "application/pdf", 245_760) }, + { file: makeFile("scan-001.pdf", "application/pdf", 1_048_576) }, + { file: makeFile("invoice-march.pdf", "application/pdf", 51_200) }, +]; + +const meta = { + title: "Tools/Shared/ResultsPreview", + component: ResultsPreview, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + files, + }, +}; + +export const SingleFile: Story = { + args: { + files: [files[0]], + }, +}; + +export const Loading: Story = { + args: { + files: [], + isGeneratingThumbnails: true, + }, +}; + +export const Empty: Story = { + args: { + files: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx b/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx new file mode 100644 index 0000000000..634841969a --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader"; + +const meta = { + title: "Tools/Shared/SubcategoryHeader", + component: SubcategoryHeader, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Page organization", + }, +}; + +export const CustomSpacing: Story = { + args: { + label: "Security", + mt: "2rem", + mb: "1rem", + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx b/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx new file mode 100644 index 0000000000..e3c10425f0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Text } from "@mantine/core"; +import ToolStep from "@app/components/tools/shared/ToolStep"; + +const meta = { + title: "Tools/Shared/ToolStep", + component: ToolStep, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Select pages", + children: Step content goes here., + }, +}; + +export const WithHelpTextAndNumber: Story = { + args: { + title: "Choose output format", + helpText: "Pick the format your converted file should use.", + showNumber: true, + _stepNumber: 2, + children: Step content goes here., + }, +}; + +export const Collapsed: Story = { + args: { + title: "Advanced settings", + isCollapsed: true, + onCollapsedClick: () => {}, + children: Step content goes here., + }, +}; diff --git a/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx b/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx new file mode 100644 index 0000000000..fcb29ed009 --- /dev/null +++ b/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShowJSView from "@app/components/tools/showJS/ShowJSView"; + +const SAMPLE_SCRIPT = `function greet(name) { + // say hello + if (!name) { + return "Hello, stranger!"; + } + return "Hello, " + name + "!"; +} + +for (let i = 0; i < 3; i++) { + console.log(greet("World")); +} +`; + +const meta = { + title: "Tools/ShowJS/ShowJSView", + component: ShowJSView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: SAMPLE_SCRIPT, + }, +}; + +export const WithDownload: Story = { + args: { + data: { + scriptText: SAMPLE_SCRIPT, + downloadUrl: "blob:mock-download-url", + downloadFilename: "extracted.js", + }, + }, +}; + +export const Empty: Story = { + args: { + data: "", + }, +}; diff --git a/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx b/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx new file mode 100644 index 0000000000..658d4ebb6e --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector"; + +const meta = { + title: "Tools/Sign/PenSizeSelector", + component: PenSizeSelector, + args: { + value: 5, + inputValue: "5", + onValueChange: () => {}, + onInputChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function PenSizeSelectorDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState(5); + const [inputValue, setInputValue] = useState("5"); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx new file mode 100644 index 0000000000..9079bb08ab --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SavedSignaturesSection from "@app/components/tools/sign/SavedSignaturesSection"; +import type { SavedSignature } from "@app/hooks/tools/sign/useSavedSignatures"; + +const mockSignatures: SavedSignature[] = [ + { + id: "sig-1", + label: "My signature", + scope: "personal", + type: "text", + dataUrl: "", + signerName: "Jordan Lee", + fontFamily: "cursive", + fontSize: 32, + textColor: "#1a1a1a", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: "sig-2", + label: "Company stamp", + scope: "shared", + type: "image", + dataUrl: + "data:image/svg+xml;base64," + + btoa( + 'Approved', + ), + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: "sig-3", + label: "Quick draw", + scope: "localStorage", + type: "canvas", + dataUrl: + "data:image/svg+xml;base64," + + btoa( + '', + ), + createdAt: Date.now(), + updatedAt: Date.now(), + }, +]; + +const meta = { + title: "Tools/Sign/SavedSignaturesSection", + component: SavedSignaturesSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: false, + maxLimit: 5, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const Empty: Story = { + args: { + signatures: [], + isAtCapacity: false, + maxLimit: 5, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const AtCapacity: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: true, + maxLimit: 3, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const AdminWithSharedDelete: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: false, + maxLimit: 5, + isAdmin: true, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx b/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx new file mode 100644 index 0000000000..9433f691af --- /dev/null +++ b/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SingleLargePageSettings from "@app/components/tools/singleLargePage/SingleLargePageSettings"; +import type { SingleLargePageParameters } from "@app/hooks/tools/singleLargePage/useSingleLargePageParameters"; + +const parameters: SingleLargePageParameters = {}; + +const meta = { + title: "Tools/SingleLargePage/SingleLargePageSettings", + component: SingleLargePageSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx new file mode 100644 index 0000000000..a3b12a58e4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SplitAutomationSettings from "@app/components/tools/split/SplitAutomationSettings"; +import { + defaultParameters, + SplitParameters, +} from "@app/hooks/tools/split/useSplitParameters"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; + +const meta = { + title: "Tools/Split/SplitAutomationSettings", + component: SplitAutomationSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const MethodSelected: Story = { + args: { + parameters: { + ...defaultParameters, + method: SPLIT_METHODS.BY_PAGES, + pages: "1,3,5", + } satisfies SplitParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: { + ...defaultParameters, + method: SPLIT_METHODS.BY_PAGES, + pages: "1,3,5", + } satisfies SplitParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx b/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx new file mode 100644 index 0000000000..a3411db819 --- /dev/null +++ b/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SplitSettings from "@app/components/tools/split/SplitSettings"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; +import { + defaultParameters, + SplitParameters, +} from "@app/hooks/tools/split/useSplitParameters"; + +const meta = { + title: "Tools/Split/SplitSettings", + component: SplitSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SplitSettingsDemo({ + initialParameters, + disabled, +}: { + initialParameters: SplitParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** No method chosen yet — shows the "select a method first" placeholder. */ +export const NoMethodSelected: Story = { + render: () => , +}; + +/** Split by pages: a single page-range text input. */ +export const ByPages: Story = { + render: () => ( + + ), +}; + +/** Split by sections: divisions, split mode, and merge checkbox. */ +export const BySections: Story = { + render: () => ( + + ), +}; + +/** Split by poster print: page size + division factors + orientation. */ +export const ByPoster: Story = { + render: () => ( + + ), +}; + +/** Disabled state: all inputs are non-interactive. */ +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx b/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx new file mode 100644 index 0000000000..8a86d24755 --- /dev/null +++ b/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TimestampPdfSettings from "@app/components/tools/timestampPdf/TimestampPdfSettings"; +import { defaultParameters } from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters"; + +const meta = { + title: "Tools/TimestampPdf/TimestampPdfSettings", + component: TimestampPdfSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx new file mode 100644 index 0000000000..51e1d08e09 --- /dev/null +++ b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; + +const meta = { + title: "Tools/ToolPicker/FavoriteStar", + component: FavoriteStar, + parameters: { layout: "centered" }, + args: { + isFavorite: false, + onToggle: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Favorited: Story = { + args: { + isFavorite: true, + }, +}; + +export const Sizes: Story = { + render: () => ( +
+ {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( + {}} size={size} /> + ))} +
+ ), +}; diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx new file mode 100644 index 0000000000..c7dfd1b4eb --- /dev/null +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx @@ -0,0 +1,82 @@ +import { useState, type ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { + ToolWorkflowProvider, + useToolWorkflow, +} from "@app/contexts/ToolWorkflowContext"; + +// ToolSearchDemo below sources toolRegistry via useToolWorkflow(), so +// ToolWorkflowProvider must be present — and it in turn needs +// ToolRegistryProvider and NavigationProvider as ancestors to build a real registry. +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +function ToolSearchDemo({ + mode = "filter", + initialValue = "", +}: { + mode?: "filter" | "dropdown" | "unstyled"; + initialValue?: string; +}) { + const { toolRegistry } = useToolWorkflow(); + const [value, setValue] = useState(initialValue); + + return ( + + ); +} + +const meta = { + title: "Tools/ToolPicker/ToolSearch", + component: ToolSearch, + decorators: [withProviders], + args: { + value: "", + onChange: () => {}, + toolRegistry: {}, + mode: "filter", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Filter mode: plain input, no dropdown, used inline above a tool grid. */ +export const Default: Story = { + render: () => , +}; + +/** + * Dropdown mode with a query pre-filled. The results panel itself only opens + * in response to a user typing (internal `dropdownOpen` state), so this + * renders the closed input — type in it to see the fuzzy-matched list. + */ +export const DropdownMode: Story = { + render: () => , +}; + +/** Unstyled mode: bare input with no wrapping container, for embedding elsewhere. */ +export const Unstyled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx b/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx new file mode 100644 index 0000000000..2e88b16eec --- /dev/null +++ b/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UnlockPdfFormsSettings from "@app/components/tools/unlockPdfForms/UnlockPdfFormsSettings"; +import { UnlockPdfFormsParameters } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters"; + +const defaultParameters: UnlockPdfFormsParameters = {}; + +const meta = { + title: "Tools/UnlockPdfForms/UnlockPdfFormsSettings", + component: UnlockPdfFormsSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx new file mode 100644 index 0000000000..1203f3bee1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ValidateSignatureReportView from "@app/components/tools/validateSignature/ValidateSignatureReportView"; +import type { + SignatureValidationReportData, + SignatureValidationSignature, +} from "@app/types/validateSignature"; + +const baseSignature: SignatureValidationSignature = { + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-05-12T10:30:00Z", + reason: "Approved", + location: "London, UK", + issuerDN: "CN=Example CA, O=Example Corp", + subjectDN: "CN=Jane Doe, O=Example Corp", + serialNumber: "1A2B3C4D5E", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "1", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, +}; + +const validData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-1", + fileName: "contract.pdf", + fileSize: 245_760, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [baseSignature], + }, + ], +}; + +const multiSignatureData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-1", + fileName: "agreement.pdf", + fileSize: 512_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [ + baseSignature, + { + ...baseSignature, + id: "sig-2", + signerName: "John Smith", + valid: false, + chainValid: false, + trustValid: false, + chainValidationError: "Certificate chain could not be verified", + errorMessage: "Untrusted certificate", + }, + ], + }, + ], +}; + +const noSignaturesData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-2", + fileName: "unsigned-report.pdf", + fileSize: 128_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [], + }, + ], +}; + +const errorData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-3", + fileName: "corrupted.pdf", + fileSize: 64_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [], + error: "Unable to parse document signatures", + }, + ], +}; + +const meta = { + title: "Tools/ValidateSignature/ValidateSignatureReportView", + component: ValidateSignatureReportView, + parameters: { layout: "padded" }, + args: { + data: validData, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const MultipleSignatures: Story = { + args: { + data: multiSignatureData, + }, +}; + +export const NoSignatures: Story = { + args: { + data: noSignaturesData, + }, +}; + +export const Error: Story = { + args: { + data: errorData, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx new file mode 100644 index 0000000000..3d8b1baa1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ValidateSignatureSettings from "@app/components/tools/validateSignature/ValidateSignatureSettings"; +import { ValidateSignatureParameters } from "@app/hooks/tools/validateSignature/useValidateSignatureParameters"; + +const meta = { + title: "Tools/ValidateSignature/ValidateSignatureSettings", + component: ValidateSignatureSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const buildParameters = ( + overrides: Partial = {}, +): ValidateSignatureParameters => ({ + certFile: null, + ...overrides, +}); + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const WithCertFile: Story = { + args: { + parameters: buildParameters({ + certFile: new File(["cert-data"], "trusted-root.crt", { + type: "application/x-x509-ca-cert", + }), + }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx new file mode 100644 index 0000000000..499b76e393 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FieldBlock from "@app/components/tools/validateSignature/reportView/FieldBlock"; + +// FieldBlock is a plain function that returns a JSX element (called directly +// as `FieldBlock(label, value)`), not a React component consumed via JSX +// props, so every story renders it through a `render` override instead of +// `args`. +const meta = { + title: "Tools/ValidateSignature/ReportView/FieldBlock", +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => FieldBlock("Signer Name", "Jane Doe"), +}; + +export const EmptyValue: Story = { + render: () => FieldBlock("Reason", ""), +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx new file mode 100644 index 0000000000..066510a0ef --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileSummaryHeader from "@app/components/tools/validateSignature/reportView/FileSummaryHeader"; + +const meta = { + title: "Tools/ValidateSignature/ReportView/FileSummaryHeader", + component: FileSummaryHeader, + args: { + fileSize: 2_456_789, + createdAt: "2026-01-15T09:30:00Z", + totalSignatures: 2, + lastSignatureDate: "2026-03-04T14:12:00Z", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoSignatures: Story = { + args: { + totalSignatures: 0, + lastSignatureDate: null, + }, +}; + +export const MissingMetadata: Story = { + args: { + fileSize: null, + createdAt: null, + totalSignatures: 1, + lastSignatureDate: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx new file mode 100644 index 0000000000..7c8b8edc1f --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSection from "@app/components/tools/validateSignature/reportView/SignatureSection"; +import type { SignatureValidationSignature } from "@app/types/validateSignature"; + +const buildSignature = ( + overrides: Partial = {}, +): SignatureValidationSignature => ({ + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-06-01T10:00:00Z", + reason: "Approved", + location: "London, UK", + issuerDN: "CN=Example CA, O=Example Corp", + subjectDN: "CN=Jane Doe, O=Example Corp", + serialNumber: "0x4F2A9C", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "3", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, + ...overrides, +}); + +const meta = { + title: "Tools/ValidateSignature/SignatureSection", + component: SignatureSection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + signature: buildSignature(), + index: 0, + }, +}; + +export const InvalidWithError: Story = { + args: { + signature: buildSignature({ + valid: false, + chainValid: false, + trustValid: false, + notExpired: false, + errorMessage: "Certificate has expired", + }), + index: 1, + }, +}; + +export const SelfSignedMinimalData: Story = { + args: { + signature: buildSignature({ + signerName: "", + reason: "", + location: "", + issuerDN: "", + subjectDN: "", + serialNumber: "", + keySize: null, + version: "", + keyUsages: [], + selfSigned: true, + }), + index: 2, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx new file mode 100644 index 0000000000..aba78f9f2c --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureStatusBadge from "@app/components/tools/validateSignature/reportView/SignatureStatusBadge"; +import type { SignatureValidationSignature } from "@app/types/validateSignature"; + +const baseSignature: SignatureValidationSignature = { + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-06-01T12:00:00Z", + reason: "Document approval", + location: "London, UK", + issuerDN: "CN=Stirling PDF CA", + subjectDN: "CN=Jane Doe", + serialNumber: "0123456789ABCDEF", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "2", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, +}; + +const meta = { + title: "Tools/ValidateSignature/SignatureStatusBadge", + component: SignatureStatusBadge, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + signature: baseSignature, + }, +}; + +export const UntrustedSigner: Story = { + args: { + signature: { + ...baseSignature, + id: "sig-2", + trustValid: false, + chainValid: false, + selfSigned: true, + }, + }, +}; + +export const Invalid: Story = { + args: { + signature: { + ...baseSignature, + id: "sig-3", + valid: false, + errorMessage: "Signature does not match document contents", + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx new file mode 100644 index 0000000000..feccc477d3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ThumbnailPreview from "@app/components/tools/validateSignature/reportView/ThumbnailPreview"; + +const meta = { + title: "Tools/ValidateSignature/ReportView/ThumbnailPreview", + component: ThumbnailPreview, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const SAMPLE_THUMBNAIL = + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ); + +export const Default: Story = { + args: { + thumbnailUrl: SAMPLE_THUMBNAIL, + fileName: "signed-contract.pdf", + }, +}; + +export const NoThumbnail: Story = { + args: { + thumbnailUrl: null, + fileName: "signed-contract.pdf", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx new file mode 100644 index 0000000000..65f57faa9d --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + DeleteButton, + EditTextButton, + AttachCommentButton, + CommentButton, + LinkButton, +} from "@app/components/viewer/AnnotationMenuButtons"; + +// This module has no single default-exported component -- it's a set of small +// action-icon buttons shared by the annotation menu. Document each separately. +const meta: Meta = { + title: "Viewer/AnnotationMenuButtons", + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Delete: Story = { + render: () => {}} />, +}; + +export const EditText: Story = { + render: () => {}} />, +}; + +export const AttachCommentAdd: Story = { + render: () => ( + {}} + onAdd={() => {}} + /> + ), +}; + +export const AttachCommentView: Story = { + render: () => ( + {}} + onAdd={() => {}} + /> + ), +}; + +export const CommentEmpty: Story = { + render: () => {}} />, +}; + +export const CommentWithContent: Story = { + render: () => {}} />, +}; + +export const LinkAdd: Story = { + render: () => ( + {}} + onAddLink={() => {}} + /> + ), +}; + +export const LinkGoTo: Story = { + render: () => ( + {}} + onAddLink={() => {}} + /> + ), +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx new file mode 100644 index 0000000000..bd9d86f0c6 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnnotationSelectionMenu } from "@app/components/viewer/AnnotationSelectionMenu"; + +// AnnotationSelectionMenu reads the active document from ActiveDocumentContext, which +// defaults to `null` outside of a live EmbedPDF document-manager session (not something the +// shared preview can stub). With no active document it short-circuits and renders nothing, +// so this story only exercises that no-active-document mount path without throwing. +const meta = { + title: "Viewer/AnnotationSelectionMenu", + component: AnnotationSelectionMenu, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + selected: false, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx new file mode 100644 index 0000000000..860f743a99 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnnotationTypeButtons } from "@app/components/viewer/AnnotationTypeButtons"; + +const meta = { + title: "Viewer/AnnotationTypeButtons", + component: AnnotationTypeButtons, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const baseArgs = { + annotation: undefined, + documentId: "doc-1", + pageIndex: 0, + annotationId: "annotation-1", + menuWidth: 260, + obj: undefined, + firstLinkTarget: null, + hasCommentContent: false, + isInSidebar: false, + currentColor: "#000000", + strokeColor: "#000000", + fillColor: "#0000ff", + backgroundColor: "#ffffff", + textColor: "#000000", + currentOpacity: 100, + currentWidth: 2, + onDelete: () => {}, + onEdit: () => {}, + onColorChange: () => {}, + onOpacityChange: () => {}, + onWidthChange: () => {}, + onPropertiesUpdate: () => {}, + onGoToLink: () => {}, + onAddLink: () => {}, + onAddToSidebar: () => {}, + onViewComment: () => {}, + onCommentColorChange: () => {}, +}; + +export const TextMarkup: Story = { + args: { + ...baseArgs, + annotationType: "textMarkup", + }, +}; + +export const Ink: Story = { + args: { + ...baseArgs, + annotationType: "ink", + }, +}; + +export const Comment: Story = { + args: { + ...baseArgs, + annotationType: "comment", + hasCommentContent: true, + }, +}; + +export const Shape: Story = { + args: { + ...baseArgs, + annotationType: "shape", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx b/frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx new file mode 100644 index 0000000000..940ceabb47 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CustomSearchLayer } from "@app/components/viewer/CustomSearchLayer"; + +// CustomSearchLayer reads its highlight rects from the EmbedPDF search plugin +// context (useSearch/useDocumentState). Outside a mounted PDFContext.Provider +// those hooks fall back to their default (empty) state, so the layer renders +// nothing — this still exercises the component mounting without throwing. +const meta = { + title: "Viewer/CustomSearchLayer", + component: CustomSearchLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + documentId: "doc-1", + pageIndex: 0, + scale: 1, + highlightColor: "rgba(255, 220, 0, 0.4)", + activeHighlightColor: "rgba(255, 140, 0, 0.6)", + opacity: 1, + padding: 2, + borderRadius: 4, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx new file mode 100644 index 0000000000..cb0576dfb2 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Center, Text } from "@mantine/core"; +import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrapper"; + +/** + * Outside of a live `` tree the document-manager plugin never + * finishes loading, so this always renders its `fallback` — the same state + * shown in the real viewer while the PDF engine is still initializing. + */ +const meta = { + title: "Viewer/DocumentReadyWrapper", + component: DocumentReadyWrapper, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + fallback: ( +
+ + Loading document… + +
+ ), + children: (documentId: string) => Document ready: {documentId}, + }, +}; + +export const NoFallback: Story = { + args: { + children: (documentId: string) => Document ready: {documentId}, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx b/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx new file mode 100644 index 0000000000..376cdca0df --- /dev/null +++ b/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkLayer } from "@app/components/viewer/LinkLayer"; + +// LinkLayer reads its link annotations from EmbedPDF's react context +// (useDocumentState/useScroll/useAnnotation). Outside of a live +// provider those hooks resolve to their documented empty defaults (no +// annotations, scale 1), so the layer renders null — this still exercises +// the component's mount path without needing a real PDF engine. +const meta = { + title: "Viewer/LinkLayer", + component: LinkLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + documentId: "storybook-doc", + pageIndex: 0, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx new file mode 100644 index 0000000000..8880c58afa --- /dev/null +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; + +const meta = { + title: "Viewer/LocalEmbedPDF", + component: LocalEmbedPDF, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// No file/url supplied — renders the "No PDF provided" empty state without +// touching the pdfium engine or blob URL setup. +export const Empty: Story = { + args: {}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +// A non-PDF file surfaces the "cannot preview" guard instead of attempting to +// load the pdfium engine. +export const UnsupportedFile: Story = { + args: { + file: new File(["not a pdf"], "notes.txt", { type: "text/plain" }), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx new file mode 100644 index 0000000000..4f10d49303 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import { NonPdfViewer } from "@app/components/viewer/NonPdfViewer"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * NonPdfViewer reads tool availability (for the "Convert to PDF" action) via + * ToolWorkflowContext, which depends on the tool registry, preferences, and + * navigation providers being mounted above it. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +function makeFile(name: string, type: string, contents: string): File { + return new File([contents], name, { type }); +} + +const meta = { + title: "Viewer/NonPdfViewer", + component: NonPdfViewer, + parameters: { layout: "fullscreen" }, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** CSV preview: parsed into a scrollable table. */ +export const Csv: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile( + "invoice.csv", + "text/csv", + "Item,Qty,Price\nWidget,3,9.99\nGadget,1,19.99\n", + ), + }, +}; + +/** JSON preview: syntax-highlighted / pretty-printed. */ +export const Json: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile( + "config.json", + "application/json", + JSON.stringify({ name: "Stirling PDF", version: 1 }, null, 2), + ), + }, +}; + +/** Unsupported file type: falls back to the "Preview not available" state. */ +export const Unsupported: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile("archive.zip", "application/zip", "binary-ish-content"), + }, +}; diff --git a/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx new file mode 100644 index 0000000000..40062505f8 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RedactionPendingTracker } from "@app/components/viewer/RedactionPendingTracker"; + +// RedactionPendingTracker reads the active document from ActiveDocumentContext, which +// defaults to `null` outside of a live EmbedPDF document-manager session (not something the +// shared preview can stub). With no active document it short-circuits and renders nothing, +// so this story only exercises that no-active-document mount path without throwing. +const meta = { + title: "Viewer/RedactionPendingTracker", + component: RedactionPendingTracker, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx new file mode 100644 index 0000000000..af01665228 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RedactionSelectionMenu } from "@app/components/viewer/RedactionSelectionMenu"; + +// RedactionSelectionMenu renders only when there's an active document ID +// (ActiveDocumentContext) and a selected redaction annotation from the live +// EmbedPDF redaction plugin. Neither exists in Storybook, so the component's +// own guard clause renders nothing here - that's its real empty state. +const meta = { + title: "Viewer/RedactionSelectionMenu", + component: RedactionSelectionMenu, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx b/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx new file mode 100644 index 0000000000..1e1a255465 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SearchInterface } from "@app/components/viewer/SearchInterface"; + +// SearchInterface reads ViewerContext via useContext with optional chaining +// throughout, so it renders fine without a ViewerProvider mounted — search +// state simply resolves to its empty/no-results defaults. +const meta = { + title: "Viewer/SearchInterface", + component: SearchInterface, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + visible: true, + onClose: () => {}, + }, +}; + +export const Hidden: Story = { + args: { + visible: false, + onClose: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx new file mode 100644 index 0000000000..5dc1e73380 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay"; + +// With no pdfSource the overlay resolves zero fields and renders null — this +// is the state it's mounted in until the host viewer has a document loaded. +const meta = { + title: "Viewer/SignatureFieldOverlay", + component: SignatureFieldOverlay, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageIndex: 0, + pdfSource: null, + documentId: "doc-1", + pageWidth: 612, + pageHeight: 792, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx new file mode 100644 index 0000000000..c6e4551333 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePlacementOverlay } from "@app/components/viewer/SignaturePlacementOverlay"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +const textSignature: SignParameters = { + signatureType: "text", + signerName: "Jordan Blake", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#1e293b", + textAlign: "left", +}; + +// The overlay positions itself relative to containerRef and only paints once a +// mousemove has been observed inside that element, so the harness supplies a +// sized, positioned container and fires a synthetic mousemove after mount to +// simulate the cursor already being over the page. +function PlacementHarness({ + signatureConfig, +}: { + signatureConfig: SignParameters | null; +}) { + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new MouseEvent("mousemove", { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + }), + ); + }, [signatureConfig]); + + return ( +
+ +
+ ); +} + +const noopContainerRef = { current: null }; + +const meta = { + title: "Viewer/SignaturePlacementOverlay", + component: SignaturePlacementOverlay, + // SignaturePlacementOverlay reads useSignature() to report its preview size + // back up — that context isn't mounted by the shared preview, so stub it here. + decorators: [ + (Story) => ( + + + + ), + ], + // Stories below override render with PlacementHarness, but Storybook's types + // still require args to satisfy the component's required props. + args: { + containerRef: noopContainerRef, + isActive: true, + signatureConfig: textSignature, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Cursor-following preview of a text signature over the page. */ +export const Default: Story = { + render: () => , +}; + +/** No signature configured yet — the overlay renders nothing. */ +export const NoSignatureConfigured: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx new file mode 100644 index 0000000000..a5580dd505 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePreviewLayer } from "@app/components/viewer/SignaturePreviewLayer"; +import type { SignaturePreview } from "@app/components/viewer/viewerTypes"; + +// A 1x1 transparent PNG — enough to satisfy the src without a real signature asset. +const PLACEHOLDER_SIGNATURE_DATA = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const SAMPLE_PREVIEWS: SignaturePreview[] = [ + { + id: "sig-1", + pageIndex: 0, + x: 0.2, + y: 0.6, + width: 0.25, + height: 0.1, + signatureData: PLACEHOLDER_SIGNATURE_DATA, + signatureType: "image", + participantName: "Jane Doe", + }, +]; + +// SignaturePreviewLayer reads pause/resume from EmbedPDF's interaction-manager +// react context (useInteractionManagerCapability). Outside a mounted PDFContext +// provider that hook resolves to its documented empty default (no capability), +// so drag/resize handlers no-op — this still exercises the component's mount +// and render path without needing a real PDF engine. +const meta = { + title: "Viewer/SignaturePreviewLayer", + component: SignaturePreviewLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageIndex: 0, + pageWidth: 600, + pageHeight: 800, + previews: SAMPLE_PREVIEWS, + readOnly: false, + placementMode: false, + onChange: () => {}, + }, +}; + +export const ReadOnly: Story = { + args: { + ...Default.args, + readOnly: true, + }, +}; + +export const PlacementMode: Story = { + args: { + ...Default.args, + previews: [], + placementMode: true, + placementData: PLACEHOLDER_SIGNATURE_DATA, + placementType: "image", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx new file mode 100644 index 0000000000..e2a7f64fc7 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx @@ -0,0 +1,95 @@ +import { createRef, useEffect, useRef } from "react"; +import type { Meta, StoryObj, Decorator } from "@storybook/react-vite"; +import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +// StampPlacementOverlay reads/writes SignatureContext (useSignature, for +// placementPreviewSize), which isn't mounted by the shared preview. +const withProviders: Decorator = (Story) => ( + + + +); + +const textSignature: SignParameters = { + signatureType: "text", + signerName: "Jane Doe", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#1e3a5f", + textAlign: "left", +}; + +// StampPlacementOverlay tracks the mouse over containerRef and only renders a +// preview once it has both a built signature image and a cursor position, so +// the demo dispatches a synthetic mousemove after mount to show it in place. +function StampPlacementOverlayDemo( + props: Partial>, +) { + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new MouseEvent("mousemove", { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + }), + ); + }, []); + + return ( +
+ +
+ ); +} + +const meta = { + title: "Viewer/StampPlacementOverlay", + component: StampPlacementOverlay, + parameters: { layout: "padded" }, + decorators: [withProviders], + // Actual rendering is handled by StampPlacementOverlayDemo's own containerRef; + // these are just placeholder values to satisfy the component's required props. + args: { + containerRef: createRef(), + isActive: true, + signatureConfig: textSignature, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Active placement mode with a text signature preview following the cursor. */ +export const Default: Story = { + render: () => , +}; + +/** Inactive placement mode — the overlay renders nothing (returns null). */ +export const Inactive: Story = { + render: () => , +}; + +/** No signature configured yet — nothing to preview, so it renders nothing. */ +export const NoSignatureConfig: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx new file mode 100644 index 0000000000..1321fe7ea8 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { SelectionSelectionMenuProps } from "@embedpdf/plugin-selection/react"; +import { TextSelectionMenu } from "@app/components/viewer/TextSelectionMenu"; + +function baseProps( + overrides: Partial = {}, +): SelectionSelectionMenuProps { + return { + rect: { origin: { x: 0, y: 0 }, size: { width: 120, height: 20 } }, + menuWrapperProps: { style: {}, ref: () => {} }, + selected: true, + placement: { suggestTop: true }, + context: { type: "selection", pageIndex: 0 }, + ...overrides, + }; +} + +const meta = { + title: "Viewer/TextSelectionMenu", + component: TextSelectionMenu, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: baseProps(), +}; + +export const BelowSelection: Story = { + args: baseProps({ placement: { suggestTop: false } }), +}; + +export const NotSelected: Story = { + args: baseProps({ selected: false }), +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx new file mode 100644 index 0000000000..eb88d5f548 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer"; + +const buildCsvFile = (contents: string, name = "data.csv"): File => + new File([contents], name, { type: "text/csv" }); + +const SAMPLE_CSV = [ + "Name,Age,City", + "Alice,30,New York", + "Bob,25,Los Angeles", + "Charlie,35,Chicago", +].join("\n"); + +const SAMPLE_TSV = [ + "Name\tAge\tCity", + "Alice\t30\tNew York", + "Bob\t25\tLos Angeles", +].join("\n"); + +const meta = { + title: "Viewer/NonPdf/CsvViewer", + component: CsvViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: buildCsvFile(SAMPLE_CSV), + isTsv: false, + }, +}; + +export const Tsv: Story = { + args: { + file: buildCsvFile(SAMPLE_TSV, "data.tsv"), + isTsv: true, + }, +}; + +export const Empty: Story = { + args: { + file: buildCsvFile(""), + isTsv: false, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx new file mode 100644 index 0000000000..173fc38903 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HtmlViewer } from "@app/components/viewer/nonpdf/HtmlViewer"; + +const sampleHtmlFile = new File( + ["

Sample document

Preview content.

"], + "sample.html", + { type: "text/html" }, +); + +const meta = { + title: "Viewer/Nonpdf/HtmlViewer", + component: HtmlViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: sampleHtmlFile, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx new file mode 100644 index 0000000000..53d0329733 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ImageViewer } from "@app/components/viewer/nonpdf/ImageViewer"; + +// 2x2 red PNG, used so the component's URL.createObjectURL(file) has real image bytes to render. +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mNk+M9QDwAEZ" + + "AGvRy0PbwAAAABJRU5ErkJggg=="; + +function makeImageFile(name: string): File { + const bytes = atob(PNG_BASE64); + const buffer = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + buffer[i] = bytes.charCodeAt(i); + } + return new File([buffer], name, { type: "image/png" }); +} + +const meta = { + title: "Viewer/NonPdf/ImageViewer", + component: ImageViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: makeImageFile("sample.png"), + fileName: "sample.png", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx new file mode 100644 index 0000000000..7261e24ad6 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer"; + +function jsonFile(name: string, contents: string) { + return new File([contents], name, { type: "application/json" }); +} + +const meta = { + title: "Viewer/NonPdf/JsonViewer", + component: JsonViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: jsonFile( + "config.json", + JSON.stringify( + { + name: "Stirling PDF", + version: "2.0.0", + features: ["merge", "split", "compress"], + settings: { theme: "dark", locale: "en-US" }, + }, + null, + 2, + ), + ), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const InvalidJson: Story = { + args: { + file: jsonFile("broken.json", "{ this is not valid json "), + }, + decorators: Default.decorators, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx new file mode 100644 index 0000000000..d37267484c --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NonPdfBanner } from "@app/components/viewer/nonpdf/NonPdfBanner"; + +const meta = { + title: "Viewer/NonPdf/NonPdfBanner", + component: NonPdfBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onConvertToPdf: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Hidden: Story = { + args: { + onConvertToPdf: undefined, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx new file mode 100644 index 0000000000..6b006b8327 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextViewer } from "@app/components/viewer/nonpdf/TextViewer"; + +const meta = { + title: "Viewer/NonPdf/TextViewer", + component: TextViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const plainTextFile = new File( + [ + Array.from( + { length: 20 }, + (_, i) => `Line ${i + 1}: the quick brown fox jumps over the lazy dog.`, + ).join("\n"), + ], + "notes.txt", + { type: "text/plain" }, +); + +const markdownFile = new File( + [ + [ + "# Sample document", + "", + "This is a **markdown** file rendered by the text viewer.", + "", + "- item one", + "- item two", + "", + "> A blockquote for good measure.", + ].join("\n"), + ], + "README.md", + { type: "text/markdown" }, +); + +export const Default: Story = { + args: { + file: plainTextFile, + isMarkdown: false, + }, +}; + +export const Markdown: Story = { + args: { + file: markdownFile, + isMarkdown: true, + }, +}; diff --git a/frontend/editor/src/core/ui/FilePicker.stories.tsx b/frontend/editor/src/core/ui/FilePicker.stories.tsx new file mode 100644 index 0000000000..53fa1ef2a0 --- /dev/null +++ b/frontend/editor/src/core/ui/FilePicker.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FilePicker } from "@app/ui/FilePicker"; + +const meta = { + title: "Primitives/FilePicker", + component: FilePicker, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onChange: () => {}, + children: "Choose file", + }, +}; + +export const AcceptPdf: Story = { + args: { + onChange: () => {}, + accept: "application/pdf", + children: "Choose PDF", + }, +}; + +export const Multiple: Story = { + args: { + onChange: () => {}, + multiple: true, + children: "Choose files", + }, +}; + +export const Disabled: Story = { + args: { + onChange: () => {}, + disabled: true, + children: "Choose file", + }, +}; diff --git a/frontend/editor/src/portal/components/AssistantButton.stories.tsx b/frontend/editor/src/portal/components/AssistantButton.stories.tsx new file mode 100644 index 0000000000..cb6c83c043 --- /dev/null +++ b/frontend/editor/src/portal/components/AssistantButton.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { AssistantButton } from "@portal/components/AssistantButton"; +import { useUI } from "@portal/contexts/UIContext"; + +function ForceOpen() { + const { openAssistant } = useUI(); + useEffect(() => { + openAssistant(); + }, [openAssistant]); + return null; +} + +const meta: Meta = { + title: "Portal/Assistant/AssistantButton", + component: AssistantButton, +}; +export default meta; +type Story = StoryObj; + +export const Closed: Story = {}; + +export const Open: Story = { + decorators: [ + (S) => ( + <> + + + + ), + ], +}; diff --git a/frontend/editor/src/portal/components/BrandMarks.stories.tsx b/frontend/editor/src/portal/components/BrandMarks.stories.tsx new file mode 100644 index 0000000000..e25fde2b5d --- /dev/null +++ b/frontend/editor/src/portal/components/BrandMarks.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BrandMark } from "@portal/components/BrandMarks"; + +const meta = { + title: "Portal/BrandMarks", + component: BrandMark, + parameters: { layout: "padded" }, + args: { + id: "s3", + size: 24, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CloudProvider: Story = { args: { id: "googledrive" } }; + +export const Neutral: Story = { args: { id: "sftp" } }; + +export const UnknownFallback: Story = { + args: { id: "some-unrecognised-source" }, +}; diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx b/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx new file mode 100644 index 0000000000..6a9166c3b2 --- /dev/null +++ b/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; + +const meta: Meta = { + title: "Portal/DownloadEditorModal", + component: DownloadEditorModal, + parameters: { layout: "fullscreen" }, + args: { open: true, onClose: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** Landing list of desktop + self-hosted install options. */ +export const Open: Story = {}; diff --git a/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx b/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..a680039046 --- /dev/null +++ b/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; + +function Boom(): never { + throw new Error("kaboom"); +} + +const meta = { + title: "Portal/ErrorBoundary", + component: ErrorBoundary, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No error: children render straight through. */ +export const Default: Story = { + args: { + children:
Everything is fine.
, + }, +}; + +/** A child throws during render; the boundary contains it with the default + * fallback card instead of taking down the rest of the page. */ +export const CaughtError: Story = { + args: { + children: , + }, +}; + +/** A custom fallback receives the boundary's reset function so it can offer + * its own retry affordance. */ +export const CustomFallback: Story = { + args: { + children: , + fallback: (reset) => ( +
+

Custom error UI.

+ +
+ ), + }, +}; diff --git a/frontend/editor/src/portal/components/HomeGreeting.stories.tsx b/frontend/editor/src/portal/components/HomeGreeting.stories.tsx new file mode 100644 index 0000000000..e2cca6dbfb --- /dev/null +++ b/frontend/editor/src/portal/components/HomeGreeting.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HomeGreeting } from "@portal/components/HomeGreeting"; + +const meta = { + title: "Portal/Home/HomeGreeting", + component: HomeGreeting, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Time-of-day greeting + today's date, shown above the paid-tier home hero. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/HomeHero.stories.tsx b/frontend/editor/src/portal/components/HomeHero.stories.tsx new file mode 100644 index 0000000000..dd1ea07c04 --- /dev/null +++ b/frontend/editor/src/portal/components/HomeHero.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HomeHero } from "@portal/components/HomeHero"; + +const meta = { + title: "Portal/Home/HomeHero", + component: HomeHero, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Pay-as-you-go tier: welcome header + setup checklist until onboarding completes. */ +export const Default: Story = { + args: { tier: "pro" }, +}; + +/** Free tier renders the same welcome-header composition as pro. */ +export const FreeTier: Story = { + args: { tier: "free" }, +}; + +/** Enterprise tier hides the status chips — the procurement deal hero owns the invite step. */ +export const EnterpriseTier: Story = { + args: { tier: "enterprise" }, +}; diff --git a/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx b/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx new file mode 100644 index 0000000000..b65041164a --- /dev/null +++ b/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; + +const meta: Meta = { + title: "Portal/LinkAccountFooterItem", + component: LinkAccountFooterItem, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Unlinked org — the "Link Stirling account" CTA appears in the sidebar footer. */ +export const Unlinked: Story = { + globals: { linkState: "unlinked" }, +}; + +/** Linked org — the CTA hides itself (renders nothing), since the state is + * already communicated elsewhere. */ +export const Linked: Story = { + globals: { linkState: "linked-subscribed" }, +}; diff --git a/frontend/editor/src/portal/components/LoginScreen.stories.tsx b/frontend/editor/src/portal/components/LoginScreen.stories.tsx new file mode 100644 index 0000000000..63db811b71 --- /dev/null +++ b/frontend/editor/src/portal/components/LoginScreen.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LoginScreen } from "@portal/components/LoginScreen"; + +const meta: Meta = { + title: "Portal/LoginScreen", + component: LoginScreen, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/PortalChrome.stories.tsx b/frontend/editor/src/portal/components/PortalChrome.stories.tsx new file mode 100644 index 0000000000..b491fee325 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalChrome.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PortalChrome } from "@portal/components/PortalChrome"; + +const meta = { + title: "Portal/Shell/PortalChrome", + component: PortalChrome, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx b/frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx new file mode 100644 index 0000000000..99735e657f --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; +import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; +import { listInstances } from "@portal/mocks/link"; +import "@portal/views/AccountLink.css"; + +// AccountLinkPanel reads the shared account-link instance from context (the +// preview only supplies LinkProvider), so wrap it in AccountLinkProvider here. +const meta: Meta = { + title: "Portal/AccountLink/AccountLinkPanel", + component: AccountLinkPanel, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** + * The Settings account-link surface: the status badge (driven by the Link + * toolbar global), the LinkAccountCard for this instance, and — once linked — + * the team-wide linked-instances table. + */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/account-link/status", () => + HttpResponse.json({ linked: true, name: "prod-eu-gateway" }), + ), + http.get("*/api/v1/account-link/instances", () => + HttpResponse.json(listInstances()), + ), + ], + }, + }, +}; + +/** Not linked — the instances table is hidden until this instance links its org. */ +export const NotLinked: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/account-link/status", () => + HttpResponse.json({ linked: false, name: null }), + ), + ], + }, + }, +}; + +/** Linked, but the signed-in admin isn't the team owner — the instances table fails to load. */ +export const LoadForbidden: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/account-link/status", () => + HttpResponse.json({ linked: true, name: "prod-eu-gateway" }), + ), + http.get("*/api/v1/account-link/instances", () => + HttpResponse.json({ detail: "Forbidden" }, { status: 403 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx new file mode 100644 index 0000000000..8228e3b1bd --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; + +const meta: Meta = { + title: "Portal/AccountLink/LinkAccountModal", + component: LinkAccountModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + onClose: () => {}, + onLinked: async () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Default "link" mode — sign in to register this instance against a Stirling account. */ +export const Default: Story = {}; + +/** "reauth" mode — an already-linked instance's session expired and needs a fresh sign-in. */ +export const Reauth: Story = { + args: { mode: "reauth" }, +}; diff --git a/frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx deleted file mode 100644 index 60d9a9962c..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Card } from "@app/ui"; -import { LinkGate } from "@portal/components/account-link/LinkGate"; - -const meta: Meta = { - title: "Portal/AccountLink/LinkGate", - component: LinkGate, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** - * Gating follows the Link toolbar global: "Unlinked" shows the lock prompt, - * any linked state renders the feature. - */ -export const Default: Story = { - args: { - feature: "AI extraction", - children: ( - A billable feature, unlocked once linked. - ), - }, -}; diff --git a/frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx b/frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx new file mode 100644 index 0000000000..5d035007f9 --- /dev/null +++ b/frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CardPlaceholder } from "@portal/components/billing/CardPlaceholder"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/CardPlaceholder", + component: CardPlaceholder, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Stand-in card form shown when Stripe isn't mounted (no publishable key / preview). */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx b/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx new file mode 100644 index 0000000000..90aba9a4fe --- /dev/null +++ b/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { InvoicesList } from "@portal/components/billing/InvoicesList"; +import type { Invoice } from "@portal/api/billing"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/InvoicesList", + component: InvoicesList, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function invoice(overrides: Partial & { id: string }): Invoice { + return { + number: null, + status: "paid", + totalMinor: 4900, + currency: "usd", + createdAt: "2026-06-01T00:00:00Z", + periodStart: "2026-05-01T00:00:00Z", + periodEnd: "2026-06-01T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/mock", + invoicePdf: "https://invoice.stripe.com/i/mock/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 1204, + ...overrides, + }; +} + +const SEVEN_INVOICES: Invoice[] = Array.from({ length: 7 }, (_, i) => + invoice({ + id: `in_mock_${i}`, + number: `INV-${1000 + i}`, + status: i === 0 ? "open" : "paid", + createdAt: `2026-0${(i % 6) + 1}-01T00:00:00Z`, + }), +); + +/** A handful of recent invoices — the common case. */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/invoices", () => + HttpResponse.json(SEVEN_INVOICES), + ), + ], + }, + }, +}; + +/** No invoices yet — free team or a subscription with no closed cycle. */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/invoices", () => HttpResponse.json([])), + ], + }, + }, +}; + +/** Fetch fails — the inline error message renders instead of the table. */ +export const LoadError: Story = { + parameters: { + msw: { + handlers: [ + http.get( + "*/api/v1/payg/invoices", + () => new HttpResponse(null, { status: 500 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx new file mode 100644 index 0000000000..a8a3d64d0d --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrepaidCapacityCard } from "@portal/components/billing/PrepaidCapacityCard"; +import { + subscribedWallet, + prepaidWallet, +} from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PrepaidCapacityCard", + component: PrepaidCapacityCard, + parameters: { layout: "padded" }, + args: { + wallet: subscribedWallet, + onBuy: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** No bundle yet — the "12 months for the price of 10" offer nudge (leader view). */ +export const OfferNudge: Story = { + args: { + wallet: subscribedWallet, + }, +}; + +/** Bundle held, plenty of capacity left — meter + "Top up" action (leader view). */ +export const BundleHealthy: Story = { + args: { + wallet: prepaidWallet, + }, +}; + +/** Bundle nearly drawn down — meter crosses into the "Running low" band. */ +export const BundleLow: Story = { + args: { + wallet: { + ...prepaidWallet, + prepaidUnitsRemaining: 10_000, + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx b/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx new file mode 100644 index 0000000000..d91d9f754e --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrepayModalHeader } from "@portal/components/billing/PrepayModalHeader"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PrepayModalHeader", + component: PrepayModalHeader, + args: { + step: 1, + total: 3, + title: "Choose an amount", + onClose: () => console.log("close"), + }, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Prepaid wizard, step 1 of 3 — badge + 3-segment progress bar. */ +export const StepOfThree: Story = {}; + +/** Metered checkout, step 2 of 2 — badge + 2-segment progress bar. */ +export const StepOfTwo: Story = { + args: { step: 2, total: 2, title: "Add a payment method" }, +}; + +/** No step supplied — badge and progress bar are hidden (e.g. a terminal confirmation screen). */ +export const NoSteps: Story = { + args: { step: undefined, title: "You're all set" }, +}; diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx index 3bb2d6dc3c..3733b2aca9 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx @@ -42,7 +42,13 @@ export const Empty: Story = { handlers: [ http.get("*/api/v1/proprietary/ui-data/infrastructure/audit-log", () => HttpResponse.json({ - summary: { totalEvents: 0, processing: 0, elevation: 0, config: 0 }, + summary: { + totalEvents: 0, + policy: 0, + processing: 0, + elevation: 0, + config: 0, + }, events: [], fullServer: true, }), @@ -74,7 +80,13 @@ export const TeamLeadScoped: Story = { handlers: [ http.get("*/api/v1/proprietary/ui-data/infrastructure/audit-log", () => HttpResponse.json({ - summary: { totalEvents: 4, processing: 2, elevation: 0, config: 1 }, + summary: { + totalEvents: 4, + policy: 0, + processing: 2, + elevation: 0, + config: 1, + }, events: [ { id: "9102", diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx new file mode 100644 index 0000000000..d2bd6bc6f2 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; + +/** Stand-in for a tool's real automation settings UI. */ +function MockCompressSettings({ + parameters, + onParameterChange, +}: { + parameters: Record; + onParameterChange: (key: string, value: unknown) => void; +}) { + return ( + + ); +} + +const editableStep = { + support: "editable", + toolId: "compress", + params: { level: 5 }, +} as unknown as WorkingToolStep; + +const noSettingsStep = { + support: "noSettings", + toolId: "flatten", + params: {}, +} as unknown as WorkingToolStep; + +const unsupportedStep = { + support: "unsupported", + toolId: "convert", + params: {}, +} as unknown as WorkingToolStep; + +const registry = { + compress: { automationSettings: MockCompressSettings }, +} as unknown as Partial; + +const meta = { + title: "Portal/Pipelines/PipelineStepSettings", + component: PipelineStepSettings, + args: { + step: editableStep, + registry, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** A tool with an editable settings UI, rendered via the tool's own component. */ +export const Editable: Story = {}; + +/** A migrated tool with no configurable parameters — shows an informational note. */ +export const NoSettings: Story = { + args: { + step: noSettingsStep, + }, +}; + +/** A tool not yet migrated to the automation mapper seam — shows a fallback warning. */ +export const Unsupported: Story = { + args: { + step: unsupportedStep, + registry: {}, + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx new file mode 100644 index 0000000000..3f18b8e53f --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { PipelineView } from "@portal/api/pipelines"; +import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; + +const PIPELINES: PipelineView[] = [ + { + id: "pipe-intake", + name: "Claims intake", + enabled: true, + status: "active", + trigger: "folder-watch", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["redact", "sanitize", "watermark"], + output: "folder", + owner: "jane@stirlingpdf.com", + }, + { + id: "pipe-archive", + name: "Archive reprocess", + enabled: false, + status: "paused", + trigger: "manual", + sources: [], + steps: [], + output: "inline", + owner: "jane@stirlingpdf.com", + }, +]; + +const meta: Meta = { + title: "Portal/Pipelines/PipelinesTable", + component: PipelinesTable, + parameters: { layout: "padded" }, + args: { pipelines: PIPELINES, onRowClick: () => {} }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { pipelines: [] }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx b/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx new file mode 100644 index 0000000000..3035a9b343 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SubcategoryId } from "@app/data/toolsTaxonomy"; +import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation"; +import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; + +const tools: ExecutableTool[] = [ + { + toolId: "merge", + name: "Merge", + icon: "🧩", + subcategoryId: SubcategoryId.GENERAL, + endpoint: "/api/v1/general/merge-pdfs", + support: "noSettings", + }, + { + toolId: "split", + name: "Split", + icon: "✂️", + subcategoryId: SubcategoryId.GENERAL, + endpoint: "/api/v1/general/split-pages", + support: "editable", + }, + { + toolId: "watermark", + name: "Watermark", + icon: "💧", + subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + endpoint: "/api/v1/security/add-watermark", + support: "editable", + }, + { + toolId: "removePassword", + name: "Remove password", + icon: "🔓", + subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + endpoint: "/api/v1/security/remove-password", + support: "editable", + }, + { + toolId: "ocr", + name: "OCR", + icon: "🔍", + subcategoryId: SubcategoryId.EXTRACTION, + endpoint: "/api/v1/misc/ocr-pdf", + support: "editable", + }, +]; + +const meta = { + title: "Portal/Pipelines/ToolPicker", + component: ToolPicker, + parameters: { layout: "padded" }, + args: { + tools, + onPick: () => {}, + onClose: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Full tool list grouped by category, as offered when adding a pipeline step. */ +export const Default: Story = {}; + +/** No tools match the registry (or the search filter), showing the empty state. */ +export const NoMatches: Story = { + args: { + tools: [], + }, +}; diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx new file mode 100644 index 0000000000..1586f9cc45 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection"; + +const meta: Meta = { + title: "Portal/Policies/ClassificationLabelsSection", + component: ClassificationLabelsSection, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx new file mode 100644 index 0000000000..627be84936 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PolicyExternalApiConfig, + type ExternalApiParams, +} from "@portal/components/policies/PolicyExternalApiConfig"; +import { + buildStepParameters, + operationById, +} from "@portal/components/policies/stepOperations"; + +const meta: Meta = { + title: "Portal/Policies/PolicyExternalApiConfig", + component: PolicyExternalApiConfig, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const EMPTY_PARAMS: ExternalApiParams = { + connectionId: "", + path: "", + method: "", + bodyMode: "", + fileFieldName: "", + responseMode: "", + resultUrlPath: "", + resultUrlHeader: "", + responseSelect: "", + requireTrue: "", + fields: "", + headers: "", + bodyTemplate: "", + includeContext: "", + includeFile: "", + operationId: "", + operationValues: "", +}; + +/** Keeps the parameters in local state, exercising onChange like the real editor does. */ +function Controlled({ initial }: { initial: ExternalApiParams }) { + const [parameters, setParameters] = useState(initial); + return ( + + ); +} + +export const OperationPicker: Story = { + render: () => , +}; + +const slackNotify = operationById("slackNotify")!; +const slackParams = buildStepParameters(slackNotify, "1", { + message: "{{run.policyName}} processed {{document.filename}}", +}); + +export const NotifyConfigured: Story = { + render: () => , +}; + +const customApiCall = operationById("customApiCall")!; +const customParams = buildStepParameters(customApiCall, "", {}); + +export const CustomApiEscapeHatch: Story = { + render: () => , +}; diff --git a/frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx b/frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx new file mode 100644 index 0000000000..fa1854415d --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PolicyPurviewConfig, + type PurviewLabelParams, +} from "@portal/components/policies/PolicyPurviewConfig"; + +const meta: Meta = { + title: "Portal/Policies/PolicyPurviewConfig", + component: PolicyPurviewConfig, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Renders the config and keeps its parameters in local state, exercising onChange. */ +function Controlled({ parameters }: { parameters: PurviewLabelParams }) { + const [value, setValue] = useState(parameters); + return ; +} + +export const Empty: Story = { + render: () => ( + + ), +}; + +export const Configured: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx b/frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx new file mode 100644 index 0000000000..434fb5f7cb --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PolicyPurviewReadConfig, + type PurviewReadParams, +} from "@portal/components/policies/PolicyPurviewReadConfig"; + +const meta: Meta = { + title: "Portal/Policies/PolicyPurviewReadConfig", + component: PolicyPurviewReadConfig, + parameters: { layout: "padded" }, + args: { + parameters: { connectionId: "" }, + onChange: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Renders the config and keeps its parameters in local state, exercising onChange. */ +function Controlled({ parameters }: { parameters: PurviewReadParams }) { + const [value, setValue] = useState(parameters); + return ; +} + +export const Empty: Story = { + render: () => , +}; + +export const ConnectionSelected: Story = { + render: () => , +}; diff --git a/frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx b/frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx new file mode 100644 index 0000000000..dd00672de2 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; +import "@portal/views/Procurement.css"; + +const meta: Meta = { + title: "Portal/Procurement/CalendlyInline", + component: CalendlyInline, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +// Calendly's widget.js can't reach the network in Storybook, so this renders the +// "unable to load" fallback link rather than the live embed. +export const Default: Story = {}; + +export const WithPrefilledEmail: Story = { + args: { email: "buyer@example.com" }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx new file mode 100644 index 0000000000..a6ca8dfcf5 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement"; +import type { QuoteResult } from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const quote: QuoteResult = { + quoteId: 488, + quoteNumber: "Q-2026-0488", + status: "sent", + currency: "USD", + annualNetMinor: 8_400_000, + tcvMinor: 25_200_000, + renewalAnnualNetMinor: 8_652_000, + cpiRatePct: 3, + lineItems: [ + { + key: "platform", + label: "Platform subscription", + kind: "RECURRING", + amountMinor: 6_000_000, + }, + { + key: "support", + label: "Premium support", + kind: "RECURRING", + amountMinor: 1_800_000, + }, + { + key: "onboarding", + label: "Onboarding services", + kind: "ONE_TIME", + amountMinor: 500_000, + }, + { + key: "loyalty", + label: "Multi-year loyalty discount", + kind: "DISCOUNT", + amountMinor: -300_000, + }, + { key: "sso", label: "SSO / SCIM", kind: "INCLUDED", amountMinor: 0 }, + ], + validUntil: "2026-08-15", + stripeQuoteId: "qt_1NWnd488", + invoiceUrl: null, + invoicePdf: null, + config: { + volume: 500, + users: 25, + intensity: 4, + sizeMult: 1.4, + deployment: "cloud", + termYears: 3, + serviceLevel: "standard", + indemnification: true, + training: false, + qbr: true, + businessName: "Northwind Logistics", + }, +}; + +/** + * The final agreement (security) step: the combined Master Service Agreement + Order Form + EULA + + * DPA, gated behind an "I agree" checkbox before the buyer can accept the quote. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementAgreement", + component: ProcurementAgreement, + parameters: { layout: "padded" }, + args: { + quote, + busy: false, + downloading: false, + onAgree: () => {}, + onDownload: () => {}, + onEdit: () => {}, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +// Agreeing: the primary CTA shows its loading state while the accept call is in flight. +export const Agreeing: Story = { + args: { busy: true }, +}; + +// Downloading: the secondary action shows its loading state instead. +export const Downloading: Story = { + args: { downloading: true }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx new file mode 100644 index 0000000000..28e9d2f99a --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; +import type { ProcurementController } from "@portal/components/procurement/useProcurement"; +import type { ProcurementSnapshot } from "@portal/api/procurement"; + +const snapshot: ProcurementSnapshot = { + dealId: 1, + stage: "trial", + deployment: "cloud", + seats: 250, + trialStartedAt: "2026-06-25T00:00:00Z", + trialEndsAt: "2026-07-09T00:00:00Z", + trialExtensionsUsed: 0, + licensed: false, + licenseKey: null, + latestQuote: null, +}; + +function makeController( + overrides: Partial = {}, +): ProcurementController { + return { + isLinked: true, + loading: false, + data: null, + started: false, + stage: undefined, + latest: null, + isIssued: false, + isDraft: true, + busy: false, + downloading: false, + downloadingLicense: false, + error: null, + setError: () => {}, + open: false, + setOpen: () => {}, + editing: false, + setEditing: () => {}, + extra: null, + setExtra: () => {}, + invoicePdf: null, + onStartTrial: () => {}, + onConfirmSetup: () => {}, + onExtendTrial: () => {}, + onReset: () => {}, + onGenerate: () => {}, + onAgree: () => {}, + onDownloadPdf: async () => {}, + onDownloadOfflineLicense: async () => {}, + ...overrides, + }; +} + +/** Deal-status hero once a deal is underway, otherwise the enterprise on-ramp. */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementBanner", + component: ProcurementBanner, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +/** No deal yet: the enterprise on-ramp upsell. */ +export const Upsell: Story = { + args: { controller: makeController() }, +}; + +/** A deal is underway: the wired deal-status hero. */ +export const DealUnderway: Story = { + args: { + controller: makeController({ + started: true, + data: snapshot, + stage: snapshot.stage, + }), + }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx new file mode 100644 index 0000000000..06361da844 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ProcurementSnapshot } from "@portal/api/procurement"; +import { + LicenseModal, + ScheduleCallModal, + TrialSetupModal, + TrialManageModal, +} from "@portal/components/procurement/ProcurementExtras"; +import "@portal/views/Procurement.css"; + +/** + * The small centred dialogs that hang off the deal-status hero's quick actions: the licence key, + * schedule-a-call, trial setup, and trial management. Grouped in one story file since none is + * substantial enough to warrant its own. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementExtras", + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +const SNAPSHOT: ProcurementSnapshot = { + dealId: 42, + stage: "trial", + deployment: "cloud", + seats: 12, + trialStartedAt: "2026-06-15T00:00:00Z", + trialEndsAt: "2026-07-29T00:00:00Z", + trialExtensionsUsed: 0, + licensed: false, + licenseKey: null, + latestQuote: null, +}; + +// Licence key with the offline add-on available. +export const License: Story = { + render: () => ( + {}} + licenseKey="MOCK-ENTERPRISE-KEY-0001" + offlineAvailable + downloadingLicense={false} + onDownloadOffline={() => {}} + /> + ), +}; + +// Still on the trial licence: warns that the offline file must be re-downloaded once the +// agreement is in place. +export const LicenseTrial: Story = { + render: () => ( + {}} + licenseKey="MOCK-TRIAL-KEY-0001" + offlineAvailable + downloadingLicense={false} + onDownloadOffline={() => {}} + trial + /> + ), +}; + +// Calendly's widget.js can't reach the network in Storybook, so this renders the +// "unable to load" fallback link rather than the live embed. +export const ScheduleCall: Story = { + render: () => ( + {}} email="buyer@example.com" /> + ), +}; + +// Deployment + seat count captured before the trial starts. +export const TrialSetup: Story = { + render: () => ( + {}} + busy={false} + onConfirm={() => {}} + /> + ), +}; + +// Trial in progress with extensions still available. +export const TrialManage: Story = { + render: () => ( + {}} + snapshot={SNAPSHOT} + busy={false} + onExtend={() => {}} + onCancel={() => {}} + /> + ), +}; + +// Both extensions used: the extend action is disabled in favour of a "contact us" message. +export const TrialManageMaxed: Story = { + render: () => ( + {}} + snapshot={{ ...SNAPSHOT, trialExtensionsUsed: 2 }} + busy={false} + onExtend={() => {}} + onCancel={() => {}} + /> + ), +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx new file mode 100644 index 0000000000..da5777f316 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; +import type { ProcurementController } from "@portal/components/procurement/useProcurement"; +import type { ProcurementSnapshot, QuoteResult } from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const quote: QuoteResult = { + quoteId: 488, + quoteNumber: "Q-2026-0488", + status: "sent", + currency: "USD", + annualNetMinor: 8_400_000, + tcvMinor: 25_200_000, + renewalAnnualNetMinor: 8_652_000, + cpiRatePct: 3, + lineItems: [ + { + key: "platform", + label: "Platform subscription", + kind: "RECURRING", + amountMinor: 6_000_000, + }, + { + key: "support", + label: "Premium support", + kind: "RECURRING", + amountMinor: 1_800_000, + }, + ], + validUntil: "2026-08-15", + stripeQuoteId: "qt_1NWnd488", + invoiceUrl: null, + invoicePdf: null, + config: { + volume: 500, + users: 25, + intensity: 4, + sizeMult: 1.4, + deployment: "cloud", + termYears: 3, + serviceLevel: "standard", + indemnification: true, + training: false, + qbr: true, + businessName: "Northwind Logistics", + }, +}; + +const snapshot: ProcurementSnapshot = { + dealId: 42, + stage: "quote", + deployment: "cloud", + seats: 25, + trialStartedAt: "2026-05-01T00:00:00Z", + trialEndsAt: "2026-06-01T00:00:00Z", + trialExtensionsUsed: 0, + licensed: false, + licenseKey: null, + latestQuote: quote, +}; + +/** Builds a fully-populated controller so callers only need to override the fields a story cares about. */ +function makeController( + overrides: Partial = {}, +): ProcurementController { + return { + isLinked: true, + loading: false, + data: snapshot, + started: true, + stage: snapshot.stage ?? undefined, + latest: quote, + isIssued: true, + isDraft: false, + busy: false, + downloading: false, + downloadingLicense: false, + error: null, + setError: () => {}, + open: true, + setOpen: () => {}, + editing: false, + setEditing: () => {}, + extra: null, + setExtra: () => {}, + invoicePdf: null, + onStartTrial: () => {}, + onConfirmSetup: () => {}, + onExtendTrial: () => {}, + onReset: () => {}, + onGenerate: () => {}, + onAgree: () => {}, + onDownloadPdf: async () => {}, + onDownloadOfflineLicense: async () => {}, + ...overrides, + }; +} + +/** + * The procurement takeover flow: quote & agreement, payment, and live stages, driven by a + * plain ProcurementController object (no live backend needed to render each stage). + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementFlow", + component: ProcurementFlow, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +// Quote issued and ready to agree to: the combined quote + agreement step. +export const Default: Story = { + args: { controller: makeController() }, +}; + +// No linked account yet: the modal shows the "link your account" empty state. +export const Unlinked: Story = { + args: { + controller: makeController({ + isLinked: false, + data: null, + started: false, + stage: undefined, + latest: null, + isIssued: false, + isDraft: true, + }), + }, +}; + +// Snapshot still loading: the skeleton placeholder shows instead of a stage. +export const Loading: Story = { + args: { + controller: makeController({ + loading: true, + data: null, + started: false, + stage: undefined, + latest: null, + }), + }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx new file mode 100644 index 0000000000..22cc7b2bc3 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PaymentStageCard, + LiveStageCard, + LicensePanel, +} from "@portal/components/procurement/ProcurementStages"; +import "@portal/views/Procurement.css"; + +/** + * The three small presentational pieces used inside the procurement takeover + * modal once a quote exists: the payment step, the live confirmation, and the + * licence panel shown alongside them. Grouped in one story file since none is + * substantial enough to warrant its own. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementStages", + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +// Subscription created: both invoice link and PDF download are offered. +export const Payment: Story = { + render: () => ( + + ), +}; + +// Invoice not yet issued: no action row renders. +export const PaymentPending: Story = { + render: () => , +}; + +// Deal is active: terminal confirmation card. +export const Live: Story = { + render: () => , +}; + +// Licence key with the offline add-on available. +export const License: Story = { + render: () => ( + {}} + /> + ), +}; + +// Offline add-on not purchased: only the copy action is offered. +export const LicenseOnlineOnly: Story = { + render: () => ( + {}} + /> + ), +}; + +// Offline licence file is being generated. +export const LicenseDownloading: Story = { + render: () => ( + {}} + /> + ), +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx new file mode 100644 index 0000000000..c5a9e69cac --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectionForm } from "@portal/components/sources/ConnectionForm"; +import { + CREATABLE_CONNECTION_TYPES, + emptyConnectionValues, +} from "@portal/components/sources/connectionTypes"; + +const s3Type = CREATABLE_CONNECTION_TYPES.find((type) => type.id === "s3")!; +const apiType = CREATABLE_CONNECTION_TYPES.find((type) => type.id === "api")!; +const purviewType = CREATABLE_CONNECTION_TYPES.find( + (type) => type.id === "purview", +)!; + +const meta = { + title: "Portal/Sources/ConnectionForm", + component: ConnectionForm, + parameters: { layout: "padded" }, + args: { + type: s3Type, + values: emptyConnectionValues(s3Type), + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +// The custom API preset exercises `visibleWhen`: picking a select value reveals extra fields +// (username/password for BASIC auth) that stay hidden for the other auth types. +export const ConditionalFieldsRevealed: Story = { + args: { + type: apiType, + values: { + ...emptyConnectionValues(apiType), + name: "Internal API", + authType: "BASIC", + }, + }, +}; + +// A partially filled preset, showing helper text and required-field markers together. +export const Filled: Story = { + args: { + type: purviewType, + values: { + ...emptyConnectionValues(purviewType), + name: "Purview", + tenantId: "00000000-0000-0000-0000-000000000000", + }, + }, +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx new file mode 100644 index 0000000000..e901a1ec55 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { IntegrationConfig } from "@portal/api/integrations"; +import { ConnectionModal } from "@portal/components/sources/ConnectionModal"; + +const EDIT_CONNECTION: IntegrationConfig = { + id: 1, + integrationType: "S3", + name: "Claims intake bucket", + scope: "TEAM", + ownerUserId: null, + ownerTeamId: 1, + enabled: true, + locked: false, + defaultAccess: "EXPLICIT_ONLY", + config: { + bucket: "acme-claims-inbox", + region: "eu-west-2", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "********", + }, + canManage: true, + createdAt: "2026-07-02T09:12:00", + updatedAt: "2026-07-02T09:12:00", +}; + +const meta: Meta = { + title: "Portal/Sources/ConnectionModal", + component: ConnectionModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + capabilities: { customApi: true }, + onClose: () => {}, + onSaved: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Creating a new connection starts on the type picker, grouped by category. */ +export const Picker: Story = {}; + +/** A pinned slot (e.g. the S3 field on a source) skips the picker and goes straight to the form. */ +export const FixedType: Story = { + args: { fixedTypeId: "s3" }, +}; + +/** Editing a stored connection reuses the form of the preset it was created from. */ +export const Edit: Story = { + args: { connection: EDIT_CONNECTION }, +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx new file mode 100644 index 0000000000..712ec1f4f6 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { ConnectionPicker } from "@portal/components/sources/ConnectionPicker"; + +const meta: Meta = { + title: "Portal/Sources/ConnectionPicker", + component: ConnectionPicker, + parameters: { layout: "padded" }, + args: { + value: "", + onChange: () => {}, + integrationType: "S3", + createTypeId: "s3", + }, +}; +export default meta; +type Story = StoryObj; + +/** Filtered to S3 connections; the global mock data has one bucket to pick. */ +export const Default: Story = {}; + +/** A preset-scoped slot (e.g. a Jira step): only Jira connections and the free-form + * custom API connection are offered, since the latter can point anywhere. */ +export const PresetScoped: Story = { + args: { + integrationType: "API", + createTypeId: "api", + presetId: "jira", + }, +}; + +/** Embedded inside a host modal (e.g. the source builder), which offers its own + * create surface instead of stacking the shared connection modal on top. */ +export const DelegatedCreate: Story = { + args: { + onCreateNew: () => {}, + }, +}; + +/** The connections list fails to load; the picker still renders empty with an + * inline error banner rather than blocking the field. */ +export const LoadError: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/integrations", () => + HttpResponse.json( + { detail: "Could not reach the backend" }, + { status: 500 }, + ), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx new file mode 100644 index 0000000000..9f7a87d62c --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectionTypePicker } from "@portal/components/sources/ConnectionTypePicker"; +import { + CREATABLE_CONNECTION_TYPES, + presetConnectionTypes, +} from "@portal/components/sources/connectionTypes"; + +const meta = { + title: "Portal/Sources/ConnectionTypePicker", + component: ConnectionTypePicker, + parameters: { layout: "padded" }, + args: { + types: presetConnectionTypes(), + onPick: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const IncludingCustomApi: Story = { + args: { types: CREATABLE_CONNECTION_TYPES }, +}; + +export const NoResults: Story = { + args: { types: [] }, +}; diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx new file mode 100644 index 0000000000..2cca577460 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; + +const meta = { + title: "Portal/Sources/S3ConnectionPicker", + component: S3ConnectionPicker, + parameters: { layout: "padded" }, + args: { + value: "", + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Selected: Story = { args: { value: "1" } }; diff --git a/frontend/editor/src/portal/components/sources/SourceModal.stories.tsx b/frontend/editor/src/portal/components/sources/SourceModal.stories.tsx new file mode 100644 index 0000000000..b71d689442 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/SourceModal.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SourceModal } from "@portal/components/sources/SourceModal"; + +// SourceModal calls useQueryClient() to invalidate source queries after a save; +// the global preview doesn't set up react-query, so provide a client here. +const queryClient = new QueryClient(); + +const meta: Meta = { + title: "Portal/Sources/SourceModal", + component: SourceModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + onClose: () => {}, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** No `sourceId`: the connector catalogue (including greyed-out coming-soon entries). */ +export const ChooseType: Story = {}; + +/** A `folder` source: the configure stage, seeded from the mocked record. */ +export const EditFolder: Story = { + args: { sourceId: "src-claims" }, +}; + +/** A `webhook` source: configure stage plus the delivery-URL row (secret stays masked). */ +export const EditWebhook: Story = { + args: { sourceId: "src-webhook" }, +}; diff --git a/frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx b/frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx new file mode 100644 index 0000000000..ea136cb143 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SourceTypeIcon } from "@portal/components/sources/SourceTypeIcon"; + +const meta: Meta = { + title: "Portal/Sources/SourceTypeIcon", + component: SourceTypeIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Folder: Story = { + args: { type: "folder" }, +}; + +export const S3: Story = { + args: { type: "s3" }, +}; + +export const Editor: Story = { + args: { type: "editor" }, +}; + +/** Unknown source types fall back to the neutral document glyph. */ +export const Unknown: Story = { + args: { type: "unrecognised" }, +}; diff --git a/frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx b/frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx new file mode 100644 index 0000000000..fa5d44e8bd --- /dev/null +++ b/frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PendingInvitations } from "@portal/components/users/PendingInvitations"; +import type { PendingInvitation } from "@portal/api/users"; + +const INVITATIONS: PendingInvitation[] = [ + { + id: 1, + email: "priya@stirlingpdf.com", + invitedBy: "tom@stirlingpdf.com", + expiresAt: new Date(Date.now() + 3 * 86400000).toISOString(), + }, + { + id: 2, + email: "lars@stirlingpdf.com", + invitedBy: "dana@stirlingpdf.com", + expiresAt: new Date(Date.now() + 86400000).toISOString(), + }, + { + id: 3, + email: "legal@meridian-partners.com", + }, +]; + +const meta: Meta = { + title: "Portal/Users/PendingInvitations", + component: PendingInvitations, + parameters: { layout: "padded" }, + args: { + invitations: INVITATIONS, + onCancel: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** A mix of invites: one expiring today, one expiring soon, one with no expiry. */ +export const Default: Story = {}; + +/** Single invite, expiring today. */ +export const ExpiresToday: Story = { + args: { + invitations: [ + { + id: 1, + email: "priya@stirlingpdf.com", + invitedBy: "tom@stirlingpdf.com", + expiresAt: new Date(Date.now() + 3 * 3600000).toISOString(), + }, + ], + }, +}; + +/** No pending invites: header still renders with a zero count. */ +export const Empty: Story = { + args: { invitations: [] }, +}; diff --git a/frontend/editor/src/portal/views/Integrations.stories.tsx b/frontend/editor/src/portal/views/Integrations.stories.tsx new file mode 100644 index 0000000000..f25c355c53 --- /dev/null +++ b/frontend/editor/src/portal/views/Integrations.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { Integrations } from "@portal/views/Integrations"; + +const meta: Meta = { + title: "Portal/Views/Integrations", + component: Integrations, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Seeded mock data: connected vendors (expandable groups) plus the available catalogue. */ +export const Default: Story = {}; + +/** + * No stored connections yet. Only the "Available" catalogue and coming-soon + * source connectors are left to show. + */ +export const NoConnections: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/integrations", () => HttpResponse.json([])), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx new file mode 100644 index 0000000000..95d2987e52 --- /dev/null +++ b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Route, Routes } from "react-router-dom"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { PipelineBuilder } from "@portal/views/PipelineBuilder"; + +/** + * Renders the builder at a specific path so its `:id` route param resolves the + * same way it does in the app, without nesting a second Router inside the + * preview's MemoryRouter. + */ +function withRoute(path: string) { + return function RouteDecorator(Story: () => React.ReactElement) { + return ( + + } /> + } /> + + ); + }; +} + +const meta: Meta = { + title: "Portal/Views/PipelineBuilder", + component: PipelineBuilder, + parameters: { layout: "padded" }, + // The builder reads the tool registry (for step labels + settings UIs), so + // it needs this provider to render at all. + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** A new, unsaved pipeline: empty operation chain, no sources selected yet. */ +export const Default: Story = { + decorators: [withRoute("/processor/pipelines/new")], +}; + +/** Editing a seeded pipeline: pre-filled name, sources, trigger and steps. */ +export const Edit: Story = { + decorators: [withRoute("/processor/pipelines/plc-redaction")], +}; diff --git a/frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx b/frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx new file mode 100644 index 0000000000..b5abb76c96 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AuthShell } from "@app/auth/ui/AuthShell"; + +/** + * The login card shell shared by the editor and the portal: a centered card + * that expands to two columns (form + right panel) on wide/tall viewports. + */ +const meta = { + title: "Auth/Auth Shell", + component: AuthShell, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
+

Sign in

+

Enter your credentials to continue.

+
+ ), + }, +}; + +export const WithRightPanel: Story = { + args: { + children: ( +
+
+

Sign in

+

Enter your credentials to continue.

+
+
+

Why Stirling PDF?

+

Fast, secure, self-hostable PDF tooling.

+
+
+ ), + }, +}; + +export const WithFooter: Story = { + args: { + children: ( +
+

Sign in

+

Enter your credentials to continue.

+
+ ), + footer: ( +
+ © Stirling PDF +
+ ), + }, +}; diff --git a/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx new file mode 100644 index 0000000000..84e085725d --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import EmailPasswordForm from "@app/auth/ui/EmailPasswordForm"; +import "@app/auth/ui/auth.css"; + +/** + * The email/password/MFA fields shared by the login and signup auth forms + */ +const meta: Meta = { + title: "Auth/Email Password Form", + component: EmailPasswordForm, + parameters: { layout: "centered" }, + render: (args) => { + const [email, setEmail] = useState(args.email); + const [password, setPassword] = useState(args.password); + const [mfaCode, setMfaCode] = useState(args.mfaCode ?? ""); + return ( +
+ +
+ ); + }, + args: { + email: "", + password: "", + setEmail: () => {}, + setPassword: () => {}, + onSubmit: () => {}, + isSubmitting: false, + submitButtonText: "Sign in", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithMfa: Story = { + args: { + showMfaField: true, + requiresMfa: true, + email: "user@example.com", + password: "hunter2", + }, +}; + +export const WithErrors: Story = { + args: { + fieldErrors: { + email: "Enter a valid email address.", + password: "Password is required.", + }, + }, +}; + +export const Submitting: Story = { + args: { + email: "user@example.com", + password: "hunter2", + isSubmitting: true, + }, +}; diff --git a/frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx b/frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx new file mode 100644 index 0000000000..9647417990 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ErrorMessage from "@app/auth/ui/ErrorMessage"; +import "@app/auth/ui/auth.css"; + +/** + * The inline error banner the auth forms render when a submission fails + */ +const meta: Meta = { + title: "Auth/Error Message", + component: ErrorMessage, + parameters: { layout: "centered" }, + args: { + error: "Invalid email or password.", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const LongMessage: Story = { + args: { + error: + "We couldn't sign you in because your account has been temporarily locked after too many failed attempts. Please try again in a few minutes.", + }, +}; + +export const Empty: Story = { + args: { + error: null, + }, +}; diff --git a/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx b/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx new file mode 100644 index 0000000000..270c60c3aa --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; +import type { SpringLoginState } from "@app/auth/ui/useSpringLogin"; +import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; +import "@app/auth/ui/auth.css"; + +/** Builds a mock useSpringLogin() state; stories override just the fields they need. */ +function mockState( + overrides: Partial = {}, +): SpringLoginState { + return { + email: "", + setEmail: () => {}, + password: "", + setPassword: () => {}, + mfaCode: "", + setMfaCode: () => {}, + requiresMfa: false, + error: null, + setError: () => {}, + isSubmitting: false, + providers: [], + loginMethod: "all", + isUserPassAllowed: true, + hasProviders: false, + signInWithEmail: async () => {}, + signInWithProvider: async () => {}, + ...overrides, + }; +} + +/** + * The shared Spring login form body: logo, error, OAuth buttons, divider, and + * the email/password form. Rendered by both the editor and the portal inside + * their own auth shells. + */ +const meta = { + title: "Auth/Spring Login Form", + component: SpringLoginForm, + parameters: { layout: "centered" }, + args: { + state: mockState(), + logoSrc: loginHeader, + logoAlt: "Stirling PDF", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const WithOAuthProviders: Story = { + args: { + state: mockState({ + providers: ["google", "github"], + hasProviders: true, + }), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithError: Story = { + args: { + state: mockState({ + email: "user@example.com", + error: "Invalid email or password.", + }), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const RequiresMfa: Story = { + args: { + state: mockState({ + email: "user@example.com", + requiresMfa: true, + }), + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx b/frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx new file mode 100644 index 0000000000..9f24d2f971 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm"; +import type { SupabaseLoginState } from "@app/auth/ui/useSupabaseLogin"; +import "@app/auth/ui/auth.css"; + +/** + * Login form combining SSO buttons and email/password, driven by a + * {@link SupabaseLoginState} from useSupabaseLogin. + */ +const meta: Meta = { + title: "Auth/Supabase Login Form", + component: SupabaseLoginForm, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +function makeState( + overrides: Partial = {}, +): SupabaseLoginState { + return { + email: "", + setEmail: () => {}, + password: "", + setPassword: () => {}, + error: null, + setError: () => {}, + isSubmitting: false, + providers: ["google", "github"], + hasProviders: true, + signInWithEmail: async () => {}, + signInWithProvider: async () => {}, + ...overrides, + }; +} + +export const Default: Story = { + args: { + state: makeState(), + }, +}; + +export const NoProviders: Story = { + args: { + state: makeState({ providers: [], hasProviders: false }), + }, +}; + +export const WithError: Story = { + args: { + state: makeState({ + email: "user@example.com", + error: "Invalid email or password.", + }), + }, +}; + +export const Submitting: Story = { + args: { + state: makeState({ + email: "user@example.com", + password: "hunter2", + isSubmitting: true, + }), + }, +}; diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx new file mode 100644 index 0000000000..2355f88810 --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated"; + +/** + * Animated Stirling logo mark, used as a "thinking" indicator in the chat panel. + */ +const meta: Meta = { + title: "Agents/StirlingLogoAnimated", + component: StirlingLogoAnimated, + parameters: { layout: "padded" }, + args: { + size: 20, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Large: Story = { + args: { size: 64 }, +}; diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx new file mode 100644 index 0000000000..3192b69303 --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; + +const meta = { + title: "Agents/StirlingLogoOutline", + component: StirlingLogoOutline, + parameters: { layout: "centered" }, + args: { + size: 20, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Large: Story = { + args: { + size: 64, + }, +}; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx new file mode 100644 index 0000000000..12c764dd4f --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyPiiField } from "@app/components/policies/PolicyPiiField"; +import { PII_PRESETS } from "@app/data/policyDefinitions"; +import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; + +const BASE_PARAMETERS: RedactParameters = { + mode: "automatic", + wordsToRedact: [], + useRegex: false, + wholeWordSearch: false, + redactColor: "#000000", + customPadding: 0.1, + convertPDFToImage: true, +}; + +// The field only owns the preset selection; the story holds parameters state +// so selecting/clearing presets is live. +function Harness({ + initialParameters, + disabled, +}: { + initialParameters: RedactParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + return ( +
+ +
+ ); +} + +const meta = { + title: "Policies/PolicyPiiField", + component: Harness, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No presets selected yet. */ +export const Default: Story = { + args: { + initialParameters: BASE_PARAMETERS, + }, +}; + +/** SSN and credit card presets pre-selected. */ +export const WithSelection: Story = { + args: { + initialParameters: { + ...BASE_PARAMETERS, + useRegex: true, + wordsToRedact: [PII_PRESETS[0].pattern, PII_PRESETS[1].pattern], + }, + }, +}; + +/** Disabled state. */ +export const Disabled: Story = { + args: { + initialParameters: BASE_PARAMETERS, + disabled: true, + }, +}; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx new file mode 100644 index 0000000000..2befe6971b --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; +import { PII_PRESETS } from "@app/data/policyDefinitions"; +import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; + +const BASE_PARAMETERS: RedactParameters = { + mode: "automatic", + wordsToRedact: [], + useRegex: true, + wholeWordSearch: false, + redactColor: "#000000", + customPadding: 0.1, + convertPDFToImage: true, +}; + +// onChange must be wired up for real, since the component normalises its +// params via an onChange call on mount. +function Harness({ + initialParameters, + disabled, +}: { + initialParameters: RedactParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + return ( +
+ +
+ ); +} + +const meta = { + title: "Policies/PolicyRedactConfig", + component: Harness, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No PII presets selected yet. */ +export const Default: Story = { + args: { + initialParameters: BASE_PARAMETERS, + }, +}; + +/** SSN and credit card presets pre-selected. */ +export const WithSelection: Story = { + args: { + initialParameters: { + ...BASE_PARAMETERS, + wordsToRedact: [PII_PRESETS[0].pattern, PII_PRESETS[1].pattern], + }, + }, +}; + +/** Locked, e.g. when the policy step is view-only. */ +export const Disabled: Story = { + args: { + initialParameters: BASE_PARAMETERS, + disabled: true, + }, +}; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx new file mode 100644 index 0000000000..e990de30ea --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; +import { + defaultParameters, + type AddWatermarkParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +// The parent policy form owns the parameters; the story holds that state so edits are live. +function Harness({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + return ( +
+ +
+ ); +} + +const meta: Meta = { + title: "Policies/PolicyWatermarkConfig", + component: Harness, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Text-only watermark settings for a policy, with flatten forced on. */ +export const Default: Story = {}; + +/** All fields locked for read-only policy review. */ +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx new file mode 100644 index 0000000000..613f8cacc7 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; +import type { User } from "@app/services/userManagementService"; + +const USER: User = { + id: 1, + username: "alice@example.com", + roleName: "adminUserSettings.admin", + enabled: true, +}; + +const meta = { + title: "Shared/ChangeUserPasswordModal", + component: ChangeUserPasswordModal, + args: { + opened: true, + user: USER, + mailEnabled: true, + onClose: () => {}, + onSuccess: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When SMTP notifications aren't configured, the email checkboxes are disabled. */ +export const MailDisabled: Story = { + args: { mailEnabled: false }, +}; + +/** A username that isn't a valid email address also disables the email checkboxes. */ +export const NonEmailUsername: Story = { + args: { user: { ...USER, username: "alice" } }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx b/frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx new file mode 100644 index 0000000000..fe677d26d6 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DividerWithText from "@app/components/shared/DividerWithText"; + +/** + * Horizontal rule, optionally with a centered text label (e.g. "or"). + */ +const meta = { + title: "Shared/DividerWithText", + component: DividerWithText, + parameters: { layout: "padded" }, + args: { + text: "or", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** With no `text`, renders a plain horizontal rule. */ +export const PlainRule: Story = { + args: { text: undefined }, +}; + +export const Subcategory: Story = { + args: { + text: "Advanced options", + variant: "subcategory", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx b/frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx new file mode 100644 index 0000000000..f860767b43 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ManageBillingButton } from "@app/components/shared/ManageBillingButton"; + +/** + * Button that opens the Stripe billing portal for the current license. + */ +const meta = { + title: "Shared/ManageBillingButton", + component: ManageBillingButton, + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Custom return URL to redirect back to once the billing portal session ends. */ +export const CustomReturnUrl: Story = { + args: { + returnUrl: "https://stirlingpdf.com/account", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx b/frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx new file mode 100644 index 0000000000..ec61584fa6 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UpdateSeatsModal from "@app/components/shared/UpdateSeatsModal"; + +/** + * Modal for adjusting enterprise seat count, redirecting to the Stripe billing portal on confirm. + */ +const meta = { + title: "Shared/UpdateSeatsModal", + component: UpdateSeatsModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + currentSeats: 10, + minimumSeats: 5, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Minimum seats equal to current seats, e.g. a fully-utilized license. */ +export const AtMinimum: Story = { + args: { + currentSeats: 5, + minimumSeats: 5, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx new file mode 100644 index 0000000000..2ff341d6f9 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner"; + +/** + * Banner explaining that an enterprise-only feature is running in demo mode. + */ +const meta: Meta = { + title: "Config/EnterpriseRequiredBanner", + component: EnterpriseRequiredBanner, + parameters: { layout: "padded" }, + args: { + show: true, + featureName: "Audit Logs", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When `show` is false the banner renders nothing. */ +export const Hidden: Story = { + args: { show: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx new file mode 100644 index 0000000000..2c8fb2aa3b --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ThemeProvider } from "@app/components/shared/ThemeProvider"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Wraps GeneralSection, which reads theme/tool-panel preferences via +// usePreferences()/useTheme() and server config via useAppConfig() — none of +// which the Storybook preview's own provider tree supplies (those are the +// portal contexts), so wrap here. AppConfigProvider uses autoFetch off so +// stories render a fixed config instead of hitting the API. The login-landing +// control itself stays hidden (loginLandingMode() defaults to non-"dynamic" +// in this build), so only the General section is visible. +const meta = { + title: "Config/GeneralWithLoginLanding", + component: GeneralWithLoginLanding, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Login enabled, no backend version known yet — Software Updates section and admin banner stay hidden. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Backend version known — shows the Software Updates section with version info. */ +export const WithBackendVersion: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Login disabled — the "For System Administrators" banner shows, prompting the env vars to enable it. */ +export const AdminBanner: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx new file mode 100644 index 0000000000..a5d57a1a73 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OverviewHeader } from "@app/components/shared/config/OverviewHeader"; +import { AuthContext } from "@app/auth/context"; +import type { AuthContextValue } from "@app/auth/types"; + +/** + * Header for the application configuration page: title, description, and + * (when signed in) the current user's email plus a log-out button. + */ +const meta: Meta = { + title: "Config/OverviewHeader", + component: OverviewHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const signedInAuth: AuthContextValue = { + session: null, + user: { + id: "user-1", + email: "jane.doe@example.com", + username: "jane.doe", + role: "ROLE_USER", + }, + displayName: "jane.doe", + isAnonymous: false, + isAdmin: false, + portalAccess: false, + role: "ROLE_USER", + loading: false, + error: null, + signOut: async () => {}, + refreshSession: async () => {}, +}; + +/** Signed out: no email line, no log-out button. */ +export const Default: Story = {}; + +/** Signed in: shows the current user's email and a log-out button. */ +export const SignedIn: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx new file mode 100644 index 0000000000..213df8951c --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AccountSection from "@app/components/shared/config/configSections/AccountSection"; +import { AuthContext } from "@app/auth/context"; +import type { AuthContextValue } from "@app/auth/types"; +import { accountService } from "@app/services/accountService"; + +/** + * Account settings panel shown inside the app config modal: password / + * username management and two-factor authentication setup. + */ +const meta: Meta = { + title: "Config/AccountSection", + component: AccountSection, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +// AccountSection gets its user from useAuth() and its MFA/account data from +// accountService on mount rather than via props, so stories set those directly. +const standardAuth: AuthContextValue = { + session: null, + user: { + id: "user-1", + email: "jane.doe@example.com", + username: "jane.doe", + role: "ROLE_USER", + }, + displayName: "jane.doe", + isAnonymous: false, + isAdmin: false, + portalAccess: false, + role: "ROLE_USER", + loading: false, + error: null, + signOut: async () => {}, + refreshSession: async () => {}, +}; + +const ssoAuth: AuthContextValue = { + ...standardAuth, + user: { + ...standardAuth.user!, + email: "jane.doe@acme-corp.com", + authenticationType: "sso", + }, +}; + +accountService.getAccountData = async () => ({ + username: "jane.doe", + role: "ROLE_USER", + settings: "{}", + changeCredsFlag: false, + oAuth2Login: false, + saml2Login: false, + mfaEnabled: false, +}); + +/** Standard account with password/username management and 2FA available to enable. */ +export const Default: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +/** Two-factor authentication already enabled: shows the disable action instead. */ +export const MfaEnabled: Story = { + decorators: [ + (Story) => { + accountService.getAccountData = async () => ({ + username: "jane.doe", + role: "ROLE_USER", + settings: "{}", + changeCredsFlag: false, + oAuth2Login: false, + saml2Login: false, + mfaEnabled: true, + }); + return ( + + + + ); + }, + ], +}; + +/** SSO-managed account: password/username changes and 2FA are hidden behind identity-provider notices. */ +export const SsoUser: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx new file mode 100644 index 0000000000..c0ba0c34cb --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import AdminAiDocumentsSection from "@app/components/shared/config/configSections/AdminAiDocumentsSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import { + AiEngineApiResponse, + MASKED_SECRET, +} from "@app/components/shared/config/configSections/aiEngineSettings"; + +// AdminAiDocumentsSection takes no props: it fetches the shared aiEngine settings +// blob via apiClient on mount and reads login state through useAppConfig(), so +// each variant mocks the GET response and wraps in a fixed AppConfigProvider +// (autoFetch off, so stories never hit the real API). It also tracks dirty state +// through useSettingsDirty()/useUnsavedChanges(), which isn't part of Storybook's +// global provider stack, so that needs wrapping here too or the hook throws. +const baseSettings: AiEngineApiResponse = { + rag: { + embeddingProvider: "voyageai", + embeddingModel: "voyage-4", + embeddingApiKey: MASKED_SECRET, + topK: 5, + maxSearches: 3, + }, +}; + +const handlerFor = (data: AiEngineApiResponse) => + http.get("*/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(data), + ); + +const meta = { + title: "Config/AdminAiDocumentsSection", + component: AdminAiDocumentsSection, + parameters: { + layout: "padded", + msw: { + handlers: [ + handlerFor(baseSettings), + http.put("*/api/v1/admin/settings", () => HttpResponse.json({})), + http.put("*/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json({}), + ), + ], + }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** VoyageAI embeddings with a saved (masked) API key and default retrieval settings. */ +export const Default: Story = {}; + +/** Ollama needs a base URL instead of an API key: the key field is hidden. */ +export const OllamaProvider: Story = { + parameters: { + msw: { + handlers: [ + handlerFor({ + rag: { + embeddingProvider: "ollama", + embeddingModel: "nomic-embed-text", + embeddingBaseUrl: "http://ollama:11434/v1", + topK: 5, + maxSearches: 3, + }, + }), + ], + }, + }, +}; + +/** While the settings request is in flight, a centered loader replaces the form. */ +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/admin/settings/section/aiEngine", async () => { + await delay("infinite"); + return HttpResponse.json(baseSettings); + }), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx new file mode 100644 index 0000000000..4061f52fd8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import AdminAiGeneralSection from "@app/components/shared/config/configSections/AdminAiGeneralSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import type { AiEngineApiResponse } from "@app/components/shared/config/configSections/aiEngineSettings"; + +// AdminAiGeneralSection fetches the shared aiEngine settings blob on mount via +// apiClient (no service seam to stub), so each variant supplies its own MSW +// handler for GET .../admin/settings/section/aiEngine. Saves PUT back to the +// same section plus the generic /admin/settings delta endpoint; the smoke +// test never clicks Save, so those aren't stubbed here. +const baseSettings: AiEngineApiResponse = { + enabled: true, + url: "http://stirling-pdf-engine:5001", + timeoutSeconds: 30, + longRunningTimeoutSeconds: 120, + streamTimeoutSeconds: 60, + features: { + chat: true, + documentQuestions: true, + createPdf: false, + mathAuditor: false, + pdfComment: false, + classify: true, + }, +}; + +const handlerFor = (data: AiEngineApiResponse) => + http.get("*/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(data), + ); + +const meta = { + title: "Config/AdminAiGeneralSection", + component: AdminAiGeneralSection, + parameters: { + layout: "padded", + msw: { handlers: [handlerFor(baseSettings)] }, + }, + // useSettingsDirty() reads UnsavedChangesContext and the section reads login + // state via useAppConfig(), neither of which the global preview supplies. + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** AI enabled, connected to an engine, with a mix of capabilities toggled on. */ +export const Default: Story = {}; + +/** Master switch off: the URL, timeouts, and capability switches are all disabled. */ +export const Disabled: Story = { + parameters: { + msw: { handlers: [handlerFor({ ...baseSettings, enabled: false })] }, + }, +}; + +/** The engine URL was changed and saved but awaits a restart to take effect. */ +export const PendingRestart: Story = { + parameters: { + msw: { + handlers: [ + handlerFor({ + ...baseSettings, + _pending: { url: "http://stirling-pdf-engine:6001" }, + }), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx new file mode 100644 index 0000000000..3caae46a25 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import AdminAiLimitsSection from "@app/components/shared/config/configSections/AdminAiLimitsSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import type { AiEngineApiResponse } from "@app/components/shared/config/configSections/aiEngineSettings"; + +// AdminAiLimitsSection takes no props: it fetches the shared aiEngine settings +// blob via apiClient on mount and reads login state through useAppConfig(), so +// each variant mocks the GET response and wraps in a fixed AppConfigProvider +// (autoFetch off, so stories never hit the real API). +const baseSettings: AiEngineApiResponse = { + limits: { + maxPages: 50, + maxCharacters: 200000, + modelMaxConcurrency: 4, + }, +}; + +const meta = { + title: "Config/AdminAiLimitsSection", + component: AdminAiLimitsSection, + parameters: { + layout: "padded", + msw: { + handlers: [ + http.get("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(baseSettings), + ), + http.put("/api/v1/admin/settings", () => HttpResponse.json({})), + http.put("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json({}), + ), + ], + }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Loaded limits with login enabled, so the sticky footer can appear once edited. */ +export const Default: Story = {}; + +/** While the settings request is in flight, a centered loader replaces the form. */ +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/admin/settings/section/aiEngine", async () => { + await delay("infinite"); + return HttpResponse.json(baseSettings); + }), + ], + }, + }, +}; + +/** A pending (restart-required) change on max pages shows the pending badge next to its label. */ +export const PendingChange: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json({ + ...baseSettings, + _pending: { limits: { maxPages: 100 } }, + } satisfies AiEngineApiResponse), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx new file mode 100644 index 0000000000..2a1a5f96eb --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx @@ -0,0 +1,106 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import AdminAiModelsSection from "@app/components/shared/config/configSections/AdminAiModelsSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import { + AiEngineApiResponse, + MASKED_SECRET, +} from "@app/components/shared/config/configSections/aiEngineSettings"; + +/** + * AdminAiModelsSection fetches its settings via apiClient on mount (through + * useAdminSettings) rather than taking props, so each story mocks the GET + * with MSW. It also reads useAppConfig()/useSettingsDirty(), which throw + * without their providers, so every story wraps in both. + */ +function withProviders(Story: () => React.JSX.Element) { + return ( + + + + + + ); +} + +function aiEngineHandler(response: AiEngineApiResponse) { + return http.get("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(response), + ); +} + +const meta = { + title: "Config/AdminAiModelsSection", + component: AdminAiModelsSection, + parameters: { layout: "padded" }, + decorators: [withProviders], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Anthropic provider with a saved (masked) API key and no base URL field. */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + aiEngineHandler({ + models: { + provider: "anthropic", + smartModel: "claude-sonnet-5", + fastModel: "claude-haiku-4-5", + smartMaxTokens: 8192, + fastMaxTokens: 2048, + apiKey: MASKED_SECRET, + }, + }), + ], + }, + }, +}; + +/** Ollama provider: no API key field, but a base URL input and an SSRF warning banner instead. */ +export const OllamaProvider: Story = { + parameters: { + msw: { + handlers: [ + aiEngineHandler({ + models: { + provider: "ollama", + smartModel: "llama3.1", + fastModel: "qwen2.5", + smartMaxTokens: 4096, + fastMaxTokens: 1024, + baseUrl: "http://ollama:11434/v1", + }, + }), + ], + }, + }, +}; + +/** Some fields are pending a config-management rollout — each shows a PendingBadge next to its label. */ +export const PendingChanges: Story = { + parameters: { + msw: { + handlers: [ + aiEngineHandler({ + models: { + provider: "anthropic", + smartModel: "claude-sonnet-5", + fastModel: "claude-haiku-4-5", + smartMaxTokens: 8192, + fastMaxTokens: 2048, + apiKey: MASKED_SECRET, + }, + _pending: { + models: { + smartModel: "claude-opus-4-8", + }, + }, + }), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx new file mode 100644 index 0000000000..2959cbb033 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import auditService from "@app/services/auditService"; + +// AdminAuditSection reads its config through useAppConfig()/useLoginRequired() +// rather than taking props, so each variant wraps it in an AppConfigProvider +// with a fixed initialConfig (autoFetch off, so stories never hit the API). +// Without login enabled + an ENTERPRISE license the component falls back to +// hardcoded demo data on its own; the "fully enabled" and "disabled" variants +// below supply that config, which makes the component call +// auditService.getSystemStatus() on mount, so each stubs that call directly +// (the module exports a plain object, the seam the component itself calls +// through) inside its own decorator so the two variants don't depend on +// render order. +const meta = { + title: "Config/AdminAuditSection", + component: AdminAuditSection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** + * No login configured yet: falls back to demo data, shows the enterprise + * banner, and the dashboard tabs are disabled. + */ +export const Default: Story = {}; + +/** Login is disabled entirely: both the login and enterprise banners show. */ +export const LoginDisabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +/** + * Login enabled with an ENTERPRISE license: both banners are hidden, the + * dashboard tabs are active, and the fetched (mocked) status backs the page + * instead of the built-in demo data. + */ +export const Enabled: Story = { + decorators: [ + (Story) => { + auditService.getSystemStatus = async () => ({ + enabled: true, + level: "INFO", + retentionDays: 90, + totalEvents: 15234, + pdfMetadataEnabled: true, + captureFileHash: true, + capturePdfAuthor: true, + captureOperationResults: true, + }); + return ( + + + + ); + }, + ], +}; + +/** Audit logging itself is turned off server-side: shows the disabled notice instead of the tabs. */ +export const AuditLoggingDisabled: Story = { + decorators: [ + (Story) => { + auditService.getSystemStatus = async () => ({ + enabled: false, + level: "OFF", + retentionDays: 0, + totalEvents: 0, + pdfMetadataEnabled: false, + captureFileHash: false, + capturePdfAuthor: false, + captureOperationResults: false, + }); + return ( + + + + ); + }, + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx new file mode 100644 index 0000000000..140445fe0e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx @@ -0,0 +1,95 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import AdminFolderAccessSection from "@app/components/shared/config/configSections/AdminFolderAccessSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; + +/** + * AdminFolderAccessSection fetches its settings via apiClient on mount (through + * useAdminSettings, plus its own implied-folder-roots request) rather than + * taking props, so each story mocks both GETs with MSW. It also reads + * useAppConfig()/useSettingsDirty(), which throw without their providers, so + * every story wraps in both. + */ +function withProviders(enableLogin: boolean) { + return function Decorator(Story: () => React.JSX.Element) { + return ( + + + + + + ); + }; +} + +function impliedRootsHandler( + roots: { path: string; reason: string }[] = [ + { path: "/srv/stirling/storage", reason: "serverStorage" }, + { path: "/srv/stirling/watched/invoices", reason: "watchedFolder" }, + ], +) { + return http.get("/api/v1/admin/settings/policies/implied-folder-roots", () => + HttpResponse.json(roots), + ); +} + +function allowedRootsHandler(allowedFolderRoots: string[]) { + return http.get("/api/v1/admin/settings/section/policies", () => + HttpResponse.json({ allowedFolderRoots }), + ); +} + +const meta = { + title: "Config/AdminFolderAccessSection", + component: AdminFolderAccessSection, + parameters: { + layout: "padded", + msw: { + handlers: [ + http.put("/api/v1/admin/settings/section/policies", () => + HttpResponse.json({}), + ), + ], + }, + }, + decorators: [withProviders(true)], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Two allowed folder roots configured, plus the always-allowed implied roots. */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + allowedRootsHandler(["/data/inbox", "/data/exports"]), + impliedRootsHandler(), + ], + }, + }, +}; + +/** No folder roots configured yet: folder sources and outputs are disabled. */ +export const NoRootsConfigured: Story = { + parameters: { + msw: { + handlers: [allowedRootsHandler([]), impliedRootsHandler([])], + }, + }, +}; + +/** Login mode disabled: the section is read-only and shows the login-required banner. */ +export const LoginDisabled: Story = { + decorators: [withProviders(false)], + parameters: { + msw: { + handlers: [allowedRootsHandler([]), impliedRootsHandler([])], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx new file mode 100644 index 0000000000..3d7e113861 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; + +/** + * Config section showing the user's API key, with copy/refresh actions and + * links to the API docs. Fetches the key from the backend on mount, so the + * different visual states are driven by mocking `get-api-key` via MSW rather + * than props (the component takes none). + */ +const meta: Meta = { + title: "Config/ConfigSections/ApiKeys", + component: ApiKeys, + parameters: { + layout: "padded", + msw: { + handlers: [ + http.post("/api/v1/user/get-api-key", () => + HttpResponse.json("demo-storybook-api-key-000000000000"), + ), + ], + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** While the key request is in flight, the card shows a skeleton placeholder. */ +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.post("/api/v1/user/get-api-key", async () => { + await delay("infinite"); + return HttpResponse.json(null); + }), + ], + }, + }, +}; + +/** When the key request fails, an error banner with a retry link is shown. */ +export const LoadError: Story = { + parameters: { + msw: { + handlers: [ + http.post("/api/v1/user/get-api-key", () => + HttpResponse.json(null, { status: 500 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx new file mode 100644 index 0000000000..d60844a709 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LoginAgreementEditor from "@app/components/shared/config/configSections/LoginAgreementEditor"; + +/** + * Per-language editor for the login agreement markdown shown on the login screen. + */ +const meta: Meta = { + title: "Config/ConfigSections/LoginAgreementEditor", + component: LoginAgreementEditor, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Inputs are locked while a parent action (e.g. saving elsewhere) is in progress. */ +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx new file mode 100644 index 0000000000..952de00331 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TeamDetailsSection from "@app/components/shared/config/configSections/TeamDetailsSection"; +import { teamService } from "@app/services/teamService"; +import { userManagementService } from "@app/services/userManagementService"; + +// TeamDetailsSection fetches through these services on mount rather than +// taking data as props, so the story stubs the service methods directly +// instead of passing mock data in. +teamService.getTeamDetails = async () => ({ + team: { id: 1, name: "Engineering" }, + teamUsers: [ + { + id: 1, + username: "alice", + email: "alice@example.com", + roleName: "adminUserSettings.admin", + rolesAsString: "ROLE_ADMIN", + enabled: true, + }, + { + id: 2, + username: "bob", + email: "bob@example.com", + roleName: "adminUserSettings.user", + rolesAsString: "ROLE_USER", + enabled: true, + }, + { + id: 3, + username: "carol", + roleName: "adminUserSettings.user", + rolesAsString: "ROLE_USER", + enabled: false, + }, + ], + availableUsers: [ + { + id: 4, + username: "dave", + roleName: "adminUserSettings.user", + rolesAsString: "ROLE_USER", + enabled: true, + }, + ], + userLastRequest: { alice: Date.now() }, +}); + +teamService.getTeams = async () => [ + { id: 1, name: "Engineering" }, + { id: 2, name: "Default" }, +]; + +userManagementService.getUsers = async () => ({ + users: [], + userSessions: {}, + userLastRequest: {}, + totalUsers: 4, + activeUsers: 3, + disabledUsers: 1, + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 10, + premiumEnabled: true, + mailEnabled: true, + lockedUsers: [], +}); + +const meta = { + title: "Config/TeamDetailsSection", + component: TeamDetailsSection, + parameters: { layout: "padded" }, + args: { + teamId: 1, + onBack: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** A team with a mix of active and disabled members. */ +export const Default: Story = {}; + +/** A team with no members yet shows the empty-state row. */ +export const Empty: Story = { + decorators: [ + (Story) => { + teamService.getTeamDetails = async () => ({ + team: { id: 2, name: "Design" }, + teamUsers: [], + availableUsers: [], + userLastRequest: {}, + }); + return ; + }, + ], + args: { teamId: 2 }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx new file mode 100644 index 0000000000..7f88ffca89 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TeamsSection from "@app/components/shared/config/configSections/TeamsSection"; +import { teamService } from "@app/services/teamService"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Fetches through teamService on mount rather than taking data as props, so +// the story stubs it directly (the module exports a plain object, so this is +// the same seam the component itself calls through). +teamService.getTeams = async () => [ + { id: 1, name: "Internal", userCount: 1 }, + { id: 2, name: "Engineering", userCount: 8 }, + { id: 3, name: "Marketing", userCount: 3 }, + { + id: 4, + name: "Customer Success and Onboarding Specialists", + userCount: 5, + }, +]; + +/** + * Admin panel for creating teams, renaming them, and moving members between + * them. + */ +const meta = { + title: "Config/ConfigSections/TeamsSection", + component: TeamsSection, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** A handful of teams, including the non-deletable "Internal" system team. */ +export const Default: Story = {}; + +/** No teams returned yet — shows the "no teams found" empty state row. */ +export const Empty: Story = { + decorators: [ + (Story) => { + teamService.getTeams = async () => []; + return ; + }, + ], +}; + +/** + * Login disabled — falls back to hardcoded example teams and shows the + * login-required banner, with every row action disabled. + */ +export const LoginDisabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx new file mode 100644 index 0000000000..cc11164ccd --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection"; + +/** + * Card showing a public API key with copy and refresh actions. + */ +const meta: Meta = { + title: "Config/ApiKeySection", + component: ApiKeySection, + parameters: { layout: "padded" }, + args: { + publicKey: "demo-publishable-key-xxxx", + copied: null, + onCopy: () => {}, + onRefresh: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** After the copy button has been clicked, it shows a "Copied!" state. */ +export const Copied: Story = { + args: { copied: "public" }, +}; + +/** The refresh button is disabled while a refresh is in progress. */ +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx new file mode 100644 index 0000000000..f23872a6ee --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal"; + +/** + * Confirmation modal shown before regenerating API keys, warning that + * existing keys will be invalidated. + */ +const meta: Meta = { + title: "Config/ApiKeys/RefreshModal", + component: RefreshModal, + args: { + opened: true, + onClose: () => {}, + onConfirm: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When `opened` is false the modal renders nothing. */ +export const Closed: Story = { + args: { opened: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx new file mode 100644 index 0000000000..299fb3ee2c --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditChartsSection from "@app/components/shared/config/configSections/audit/AuditChartsSection"; + +/** + * Dashboard of audit-log charts (events over time, by type, by user). + * When `loginEnabled` is false it renders deterministic demo data instead + * of calling the audit API, which is what these stories rely on. + */ +const meta: Meta = { + title: "Config/AuditChartsSection", + component: AuditChartsSection, + parameters: { layout: "padded" }, + args: { + loginEnabled: false, + timePeriod: "week", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Day view of the same demo dataset. */ +export const DayPeriod: Story = { + args: { timePeriod: "day" }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx new file mode 100644 index 0000000000..3bc860885d --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditClearDataSection from "@app/components/shared/config/configSections/audit/AuditClearDataSection"; + +/** + * Destructive action card for permanently clearing all audit logs, gated + * behind a randomly generated confirmation code the user must retype. + */ +const meta: Meta = { + title: "Config/Audit/AuditClearDataSection", + component: AuditClearDataSection, + parameters: { layout: "padded" }, + args: { + loginEnabled: true, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When login is disabled, the delete action is disabled too. */ +export const LoginDisabled: Story = { + args: { loginEnabled: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx new file mode 100644 index 0000000000..702610e7b9 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditEventsTable from "@app/components/shared/config/configSections/audit/AuditEventsTable"; + +/** + * Table of audit events with sorting, an event-details modal, and pagination. + * When `loginEnabled` is false (auth disabled), it shows built-in sample + * events instead of calling the backend — used here to keep stories + * deterministic without a live API. + */ +const meta: Meta = { + title: "Config/Audit/AuditEventsTable", + component: AuditEventsTable, + parameters: { layout: "padded" }, + args: { + loginEnabled: false, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Adds the "Author" and "File Hash" columns when PDF metadata capture is enabled. */ +export const WithFileMetadataColumns: Story = { + args: { + captureFileHash: true, + capturePdfAuthor: true, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx new file mode 100644 index 0000000000..246e974a34 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditExportSection from "@app/components/shared/config/configSections/audit/AuditExportSection"; + +const meta = { + title: "Config/AuditExportSection", + component: AuditExportSection, + parameters: { layout: "padded" }, + args: { + loginEnabled: true, + captureFileHash: false, + capturePdfAuthor: false, + captureOperationResults: false, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** All optional PDF/file metadata fields enabled and selectable. */ +export const AllFieldsEnabled: Story = { + args: { + captureFileHash: true, + capturePdfAuthor: true, + captureOperationResults: true, + }, +}; + +/** Without an active login, the format, fields, filters, and export button are all disabled. */ +export const LoginDisabled: Story = { + args: { loginEnabled: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx new file mode 100644 index 0000000000..4ddee00974 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm"; +import { AuditFilters } from "@app/services/auditService"; + +/** + * Shared filter form for audit components: quick date presets, event type / user + * multi-selects, and start/end date pickers. + */ +const meta: Meta = { + title: "Config/Audit/AuditFiltersForm", + component: AuditFiltersForm, + parameters: { layout: "padded" }, + args: { + eventTypes: ["LOGIN", "LOGOUT", "FILE_UPLOAD", "FILE_DOWNLOAD"], + users: ["alice", "bob", "carol"], + onFilterChange: () => {}, + onClearFilters: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + filters: {}, + }, +}; + +/** With some filters already applied. */ +export const WithFiltersApplied: Story = { + args: { + filters: { + eventType: ["LOGIN"], + username: ["alice"], + startDate: "2026-07-01", + endDate: "2026-07-15", + }, + }, +}; + +/** All inputs disabled, e.g. while a request is in flight. */ +export const Disabled: Story = { + args: { + filters: {}, + disabled: true, + }, +}; + +/** Interactive: filter state updates live as the form is used. */ +export const Interactive: Story = { + render: (args) => { + function InteractiveForm() { + const [filters, setFilters] = useState({}); + return ( + + setFilters((prev) => ({ ...prev, [key]: value })) + } + onClearFilters={() => setFilters({})} + /> + ); + } + return ; + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx new file mode 100644 index 0000000000..01e8a66ea2 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditStatsCards from "@app/components/shared/config/configSections/audit/AuditStatsCards"; + +/** + * Stat cards summarising audit log activity (events, success rate, active + * users, latency) for a given time period. With `loginEnabled={false}` it + * renders built-in demo data instead of calling the audit API. + */ +const meta: Meta = { + title: "Config/Audit/AuditStatsCards", + component: AuditStatsCards, + parameters: { layout: "padded" }, + args: { + loginEnabled: false, + timePeriod: "week", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Stats for a single day period. */ +export const Day: Story = { + args: { timePeriod: "day" }, +}; + +/** Stats for a full month period. */ +export const Month: Story = { + args: { timePeriod: "month" }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx new file mode 100644 index 0000000000..d7a93740d8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditSystemStatus from "@app/components/shared/config/configSections/audit/AuditSystemStatus"; +import type { AuditSystemStatus as AuditStatus } from "@app/services/auditService"; + +const meta = { + title: "Config/Audit/AuditSystemStatus", + component: AuditSystemStatus, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const baseStatus: AuditStatus = { + enabled: true, + level: "STANDARD", + retentionDays: 90, + totalEvents: 12483, + pdfMetadataEnabled: true, + captureFileHash: true, + capturePdfAuthor: true, + captureOperationResults: true, +}; + +export const Default: Story = { + args: { + status: baseStatus, + }, +}; + +/** Audit logging disabled, and the opt-in fields not yet enabled in settings. */ +export const Disabled: Story = { + args: { + status: { + ...baseStatus, + enabled: false, + totalEvents: 0, + capturePdfAuthor: false, + captureFileHash: false, + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx new file mode 100644 index 0000000000..4926f2c805 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection"; +import type { PlanTier } from "@app/services/licenseService"; + +const FEATURES = [ + { name: "PDF conversion", included: true }, + { name: "Digital signatures", included: true }, + { name: "SSO", included: false }, +]; + +const PLANS: PlanTier[] = [ + { + id: "free", + name: "Free", + price: 0, + currency: "$", + period: "month", + features: FEATURES, + highlights: ["Core PDF tools", "Single user"], + lookupKey: "free", + }, + { + id: "selfhosted:server:monthly", + name: "Server", + price: 29, + currency: "$", + period: "month", + popular: true, + features: FEATURES, + highlights: ["Everything in Free", "Team workspaces", "Priority support"], + lookupKey: "selfhosted:server:monthly", + }, + { + id: "selfhosted:enterprise:monthly", + name: "Enterprise", + price: 99, + currency: "$", + period: "month", + seatPrice: 12, + requiresSeats: true, + features: FEATURES, + highlights: ["Everything in Server", "SSO", "Dedicated support"], + lookupKey: "selfhosted:enterprise:monthly", + }, +]; + +const meta = { + title: "Config/Plan/AvailablePlansSection", + component: AvailablePlansSection, + parameters: { layout: "padded" }, + args: { + plans: PLANS, + onUpgradeClick: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Adds the currency selector shown when currency props are supplied. */ +export const WithCurrencySelector: Story = { + args: { + currency: "usd", + onCurrencyChange: () => {}, + currencyOptions: [ + { value: "usd", label: "USD ($)" }, + { value: "eur", label: "EUR (€)" }, + { value: "gbp", label: "GBP (£)" }, + ], + }, +}; + +/** Logged-out visitors see disabled upgrade/manage controls. */ +export const LoginDisabled: Story = { + args: { + loginEnabled: false, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx new file mode 100644 index 0000000000..bb346bdd6e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable"; +import type { PlanFeature } from "@app/types/license"; + +const features: PlanFeature[] = [ + { name: "PDF merge & split", included: true }, + { name: "OCR", included: true }, + { name: "Custom watermarking", included: true }, + { name: "Priority support", included: false }, +]; + +const plans = [ + { + name: "Free", + tier: "free", + features: features.map((f, i) => ({ ...f, included: i < 2 })), + }, + { + name: "Server", + tier: "server", + popular: true, + features: features.map((f, i) => ({ ...f, included: i < 3 })), + }, + { + name: "Enterprise", + tier: "enterprise", + features: features.map((f) => ({ ...f, included: true })), + }, +]; + +/** + * Table comparing feature availability across plan tiers. + */ +const meta: Meta = { + title: "Config/Plan/FeatureComparisonTable", + component: FeatureComparisonTable, + parameters: { layout: "padded" }, + args: { + plans, + currentTier: "free", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When the current tier is enterprise, the "Popular" badge is suppressed on the Server plan. */ +export const CurrentTierEnterprise: Story = { + args: { currentTier: "enterprise" }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx new file mode 100644 index 0000000000..742ad93910 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard"; +import type { PlanTierGroup } from "@app/services/licenseService"; + +const freePlanGroup: PlanTierGroup = { + tier: "free", + name: "Free", + monthly: { + id: "free", + name: "Free", + price: 0, + currency: "$", + period: "month", + features: [], + highlights: ["Basic PDF tools", "Community support"], + lookupKey: "selfhosted:free", + }, + yearly: null, + features: [], + highlights: ["Basic PDF tools", "Community support"], + popular: false, +}; + +const serverPlanGroup: PlanTierGroup = { + tier: "server", + name: "Server", + monthly: { + id: "server-monthly", + name: "Server", + price: 29, + currency: "$", + period: "month", + features: [], + highlights: ["Unlimited users", "Priority support", "SSO"], + lookupKey: "selfhosted:server:monthly", + }, + yearly: { + id: "server-yearly", + name: "Server", + price: 290, + currency: "$", + period: "year", + features: [], + highlights: ["Unlimited users", "Priority support", "SSO"], + lookupKey: "selfhosted:server:yearly", + }, + features: [], + highlights: ["Unlimited users", "Priority support", "SSO"], + popular: true, +}; + +const enterprisePlanGroup: PlanTierGroup = { + tier: "enterprise", + name: "Enterprise", + monthly: { + id: "enterprise-monthly", + name: "Enterprise", + price: 0, + currency: "$", + period: "month", + seatPrice: 12, + requiresSeats: true, + features: [], + highlights: ["Dedicated support", "Custom SLAs", "Advanced security"], + lookupKey: "selfhosted:enterprise:monthly", + }, + yearly: { + id: "enterprise-yearly", + name: "Enterprise", + price: 0, + currency: "$", + period: "year", + seatPrice: 120, + requiresSeats: true, + features: [], + highlights: ["Dedicated support", "Custom SLAs", "Advanced security"], + lookupKey: "selfhosted:enterprise:yearly", + }, + features: [], + highlights: ["Dedicated support", "Custom SLAs", "Advanced security"], + popular: false, +}; + +const meta = { + title: "Config/Plan/PlanCard", + component: PlanCard, + parameters: { layout: "padded" }, + args: { + planGroup: serverPlanGroup, + isCurrentTier: false, + isDowngrade: false, + onUpgradeClick: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Free tier plan, shown as a permanently-included option. */ +export const FreePlan: Story = { + args: { + planGroup: freePlanGroup, + }, +}; + +/** Enterprise tier, priced per-seat and blocked until the Server plan is active. */ +export const EnterprisePlan: Story = { + args: { + planGroup: enterprisePlanGroup, + currentTier: "free", + }, +}; + +/** The plan the user already owns, showing the "Manage" action and seat count. */ +export const CurrentTier: Story = { + args: { + planGroup: serverPlanGroup, + isCurrentTier: true, + currentTier: "server", + currentLicenseInfo: { + licenseType: "SERVER", + enabled: true, + maxUsers: 25, + hasKey: true, + }, + onManageClick: () => {}, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx new file mode 100644 index 0000000000..e7fed1a275 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UsageAnalyticsChart from "@app/components/shared/config/configSections/usage/UsageAnalyticsChart"; + +/** + * Bar chart summarising endpoint usage counts. + */ +const meta: Meta = { + title: "Config/Usage/UsageAnalyticsChart", + component: UsageAnalyticsChart, + parameters: { layout: "padded" }, + args: { + data: [ + { label: "/api/v1/pdf/merge", value: 482 }, + { label: "/api/v1/pdf/split", value: 317 }, + { label: "/api/v1/pdf/compress", value: 210 }, + { label: "/api/v1/pdf/convert-to-pdf", value: 96 }, + ], + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No usage data yet available. */ +export const Empty: Story = { + args: { data: [] }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx new file mode 100644 index 0000000000..3d7d6e2e4f --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UsageAnalyticsTable from "@app/components/shared/config/configSections/usage/UsageAnalyticsTable"; +import type { EndpointStatistic } from "@app/services/usageAnalyticsService"; + +const ENDPOINTS: EndpointStatistic[] = [ + { endpoint: "/api/v1/general/merge-pdfs", visits: 1284, percentage: 34.12 }, + { + endpoint: "/api/v1/security/remove-password", + visits: 842, + percentage: 22.36, + }, + { endpoint: "/api/v1/convert/pdf-to-word", visits: 601, percentage: 15.97 }, + { endpoint: "/api/v1/misc/compress-pdf", visits: 388, percentage: 10.31 }, +]; + +const meta = { + title: "Config/Usage/UsageAnalyticsTable", + component: UsageAnalyticsTable, + parameters: { layout: "padded" }, + args: { + data: ENDPOINTS, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No usage recorded yet renders an empty-state row instead of the table body. */ +export const Empty: Story = { + args: { data: [] }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx new file mode 100644 index 0000000000..8d25b6b33e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StripeCheckout from "@app/components/shared/stripeCheckout/StripeCheckout"; +import type { PlanTierGroup } from "@app/services/licenseService"; + +const PLAN_GROUP: PlanTierGroup = { + tier: "server", + name: "Server", + monthly: { + id: "server-monthly", + name: "Server Monthly", + price: 29, + currency: "usd", + period: "monthly", + features: [], + highlights: ["Unlimited documents", "Priority support"], + lookupKey: "selfhosted:server:monthly", + }, + yearly: { + id: "server-yearly", + name: "Server Yearly", + price: 290, + currency: "usd", + period: "yearly", + features: [], + highlights: ["Unlimited documents", "Priority support", "2 months free"], + lookupKey: "selfhosted:server:yearly", + }, + features: [], + highlights: ["Unlimited documents", "Priority support"], +}; + +/** + * The multi-stage Stripe checkout modal (email -> plan selection -> payment -> success/error). + */ +const meta: Meta = { + title: "StripeCheckout/StripeCheckout", + component: StripeCheckout, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + planGroup: PLAN_GROUP, + minimumSeats: 1, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const HostedCheckoutSuccess: Story = { + args: { + hostedCheckoutSuccess: { + isUpgrade: false, + licenseKey: "STIRLING-XXXX-XXXX-XXXX", + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx new file mode 100644 index 0000000000..b69dc3d9bd --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay"; + +/** + * Renders a plan's price, either a simple single price or an enterprise + * base/seat/total breakdown. + */ +const meta = { + title: "StripeCheckout/PriceDisplay", + component: PriceDisplay, + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Simple: Story = { + args: { + mode: "simple", + price: 999, + currency: "usd", + period: "per month", + }, +}; + +export const Enterprise: Story = { + args: { + mode: "enterprise", + basePrice: 4999, + seatPrice: 1500, + currency: "usd", + period: "month", + }, +}; + +export const EnterpriseWithTotal: Story = { + args: { + mode: "enterprise", + basePrice: 4999, + seatPrice: 1500, + totalPrice: 19999, + currency: "usd", + period: "year", + seatCount: 10, + size: "lg", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx new file mode 100644 index 0000000000..2b85605458 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge"; + +/** + * The small badge overlaid on pricing plan cards (current plan, popular, savings). + */ +const meta = { + title: "StripeCheckout/PricingBadge", + component: PricingBadge, + parameters: { layout: "centered" }, + args: { + type: "current", + label: "Current plan", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Current: Story = {}; + +export const Popular: Story = { + args: { + type: "popular", + label: "Most popular", + }, +}; + +export const Savings: Story = { + args: { + type: "savings", + label: "Save 20%", + savingsPercent: 20, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx new file mode 100644 index 0000000000..a428aca099 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage"; + +/** + * The email-collection step of the Stripe checkout flow. + */ +const meta = { + title: "StripeCheckout/EmailStage", + component: EmailStage, + parameters: { layout: "centered" }, + args: { + emailInput: "", + setEmailInput: () => {}, + emailError: "", + onSubmit: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Filled: Story = { + args: { + emailInput: "jane@example.com", + }, +}; + +export const WithError: Story = { + args: { + emailInput: "not-an-email", + emailError: "Please enter a valid email address", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx new file mode 100644 index 0000000000..e0e7e082f8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage"; + +/** + * The error state shown when a Stripe checkout attempt fails. + */ +const meta = { + title: "StripeCheckout/ErrorStage", + component: ErrorStage, + parameters: { layout: "centered" }, + args: { + error: "Your card was declined. Please try a different payment method.", + onClose: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NetworkError: Story = { + args: { + error: + "We couldn't reach the payment provider. Please check your connection and try again.", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx new file mode 100644 index 0000000000..8e928cfe3a --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage"; +import type { PlanTier } from "@app/services/licenseService"; + +const serverPlan: PlanTier = { + id: "server-monthly", + name: "Server", + price: 29, + currency: "£", + period: "month", + features: [], + highlights: [], + lookupKey: "selfhosted:server:monthly", +}; + +/** + * The payment step of the Stripe checkout flow. Renders a loading state while + * the checkout session is being prepared, then hands off to Stripe's + * embedded checkout once a client secret is available. + */ +const meta = { + title: "StripeCheckout/PaymentStage", + component: PaymentStage, + parameters: { layout: "centered" }, + args: { + clientSecret: null, + selectedPlan: null, + onPaymentComplete: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Redirecting: Story = { + args: { + clientSecret: "demo-client-secret-xxxx", + selectedPlan: serverPlan, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx new file mode 100644 index 0000000000..c37e51ed53 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage"; +import { PlanTierGroup } from "@app/services/licenseService"; + +/** + * The plan-selection step of the Stripe checkout flow, letting the user + * choose between monthly and yearly billing for the selected plan tier. + */ +const serverPlanGroup: PlanTierGroup = { + tier: "server", + name: "Server", + monthly: { + id: "server-monthly", + name: "Server", + price: 29, + currency: "£", + period: "month", + features: [], + highlights: [], + lookupKey: "selfhosted:server:monthly", + }, + yearly: { + id: "server-yearly", + name: "Server", + price: 290, + currency: "£", + period: "year", + features: [], + highlights: [], + lookupKey: "selfhosted:server:yearly", + }, + features: [], + highlights: [], +}; + +const enterprisePlanGroup: PlanTierGroup = { + tier: "enterprise", + name: "Enterprise", + monthly: { + id: "enterprise-monthly", + name: "Enterprise", + price: 499, + currency: "£", + period: "month", + features: [], + highlights: [], + seatPrice: 15, + requiresSeats: true, + lookupKey: "selfhosted:enterprise:monthly", + }, + yearly: { + id: "enterprise-yearly", + name: "Enterprise", + price: 4999, + currency: "£", + period: "year", + features: [], + highlights: [], + seatPrice: 150, + requiresSeats: true, + lookupKey: "selfhosted:enterprise:yearly", + }, + features: [], + highlights: [], +}; + +const meta = { + title: "StripeCheckout/PlanSelectionStage", + component: PlanSelectionStage, + args: { + planGroup: serverPlanGroup, + minimumSeats: 1, + savings: null, + onSelectPlan: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithSavings: Story = { + args: { + savings: { + amount: 58, + percent: 20, + currency: "£", + }, + }, +}; + +export const Enterprise: Story = { + args: { + planGroup: enterprisePlanGroup, + minimumSeats: 5, + savings: { + amount: 900, + percent: 15, + currency: "£", + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx new file mode 100644 index 0000000000..a02c9caa5c --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage"; + +/** + * The success state shown after a Stripe checkout completes, including + * license key polling and reveal. + */ +const meta = { + title: "StripeCheckout/SuccessStage", + component: SuccessStage, + parameters: { layout: "centered" }, + args: { + pollingStatus: "ready", + currentLicenseKey: null, + licenseKey: "STIRLING-XXXX-XXXX-XXXX-XXXX", + onClose: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Polling: Story = { + args: { + pollingStatus: "polling", + currentLicenseKey: null, + licenseKey: null, + }, +}; + +export const UpgradeComplete: Story = { + args: { + pollingStatus: "ready", + currentLicenseKey: "STIRLING-EXISTING-KEY", + licenseKey: null, + }, +}; + +export const Timeout: Story = { + args: { + pollingStatus: "timeout", + currentLicenseKey: null, + licenseKey: null, + }, +}; diff --git a/frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx b/frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx new file mode 100644 index 0000000000..0fdd4ab393 --- /dev/null +++ b/frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import ParticipantView from "@app/components/workflow/ParticipantView"; +import type { + ParticipantResponse, + WorkflowSessionResponse, +} from "@app/services/workflowService"; + +/** + * The signing hook (`useParticipantSession`) fires two GETs on mount — + * session + participant details — so every story mocks both via MSW rather + * than passing data through props (the component only accepts a `token`). + */ +function sessionHandlers( + session: WorkflowSessionResponse, + participant: ParticipantResponse, +) { + return [ + http.get("/api/v1/workflow/participant/session", () => + HttpResponse.json(session), + ), + http.get("/api/v1/workflow/participant/details", () => + HttpResponse.json(participant), + ), + ]; +} + +const baseSession: WorkflowSessionResponse = { + sessionId: "session-1", + ownerId: 1, + ownerUsername: "alex", + workflowType: "SIGNING", + documentName: "vendor-agreement.pdf", + message: "Please sign by end of week, thanks!", + dueDate: "2026-07-20T00:00:00Z", + status: "IN_PROGRESS", + finalized: false, + createdAt: "2026-07-10T09:00:00Z", + updatedAt: "2026-07-10T09:00:00Z", + participants: [], + hasProcessedFile: false, +}; + +const baseParticipant: ParticipantResponse = { + id: 1, + email: "jordan@example.com", + name: "Jordan", + status: "NOTIFIED", + shareToken: null, + accessRole: "EDITOR", + lastUpdated: "2026-07-10T09:00:00Z", + hasCompleted: false, + isExpired: false, +}; + +const meta = { + title: "Workflow/ParticipantView", + component: ParticipantView, + parameters: { layout: "padded" }, + args: { + token: "story-participant-token", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Awaiting signature: the certificate + signing form is shown. */ +export const Default: Story = { + parameters: { + msw: { handlers: sessionHandlers(baseSession, baseParticipant) }, + }, +}; + +/** Participant already signed: the form is replaced with a completion banner. */ +export const Completed: Story = { + parameters: { + msw: { + handlers: sessionHandlers( + { ...baseSession, status: "COMPLETED", finalized: true }, + { ...baseParticipant, status: "SIGNED", hasCompleted: true }, + ), + }, + }, +}; + +/** The participant's access window has passed: signing is blocked. */ +export const Expired: Story = { + parameters: { + msw: { + handlers: sessionHandlers(baseSession, { + ...baseParticipant, + status: "PENDING", + isExpired: true, + expiresAt: "2026-07-01T00:00:00Z", + }), + }, + }, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f467cd4e47..86c998e997 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -94,6 +94,7 @@ "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", "@storybook/addon-themes": "^9.1.20", + "@storybook/addon-vitest": "^9.1.20", "@storybook/react-vite": "^9.1.20", "@tauri-apps/cli": "^2.9.6", "@testing-library/dom": "^10.4.1", @@ -112,6 +113,7 @@ "@typescript-eslint/parser": "^8.61.1", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", + "@vitest/browser": "^3.2.6", "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", @@ -2962,6 +2964,13 @@ "node": ">=18" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3609,6 +3618,44 @@ "storybook": "^9.1.20" } }, + "node_modules/@storybook/addon-vitest": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-9.1.20.tgz", + "integrity": "sha512-6zN/qe9Z/7pklbUQJrnjwdTTqRG5PWy4Tyx1J90nwQq6yekj436S8/5NrvXaSns0zYNoZLsynh8nWZPweeu05w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^1.4.0", + "prompts": "^2.4.0", + "ts-dedent": "^2.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@vitest/browser": "^3.0.0 || ^4.0.0", + "@vitest/browser-playwright": "^4.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0", + "storybook": "^9.1.20", + "vitest": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/runner": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@storybook/builder-vite": { "version": "9.1.20", "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.20.tgz", @@ -6200,6 +6247,110 @@ "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/@vitest/browser": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.6.tgz", + "integrity": "sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.6", + "@vitest/utils": "3.2.6", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.6", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", @@ -10724,6 +10875,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12297,6 +12458,16 @@ "ufo": "^1.6.3" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -13463,6 +13634,20 @@ "node": ">=0.4.0" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -14822,6 +15007,28 @@ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -16194,6 +16401,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 06059a3962..404366383a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -116,6 +116,7 @@ "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", "@storybook/addon-themes": "^9.1.20", + "@storybook/addon-vitest": "^9.1.20", "@storybook/react-vite": "^9.1.20", "@tauri-apps/cli": "^2.9.6", "@testing-library/dom": "^10.4.1", @@ -134,6 +135,7 @@ "@typescript-eslint/parser": "^8.61.1", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", + "@vitest/browser": "^3.2.6", "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000000..f9b138bc97 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,7 @@ +// Root Vitest config so the Storybook addon-vitest Testing UI (and a bare +// `vitest` run from frontend/) discovers the Storybook browser-test project. +// The project definition lives in .storybook/vitest.config.ts; this re-exports +// it from the conventional root location. The editor unit tests are separate +// (editor/vitest.config.ts, run with `vitest --root editor`). +// eslint-disable-next-line no-restricted-imports -- config re-export; no @-alias covers .storybook/ +export { default } from "./.storybook/vitest.config"; From af1acb68d5b15eae463bf99838e34e21639ed9ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:13:07 +0100 Subject: [PATCH 004/262] build(deps): bump actions/setup-python from 6.2.0 to 7.0.0 (#7185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 7.0.0.
Release notes

Sourced from actions/setup-python's releases.

v7.0.0

What's Changed

Enhancements

Bug Fix

Dependency Upgrade

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v6...v7.0.0

v6.3.0

What's Changed

Enhancement

Dependency update

Documentation

New Contributors

Full Changelog: https://github.com/actions/setup-python/compare/v6.2.0...v6.3.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-python&package-manager=github_actions&previous-version=6.2.0&new-version=7.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check_toml.yml | 2 +- .github/workflows/coverage-aggregate.yml | 2 +- .github/workflows/docker-compose-tests.yml | 2 +- .github/workflows/e2e-live.yml | 4 ++-- .github/workflows/frontend-validation.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index b7a277f873..279e57f95d 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -196,7 +196,7 @@ jobs: core.exportVariable("REFERENCE_FILE", referenceFilePath); - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index bbed57db4d..42aa971971 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -62,7 +62,7 @@ jobs: cache-disabled: true - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 2db9e5cf72..68bc1722be 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -74,7 +74,7 @@ jobs: sudo chmod +x /usr/local/bin/docker-compose - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: "pip" # caching pip dependencies diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 3a1b4e3af1..a082f89bf5 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -84,7 +84,7 @@ jobs: fi - name: Set up Python for coverage summary if: always() && steps.live-coverage.outputs.report == 'true' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install defusedxml for coverage summary @@ -124,7 +124,7 @@ jobs: # a summary even on backend failure, as long as some Playwright # tests ran far enough to dump V8 coverage. if: always() - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index a539a1b7b7..0e0a35a25e 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -117,7 +117,7 @@ jobs: run: task frontend:test:coverage - name: Set up Python for coverage summary if: always() - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install defusedxml for coverage summary diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 357f5d5a87..656c636e48 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -52,7 +52,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: "pip" # caching pip dependencies From dff3101ca7d2afd108efc8746564f31955dc4cdc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:13:22 +0100 Subject: [PATCH 005/262] build(deps): bump pillow from 12.2.0 to 12.3.0 in /engine in the uv group across 1 directory (#7119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv group with 1 update in the /engine directory: [pillow](https://github.com/python-pillow/Pillow). Updates `pillow` from 12.2.0 to 12.3.0
Release notes

Sourced from pillow's releases.

12.3.0

https://pillow.readthedocs.io/en/stable/releasenotes/12.3.0.html

Removals

Documentation

Dependencies

Testing

... (truncated)

Commits
  • bb1d8e8 12.3.0 version bump
  • e63fc48 Add release notes for SBOM and performance improvements (#9747)
  • 13b701b Add release notes for #9679
  • 5564ca7 List methods
  • a0920fd Speed up ImageChops operations (#9738)
  • 07e9a6c Speed up Image.filter() (#9736)
  • a94578c Speed up Image.getchannel(), Image.merge(), Image.putalpha() and `Image...
  • 53e02c4 Speed up Image.fill(), Image.linear_gradient() and `Image.radial_gradient...
  • af03747 Speed up Image.resample() (#9739)
  • 5c9ca56 Speed up alpha_composite, matrix, negative, quantize (#9740)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pillow&package-manager=uv&previous-version=12.2.0&new-version=12.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- engine/uv.lock | 108 +++++++++++++++++++++++++------------------------ 1 file changed, 56 insertions(+), 52 deletions(-) diff --git a/engine/uv.lock b/engine/uv.lock index 5e9d166af0..258c38b3de 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -2059,60 +2059,64 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] From e05b7f12dadacc1aa545a57daca12043eff87d90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:13:30 +0100 Subject: [PATCH 006/262] build(deps): bump madrapps/jacoco-report from 1.7.2 to 1.8.0 (#6750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [madrapps/jacoco-report](https://github.com/madrapps/jacoco-report) from 1.7.2 to 1.8.0.
Release notes

Sourced from madrapps/jacoco-report's releases.

v1.8.0

What's Changed

Full Changelog: https://github.com/Madrapps/jacoco-report/compare/v1.7.2...v1.8.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=madrapps/jacoco-report&package-manager=github_actions&previous-version=1.7.2&new-version=1.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) You can trigger a rebase of this PR by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
> **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/backend-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 28974a5ff6..0479f91f0e 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -241,7 +241,7 @@ jobs: # so skip it for merge_group runs and workflow_dispatch. if: github.event_name == 'pull_request' id: jacoco - uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2 + uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0 with: paths: | ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml From 66b80a80c0b5a4d9c0eed4a225428b1a62cba4c8 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:31:47 +0100 Subject: [PATCH 007/262] fix(saas): personal teams must be allowed to share a name (new signups get no team) (#7180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug Every SaaS signup after the very first one is created with `team_id = NULL`, no team membership and no `home_team_id`. A brand-new account: ``` user_id | username | team_id | authenticationtype | home_team_id | memberships 952 | hedewot627@candaba.com | null | web | null | null ``` Since #7070 derives Processor access from leading a team, these accounts are silently redirected out of the Processor and back to the editor. ## Cause `SaasTeamService.createPersonalTeam` names every personal team the literal `"My Team"`, and `stirling_pdf.teams.name` was unique — so the insert throws a duplicate-key error for the second account onwards. Team creation is best-effort (caught, logged at WARN), so the account is created anyway, permanently team-less. Migration `20251211000000` had already dropped that constraint for exactly this reason, but it dropped it **by name** while the entity still declared `@Column(unique = true)`. With Flyway retired for `:saas` (#7100), `ddl-auto=update` reconciles the schema — so Hibernate re-created the constraint on the next boot under a generated name the old `DROP` could never match. The data bug predates #7070; that PR only made it visible. ## Changes - **`Team.name` no longer unique.** `TeamController` already enforces uniqueness for admin-created teams (`existsByNameIgnoreCase` on create and rename, 409), so nothing user-facing changes. `findByName` is only used for the `Default`/`Internal` system teams. - **Existing team-less accounts recover on authentication.** Signup is the only other place a team is assigned and nothing back-fills `team_id`, so without this they stay locked out. Guests excluded by design; healthy accounts short-circuit on a null check (`team` is `EAGER`). - **Tests:** team recovered, existing team untouched, guest stays team-less. ## Deploy order Needs `20260806000000_teams_name_drop_unique` (SaaS repo, `v3`) to drop the constraint from the live schema — **deployed after this**, or Hibernate re-adds it on the next boot. ## Verification `:saas:compileJava`, `:proprietary:compileJava`, spotless on both, and the `:saas` team/auth-filter tests (`TeamRecovery`: 3 tests, 0 failures). --- .../software/proprietary/model/Team.java | 4 +- .../SupabaseAuthenticationFilter.java | 24 +++++- .../SupabaseAuthenticationFilterMoreTest.java | 74 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java index 0009ee386e..a54959b0ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -29,7 +29,9 @@ public class Team implements Serializable { @Column(name = "team_id") private Long id; - @Column(name = "name", unique = true, nullable = false) + // Not unique: SaaS personal teams all share the name "My Team". TeamController enforces + // uniqueness for admin-created teams. + @Column(name = "name", nullable = false) private String name; @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 890bc081a2..684e22a98f 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -239,7 +239,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { && !supabaseUser.isAnonymous()) { user = upgradeAnonymousUser(user, supabaseUser, jwt); } - return user; + return recoverMissingTeam(user); } return createUser(jwt, supabaseId, email, appMetadata); @@ -406,6 +406,28 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { return savedUser; } + /** + * Recover an account stranded without a team: signup is the only other place one is assigned, + * so a null team_id is otherwise permanent — and portal access derives from leading a team. + * Guests get none by design. + */ + private User recoverMissingTeam(User user) { + if (user.getTeam() != null + || ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())) { + return user; + } + try { + user.setTeam(saasTeamService.ensurePersonalTeam(user)); + log.info("Assigned a personal team to user {} which had none", user.getId()); + } catch (Exception e) { + log.warn( + "Could not assign a personal team to user {}: {}", + user.getId(), + e.getMessage()); + } + return user; + } + private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException { Authentication existing = SecurityContextHolder.getContext().getAuthentication(); if (existing != null && existing.isAuthenticated()) { diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java index 87e100375f..3b8a0292c7 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java @@ -585,4 +585,78 @@ class SupabaseAuthenticationFilterMoreTest { verify(userService, times(1)).saveUser(any(User.class)); } } + + @Nested + @DisplayName("Team recovery for existing accounts") + class TeamRecovery { + + private User existingWebUser(UUID supabaseId) { + User local = newUser("real@example.com"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.WEB); + return local; + } + + @Test + @DisplayName("an existing account with no team is given a personal team") + void assignsTeamWhenMissing() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")) + .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); + + User local = existingWebUser(supabaseId); + Team recovered = new Team(); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + when(saasTeamService.ensurePersonalTeam(local)).thenReturn(recovered); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService).ensurePersonalTeam(local); + assertThat(local.getTeam()).isSameAs(recovered); + } + + @Test + @DisplayName("an account that already has a team is left alone") + void noOpWhenTeamPresent() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")) + .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); + + User local = existingWebUser(supabaseId); + Team existing = new Team(); + local.setTeam(existing); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + assertThat(local.getTeam()).isSameAs(existing); + } + + @Test + @DisplayName("a guest session is never given a team") + void guestStaysTeamless() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")).thenReturn(fullJwt(supabaseId, null, true, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, null, true)); + + User local = newUser("anon_guest"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.ANONYMOUS); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + assertThat(local.getTeam()).isNull(); + } + } } From 999b5e5995b3e28a6511f47ce765998394437711 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 29 Jul 2026 12:22:47 +0100 Subject: [PATCH 008/262] Add persistent outputs to Processor (#7071) # Description of Changes image Change policies to point towards a source for its output instead of a dynamically defined output location for the pipeline. This allows for easy reuse of outputs in different pipelines and makes it impossible to break complex pipelines by accidentally updating the source but not the output and vice versa. Also makes outputs a list to match the inputs, so it's possible for a pipeline to output to multiple locations. We should consider whether we want to continue calling these Sources since they're now being used as both inputs and outputs, but that decision is beyond the scope of this PR. Also updates the existing S3 DB migration script and adds a new one to migrate to the new schema. Neither of these scripts are possible with SQL since it involves parsing and restructuring JSON. I've updated them so that they only ever run once on startup and mark themselves as completed. --- .../policy/controller/PolicyController.java | 53 +++++-- .../policy/engine/PolicyEngine.java | 32 +++- .../policy/migration/CompletedMigration.java | 40 +++++ .../CompletedMigrationRepository.java | 7 + .../policy/migration/CompletedMigrations.java | 18 +++ .../InProcessCompletedMigrations.java | 23 +++ .../migration/JpaCompletedMigrations.java | 38 +++++ .../policy/model/PipelineDefinition.java | 15 +- .../proprietary/policy/model/Policy.java | 49 +++++- .../output/PolicyInlineOutputMigration.java | 144 +++++++++++++++++ .../policy/output/PolicyOutputResolver.java | 53 +++++++ .../overview/PolicyOverviewService.java | 18 ++- .../s3/EmbeddedS3CredentialMigration.java | 29 ++-- .../proprietary/policy/source/Source.java | 25 ++- .../policy/source/SourceController.java | 11 +- .../policy/source/SourceOverviewService.java | 12 +- .../policy/store/InProcessPolicyStore.java | 1 + .../policy/store/JpaPolicyStore.java | 1 + .../configuration/DatabaseConfig.java | 2 + .../controller/PolicyControllerTest.java | 6 +- .../policy/engine/PolicyEngineTest.java | 88 ++++++++++- .../policy/engine/PolicyRunRegistryTest.java | 3 +- .../PolicyInlineOutputMigrationTest.java | 149 ++++++++++++++++++ .../output/PolicyOutputResolverTest.java | 73 +++++++++ .../s3/EmbeddedS3CredentialMigrationTest.java | 7 +- .../public/locales/en-US/translation.toml | 13 +- frontend/editor/src/portal/api/pipelines.ts | 14 +- .../pipelines/DestinationPicker.tsx | 62 ++++++++ .../components/pipelines/outputModes.ts | 10 +- .../src/portal/mocks/handlers/pipelines.ts | 10 +- .../src/portal/views/PipelineBuilder.test.tsx | 117 +++++++------- .../src/portal/views/PipelineBuilder.tsx | 147 ++++------------- .../services/policyPipeline.test.ts | 2 +- .../proprietary/services/policyPipeline.ts | 5 +- 34 files changed, 1017 insertions(+), 260 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigration.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrations.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/InProcessCompletedMigrations.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java create mode 100644 frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 06172b8cbc..a34c392597 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -68,6 +68,7 @@ import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse; import stirling.software.proprietary.policy.overview.PolicyOverviewService; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.policy.source.EditorSource; +import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceAccessGuard; import stirling.software.proprietary.policy.source.SourceDocCounter; import stirling.software.proprietary.policy.source.SourceStore; @@ -248,6 +249,7 @@ public class PolicyController { requirePolicyEditingAllowed(); Policy owned = withStoredOutputSecrets(resolveOwnership(policy)); requireAccessibleSources(owned); + requireAccessibleOutput(owned); try { policyValidator.validate(owned); } catch (IllegalArgumentException e) { @@ -290,6 +292,40 @@ public class PolicyController { } } + /** + * A policy's output destination is a {@link Source} used as a write target: it must resolve to + * a source in the caller's team, so a client can neither reference a non-existent location nor + * reach across teams to write to another team's. The editor is virtual and has no writable + * location, so it can't be a destination. The config is then validated on this (request) thread + * so an S3 destination's connection is authorization-checked against the caller - the async + * delivery worker has no principal. A policy with no reference (inline / editor / one-off) has + * nothing to check. + */ + private void requireAccessibleOutput(Policy policy) { + for (String outputId : policy.outputIds()) { + Source destination = + sourceStore + .get(outputId) + .filter(sourceAccessGuard::canAccess) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unknown or inaccessible output source: " + + outputId)); + if (EditorSource.TYPE.equals(destination.type())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "The editor can't be used as an output destination"); + } + try { + policyValidator.validateOutput(destination.toOutputSpec()); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + } + /** * Assign owner + owning team server-side. Create stamps the current user and their team; update * preserves the existing owner and team after verifying the policy belongs to the caller's team @@ -323,6 +359,7 @@ public class PolicyController { policy.sourceIds(), policy.steps(), policy.output(), + policy.outputIds(), teamId); } @@ -358,16 +395,7 @@ public class PolicyController { } private static Policy withOutput(Policy policy, OutputSpec output) { - return new Policy( - policy.id(), - policy.name(), - policy.owner(), - policy.enabled(), - policy.trigger(), - policy.sourceIds(), - policy.steps(), - output, - policy.teamId()); + return policy.withOutput(output); } /** @@ -573,8 +601,9 @@ public class PolicyController { // step dereferences its connection by id on a principal-less worker thread, so this // request thread is the only place that reference can be checked against the caller. policyValidator.validateSteps(definition.steps()); - if (definition.output() != null) { - policyValidator.validateOutput(definition.output()); + // Every destination is checked; an ad-hoc run with no destinations validates nothing. + for (OutputSpec output : definition.outputs()) { + policyValidator.validateOutput(output); } } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index abdb69cb07..e6dce0ee7b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -37,6 +37,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.WaitState; import stirling.software.proprietary.policy.output.OutputDelivery; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.service.DownstreamEntitlementError; @@ -72,6 +73,7 @@ public class PolicyEngine { private final FileStorage fileStorage; private final JobOwnershipService jobOwnershipService; private final List outputSinks; + private final PolicyOutputResolver outputResolver; private final ResourceMonitor resourceMonitor; private final JobQueue jobQueue; @@ -119,8 +121,14 @@ public class PolicyEngine { // the owner owns those outputs. String triggeringUser = currentActingPrincipal(); String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); + // Resolve the referenced output destinations live (like sourceIds), so a stored policy + // delivers to each of its saved Source destinations. Unreferenced policies fall back to + // their inline output. + PipelineDefinition definition = + new PipelineDefinition( + policy.name(), policy.steps(), outputResolver.resolve(policy)); return submitForPrincipal( - policy.owner(), fileOwner, policy.id(), policy.toDefinition(), inputs, listener); + policy.owner(), fileOwner, policy.id(), definition, inputs, listener); } private PolicyRunHandle submitForPrincipal( @@ -212,13 +220,21 @@ public class PolicyEngine { run.markRunning(); PolicyExecutionResult result = stepExecutor.execute(run.getDefinition(), inputs, listener); - OutputSpec output = run.getDefinition().output(); - List outputs = - sinkFor(output) - .deliver( - new OutputDelivery(runId, run.getPolicyId()), - result.files(), - output); + // Deliver the run's files to every destination; no destinations means inline + // delivery (results stored/returned to the caller), preserving ad-hoc/AI behaviour. + List destinations = run.getDefinition().outputs(); + if (destinations.isEmpty()) { + destinations = List.of(OutputSpec.inline()); + } + List outputs = new ArrayList<>(); + for (OutputSpec destination : destinations) { + outputs.addAll( + sinkFor(destination) + .deliver( + new OutputDelivery(runId, run.getPolicyId()), + result.files(), + destination)); + } taskManager.setMultipleFileResults(runId, outputs); taskManager.setComplete(runId); run.complete(outputs); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigration.java new file mode 100644 index 0000000000..9837cd4f45 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigration.java @@ -0,0 +1,40 @@ +package stirling.software.proprietary.policy.migration; + +import java.io.Serializable; +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A one-time policy-subsystem migration that has finished, keyed by a stable migration id. Its + * presence lets a migration skip its (otherwise every-boot) scan once it has run, instead of + * re-scanning and finding nothing to do forever. + */ +@Entity +@Table(name = "policy_completed_migrations") +@NoArgsConstructor +@Getter +@Setter +public class CompletedMigration implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id") + private String id; + + @Column(name = "applied_at") + private Instant appliedAt; + + public CompletedMigration(String id, Instant appliedAt) { + this.id = id; + this.appliedAt = appliedAt; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java new file mode 100644 index 0000000000..23dec902c5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.policy.migration; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CompletedMigrationRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrations.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrations.java new file mode 100644 index 0000000000..f03defa92b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrations.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.policy.migration; + +/** + * Tracks which one-time policy-subsystem migrations have finished, so a migration can skip its + * every-boot scan once done. {@link JpaCompletedMigrations} is the runtime bean; {@link + * InProcessCompletedMigrations} backs tests. + */ +public interface CompletedMigrations { + + /** Whether the migration with this id has already been recorded as complete. */ + boolean isDone(String id); + + /** + * Record the migration as complete. Safe to call concurrently: a race on first boot leaves the + * marker recorded exactly once and never propagates a failure to the caller. + */ + void markDone(String id); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/InProcessCompletedMigrations.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/InProcessCompletedMigrations.java new file mode 100644 index 0000000000..0744c85965 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/InProcessCompletedMigrations.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.policy.migration; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link CompletedMigrations} for tests and any future no-database mode. {@link + * JpaCompletedMigrations} is the runtime bean. + */ +public class InProcessCompletedMigrations implements CompletedMigrations { + + private final Set done = ConcurrentHashMap.newKeySet(); + + @Override + public boolean isDone(String id) { + return done.contains(id); + } + + @Override + public void markDone(String id) { + done.add(id); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java new file mode 100644 index 0000000000..853e18be51 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.policy.migration; + +import java.time.Instant; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Durable {@link CompletedMigrations} backed by JPA; the runtime bean. {@code markDone} relies on + * the primary-key uniqueness of {@link CompletedMigration#getId()} to stay safe under a concurrent + * first boot: whichever node inserts first wins, and the loser's duplicate insert is swallowed + * rather than propagated, so it never disturbs the migration that called it. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class JpaCompletedMigrations implements CompletedMigrations { + + private final CompletedMigrationRepository repository; + + @Override + public boolean isDone(String id) { + return repository.existsById(id); + } + + @Override + public void markDone(String id) { + try { + repository.save(new CompletedMigration(id, Instant.now())); + } catch (DataIntegrityViolationException alreadyRecorded) { + // A concurrent boot recorded the same marker first; the row exists, so we are done. + log.debug("Completion marker '{}' was already recorded concurrently", id); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java index 146424b0fb..209756c67f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java @@ -3,13 +3,20 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * An ordered chain of tool steps plus an output destination; the unit the engine executes. + * An ordered chain of tool steps plus its output destinations; the unit the engine executes. * - *

{@code output} may be null for callers that handle result files themselves (e.g. the AI - * workflow, which builds its own response payload). + *

{@code outputs} may be empty for callers that handle result files themselves (e.g. the AI + * workflow, which builds its own response payload) - the engine then falls back to inline delivery. + * A run's files are delivered to every destination in the list. */ -public record PipelineDefinition(String name, List steps, OutputSpec output) { +public record PipelineDefinition(String name, List steps, List outputs) { public PipelineDefinition { steps = steps == null ? List.of() : steps; + outputs = outputs == null ? List.of() : List.copyOf(outputs); + } + + /** Convenience for the common single-destination (or inline) case. A null output is empty. */ + public PipelineDefinition(String name, List steps, OutputSpec output) { + this(name, steps, output == null ? List.of() : List.of(output)); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 51dcc4ac0c..ae9fedc46a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -3,12 +3,14 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * A stored automation: ordered tool steps, input sources, and an output destination. + * A stored automation: ordered tool steps, input sources, and output destinations. * *

Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code * null} trigger means manual-only. Trigger decides when; {@code sourceIds} reference the persisted - * {@code Source} connections (resolved live at run time) that decide where files come from; a run - * pulls from every referenced source. + * {@code Source} locations (resolved live at run time) files come from; a run pulls from every + * referenced source. {@code outputIds} reference the {@code Source} locations (resolved live) a + * run's files are delivered to - a run is delivered to every one; when empty the inline {@link + * #output} is used (results returned to the caller), the case for editor and one-off policies. */ public record Policy( String id, @@ -19,12 +21,32 @@ public record Policy( List sourceIds, List steps, OutputSpec output, + List outputIds, Long teamId) { public Policy { sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; + outputIds = outputIds == null ? List.of() : List.copyOf(outputIds); + } + + /** + * Without output references: the inline output is used as-is. Kept for the engine, migrations, + * and tests, and for editor/one-off policies that return results to the caller rather than a + * stored destination. + */ + public Policy( + String id, + String name, + String owner, + boolean enabled, + TriggerConfig trigger, + List sourceIds, + List steps, + OutputSpec output, + Long teamId) { + this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), teamId); } /** @@ -40,7 +62,7 @@ public record Policy( List sourceIds, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, sourceIds, steps, output, null); + this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), null); } /** A policy with no configured sources (a generator, or files supplied directly to a run). */ @@ -52,10 +74,25 @@ public record Policy( TriggerConfig trigger, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, List.of(), steps, output, null); + this(id, name, owner, enabled, trigger, List.of(), steps, output, List.of(), null); } - /** This policy's pipeline as the engine sees it. */ + /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ + public Policy withOutput(OutputSpec resolved) { + return new Policy( + id, name, owner, enabled, trigger, sourceIds, steps, resolved, outputIds, teamId); + } + + /** A copy referencing the given saved output destinations. */ + public Policy withOutputIds(List newOutputIds) { + return new Policy( + id, name, owner, enabled, trigger, sourceIds, steps, output, newOutputIds, teamId); + } + + /** + * This policy's pipeline as the engine sees it (inline output; destinations resolved + * elsewhere). + */ public PipelineDefinition toDefinition() { return new PipelineDefinition(name, steps, output); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java new file mode 100644 index 0000000000..a93b60d000 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java @@ -0,0 +1,144 @@ +package stirling.software.proprietary.policy.output; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.migration.CompletedMigrations; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * One-time, idempotent migration of policies' inline output destinations onto stored {@link Source} + * references: policies written before a destination was a saved location carry their folder/S3 + * destination inline; this points each at a {@link Source} (reusing one at the same location, or + * creating it) so the destination becomes a managed location like any other source. Policies with + * an inline "return to caller" output have no location to store and are left as-is. + * + *

Idempotent by construction: a policy that already carries an {@code outputId} is skipped, so a + * sequential re-run finds nothing to do. Matches are keyed by the write-relevant config within a + * team, so an output to a folder/prefix an input source already covers links to that same source - + * unifying the "output of A is the input of B" case onto one location. A concurrent multi-node boot + * can at worst create a redundant (unreferenced) source row, never corrupt a policy. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyInlineOutputMigration { + + /** Completion-marker id: once recorded, later boots skip the scan entirely. */ + private static final String MIGRATION_ID = "policy-inline-output"; + + // Destination types worth persisting as a location; "inline" has nothing to store. + private static final List DESTINATION_TYPES = List.of("folder", "s3"); + // The options that actually address a write destination, per type. Read-only options (e.g. a + // folder's consume mode) are excluded so an output matches an existing input source at the same + // place regardless of how that source reads. + private static final Map> ADDRESS_OPTIONS = + Map.of("folder", List.of("directory"), "s3", List.of("connectionId", "prefix")); + // Field separator for the dedup key: a unit-separator control char that cannot appear in a + // directory/prefix/connection id, so distinct field sets can never collide. + private static final char DELIMITER = '\u001f'; + + private final PolicyStore policyStore; + private final SourceStore sourceStore; + private final CompletedMigrations completedMigrations; + + // Runs after EmbeddedS3CredentialMigration (@Order(1)) so any legacy S3 output has already had + // its embedded credentials extracted into a connection; the Source created here then references + // that connection rather than copying credentials into source_json. Not wrapped in a single + // transaction: each store write is its own (idempotent) commit, so a crash mid-run just re-runs + // next boot, and the marker below is written only once the whole pass succeeds. + @Order(2) + @EventListener(ApplicationReadyEvent.class) + public void migrate() { + if (completedMigrations.isDone(MIGRATION_ID)) { + return; + } + Map byAddress = indexExistingSources(); + int migrated = 0; + for (Policy policy : policyStore.all()) { + if (!policy.outputIds().isEmpty()) { + continue; // already references one or more locations + } + OutputSpec output = policy.output(); + if (output == null || !DESTINATION_TYPES.contains(output.type())) { + continue; // inline / editor / no location to migrate + } + Source destination = destinationFor(policy, output, byAddress); + policyStore.save(policy.withOutputIds(List.of(destination.id()))); + migrated++; + } + if (migrated > 0) { + log.info("Linked {} policy output(s) to stored source locations", migrated); + } + completedMigrations.markDone(MIGRATION_ID); + } + + /** Reuses an existing team source at the same address, else creates a minimal one. */ + private Source destinationFor(Policy policy, OutputSpec spec, Map byAddress) { + String key = addressKey(policy.teamId(), spec.type(), spec.options()); + Source existing = byAddress.get(key); + if (existing != null) { + return existing; + } + Source created = + sourceStore.save( + new Source( + null, + destinationName(spec), + spec.type(), + spec.options(), + true, + policy.owner(), + policy.teamId())); + byAddress.put(key, created); + return created; + } + + private Map indexExistingSources() { + Map byKey = new LinkedHashMap<>(); + for (Source source : sourceStore.all()) { + if (!DESTINATION_TYPES.contains(source.type())) { + continue; + } + byKey.putIfAbsent(addressKey(source.teamId(), source.type(), source.options()), source); + } + return byKey; + } + + private static String addressKey(Long teamId, String type, Map options) { + StringBuilder key = new StringBuilder(); + key.append(teamId == null ? "" : teamId).append(DELIMITER); + key.append(type == null ? "" : type).append(DELIMITER); + for (String option : ADDRESS_OPTIONS.getOrDefault(type, List.of())) { + Object value = options.get(option); + key.append(value == null ? "" : value.toString()).append(DELIMITER); + } + return key.toString(); + } + + /** A readable default name derived from the destination; the user can rename it later. */ + private static String destinationName(OutputSpec spec) { + if ("folder".equals(spec.type())) { + Object directory = spec.options().get("directory"); + return directory == null ? "Folder" : "Folder: " + directory; + } + if ("s3".equals(spec.type())) { + Object prefix = spec.options().get("prefix"); + return prefix == null || prefix.toString().isBlank() ? "S3 bucket" : "S3: " + prefix; + } + return spec.type(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java new file mode 100644 index 0000000000..3e9d804683 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java @@ -0,0 +1,53 @@ +package stirling.software.proprietary.policy.output; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; + +/** + * Resolves a policy's effective output destinations at run time: each {@code outputId} references a + * {@link Source} used as a destination, looked up live (so editing a location updates every policy + * that writes to it), exactly as input {@code sourceIds} are resolved. A run is delivered to every + * resolved destination. A policy with no references keeps its inline output (results returned to + * the caller) - the case for editor and one-off policies. A reference that no longer resolves + * (location deleted out from under a live policy - normally blocked by the source delete guard) is + * skipped; if none resolve, delivery falls back to inline so the run still completes. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyOutputResolver { + + private final SourceStore sourceStore; + + public List resolve(Policy policy) { + List outputIds = policy.outputIds(); + if (outputIds.isEmpty()) { + return List.of(policy.output()); + } + List resolved = new ArrayList<>(); + for (String outputId : outputIds) { + sourceStore + .get(outputId) + .map(Source::toOutputSpec) + .ifPresentOrElse( + resolved::add, + () -> + log.warn( + "Policy {} references missing output source {}; skipping" + + " that destination", + policy.id(), + outputId)); + } + return resolved.isEmpty() ? List.of(policy.output()) : resolved; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index 117d78de40..c927ec199c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -4,6 +4,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; @@ -75,10 +76,25 @@ public class PolicyOverviewService { triggerSummary(policy.trigger()), sources, steps, - outputSummary(policy.output()), + outputSummary(policy, sourceNames), policy.owner()); } + /** + * A policy that delivers to sources shows those locations' display names, comma-joined (each + * falling back to its id if it's since been deleted or isn't visible); otherwise the inline + * output's type. + */ + private static String outputSummary(Policy policy, Map sourceNames) { + List outputIds = policy.outputIds(); + if (!outputIds.isEmpty()) { + return outputIds.stream() + .map(id -> sourceNames.getOrDefault(id, id)) + .collect(Collectors.joining(", ")); + } + return outputSummary(policy.output()); + } + /** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */ private static String triggerSummary(TriggerConfig trigger) { return trigger == null ? "manual" : trigger.type(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java index e324cda525..bbbb9cb8b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java @@ -6,8 +6,8 @@ import java.util.Map; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,6 +18,7 @@ import stirling.software.proprietary.integration.model.IntegrationConfig; import stirling.software.proprietary.integration.model.IntegrationType; import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.migration.CompletedMigrations; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.Source; @@ -45,6 +46,9 @@ import tools.jackson.databind.ObjectMapper; @RequiredArgsConstructor public class EmbeddedS3CredentialMigration { + /** Completion-marker id: once recorded, later boots skip the scan entirely. */ + private static final String MIGRATION_ID = "embedded-s3-credentials"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final List CONNECTION_OPTIONS = List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey"); @@ -56,10 +60,19 @@ public class EmbeddedS3CredentialMigration { private final PolicyStore policyStore; private final IntegrationConfigRepository connections; private final TeamRepository teamRepository; + private final CompletedMigrations completedMigrations; + // Must run before PolicyInlineOutputMigration: that migration copies a policy's inline output + // options into a Source, so embedded S3 credentials have to be extracted into a connection here + // first, or they would be copied verbatim (plaintext) into the new source row. Each rewrite is + // its own idempotent commit (dedup by credential key), so a crash mid-run just re-runs next + // boot; the completion marker below is written only once the whole pass succeeds. + @Order(1) @EventListener(ApplicationReadyEvent.class) - @Transactional public void migrate() { + if (completedMigrations.isDone(MIGRATION_ID)) { + return; + } Map byCredentialKey = indexExistingConnections(); int migrated = 0; for (Source source : sourceStore.all()) { @@ -89,6 +102,7 @@ public class EmbeddedS3CredentialMigration { if (migrated > 0) { log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated); } + completedMigrations.markDone(MIGRATION_ID); } private static boolean embedsCredentials(Map options) { @@ -196,15 +210,6 @@ public class EmbeddedS3CredentialMigration { } private static Policy withOutput(Policy policy, OutputSpec output) { - return new Policy( - policy.id(), - policy.name(), - policy.owner(), - policy.enabled(), - policy.trigger(), - policy.sourceIds(), - policy.steps(), - output, - policy.teamId()); + return policy.withOutput(output); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java index ca80c1b46f..c064a33d9a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java @@ -3,14 +3,18 @@ package stirling.software.proprietary.policy.source; import java.util.Map; import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.OutputSpec; /** - * A persisted, reusable input connection: the instantiation of a source definition. Policies - * reference sources by {@code id} rather than embedding their config, so one connection is - * configured once and can feed many policies. + * A persisted, reusable storage location: the instantiation of a source definition. Policies + * reference sources by {@code id} rather than embedding their config, so one location is configured + * once and can be used by many policies - as an input (files come from it) and/or as an output (a + * run's files are delivered to it), which is how a folder or bucket can be both the output of one + * pipeline and the input of the next. * - *

{@code type} keys an {@link stirling.software.proprietary.policy.input.InputSource} bean, - * matching {@link InputSpec#type()}; {@code options} is that source's config. {@code owner} and + *

{@code type} keys an {@link stirling.software.proprietary.policy.input.InputSource} bean (and, + * for writable types, a {@link stirling.software.proprietary.policy.output.PolicyOutputSink}), + * matching {@link InputSpec#type()}; {@code options} is that location's config. {@code owner} and * {@code teamId} scope the source to a team, mirroring {@link * stirling.software.proprietary.policy.model.Policy}. */ @@ -27,8 +31,17 @@ public record Source( options = options == null ? Map.of() : options; } - /** The runtime form the policy engine resolves and runs against. */ + /** The runtime form the policy engine resolves and reads inputs from. */ public InputSpec toInputSpec() { return new InputSpec(type, options); } + + /** + * The runtime form the policy engine delivers a run's outputs to, when this source is used as a + * policy's destination. Read-only options (e.g. a folder's consume mode) are simply ignored by + * the output sink. + */ + public OutputSpec toOutputSpec() { + return new OutputSpec(type, options); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java index 6539d9a34d..73622738f0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -307,10 +307,17 @@ public class SourceController { } } - /** Names of the caller's visible policies that reference the given source. */ + /** + * Names of the caller's visible policies that reference the given source - as an input ({@code + * sourceIds}) or as their output destination ({@code outputId}), so a location in use either + * way is protected from deletion. + */ private List referencingPolicyNames(String sourceId) { return policyAccessGuard.visibleFrom(policyStore).stream() - .filter(policy -> policy.sourceIds().contains(sourceId)) + .filter( + policy -> + policy.sourceIds().contains(sourceId) + || policy.outputIds().contains(sourceId)) .map(Policy::name) .toList(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 2c02b9f152..0f9df21440 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -3,8 +3,10 @@ package stirling.software.proprietary.policy.source; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; @@ -115,11 +117,17 @@ public class SourceOverviewService { return sources instanceof List list && list.contains(EditorSource.ID); } - /** Policies referencing each source id, across the caller's visible policies. */ + /** + * Policies referencing each source id, across the caller's visible policies. A source counts + * whether a policy reads from it ({@code sourceIds}) or writes to it ({@code outputId}); a + * policy that does both counts once. + */ private static Map> referencesBySource(List policies) { Map> bySource = new HashMap<>(); for (Policy policy : policies) { - for (String sourceId : policy.sourceIds()) { + Set referenced = new LinkedHashSet<>(policy.sourceIds()); + referenced.addAll(policy.outputIds()); + for (String sourceId : referenced) { bySource.computeIfAbsent(sourceId, key -> new ArrayList<>()).add(policy); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 685ddb21db..6498e7e28a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -36,6 +36,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.sourceIds(), policy.steps(), policy.output(), + policy.outputIds(), policy.teamId()); policies.put(id, stored); // Existing policy keeps its position; a new one appends to the end of its team's queue. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 41da23c734..1b598b88c1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -44,6 +44,7 @@ public class JpaPolicyStore implements PolicyStore { policy.sourceIds(), policy.steps(), policy.output(), + policy.outputIds(), policy.teamId()); PolicyEntity entity = new PolicyEntity(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index a9c567885f..a5bb305e06 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -34,6 +34,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.migration", "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.repository", @@ -46,6 +47,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.migration", "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.model", diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index cf33de8656..351eecd581 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -135,7 +135,7 @@ class PolicyControllerTest { private static PipelineDefinition definitionWithStep() { return new PipelineDefinition( - "pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null); + "pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), List.of()); } private static Policy policy(String id, Long teamId) { @@ -190,7 +190,7 @@ class PolicyControllerTest { @Test @DisplayName("rejects a pipeline with no steps") void rejectsEmptyPipeline() { - PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), null); + PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); assertThatThrownBy(() -> controller.run(empty, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class) @@ -241,7 +241,7 @@ class PolicyControllerTest { @Test @DisplayName("rejects a pipeline with no steps") void rejectsEmpty() { - PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), null); + PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); assertThatThrownBy(() -> controller.runStream(empty, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index 78348fa5c4..e69f0f5476 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -15,8 +15,10 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -37,6 +39,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.client.HttpClientErrorException; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.FileStorage; import stirling.software.common.service.FileStorage.StoredFile; import stirling.software.common.service.InternalApiClient; @@ -55,7 +58,11 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.output.InlineOutputSink; +import stirling.software.proprietary.policy.output.OutputDelivery; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; +import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; import tools.jackson.databind.json.JsonMapper; @@ -81,6 +88,7 @@ class PolicyEngineTest { @TempDir Path tempDir; + private final RecordingSink recordingSink = new RecordingSink(); private PolicyRunRegistry registry; private PolicyEngine engine; @@ -98,6 +106,7 @@ class PolicyEngineTest { JsonMapper.builder().build()); registry = new PolicyRunRegistry(new ApplicationProperties()); InlineOutputSink sink = new InlineOutputSink(fileStorage); + PolicyOutputResolver outputResolver = new PolicyOutputResolver(new InProcessSourceStore()); engine = new PolicyEngine( executor, @@ -105,7 +114,8 @@ class PolicyEngineTest { registry, fileStorage, jobOwnershipService, - List.of(sink), + List.of(sink, recordingSink), + outputResolver, resourceMonitor, jobQueue); @@ -156,6 +166,36 @@ class PolicyEngineTest { verify(taskManager, atLeastOnce()).addNote(eq(runId), anyString()); } + @Test + void deliversTheRunsFilesToEveryDestination() throws Exception { + when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false); + stubEndpoint(COMPRESS, pdf("compressed", "out.pdf")); + + // Two destinations of a recording sink; each fully reads the (shared) result file, so this + // also proves the result Resources are re-readable across more than one delivery. + PipelineDefinition definition = + new PipelineDefinition( + "multi", + List.of(new PipelineStep(COMPRESS, Map.of())), + List.of( + new OutputSpec("record", Map.of("dest", "a")), + new OutputSpec("record", Map.of("dest", "b")))); + + PolicyRun run = + engine.submit( + definition, + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals(PolicyRunStatus.COMPLETED, run.getStatus()); + // One result file per destination, and each destination read the same output content. + assertEquals(2, run.getOutputs().size()); + assertEquals(List.of("a:compressed", "b:compressed"), recordingSink.deliveries()); + } + @Test void submitFailsRunWhenAToolErrors() throws Exception { when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); @@ -385,4 +425,50 @@ class PolicyEngineTest { } }; } + + /** + * A test output sink (type "record") that fully reads each delivered file and records + * "{dest}:{content}" per file, so a test can assert the run was delivered to every destination. + */ + private static final class RecordingSink implements PolicyOutputSink { + + private final List deliveries = new ArrayList<>(); + + List deliveries() { + return deliveries; + } + + @Override + public String type() { + return "record"; + } + + @Override + public boolean supports(OutputSpec spec) { + return spec != null && "record".equals(spec.type()); + } + + @Override + public List deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) + throws IOException { + String dest = String.valueOf(spec.options().get("dest")); + List results = new ArrayList<>(); + for (Resource file : outputs) { + byte[] bytes; + try (InputStream is = file.getInputStream()) { + bytes = is.readAllBytes(); + } + deliveries.add(dest + ":" + new String(bytes)); + results.add( + ResultFile.builder() + .fileId("rec-" + dest) + .fileName(dest + "/" + file.getFilename()) + .contentType("application/pdf") + .fileSize(bytes.length) + .build()); + } + return results; + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java index 1549ba62ac..0d1361c5d9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java @@ -95,7 +95,8 @@ class PolicyRunRegistryTest { } private PolicyRun register(String runId) { - PolicyRun run = new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), null)); + PolicyRun run = + new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), List.of())); registry.register(run); return run; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java new file mode 100644 index 0000000000..082ca8cd42 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.policy.output; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.policy.migration.InProcessCompletedMigrations; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link PolicyInlineOutputMigration}: policies carrying a folder/S3 destination inline + * are rewritten to reference a {@link Source} location by id; inline (return-to-caller) policies + * are left untouched; the pass is idempotent; two policies sharing a destination in one team share + * one source; and an output at a location an input source already covers reuses that source. + */ +class PolicyInlineOutputMigrationTest { + + private final PolicyStore policyStore = new InProcessPolicyStore(); + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyInlineOutputMigration migration = + new PolicyInlineOutputMigration( + policyStore, sourceStore, new InProcessCompletedMigrations()); + + @Test + void migratesAFolderPolicyToAStoredSource() { + Policy saved = policyStore.save(folderPolicy("Archive", "/out")); + + migration.migrate(); + + Policy migrated = policyStore.get(saved.id()).orElseThrow(); + assertEquals(1, migrated.outputIds().size()); + Source destination = sourceStore.get(migrated.outputIds().get(0)).orElseThrow(); + assertEquals("folder", destination.type()); + assertEquals("/out", destination.options().get("directory")); + } + + @Test + void leavesInlinePoliciesUntouched() { + Policy saved = + policyStore.save( + new Policy( + null, + "Editor run", + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + + migration.migrate(); + + assertTrue(policyStore.get(saved.id()).orElseThrow().outputIds().isEmpty()); + assertTrue(sourceStore.all().isEmpty()); + } + + @Test + void isIdempotent() { + policyStore.save(folderPolicy("Archive", "/out")); + + migration.migrate(); + int afterFirst = sourceStore.all().size(); + migration.migrate(); + + assertEquals(afterFirst, sourceStore.all().size()); + } + + @Test + void skipsTheScanOnceComplete() { + // First pass records the completion marker (even with nothing to migrate). + migration.migrate(); + + // A migratable folder policy created afterwards is left untouched: the marker means the + // migration never scans again, rather than re-scanning and finding it every boot. + Policy later = policyStore.save(folderPolicy("Late", "/late")); + migration.migrate(); + + assertTrue(policyStore.get(later.id()).orElseThrow().outputIds().isEmpty()); + assertTrue(sourceStore.all().isEmpty()); + } + + @Test + void dedupesASharedDestinationWithinATeam() { + policyStore.save(teamFolderPolicy("A", "/shared", 7L)); + policyStore.save(teamFolderPolicy("B", "/shared", 7L)); + + migration.migrate(); + + assertEquals(1, sourceStore.all().size()); + } + + @Test + void reusesAnExistingInputSourceAtTheSameLocation() { + // An input source already reads /shared (with consume mode); a policy that outputs there + // should link to that same source, not mint a duplicate. + Source existing = + sourceStore.save( + new Source( + null, + "Shared", + "folder", + Map.of("directory", "/shared", "mode", "consume"), + true, + "owner", + 7L)); + Policy saved = policyStore.save(teamFolderPolicy("Writer", "/shared", 7L)); + + migration.migrate(); + + assertEquals(1, sourceStore.all().size()); + assertEquals(List.of(existing.id()), policyStore.get(saved.id()).orElseThrow().outputIds()); + } + + private static Policy folderPolicy(String name, String directory) { + return new Policy( + null, + name, + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.folder(directory)); + } + + private static Policy teamFolderPolicy(String name, String directory, Long teamId) { + return new Policy( + null, + name, + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.folder(directory), + teamId); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java new file mode 100644 index 0000000000..d65c5b24f2 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.policy.output; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; + +/** + * Tests for {@link PolicyOutputResolver}: a policy's {@code outputIds} resolve live to the stored + * sources used as destinations (one spec each), an unreferenced policy keeps its inline output, and + * a dangling reference falls back to inline delivery rather than failing the run. + */ +class PolicyOutputResolverTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyOutputResolver resolver = new PolicyOutputResolver(sourceStore); + + @Test + void resolvesEachOutputIdToItsStoredSource() { + Source archive = sourceStore.save(folder("Archive", "/out")); + Source backup = sourceStore.save(folder("Backup", "/backup")); + + List specs = + resolver.resolve(policy().withOutputIds(List.of(archive.id(), backup.id()))); + + assertEquals(2, specs.size()); + assertEquals("/out", specs.get(0).options().get("directory")); + assertEquals("/backup", specs.get(1).options().get("directory")); + } + + @Test + void anUnreferencedPolicyKeepsItsInlineOutput() { + List specs = resolver.resolve(policy()); + + assertEquals(1, specs.size()); + assertEquals("inline", specs.get(0).type()); + } + + @Test + void whenNoReferencesResolveItFallsBackToInline() { + List specs = + resolver.resolve(policy().withOutputIds(List.of("does-not-exist"))); + + assertEquals(1, specs.size()); + assertEquals("inline", specs.get(0).type()); + } + + private static Source folder(String name, String directory) { + return new Source( + null, name, "folder", Map.of("directory", directory), true, "owner", null); + } + + private static Policy policy() { + return new Policy( + "p1", + "Pipeline", + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java index ff14d8cf81..a794ee104e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java @@ -23,6 +23,7 @@ import stirling.software.proprietary.access.model.OwnerScope; import stirling.software.proprietary.integration.model.IntegrationConfig; import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.migration.InProcessCompletedMigrations; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -49,7 +50,11 @@ class EmbeddedS3CredentialMigrationTest { void setUp() { migration = new EmbeddedS3CredentialMigration( - sourceStore, policyStore, connections, teamRepository); + sourceStore, + policyStore, + connections, + teamRepository, + new InProcessCompletedMigrations()); AtomicLong ids = new AtomicLong(100); // Lenient: the nothing-to-migrate cases never create a connection. lenient().when(connections.findAll()).thenReturn(List.of()); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 48677633c4..913cdf20a4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7771,20 +7771,16 @@ addTool = "Add tool" cancel = "Cancel" chainEmpty = "Add a tool to start building your pipeline." create = "Create pipeline" -directory = "Output folder" -directoryHelp = "Absolute path on the server. Must be within the configured allowed folders." editingUnsupported = "Displaying these tool params for editing is not supported yet." moveDown = "Move down" moveUp = "Move up" name = "Name" namePlaceholder = "e.g. Redaction sweep" -noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly." noToolSettings = "This tool has no configurable settings." operations_one = "Operation ({{count}})" operations_other = "Operations ({{count}})" -output = "Output" +output = "Destinations" removeStep = "Remove operation" -s3PrefixHelp = "Outputs are uploaded under this key prefix." save = "Save changes" scheduleEvery = "Run every" sources = "Sources" @@ -7819,11 +7815,6 @@ active = "Active" paused = "Paused" total = "Pipelines" -[portal.pipelines.output] -folder = "Write to folder" -inline = "Return files" -s3 = "Write to Amazon S3" - [portal.pipelines.run] allProcessed_one = "Nothing to run: the source's {{count}} document has already been processed." allProcessed_other = "Nothing to run: all {{count}} documents in the sources have already been processed." @@ -8704,7 +8695,7 @@ confirm = "Delete" title = "Delete source?" [portal.sources.empty] -description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from." +description = "Connect a storage location so your policies have somewhere to pull data from." title = "No sources connected yet" [portal.sources.kpi] diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index e75711b912..2a4f25b79e 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -29,8 +29,8 @@ export interface OutputSpec { options: Record; } -/** The output destinations the pipeline builder can offer. */ -export type PipelineOutputMode = "inline" | "folder" | "s3"; +/** Source types that can be written to (used as a pipeline's output destination). */ +export type PipelineOutputMode = "folder" | "s3"; /** * The stored policy record: the create/update body (`id` blank on create) and what @@ -45,7 +45,17 @@ export interface Policy { trigger: TriggerConfig | null; sourceIds: string[]; steps: PipelineStep[]; + /** + * Inline output, used only when no destinations are referenced (editor/one-off runs that return + * results to the caller). Portal pipelines set {@link outputIds} instead. + */ output: OutputSpec; + /** + * The saved Sources this policy delivers its output to (each a source used as a write target), + * resolved live at run time; a run is delivered to every one. Empty means the inline {@link + * output} is used. + */ + outputIds: string[]; teamId?: number | null; } diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx new file mode 100644 index 0000000000..683c6301b4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button, Checkbox } from "@app/ui"; + +/** + * Picks the saved sources a pipeline delivers its output to. A destination is just + * a source used as a write target, and a pipeline may write to several, so this is + * a checklist over the same locations the builder loaded (filtered to writable + * types by the caller) - mirroring the input-sources checklist. Creating a new one + * is delegated to {@code onCreateNew} (the builder navigates to the source builder, + * prompting about unsaved edits first). + */ +interface DestinationOption { + id: string; + name: string; +} + +interface DestinationPickerProps { + sources: DestinationOption[]; + value: string[]; + onChange: (outputIds: string[]) => void; + /** Leave the builder to create a new source location (navigate-away, like inputs). */ + onCreateNew: () => void; +} + +export function DestinationPicker({ + sources, + value, + onChange, + onCreateNew, +}: DestinationPickerProps) { + const { t } = useTranslation(); + + function toggle(id: string, checked: boolean) { + onChange( + checked ? [...value, id] : value.filter((existing) => existing !== id), + ); + } + + return ( + <> +

+ {sources.map((source) => ( + toggle(source.id, e.target.checked)} + label={source.name} + /> + ))} +
+ + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/outputModes.ts b/frontend/editor/src/portal/components/pipelines/outputModes.ts index a8e698c676..0d76cf122e 100644 --- a/frontend/editor/src/portal/components/pipelines/outputModes.ts +++ b/frontend/editor/src/portal/components/pipelines/outputModes.ts @@ -1,11 +1,11 @@ import type { PipelineOutputMode } from "@portal/api/pipelines"; /** - * The output destinations the pipeline builder offers. An extension point: - * deployments where a destination cannot work shadow this module and filter - * the list (e.g. hosted deployments never write to the server's filesystem, - * so folder outputs are not offered there). + * The source types that can be written to, i.e. offered as a pipeline's output destination. An + * extension point: deployments where a destination cannot work shadow this module and filter the + * list (e.g. hosted deployments never write to the server's filesystem, so folder destinations are + * not offered there and only S3 remains). */ export function availableOutputModes(): PipelineOutputMode[] { - return ["inline", "folder", "s3"]; + return ["folder", "s3"]; } diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index 4c18da8b6e..fdfa22e4b5 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -53,6 +53,7 @@ function seedPipelines(): StoredPolicy[] { { operation: "/api/v1/security/sanitize-pdf", parameters: {} }, ], output: { type: "inline", options: {} }, + outputIds: ["src-archive", "src-contracts"], }, { id: "plc-archive", @@ -62,7 +63,8 @@ function seedPipelines(): StoredPolicy[] { trigger: null, sourceIds: ["src-contracts", "src-archive"], steps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }], - output: { type: "folder", options: { directory: "/data/archive-out" } }, + output: { type: "inline", options: {} }, + outputIds: ["src-contracts"], }, { id: "plc-onboarding", @@ -76,6 +78,7 @@ function seedPipelines(): StoredPolicy[] { { operation: "/api/v1/misc/flatten", parameters: {} }, ], output: { type: "inline", options: {} }, + outputIds: [], }, ]; } @@ -104,7 +107,10 @@ function toView(policy: StoredPolicy): PipelineView { name: SOURCE_NAMES[id] ?? id, })), steps: policy.steps.map((s) => s.operation), - output: policy.output?.type ?? "inline", + output: + policy.outputIds && policy.outputIds.length > 0 + ? policy.outputIds.map((id) => SOURCE_NAMES[id] ?? id).join(", ") + : (policy.output?.type ?? "inline"), owner: policy.owner ?? "you@acme.com", }; } diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index fe88ad7917..9d3f60f54e 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -8,6 +8,7 @@ import { import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { Policy, TriggerOutcome } from "@portal/api/pipelines"; +import type { SourceView } from "@portal/api/sources"; import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext"; import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; import { PipelineBuilder } from "@portal/views/PipelineBuilder"; @@ -60,6 +61,22 @@ vi.mock("@portal/api/integrations", () => ({ createIntegration: (...args: unknown[]) => createIntegration(...args), })); +// The destination picker just selects saved sources; stub it to a button that +// picks a fixed source, keeping this suite focused on the builder. +vi.mock("@portal/components/pipelines/DestinationPicker", () => ({ + DestinationPicker: ({ + value, + onChange, + }: { + value: string[]; + onChange: (ids: string[]) => void; + }) => ( + + ), +})); + // One editable tool, Compress, so the picker and step settings have something to render. vi.mock("@app/contexts/ToolRegistryContext", () => { const compress = { @@ -113,6 +130,20 @@ const POLICY: Policy = { sourceIds: [], steps: [], output: { type: "inline", options: {} }, + outputIds: [], +}; + +const SOURCE: SourceView = { + id: "src-in", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, }; function outcome(overrides: Partial): TriggerOutcome { @@ -152,7 +183,7 @@ describe("PipelineBuilder", () => { fetchSources.mockReset(); fetchPipeline.mockResolvedValue(POLICY); fetchTriggers.mockResolvedValue([]); - fetchSources.mockResolvedValue({ kpis: [], sources: [] }); + fetchSources.mockResolvedValue({ kpis: [], sources: [SOURCE] }); savePipeline.mockResolvedValue({}); deletePipeline.mockResolvedValue(undefined); triggerPipeline.mockResolvedValue(outcome({ runIds: ["run-1"] })); @@ -175,6 +206,12 @@ describe("PipelineBuilder", () => { fireEvent.click(screen.getByRole("button", { name: /addTool/ })); fireEvent.click(await screen.findByText("Compress")); + // A pipeline must have at least one input source and one output destination. + fireEvent.click( + await screen.findByRole("checkbox", { name: "Claims intake" }), + ); + fireEvent.click(screen.getByText("pick output")); + fireEvent.click(screen.getByText("portal.pipelines.composer.create")); await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); @@ -182,6 +219,8 @@ describe("PipelineBuilder", () => { expect.objectContaining({ name: "Nightly compress", trigger: null, + sourceIds: ["src-in"], + outputIds: ["src-1"], steps: [ expect.objectContaining({ operation: "/api/v1/misc/compress-pdf" }), ], @@ -190,76 +229,30 @@ describe("PipelineBuilder", () => { expect(await screen.findByText("pipelines list")).toBeInTheDocument(); }); - it("saves an s3 output referencing an inline-created connection", async () => { - createIntegration.mockResolvedValue({ id: 12, name: "Claims bucket" }); + it("requires at least one source and one destination before saving", async () => { renderBuilder("/processor/pipelines/new"); fireEvent.change(await screen.findByRole("textbox"), { - target: { value: "Bucket to bucket" }, + target: { value: "Needs both" }, }); - fireEvent.click(screen.getByLabelText("portal.pipelines.output.s3")); + const saveButton = () => + screen.getByText("portal.pipelines.composer.create").closest("button"); - // With s3 selected but no connection chosen, saving is blocked. The - // connection picker + prefix are inline (no modal), like the folder output. - expect( - screen.getByText("portal.pipelines.composer.create").closest("button"), - ).toBeDisabled(); + // Name only: blocked (no source, no destination). + expect(saveButton()).toBeDisabled(); - // No connections exist: create one inline from the picker. Target fields by - // label, not position - the picker's Mantine Select also carries an input - // role and would shift index-based queries. + // A source but still no destination: blocked. fireEvent.click( - await screen.findByText("portal.connections.picker.createNew"), - ); - fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { - target: { value: "Claims bucket" }, - }); - fireEvent.change( - screen.getByLabelText( - /portal\.connections\.types\.s3\.fields\.bucket\.label/, - ), - { target: { value: "claims-processed" } }, - ); - fireEvent.change( - screen.getByLabelText( - /portal\.connections\.types\.s3\.fields\.accessKeyId\.label/, - ), - { target: { value: "AKIAEXAMPLE" } }, - ); - fireEvent.change( - screen.getByLabelText( - /portal\.connections\.types\.s3\.fields\.secretAccessKey\.label/, - ), - { target: { value: "shh-secret" } }, - ); - fireEvent.click(screen.getByText("portal.connections.picker.save")); - await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1)); - // The connection modal closes once saved and the connection is selected. - await waitFor(() => - expect( - screen.queryByText("portal.connections.picker.save"), - ).not.toBeInTheDocument(), + await screen.findByRole("checkbox", { name: "Claims intake" }), ); + expect(saveButton()).toBeDisabled(); - fireEvent.change( - screen.getByLabelText( - /portal\.sources\.types\.s3\.fields\.prefix\.label/, - ), - { target: { value: "processed/" } }, - ); + // Both chosen: allowed, and both are sent. + fireEvent.click(screen.getByText("pick output")); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); - await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); expect(savePipeline).toHaveBeenCalledWith( - expect.objectContaining({ - output: { - type: "s3", - options: { - connectionId: "12", - prefix: "processed/", - }, - }, - }), + expect.objectContaining({ sourceIds: ["src-in"], outputIds: ["src-1"] }), ); }); @@ -408,6 +401,12 @@ describe("PipelineBuilder", () => { ); fireEvent.click(await screen.findByText("Ops alerts")); + // Saving needs at least one input source and one destination. + fireEvent.click( + await screen.findByRole("checkbox", { name: "Claims intake" }), + ); + fireEvent.click(screen.getByText("pick output")); + fireEvent.click(screen.getByText("portal.pipelines.composer.create")); await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index fc23ac1516..0955049605 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -14,7 +14,6 @@ import { Button, Checkbox, EmptyState, - FormField, Input, Modal, RadioGroup, @@ -40,17 +39,15 @@ import { fetchTriggers, savePipeline, triggerPipeline, - type OutputSpec, type Policy, type PolicyRunView, - type PipelineOutputMode, type TriggerConfig, type TriggerInfo, type TriggerOutcome, } from "@portal/api/pipelines"; import { clearProcessedHistory } from "@portal/api/policies"; +import { DestinationPicker } from "@portal/components/pipelines/DestinationPicker"; import { availableOutputModes } from "@portal/components/pipelines/outputModes"; -import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; import { type SourceView } from "@portal/api/sources"; import { useSources } from "@portal/queries/sources"; import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; @@ -70,21 +67,6 @@ import { } from "@portal/components/pipelines/integrationStep"; import "@portal/views/PipelineBuilder.css"; -type OutputMode = PipelineOutputMode; - -/** New pipelines (and specs of unoffered types) start on the first offered destination. */ -const DEFAULT_OUTPUT_MODE = availableOutputModes()[0]; - -/** The s3 output's options: a stored connection reference plus the per-use prefix. */ -interface S3OutputOptions { - connectionId: string; - prefix: string; -} - -const EMPTY_S3_OUTPUT: S3OutputOptions = { - connectionId: "", - prefix: "", -}; type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS"; const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"]; @@ -123,31 +105,6 @@ function parseTrigger(trigger: TriggerConfig | null): { return { triggerType: trigger.type, count: "1", unit: "HOURS" }; } -function parseOutput(output: OutputSpec | undefined): { - mode: OutputMode; - directory: string; - s3: S3OutputOptions; -} { - if (output?.type === "folder") { - return { - mode: "folder", - directory: String(output.options?.directory ?? ""), - s3: EMPTY_S3_OUTPUT, - }; - } - if (output?.type === "s3") { - return { - mode: "s3", - directory: "", - s3: { - connectionId: String(output.options?.connectionId ?? ""), - prefix: String(output.options?.prefix ?? ""), - }, - }; - } - return { mode: DEFAULT_OUTPUT_MODE, directory: "", s3: EMPTY_S3_OUTPUT }; -} - /** * Full-page pipeline builder (route: /pipelines/new and /pipelines/:id). Pipeline-level settings * (sources, trigger, output) sit above the operation list; the operation list and the selected @@ -193,6 +150,15 @@ export function PipelineBuilder() { ), [sourcesState.data], ); + // A destination is a source used as a write target: only writable types (folder/S3, filtered per + // deployment) can be picked, and the virtual editor is already excluded from availableSources. + const writableSources = useMemo( + () => + availableSources.filter((source) => + (availableOutputModes() as string[]).includes(source.type), + ), + [availableSources], + ); const triggers = useMemo( () => triggersState.data ?? [], [triggersState.data], @@ -207,9 +173,7 @@ export function PipelineBuilder() { const [triggerType, setTriggerType] = useState(MANUAL); const [scheduleCount, setScheduleCount] = useState("1"); const [scheduleUnit, setScheduleUnit] = useState("HOURS"); - const [outputMode, setOutputMode] = useState(DEFAULT_OUTPUT_MODE); - const [outputDirectory, setOutputDirectory] = useState(""); - const [outputS3, setOutputS3] = useState(EMPTY_S3_OUTPUT); + const [outputIds, setOutputIds] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [seeded, setSeeded] = useState(false); @@ -234,7 +198,6 @@ export function PipelineBuilder() { if (isEdit && !policyState.data) return; const policy = policyState.data ?? undefined; const trigger = parseTrigger(policy?.trigger ?? null); - const output = parseOutput(policy?.output); setName(policy?.name ?? ""); setEnabled(policy?.enabled ?? true); setSourceIds(policy?.sourceIds ?? []); @@ -244,9 +207,7 @@ export function PipelineBuilder() { setTriggerType(trigger.triggerType); setScheduleCount(trigger.count); setScheduleUnit(trigger.unit); - setOutputMode(output.mode); - setOutputDirectory(output.directory); - setOutputS3(output.s3); + setOutputIds(policy?.outputIds ?? []); setSeeded(true); }, [isEdit, policyState.data, allTools, seeded]); @@ -371,9 +332,7 @@ export function PipelineBuilder() { triggerType, scheduleCount, scheduleUnit, - outputMode, - outputDirectory, - outputS3, + outputIds: [...outputIds].sort(), }); const baseline = useRef(null); useEffect(() => { @@ -383,12 +342,12 @@ export function PipelineBuilder() { const scheduleCountValid = triggerType !== "schedule" || Number(scheduleCount) > 0; - const s3OutputValid = - outputMode !== "s3" || outputS3.connectionId.trim() !== ""; - const outputValid = - (outputMode !== "folder" || outputDirectory.trim() !== "") && s3OutputValid; + // A pipeline must have at least one input source and at least one output destination. + const sourceValid = sourceIds.length > 0; + const outputValid = outputIds.length > 0; const canSave = name.trim() !== "" && + sourceValid && scheduleCountValid && outputValid && !hasUploadSteps && @@ -436,8 +395,8 @@ export function PipelineBuilder() { else navigate(destination); } - // Jump to the Sources page with its create wizard open, for when the source you want to run - // this pipeline over doesn't exist yet. + // Jump to the source builder, for when the source you want to read from or write to doesn't + // exist yet. Inputs and the output destination are both saved sources, so both create one here. function goToSources() { attemptLeave(sourcesPath); } @@ -446,12 +405,6 @@ export function PipelineBuilder() { if (!canSave) return; setSubmitting(true); setError(null); - const output: OutputSpec = - outputMode === "folder" - ? { type: "folder", options: { directory: outputDirectory.trim() } } - : outputMode === "s3" - ? { type: "s3", options: { ...outputS3 } } - : { type: "inline", options: {} }; const policy: Policy = { id: policyState.data?.id ?? undefined, name: name.trim(), @@ -459,7 +412,10 @@ export function PipelineBuilder() { trigger: buildTrigger(), sourceIds, steps: steps.map((step) => serializeToolStep(step, allTools)), - output, + // Destinations are the referenced saved sources; the inline output field is + // preserved as-is (e.g. an editor policy's membership metadata) or defaults to inline. + output: policyState.data?.output ?? { type: "inline", options: {} }, + outputIds, }; try { await savePipeline(policy); @@ -716,10 +672,6 @@ export function PipelineBuilder() {

{t("portal.pipelines.composer.sourcesLoading")}

- ) : availableSources.length === 0 ? ( -

- {t("portal.pipelines.composer.noSources")} -

) : (
{availableSources.map((source) => ( @@ -787,55 +739,12 @@ export function PipelineBuilder() { {t("portal.pipelines.composer.output")} - - name="pipeline-output" - value={outputMode} - onChange={setOutputMode} - options={availableOutputModes().map((mode) => ({ - value: mode, - label: t(`portal.pipelines.output.${mode}`), - }))} + - {outputMode === "folder" && ( - - setOutputDirectory(e.target.value)} - /> - - )} - {outputMode === "s3" && ( - <> - - - setOutputS3((s) => ({ ...s, connectionId })) - } - /> - - - - setOutputS3((s) => ({ ...s, prefix: e.target.value })) - } - /> - - - )}
diff --git a/frontend/editor/src/proprietary/services/policyPipeline.test.ts b/frontend/editor/src/proprietary/services/policyPipeline.test.ts index e6017e99b1..776495c344 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.test.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.test.ts @@ -32,7 +32,7 @@ describe("buildPipelineDefinition", () => { expect(unresolved).toEqual([]); expect(definition.name).toBe("Secure Ingestion"); - expect(definition.output).toEqual({ type: "inline", options: {} }); + expect(definition.outputs).toEqual([{ type: "inline", options: {} }]); expect(definition.steps).toEqual([ { operation: "/api/v1/misc/compress-pdf", parameters: {} }, { diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index c04adb78b6..ed073b9b1c 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -32,7 +32,8 @@ export interface BackendOutputSpec { export interface BackendPipelineDefinition { name: string; steps: BackendPipelineStep[]; - output: BackendOutputSpec; + /** Destinations a run's files are delivered to; a single inline entry for one-off/editor runs. */ + outputs: BackendOutputSpec[]; } /** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */ @@ -191,7 +192,7 @@ export function buildPipelineDefinition( definition: { name: automation.name, steps, - output: { type: "inline", options: {} }, + outputs: [{ type: "inline", options: {} }], }, unresolved, }; From 8a5470dd019594c391e99e347712e61574ec731f Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:28:24 +0100 Subject: [PATCH 009/262] Add an accessibility regression gate for Storybook (#7086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Follow-up to #7073. Turns the story scan into an accessibility gate: stories run axe in a real browser, and CI flags a change that adds a **new** violation. The app has plenty of existing a11y problems (mostly theme-level colour contrast), so rather than block everything on those, they're recorded in `.storybook/a11y-baseline.json` and grandfathered. The gate cares about three things: - a story breaking a rule it wasn't already breaking - a story that fails to render at all - a scan that didn't cover everything it was asked to Starting point: 839 stories carry a known violation, 1058 story-rule pairs. ## Where it runs - **Pull requests** scan only the stories the branch touches — usually seconds. A full sweep is ~30 minutes, too slow to sit in front of every merge, and the `frontend` path filter is broad enough that unrelated changes would pay for it. - **Nightly** scans every story, so a violation introduced somewhere other than the story itself — a shared component, a theme token — still surfaces within a day. - Both upload their scan reports as artifacts; the reports carry the offending selector and help text, without which a red run can only be understood by reproducing it locally. - **Advisory to start with.** It is deliberately not in `all-checks-passed`, so it reports without blocking. Worth promoting once a few weeks of runs show the pass/fail is stable. ## Using it - **Fixed some violations?** `task frontend:storybook:a11y:record` re-records so the gate locks the improvement in. - **Locally:** `task frontend:storybook:a11y:changed` for your branch, `task frontend:storybook:a11y` for everything. - **New component?** Its story is picked up automatically. ## Testing - Every story — 526 files, ~1,450 stories — runs in a real browser with no render failures, and the gate reports no regressions against the baseline. - Running the gate over a single changed story takes seconds, which is the pull-request path. - The gate's own behaviour is covered against synthetic scan reports: a new rule fails, the same rule on more nodes does not, a crashed story fails, an incomplete scan refuses to report, and re-recording refuses while anything is crashing. - Typecheck (all build variants), ESLint and Prettier pass. ## Notes for reviewers Some of this PR is making the mechanism trustworthy rather than adding features, so it's worth knowing what changed and why: - Rule ids come from the axe docs URL in each violation, not a hand-maintained list of rule names — the old list silently ignored 39 of axe's 104 rules, including `object-alt`, `target-size` and the table rules. - The baseline records **which** rules a story breaks, not how many nodes break them. Node counts drift between runs because stories fetch asynchronously and axe samples whatever has rendered, which made unrelated changes look like regressions. For the same reason the baseline is the union of repeated scans, so a run can only be a subset of it. - A story that fails for a non-a11y reason used to yield no rule id and was recorded as clean, which hid crashes and could mask real violations. Those now fail, and re-recording refuses to run while any story is crashing. - The scan writes a manifest of every story file it intends to cover and the check fails unless all of them reported, so a dropped batch can't read as "no violations". - Vite was pre-bundling the JSX runtime mid-run and reloading the page, which crashed whichever stories were loading; those deps are now named up front and the per-story timeout is above the 5s default. Colour contrast dominates the baseline and is theme-level, tracked separately from this. --- .github/config/.files.yaml | 1 + .github/workflows/build.yml | 11 + .github/workflows/frontend-a11y.yml | 57 + .github/workflows/nightly.yml | 40 + .taskfiles/frontend.yml | 44 +- frontend/.gitignore | 1 + frontend/.storybook/a11y-baseline.json | 2622 ++++++++++++++++++++++++ frontend/.storybook/a11y-check.mjs | 202 ++ frontend/.storybook/a11y-scan.sh | 84 + frontend/.storybook/preview.tsx | 10 +- frontend/.storybook/vitest.config.ts | 20 +- frontend/.storybook/vitest.setup.ts | 11 +- 12 files changed, 3083 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/frontend-a11y.yml create mode 100644 frontend/.storybook/a11y-baseline.json create mode 100644 frontend/.storybook/a11y-check.mjs create mode 100644 frontend/.storybook/a11y-scan.sh diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 64454c5fe1..a9d4d3550e 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -84,6 +84,7 @@ frontend: &frontend - .taskfiles/frontend.yml - .taskfiles/e2e.yml - .github/workflows/frontend-validation.yml + - .github/workflows/frontend-a11y.yml - .github/workflows/e2e-stubbed.yml - .github/workflows/e2e-live.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d4130f214..c8275c023b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,6 +99,17 @@ jobs: uses: ./.github/workflows/frontend-validation.yml secrets: inherit + # Advisory: deliberately NOT in all-checks-passed. It reports on the stories a + # branch touches so a regression is visible in review, but a browser scan is + # too new here to block merges on. Promote it once its pass/fail proves stable. + frontend-a11y: + if: needs.files-changed.outputs.frontend == 'true' + needs: [files-changed] + permissions: + contents: read + uses: ./.github/workflows/frontend-a11y.yml + secrets: inherit + playwright-e2e: if: needs.files-changed.outputs.frontend == 'true' needs: [files-changed] diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml new file mode 100644 index 0000000000..d763447048 --- /dev/null +++ b/.github/workflows/frontend-a11y.yml @@ -0,0 +1,57 @@ +name: Frontend a11y regression gate + +# Reusable workflow called from build.yml when frontend sources change. +# +# Scans the stories this branch touches in real Chromium and runs axe against +# each. Existing violations are grandfathered in .storybook/a11y-baseline.json; +# the check fails on a NEW violation — a story breaking a rule it wasn't already +# breaking — or on a story that fails to render at all. +# +# Only changed stories, because a full sweep is ~30 minutes: far too slow to sit +# in front of every merge. The whole suite is scanned nightly instead +# (nightly.yml), which catches anything a branch didn't touch. +# +# Advisory for now: this is not in build.yml's all-checks-passed list, so a +# failure reports without blocking. Promote it once a few weeks of runs show the +# pass/fail is stable. +on: + workflow_call: + +permissions: + contents: read + +jobs: + frontend-a11y: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Need the base branch too, to diff against it. + fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - name: a11y gate (changed stories) + run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }} + - name: Upload scan reports + # The reports carry the offending selector and help text for each + # violation; without them a red run can only be understood by + # reproducing the whole scan locally. + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: a11y-scan-${{ github.run_id }} + path: frontend/.a11y-scan/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4047157a45..301a1fc8df 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -53,6 +53,46 @@ jobs: path: frontend/playwright-report/ retention-days: 14 + # Whole-suite accessibility sweep. Pull requests only scan the stories they + # touch (frontend-a11y.yml) because a full pass takes ~30 minutes; this covers + # everything else, so a violation introduced by a change somewhere other than + # the story itself — a shared component, a theme token — still surfaces within + # a day. + a11y-all-stories: + name: a11y (every story) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + - name: a11y gate (every story) + run: task frontend:storybook:a11y + + - name: Upload scan reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: a11y-scan-nightly-${{ github.run_id }} + path: frontend/.a11y-scan/ + retention-days: 14 + if-no-files-found: ignore + # Builds all desktop platforms on a schedule so the Rust dependency cache is # written on main, where PR and merge-queue tauri builds can restore it. warm-tauri-cache: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 3e608855f3..1338aae8e1 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -203,13 +203,55 @@ tasks: - npx playwright install chromium storybook:test: - desc: "Scan every story in real Chromium — each must mount without throwing" + desc: "Scan every story in real Chromium: it must render and pass axe" deps: [install, storybook:browser] cmds: # Runs each story as a browser test. Pass a filter through, e.g. # task frontend:storybook:test -- Button - npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}} + storybook:a11y: + desc: "a11y regression gate over every story: fail only on NEW axe violations" + deps: [install, storybook:browser] + cmds: + - bash .storybook/a11y-scan.sh + - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt + + storybook:a11y:changed: + desc: "a11y gate over stories changed vs a base ref (default origin/main)" + summary: | + Scans only the stories this branch touches, which is what pull requests + run — a full scan takes ~30 minutes, far too long to sit in front of every + merge. The nightly job covers the rest of the suite. + + Pass a base ref through CLI_ARGS, e.g. + task frontend:storybook:a11y:changed -- origin/release + deps: [install, storybook:browser] + vars: + BASE: '{{.CLI_ARGS | default "origin/main"}}' + # Stories touched by this branch, plus any not yet committed. + CHANGED: + sh: | + { git diff --name-only --diff-filter=d {{.CLI_ARGS | default "origin/main"}}...HEAD -- '*.stories.ts' '*.stories.tsx'; + git diff --name-only --diff-filter=d -- '*.stories.ts' '*.stories.tsx'; + git ls-files --others --exclude-standard -- '*.stories.ts' '*.stories.tsx'; } \ + | sed 's|^frontend/||' | sort -u | tr '\n' ' ' + cmds: + - cmd: | + if [ -z "{{.CHANGED}}" ]; then + echo "a11y: no story files changed vs {{.BASE}} — nothing to check" + exit 0 + fi + bash .storybook/a11y-scan.sh {{.CHANGED}} + node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt + + storybook:a11y:record: + desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)" + deps: [install, storybook:browser] + cmds: + - bash .storybook/a11y-scan.sh + - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record + # ============================================================ # Code quality # ============================================================ diff --git a/frontend/.gitignore b/frontend/.gitignore index 0605e6e79f..c0c467073d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -48,3 +48,4 @@ test-results /scripts/dev-update-test/.update-dist/ /scripts/dev-update-test/screenshots/ /editor/src-tauri/tauri.conf.dev-update.json +.a11y-scan/ diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json new file mode 100644 index 0000000000..10fc530c77 --- /dev/null +++ b/frontend/.storybook/a11y-baseline.json @@ -0,0 +1,2622 @@ +{ + "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ + "aria-input-field-name", + "button-name", + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ + "aria-input-field-name", + "button-name", + "color-contrast" + ], + "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ + "color-contrast" + ], + "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Multiple Files": [ + "color-contrast" + ], + "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Compact": [ + "color-contrast" + ], + "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ + "aria-required-children" + ], + "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Shared With Me": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ + "aria-dialog-name", + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ + "aria-dialog-name", + "aria-progressbar-name" + ], + "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ + "aria-dialog-name", + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Small": [ + "color-contrast" + ], + "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ + "label" + ], + "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ + "label" + ], + "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ + "color-contrast", + "label-title-only" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ + "color-contrast" + ], + "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Custom Fallback": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileGrid.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ + "color-contrast", + "label" + ], + "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ + "button-name", + "color-contrast", + "label" + ], + "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Custom Placeholder": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ + "color-contrast" + ], + "editor/src/core/components/shared/Footer.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ + "color-contrast" + ], + "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ + "button-name", + "color-contrast", + "svg-img-alt" + ], + "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ + "color-contrast" + ], + "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/OverviewHeader.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Large Size": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ + "label" + ], + "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ + "label" + ], + "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Minimal Links": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Frontend": [ + "color-contrast" + ], + "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Encrypted": [ + "color-contrast" + ], + "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Multiple Files Selected": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: No File Selected": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ + "color-contrast" + ], + "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: With Signature": [ + "color-contrast" + ], + "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: With Tool Name": [ + "color-contrast" + ], + "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/ToolRenderer.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Text Stamp With Preview": [ + "color-contrast" + ], + "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ + "button-name", + "color-contrast", + "label" + ], + "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ + "button-name", + "color-contrast", + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ + "button-name", + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ + "color-contrast" + ], + "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ + "button-name", + "color-contrast", + "label" + ], + "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Auto Sign Mode": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Invisible": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Finalized": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ + "button-name" + ], + "editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ + "color-contrast" + ], + "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ + "color-contrast" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "color-contrast", + "label" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ + "aria-input-field-name", + "color-contrast", + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Azw 3 Output": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Custom Area": [ + "color-contrast" + ], + "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx :: Automation Info": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ + "color-contrast" + ], + "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Custom Render Dpi": [ + "color-contrast" + ], + "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Flatten Only Forms": [ + "color-contrast" + ], + "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: No Data": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: All Passed": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Hidden Title": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Custom Empty Message": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ + "button-name" + ], + "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx :: Invalid Input": [ + "color-contrast" + ], + "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ + "button-name", + "label" + ], + "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: All Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Unknown Type": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Last File": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/NoToolsFound.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ + "color-contrast", + "label" + ], + "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ + "label" + ], + "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: With Help Text And Number": [ + "color-contrast" + ], + "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ + "color-contrast" + ], + "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Admin With Shared Delete": [ + "color-contrast" + ], + "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: At Capacity": [ + "color-contrast" + ], + "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Poster": [ + "color-contrast" + ], + "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Sections": [ + "color-contrast" + ], + "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: No Method Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ + "aria-prohibited-attr" + ], + "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ + "aria-prohibited-attr" + ], + "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ + "aria-prohibited-attr" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ + "aria-allowed-attr", + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ + "aria-allowed-attr", + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Empty Value": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Missing Metadata": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: No Signatures": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ + "aria-allowed-attr", + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ + "aria-allowed-attr", + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ + "aria-allowed-attr", + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ + "aria-allowed-attr" + ], + "editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Unsupported File": [ + "color-contrast" + ], + "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Csv": [ + "color-contrast" + ], + "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Unsupported": [ + "color-contrast" + ], + "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Hidden": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Tsv": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx :: Invalid Json": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ + "color-contrast" + ], + "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], + "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ + "button-name" + ], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": ["color-contrast"], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": ["color-contrast"], + "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], + "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], + "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ + "color-contrast" + ], + "editor/src/core/ui/Chip.stories.tsx :: Playground": [ + "color-contrast", + "nested-interactive" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ + "color-contrast" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ + "color-contrast" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], + "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], + "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/Inline.stories.tsx :: Space Between": ["color-contrast"], + "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], + "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], + "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], + "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ + "color-contrast" + ], + "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ + "color-contrast" + ], + "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ + "color-contrast" + ], + "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ + "color-contrast" + ], + "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ + "color-contrast" + ], + "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ + "color-contrast" + ], + "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], + "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], + "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], + "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": ["label"], + "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], + "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], + "editor/src/core/ui/StatusBadge.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/StatusBadge.stories.tsx :: Live": ["color-contrast"], + "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], + "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], + "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], + "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ + "color-contrast" + ], + "editor/src/core/ui/Tabs.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ + "color-contrast" + ], + "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], + "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ + "color-contrast" + ], + "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ + "color-contrast" + ], + "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ + "aria-allowed-role" + ], + "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ + "aria-allowed-role" + ], + "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ + "aria-hidden-focus" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ + "aria-hidden-focus", + "color-contrast" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ + "aria-hidden-focus" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ + "aria-hidden-focus" + ], + "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ + "color-contrast" + ], + "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ + "color-contrast" + ], + "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ + "color-contrast" + ], + "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ + "color-contrast" + ], + "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ + "color-contrast" + ], + "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ + "color-contrast" + ], + "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ + "color-contrast" + ], + "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ + "color-contrast" + ], + "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ + "color-contrast" + ], + "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ + "color-contrast" + ], + "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ + "color-contrast" + ], + "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ + "color-contrast" + ], + "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ + "color-contrast" + ], + "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ + "color-contrast" + ], + "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ + "color-contrast" + ], + "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ + "color-contrast" + ], + "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ + "color-contrast" + ], + "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ + "color-contrast" + ], + "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ + "color-contrast" + ], + "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/DocsSection.stories.tsx :: Without Lead": [ + "color-contrast" + ], + "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ + "color-contrast" + ], + "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ + "color-contrast" + ], + "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ + "aria-prohibited-attr", + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ + "aria-prohibited-attr", + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ + "empty-table-header" + ], + "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/infrastructure/SecurityTab.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/StorageTab.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ + "empty-table-header" + ], + "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ + "aria-dialog-name" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ + "aria-dialog-name" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Schedule Call": [ + "aria-dialog-name" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ + "aria-dialog-name" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage Maxed": [ + "aria-dialog-name" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ + "color-contrast" + ], + "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ + "color-contrast" + ], + "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ + "color-contrast" + ], + "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ + "color-contrast" + ], + "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ + "color-contrast" + ], + "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ + "color-contrast" + ], + "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ + "color-contrast" + ], + "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ + "color-contrast" + ], + "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], + "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ + "color-contrast" + ], + "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], + "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ + "color-contrast" + ], + "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ + "color-contrast", + "landmark-unique" + ], + "editor/src/portal/views/Documents.stories.tsx :: Default": [ + "aria-prohibited-attr", + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/views/Documents.stories.tsx :: Empty": [ + "aria-prohibited-attr", + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ + "color-contrast", + "landmark-no-duplicate-banner", + "landmark-unique" + ], + "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], + "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ + "color-contrast", + "landmark-no-duplicate-banner", + "landmark-unique" + ], + "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ + "color-contrast", + "landmark-no-duplicate-banner", + "landmark-unique" + ], + "editor/src/portal/views/Integrations.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ + "color-contrast" + ], + "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], + "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], + "editor/src/portal/views/Sources.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Sources.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ + "aria-hidden-focus" + ], + "editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx :: With Errors": [ + "color-contrast" + ], + "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ + "color-contrast" + ], + "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ + "color-contrast" + ], + "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ + "button-name", + "color-contrast", + "label" + ], + "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ + "color-contrast", + "label" + ], + "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ + "label" + ], + "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ + "label" + ], + "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ + "label" + ], + "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Sso User": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Load Error": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: With File Metadata Columns": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ + "color-contrast", + "label" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Enterprise Plan": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Free Plan": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise With Total": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Simple": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Current": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Popular": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Redirecting": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ + "color-contrast" + ], + "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Completed": [ + "color-contrast" + ], + "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Expired": [ + "color-contrast" + ], + "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ + "image-redundant-alt" + ] +} diff --git a/frontend/.storybook/a11y-check.mjs b/frontend/.storybook/a11y-check.mjs new file mode 100644 index 0000000000..7eef5e079e --- /dev/null +++ b/frontend/.storybook/a11y-check.mjs @@ -0,0 +1,202 @@ +// a11y regression gate — baseline diff. +// +// Consumes the Storybook Vitest scan's JSON reporter output (one or more files +// in --in) and compares each story's axe rule violations against +// .storybook/a11y-baseline.json. Existing violations are grandfathered; the gate +// fails when a story breaks a rule it wasn't already breaking, when a story +// crashes, or when the scan didn't cover everything it was supposed to. +// +// node a11y-check.mjs --in --manifest # diff (the gate) +// node a11y-check.mjs --in --manifest --record # (re)write baseline +// node a11y-check.mjs --in --merge # union into baseline +// +// Exit codes: 0 pass · 1 regression · 2 unusable scan (incomplete/no input). +// +// The baseline records WHICH rules a story breaks, not how many nodes break +// them. Node counts drift between runs (stories fetch data asynchronously, so +// axe sometimes samples the DOM before late content lands), which made a +// count-based gate cry wolf on unrelated changes. Rule presence is stable, and +// "story now breaks a rule it didn't before" is the regression worth blocking. +// +// Run from frontend/. Baseline path defaults next to this script. + +import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const args = process.argv.slice(2); +const opt = (n, d) => { + const i = args.indexOf(n); + const v = i >= 0 ? args[i + 1] : undefined; + // A flag given without a value is a mistake, not a request for the default. + if (i >= 0 && (v === undefined || v.startsWith("--"))) { + console.error(`a11y-check: ${n} requires a value`); + process.exit(2); + } + return v ?? d; +}; +const record = args.includes("--record"); +const merge = args.includes("--merge"); +const inDir = opt("--in", ".a11y-scan"); +const manifestFile = opt("--manifest", ""); +const baselineFile = opt("--baseline", join(here, "a11y-baseline.json")); + +// Every axe violation block in the matcher's message ends with a link to the +// rule's docs, e.g. .../rules/axe/4.11/color-contrast?application=axeAPI. That +// URL is the only machine-stable rule id in the message — the human-readable +// help text around it is free to change between addon and axe versions. +const RULE_URL = /dequeuniversity\.com\/rules\/axe\/[\d.]+\/([a-z0-9-]+)/g; + +/** Reads every scan report in `dir`, keyed by " :: ". */ +function collect(dir) { + const rules = {}; // storyKey -> Set(ruleId) + const crashed = []; // storyKey[] — failed for a non-a11y reason + const seenFiles = new Set(); + let scanned = 0; + + for (const cf of readdirSync(dir).filter((f) => /\.json$/.test(f))) { + let report; + try { + report = JSON.parse(readFileSync(join(dir, cf), "utf8")); + } catch { + console.error(`a11y-check: unreadable scan report: ${cf}`); + process.exit(2); + } + for (const tf of report.testResults || []) { + const norm = tf.name.replace(/\\/g, "/"); + const idx = norm.search(/editor\/src\//); + const file = idx >= 0 ? norm.slice(idx) : norm; + seenFiles.add(file); + for (const a of tf.assertionResults || []) { + scanned++; + if (a.status === "passed") continue; + const key = `${file} :: ${a.title || a.fullName || "?"}`; + const found = new Set(); + for (const m of a.failureMessages || []) + for (const hit of m.matchAll(RULE_URL)) found.add(hit[1]); + if (found.size) { + rules[key] = rules[key] || new Set(); + for (const id of found) rules[key].add(id); + } else { + // No rule link anywhere in the message: the story threw, timed out or + // otherwise failed. Silently dropping these is how a broken scan can + // look clean — and a crashed story reports no violations at all, + // which would also mask any it really has. + crashed.push(key); + } + } + } + } + return { rules, crashed, seenFiles, scanned }; +} + +if (!existsSync(inDir)) { + console.error(`a11y-check: scan dir not found: ${inDir}`); + process.exit(2); +} +const { rules, crashed, seenFiles, scanned } = collect(inDir); +const observed = {}; +for (const [k, set] of Object.entries(rules)) observed[k] = [...set].sort(); + +// Completeness: every story file the scan was asked to cover must appear in the +// reports. A dropped batch (killed process, empty report) would otherwise read +// as "those stories have no violations" and pass. +if (manifestFile) { + if (!existsSync(manifestFile)) { + console.error(`a11y-check: manifest not found: ${manifestFile}`); + process.exit(2); + } + const expected = readFileSync(manifestFile, "utf8") + .split("\n") + .map((l) => l.trim().replace(/\\/g, "/")) + .filter(Boolean); + const missing = expected.filter((f) => !seenFiles.has(f)); + if (missing.length) { + console.error( + `a11y-check: scan is incomplete — ${missing.length} of ${expected.length} story files produced no results:`, + ); + missing.slice(0, 20).forEach((f) => console.error(` ${f}`)); + if (missing.length > 20) + console.error(` … and ${missing.length - 20} more`); + console.error("Refusing to report on a partial scan."); + process.exit(2); + } +} + +if (record || merge) { + const base = + merge && existsSync(baselineFile) + ? JSON.parse(readFileSync(baselineFile, "utf8")) + : {}; + if (crashed.length) { + console.error( + `a11y-check: refusing to record — ${crashed.length} story(ies) failed for a non-a11y reason:`, + ); + crashed.slice(0, 20).forEach((k) => console.error(` ${k}`)); + console.error("Fix those first; recording now would bake in blind spots."); + process.exit(2); + } + // Union, so a re-record can only widen what's grandfathered — it never drops + // a rule that a slower run happened to miss and then reports it as new. + for (const [k, list] of Object.entries(observed)) + base[k] = [...new Set([...(base[k] || []), ...list])].sort(); + const sorted = {}; + for (const k of Object.keys(base).sort()) sorted[k] = base[k]; + writeFileSync(baselineFile, JSON.stringify(sorted, null, 2) + "\n"); + const pairs = Object.values(sorted).reduce((s, l) => s + l.length, 0); + console.log( + `baseline ${merge ? "merged" : "recorded"}: ${scanned} stories scanned, ` + + `${Object.keys(sorted).length} with violations, ${pairs} story-rule pairs.`, + ); + process.exit(0); +} + +if (!existsSync(baselineFile)) { + console.error("a11y-check: no baseline file:", baselineFile); + process.exit(2); +} +const baseline = JSON.parse(readFileSync(baselineFile, "utf8")); + +const regressions = []; +for (const [key, list] of Object.entries(observed)) { + const base = baseline[key] || []; + for (const id of list) + if (!base.includes(id)) regressions.push(`NEW ${key} ${id}`); +} +// Stories that improved — reported so the baseline can be ratcheted down, never +// a failure. +const fixed = Object.keys(baseline).filter( + (k) => seenFiles.has(k.split(" :: ")[0]) && !observed[k], +); + +const pairs = Object.values(observed).reduce((s, l) => s + l.length, 0); +console.log( + `a11y scan: ${scanned} stories, ${Object.keys(observed).length} with violations, ` + + `${pairs} story-rule pairs (baselined).`, +); + +if (crashed.length) { + console.error(`\n✖ ${crashed.length} story(ies) failed to render:`); + crashed.slice(0, 50).forEach((k) => console.error(` ${k}`)); + if (crashed.length > 50) console.error(` … and ${crashed.length - 50} more`); +} +if (regressions.length) { + console.error(`\n✖ ${regressions.length} a11y regression(s) vs baseline:`); + regressions.slice(0, 100).forEach((r) => console.error(` ${r}`)); + if (regressions.length > 100) + console.error(` … and ${regressions.length - 100} more`); + console.error( + "\nFix the new violation(s). If a story was renamed or moved its old " + + "baseline key no longer matches — re-record: task frontend:storybook:a11y:record", + ); +} +if (crashed.length || regressions.length) process.exit(1); + +if (fixed.length) + console.log( + `✓ no a11y regressions. ${fixed.length} baselined story(ies) now clean — ` + + `re-record to lock that in: task frontend:storybook:a11y:record`, + ); +else console.log("✓ no a11y regressions vs baseline."); +process.exit(0); diff --git a/frontend/.storybook/a11y-scan.sh b/frontend/.storybook/a11y-scan.sh new file mode 100644 index 0000000000..855a4449dc --- /dev/null +++ b/frontend/.storybook/a11y-scan.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Run the Storybook Vitest scan in batches and emit one JSON report per batch +# into .a11y-scan/, plus a manifest of every story file the run was supposed to +# cover and a log of the raw output. Consumed by a11y-check.mjs, which fails if +# any manifest entry produced no results. Run from frontend/. +# +# a11y-scan.sh scan every story +# a11y-scan.sh [file…] scan only these story files +# +# Batching keeps each browser session small: a single run over the whole story +# set holds one Chromium context open for the entire scan, so one crash in it +# costs every story after that point. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +OUT=".a11y-scan" +rm -rf "$OUT" +mkdir -p "$OUT" +MANIFEST="$OUT/manifest.txt" +LOG="$OUT/scan.log" + +if [ "$#" -gt 0 ]; then + # Explicit list (the pull-request path passes just the stories a branch + # touched). Anything that no longer exists is dropped, so a deleted story + # doesn't fail the manifest check. + for f in "$@"; do [ -f "$f" ] && printf '%s\n' "$f"; done | sort -u >"$MANIFEST" +else + # Tracked story files plus any not yet committed, so a new story can be + # checked before it is added to the index. + { + git ls-files 'editor/src/**/*.stories.ts' 'editor/src/**/*.stories.tsx' + git ls-files --others --exclude-standard 'editor/src/**/*.stories.ts' \ + 'editor/src/**/*.stories.tsx' + } | sort -u >"$MANIFEST" +fi + +mapfile -t FILES <"$MANIFEST" +TOTAL=${#FILES[@]} +if [ "$TOTAL" -eq 0 ]; then + if [ "$#" -gt 0 ]; then + echo "a11y-scan: no existing story files in the given list — nothing to scan" + exit 0 + fi + echo "a11y-scan: no story files found — check the glob" >&2 + exit 2 +fi + +CHUNK=20 +NB=$(((TOTAL + CHUNK - 1) / CHUNK)) +echo "a11y-scan: $TOTAL story files, $NB batches of $CHUNK" + +failed=() +i=0 +ci=0 +while [ "$i" -lt "$TOTAL" ]; do + ci=$((ci + 1)) + batch=("${FILES[@]:i:CHUNK}") + i=$((i + CHUNK)) + out="$OUT/chunk-$ci.json" + filters=() + for f in "${batch[@]}"; do filters+=("${f%.tsx}"); done + # The scan exits non-zero whenever a story has a violation — expected here, so + # the report is what matters, not the status. Output is teed to the log so a red + # CI run still has the offending selectors and help text to work from. + for attempt in 1 2; do + timeout 300 npx vitest run --config .storybook/vitest.config.ts \ + --reporter=json --outputFile="$out" "${filters[@]}" >>"$LOG" 2>&1 + [ -s "$out" ] && break + echo "a11y-scan: batch $ci produced no report (attempt $attempt)" >&2 + done + if [ -s "$out" ]; then + echo " batch $ci/$NB done" + else + failed+=("$ci") + echo " batch $ci/$NB FAILED — no report" >&2 + fi +done + +echo "a11y-scan: $(ls "$OUT"/chunk-*.json 2>/dev/null | wc -l)/$NB batches produced reports" +if [ ${#failed[@]} -gt 0 ]; then + echo "a11y-scan: ${#failed[@]} batch(es) produced no report: ${failed[*]}" >&2 + echo "a11y-scan: see $LOG. Not reporting on a partial scan." >&2 + exit 2 +fi diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index cd2e53153d..a5cc4161c4 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -242,12 +242,10 @@ const preview: Preview = { ], }, a11y: { - // Run axe automatically against the story root; violations show in the - // Accessibility panel. `context` replaced `element` in addon-a11y 9.x. - context: "#storybook-root", - config: {}, - options: {}, - test: "todo", + // Run axe against the rendered story; `test: "error"` fails the scan on + // any violation. Context is left at the addon default (the document root) + // so it resolves under both the Storybook UI and the Vitest browser mount. + test: "error", }, }, globalTypes: { diff --git a/frontend/.storybook/vitest.config.ts b/frontend/.storybook/vitest.config.ts index 9535f63a47..8c0d0e81f2 100644 --- a/frontend/.storybook/vitest.config.ts +++ b/frontend/.storybook/vitest.config.ts @@ -4,10 +4,9 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; /** * Dedicated Vitest config that turns every story into a browser test: it mounts - * the story in real Chromium as a render/smoke check (a story must mount without - * throwing). a11y is currently report-only (preview's `a11y.test: "todo"`) and is - * not yet enforced here — flipping it to pass/fail is a follow-up. Kept separate - * from editor/vitest.config.ts (the jsdom unit tests) so the two suites don't collide. + * the story in real Chromium and runs axe against it, so a story fails if it + * throws on mount or trips an accessibility rule. Kept separate from + * editor/vitest.config.ts (the jsdom unit tests) so the two suites don't collide. * * The storybook test must live in a `test.projects[]` entry (not a flat config) * so Vitest wires up the browser test runner correctly. @@ -23,8 +22,8 @@ export default defineConfig({ // JSX runtime import, so Vite optimizes them lazily mid-run and emits // "optimized dependencies changed, reloading". That reload tears down the // browser worker and whichever stories were mid-load fail with a bogus - // "Failed to fetch dynamically imported module" — a result that looks real. - // Naming them here keeps a run deterministic. + // "Failed to fetch dynamically imported module" — a scan that then looks + // like a real result. Naming them here keeps a run deterministic. include: [ "react", "react/jsx-runtime", @@ -42,10 +41,11 @@ export default defineConfig({ plugins: [storybookTest({ configDir: resolve(__dirname) })], test: { name: "storybook", - // Mounting a story takes well over Vitest's 5s default on the heavier - // screens, and a story that trips the timeout is reported as a failure - // with no message — which reads like a crash. Give it room; a - // genuinely hung story still fails, just later. + // Mounting a story and running a full axe pass over it takes well + // over Vitest's 5s default on the heavier screens, and a story that + // trips the timeout is reported as a failure with no message — which + // reads like a crash and makes the run non-reproducible. Give it + // room; a genuinely hung story still fails, just later. testTimeout: 60_000, hookTimeout: 60_000, browser: { diff --git a/frontend/.storybook/vitest.setup.ts b/frontend/.storybook/vitest.setup.ts index 6265c9bf54..22fa4c027e 100644 --- a/frontend/.storybook/vitest.setup.ts +++ b/frontend/.storybook/vitest.setup.ts @@ -1,10 +1,15 @@ import { beforeAll } from "vitest"; import { setProjectAnnotations } from "@storybook/react-vite"; +import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; // eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias. import * as projectAnnotations from "./preview"; -// Apply the same decorators/parameters/globals the Storybook UI uses (providers, -// i18n, theme) so stories run under Vitest render identically to the browser. -const project = setProjectAnnotations([projectAnnotations]); +// Include addon-a11y's annotations so its axe checks run under Vitest, not only +// in the Storybook UI panel. projectAnnotations supplies the same +// decorators/parameters/globals (providers, i18n, theme) as the browser. +const project = setProjectAnnotations([ + a11yAddonAnnotations, + projectAnnotations, +]); beforeAll(project.beforeAll); From 4d207f0c3f326a6c54cbf7de54b94a4bfe39025d Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:18:55 +0100 Subject: [PATCH 010/262] a11y job: scan stories when their component changes; fix cold-start false failures (#7191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two fixes to the pull-request a11y job (#7086 follow-up), both found on its first day live. ### It now scans a component's stories when the component changes The job picked its scan set from changed **story files** alone. But a story renders the live component — editing `Button.tsx` changes what every Button story shows without touching a story file, and the job scanned nothing. That's the common way a11y regressions arrive, and it was exactly the case the job missed. The scan set now also includes stories whose **same-named sibling source file changed**: edit `Button.tsx` or `Button.css` and `Button.stories.tsx` is scanned. Changes that ripple further than a component's own stories (shared UI, theme tokens) remain the nightly sweep's job. ### It no longer fails on cold-start infrastructure noise The job's first real run (#7163) flagged a story as "failed to render". The story was fine — on a cold dependency cache (**every** CI run), Vite discovered the preview's own dependency graph mid-run and reloaded the page, killing whichever story happened to be loading with `Failed to fetch dynamically imported module`. Reproduced on a cold cache, passes on a warm one. - The preview's deps are named in `optimizeDeps.include`, which removes the mid-run reload (verified cold). - A batch whose report contains crash-class failures (failures carrying no axe rule) is retried once — a one-off infrastructure death passes the retry, a story that genuinely can't render fails both attempts and is still reported. Also: the scan-report artifacts were never actually uploading — they live in a dot-directory, which `upload-artifact` silently skips as hidden by default. `include-hidden-files: true` fixes that for the PR job and the nightly, so a red run finally has its evidence attached. ### The glue is Node now, so tasks work from any shell Raised in review: the pipeline leaned on `bash`, `sed`, `grep`, `sort` and `tr`. Task runs its commands in an embedded POSIX interpreter, but those are external binaries it has to find on PATH — and a Windows dev calling tasks from **PowerShell** has none of them (`sed`/`tr` missing outright, `sort` resolves to Windows' own, and `bash` resolves to *WSL's*). Confirmed broken by running the task from PowerShell before the change. The batch runner and affected-story detection are now small Node scripts (`a11y-scan.mjs`, `a11y-changed.mjs`) — the repo already requires Node, so one implementation serves PowerShell, git-bash and CI alike, instead of maintaining `.sh`/`.ps1` twins. ## Testing - Sibling detection: editing `Tabs.tsx` (component only) pulls `Tabs.stories.tsx` into the scan set; editing a `.css` sibling does the same; nothing unrelated leaks in. - **From PowerShell**: `task frontend:storybook:a11y:changed` early-exits cleanly with no changes, and with a component edit it detects the sibling, runs the browser scan and passes the gate — same result from git-bash. - Cold cache end-to-end: cleared both Vite caches, ran the scan — no re-optimize, no reload, stories fail only on their (baselined) axe results. - Crash classifier: 1 on a synthetic crash report, 0 on axe-only failures, 0 on a real report — so the retry can't be triggered by legitimate violations. - Full scan + gate run green end-to-end; taskfile parses, workflows are valid YAML, Prettier/ESLint pass. #7163's red check needs no action from that PR's author — it should go green on re-run once this lands. --- .github/workflows/frontend-a11y.yml | 3 + .github/workflows/nightly.yml | 3 + .taskfiles/frontend.yml | 28 ++-- frontend/.storybook/a11y-changed.mjs | 48 +++++++ frontend/.storybook/a11y-scan.mjs | 197 +++++++++++++++++++++++++++ frontend/.storybook/a11y-scan.sh | 84 ------------ frontend/.storybook/vitest.config.ts | 26 +++- 7 files changed, 285 insertions(+), 104 deletions(-) create mode 100644 frontend/.storybook/a11y-changed.mjs create mode 100644 frontend/.storybook/a11y-scan.mjs delete mode 100644 frontend/.storybook/a11y-scan.sh diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index d763447048..f4d3e3b93a 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -55,3 +55,6 @@ jobs: path: frontend/.a11y-scan/ retention-days: 7 if-no-files-found: ignore + # The reports live in a dot-directory, which upload-artifact treats as + # hidden and silently skips by default. + include-hidden-files: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 301a1fc8df..50346a6a39 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -92,6 +92,9 @@ jobs: path: frontend/.a11y-scan/ retention-days: 14 if-no-files-found: ignore + # The reports live in a dot-directory, which upload-artifact treats as + # hidden and silently skips by default. + include-hidden-files: true # Builds all desktop platforms on a schedule so the Rust dependency cache is # written on main, where PR and merge-queue tauri builds can restore it. diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 1338aae8e1..8c32efc5e3 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -214,42 +214,42 @@ tasks: desc: "a11y regression gate over every story: fail only on NEW axe violations" deps: [install, storybook:browser] cmds: - - bash .storybook/a11y-scan.sh + - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt storybook:a11y:changed: - desc: "a11y gate over stories changed vs a base ref (default origin/main)" + desc: "a11y gate over the stories this branch affects (default base origin/main)" summary: | - Scans only the stories this branch touches, which is what pull requests - run — a full scan takes ~30 minutes, far too long to sit in front of every - merge. The nightly job covers the rest of the suite. + Scans the stories a branch affects, which is what pull requests run — a + full scan takes ~30 minutes, far too long to sit in front of every merge. + A story is affected if its file changed, or if a same-named sibling + source file changed (editing Button.tsx or Button.css re-scans + Button.stories.tsx — the story renders the live component, so a component + edit changes what the story shows without touching the story file). + Changes that ripple further than a component's own stories are covered by + the nightly full sweep. Pass a base ref through CLI_ARGS, e.g. task frontend:storybook:a11y:changed -- origin/release deps: [install, storybook:browser] vars: BASE: '{{.CLI_ARGS | default "origin/main"}}' - # Stories touched by this branch, plus any not yet committed. CHANGED: - sh: | - { git diff --name-only --diff-filter=d {{.CLI_ARGS | default "origin/main"}}...HEAD -- '*.stories.ts' '*.stories.tsx'; - git diff --name-only --diff-filter=d -- '*.stories.ts' '*.stories.tsx'; - git ls-files --others --exclude-standard -- '*.stories.ts' '*.stories.tsx'; } \ - | sed 's|^frontend/||' | sort -u | tr '\n' ' ' + sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}} cmds: - cmd: | if [ -z "{{.CHANGED}}" ]; then - echo "a11y: no story files changed vs {{.BASE}} — nothing to check" + echo "a11y: no story files affected vs {{.BASE}} — nothing to check" exit 0 fi - bash .storybook/a11y-scan.sh {{.CHANGED}} + node .storybook/a11y-scan.mjs {{.CHANGED}} node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt storybook:a11y:record: desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)" deps: [install, storybook:browser] cmds: - - bash .storybook/a11y-scan.sh + - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record # ============================================================ diff --git a/frontend/.storybook/a11y-changed.mjs b/frontend/.storybook/a11y-changed.mjs new file mode 100644 index 0000000000..6aacf6274c --- /dev/null +++ b/frontend/.storybook/a11y-changed.mjs @@ -0,0 +1,48 @@ +// Prints the story files a branch affects, one per line — the scan set for the +// pull-request a11y gate. A story is affected if its file changed against the +// base ref (or is uncommitted/untracked), or if a same-named sibling source +// file changed: stories render the live component, so editing Button.tsx or +// Button.css changes what Button.stories.tsx shows without touching it. +// +// node a11y-changed.mjs [base-ref] (default origin/main) +// +// Node rather than shell so the task works no matter what invokes it — Task's +// embedded interpreter runs on Windows, but sed/grep/sort do not exist for +// developers calling tasks from PowerShell. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +const base = process.argv[2] || "origin/main"; + +const git = (...args) => + execFileSync("git", args, { encoding: "utf8" }) + .split("\n") + .map((l) => l.trim().replace(/^frontend\//, "")) + .filter(Boolean); + +const STORY = /\.stories\.tsx?$/; +const TEST = /\.test\.tsx?$/; +const SOURCE = /\.(ts|tsx|css)$/; + +// Committed changes vs the base, plus working-tree changes, plus untracked +// files — so the gate covers exactly what the branch would merge and what a +// developer is about to commit. +const changed = [ + ...git("diff", "--name-only", "--diff-filter=d", `${base}...HEAD`), + ...git("diff", "--name-only", "--diff-filter=d"), + ...git("ls-files", "--others", "--exclude-standard"), +]; + +const stories = new Set(); +for (const f of changed) { + if (STORY.test(f)) { + stories.add(f); + continue; + } + if (TEST.test(f) || !SOURCE.test(f)) continue; + const sibling = f.replace(SOURCE, ""); + for (const s of [`${sibling}.stories.tsx`, `${sibling}.stories.ts`]) + if (existsSync(s)) stories.add(s); +} + +process.stdout.write([...stories].sort().join("\n")); diff --git a/frontend/.storybook/a11y-scan.mjs b/frontend/.storybook/a11y-scan.mjs new file mode 100644 index 0000000000..77aed79d70 --- /dev/null +++ b/frontend/.storybook/a11y-scan.mjs @@ -0,0 +1,197 @@ +// Runs the Storybook Vitest scan in batches and emits one JSON report per batch +// into .a11y-scan/, plus a manifest of every story file the run was supposed to +// cover and a log of the raw output. Consumed by a11y-check.mjs, which fails if +// any manifest entry produced no results. Run from frontend/. +// +// node a11y-scan.mjs scan every story +// node a11y-scan.mjs [file…] scan only these story files +// +// Batching keeps each browser session small: a single run over the whole story +// set holds one Chromium context open for the entire scan, so one crash in it +// costs every story after that point. +// +// Node rather than shell so the task works no matter what invokes it — Task's +// embedded interpreter runs on Windows, but bash/sed/sort do not exist for +// developers calling tasks from PowerShell. +import { execFileSync, spawn } from "node:child_process"; +import { + existsSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +const OUT = ".a11y-scan"; +const CHUNK = 20; +const BATCH_TIMEOUT_MS = 300_000; + +const git = (...args) => + execFileSync("git", args, { encoding: "utf8" }) + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + +rmSync(OUT, { recursive: true, force: true }); +mkdirSync(OUT, { recursive: true }); +const manifestFile = join(OUT, "manifest.txt"); +const logFile = join(OUT, "scan.log"); + +const args = process.argv.slice(2); +let files; +if (args.length > 0) { + // Explicit list (the pull-request path passes just the stories a branch + // affects). Anything that no longer exists is dropped, so a deleted story + // doesn't fail the manifest check. + files = [...new Set(args.filter((f) => existsSync(f)))].sort(); + if (files.length === 0) { + console.log( + "a11y-scan: no existing story files in the given list — nothing to scan", + ); + writeFileSync(manifestFile, ""); + process.exit(0); + } +} else { + // Tracked story files plus any not yet committed, so a new story can be + // checked before it is added to the index. + files = [ + ...new Set([ + ...git( + "ls-files", + "--", + "editor/src/**/*.stories.ts", + "editor/src/**/*.stories.tsx", + ), + ...git( + "ls-files", + "--others", + "--exclude-standard", + "--", + "editor/src/**/*.stories.ts", + "editor/src/**/*.stories.tsx", + ), + ]), + ].sort(); + if (files.length === 0) { + console.error("a11y-scan: no story files found — check the glob"); + process.exit(2); + } +} +writeFileSync(manifestFile, files.join("\n") + "\n"); + +/** Failures carrying no axe rule — a throw, a timeout, a dropped browser page. */ +function crashCount(reportFile) { + try { + const report = JSON.parse(readFileSync(reportFile, "utf8")); + let crashes = 0; + for (const tf of report.testResults ?? []) + for (const a of tf.assertionResults ?? []) { + if (a.status === "passed") continue; + const msg = (a.failureMessages ?? []).join("\n"); + if (!/dequeuniversity\.com\/rules\/axe\//.test(msg)) crashes++; + } + return crashes; + } catch { + return -1; // unreadable report counts as a failed attempt + } +} + +/** Runs one vitest batch, tee'd to the log, killed (whole tree) on timeout. */ +function runBatch(filters, outputFile) { + return new Promise((resolve) => { + const cmd = + `npx vitest run --config .storybook/vitest.config.ts ` + + `--reporter=json --outputFile=${outputFile} ` + + filters.map((f) => `"${f}"`).join(" "); + const log = openSync(logFile, "a"); + const child = spawn(cmd, { + shell: true, + detached: process.platform !== "win32", + stdio: ["ignore", log, log], + }); + const timer = setTimeout(() => { + if (process.platform === "win32") { + try { + execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + } catch { + /* already gone */ + } + } else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + }, BATCH_TIMEOUT_MS); + child.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +const hasReport = (f) => existsSync(f) && statSync(f).size > 0; + +const batches = []; +for (let i = 0; i < files.length; i += CHUNK) + batches.push(files.slice(i, i + CHUNK)); +console.log( + `a11y-scan: ${files.length} story files, ${batches.length} batches of ${CHUNK}`, +); + +const failed = []; +for (let bi = 0; bi < batches.length; bi++) { + const n = bi + 1; + const out = join(OUT, `chunk-${n}.json`); + // The scan exits non-zero whenever a story has a violation — expected here, + // so the report is what matters, not the status. + // + // A batch is retried once when it produced no report, or when its report + // contains crash-class failures. A one-off infrastructure death — the + // browser page dropping, a Vite dep re-optimize reloading mid-run — passes + // on the retry; a story that genuinely cannot render fails both attempts. + const filters = batches[bi].map((f) => f.replace(/\.tsx$/, "")); + for (let attempt = 1; attempt <= 2; attempt++) { + await runBatch(filters, out); + if (!hasReport(out)) { + console.error( + `a11y-scan: batch ${n} produced no report (attempt ${attempt})`, + ); + continue; + } + if (attempt === 1) { + const crashes = crashCount(out); + if (crashes !== 0) { + console.error( + `a11y-scan: batch ${n} has ${crashes} crash-class failure(s) — retrying once`, + ); + rmSync(out, { force: true }); + continue; + } + } + break; + } + if (hasReport(out)) console.log(` batch ${n}/${batches.length} done`); + else { + failed.push(n); + console.error(` batch ${n}/${batches.length} FAILED — no report`); + } +} + +const present = batches.filter((_, i) => + hasReport(join(OUT, `chunk-${i + 1}.json`)), +).length; +console.log(`a11y-scan: ${present}/${batches.length} batches produced reports`); +if (failed.length > 0) { + console.error( + `a11y-scan: ${failed.length} batch(es) produced no report: ${failed.join(" ")}`, + ); + console.error(`a11y-scan: see ${logFile}. Not reporting on a partial scan.`); + process.exit(2); +} diff --git a/frontend/.storybook/a11y-scan.sh b/frontend/.storybook/a11y-scan.sh deleted file mode 100644 index 855a4449dc..0000000000 --- a/frontend/.storybook/a11y-scan.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# Run the Storybook Vitest scan in batches and emit one JSON report per batch -# into .a11y-scan/, plus a manifest of every story file the run was supposed to -# cover and a log of the raw output. Consumed by a11y-check.mjs, which fails if -# any manifest entry produced no results. Run from frontend/. -# -# a11y-scan.sh scan every story -# a11y-scan.sh [file…] scan only these story files -# -# Batching keeps each browser session small: a single run over the whole story -# set holds one Chromium context open for the entire scan, so one crash in it -# costs every story after that point. -set -uo pipefail -cd "$(dirname "$0")/.." || exit 1 - -OUT=".a11y-scan" -rm -rf "$OUT" -mkdir -p "$OUT" -MANIFEST="$OUT/manifest.txt" -LOG="$OUT/scan.log" - -if [ "$#" -gt 0 ]; then - # Explicit list (the pull-request path passes just the stories a branch - # touched). Anything that no longer exists is dropped, so a deleted story - # doesn't fail the manifest check. - for f in "$@"; do [ -f "$f" ] && printf '%s\n' "$f"; done | sort -u >"$MANIFEST" -else - # Tracked story files plus any not yet committed, so a new story can be - # checked before it is added to the index. - { - git ls-files 'editor/src/**/*.stories.ts' 'editor/src/**/*.stories.tsx' - git ls-files --others --exclude-standard 'editor/src/**/*.stories.ts' \ - 'editor/src/**/*.stories.tsx' - } | sort -u >"$MANIFEST" -fi - -mapfile -t FILES <"$MANIFEST" -TOTAL=${#FILES[@]} -if [ "$TOTAL" -eq 0 ]; then - if [ "$#" -gt 0 ]; then - echo "a11y-scan: no existing story files in the given list — nothing to scan" - exit 0 - fi - echo "a11y-scan: no story files found — check the glob" >&2 - exit 2 -fi - -CHUNK=20 -NB=$(((TOTAL + CHUNK - 1) / CHUNK)) -echo "a11y-scan: $TOTAL story files, $NB batches of $CHUNK" - -failed=() -i=0 -ci=0 -while [ "$i" -lt "$TOTAL" ]; do - ci=$((ci + 1)) - batch=("${FILES[@]:i:CHUNK}") - i=$((i + CHUNK)) - out="$OUT/chunk-$ci.json" - filters=() - for f in "${batch[@]}"; do filters+=("${f%.tsx}"); done - # The scan exits non-zero whenever a story has a violation — expected here, so - # the report is what matters, not the status. Output is teed to the log so a red - # CI run still has the offending selectors and help text to work from. - for attempt in 1 2; do - timeout 300 npx vitest run --config .storybook/vitest.config.ts \ - --reporter=json --outputFile="$out" "${filters[@]}" >>"$LOG" 2>&1 - [ -s "$out" ] && break - echo "a11y-scan: batch $ci produced no report (attempt $attempt)" >&2 - done - if [ -s "$out" ]; then - echo " batch $ci/$NB done" - else - failed+=("$ci") - echo " batch $ci/$NB FAILED — no report" >&2 - fi -done - -echo "a11y-scan: $(ls "$OUT"/chunk-*.json 2>/dev/null | wc -l)/$NB batches produced reports" -if [ ${#failed[@]} -gt 0 ]; then - echo "a11y-scan: ${#failed[@]} batch(es) produced no report: ${failed[*]}" >&2 - echo "a11y-scan: see $LOG. Not reporting on a partial scan." >&2 - exit 2 -fi diff --git a/frontend/.storybook/vitest.config.ts b/frontend/.storybook/vitest.config.ts index 8c0d0e81f2..8519e50267 100644 --- a/frontend/.storybook/vitest.config.ts +++ b/frontend/.storybook/vitest.config.ts @@ -18,18 +18,32 @@ export default defineConfig({ // Pre-scan every story + the preview so Vite discovers the story set's large // dep surface (embedpdf plugins, @mui icons, …) in one pass up front. entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"], - // `entries` alone does not catch deps reached only through a transformed - // JSX runtime import, so Vite optimizes them lazily mid-run and emits - // "optimized dependencies changed, reloading". That reload tears down the - // browser worker and whichever stories were mid-load fail with a bogus - // "Failed to fetch dynamically imported module" — a scan that then looks - // like a real result. Naming them here keeps a run deterministic. + // `entries` alone does not catch deps reached through a transformed JSX + // runtime import, nor the preview's own dependency graph (the test plugin + // injects the preview in a way the entry scanner doesn't crawl). Vite then + // optimizes them lazily mid-run and emits "optimized dependencies changed, + // reloading" — the reload tears down the browser worker and whichever + // stories were mid-load fail with a bogus "Failed to fetch dynamically + // imported module" that reads like a real crash. Only a cold dep cache + // hits this, which is every CI run. Naming them keeps a run deterministic. include: [ "react", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom", "react-dom/client", + "@storybook/react-vite", + "@storybook/addon-a11y/preview", + "@storybook/addon-themes", + "msw-storybook-addon", + "react-router-dom", + "@tanstack/react-query", + "i18next", + "react-i18next", + "smol-toml", + "@mantine/core", + "@supabase/supabase-js", + "axios", ], }, test: { From bbd4d2c3ac106321edcbf9c8df8ef9f45e2ae70f Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 29 Jul 2026 15:33:34 +0100 Subject: [PATCH 011/262] Redesign toml sorting to speed up from ~40s to ~2s (#7192) # Description of Changes The `pre-commit` tool to sort the translations is really slow. It took ~40 seconds to run because it's using a parser which attempts to save all of the formatting data from the Toml. Our translations toml is pretty much entirely formatted anyway, so there's no point in trying to preserve any of that data. The only thing we lose is 5 comments, none of which are needed anyway and only appear in the US translation file. By switching to Python stdlib `tomllib` reading and `tomli-w` for writing, we can make the Toml formatting job take 2.11 seconds, where it used to take 39.78s. The whole pre-commit job now takes 4.58 seconds. --- .taskfiles/pre-commit.yml | 4 +- .../public/locales/en-US/translation.toml | 5 - scripts/pre-commit/pyproject.toml | 2 +- scripts/pre-commit/sort_locale_toml.py | 96 +++++++++++++++++++ scripts/pre-commit/uv.lock | 24 ++--- 5 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 scripts/pre-commit/sort_locale_toml.py diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 136f852a5b..f2e488e815 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -73,7 +73,7 @@ tasks: - task: gitleaks install: - desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)" + desc: "Install the pinned pre-commit Python tools" run: once cmds: - uv sync --project scripts/pre-commit --locked @@ -112,7 +112,7 @@ tasks: toml-sort: deps: [install] cmds: - - uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}} + - uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}} whitespace: cmds: diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 913cdf20a4..abbe1446ff 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -692,7 +692,6 @@ manualLinks = "Manual downloads: click the links and place the files into the te noLanguages = "No tessdata languages found in the configured directory." permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder." -# AI engine admin settings (AI nav group) [admin.settings.ai.documents] description = "Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved." title = "Documents & RAG" @@ -7320,7 +7319,6 @@ sectionsAriaLabel = "Infrastructure sections" subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace." title = "Infrastructure" -# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs. [portal.infrastructure.apiKeys] createKey = "Create key" heading = "API keys" @@ -8415,13 +8413,10 @@ region = "State / region" regionPlaceholder = "California" running = "{{annual}} / yr · {{years}}-yr {{tcv}}" s1Sub = "Your team, and the PDFs you expect to run each year." -# Step 1 — volume s1Title = "How much will you process?" s2Sub = "Longer terms discount the rate; your service level sets support." -# Step 2 — commitment & service s2Title = "Commitment and service" s3Sub = "For the quote and the agreement it generates." -# Step 3 — details s3Title = "Your details" serviceLevel = "Service level" size_compact = "Compact" diff --git a/scripts/pre-commit/pyproject.toml b/scripts/pre-commit/pyproject.toml index cd13b56f5e..9a730bdcb5 100644 --- a/scripts/pre-commit/pyproject.toml +++ b/scripts/pre-commit/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.11" dependencies = [ "ruff==0.15.14", "codespell==2.4.2", - "toml-sort==0.24.4", + "tomli-w==1.2.0", ] [tool.uv] diff --git a/scripts/pre-commit/sort_locale_toml.py b/scripts/pre-commit/sort_locale_toml.py new file mode 100644 index 0000000000..1515fe2cfe --- /dev/null +++ b/scripts/pre-commit/sort_locale_toml.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Key-sort the locale translation.toml files. + +python sort_locale_toml.py ... # check: report, exit 1 if unsorted +python sort_locale_toml.py --fix ... # fix: rewrite in place +""" + +from __future__ import annotations + +import subprocess +import sys +import tomllib +from pathlib import Path + +import tomli_w + + +class SortError(Exception): + """A file could not be sorted without risking its contents.""" + + +def ordered(table: dict[str, object]) -> dict[str, object]: + """Rebuild a table with its keys sorted, and sub-tables after its own keys.""" + keys = {key: value for key, value in table.items() if not isinstance(value, dict)} + subtables = {key: value for key, value in table.items() if isinstance(value, dict)} + result: dict[str, object] = {key: keys[key] for key in sorted(keys, key=str.lower)} + for key in sorted(subtables, key=str.lower): + result[key] = ordered(subtables[key]) + return result + + +def tracked_files(path_specs: list[str]) -> list[str]: + result = subprocess.run( + ["git", "ls-files", "-z", *path_specs], + check=True, + capture_output=True, + text=True, + ) + return [path for path in result.stdout.split("\0") if path] + + +def sort_file(path: str, fix: bool) -> bool: + """Rewrite one file if `fix`; return whether it was not already sorted.""" + text = Path(path).read_text(encoding="utf-8") + try: + original = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + raise SortError(f"{path}: invalid TOML: {exc}") from exc + + expected = tomli_w.dumps(ordered(original)) + if expected == text: + return False + + try: + reordered = tomllib.loads(expected) + except tomllib.TOMLDecodeError as exc: + raise SortError( + f"{path}: refusing to sort, the sorted output is not valid TOML: {exc}" + ) from exc + if reordered != original: + raise SortError( + f"{path}: refusing to sort, sorting would change the file's contents" + ) + + if fix: + Path(path).write_text(expected, encoding="utf-8") + return True + + +def main() -> int: + args = sys.argv[1:] + fix = "--fix" in args + pathspecs = [a for a in args if a != "--fix"] + + offenders: list[str] = [] + errors: list[str] = [] + for path in tracked_files(pathspecs): + try: + if sort_file(path, fix): + offenders.append(path) + except SortError as exc: + errors.append(str(exc)) + + for error in errors: + print(error, file=sys.stderr) + if offenders and not fix: + print(f"{len(offenders)} file(s) need TOML sorting:") + for path in offenders: + print(f" {path}") + if offenders and fix: + print(f"Sorted TOML in {len(offenders)} file(s).") + return 1 if errors or (offenders and not fix) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pre-commit/uv.lock b/scripts/pre-commit/uv.lock index b90593a6e8..2a3c8ee11d 100644 --- a/scripts/pre-commit/uv.lock +++ b/scripts/pre-commit/uv.lock @@ -43,33 +43,21 @@ source = { virtual = "." } dependencies = [ { name = "codespell" }, { name = "ruff" }, - { name = "toml-sort" }, + { name = "tomli-w" }, ] [package.metadata] requires-dist = [ { name = "codespell", specifier = "==2.4.2" }, { name = "ruff", specifier = "==0.15.14" }, - { name = "toml-sort", specifier = "==0.24.4" }, + { name = "tomli-w", specifier = "==1.2.0" }, ] [[package]] -name = "toml-sort" -version = "0.24.4" +name = "tomli-w" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tomlkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/c5/d6f650fdcf8e1f83096815fa67fb13a9a345b99da6015c60c4b7e4a8ea2b/toml_sort-0.24.4.tar.gz", hash = "sha256:429b69f5b98b7047a11380c80ecf0838556bdea1a8902d0be564961c48841423", size = 17793, upload-time = "2026-03-24T14:05:53.637Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/5a/1f0e54df4eacf0f4d8f94ba50cf72be33d2a3f04babdfb1931bead48a0ab/toml_sort-0.24.4-py3-none-any.whl", hash = "sha256:125aa5fb94f33c542c6901040456145dd38f79bbb310b56b436a93057d30a739", size = 16577, upload-time = "2026-03-24T14:05:54.757Z" }, -] - -[[package]] -name = "tomlkit" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] From b4a264239c468c89ce9639bfd15e1743e38b3045 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:15:49 +0100 Subject: [PATCH 012/262] fix(saas): provision a new user and their personal team atomically (#7193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SaaS accounts were landing with `team_id = null`. That state is unrecoverable: portal access derives from leading a team, and signup is the only place one is assigned. Five things had to be fixed, all on the signup path. Only the last is a behaviour change you'd notice. ### 1. Shared-PK entity was routed to `merge()` `SaasUserExtensions` pre-sets its `@MapsId` id in the constructor, so Spring Data's id-nullness check treated a brand-new row as existing and `save()` failed with `AssertionFailure: null identifier`. Now implements `Persistable` and decides on the creation timestamp — the idiom already used by `ProcessedFileEntity` and `SourceDocCountEntity`. This was the blocker. It threw on every signup, and because the failure was swallowed (see 3) every new account was stranded. ### 2. User and team were committed separately `createUser()` is annotated `@Transactional` but is called as `this.createUser(...)`, and self-invocation bypasses the proxy — so the annotation did nothing. `saveUser()` and `ensurePersonalTeam()` each committed in their own transaction, leaving a window where a **committed user was visible with `team_id = null`**. Parallel requests entering that window each provisioned a team, producing duplicates (observed: teams 160/161 and 162/163 for one user). Both writes now happen in one transaction via `SaasTeamService.saveUserWithPersonalTeam()`. The window is gone, so there is nothing left to race over. ### 3. A failed team create was swallowed The old code logged at WARN and committed the user anyway. It now propagates: the shared transaction rolls the user back, the request 401s, and a retry starts clean. Nothing half-built is committed. This is the deliberate trade — a transient failure now surfaces instead of silently producing an account that can never reach the portal. ### 4. Per-request healing removed `recoverMissingTeam` (added in #7180) ran on **every authenticated request** whose user had no team, with no mutual exclusion. Under a burst of parallel requests it was itself a source of concurrent provisioning. Provisioning belongs to signup alone. ### 5. Policy seeding could not run `@TransactionalEventListener(AFTER_COMMIT)` leaves the *completed* transaction bound to the thread, so `JpaPolicyStore.save`'s `@Transactional` joined it instead of opening a live one — and its `FOR UPDATE` lock threw `TransactionRequiredException`. Now seeded in `BEFORE_COMMIT`: the lock has a live transaction, rollback safety is unchanged (a rolled-back team still leaves no policy), and it stays on a single pooled connection. ## Verified `:saas:test` green, both spotless gates green, on top of current `main`. Manually on a live signup: **one** team per user, and the concurrent-signup race resolves correctly through the pre-existing unique-constraint catch (`users_supabase_auth_id_key` violation → refetch the winner). 12 filter tests needed updating. Two of them asserted behaviour this PR deliberately removes (`personalTeamFailureSwallowed`, `assignsTeamWhenMissing`), so they were rewritten to assert the new contract rather than re-stubbed into passing. ## Not in scope - **Existing stranded accounts** are not repaired — with the healer gone, nothing fixes them on the request path. They need a one-off backfill or deletion. - **A DB-level invariant.** A partial unique index (`UNIQUE(created_by_user_id) WHERE is_personal`) would make duplicate personal teams impossible rather than merely unreachable. Wanted, but it is a Supabase migration in the SaaS repo, so it is deliberately separate. - **Per-request auth cost.** The filter still does two remote-Postgres round-trips per authenticated request; a frontend request storm makes that expensive. Being handled separately. --- .../DefaultClassificationPolicySeeder.java | 7 +- .../saas/model/SaasUserExtensions.java | 17 ++++- .../SupabaseAuthenticationFilter.java | 68 ++++--------------- .../saas/service/SaasTeamService.java | 40 +++++++++++ .../SupabaseAuthenticationFilterMoreTest.java | 58 ++++++++-------- .../SupabaseAuthenticationFilterTest.java | 19 +++--- 6 files changed, 112 insertions(+), 97 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index aca6ae0e84..4150dc2f31 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -46,9 +46,10 @@ public class DefaultClassificationPolicySeeder { .ifPresent(team -> seedIfMissing(team.getId(), team.getName())); } - // Any team created at runtime (admin-created, SaaS sign-ups); after the team's commit so a - // rolled-back team never leaves a policy behind. - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + // Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own + // transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a + // live transaction, which AFTER_COMMIT cannot offer. + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void onTeamCreated(TeamCreatedEvent event) { seedIfMissing(event.teamId(), event.teamName()); } diff --git a/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java b/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java index 036fdd27be..8d7dda1726 100644 --- a/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java +++ b/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java @@ -7,6 +7,7 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.data.domain.Persistable; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -16,6 +17,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.MapsId; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import lombok.Getter; import lombok.NoArgsConstructor; @@ -37,7 +39,7 @@ import stirling.software.proprietary.security.model.User; @NoArgsConstructor @Getter @Setter -public class SaasUserExtensions implements Serializable { +public class SaasUserExtensions implements Serializable, Persistable { private static final long serialVersionUID = 1L; @@ -80,4 +82,17 @@ public class SaasUserExtensions implements Serializable { public boolean isMeteredBillingEnabled() { return Boolean.TRUE.equals(hasMeteredBillingEnabled); } + + @Override + public Long getId() { + return userId; + } + + // Decided on the timestamp, not the id: the constructor pre-sets the @MapsId id, so an + // id-based check would route a new row to merge() and fail with "null identifier". + @Override + @Transient + public boolean isNew() { + return createdAt == null; + } } diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 684e22a98f..a4b2366379 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -239,7 +239,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { && !supabaseUser.isAnonymous()) { user = upgradeAnonymousUser(user, supabaseUser, jwt); } - return recoverMissingTeam(user); + return user; } return createUser(jwt, supabaseId, email, appMetadata); @@ -271,10 +271,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { user.setUsername(supabaseUser.getEmail()); } try { - User saved = userService.saveUser(user); // Give the account its own team rather than the shared Default team. - saved.setTeam(saasTeamService.ensurePersonalTeam(saved)); - return saved; + return saasTeamService.saveUserWithPersonalTeam(user); } catch (DataIntegrityViolationException e) { log.warn( "Email collision upgrading anonymous user {} to {}: {}", @@ -372,60 +370,22 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { throw new AuthenticationFailureException("Failed to create SupabaseUser", e); } - User savedUser; - boolean weCreatedThisUser = true; + // Guests get NO team: the editor is free and needs none. Everyone else is provisioned + // atomically, so a user visible to a parallel request always already has one. try { - savedUser = userService.saveUser(newUser); + return isAnonymous(jwt) + ? userService.saveUser(newUser) + : saasTeamService.saveUserWithPersonalTeam(newUser); } catch (DataIntegrityViolationException dup) { // Parallel filter won the race; fetch the winning row. - weCreatedThisUser = false; - savedUser = - userService - .findBySupabaseId(supabaseId) - .orElseThrow( - () -> - new AuthenticationFailureException( - "User creation conflict, but unable to find existing user", - dup)); + return userService + .findBySupabaseId(supabaseId) + .orElseThrow( + () -> + new AuthenticationFailureException( + "User creation conflict, but unable to find existing user", + dup)); } - - // Only the DB-race winner runs first-time init; the losers skip it. Guests (anonymous - // sessions) get NO team: the editor is free and needs none, and automation requires a - // real account. - if (weCreatedThisUser && !isAnonymous(jwt)) { - try { - savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser)); - } catch (Exception e) { - log.warn( - "Failed to create personal team for new user {} ({}): {}", - LogRedactionUtils.redactSupabaseId(supabaseId), - LogRedactionUtils.redactEmail(savedUser.getUsername()), - e.getMessage()); - } - } - return savedUser; - } - - /** - * Recover an account stranded without a team: signup is the only other place one is assigned, - * so a null team_id is otherwise permanent — and portal access derives from leading a team. - * Guests get none by design. - */ - private User recoverMissingTeam(User user) { - if (user.getTeam() != null - || ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())) { - return user; - } - try { - user.setTeam(saasTeamService.ensurePersonalTeam(user)); - log.info("Assigned a personal team to user {} which had none", user.getId()); - } catch (Exception e) { - log.warn( - "Could not assign a personal team to user {}: {}", - user.getId(), - e.getMessage()); - } - return user; } private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException { diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index bda0602261..0038cca42a 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -53,6 +53,18 @@ public class SaasTeamService { public static final String DEFAULT_TEAM_NAME = "Default"; public static final String INTERNAL_TEAM_NAME = "Internal"; + /** + * Persist a user and their personal team atomically: an account with no team has no portal + * access and no way to acquire one, so a teamless user must never be committed. Constraint + * violations (the concurrent-signup race) propagate for the caller to resolve. + */ + @Transactional + public User saveUserWithPersonalTeam(User user) { + User saved = userService.saveUser(user); + saved.setTeam(ensurePersonalTeam(saved)); + return saved; + } + /** Returns the user's personal team, creating one if they have none. Idempotent. */ @Transactional public Team ensurePersonalTeam(User user) { @@ -60,9 +72,37 @@ public class SaasTeamService { if (existing != null && saasTeamExtensionService.isPersonal(existing)) { return existing; } + // An empty users.team_id does not prove there is no personal team; adopt one the user + // already owns rather than minting a second. + Team owned = existingPersonalTeam(user); + if (owned != null) { + user.setTeam(owned); + userService.saveUser(user); + return owned; + } return createPersonalTeam(user); } + /** + * The personal team the user already owns — their recorded home, else a solo team they lead. + */ + private Team existingPersonalTeam(User user) { + Long homeId = saasUserExtensionService.getHomeTeamId(user); + if (homeId != null) { + Team home = teamRepository.findById(homeId).orElse(null); + if (home != null) { + return home; + } + } + for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { + Team team = membership.getTeam(); + if (membership.isLeader() && membershipRepository.countByTeamId(team.getId()) == 1) { + return team; + } + } + return null; + } + /** * Create personal team for new user during signup or migrate existing user from Default team * diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java index 3b8a0292c7..f6181dcebe 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java @@ -315,14 +315,13 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); - verify(userService).saveUser(any(User.class)); - verify(saasTeamService).ensurePersonalTeam(any(User.class)); + verify(saasTeamService).saveUserWithPersonalTeam(any(User.class)); assertThat(local.getEmail()).isEqualTo("real@example.com"); assertThat(local.getUsername()).isEqualTo("real@example.com"); assertThat(local.getAuthenticationType()) @@ -342,8 +341,8 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); @@ -365,7 +364,7 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("email exists")); bearer("tok"); @@ -489,12 +488,13 @@ class SupabaseAuthenticationFilterMoreTest { org.mockito.Mockito.doThrow(new DataIntegrityViolationException("dup")) .when(supabaseUserService) .createSupabaseUser(eq(supabaseId), any(), eq(false)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); } @@ -516,7 +516,7 @@ class SupabaseAuthenticationFilterMoreTest { filter.doFilter(request, response, chain); assertThat(response.getStatus()).isEqualTo(401); - verify(userService, never()).saveUser(any()); + verify(saasTeamService, never()).saveUserWithPersonalTeam(any()); } @Test @@ -533,13 +533,13 @@ class SupabaseAuthenticationFilterMoreTest { when(userService.findBySupabaseId(supabaseId)) .thenReturn(Optional.empty()) .thenReturn(Optional.of(winner)); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("dup user")); bearer("tok"); filter.doFilter(request, response, chain); - // Race loser does not run first-time init (ensurePersonalTeam). + // The winner committed user and team together, so the loser just adopts its row. verify(saasTeamService, never()).ensurePersonalTeam(any()); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); @@ -554,7 +554,7 @@ class SupabaseAuthenticationFilterMoreTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUser(supabaseId, "lost@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("dup user")); bearer("tok"); @@ -564,31 +564,30 @@ class SupabaseAuthenticationFilterMoreTest { } @Test - @DisplayName("personal team creation failure for a new user is swallowed") - void personalTeamFailureSwallowed() throws Exception { + @DisplayName("personal team creation failure fails the request, it is not swallowed") + void personalTeamFailureFailsAuth() throws Exception { UUID supabaseId = UUID.randomUUID(); Jwt jwt = fullJwt(supabaseId, "team@example.com", false, "email"); when(jwtDecoder.decode("tok")).thenReturn(jwt); when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUser(supabaseId, "team@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new IllegalStateException("team boom")); bearer("tok"); filter.doFilter(request, response, chain); - // Auth still succeeds even though team creation failed. - assertThat(SecurityContextHolder.getContext().getAuthentication()) - .isInstanceOf(EnhancedJwtAuthenticationToken.class); - verify(userService, times(1)).saveUser(any(User.class)); + // A teamless account has no portal access, so a failed provision must surface + // rather than admit a half-built user. + assertThat(response.getStatus()).isEqualTo(401); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } } @Nested - @DisplayName("Team recovery for existing accounts") - class TeamRecovery { + @DisplayName("Existing accounts are never re-provisioned on the request path") + class ExistingAccountProvisioning { private User existingWebUser(UUID supabaseId) { User local = newUser("real@example.com"); @@ -598,8 +597,8 @@ class SupabaseAuthenticationFilterMoreTest { } @Test - @DisplayName("an existing account with no team is given a personal team") - void assignsTeamWhenMissing() throws Exception { + @DisplayName("a teamless account is left alone, not healed on every request") + void teamlessAccountIsNotHealed() throws Exception { UUID supabaseId = UUID.randomUUID(); when(jwtDecoder.decode("tok")) .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); @@ -607,15 +606,16 @@ class SupabaseAuthenticationFilterMoreTest { .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); User local = existingWebUser(supabaseId); - Team recovered = new Team(); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(saasTeamService.ensurePersonalTeam(local)).thenReturn(recovered); bearer("tok"); filter.doFilter(request, response, chain); - verify(saasTeamService).ensurePersonalTeam(local); - assertThat(local.getTeam()).isSameAs(recovered); + // Healing here would run per request with no mutual exclusion, so parallel + // requests would mint duplicate teams. Provisioning belongs to signup alone. + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + verify(saasTeamService, never()).saveUserWithPersonalTeam(any(User.class)); + assertThat(local.getTeam()).isNull(); } @Test diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java index 58cf2d72a0..d78be84189 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java @@ -168,7 +168,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "bob@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any())).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.saveUserWithPersonalTeam(any())).thenAnswer(inv -> inv.getArgument(0)); request.setRequestURI("/api/v1/something"); request.setMethod("POST"); @@ -176,10 +176,9 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); verify(supabaseUserService).createSupabaseUser(supabaseId, "bob@example.com", false); - // New users get their own personal team, never the shared Default team. - verify(saasTeamService).ensurePersonalTeam(any(User.class)); + // Own personal team, never the shared Default team, written with the user. + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); verify(teamService, never()).getOrCreateDefaultTeam(); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); @@ -194,7 +193,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "carol@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -210,7 +209,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test @@ -222,7 +221,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "dave@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -238,7 +237,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test @@ -250,7 +249,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "eve@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -266,7 +265,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test From 4dc0927104dc7943a6b8fc3f0c860194e16554a1 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:08:08 +0100 Subject: [PATCH 013/262] a11y job: emit the affected-story list on one line (#7196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Fixes the a11y check failing with `permission denied` on any PR that touches more than one story (currently hitting #7163). The script that lists which stories to scan printed one path per line. That list gets pasted into a shell command, so everything after the first line fell out of the command — the shell treated the second path as a command of its own and failed. One-line fix: print the list on a single line. ## Testing Changed two components and ran the task from both git-bash and PowerShell — both stories scanned, check passes. #7163's red check should go green on re-run once this is in. --- frontend/.storybook/a11y-changed.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/.storybook/a11y-changed.mjs b/frontend/.storybook/a11y-changed.mjs index 6aacf6274c..5ff7a9e633 100644 --- a/frontend/.storybook/a11y-changed.mjs +++ b/frontend/.storybook/a11y-changed.mjs @@ -45,4 +45,12 @@ for (const f of changed) { if (existsSync(s)) stories.add(s); } -process.stdout.write([...stories].sort().join("\n")); +// One line, each path quoted: the output is interpolated into a task command, +// where a newline would end the command after the first story and an unquoted +// space would split a path into two arguments. +process.stdout.write( + [...stories] + .sort() + .map((s) => `"${s}"`) + .join(" "), +); From b35329c8f5e134d07c687fbc34de2e97154b5c35 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:13:41 +0100 Subject: [PATCH 014/262] a11y scan: generate required assets, and fail when a story file can't load (#7201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two related bugs found while looking at why #7187's a11y check behaves differently on CI than locally. ### 21 stories were never being scanned on CI The scan tasks only depended on `install`, not `prepare`. On a fresh checkout that means the generated icon set (`editor/src/assets/material-symbols-icons.json`, gitignored) doesn't exist, so every story that reaches `LocalIcon` fails to import: ``` Failed to resolve import "../../../assets/material-symbols-icons.json" from "editor/src/core/components/shared/LocalIcon.tsx" ``` On CI that was four story files / 21 stories, every run. It works locally only because our trees already have the file from a previous build. The scan tasks now depend on `prepare`, like the `build:*` tasks do. ### The gate reported those runs as clean Worse than the missing stories: a file that fails to import produces a **failed suite with no assertions**. Every check in `a11y-check.mjs` reads assertions, so the file satisfied the manifest, contributed nothing to compare, and the run printed `✓ no a11y regressions`. An assertion-less failed suite now fails the gate and points at the scan log for the underlying resolve error. `--record` refuses in the same situation, so a baseline can't be written that quietly drops those stories. Also switched the affected-story emptiness test to single quotes, since that list now carries its own per-path quoting (it was producing `[ -z ""a" "b"" ]`). ## Testing - Deleted the generated asset to reproduce a fresh checkout: the gate **fails** with the file named and the cause explained, where before it printed `✓ no a11y regressions` and exited 0. - With the `prepare` dependency the task regenerates the asset itself and the previously-invisible files scan: 21 stories, 35 story-rule pairs, all already baselined. --- .taskfiles/frontend.yml | 14 ++++++------ frontend/.storybook/a11y-check.mjs | 36 +++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 8c32efc5e3..3c2113bf96 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -184,13 +184,13 @@ tasks: storybook: desc: "Start Storybook dev server" - deps: [install] + deps: [prepare] cmds: - npx storybook dev -p 6006 {{.CLI_ARGS}} storybook:build: desc: "Build static Storybook" - deps: [install] + deps: [prepare] cmds: - npx storybook build {{.CLI_ARGS}} @@ -204,7 +204,7 @@ tasks: storybook:test: desc: "Scan every story in real Chromium: it must render and pass axe" - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] cmds: # Runs each story as a browser test. Pass a filter through, e.g. # task frontend:storybook:test -- Button @@ -212,7 +212,7 @@ tasks: storybook:a11y: desc: "a11y regression gate over every story: fail only on NEW axe violations" - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] cmds: - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt @@ -231,14 +231,14 @@ tasks: Pass a base ref through CLI_ARGS, e.g. task frontend:storybook:a11y:changed -- origin/release - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] vars: BASE: '{{.CLI_ARGS | default "origin/main"}}' CHANGED: sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}} cmds: - cmd: | - if [ -z "{{.CHANGED}}" ]; then + if [ -z '{{.CHANGED}}' ]; then echo "a11y: no story files affected vs {{.BASE}} — nothing to check" exit 0 fi @@ -247,7 +247,7 @@ tasks: storybook:a11y:record: desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)" - deps: [install, storybook:browser] + deps: [prepare, storybook:browser] cmds: - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record diff --git a/frontend/.storybook/a11y-check.mjs b/frontend/.storybook/a11y-check.mjs index 7eef5e079e..43092812ff 100644 --- a/frontend/.storybook/a11y-check.mjs +++ b/frontend/.storybook/a11y-check.mjs @@ -52,6 +52,7 @@ const RULE_URL = /dequeuniversity\.com\/rules\/axe\/[\d.]+\/([a-z0-9-]+)/g; function collect(dir) { const rules = {}; // storyKey -> Set(ruleId) const crashed = []; // storyKey[] — failed for a non-a11y reason + const unloadable = []; // storyFile[] — the file itself never ran const seenFiles = new Set(); let scanned = 0; @@ -68,6 +69,14 @@ function collect(dir) { const idx = norm.search(/editor\/src\//); const file = idx >= 0 ? norm.slice(idx) : norm; seenFiles.add(file); + // A story file that fails to import produces a failed suite with no + // assertions at all. Every other check here reads assertions, so such a + // file satisfies the manifest and contributes nothing — its stories go + // unscanned while the run still reports clean. + if ((tf.assertionResults || []).length === 0 && tf.status !== "passed") { + unloadable.push(file); + continue; + } for (const a of tf.assertionResults || []) { scanned++; if (a.status === "passed") continue; @@ -88,14 +97,14 @@ function collect(dir) { } } } - return { rules, crashed, seenFiles, scanned }; + return { rules, crashed, unloadable, seenFiles, scanned }; } if (!existsSync(inDir)) { console.error(`a11y-check: scan dir not found: ${inDir}`); process.exit(2); } -const { rules, crashed, seenFiles, scanned } = collect(inDir); +const { rules, crashed, unloadable, seenFiles, scanned } = collect(inDir); const observed = {}; for (const [k, set] of Object.entries(rules)) observed[k] = [...set].sort(); @@ -129,6 +138,14 @@ if (record || merge) { merge && existsSync(baselineFile) ? JSON.parse(readFileSync(baselineFile, "utf8")) : {}; + if (unloadable.length) { + console.error( + `a11y-check: refusing to record — ${unloadable.length} story file(s) failed to load:`, + ); + unloadable.slice(0, 20).forEach((f) => console.error(` ${f}`)); + console.error("Their stories never ran, so the baseline would lose them."); + process.exit(2); + } if (crashed.length) { console.error( `a11y-check: refusing to record — ${crashed.length} story(ies) failed for a non-a11y reason:`, @@ -176,6 +193,19 @@ console.log( `${pairs} story-rule pairs (baselined).`, ); +if (unloadable.length) { + console.error( + `\n✖ ${unloadable.length} story file(s) failed to load, so their stories never ran:`, + ); + unloadable.slice(0, 50).forEach((f) => console.error(` ${f}`)); + if (unloadable.length > 50) + console.error(` … and ${unloadable.length - 50} more`); + console.error( + "\nA file that cannot be imported reports no violations at all. The resolve " + + "or transform error is in the scan log (.a11y-scan/scan.log, uploaded as a " + + "run artifact); a missing generated asset is the usual cause.", + ); +} if (crashed.length) { console.error(`\n✖ ${crashed.length} story(ies) failed to render:`); crashed.slice(0, 50).forEach((k) => console.error(` ${k}`)); @@ -191,7 +221,7 @@ if (regressions.length) { "baseline key no longer matches — re-record: task frontend:storybook:a11y:record", ); } -if (crashed.length || regressions.length) process.exit(1); +if (unloadable.length || crashed.length || regressions.length) process.exit(1); if (fixed.length) console.log( From 9d01866c83743a7639332006d41f91915809ecbc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 30 Jul 2026 15:08:04 +0100 Subject: [PATCH 015/262] Set PRs to build Mac and Windows binaries when building desktop code (#7203) # Description of Changes Currently, desktop PRs only build on Linux, which none of the core maintainers currently use. Change it so that desktop PRs build Mac and Windows, so core maintainers can test the built version. --- .github/workflows/build.yml | 7 +++---- .github/workflows/tauri-build.yml | 14 ++++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8275c023b..47170d7f07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -174,13 +174,12 @@ jobs: pull-requests: write uses: ./.github/workflows/tauri-build.yml secrets: inherit - # PR smoke build: Linux only (fastest + cheapest to compile), unsigned, - # deb-only, no AppImage. The full signed multi-OS matrix runs on release; + # PR smoke build: macOS + Windows (the platforms our developers use). + # The full signed multi-OS matrix runs on release; # nightly still warms the Rust cache with all-OS defaults. with: - platform: linux + platform: windows-macos sign: false - minimal: true ai-engine: if: needs.files-changed.outputs.engine == 'true' diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index c97ae4eaef..7daaf18bc1 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -12,7 +12,7 @@ on: workflow_call: inputs: platform: - description: "Platform to build (windows, macos, linux, or all)." + description: "Platform to build (windows, macos, linux, windows-macos, or all)." required: false type: string default: "all" @@ -29,7 +29,7 @@ on: workflow_dispatch: inputs: platform: - description: "Platform to build (windows, macos, linux, or all)" + description: "Platform to build (windows, macos, linux, windows-macos, or all)" required: true default: "all" type: choice @@ -38,6 +38,7 @@ on: - windows - macos - linux + - windows-macos sign: description: "Sign and notarize the bundles." required: false @@ -76,10 +77,11 @@ jobs: LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}' case "$PLATFORM" in - windows) ENTRIES=("$WINDOWS") ;; - macos) ENTRIES=("$MACOS") ;; - linux) ENTRIES=("$LINUX") ;; - *) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;; + windows) ENTRIES=("$WINDOWS") ;; + macos) ENTRIES=("$MACOS") ;; + linux) ENTRIES=("$LINUX") ;; + windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;; + *) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;; esac # Drop macOS entries when Apple certificate secret is unavailable From 3bee6d212e28e7ff9b11622e1bc7c4e02c3c251e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 31 Jul 2026 09:53:01 +0100 Subject: [PATCH 016/262] Change pipelines to have 1 input and 1 output (#7121) # Description of Changes Change pipelines so that sources and triggers are grouped into a list of inputs, so you can have a different trigger for each source in the list. This is necessary because triggers are not universally supported by all source types. If you wanted to have a pipeline pull from both a folder and an S3 bucket, the current system allows you to choose "Folder Watch" as the trigger, which will either do nothing or crash when it's paired with the S3 bucket. I've got reservations about actually allowing different triggers for every source because it allows for user workflows that I don't believe exist, like "I want this folder to be polled every minute and this other one to be polled every hour, but they should run the same tools and should output to the same place". Because of this (with agreement from Connor, Anthony and Matt) I've changed this PR to artificially limit pipelines to having 1 input & output at this stage. The backend is still shaped to support multiple inputs & outputs so it should be trivial to re-add support for them in the future if we decide we want to, but the UI can be much simpler and easier to understand with just 1 input and output. image --- .../policy/controller/PolicyController.java | 3 +- .../policy/engine/PolicyRunner.java | 42 ++- .../policy/engine/PolicyValidator.java | 52 ++- .../policy/model/PipelineInput.java | 16 + .../proprietary/policy/model/Policy.java | 61 +-- .../policy/model/PolicyBinding.java | 31 ++ .../overview/PolicyOverviewService.java | 13 +- .../DefaultClassificationPolicySeeder.java | 1 - .../policy/store/InProcessPolicyStore.java | 13 +- .../policy/store/JpaPolicyStore.java | 54 ++- .../policy/store/PolicyEntity.java | 11 +- .../policy/store/PolicyRepository.java | 8 +- .../proprietary/policy/store/PolicyStore.java | 8 +- .../policy/trigger/FolderWatchTrigger.java | 77 ++-- .../policy/trigger/PolicyTrigger.java | 13 +- .../policy/trigger/ScheduleTrigger.java | 44 ++- .../policy/trigger/WebhookTrigger.java | 64 ++-- .../policy/config/FolderAccessGuardTest.java | 10 +- .../policy/config/PolicyAccessGuardTest.java | 2 +- .../controller/PolicyControllerTest.java | 10 +- .../policy/engine/PolicyEngineTest.java | 4 +- .../policy/engine/PolicyRunnerTest.java | 5 +- .../policy/engine/PolicyValidatorTest.java | 59 ++- .../PolicyInlineOutputMigrationTest.java | 3 - .../output/PolicyOutputResolverTest.java | 1 - .../overview/PolicyOverviewServiceTest.java | 13 +- .../s3/EmbeddedS3CredentialMigrationTest.java | 4 +- .../s3/PolicyS3ConnectionUsageCheckTest.java | 1 - ...DefaultClassificationPolicySeederTest.java | 1 - .../policy/source/SourceControllerTest.java | 4 +- .../source/SourceOverviewServiceTest.java | 8 +- .../store/InProcessPolicyStoreTest.java | 20 +- .../policy/store/JpaPolicyStoreTest.java | 54 ++- .../trigger/FolderWatchTriggerTest.java | 99 +++-- .../policy/trigger/ScheduleTriggerTest.java | 87 +++-- .../policy/trigger/WebhookTriggerTest.java | 39 +- .../public/locales/en-US/translation.toml | 8 +- frontend/editor/src/portal/api/pipelines.ts | 18 +- .../pipelines/DestinationPicker.tsx | 47 ++- .../src/portal/mocks/handlers/pipelines.ts | 42 ++- .../src/portal/views/PipelineBuilder.css | 23 ++ .../src/portal/views/PipelineBuilder.test.tsx | 131 +++++-- .../src/portal/views/PipelineBuilder.tsx | 350 +++++++++++------- 43 files changed, 1006 insertions(+), 548 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index a34c392597..b6156bc2b8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -355,8 +355,7 @@ public class PolicyController { policy.name(), owner, policy.enabled(), - policy.trigger(), - policy.sourceIds(), + policy.inputs(), policy.steps(), policy.output(), policy.outputIds(), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 866fe0910d..837e3bd0ae 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -15,6 +15,7 @@ import stirling.software.proprietary.policy.input.ResolvedInput; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; @@ -42,30 +43,46 @@ public class PolicyRunner { private final SourceDocCounter docCounter; private final ProcessedLedger processedLedger; - /** Full-listing sweep: resolve every source, then reconcile the ledger. */ + /** Full-listing sweep over every input: resolve each source, then reconcile the ledger. */ public SweepOutcome run(Policy policy) { return run(policy, SweepKind.FULL); } - /** - * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so - * one failure does not affect the others. No sources means one run with no input (generator - * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the - * rest. Returns the ids of the runs it started plus what the sweep skipped, so a manual trigger - * can report which runs to follow or why nothing ran. - */ + /** Sweep every input of the policy at the given listing depth. */ public SweepOutcome run(Policy policy, SweepKind sweep) { + return run(policy, policy.inputs(), sweep); + } + + /** + * Fire one input binding: a background trigger pulling its own source without touching the + * policy's other inputs. Never reconciles the ledger (it sees a single source, so pruning would + * wrongly forget the rest); a full-policy sweep handles that. + */ + public SweepOutcome runInput(Policy policy, PipelineInput input, SweepKind sweep) { + return run(policy, List.of(input), sweep); + } + + /** + * Core sweep: pulls each of the given inputs' sources; each yielded unit becomes its own run so + * one failure does not affect the others. No inputs means one run with no input (generator + * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the + * rest. Presence cleanup only runs when the sweep covered every input of the policy - a + * single-binding fire cannot reconcile the whole policy's ledger. Returns the ids of the runs + * it started plus what the sweep skipped, so a manual trigger can report which runs to follow + * or why nothing ran. + */ + public SweepOutcome run(Policy policy, List inputs, SweepKind sweep) { long sweepStart = System.currentTimeMillis(); PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger); List runIds = new ArrayList<>(); - List sourceIds = policy.sourceIds(); - if (sourceIds.isEmpty()) { + if (inputs.isEmpty()) { // Generator pipeline: one run with no input. Still fall through to the cleanup // below so rows recorded for its folder outputs are pruned like anything else, // instead of accumulating until the policy is deleted. runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); } - for (String sourceId : sourceIds) { + for (PipelineInput input : inputs) { + String sourceId = input.sourceId(); Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { // No veto: a deleted source's rows should age out via the cleanup below. @@ -84,7 +101,8 @@ public class PolicyRunner { } runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec(), context)); } - if (context.cleanupAllowed()) { + boolean fullPolicy = inputs.size() == policy.inputs().size(); + if (fullPolicy && context.cleanupAllowed()) { processedLedger.markSeen(policy.id(), context.presentIdentities()); int removed = processedLedger.deleteUnseen(policy.id(), sweepStart); if (removed > 0) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index c2d1357889..b30a760fe3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; @@ -18,10 +19,11 @@ import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** - * Validates a policy at save time by delegating each facet (trigger, sources, steps, output) to the - * bean that handles its type, so a misconfiguration fails fast rather than at run time. A null - * trigger is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} - * must resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. + * Validates a policy at save time by delegating each facet (inputs, their triggers, output) to the + * bean that handles its type, so a misconfiguration fails fast rather than at run time. Each + * input's {@code sourceId} must resolve to a persisted {@link Source} whose config its {@link + * InputSource} bean accepts; its optional trigger must be a known type compatible with that source. + * A null trigger is a manual-only input and skips trigger validation. */ @Service @RequiredArgsConstructor @@ -34,21 +36,31 @@ public class PolicyValidator { private final SourceStore sourceStore; /** - * @throws IllegalArgumentException if any facet's type is unknown, a referenced source does not - * exist, or any config is invalid + * @throws IllegalArgumentException if the policy has more than one input or output, any facet's + * type is unknown, a referenced source does not exist, a trigger is incompatible with its + * input's source, or any config is invalid */ public void validate(Policy policy) { - if (policy.trigger() != null) { - triggerFor(policy.trigger()).validate(policy); + // Deliberate product cap, not a model limit: the lists stay lists so multiple + // inputs/outputs can be supported later, but today a policy carries at most one of + // each (zero of either remains fine - run on demand / inline output). + if (policy.inputs().size() > 1) { + throw new IllegalArgumentException("a policy supports at most one input"); } - for (String sourceId : policy.sourceIds()) { + if (policy.outputIds().size() > 1) { + throw new IllegalArgumentException("a policy supports at most one output"); + } + for (PipelineInput input : policy.inputs()) { Source source = sourceStore - .get(sourceId) + .get(input.sourceId()) .orElseThrow( () -> new IllegalArgumentException( - "unknown source: " + sourceId)); + "unknown source: " + input.sourceId())); + if (input.trigger() != null) { + validateTrigger(policy, input, source); + } InputSpec spec = source.toInputSpec(); inputSourceFor(spec).validate(spec); } @@ -73,6 +85,24 @@ public class PolicyValidator { } } + /** + * Check an input's trigger is a known type whose source constraints its source satisfies (e.g. + * folder-watch only on a folder source), then let the trigger validate its own options. + */ + private void validateTrigger(Policy policy, PipelineInput input, Source source) { + PolicyTrigger trigger = triggerFor(input.trigger()); + if (!trigger.supportedSourceTypes().isEmpty() + && !trigger.supportedSourceTypes().contains(source.type())) { + throw new IllegalArgumentException( + "trigger '" + + trigger.type() + + "' is not compatible with source type '" + + source.type() + + "'"); + } + trigger.validate(policy, input); + } + /** * Validate an output spec against its sink. Must be called on a request thread (caller's * principal present) so an S3 output's connection is authorization-checked against the caller - diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java new file mode 100644 index 0000000000..15242a9916 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineInput.java @@ -0,0 +1,16 @@ +package stirling.software.proprietary.policy.model; + +/** + * One input of a policy: a reference to a persisted {@code Source} paired with the {@link + * TriggerConfig} that decides when this source is pulled. The trigger lives on the + * binding, not on the source (so one connection can feed many policies on different schedules) and + * not on the policy (so a folder input can be watched while an S3 input on the same policy polls). + * A {@code null} trigger means this input is pulled only when the policy is run on demand. + */ +public record PipelineInput(String sourceId, TriggerConfig trigger) { + + /** An input with no automatic trigger: pulled only on a manual run. */ + public static PipelineInput manual(String sourceId) { + return new PipelineInput(sourceId, null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index ae9fedc46a..a3d43b712d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -3,29 +3,31 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * A stored automation: ordered tool steps, input sources, and output destinations. + * A stored automation: ordered tool steps, input bindings, and output destinations. * - *

Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code - * null} trigger means manual-only. Trigger decides when; {@code sourceIds} reference the persisted - * {@code Source} locations (resolved live at run time) files come from; a run pulls from every - * referenced source. {@code outputIds} reference the {@code Source} locations (resolved live) a - * run's files are delivered to - a run is delivered to every one; when empty the inline {@link - * #output} is used (results returned to the caller), the case for editor and one-off policies. + *

Always runnable on demand. Each {@link PipelineInput} references a persisted {@code Source} + * connection (resolved live at run time) and carries its own optional {@link TriggerConfig}: the + * trigger decides when that source is pulled, so one input can be watched while another polls, and + * a {@code null} trigger makes that input manual-only. An input with no trigger, or a policy with + * no triggered inputs, still runs when the policy is run on demand; a manual run pulls every input. + * + *

{@code outputIds} reference the {@code Source} locations (resolved live) a run's files are + * delivered to - a run is delivered to every one; when empty the inline {@link #output} is used + * (results returned to the caller), the case for editor and one-off policies. */ public record Policy( String id, String name, String owner, boolean enabled, - TriggerConfig trigger, - List sourceIds, + List inputs, List steps, OutputSpec output, List outputIds, Long teamId) { public Policy { - sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds); + inputs = inputs == null ? List.of() : List.copyOf(inputs); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; outputIds = outputIds == null ? List.of() : List.copyOf(outputIds); @@ -41,12 +43,11 @@ public record Policy( String name, String owner, boolean enabled, - TriggerConfig trigger, - List sourceIds, + List inputs, List steps, OutputSpec output, Long teamId) { - this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), teamId); + this(id, name, owner, enabled, inputs, steps, output, List.of(), teamId); } /** @@ -58,35 +59,35 @@ public record Policy( String name, String owner, boolean enabled, - TriggerConfig trigger, - List sourceIds, + List inputs, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), null); + this(id, name, owner, enabled, inputs, steps, output, List.of(), null); } - /** A policy with no configured sources (a generator, or files supplied directly to a run). */ - public Policy( - String id, - String name, - String owner, - boolean enabled, - TriggerConfig trigger, - List steps, - OutputSpec output) { - this(id, name, owner, enabled, trigger, List.of(), steps, output, List.of(), null); + /** The source ids this policy pulls from, in input order; a derived view for reads. */ + public List sourceIds() { + return inputs.stream().map(PipelineInput::sourceId).toList(); + } + + /** The distinct trigger types configured across this policy's inputs (manual inputs aside). */ + public List triggerTypes() { + return inputs.stream() + .map(PipelineInput::trigger) + .filter(trigger -> trigger != null) + .map(TriggerConfig::type) + .distinct() + .toList(); } /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ public Policy withOutput(OutputSpec resolved) { - return new Policy( - id, name, owner, enabled, trigger, sourceIds, steps, resolved, outputIds, teamId); + return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId); } /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { - return new Policy( - id, name, owner, enabled, trigger, sourceIds, steps, output, newOutputIds, teamId); + return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java new file mode 100644 index 0000000000..18e93ce1bc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyBinding.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.policy.model; + +import java.util.List; + +/** + * A policy paired with one of its {@link PipelineInput}s: the unit a background trigger fires. A + * policy with two triggered inputs yields two bindings, so each fires independently on its own + * trigger and pulls only its own source. + */ +public record PolicyBinding(Policy policy, PipelineInput input) { + + /** + * The bindings across these policies whose input carries a trigger of the given type. Shared by + * the {@code PolicyStore} implementations so every backend derives a trigger's bindings the + * same way. Callers pass the policies a background trigger should consider (i.e. the enabled + * ones). + */ + public static List matching(List policies, String triggerType) { + return policies.stream() + .flatMap( + policy -> + policy.inputs().stream() + .filter( + input -> + input.trigger() != null + && triggerType.equals( + input.trigger().type())) + .map(input -> new PolicyBinding(policy, input))) + .toList(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index c927ec199c..272917ec7f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -14,7 +14,6 @@ import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; -import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceAccessGuard; import stirling.software.proprietary.policy.source.SourceStore; @@ -73,7 +72,7 @@ public class PolicyOverviewService { policy.name(), policy.enabled(), policy.enabled() ? "active" : "paused", - triggerSummary(policy.trigger()), + triggerSummary(policy), sources, steps, outputSummary(policy, sourceNames), @@ -95,9 +94,13 @@ public class PolicyOverviewService { return outputSummary(policy.output()); } - /** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */ - private static String triggerSummary(TriggerConfig trigger) { - return trigger == null ? "manual" : trigger.type(); + /** + * Summarise a policy's triggers for the overview row: "manual" when no input is triggered, + * otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule"). + */ + private static String triggerSummary(Policy policy) { + List types = policy.triggerTypes(); + return types.isEmpty() ? "manual" : String.join(", ", types); } private static String outputSummary(OutputSpec output) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 4150dc2f31..63ebae833a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -87,7 +87,6 @@ public class DefaultClassificationPolicySeeder { POLICY_NAME, "system", true, - null, List.of(), List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), new OutputSpec("inline", options), diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 6498e7e28a..de6be8ef5d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -9,6 +9,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; /** * In-memory {@link PolicyStore} for tests and any future no-database mode. {@link JpaPolicyStore} @@ -32,8 +33,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.name(), policy.owner(), policy.enabled(), - policy.trigger(), - policy.sourceIds(), + policy.inputs(), policy.steps(), policy.output(), policy.outputIds(), @@ -77,12 +77,9 @@ public class InProcessPolicyStore implements PolicyStore { } @Override - public List findByTriggerType(String triggerType) { - return policies.values().stream() - .filter(Policy::enabled) - .filter(policy -> policy.trigger() != null) - .filter(policy -> triggerType.equals(policy.trigger().type())) - .toList(); + public List findBindingsByTriggerType(String triggerType) { + List enabled = policies.values().stream().filter(Policy::enabled).toList(); + return PolicyBinding.matching(enabled, triggerType); } @Override diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 1b598b88c1..4f91f9e930 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -12,8 +12,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; /** * Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via @@ -40,8 +44,7 @@ public class JpaPolicyStore implements PolicyStore { policy.name(), policy.owner(), policy.enabled(), - policy.trigger(), - policy.sourceIds(), + policy.inputs(), policy.steps(), policy.output(), policy.outputIds(), @@ -52,7 +55,6 @@ public class JpaPolicyStore implements PolicyStore { entity.setName(stored.name()); entity.setOwner(stored.owner()); entity.setEnabled(stored.enabled()); - entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type()); entity.setTeamId(stored.teamId()); // Preserve an existing policy's run-order position; append a new one to the end of its // team's queue (max + 1), so setting up a policy adds it last by default. @@ -119,11 +121,13 @@ public class JpaPolicyStore implements PolicyStore { } @Override - public List findByTriggerType(String triggerType) { - return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream() - .map(this::toPolicy) - .flatMap(Optional::stream) - .toList(); + public List findBindingsByTriggerType(String triggerType) { + List enabled = + repository.findByEnabledTrue().stream() + .map(this::toPolicy) + .flatMap(Optional::stream) + .toList(); + return PolicyBinding.matching(enabled, triggerType); } @Override @@ -139,7 +143,8 @@ public class JpaPolicyStore implements PolicyStore { // One unreadable row must never abort a bulk read or crash startup. private Optional toPolicy(PolicyEntity entity) { try { - return Optional.of(objectMapper.readValue(entity.getPolicyJson(), Policy.class)); + JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())); + return Optional.of(objectMapper.treeToValue(node, Policy.class)); } catch (Exception e) { log.error( "Skipping unreadable policy id={} name={}: stored JSON could not be parsed" @@ -150,4 +155,35 @@ public class JpaPolicyStore implements PolicyStore { return Optional.empty(); } } + + /** + * Migrate a policy JSON blob written before triggers moved onto inputs. The old shape carried a + * single policy-level {@code trigger} and a {@code sourceIds} list; pair each source with that + * trigger so an upgraded policy keeps firing. A trigger incompatible with a source + * (folder-watch on an S3 source) is simply inert at run time, matching the old behaviour where + * such a source was never watched. New-shape blobs (already carrying {@code inputs}) are + * returned untouched. + */ + private JsonNode upgradeLegacyShape(JsonNode root) { + if (!(root instanceof ObjectNode obj) || obj.has("inputs")) { + return root; + } + JsonNode trigger = obj.get("trigger"); + JsonNode sourceIds = obj.get("sourceIds"); + ArrayNode inputs = objectMapper.createArrayNode(); + if (sourceIds != null && sourceIds.isArray()) { + for (JsonNode sourceId : sourceIds) { + ObjectNode input = objectMapper.createObjectNode(); + input.set("sourceId", sourceId); + if (trigger != null && !trigger.isNull()) { + input.set("trigger", trigger); + } + inputs.add(input); + } + } + obj.set("inputs", inputs); + obj.remove("trigger"); + obj.remove("sourceIds"); + return obj; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index c871267bc4..fa639b5c19 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -17,10 +17,10 @@ import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConve /** * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives * as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies - * for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch - * their policies, and {@code teamId} so the caller's team can be loaded without scanning every - * team's rows. {@code owner} and {@code teamId} are plain values, not foreign keys, to stay - * decoupled from the security entities. + * for querying, notably {@code enabled} so background triggers can scan the active policies, and + * {@code teamId} so the caller's team can be loaded without scanning every team's rows. {@code + * owner} and {@code teamId} are plain values, not foreign keys, to stay decoupled from the security + * entities. */ @Entity @Table(name = "policies") @@ -44,9 +44,6 @@ public class PolicyEntity implements Serializable { @Column(name = "enabled") private boolean enabled; - @Column(name = "trigger_type") - private String triggerType; - @Column(name = "team_id") private Long teamId; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java index 82ae4355dd..1c8465c82a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java @@ -13,8 +13,12 @@ import jakarta.persistence.LockModeType; @Repository public interface PolicyRepository extends JpaRepository { - /** Enabled policies of a given trigger type, for background triggers to activate. */ - List findByTriggerTypeAndEnabledTrue(String triggerType); + /** + * Enabled policies, for background triggers to scan for inputs of their trigger type. Which + * inputs (and their trigger types) a policy carries lives in the JSON blob, so the type filter + * is applied after parsing rather than in SQL. + */ + List findByEnabledTrue(); /** * Policies belonging to a team, in run order (ascending {@code sortOrder}; a null order sorts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index ac9e210439..fa2a7e9b87 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; /** Stores {@link Policy} definitions. */ public interface PolicyStore { @@ -18,8 +19,11 @@ public interface PolicyStore { /** Policies owned by the given team, loaded scoped rather than fetched globally. */ List findByTeam(Long teamId); - /** Enabled policies with the given trigger type, for background triggers. */ - List findByTriggerType(String triggerType); + /** + * Enabled inputs with the given trigger type, as {@code (policy, input)} bindings, so a + * background trigger fires each input independently and pulls only its own source. + */ + List findBindingsByTriggerType(String triggerType); /** * Set the team's run order from {@code orderedIds} (position → sortOrder). Only policies that diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index a4470a418b..5b859a4786 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -31,7 +31,9 @@ import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; @@ -85,10 +87,10 @@ public class FolderWatchTrigger implements PolicyTrigger { } @Override - public void validate(Policy policy) { - if (watchDirsOf(policy).isEmpty()) { + public void validate(Policy policy, PipelineInput input) { + if (watchDirsOf(input).isEmpty()) { throw new IllegalArgumentException( - "folder-watch trigger requires at least one watchable (folder) input source"); + "folder-watch trigger requires a watchable (folder) input source"); } } @@ -185,24 +187,30 @@ public class FolderWatchTrigger implements PolicyTrigger { return changed; } - /** Run every folder-watch policy that draws from one of the changed directories. */ + /** Fire every folder-watch input that draws from one of the changed directories. */ void runForChangedDirs(Set changedDirs) { if (changedDirs.isEmpty()) { return; } - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { List dirs; try { - dirs = watchDirsOf(policy); + dirs = watchDirsOf(binding.input()); } catch (RuntimeException e) { log.warn( - "Folder-watch policy {} is misconfigured: {}", policy.id(), e.getMessage()); + "Folder-watch input {}/{} is misconfigured: {}", + binding.policy().id(), + binding.input().sourceId(), + e.getMessage()); continue; } if (dirs.stream().anyMatch(changedDirs::contains)) { - log.debug("Folder-watch policy {} ({}) saw activity", policy.id(), policy.name()); + log.debug( + "Folder-watch input {}/{} saw activity", + binding.policy().id(), + binding.input().sourceId()); // Light: the periodic reconcile does the full sweep. - policyRunner.run(policy, SweepKind.LIGHT); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.LIGHT); } } } @@ -216,15 +224,16 @@ public class FolderWatchTrigger implements PolicyTrigger { } } - /** Reconcile safety net: run every folder-watch policy regardless of watch events. */ + /** Reconcile safety net: run every folder-watch input regardless of watch events. */ void runAll() { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { try { - policyRunner.run(policy); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.FULL); } catch (RuntimeException e) { log.warn( - "Folder-watch reconcile run failed for policy {}: {}", - policy.id(), + "Folder-watch reconcile run failed for input {}/{}: {}", + binding.policy().id(), + binding.input().sourceId(), e.getMessage()); } } @@ -269,41 +278,43 @@ public class FolderWatchTrigger implements PolicyTrigger { return Set.copyOf(keysByDir.keySet()); } - /** Every existing directory any current folder-watch policy wants watched. */ + /** Every existing directory any current folder-watch input wants watched. */ private Set desiredDirs() { Set dirs = new HashSet<>(); - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { try { - for (Path dir : watchDirsOf(policy)) { + for (Path dir : watchDirsOf(binding.input())) { if (Files.isDirectory(dir)) { dirs.add(dir); } } } catch (RuntimeException e) { log.warn( - "Folder-watch policy {} is misconfigured: {}", policy.id(), e.getMessage()); + "Folder-watch input {}/{} is misconfigured: {}", + binding.policy().id(), + binding.input().sourceId(), + e.getMessage()); } } return dirs; } // Absolute + normalised so registration keys and event-time matching compare regardless of how - // the path was configured. - private List watchDirsOf(Policy policy) { + // the path was configured. Empty for a non-folder or missing source (that input is never + // watched), so a folder-watch trigger paired with an S3 input is simply inert. + private List watchDirsOf(PipelineInput input) { List dirs = new ArrayList<>(); - for (String sourceId : policy.sourceIds()) { - Source source = sourceStore.get(sourceId).orElse(null); - if (source == null) { - continue; - } - InputSpec spec = source.toInputSpec(); - InputSource inputSource = sourceFor(spec); - if (inputSource == null) { - continue; - } - for (Path dir : inputSource.watchTargets(spec)) { - dirs.add(dir.toAbsolutePath().normalize()); - } + Source source = sourceStore.get(input.sourceId()).orElse(null); + if (source == null) { + return dirs; + } + InputSpec spec = source.toInputSpec(); + InputSource inputSource = sourceFor(spec); + if (inputSource == null) { + return dirs; + } + for (Path dir : inputSource.watchTargets(spec)) { + dirs.add(dir.toAbsolutePath().normalize()); } return dirs; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java index ade5357162..e1e0cc7873 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java @@ -2,11 +2,13 @@ package stirling.software.proprietary.policy.trigger; import java.util.Set; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; /** - * Decides when a policy runs. On firing it hands the policy to {@code PolicyRunner}; it - * never resolves sources itself. New trigger kinds are just new beans of this type. + * Decides when a policy input runs. On firing it hands the binding to {@code + * PolicyRunner}, which pulls only that input's source; it never resolves sources itself. New + * trigger kinds are just new beans of this type. */ public interface PolicyTrigger { @@ -32,10 +34,11 @@ public interface PolicyTrigger { } /** - * Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole - * {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that. + * Validate one input's use of this trigger at save time so misconfiguration fails fast, not at + * fire time. Receives the owning {@link Policy} and the specific {@link PipelineInput} so a + * trigger that depends on the input's source (folder-watch) can check it. */ - default void validate(Policy policy) {} + default void validate(Policy policy, PipelineInput input) {} default void start() {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 2018ffd126..f4ffb30146 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -17,14 +17,18 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.Schedule; import stirling.software.proprietary.policy.store.PolicyStore; import tools.jackson.databind.ObjectMapper; /** - * Fires policies on a {@link Schedule}: a fixed-interval sweep runs each due "schedule" policy. + * Fires policy inputs on a {@link Schedule}: a fixed-interval sweep pulls each due "schedule" + * input, independently of the policy's other inputs. * *

Last-fire times are in memory, so this assumes a single node and resets on restart. */ @@ -40,17 +44,20 @@ public class ScheduleTrigger implements PolicyTrigger { private final ObjectMapper objectMapper; private final ApplicationProperties applicationProperties; - private final Map lastFiredByPolicy = new ConcurrentHashMap<>(); + private final Map lastFiredByBinding = new ConcurrentHashMap<>(); private volatile ScheduledExecutorService scheduler; + /** Identifies a schedule binding: one input (by source) of one policy. */ + private record BindingKey(String policyId, String sourceId) {} + @Override public String type() { return TYPE; } @Override - public void validate(Policy policy) { - ScheduleConfig.from(objectMapper, policy.trigger().options()); + public void validate(Policy policy, PipelineInput input) { + ScheduleConfig.from(objectMapper, input.trigger().options()); } @Override @@ -83,19 +90,26 @@ public class ScheduleTrigger implements PolicyTrigger { } } - /** Fire every scheduled policy that is due as of {@code now}. Package-visible for testing. */ + /** Fire every scheduled input that is due as of {@code now}. Package-visible for testing. */ void sweep(Instant now) { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { + Policy policy = binding.policy(); + PipelineInput input = binding.input(); ScheduleConfig config; try { - config = ScheduleConfig.from(objectMapper, policy.trigger().options()); + config = ScheduleConfig.from(objectMapper, input.trigger().options()); } catch (IllegalArgumentException e) { - log.warn("Scheduled policy {} is misconfigured: {}", policy.id(), e.getMessage()); + log.warn( + "Scheduled input {}/{} is misconfigured: {}", + policy.id(), + input.sourceId(), + e.getMessage()); continue; } - // Baseline a newly-seen policy to now so it does not fire immediately. - Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now); + // Baseline a newly-seen binding to now so it does not fire immediately. + BindingKey key = new BindingKey(policy.id(), input.sourceId()); + Instant last = lastFiredByBinding.computeIfAbsent(key, id -> now); ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone())); if (next.toInstant().isAfter(now)) { continue; @@ -105,9 +119,13 @@ public class ScheduleTrigger implements PolicyTrigger { next = later; later = config.schedule().nextAfter(later); } - lastFiredByPolicy.put(policy.id(), next.toInstant()); - log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name()); - policyRunner.run(policy); + lastFiredByBinding.put(key, next.toInstant()); + log.info( + "Scheduled input {}/{} ({}) is due", + policy.id(), + input.sourceId(), + policy.name()); + policyRunner.runInput(policy, input, SweepKind.FULL); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java index 816f510bba..d5bb412a7c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java @@ -13,7 +13,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.SweepKind; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; @@ -50,15 +52,14 @@ public class WebhookTrigger implements PolicyTrigger { } @Override - public void validate(Policy policy) { - boolean hasWebhookSource = - policy.sourceIds().stream() - .map(sourceStore::get) - .flatMap(java.util.Optional::stream) - .anyMatch(source -> WEBHOOK_SOURCE_TYPE.equals(source.type())); - if (!hasWebhookSource) { - throw new IllegalArgumentException( - "webhook trigger requires at least one webhook input source"); + public void validate(Policy policy, PipelineInput input) { + boolean isWebhookSource = + sourceStore + .get(input.sourceId()) + .filter(source -> WEBHOOK_SOURCE_TYPE.equals(source.type())) + .isPresent(); + if (!isWebhookSource) { + throw new IllegalArgumentException("webhook trigger requires a webhook input source"); } } @@ -83,29 +84,38 @@ public class WebhookTrigger implements PolicyTrigger { } } + /** Fire every webhook input fed by this webhook, pulling only that input's source. */ public void fireForWebhook(String webhookId) { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { - if (!referencesWebhook(policy, webhookId)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { + if (!referencesWebhook(binding.input(), webhookId)) { continue; } try { - log.debug("Webhook policy {} ({}) saw a delivery", policy.id(), policy.name()); - policyRunner.run(policy, SweepKind.LIGHT); + log.debug( + "Webhook input {}/{} saw a delivery", + binding.policy().id(), + binding.input().sourceId()); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.LIGHT); } catch (RuntimeException e) { - log.warn("Webhook run failed for policy {}: {}", policy.id(), e.getMessage()); + log.warn( + "Webhook run failed for input {}/{}: {}", + binding.policy().id(), + binding.input().sourceId(), + e.getMessage()); } } } private void safeReconcile() { try { - for (Policy policy : policyStore.findByTriggerType(TYPE)) { + for (PolicyBinding binding : policyStore.findBindingsByTriggerType(TYPE)) { try { - policyRunner.run(policy); + policyRunner.runInput(binding.policy(), binding.input(), SweepKind.FULL); } catch (RuntimeException e) { log.warn( - "Webhook reconcile run failed for policy {}: {}", - policy.id(), + "Webhook reconcile run failed for input {}/{}: {}", + binding.policy().id(), + binding.input().sourceId(), e.getMessage()); } } @@ -114,17 +124,13 @@ public class WebhookTrigger implements PolicyTrigger { } } - private boolean referencesWebhook(Policy policy, String webhookId) { - for (String sourceId : policy.sourceIds()) { - Source source = sourceStore.get(sourceId).orElse(null); - if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) { - continue; - } - Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); - if (configured != null && configured.toString().equals(webhookId)) { - return true; - } + /** Whether this input draws from the webhook source the delivery arrived on. */ + private boolean referencesWebhook(PipelineInput input, String webhookId) { + Source source = sourceStore.get(input.sourceId()).orElse(null); + if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) { + return false; } - return false; + Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); + return configured != null && configured.toString().equals(webhookId); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java index 351dcd1d2f..431d74d8b8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java @@ -17,6 +17,7 @@ import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; @@ -189,6 +190,13 @@ class FolderAccessGuardTest { null)) .id()) .toList(); - return new Policy("p1", "p", "owner", true, null, sourceIds, List.of(), output); + return new Policy( + "p1", + "p", + "owner", + true, + sourceIds.stream().map(PipelineInput::manual).toList(), + List.of(), + output); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java index 35c4d2631e..c3f6552dc5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java @@ -90,6 +90,6 @@ class PolicyAccessGuardTest { private static Policy inTeam(Long teamId) { return new Policy( - null, "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); + null, "p", "owner", true, List.of(), List.of(), OutputSpec.inline(), teamId); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 351eecd581..c29c96a8ed 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -139,7 +139,7 @@ class PolicyControllerTest { } private static Policy policy(String id, Long teamId) { - return new Policy(id, "name", "owner", true, null, List.of(), List.of(), null, teamId); + return new Policy(id, "name", "owner", true, List.of(), List.of(), null, teamId); } private static Policy s3OutputPolicy(String id, String secret) { @@ -150,7 +150,7 @@ class PolicyControllerTest { "bucket", "outbox", "accessKeyId", "AKIAEXAMPLE", "secretAccessKey", secret)); - return new Policy(id, "name", "owner", true, null, List.of(), List.of(), output, 1L); + return new Policy(id, "name", "owner", true, List.of(), List.of(), output, 1L); } private static PolicyRunHandle handle(String runId) { @@ -401,8 +401,7 @@ class PolicyControllerTest { void updatePreservesOwnership() { applicationProperties.getSecurity().setEnableLogin(false); Policy existing = - new Policy( - "p2", "name", "origOwner", true, null, List.of(), List.of(), null, 3L); + new Policy("p2", "name", "origOwner", true, List.of(), List.of(), null, 3L); when(policyStore.get("p2")).thenReturn(Optional.of(existing)); when(policyAccessGuard.canAccess(existing)).thenReturn(true); when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0)); @@ -410,8 +409,7 @@ class PolicyControllerTest { ResponseEntity response = controller.savePolicy( new Policy( - "p2", "name", "forged", true, null, List.of(), List.of(), null, - 77L)); + "p2", "name", "forged", true, List.of(), List.of(), null, 77L)); assertThat(response.getBody().owner()).isEqualTo("origOwner"); assertThat(response.getBody().teamId()).isEqualTo(3L); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index e69f0f5476..560c80d7cd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -262,7 +262,7 @@ class PolicyEngineTest { "rotate", "owner", true, - null, + List.of(), List.of(new PipelineStep(ROTATE, Map.of())), OutputSpec.inline()); @@ -306,7 +306,7 @@ class PolicyEngineTest { "rotate", "alice", true, - null, + List.of(), List.of(new PipelineStep(ROTATE, Map.of())), OutputSpec.inline()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index e21f371305..51959a2525 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -36,6 +36,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; @@ -306,7 +307,6 @@ class PolicyRunnerTest { "p", "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), @@ -338,8 +338,7 @@ class PolicyRunnerTest { "p", "owner", true, - null, - sourceIds, + sourceIds.stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 2e9613a024..6440cefa4a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -20,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; @@ -60,25 +61,27 @@ class PolicyValidatorTest { validator.validate(policy); - verify(trigger).validate(policy); + verify(trigger).validate(policy, policy.inputs().get(0)); verify(inputSource).validate(InputSpec.folder("/in")); verify(outputSink).validate(policy.output()); } @Test - void skipsTriggerValidationForAManualOnlyPolicy() { + void skipsTriggerValidationForAManualOnlyInput() { when(inputSource.supports(any())).thenReturn(true); when(outputSink.supports(any())).thenReturn(true); validator.validate(manualOnly()); - verify(trigger, never()).validate(any()); + verify(trigger, never()).validate(any(), any()); } @Test void surfacesAnInvalidConfigFromAHandler() { when(trigger.type()).thenReturn("schedule"); - doThrow(new IllegalArgumentException("invalid schedule")).when(trigger).validate(any()); + doThrow(new IllegalArgumentException("invalid schedule")) + .when(trigger) + .validate(any(), any()); IllegalArgumentException ex = assertThrows( @@ -120,14 +123,55 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("unknown trigger type")); } + // The one-input/one-output caps are a product decision, not a model limit: the lists stay so + // multiple can be supported later, but saving more than one of either is rejected today. + + @Test + void rejectsMoreThanOneInput() { + Policy twoInputs = + new Policy( + "p1", + "p", + "owner", + true, + List.of( + PipelineInput.manual(folderSourceId()), + PipelineInput.manual(folderSourceId())), + List.of(), + OutputSpec.inline()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> validator.validate(twoInputs)); + assertTrue(ex.getMessage().contains("at most one input")); + } + + @Test + void rejectsMoreThanOneOutput() { + Policy twoOutputs = manualOnly().withOutputIds(List.of("out-a", "out-b")); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> validator.validate(twoOutputs)); + assertTrue(ex.getMessage().contains("at most one output")); + } + + @Test + void allowsZeroInputsAndZeroOutputs() { + when(outputSink.supports(any())).thenReturn(true); + Policy bare = + new Policy("p1", "p", "owner", true, List.of(), List.of(), OutputSpec.inline()); + + validator.validate(bare); + } + private Policy policy(String triggerType) { return new Policy( "p1", "p", "owner", true, - new TriggerConfig(triggerType, Map.of()), - List.of(folderSourceId()), + List.of( + new PipelineInput( + folderSourceId(), new TriggerConfig(triggerType, Map.of()))), List.of(), OutputSpec.inline()); } @@ -138,8 +182,7 @@ class PolicyValidatorTest { "p", "owner", true, - null, - List.of(folderSourceId()), + List.of(PipelineInput.manual(folderSourceId())), List.of(), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java index 082ca8cd42..8cd91c56e9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java @@ -54,7 +54,6 @@ class PolicyInlineOutputMigrationTest { "Editor run", "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -128,7 +127,6 @@ class PolicyInlineOutputMigrationTest { name, "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.folder(directory)); @@ -140,7 +138,6 @@ class PolicyInlineOutputMigrationTest { name, "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.folder(directory), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java index d65c5b24f2..acaa68dae1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java @@ -65,7 +65,6 @@ class PolicyOutputResolverTest { "Pipeline", "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 221cbc4482..8610926c2a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -16,6 +16,7 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; @@ -57,8 +58,9 @@ class PolicyOverviewServiceTest { "Redaction", "owner", true, - new TriggerConfig("schedule", Map.of()), - List.of(claims.id()), + List.of( + new PipelineInput( + claims.id(), new TriggerConfig("schedule", Map.of()))), List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())), OutputSpec.inline())); policyStore.save( @@ -67,7 +69,6 @@ class PolicyOverviewServiceTest { "Archive (paused)", "owner", false, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -102,8 +103,7 @@ class PolicyOverviewServiceTest { "Orphan", "owner", true, - null, - List.of("src-missing"), + List.of(PipelineInput.manual("src-missing")), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -170,8 +170,7 @@ class PolicyOverviewServiceTest { name, "owner", true, - null, - List.of(sourceIds), + List.of(sourceIds).stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), teamId)); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java index a794ee104e..41f80461cd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java @@ -25,6 +25,7 @@ import stirling.software.proprietary.integration.repository.IntegrationConfigRep import stirling.software.proprietary.model.Team; import stirling.software.proprietary.policy.migration.InProcessCompletedMigrations; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.InProcessSourceStore; @@ -97,8 +98,7 @@ class EmbeddedS3CredentialMigrationTest { "Rotate", "alice", true, - null, - List.of(source.id()), + List.of(PipelineInput.manual(source.id())), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), new OutputSpec( "s3", diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java index 847a553ba6..4af725428d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java @@ -39,7 +39,6 @@ class PolicyS3ConnectionUsageCheckTest { "Rotate", "alice", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), new OutputSpec("s3", Map.of("connectionId", "5")), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index 2f270afddb..49159e7d6e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -41,7 +41,6 @@ class DefaultClassificationPolicySeederTest { "Classification Policy", "system", true, - null, List.of(), List.of(), new OutputSpec("inline", Map.of("categoryId", "classification")), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java index 545ea8e50f..20f783f75c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -30,6 +30,7 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.input.WebhookInputSource; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.InProcessPolicyStore; @@ -274,8 +275,7 @@ class SourceControllerTest { name, "owner", true, - null, - List.of(sourceId), + List.of(PipelineInput.manual(sourceId)), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java index 3f63e18407..a8c295acc8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -16,6 +16,7 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.InProcessPolicyStore; @@ -216,8 +217,7 @@ class SourceOverviewServiceTest { name, "owner", true, - null, - List.of(sourceIds), + List.of(sourceIds).stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); } @@ -232,7 +232,6 @@ class SourceOverviewServiceTest { name, "owner", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), new OutputSpec("inline", Map.of("sources", List.of("editor"))))); @@ -245,8 +244,7 @@ class SourceOverviewServiceTest { name, "owner", true, - null, - List.of(sourceIds), + List.of(sourceIds).stream().map(PipelineInput::manual).toList(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), teamId)); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java index 60f14be20d..f74a4cfb5b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/InProcessPolicyStoreTest.java @@ -12,11 +12,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; -/** Tests for {@link InProcessPolicyStore}: id assignment, upsert, trigger-type lookup, delete. */ +/** Tests for {@link InProcessPolicyStore}: id assignment, upsert, binding lookup, delete. */ class InProcessPolicyStoreTest { private PolicyStore store; @@ -45,7 +47,7 @@ class InProcessPolicyStoreTest { "after", "owner", true, - null, + List.of(), List.of(), OutputSpec.inline())); @@ -54,16 +56,16 @@ class InProcessPolicyStoreTest { } @Test - void findByTriggerTypeReturnsOnlyEnabledMatches() { + void findBindingsByTriggerTypeReturnsOnlyEnabledMatches() { store.save(policy(null, "nightly", "schedule", true)); store.save(policy(null, "nightly-disabled", "schedule", false)); store.save(policy(null, "hooked", "webhook", true)); store.save(policy(null, "on-demand", null, true)); // manual-only: no trigger - List scheduled = store.findByTriggerType("schedule"); + List scheduled = store.findBindingsByTriggerType("schedule"); assertEquals(1, scheduled.size()); - assertEquals("nightly", scheduled.get(0).name()); + assertEquals("nightly", scheduled.get(0).policy().name()); } @Test @@ -76,14 +78,16 @@ class InProcessPolicyStoreTest { } private static Policy policy(String id, String name, String triggerType, boolean enabled) { - TriggerConfig trigger = - triggerType == null ? null : new TriggerConfig(triggerType, Map.of()); + PipelineInput input = + triggerType == null + ? PipelineInput.manual("src") + : new PipelineInput("src", new TriggerConfig(triggerType, Map.of())); return new Policy( id, name, "owner", enabled, - trigger, + List.of(input), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index e9667f2e8d..2a1d2b4f11 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -19,8 +19,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; import tools.jackson.databind.ObjectMapper; @@ -53,8 +55,9 @@ class JpaPolicyStoreTest { "compress incoming", "alice", true, - new TriggerConfig("schedule", Map.of()), - List.of("src-in"), + List.of( + new PipelineInput( + "src-in", new TriggerConfig("schedule", Map.of()))), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -63,7 +66,6 @@ class JpaPolicyStoreTest { verify(repository).save(captor.capture()); PolicyEntity entity = captor.getValue(); assertEquals(saved.id(), entity.getId()); - assertEquals("schedule", entity.getTriggerType()); assertTrue(entity.isEnabled()); // The stored JSON round-trips back to an equal policy. assertEquals(saved, objectMapper.readValue(entity.getPolicyJson(), Policy.class)); @@ -77,7 +79,7 @@ class JpaPolicyStoreTest { "rotate", "alice", true, - null, // manual-only: no automatic trigger + List.of(), // no inputs: run on demand only List.of( new PipelineStep( "/api/v1/general/rotate-pdf", Map.of("angle", 90))), @@ -87,6 +89,30 @@ class JpaPolicyStoreTest { assertEquals(policy, store.get("p1").orElseThrow()); } + @Test + void getUpgradesLegacyTriggerAndSourceIdsToPerInputTriggers() { + // A blob written before triggers moved onto inputs: one policy-level trigger + sourceIds. + String legacyJson = + "{\"id\":\"p1\",\"name\":\"legacy\",\"owner\":\"alice\",\"enabled\":true," + + "\"trigger\":{\"type\":\"schedule\",\"options\":{}}," + + "\"sourceIds\":[\"s1\",\"s2\"],\"steps\":[]," + + "\"output\":{\"type\":\"inline\",\"options\":{}}}"; + PolicyEntity entity = new PolicyEntity(); + entity.setId("p1"); + entity.setName("legacy"); + entity.setEnabled(true); + entity.setPolicyJson(legacyJson); + when(repository.findById("p1")).thenReturn(Optional.of(entity)); + + Policy upgraded = store.get("p1").orElseThrow(); + + assertEquals( + List.of( + new PipelineInput("s1", new TriggerConfig("schedule", Map.of())), + new PipelineInput("s2", new TriggerConfig("schedule", Map.of()))), + upgraded.inputs()); + } + @Test void saveDenormalizesTeamIdForScopedQueries() { store.save( @@ -95,7 +121,6 @@ class JpaPolicyStoreTest { "scoped", "alice", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), @@ -114,7 +139,6 @@ class JpaPolicyStoreTest { "ours", "alice", true, - null, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline(), @@ -128,23 +152,28 @@ class JpaPolicyStoreTest { } @Test - void findByTriggerTypeUsesTheEnabledQuery() { + void findBindingsByTriggerTypeScansEnabledPoliciesForMatchingInputs() { Policy policy = new Policy( "p1", "watch", "alice", true, - new TriggerConfig("schedule", Map.of()), + List.of( + new PipelineInput( + "src-in", new TriggerConfig("schedule", Map.of())), + PipelineInput.manual("src-manual")), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); - when(repository.findByTriggerTypeAndEnabledTrue("schedule")) - .thenReturn(List.of(entityFor(policy))); + when(repository.findByEnabledTrue()).thenReturn(List.of(entityFor(policy))); - List scheduled = store.findByTriggerType("schedule"); + List scheduled = store.findBindingsByTriggerType("schedule"); + // Only the scheduled input yields a binding; the manual input on the same policy does not. assertEquals(1, scheduled.size()); - assertEquals("p1", scheduled.get(0).id()); + assertEquals("p1", scheduled.get(0).policy().id()); + assertEquals("src-in", scheduled.get(0).input().sourceId()); + assertEquals("schedule", scheduled.get(0).input().trigger().type()); } @Test @@ -163,7 +192,6 @@ class JpaPolicyStoreTest { entity.setName(policy.name()); entity.setOwner(policy.owner()); entity.setEnabled(policy.enabled()); - entity.setTriggerType(policy.trigger() == null ? null : policy.trigger().type()); entity.setTeamId(policy.teamId()); entity.setPolicyJson(objectMapper.writeValueAsString(policy)); return entity; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java index 2c3adf691e..c955e9ed8d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java @@ -14,6 +14,7 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.WatchService; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,8 +32,10 @@ import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; @@ -41,10 +44,10 @@ import stirling.software.proprietary.policy.store.PolicyStore; /** * Tests for {@link FolderWatchTrigger}'s dispatch logic via the package-visible {@code - * runForChangedDirs}/{@code runAll}, plus its cross-facet validation. The OS watch loop and - * scheduled reconcile are thin glue around these and are not exercised here (a real {@code - * WatchService} is timing-dependent), mirroring how {@link ScheduleTriggerTest} drives {@code - * sweep} directly. The folder source is stubbed to mirror {@code FolderInputSource.watchTargets}. + * runForChangedDirs}/{@code runAll}, plus its per-input validation. The OS watch loop and scheduled + * reconcile are thin glue around these and are not exercised here (a real {@code WatchService} is + * timing-dependent), mirroring how {@link ScheduleTriggerTest} drives {@code sweep} directly. The + * folder source is stubbed to mirror {@code FolderInputSource.watchTargets}. */ @ExtendWith(MockitoExtension.class) class FolderWatchTriggerTest { @@ -83,39 +86,43 @@ class FolderWatchTriggerTest { } @Test - void validateRejectsPolicyWithNoWatchableSource() { + void validateRejectsInputWithNoWatchableSource() { + PolicyBinding binding = + bindings(folderWatch("p1", List.of(new InputSpec("folder", Map.of())))).get(0); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(folderWatch("p1", List.of()))); + () -> trigger.validate(binding.policy(), binding.input())); } @Test - void validateAcceptsPolicyWithAFolderSource() { - trigger.validate(folderWatch("p1", List.of(InputSpec.folder("/in")))); + void validateAcceptsAFolderSource() { + PolicyBinding binding = + bindings(folderWatch("p1", List.of(InputSpec.folder("/in")))).get(0); + trigger.validate(binding.policy(), binding.input()); } @Test - void runsOnlyPoliciesDrawingFromTheChangedDirectory() { + void runsOnlyInputsDrawingFromTheChangedDirectory() { Policy a = folderWatch("a", List.of(InputSpec.folder("/in/a"))); Policy b = folderWatch("b", List.of(InputSpec.folder("/in/b"))); - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(a, b)); trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(a, SweepKind.LIGHT); - verify(policyRunner, never()).run(eq(b), any()); + verify(policyRunner).runInput(a, a.inputs().get(0), SweepKind.LIGHT); + verify(policyRunner, never()).runInput(eq(b), any(), any()); } @Test - void skipsAMisconfiguredPolicyButStillRunsTheOthers() { + void skipsAMisconfiguredInputButStillRunsTheOthers() { Policy bad = folderWatch("bad", List.of(new InputSpec("folder", Map.of()))); Policy good = folderWatch("good", List.of(InputSpec.folder("/in/a"))); - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(bad, good)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(bad, good)); trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(good, SweepKind.LIGHT); - verify(policyRunner, never()).run(eq(bad), any()); + verify(policyRunner).runInput(good, good.inputs().get(0), SweepKind.LIGHT); + verify(policyRunner, never()).runInput(eq(bad), any(), any()); } @Test @@ -126,15 +133,15 @@ class FolderWatchTriggerTest { } @Test - void reconcileRunsEveryFolderWatchPolicyAsASafetyNet() { + void reconcileRunsEveryFolderWatchInputAsASafetyNet() { Policy a = folderWatch("a", List.of(InputSpec.folder("/in/a"))); Policy b = folderWatch("b", List.of(InputSpec.folder("/in/b"))); - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(a, b)); trigger.runAll(); - verify(policyRunner).run(a); - verify(policyRunner).run(b); + verify(policyRunner).runInput(a, a.inputs().get(0), SweepKind.FULL); + verify(policyRunner).runInput(b, b.inputs().get(0), SweepKind.FULL); } @Test @@ -151,15 +158,16 @@ class FolderWatchTriggerTest { try { trigger.watchService = service; - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a, b, m)); + when(policyStore.findBindingsByTriggerType("folder-watch")) + .thenReturn(bindings(a, b, m)); trigger.syncRegistrations(); // Existing dirs are watched; the non-existent one is skipped. assertEquals( Set.of(normalized(dirA.toString()), normalized(dirB.toString())), trigger.watchedDirs()); - // b's policy is removed: its registration is cancelled, a remains. - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(a)); + // b's input is removed: its registration is cancelled, a remains. + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(a)); trigger.syncRegistrations(); assertEquals(Set.of(normalized(dirA.toString())), trigger.watchedDirs()); } finally { @@ -175,15 +183,15 @@ class FolderWatchTriggerTest { WatchService service = FileSystems.getDefault().newWatchService(); try { trigger.watchService = service; - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(p)); + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(bindings(p)); - // The mutation hook registers the new policy's directory without waiting for a + // The mutation hook registers the new input's directory without waiting for a // reconcile. trigger.onPoliciesChanged(); assertEquals(Set.of(normalized(dir.toString())), trigger.watchedDirs()); - // Once the policy is gone, the same hook cancels its registration. - when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of()); + // Once the input is gone, the same hook cancels its registration. + when(policyStore.findBindingsByTriggerType("folder-watch")).thenReturn(List.of()); trigger.onPoliciesChanged(); assertEquals(Set.of(), trigger.watchedDirs()); } finally { @@ -195,31 +203,40 @@ class FolderWatchTriggerTest { return Path.of(dir).toAbsolutePath().normalize(); } + /** Every (policy, input) binding across the given policies, as the store would return them. */ + private static List bindings(Policy... policies) { + return Arrays.stream(policies) + .flatMap( + policy -> policy.inputs().stream().map(in -> new PolicyBinding(policy, in))) + .toList(); + } + /** Persists each spec as a source and returns a folder-watch policy referencing them by id. */ private Policy folderWatch(String id, List sources) { - List sourceIds = + List inputs = sources.stream() .map( spec -> - sourceStore - .save( - new Source( - null, - "src", - spec.type(), - spec.options(), - true, - "owner", - null)) - .id()) + new PipelineInput( + sourceStore + .save( + new Source( + null, + "src", + spec.type(), + spec.options(), + true, + "owner", + null)) + .id(), + new TriggerConfig("folder-watch", Map.of()))) .toList(); return new Policy( id, "watcher", "owner", true, - new TriggerConfig("folder-watch", Map.of()), - sourceIds, + inputs, List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java index 3f81392285..b7b7dccc9a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java @@ -25,8 +25,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.Schedule; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.store.PolicyStore; @@ -35,9 +37,9 @@ import tools.jackson.databind.json.JsonMapper; /** * Tests for {@link ScheduleTrigger}'s due-firing logic via the package-visible {@code - * sweep(Instant)}. The trigger only decides when a policy is due; pulling sources and starting runs - * is the {@link PolicyRunner}'s job, so these assert it delegates to the runner. Schedules default - * to UTC, so explicit UTC instants make these deterministic. + * sweep(Instant)}. The trigger only decides when an input is due; pulling the source and starting + * runs is the {@link PolicyRunner}'s job, so these assert it delegates to the runner per binding. + * Schedules default to UTC, so explicit UTC instants make these deterministic. */ @ExtendWith(MockitoExtension.class) class ScheduleTriggerTest { @@ -59,102 +61,105 @@ class ScheduleTriggerTest { @Test void firesOncePerScheduleWhenItComesDue() { - Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:30Z"); trigger.sweep(t0); // first sight: baseline, must not fire immediately - verify(policyRunner, never()).run(any()); + verify(policyRunner, never()).runInput(any(), any(), any()); trigger.sweep(t0.plusSeconds(120)); // the one-minute mark has passed - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test void anIntervalMatchingTheSweepPeriodFiresEverySweepDespiteJitter() { - Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(t0); // baseline // The sweep that fires runs a few ms late (scheduler jitter)... trigger.sweep(t0.plusSeconds(60).plusMillis(5)); - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); // ...and the next sweep lands exactly on the 60s grid. Anchoring lastFired to the due // time (not the jittered observation) means this must still fire, not alias to skip. trigger.sweep(t0.plusSeconds(120)); - verify(policyRunner, times(2)).run(eq(policy)); + verify(policyRunner, times(2)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test void aGapFiresOnceNotOncePerMissedInterval() { - Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(t0); // baseline // Ten minutes of downtime: nine missed due points collapse into one firing. trigger.sweep(t0.plusSeconds(600)); - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); // Not due again until a full interval after the latest due point. trigger.sweep(t0.plusSeconds(630)); - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); trigger.sweep(t0.plusSeconds(660)); - verify(policyRunner, times(2)).run(eq(policy)); + verify(policyRunner, times(2)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test void doesNotFireBeforeTheNextScheduledTime() { - Policy policy = scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + PolicyBinding binding = + scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(t0); trigger.sweep(t0.plusSeconds(60)); // next 03:00 is far away - verify(policyRunner, never()).run(any()); + verify(policyRunner, never()).runInput(any(), any(), any()); } @Test void firesWeeklyOnAChosenDay() { // 2026-06-05 is a Friday; the next Monday 09:00 is the soonest firing. - Policy policy = + PolicyBinding binding = scheduled("p1", new Schedule.Weekly(Set.of(DayOfWeek.MONDAY), LocalTime.of(9, 0))); - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); Instant friday = Instant.parse("2026-06-05T10:00:00Z"); trigger.sweep(friday); // baseline trigger.sweep(Instant.parse("2026-06-08T09:00:00Z")); // Monday 09:00 - verify(policyRunner, times(1)).run(eq(policy)); + verify(policyRunner, times(1)).runInput(eq(binding.policy()), eq(binding.input()), any()); } @Test - void skipsPoliciesWithAnInvalidSchedule() { - Policy policy = scheduledWithRawOptions("p1", Map.of()); // no schedule - when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + void skipsInputsWithAnInvalidSchedule() { + PolicyBinding binding = scheduledWithRawOptions("p1", Map.of()); // no schedule + when(policyStore.findBindingsByTriggerType("schedule")).thenReturn(List.of(binding)); trigger.sweep(Instant.parse("2026-06-05T10:00:00Z")); - verify(policyRunner, never()).run(any()); + verify(policyRunner, never()).runInput(any(), any(), any()); } @Test void validateRejectsMissingSchedule() { + PolicyBinding binding = scheduledWithRawOptions("p1", Map.of()); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(scheduledWithRawOptions("p1", Map.of()))); + () -> trigger.validate(binding.policy(), binding.input())); } @Test void validateRejectsAnInvalidSchedule() { Map options = Map.of("schedule", Map.of("type", "every", "count", -5, "unit", "MINUTES")); + PolicyBinding binding = scheduledWithRawOptions("p1", options); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(scheduledWithRawOptions("p1", options))); + () -> trigger.validate(binding.policy(), binding.input())); } @Test @@ -162,21 +167,25 @@ class ScheduleTriggerTest { Map options = new LinkedHashMap<>(); options.put("schedule", new Schedule.Daily(LocalTime.of(2, 0))); options.put("zone", "Europe/London"); - trigger.validate(scheduledWithRawOptions("p1", options)); + PolicyBinding binding = scheduledWithRawOptions("p1", options); + trigger.validate(binding.policy(), binding.input()); } - private static Policy scheduled(String id, Schedule schedule) { + private static PolicyBinding scheduled(String id, Schedule schedule) { return scheduledWithRawOptions(id, Map.of("schedule", schedule)); } - private static Policy scheduledWithRawOptions(String id, Map options) { - return new Policy( - id, - "nightly", - "owner", - true, - new TriggerConfig("schedule", options), - List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), - OutputSpec.inline()); + private static PolicyBinding scheduledWithRawOptions(String id, Map options) { + PipelineInput input = new PipelineInput("s1", new TriggerConfig("schedule", options)); + Policy policy = + new Policy( + id, + "nightly", + "owner", + true, + List.of(input), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline()); + return new PolicyBinding(policy, input); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java index 63b721dd6d..a5b06bde01 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java @@ -2,10 +2,12 @@ package stirling.software.proprietary.policy.trigger; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -19,8 +21,10 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyBinding; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; @@ -46,37 +50,45 @@ class WebhookTriggerTest { } @Test - void firesOnlyPoliciesReferencingTheDeliveredWebhook() { + void firesOnlyInputsReferencingTheDeliveredWebhook() { Policy matching = webhookPolicy("a", "whkA"); Policy other = webhookPolicy("b", "whkB"); - when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(matching, other)); + when(policyStore.findBindingsByTriggerType(TYPE)).thenReturn(bindings(matching, other)); trigger.fireForWebhook("whkA"); - verify(policyRunner).run(matching, SweepKind.LIGHT); - verify(policyRunner, never()).run(other, SweepKind.LIGHT); + verify(policyRunner).runInput(matching, matching.inputs().get(0), SweepKind.LIGHT); + verify(policyRunner, never()).runInput(eq(other), any(), any()); } @Test void ignoresADeliveryForAnUnknownWebhookId() { Policy policy = webhookPolicy("a", "whkA"); - when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(policy)); + when(policyStore.findBindingsByTriggerType(TYPE)).thenReturn(bindings(policy)); trigger.fireForWebhook("whkZ"); - verify(policyRunner, never()).run(any(), any(SweepKind.class)); + verify(policyRunner, never()).runInput(any(), any(), any()); } @Test void validateRequiresAWebhookSource() { + // A non-webhook source (here: an id that resolves to nothing) is rejected. + Policy notWebhook = policy("p", PipelineInput.manual("missing-source")); assertThrows( IllegalArgumentException.class, - () -> trigger.validate(policy("p", webhookTriggerConfig(), List.of()))); - trigger.validate(webhookPolicy("p", "whkA")); + () -> trigger.validate(notWebhook, notWebhook.inputs().get(0))); + + Policy hooked = webhookPolicy("p", "whkA"); + trigger.validate(hooked, hooked.inputs().get(0)); } - private static TriggerConfig webhookTriggerConfig() { - return new TriggerConfig(TYPE, Map.of()); + /** Every (policy, input) binding across the given policies, as the store would return them. */ + private static List bindings(Policy... policies) { + return Arrays.stream(policies) + .flatMap( + policy -> policy.inputs().stream().map(in -> new PolicyBinding(policy, in))) + .toList(); } private Policy webhookPolicy(String id, String webhookId) { @@ -98,17 +110,16 @@ class WebhookTriggerTest { "owner", null)) .id(); - return policy(id, webhookTriggerConfig(), List.of(sourceId)); + return policy(id, new PipelineInput(sourceId, new TriggerConfig(TYPE, Map.of()))); } - private static Policy policy(String id, TriggerConfig trigger, List sourceIds) { + private static Policy policy(String id, PipelineInput input) { return new Policy( id, "hook", "owner", true, - trigger, - sourceIds, + List.of(input), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index abbe1446ff..d02e60b84c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7745,11 +7745,17 @@ newPipeline = "New pipeline" addStep = "Add tool" back = "Back to pipelines" chooseAccount = "Choose an account" +chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" +chooseSource = "Choose a source" discard = "Discard changes" enabled = "Enabled" +inputs = "Input" +inputSource = "Input source" +inputTrigger = "Trigger" keepEditing = "Keep editing" needsUpload = "Needs an uploaded file" +noSources = "No sources connected yet. Create one to use it as an input." noToolMatches = "No tools match your search." pipelineSettings = "Pipeline settings" searchTools = "Search tools" @@ -7777,7 +7783,7 @@ namePlaceholder = "e.g. Redaction sweep" noToolSettings = "This tool has no configurable settings." operations_one = "Operation ({{count}})" operations_other = "Operations ({{count}})" -output = "Destinations" +output = "Destination" removeStep = "Remove operation" save = "Save changes" scheduleEvery = "Run every" diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index 2a4f25b79e..d6088d06ff 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -17,12 +17,22 @@ export interface PipelineStep { fileParameters?: Record; } -/** When a policy fires automatically. `type` keys a trigger bean (e.g. "schedule"). */ +/** When a policy input fires automatically. `type` keys a trigger bean (e.g. "schedule"). */ export interface TriggerConfig { type: string; options: Record; } +/** + * One input of a pipeline: a persisted source paired with the trigger that decides when that + * source is pulled. A `null` trigger means the input is pulled only on a manual run. Mirrors the + * backend `PipelineInput`. + */ +export interface PipelineInput { + sourceId: string; + trigger: TriggerConfig | null; +} + /** Where a run's outputs are delivered. `type` keys an output sink (e.g. "inline"). */ export interface OutputSpec { type: string; @@ -35,15 +45,15 @@ export type PipelineOutputMode = "folder" | "s3"; /** * The stored policy record: the create/update body (`id` blank on create) and what * the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId` - * are stamped server-side. A `null` trigger means manual-only. + * are stamped server-side. Each input pairs a source with its own trigger; an input + * with a `null` trigger (or a policy with no triggered inputs) runs only on demand. */ export interface Policy { id?: string; name: string; owner?: string | null; enabled: boolean; - trigger: TriggerConfig | null; - sourceIds: string[]; + inputs: PipelineInput[]; steps: PipelineStep[]; /** * Inline output, used only when no destinations are referenced (editor/one-off runs that return diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx index 683c6301b4..de1187c3da 100644 --- a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { Button, Checkbox } from "@app/ui"; +import { Button, Select } from "@app/ui"; /** - * Picks the saved sources a pipeline delivers its output to. A destination is just - * a source used as a write target, and a pipeline may write to several, so this is - * a checklist over the same locations the builder loaded (filtered to writable - * types by the caller) - mirroring the input-sources checklist. Creating a new one - * is delegated to {@code onCreateNew} (the builder navigates to the source builder, - * prompting about unsaved edits first). + * Picks the saved source a pipeline delivers its output to. A destination is just a + * source used as a write target. The value stays a list ({@code outputIds}) because + * the model supports several, but the product caps a pipeline at one destination + * today, so this renders a single dropdown over the same locations the builder + * loaded (filtered to writable types by the caller). Creating a new one is delegated + * to {@code onCreateNew} (the builder navigates to the source builder, prompting + * about unsaved edits first). */ interface DestinationOption { id: string; @@ -31,23 +32,21 @@ export function DestinationPicker({ }: DestinationPickerProps) { const { t } = useTranslation(); - function toggle(id: string, checked: boolean) { - onChange( - checked ? [...value, id] : value.filter((existing) => existing !== id), - ); - } - return ( - <> -

- {sources.map((source) => ( - toggle(source.id, e.target.checked)} - label={source.name} - /> - ))} +
+
+ setScheduleCount(e.target.value)} - className="portal-pipelines__schedule-count" - /> - changeInputSource(value ?? "")} + options={sourceOptions} + /> +
+
+ + updateInput({ scheduleCount: e.target.value }) + } + className="portal-pipelines__schedule-count" + /> + diff --git a/frontend/editor/src/portal/components/sources/connectionTypes.ts b/frontend/editor/src/portal/components/sources/connectionTypes.ts index ad5dfa3f17..d71d0ce2dc 100644 --- a/frontend/editor/src/portal/components/sources/connectionTypes.ts +++ b/frontend/editor/src/portal/components/sources/connectionTypes.ts @@ -29,6 +29,12 @@ export interface ConnectionFieldDef { defaultValue?: string; /** Shown only when another field has one of these values, e.g. auth fields per authType. */ visibleWhen?: { key: string; oneOf: string[] }; + /** + * When this field changes, move another field onto the default paired with the new value (FTP + * port per encryption mode) — but only while the target still holds a default, never a custom + * value the operator typed. + */ + syncsDefault?: { targetKey: string; map: Record }; } export interface CreatableConnectionType { @@ -122,6 +128,162 @@ const S3_FIELDS: ConnectionFieldDef[] = [ }, ]; +// Network file servers (SFTP/FTP/SMB). The protocol is baked into presetConfig; the operator +// supplies host and credentials. host/port/username/password reuse the shared commonFields copy. +const SFTP_FIELDS: ConnectionFieldDef[] = [ + { + key: "host", + labelKey: `${COMMON}.host.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.sftp.hostPlaceholder`, + }, + { + key: "port", + labelKey: `${COMMON}.port.label`, + control: "text", + defaultValue: "22", + }, + { + key: "username", + labelKey: `${COMMON}.username.label`, + control: "text", + required: true, + }, + { + key: "password", + labelKey: `${COMMON}.password.label`, + control: "password", + helperTextKey: `${PREFIX}.sftp.fields.password.helperText`, + }, + { + key: "privateKey", + labelKey: `${PREFIX}.sftp.fields.privateKey.label`, + control: "textarea", + helperTextKey: `${PREFIX}.sftp.fields.privateKey.helperText`, + }, + { + key: "privateKeyPassphrase", + labelKey: `${PREFIX}.sftp.fields.passphrase.label`, + control: "password", + }, + { + key: "hostKeyFingerprint", + labelKey: `${PREFIX}.sftp.fields.hostKeyFingerprint.label`, + control: "text", + placeholderKey: `${PREFIX}.sftp.fields.hostKeyFingerprint.placeholder`, + helperTextKey: `${PREFIX}.sftp.fields.hostKeyFingerprint.helperText`, + }, +]; + +const FTP_FIELDS: ConnectionFieldDef[] = [ + { + key: "host", + labelKey: `${COMMON}.host.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.ftp.hostPlaceholder`, + }, + { + key: "port", + labelKey: `${COMMON}.port.label`, + control: "text", + defaultValue: "21", + }, + { + key: "username", + labelKey: `${COMMON}.username.label`, + control: "text", + required: true, + }, + { + key: "password", + labelKey: `${COMMON}.password.label`, + control: "password", + required: true, + }, + { + key: "security", + labelKey: `${PREFIX}.ftp.fields.security.label`, + control: "select", + defaultValue: "NONE", + helperTextKey: `${PREFIX}.ftp.fields.security.helperText`, + // Implicit FTPS listens on 990; follow the untouched port default across modes. + syncsDefault: { + targetKey: "port", + map: { NONE: "21", EXPLICIT: "21", IMPLICIT: "990" }, + }, + options: [ + { value: "NONE", labelKey: `${PREFIX}.ftp.fields.security.options.none` }, + { + value: "EXPLICIT", + labelKey: `${PREFIX}.ftp.fields.security.options.explicit`, + }, + { + value: "IMPLICIT", + labelKey: `${PREFIX}.ftp.fields.security.options.implicit`, + }, + ], + }, + { + key: "passive", + labelKey: `${PREFIX}.ftp.fields.passive.label`, + control: "select", + defaultValue: "true", + options: [ + { + value: "true", + labelKey: `${PREFIX}.ftp.fields.passive.options.passive`, + }, + { + value: "false", + labelKey: `${PREFIX}.ftp.fields.passive.options.active`, + }, + ], + }, +]; + +const SMB_FIELDS: ConnectionFieldDef[] = [ + { + key: "host", + labelKey: `${COMMON}.host.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.smb.hostPlaceholder`, + }, + { + key: "port", + labelKey: `${COMMON}.port.label`, + control: "text", + defaultValue: "445", + }, + { + key: "share", + labelKey: `${PREFIX}.smb.fields.share.label`, + control: "text", + required: true, + placeholderKey: `${PREFIX}.smb.fields.share.placeholder`, + }, + { + key: "username", + labelKey: `${COMMON}.username.label`, + control: "text", + required: true, + }, + { + key: "password", + labelKey: `${COMMON}.password.label`, + control: "password", + required: true, + }, + { + key: "domain", + labelKey: `${PREFIX}.smb.fields.domain.label`, + control: "text", + helperTextKey: `${PREFIX}.smb.fields.domain.helperText`, + }, +]; + const PURVIEW_FIELDS: ConnectionFieldDef[] = [ { key: "tenantId", @@ -587,6 +749,46 @@ export const CREATABLE_CONNECTION_TYPES: CreatableConnectionType[] = [ searchTerms: ["aws", "bucket", "minio", "object storage"], fields: S3_FIELDS, }, + { + id: "sftp", + integrationType: "NETWORK", + kind: "preset", + category: "storage", + labelKey: `${PREFIX}.sftp.label`, + descriptionKey: `${PREFIX}.sftp.description`, + searchTerms: ["sftp", "ssh", "scp", "drop folder", "file transfer"], + presetConfig: { protocol: "SFTP" }, + fields: SFTP_FIELDS, + }, + { + id: "ftp", + integrationType: "NETWORK", + kind: "preset", + category: "storage", + labelKey: `${PREFIX}.ftp.label`, + descriptionKey: `${PREFIX}.ftp.description`, + searchTerms: ["ftp", "ftps", "file transfer", "drop folder"], + presetConfig: { protocol: "FTP" }, + fields: FTP_FIELDS, + }, + { + id: "smb", + integrationType: "NETWORK", + kind: "preset", + category: "storage", + labelKey: `${PREFIX}.smb.label`, + descriptionKey: `${PREFIX}.smb.description`, + searchTerms: [ + "smb", + "cifs", + "samba", + "network drive", + "windows share", + "unc", + ], + presetConfig: { protocol: "SMB" }, + fields: SMB_FIELDS, + }, { id: "purview", integrationType: "PURVIEW", diff --git a/frontend/editor/src/portal/components/sources/sourceTypes.ts b/frontend/editor/src/portal/components/sources/sourceTypes.ts index e25490a2bf..ee51ba21f3 100644 --- a/frontend/editor/src/portal/components/sources/sourceTypes.ts +++ b/frontend/editor/src/portal/components/sources/sourceTypes.ts @@ -37,6 +37,18 @@ const SOURCE_TYPE_META: Record = { labelKey: "portal.sources.types.s3.label", accent: "brand", }, + sftp: { + labelKey: "portal.sources.types.sftp.label", + accent: "default", + }, + ftp: { + labelKey: "portal.sources.types.ftp.label", + accent: "default", + }, + network: { + labelKey: "portal.sources.types.network.label", + accent: "default", + }, webhook: { labelKey: "portal.sources.types.webhook.label", accent: "warning", @@ -56,12 +68,17 @@ export function sourceTypeMeta(type: string): SourceTypeMeta { export interface SourceFieldDef { key: string; labelKey: string; - control: "text" | "password" | "select" | "s3Connection"; + control: "text" | "password" | "select" | "s3Connection" | "connection"; required?: boolean; placeholderKey?: string; helperTextKey?: string; options?: { value: string; labelKey: string }[]; defaultValue?: string; + /** + * For `control: "connection"` - the connection-catalogue entry id this slot accepts (e.g. + * "sftp"). Filters the picker to matching connections and pins the inline "new connection" form. + */ + connectionTypeId?: string; } /** A source type the wizard can create, with the fields its config needs. */ @@ -72,6 +89,64 @@ export interface CreatableSourceType { fields: SourceFieldDef[]; } +/** + * The config a network source (SFTP/FTP/SMB) needs: a stored connection of the matching protocol, + * the folder to poll, and the same consume/snapshot + recursion choices as a folder source. Shared + * copy across the three protocols, since only the connection type differs. + */ +function networkSourceFields(connectionTypeId: string): SourceFieldDef[] { + return [ + { + key: "connectionId", + labelKey: "portal.sources.networkFields.connection.label", + control: "connection", + connectionTypeId, + required: true, + helperTextKey: "portal.sources.networkFields.connection.helperText", + }, + { + key: "directory", + labelKey: "portal.sources.networkFields.directory.label", + control: "text", + placeholderKey: "portal.sources.networkFields.directory.placeholder", + helperTextKey: "portal.sources.networkFields.directory.helperText", + }, + { + key: "mode", + labelKey: "portal.sources.networkFields.mode.label", + control: "select", + defaultValue: "consume", + helperTextKey: "portal.sources.networkFields.mode.helperText", + options: [ + { + value: "consume", + labelKey: "portal.sources.networkFields.mode.options.consume", + }, + { + value: "snapshot", + labelKey: "portal.sources.networkFields.mode.options.snapshot", + }, + ], + }, + { + key: "recursive", + labelKey: "portal.sources.networkFields.recursive.label", + control: "select", + defaultValue: "false", + options: [ + { + value: "false", + labelKey: "portal.sources.networkFields.recursive.options.top", + }, + { + value: "true", + labelKey: "portal.sources.networkFields.recursive.options.all", + }, + ], + }, + ]; +} + export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ { type: "folder", @@ -182,6 +257,24 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ }, ], }, + { + type: "sftp", + labelKey: "portal.sources.types.sftp.label", + descriptionKey: "portal.sources.types.sftp.description", + fields: networkSourceFields("sftp"), + }, + { + type: "ftp", + labelKey: "portal.sources.types.ftp.label", + descriptionKey: "portal.sources.types.ftp.description", + fields: networkSourceFields("ftp"), + }, + { + type: "network", + labelKey: "portal.sources.types.network.label", + descriptionKey: "portal.sources.types.network.description", + fields: networkSourceFields("smb"), + }, { type: WEBHOOK_SOURCE_TYPE, labelKey: "portal.sources.types.webhook.label", @@ -208,8 +301,6 @@ export const COMING_SOON_SOURCE_TYPES: ComingSoonSourceType[] = [ "googledrive", "dropbox", "box", - "network", - "sftp", "email", ].map((type) => ({ type, diff --git a/gradle.properties b/gradle.properties index 0e8306a444..2a33a5cebc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,8 @@ org.gradle.java.installations.auto-download=true org.gradle.daemon=true # org.gradle.configuration-cache=true + +# Gradle daemon heap. Without this the daemon uses Gradle's 512m default, which the SaaS build +# variant (it compiles saas + proprietary + core together) exhausts during compileTestJava - the +# GC thrashes and the daemon is stopped. Give all flavours comfortable headroom. +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 From 28480d137a4079953e64c2ce6997f80a61c1376b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:58:14 +0100 Subject: [PATCH 063/262] Cache Gradle dependencies in the live E2E workflow (#7234) # Description of Changes Fixes the `playwright-e2e-live` failure seen on [run 30694073128](https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/30694073128). The backend never started: ``` > Could not resolve org.springframework.boot:spring-boot-buildpack-platform:4.0.6. > Could not GET 'https://repo.maven.apache.org/maven2/.../spring-boot-buildpack-platform-4.0.6.pom'. Received status code 429 from server: Too Many Requests > There are 14 more failures with identical causes. BUILD FAILED in 21s ``` Gradle was throttled by Maven Central while resolving the buildscript classpath, `:stirling-pdf:bootRun` died, and the runner's "backend exited before becoming ready" guard aborted the suite before a single test ran. `e2e-live.yml` was the only Java-running workflow with no Gradle dependency cache, no `setup-gradle`, and no Maven mirror env - every sibling (`backend-build.yml`, `db-migration-test.yml`, `coverage-aggregate.yml`) has all three. So it downloaded the Gradle distribution and resolved the entire classpath cold from Maven Central on every single run, and eventually got throttled. Added: - the same `Cache Gradle dependency artifacts` + `Setup Gradle` pair used by `backend-build.yml` - `MAVEN_USER` / `MAVEN_PASSWORD` / `MAVEN_PUBLIC_URL` on the two Gradle-invoking steps, so runs that have the secrets use the internal mirror instead of hitting Central - a `Prime Gradle dependencies` step that retries 3x with backoff. Gradle does not retry 429s, and doing the cold resolve up front means a rate-limit failure retries cheaply instead of killing a backgrounded `bootRun` twenty minutes in Side benefit: the job gets faster once the cache is warm. ## Notes for reviewers - The 429 itself is transient infrastructure behaviour - a re-run would likely have gone green. The defect being fixed is that this job had no cache to fall back on, so it was exposed to it on every run. - `:stirling-pdf:classes` does not trigger a frontend build (`buildWithFrontend` defaults off, `app/core/build.gradle:147`), so priming before the Vite build step is safe. It is not wasted work either - `bootRun` compiles the same classes. - This PR originally also carried a fix for the `tauri-build` updater-key failure on that same run. #7181 fixes that more simply and has been merged, so that half has been dropped here. - Workflow changes cannot be fully verified locally; a CI run on this branch is the real check. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/workflows/e2e-live.yml | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index af3ca377c1..302663cbf9 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -25,6 +25,39 @@ jobs: with: java-version: "25" distribution: "temurin" + # Same cache layer as backend-build.yml. Without it every run resolved the + # whole classpath cold and eventually got HTTP 429 from Maven Central. + - name: Cache Gradle dependency artifacts + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches/modules-2/files-2.1 + ~/.gradle/caches/modules-2/metadata-2.* + key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + gradle-version: 9.6.1 + cache-disabled: true + # 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: @@ -53,6 +86,11 @@ jobs: # to aggregate. Chromium-only - other engines silently skip. PW_COVERAGE: "1" PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json + # Internal mirror, as in backend-build.yml. Empty on Dependabot and + # fork PRs, where the build falls back to Maven Central. + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} run: task e2e:live - name: Flag flaky tests # Runs regardless of the test outcome: a flaky test (passed on retry) @@ -66,6 +104,10 @@ jobs: - name: Generate JaCoCo report from e2e:live .exec if: always() id: live-coverage + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} # `if: always()` so even a failed test run still produces a # report from whatever flows did exercise the backend before # the failure. The task itself tolerates a missing .exec From 22815b545880f5c3066f3018abaf0d819aaf7863 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 3 Aug 2026 17:18:21 +0100 Subject: [PATCH 064/262] Sign Mac PR builds (#7267) # Description of Changes Mac builds in PRs are not currently signed, which means you can't run them when downloaded. This restores the functionality so that Mac builds are always signed. --- .github/workflows/build.yml | 6 ++++-- .github/workflows/tauri-build.yml | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07dbb781da..9d4aee192d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -174,12 +174,14 @@ jobs: pull-requests: write uses: ./.github/workflows/tauri-build.yml secrets: inherit - # PR smoke build: macOS + Windows (the platforms our developers use). + # PR smoke build: macOS + Windows (the platforms our developers use). + # sign: true only reaches macOS - tauri-build's per-platform gate keeps + # Windows/Linux signing on main, and an unsigned .dmg cannot be opened. # The full signed multi-OS matrix runs on release; # nightly still warms the Rust cache with all-OS defaults. with: platform: windows-macos - sign: false + sign: true ai-engine: if: needs.files-changed.outputs.engine == 'true' diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 65aac82520..e504826d35 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -108,6 +108,11 @@ jobs: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} + # Per-platform sign gate. macOS signs on any run with the cert available, + # PRs included: Gatekeeper blocks an unsigned .dmg, so an unsigned macOS + # PR build is not testable. Windows and Linux stay main-only, matching the + # gates on their own signing steps below. + SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -274,7 +279,7 @@ jobs: } - name: Import Apple Developer Certificate - if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' + if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -295,7 +300,7 @@ jobs: rm certificate.p12 - name: Verify Certificate - if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' + if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' run: | echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db @@ -368,7 +373,7 @@ jobs: fi - name: Build Tauri app (signed) - if: inputs.sign + if: env.SIGN_BUNDLE == 'true' uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -403,7 +408,7 @@ jobs: args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }} - name: Build Tauri app (unsigned) - if: ${{ !inputs.sign }} + if: env.SIGN_BUNDLE != 'true' uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -458,7 +463,7 @@ jobs: fi - name: Verify notarization (macOS only) - if: inputs.sign && matrix.platform == 'macos-15' + if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' run: | echo "🔍 Verifying notarization status..." cd ./frontend/editor/src-tauri/target From c91d63f21500221ace37102b48cab5f2db8882d9 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:59:28 +0100 Subject: [PATCH 065/262] New design, part one: shared branding, nav surfaces and theme tokens (#7163) ## Overview First part of the move over to the new designs. This lays the groundwork (shared brand components, button/nav styling, theme tokens) and applies it across the editor and the processor. Later parts will build on top of it. ## What's changed **Branding** - Shared `Logo` and `BrandMark` components used everywhere, so the mark and wordmark are identical across the editor, processor, auth pages and the chat FAB. - The sidebar logo doubles as the editor to processor switcher, morphing into a chevron on hover. It only appears for users who can actually reach the processor. **Navigation and layout** - Both sidebars restructured onto the floating nav surface treatment, with rounded panels sitting on the app canvas. - The editor file sidebar is now three sections (controls, PDF Library, settings) and the workbench top bar and tools panel match. - Added a collapse toggle to both sidebars, with an animated expand and collapse and a tidy icon rail when collapsed. The processor did not have a desktop collapse before. **Components** - Buttons and action icons now share one styling system, so both react to the same tokens. - Secondary buttons in dark mode use a neutral fill and border instead of inheriting the primary colour. - Status badges default to a clean dot with no background, with a filled pill as the alternative. - Metric strips gained a row layout with an optional leading icon. **Theme** - Colour tokens consolidated. Literal colours live only in the palette file, everything else references the semantic `--c-*` tokens. - `saas-theme.css` removed and the parts that were genuinely needed moved into the shared theme, so all builds get them. - The colour linter enforces this across the app and runs in CI. ## Notes - Nothing functional should change here, it is styling plus the sidebar collapse feature. - Main has been merged in. The Sources and billing pages picked up changes from main during that merge and are worth a look alongside the new styling. --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../public/locales/en-US/translation.toml | 9 +- frontend/editor/scripts/lint/theme-lint.mjs | 1 - .../assets/brand/branding-logo/logo-mark.svg | 4 + .../brand/branding-logo/wordmark-dark.svg | 3 + .../brand/branding-logo/wordmark-light.svg | 3 + .../components/fileEditor/AddFileCard.tsx | 2 +- .../fileEditor/FileEditor.module.css | 20 +- .../core/components/shared/AppConfigModal.tsx | 21 +- .../components/shared/AppConfigModalLazy.tsx | 4 + .../src/core/components/shared/AppSwitch.css | 27 - .../components/shared/AppSwitch.stories.tsx | 35 -- .../src/core/components/shared/AppSwitch.tsx | 76 +-- .../core/components/shared/AppSwitcher.tsx | 24 +- .../src/core/components/shared/BrandMark.css | 56 ++ .../src/core/components/shared/BrandMark.tsx | 36 ++ .../core/components/shared/BrandSwitcher.css | 15 + .../shared/BrandSwitcher.stories.tsx | 16 + .../core/components/shared/BrandSwitcher.tsx | 57 ++ .../core/components/shared/FileSidebar.css | 185 +++--- .../core/components/shared/FileSidebar.tsx | 562 +++++++++--------- .../core/components/shared/LandingActions.tsx | 2 + .../core/components/shared/LandingPage.css | 54 +- .../core/components/shared/LandingPage.tsx | 31 +- .../components/shared/SidebarToggleIcon.tsx | 39 ++ .../core/components/shared/WorkbenchBar.css | 9 +- .../core/components/tools/RightSidebar.tsx | 90 ++- .../src/core/components/tools/ToolPanel.css | 24 +- .../src/core/components/tools/ToolPicker.tsx | 2 +- .../core/components/viewer/EmbedPdfViewer.tsx | 5 +- .../components/viewer/PdfViewerToolbar.css | 35 ++ .../components/viewer/PdfViewerToolbar.tsx | 9 +- frontend/editor/src/core/pages/HomePage.tsx | 1 + frontend/editor/src/core/styles/theme.css | 53 ++ .../tests/live/edge-cases-security.spec.ts | 4 +- .../stubbed/language-localization.spec.ts | 10 +- .../core/tests/stubbed/main-dashboard.spec.ts | 13 +- .../core/tests/stubbed/tool-search.spec.ts | 24 +- frontend/editor/src/core/theme/colors.css | 60 +- frontend/editor/src/core/theme/dimensions.css | 5 + frontend/editor/src/core/theme/primitives.css | 14 +- frontend/editor/src/core/tokens/tokens.css | 10 - frontend/editor/src/core/ui/ActionIcon.tsx | 25 +- frontend/editor/src/core/ui/Button.tsx | 26 +- frontend/editor/src/core/ui/ChatFABButton.css | 9 +- frontend/editor/src/core/ui/ChatFABButton.tsx | 19 +- .../editor/src/core/ui/Inline.stories.tsx | 4 +- frontend/editor/src/core/ui/Logo.css | 33 + frontend/editor/src/core/ui/Logo.stories.tsx | 48 ++ frontend/editor/src/core/ui/Logo.tsx | 89 +++ frontend/editor/src/core/ui/MetricStrip.css | 68 ++- .../src/core/ui/MetricStrip.stories.tsx | 46 ++ frontend/editor/src/core/ui/MetricStrip.tsx | 32 +- frontend/editor/src/core/ui/NavItem.tsx | 1 + frontend/editor/src/core/ui/NavSurface.css | 5 + .../editor/src/core/ui/NavSurface.stories.tsx | 62 ++ frontend/editor/src/core/ui/NavSurface.tsx | 30 + .../src/core/ui/PanelHeader.stories.tsx | 4 +- frontend/editor/src/core/ui/StatusBadge.css | 55 +- .../src/core/ui/StatusBadge.stories.tsx | 2 +- frontend/editor/src/core/ui/StatusBadge.tsx | 16 +- frontend/editor/src/core/ui/accents.css | 24 +- frontend/editor/src/core/ui/index.ts | 2 + .../desktop/components/shared/AppSwitcher.tsx | 19 +- .../editor/src/portal/components/AppShell.css | 3 - .../editor/src/portal/components/AppShell.tsx | 12 +- .../portal/components/PortalSettingsHost.tsx | 4 + .../editor/src/portal/components/Sidebar.css | 151 +++-- .../editor/src/portal/components/Sidebar.tsx | 124 ++-- .../account-link/LinkedInstancesTable.tsx | 2 +- .../editor-admin/DeploymentSummaryStrip.tsx | 7 +- .../editor-admin/DeploymentTargets.tsx | 6 +- .../editor-admin/InstanceHealthTable.tsx | 6 +- .../components/infrastructure/AuditTab.tsx | 5 +- .../infrastructure/DeploymentsTab.tsx | 6 +- .../components/infrastructure/ModelsTab.tsx | 11 +- .../portal/components/pipelines/KpiStrip.tsx | 3 +- .../components/pipelines/PipelinesTable.tsx | 6 +- .../components/policies/CatalogueSummary.tsx | 3 +- .../policies/PolicyCatalogueTable.tsx | 6 +- .../policies/PolicyCategoryCard.tsx | 1 - .../components/policies/PolicyDetailPanel.tsx | 5 +- .../portal/components/sources/KpiStrip.tsx | 3 +- .../components/sources/SourcesTable.tsx | 6 +- .../editor/src/portal/contexts/UIContext.tsx | 31 + .../src/portal/views/Infrastructure.css | 19 - .../proprietary/auth/ui/AuthShell.module.css | 2 +- .../proprietary/auth/ui/AuthSignupPrompt.tsx | 4 +- .../proprietary/auth/ui/EmailPasswordForm.tsx | 4 +- .../src/proprietary/auth/ui/auth-theme.css | 2 - .../agents/StirlingLogoOutline.stories.tsx | 22 - .../components/agents/StirlingLogoOutline.tsx | 21 - .../proprietary/components/chat/ChatPanel.css | 35 +- .../proprietary/components/chat/ChatPanel.tsx | 13 +- .../components/shared/AppSwitcher.tsx | 29 +- .../editor/src/proprietary/routes/Login.tsx | 44 +- .../editor/src/proprietary/routes/Signup.tsx | 1 + frontend/editor/src/saas/App.tsx | 2 +- .../onboarding/OnboardingChecklist.module.css | 11 +- .../saas/components/shared/AppConfigModal.tsx | 22 +- .../saas/components/shared/AppSwitcher.tsx | 41 ++ .../src/saas/hooks/usePortalAccess.test.tsx | 108 ++++ .../editor/src/saas/hooks/usePortalAccess.ts | 52 ++ frontend/editor/src/saas/routes/Login.tsx | 109 ++-- frontend/editor/src/saas/routes/Signup.tsx | 101 ++-- .../saas/routes/login/EmailPasswordForm.tsx | 2 +- .../editor/src/saas/styles/saas-theme.css | 173 ------ 106 files changed, 2121 insertions(+), 1366 deletions(-) create mode 100644 frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg create mode 100644 frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg create mode 100644 frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg delete mode 100644 frontend/editor/src/core/components/shared/AppSwitch.css delete mode 100644 frontend/editor/src/core/components/shared/AppSwitch.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandMark.css create mode 100644 frontend/editor/src/core/components/shared/BrandMark.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.css create mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarToggleIcon.tsx create mode 100644 frontend/editor/src/core/components/viewer/PdfViewerToolbar.css create mode 100644 frontend/editor/src/core/ui/Logo.css create mode 100644 frontend/editor/src/core/ui/Logo.stories.tsx create mode 100644 frontend/editor/src/core/ui/Logo.tsx create mode 100644 frontend/editor/src/core/ui/NavSurface.css create mode 100644 frontend/editor/src/core/ui/NavSurface.stories.tsx create mode 100644 frontend/editor/src/core/ui/NavSurface.tsx delete mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx delete mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx create mode 100644 frontend/editor/src/saas/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/saas/hooks/usePortalAccess.test.tsx create mode 100644 frontend/editor/src/saas/hooks/usePortalAccess.ts delete mode 100644 frontend/editor/src/saas/styles/saas-theme.css diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 77e41e2ffd..bf00cfd08d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3877,10 +3877,10 @@ customizeGroups = "Customize groups" dropHint = "Open files to get started" dropToAdd = "Drop files to add" expand = "Expand sidebar" -files = "Files" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" leaveMyFiles = "Leave My Files" +library = "PDF Library" myFiles = "My Files" noFiles = "No files yet" openFileManager = "Browse all files & folders" @@ -4830,8 +4830,7 @@ welcomeTitle = "You've been invited!" addFiles = "Add Files" mobileUpload = "Upload from Mobile" openFromComputer = "Open from computer" -uploadFromComputer = "Upload from computer" -workbenchEmptyStateHero = "Drop a PDF anywhere" +uploadFromComputer = "Browse files" [language] direction = "ltr" @@ -4896,7 +4895,6 @@ signInWith = "Sign in with" title = "Sign in" unexpectedError = "Unexpected error: {{message}}" updatePassword = "Update password" -useEmailInstead = "Login with email" useMagicLink = "Use magic link instead" username = "Username" youAreLoggedIn = "You are logged in!" @@ -8720,7 +8718,6 @@ account-link = "Account link" [portal.shell.sidebar] appEditor = "Editor" appProcessor = "Processor" -brandSuffix = "Stirling Processor" linkAccount = "Link Stirling account" primaryNav = "Primary navigation" switchApp = "Switch app" @@ -10761,8 +10758,10 @@ backToAllTools = "Back to all tools" collapse = "Collapse panel" expand = "Expand panel" goBack = "Go back" +pdfTools = "PDF Tools" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" +searchTools = "Search tools" toolsHeader = "Tools" viewAllTools = "View all tools" diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 71a9cf07e5..0ae53a3ce9 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -740,7 +740,6 @@ const PRIMITIVE_LAYER = [ /^editor\/src\/core\/theme\//, /^editor\/src\/core\/styles\/theme\.css$/, /^editor\/src\/core\/tokens\/tokens\.css$/, - /^editor\/src\/saas\/styles\/saas-theme\.css$/, /^editor\/src\/proprietary\/auth\/ui\/auth-theme\.css$/, /^editor\/src\/core\/ui\/accents\.css$/, ]; diff --git a/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg b/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg new file mode 100644 index 0000000000..de7d337f91 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg new file mode 100644 index 0000000000..5c29f32920 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg new file mode 100644 index 0000000000..a9eeaa9e74 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx index 5491138e6b..94d2e35e47 100644 --- a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx @@ -181,11 +181,11 @@ const AddFileCard = ({ {/* Instruction Text */} {terminology.dropFilesHere} diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css index c5770f1ea7..410ade863b 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css @@ -325,13 +325,19 @@ ========================= */ .addFileCard { - width: 260px; - max-width: 260px; - height: calc(310px - 0.5rem); - margin: 0.5rem auto 0; - background: var(--c-bg); - border: 1.5px solid var(--c-border); - border-radius: 12px; + /* Fill the same slot a portrait page thumbnail does. Height: the 310px + .thumbWrap minus the always-reserved 26px toolchain bar (22px + 4px) that + sits above the page. Width: the file card's 260px minus its 10px side + padding. Offset 36px down (that padding-top + the bar) so the two line up. + A fixed size rather than an aspect-ratio, because each thumbnail derives + --thumb-aspect from its own PDF's page dimensions. */ + height: calc(310px - 26px); + width: calc(260px - 20px); + max-width: 100%; + margin: 36px auto auto; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: 0.625rem; box-shadow: var(--shadow-md); cursor: pointer; transition: diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 9ad7963b6f..3805b4b912 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -47,6 +47,8 @@ interface AppConfigModalProps { initialSection?: NavKey | null; /** Host-specific sections appended after the build's registry sections. */ extraSections?: ConfigNavSection[]; + /** Registry section keys to drop, for hosts a section can't run in. */ + hiddenSectionKeys?: NavKey[]; } // Extract section from URL path (e.g., /settings/people -> people) @@ -65,6 +67,7 @@ const AppConfigModalInner: React.FC = ({ urlSync = true, initialSection, extraSections, + hiddenSectionKeys, }) => { const { t } = useTranslation(); // Initialize from the URL so a deep link (`/settings/people`) lands on the @@ -218,13 +221,17 @@ const AppConfigModalInner: React.FC = ({ handleCloseSync, config?.showSettingsWhenNoLogin ?? true, ); - const configNavSections = useMemo( - () => - extraSections?.length - ? [...registrySections, ...extraSections] - : registrySections, - [registrySections, extraSections], - ); + const configNavSections = useMemo(() => { + const base = hiddenSectionKeys?.length + ? registrySections + .map((s) => ({ + ...s, + items: s.items.filter((i) => !hiddenSectionKeys.includes(i.key)), + })) + .filter((s) => s.items.length > 0) + : registrySections; + return extraSections?.length ? [...base, ...extraSections] : base; + }, [registrySections, extraSections, hiddenSectionKeys]); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx index 1ba57618f0..6c23178219 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx @@ -20,6 +20,8 @@ interface AppConfigModalLazyProps { initialSection?: NavKey | null; /** Host-specific sections appended after the build's registry sections. */ extraSections?: ConfigNavSection[]; + /** Registry section keys to drop, for hosts a section can't run in. */ + hiddenSectionKeys?: NavKey[]; } export default function AppConfigModalLazy({ @@ -28,6 +30,7 @@ export default function AppConfigModalLazy({ urlSync, initialSection, extraSections, + hiddenSectionKeys, }: AppConfigModalLazyProps) { const [shouldMount, setShouldMount] = useState(false); @@ -44,6 +47,7 @@ export default function AppConfigModalLazy({ urlSync={urlSync} initialSection={initialSection} extraSections={extraSections} + hiddenSectionKeys={hiddenSectionKeys} /> )} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css deleted file mode 100644 index bccf8fbceb..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitch.css +++ /dev/null @@ -1,27 +0,0 @@ -/* Trigger: bare square icon button that blends into either sidebar's header. */ -.app-switch-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border: none; - background: none; - cursor: pointer; - border-radius: var(--radius-sm); - color: var(--c-text-subtle); - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.app-switch-btn:hover { - background: var(--c-hover); - color: var(--c-text-muted); -} - -.app-switch-icon { - width: 1rem; - height: 1.0625rem; - display: block; -} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx deleted file mode 100644 index 6d4449fe58..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AppSwitch } from "@app/components/shared/AppSwitch"; - -/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */ -const meta: Meta = { - title: "Shared/AppSwitch", - component: AppSwitch, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -export const Editor: Story = { - args: { - current: "editor", - theme: "light", - onSwitch: () => {}, - }, -}; - -export const Processor: Story = { - args: { - current: "processor", - theme: "light", - onSwitch: () => {}, - }, -}; - -export const DarkTheme: Story = { - args: { - current: "editor", - theme: "dark", - onSwitch: () => {}, - }, -}; diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index b713e75e7c..71d35ed5ad 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -1,52 +1,27 @@ import { useTranslation } from "react-i18next"; -import { Button, Dropdown } from "@app/ui"; -import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; -import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; -import "@app/components/shared/AppSwitch.css"; +import { Dropdown } from "@app/ui"; +import { BrandMark } from "@app/components/shared/BrandMark"; export type AppSwitchTarget = "editor" | "processor"; -function ChevronDownIcon() { - return ( - - - - ); -} - -interface AppSwitchProps { +interface AppSwitchMenuItemsProps { /** The app this switcher is rendered in (shown as active in the menu). */ current: AppSwitchTarget; - /** Resolved color scheme; picks the brand mark for the menu items. */ - theme: "light" | "dark"; /** Invoked with the selected app; only called for apps other than `current`. */ onSwitch: (app: AppSwitchTarget) => void; - className?: string; } /** - * The editor ⇄ processor app switcher (chevron button → app menu). The editor - * and portal sidebars render this same element so the two apps present one - * identical switcher; each host supplies its own theme source and navigation. + * The editor / processor items for the app-switch menu. Rendered inside the + * BrandSwitcher's logo dropdown, which both apps use as their switcher. The + * mark is the shared , which recolours itself from the theme + * tokens, so no colour-scheme prop needs threading down here. */ -export function AppSwitch({ +export function AppSwitchMenuItems({ current, - theme, onSwitch, - className, -}: AppSwitchProps) { +}: AppSwitchMenuItemsProps) { const { t } = useTranslation(); - const mark = theme === "dark" ? markDark : markLight; const apps: Array<{ id: AppSwitchTarget; label: string }> = [ { id: "processor", @@ -55,28 +30,17 @@ export function AppSwitch({ { id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") }, ]; return ( - - - - - - {apps.map((app) => ( - onSwitch(app.id)} - leading={} - > - {app.label} - - ))} - - + {app.label} + + ))} + ); } diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx index 298a46e1ac..aaf55148ef 100644 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitcher.tsx @@ -1,8 +1,22 @@ +import { Logo } from "@app/ui/Logo"; + +export interface AppSwitcherProps { + /** Icon-only brand mark for the collapsed rail. */ + collapsed?: boolean; +} + /** - * Core stub for the sidebar app switcher. Builds that bundle the admin portal - * (proprietary/saas) shadow this with a real switcher; core has no portal, so - * there is nothing to switch to. + * Sidebar brand header. Core has no admin portal to switch to, so it just + * shows the Stirling logo. Builds that bundle the portal (proprietary/saas) + * shadow this with a version whose logo doubles as the editor⇄processor + * switcher. */ -export function AppSwitcher() { - return null; +export function AppSwitcher({ collapsed }: AppSwitcherProps) { + return ( + + ); } diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css new file mode 100644 index 0000000000..7ddff9b4c7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -0,0 +1,56 @@ +/* Morphing Stirling mark. At rest: the two-tone red brand parallelograms + (from the `d` attributes in the markup). When an ancestor marked + [data-brandmark-morph] is hovered / focused / open, each parallelogram is + transformed into one arm of a smaller, symmetric downward chevron in the + primary text colour. + + The morph uses a CSS `transform` (not the `d` property) so it works in every + browser: both the logo shape and its target chevron arm are parallelograms, + and an affine matrix maps one exactly onto the other. The matrices below were + solved to send each path's 4 corners onto the chevron-arm corners: + arm A (left): M13 27 L35.5 41 L35.5 53 L13 39 Z + arm B (right): M35.5 41 L58 27 L58 39 L35.5 53 Z + (mirror images — equal area + dimensions). */ +.sui-brandmark { + display: block; + width: auto; + overflow: visible; +} + +.sui-brandmark__a, +.sui-brandmark__b { + transform-box: view-box; + transform-origin: 0 0; + transition: + transform var(--motion-slow), + fill var(--motion-slow); +} + +/* Rest state — brand mark. */ +.sui-brandmark__a { + fill: var(--c-brand-mark-soft); +} +.sui-brandmark__b { + fill: var(--c-brand-mark); +} + +/* Morphed state — the two chevron arms, both in the primary text colour. */ +[data-brandmark-morph]:hover .sui-brandmark__a, +[data-brandmark-morph]:focus-visible .sui-brandmark__a, +[data-brandmark-morph].is-open .sui-brandmark__a { + fill: var(--c-text); + transform: matrix(0.483871, 0.584583, 0, 0.338028, 13, 13.8169); +} +[data-brandmark-morph]:hover .sui-brandmark__b, +[data-brandmark-morph]:focus-visible .sui-brandmark__b, +[data-brandmark-morph].is-open .sui-brandmark__b { + fill: var(--c-text); + transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428); +} + +@media (prefers-reduced-motion: reduce) { + .sui-brandmark__a, + .sui-brandmark__b { + transition: none; + } +} diff --git a/frontend/editor/src/core/components/shared/BrandMark.tsx b/frontend/editor/src/core/components/shared/BrandMark.tsx new file mode 100644 index 0000000000..f140eddf7f --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandMark.tsx @@ -0,0 +1,36 @@ +import "@app/components/shared/BrandMark.css"; + +interface BrandMarkProps { + /** Height of the mark (CSS length). */ + height?: string; + className?: string; +} + +/** + * The Stirling logo mark as inline SVG so it can morph. At rest it is the + * two-tone red brand mark; when an ancestor marked `[data-brandmark-morph]` is + * hovered / focused / open (`.is-open`), the two parallelograms slide into a + * smaller downward chevron in the primary text colour — a self-explaining + * "this opens a menu" affordance. See BrandMark.css for the morph geometry. + */ +export function BrandMark({ height = "1.6rem", className }: BrandMarkProps) { + return ( + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css new file mode 100644 index 0000000000..dc1659ea15 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.css @@ -0,0 +1,15 @@ +/* Logo + app-switch dropdown, shared between the editor and the processor. + The logo itself is the trigger (its mark morphs into a chevron on hover). */ +.sui-brand-switcher { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} + +/* Tighten the ghost-button padding so the lockup sits flush like a plain logo, + and negative-margin it back so the hover surface still extends past the text. */ +.sui-brand-switcher__trigger.sui-btn { + --button-padding-x: 0.375rem; + margin-inline: -0.375rem; +} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx new file mode 100644 index 0000000000..92deb518b2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; + +const meta: Meta = { + title: "Brand/BrandSwitcher", + component: BrandSwitcher, + parameters: { layout: "centered" }, + args: { current: "processor", onSwitch: () => {} }, + argTypes: { + current: { control: "inline-radio", options: ["editor", "processor"] }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx new file mode 100644 index 0000000000..474173fee2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Dropdown } from "@app/ui"; +import { Logo } from "@app/ui/Logo"; +import { BrandMark } from "@app/components/shared/BrandMark"; +import { + AppSwitchMenuItems, + type AppSwitchTarget, +} from "@app/components/shared/AppSwitch"; +import "@app/components/shared/BrandSwitcher.css"; + +interface BrandSwitcherProps { + /** The app this is rendered in (shown active in the menu). */ + current: AppSwitchTarget; + /** Called with the selected app (only for the non-current one). */ + onSwitch: (app: AppSwitchTarget) => void; + /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */ + collapsed?: boolean; + className?: string; +} + +/** + * Brand lockup that doubles as the editor⇄processor switcher. The whole logo + * is the dropdown trigger: on hover / focus / open the mark morphs into a + * downward chevron (see BrandMark), so no separate chevron button is needed. + * Shared so the editor and the processor present one identical header. + */ +export function BrandSwitcher({ + current, + onSwitch, + collapsed = false, + className, +}: BrandSwitcherProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 7dce3d32b9..e2393114df 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,17 +1,32 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--c-bg-raised); - border-right: 1px solid var(--c-border-subtle); + background-color: var(--c-bg); display: flex; flex-direction: column; height: 100%; position: relative; - z-index: 10; - /* Animating width + min-width + max-width together can leave the flex layout - stuck on the pre-animation size in some browsers. Snap instead and rely - on the inner content fade for visual smoothness. */ + /* Above the workbench column (also z-10, but later in the DOM, so it would + otherwise paint over us). The brand switcher's menu is wider than the + collapsed rail and has to spill across that boundary intact. */ + z-index: var(--z-dropdown); flex-shrink: 0; + /* Slide the rail between collapsed/expanded. The delayed content-fade + (sidebar-content-in, 0.18s) is timed against this 0.22s so labels resolve + only after the width has settled — no squashed text mid-animation. */ + transition: + width var(--motion-spring), + min-width var(--motion-spring), + max-width var(--motion-spring); + /* Gap around the floating boxes. */ + padding: var(--nav-gutter); + gap: var(--nav-gutter); +} + +@media (prefers-reduced-motion: reduce) { + .file-sidebar { + transition: none; + } } .file-sidebar-inner { @@ -19,8 +34,74 @@ flex-direction: column; flex: 1; min-height: 0; + gap: 0.5rem; +} + +/* ---- Brand header (logo / editor⇄processor switcher) ---- */ +.file-sidebar-brand { + display: flex; + align-items: center; + min-height: 40px; + padding: 0 0.375rem; + flex-shrink: 0; +} + +.file-sidebar-collapse-toggle { + margin-left: auto; + flex-shrink: 0; +} + +.file-sidebar[data-collapsed="true"] .file-sidebar-brand { + flex-direction: column; + gap: 0.25rem; + padding: 0; +} +.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle { + margin-left: 0; +} + +/* ---- Three floating nav-surface boxes (controls / files / footer) ---- */ +/* Horizontal padding is 0 so row highlights bleed to the surface edges; each + row's own inner padding keeps its text/icon indented. */ +.file-sidebar-controls { + padding: 0.25rem 0; + flex-shrink: 0; +} +.file-sidebar-files-box { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0.25rem 0; overflow: hidden; } +.file-sidebar-footer-box { + padding: 0.25rem 0; + flex-shrink: 0; +} + +/* 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 { + padding: 0.25rem; +} +.file-sidebar[data-collapsed="true"] .file-sidebar-files-box { + display: none; +} +.file-sidebar[data-collapsed="true"] .file-sidebar-inner { + flex: 0 0 auto; +} +/* Centre each row's icon in the narrow rail (no side padding/margin to shove + it off the edge). */ +.file-sidebar[data-collapsed="true"] .file-sidebar-search-row, +.file-sidebar[data-collapsed="true"] .file-sidebar-action-row, +.file-sidebar[data-collapsed="true"] .file-sidebar-cloud-row { + justify-content: center; + padding-inline: 0; + margin: 0; +} /* ---- Native file drag-and-drop ---- */ .file-sidebar[data-file-drag-over] { @@ -58,72 +139,16 @@ color: var(--mantine-color-blue-6, var(--c-primary)); } -/* ---- Header ---- */ -.file-sidebar-header { - display: flex; - align-items: center; - height: 48px; - padding: 0 14px; - gap: 10px; - cursor: pointer; - border-radius: 4px; - margin: 4px 4px 0 4px; - flex-shrink: 0; - transition: background-color 0.15s ease; -} - -/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */ - -.file-sidebar-header:hover { - background-color: var(--c-hover); -} - -.file-sidebar-menu-icon { - color: var(--c-text-subtle) !important; - font-size: 18px !important; - flex-shrink: 0; -} - -/* Inherits font-size so swap-in icons render at 18px like the original. */ -.file-sidebar-menu-icon > svg { - font-size: inherit; - width: 1em; - height: 1em; -} - -/* Flip directional toggle icons in RTL (skipped for the symmetric burger). */ -[dir="rtl"] .file-sidebar-menu-icon[data-toggle-flip-rtl="true"] > svg { - transform: scaleX(-1); -} - -.file-sidebar-brand-text { - height: 22px; - width: auto; - flex-shrink: 0; -} - -/* App switcher (portal builds only) sits at the far end of the header row. - The content-fade animation makes this span a stacking context, which would - trap the menu's z-index below later sidebar rows — elevate the span so the - open menu paints above them. */ -.file-sidebar-app-switch { - margin-inline-start: auto; - display: flex; - align-items: center; - position: relative; - z-index: var(--z-dropdown); -} - /* ---- Search row ---- */ .file-sidebar-search-row { display: flex; align-items: center; min-height: 32px; - padding: 0 14px; + padding: 0 8px; gap: 0; cursor: pointer; border-radius: 4px; - margin: 0 4px; + margin: 0; flex-shrink: 0; transition: background-color 0.15s ease; } @@ -160,7 +185,7 @@ .file-sidebar-search-label { margin-left: 12px; font-size: 14px; - color: var(--c-text-muted); + color: var(--c-text); } /* ---- Scrollable content ---- */ @@ -170,7 +195,8 @@ min-height: 0; display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .file-sidebar-scroll::-webkit-scrollbar { @@ -189,10 +215,10 @@ display: flex; align-items: center; height: 32px; - padding: 0 14px; + padding: 0 8px; cursor: pointer; border-radius: 4px; - margin: 0 4px; + margin: 0; gap: 0; transition: background-color 0.15s ease; flex-shrink: 0; @@ -234,7 +260,7 @@ .file-sidebar-action-label { margin-left: 12px; font-size: 14px; - color: var(--c-text-muted); + color: var(--c-text); white-space: nowrap; } @@ -243,10 +269,10 @@ display: flex; align-items: center; height: 32px; - padding: 0 14px; + padding: 0 8px; cursor: pointer; border-radius: 4px; - margin: 0 4px; + margin: 0; gap: 0; transition: background-color 0.15s ease; flex-shrink: 0; @@ -452,18 +478,16 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 10px 14px 6px 14px; - margin: 4px 0 0 0; - border-top: 1px solid var(--c-border-subtle); + padding: 0 6px 2px 6px; + margin: 0; flex-shrink: 0; } .file-sidebar-section-label { - font-size: 13px; + font-size: 0.875rem; font-weight: 600; - letter-spacing: 0.02em; - color: var(--c-text-subtle); - text-transform: uppercase; + letter-spacing: -0.01em; + color: var(--c-text); } /* Slim "Adding files… X/Y" progress row shown during a bulk drop's pre-scan, @@ -570,10 +594,9 @@ display: flex; align-items: center; gap: 8px; - padding: 8px 10px; - border-top: 1px solid var(--c-border-subtle); + padding: 4px 6px; flex-shrink: 0; - min-height: 48px; + min-height: 40px; } /* Bottom bar settings icon tracks the right edge during collapse animation */ diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 501c5385d1..7d09261226 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -8,6 +8,7 @@ import React, { } from "react"; import { Loader, Tooltip } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; +import { NavSurface } from "@app/ui/NavSurface"; import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -29,10 +30,9 @@ import { } from "@app/contexts/IndexedDBContext"; import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; -import { Wordmark } from "@app/components/shared/Wordmark"; import { AppSwitcher } from "@app/components/shared/AppSwitcher"; +import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import type { StirlingFileStub } from "@app/types/fileContext"; -import MenuIcon from "@mui/icons-material/Menu"; import SearchIcon from "@mui/icons-material/Search"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; @@ -152,12 +152,12 @@ const FileSidebar = forwardRef( collapsed = false, onToggleCollapse, onOpenSettings, - toggleAriaLabel, - toggleIcon, onUploadFiles, onPickGoogleDriveFiles, onSearchClick, extraAction, + toggleAriaLabel, + toggleIcon, }, ref, ) { @@ -833,110 +833,78 @@ const FileSidebar = forwardRef(
)}
- {/* Header: hamburger + branding */} - -
onToggleCollapse?.()} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onToggleCollapse?.(); +
+ + {onToggleCollapse && ( + onToggleCollapse()} + aria-label={ + toggleAriaLabel ?? + (collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar")) } - }} - aria-label={ - toggleAriaLabel ?? - (collapsed - ? t("fileSidebar.expand", "Expand sidebar") - : t("fileSidebar.collapse", "Collapse sidebar")) - } - > - {/* Wrapper carries sizing; data-toggle-flip-rtl flips icon in RTL. */} - - {toggleIcon ?? } - - {!collapsed && ( - - )} - {!collapsed && ( - // The header row itself toggles collapse; stop the switcher's - // clicks and key presses from reaching it. - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - - - )} -
- + {toggleIcon ?? } + + )} +
- {/* Search row */} - -
e.key === "Enter" && handleSearchClick() - : undefined - } + {/* Box 1 — top controls (search + open / my files / cloud). No title. */} + + {/* Search row */} + - {searchActive && !collapsed ? ( - { - e.stopPropagation(); - handleSearchClose(); - }} - /> - ) : ( - - )} - {!collapsed && - (searchActive ? ( - setSearchQuery(e.target.value)} - placeholder={t( - "fileSidebar.searchPlaceholder", - "Search files...", - )} - onClick={(e) => e.stopPropagation()} +
e.key === "Enter" && handleSearchClick() + : undefined + } + > + {searchActive && !collapsed ? ( + { + e.stopPropagation(); + handleSearchClose(); + }} /> ) : ( - - {t("fileSidebar.search", "Search")} - - ))} -
-
+ + )} + {!collapsed && + (searchActive ? ( + setSearchQuery(e.target.value)} + placeholder={t( + "fileSidebar.searchPlaceholder", + "Search files...", + )} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {t("fileSidebar.search", "Search")} + + ))} +
+
- {/* Scrollable content */} -
{/* Hidden native file input - kept outside the !collapsed gate so the "Open from computer" row below (always rendered) can fire it in either sidebar state without a silent no-op. */} @@ -1157,145 +1125,158 @@ const FileSidebar = forwardRef( )}
)} + - {/* Files section - always visible when expanded */} - {!collapsed && ( -
-
- - {t("fileSidebar.files", "Files")} - - - navigate("/files")} - title={t( - "fileSidebar.openFileManager", - "Browse all files & folders", - )} - aria-label={t( - "fileSidebar.openFileManager", - "Browse all files & folders", - )} - data-testid="open-files-page" - > - - - nativeFileInputRef.current?.click()} - title={t("fileSidebar.addFiles", "Add files")} - aria-label={t("fileSidebar.addFiles", "Add files")} - > - - -
- - - - {!stubsLoaded ? ( -
- + {/* Box 2 — the file tree (this box scrolls). */} + +
+ {/* Files section - always visible when expanded */} + {!collapsed && ( +
+
+ + {t("fileSidebar.library", "PDF Library")} + + + navigate("/files")} + title={t( + "fileSidebar.openFileManager", + "Browse all files & folders", + )} + aria-label={t( + "fileSidebar.openFileManager", + "Browse all files & folders", + )} + data-testid="open-files-page" + > + + + nativeFileInputRef.current?.click()} + title={t("fileSidebar.addFiles", "Add files")} + aria-label={t("fileSidebar.addFiles", "Add files")} + > + +
- ) : filteredFileStubs.length > 0 ? ( -
- {fileGroups ? ( - <> - {fileGroups.map((group) => { - const isOpen = - groupOpen[group.id] ?? group.defaultExpanded; - return ( -
- -
- {isOpen && group.stubs.map(renderFileRow)} -
-
- ); - })} - - - ) : ( - filteredFileStubs.map(renderFileRow) - )} -
- ) : ( - !searchActive && ( -
-

- {t("fileSidebar.noFiles", "No files yet")} -

-

- {t("fileSidebar.dropHint", "Open files to get started")} -

+ + + + {!stubsLoaded ? ( +
+
- ) - )} -
- )} -
+ ) : filteredFileStubs.length > 0 ? ( +
+ {fileGroups ? ( + <> + {fileGroups.map((group) => { + const isOpen = + groupOpen[group.id] ?? group.defaultExpanded; + return ( +
+ +
+ {isOpen && group.stubs.map(renderFileRow)} +
+
+ ); + })} + + + ) : ( + filteredFileStubs.map(renderFileRow) + )} +
+ ) : ( + !searchActive && ( +
+

+ {t("fileSidebar.noFiles", "No files yet")} +

+

+ {t( + "fileSidebar.dropHint", + "Open files to get started", + )} +

+
+ ) + )} +
+ )} +
+
{/* Kebab "Save to cloud" upload modal (one file at a time). */} @@ -1325,65 +1306,70 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Bottom bar: user name + settings */} - -
+ {/* Bottom bar: user name + settings */} + 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} - ${t("fileSidebar.openSettings", "Open settings")}` : displayName } - style={onOpenSettings ? { cursor: "pointer" } : undefined} + position="right" + withinPortal + disabled={!collapsed} >
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} > - {showProfilePicture ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() +
+ {showProfilePicture ? ( + setPictureFailed(true)} + /> + ) : ( + displayName.charAt(0).toUpperCase() + )} +
+ {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( +
+ +
)}
- {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
+
+
); }, diff --git a/frontend/editor/src/core/components/shared/LandingActions.tsx b/frontend/editor/src/core/components/shared/LandingActions.tsx index ec237b0394..d69f90a1ef 100644 --- a/frontend/editor/src/core/components/shared/LandingActions.tsx +++ b/frontend/editor/src/core/components/shared/LandingActions.tsx @@ -33,6 +33,7 @@ export function LandingActions({
@@ -263,37 +264,62 @@ export default function RightSidebar() { onChange={handleHeaderSearchChange} toolRegistry={toolRegistry} mode="filter" - autoFocus={allToolsView && !inToolView} + autoFocus />
- ) : null} - {showCloseButton ? ( - - - ) : ( - - - + + {t("toolPanel.pdfTools", "PDF Tools")} + )} +
+ {!showCloseButton && ( + { + if (headerSearchOpen) handleHeaderSearchChange(""); + setHeaderSearchOpen((open) => !open); + }} + aria-label={t("toolPanel.searchTools", "Search tools")} + className="tool-panel__expand-btn" + > + {headerSearchOpen ? ( + + ) : ( + + )} + + )} + {showCloseButton ? ( + + + + ) : ( + + + + )} +
)} diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index 5ba31e5b86..41100bbf9f 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -11,6 +11,7 @@ .tool-panel { position: relative; + background: var(--c-surface); transition: width 0.3s ease, max-width 0.3s ease; @@ -18,6 +19,14 @@ user-select: none; } +.tool-panel--floating { + margin: var(--nav-gutter) var(--nav-gutter) var(--nav-gutter) 0; + height: calc(100vh - (var(--nav-gutter) * 2)); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-nav); +} + .tool-panel__collapsed-strip { display: flex; flex-direction: column; @@ -151,8 +160,21 @@ box-sizing: border-box; } -.tool-panel__compact-header .tool-panel__expand-btn { +.tool-panel__compact-title { + flex: 1 1 auto; + min-width: 0; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--c-text); +} + +.tool-panel__compact-header-actions { + display: flex; + align-items: center; + gap: 0.25rem; margin-left: auto; + flex-shrink: 0; } .tool-panel__compact-header-search { diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 6cda34aff2..5c5ce0e261 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -50,7 +50,7 @@ const SCROLLABLE_STYLE: React.CSSProperties = { const CONTAINER_STYLE: React.CSSProperties = { display: "flex", flexDirection: "column", - background: "var(--c-bg-raised)", + background: "var(--c-surface)", }; const toTitleCase = (s: string) => s.replace( diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 1e21637849..cc4a04598e 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -1364,9 +1364,12 @@ const EmbedPdfViewerContent = ({ {/* Bottom Toolbar Overlay */} {effectiveFile && (
diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index cd0a838421..983d5213ed 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -499,6 +499,7 @@ export default function HomePage() { gap={0} h="100%" className="flex-nowrap flex" + bg="var(--c-bg)" > { test("should prevent XSS via search input", async ({ page }) => { await loginAndSetup(page); - // Step 1: Enter XSS payload in the search box + // Step 1: Open the search box (the tool panel header shows a search + // toggle; the field only mounts once it's pressed) and enter the payload + await page.getByRole("button", { name: /search tools/i }).click(); const searchBox = page.getByPlaceholder(/search|cari/i).first(); await searchBox.fill('">'); diff --git a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts index b351acb1be..30ecc8d779 100644 --- a/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/language-localization.spec.ts @@ -54,10 +54,12 @@ test.describe("13. Language / Localization", () => { // Step 5: Wait for page reload (language change triggers window.location.reload()) await page.waitForLoadState("domcontentloaded"); - // Step 6: Verify the UI text is in English - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ - timeout: 10000, - }); + // Step 6: Verify the UI text is in English. The tool search is a + // header toggle, so assert its English label rather than the field, + // which only mounts once the toggle is pressed. + await expect( + page.getByRole("button", { name: /search tools/i }).first(), + ).toBeVisible({ timeout: 10000 }); } }); }); diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts index c66f392fa1..b8efb283fc 100644 --- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts @@ -17,6 +17,14 @@ test.describe("2. Main Dashboard / Home Page", () => { page.locator('[data-testid="config-button"]').first(), ).toBeVisible(); + // Tool search sits behind a header toggle now, so assert the affordance + // AND that pressing it actually mounts a usable search field — dropping + // the second half would stop covering the input entirely. + const searchToggle = page + .getByRole("button", { name: /search tools/i }) + .first(); + await expect(searchToggle).toBeVisible(); + await searchToggle.click(); await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); await expect( @@ -74,7 +82,10 @@ test.describe("2. Main Dashboard / Home Page", () => { await page.goto("/"); - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); + // Tool search is a header toggle; the field mounts only once pressed. + await expect( + page.getByRole("button", { name: /search tools/i }).first(), + ).toBeVisible(); }); }); diff --git a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts index 397c86d605..7dfda74b79 100644 --- a/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/tool-search.spec.ts @@ -1,13 +1,24 @@ +import type { Page } from "@playwright/test"; import { test, expect } from "@app/tests/helpers/stub-test-base"; +/** + * The tool panel header shows a search *toggle*; the field only mounts once + * it's pressed. Open it and hand back the focused input. + */ +async function openToolSearch(page: Page) { + await page.getByRole("button", { name: /search tools/i }).click(); + const searchBox = page.getByPlaceholder(/search|cari/i).first(); + await expect(searchBox).toBeVisible({ timeout: 5000 }); + return searchBox; +} + test.describe("3. Tool Search", () => { test.describe("3.1 Search - Happy Path", () => { test("should filter tools in real time based on search input", async ({ page, }) => { - // Step 1: Click on the search box - const searchBox = page.getByPlaceholder(/search|cari/i).first(); - await searchBox.click(); + // Step 1: Open the search box from the header toggle + const searchBox = await openToolSearch(page); // Step 2: Type "merge" await searchBox.fill("merge"); @@ -31,9 +42,8 @@ test.describe("3. Tool Search", () => { test("should handle queries with no matching tools gracefully", async ({ page, }) => { - // Step 1: Click on the search box - const searchBox = page.getByPlaceholder(/search|cari/i).first(); - await searchBox.click(); + // Step 1: Open the search box from the header toggle + const searchBox = await openToolSearch(page); // Step 2: Type xyznonexistent123 await searchBox.fill("xyznonexistent123"); @@ -61,7 +71,7 @@ test.describe("3. Tool Search", () => { test.describe("3.3 Search - Special Characters", () => { test("should sanitize search input against XSS", async ({ page }) => { // Step 1: Type XSS payload into the search box - const searchBox = page.getByPlaceholder(/search|cari/i).first(); + const searchBox = await openToolSearch(page); await searchBox.fill(""); // Step 2: Verify no script execution occurs (no alert dialog) diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index ced2c5dcb6..c7b0ccb08f 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -5,9 +5,9 @@ :root, [data-theme="light"], html[data-app-theme="light"] { - --c-bg: var(--p-gray-50); + --c-bg: var(--p-paper); --c-bg-raised: var(--p-white); - --c-surface: var(--p-white); + --c-surface: var(--p-snow); --c-surface-raised: var(--p-white); --c-surface-sunken: var(--p-gray-100); --c-input-bg: var(--p-white); @@ -15,12 +15,17 @@ html[data-app-theme="light"] { --c-active: var(--p-gray-100); --c-overlay: rgba(0, 0, 0, 0.5); - --c-text: var(--p-gray-900); + --c-text: var(--p-ink); --c-text-muted: var(--p-gray-600); - --c-text-subtle: var(--p-gray-500); + --c-text-subtle: var(--p-gray-550); --c-text-on-primary: var(--p-white); - --c-border: var(--p-gray-250); + --c-btn-solid: var(--c-text); + --c-btn-inverse: var(--p-snow); + --c-btn-secondary: var(--c-btn-inverse); + --c-btn-secondary-border: var(--c-border); + + --c-border: var(--p-c-f0f0f0); --c-border-subtle: var(--p-gray-200); --c-border-strong: var(--p-gray-400); @@ -55,7 +60,8 @@ html[data-app-theme="light"] { marks, vendor colours, categorical avatar dots, static illustrations, and the multi-hue gradients on feature/upgrade/onboarding surfaces. Named here so components reference a --c-* token, never a raw --p-*. */ - --c-brand-mark: var(--p-brand-red-650); /* Stirling logo mark fill */ + --c-brand-mark: var(--p-brand-red-650); + --c-brand-mark-soft: var(--p-brand-red-400); /* Stirling logo mark fill */ --c-accent-stripe: var(--p-periwinkle-500); /* Stripe "connect" CTA */ /* Feature-accent hues (fixed) used as stops in multi-hue gradients. */ @@ -110,9 +116,9 @@ html[data-app-theme="light"] { /* ── MIDNIGHT (original navy) — also the default portal/Storybook dark ────── */ [data-theme="dark"], html[data-app-theme="midnight"] { - --c-bg: var(--p-zinc-900); + --c-bg: var(--p-c-141416); --c-bg-raised: var(--p-zinc-850); - --c-surface: var(--p-zinc-800); + --c-surface: var(--p-c-1a1a1d); --c-surface-raised: var(--p-zinc-650); --c-surface-sunken: var(--p-zinc-850); --c-input-bg: var(--p-zinc-650); @@ -120,12 +126,16 @@ html[data-app-theme="midnight"] { --c-active: var(--p-gray-800); --c-overlay: rgba(0, 0, 0, 0.6); - --c-text: var(--p-zinc-100); + --c-text: var(--p-snow); --c-text-muted: var(--p-zinc-200); --c-text-subtle: var(--p-zinc-300); --c-text-on-primary: var(--p-white); + --c-btn-solid: var(--c-text); + --c-btn-inverse: var(--p-ink); + --c-btn-secondary: var(--p-c-1a1a1d); + --c-btn-secondary-border: var(--p-c-343439); - --c-border: var(--p-zinc-650); + --c-border: var(--p-c-28282d); --c-border-subtle: rgba(255, 255, 255, 0.05); --c-border-strong: var(--p-zinc-500); @@ -157,9 +167,9 @@ html[data-app-theme="custom"] { --c-accent-fg: var(--c-primary); /* Primary-tinted surfaces (light base). Neutralised by the default override. */ - --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-gray-50)); + --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-paper)); --c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white)); - --c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-white)); + --c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-snow)); --c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white)); --c-surface-sunken: color-mix( in srgb, @@ -169,7 +179,7 @@ html[data-app-theme="custom"] { --c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white)); --c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50)); --c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100)); - --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-gray-250)); + --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-f0f0f0)); --c-border-subtle: color-mix( in srgb, var(--c-primary) 10%, @@ -270,16 +280,20 @@ html[data-app-theme="custom"] { /* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { /* Neutral text / borders / overlay (not accent-tinted). */ - --c-text: var(--p-zinc-100); + --c-text: var(--p-snow); --c-text-muted: var(--p-zinc-200); --c-text-subtle: var(--p-zinc-300); + --c-btn-solid: var(--c-text); + --c-btn-inverse: var(--p-ink); + --c-btn-secondary: var(--p-c-1a1a1d); + --c-btn-secondary-border: var(--p-c-343439); --c-border-strong: var(--p-zinc-500); --c-overlay: rgba(0, 0, 0, 0.6); /* Accent-tinted surfaces (dark base). Neutralised by the default override. */ - --c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-950)); + --c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-141416)); --c-bg-raised: color-mix(in srgb, var(--c-primary) 9%, var(--p-zinc-850)); - --c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-800)); + --c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-1a1a1d)); --c-surface-raised: color-mix( in srgb, var(--c-primary) 9%, @@ -293,7 +307,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { --c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900)); --c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750)); --c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700)); - --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-zinc-650)); + --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-28282d)); --c-border-subtle: color-mix( in srgb, var(--c-primary) 10%, @@ -335,27 +349,27 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { /* ── DEFAULT (no tint) — surfaces opt out of the accent tint (neutral white/grey light, zinc dark); --c-primary stays for buttons. Extra [data-accent="default"] beats the tinted blocks. ── */ html[data-app-theme="custom"][data-accent="default"] { - --c-bg: var(--p-gray-50); + --c-bg: var(--p-paper); --c-bg-raised: var(--p-white); - --c-surface: var(--p-white); + --c-surface: var(--p-snow); --c-surface-raised: var(--p-white); --c-surface-sunken: var(--p-gray-100); --c-input-bg: var(--p-white); --c-hover: var(--p-gray-50); --c-active: var(--p-gray-100); - --c-border: var(--p-gray-250); + --c-border: var(--p-c-f0f0f0); --c-border-subtle: var(--p-gray-200); } html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="dark"] { - --c-bg: var(--p-zinc-950); + --c-bg: var(--p-c-141416); --c-bg-raised: var(--p-zinc-850); - --c-surface: var(--p-zinc-800); + --c-surface: var(--p-c-1a1a1d); --c-surface-raised: var(--p-zinc-775); --c-surface-sunken: var(--p-zinc-900); --c-input-bg: var(--p-zinc-900); --c-hover: var(--p-zinc-750); --c-active: var(--p-zinc-700); - --c-border: var(--p-zinc-650); + --c-border: var(--p-c-28282d); --c-border-subtle: var(--p-zinc-700); } diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index 8042fcade6..aa0a1b38cc 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -28,6 +28,10 @@ --radius-xl: 16px; --radius-pill: 9999px; + --radius-nav: 0.625rem; + --nav-gutter: 0.5rem; + --nav-rail-w: 3.5rem; + /* ── Layout sizing ── */ --footer-height: 2rem; --landing-stack-w: 224px; @@ -51,6 +55,7 @@ --motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --motion-slow: 0.3s ease; --motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1); + --motion-spring: 0.22s cubic-bezier(0.32, 0.72, 0, 1); --fullscreen-anim-duration-in: 0.28s; --fullscreen-anim-duration-out: 0.22s; diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css index 6ea844e9ed..239658dfeb 100644 --- a/frontend/editor/src/core/theme/primitives.css +++ b/frontend/editor/src/core/theme/primitives.css @@ -11,6 +11,10 @@ --p-gray-300: #d1d5db; --p-gray-400: #9ca3af; --p-gray-500: #6b7280; + /* Subtle body text in light mode. gray-500 clears 4.5:1 on pure white but + only reaches 4.39:1 on the --p-paper canvas; this is the same hue nudged + dark enough to pass (4.87:1) while staying lighter than gray-600. */ + --p-gray-550: #646b76; --p-gray-600: #4b5563; --p-gray-700: #374151; --p-gray-800: #1f2937; @@ -29,6 +33,10 @@ --p-zinc-300: #71717a; --p-zinc-200: #a1a1aa; --p-zinc-100: #f4f4f5; + --p-c-141416: #141416; + --p-c-1a1a1d: #1a1a1d; + --p-c-28282d: #28282d; + --p-c-343439: #343439; --p-blue-400: #60a5fa; --p-blue-500: #3b82f6; --p-blue-600: #2563eb; @@ -46,6 +54,7 @@ /* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */ --p-brand-red-200: #d9a8a8; --p-brand-red-300: #d98a8a; + --p-brand-red-400: #ad7373; --p-brand-red-650: #8e3131; --p-brand-red-700: #7a2929; --p-brand-red-900: #5a2424; @@ -84,6 +93,10 @@ --p-tint-blue: #eef1fb; --p-tint-violet: #f6f4fc; --p-tint-pink: #fbf4f7; + --p-paper: #f5f4f1; + --p-ink: #373530; + --p-snow: #fafafa; + --p-c-f0f0f0: #f0f0f0; /* Notion-style procurement view palette. */ --p-notion-blue: #2383e2; @@ -92,7 +105,6 @@ --p-notion-ink: #37352f; --p-notion-gray: #9b9a97; --p-notion-gray-strong: #787774; - --p-notion-paper: #f5f4f1; --p-notion-paper-2: #f0eee9; --p-notion-border: #e3e1dc; --p-notion-border-2: #eae8e3; diff --git a/frontend/editor/src/core/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css index c36fae35fc..2fe1fc3b70 100644 --- a/frontend/editor/src/core/tokens/tokens.css +++ b/frontend/editor/src/core/tokens/tokens.css @@ -326,16 +326,6 @@ opacity: 0.5; } } -@keyframes pulseRing { - 0% { - transform: scale(0.8); - opacity: 1; - } - 100% { - transform: scale(2.2); - opacity: 0; - } -} @keyframes spin { to { transform: rotate(360deg); diff --git a/frontend/editor/src/core/ui/ActionIcon.tsx b/frontend/editor/src/core/ui/ActionIcon.tsx index 682e4b41e5..ca8dd0b9ef 100644 --- a/frontend/editor/src/core/ui/ActionIcon.tsx +++ b/frontend/editor/src/core/ui/ActionIcon.tsx @@ -103,15 +103,22 @@ export const ActionIcon = forwardRef( "--ai-hover-color": "var(--c-text)", "--ai-bd": "1px solid transparent", } - : { - "--ai-bg": "transparent", - "--ai-hover": "var(--_tint)", - "--ai-color": "var(--_text)", - "--ai-bd": - variant === "secondary" - ? "1px solid var(--_bd)" - : "1px solid transparent", - }; + : variant === "secondary" + ? { + // Filled when the accent defines --_solid-2 (default = inverse + // ink/snow); otherwise falls back to the outlined look. + "--ai-bg": "var(--_solid-2, transparent)", + "--ai-hover": "var(--_solid-2-hover, var(--_tint))", + "--ai-color": "var(--_on-2, var(--_text))", + "--ai-bd": "1px solid var(--_bd-2, var(--_bd))", + } + : { + // tertiary (ghost) — neutral text + hover for the default accent. + "--ai-bg": "transparent", + "--ai-hover": "var(--_tert-tint, var(--_tint))", + "--ai-color": "var(--_tert-text, var(--_text))", + "--ai-bd": "1px solid transparent", + }; // Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing. const Comp = MantineActionIcon as ElementType; diff --git a/frontend/editor/src/core/ui/Button.tsx b/frontend/editor/src/core/ui/Button.tsx index 7d2ff6d457..8c04aa226c 100644 --- a/frontend/editor/src/core/ui/Button.tsx +++ b/frontend/editor/src/core/ui/Button.tsx @@ -193,15 +193,23 @@ const ButtonRoot = forwardRef( "--button-hover-color": "var(--c-text)", "--button-bd": "1px solid transparent", } - : { - "--button-bg": "transparent", - "--button-hover": "var(--_tint)", - "--button-color": "var(--_text)", - "--button-bd": - variant === "secondary" - ? "1px solid var(--_bd)" - : "1px solid transparent", - }; + : variant === "secondary" + ? { + // Filled when the accent defines --_solid-2 (default = inverse + // ink/snow); otherwise falls back to the outlined look. + "--button-bg": "var(--_solid-2, transparent)", + "--button-hover": "var(--_solid-2-hover, var(--_tint))", + "--button-color": "var(--_on-2, var(--_text))", + "--button-bd": "1px solid var(--_bd-2, var(--_bd))", + } + : { + // tertiary (ghost) — neutral text + hover when the accent + // defines --_tert-* (default); otherwise the accent link colour. + "--button-bg": "transparent", + "--button-hover": "var(--_tert-tint, var(--_tint))", + "--button-color": "var(--_tert-text, var(--_text))", + "--button-bd": "1px solid transparent", + }; // Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing. const Comp = MantineButton as ElementType; diff --git a/frontend/editor/src/core/ui/ChatFABButton.css b/frontend/editor/src/core/ui/ChatFABButton.css index 171abf8809..b68713aa1f 100644 --- a/frontend/editor/src/core/ui/ChatFABButton.css +++ b/frontend/editor/src/core/ui/ChatFABButton.css @@ -5,11 +5,10 @@ width: 56px; height: 56px; border-radius: 16px; - border: none; - background: var(--c-primary); - color: var(--c-text-on-primary); + border: 1px solid var(--c-btn-secondary-border); + background: var(--c-btn-secondary); cursor: pointer; - box-shadow: 0 4px 16px color-mix(in srgb, var(--c-primary) 40%, transparent); + box-shadow: var(--shadow-md); transition: transform 180ms cubic-bezier(0.32, 0.72, 0, 1), box-shadow 180ms ease; @@ -20,7 +19,7 @@ .chat-fab-btn:hover { transform: scale(1.09); - box-shadow: 0 6px 22px color-mix(in srgb, var(--c-primary) 52%, transparent); + box-shadow: var(--shadow-lg); } .chat-fab-btn:active { diff --git a/frontend/editor/src/core/ui/ChatFABButton.tsx b/frontend/editor/src/core/ui/ChatFABButton.tsx index 5ba3c8113b..672bd7c1c4 100644 --- a/frontend/editor/src/core/ui/ChatFABButton.tsx +++ b/frontend/editor/src/core/ui/ChatFABButton.tsx @@ -1,4 +1,5 @@ import type { ButtonHTMLAttributes } from "react"; +import { BrandMark } from "@app/components/shared/BrandMark"; import "@app/ui/ChatFABButton.css"; export interface ChatFABButtonProps extends ButtonHTMLAttributes { @@ -25,20 +26,10 @@ export function ChatFABButton({ return ( diff --git a/frontend/editor/src/core/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css index 230c99b40d..4f64737b32 100644 --- a/frontend/editor/src/core/ui/StatusBadge.css +++ b/frontend/editor/src/core/ui/StatusBadge.css @@ -2,35 +2,21 @@ display: inline-flex; align-items: center; gap: 0.375rem; - border-radius: var(--radius-pill); font-family: var(--font-sans); font-weight: 500; letter-spacing: 0.01em; - border: 1px solid transparent; line-height: 1; color: var(--sui-status-c, var(--c-text-subtle)); - background: color-mix( - in srgb, - var(--sui-status-c, var(--c-text-subtle)) 12%, - transparent - ); - border-color: color-mix( - in srgb, - var(--sui-status-c, var(--c-text-subtle)) 28%, - transparent - ); } + .sui-status--sm { font-size: 0.6875rem; - padding: 0.125rem 0.5rem; } .sui-status--md { font-size: 0.75rem; - padding: 0.1875rem 0.625rem; } .sui-status--lg { font-size: 0.8125rem; - padding: 0.3125rem 0.75rem; } .sui-status__dot { @@ -38,25 +24,40 @@ height: 0.375rem; border-radius: 50%; background: currentColor; - position: relative; -} -.sui-status__dot--pulse::after { - content: ""; - position: absolute; - inset: -0.125rem; - border-radius: 50%; - border: 2px solid currentColor; - animation: pulseRing 1.4s ease-out infinite; } -/* Neutral keeps the plain muted surface rather than an accent tint. */ +.sui-status--pill { + border-radius: var(--radius-pill); + border: 1px solid + color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 28%, + transparent + ); + background: color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 12%, + transparent + ); +} +.sui-status--pill.sui-status--sm { + padding: 0.125rem 0.5rem; +} +.sui-status--pill.sui-status--md { + padding: 0.1875rem 0.625rem; +} +.sui-status--pill.sui-status--lg { + padding: 0.3125rem 0.75rem; +} + .sui-status--neutral { color: var(--c-text-subtle); +} +.sui-status--pill.sui-status--neutral { background: var(--c-surface-sunken); border-color: var(--c-border-subtle); } -/* Tones only pick the accent; the base rule builds the fill + border. `-dark` - is theme-adaptive, so text stays legible on the pale fill in both themes. */ + .sui-status--success { --sui-status-c: var(--color-green-dark); } diff --git a/frontend/editor/src/core/ui/StatusBadge.stories.tsx b/frontend/editor/src/core/ui/StatusBadge.stories.tsx index 8b77c58e41..9edeb00717 100644 --- a/frontend/editor/src/core/ui/StatusBadge.stories.tsx +++ b/frontend/editor/src/core/ui/StatusBadge.stories.tsx @@ -33,7 +33,7 @@ export const AllTones: Story = { }; export const Live: Story = { - args: { tone: "success", pulse: true, children: "Live" }, + args: { tone: "success", children: "Live" }, }; export const Sizes: Story = { diff --git a/frontend/editor/src/core/ui/StatusBadge.tsx b/frontend/editor/src/core/ui/StatusBadge.tsx index 5ffd5d44f9..7d80819edd 100644 --- a/frontend/editor/src/core/ui/StatusBadge.tsx +++ b/frontend/editor/src/core/ui/StatusBadge.tsx @@ -14,28 +14,21 @@ export type StatusSize = "sm" | "md" | "lg"; export interface StatusBadgeProps { tone?: StatusTone; size?: StatusSize; - /** Show a leading coloured dot. */ showDot?: boolean; - /** Render the dot with a pulse animation (active / live indicator). */ - pulse?: boolean; children?: ReactNode; className?: string; } -/** - * Inline status pill used across surfaces — pipeline rows, document status, - * deployments, audit logs. Tone maps to semantic meaning, not raw colour. - */ export function StatusBadge({ tone = "neutral", size = "md", showDot = true, - pulse = false, children, className, }: StatusBadgeProps) { const cls = [ "sui-status", + showDot ? "" : "sui-status--pill", `sui-status--${tone}`, `sui-status--${size}`, className ?? "", @@ -44,12 +37,7 @@ export function StatusBadge({ .join(" "); return ( - {showDot && ( - - )} + {showDot && } {children} ); diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css index 404f98cc52..cf83edacaf 100644 --- a/frontend/editor/src/core/ui/accents.css +++ b/frontend/editor/src/core/ui/accents.css @@ -2,16 +2,26 @@ * accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */ .sui-acc-default { - --_solid: var(--c-primary); - --_solid-hover: var(--c-primary-hover); - --_on: #ffffff; + --_solid: var(--c-btn-solid); + --_solid-hover: color-mix( + in srgb, + var(--c-btn-solid) 85%, + var(--c-btn-inverse) + ); + --_on: var(--c-btn-inverse); --_text: var(--c-primary-hover); --_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface)); --_tint: color-mix(in srgb, var(--c-primary) 12%, transparent); -} - -html[data-app-theme="custom"] .sui-acc-default { - --_on: var(--c-text-on-primary); + --_solid-2: var(--c-btn-secondary); + --_solid-2-hover: color-mix( + in srgb, + var(--c-btn-secondary) 92%, + var(--c-btn-solid) + ); + --_on-2: var(--c-btn-solid); + --_bd-2: var(--c-btn-secondary-border); + --_tert-text: var(--c-text); + --_tert-tint: var(--c-hover); } /* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the fill and the outline/text are the SAME red in both light and dark. */ diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index d94dfab813..45212d8038 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -1,5 +1,6 @@ export * from "@app/ui/Button"; export * from "@app/ui/ActionIcon"; +export * from "@app/ui/Logo"; export * from "@app/ui/FilePicker"; export * from "@app/ui/SegmentedControl"; export * from "@app/ui/StatusBadge"; @@ -8,6 +9,7 @@ export * from "@app/ui/ToggleSwitch"; export * from "@app/ui/ProgressBar"; export * from "@app/ui/MetricCard"; export * from "@app/ui/NavItem"; +export * from "@app/ui/NavSurface"; export * from "@app/ui/PanelHeader"; export * from "@app/ui/CodeBlock"; export * from "@app/ui/SectionDivider"; diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx index 9d7c2c095e..21896d9a1d 100644 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx @@ -1,9 +1,18 @@ +import { Logo } from "@app/ui/Logo"; +import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; + /** * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so shadow the switcher back to empty — - * otherwise the desktop bundle would reference @portal via the proprietary - * switcher's imports. + * desktop/routes/adminRouteExtensions), so there's nothing to switch to — + * shadow the brand header back to a plain logo. (Also avoids the desktop + * bundle referencing @portal via the proprietary switcher's imports.) */ -export function AppSwitcher() { - return null; +export function AppSwitcher({ collapsed }: AppSwitcherProps) { + return ( + + ); } diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 6535832399..152c45f7c5 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -43,9 +43,6 @@ } .portal-shell__topbar-wordmark { - height: 1.375rem; - width: auto; - display: block; margin-right: auto; } diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index c50b4000e9..77c8f90686 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import { ActionIcon } from "@app/ui"; import { Sidebar } from "@portal/components/Sidebar"; -import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; -import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; -import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; +import { Logo } from "@app/ui/Logo"; import "@portal/components/AppShell.css"; /** @@ -17,7 +15,6 @@ import "@portal/components/AppShell.css"; */ function MobileTopbar() { const { t } = useTranslation(); - const { theme } = useTheme(); const { mobileNavOpen, toggleMobileNav, openSearch } = useUI(); return (
@@ -30,10 +27,11 @@ function MobileTopbar() { > - {t("portal.shell.sidebar.brandSuffix")}
diff --git a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx index a83d560038..a61193f17a 100644 --- a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx +++ b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx @@ -69,11 +69,7 @@ export function InstanceHealthTable({ instances }: Props) { key: "status", header: t("portal.editorAdmin.health.columns.status"), render: (i) => ( - + {t(INSTANCE_STATUS_LABEL[i.status])} ), diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx index a415abf107..da9831ec4c 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx @@ -5,6 +5,7 @@ import { Card, EmptyState, MetricCard, + MetricStrip, StatusBadge, Table, Tabs, @@ -140,7 +141,7 @@ export function AuditTab() { /> {data && ( -
+ -
+ )} {!forbidden && ( diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx index 59e4b04d54..8e96e4d997 100644 --- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx @@ -74,11 +74,7 @@ export function DeploymentsTab() { key: "status", header: t("portal.infrastructure.deployments.regionColumns.status"), render: (r) => ( - + {t(REGION_LABEL[r.status])} ), diff --git a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx index 86b563aabd..6d097b33af 100644 --- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx @@ -5,6 +5,7 @@ import { Chip, EmptyState, MetricCard, + MetricStrip, ProgressBar, Select, StatusBadge, @@ -65,11 +66,7 @@ export function ModelsTab() { key: "status", header: t("portal.infrastructure.models.columns.status"), render: (m) => ( - + {t(MODEL_LABEL[m.status])} ), @@ -174,7 +171,7 @@ export function ModelsTab() { /> {data && ( -
+ -
+ )}
diff --git a/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx b/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx index 39bac738c9..a306536718 100644 --- a/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx +++ b/frontend/editor/src/portal/components/pipelines/KpiStrip.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@app/ui"; +import { PipelinesIcon } from "@portal/components/icons"; import type { PipelinesOverviewResponse } from "@portal/api/pipelines"; /** @@ -22,7 +23,7 @@ interface KpiStripProps { export function KpiStrip({ data, loading }: KpiStripProps) { const { t } = useTranslation(); return ( - + }> {KPI_LABEL_KEYS.map((labelKey, i) => { const k = loading ? undefined : data?.kpis[i]; return ( diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx index 38c46547ca..e0ac5970f6 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx @@ -49,11 +49,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) { key: "status", header: t("portal.pipelines.table.status"), render: (p) => ( - + {t(`portal.pipelines.status.${p.status}`)} ), diff --git a/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx b/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx index aab40030d5..c7e2fd4f26 100644 --- a/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx +++ b/frontend/editor/src/portal/components/policies/CatalogueSummary.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@app/ui"; +import { PoliciesIcon } from "@portal/components/icons"; import type { PoliciesResponse } from "@portal/api/policies"; interface CatalogueSummaryProps { @@ -16,7 +17,7 @@ export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) { const { t } = useTranslation(); const s = loading ? undefined : data?.summary; return ( - + }> + {paused ? t("portal.policies.status.paused") : t("portal.policies.status.active")} diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx index c5169217dd..58fee82d83 100644 --- a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx @@ -87,7 +87,6 @@ export function PolicyCategoryCard({ {status === "paused" ? t("portal.policies.status.paused") diff --git a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx index e198fd92c8..336097d9fa 100644 --- a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx @@ -196,10 +196,7 @@ export function PolicyDetailPanel({ > {/* Status + trigger strip */}
- + {isPaused ? t("portal.policies.status.paused") : t("portal.policies.status.active")} diff --git a/frontend/editor/src/portal/components/sources/KpiStrip.tsx b/frontend/editor/src/portal/components/sources/KpiStrip.tsx index 16e8f547f3..970a0a7982 100644 --- a/frontend/editor/src/portal/components/sources/KpiStrip.tsx +++ b/frontend/editor/src/portal/components/sources/KpiStrip.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@app/ui"; +import { SourcesIcon } from "@portal/components/icons"; import type { SourcesResponse } from "@portal/api/sources"; /** @@ -22,7 +23,7 @@ interface KpiStripProps { export function KpiStrip({ data, loading }: KpiStripProps) { const { t } = useTranslation(); return ( - + }> {KPI_LABEL_KEYS.map((labelKey, i) => { const k = loading ? undefined : data?.kpis[i]; return ( diff --git a/frontend/editor/src/portal/components/sources/SourcesTable.tsx b/frontend/editor/src/portal/components/sources/SourcesTable.tsx index 23017299a1..9d99a8c8ea 100644 --- a/frontend/editor/src/portal/components/sources/SourcesTable.tsx +++ b/frontend/editor/src/portal/components/sources/SourcesTable.tsx @@ -61,11 +61,7 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) { key: "status", header: t("portal.sources.table.status"), render: (s) => ( - + {t(`portal.sources.status.${s.status}`)} ), diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index f55c2f5ee9..fbf572d3cc 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -17,6 +17,8 @@ interface UIContextValue { openMobileNav: () => void; closeMobileNav: () => void; toggleMobileNav: () => void; + sidebarCollapsed: boolean; + toggleSidebarCollapsed: () => void; assistantOpen: boolean; openAssistant: () => void; @@ -52,9 +54,29 @@ interface UIContextValue { const UIContext = createContext(null); +const SIDEBAR_COLLAPSED_KEY = "stirling.portalSidebarCollapsed"; + +function readSidebarCollapsed(): boolean { + try { + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"; + } catch { + return false; + } +} + +function writeSidebarCollapsed(collapsed: boolean): void { + try { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed)); + } catch { + // private mode / quota: silently no-op + } +} + export function UIProvider({ children }: { children: ReactNode }) { const [searchOpen, setSearchOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = + useState(readSidebarCollapsed); const [assistantOpen, setAssistantOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState< @@ -85,6 +107,14 @@ export function UIProvider({ children }: { children: ReactNode }) { closeMobileNav: () => setMobileNavOpen(false), toggleMobileNav: () => setMobileNavOpen((o) => !o), + sidebarCollapsed, + toggleSidebarCollapsed: () => + setSidebarCollapsed((c) => { + const next = !c; + writeSidebarCollapsed(next); + return next; + }), + assistantOpen, openAssistant: () => setAssistantOpen(true), closeAssistant: () => setAssistantOpen(false), @@ -129,6 +159,7 @@ export function UIProvider({ children }: { children: ReactNode }) { [ searchOpen, mobileNavOpen, + sidebarCollapsed, assistantOpen, settingsOpen, settingsInitialSection, diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css index 2bff218e96..cd532daeeb 100644 --- a/frontend/editor/src/portal/views/Infrastructure.css +++ b/frontend/editor/src/portal/views/Infrastructure.css @@ -185,25 +185,6 @@ text-align: right; } -/* Metric strip (audit) */ -.portal-infra__metrics { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.75rem; -} - -@media (max-width: 50rem) { - .portal-infra__metrics { - grid-template-columns: repeat(2, 1fr); - } -} - -@media (max-width: 30rem) { - .portal-infra__metrics { - grid-template-columns: 1fr; - } -} - /* ── API keys ───────────────────────────────────────────────────────────── */ .portal-infra__keys { display: flex; diff --git a/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css b/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css index bb0e418d88..9690a8f08f 100644 --- a/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css +++ b/frontend/editor/src/proprietary/auth/ui/AuthShell.module.css @@ -5,7 +5,7 @@ flex-direction: column; align-items: center; justify-content: center; - background-color: var(--auth-bg-color); + background-color: var(--c-bg); padding: 1.5rem 1.5rem 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; diff --git a/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx b/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx index baaad54d7b..3a9cbf45f4 100644 --- a/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx +++ b/frontend/editor/src/proprietary/auth/ui/AuthSignupPrompt.tsx @@ -9,7 +9,7 @@ interface AuthSignupPromptProps { /** * "Don't have an account? Sign up" row shown beneath the login form. The prompt - * is muted; the action reads as a brand-coloured link. + * is muted; the action reads as a blue link so it pops. */ export default function AuthSignupPrompt({ onSignUp }: AuthSignupPromptProps) { const { t } = useTranslation(); @@ -19,7 +19,7 @@ export default function AuthSignupPrompt({ onSignUp }: AuthSignupPromptProps) { diff --git a/frontend/editor/src/proprietary/auth/ui/auth-theme.css b/frontend/editor/src/proprietary/auth/ui/auth-theme.css index 8580d59e0b..520df4e510 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth-theme.css +++ b/frontend/editor/src/proprietary/auth/ui/auth-theme.css @@ -2,7 +2,6 @@ :root { /* Auth page colors (light mode) */ - --auth-bg-color: var(--p-gray-100); --auth-card-bg: #ffffff; --auth-label-text: var(--p-gray-700); --auth-input-border: var(--p-gray-300); @@ -33,7 +32,6 @@ } [data-mantine-color-scheme="dark"] { - --auth-bg-color: var(--c-surface-sunken); --auth-card-bg: var(--c-surface); --auth-label-text: var(--c-text-muted); --auth-input-border: var(--c-border); diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx deleted file mode 100644 index 3192b69303..0000000000 --- a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; - -const meta = { - title: "Agents/StirlingLogoOutline", - component: StirlingLogoOutline, - parameters: { layout: "centered" }, - args: { - size: 20, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Large: Story = { - args: { - size: 64, - }, -}; diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx deleted file mode 100644 index c52b40df24..0000000000 --- a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/** - * A temp stirling logo, may change in future. - */ -export function StirlingLogoOutline({ size = 20 }: { size?: number }) { - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css index e95078bcdf..f301b822af 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -145,15 +145,11 @@ /* Same treatment for the input pill and quick-action cards. */ [data-mantine-color-scheme="dark"] .chat-panel-input { background: transparent; - box-shadow: - 0 0 0 1px var(--c-border-subtle), - 0 6px 16px rgba(0, 0, 0, 0.25); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); } [data-mantine-color-scheme="dark"] .chat-panel-input:focus-within { - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent), - 0 8px 22px color-mix(in srgb, var(--mantine-color-blue-6) 18%, transparent); + box-shadow: 0 8px 22px color-mix(in srgb, var(--c-btn-solid) 18%, transparent); } [data-mantine-color-scheme="dark"] .chat-quick-action { @@ -331,16 +327,12 @@ border-radius: 1.1rem; background: var(--mantine-color-body); flex-shrink: 0; - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.04), - 0 4px 14px rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); transition: box-shadow 160ms ease-out; } .chat-panel-input:focus-within { - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-6) 20%, transparent), - 0 6px 18px color-mix(in srgb, var(--mantine-color-blue-6) 12%, transparent); + box-shadow: 0 6px 18px color-mix(in srgb, var(--c-btn-solid) 12%, transparent); } /* Kill the Mantine Textarea's own border/outline — the wrapper owns the chrome. */ @@ -479,12 +471,12 @@ } .chat-bubble-user { - background: var(--mantine-color-blue-filled) !important; - color: white !important; + background: var(--c-btn-solid) !important; + color: var(--c-btn-inverse) !important; } .chat-bubble-user * { - color: white !important; + color: var(--c-btn-inverse) !important; } /* Assistant messages: no bubble, free-flowing with side padding */ @@ -517,12 +509,23 @@ padding: 0.2rem 0.5rem 0.35rem; } +[data-mantine-color-scheme="dark"] .chat-panel__header .sui-panelhdr__icon, +.chat-panel__header .sui-panelhdr__icon { + background: transparent; + border: none; +} + +.chat-panel__header .chat-panel__header-mark { + width: auto; + height: 26px; +} + .chat-progress-live__logo { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; - color: var(--mantine-color-blue-filled); + color: var(--c-brand-mark); } /* Shimmer: a soft highlight sweeps left-to-right across the muted label. */ diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx index d0ff7e19e3..2ff5ccfbe9 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx @@ -41,7 +41,8 @@ import { import { formatRelativeTime } from "@app/utils/timeUtils"; import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated"; -import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; +import { BrandMark } from "@app/components/shared/BrandMark"; +import { Logo } from "@app/ui/Logo"; import { PanelHeader } from "@app/ui/PanelHeader"; import { ChatQuickActions } from "@app/components/chat/ChatQuickActions"; import "@app/components/chat/ChatPanel.css"; @@ -474,8 +475,14 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) { return ( } - title={t("agents.stirling_name", "Stirling")} + icon={} + title={ + + } loading={isLoading} className="chat-panel__header" barClassName="chat-panel__agent-pill-vt" diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx index 8e500a0c1a..2e02db3068 100644 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx @@ -1,28 +1,29 @@ import { useNavigate } from "react-router-dom"; -import { useMantineColorScheme } from "@mantine/core"; import { useAuth } from "@app/auth/context"; -import { AppSwitch } from "@app/components/shared/AppSwitch"; +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"; -/** - * Sidebar app switcher between the editor and the admin portal. Both are - * route-sets of one SPA (the portal mounts at PORTAL_BASENAME), so switching - * is a client-side navigation. Hidden for users without portal access — they - * have nowhere to switch to. Renders the same AppSwitch element as the - * portal's sidebar. - */ -export function AppSwitcher() { +export function AppSwitcher({ collapsed }: AppSwitcherProps) { const { portalAccess } = useAuth(); const navigate = useNavigate(); - const { colorScheme } = useMantineColorScheme(); - if (!portalAccess) return null; + if (!portalAccess) { + return ( + + ); + } return ( - navigate(PORTAL_BASENAME)} + collapsed={collapsed} /> ); } diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index 3e6764bf60..1686f8c795 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -16,10 +16,6 @@ import AuthLayout from "@app/routes/authShared/AuthLayout"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; import { BASE_PATH, withBasePath } from "@app/constants/app"; import { updateSupportedLanguages } from "@app/i18n"; -import { - DEBUG_SHOW_ALL_PROVIDERS, - oauthProviderConfig, -} from "@app/auth/ui/OAuthButtons"; import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; import AuthSignupPrompt from "@app/auth/ui/AuthSignupPrompt"; import AuthDefaultCredentials from "@app/auth/ui/AuthDefaultCredentials"; @@ -48,10 +44,9 @@ export default function Login() { const { refetch } = useAppConfig(); const { t } = useTranslation(); const [successMessage, setSuccessMessage] = useState(null); - const [showEmailForm, setShowEmailForm] = useState(false); + const [showEmailForm, setShowEmailForm] = useState(true); const [_enableLogin, setEnableLogin] = useState(null); const [ssoAutoLogin, setSsoAutoLogin] = useState(false); - const [hasSSOProviders, setHasSSOProviders] = useState(false); const backendProbe = useBackendProbe(); const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false); const [showDefaultCredentials, setShowDefaultCredentials] = useState(false); @@ -235,26 +230,13 @@ export default function Login() { } }, [backendProbe.status, refetch]); - // Update hasSSOProviders and showEmailForm when providers or loginMethod change + // The email/password form is always shown when username/password auth is + // allowed; SSO-only mode hides it. useEffect(() => { - // In debug mode, check if any providers exist in the config - const hasProviders = DEBUG_SHOW_ALL_PROVIDERS - ? Object.keys(oauthProviderConfig).length > 0 - : login.providers.length > 0; - setHasSSOProviders(hasProviders); - - // Check if username/password authentication is allowed const userPassAllowed = login.loginMethod === "all" || login.loginMethod === "normal"; - - // Show email form if no SSO providers exist AND username/password is allowed - if (!hasProviders && userPassAllowed) { - setShowEmailForm(true); - } else if (!userPassAllowed) { - // Hide email form if username/password auth is not allowed - setShowEmailForm(false); - } - }, [login.providers, login.loginMethod]); + setShowEmailForm(userPassAllowed); + }, [login.loginMethod]); // Auto-login to SSO when enabled and only one SSO option exists useEffect(() => { @@ -502,22 +484,6 @@ export default function Login() {
) : undefined } - beforeEmailForm={ - hasSSOProviders && !showEmailForm && isUserPassAllowed ? ( -
- -
- ) : undefined - } footer={ <> {isFirstTimeSetup && diff --git a/frontend/editor/src/proprietary/routes/Signup.tsx b/frontend/editor/src/proprietary/routes/Signup.tsx index 68be029969..69e2ef9929 100644 --- a/frontend/editor/src/proprietary/routes/Signup.tsx +++ b/frontend/editor/src/proprietary/routes/Signup.tsx @@ -135,6 +135,7 @@ export default function Signup() { variant="tertiary" onClick={() => navigate("/login")} className="auth-link-black" + style={{ color: "var(--c-primary)" }} > {t("login.logIn", "Log In")} diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 1da92bd190..32af9a6782 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -25,7 +25,7 @@ import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; // Import global styles import "@app/styles/tailwind.css"; -import "@app/styles/saas-theme.css"; +import "@app/auth/ui/auth-theme.css"; import "@app/styles/cookieconsent.css"; import "@app/styles/index.css"; diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css index 344743473a..888401b47d 100644 --- a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css +++ b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css @@ -1,9 +1,12 @@ +/* Sits as a direct child of the sidebar, so the rail's own padding + gap + handle the spacing; a margin here would inset it narrower than the + sibling nav-surface boxes. Matches those boxes' surface treatment — the + brand mark in the header is what draws the eye, so no louder border. */ .card { - margin: 0.5rem; + margin: 0; padding: 0.625rem 0.75rem 0.6875rem; - background: var(--mantine-color-body); - border: 1px solid - color-mix(in srgb, var(--mantine-color-default-border) 45%, transparent); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: 0.625rem; box-shadow: none; } diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 3f7b4de5ab..0d534fa088 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -33,6 +33,8 @@ interface AppConfigModalProps { initialSection?: NavKey | null; /** Host-specific sections appended after the saas registry sections. */ extraSections?: ConfigNavSection[]; + /** Registry section keys to drop, for hosts a section can't run in. */ + hiddenSectionKeys?: NavKey[]; } const AppConfigModal: React.FC = ({ @@ -40,6 +42,7 @@ const AppConfigModal: React.FC = ({ onClose, initialSection, extraSections, + hiddenSectionKeys, }) => { const isMobile = useMediaQuery("(max-width: 1024px)"); @@ -153,8 +156,23 @@ const AppConfigModal: React.FC = ({ isAnonymous, t, }); - return extraSections?.length ? [...sections, ...extraSections] : sections; - }, [openLogoutConfirm, isDev, isAnonymous, t, extraSections]); + const base = hiddenSectionKeys?.length + ? sections + .map((sec) => ({ + ...sec, + items: sec.items.filter((i) => !hiddenSectionKeys.includes(i.key)), + })) + .filter((sec) => sec.items.length > 0) + : sections; + return extraSections?.length ? [...base, ...extraSections] : base; + }, [ + openLogoutConfirm, + isDev, + isAnonymous, + t, + extraSections, + hiddenSectionKeys, + ]); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx new file mode 100644 index 0000000000..364f094478 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx @@ -0,0 +1,41 @@ +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 ( + + ); + } + + return ( + navigate(PORTAL_BASENAME)} + collapsed={collapsed} + /> + ); +} diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx new file mode 100644 index 0000000000..a0e8ba618d --- /dev/null +++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +const get = vi.fn(); +let currentUserId: string | null = null; + +vi.mock("@app/services/apiClient", () => ({ + default: { + get: (...args: unknown[]) => get(...args), + }, +})); + +vi.mock("@app/auth/UseSession", () => ({ + useAuth: () => ({ user: currentUserId ? { id: currentUserId } : null }), +})); + +const { usePortalAccess } = await import("@app/hooks/usePortalAccess"); + +function meReturning(portalAccess: boolean) { + return { data: { user: { portalAccess } } }; +} + +describe("usePortalAccess", () => { + beforeEach(() => { + get.mockReset(); + currentUserId = null; + }); + + it("reports the backend's answer for the signed-in user", async () => { + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + + const { result } = renderHook(() => usePortalAccess()); + + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("re-asks the backend when a different user signs in without a reload", async () => { + // An admin gets a yes... + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + const { result, rerender } = renderHook(() => usePortalAccess()); + await waitFor(() => expect(result.current).toBe(true)); + + // ...then Supabase swaps the identity in place (signed out in another + // tab, revoked session, new sign-in) — no page load in between. + currentUserId = "member-2"; + get.mockResolvedValue(meReturning(false)); + rerender(); + + // The member must not inherit the admin's answer. + await waitFor(() => expect(result.current).toBe(false)); + expect(get).toHaveBeenCalledTimes(2); + }); + + it("drops the answer when the user signs out", async () => { + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + const { result, rerender } = renderHook(() => usePortalAccess()); + await waitFor(() => expect(result.current).toBe(true)); + + currentUserId = null; + rerender(); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("reports no access, and asks nothing, for a guest", () => { + currentUserId = null; + + const { result } = renderHook(() => usePortalAccess()); + + expect(result.current).toBe(false); + expect(get).not.toHaveBeenCalled(); + }); + + it("treats a failed lookup as no access, and a later mount asks again", async () => { + currentUserId = "admin-1"; + get.mockRejectedValueOnce(new Error("401")); + const first = renderHook(() => usePortalAccess()); + await waitFor(() => expect(get).toHaveBeenCalledTimes(1)); + expect(first.result.current).toBe(false); + first.unmount(); + + // The failure isn't sticky. + get.mockResolvedValue(meReturning(true)); + const second = renderHook(() => usePortalAccess()); + await waitFor(() => expect(second.result.current).toBe(true)); + }); + + it("ignores a response that lands after unmount", async () => { + currentUserId = "admin-1"; + let resolveMe: (v: unknown) => void = () => {}; + get.mockReturnValue( + new Promise((resolve) => { + resolveMe = resolve; + }), + ); + + const { result, unmount } = renderHook(() => usePortalAccess()); + unmount(); + resolveMe(meReturning(true)); + + // No state update on an unmounted hook (React would warn); the stale + // answer is simply dropped. + expect(result.current).toBe(false); + }); +}); diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts new file mode 100644 index 0000000000..442061cbe1 --- /dev/null +++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import apiClient from "@app/services/apiClient"; +import { useAuth } from "@app/auth/UseSession"; + +/** + * 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. + * + * 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 + * 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. + * + * 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. + */ +export function usePortalAccess(): boolean { + const { user } = useAuth(); + const userId = user?.id ?? null; + const [access, setAccess] = useState(false); + + useEffect(() => { + // Signed out: nothing to ask, and any previous answer is void. + if (userId === null) { + setAccess(false); + return; + } + + 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; +} diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index ab75b7bb79..03f654f4d0 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -30,7 +30,6 @@ export default function Login() { const [isSigningIn, setIsSigningIn] = useState(false); const [error, setError] = useState(null); const [showMagicLinkForm, setShowMagicLinkForm] = useState(false); - const [showEmailForm, setShowEmailForm] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [magicLinkEmail, setMagicLinkEmail] = useState(""); @@ -43,7 +42,6 @@ export default function Login() { const emailFromQuery = url.searchParams.get("email"); if (emailFromQuery) { setEmail(emailFromQuery); - setShowEmailForm(true); } } catch (_) { // ignore @@ -256,15 +254,8 @@ export default function Login() { } }; - const toggleEmailForm = () => { - setShowEmailForm((v) => !v); - setShowMagicLinkForm(false); - setMagicLinkSent(false); - }; - const toggleMagicLink = () => { setShowMagicLinkForm((v) => !v); - setShowEmailForm(false); setMagicLinkSent(false); }; @@ -374,71 +365,30 @@ export default function Login() {
- {/* Email & Password button */} - - - {/* Email form — animated expand */} -
-
-
- - -
-
-
- - {/* Skip */} -
+ {/* Email + password form — always visible (no expander toggle) */} +
+
- {/* Bottom */} + {/* Create an account — pushed to the bottom */}
{t("login.createAccount", "Create an account")}
+ + {/* Skip — small + muted, at the very bottom */} +
+ +
); } diff --git a/frontend/editor/src/saas/routes/Signup.tsx b/frontend/editor/src/saas/routes/Signup.tsx index 942cfefb5a..e9529944d6 100644 --- a/frontend/editor/src/saas/routes/Signup.tsx +++ b/frontend/editor/src/saas/routes/Signup.tsx @@ -29,7 +29,6 @@ export default function Signup() { const { t } = useTranslation(); const [isSigningUp, setIsSigningUp] = useState(false); const [error, setError] = useState(null); - const [showEmailForm, setShowEmailForm] = useState(false); const [name, setName] = useState(undefined as string | undefined); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -209,67 +208,26 @@ export default function Signup() { />
- {/* Email & Password button */} - - - {/* Email form — animated expand */} -
-
-
- -
-
+ {/* Sign-up form — always visible (no expander toggle) */} +
+
- {/* Skip */} -
- -
- - {/* Bottom */} + {/* Already have an account — pushed to the bottom */}
{t("signup.alreadyHaveAccount", "I already have an account")}
+ + {/* Skip — small + muted, at the very bottom */} +
+ +
); } diff --git a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx index a7435eb43b..27963cff83 100644 --- a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx +++ b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx @@ -82,7 +82,7 @@ export default function EmailPasswordForm({ + {view?.host && ( + {view.host} + )} + {adoption && ( + <> + + + {t("portal.home.editor.activeOfDeployed", adoption)} + + )}
-
- {view.host} - {view.meta.map((item, i) => ( - - · - {item} - - ))} -
+ {view && view.meta.length > 0 && ( +
+ {view.meta.map((item, i) => ( + + {i > 0 && ( + · + )} + {item} + + ))} +
+ )} )} + {/* Open in browser is a left-seated secondary in every state, and carries no arrow + (marketing note D244); the deploy ask beside it is the only button that can go loud. + Reaching the editor never depends on deployment data — it falls back to the configured + editor URL — so this stays live even when the deployment endpoint is unavailable. */}
- {!hideChips && ( - - )} - +
diff --git a/frontend/editor/src/portal/components/HomeHero.stories.tsx b/frontend/editor/src/portal/components/HomeHero.stories.tsx index dd1ea07c04..8784bde97a 100644 --- a/frontend/editor/src/portal/components/HomeHero.stories.tsx +++ b/frontend/editor/src/portal/components/HomeHero.stories.tsx @@ -16,17 +16,10 @@ const meta = { export default meta; type Story = StoryObj; -/** Pay-as-you-go tier: welcome header + setup checklist until onboarding completes. */ -export const Default: Story = { - args: { tier: "pro" }, -}; - -/** Free tier renders the same welcome-header composition as pro. */ -export const FreeTier: Story = { - args: { tier: "free" }, -}; - -/** Enterprise tier hides the status chips — the procurement deal hero owns the invite step. */ -export const EnterpriseTier: Story = { - args: { tier: "enterprise" }, -}; +/** + * The hero is the Editor deployment rail on every tier and in both editions — it reports its own + * deployment state and deploy ask, so there is nothing tier-specific left to compose. A live + * procurement deal attaches the deal-status hero as the rail's footer; that comes from + * useProcurement, so it follows the mocked backend rather than a story arg. + */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 03bcc17ed0..e81d42dfef 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -1,60 +1,50 @@ -import type { Tier } from "@portal/contexts/TierContext"; +import { useEffect } from "react"; +import { Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; -import { WelcomeBanner } from "@portal/components/WelcomeBanner"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import { useOnboardingProgress } from "@portal/hooks/useOnboardingProgress"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; /** - * The Home hero, composed with a procurement-aware, progress-aware footer: - * - * - no live deployment → welcome header (+ setup steps until complete) - * - deployment live → deployed-Editor status header (+ steps until complete) - * - onboarding complete → header only; the setup steps collapse away - * - enterprise → status header with chips hidden (the deal hero owns invite) - * - * The footer is the deal-status hero while a procurement deal is underway - * (procurement is a bolt-on to any tier); otherwise the setup checklist, until - * every step is done — then it collapses to just the header, matching the - * deployed-status card. The procurement takeover modals render alongside. + * The Home hero: always the Editor deployment rail, carrying the deal-status hero as its footer + * while a procurement deal is underway (procurement is a bolt-on to any tier). The rail states its + * own deployment status and deploy ask, so there is nothing for a tier to choose between. The + * procurement takeover modals render alongside. */ -export function HomeHero({ tier }: { tier: Tier }) { - const { openLinkModal } = useUI(); +export function HomeHero() { const procurement = useProcurement(); - const progress = useOnboardingProgress(); + const { trialSetupRequested, clearTrialSetupRequest } = useUI(); const dealActive = procurement.isLinked && procurement.started && !!procurement.data; - // Start the enterprise flow right here on Home: open the trial-setup modal when the account is - // linked, otherwise prompt to link first — no navigating off to the procurement view. - const onStartEnterprise = () => { - if (procurement.isLinked) procurement.onStartTrial(); - else openLinkModal(); - }; - - // Steps collapse once onboarding is complete; a live deal always keeps its - // hero. Otherwise the setup checklist carries the (progress-aware) steps. - const footer = dealActive ? ( - - ) : progress.allComplete ? undefined : ( - - ); - - // The live-status header (EditorStatusCard) needs a real deployment to show; - // without one it renders nothing, so route to it only when actually deployed. - // Everything else — including a step completed via the local download flag — - // keeps the always-present welcome header, so the card never vanishes. - const showStatus = progress.deployed; + // Someone said yes to enterprise elsewhere (the billing upsell, a sales link). Open trial setup + // once the snapshot has landed, so a buyer who already has a deal is not asked to start another. + useEffect(() => { + if (!trialSetupRequested || procurement.loading) return; + clearTrialSetupRequest(); + if (!procurement.started) procurement.onExploreEnterprise(); + }, [trialSetupRequested, procurement, clearTrialSetupRequest]); return ( <> - {showStatus ? ( - + {procurement.loading ? ( + // Hold the rail's shape rather than committing to a footer: branching before the snapshot + // lands paints the no-deal rail first, flashing on every refresh of an active deal. +
+
+ + +
+
) : ( - + + ) : undefined + } + /> )} diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css deleted file mode 100644 index 0fcb71e678..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.css +++ /dev/null @@ -1,122 +0,0 @@ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Getting-started steps (home-hero body) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-setup { - display: flex; - flex-direction: column; -} - -.portal-setup__list { - list-style: none; - margin: 0; - padding: 0; -} - -.portal-setup__row { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 0.875rem; - width: 100%; - padding: 0.6875rem 1.25rem; - border: none; - border-top: 1px solid var(--c-border-subtle); - background: transparent; - text-align: left; - cursor: pointer; - transition: background var(--motion-fast); -} -.portal-setup__item:first-child .portal-setup__row { - border-top: none; -} -.portal-setup__row:hover { - background: var(--c-hover); -} - -/* Numbered step marker */ -.portal-setup__num { - display: grid; - place-items: center; - width: 1.5rem; - height: 1.5rem; - flex-shrink: 0; - border-radius: 50%; - border: 1px solid var(--c-border); - font-size: 0.75rem; - font-weight: 600; - color: var(--c-text-subtle); -} -/* Completed step: filled green check. */ -.portal-setup__num.is-done { - border-color: var(--color-green); - background: var(--color-green); - color: #fff; -} -.portal-setup__row.is-done .portal-setup__text strong { - color: var(--c-text-muted); -} - -.portal-setup__text { - display: flex; - flex-direction: column; - min-width: 0; -} -.portal-setup__text strong { - font-size: 0.875rem; - font-weight: 600; - color: var(--c-text); -} -.portal-setup__text span { - font-size: 0.75rem; - line-height: 1.4; - color: var(--c-text-subtle); -} - -/* ── Enterprise upsell rung ── */ -.portal-setup__enterprise { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 0.75rem 1.25rem; - border-top: 1px solid var(--c-border-subtle); - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--c-primary) 5%, transparent) 0%, - transparent 55% - ); -} - -.portal-setup__enterprise-copy { - display: flex; - align-items: center; - gap: 0.75rem; - min-width: 0; - flex: 1; -} - -.portal-setup__enterprise-tag { - flex-shrink: 0; - padding: 0.1875rem 0.5625rem; - border-radius: var(--radius-md); - font-size: 0.59375rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--c-primary-hover); - background: var(--c-primary-tint); -} - -.portal-setup__enterprise-text { - margin: 0; - font-size: 0.8125rem; - line-height: 1.45; - color: var(--c-text-subtle); - min-width: 0; -} -.portal-setup__enterprise-text strong { - color: var(--c-text); - font-weight: 700; -} diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx deleted file mode 100644 index 432be155d7..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; - -const base: OnboardingProgress = { - loading: false, - deployed: false, - editorDone: false, - policiesDone: false, - inviteDone: false, - policiesActive: 0, - policiesRecommended: 6, - allComplete: false, -}; - -const meta: Meta = { - title: "Portal/Home/SetupChecklist", - component: SetupChecklist, - parameters: { layout: "padded" }, - args: { progress: base }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** A fresh workspace — no step complete yet. */ -export const NotStarted: Story = {}; - -/** Policies confirmed; editor + invite still open. */ -export const InProgress: Story = { - args: { - progress: { - ...base, - policiesDone: true, - policiesActive: 2, - policiesRecommended: 5, - }, - }, -}; - -/** Editor deployed + policies on; only the invite step remains. */ -export const AlmostDone: Story = { - args: { - progress: { - ...base, - editorDone: true, - policiesDone: true, - policiesActive: 4, - policiesRecommended: 3, - }, - }, -}; diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx deleted file mode 100644 index d854beb771..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; -import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; -import CheckRounded from "@mui/icons-material/CheckRounded"; -import "@portal/components/SetupChecklist.css"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Enterprise upsell rung */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** - * Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided - * trial, subscribed (paying) orgs jump straight to a quote — both open the - * procurement flow. When {@code onStart} is given the CTA opens the flow's setup - * modal over Home; otherwise it falls back to navigating to the procurement view. - */ -function EnterpriseRung({ - paying, - onStart, -}: { - paying: boolean; - onStart?: () => void; -}) { - const { t } = useTranslation(); - const { setActiveView } = useView(); - return ( -
-
- - {t("portal.home.onboarding.enterprise.tag")} - -

- {t("portal.home.onboarding.enterprise.lead")}{" "} - {t("portal.home.onboarding.enterprise.body")} -

-
- -
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Getting-started steps */ -/* ──────────────────────────────────────────────────────────────────────── */ - -interface Step { - id: string; - title: string; - blurb: string; - done: boolean; - onClick: () => void; -} - -/** - * Numbered getting-started steps, rendered as the body of the home hero. Each - * row opens its in-app surface; a completed step (from {@link OnboardingProgress}) - * swaps its number for a check. When every step is done the parent collapses the - * hero to the deployed-status header and stops rendering this list entirely. - */ -export function SetupChecklist({ - progress, - onStartEnterprise, -}: { - progress: OnboardingProgress; - /** Start the enterprise flow in place (opens the setup modal over Home). Falls back to - * navigating to the procurement view when omitted (e.g. in isolated stories). */ - onStartEnterprise?: () => void; -}) { - const { t } = useTranslation(); - const { tier } = useTier(); - const { setActiveView } = useView(); - const [downloadOpen, setDownloadOpen] = useState(false); - - const steps: Step[] = [ - { - id: "editor", - title: t("portal.home.onboarding.steps.editor.title"), - blurb: t("portal.home.onboarding.steps.editor.blurb"), - done: progress.editorDone, - // Downloads are per-OS, so open the install picker rather than route away. - onClick: () => setDownloadOpen(true), - }, - { - id: "policies", - title: t("portal.home.onboarding.steps.policies.title"), - blurb: t("portal.home.onboarding.steps.policies.blurb", { - active: progress.policiesActive, - recommended: progress.policiesRecommended, - }), - done: progress.policiesDone, - onClick: () => setActiveView("policies"), - }, - { - id: "invite", - title: t("portal.home.onboarding.steps.invite.title"), - blurb: t("portal.home.onboarding.steps.invite.blurb"), - done: progress.inviteDone, - onClick: () => setActiveView("users"), - }, - ]; - - return ( -
-
    - {steps.map((s, i) => ( -
  1. - -
  2. - ))} -
- - - - setDownloadOpen(false)} - /> -
- ); -} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css deleted file mode 100644 index 157ee24ac0..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ /dev/null @@ -1,103 +0,0 @@ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero — compact product header + steps */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-welcome { - border-radius: var(--radius-xl); - border: 1px solid var(--c-border-subtle); - overflow: hidden; - isolation: isolate; - background: var(--c-surface); -} - -/* ── Dark product header strip ── */ -.portal-welcome__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 0.875rem 1.25rem; - background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark)); -} - -.portal-welcome__brand { - display: flex; - align-items: center; - gap: 0.875rem; - min-width: 0; -} - -.portal-welcome__mark { - display: grid; - place-items: center; - flex-shrink: 0; -} -.portal-welcome__mark img { - display: block; - height: 1.75rem; - width: auto; -} - -.portal-welcome__brand-text { - display: flex; - align-items: baseline; - gap: 0.625rem; - min-width: 0; - flex-wrap: wrap; -} - -.portal-welcome__product { - font-size: 1.125rem; - font-weight: 700; - letter-spacing: -0.01em; - color: #fff; -} - -.portal-welcome__stats { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.8125rem; - color: rgba(255, 255, 255, 0.55); -} - -/* Header action group: icon buttons + the CTA. */ -.portal-welcome__actions { - display: flex; - align-items: center; - gap: 0.5rem; -} -.portal-welcome__icon-btn { - display: grid; - place-items: center; - width: 2.25rem; - height: 2.25rem; - flex-shrink: 0; - border-radius: var(--radius-md); - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.08); - color: #fff; - cursor: pointer; - transition: background var(--motion-fast); -} -.portal-welcome__icon-btn:hover { - background: rgba(255, 255, 255, 0.16); -} - -/* White CTA on the dark header, matching the marketing card. */ -.portal-welcome__header .portal-welcome__cta.sui-btn { - background: #ffffff; - border-color: #ffffff; - color: var(--c-hero-dark-cta-text); -} -.portal-welcome__header .portal-welcome__cta.sui-btn:hover { - background: rgba(255, 255, 255, 0.88); - border-color: rgba(255, 255, 255, 0.88); -} - -/* ── Steps + enterprise (setup checklist) sit directly under the header ── */ -.portal-welcome__footer { - background: var(--c-surface); -} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx deleted file mode 100644 index 8dba4caf36..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { WelcomeBanner } from "@portal/components/WelcomeBanner"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; - -const progress: OnboardingProgress = { - loading: false, - deployed: false, - editorDone: false, - policiesDone: true, - inviteDone: false, - policiesActive: 2, - policiesRecommended: 5, - allComplete: false, -}; - -const meta: Meta = { - title: "Portal/Home/WelcomeBanner", - component: WelcomeBanner, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** The hero on its own, no attached footer. */ -export const Default: Story = {}; - -/** The hero as it renders on the free-tier home: the "Finish setting up" - * checklist attached as the footer strip. */ -export const WithSetupChecklist: Story = { - args: { - footer: , - }, -}; diff --git a/frontend/editor/src/portal/components/WelcomeBanner.tsx b/frontend/editor/src/portal/components/WelcomeBanner.tsx deleted file mode 100644 index c1d6857bdb..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { ReactNode } from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; -import { EDITOR_URL } from "@portal/auth/editorUrl"; -import { - DownloadIcon, - ExternalLinkIcon, - UserPlusIcon, -} from "@portal/components/icons"; -import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; -import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; -import "@portal/components/WelcomeBanner.css"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero */ -/* */ -/* A compact product header — brand mark, "PDF Editor" + social-proof */ -/* stats, and a single "Open in browser" CTA — over the getting-started */ -/* steps (passed in as {@code footer}). Deliberately lean: the onboarding */ -/* steps, not marketing copy, are the point of the card. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -interface WelcomeBannerProps { - /** - * The getting-started steps + enterprise rung, rendered inside the card - * below the header. Kept as a slot so the hero stays a presentational shell. - */ - footer?: ReactNode; -} - -export function WelcomeBanner({ footer }: WelcomeBannerProps) { - const { t } = useTranslation(); - const { setActiveView } = useView(); - const [installOpen, setInstallOpen] = useState(false); - - return ( -
-
-
- - - -
- - {t("portal.welcome.productName")} - - - {t("portal.welcome.stats")} - -
-
-
- - - -
-
- - {footer &&
{footer}
} - - setInstallOpen(false)} - /> -
- ); -} diff --git a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx index b5d0d58a3b..7d322a7be6 100644 --- a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx @@ -665,7 +665,6 @@ export function BundleCheckoutModal({ {t("portal.billing.prepaid.buy.cancel", "Cancel")} - @@ -701,7 +700,6 @@ export function BundleCheckoutModal({ {t("portal.billing.prepaid.buy.back", "Back")} diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index d3a1baf036..e2bbb24a34 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -85,7 +85,6 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { const switchOnAction = isLeader ? ( } diff --git a/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx index c099836ee4..f07083f215 100644 --- a/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx +++ b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx @@ -1,15 +1,14 @@ import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -// The trademarked Stirling wordmark — the font is baked into the SVG (no brand webfont is loaded), so -// we render the same asset the portal nav uses rather than styled text. Theme-switched in CSS. -import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; -import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; /** - * Shared header for the prepay-flow modals — the prepaid wizard (activation → calculator → pay, of 3) - * and the metered checkout (spend limit → payment, of 2): Stirling brand + "Step N of M" badge + close, - * an M-segment progress bar, and the step title. Pass {@code step=undefined} to hide the badge + - * progress (e.g. a terminal confirmation). + * The prepay flows' stepped header — the prepaid wizard (activation → calculator → pay, of 3) and + * the metered checkout (spend limit → payment, of 2). + * + * Chrome and copy only: the layout is the shared {@link StepModalHeader}, so this flow reads the + * same as every other stepped modal. Keeps the billing root class, which the framed-checkout rule + * targets to own the header's padding. Pass {@code step=undefined} to hide the badge + progress + * (e.g. a terminal confirmation). */ export function PrepayModalHeader({ step, @@ -24,68 +23,27 @@ export function PrepayModalHeader({ onClose: () => void; }) { const { t } = useTranslation(); - const showSteps = step != null; - const filled = step ?? 0; return ( -
-
-
- Stirling - -
-
- {showSteps && ( - - {t( - "portal.billing.prepaid.buy.step", - "Step {{current}} of {{total}}", - { current: step, total }, - )} - - )} -
-
- {showSteps && ( -
- = 1 ? "is-filled" : ""} /> - = 2 ? "is-filled" : ""} /> - {total >= 3 && = 3 ? "is-filled" : ""} />} -
- )} -
{title}
-
+ ); } diff --git a/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx index 9ee2fa1f5e..5c7b510618 100644 --- a/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx +++ b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx @@ -191,7 +191,7 @@ export function SpendLimitCard({ > {t("portal.billing.spendLimit.cancel", "Cancel")} - diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx index 4787c40b37..ee76a780c2 100644 --- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -451,7 +451,6 @@ export function StripeCheckoutModal({ {t("portal.billing.checkout.cap.back", "Back")} - - - } - > -

{copy.body}

- {needsFile && ( -
- setFile(e.target.files?.[0] ?? null)} - /> - - - {file ? file.name : t("portal.procurement.modal.noFile")} - -
- )} - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx b/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx deleted file mode 100644 index a59386dc3a..0000000000 --- a/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DealJourney } from "@portal/components/procurement/DealJourney"; -import { buildProcurement } from "@portal/mocks/procurement"; -import type { Deal } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const data = buildProcurement("enterprise"); -const deal = data.deal as Deal; - -const meta: Meta = { - title: "Portal/Procurement/DealJourney", - component: DealJourney, - parameters: { layout: "padded" }, - args: { deal, journey: data.journey, onAdvance: () => {} }, -}; -export default meta; -type Story = StoryObj; - -// Mid-journey at the Agreement stage, the seeded deal state. -export const Default: Story = {}; - -// Evaluating: the trial strip shows runway + key; next step builds the quote. -export const AtTrial: Story = { - args: { deal: { ...deal, currentStage: "trial" } }, -}; - -// Terminal stage, provisioning, no further CTA. -export const Live: Story = { - args: { deal: { ...deal, currentStage: "active" } }, -}; diff --git a/frontend/editor/src/portal/components/procurement/DealJourney.tsx b/frontend/editor/src/portal/components/procurement/DealJourney.tsx deleted file mode 100644 index 3e8c86e58d..0000000000 --- a/frontend/editor/src/portal/components/procurement/DealJourney.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; -import type { Deal, DealStage, JourneyStep } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; - -/** - * The deal's commercial journey in one card: who's guiding it (the solutions - * engineer), where it sits (the stage stepper), trial runway while evaluating, - * and the single next action that advances the deal. Mirrors "one next action - * at a time"; the full per-stage checklist lives in the Documents card. - */ -export function DealJourney({ - deal, - journey, - onAdvance, - advancing = false, -}: { - deal: Deal; - journey: JourneyStep[]; - onAdvance: (stage: DealStage) => void; - advancing?: boolean; -}) { - const { t } = useTranslation(); - const { engineer, trial, currentStage } = deal; - const currentStep = journey.find((s) => s.stage === currentStage); - const isTerminal = - journey.length > 0 && journey[journey.length - 1].stage === currentStage; - - return ( - -
-
- - {t("portal.procurement.journey.eyebrow")} - -

- {t("portal.procurement.journey.title")} -

-

- {t("portal.procurement.journey.subtitle")} -

-
-
- - {t("portal.procurement.journey.engineerLabel")} - - {engineer.name} - - {engineer.email} - -
-
- -
- -
- - {currentStage === "trial" && ( -
- - {t("portal.procurement.journey.trialTitle")} - - - {t("portal.procurement.journey.daysLeft", { - count: trial.daysLeft, - })} - - {trial.key} -
- )} - -
-
- - - {isTerminal - ? t("portal.procurement.journey.live") - : t("portal.procurement.journey.nextStep", { - action: currentStep ? t(currentStep.gatingAction) : "", - })} - -
- {!isTerminal && currentStep && ( - - )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index 9421de28bb..77c008ce41 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -12,6 +12,10 @@ const base: ProcurementSnapshot = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + businessName: null, + contactName: null, + contactEmail: null, + agreementSignedVersion: null, latestQuote: null, }; @@ -23,11 +27,11 @@ const meta: Meta = { args: { canSchedule: true, onExpand: () => {}, + onAcceptQuote: () => {}, onLicense: () => {}, onInvite: () => {}, onSchedule: () => {}, onManageTrial: () => {}, - onNavigate: () => {}, }, }; export default meta; diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx index 092e259bb3..ef2172bb27 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx @@ -1,31 +1,62 @@ -import { useEffect } from "react"; +import { useEffect, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; -import type { ViewId } from "@portal/contexts/ViewContext"; import { FLOW_JOURNEY, + type DealStage, type ProcurementSnapshot, } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; +import { + CalendarIcon, + CheckIcon, + DocumentsIcon, + KeyIcon, + UserPlusIcon, +} from "@portal/components/icons"; import { warmCalendly } from "@portal/components/procurement/CalendlyInline"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; +/** What each stage asks of the buyer, read out in the stage sentence. */ +const STAGE_SENTENCE: Record = { + // Exploring sits on the Trial rung: same sentence, since the ask is what differs. + exploring: "portal.procurement.hero.sentenceTrial", + trial: "portal.procurement.hero.sentenceTrial", + quote: "portal.procurement.hero.sentenceQuote", + security: "portal.procurement.hero.sentenceAgreement", + procurement: "portal.procurement.hero.sentencePayment", + active: "portal.procurement.hero.sentenceLive", +}; + +/** The primary action for each stage; expanding the flow runs it. */ +const STAGE_CTA: Record = { + exploring: "portal.procurement.hero.ctaExploring", + trial: "portal.procurement.hero.ctaTrial", + quote: "portal.procurement.hero.ctaQuote", + security: "portal.procurement.hero.ctaAgreement", + // Same label whether it links straight to Stripe or, lacking an invoice URL, opens the stage dialog + // where the invoice actions live — the buyer is being sent to the invoice either way. + procurement: "portal.procurement.payment.viewInvoice", + active: "portal.procurement.hero.open", +}; + /** - * The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the - * deal stage: quick-action chips (trial countdown → manage, licence key, invite teammates, - * schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that - * expands the flow into the takeover modal. Matches the marketing prototype. + * The enterprise deal-status hero on Home (procurement lives here, not as a nav tab) — this card IS + * the procurement surface. It carries the journey as a segmented progress band plus a stage + * sentence, and one primary action with quiet icon buttons beside it; the flow itself opens in the + * takeover modal. Rollout setup lives in the non-procurement setup checklist, not here. */ export function DealStatusHero({ snapshot, busy = false, canSchedule, onExpand, + onAcceptQuote, onLicense, onInvite, onSchedule, onManageTrial, - onNavigate, + onDocuments, }: { snapshot: ProcurementSnapshot; busy?: boolean; @@ -33,11 +64,17 @@ export function DealStatusHero({ * "Schedule a call" action only appears when the org has linked its account. */ canSchedule: boolean; onExpand: () => void; + /** + * Accept the issued quote, which advances the deal to the agreement. Offered here rather than + * inside the quote review so the buyer can circulate the quote and come back to decide. + */ + onAcceptQuote: () => void; onLicense: () => void; onInvite: () => void; onSchedule: () => void; onManageTrial: () => void; - onNavigate: (view: ViewId) => void; + /** Open the Documents reference (agreement, quote, invoice, EULA, SLA, subprocessors). */ + onDocuments: () => void; }) { const { t } = useTranslation(); @@ -49,129 +86,181 @@ export function DealStatusHero({ const stage = snapshot.stage ?? "trial"; const inTrial = stage === "trial"; - const cta = - stage === "trial" - ? t("portal.procurement.hero.ctaTrial") - : stage === "quote" - ? t("portal.procurement.hero.ctaQuote") - : stage === "procurement" - ? t("portal.procurement.hero.ctaPayment") - : t("portal.procurement.hero.ctaLive"); + const isLive = stage === "active"; + // A live quote is sitting with the buyer. A draft (or an expired/cancelled one) is not something to + // accept — that stage still means "finish building it". + const quoteAwaitingDecision = + stage === "quote" && + (snapshot.latestQuote?.status === "sent" || + snapshot.latestQuote?.status === "open"); + // Paying happens on Stripe, so the card links straight there rather than opening a dialog whose only + // real action was the same link. Without an invoice URL there is nothing to link to, so the stage + // falls back to its dialog, where the signed agreement is still reachable. + const invoiceUrl = + stage === "procurement" ? snapshot.latestQuote?.invoiceUrl : null; + // Known from trial setup onward. The quote's own copy wins when present, since the buyer may have + // corrected it there; before either exists the eyebrow stands alone rather than inventing a name. + const company = + snapshot.latestQuote?.config.businessName?.trim() || + snapshot.businessName?.trim(); - const setupSteps: { title: string; sub: string; view: ViewId }[] = [ - { - title: t("portal.procurement.hero.setup1Title"), - sub: t("portal.procurement.hero.setup1Sub"), - view: "users", - }, - { - title: t("portal.procurement.hero.setup2Title"), - sub: t("portal.procurement.hero.setup2Sub"), - view: "sources", - }, - { - title: t("portal.procurement.hero.setup3Title"), - sub: t("portal.procurement.hero.setup3Sub"), - view: "policies", - }, - ]; + // Exploring is presented as the Trial rung — same position, sentence and next step — because the + // buyer has entered the journey; only the ask differs, since no trial has actually started. + const journeyStage = stage === "exploring" ? "trial" : stage; + const currentIdx = Math.max( + 0, + FLOW_JOURNEY.findIndex((s) => s.stage === journeyStage), + ); + const nextStage = FLOW_JOURNEY[currentIdx + 1]; return (
-
+
- {t("portal.procurement.hero.eyebrow")} + {company + ? t("portal.procurement.hero.eyebrowCompany", { company }) + : t("portal.procurement.hero.eyebrow")} - - {t("portal.procurement.hero.company")} - -
-
+ +
+ {FLOW_JOURNEY.map((s, i) => ( + + ))} +
+ +

+ {t(FLOW_JOURNEY[currentIdx].label)} + {` · ${t(STAGE_SENTENCE[stage])} `} + {nextStage && ( + + {t("portal.procurement.hero.next", { + stage: t(nextStage.label), + })} + + )} +

+ {inTrial && snapshot.trialEndsAt && ( - - )} - {snapshot.licenseKey && ( - - )} - {stage !== "active" && ( - - )} - {canSchedule && ( - +
+ +
)}
-
- -
- - {inTrial && ( -
    - {setupSteps.map((s) => ( -
  • - -
  • - ))} -
+ {isLive && ( +
+ + + + + + {t("portal.procurement.hero.liveTitle")} + + + {t("portal.procurement.hero.liveSub")} + + +
)} -
- - - {t("portal.procurement.hero.nextStep", { action: cta })} - -
- + + + ) : invoiceUrl ? ( + + ) : ( + + )} +
+ {snapshot.licenseKey && ( + + + + )} + + + + {!isLive && ( + + + + )} + {canSchedule && ( + + + + )}
); } +/** A quiet icon-only secondary action; its label carries in the tooltip and to screen readers. */ +function IconAction({ + label, + onClick, + children, +}: { + label: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + function daysLeft(iso: string): number { const end = new Date(iso).getTime(); return Math.max(0, Math.ceil((end - Date.now()) / 86_400_000)); diff --git a/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx b/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx deleted file mode 100644 index 695b8c6941..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DocRow } from "@portal/components/procurement/DocRow"; -import type { LedgerDoc } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const meta: Meta = { - title: "Portal/Procurement/DocRow", - component: DocRow, - parameters: { layout: "padded" }, - args: { onAction: () => {} }, -}; -export default meta; -type Story = StoryObj; - -const sign: LedgerDoc = { - id: "d1", - name: "Stirling Enterprise Agreement", - sub: "One signature: MSA + order form + EULA + DPA.", - status: "action", - action: "sign", -}; - -const download: LedgerDoc = { - id: "d2", - name: "SOC 2 Type II report", - sub: "Independent audit of our security controls.", - status: "available", - action: "download", -}; - -const paidAddon: LedgerDoc = { - id: "d3", - name: "Onboarding & training", - sub: "Guided rollout and live training for your team.", - status: "request", - action: "request", - optional: true, - fee: 7_500, -}; - -const done: LedgerDoc = { - id: "d4", - name: "Formal quote", - sub: "Committed-volume pricing, term and line items.", - status: "complete", - action: "download", -}; - -// Deal-advancing action, filled purple CTA. -export const SignAction: Story = { args: { doc: sign } }; - -// Quiet outline action for a ready download. -export const Download: Story = { args: { doc: download } }; - -// Optional paid add-on, chips flag it and the fee folds into the CTA. -export const PaidAddon: Story = { args: { doc: paidAddon } }; - -// Completed paperwork keeps a record but offers no further action. -export const Complete: Story = { args: { doc: done } }; - -// A row in a future, not-yet-reached stage, dimmed, marked "Upcoming", inert. -export const Locked: Story = { args: { doc: sign, locked: true } }; diff --git a/frontend/editor/src/portal/components/procurement/DocRow.tsx b/frontend/editor/src/portal/components/procurement/DocRow.tsx deleted file mode 100644 index b4329fbfa4..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Chip, StatusBadge } from "@app/ui"; -import type { LedgerDoc } from "@portal/api/procurement"; -import { - ACTION_LABEL_KEY, - STATUS_LABEL_KEY, - STATUS_TONE, - USD, -} from "@portal/components/procurement/format"; - -/** Maps a document's action to the button accent + variant. */ -function buttonStyle(doc: LedgerDoc): { - variant: "primary" | "secondary"; - accent: "premium" | "default"; -} { - // The agreement signature and online payment are the deal-advancing actions; - // give them the filled premium CTA. Everything else is a quieter outline. - if (doc.action === "sign" || doc.action === "pay") { - return { variant: "primary", accent: "premium" }; - } - return { variant: "secondary", accent: "default" }; -} - -/** - * A single document in the ledger or supporting pool: name + sub-line on the - * left, status badge and action button on the right. Optional/fee-bearing docs - * carry a chip so the buyer sees a paid add-on before clicking. `locked` is for - * rows in a future, not-yet-reached stage: dimmed, marked "Upcoming", inert. - */ -export function DocRow({ - doc, - onAction, - locked = false, -}: { - doc: LedgerDoc; - onAction: (doc: LedgerDoc) => void; - locked?: boolean; -}) { - const { t } = useTranslation(); - const { variant, accent } = buttonStyle(doc); - // Locked (future-stage), in-progress (pending) and completed paperwork all - // offer no action; only "available", "action" and "request" docs do. - const actionable = - !locked && doc.status !== "complete" && doc.status !== "pending"; - const label = t(ACTION_LABEL_KEY[doc.action]); - const actionLabel = - doc.fee !== undefined ? `${label} · ${USD.format(doc.fee)}` : label; - - return ( -
-
-
- {doc.name} - {doc.optional && ( - - {t("portal.procurement.docs.optional")} - - )} - {doc.fee !== undefined && ( - - {t("portal.procurement.docs.paidAddon")} - - )} -
-

{doc.sub}

-
-
- - {locked - ? t("portal.procurement.docs.upcoming") - : t(STATUS_LABEL_KEY[doc.status])} - - {actionable && ( - - )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx deleted file mode 100644 index 5de6692999..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DocumentLedger } from "@portal/components/procurement/DocumentLedger"; -import { buildProcurement } from "@portal/mocks/procurement"; -import "@portal/views/Procurement.css"; - -const data = buildProcurement("enterprise"); - -const meta: Meta = { - title: "Portal/Procurement/DocumentLedger", - component: DocumentLedger, - parameters: { layout: "padded" }, - args: { - groups: data.ledger, - supporting: data.supporting, - journey: data.journey, - currentStage: data.deal?.currentStage ?? "trial", - onAction: () => {}, - }, -}; -export default meta; -type Story = StoryObj; - -// Mid-journey: the Agreement stage is open, earlier stages read as done, later -// stages are locked previews, and the supporting pool sits collapsed below. -export const Default: Story = {}; - -// Day one: only the Trial stage has been reached; everything ahead is locked. -export const AtTrial: Story = { - args: { currentStage: "trial" }, -}; diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx deleted file mode 100644 index aa9e6ae8ce..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Card, Chip, Collapsible } from "@app/ui"; -import type { - DealStage, - JourneyStep, - LedgerDoc, - LedgerGroup, - SupportingGroup, -} from "@portal/api/procurement"; -import { DocRow } from "@portal/components/procurement/DocRow"; - -/** - * The "Documents" card: every artifact the deal needs, as a stage accordion - * that mirrors the journey. Only the current stage is open by default; earlier - * stages read as done, later stages are locked previews. A collapsed-by-default - * "Supporting your evaluation" pool holds the stage-agnostic paperwork. - */ -export function DocumentLedger({ - groups, - supporting, - journey, - currentStage, - onAction, -}: { - groups: LedgerGroup[]; - supporting: SupportingGroup[]; - journey: JourneyStep[]; - currentStage: DealStage; - onAction: (doc: LedgerDoc) => void; -}) { - const { t } = useTranslation(); - const order = journey.map((s) => s.stage); - const curIdx = order.indexOf(currentStage); - // Follow the deal: the stage you're in opens first; any other stage can be - // peeked. null collapses them all. Advancing moves the open section along. - const [openStage, setOpenStage] = useState(currentStage); - const [supportingOpen, setSupportingOpen] = useState(false); - useEffect(() => setOpenStage(currentStage), [currentStage]); - - return ( - -
-

- {t("portal.procurement.docs.title")} -

-

- {t("portal.procurement.docs.subtitle")} -

-
- -
- {groups.map((group) => { - const idx = order.indexOf(group.stage); - const done = idx < curIdx; - const cur = group.stage === currentStage; - const locked = idx > curIdx; - const blurb = journey.find((s) => s.stage === group.stage)?.blurb; - const open = openStage === group.stage; - const count = group.docs.length; - - return ( - setOpenStage(open ? null : group.stage)} - header={ - <> - - - {t(group.label)} - - {blurb && ( - - · {t(blurb)} - - )} - {cur && ( - - {t("portal.procurement.docs.here")} - - )} - {done && ( - - {t("portal.procurement.docs.done")} - - )} - - } - aside={ - - {t("portal.procurement.docs.count", { count })} - - } - > -
- {group.docs.map((doc) => ( - - ))} -
-
- ); - })} - - {supporting.length > 0 && ( - setSupportingOpen((o) => !o)} - header={ - - - {t("portal.procurement.docs.supportingTitle")} - - - {t("portal.procurement.docs.supportingSubtitle")} - - - } - aside={ - - {supportingOpen - ? t("portal.procurement.docs.hide") - : t("portal.procurement.docs.show")} - - } - > -
- {supporting.map((group) => ( -
-
{group.label}
-
- {group.docs.map((doc) => ( - - ))} -
-
- ))} -
-
- )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx b/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx deleted file mode 100644 index eae9db4d99..0000000000 --- a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LockedState } from "@portal/components/procurement/LockedState"; -import { JOURNEY } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const meta: Meta = { - title: "Portal/Procurement/LockedState", - component: LockedState, - parameters: { layout: "padded" }, - args: { onTalkToSales: () => {} }, -}; -export default meta; -type Story = StoryObj; - -// Shown to free/pro buyers, the journey preview behind the upgrade prompt. -export const Default: Story = { - args: { journey: JOURNEY }, -}; diff --git a/frontend/editor/src/portal/components/procurement/LockedState.tsx b/frontend/editor/src/portal/components/procurement/LockedState.tsx deleted file mode 100644 index 8e70ba18c4..0000000000 --- a/frontend/editor/src/portal/components/procurement/LockedState.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@app/ui"; -import type { JourneyStep } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; - -/** - * Enterprise-only gate for free/pro buyers. Shows the journey as a greyed - * preview behind an upgrade prompt so the buyer understands what the - * commercial track looks like before they talk to sales. - */ -export function LockedState({ - journey, - onTalkToSales, -}: { - journey: JourneyStep[]; - onTalkToSales: () => void; -}) { - const { t } = useTranslation(); - return ( -
- - {t("portal.procurement.locked.talkToSales")} - - } - /> - - - -
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx index a6ca8dfcf5..fd47d867c6 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx @@ -69,10 +69,9 @@ const meta: Meta = { args: { quote, busy: false, - downloading: false, onAgree: () => {}, - onDownload: () => {}, - onEdit: () => {}, + onRequestChanges: () => {}, + onClose: () => {}, }, }; export default meta; @@ -81,12 +80,7 @@ type Story = StoryObj; export const Default: Story = {}; -// Agreeing: the primary CTA shows its loading state while the accept call is in flight. -export const Agreeing: Story = { +// Signing: the primary CTA shows its loading state while the accept call is in flight. +export const Signing: Story = { args: { busy: true }, }; - -// Downloading: the secondary action shows its loading state instead. -export const Downloading: Story = { - args: { downloading: true }, -}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 336b6af5cb..318f23aca5 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -1,152 +1,267 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; -import type { QuoteResult } from "@portal/api/procurement"; -import { money } from "@portal/components/procurement/format"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Button } from "@app/ui"; +import { + fetchAgreementDocument, + fetchAgreementPdf, + recordAgreementSignature, + type QuoteResult, +} from "@portal/api/procurement"; +import { DownloadIcon } from "@portal/components/icons"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; +import { useAsync } from "@portal/hooks/useAsync"; import "@portal/views/Procurement.css"; /** - * The agreement (security) step: a single combined Stirling Enterprise Agreement — Master Service - * Agreement + Order Form (from the issued quote) + EULA + Data Processing Agreement — that the buyer - * reviews and agrees to before it's accepted into a subscription. No e-signature for now: an explicit - * "I agree" click stands in (the terms reference the accepted quote). Document body is static legal - * copy; the surrounding UI is translated. + * The agreement (security) step: the buyer reviews the full Stirling Enterprise Agreement — Master + * Services Agreement + Order Form (from the quote) + Data Processing Addendum, one signature — then + * signs it. The document body is served by the backend from the versioned legal registry (static + * legal copy, English only); this component renders it, gates signing behind a scroll-through, and + * captures the typed legal name, signatory, title, and authority. On sign it records the signature + * (pinned to the exact document version + a hash) and then accepts the quote into a subscription. + * + * Presented as the document itself rather than a card about it: this step names the agreement in the + * dialog's own header and carries its download there, so the terms are read on paper-like stock + * instead of in app chrome. That is why it draws its own header — see ProcurementFlow. */ export function ProcurementAgreement({ quote, busy, - downloading, onAgree, - onDownload, - onEdit, + onRequestChanges, + onClose, }: { quote: QuoteResult; busy: boolean; - downloading: boolean; - /** Accept the quote straight into a committed subscription (this is also the agreement). */ + /** Accept the quote straight into a committed subscription (runs after the signature is saved). */ onAgree: () => void; - onDownload: () => void; - onEdit: () => void; + /** Hand the buyer to their SE to negotiate terms: closes this and opens scheduling. */ + onRequestChanges: () => void; + /** This step draws the dialog's header, so it carries the close too. */ + onClose?: () => void; }) { const { t } = useTranslation(); - const [checked, setChecked] = useState(false); - const annual = money(quote.annualNetMinor, quote.currency); - const tcv = money(quote.tcvMinor, quote.currency); - const renewal = money(quote.renewalAnnualNetMinor, quote.currency); - const years = quote.config.termYears; + const { data: doc, loading } = useAsync(fetchAgreementDocument, []); + + const [legalName, setLegalName] = useState(quote.config.businessName ?? ""); + const [signatory, setSignatory] = useState(quote.config.contactName ?? ""); + const [title, setTitle] = useState(""); + const [confirmed, setConfirmed] = useState(false); + const [scrolledToEnd, setScrolledToEnd] = useState(false); + const [signing, setSigning] = useState(false); + const [downloadingMsa, setDownloadingMsa] = useState(false); + const [error, setError] = useState(false); + const [downloadError, setDownloadError] = useState(false); + const docRef = useRef(null); + + const downloadMsa = async () => { + setDownloadingMsa(true); + setDownloadError(false); + try { + const blob = await fetchAgreementPdf(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "stirling-enterprise-agreement.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch { + // Surface the failure — the PDF is rendered server-side, so a failure here means the + // render service is unavailable rather than something the buyer can retry around. + setDownloadError(true); + } finally { + setDownloadingMsa(false); + } + }; + + const onScroll = () => { + const el = docRef.current; + if (!el) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24) { + setScrolledToEnd(true); + } + }; + + const ready = + scrolledToEnd && + confirmed && + legalName.trim().length > 0 && + signatory.trim().length > 0; + + const sign = async () => { + setError(false); + setSigning(true); + try { + await recordAgreementSignature({ + customerLegalName: legalName.trim(), + signatoryName: signatory.trim(), + signatoryTitle: title.trim(), + authorityConfirmed: confirmed, + }); + onAgree(); // proceed into the committed subscription + } catch { + setError(true); + } finally { + // In `finally`, not only on failure: accepting can fail after the signature is recorded, and + // the controller deliberately keeps this dialog open on failure so the error is readable. With + // the flag left set, the button span the rest of the session and there was no way to retry. + setSigning(false); + } + }; return ( - - - {t("portal.procurement.agreement.eyebrow")} - -

- {t("portal.procurement.agreement.title")} -

-

- {t("portal.procurement.agreement.intro")} -

+
+ + {/* On the document, like the quote's: it downloads what is on screen. */} + + {/* Redlines are a conversation, not a form: this hands the buyer to their SE rather than + pretending the terms can be amended in the app. */} + +
+ } + /> -
-

1. Master Service Agreement

-

- This Stirling Enterprise Agreement ("Agreement") is entered into - between Stirling PDF Inc. ("Stirling") and the customer identified on - the Order Form ("Customer"). It governs Customer's access to and use - of the Stirling enterprise platform and related services (the - "Service"). Stirling will provide the Service with commercially - reasonable skill and care and in accordance with the service levels - set out in the Order Form. -

+ {/* The tray scrolls, not the paper. The paper is its natural height inside it, so mid-document it + runs flush to the footer with no grey beneath, and the tray's bottom padding only comes into + view once the buyer reaches the end — the page ending is what shows they got there. */} +
+
+ {loading &&

{t("portal.procurement.agreement.loading")}

} + {!loading && !doc && ( +

{t("portal.procurement.agreement.loadError")}

+ )} + {doc && ( + <> + {/* Letterhead: the reference ties the terms to the quote they price, and the version + label pins what was signed. The document's own heading follows, so this adds a + masthead rather than repeating the title. */} +
+ + {t("portal.procurement.agreement.confidential")} + + + {t("portal.procurement.agreement.ref", { + ref: quote.quoteNumber, + version: doc.versionLabel, + })} + +
+
+ {doc.markdown} +
+ + )} +
+
-

2. Order Form

-

- Quote {quote.quoteNumber} forms the Order Form for - this Agreement. Customer commits to a {years}-year term at{" "} - {annual} per year (total contract value{" "} - {tcv}), billed annually in advance by invoice. Fees - are exclusive of taxes. The committed volume, service level, and - add-ons are itemised below: + {error && ( +

+ {t("portal.procurement.agreement.signError")}

-
    - {quote.lineItems.map((li) => ( -
  • - {li.label} - - {li.kind === "INCLUDED" - ? t("portal.procurement.builder.included") - : money(li.amountMinor, quote.currency)} + )} + {downloadError && ( +

    + {t("portal.procurement.agreement.downloadDraftError")} +

    + )} + + {/* The signature block: who is bound, who signs, and the act of signing, on one line — the + shape of a paper signature block rather than a form above a button. The consent sits + directly under the fields it qualifies, with no rule between them: it is part of signing, + not a separate section, and boxing it cost the document a quarter of its height. */} +
    +
    +
    +
  • - ))} -
+ setLegalName(e.target.value)} + /> + + + +
+ +
-

3. Term, renewal and annual fee adjustment

-

- This Agreement runs for the committed {years}-year term set out in the - Order Form. It then renews automatically for successive one-year terms - unless either party gives written notice of non-renewal at least 30 - days before the end of the then-current term. On each renewal the - annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment. - Based on this quote, the first renewal year would be approximately{" "} - {renewal} per year; the committed term above is - billed at the rate in the Order Form and is not affected. -

- -

4. End-User License Agreement

-

- Subject to the terms of this Agreement, Stirling grants Customer a - non-exclusive, non-transferable right to use the Service for its - internal business purposes during the term. Customer is responsible - for its users' compliance and for the content it processes. The - Service, and all intellectual property in it, remains Stirling's. -

- -

5. Data Processing Agreement

-

- Where Stirling processes personal data on Customer's behalf, it does - so only on Customer's documented instructions and applies appropriate - technical and organisational measures. Sub-processors, international - transfers, and security commitments are as described in Stirling's - Data Processing Agreement and Trust Center, incorporated here by - reference. -

- -

6. Acceptance

-

- By agreeing below, Customer accepts this Agreement and the Order Form. - On acceptance, Stirling will issue the committed annual subscription - and its first invoice. This preview stands in for e-signature during - the pilot. -

+ {/* Consent under the fields it qualifies; the gate's state under the button it gates, so the + reason signing is unavailable sits beside the unavailable thing. */} +
+ + {doc && !scrolledToEnd && ( + + {t("portal.procurement.agreement.scrollHint")} + + )} +
- - - -
- - - -
- + ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx deleted file mode 100644 index 28e9d2f99a..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; -import type { ProcurementController } from "@portal/components/procurement/useProcurement"; -import type { ProcurementSnapshot } from "@portal/api/procurement"; - -const snapshot: ProcurementSnapshot = { - dealId: 1, - stage: "trial", - deployment: "cloud", - seats: 250, - trialStartedAt: "2026-06-25T00:00:00Z", - trialEndsAt: "2026-07-09T00:00:00Z", - trialExtensionsUsed: 0, - licensed: false, - licenseKey: null, - latestQuote: null, -}; - -function makeController( - overrides: Partial = {}, -): ProcurementController { - return { - isLinked: true, - loading: false, - data: null, - started: false, - stage: undefined, - latest: null, - isIssued: false, - isDraft: true, - busy: false, - downloading: false, - downloadingLicense: false, - error: null, - setError: () => {}, - open: false, - setOpen: () => {}, - editing: false, - setEditing: () => {}, - extra: null, - setExtra: () => {}, - invoicePdf: null, - onStartTrial: () => {}, - onConfirmSetup: () => {}, - onExtendTrial: () => {}, - onReset: () => {}, - onGenerate: () => {}, - onAgree: () => {}, - onDownloadPdf: async () => {}, - onDownloadOfflineLicense: async () => {}, - ...overrides, - }; -} - -/** Deal-status hero once a deal is underway, otherwise the enterprise on-ramp. */ -const meta: Meta = { - title: "Portal/Procurement/ProcurementBanner", - component: ProcurementBanner, - parameters: { layout: "padded" }, -}; -export default meta; - -type Story = StoryObj; - -/** No deal yet: the enterprise on-ramp upsell. */ -export const Upsell: Story = { - args: { controller: makeController() }, -}; - -/** A deal is underway: the wired deal-status hero. */ -export const DealUnderway: Story = { - args: { - controller: makeController({ - started: true, - data: snapshot, - stage: snapshot.stage, - }), - }, -}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index d47ffa0b48..626cc8e9fb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -1,13 +1,10 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; import { DealStatusHero } from "@portal/components/procurement/DealStatusHero"; import type { ProcurementController } from "@portal/components/procurement/useProcurement"; /** - * The deal-status hero, wired to a shared ProcurementController. Rendered both - * standalone (the /procurement route) and as the Home hero card's footer once a - * deal is underway. Assumes an active deal (controller.data present). + * The deal-status hero, wired to a shared ProcurementController. Rendered as the Home hero card's + * footer once a deal is underway; assumes an active deal (controller.data present). */ export function ControlledDealStatusHero({ controller, @@ -21,60 +18,18 @@ export function ControlledDealStatusHero({ snapshot={controller.data} busy={controller.busy} canSchedule={controller.isLinked} - onExpand={() => controller.setOpen(true)} + onExpand={() => + // Exploring has no journey to expand into yet — its ask is to set the trial up. + controller.stage === "exploring" + ? controller.onStartTrial() + : controller.setOpen(true) + } + onAcceptQuote={() => void controller.onAcceptQuote()} onLicense={() => controller.setExtra("license")} onInvite={() => setActiveView("users")} onSchedule={() => controller.setExtra("schedule")} onManageTrial={() => controller.setExtra("trial")} - onNavigate={setActiveView} + onDocuments={() => controller.setExtra("documents")} /> ); } - -/** - * Enterprise on-ramp shown when no deal exists yet. Only used on the dedicated - * /procurement route — on Home the setup checklist's Enterprise rung owns the - * on-ramp, so this doesn't render there. - */ -export function ProcurementUpsell({ - controller, -}: { - controller: ProcurementController; -}) { - const { t } = useTranslation(); - return ( - -
- - {t("portal.procurement.upsell.homeBadge")} - -

- {t("portal.procurement.upsell.homeHeadline")} - {t("portal.procurement.upsell.homeBody")} -

-
- -
- ); -} - -/** Deal-status hero when a deal is underway, otherwise the enterprise on-ramp. */ -export function ProcurementBanner({ - controller, -}: { - controller: ProcurementController; -}) { - return controller.isLinked && controller.started && controller.data ? ( - - ) : ( - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx index 06361da844..7a6c6ffb18 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx @@ -31,6 +31,10 @@ const SNAPSHOT: ProcurementSnapshot = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + agreementSignedVersion: null, + businessName: null, + contactName: null, + contactEmail: null, latestQuote: null, }; @@ -72,13 +76,14 @@ export const ScheduleCall: Story = { ), }; -// Deployment + seat count captured before the trial starts. +// Two steps before the trial starts: how they'll run it, then who is buying. export const TrialSetup: Story = { render: () => ( {}} busy={false} + onScheduleCall={() => {}} onConfirm={() => {}} /> ), diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 44bbea1264..10ea20ed0c 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -1,11 +1,20 @@ import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { Button } from "@app/ui"; -import type { ProcurementSnapshot } from "@portal/api/procurement"; +import { + fetchLegalDocument, + recordLegalConsent, + type ProcurementSnapshot, + type TrialSetupDetails, +} from "@portal/api/procurement"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; import { LicensePanel } from "@portal/components/procurement/ProcurementStages"; -import { useFocusTrap } from "@portal/components/procurement/ProcurementModal"; +import { FlowModal } from "@portal/components/shared/FlowModal"; +import { useAsync } from "@portal/hooks/useAsync"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; /** @@ -14,6 +23,8 @@ import "@portal/views/Procurement.css"; * scheduler. The shells and wiring are real so the hero behaves like the marketing prototype. */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + function SideModal({ open, onClose, @@ -21,56 +32,243 @@ function SideModal({ subtitle, children, footer, + headerAside, wide = false, }: { open: boolean; onClose: () => void; title: string; subtitle?: string; + /** Sits on the title row, before the close button (e.g. a "Step 1 of 2" badge). */ + headerAside?: React.ReactNode; children: React.ReactNode; footer?: React.ReactNode; wide?: boolean; }) { - const { t } = useTranslation(); - const trapRef = useFocusTrap(open); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open, onClose]); - - if (!open) return null; - return createPortal( -
e.target === e.currentTarget && onClose()} - > -
- -
-

{title}

+ return ( + +
+

{title}

+ {headerAside} +
{subtitle &&

{subtitle}

} + + } + > + {children} +
+ ); +} + +/** + * Reader for a versioned legal document (EULA, SLA exhibit, subprocessors), fetched from the + * backend registry and rendered as markdown. Open when {@code docId} is set. Drafts are badged. + */ +export function LegalDocumentModal({ + docId, + onClose, +}: { + docId: string | null; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { data, loading } = useAsync( + () => (docId ? fetchLegalDocument(docId) : Promise.resolve(null)), + [docId], + ); + return ( + + {loading && ( +

{t("portal.legal.loading")}

+ )} + {!loading && !data && ( +

{t("portal.legal.loadError")}

+ )} + {data && ( +
+ {data.markdown}
-
{children}
- {footer &&
{footer}
} + )} +
+ ); +} + +// ── Documents ──────────────────────────────────────────────────────────────── +/** + * The deal's paperwork in one place, reachable throughout the journey (not tied to the current + * stage): the enterprise agreement, the quote, the invoice, and the reference documents (EULA, SLA + * exhibit, subprocessors). Each row downloads or views the real artifact when it's available, and + * reads as "available later" until then. The per-stage download buttons remain the primary path; + * this is the secondary, always-on reference. + */ +export function DocumentsModal({ + open, + onClose, + agreementVersion, + downloadingAgreement, + onDownloadAgreement, + onViewAgreement, + quoteAvailable, + downloadingQuote, + onDownloadQuote, + invoiceUrl, + invoicePdf, +}: { + open: boolean; + onClose: () => void; + agreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadAgreement: () => void; + /** Jump to the agreement/sign stage in the flow (used before it's signed). */ + onViewAgreement: () => void; + quoteAvailable: boolean; + downloadingQuote?: boolean; + onDownloadQuote: () => void; + invoiceUrl?: string | null; + invoicePdf?: string | null; +}) { + const { t } = useTranslation(); + const [legalDoc, setLegalDoc] = useState(null); + const invoice = invoiceUrl || invoicePdf || null; + + return ( + <> + +
    + + + openApiUrl(invoice), + } + : { + unavailable: t("portal.procurement.documents.laterInvoice"), + } + } + /> + setLegalDoc("eula"), + }} + /> + setLegalDoc("sla"), + }} + /> + setLegalDoc("subprocessors"), + }} + /> +
+
+ setLegalDoc(null)} /> + + ); +} + +/** One row in the Documents list: name + sub on the left, an action button or a muted note. */ +function DocItem({ + name, + sub, + action, +}: { + name: string; + sub: string; + action: + | { label: string; onClick: () => void; loading?: boolean } + | { unavailable: string }; +}) { + return ( +
  • +
    + {name} + {sub}
    -
  • , - document.body, + {"unavailable" in action ? ( + {action.unavailable} + ) : ( + + )} + ); } @@ -154,82 +352,233 @@ export function TrialSetupModal({ open, onClose, busy, + email, + onScheduleCall, onConfirm, }: { open: boolean; onClose: () => void; busy: boolean; - onConfirm: (deployment: string, seats: number) => void; + /** Linked-account email, prefilled as the work email on the details step. */ + email?: string; + /** Open the scheduler — the step-1 escape hatch for buyers who want to talk first. */ + onScheduleCall: () => void; + onConfirm: ( + deployment: string, + seats: number, + details: TrialSetupDetails, + ) => void; }) { const { t } = useTranslation(); + const [step, setStep] = useState(0); const [deployment, setDeployment] = useState("cloud"); const [seats, setSeats] = useState(""); + const [contactName, setContactName] = useState(""); + const [businessName, setBusinessName] = useState(""); + const [contactEmail, setContactEmail] = useState(""); + const [inviteEmails, setInviteEmails] = useState(""); + const [eula, setEula] = useState(false); + const [legalDoc, setLegalDoc] = useState(null); // Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger. useEffect(() => { if (open) { + setStep(0); setDeployment("cloud"); setSeats(""); + setContactName(""); + setBusinessName(""); + setContactEmail(email ?? ""); + setInviteEmails(""); + setEula(false); } - }, [open]); + }, [open, email]); + + // The buying entity is what the quote and agreement are drawn against, so it is required here + // rather than deferred to the quote; invites are genuinely optional. + const detailsValid = + contactName.trim().length > 0 && + businessName.trim().length > 0 && + EMAIL_RE.test(contactEmail.trim()); + + const confirm = () => { + void recordLegalConsent("eula", "trial"); // clickwrap consent, best-effort + onConfirm(deployment, Math.max(0, Number(seats) || 0), { + businessName: businessName.trim(), + contactName: contactName.trim(), + contactEmail: contactEmail.trim(), + inviteEmails: inviteEmails.trim(), + }); + }; return ( - onConfirm(deployment, Math.max(0, Number(seats) || 0))} - > - {t("portal.procurement.setup.start")} - - } - > - + + + ) : ( + <> + + + + ) + } + > + - -

    - {t("portal.procurement.setup.seatsHint")} -

    -
    + {step === 0 && ( + <> + + + + )} + + {step === 1 && ( + <> +
    + + +
    + + + + + + )} + + setLegalDoc(null)} /> + ); } @@ -280,7 +629,6 @@ export function TrialManageModal({ } @@ -103,46 +113,63 @@ export function ProcurementFlow({ {isLinked && started && ( <> -
    - -
    - - {(editing || - (isDraft && (stage === "trial" || stage === "quote"))) && ( + {builderShowing && ( setOpen(false)} onGenerate={onGenerate} + // Null while re-editing: the buyer asked for the form, not the paper they just left. + issued={!editing && isIssued ? latest : null} + downloading={downloading} + onDownload={onDownloadPdf} /> )} - {/* Quote + agreement are one step: review the itemised quote and the agreement, then - accept straight into a committed subscription. Once accepted you can't go back. - ("security" is the retired agreement stage — still handled so an older deal that - stopped there isn't left blank.) */} - {!editing && - isIssued && - (stage === "quote" || stage === "security") && - latest && ( - setEditing(true)} - /> - )} + {/* Agreement step: review and sign the enterprise agreement. Signing accepts the quote + into a committed subscription (Stripe). */} + {agreementShowing && latest && ( + { + setOpen(false); + setExtra("schedule"); + }} + onClose={() => setOpen(false)} + /> + )} {!editing && stage === "procurement" && latest && ( )} - {!editing && stage === "active" && } + {!editing && stage === "active" && ( + + )} )} @@ -151,6 +178,8 @@ export function ProcurementFlow({ open={extra === "setup"} onClose={() => setExtra(null)} busy={busy} + email={scheduleEmail ?? undefined} + onScheduleCall={() => setExtra("schedule")} onConfirm={onConfirmSetup} /> {data?.licenseKey && ( @@ -185,6 +214,23 @@ export function ProcurementFlow({ }} /> )} + setExtra(null)} + agreementVersion={data?.agreementSignedVersion} + downloadingAgreement={downloadingAgreement} + onDownloadAgreement={onDownloadSignedAgreement} + onViewAgreement={() => { + setExtra(null); + setEditing(false); + setOpen(true); + }} + quoteAvailable={!!latest?.stripeQuoteId} + downloadingQuote={downloading} + onDownloadQuote={onDownloadPdf} + invoiceUrl={latest?.invoiceUrl} + invoicePdf={latest?.invoicePdf ?? invoicePdf} + /> ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx deleted file mode 100644 index ae424922ad..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; - -/** - * The end-to-end procurement experience (Home hero + takeover modal), driven by the `procurementSaas` - * MSW handlers: start trial → build quote → generate (issue Stripe Quote) → milestone (download PDF / - * accept). `autoOpen` opens the modal so the flow is immediately clickable. - */ -const meta: Meta = { - title: "Portal/Procurement/ProcurementHome", - component: ProcurementHome, - parameters: { layout: "fullscreen" }, -}; -export default meta; - -type Story = StoryObj; - -export const Default: Story = { args: { autoOpen: true } }; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx deleted file mode 100644 index 1877e0bc3c..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; -import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; -import { useProcurement } from "@portal/components/procurement/useProcurement"; -import "@portal/views/Procurement.css"; - -/** - * The standalone procurement experience: a deal-status hero (or enterprise - * on-ramp when no deal exists) above the full-screen takeover flow that holds - * the journey — build + issue a quote, review + agree to the enterprise - * agreement, then accept into a committed subscription. Rendered at - * /procurement (autoOpen). On Home the deal-status hero instead attaches to the - * tier hero card's footer (see HomeHero) so this component isn't used there. - */ -export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) { - const controller = useProcurement(autoOpen); - return ( - <> - - - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx index 7fb8b865f2..14934905d6 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx @@ -31,9 +31,7 @@ export const Open: Story = { contract and go live.

    - +
    diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx index 852c4e68c4..881bb778ce 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx @@ -1,104 +1,49 @@ -import { useEffect, useRef } from "react"; -import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; +import type { ReactNode } from "react"; +import { FlowModal } from "@portal/components/shared/FlowModal"; import "@portal/views/Procurement.css"; -/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */ -export function useFocusTrap(open: boolean) { - const ref = useRef(null); - useEffect(() => { - if (!open) return; - const panel = ref.current; - if (!panel) return; - const prev = document.activeElement as HTMLElement | null; - const focusables = () => - Array.from( - panel.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ), - ).filter((el) => !el.hasAttribute("disabled")); - (focusables()[0] ?? panel).focus(); - const onKey = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - const items = focusables(); - if (items.length === 0) return; - const first = items[0]; - const last = items[items.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; - panel.addEventListener("keydown", onKey); - return () => { - panel.removeEventListener("keydown", onKey); - prev?.focus?.(); - }; - }, [open]); - return ref; -} - /** - * Full-screen takeover modal for the procurement flow, copying the prototype's modal design - * (portaled to body, dimmed + blurred backdrop, rounded panel, close button). The Home deal-status - * hero expands into this. + * The procurement takeover: the shared {@link FlowModal} at takeover width. Chrome and copy only — + * the shell (portal, focus trap, Escape, close, header/body bands) is shared, so this dialog cannot + * drift from the trial and licence dialogs the way two hand-rolled shells did. */ export function ProcurementModal({ open, onClose, title, subtitle, + headerless = false, children, }: { open: boolean; onClose: () => void; + /** Dialog label. Omit `subtitle` (and pass `headerless`) when the step renders its own heading. */ title: string; subtitle?: string; - children: React.ReactNode; + /** + * Skip the title block, which takes the shell's close with it: the step inside supplies the + * heading, step badge and its own close (see StepModalHeader), so the shell would otherwise stack + * a second header and leave a stray close above it. Escape and the backdrop still dismiss. + */ + headerless?: boolean; + children: ReactNode; }) { - const { t } = useTranslation(); - const trapRef = useFocusTrap(open); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [open, onClose]); - - if (!open) return null; - - return createPortal( -
    e.target === e.currentTarget && onClose()} + return ( + +

    {title}

    + {subtitle &&

    {subtitle}

    } + + ) + } > -
    - -
    -

    {title}

    - {subtitle &&

    {subtitle}

    } -
    -
    {children}
    -
    -
    , - document.body, + {children} + ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index 35460a7ba5..fcf926f481 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -1,61 +1,92 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; +import { Button } from "@app/ui"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; /** - * The stage-specific cards shown inside the procurement takeover modal once a quote exists: the - * issued-quote milestone, the subscription-created payment step, and the live confirmation. Each is - * a pure presentational view driven by props; ProcurementHome owns the state and the actions. + * The stage-specific views shown inside the procurement takeover modal once the agreement is signed: + * the subscription-created payment step and the live confirmation. Each is a pure presentational view + * driven by props; the controller owns the state and the actions. + * + * Neither is wrapped in a Card: the dialog is already the surface, and a card inside it drew a second + * border around content that filled it. Both wear the same eyebrow/title/description stack and put + * their actions in the flow's footer bar, so the last two steps of the journey read like the ones + * before them rather than like panels that wandered in. */ -/** The subscription-created step: pay or download the first invoice. */ +/** The subscription-created step: pay or download the first invoice, and the signed agreement. */ export function PaymentStageCard({ invoiceUrl, invoicePdf, + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, }: { invoiceUrl?: string | null; invoicePdf?: string | null; + /** Version label of the signed agreement PDF, if one is available to download. */ + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; }) { const { t } = useTranslation(); return ( - +
    + + {t("portal.procurement.payment.eyebrow")} +

    {t("portal.procurement.payment.title")}

    {t("portal.procurement.payment.description")}

    - {(invoiceUrl || invoicePdf) && ( -
    - {invoiceUrl && ( - - )} - {invoicePdf && ( - - )} + {(invoiceUrl || invoicePdf || signedAgreementVersion) && ( +
    +
    + {signedAgreementVersion && onDownloadSignedAgreement && ( + + )} + {invoicePdf && ( + + )} + {invoiceUrl && ( + + )} +
    )} - +
    ); } /** The live confirmation once the deal is active. */ -export function LiveStageCard() { +export function LiveStageCard({ + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, +}: { + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; +} = {}) { const { t } = useTranslation(); return ( - +
    {t("portal.procurement.live.eyebrow")} @@ -65,7 +96,20 @@ export function LiveStageCard() {

    {t("portal.procurement.live.description")}

    - + {signedAgreementVersion && onDownloadSignedAgreement && ( +
    +
    + +
    +
    + )} +
    ); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index b392247563..98fcbd4f04 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -3,18 +3,24 @@ import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { DocumentsIcon, + DownloadIcon, PoliciesIcon, UsersIcon, } from "@portal/components/icons"; import { money } from "@portal/components/procurement/format"; import { buildQuote, + recordLegalConsent, type QuoteConfigInput, type QuoteResult, } from "@portal/api/procurement"; +import { LegalDocumentModal } from "@portal/components/procurement/ProcurementExtras"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; import "@portal/views/Procurement.css"; -const STEPS = ["volume", "plan", "details"] as const; +const STEPS = ["volume", "plan", "details", "review"] as const; +const DETAILS_STEP = 2; +const REVIEW_STEP = 3; const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71) // Governance posture: the intensity (runs per PDF) fed to the committed-volume curve. const POSTURES = [ @@ -30,22 +36,57 @@ const SIZE_TIERS = [ ] as const; /** - * The enterprise quote builder — volume → commitment & service → details. A client-side preview - * drives the live footer total; the backend is authoritative. Completing the form generates the - * quote directly (build + issue in one step) — the issued quote is then shown as the milestone, so - * there's no redundant in-builder preview. + * The enterprise quote builder — volume → commitment & service → details → review. A client-side + * preview drives the live footer total; the backend is authoritative. Generating builds and issues in + * one go, and the issued quote comes back as the fourth step: the buyer reads the real itemised paper + * and can download it, but does not accept here. Accepting is a decision taken from the deal card, + * deliberately, so circulating the quote internally is not a dead end in a modal. */ export function QuoteBuilder({ deployment, seats = 0, + email, + onClose, + dealDetails, initial, + eulaAlreadyAgreed = false, onGenerate, + issued, + downloading = false, + onDownload, }: { deployment: string; /** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */ seats?: number; + /** Linked-account email; prefills the contact email on a fresh quote's details step. */ + email?: string | null; + /** Dismiss the dialog. The builder draws its own header, so it carries the close too. */ + onClose?: () => void; + /** + * The buying entity captured at trial setup. Seeds a fresh quote's details step so it confirms + * what is already known rather than asking twice; the buyer can still correct it here, since a + * deal can change hands between trial and quote. + */ + dealDetails?: { + businessName?: string | null; + contactName?: string | null; + contactEmail?: string | null; + }; /** Seed the builder from an existing quote's config (re-editing a quote). */ initial?: QuoteConfigInput; + /** + * The issued quote, which is what the review step shows. Its arrival is also what opens that step: + * the parent issues the quote and it lands by snapshot refresh, so there is no synchronous result + * to advance on. Null while re-editing, so editing reopens the form rather than the paper. + */ + issued?: QuoteResult | null; + downloading?: boolean; + onDownload?: () => void; + /** + * The buyer already accepted the EULA (e.g. at trial start). When true, the EULA clickwrap is + * hidden here and no consent is recorded at quote time — it's only collected once. + */ + eulaAlreadyAgreed?: boolean; /** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */ onGenerate: (quote: QuoteResult) => void; }) { @@ -65,9 +106,9 @@ export function QuoteBuilder({ indemnification: false, training: false, qbr: false, - businessName: "", - contactName: "", - contactEmail: "", + businessName: dealDetails?.businessName ?? "", + contactName: dealDetails?.contactName ?? "", + contactEmail: dealDetails?.contactEmail ?? email ?? "", addressLine1: "", addressLine2: "", city: "", @@ -79,29 +120,81 @@ export function QuoteBuilder({ ); // A seeded quote carries a volume but no user count, so treat it as manually set. const [manualVolume, setManualVolume] = useState(initial != null); - const [eula, setEula] = useState(initial != null); + // Never pre-ticked, even when re-editing a quote: a consent the buyer did not tick in this session + // is not a consent, and recordLegalConsent would have logged one as though they had. + const [eula, setEula] = useState(false); + const [legalDoc, setLegalDoc] = useState(null); const [busy, setBusy] = useState(false); + // Only surface field errors once the buyer tries to generate — no red fields on first sight. + const [showErrors, setShowErrors] = useState(false); function set(k: K, v: QuoteConfigInput[K]) { setCfg((c) => ({ ...c, [k]: v })); } - // Re-editing an existing quote: everything is seeded, so jump to the last step (details) with the + // Required buyer details before a quote can be generated (Order Form / invoice need these). + const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + (cfg.contactEmail ?? "").trim(), + ); + const valid = { + businessName: cfg.businessName.trim().length > 0, + contactName: (cfg.contactName ?? "").trim().length > 0, + contactEmail: emailOk, + addressLine1: (cfg.addressLine1 ?? "").trim().length > 0, + city: (cfg.city ?? "").trim().length > 0, + region: (cfg.region ?? "").trim().length > 0, + postalCode: (cfg.postalCode ?? "").trim().length > 0, + }; + const detailsValid = Object.values(valid).every(Boolean); + const eulaOk = eulaAlreadyAgreed || eula; + const canGenerate = detailsValid && eulaOk; + + // Re-editing an existing quote: everything is seeded, so jump to the details step with the // agreement pre-accepted — one click re-generates, or Back to change a field. No walking from step 1. // Mount-only: seed the step from `initial` once (deliberately no deps). useEffect(() => { - if (initial) setStep(STEPS.length - 1); + if (initial) setStep(DETAILS_STEP); }, []); + // Issuing lands by snapshot refresh rather than as a return value, so the arrival of the issued + // quote is what opens the review step. Keyed on the quote's id, not the object: React Query hands + // back a fresh object on every refetch, which would yank a buyer who had walked Back to the form. + useEffect(() => { + if (issued) setStep(REVIEW_STEP); + }, [issued?.quoteId]); + const preview = previewAnnualMinor(cfg); const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0); + // On the review step the footer quotes the issued figures rather than the client-side preview, so + // the running total never disagrees with the paper directly above it. + const onPaper = issued != null && step === REVIEW_STEP; + const running = onPaper + ? { + annual: money(issued.annualNetMinor, issued.currency), + years: issued.config.termYears, + tcv: money(issued.tcvMinor, issued.currency), + } + : { + annual: money(preview), + years: cfg.termYears, + tcv: money(tcvPreview), + }; + // Fully filled → price + hand the draft to the parent to issue as a Stripe Quote (which then shows // as the milestone). No separate in-builder preview step. async function generate() { + if (!canGenerate) { + setShowErrors(true); + return; + } setBusy(true); try { - onGenerate(await buildQuote(cfg)); + const quote = await buildQuote(cfg); + // Record the EULA clickwrap only when it's collected here — i.e. the buyer didn't already + // accept it at trial start. Best-effort. + if (!eulaAlreadyAgreed) void recordLegalConsent("eula", "quote"); + onGenerate(quote); } finally { setBusy(false); } @@ -109,22 +202,16 @@ export function QuoteBuilder({ return (
    -
    -

    - {t("portal.procurement.builder.title")} -

    - - {t("portal.procurement.builder.stepOf", { - n: step + 1, - total: STEPS.length, - })} - -
    -
    - {STEPS.map((s, i) => ( - - ))} -
    +
    {step === 0 && ( @@ -171,15 +258,6 @@ export function QuoteBuilder({ ? t("portal.procurement.builder.volManual") : t("portal.procurement.builder.volNoUsers")}

    - - )} - - {step === 1 && ( - } - title={t("portal.procurement.builder.s2Title")} - sub={t("portal.procurement.builder.s2Sub")} - >
    {POSTURES.map((p) => ( @@ -209,7 +287,15 @@ export function QuoteBuilder({ ))}
    +
    + )} + {step === 1 && ( + } + title={t("portal.procurement.builder.s2Title")} + sub={t("portal.procurement.builder.s2Sub")} + >
    {[1, 2, 3, 4, 5].map((y) => ( @@ -287,7 +373,11 @@ export function QuoteBuilder({ sub={t("portal.procurement.builder.s3Sub")} >
    - + set("businessName", e.target.value)} /> - +
    - + set("contactEmail", e.target.value)} /> - +
    - + set("city", e.target.value)} /> - + set("region", e.target.value)} /> - +
    - + {!eulaAlreadyAgreed && ( + + )} + {showErrors && !canGenerate && ( +

    + {t("portal.procurement.builder.completeRequired")} +

    + )} )} + + {/* No step heading here, unlike the form steps: the quote is the content, and a heading over + it only repeats what the paper already says. The real issued figures, not the footer's + client-side preview — this is the document the buyer circulates, so it has to match the + PDF and the Stripe quote exactly. */} + {step === REVIEW_STEP && issued && ( +
    +
    +
    +
    +
    Stirling PDF
    +
    + {t("portal.procurement.builder.paperEyebrow")} +
    +
    +
    +
    + {issued.quoteNumber} +
    + {issued.validUntil && ( +
    + {t("portal.procurement.review.validUntil", { + date: new Date(issued.validUntil).toLocaleDateString(), + })} +
    + )} + {/* On the document rather than in the footer: it downloads this paper, so it + belongs to it, and the footer stays the flow's own Back/Done. */} + +
    +
    + + {issued.config.businessName?.trim() && ( +
    +
    + {t("portal.procurement.builder.paperFor")} +
    +
    + {issued.config.businessName} +
    +
    + )} + +
      + {issued.lineItems.map((li) => ( +
    • + {li.label} + {money(li.amountMinor, issued.currency)} +
    • + ))} +
    + +
    +
    +
    + {t("portal.procurement.review.annual")} +
    +
    + {t("portal.procurement.review.tcv", { + years: issued.config.termYears, + tcv: money(issued.tcvMinor, issued.currency), + })} +
    +
    + {t("portal.procurement.review.renewal", { + amount: money( + issued.renewalAnnualNetMinor, + issued.currency, + ), + pct: issued.cpiRatePct, + })} +
    + {issued.config.poNumber?.trim() && ( +
    + {t("portal.procurement.review.poNumber", { + po: issued.config.poNumber.trim(), + })} +
    + )} +
    +
    + {money(issued.annualNetMinor, issued.currency)} +
    +
    +
    +
    + )}
    - {t("portal.procurement.builder.running", { - annual: money(preview), - years: cfg.termYears, - tcv: money(tcvPreview), - })} + {t("portal.procurement.builder.running", running)}
    {step > 0 && ( @@ -408,7 +630,6 @@ export function QuoteBuilder({ {step === 0 && ( )} {step === 1 && ( - )} - {step === 2 && ( - )} + {/* No Accept here: the review step ends on the deal card, where accepting is one of two + deliberate choices rather than the only way out of a modal. Download lives on the + document itself. */} + {step === REVIEW_STEP && ( + + )}
    + setLegalDoc(null)} />
    ); } @@ -470,14 +690,25 @@ function Step({ function Field({ label, + required, + invalid, children, }: { label: string; + required?: boolean; + invalid?: boolean; children: React.ReactNode; }) { return ( -
    ); diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index e26fd74a31..b721768952 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -1,34 +1,10 @@ -.portal-proc { - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1.5rem; - max-width: 84rem; - margin: 0 auto; -} - -/* Page header */ -.portal-proc__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; -} - -.portal-proc__title { - margin: 0; - font-size: 1.375rem; - font-weight: 600; - color: var(--c-text); -} - .portal-proc__subtitle { margin: 0.25rem 0 0; font-size: 0.8125rem; color: var(--c-text-subtle); } -/* Eyebrow label shared by the journey header + SE block */ +/* Eyebrow label above a stage panel's heading */ .portal-proc__eyebrow { display: block; font-size: 0.6875rem; @@ -38,613 +14,33 @@ color: var(--c-text-subtle); margin-bottom: 0.25rem; } - -/* ── Journey card ─────────────────────────────────────────────────────── */ -/* Stacked, border-divided sections: header / stepper / trial / next step. */ -.portal-proc__journey-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1.25rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); - flex-wrap: wrap; -} - -.portal-proc__journey-title { - margin: 0; - font-size: 1.0625rem; - font-weight: 700; - color: var(--c-text); -} - -.portal-proc__journey-sub { - margin: 0.25rem 0 0; - max-width: 36rem; - font-size: 0.8125rem; - line-height: 1.5; - color: var(--c-text-subtle); -} - -.portal-proc__se { - display: flex; - flex-direction: column; - text-align: right; - flex-shrink: 0; -} - -.portal-proc__se .portal-proc__eyebrow { - margin-bottom: 0.25rem; -} - -.portal-proc__se-name { - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__se-email { - font-size: 0.75rem; - color: var(--c-primary); - text-decoration: none; -} - -.portal-proc__se-email:hover { - text-decoration: underline; -} - -/* Stepper band */ -.portal-proc__journey-stepper { - padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__steps { - display: flex; - align-items: flex-start; -} - -.portal-proc__steps--locked { - opacity: 0.55; - filter: grayscale(0.4); - pointer-events: none; -} - -.portal-proc__step { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.4rem; - min-width: 4rem; -} - -.portal-proc__step-dot { - width: 0.875rem; - height: 0.875rem; - border-radius: 50%; - background: var(--c-border); -} - -.portal-proc__step--complete .portal-proc__step-dot { - background: var(--color-green); -} - -.portal-proc__step--current .portal-proc__step-dot { - background: var(--color-purple); - box-shadow: 0 0 0 4px var(--color-purple-light); -} - -.portal-proc__step-label { - font-size: 0.6875rem; - font-weight: 500; - text-align: center; - white-space: nowrap; - color: var(--c-text-subtle); -} - -.portal-proc__step--complete .portal-proc__step-label { - color: var(--c-text-subtle); -} - -.portal-proc__step--current .portal-proc__step-label { - font-weight: 700; - color: var(--c-text); -} - -/* Connector aligns with the 0.875rem dots: (14px − 2px) / 2 = 6px down */ -.portal-proc__step-line { - flex: 1; - height: 2px; - margin: 0.375rem 0.375rem 0; - background: var(--c-border-subtle); -} - -.portal-proc__step-line[data-filled="true"] { - background: var(--color-green); -} - -/* Trial status strip (shown while evaluating) */ -.portal-proc__trial { - display: flex; - align-items: center; - gap: 0.625rem; - flex-wrap: wrap; - padding: 0.75rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); - background: var(--color-bg-code); -} - -.portal-proc__trial-title { - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__trial-dim { - font-size: 0.78125rem; - color: var(--c-text-subtle); -} - -.portal-proc__trial-key { - font-family: - ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; - font-size: 0.6875rem; - color: var(--c-text-subtle); - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 0.375rem; - padding: 0.1875rem 0.5rem; -} - -/* Next-step row: one primary action at a time */ -.portal-proc__next { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 1rem 1.5rem; -} - -.portal-proc__next-label { - display: flex; - align-items: center; - gap: 0.625rem; - font-size: 0.84375rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__next-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: var(--color-amber); - flex-shrink: 0; -} - -.portal-proc__next-dot[data-live="true"] { - background: var(--color-green); -} - -/* ── Documents card ───────────────────────────────────────────────────── */ -.portal-proc__docs-head { - padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__docs-title { - margin: 0; - font-size: 0.9375rem; - font-weight: 700; - color: var(--c-text); -} - -.portal-proc__docs-sub { - margin: 0.25rem 0 0; - font-size: 0.78125rem; - color: var(--c-text-subtle); -} - -.portal-proc__docs-body { - padding: 0.375rem 1.5rem 1.125rem; -} - -/* Accordion spacing — the disclosure chrome itself lives in shared Collapsible - (.sui-collapsible); here we only space the stacked sections. */ -.portal-proc__docs-body .sui-collapsible { - margin-top: 0.875rem; -} - -/* Stage header bits */ -.portal-proc__stage-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - flex-shrink: 0; -} - -.portal-proc__stage-dot[data-state="done"] { - background: var(--color-green); -} - -.portal-proc__stage-dot[data-state="current"] { - background: var(--color-purple); -} - -.portal-proc__stage-dot[data-state="upcoming"] { - background: var(--c-border); -} - -.portal-proc__stage-label { - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; - color: var(--c-text-subtle); -} - -.portal-proc__stage-label[data-current] { - color: var(--c-text); -} - -.portal-proc__stage-hint { - font-size: 0.71875rem; - color: var(--c-text-subtle); -} - -.portal-proc__stage-count { - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -/* Document lists inside the accordion */ -.portal-proc__doc-list { - border-top: 1px solid var(--c-border-subtle); -} - -.portal-proc__doc-list--boxed { - border: 1px solid var(--c-border-subtle); - border-radius: 0.5rem; - overflow: hidden; -} - -/* Supporting section — extra separation from the stage accordion above it. - Scoped to match the general .sui-collapsible spacing rule's specificity. */ -.portal-proc__docs-body .portal-proc__supporting-acc { - margin-top: 1.5rem; -} - -.portal-proc__supporting-head { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; -} - -.portal-proc__supporting-sub { - font-size: 0.71875rem; - font-weight: 400; - line-height: 1.45; - color: var(--c-text-subtle); -} - -.portal-proc__acc-toggle-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--c-primary); -} - -.portal-proc__supporting-groups { - display: flex; - flex-direction: column; - gap: 1rem; - border-top: 1px solid var(--c-border-subtle); - padding: 0.875rem; -} - -.portal-proc__group-label { - font-size: 0.6875rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--c-text-subtle); - margin-bottom: 0.5rem; -} - -/* ── Document rows (ledger + supporting) ──────────────────────────────── */ -.portal-proc__doc { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.75rem 0.875rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__doc:last-child { - border-bottom: none; -} - -.portal-proc__doc[data-locked] { - opacity: 0.6; -} - -.portal-proc__doc-text { - min-width: 0; -} - -.portal-proc__doc-name-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.portal-proc__doc-name { - font-size: 0.84375rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__doc-sub { - margin: 0.0625rem 0 0; - font-size: 0.71875rem; - line-height: 1.4; - color: var(--c-text-subtle); -} - -.portal-proc__doc-actions { - display: flex; - align-items: center; - gap: 0.75rem; - flex-shrink: 0; -} - -/* ── Locked state ─────────────────────────────────────────────────────── */ -.portal-proc__locked { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* ── Action modal ─────────────────────────────────────────────────────── */ -.portal-proc__modal-body { - margin: 0 0 1rem; - font-size: 0.875rem; - line-height: 1.55; - color: var(--c-text-muted); -} - -.portal-proc__modal-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.625rem; -} - -.portal-proc__upload { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.portal-proc__upload-input { - display: none; -} - -.portal-proc__upload-name { - font-size: 0.75rem; - color: var(--c-text-subtle); -} - -@media (max-width: 48rem) { - .portal-proc__journey-head { - flex-direction: column; - } - - .portal-proc__se { - text-align: left; - } - - .portal-proc__steps { - overflow-x: auto; - } -} - -/* ── Enterprise upsell CTA (Home / Usage on-ramp) ─────────────────────────── */ -.portal-proc__upsell { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; -} -.portal-proc__upsell-badge { - display: inline-block; - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--c-primary); - background: var(--c-primary-subtle); - padding: 0.15rem 0.5rem; - border-radius: 0.375rem; - margin-bottom: 0.4rem; -} -.portal-proc__upsell-copy { - margin: 0; - font-size: 0.8125rem; - line-height: 1.45; - color: var(--c-text-subtle); - max-width: 44rem; -} -.portal-proc__upsell-copy strong { - color: var(--c-text); -} - -/* ── Quote builder ────────────────────────────────────────────────────────── */ -.portal-proc__builder-head { - display: flex; - align-items: baseline; - justify-content: space-between; - margin-bottom: 1rem; -} .portal-proc__builder-title { margin: 0; font-size: 1rem; font-weight: 650; color: var(--c-text); } -.portal-proc__builder-step { - font-size: 0.75rem; - color: var(--c-text-subtle); -} -.portal-proc__builder-body { - display: flex; - flex-direction: column; - gap: 0.85rem; -} -.portal-proc__field { - display: flex; - flex-direction: column; - gap: 0.3rem; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} -.portal-proc__field input, -.portal-proc__field select { - padding: 0.45rem 0.6rem; - border: 1px solid var(--c-border); - border-radius: 0.5rem; - font-size: 0.875rem; - background: var(--c-input-bg); - color: var(--c-text); -} -.portal-proc__builder-addons { - display: flex; - flex-direction: column; - gap: 0.4rem; - font-size: 0.8125rem; - color: var(--c-text-muted); -} -.portal-proc__builder-addons label { - display: flex; - align-items: center; - gap: 0.5rem; -} -.portal-proc__builder-actions { - display: flex; - justify-content: flex-end; - gap: 0.6rem; - margin-top: 0.5rem; -} -.portal-proc__quote-head { - display: flex; - align-items: baseline; - justify-content: space-between; - border-bottom: 1px solid var(--c-border); - padding-bottom: 0.5rem; -} -.portal-proc__quote-number { - font-weight: 650; - color: var(--c-text); -} -.portal-proc__quote-valid { - font-size: 0.75rem; - color: var(--c-text-subtle); -} -.portal-proc__quote-lines { - list-style: none; - margin: 0; - padding: 0; -} -.portal-proc__quote-lines li { - display: flex; - justify-content: space-between; - padding: 0.4rem 0; - font-size: 0.8125rem; - color: var(--c-text-muted); - border-bottom: 1px solid var(--c-border-subtle); -} -.portal-proc__quote-lines li[data-kind="DISCOUNT"] { - color: var(--c-success); -} -.portal-proc__quote-total { - display: flex; - justify-content: space-between; - align-items: baseline; - padding: 0.6rem 0 0.2rem; - font-size: 0.9375rem; -} -.portal-proc__quote-total strong { - font-size: 1.25rem; - color: var(--c-text); -} -.portal-proc__quote-tcv { - font-size: 0.75rem; - color: var(--c-text-subtle); -} /* ══ Quote builder — copied from the marketing prototype (tight density) ═════ */ .portal-qb { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: inset 0 0 0 1px var(--c-border-subtle); - overflow: hidden; -} -.portal-qb__head { display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 24px 14px; - border-bottom: 1px solid var(--c-border-subtle); - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--c-primary) 5.5%, transparent) 0%, - transparent 100% - ); -} -.portal-qb__title { - margin: 0; - font-size: 16px; - font-weight: 700; - color: var(--c-text); -} -.portal-qb__stepchip { - font-size: 11px; - font-weight: 700; - color: var(--c-text-subtle); - background: var(--c-surface-sunken); - padding: 3px 10px; - border-radius: 999px; -} -.portal-qb__progress { - display: flex; - gap: 6px; - padding: 12px 24px 0; -} -.portal-qb__progress span { - flex: 1; - height: 6px; - border-radius: 999px; - background: var(--c-surface-sunken); - transition: background 0.3s; -} -.portal-qb__progress span[data-on] { - background: var(--c-primary); + flex-direction: column; } +/* No padding or scroll of its own: FlowModal's panel supplies both, and nesting a second scroll + container inside a scrolling panel gave the builder two scrollbars. */ +/* This gap is the builder's only vertical rhythm: the blocks inside carry no bottom margins of their + own, so spacing cannot double up the way a margin plus a gap did. The top margin is the breathing + room under the stepped header, which deliberately has no bottom margin so its host sets this. */ .portal-qb__body { - padding: 20px 24px; - max-height: 56vh; - overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1.05rem; } .portal-qb__intro { display: flex; align-items: center; gap: 12px; - margin-bottom: 18px; } .portal-qb__intro-icon { width: 38px; @@ -667,9 +63,10 @@ color: var(--c-text-subtle); margin-top: 1px; } +/* No bottom margin: inside .portal-qb__body the gap spaces these, and inside .portal-qb__row a + margin only reserved dead space under the inputs. */ .portal-qb__field { display: block; - margin-bottom: 18px; } .portal-qb__field-label { display: block; @@ -691,6 +88,18 @@ background: var(--c-input-bg); color: var(--c-text); } +.portal-qb__req { + color: var(--c-danger); +} +.portal-qb__field[data-invalid] input, +.portal-qb__field[data-invalid] select { + border-color: var(--c-danger); +} +.portal-qb__error { + margin: 10px 0 0; + font-size: 12px; + color: var(--c-danger); +} .portal-qb__row { display: flex; gap: 14px; @@ -701,7 +110,7 @@ min-width: 190px; } .portal-qb__hint { - margin: 7px 0 0; + margin: 0; font-size: 11.5px; color: var(--c-text-subtle); line-height: 1.4; @@ -736,11 +145,22 @@ gap: 10px; flex-wrap: wrap; } +/* Short-label options that should sit across one row rather than wrapping 2 + 1. Tighter padding + and label size so each caption fits on a single line at three-across. Deliberately no `nowrap`: + a longer translation should wrap rather than clip or push the card out of the row. */ +.portal-qb__opts--across .portal-qb__opt { + min-width: 0; + padding: 11px 12px; +} +.portal-qb__opts--across .portal-qb__opt-sub { + font-size: 11px; + line-height: 1.35; +} .portal-qb__opt { text-align: left; flex: 1; min-width: 150px; - padding: 12px 14px; + padding: 9px 11px; border-radius: 9px; border: 1px solid var(--c-border); background: var(--c-surface); @@ -834,7 +254,11 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 14px 24px; + /* Bleeds to the panel's edges by reading the shell's own inset, so changing FlowModal's padding + can no longer leave this footer stopping short of them. */ + margin: 0.85rem calc(-1 * var(--flowmodal-inset)) + calc(-1 * var(--flowmodal-body-end)); + padding: 0.8rem var(--flowmodal-inset) 0.9rem; border-top: 1px solid var(--c-border-subtle); flex-wrap: wrap; } @@ -846,13 +270,13 @@ display: flex; gap: 10px; } -/* Step 4 — the itemised quote paper */ +/* Step 4 — the itemised quote paper, on a sunken tray that runs to the panel's edges. No scroll of + its own: the dialog body already scrolls, and nesting a second scroller gave the builder two + scrollbars. The bleed reads the shell's inset rather than hard-coding a copy of it. */ .portal-qb__papertray { background: var(--c-surface-sunken); - padding: 18px; - max-height: 56vh; - overflow-y: auto; - margin: -20px -24px; + padding: 14px var(--flowmodal-inset); + margin: 0 calc(-1 * var(--flowmodal-inset)); } .portal-qb__paper { background: var(--c-surface); @@ -880,6 +304,11 @@ .portal-qb__paper-meta { text-align: right; } +/* Reads as a link on the document rather than a button in a toolbar: right-aligned under the quote's + own metadata, with the row's padding trimmed so it sits tight to the date above it. */ +.portal-qb__paper-download { + margin: 0.2rem -0.5rem -0.25rem 0; +} .portal-qb__quote-number { font-size: 12.5px; font-weight: 700; @@ -955,27 +384,24 @@ margin-top: 3px; } -/* ── Enterprise upsell text wrapper (Home on-ramp) ────────────────────────── */ -.portal-proc__upsell-text { - flex: 1 1 20rem; -} - /* ── Deal-status hero (Home, active deal) ─────────────────────────────────── */ .portal-hero { border: 1px solid var(--c-border); border-radius: 12px; padding: 1.1rem 1.25rem; - background: - radial-gradient( - 120% 140% at 100% 0%, - color-mix(in srgb, var(--c-hue-violet) 8%, transparent), - transparent 55% - ), - var(--c-surface); + background: var(--c-surface); display: flex; flex-direction: column; gap: 1rem; } + +/* Attached under the editor rail the two read as one card, so the hero drops its standalone frame + and lets the footer's top border be the only seam. It keeps the frame on the procurement view, + where it stands alone. */ +.portal-editor-hero__footer .portal-hero { + border: none; + border-radius: 0; +} .portal-hero__top { display: flex; align-items: flex-start; @@ -983,25 +409,56 @@ gap: 1rem; flex-wrap: wrap; } +.portal-hero__ident { + flex: 1; + min-width: 0; +} .portal-hero__eyebrow { display: block; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--c-primary); + color: var(--c-text-subtle); + margin-bottom: 0.55rem; } -.portal-hero__company { - display: block; - font-size: 1.0625rem; - font-weight: 650; +/* The journey band: one segment per stage, filled through the current one. A progress indicator, + so it lives inside the block whose progress it reports and deliberately does not pulse. */ +.portal-hero__bar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 0.625rem; +} +.portal-hero__bar span { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--c-hover); + transition: background 0.3s ease; +} +.portal-hero__bar span[data-on] { + background: var(--c-primary); +} +/* The hero's one-line status: bold stage · what it asks, then Next: … */ +.portal-hero__sentence { + margin: 0; + font-size: 0.84375rem; + line-height: 1.5; + color: var(--c-text-muted); +} +.portal-hero__sentence strong { + font-weight: 700; color: var(--c-text); - margin-top: 0.2rem; +} +.portal-hero__sentence-next { + color: var(--c-text-subtle); } .portal-hero__chips { display: flex; gap: 0.4rem; flex-wrap: wrap; + margin-top: 0.5rem; } .portal-hero__chip { font-size: 0.6875rem; @@ -1011,88 +468,74 @@ border-radius: 999px; padding: 0.2rem 0.6rem; } -.portal-hero__stepper { - overflow-x: auto; -} -.portal-hero__next { +/* One action row: the stage's primary CTA leads, quiet icon actions sit beside it. No dividers. */ +.portal-hero__cta { display: flex; align-items: center; - justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; - padding-top: 0.85rem; - border-top: 1px solid var(--c-border-subtle); } -.portal-hero__next-label { +.portal-hero__icons { + display: flex; + align-items: center; + gap: 0.5rem; +} +.portal-hero__iconbtn { + width: 34px; + height: 34px; display: inline-flex; align-items: center; - gap: 0.45rem; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text-muted); -} -.portal-hero__next-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 999px; - background: var(--c-primary); - box-shadow: 0 0 0 3px var(--c-primary-subtle); -} - -/* ── Full-screen takeover modal (procurement flow) ────────────────────────── */ -.portal-procmodal { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: flex-start; justify-content: center; - padding: clamp(0.5rem, 4vh, 3rem) 1rem; - overflow-y: auto; - background: var(--c-overlay); - backdrop-filter: blur(6px) saturate(160%); - -webkit-backdrop-filter: blur(6px) saturate(160%); - animation: portal-procmodal-fade 0.15s ease-out; -} -@keyframes portal-procmodal-fade { - from { - opacity: 0; - } - to { - opacity: 1; - } -} -.portal-procmodal__panel { - position: relative; - width: 100%; - max-width: 62rem; - background: var(--c-surface); + border-radius: 9px; border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); - padding: 1.5rem 1.5rem 1.75rem; -} -.portal-procmodal__close { - position: absolute; - top: 0.85rem; - right: 0.85rem; - border: none; - background: var(--c-border-subtle); - color: var(--c-text-subtle); - width: 1.9rem; - height: 1.9rem; - border-radius: 8px; - font-size: 0.85rem; + background: var(--c-surface); + color: var(--c-text-muted); cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } -.portal-procmodal__close:hover { - background: var(--c-border); +.portal-hero__iconbtn:hover { + background: var(--c-hover); + border-color: var(--c-border-strong); color: var(--c-text); } -.portal-procmodal__header { - margin-bottom: 1.25rem; - padding-right: 2.5rem; +/* Terminal state: the deal is done, so the row reports rather than asks. */ +.portal-hero__live { + display: flex; + align-items: center; + gap: 0.75rem; } +.portal-hero__live-tile { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + /* Holds a CheckIcon, which takes its stroke from `color` and its size from its own prop. */ + color: var(--c-success); + background: var(--c-success-subtle); +} +.portal-hero__live-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.portal-hero__live-title { + font-size: 0.90625rem; + font-weight: 700; + color: var(--c-text); +} +.portal-hero__live-sub { + font-size: 0.78125rem; + color: var(--c-text-subtle); + margin-top: 1px; +} + +/* ── Procurement takeover: heading type only, the shell is the shared Modal ── */ .portal-procmodal__title { margin: 0; font-size: 1.35rem; @@ -1104,15 +547,6 @@ font-size: 0.875rem; color: var(--c-text-subtle); } -.portal-procmodal__body { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.portal-proc__modal-stepper { - overflow-x: auto; -} .portal-proc__payment-actions { display: flex; gap: 0.6rem; @@ -1124,7 +558,7 @@ padding: 1rem; border: 1px solid var(--c-border); border-radius: 0.6rem; - background: var(--color-surface-2, rgba(0, 0, 0, 0.02)); + background: var(--c-surface-sunken); } .portal-proc__license-label { display: block; @@ -1139,7 +573,7 @@ margin-top: 0.4rem; padding: 0.55rem 0.7rem; border-radius: 0.4rem; - background: var(--color-surface-3, rgba(0, 0, 0, 0.05)); + background: var(--c-surface-raised); font-family: var(--font-mono, monospace); font-size: 0.85rem; word-break: break-all; @@ -1150,45 +584,9 @@ font-size: 0.75rem; color: var(--c-text-subtle, var(--c-text-muted)); } -.portal-proc__milestone-for { - margin: 0.15rem 0 0; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text-muted); -} -.portal-proc__milestone-lines { - margin: 0.85rem 0 0.5rem; -} -.portal-proc__milestone-totals { - display: flex; - align-items: baseline; - gap: 1rem; - flex-wrap: wrap; - margin: 0.75rem 0 0.25rem; -} -.portal-proc__milestone-annual { - font-size: 1.75rem; - font-weight: 700; - color: var(--c-text); -} -.portal-proc__milestone-annual small { - font-size: 0.8125rem; - font-weight: 500; - color: var(--c-text-subtle); -} -.portal-proc__milestone-tcv { - font-size: 0.8125rem; - color: var(--c-text-subtle); -} /* Hero next-step action row (primary CTA + optional extend-trial). */ -.portal-hero__next-actions { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -/* Hero quick-action chips (clickable pills next to the company name). */ +/* Hero quick-action chips (the trial countdown pill under the stage sentence). */ .portal-hero__chip--action { border: 1px solid var(--c-border); cursor: pointer; @@ -1203,101 +601,8 @@ transform: translateY(-1px); } -/* Hero rollout checklist (trial): the "do this now" setup steps. */ -.portal-hero__checklist { - list-style: none; - margin: 0; - padding: 0; - border-top: 1px solid var(--c-border-subtle); -} -.portal-hero__checklist li { - border-bottom: 1px solid var(--c-border-subtle); -} -.portal-hero__checklist button { - display: flex; - align-items: center; - gap: 0.85rem; - width: 100%; - padding: 0.7rem 0.25rem; - background: none; - border: none; - cursor: pointer; - text-align: left; -} -.portal-hero__checklist button:hover { - background: var(--c-hover, var(--c-border-subtle)); -} -.portal-hero__check-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 999px; - background: var(--c-border); - flex-shrink: 0; -} -.portal-hero__check-text { - flex: 1; - min-width: 0; -} -.portal-hero__check-title { - display: block; - font-size: 0.85rem; - font-weight: 600; - color: var(--c-text); -} -.portal-hero__check-sub { - display: block; - font-size: 0.75rem; - color: var(--c-text-subtle); - margin-top: 0.05rem; -} -.portal-hero__check-pill { - font-size: 0.6875rem; - font-weight: 600; - color: var(--c-text-subtle); - background: var(--c-border-subtle); - border-radius: 999px; - padding: 0.15rem 0.55rem; - flex-shrink: 0; -} - /* ── Side dialogs (Key documents / Schedule a call / Trial) ───────────────── */ -.portal-sidemodal { - position: fixed; - inset: 0; - z-index: 1100; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - overflow-y: auto; - background: var(--c-overlay); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - animation: portal-procmodal-fade 0.15s ease-out; - /* Portaled to , outside .portal-scope, so set the portal UI font explicitly. */ - font-family: var(--font-sans); -} -.portal-sidemodal__panel { - position: relative; - width: 100%; - max-width: 30rem; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); - padding: 1.35rem 1.4rem 1.4rem; - max-height: 86vh; - overflow-y: auto; -} -/* Wide enough for Calendly's two-pane layout (its single-column layout below ~680px inner width is - tall and scrolls); paired with the embed's taller fixed height so the time view needs no scroll. */ -.portal-sidemodal__panel--wide { - max-width: 52rem; -} -.portal-sidemodal__header { - margin-bottom: 1rem; - padding-right: 2rem; -} +/* Chrome and width come from the shared Modal via FlowModal; only type and content live here. */ .portal-sidemodal__title { margin: 0; font-size: 1.05rem; @@ -1316,15 +621,6 @@ color: var(--c-text-subtle); line-height: 1.55; } -.portal-sidemodal__footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - margin-top: 1.1rem; - padding-top: 0.9rem; - border-top: 1px solid var(--c-border-subtle); -} .portal-sidemodal__ghost { border: none; background: none; @@ -1336,63 +632,6 @@ color: var(--c-text-muted); } -/* Key documents ledger. */ -.portal-docs__group + .portal-docs__group { - margin-top: 1rem; -} -.portal-docs__group-title { - font-size: 0.6875rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--c-text-subtle); - margin-bottom: 0.4rem; -} -.portal-docs__list { - list-style: none; - margin: 0; - padding: 0; -} -.portal-docs__row { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.55rem 0; - border-top: 1px solid var(--c-border-subtle); -} -.portal-docs__row-text { - flex: 1; - min-width: 0; -} -.portal-docs__row-name { - display: block; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} -.portal-docs__row-sub { - display: block; - font-size: 0.72rem; - color: var(--c-text-subtle); - margin-top: 0.05rem; -} -.portal-docs__row-action { - font-size: 0.6875rem; - font-weight: 600; - border-radius: 999px; - padding: 0.2rem 0.6rem; - flex-shrink: 0; - color: var(--c-text-subtle); - background: var(--c-border-subtle); -} -.portal-docs__row-action[data-status="action"] { - color: var(--c-primary); - background: var(--c-primary-subtle); -} -.portal-docs__row-action[data-status="request"] { - color: var(--c-text-subtle); -} - /* Calendly scheduler embed (Schedule a call). The widget is always light (Calendly renders inputs on white), so give it a white surface — it reads as a clean card even inside the dark-mode modal. */ .portal-calendly { @@ -1418,17 +657,173 @@ } /* ── Agreement (security) step ────────────────────────────────────────────── */ -.portal-agreement__doc { - margin: 1rem 0; - max-height: 22rem; - overflow-y: auto; - padding: 1rem 1.1rem; - border: 1px solid var(--c-border); - border-radius: 10px; - background: var(--color-bg-subtle, var(--c-bg)); - font-size: 0.8125rem; - line-height: 1.55; +/* The terminal steps (payment, live): a stacked eyebrow/title/description with the flow's own footer + bar beneath. No card of their own — the dialog is already the surface. */ +.portal-procstage { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +/* Nothing to report on the left of these, so the actions take the whole bar. */ +.portal-procstage__foot { + justify-content: flex-end; +} + +/* The two document actions read as a pair, close together and set apart from the close beside them. */ +.portal-agreement__actions { + display: flex; + align-items: center; + gap: 0.1rem; +} + +/* The signature block: the three fields that name the bound party and its signatory, on the same line + as the act of signing, with the consent directly beneath them and no rule between. Fields shrink + below the row's usual floor so all three plus the button hold one line at the takeover's width; + they wrap rather than clip if a translation runs long. */ +/* No rule above it: the tray's grey ends where the signature block begins, which is boundary enough, + and a border there cut the document off from the page it sits on. */ +.portal-agreement__signbar { + flex-direction: column; + align-items: stretch; + gap: 0.6rem; + border-top: none; +} +/* Bottom-aligned: a field is a label above an input, so aligning to the block's centre or its top + leaves the button off the input row. Sharing the input's bottom edge puts its centre on theirs, both + being 37px. The gate note is deliberately NOT in this row — hanging it off the button would make the + column taller than the fields and drag the button back up off the row. */ +.portal-agreement__signrow { + display: flex; + align-items: flex-end; + gap: 0.9rem; +} +/* Consent on the left, the gate's state on the right so it lands under the button it explains. */ +.portal-agreement__signfoot { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.portal-agreement__gate { + flex: 0 0 auto; + font-size: 0.6875rem; color: var(--c-text-subtle); + white-space: nowrap; +} +.portal-agreement__signrow .portal-agreement__signfields { + flex: 1; + gap: 0.6rem; + min-width: 0; +} +.portal-agreement__signrow .portal-qb__field { + min-width: 8.5rem; +} +/* Unboxed: a line of small print under the fields, not a panel competing with the document. */ +.portal-agreement__accept { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.75rem; + line-height: 1.45; + color: var(--c-text-subtle); + cursor: pointer; +} +.portal-agreement__accept input { + margin: 0.1rem 0 0; + flex: 0 0 auto; +} + +/* The agreement is presented as paper, not as app copy in a box: a sunken tray running to the panel's + edges, with the terms on white stock in a serif face. Signing is the most consequential thing a + buyer does here, so the document should read like the document it is. */ +/* The document takes every pixel the dialog can spare: it is what the buyer is here to read, and a + fixed height left it a small white box floating in a tall panel. The chain has to carry the fill — + each link needs min-height:0 or a flex child refuses to shrink below its content. */ +.portal-agreement { + display: flex; + flex-direction: column; + gap: 0.75rem; + flex: 1 1 auto; + min-height: 0; +} +/* The tray is the scroll container, so the paper inside it behaves like a page being scrolled past a + window: flush to the footer while there is more to read, and revealing the tray's bottom padding + only at the end. Scrolling the paper instead left a permanent grey band under it, which read as a + box with contents rather than a document. */ +/* `scroll`, not `auto`, and an explicitly styled bar: signing is gated on reaching the end of the + document, so the buyer has to be able to see there is more of it and how far through they are. + Overlay scrollbars fade out when idle, which hid both. Styling the bar also opts Chromium out of + overlay behaviour, so it stays put. Matches the treatment on the files page. */ +.portal-agreement__tray { + background: var(--c-surface-sunken); + padding: 14px var(--flowmodal-inset); + margin: 0 calc(-1 * var(--flowmodal-inset)); + flex: 1 1 auto; + min-height: 0; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: var(--c-text-subtle) var(--c-border-subtle); +} +.portal-agreement__tray::-webkit-scrollbar { + width: 0.625rem; +} +.portal-agreement__tray::-webkit-scrollbar-track { + background: var(--c-border-subtle); + border-radius: 999px; +} +.portal-agreement__tray::-webkit-scrollbar-thumb { + background: var(--c-text-subtle); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} +.portal-agreement__tray::-webkit-scrollbar-thumb:hover { + background: var(--c-text-muted); + background-clip: content-box; +} +.portal-agreement__doc { + /* A floor so a short document still reads as a page; no ceiling, so a long one runs on and the tray + does the scrolling. */ + min-height: 16rem; + padding: 1.35rem 1.6rem; + border: 1px solid var(--c-border-subtle); + border-radius: 12px; + background: var(--c-surface); + box-shadow: var(--shadow-sm); + /* No serif token exists in the theme — the serif is specific to rendering legal terms as paper. */ + font-family: Georgia, "Times New Roman", "Liberation Serif", serif; + font-size: 0.84rem; + line-height: 1.62; + color: var(--c-text-muted); +} +/* Masthead above the document's own heading: confidentiality on the left, the quote reference and + signed version on the right, over the rule that opens the terms. */ +.portal-agreement__letterhead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding-bottom: 0.6rem; + margin-bottom: 1rem; + border-bottom: 2px solid var(--c-text); + font-family: var(--font-sans); + font-size: 0.6875rem; + letter-spacing: 0.04em; + color: var(--c-text-subtle); +} +.portal-agreement__confidential { + font-weight: 700; + text-transform: uppercase; +} +/* The document's own title, centred like an executed agreement's. */ +.portal-agreement__md > h1:first-child, +.portal-agreement__md > h2:first-child { + text-align: center; + font-size: 1.05rem; + letter-spacing: 0.02em; + text-transform: uppercase; + margin-bottom: 1rem; } .portal-agreement__doc h4 { margin: 1rem 0 0.35rem; @@ -1445,29 +840,129 @@ .portal-agreement__doc strong { color: var(--c-text); } -.portal-agreement__accept { - margin-top: 0.25rem; +.portal-agreement__md h1 { + font-size: 0.95rem; + font-weight: 700; + color: var(--c-text); + margin: 1.1rem 0 0.5rem; } -.portal-agreement__lines { - margin: 0.4rem 0 0.6rem; +.portal-agreement__md h2 { + font-size: 0.875rem; + font-weight: 650; + color: var(--c-text); + margin: 1rem 0 0.4rem; } -.portal-proc__reset { - display: flex; - justify-content: center; - padding-top: 0.5rem; +.portal-agreement__md h3 { + font-size: 0.8125rem; + font-weight: 650; + color: var(--c-text); + margin: 0.9rem 0 0.3rem; } -.portal-proc__reset button { +.portal-agreement__md h1:first-child, +.portal-agreement__md h2:first-child { + margin-top: 0; +} +.portal-agreement__md p { + margin: 0 0 0.55rem; +} +.portal-agreement__md strong { + color: var(--c-text); +} +.portal-agreement__md ul { + margin: 0 0 0.6rem; + padding-left: 1.1rem; +} +.portal-agreement__md li { + margin-bottom: 0.2rem; +} +.portal-agreement__md table { + border-collapse: collapse; + width: 100%; + margin: 0.4rem 0 0.8rem; + font-size: 0.78rem; +} +.portal-agreement__md th, +.portal-agreement__md td { + border: 1px solid var(--c-border); + padding: 0.35rem 0.5rem; + text-align: left; + vertical-align: top; +} +.portal-agreement__md th { + background: var(--c-bg); + font-weight: 650; + color: var(--c-text); +} +.portal-agreement__signfields { + margin-top: 0.75rem; +} +.portal-proc__error { + color: var(--c-danger); + font-size: 0.8125rem; + margin: 0.5rem 0 0; +} +.portal-legal__link { border: none; background: none; + padding: 0; + font: inherit; + color: var(--c-text); + text-decoration: underline; + cursor: pointer; +} +.portal-legal__link:hover { + color: var(--c-text); +} +.portal-docmodal { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.portal-docmodal__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 0; + border-bottom: 1px solid var(--c-border); +} +.portal-docmodal__row:last-child { + border-bottom: none; +} +.portal-docmodal__text { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} +.portal-docmodal__name { + font-weight: 650; + font-size: 0.875rem; + color: var(--c-text); +} +.portal-docmodal__sub { + font-size: 0.75rem; + color: var(--c-text-muted); +} +.portal-docmodal__later { + flex-shrink: 0; font-size: 0.75rem; color: var(--c-text-subtle); - cursor: pointer; - text-decoration: underline; + white-space: nowrap; } -.portal-proc__reset button:hover:not(:disabled) { + +/* Title row: the step badge rides beside the heading, not on a line of its own. */ +.portal-sidemodal__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +/* Quiet hint sat opposite the primary action in a dialog footer. */ +.portal-sidemodal__foot-hint { + font-size: 0.75rem; color: var(--c-text-subtle); } -.portal-proc__reset button:disabled { - opacity: 0.5; - cursor: default; -} diff --git a/frontend/editor/src/portal/views/Procurement.tsx b/frontend/editor/src/portal/views/Procurement.tsx deleted file mode 100644 index 1f6e2329a9..0000000000 --- a/frontend/editor/src/portal/views/Procurement.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; -import "@portal/views/Procurement.css"; - -/** - * /procurement — procurement is no longer a nav tab; it lives on Home as the deal-status hero. - * This route is kept for deep links (and the Usage "Build your quote" CTA): it renders the same - * surface, opening the takeover modal once a deal is underway. - */ -export function Procurement() { - return ( -
    - -
    - ); -} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d2853d8f1e..256bdf640a 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -258,12 +258,7 @@ export default defineConfig( // can't represent. Exempt ONLY the raw- ); diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 7d09261226..742334015c 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -81,9 +81,10 @@ const EXPANDED_WIDTH = "16.25rem"; // ~260px const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder"; -// Stable empty props for rows without folders, so the memoized FileItem -// isn't re-rendered by a fresh `?? []` identity on every list render. +// Stable empty props for rows without folders/policies, so the memoized +// FileItem isn't re-rendered by a fresh `?? []` identity on every list render. const NO_FOLDERS: never[] = []; +const NO_POLICIES: never[] = []; /** Only surface the "Adding files…" progress row for drops big enough that the * pre-dispatch scan is user-visible; small adds finish before it would paint. */ @@ -790,7 +791,7 @@ const FileSidebar = forwardRef( onDragStart={handleWatchedFolderDragStart} folders={memberFolders} onFolderClick={openWatchedFolder} - policies={policyFileBadges.get(stub.id as string) ?? []} + policies={policyFileBadges.get(stub.id as string) ?? NO_POLICIES} onDelete={isWatchedFoldersActive ? undefined : handleSidebarDelete} onSaveToCloud={isWatchedFoldersActive ? undefined : handleSaveToCloud} canSaveToCloud={storageEnabled && fileOrigin !== "shared-with-me"} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index a289a245f1..c1a93594c5 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -167,7 +167,9 @@ export interface FileItemProps { const MAX_VISIBLE_FOLDER_TAGS = 2; -export function FileItem({ +// Memoized: sidebar rows bail out unless THEIR props change, so one file's +// update (e.g. a new version landing) re-renders one row, not the whole list. +export const FileItem = React.memo(function FileItem({ fileId, name, size, @@ -509,4 +511,4 @@ export function FileItem({ )} ); -} +}); diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.css b/frontend/editor/src/core/components/shared/PolicyBadges.css index 3a919e24c9..8526de44c6 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.css +++ b/frontend/editor/src/core/components/shared/PolicyBadges.css @@ -41,22 +41,3 @@ transform: rotate(360deg); } } - -.policy-badge--recent { - animation: policy-badge-pulse 4.5s ease-in-out forwards; -} -@keyframes policy-badge-pulse { - 0%, - 24%, - 48% { - box-shadow: 0 0 0 0 transparent; - } - 12%, - 36% { - box-shadow: 0 0 5px 2px currentColor; - } - 60%, - 100% { - box-shadow: 0 0 0 0 transparent; - } -} diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx index 81969bf615..c3646f774a 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx +++ b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx @@ -2,10 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { PolicyBadges } from "@app/components/shared/PolicyBadges"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; +// Real catalog category ids, so each badge renders its own shared glyph +// (policyCategoryIcon) rather than the unknown-category fallback. Accents mirror +// policyAccentVar's mapping — that lives in the proprietary layer, which a core +// story can't import. const mockPolicies: FileItemPolicyRef[] = [ - { id: "policy-1", name: "Redact PII", accentColor: "#e03131", recent: true }, - { id: "policy-2", name: "Sanitize", accentColor: "#2f9e44", recent: false }, - { id: "policy-3", name: "Watermark", accentColor: "#4263eb", recent: false }, + { id: "security", name: "Redact PII", accentColor: "var(--color-purple)" }, + { id: "compliance", name: "Sanitize", accentColor: "var(--color-green)" }, + { id: "ingestion", name: "Watermark", accentColor: "var(--color-blue)" }, ]; const meta = { @@ -22,15 +26,25 @@ export const Default: Story = { }, }; +/** A blocking policy mid-run: spinner, and the file's exit points are gated. */ export const Enforcing: Story = { + args: { + policies: [ + { ...mockPolicies[0], enforcing: true }, + ...mockPolicies.slice(1), + ], + }, +}; + +/** A non-blocking run (classification tagging): same spinner, nothing gated. */ +export const Background: Story = { args: { policies: [ { - id: "policy-1", - name: "Redact PII", - accentColor: "#e03131", - recent: false, - enforcing: true, + id: "classification", + name: "Classification", + accentColor: "var(--color-orange)", + background: true, }, ...mockPolicies.slice(1), ], diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.tsx index f8661b9176..1fa57ca70a 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.tsx +++ b/frontend/editor/src/core/components/shared/PolicyBadges.tsx @@ -1,6 +1,6 @@ import { Tooltip } from "@mantine/core"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import AutorenewIcon from "@mui/icons-material/Autorenew"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import { useTranslation } from "react-i18next"; import "@app/components/shared/PolicyBadges.css"; @@ -10,20 +10,20 @@ export interface FileItemPolicyRef { name: string; /** CSS colour for the badge (matches the policy's accent). */ accentColor: string; - /** True only just after the policy was applied — drives the one-off glow, so - * it doesn't replay on every reload of an already-enforced file. */ - recent: boolean; - /** True while the policy run is actively in-flight on this file. */ + /** True while a BLOCKING policy run is in-flight on this file (gates actions). */ enforcing?: boolean; + /** True while a non-blocking run (e.g. classification) is in-flight — shows + * the same spinner but never gates anything. */ + background?: boolean; } const MAX_VISIBLE = 3; /** - * The canonical policy badge row: one accent-tinted shield per policy that has - * run on a file, spinning while a run is in flight, glowing briefly after it - * lands. Every surface that shows per-file policy badges (file sidebar, file - * editor thumbnails, files page) renders this so they stay identical. + * The canonical policy badge row: one accent-tinted category icon per policy + * that has run on a file, spinning while a run is in flight. Every surface that + * shows per-file policy badges (file sidebar, file editor thumbnails, files + * page) renders this so they stay identical. */ export function PolicyBadges({ policies, @@ -40,33 +40,40 @@ export function PolicyBadges({ className={`policy-badges${className ? ` ${className}` : ""}`} data-no-select > - {policies.slice(0, MAX_VISIBLE).map((policy) => ( - - { + const running = policy.enforcing || policy.background; + return ( + - {policy.enforcing ? ( - - ) : ( - - )} - - - ))} + + {running ? ( + + ) : ( + policyCategoryIcon(policy.id, { fontSize: "0.7rem" }) + )} + + + ); + })} ); } diff --git a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx index cb9e931e80..9d5d2475b3 100644 --- a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx @@ -4,6 +4,8 @@ export function PolicyEnforcingOverlay(_props: { zIndex?: number; /** CSS colour var for the enforcing policy's accent; tints the icon/spinner. */ accentVar?: string; + /** Category of the enforcing policy — picks its icon in the real overlay. */ + categoryId?: string; }) { return null; } diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 5cc1e6cabd..55ca01d307 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -19,7 +19,8 @@ import { } from "@app/components/filesPage/filesPageReturnRoute"; import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; import { - useFileState, + useAllFiles, + useFileSelectors, useFileSelection, useFileActions, } from "@app/contexts/FileContext"; @@ -121,10 +122,10 @@ export default function WorkbenchBar({ const { sharingEnabled } = useSharingEnabled(); const viewerContext = React.useContext(ViewerContext); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { selectedFiles, selectedFileIds } = useFileSelection(); const { actions: fileActions } = useFileActions(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileId, setActiveFileId } = useViewer(); const policyFileBadges = usePolicyFileBadges(); // Block print/export while any file the export would touch is under active diff --git a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx index cc33ccbbb4..fe4a6f8e48 100644 --- a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx @@ -11,7 +11,7 @@ import { } from "@app/utils/convertUtils"; import { getConversionEndpoints } from "@app/data/toolsTaxonomy"; import { useFileSelection } from "@app/contexts/FileContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { useFileSelector, useFileSelectors } from "@app/contexts/FileContext"; import { detectFileExtension } from "@app/utils/fileUtils"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus"; @@ -62,8 +62,8 @@ const ConvertSettings = ({ const { t } = useTranslation(); const theme = useMantineTheme(); const { setSelectedFiles } = useFileSelection(); - const { state, selectors } = useFileState(); - const activeFiles = state.files.ids; + const selectors = useFileSelectors(); + const activeFiles = useFileSelector((s) => s.files.ids); const { preferences } = usePreferences(); const allEndpoints = useMemo(() => { diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index 8c89029913..7918bac7ca 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -11,7 +11,7 @@ import { Tooltip } from "@app/components/shared/Tooltip"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; import { saveOperationResults } from "@app/services/operationResultsSaveService"; -import { useFileActions, useFileState } from "@app/contexts/FileContext"; +import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; import { FileId } from "@app/types/fileContext"; import i18n from "@app/i18n"; @@ -40,7 +40,7 @@ function ReviewStepContent({ const DownloadIcon = icons.download; const stepRef = useRef(null); const { actions: fileActions } = useFileActions(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const handleUndo = async () => { try { diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index cc4a04598e..be4a4a9971 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -12,7 +12,12 @@ import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import LockIcon from "@mui/icons-material/Lock"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileSelector, + useFileSelectors, + useFileActions, +} from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; @@ -259,9 +264,9 @@ const EmbedPdfViewerContent = ({ const redactionTrackerRef = useRef(null); // Get current file from FileContext - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { actions } = useFileActions(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const activeFilesRef = useRef(activeFiles); activeFilesRef.current = activeFiles; const activeFileIds = activeFiles.map((f) => f.fileId); @@ -392,11 +397,11 @@ const EmbedPdfViewerContent = ({ }, [previewFile, fileWithUrl]); // Check if the current file is encrypted (gate the viewer to prevent PDFium crash) - const isCurrentFileEncrypted = React.useMemo(() => { - if (!currentFile || !isStirlingFile(currentFile)) return false; - const stub = selectors.getStirlingFileStub(currentFile.fileId); - return stub?.processedFile?.isEncrypted === true; - }, [currentFile, selectors]); + const isCurrentFileEncrypted = useFileSelector((s) => + currentFile && isStirlingFile(currentFile) + ? s.files.byId[currentFile.fileId]?.processedFile?.isEncrypted === true + : false, + ); const bookmarkCacheKey = React.useMemo(() => { if (currentFile && isStirlingFile(currentFile)) { diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx index ed4a2e2263..bd2704963a 100644 --- a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx @@ -4,7 +4,7 @@ import { Button } from "@app/ui/Button"; import ArticleIcon from "@mui/icons-material/Article"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { @@ -126,8 +126,7 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) { // ─── Wrapper that resolves the active file from FileContext ─────────────────── export function NonPdfViewerWrapper(props: ViewerProps) { - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileIndex } = useViewer(); const file = diff --git a/frontend/editor/src/core/components/viewer/Viewer.tsx b/frontend/editor/src/core/components/viewer/Viewer.tsx index 08103ca1f5..36a1080dbb 100644 --- a/frontend/editor/src/core/components/viewer/Viewer.tsx +++ b/frontend/editor/src/core/components/viewer/Viewer.tsx @@ -5,7 +5,7 @@ import { NonPdfViewerWrapper, type ViewerProps, } from "@app/components/viewer/NonPdfViewer"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { isStirlingFile } from "@app/types/fileContext"; import { isPdfFile } from "@app/utils/fileUtils"; @@ -26,8 +26,7 @@ type SignatureOverlayPassThrough = Pick< >; const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileId } = useViewer(); // Determine the active file — previewFile takes priority, then look up by stable ID diff --git a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx index 32b4b1ce11..fda6c2d2a5 100644 --- a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx @@ -5,7 +5,11 @@ import { ActionIcon } from "@app/ui/ActionIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; import { ViewerContext } from "@app/contexts/ViewerContext"; import { useSignature } from "@app/contexts/SignatureContext"; -import { useFileState, useFileContext } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileSelectors, + useFileContext, +} from "@app/contexts/FileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { useNavigationState, @@ -39,9 +43,9 @@ export default function ViewerAnnotationControls({ const { historyApiRef, isPlacementMode } = useSignature(); // File state for save functionality - const { state, selectors } = useFileState(); + const selectors = useFileSelectors(); + const { files: activeFiles, fileIds } = useAllFiles(); const { actions: fileActions } = useFileContext(); - const activeFiles = selectors.getFiles(); // Check if we're in sign mode or redaction mode const { selectedTool } = useNavigationState(); @@ -83,7 +87,7 @@ export default function ViewerAnnotationControls({ !historyApiRef?.current?.canUndo() ) return; - if (activeFiles.length === 0 || state.files.ids.length === 0) return; + if (activeFiles.length === 0 || fileIds.length === 0) return; try { const arrayBuffer = await viewerContext.exportActions.saveAsCopy(); @@ -92,7 +96,7 @@ export default function ViewerAnnotationControls({ const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: "application/pdf", }); - const parentStub = selectors.getStirlingFileStub(state.files.ids[0]); + const parentStub = selectors.getStirlingFileStub(fileIds[0]); if (!parentStub) return; const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( @@ -100,11 +104,7 @@ export default function ViewerAnnotationControls({ parentStub, "redact", ); - await fileActions.consumeFiles( - [state.files.ids[0]], - stirlingFiles, - stubs, - ); + await fileActions.consumeFiles([fileIds[0]], stirlingFiles, stubs); // Clear unsaved changes flags after successful save setHasUnsavedChanges(false); diff --git a/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx index f063cbffe2..4ef4b30cda 100644 --- a/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx @@ -9,7 +9,7 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { Tooltip } from "@app/components/shared/Tooltip"; import ShareManagementModal from "@app/components/shared/ShareManagementModal"; import { useViewer } from "@app/contexts/ViewerContext"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; import { uploadHistoryChain } from "@app/services/serverStorageUpload"; import { fileStorage } from "@app/services/fileStorage"; import { alert } from "@app/components/toast"; @@ -39,7 +39,7 @@ export default function ViewerShareButton({ }: ViewerShareButtonProps) { const { t } = useTranslation(); const { activeFileId } = useViewer(); - const { selectors } = useFileState(); + const { fileStubs } = useAllFiles(); const { actions } = useFileActions(); const [confirmOpen, setConfirmOpen] = useState(false); const [saving, setSaving] = useState(false); @@ -49,7 +49,7 @@ export default function ViewerShareButton({ // Resolve strictly to the file shown in the viewer. Never fall back to an // arbitrary file — sharing the wrong document would be worse than not // sharing. If there's no active file, the button is disabled (see isDisabled). - const stubs = selectors.getStirlingFileStubs(); + const stubs = fileStubs; const stub = activeFileId ? stubs.find((s) => s.id === activeFileId) : undefined; diff --git a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx index 2501ddfb79..0cb11837cc 100644 --- a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx @@ -3,7 +3,7 @@ import { useZoom, ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useSpread, SpreadMode } from "@embedpdf/plugin-spread/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { determineAutoZoom, DEFAULT_FALLBACK_ZOOM, @@ -36,7 +36,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { const { provides: zoom, state: zoomState } = useZoom(documentId); const { spreadMode } = useSpread(documentId); const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); - const { selectors } = useFileState(); + const { fileStubs } = useAllFiles(); const hasSetInitialZoom = useRef(false); const lastSpreadMode = useRef(spreadMode ?? SpreadMode.None); @@ -62,7 +62,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { } }, []); - const stubs = selectors.getStirlingFileStubs(); + const stubs = fileStubs; const firstFileStub = stubs[0]; const firstFileId = firstFileStub?.id; diff --git a/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts b/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts index c2ccdec1be..5352ffc748 100644 --- a/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts +++ b/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { computeReadAloudHighlightRect } from "@app/components/viewer/readAloudHighlight"; -import { useFileState } from "@app/contexts/FileContext"; +import { useFileSelectors } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useStopReadAloudOnNavigation } from "@app/components/viewer/useStopReadAloudOnNavigation"; import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; @@ -60,7 +60,7 @@ function createHighlightElement( export function useViewerReadAloud(defaultLanguage?: string) { const viewer = useViewer(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const [isReadingAloud, setIsReadingAloud] = useState(false); const [speechRate, setSpeechRate] = useState(1); diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index d0808665cd..a982c347ca 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -16,6 +16,7 @@ import { useReducer, useCallback, useEffect, + useLayoutEffect, useRef, useMemo, useState, @@ -23,7 +24,6 @@ import { import { FileContextProviderProps, FileContextSelectors, - FileContextStateValue, FileContextActionsValue, FileContextActions, FileId, @@ -36,6 +36,7 @@ import { import { fileContextReducer, initialFileContextState, + withReducerIdentityGuard, } from "@app/contexts/file/FileReducer"; import { createFileSelectors } from "@app/contexts/file/fileSelectors"; import { @@ -49,8 +50,9 @@ import { } from "@app/contexts/file/fileActions"; import { FileLifecycleManager } from "@app/contexts/file/lifecycle"; import { - FileStateContext, + FileStoreContext, FileActionsContext, + type FileStateStore, } from "@app/contexts/file/contexts"; import { IndexedDBProvider, @@ -75,10 +77,13 @@ function FileContextInner({ children, enablePersistence = true, }: FileContextProviderProps) { - const [state, dispatch] = useReducer( - fileContextReducer, - initialFileContextState, + // Guarded in dev: warns if a reducer case reallocates a slice without changing + // it, which would silently defeat the selector-subscription bail-out. + const guardedReducer = useMemo( + () => withReducerIdentityGuard(fileContextReducer), + [], ); + const [state, dispatch] = useReducer(guardedReducer, initialFileContextState); // Always call the hook unconditionally to satisfy React's rules of hooks. // IndexedDB context is only used when enablePersistence is true. @@ -657,14 +662,28 @@ function FileContextInner({ ], ); - // Split context values to minimize re-renders - const stateValue = useMemo( + // Subscription store bridge: the context value is STABLE, so consumers only + // re-render when the slice they select (via useFileSelector) changes — not on + // every state change. Listeners are notified after each committed state. + const listenersRef = useRef void>>(new Set()); + const store = useMemo( () => ({ - state, + getState: () => stateRef.current, + subscribe: (listener) => { + listenersRef.current.add(listener); + return () => { + listenersRef.current.delete(listener); + }; + }, selectors, }), - [state, selectors], + [selectors], ); + // Layout effect (not passive): subscribers re-render before the browser + // paints, so a state change can never show a frame with stale consumers. + useLayoutEffect(() => { + for (const listener of listenersRef.current) listener(); + }, [state]); const actionsValue = useMemo( () => ({ @@ -698,7 +717,7 @@ function FileContextInner({ }, [lifecycleManager]); return ( - + {children} - + ); } @@ -758,6 +777,10 @@ export function FileContextProvider({ export { useFileState, useFileActions, + useFileSelector, + useFileSelectors, + useFileIndex, + shallowEqual, useCurrentFile, useFileSelection, useFileManagement, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 22d95606c1..5e5d08dbb4 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -9,7 +9,11 @@ import React, { useCallback, } from "react"; import { useNavigation } from "@app/contexts/NavigationContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { + useFileIndex, + useFileSelector, + useFileSelectors, +} from "@app/contexts/FileContext"; import { isStirlingFile } from "@app/types/fileContext"; import type { FileId } from "@app/types/file"; import { enforceExportPolicies } from "@app/services/policyExport"; @@ -244,25 +248,21 @@ export const ViewerProvider: React.FC = ({ children }) => { const [activeFileId, setActiveFileId] = useState(null); // activeFileIndex is derived from activeFileId so they can never desync. - // ViewerProvider sits inside FileContextProvider so useFileState is valid here. - const { selectors, state } = useFileState(); + // ViewerProvider sits inside FileContextProvider so these hooks are valid here. + const selectors = useFileSelectors(); + const fileIds = useFileSelector((s) => s.files.ids); // Clear activeFileId when its file is removed from the workbench. // Dep on state.files.ids so the effect re-runs on every add/remove. useEffect(() => { if (!activeFileId) return; - const stillInWorkbench = state.files.ids.some( + const stillInWorkbench = fileIds.some( (id) => (id as string) === activeFileId, ); if (!stillInWorkbench) setActiveFileId(null); - }, [activeFileId, state.files.ids]); + }, [activeFileId, fileIds]); - const activeFileIndex = useMemo(() => { - if (!activeFileId) return 0; - const files = selectors.getFiles(); - const idx = files.findIndex((f) => f.fileId === activeFileId); - return idx >= 0 ? idx : 0; - }, [activeFileId, selectors]); + const activeFileIndex = useFileIndex(activeFileId); const setActiveFileIndex = useCallback( (index: number) => { const files = selectors.getFiles(); diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 91bd62cf4d..2697974823 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -425,3 +425,83 @@ export function fileContextReducer( return state; } } + +// ── Dev-only structural-sharing guard ────────────────────────────────────── +// +// The file hooks bail a consumer out of re-rendering when the slice it selects +// keeps its object identity across a dispatch. That optimisation silently +// breaks if a reducer case returns a NEW identity for a slice it didn't +// actually change (e.g. an unnecessary `{ ...state.files }`): every consumer of +// that slice re-renders for nothing, with no test failure. This wrapper warns +// when that happens. No-op in production. + +function idsUnchanged(a: FileId[], b: FileId[]): boolean { + return a.length === b.length && a.every((id, i) => id === b[i]); +} + +function byIdUnchanged( + a: Record, + b: Record, +): boolean { + const keysA = Object.keys(a); + return ( + keysA.length === Object.keys(b).length && + keysA.every((id) => a[id as FileId] === b[id as FileId]) + ); +} + +function uiUnchanged( + a: FileContextState["ui"], + b: FileContextState["ui"], +): boolean { + return (Object.keys(a) as Array).every( + (k) => a[k] === b[k], + ); +} + +function setUnchanged(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +/** + * Wrap a reducer so, outside production, it warns when an action reallocates a + * top-level state slice without changing its contents — which would defeat the + * selector-subscription bail-out in the file hooks. + */ +export function withReducerIdentityGuard( + reducer: (s: FileContextState, a: FileContextAction) => FileContextState, +): (s: FileContextState, a: FileContextAction) => FileContextState { + if (process.env.NODE_ENV === "production") return reducer; + return (state, action) => { + const next = reducer(state, action); + if (next === state) return next; + if ( + next.files !== state.files && + idsUnchanged(next.files.ids, state.files.ids) && + byIdUnchanged(next.files.byId, state.files.byId) + ) { + console.error( + `[FileReducer] '${action.type}' reallocated state.files without changing it — ` + + "this re-renders every file consumer for nothing. Return the existing slice unchanged.", + ); + } + if (next.ui !== state.ui && uiUnchanged(next.ui, state.ui)) { + console.error( + `[FileReducer] '${action.type}' reallocated state.ui without changing it — ` + + "this re-renders every UI consumer for nothing. Return the existing slice unchanged.", + ); + } + if ( + next.pinnedFiles !== state.pinnedFiles && + setUnchanged(next.pinnedFiles, state.pinnedFiles) + ) { + console.error( + `[FileReducer] '${action.type}' reallocated state.pinnedFiles without changing it — ` + + "this re-renders every pinned-files consumer for nothing.", + ); + } + return next; + }; +} diff --git a/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts b/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts new file mode 100644 index 0000000000..faf6501a0c --- /dev/null +++ b/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { fileContextReducer } from "@app/contexts/file/FileReducer"; +import type { + FileContextAction, + FileContextState, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * Classification is non-blocking: while it runs, the user can manually run a + * tool on the same file. Classification's only write is a metadata-only, + * shallow-merged UPDATE_FILE_RECORD stamping `classificationLabels`; a manual + * tool run produces a NEW document via CONSUME_FILES (new id + version). These + * tests drive the REAL reducer through every interleaving (classification lands + * before / during / after the tool run) and prove the invariant the design + * relies on: the tool's output document is byte-for-byte what the tool produced, + * regardless of when classification lands. (Label PLACEMENT in the mid-run race + * is the orchestration's job — usePolicyAutoRun resolves targets at write time; + * see usePolicyAutoRun.race.test.tsx. Here we lock the reducer backstop.) + */ + +const stub = ( + id: string, + extra: Partial = {}, +): StirlingFileStub => + ({ + id: id as FileId, + name: "doc.pdf", + versionNumber: 1, + ...extra, + }) as StirlingFileStub; + +function stateWith(...stubs: StirlingFileStub[]): FileContextState { + return { + files: { + ids: stubs.map((s) => s.id), + byId: Object.fromEntries(stubs.map((s) => [s.id, s])) as Record< + FileId, + StirlingFileStub + >, + }, + pinnedFiles: new Set(), + ui: { + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false, + errorFileIds: [], + }, + }; +} + +const LABELS = ["Invoice"]; + +// A manual tool run on `inputId` producing a new versioned document `outputId`. +// Mirrors what useToolOperation dispatches: the reducer stamps provenance +// (derivedFromTool, sourceFileIds) and inherits labels itself. +const toolRun = (inputId: string, outputId: string): FileContextAction => ({ + type: "CONSUME_FILES", + payload: { + inputFileIds: [inputId as FileId], + outputStirlingFileStubs: [stub(outputId, { versionNumber: 2 })], + silent: false, + }, +}); + +// Classification stamping labels onto a target id (the reducer merges shallowly). +const classify = (targetId: string): FileContextAction => ({ + type: "UPDATE_FILE_RECORD", + payload: { + id: targetId as FileId, + updates: { classificationLabels: LABELS }, + }, +}); + +describe("classification landing vs a manually-run tool", () => { + it("PRE: classification lands first — tool output is correct AND inherits the label", () => { + let s = stateWith(stub("orig")); + s = fileContextReducer(s, classify("orig")); + s = fileContextReducer(s, toolRun("orig", "out")); + + const out = s.files.byId["out" as FileId]; + expect(out).toBeDefined(); + expect(out.versionNumber).toBe(2); // the document the tool produced + expect(s.files.byId["orig" as FileId]).toBeUndefined(); // input consumed + // Label carried forward onto the tool's new version. + expect(out.classificationLabels).toEqual(LABELS); + }); + + it("POST: classification lands after the tool run, targeting the new leaf — output untouched, label applied, nothing else clobbered", () => { + let s = stateWith(stub("orig")); + s = fileContextReducer(s, toolRun("orig", "out")); + + const before = s.files.byId["out" as FileId]; + // classificationLabelTargets resolves the run's descendants: "out" matches + // because its sourceFileIds includes "orig". + expect(before.sourceFileIds).toContain("orig" as FileId); + + s = fileContextReducer(s, classify("out")); + const after = s.files.byId["out" as FileId]; + + // The label write is a shallow merge: ONLY classificationLabels changes. + expect(after.classificationLabels).toEqual(LABELS); + expect({ ...after, classificationLabels: undefined }).toEqual({ + ...before, + classificationLabels: undefined, + }); + expect(after.versionNumber).toBe(2); + }); + + it("MID (the race): a label write aimed at an already-consumed id no-ops — output document is CORRECT, nothing is resurrected", () => { + // In production this stale-id write no longer happens: usePolicyAutoRun + // resolves the label targets AT WRITE TIME, so the labels land on the live + // leaf instead (see usePolicyAutoRun.race.test.tsx). This test locks the + // reducer-level BACKSTOP behind that: even if a stale id does get written, + // it cannot corrupt or resurrect anything. + let s = stateWith(stub("orig")); + + const staleTargetId = "orig"; + + // During that window the user runs a tool: orig -> out. orig had no labels + // yet, so the new leaf inherits none. + s = fileContextReducer(s, toolRun("orig", "out")); + const out = s.files.byId["out" as FileId]; + expect(out.versionNumber).toBe(2); + expect(out.classificationLabels).toBeUndefined(); + + // Classification's write finally lands — on the now-consumed snapshot id. + const beforeWrite = s; + s = fileContextReducer(s, classify(staleTargetId)); + + // No-op on a missing record: reducer returns the SAME state reference, so no + // zombie "orig" record is resurrected and nothing is corrupted. + expect(s).toBe(beforeWrite); + expect(s.files.byId["orig" as FileId]).toBeUndefined(); + + // The tool's output document is intact and exactly what the tool produced. + const finalOut = s.files.byId["out" as FileId]; + expect(finalOut.versionNumber).toBe(2); + expect(finalOut.sourceFileIds).toContain("orig" as FileId); + // At the reducer level the stale write leaves the leaf unlabelled — which + // is why the orchestration resolves targets at write time instead. The + // DOCUMENT is unaffected either way. + expect(finalOut.classificationLabels).toBeUndefined(); + }); + + it("classification can never overwrite a tool output's document fields (only the label)", () => { + // Tool output already carries its own state; classification must not disturb it. + let s = stateWith( + stub("out", { + versionNumber: 7, + thumbnailUrl: "blob:thumb", + isPinned: true, + } as Partial), + ); + + s = fileContextReducer(s, classify("out")); + const after = s.files.byId["out" as FileId]; + + expect(after.versionNumber).toBe(7); + expect(after.thumbnailUrl).toBe("blob:thumb"); + expect((after as { isPinned?: boolean }).isPinned).toBe(true); + expect(after.classificationLabels).toEqual(LABELS); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/contexts.ts b/frontend/editor/src/core/contexts/file/contexts.ts index c17a178043..6bc74e11ae 100644 --- a/frontend/editor/src/core/contexts/file/contexts.ts +++ b/frontend/editor/src/core/contexts/file/contexts.ts @@ -4,14 +4,28 @@ import { createContext } from "react"; import { + FileContextState, + FileContextSelectors, FileContextStateValue, FileContextActionsValue, } from "@app/types/fileContext"; -// Split contexts for performance -export const FileStateContext = createContext< - FileContextStateValue | undefined ->(undefined); +/** + * Subscription store for file state. The context VALUE is stable — consumers + * subscribe and select slices (see useFileSelector), re-rendering only when + * their selected slice changes, instead of on every state change. + */ +export interface FileStateStore { + getState: () => FileContextState; + subscribe: (listener: () => void) => () => void; + /** Stable selector API (reads live state via refs). */ + selectors: FileContextSelectors; +} + +export const FileStoreContext = createContext( + undefined, +); + export const FileActionsContext = createContext< FileContextActionsValue | undefined >(undefined); diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index 77447003f6..db99e1c673 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -370,32 +370,57 @@ export async function addFiles( // Collect hydrations to schedule after dispatch so updateStirlingFileStub finds files in state. const pendingHydrations: Array<() => Promise> = []; + // Per-chunk persistence promises (kicked off as chunks flush, awaited before + // return). See flushChunk — we stream writes instead of one batch at the end. + const persistPromises: Array> = []; - // Stream the batch into the workspace in chunks. The per-file pre-scan below - // (dedupe, encryption sniff — which reads each PDF's bytes) takes real time - // for a big folder drop; a single end-of-loop dispatch would leave the UI - // frozen-looking for seconds and then dump hundreds of rows in one render. - // Chunked dispatch keeps rows (and their thumbnail hydrations) streaming in, - // and the progress store drives the sidebar's "Adding files…" indicator. - const DISPATCH_CHUNK = 25; + // Dispatch stubs in chunks so rows (and thumbnail hydrations) stream in + // rather than dumping the whole drop in one render. + const DISPATCH_CHUNK = 5; let flushedStubs = 0; let flushedHydrations = 0; - const flushChunk = () => { - if ( - !options.skipWorkspaceDispatch && - stirlingFileStubs.length > flushedStubs - ) { - dispatch({ - type: "ADD_FILES", - payload: { stirlingFileStubs: stirlingFileStubs.slice(flushedStubs) }, - }); + // Flushes the pending chunk and returns this chunk's persistence promises, + // so the caller can await the writes (see the loop's yield) before the policy + // auto-run tries to read the file back from storage. + const flushChunk = (): Array> => { + const chunkWrites: Array> = []; + if (stirlingFileStubs.length > flushedStubs) { + const from = flushedStubs; + const newStubs = stirlingFileStubs.slice(from); flushedStubs = stirlingFileStubs.length; + if (!options.skipWorkspaceDispatch) { + dispatch({ + type: "ADD_FILES", + payload: { stirlingFileStubs: newStubs }, + }); + } + // Persist each chunk as it flushes, not one batch at the end: the policy + // auto-run reads files from IndexedDB with no in-memory fallback. + if (enablePersistence) { + const newFiles = stirlingFiles.slice(from); + for (let i = 0; i < newFiles.length; i++) { + const sf = newFiles[i]; + const stub = newStubs[i]; + const write = fileStorage + .storeStirlingFile(sf, stub) + .catch((error) => { + console.error( + "Failed to persist file to storage:", + sf.name, + error, + ); + }); + chunkWrites.push(write); + persistPromises.push(write); + } + } } // Hydrations only after their chunk is dispatched, so // updateStirlingFileStub finds the files in state. while (flushedHydrations < pendingHydrations.length) { scheduleMetadataHydration(pendingHydrations[flushedHydrations++]); } + return chunkWrites; }; reportBulkAddProgress(0, filesToProcess.length); @@ -554,38 +579,25 @@ export async function addFiles( reportBulkAddProgress(++scannedCount, filesToProcess.length); if (stirlingFileStubs.length - flushedStubs >= DISPATCH_CHUNK) { - flushChunk(); + const chunkWrites = flushChunk(); + // Yield a MACROTASK so React commits this chunk and runs its effects + // (incl. the policy-enforcement dispatch) before the next chunk scans. + // The per-file awaits above are only microtasks, which don't give React + // a turn — without this, all dispatches batch and processing can't begin + // until the whole drop is scanned. Awaiting the chunk's writes first means + // the auto-run finds each file's bytes already committed in storage. + await Promise.all(chunkWrites); + await new Promise((resolve) => setTimeout(resolve)); } } // Flush the remainder (also the sole dispatch for small batches). flushChunk(); - // Persist to storage if enabled using fileStorage service - if (enablePersistence && stirlingFiles.length > 0) { - await Promise.all( - stirlingFiles.map(async (stirlingFile, index) => { - try { - // Get corresponding stub with all metadata - const fileStub = stirlingFileStubs[index]; - - // Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly - await fileStorage.storeStirlingFile(stirlingFile, fileStub); - - if (DEBUG) - console.log( - `📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, - fileStub, - ); - } catch (error) { - console.error( - "Failed to persist file to storage:", - stirlingFile.name, - error, - ); - } - }), - ); + // Wait for the per-chunk writes (streamed in flushChunk) to commit, so + // addFiles only resolves once every file is durably stored. + if (enablePersistence && persistPromises.length > 0) { + await Promise.all(persistPromises); } if (!options.skipUploadTracking && stirlingFiles.length > 0) { diff --git a/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx b/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx new file mode 100644 index 0000000000..5a8f3243f9 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, act } from "@testing-library/react"; +import { useEffect } from "react"; +import { MantineProvider } from "@mantine/core"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileContext, + useFileSelection, + useFileSelectors, + useStirlingFileStub, + useFileActions, +} from "@app/contexts/file/fileHooks"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import type { FileContextAction } from "@app/types/fileContext"; + +/** + * Proves the selector-subscription contract: a consumer re-renders only when + * the slice it selects changes — a single file's update doesn't re-render + * other files' consumers, and selection changes don't re-render list consumers. + */ + +const stub = (id: string): StirlingFileStub => + ({ + id: id as FileId, + name: `${id}.pdf`, + type: "application/pdf", + size: 1, + lastModified: 0, + }) as StirlingFileStub; + +const renders: Record = {}; +let dispatchRef: React.Dispatch | null = null; + +function Controller() { + const { dispatch } = useFileActions(); + dispatchRef = dispatch; + return null; +} + +function StubWatcher({ fileId }: { fileId: string }) { + useStirlingFileStub(fileId as FileId); + renders[`stub-${fileId}`] = (renders[`stub-${fileId}`] ?? 0) + 1; + return null; +} + +function ListWatcher() { + useAllFiles(); + renders.list = (renders.list ?? 0) + 1; + return null; +} + +function SelectionWatcher() { + useFileSelection(); + renders.selection = (renders.selection ?? 0) + 1; + return null; +} + +function setup() { + for (const key of Object.keys(renders)) delete renders[key]; + dispatchRef = null; + render( + + + + + + + + + , + ); + act(() => { + dispatchRef!({ + type: "ADD_FILES", + payload: { stirlingFileStubs: [stub("a"), stub("b")] }, + }); + }); + return { ...renders }; +} + +describe("file hooks — selector subscriptions", () => { + it("updating one file re-renders that file's consumer, not the other's", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "UPDATE_FILE_RECORD", + payload: { id: "b" as FileId, updates: { name: "renamed.pdf" } }, + }); + }); + expect(renders["stub-b"]).toBeGreaterThan(before["stub-b"]); + expect(renders["stub-a"]).toBe(before["stub-a"]); + }); + + it("selection changes don't re-render file-list or per-file consumers", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "SET_SELECTED_FILES", + payload: { fileIds: ["a" as FileId] }, + }); + }); + expect(renders.selection).toBeGreaterThan(before.selection); + expect(renders.list).toBe(before.list); + expect(renders["stub-a"]).toBe(before["stub-a"]); + expect(renders["stub-b"]).toBe(before["stub-b"]); + }); + + it("file-list changes don't re-render selection-only consumers", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "UPDATE_FILE_RECORD", + payload: { id: "a" as FileId, updates: { name: "x.pdf" } }, + }); + }); + expect(renders.selection).toBe(before.selection); + }); +}); + +describe("useFileSelectors — render-phase misuse guard", () => { + const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((args) => + String(args[0]).includes("[useFileSelectors]"), + ); + + function RenderTimeMisuse() { + const selectors = useFileSelectors(); + selectors.getAllFileIds(); // during render — must be flagged + return null; + } + + function EffectTimeUse() { + const selectors = useFileSelectors(); + useEffect(() => { + selectors.getAllFileIds(); // after commit — legitimate + }, [selectors]); + return null; + } + + it("flags a selector invoked during render", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + + + , + ); + expect(guardErrors(spy).length).toBeGreaterThan(0); + spy.mockRestore(); + }); + + it("does not flag selector reads from effects", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + + + , + ); + expect(guardErrors(spy)).toHaveLength(0); + spy.mockRestore(); + }); +}); + +describe("useFileContext — render-phase misuse guard", () => { + // useFileContext subscribes to files + pinnedFiles only, so a render-time read + // of the SELECTION slice through its exposed selectors would silently go + // stale. The guard covers exactly those selectors and nothing else. + const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((args) => + String(args[0]).includes("[useFileContext]"), + ); + + const renderWithGuard = (node: React.ReactNode) => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + {node} + , + ); + const errors = guardErrors(spy); + spy.mockRestore(); + return errors; + }; + + function SelectionReadDuringRender() { + const { selectors } = useFileContext(); + selectors.getSelectedFiles(); // unsubscribed slice — must be flagged + return null; + } + + function FilesReadDuringRender() { + const { selectors } = useFileContext(); + selectors.getStirlingFileStubs(); // files slice IS subscribed — legitimate + return null; + } + + function SelectionReadFromEffect() { + const { selectors } = useFileContext(); + useEffect(() => { + selectors.getSelectedFiles(); // after commit — legitimate + }, [selectors]); + return null; + } + + it("flags a selection read during render", () => { + expect( + renderWithGuard().length, + ).toBeGreaterThan(0); + }); + + it("does not flag reads of a slice it subscribes to", () => { + expect(renderWithGuard()).toHaveLength(0); + }); + + it("does not flag selection reads from effects", () => { + expect(renderWithGuard()).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/fileHooks.ts b/frontend/editor/src/core/contexts/file/fileHooks.ts index fcd3ba7ef7..f5c1bfc460 100644 --- a/frontend/editor/src/core/contexts/file/fileHooks.ts +++ b/frontend/editor/src/core/contexts/file/fileHooks.ts @@ -1,27 +1,187 @@ /** - * Performant file hooks - Clean API using FileContext + * Performant file hooks — selector subscriptions over the FileStateStore. + * Each hook re-renders its consumer only when the slice it selects changes, + * not on every file-state change. */ -import { useContext, useMemo } from "react"; +import { useContext, useLayoutEffect, useMemo, useRef } from "react"; +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector.js"; import { - FileStateContext, + FileStoreContext, FileActionsContext, + FileStateStore, FileContextStateValue, FileContextActionsValue, } from "@app/contexts/file/contexts"; -import { StirlingFileStub, StirlingFile } from "@app/types/fileContext"; +import { + StirlingFileStub, + StirlingFile, + FileContextState, + FileContextSelectors, +} from "@app/types/fileContext"; import { FileId } from "@app/types/file"; +const GUARD_MISUSE = process.env.NODE_ENV !== "production"; + +/** Shallow equality over object/array slices assembled by selectors. */ +export function shallowEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if ( + typeof a !== "object" || + a === null || + typeof b !== "object" || + b === null + ) { + return false; + } + const keysA = Object.keys(a); + if (keysA.length !== Object.keys(b).length) return false; + return keysA.every((key) => + Object.is( + (a as Record)[key], + (b as Record)[key], + ), + ); +} + +function useFileStore(): FileStateStore { + const store = useContext(FileStoreContext); + if (!store) { + throw new Error("File hooks must be used within a FileContextProvider"); + } + return store; +} + +/** + * Subscribe to a slice of file state. The component re-renders only when the + * selected value changes (Object.is by default; pass shallowEqual for slices + * assembled into fresh objects/arrays). + */ +export function useFileSelector( + selector: (state: FileContextState) => T, + isEqual?: (a: T, b: T) => boolean, +): T { + const store = useFileStore(); + return useSyncExternalStoreWithSelector( + store.subscribe, + store.getState, + store.getState, + selector, + isEqual, + ); +} + +/** Selectors that read `ui.selectedFileIds`. A hook that doesn't subscribe to + * that slice must not let consumers call these during render. */ +const SELECTION_SELECTORS: ReadonlyArray = [ + "getSelectedFiles", + "getSelectedStirlingFileStubs", +]; + +/** Wrap selectors so a call made during render logs loudly (dev/test only). + * Render-time vs event-time isn't statically lintable, so this is the guard. + * `keys` limits the wrap to the selectors whose slice the calling hook does NOT + * subscribe to — the rest are safe to read during render and pass through. */ +function guardSelectors( + selectors: FileContextSelectors, + isRendering: () => boolean, + hookName: string, + keys?: ReadonlyArray, +): FileContextSelectors { + const guardedKeys = + keys ?? (Object.keys(selectors) as Array); + const guarded: Record = { ...selectors }; + for (const key of guardedKeys) { + const original = selectors[key] as unknown as ( + ...args: unknown[] + ) => unknown; + guarded[key] = (...args: unknown[]) => { + if (isRendering()) { + console.error( + `[${hookName}] ${key}() was called during render. This read doesn't ` + + "subscribe to the state it depends on, so the UI can go stale — use " + + "useFileSelector / useFileSelection / useAllFiles for render-time data.", + ); + } + return original(...args); + }; + } + return guarded as unknown as FileContextSelectors; +} + +/** + * Wrap a hook's exposed selectors in the render-phase misuse guard (no-op in + * production). `keys` names the selectors the calling hook doesn't subscribe to; + * omit it to guard every selector (for hooks that subscribe to nothing). + */ +function useGuardedSelectors( + selectors: FileContextSelectors, + hookName: string, + keys?: ReadonlyArray, +): FileContextSelectors { + // True exactly while this consumer is rendering: set on every render, cleared + // by the layout effect once that render commits. + const renderPhase = useRef(false); + renderPhase.current = GUARD_MISUSE; + useLayoutEffect(() => { + renderPhase.current = false; + }); + return useMemo( + () => + GUARD_MISUSE + ? guardSelectors(selectors, () => renderPhase.current, hookName, keys) + : selectors, + [selectors, hookName, keys], + ); +} + +/** + * Stable selector API with NO state subscription — never re-renders. For + * event-time reads (callbacks/effects), which see live state when invoked. + * Render-time reads need a reactive hook (useAllFiles/useFileSelector) or + * they go stale — calling one during render logs an error outside production. + */ +export function useFileSelectors(): FileContextSelectors { + const { selectors } = useFileStore(); + return useGuardedSelectors(selectors, "useFileSelectors"); +} + +/** + * Position of `fileId` in the resolved file list — the SAME array useAllFiles() + * returns, which drops ids whose bytes haven't hydrated into memory yet, so the + * index lines up with what consumers actually index into. 0 when unset/absent. + * + * Selects a NUMBER, so the consumer re-renders only when the index actually + * moves. useAllFiles() would do the job too, but it re-renders on every + * unrelated stub update (thumbnail hydration, labels, …) — too costly for a + * high-level provider whose context value isn't memoized. + */ +export function useFileIndex(fileId: string | null | undefined): number { + // Raw (unguarded) selectors: the read below runs inside the subscription + // selector, so it IS reactive and the render-phase guard doesn't apply. + const { selectors } = useFileStore(); + return useFileSelector((s) => { + if (!fileId) return 0; + const index = selectors + .getFiles(s.files.ids) + .findIndex((file) => file.fileId === fileId); + return index >= 0 ? index : 0; + }); +} + /** * Hook for accessing file state (will re-render on any state change) * Use individual selector hooks below for better performance */ export function useFileState(): FileContextStateValue { - const context = useContext(FileStateContext); - if (!context) { - throw new Error("useFileState must be used within a FileContextProvider"); - } - return context; + const store = useFileStore(); + const state = useFileSelector((s) => s); + // Selectors are exposed unguarded on purpose: this hook subscribes to the + // WHOLE state, so a render-time selector read can't go stale. + return useMemo( + () => ({ state, selectors: store.selectors }), + [state, store.selectors], + ); } /** @@ -39,21 +199,21 @@ export function useFileActions(): FileContextActionsValue { * Hook for current/primary file (first in list) */ export function useCurrentFile(): { file?: File; record?: StirlingFileStub } { - const { state, selectors } = useFileState(); - - const primaryFileId = state.files.ids[0]; - const primaryFileRecord = primaryFileId - ? state.files.byId[primaryFileId] - : undefined; + const { selectors } = useFileStore(); + const { primaryFileId, record } = useFileSelector( + (s) => ({ + primaryFileId: s.files.ids[0], + record: s.files.ids[0] ? s.files.byId[s.files.ids[0]] : undefined, + }), + shallowEqual, + ); return useMemo( () => ({ file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, - record: primaryFileId - ? selectors.getStirlingFileStub(primaryFileId) - : undefined, + record, }), - [primaryFileId, primaryFileRecord, selectors], + [primaryFileId, record, selectors], ); } @@ -61,27 +221,35 @@ export function useCurrentFile(): { file?: File; record?: StirlingFileStub } { * Hook for file selection state and actions */ export function useFileSelection() { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); const { actions } = useFileActions(); + const selectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); + const selectedPageNumbers = useFileSelector((s) => s.ui.selectedPageNumbers); + // Only the SELECTED files' records — an unrelated file's update never + // re-renders selection consumers. + const selectedStubs = useFileSelector( + (s) => s.ui.selectedFileIds.map((id) => s.files.byId[id]), + shallowEqual, + ); // Memoize selected files to avoid recreating arrays const selectedFiles = useMemo(() => { return selectors.getSelectedFiles(); - }, [state.ui.selectedFileIds, state.files.byId, selectors]); + }, [selectedFileIds, selectedStubs, selectors]); return useMemo( () => ({ selectedFiles, - selectedFileIds: state.ui.selectedFileIds, - selectedPageNumbers: state.ui.selectedPageNumbers, + selectedFileIds, + selectedPageNumbers, setSelectedFiles: actions.setSelectedFiles, setSelectedPages: actions.setSelectedPages, clearSelections: actions.clearSelections, }), [ selectedFiles, - state.ui.selectedFileIds, - state.ui.selectedPageNumbers, + selectedFileIds, + selectedPageNumbers, actions.setSelectedFiles, actions.setSelectedPages, actions.clearSelections, @@ -111,57 +279,64 @@ export function useFileManagement() { * Hook for UI state */ export function useFileUI() { - const { state } = useFileState(); const { actions } = useFileActions(); + const ui = useFileSelector( + (s) => ({ + isProcessing: s.ui.isProcessing, + processingProgress: s.ui.processingProgress, + hasUnsavedChanges: s.ui.hasUnsavedChanges, + }), + shallowEqual, + ); return useMemo( () => ({ - isProcessing: state.ui.isProcessing, - processingProgress: state.ui.processingProgress, - hasUnsavedChanges: state.ui.hasUnsavedChanges, + ...ui, setProcessing: actions.setProcessing, setUnsavedChanges: actions.setHasUnsavedChanges, }), - [state.ui, actions], + [ui, actions], ); } /** - * Hook for specific file by ID (optimized for individual file access) + * Hook for specific file by ID (optimized for individual file access): + * re-renders only when THAT file's record changes. */ export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub; } { - const { state, selectors } = useFileState(); - const fileRecord = state.files.byId[fileId]; + const { selectors } = useFileStore(); + const record = useFileSelector((s) => s.files.byId[fileId]); return useMemo( () => ({ file: selectors.getFile(fileId), - record: selectors.getStirlingFileStub(fileId), + record, }), - [fileId, fileRecord, selectors], + [fileId, record, selectors], ); } /** - * Hook for all files (use sparingly - causes re-renders on file list changes) + * Hook for all files: re-renders on file-list changes only (not selection/UI). */ export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileStub[]; fileIds: FileId[]; } { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); + const files = useFileSelector((s) => s.files); return useMemo( () => ({ - files: selectors.getFiles(), - fileStubs: selectors.getStirlingFileStubs(), - fileIds: state.files.ids, + files: selectors.getFiles(files.ids), + fileStubs: selectors.getStirlingFileStubs(files.ids), + fileIds: files.ids, }), - [state.files.ids, state.files.byId, selectors], + [files, selectors], ); } @@ -173,30 +348,47 @@ export function useSelectedFiles(): { selectedFileStubs: StirlingFileStub[]; selectedFileIds: FileId[]; } { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); + const selectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); + // Only the SELECTED files' records — see useFileSelection. + const selectedStubs = useFileSelector( + (s) => s.ui.selectedFileIds.map((id) => s.files.byId[id]), + shallowEqual, + ); return useMemo( () => ({ selectedFiles: selectors.getSelectedFiles(), selectedFileStubs: selectors.getSelectedStirlingFileStubs(), - selectedFileIds: state.ui.selectedFileIds, + selectedFileIds, }), - [state.ui.selectedFileIds, state.files.byId, selectors], + [selectedFileIds, selectedStubs, selectors], ); } -// Navigation management removed - moved to NavigationContext - /** - * Primary API hook for file context operations - * Used by tools for core file context functionality + * Primary API hook for file context operations. Used by tools for core file + * context functionality. Re-renders only when the slices it exposes reactively + * (files, pinned files) change — not on selection/UI changes. */ export function useFileContext() { - const { state, selectors } = useFileState(); + const store = useFileStore(); const { actions } = useFileActions(); + const { files, pinnedFiles } = useFileSelector( + (s) => ({ files: s.files, pinnedFiles: s.pinnedFiles }), + shallowEqual, + ); + // This hook subscribes to files + pinnedFiles, so those selectors are safe to + // read during render; the SELECTION ones aren't (no subscription to + // ui.selectedFileIds), so they carry the misuse guard. + const selectors = useGuardedSelectors( + store.selectors, + "useFileContext", + SELECTION_SELECTORS, + ); - return useMemo( - () => ({ + return useMemo(() => { + return { // Lifecycle management trackBlobUrl: actions.trackBlobUrl, scheduleCleanup: actions.scheduleCleanup, @@ -213,10 +405,11 @@ export function useFileContext() { _operationId: string, _error: string, ) => {}, // Operation tracking not implemented - // File ID lookup + // File ID lookup (reads live state at call time) findFileId: (file: File) => { - return state.files.ids.find((id) => { - const record = state.files.byId[id]; + const { files: liveFiles } = store.getState(); + return liveFiles.ids.find((id) => { + const record = liveFiles.byId[id]; return ( record && record.name === file.name && @@ -227,19 +420,18 @@ export function useFileContext() { }, // Pinned files - pinnedFiles: state.pinnedFiles, + pinnedFiles, pinFile: actions.pinFile, unpinFile: actions.unpinFile, isFilePinned: selectors.isFilePinned, // Active files - activeFiles: selectors.getFiles(), + activeFiles: selectors.getFiles(files.ids), openEncryptedUnlockPrompt: actions.openEncryptedUnlockPrompt, // Direct access to actions and selectors (for advanced use cases) actions, selectors, - }), - [state, selectors, actions], - ); + }; + }, [files, pinnedFiles, actions, store, selectors]); } diff --git a/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts b/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts new file mode 100644 index 0000000000..c250c3e4dc --- /dev/null +++ b/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withReducerIdentityGuard } from "@app/contexts/file/FileReducer"; +import type { + FileContextState, + FileContextAction, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const stub = (id: string): StirlingFileStub => + ({ id: id as FileId, name: `${id}.pdf` }) as StirlingFileStub; + +function baseState(): FileContextState { + return { + files: { ids: ["a" as FileId], byId: { ["a" as FileId]: stub("a") } }, + pinnedFiles: new Set(), + ui: { + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false, + errorFileIds: [], + }, + }; +} + +const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((a) => String(a[0]).includes("[FileReducer]")); + +afterEach(() => vi.restoreAllMocks()); + +describe("withReducerIdentityGuard", () => { + it("warns when a slice is reallocated but unchanged", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + // Bad reducer: rebuilds `files` (new ref) with identical contents. + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + files: { ids: [...s.files.ids], byId: { ...s.files.byId } }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(guardErrors(spy)).toHaveLength(1); + expect(String(guardErrors(spy)[0][0])).toContain("state.files"); + }); + + it("stays quiet when a slice genuinely changes", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + files: { + ids: [...s.files.ids, "b" as FileId], + byId: { ...s.files.byId, ["b" as FileId]: stub("b") }, + }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(guardErrors(spy)).toHaveLength(0); + }); + + it("stays quiet when the reducer returns the same state reference", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => s); + const state = baseState(); + expect( + guarded(state, { type: "NOOP" } as unknown as FileContextAction), + ).toBe(state); + expect(guardErrors(spy)).toHaveLength(0); + }); + + it("flags a needless ui reallocation", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + ui: { ...s.ui }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(String(guardErrors(spy)[0][0])).toContain("state.ui"); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx b/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx new file mode 100644 index 0000000000..e10bfed517 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + FileStoreContext, + type FileStateStore, +} from "@app/contexts/file/contexts"; +import { useFileIndex } from "@app/contexts/file/fileHooks"; +import type { + FileContextSelectors, + FileContextState, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * useFileIndex replaced a render-time `selectors.getFiles()` read in + * ViewerContext, which never re-subscribed and so survived a file-list change + * that moved the active file. These tests drive a hand-built store (the real one + * needs IndexedDB to populate its File map) and lock the two properties the fix + * depends on: the index tracks the RESOLVED file list, and the consumer + * re-renders only when the index actually moves. + */ + +function makeStore(ids: string[], resolved: string[]) { + let state: FileContextState = { + files: { ids: ids as FileId[], byId: {} }, + } as FileContextState; + let resolvedIds = new Set(resolved); + const listeners = new Set<() => void>(); + + // Mirrors createFileSelectors.getFiles: maps ids through the in-memory File + // map and DROPS the ones whose bytes haven't landed yet. + const selectors = { + getFiles: (requested?: FileId[]) => + (requested ?? state.files.ids) + .filter((id) => resolvedIds.has(id as string)) + .map((id) => ({ fileId: id })), + } as unknown as FileContextSelectors; + + const store: FileStateStore = { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + selectors, + }; + + const update = (nextIds: string[], nextResolved: string[] = nextIds) => { + act(() => { + state = { + files: { ids: nextIds as FileId[], byId: {} }, + } as FileContextState; + resolvedIds = new Set(nextResolved); + listeners.forEach((listener) => listener()); + }); + }; + + return { store, update }; +} + +function setup(ids: string[], resolved: string[], fileId: string | null) { + const { store, update } = makeStore(ids, resolved); + let renders = 0; + let index = -1; + + function Probe() { + index = useFileIndex(fileId); + renders++; + return null; + } + + render( + + + , + ); + + return { update, get: () => index, renderCount: () => renders }; +} + +describe("useFileIndex", () => { + it("reports the active file's position in the resolved list", () => { + const { get } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + expect(get()).toBe(2); + }); + + it("skips ids whose bytes haven't hydrated, matching what consumers index into", () => { + // "a" has no File yet, so getFiles() yields [b, c] — "c" sits at 1, not 2. + const { get } = setup(["a", "b", "c"], ["b", "c"], "c"); + expect(get()).toBe(1); + }); + + it("updates when the list reorders under a stable active file", () => { + // The regression this fixes: activeFileId never changed, so the old + // useMemo kept returning the pre-reorder index. + const { get, update } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + expect(get()).toBe(2); + update(["c", "a", "b"]); + expect(get()).toBe(0); + }); + + it("updates when a file ahead of the active one is removed", () => { + const { get, update } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + update(["b", "c"]); + expect(get()).toBe(1); + }); + + it("falls back to 0 when the active file leaves the list", () => { + const { get, update } = setup(["a", "b"], ["a", "b"], "b"); + update(["a"]); + expect(get()).toBe(0); + }); + + it("returns 0 with no active file", () => { + const { get } = setup(["a", "b"], ["a", "b"], null); + expect(get()).toBe(0); + }); + + it("does not re-render when a store change leaves the index alone", () => { + // Selecting a NUMBER is the point: appending after the active file, or any + // unrelated stub churn, must not re-render the consumer. + const { get, update, renderCount } = setup(["a", "b"], ["a", "b"], "a"); + const before = renderCount(); + update(["a", "b", "c"]); + expect(get()).toBe(0); + expect(renderCount()).toBe(before); + }); +}); diff --git a/frontend/editor/src/core/tools/Convert.tsx b/frontend/editor/src/core/tools/Convert.tsx index de29e4ae94..b1d214f1a6 100644 --- a/frontend/editor/src/core/tools/Convert.tsx +++ b/frontend/editor/src/core/tools/Convert.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; @@ -14,8 +14,7 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool"; const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const selectedFiles = useViewScopedFiles(); const scrollContainerRef = useRef(null); diff --git a/frontend/editor/src/desktop/hooks/useExitWarning.ts b/frontend/editor/src/desktop/hooks/useExitWarning.ts index 1e6b4423fc..4a5b16fb8f 100644 --- a/frontend/editor/src/desktop/hooks/useExitWarning.ts +++ b/frontend/editor/src/desktop/hooks/useExitWarning.ts @@ -1,14 +1,14 @@ import { useEffect, useRef } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { message } from "@tauri-apps/plugin-dialog"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { useFileSelectors, useFileActions } from "@app/contexts/FileContext"; import { downloadFile } from "@app/services/downloadService"; import type { StirlingFileStub } from "@app/types/fileContext"; import { useTranslation } from "react-i18next"; export function useExitWarning() { const { t } = useTranslation(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { actions: fileActions } = useFileActions(); const selectorsRef = useRef(selectors); const isClosingRef = useRef(false); diff --git a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts index 927d5f06cf..b3faa64bb7 100644 --- a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts +++ b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts @@ -1,5 +1,9 @@ import { useEffect } from "react"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { + useFileSelector, + useFileSelectors, + useFileActions, +} from "@app/contexts/FileContext"; // Save through the export gateway so a "run on export" policy enforces before // the file is written out (no-op when no such policy is active). import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; @@ -10,7 +14,8 @@ import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWith * Matches WorkbenchBar button behavior: saves selected files if any, otherwise all files */ export function useSaveShortcut() { - const { selectors, state } = useFileState(); + const selectors = useFileSelectors(); + const currentSelectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); const { actions: fileActions } = useFileActions(); useEffect(() => { @@ -20,7 +25,7 @@ export function useSaveShortcut() { event.preventDefault(); // Get selected files or all files if nothing selected - const selectedFileIds = state.ui.selectedFileIds; + const selectedFileIds = currentSelectedFileIds; const filesToSave = selectedFileIds.length > 0 ? selectors.getFiles(selectedFileIds) @@ -63,5 +68,5 @@ export function useSaveShortcut() { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [selectors, state.ui.selectedFileIds, fileActions]); + }, [selectors, currentSelectedFileIds, fileActions]); } diff --git a/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts b/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts new file mode 100644 index 0000000000..74baf59006 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { classificationLabelTargetStubs } from "@app/components/policies/usePolicyAutoRun"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +// Loosely-typed builder: FileId is a branded string, so accept plain string ids +// in tests and cast — classificationLabelTargetStubs only reads id/parent/sources. +const stub = (s: { + id: string; + parentFileId?: string; + sourceFileIds?: string[]; +}): StirlingFileStub => s as unknown as StirlingFileStub; + +const ids = (stubs: StirlingFileStub[]) => stubs.map((s) => s.id as string); + +describe("classificationLabelTargetStubs", () => { + it("targets the run's own file when it's still the leaf", () => { + const stubs = [stub({ id: "a" }), stub({ id: "b" })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a"]); + }); + + it("targets a descendant leaf when the file was edited during the run", () => { + // "a" was consumed into leaf "a2" (edit forked a new version mid-run). + const stubs = [stub({ id: "a2", sourceFileIds: ["a"] })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a2"]); + }); + + it("targets a direct child via parentFileId", () => { + const stubs = [stub({ id: "a2", parentFileId: "a" })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a2"]); + }); + + it("returns the stubs themselves, so the caller can see what's already tagged", () => { + const target = stub({ id: "a" }); + expect(classificationLabelTargetStubs("a", [target])[0]).toBe(target); + }); + + it("is empty when the document has left the workspace (file closed)", () => { + // No fallback to the run's own id: stamping a consumed id would no-op + // anyway, and an empty result lets the caller settle without downloading. + expect(classificationLabelTargetStubs("a", [stub({ id: "z" })])).toEqual( + [], + ); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts new file mode 100644 index 0000000000..355d8b5526 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + acquireDispatchSlot, + releaseDispatchSlot, + resetDispatchSemaphoreForTests, +} from "@app/components/policies/dispatchSemaphore"; + +// Drain the microtask queue so an acquire's await-resume AND the caller's .then +// have both run. +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => resetDispatchSemaphoreForTests()); + +describe("dispatchSemaphore", () => { + it("lets up to 4 acquire without waiting, then blocks the 5th", async () => { + for (let i = 0; i < 4; i++) await acquireDispatchSlot(); + let fifthAcquired = false; + void acquireDispatchSlot().then(() => { + fifthAcquired = true; + }); + await flush(); + expect(fifthAcquired).toBe(false); + releaseDispatchSlot(); + await flush(); + expect(fifthAcquired).toBe(true); + }); + + it("serves a priority (chained) waiter before earlier normal waiters", async () => { + for (let i = 0; i < 4; i++) await acquireDispatchSlot(); // pool full + const order: string[] = []; + // Two normal (new-file) dispatches queue first… + void acquireDispatchSlot(false).then(() => order.push("normal-1")); + void acquireDispatchSlot(false).then(() => order.push("normal-2")); + // …then a chained dispatch arrives — it must jump ahead. + void acquireDispatchSlot(true).then(() => order.push("chained")); + await flush(); + + releaseDispatchSlot(); + await flush(); + releaseDispatchSlot(); + await flush(); + releaseDispatchSlot(); + await flush(); + + expect(order).toEqual(["chained", "normal-1", "normal-2"]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts new file mode 100644 index 0000000000..d5119e5923 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts @@ -0,0 +1,42 @@ +/** + * Bounded concurrency for policy run-dispatch uploads. + * + * Each dispatch POSTs a file's bytes; firing a whole drop at once saturates the + * browser's per-origin connection pool, so status polls and output downloads of + * already-running files queue behind the pending uploads and nothing visibly + * progresses. A small window keeps connections free. + * + * `priority` (a chained/downstream dispatch) jumps to the FRONT of the queue, so + * a file already mid-chain finishes its whole policy flow before a brand-new + * file's first policy starts. Without it a chained dispatch would sit behind the + * entire first-policy wave (FIFO) — e.g. classification wouldn't start on any + * file until security had finished on all of them. + */ +const MAX_CONCURRENT_DISPATCHES = 4; + +let slotsInUse = 0; +const waiters: Array<() => void> = []; + +export async function acquireDispatchSlot(priority = false): Promise { + if (slotsInUse < MAX_CONCURRENT_DISPATCHES) { + slotsInUse++; + return; + } + await new Promise((resolve) => { + if (priority) waiters.unshift(resolve); + else waiters.push(resolve); + }); +} + +export function releaseDispatchSlot(): void { + const next = waiters.shift(); + // Hand the slot straight to the next waiter, else free it. + if (next) next(); + else slotsInUse--; +} + +/** Test-only: reset module state between cases. */ +export function resetDispatchSemaphoreForTests(): void { + slotsInUse = 0; + waiters.length = 0; +} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx index 5837bfb285..bc4fa54dff 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -2,21 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; /** - * Batch integration test for the policy auto-run orchestration, at the scale the - * user hit the bug: 61 files uploaded at once, two active upload policies - * (Classification → Security) chained. Drives the REAL policyRunStore + the REAL - * hook effects (dispatch → poll → import → chain), mocking only the IO boundaries - * (network, storage, thumbnail/stub creation). - * - * Proves the invariants the user asked for: - * - 61 files ⇒ exactly 122 runs (61 classification, then 61 security). - * - Delivery is SILENT + in place (consumeFiles called with { silent: true }), - * never adding a second copy — the workspace never grows past 61. - * - No runaway: if the loop guard regressed, the run count would blow past 122 - * (or the test would time out), so an exact 122 is a hard regression gate. - * - Closing all files mid-run does NOT re-open them: with the workspace emptied, - * outputs are delivered to storage (persistVersionedOutputs), never re-added - * to the workspace via consumeFiles. + * Batch integration test (61 files, two chained upload policies) driving the real + * store + hook effects, IO mocked. Classification is forced last (see the sort). */ const FILE_COUNT = 61; @@ -25,13 +12,15 @@ const FILE_COUNT = 61; // the workbench, mirrored into useAllFiles. consumeFiles mutates it in place // (input id → output id) exactly as the real silent reducer would. const mocks = vi.hoisted(() => ({ - workspace: [] as Array<{ id: string }>, + workspace: [] as Array<{ id: string; classificationLabels?: string[] }>, consumeSilentCalls: 0, consumeNonSilentCalls: 0, persistCalls: 0, addFilesCalls: 0, stubCounter: 0, backendOutCounter: 0, + dispatchInFlight: 0, + maxDispatchInFlight: 0, bumpRevision: vi.fn(), runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), @@ -66,7 +55,8 @@ vi.mock("@app/contexts/IndexedDBContext", () => ({ vi.mock("@app/hooks/usePolicies", () => ({ usePolicies: () => ({ policies: { - // Classification runs first (order 0), Security second (order 1). + // Classification is configured first (order 0) but is FORCED to run last + // by the orchestrator; Security (order 1) therefore runs first. classification: { configured: true, status: "active", @@ -107,7 +97,9 @@ vi.mock("@app/services/fileStubHelpers", () => ({ createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs, })); vi.mock("@app/services/fileClassification", () => ({ - readClassificationLabelsFromFile: vi.fn().mockResolvedValue(null), + // Classification always resolves labels here, so the metadata-only import path + // stamps them onto the stub. + readClassificationLabelsFromFile: vi.fn().mockResolvedValue(["Invoice"]), })); import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; @@ -141,6 +133,8 @@ beforeEach(() => { mocks.addFilesCalls = 0; mocks.stubCounter = 0; mocks.backendOutCounter = 0; + mocks.dispatchInFlight = 0; + mocks.maxDispatchInFlight = 0; mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ id: `file-${i}`, @@ -155,15 +149,31 @@ beforeEach(() => { mocks.persistVersionedOutputs.mockImplementation(async () => { mocks.persistCalls += 1; }); - mocks.updateFileMetadata.mockResolvedValue(false); + mocks.updateFileMetadata.mockResolvedValue(true); mocks.downloadPolicyOutput.mockResolvedValue( new Blob(["x"], { type: "application/pdf" }), ); + // Apply stub updates to the shared workspace, as the real reducer does — the + // label stamp's second pass reads them back to stay idempotent. + mocks.updateStirlingFileStub.mockImplementation( + (id: string, updates: Record) => { + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, updates); + }, + ); // Each dispatch gets a unique run id; the run's single backend output likewise. - mocks.runStoredPolicy.mockImplementation( - async () => `run-${mocks.stubCounter++}`, - ); + // Takes real time so overlapping dispatches are measurable (the upload window). + mocks.runStoredPolicy.mockImplementation(async () => { + mocks.dispatchInFlight++; + mocks.maxDispatchInFlight = Math.max( + mocks.maxDispatchInFlight, + mocks.dispatchInFlight, + ); + await new Promise((resolve) => setTimeout(resolve, 2)); + mocks.dispatchInFlight--; + return `run-${mocks.stubCounter++}`; + }); mocks.getPolicyRun.mockImplementation(async (runId: string) => ({ runId, policyId: null, @@ -225,8 +235,8 @@ async function runUntilSettled(expectedRuns: number) { }); } -describe("policy auto-run — 61-file batch through a Classification → Security chain", () => { - it("produces exactly 122 runs (61 classification, then 61 security)", async () => { +describe("policy auto-run — 61-file batch through a Security → Classification chain", () => { + it("produces exactly 122 runs (61 security, then 61 classification)", async () => { await runUntilSettled(FILE_COUNT * 2); const classification = latestRuns.filter( @@ -239,15 +249,26 @@ describe("policy auto-run — 61-file batch through a Classification → Securit expect(latestRuns).toHaveLength(FILE_COUNT * 2); }); - it("delivers every output SILENTLY in place — workspace never grows past 61", async () => { + it("bounds concurrent dispatch uploads so polls/downloads keep connections", async () => { + await runUntilSettled(FILE_COUNT * 2); + expect(mocks.maxDispatchInFlight).toBeGreaterThan(1); // still parallel… + expect(mocks.maxDispatchInFlight).toBeLessThanOrEqual(4); // …but windowed + }); + + it("versions on Security in place, tags on Classification — workspace never grows past 61", async () => { await runUntilSettled(FILE_COUNT * 2); - // 122 deliveries, all silent (background), none via the disruptive path. - expect(mocks.consumeSilentCalls).toBe(FILE_COUNT * 2); + // Only the 61 Security runs fork a version, and every one silently in place. + expect(mocks.consumeSilentCalls).toBe(FILE_COUNT); expect(mocks.consumeNonSilentCalls).toBe(0); + // Classification never forks a version — it only stamps labels onto the stub. + expect(mocks.updateStirlingFileStub).toHaveBeenCalledTimes(FILE_COUNT); + for (const call of mocks.updateStirlingFileStub.mock.calls) { + expect(call[1]).toEqual({ classificationLabels: ["Invoice"] }); + } // Never added as brand-new files either. expect(mocks.addFilesCalls).toBe(0); - // In-place versioning: each file replaced twice, count unchanged. + // In-place versioning + metadata-only tagging: count unchanged. expect(mocks.workspace).toHaveLength(FILE_COUNT); }); @@ -273,8 +294,8 @@ describe("policy auto-run — 61-file batch through a Classification → Securit ); }); - // Still fully processed (chain intact), but delivered to STORAGE, never - // re-added to the workbench — the workspace stays empty. + // Still fully processed (chain intact), but Security's versions went to + // STORAGE, never re-added to the workbench — the workspace stays empty. expect(latestRuns).toHaveLength(FILE_COUNT * 2); expect(mocks.workspace).toHaveLength(0); expect(mocks.consumeSilentCalls).toBe(0); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx new file mode 100644 index 0000000000..b67648e7fc --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +/** + * Mid-run race: classification is in flight (its labelled output is still + * downloading) when the user manually runs a tool on the same file — e.g. + * quickly redacting it — which consumes the input and forks a new leaf. + * + * The label targets must be resolved AT WRITE TIME (after the download/parse + * window), not snapshotted at run completion: a stale snapshot points at the + * consumed id, no-ops, and silently loses the labels — the file then shows the + * classification badge (provenance-resolved) but never gets its labels. + */ + +const mocks = vi.hoisted(() => ({ + workspace: [] as Array<{ + id: string; + sourceFileIds?: string[]; + derivedFromTool?: boolean; + classificationLabels?: string[]; + }>, + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + listPolicyRuns: vi.fn(), + downloadPolicyOutput: vi.fn(), + getStirlingFile: vi.fn(), + getStirlingFileStub: vi.fn(), + persistVersionedOutputs: vi.fn(), + updateFileMetadata: vi.fn(), + createStirlingFilesAndStubs: vi.fn(), + addFiles: vi.fn(), + updateStirlingFileStub: vi.fn(), + consumeFiles: vi.fn(), + bumpRevision: vi.fn(), +})); + +// Classification chains server-side only when the AI engine is on (else it runs +// client-side); this race is in the server import path, so force the engine on. +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs: mocks.workspace }), + useFileManagement: () => ({ + addFiles: mocks.addFiles, + updateStirlingFileStub: mocks.updateStirlingFileStub, + }), + useFileContext: () => ({ consumeFiles: mocks.consumeFiles }), +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: mocks.bumpRevision }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + classification: { + configured: true, + status: "active", + backendId: "backend-classification", + runOn: "upload", + order: 0, + outputMode: "new_version", + outputName: "", + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: mocks.runStoredPolicy, + getPolicyRun: mocks.getPolicyRun, + listPolicyRuns: mocks.listPolicyRuns, + downloadPolicyOutput: mocks.downloadPolicyOutput, + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: mocks.getStirlingFile, + getStirlingFileStub: mocks.getStirlingFileStub, + persistVersionedOutputs: mocks.persistVersionedOutputs, + updateFileMetadata: mocks.updateFileMetadata, + }, +})); +vi.mock("@app/services/fileStubHelpers", () => ({ + createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs, +})); +vi.mock("@app/services/fileClassification", () => ({ + readClassificationLabelsFromFile: vi.fn().mockResolvedValue(["Invoice"]), +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + usePolicyRuns, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +let latestRuns: PolicyRunRecord[] = []; +function Harness() { + usePolicyAutoRun(); + latestRuns = usePolicyRuns(); + return null; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + vi.clearAllMocks(); + + mocks.workspace = [{ id: "file-0" }]; + + mocks.listPolicyRuns.mockResolvedValue([]); + mocks.getStirlingFile.mockResolvedValue( + new File(["x"], "doc.pdf", { type: "application/pdf" }), + ); + mocks.getStirlingFileStub.mockResolvedValue(null); + mocks.updateFileMetadata.mockResolvedValue(true); + // Apply stub updates to the shared workspace, as the real reducer does — the + // label stamp's second pass reads them back to stay idempotent. + mocks.updateStirlingFileStub.mockImplementation( + (id: string, updates: Record) => { + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, updates); + }, + ); + mocks.runStoredPolicy.mockResolvedValue("run-0"); + mocks.getPolicyRun.mockResolvedValue({ + runId: "run-0", + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + outputs: [{ fileId: "backend-out-0", fileName: "doc.pdf" }], + }); +}); + +async function settleImport(timeout = 8000) { + await act(async () => { + await vi.waitFor( + () => { + expect(latestRuns.filter((r) => r.imported)).toHaveLength(1); + }, + { timeout, interval: 20 }, + ); + }); +} + +describe("classification vs a mid-run manual tool edit", () => { + it("labels land on the forked leaf when a tool consumes the file during the label download", async () => { + // The classified output's download hangs until we release it — this is the + // async window the user's edit slips into. + const download = deferred(); + mocks.downloadPolicyOutput.mockReturnValue(download.promise); + + const { rerender } = renderHook(() => Harness()); + + // Run dispatched, completed, import started — now hanging in the window. + await act(async () => { + await vi.waitFor( + () => expect(mocks.downloadPolicyOutput).toHaveBeenCalled(), + { timeout: 8000, interval: 20 }, + ); + }); + + // User quickly redacts: the tool consumes file-0 and forks a new leaf. + // (derivedFromTool + sourceFileIds are what CONSUME_FILES stamps.) + act(() => { + mocks.workspace = [ + { + id: "file-0~redacted", + sourceFileIds: ["file-0"], + derivedFromTool: true, + }, + ]; + rerender(); + }); + + // The download finally lands. + download.resolve(new Blob(["x"], { type: "application/pdf" })); + await settleImport(); + + // Labels stamped onto the LIVE leaf, not no-oped on the consumed id. + const stampedIds = mocks.updateStirlingFileStub.mock.calls.map((c) => c[0]); + expect(stampedIds).toEqual(["file-0~redacted"]); + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith( + "file-0~redacted", + { classificationLabels: ["Invoice"] }, + ); + // Badge persists on the leaf: the run's outputFileIds are the tagged files. + expect(latestRuns[0].outputFileIds).toEqual(["file-0~redacted"]); + }); + + it("control: with no mid-run edit, labels land on the original file", async () => { + mocks.downloadPolicyOutput.mockResolvedValue( + new Blob(["x"], { type: "application/pdf" }), + ); + + renderHook(() => Harness()); + await settleImport(); + + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("file-0", { + classificationLabels: ["Invoice"], + }); + expect(latestRuns[0].outputFileIds).toEqual(["file-0"]); + }); + + it("stamps the forked leaf when the consume lands in the same frame as the first stamp", async () => { + // Tighter than the case above: the consume is dispatched but hasn't rendered + // when the labels are stamped, so the workspace snapshot still shows file-0 + // and that stamp no-ops against the real reducer. The post-commit second pass + // is what saves the labels. + mocks.downloadPolicyOutput.mockResolvedValue( + new Blob(["x"], { type: "application/pdf" }), + ); + mocks.updateStirlingFileStub.mockImplementation((id: string) => { + // file-0 is already consumed, so its stamp is lost (no Object.assign) and + // the forked leaf only becomes visible afterwards. Mutate the workspace in + // place: the hook holds it by ref, which is what the second pass re-reads. + if (id === "file-0") { + mocks.workspace.splice(0, mocks.workspace.length, { + id: "file-0~redacted", + sourceFileIds: ["file-0"], + }); + return; + } + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, { classificationLabels: ["Invoice"] }); + }); + + renderHook(() => Harness()); + await settleImport(); + + const stampedIds = mocks.updateStirlingFileStub.mock.calls.map((c) => c[0]); + expect(stampedIds).toEqual(["file-0", "file-0~redacted"]); + // The leaf becoming visible also queues its own classification run, so pick + // the settled one rather than assuming an index. + const imported = latestRuns.find((r) => r.imported); + expect(imported?.outputFileIds).toContain("file-0~redacted"); + }); +}); + +// The label read backs off between attempts (2s, then 4s), so these run on fake +// timers — sleeping for real would hold a worker long enough to starve the suite. +describe("classification label-read failures", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + async function settleOnFakeTime(maxMs = 30_000) { + for (let elapsed = 0; elapsed < maxMs; elapsed += 250) { + if (latestRuns.some((r) => r.imported)) return; + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + throw new Error("classification run never settled"); + } + + it("retries a transient failure instead of leaving the run unsettled", async () => { + // The import effect only re-runs when the run store changes, so bailing out + // on a transient failure would leave this run "running" forever. + mocks.downloadPolicyOutput + .mockRejectedValueOnce(new Error("network blip")) + .mockResolvedValue(new Blob(["x"], { type: "application/pdf" })); + + renderHook(() => Harness()); + await settleOnFakeTime(); + + expect(mocks.downloadPolicyOutput).toHaveBeenCalledTimes(2); + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("file-0", { + classificationLabels: ["Invoice"], + }); + }); + + it("settles a run whose labels never become readable", async () => { + // Permanent failure: give up after the retry budget and settle unlabelled, + // rather than spinning the file's "running" pill indefinitely. + mocks.downloadPolicyOutput.mockRejectedValue(new Error("network down")); + + renderHook(() => Harness()); + await settleOnFakeTime(); + + expect(mocks.updateStirlingFileStub).not.toHaveBeenCalled(); + expect(latestRuns.find((r) => r.imported)?.outputFileIds).toEqual([]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index c25f80ce65..3c54e5cc42 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -69,8 +69,17 @@ afterEach(() => vi.useRealTimers()); describe("auto-run queue-rejection retry", () => { it("relabels a queue-rejected run as retrying, then re-dispatches it in place", async () => { - // The polled run comes back queue-rejected; the retry resolves the file + fires a fresh run. - getRunApi.mockResolvedValue(queueFullView); + // The polled run comes back queue-rejected once; the retry resolves the file + // and fires a fresh run, whose own polls then see it genuinely running. + getRunApi.mockResolvedValueOnce(queueFullView).mockResolvedValue({ + runId: "run-2", + status: "RUNNING", + currentStep: 1, + stepCount: 2, + error: null, + errorCode: null, + outputs: [], + } as never); getFile.mockResolvedValue({ size: 1234 } as never); runStored.mockResolvedValue("run-2"); @@ -92,19 +101,20 @@ describe("auto-run queue-rejection retry", () => { return usePolicyRuns(); }); - // First poll (2s cadence) sees the rejection → relabel as a soft "retrying" row. + // First poll sees the rejection → relabel as a soft "retrying" row. await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(getRun("run-1")?.retrying).toBe(true); expect(runStored).not.toHaveBeenCalled(); - // After the first backoff window (BASE 4s) the rejected record is dropped and a fresh run fires. + // After the first backoff window (BASE 4s) the rejected record is dropped and + // a fresh run fires; its own first poll shows it genuinely running. await act(async () => { - await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(6000); }); expect(runStored).toHaveBeenCalledWith("backend-1", [{ size: 1234 }]); expect(getRun("run-1")).toBeUndefined(); - expect(getRun("run-2")?.status).toBe("PENDING"); + expect(getRun("run-2")?.status).toBe("RUNNING"); }); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index ca096b6792..e475d784ea 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -39,6 +39,11 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { readClassificationLabelsFromFile } from "@app/services/fileClassification"; +import { isClassificationCategory } from "@app/data/policyCategories"; +import { + acquireDispatchSlot, + releaseDispatchSlot, +} from "@app/components/policies/dispatchSemaphore"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { PoliciesByCategory } from "@app/types/policies"; import { usePolicies } from "@app/hooks/usePolicies"; @@ -59,6 +64,10 @@ import { /** Status poll cadence. */ const POLL_MS = 2000; +/** First poll fires early so a fresh run shows real progress quickly instead of + * sitting on an indeterminate spinner for a full poll interval. */ +const FIRST_POLL_MS = 500; + /** The server aborts any single tool step that runs longer than its internal-API * read timeout, then fails the run — so a run can legitimately stay in flight * for up to this long per step. The client must keep polling at least that long, @@ -175,7 +184,14 @@ export function usePolicyAutoRun(): void { // Classification policy out of the server chain when the AI engine is off. !(id === "classification" && !aiEnabled), ) - .sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0)) + // Classification runs last: it's non-blocking, so an enforcement policy + // running after it would fork a new version and drop the user's edits. + .sort(([idA, a], [idB, b]) => { + const ca = isClassificationCategory(idA) ? 1 : 0; + const cb = isClassificationCategory(idB) ? 1 : 0; + if (ca !== cb) return ca - cb; + return (a.order ?? 0) - (b.order ?? 0); + }) .map(([id]) => id), [policies, aiEnabled], ); @@ -310,6 +326,7 @@ export function usePolicyAutoRun(): void { backendId, outputId as FileId, run.fileName, + true, // chained → jump the dispatch queue ahead of new files ).catch(() => {}); } } @@ -330,15 +347,33 @@ export function usePolicyAutoRun(): void { // so the enforced file appears in the app rather than only on the backend. useEffect(() => { for (const run of runs) { + const classification = isClassificationCategory(run.categoryId); if ( run.status !== "COMPLETED" || run.imported || - !run.outputs?.length || - importing.current.has(run.runId) + importing.current.has(run.runId) || + // Classification settles even with no outputs (nothing to tag); other + // policies need an output to import. + (!run.outputs?.length && !classification) ) { continue; } importing.current.add(run.runId); + // Classification is metadata-only: stamp labels onto the current leaf of + // the file it ran on (no version fork). See importClassificationLabels. + if (classification) { + // Targets are resolved by importClassificationLabels AT WRITE TIME (not + // snapshotted here): its download/parse is an async window during which + // a manual tool run can consume the input and fork a new leaf, and a + // stale snapshot would no-op on the dead id and lose the labels. + void importClassificationLabels( + run, + () => + classificationLabelTargetStubs(run.fileId, fileStubsRef.current), + { updateStirlingFileStub, bumpRevision }, + ).finally(() => importing.current.delete(run.runId)); + continue; + } // Honour the policy's output mode: a new file, or a new version of the // input file it ran on (needs that input's stub, still in the workspace). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; @@ -501,6 +536,142 @@ function categoryForPolicy( )?.[0]; } +interface ClassificationImportContext { + updateStirlingFileStub: ( + fileId: FileId, + updates: Partial, + ) => void; + bumpRevision: () => void; +} + +/** Workspace stubs to tag with a classification run's labels: the file it ran + * on plus any live descendants, so an edit made during the async run (which + * forks a new leaf) still shows the tags. Empty once the document has left the + * workspace (closed, or a reconciled run with no local input link). */ +export function classificationLabelTargetStubs( + runFileId: string, + stubs: ReadonlyArray, +): StirlingFileStub[] { + return stubs.filter( + (s) => + (s.id as string) === runFileId || + s.parentFileId === runFileId || + s.sourceFileIds?.includes(runFileId as FileId), + ); +} + +/** Attempts to read a completed run's labels before giving up, and the backoff + * between them (delay × attempt). The import effect only re-runs when the run + * store changes, so a transient read failure has to be retried HERE: bailing + * out would leave the run unsettled and the file's "running" pill spinning + * until unrelated policy activity happened to nudge the effect. */ +const LABEL_READ_ATTEMPTS = 3; +const LABEL_READ_RETRY_MS = 2000; + +/** + * Read classification labels out of a completed run's output PDF. A 404 means + * that output aged out, so it's skipped; any other failure is transient and + * retried with backoff. Returns null when there are genuinely no labels to + * apply (including a run with no outputs), so the caller can settle the run. + */ +async function readRunLabels(run: PolicyRunRecord): Promise { + for (let attempt = 0; attempt < LABEL_READ_ATTEMPTS; attempt++) { + if (attempt > 0) await delay(LABEL_READ_RETRY_MS * attempt); + let transientFailure = false; + for (const out of run.outputs) { + try { + const blob = await downloadPolicyOutput(out.fileId, run.target); + const file = new File([blob], out.fileName ?? run.fileName, { + type: blob.type || "application/pdf", + }); + const labels = await readClassificationLabelsFromFile(file); + if (labels && labels.length > 0) return labels; + } catch (err) { + if (!isNotFoundError(err)) transientFailure = true; + } + } + // Every output was read (or had aged out): there are no labels to apply. + if (!transientFailure) return null; + } + // Out of attempts. Settle the run unlabelled rather than spin forever; the + // file keeps its classification badge, just without tags. + return null; +} + +/** + * Stamp `labels` onto the run's live descendants in place (workspace + storage) + * — no versioned child, no history entry, only tags. Returns the tagged ids. + * + * Runs twice, because `resolveTargets` reads a rendered snapshot of the + * workspace: a CONSUME_FILES that was dispatched but not yet rendered when the + * first pass ran leaves its target already gone by the time UPDATE_FILE_RECORD + * is processed, so that stamp no-ops and the labels would be silently lost. The + * second pass sees the forked leaf and tags it. Each id is stamped at most once + * across both passes, so the pass costs nothing when no consume raced. + */ +async function stampClassificationLabels( + labels: string[], + resolveTargets: () => StirlingFileStub[], + ctx: ClassificationImportContext, +): Promise { + const updates = { classificationLabels: labels }; + const tagged = new Set(); + + for (let pass = 0; pass < 2; pass++) { + // Resolve and stamp the store in one synchronous block — no await between + // them, so a target can't be consumed in between. A consume AFTER the stamp + // is safe too: the CONSUME_FILES reducer carries classificationLabels onto + // the new leaf. + const fresh = resolveTargets().filter((s) => !tagged.has(s.id)); + for (const stub of fresh) { + tagged.add(stub.id); + ctx.updateStirlingFileStub(stub.id, updates); + } + + let mutated = false; + for (const stub of fresh) { + if (await fileStorage.updateFileMetadata(stub.id, updates)) + mutated = true; + } + if (mutated) ctx.bumpRevision(); + + // Yield a macrotask so React processes this pass's stamps (and any consume + // that raced them) before the next pass re-resolves. + if (pass === 0) await new Promise((resolve) => setTimeout(resolve)); + } + return Array.from(tagged); +} + +/** + * Deliver a classification run: read its labels and tag the live document with + * them. Metadata-only — nothing is versioned. + */ +async function importClassificationLabels( + run: PolicyRunRecord, + resolveTargets: () => StirlingFileStub[], + ctx: ClassificationImportContext, +): Promise { + if (resolveTargets().length === 0) { + // The document left the workspace (closed, or a server-reconciled run with + // no local input link) — nothing to tag. + updateRun(run.runId, { imported: true }); + return; + } + const labels = await readRunLabels(run); + const targetIds = + labels && labels.length > 0 + ? await stampClassificationLabels(labels, resolveTargets, ctx) + : []; + // Settle either way so it stops re-importing. outputFileIds are the TAGGED + // workspace files (no forked version), so their policy badge persists. Safe + // to chain-key on: classification is always last, so nothing chains off it. + updateRun(run.runId, { + imported: true, + importedFileIds: run.outputs.map((o) => o.fileId), + outputFileIds: targetIds, + }); +} + /** * Fetch a completed run's not-yet-imported output files and deliver them to the * workspace. Per-output, via allSettled: each output is tracked once delivered, @@ -737,6 +908,9 @@ async function runPolicyOnFile( backendId: string, fileId: FileId, fileName: string, + // Chained (downstream) dispatch — jumps the dispatch queue so a file mid-chain + // finishes its flow before new files start (see acquireDispatchSlot). + priority = false, ): Promise { // A freshly-uploaded file's bytes are written to IndexedDB asynchronously, so // its stub can appear in the file list a beat before getStirlingFile resolves @@ -762,6 +936,9 @@ async function runPolicyOnFile( markDispatched(categoryId, fileId); return; } + // Bounded upload window — see MAX_CONCURRENT_DISPATCHES. Only the POST is + // gated; the IDB wait above never holds a slot. + await acquireDispatchSlot(priority); try { const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); @@ -783,6 +960,8 @@ async function runPolicyOnFile( // the absent run simply won't appear in the activity feed. If the backend did // start a run we never recorded, reconcileServerRuns rediscovers it. markDispatched(categoryId, fileId); + } finally { + releaseDispatchSlot(); } } @@ -803,8 +982,10 @@ export async function poll( // would quit while a long step is still legitimately running. let budgetMs = DEFAULT_STEP_COUNT * STEP_TIMEOUT_MS + POLL_GRACE_MS; const startedAt = Date.now(); + let nextDelayMs = FIRST_POLL_MS; while (Date.now() - startedAt < budgetMs) { - await delay(POLL_MS); + await delay(nextDelayMs); + nextDelayMs = POLL_MS; let view; try { view = await getPolicyRun(runId); diff --git a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx index 45dea92070..048bca15ac 100644 --- a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx @@ -11,6 +11,7 @@ import { import { ActionIcon } from "@app/ui/ActionIcon"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import CloseIcon from "@mui/icons-material/Close"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import { useTranslation } from "react-i18next"; interface PolicyEnforcingOverlayProps { @@ -23,6 +24,9 @@ interface PolicyEnforcingOverlayProps { /** CSS colour var of the enforcing policy's accent (e.g. `var(--color-orange)`), * so the icon/spinner match that policy's badge instead of a fixed blue. */ accentVar?: string; + /** Category of the enforcing policy — picks its shared icon (shield for + * security, label for classification, …); generic shield when unknown. */ + categoryId?: string; } /** @@ -35,6 +39,7 @@ export function PolicyEnforcingOverlay({ zIndex = 200, onDismiss, accentVar, + categoryId, }: PolicyEnforcingOverlayProps) { const { t } = useTranslation(); if (!enforcing) return null; @@ -87,7 +92,11 @@ export function PolicyEnforcingOverlay({ : undefined } > - + {categoryId ? ( + policyCategoryIcon(categoryId, { fontSize: 26 }) + ) : ( + + )} {t("policy.enforcingTitle", "Enforcing policy…")} diff --git a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx index 75fa9cb952..71d5294f2d 100644 --- a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx +++ b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx @@ -71,6 +71,7 @@ export function PolicyEnforcementOverlay({ runs }: Props) { progress={progress} onDismiss={() => setDismissed(true)} accentVar={policyAccentVar(inFlight.categoryId)} + categoryId={inFlight.categoryId} /> ); } diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx index e1d0cd96ab..1f04ea22ca 100644 --- a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx +++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx @@ -8,6 +8,7 @@ import { usePolicyRuns, type PolicyRunRecord, } from "@app/components/policies/policyRunStore"; +import { isClassificationCategory } from "@app/data/policyCategories"; import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay"; type SignatureOverlayPassThrough = Pick< @@ -29,6 +30,8 @@ const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { ? allRuns.filter( (r: PolicyRunRecord) => r.fileId === activeFileId && + // Classification runs async and must never block the viewer. + !isClassificationCategory(r.categoryId) && (POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying === true), ) : []; diff --git a/frontend/editor/src/proprietary/data/policyCategories.test.ts b/frontend/editor/src/proprietary/data/policyCategories.test.ts new file mode 100644 index 0000000000..4b4fca2b7e --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyCategories.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { + isClassificationCategory, + pinClassificationLast, +} from "@app/data/policyCategories"; + +describe("isClassificationCategory", () => { + it("recognises the classification category and nothing else", () => { + expect(isClassificationCategory("classification")).toBe(true); + expect(isClassificationCategory("security")).toBe(false); + expect(isClassificationCategory("")).toBe(false); + }); +}); + +describe("pinClassificationLast", () => { + it("moves classification to the end, preserving other order", () => { + expect( + pinClassificationLast(["classification", "security", "compliance"]), + ).toEqual(["security", "compliance", "classification"]); + }); + + it("leaves an order without classification untouched", () => { + expect(pinClassificationLast(["security", "compliance"])).toEqual([ + "security", + "compliance", + ]); + }); + + it("is a no-op when classification is already last", () => { + expect(pinClassificationLast(["security", "classification"])).toEqual([ + "security", + "classification", + ]); + }); + + it("handles classification as the only policy", () => { + expect(pinClassificationLast(["classification"])).toEqual([ + "classification", + ]); + }); +}); diff --git a/frontend/editor/src/proprietary/data/policyCategories.ts b/frontend/editor/src/proprietary/data/policyCategories.ts new file mode 100644 index 0000000000..820a93366b --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyCategories.ts @@ -0,0 +1,21 @@ +/** The classification policy's catalog category id. */ +export const CLASSIFICATION_CATEGORY_ID = "classification"; + +/** + * Classification is metadata-only: it runs async (never blocks), never forks a + * version, and always runs last. This predicate gates that special handling. + */ +export function isClassificationCategory(categoryId: string): boolean { + return categoryId === CLASSIFICATION_CATEGORY_ID; +} + +/** + * Move classification to the end of an execution order (others keep their order), + * so a persisted/displayed order can't place it anywhere but last. + */ +export function pinClassificationLast(orderedCategoryIds: string[]): string[] { + return [ + ...orderedCategoryIds.filter((id) => !isClassificationCategory(id)), + ...orderedCategoryIds.filter((id) => isClassificationCategory(id)), + ]; +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 8043e6b66a..389f98cd65 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -35,6 +35,7 @@ import { removePolicy, } from "@app/services/policyBackend"; import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; +import { pinClassificationLast } from "@app/data/policyCategories"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -326,9 +327,12 @@ export function usePolicies() { * first for an instant re-render; the next reconcile re-reads the server order. */ const reorderPolicies = useCallback((orderedCategoryIds: string[]) => { - persistPolicyOrder(orderedCategoryIds); + // Pin classification last so the persisted/server order matches execution + // (it always runs last — see usePolicyAutoRun). + const ordered = pinClassificationLast(orderedCategoryIds); + persistPolicyOrder(ordered); const current = loadPolicies(); - const backendIds = orderedCategoryIds + const backendIds = ordered .map((categoryId) => current[categoryId]?.backendId) .filter((id): id is string => !!id); if (backendIds.length > 0) { diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts index c5d8b9952b..2364321213 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect } from "vitest"; -import { buildPolicyBadgeMap } from "@app/hooks/usePolicyFileBadges"; +import { + buildPolicyBadgeMap, + reusePolicyBadgeArrays, +} from "@app/hooks/usePolicyFileBadges"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; -const NOW = 1_000_000; const labels = new Map([ ["security", "Security"], ["watermark", "Watermark"], + ["classification", "Classification"], ]); function run(overrides: Partial): PolicyRunRecord { @@ -20,29 +23,24 @@ function run(overrides: Partial): PolicyRunRecord { outputs: [], outputFileIds: ["out"], error: null, - startedAt: NOW - 1_000, // recent by default + startedAt: 0, ...overrides, }; } describe("buildPolicyBadgeMap — badge follows the document onto derived files", () => { - it("badges a policy's direct output, and marks it recent within the window", () => { - const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels, NOW); - const badges = map.get("out") ?? []; - expect(badges.map((b) => b.id)).toEqual(["security"]); - expect(badges[0].recent).toBe(true); + it("badges a policy's direct output", () => { + const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels); + expect((map.get("out") ?? []).map((b) => b.id)).toEqual(["security"]); }); - it("a versioned edit inherits the badge via parentFileId (never glows)", () => { + it("a versioned edit inherits the badge via parentFileId", () => { const map = buildPolicyBadgeMap( [run({})], [{ id: "out" }, { id: "edit", parentFileId: "out" }], labels, - NOW, ); - const edit = map.get("edit") ?? []; - expect(edit.map((b) => b.id)).toEqual(["security"]); - expect(edit[0].recent).toBe(false); + expect((map.get("edit") ?? []).map((b) => b.id)).toEqual(["security"]); }); it("SPLIT parts inherit the badge via sourceFileIds, though they have no parent", () => { @@ -56,11 +54,9 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" { id: "part2", sourceFileIds: ["out"] }, ], labels, - NOW, ); expect((map.get("part1") ?? []).map((b) => b.id)).toEqual(["security"]); expect((map.get("part2") ?? []).map((b) => b.id)).toEqual(["security"]); - expect((map.get("part1") ?? [])[0].recent).toBe(false); }); it("resolves transitively when an intermediate edit was consumed/removed", () => { @@ -70,7 +66,6 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" [run({})], [{ id: "part", sourceFileIds: ["editGone", "out"] }], labels, - NOW, ); expect((map.get("part") ?? []).map((b) => b.id)).toEqual(["security"]); }); @@ -83,7 +78,6 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" ], [{ id: "merged", sourceFileIds: ["a", "b"] }], labels, - NOW, ); expect((map.get("merged") ?? []).map((b) => b.id).sort()).toEqual([ "security", @@ -96,24 +90,33 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" [run({})], [{ id: "out" }, { id: "unrelated", sourceFileIds: ["someUpload"] }], labels, - NOW, ); expect(map.has("unrelated")).toBe(false); }); - it("inherited badges never glow even when the source run is recent", () => { + it("a completed classification run badges the files it tagged", () => { + // Classification is metadata-only: its outputFileIds are the tagged + // workspace files (no forked version), so the label badge persists there. const map = buildPolicyBadgeMap( - [run({ startedAt: NOW })], // maximally recent - [{ id: "out" }, { id: "part", sourceFileIds: ["out"] }], + [ + run({ + categoryId: "classification", + fileId: "in", + outputFileIds: ["in"], + imported: true, + }), + ], + [{ id: "in" }], labels, - NOW, ); - expect((map.get("out") ?? [])[0].recent).toBe(true); - expect((map.get("part") ?? [])[0].recent).toBe(false); + const badges = map.get("in") ?? []; + expect(badges.map((b) => b.id)).toEqual(["classification"]); + expect(badges[0].enforcing).toBeUndefined(); + expect(badges[0].background).toBeUndefined(); }); }); -describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", () => { +describe("buildPolicyBadgeMap — in-flight indicators", () => { const enforcingOn = ( map: Map, id: string, @@ -124,7 +127,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "RUNNING", outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(true); }); @@ -136,7 +138,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "COMPLETED" })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(before, "in")).toBe(true); @@ -144,7 +145,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "COMPLETED", imported: true })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(after, "in")).toBe(false); }); @@ -155,7 +155,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status, outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(false); } @@ -166,7 +165,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "FAILED", retrying: true, outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(true); }); @@ -176,8 +174,97 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "RUNNING", fileId: "", outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(false); }); + + it("an in-flight classification run is background, never enforcing", () => { + // Non-blocking: shows a spinner but must never trip the enforcing flag + // that gates actions and overlays. + const map = buildPolicyBadgeMap( + [ + run({ + categoryId: "classification", + status: "RUNNING", + outputFileIds: [], + }), + ], + [{ id: "in" }], + labels, + ); + const badges = map.get("in") ?? []; + expect(badges.map((b) => b.id)).toEqual(["classification"]); + expect(badges[0].background).toBe(true); + expect(enforcingOn(map, "in")).toBe(false); + }); +}); + +describe("reusePolicyBadgeArrays — per-file identity across rebuilds", () => { + // buildPolicyBadgeMap allocates fresh arrays every call and the run store hands + // back a new `runs` array on every status poll, so without this the memoized + // sidebar rows get a new `policies` prop for EVERY badged file on each tick. + const build = (runs: PolicyRunRecord[], stubs: { id: string }[]) => + buildPolicyBadgeMap(runs, stubs, labels); + + const twoFiles = [{ id: "a" }, { id: "b" }]; + // Settled + imported, so the badge is a plain one (a COMPLETED run keeps + // `enforcing` until its outputs land — see the in-flight tests above). + const settled = (id: string) => + run({ + runId: `r${id}`, + fileId: id, + outputFileIds: [id], + status: "COMPLETED", + imported: true, + }); + const bothSettled = () => [settled("a"), settled("b")]; + + it("returns the same map when nothing changed", () => { + const first = build(bothSettled(), twoFiles); + const second = reusePolicyBadgeArrays( + first, + build(bothSettled(), twoFiles), + ); + expect(second).toBe(first); + }); + + it("keeps the untouched file's array identity when another file changes", () => { + const first = build(bothSettled(), twoFiles); + // "a" goes in-flight; "b" is unaffected and must keep its exact array. + const next = build( + [ + run({ + runId: "ra", + fileId: "a", + outputFileIds: ["a"], + status: "RUNNING", + }), + settled("b"), + ], + twoFiles, + ); + const second = reusePolicyBadgeArrays(first, next); + expect(second).not.toBe(first); + expect(second.get("b")).toBe(first.get("b")); + expect(second.get("a")).not.toBe(first.get("a")); + expect((second.get("a") ?? [])[0].enforcing).toBe(true); + expect((first.get("a") ?? [])[0].enforcing).toBeUndefined(); + }); + + it("a new badged file doesn't disturb the existing files' arrays", () => { + const first = build(bothSettled(), twoFiles); + const next = build( + [...bothSettled(), settled("c")], + [...twoFiles, { id: "c" }], + ); + const second = reusePolicyBadgeArrays(first, next); + expect(second.get("a")).toBe(first.get("a")); + expect(second.get("b")).toBe(first.get("b")); + expect((second.get("c") ?? []).map((b) => b.id)).toEqual(["security"]); + }); + + it("passes the fresh map straight through on the first build", () => { + const map = build(bothSettled(), twoFiles); + expect(reusePolicyBadgeArrays(null, map)).toBe(map); + }); }); diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 40fcd0399e..52779b9cf8 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -1,17 +1,12 @@ -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { usePolicyRuns } from "@app/components/policies/policyRunStore"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { policyAccentVar } from "@app/components/policies/policyStatus"; +import { isClassificationCategory } from "@app/data/policyCategories"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; -/** How long after a run a badge counts as "recent" (drives the one-off glow). - * Measured from run start — must exceed the longest realistic policy wall-clock - * time so the glow still fires after a slow run completes and imports. Old or - * reloaded runs fall outside this window, suppressing the glow on page reload. */ -const RECENT_MS = 5 * 60 * 1000; - /** Minimal provenance shape needed to resolve a file's inherited badges. */ type LineageStub = { id: string; @@ -19,14 +14,10 @@ type LineageStub = { sourceFileIds?: string[]; }; -/** Merge a ref into a list, deduping by policy id. A direct (recent) hit wins - * the glow over an inherited one for the same policy. */ +/** Merge a ref into a list, deduping by policy id. */ function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void { - const existing = list.find((p) => p.id === ref.id); - if (!existing) { + if (!list.some((p) => p.id === ref.id)) { list.push(ref); - } else if (ref.recent) { - existing.recent = true; } } @@ -42,21 +33,18 @@ function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void { * from: its transitive `sourceFileIds` (recorded at the consume boundary, so it * covers split/merge/convert too) plus, defensively, its `parentFileId`. * Because `sourceFileIds` is transitive, a flat lookup suffices — no chain walk, - * and it survives a consumed intermediate. Inherited badges never glow - * (recent=false): only the original application does. + * and it survives a consumed intermediate. */ export function buildPolicyBadgeMap( runs: ReadonlyArray, stubs: ReadonlyArray, labelById: ReadonlyMap, - now: number, ): Map { // Direct badges: a file that IS a policy run's output. const directByFile = new Map(); for (const run of runs) { const name = labelById.get(run.categoryId); if (!name) continue; - const recent = now - run.startedAt < RECENT_MS; for (const fileId of run.outputFileIds ?? []) { const list = directByFile.get(fileId) ?? []; if (!list.some((p) => p.id === run.categoryId)) { @@ -64,7 +52,6 @@ export function buildPolicyBadgeMap( id: run.categoryId, name, accentColor: policyAccentVar(run.categoryId), - recent, }); directByFile.set(fileId, list); } @@ -86,7 +73,7 @@ export function buildPolicyBadgeMap( // from. `sourceFileIds` is the transitive provenance set (so a flat lookup // catches even ancestors whose intermediate edits were consumed), and // `parentFileId` is included defensively for any child not created via a - // consume. Inherited badges are marked recent=false (carried, not applied). + // consume. for (const stub of stubs) { const sources = new Set(stub.sourceFileIds ?? []); if (stub.parentFileId) sources.add(stub.parentFileId); @@ -94,14 +81,17 @@ export function buildPolicyBadgeMap( const srcBadges = directByFile.get(src); if (!srcBadges?.length) continue; const list = result.get(stub.id) ?? []; - for (const ref of srcBadges) mergeRef(list, { ...ref, recent: false }); + for (const ref of srcBadges) mergeRef(list, { ...ref }); result.set(stub.id, list); } } // In-flight pass: add (or upgrade) a badge on the input file for any run that // is currently being processed, so the sidebar shows a spinning indicator - // while the policy is actively enforcing — not just after it completes. + // while the policy is actively running — not just after it completes. + // Blocking policies set `enforcing` (which gates actions/overlays); + // classification is non-blocking, so it sets `background` instead — same + // spinner, but nothing is ever gated on it. // Keep the spinner until `imported` is true: the status reaches COMPLETED // before the output files are imported into the workspace, so gating on // status alone would drop the badge during that async gap. @@ -112,17 +102,19 @@ export function buildPolicyBadgeMap( if (settled && !run.retrying) continue; const name = labelById.get(run.categoryId); if (!name) continue; + const inFlightFlag = isClassificationCategory(run.categoryId) + ? ("background" as const) + : ("enforcing" as const); const list = result.get(run.fileId) ?? []; const existing = list.find((p) => p.id === run.categoryId); if (existing) { - existing.enforcing = true; + existing[inFlightFlag] = true; } else { list.push({ id: run.categoryId, name, accentColor: policyAccentVar(run.categoryId), - recent: false, - enforcing: true, + [inFlightFlag]: true, }); result.set(run.fileId, list); } @@ -131,20 +123,70 @@ export function buildPolicyBadgeMap( return result; } +/** Field-wise equality for a badge ref — the whole shape `PolicyBadges` renders. */ +function sameRef(a: FileItemPolicyRef, b: FileItemPolicyRef): boolean { + return ( + a.id === b.id && + a.name === b.name && + a.accentColor === b.accentColor && + !!a.enforcing === !!b.enforcing && + !!a.background === !!b.background + ); +} + +function sameRefs(a: FileItemPolicyRef[], b: FileItemPolicyRef[]): boolean { + return a.length === b.length && a.every((ref, i) => sameRef(ref, b[i])); +} + +/** + * Carry the previous map's array references over to files whose badges didn't + * change, and return the previous MAP itself when none did. + * + * {@link buildPolicyBadgeMap} allocates a fresh array per badged file on every + * call, and the run store hands back a new `runs` array on every status poll — + * so without this, one file's poll tick gives EVERY badged file a new `policies` + * identity, and the memoized sidebar rows can never bail out (the case the + * memoization exists for). `NO_POLICIES` in FileSidebar only covers the rows + * with no badges at all. + */ +export function reusePolicyBadgeArrays( + previous: Map | null, + next: Map, +): Map { + if (!previous) return next; + let changed = previous.size !== next.size; + for (const [fileId, refs] of next) { + const before = previous.get(fileId); + if (before && sameRefs(before, refs)) next.set(fileId, before); + else changed = true; + } + return changed ? next : previous; +} + /** * Distinct policies that have produced each file, keyed by fileId, derived from * the reactive policy run store. Drives the file sidebar's shield badges. The * badge follows a document down its tool-edit chain — see * {@link buildPolicyBadgeMap}. Shadows the core stub via the {@code @app/*} * alias cascade. + * + * Per-file array identity is preserved across rebuilds so memoized consumers + * (the sidebar rows) only re-render for the file that actually changed — see + * {@link reusePolicyBadgeArrays}. */ export function usePolicyFileBadges(): Map { const runs = usePolicyRuns(); const { fileStubs } = useAllFiles(); + const previous = useRef | null>(null); return useMemo(() => { const labelById = new Map( loadPolicyCatalog().categories.map((c) => [c.id, c.label]), ); - return buildPolicyBadgeMap(runs, fileStubs, labelById, Date.now()); + const map = reusePolicyBadgeArrays( + previous.current, + buildPolicyBadgeMap(runs, fileStubs, labelById), + ); + previous.current = map; + return map; }, [runs, fileStubs]); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 970d6703cd..7db904ddf6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,6 +84,7 @@ "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", + "use-sync-external-store": "^1.6.0", "web-vitals": "^5.1.0" }, "devDependencies": { @@ -109,6 +110,7 @@ "@types/node": "^24.5.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.9", + "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/frontend/package.json b/frontend/package.json index 1763a2126a..dfcfa2842c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -81,6 +81,7 @@ "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", + "use-sync-external-store": "^1.6.0", "web-vitals": "^5.1.0" }, "scripts": { @@ -131,6 +132,7 @@ "@types/node": "^24.5.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.9", + "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", From a2dd0298dc7931c1e7f202343a75937a4edd889a Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:04 +0200 Subject: [PATCH 080/262] refactor(api): replace length checks with isEmpty (#7214) # Description of Changes Stylistic problem reported by static analyzer. Changes: * Replaced `sb.length() > 0` and `sb.length() == 0` with `!sb.isEmpty()` and `sb.isEmpty()` for `StringBuilder`, `String`, and collections throughout the codebase, improving readability and aligning with modern Java best practices. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] 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. --- .../common/pdf/PdfMarkdownConverter.java | 2 +- .../software/common/util/GeneralUtils.java | 2 +- .../common/pdf/PdfMarkdownConverterTest.java | 2 +- .../SPDF/config/ExternalAppDepConfig.java | 2 +- .../SPDF/controller/api/UIDataController.java | 2 +- .../api/security/PasswordController.java | 8 ++++---- .../api/security/RedactExecuteService.java | 18 +++++++++--------- .../SPDF/controller/web/MetricsController.java | 6 +++--- .../SPDF/service/HardwareKeyStoreService.java | 4 ++-- .../controller/api/UserController.java | 2 +- .../security/service/UserService.java | 2 +- .../service/PortalInfraAuditService.java | 2 +- .../service/UserLicenseSettingsService.java | 4 ++-- 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java index c19468b5ed..73f2d7f5ad 100644 --- a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java +++ b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java @@ -983,7 +983,7 @@ public class PdfMarkdownConverter { ordered.sort(Comparator.comparingDouble((Line l) -> l.y).reversed()); StringBuilder sb = new StringBuilder(); for (Line l : ordered) { - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append(' '); } sb.append(l.text); diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 4a9ef0834b..52f79f733a 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -941,7 +941,7 @@ public class GeneralUtils { } // If no MAC address found, use hostname as fallback - if (sb.length() == 0) { + if (sb.isEmpty()) { String hostname = InetAddress.getLocalHost().getHostName(); sb.append(hostname != null ? hostname : "unknown-host"); log.warn("No MAC address found, using hostname for fingerprint generation"); diff --git a/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java index b3c104da85..7e1d3d2e35 100644 --- a/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java +++ b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java @@ -154,7 +154,7 @@ class PdfMarkdownConverterTest { || isTableSeparatorRow(line)) { continue; } - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append('\n'); } sb.append(line); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 8755dfe2ef..46b1976ca7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -321,7 +321,7 @@ public class ExternalAppDepConfig { new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null) { - if (sb.length() > 0) sb.append('\n'); + if (!sb.isEmpty()) sb.append('\n'); sb.append(line); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index de391c7c32..a3ed09fe5d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -130,7 +130,7 @@ public class UIDataController { objectMapper.readValue( config, new TypeReference>() {}); String name = (String) jsonContent.get("name"); - if (name == null || name.length() < 1) { + if (name == null || name.isEmpty()) { String filename = jsonFiles .get(pipelineConfigs.indexOf(config)) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java index 690c82ca8f..2ad494fc63 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java @@ -124,15 +124,15 @@ public class PasswordController { StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap); - if ((ownerPassword != null && ownerPassword.length() > 0) - || (password != null && password.length() > 0)) { + if ((ownerPassword != null && !ownerPassword.isEmpty()) + || (password != null && !password.isEmpty())) { spp.setEncryptionKeyLength(keyLength); } spp.setPermissions(ap); document.protect(spp); - if ((ownerPassword == null || ownerPassword.length() == 0) - && (password == null || password.length() == 0)) + if ((ownerPassword == null || ownerPassword.isEmpty()) + && (password == null || password.isEmpty())) return WebResponseUtils.pdfDocToWebResponse( document, GeneralUtils.generateFilename( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java index 4a53be97b6..c43abcf666 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java @@ -760,12 +760,12 @@ class RedactExecuteService { char ch = raw.charAt(i); if (Character.isLetterOrDigit(ch)) { current.append(ch); - } else if (current.length() > 0) { + } else if (!current.isEmpty()) { tokens.add(current.toString()); current.setLength(0); } } - if (current.length() > 0) tokens.add(current.toString()); + if (!current.isEmpty()) tokens.add(current.toString()); if (tokens.size() < 2) return null; StringBuilder out = new StringBuilder(); for (int i = 0; i < tokens.size(); i++) { @@ -788,25 +788,25 @@ class RedactExecuteService { StringBuilder current = new StringBuilder(); for (String token : tokens) { if (token.isEmpty()) { - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); current.setLength(0); } } else if (token.length() == 1) { current.append(token); } else { - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); current.setLength(0); } - if (result.length() > 0) result.append(' '); + if (!result.isEmpty()) result.append(' '); result.append(token); } } - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); } return result.toString().trim(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index de28d66ca9..4bbf3dfb82 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -251,7 +251,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { @@ -292,7 +292,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { @@ -332,7 +332,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java index 988b935f27..93ca36d08e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java @@ -237,12 +237,12 @@ public class HardwareKeyStoreService { combined.append(env); } if (prop != null && !prop.isBlank()) { - if (combined.length() > 0) { + if (!combined.isEmpty()) { combined.append(java.io.File.pathSeparator); } combined.append(prop); } - if (combined.length() == 0) { + if (combined.isEmpty()) { return List.of(); } return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index b19c052ff1..fdacda72b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -199,7 +199,7 @@ public class UserController { return ResponseEntity.status(HttpStatus.CONFLICT) .body(Map.of("error", "usernameExists", "message", "Username already exists")); } - if (newUsername != null && newUsername.length() > 0) { + if (newUsername != null && !newUsername.isEmpty()) { try { userService.changeUsername(user, newUsername); } catch (IllegalArgumentException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 0cb4653ef1..e08dd52d07 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -205,7 +205,7 @@ public class UserService implements UserServiceInterface { User user = findByUsernameIgnoreCase(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - if (user.getApiKey() == null || user.getApiKey().length() == 0) { + if (user.getApiKey() == null || user.getApiKey().isEmpty()) { user = addApiKeyToUser(username); } return user.getApiKey(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java index c47df3649f..86f944ca9d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java @@ -224,7 +224,7 @@ public class PortalInfraAuditService { if (word.isEmpty()) { continue; } - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append(' '); } String lower = word.toLowerCase(Locale.ROOT); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index 54660a1ccb..085a9ffcf1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -515,7 +515,7 @@ public class UserLicenseSettingsService { appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID()); appendIfPresent(builder, applicationProperties.getPremium().getKey()); - if (builder.length() == 0) { + if (builder.isEmpty()) { builder.append(DEFAULT_INTEGRITY_SECRET); } @@ -524,7 +524,7 @@ public class UserLicenseSettingsService { private void appendIfPresent(StringBuilder builder, String value) { if (value != null && !value.isBlank()) { - if (builder.length() > 0) { + if (!builder.isEmpty()) { builder.append(SIGNATURE_SEPARATOR); } builder.append(value); From e5c6ceedc52a3aee263ffb20ca2ff4813ca9799c Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:25 +0200 Subject: [PATCH 081/262] fix(ui): resolve double scrollbar issue in Sidebar Categories modal (#7142) # Description of Changes Resolves double scrollbar design bug. ### New image ### Old image --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] 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) - [x] I have performed a self-review of my own code - [x] 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) - [x] 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. --- .../proprietary/components/shared/FileSidebarGroupControls.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css index 16712e351a..0889dfe3d1 100644 --- a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css +++ b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css @@ -3,9 +3,6 @@ display: flex; flex-direction: column; gap: 12px; - max-height: 60vh; - overflow-y: auto; - padding-right: 4px; } .fsg-footer { From 732a6025038edfa6250b11b32ce100f9116d5273 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:43 +0200 Subject: [PATCH 082/262] style(scanner-effect): remove padding from ToolButton of Scanner-effect (#7205) # Description of Changes ### New image ### Old image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] 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. --- .../editor/src/core/components/tools/toolPicker/ToolButton.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index 78c573bb38..4792fd721a 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -286,7 +286,7 @@ const ToolButton: React.FC = ({ accent="neutral" onClick={() => handleClick(id)} size="sm" - p="sm" + p="none" fullWidth justify="start" className="tool-button" @@ -297,6 +297,7 @@ const ToolButton: React.FC = ({ borderRadius: 0, cursor: visuallyUnavailable ? "not-allowed" : undefined, overflow: "visible", + ...selectedBg, }} > {buttonContent} From cc1c6bc9e37ecc4904b74a489fa6f0d4a6f98d4e Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:01:46 +0200 Subject: [PATCH 083/262] style(people): Fix convert dropdown single-category formatting and admin table header styling (#7139) # Description of Changes The dropdown table with the people looked out-place mainly due to the blue, i think... this simplifies and make more consistent with the rest of the "new" UI and not so aggresive with the colour schema. ### New: image ### Old: image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] 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. --- .../config/configSections/PeopleSection.tsx | 24 ++++--------------- .../configSections/TeamDetailsSection.tsx | 24 ++++--------------- .../config/configSections/TeamsSection.tsx | 9 +------ 3 files changed, 9 insertions(+), 48 deletions(-) diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 10e9f39fb0..d955cb341b 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -588,38 +588,22 @@ export default function PeopleSection() { {/* Members Table */} - +
    - - + + {t("workspace.people.user")} {t("workspace.people.role")} - + {t("workspace.people.team")} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx index cabdfb642c..d3702e7f80 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx @@ -408,29 +408,13 @@ export default function TeamDetailsSection({ {/* Members Table */} -
    +
    - - + + {t("workspace.people.user")} - + {t("workspace.people.role")} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx index 4559296afe..d4da200d59 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx @@ -298,19 +298,13 @@ export default function TeamsSection() { verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } > - + {t("workspace.teams.teamName")} @@ -319,7 +313,6 @@ export default function TeamsSection() { style={{ fontWeight: 600, fontSize: "0.875rem", - color: "var(--mantine-color-gray-7)", }} > {t("workspace.teams.totalMembers")} From 9aaf4173036f4ebca74c8ea8379e4362a17bd969 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:11:26 +0200 Subject: [PATCH 084/262] perf(ui): lazy-load MobileScannerPage and optimize bundle splitting (#7122) # Description of Changes This pull request improves the frontend's performance and code organization by optimizing how certain pages are loaded and by updating the application's bundle splitting strategy. Changes: * Updated the `manualChunks` configuration in `vite.config.ts` to more granularly split vendor dependencies into separate chunks based on their library or usage, which can improve caching and load performance. * Updated MobileScanner code to be lazy loaded --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/editor/src/core/App.tsx | 5 ++-- frontend/editor/src/proprietary/App.tsx | 5 ++-- frontend/editor/src/saas/App.tsx | 5 ++-- frontend/editor/vite.config.ts | 33 ++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 566e861520..6fd23e5510 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; import { AppLayout } from "@app/components/AppLayout"; @@ -6,9 +6,10 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import HomePage from "@app/pages/HomePage"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); + // Import global styles import "@app/styles/tailwind.css"; import "@app/styles/cookieconsent.css"; diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index e98064ebc8..a2103bd60a 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, lazy } from "react"; import { Routes, Route, useParams } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; import { AppLayout } from "@app/components/AppLayout"; @@ -12,9 +12,10 @@ import AuthCallback from "@app/routes/AuthCallback"; import InviteAccept from "@app/routes/InviteAccept"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import ParticipantView from "@app/components/workflow/ParticipantView"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; + +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 32af9a6782..9509f630b0 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -1,4 +1,4 @@ -import { Suspense, type ReactNode } from "react"; +import { Suspense, lazy, type ReactNode } from "react"; import { Routes, Route, useLocation } from "react-router-dom"; import { isAuthRoute } from "@app/utils/pathUtils"; import { AppProviders } from "@app/components/AppProviders"; @@ -16,13 +16,14 @@ import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; import OAuthConsent from "@app/routes/OAuthConsent"; import ShareLinkPage from "@app/routes/ShareLinkPage"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); + // Import global styles import "@app/styles/tailwind.css"; import "@app/auth/ui/auth-theme.css"; diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 0d367d2465..2e72d1efec 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -358,9 +358,36 @@ export default defineConfig(async ({ mode, command }) => { target: "esnext", rollupOptions: { output: { - manualChunks: { - "vendor-react": ["react", "react-dom"], - "pdf-engine": ["@embedpdf/engines", "@embedpdf/pdfium"], + manualChunks(id) { + if (id.includes("material-symbols-icons.json")) + return "vendor-iconset"; + if (id.includes("node_modules")) { + if (id.includes("pdfjs-dist")) return "vendor-pdfjs"; + if (id.includes("@embedpdf")) return "vendor-embedpdf"; + if ( + id.includes("react") || + id.includes("@mantine") || + id.includes("@emotion") || + id.includes("@mui") || + id.includes("@iconify") + ) { + return "vendor-ui"; + } + if (id.includes("@supabase")) return "vendor-supabase"; + if (id.includes("posthog-js") || id.includes("@posthog")) + return "vendor-posthog"; + if (id.includes("@cantoo/pdf-lib") || id.includes("pdf-lib")) + return "vendor-pdflib"; + if ( + id.includes("recharts") || + id.includes("d3") || + id.includes("decimal.js") + ) + return "vendor-charts"; + if (id.includes("jszip") || id.includes("pako")) + return "vendor-zip"; + if (id.includes("i18next")) return "vendor-i18n"; + } }, }, }, From 934ad180cb9cf87aa2709c1b40f4e5ca4ee2f223 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:16:15 +0200 Subject: [PATCH 085/262] fix(ui): handle 404 policy error on upload without toast popup (#7254) # Description of Changes Fixes policy error pop-ups that sometimes happen upon upload. Changes: - Improved the error handling in `runPolicyOnFile` to log detailed debug information when policy dispatch fails, making it easier to trace issues such as missing policies or backend errors. - Updated the `runStoredPolicy` function to pass `{ suppressErrorToast: true }` to the API client, preventing error toasts from appearing in the UI when the policy run fails. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] 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. --- .../proprietary/components/policies/usePolicyAutoRun.ts | 8 ++++++-- frontend/editor/src/proprietary/services/policyApi.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index e475d784ea..597136a32f 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -955,10 +955,14 @@ async function runPolicyOnFile( error: null, startedAt: Date.now(), }); - } catch { - // Dispatch failed (offline / backend error). Mark dispatched so we don't hammer; + } catch (err) { + // Dispatch failed (e.g. policy deleted/404 or backend offline). Mark dispatched so we don't hammer; // the absent run simply won't appear in the activity feed. If the backend did // start a run we never recorded, reconcileServerRuns rediscovers it. + console.debug( + `[PolicyAutoRun] Failed to dispatch policy ${categoryId} (${backendId}):`, + err, + ); markDispatched(categoryId, fileId); } finally { releaseDispatchSlot(); diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index a10c7a8382..b0d78f36f7 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -73,6 +73,7 @@ export async function runStoredPolicy( const res = await apiClient.post( `/api/v1/policies/${encodeURIComponent(id)}/run`, form, + { suppressErrorToast: true }, ); return res.data.jobId; } From 7cccee4c346c5bf10058bb7e570c23f7374bb9f5 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:17:27 +0200 Subject: [PATCH 086/262] refactor(get-info): remove redundant PDF validation logic (#7213) # Description of Changes Could not get past validation, since very few endpoint have such validation, i think redundant. Changes: * Removed the `validatePdfFile` method, which previously checked for file presence, size limits, and content type, from `GetInfoOnPDF.java`. * Deleted the invocation of `validatePdfFile` and its associated error handling from the `getPdfInfo` method, so uploaded files are no longer validated at this layer. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] 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. --- .../controller/api/security/GetInfoOnPDF.java | 28 ------- .../api/security/GetInfoOnPDFMoreTest.java | 15 ---- .../api/security/GetInfoOnPDFTest.java | 76 ------------------- 3 files changed, 119 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index d29480fd3c..d2775e910b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -61,7 +61,6 @@ import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.WebResponseUtils; @@ -270,25 +269,6 @@ public class GetInfoOnPDF { } } - private static void validatePdfFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new IllegalArgumentException("PDF file is required"); - } - - if (file.getSize() > MAX_FILE_SIZE) { - throw ExceptionUtils.createIllegalArgumentException( - "error.fileSizeLimit", - "File size ({0} bytes) exceeds maximum allowed size ({1} bytes)", - file.getSize(), - MAX_FILE_SIZE); - } - - String contentType = file.getContentType(); - if (contentType != null && !"application/pdf".equals(contentType)) { - log.warn("File content type is {}, expected application/pdf", contentType); - } - } - private static ResponseEntity createErrorResponse(String errorMessage) { try { ObjectNode errorNode = objectMapper.createObjectNode(); @@ -1104,14 +1084,6 @@ public class GetInfoOnPDF { public ResponseEntity getPdfInfo(@ModelAttribute PDFFile request) throws IOException { MultipartFile inputFile = request.getFileInput(); - // Validate input - try { - validatePdfFile(inputFile); - } catch (IllegalArgumentException e) { - log.error("Invalid PDF file: {}", e.getMessage()); - return createErrorResponse("Invalid PDF file: " + e.getMessage()); - } - List verificationResults = null; try { verificationResults = veraPDFService.validatePDF(inputFile.getInputStream()); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java index 7e2aa388fa..1df66f739e 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java @@ -264,21 +264,6 @@ class GetInfoOnPDFMoreTest { @DisplayName("error handling") class Errors { - @Test - @DisplayName("empty file input yields an error response") - void emptyFile() throws Exception { - MockMultipartFile mf = - new MockMultipartFile("fileInput", "x.pdf", "application/pdf", new byte[0]); - PDFFile request = new PDFFile(); - request.setFileInput(mf); - ResponseEntity resp = getInfoOnPDF.getPdfInfo(request); - // createErrorResponse returns HTTP 200 with a JSON body carrying an "error" field. - assertThat(resp.getBody()).isNotNull(); - JsonNode body = om.readTree(resp.getBody()); - assertThat(body.has("error")).isTrue(); - assertThat(body.get("error").asText("")).contains("Invalid"); - } - @Test @DisplayName("veraPDF failure is swallowed and a report is still produced") void veraPdfFailureSwallowed() throws Exception { diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java index efc09cf190..8d17d729ea 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java @@ -556,24 +556,6 @@ class GetInfoOnPDFTest { @DisplayName("Validation and Error Handling Tests") class ValidationErrorTests { - @Test - @DisplayName("Should reject null file") - void testValidation_NullFile() throws IOException { - PDFFile request = new PDFFile(); - request.setFileInput(null); - - ResponseEntity response = getInfoOnPDF.getPdfInfo(request); - - Assertions.assertEquals( - HttpStatus.OK, response.getStatusCode()); // Returns error JSON with 200 - String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - Assertions.assertTrue(jsonNode.has("error")); - Assertions.assertTrue( - jsonNode.get("error").asText("").contains("PDF file is required")); - } - @Test @DisplayName("Should reject empty file") void testValidation_EmptyFile() throws IOException { @@ -591,64 +573,6 @@ class GetInfoOnPDFTest { Assertions.assertTrue(jsonNode.has("error")); } - - @Test - @DisplayName("Should reject file that exceeds max size") - void testValidation_TooLargeFile() throws IOException { - MultipartFile largeFile = - new MultipartFile() { - @Override - public String getName() { - return "file"; - } - - @Override - public String getOriginalFilename() { - return "large.pdf"; - } - - @Override - public String getContentType() { - return MediaType.APPLICATION_PDF_VALUE; - } - - @Override - public boolean isEmpty() { - return false; - } - - @Override - public long getSize() { - // Report 101 MB without allocating memory - return 101L * 1024L * 1024L; - } - - @Override - public byte[] getBytes() { - return new byte[0]; - } - - @Override - public java.io.InputStream getInputStream() { - return java.io.InputStream.nullInputStream(); - } - - @Override - public void transferTo(java.io.File dest) throws IllegalStateException {} - }; - - PDFFile request = new PDFFile(); - request.setFileInput(largeFile); - - ResponseEntity response = getInfoOnPDF.getPdfInfo(request); - - String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - Assertions.assertTrue(jsonNode.has("error")); - Assertions.assertTrue( - jsonNode.get("error").asText("").contains("exceeds maximum allowed size")); - } } @Nested From e560ee4cc45e1bde1e71012f888e05f83add8b86 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:25:22 +0200 Subject: [PATCH 087/262] chore(deps): update junrar dependency to version 8.0.0 (#7210) # Description of Changes Since this was a major version update i tested manually, afterwards figured i'll submit as PR. This version of junrar adds long-awaited (by me) RAR 5 support to the library. RAR 5 is newest version of the RAR file format and was not available in previous Junrar version, but is somewhat common for CBR files to be RAR 5. For junrar release notes see: https://github.com/junrar/junrar/releases/tag/v8.0.0 Changes: - Bumped junrar dep to version 8.0.0 --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] 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. --- app/common/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 73be441940..942dddc5fd 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -16,7 +16,7 @@ dependencies { api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion" api "org.apache.pdfbox:xmpbox:$pdfboxVersion" api "org.apache.pdfbox:preflight:$pdfboxVersion" - api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files + api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'org.snakeyaml:snakeyaml-engine:3.0.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" From 866e56728d11a8e9e6c5313cdb860e5e814abac1 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:06:16 +0200 Subject: [PATCH 088/262] fix(storage): delete share access records before expired share links (#7161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Updated expired share-link cleanup to delete related `FileShareAccess` records before deleting their parent `FileShare` records. - Wrapped the cleanup operation in a transaction to ensure the deletion order is enforced atomically. - Prevents foreign-key constraint violations and scheduled-task failures during cleanup. - The full backend check was limited by a Gradle distribution download/network error. ```cmd [backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN org.hibernate.orm.jdbc.error - HHH000247: ErrorCode: 23503, SQLState: 23503 [backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN org.hibernate.orm.jdbc.error - Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240] [backend:dev:proprietary] 16:25:43.380 [scheduled-vt-2] ERROR o.s.s.s.TaskUtils$LoggingErrorHandler - Unexpected error occurred in scheduled task [backend:dev:proprietary] org.springframework.dao.DataIntegrityViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?]; SQL [delete from file_shares where file_share_id=?]; constraint [FKQ6V4QH5LFCAWII0ABRVSJO5SG] [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:169) [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:131) [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.translateExceptionIfPossible(HibernateExceptionTranslator.java:105) [backend:dev:proprietary] at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:223) [backend:dev:proprietary] at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:557) [backend:dev:proprietary] at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:794) [backend:dev:proprietary] at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:757) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:687) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:408) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:130) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:135) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:166) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:222) [backend:dev:proprietary] at jdk.proxy4/jdk.proxy4.$Proxy246.deleteAll(Unknown Source) [backend:dev:proprietary] at stirling.software.proprietary.storage.service.StorageCleanupService.cleanupExpiredShareLinks(StorageCleanupService.java:71) [backend:dev:proprietary] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) [backend:dev:proprietary] at java.base/java.lang.reflect.Method.invoke(Method.java:565) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.runInternal(ScheduledMethodRunnable.java:128) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.lambda$run$1(ScheduledMethodRunnable.java:122) [backend:dev:proprietary] at io.micrometer.observation.Observation.observe(Observation.java:569) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:122) [backend:dev:proprietary] at org.springframework.scheduling.config.Task$OutcomeTrackingRunnable.run(Task.java:88) [backend:dev:proprietary] at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54) [backend:dev:proprietary] at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:545) [backend:dev:proprietary] at java.base/java.util.concurrent.FutureTask.runAndReset(FutureTask.java:369) [backend:dev:proprietary] at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:310) [backend:dev:proprietary] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) [backend:dev:proprietary] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) [backend:dev:proprietary] at java.base/java.lang.VirtualThread.run(VirtualThread.java:460) [backend:dev:proprietary] Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?] [backend:dev:proprietary] at org.hibernate.dialect.H2Dialect.lambda$buildSQLExceptionConversionDelegate$0(H2Dialect.java:840) [backend:dev:proprietary] at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:34) [backend:dev:proprietary] at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:115) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:184) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.performNonBatchedMutation(AbstractMutationExecutor.java:145) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.MutationExecutorSingleNonBatched.performNonBatchedOperations(MutationExecutorSingleNonBatched.java:53) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.execute(AbstractMutationExecutor.java:66) [backend:dev:proprietary] at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.doStaticDelete(AbstractDeleteCoordinator.java:268) [backend:dev:proprietary] at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.delete(AbstractDeleteCoordinator.java:79) [backend:dev:proprietary] at org.hibernate.action.internal.EntityDeleteAction.execute(EntityDeleteAction.java:119) [backend:dev:proprietary] at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:634) [backend:dev:proprietary] at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:505) [backend:dev:proprietary] at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:381) [backend:dev:proprietary] at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40) [backend:dev:proprietary] at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:138) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.fireFlush(SessionImpl.java:1484) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:481) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2111) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2033) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:410) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:166) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commitNoRollbackOnly(JdbcResourceLocalTransactionCoordinatorImpl.java:248) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:242) [backend:dev:proprietary] at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:89) [backend:dev:proprietary] at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:553) [backend:dev:proprietary] ... 27 common frames omitted [backend:dev:proprietary] Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240] [backend:dev:proprietary] at org.h2.message.DbException.getJdbcSQLException(DbException.java:520) [backend:dev:proprietary] at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) [backend:dev:proprietary] at org.h2.message.DbException.get(DbException.java:223) [backend:dev:proprietary] at org.h2.message.DbException.get(DbException.java:199) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:363) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRowRefTable(ConstraintReferential.java:380) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:254) [backend:dev:proprietary] at org.h2.table.Table.fireConstraints(Table.java:1208) [backend:dev:proprietary] at org.h2.table.Table.fireAfterRow(Table.java:1226) [backend:dev:proprietary] at org.h2.command.dml.Delete.update(Delete.java:81) [backend:dev:proprietary] at org.h2.command.dml.DataChangeStatement.update(DataChangeStatement.java:77) [backend:dev:proprietary] at org.h2.command.CommandContainer.update(CommandContainer.java:139) [backend:dev:proprietary] at org.h2.command.Command.executeUpdate(Command.java:306) [backend:dev:proprietary] at org.h2.command.Command.executeUpdate(Command.java:250) [backend:dev:proprietary] at org.h2.jdbc.JdbcPreparedStatement.executeUpdateInternal(JdbcPreparedStatement.java:213) [backend:dev:proprietary] at org.h2.jdbc.JdbcPreparedStatement.executeUpdate(JdbcPreparedStatement.java:172) [backend:dev:proprietary] at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61) [backend:dev:proprietary] at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:181) [backend:dev:proprietary] ... 48 common frames omitted ``` --- ## 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. --- .../proprietary/storage/service/StorageCleanupService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java index 5ea5f28def..32c54c6298 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java @@ -7,12 +7,14 @@ import java.util.concurrent.TimeUnit; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.storage.model.StorageCleanupEntry; import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; import stirling.software.proprietary.storage.repository.FileShareRepository; import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; @@ -25,6 +27,7 @@ public class StorageCleanupService { private final StorageProvider storageProvider; private final StorageCleanupEntryRepository cleanupEntryRepository; + private final FileShareAccessRepository fileShareAccessRepository; private final FileShareRepository fileShareRepository; @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) @@ -62,12 +65,14 @@ public class StorageCleanupService { } @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + @Transactional public void cleanupExpiredShareLinks() { List expired = fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now()); if (expired.isEmpty()) { return; } + expired.forEach(fileShareAccessRepository::deleteByFileShare); fileShareRepository.deleteAll(expired); } } From b10fc1b2de79ced7e7b26d89191c27868619dd80 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:06:28 +0200 Subject: [PATCH 089/262] fix(java): prevent executor, task, regex, and stream resource leaks (#7284) # Description of Changes - Added graceful shutdown handling for service-owned executors in `JobExecutorService`, `PolicyEngine`, and `AsyncConfig`. - Added expiration and cleanup for abandoned pending jobs in `TaskManager`. - Replaced the unbounded regex pattern cache with a bounded cache limited to 512 entries. - Ensured `Files.walk()` is closed correctly in `MobileScannerService`. - These changes prevent unbounded heap growth, lingering virtual-thread executors, and file-descriptor leaks. - Added configurable pending-job expiration through `stirling.job.pendingExpiryMinutes`, defaulting to 24 hours. --- ## 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. --- .../common/service/JobExecutorService.java | 16 ++++++++ .../common/service/MobileScannerService.java | 28 +++++++------ .../software/common/service/TaskManager.java | 27 ++++++++++--- .../common/util/RegexPatternUtils.java | 40 +++++++++++++++---- .../proprietary/config/AsyncConfig.java | 30 ++++++++++++-- .../policy/engine/PolicyEngine.java | 17 ++++++++ 6 files changed, 128 insertions(+), 30 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 23a23e868b..f283f65763 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import jakarta.annotation.PreDestroy; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @@ -63,6 +64,21 @@ public class JobExecutorService { "Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs); } + /** Stop the service-owned executor when the application context is closed or restarted. */ + @PreDestroy + public void shutdown() { + log.debug("Shutting down job executor"); + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + executor.shutdownNow(); + } + } + public ResponseEntity runJobGeneric(boolean async, Supplier work) { return runJobGeneric(async, work, -1); } diff --git a/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java b/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java index 7c544b6242..18958841b2 100644 --- a/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java +++ b/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java @@ -225,19 +225,21 @@ public class MobileScannerService { Path sessionDir = getSafeSessionDirectory(sessionId); if (Files.exists(sessionDir)) { // Delete all files in session directory - Files.walk(sessionDir) - .sorted( - (a, b) -> - -a.compareTo(b)) // Reverse order to delete files before - // directory - .forEach( - path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - log.warn("Failed to delete file: {}", path, e); - } - }); + try (var paths = Files.walk(sessionDir)) { + paths.sorted( + (a, b) -> + -a.compareTo( + b)) // Reverse order to delete files before + // directory + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete file: {}", path, e); + } + }); + } } log.info("Deleted session: {}", sessionId); } catch (IllegalArgumentException e) { diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java index 3fd9ac3fe4..f504b39395 100644 --- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -48,6 +48,10 @@ public class TaskManager { @Value("${stirling.jobResultExpiryMinutes:30}") private int jobResultExpiryMinutes = 30; + /** Maximum age of a task that never reached a terminal state. */ + @Value("${stirling.job.pendingExpiryMinutes:1440}") + private int pendingJobExpiryMinutes = 1440; + private final FileStorage fileStorage; private final JobStore jobStore; private final ClusterBackplane clusterBackplane; @@ -332,19 +336,32 @@ public class TaskManager { } LocalDateTime expiryThreshold = LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES); + LocalDateTime pendingExpiryThreshold = + LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES); int removedCount = 0; try { for (Map.Entry entry : jobResults.entrySet()) { JobResult result = entry.getValue(); - // Remove completed jobs that are older than the expiry threshold - if (result.isComplete() - && result.getCompletedAt() != null - && result.getCompletedAt().isBefore(expiryThreshold)) { + boolean expiredCompletedJob = + result.isComplete() + && result.getCompletedAt() != null + && result.getCompletedAt().isBefore(expiryThreshold); + boolean abandonedPendingJob = + !result.isComplete() + && result.getCreatedAt() != null + && result.getCreatedAt().isBefore(pendingExpiryThreshold); + + // Remove old terminal results and abandoned pending jobs. Without the second + // branch, a client that starts a task and never completes it keeps its result in + // memory forever. + if (expiredCompletedJob || abandonedPendingJob) { // Clean up file results - cleanupJobFiles(result, entry.getKey()); + if (expiredCompletedJob) { + cleanupJobFiles(result, entry.getKey()); + } // Remove the job result jobResults.remove(entry.getKey()); diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index b4821edd9c..9d2d1b74db 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -1,17 +1,22 @@ package stirling.software.common.util; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.UncheckedExecutionException; + import lombok.extern.slf4j.Slf4j; @Slf4j public final class RegexPatternUtils { private static final RegexPatternUtils INSTANCE = new RegexPatternUtils(); - private final ConcurrentHashMap patternCache = new ConcurrentHashMap<>(); + private static final long MAX_CACHED_PATTERNS = 512; + private final Cache patternCache = + CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PATTERNS).build(); private static final String WHITESPACE_REGEX = "\\s++"; private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$"; @@ -51,7 +56,7 @@ public final class RegexPatternUtils { throw new IllegalArgumentException("Regex pattern cannot be null"); } - return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern); + return getOrCompile(new PatternKey(regex, 0)); } /** @@ -77,7 +82,7 @@ public final class RegexPatternUtils { throw new IllegalArgumentException("Regex pattern cannot be null"); } - return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern); + return getOrCompile(new PatternKey(regex, flags)); } /** @@ -98,7 +103,7 @@ public final class RegexPatternUtils { * @return true if pattern is cached, false otherwise */ public boolean isCached(String regex, int flags) { - return regex != null && patternCache.containsKey(new PatternKey(regex, flags)); + return regex != null && patternCache.getIfPresent(new PatternKey(regex, flags)) != null; } /** @@ -107,7 +112,7 @@ public final class RegexPatternUtils { * @return number of patterns currently cached */ public int getCacheSize() { - return patternCache.size(); + return (int) patternCache.size(); } /** @@ -115,7 +120,7 @@ public final class RegexPatternUtils { * useful for testing or memory cleanup in long-running applications. */ public void clearCache() { - patternCache.clear(); + patternCache.invalidateAll(); log.debug("Regex pattern cache cleared"); } @@ -141,13 +146,32 @@ public final class RegexPatternUtils { return false; } PatternKey key = new PatternKey(regex, flags); - boolean removed = patternCache.remove(key) != null; + boolean removed = patternCache.getIfPresent(key) != null; + patternCache.invalidate(key); if (removed) { log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags); } return removed; } + private Pattern getOrCompile(PatternKey key) { + try { + return patternCache.get(key, () -> compilePattern(key)); + } catch (UncheckedExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof PatternSyntaxException patternSyntaxException) { + throw patternSyntaxException; + } + throw e; + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof PatternSyntaxException patternSyntaxException) { + throw patternSyntaxException; + } + throw new IllegalStateException("Failed to compile regex pattern", cause); + } + } + /** * Internal method to compile a pattern and handle errors consistently. * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java index ea096a8d23..3ba4adcbee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.config; import java.util.Map; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.slf4j.MDC; @@ -12,10 +13,15 @@ import org.springframework.core.task.support.TaskExecutorAdapter; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; +import jakarta.annotation.PreDestroy; + @Configuration @EnableAsync public class AsyncConfig { + private ExecutorService auditExecutorService; + private ExecutorService aiStreamExecutorService; + /** * MDC context-propagating task decorator. Copies MDC context from the caller thread to the * virtual thread executing the task. @@ -44,8 +50,8 @@ public class AsyncConfig { @Bean(name = "auditExecutor") public Executor auditExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + auditExecutorService = Executors.newVirtualThreadPerTaskExecutor(); + TaskExecutorAdapter adapter = new TaskExecutorAdapter(auditExecutorService); adapter.setTaskDecorator(new MDCContextTaskDecorator()); return adapter; } @@ -53,9 +59,25 @@ public class AsyncConfig { /** Propagates the request's SecurityContext onto background AI-orchestration threads. */ @Bean(name = "aiStreamExecutor") public Executor aiStreamExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + aiStreamExecutorService = Executors.newVirtualThreadPerTaskExecutor(); + TaskExecutorAdapter adapter = new TaskExecutorAdapter(aiStreamExecutorService); adapter.setTaskDecorator(new MDCContextTaskDecorator()); return new DelegatingSecurityContextExecutor(adapter); } + + /** + * Close the underlying executors because the exposed Spring adapters do not own their + * lifecycle. + */ + @PreDestroy + void shutdown() { + shutdownExecutor(auditExecutorService); + shutdownExecutor(aiStreamExecutorService); + } + + private void shutdownExecutor(ExecutorService executor) { + if (executor != null) { + executor.shutdownNow(); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index e6dce0ee7b..1f31d153a6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -16,6 +16,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientResponseException; +import jakarta.annotation.PreDestroy; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -79,6 +81,21 @@ public class PolicyEngine { private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor(); + /** Stop the service-owned executor when the application context is closed or restarted. */ + @PreDestroy + void shutdown() { + log.debug("Shutting down policy engine executor"); + asyncExecutor.shutdown(); + try { + if (!asyncExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { + asyncExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + asyncExecutor.shutdownNow(); + } + } + /** * Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job * (status/notes/results observable via the job endpoints); its future resolves when the run From 2265e48b3215f77a3b52cf0c6ce47f94f1dcc3c2 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:43:53 +0200 Subject: [PATCH 090/262] chore(ci): optimize GitHub Actions Gradle caching across workflows (#7299) # Description of Changes This PR refactors Gradle caching across the GitHub Actions workflows to improve cache reuse, reduce dependency resolution overhead, and shorten CI execution times. ### What was changed - Replaced multiple `gradle/actions/setup-gradle` steps with a unified `actions/cache`-based Gradle User Home cache strategy. - Standardized cache paths across workflows to include: - `~/.gradle/caches` - `~/.gradle/wrapper` - Introduced consistent cache keys using: - Runner OS - Runner architecture - JDK version - Hashes of Gradle wrapper, version catalog, Gradle build files, and project build scripts. - Added restore keys to maximize cache hit rates across similar environments. - Added a new **`gradle-cache-prime`** job in the main build workflow that: - Restores or creates the shared Gradle cache. - Resolves backend dependencies before downstream jobs execute. - Makes the populated cache available to subsequent jobs. - Updated workflow dependencies so Gradle-based jobs wait for the cache priming job before execution. - Simplified and unified Gradle cache handling across numerous CI workflows, including backend builds, OpenAPI generation, database migration tests, Docker tests, Tauri builds, Swagger generation, enterprise builds, release workflows, and license generation. - Updated workflow comments to reflect the new caching strategy and shared cache behavior. ### Why the change was made The previous workflows used a mixture of Gradle setup actions and partial dependency caches, leading to duplicated dependency downloads, inconsistent cache behavior, and longer CI runtimes. Consolidating all workflows onto a shared Gradle User Home cache with a dedicated cache priming job improves cache reuse, reduces unnecessary dependency resolution, and makes CI execution more consistent. --- ## 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. --- .../workflows/PR-Demo-Comment-with-react.yml | 12 +++-- .github/workflows/backend-build.yml | 16 +++--- .github/workflows/build-enterprise.yml | 10 ++++ .github/workflows/build.yml | 53 +++++++++++++++---- .github/workflows/check-generated-models.yml | 12 +++-- .github/workflows/check-licence.yml | 16 +++--- .github/workflows/check-openapi.yml | 16 +++--- .github/workflows/coverage-aggregate.yml | 22 ++++---- .github/workflows/db-migration-test.yml | 20 +++---- .github/workflows/docker-compose-tests.yml | 16 +++--- .github/workflows/e2e-live.yml | 17 +++--- .../frontend-backend-licenses-update.yml | 12 +++-- .github/workflows/multiOSReleases.yml | 36 ++++++++----- .github/workflows/push-docker.yml | 12 ++--- .github/workflows/swagger.yml | 12 +++-- .github/workflows/tauri-build.yml | 12 +++-- .github/workflows/test-build-docker.yml | 16 +++--- .github/workflows/testdriver.yml | 12 +++-- 18 files changed, 187 insertions(+), 135 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index e48a6170da..3826897a39 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -211,10 +211,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 4d133593d3..47b5591e19 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -40,20 +40,16 @@ jobs: java-version: ${{ matrix.jdk-version }} distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 0fb02ad90f..398cca3144 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -60,6 +60,16 @@ jobs: 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', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d4aee192d..4ad4b046e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,8 +60,43 @@ jobs: with: filters: .github/config/.files.yaml - build: + gradle-cache-prime: + name: Prime shared Gradle cache needs: [files-changed] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.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', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve backend dependencies + run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon + env: + STIRLING_FLAVOR: saas + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + build: + needs: [files-changed, gradle-cache-prime] permissions: actions: read contents: read @@ -76,7 +111,7 @@ jobs: # works after Hibernate's ddl-auto=update migrates the schema. Gated on # the `project` filter so doc-only PRs skip this ~5-minute job. if: needs.files-changed.outputs.project == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/db-migration-test.yml @@ -84,7 +119,7 @@ jobs: check-generateOpenApiDocs: if: needs.files-changed.outputs.openapi == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-openapi.yml @@ -120,7 +155,7 @@ jobs: playwright-e2e-live: if: needs.files-changed.outputs.frontend == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/e2e-live.yml @@ -128,7 +163,7 @@ jobs: playwright-e2e-enterprise: if: needs.files-changed.outputs.proprietary == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/build-enterprise.yml @@ -136,7 +171,7 @@ jobs: check-licence: if: needs.files-changed.outputs.build == 'true' - needs: [files-changed, build] + needs: [files-changed, build, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-licence.yml @@ -144,7 +179,7 @@ jobs: docker-compose-tests: if: needs.files-changed.outputs.project == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: actions: write contents: read @@ -156,7 +191,7 @@ 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] + needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime] permissions: contents: read packages: read @@ -199,7 +234,7 @@ jobs: # frontend filter, so a CSS-only PR does not pay for a backend build. generated-models: if: needs.files-changed.outputs.generated-models == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read pull-requests: write diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index a758c4268e..a8559a7d78 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -42,10 +42,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.0 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index c9f64a0b93..61d21c0501 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -26,20 +26,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index eb89d32629..c188e8ff23 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -27,20 +27,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index e3a871b08e..1ece083252 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -46,20 +46,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -81,7 +77,7 @@ jobs: # Each lands as a sibling dir under coverage-execs/, with the .exec # files preserving their original relative paths. - name: Download all .exec artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: jacoco-exec-* path: coverage-execs/ @@ -206,7 +202,7 @@ jobs: # absence on backend-only runs by skipping the download entirely # when the producer job was not part of this workflow run. if: inputs.frontend-validation-result == 'success' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: frontend-coverage path: matrix-inputs/vitest/ @@ -216,7 +212,7 @@ jobs: # e2e-live uploads the artifact with a stable name. Skip the # download entirely when the producer job did not run. if: inputs.playwright-e2e-live-result == 'success' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: playwright-frontend-coverage path: matrix-inputs/playwright/ diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 8bd28fc060..edb2d0547c 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -30,23 +30,19 @@ jobs: java-version: 25 distribution: temurin - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true - - # No `-PnoSpotless` here yet because the upstream cache layer matches the - # backend build's; reuse keeps cold-cache cost identical. + # Keep the normal formatting path here so this smoke test exercises the + # same Gradle configuration as the backend build. - name: Build Stirling-PDF JAR env: MAVEN_USER: ${{ secrets.MAVEN_USER }} diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 9b81f774ef..049db8adc0 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -38,20 +38,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 302663cbf9..0e64b89099 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -25,21 +25,16 @@ jobs: with: java-version: "25" distribution: "temurin" - # Same cache layer as backend-build.yml. Without it every run resolved the - # whole classpath cold and eventually got HTTP 429 from Maven Central. - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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. diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 8c852ae08f..1f901a7f05 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -350,10 +350,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 8303cf102a..f5a99a0ef2 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -57,20 +57,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependencies + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} restore-keys: | - gradle-${{ runner.os }}- - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -151,10 +147,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 Node.js if: matrix.variant.build_frontend == true @@ -255,10 +257,16 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 12fcd23f6c..f4416a726d 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -65,20 +65,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependencies + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} restore-keys: | - gradle-${{ runner.os }}- - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Docker Buildx id: buildx diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index ffbeafdd1e..76b28c4566 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -39,10 +39,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 415e8be4e5..09422a8093 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -179,10 +179,16 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index d935cac53d..7f8c72d41e 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -84,20 +84,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', '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 diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index a7f74df4a7..a8d777744a 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -38,10 +38,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Build with Gradle run: ./gradlew build From 8094765babe12b96d46e5d61f1d258fa29b1eb81 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:44:12 +0200 Subject: [PATCH 091/262] build(licenses): Module-specific license. Add dependency overrides. (#7049) # Description of Changes This change adds a version-scoped override mechanism for dependencies whose published metadata does not expose a detectable license. - Added `app/license-overrides.json` with verified Apache License 2.0 metadata for: - `com.hubspot.immutables:immutables-exceptions:1.9` - `com.hubspot:algebra:1.5` - Added `ModuleLicenseOverrideFilter` as custom `buildSrc` logic for the Gradle dependency license report plugin. - Applied overrides only when the exact `group:artifact:version` matches and no usable license metadata was detected. - Added automatic maintenance of the override file: - Removes overrides when the dependency is no longer resolved. - Removes overrides when the dependency starts publishing valid license metadata. - Migrates stale overrides to newer unresolved versions and clears their metadata for re-verification. - Adds null-valued placeholders for newly detected dependencies without license metadata. - Preserves populated overrides for newer versions when already present. - Added Gradle version-aware dependency ordering for override migration. - Registered `app/license-overrides.json` as an input for license-report and license-check preparation tasks. - Centralized the dependency license report plugin version in `buildSrc`. - Added unit tests covering override application, cleanup, migration, exact-version matching, concurrent versions, placeholder generation, and numeric version ordering. - Added documentation describing the override lifecycle, verification requirements, maintenance workflow, and validation commands. - Replaced broad null-license allowances for the two HubSpot modules with explicit Apache License 2.0 metadata. - Added accepted GNU Lesser General Public License name variants encountered in dependency metadata. The change was made because some dependencies have known upstream licenses but do not publish license metadata in a form detected by the Gradle license report plugin. Previously, these dependencies were permitted through module-specific null-license exceptions, leaving incomplete information in the generated report. The new mechanism supplies verified metadata without overriding valid metadata published by dependencies. --- ## 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. --- app/allowed-licenses.json | 16 +- app/license-overrides.json | 12 + build.gradle | 13 +- buildSrc/README.md | 168 +++++++++++ buildSrc/build.gradle | 22 ++ .../gradle/ModuleLicenseOverrideFilter.groovy | 173 ++++++++++++ .../ModuleLicenseOverrideFilterTest.groovy | 265 ++++++++++++++++++ docker/backend/Dockerfile | 2 + docker/embedded/Dockerfile | 2 + docker/embedded/Dockerfile.fat | 2 + docker/embedded/Dockerfile.ultra-lite | 2 + 11 files changed, 668 insertions(+), 9 deletions(-) create mode 100644 app/license-overrides.json create mode 100644 buildSrc/README.md create mode 100644 buildSrc/build.gradle create mode 100644 buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy create mode 100644 buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 9b5ef66556..033661629f 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -156,6 +156,14 @@ "moduleName": ".*", "moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception" }, + { + "moduleName": ".*", + "moduleLicense": "GNU Lesser Public License" + }, + { + "moduleName": ".*", + "moduleLicense": "The GNU Lesser General Public License" + }, { "moduleName": "com.martiansoftware:jsap", "moduleLicense": "LGPL" @@ -224,14 +232,6 @@ "moduleName": "com.google.re2j:re2j", "moduleLicense": "Go License" }, - { - "moduleName": "com.hubspot:algebra", - "moduleLicense": null - }, - { - "moduleName": "com.hubspot.immutables:immutables-exceptions", - "moduleLicense": null - }, { "moduleName": ".*", "moduleLicense": "UnRar License" diff --git a/app/license-overrides.json b/app/license-overrides.json new file mode 100644 index 0000000000..0ea43fcaf9 --- /dev/null +++ b/app/license-overrides.json @@ -0,0 +1,12 @@ +{ + "com.hubspot.immutables:immutables-exceptions:1.9": { + "name": "The Apache License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", + "projectUrl": "https://github.com/HubSpot/hubspot-immutables/tree/58628096ac99b286fe4f8bfe12aa3cff0f0589d3" + }, + "com.hubspot:algebra:1.5": { + "name": "The Apache License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", + "projectUrl": "https://github.com/HubSpot/algebra/tree/5d42983fd3a26539df9ba2cbeac32a1bddce0494" + } +} diff --git a/build.gradle b/build.gradle index 2af5522a73..31fa6b1bdb 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" id "com.diffplug.spotless" version "8.8.0" - id "com.github.jk1.dependency-license-report" version "3.1.2" + id "com.github.jk1.dependency-license-report" //id "nebula.lint" version "19.0.3" id "org.sonarqube" version "7.2.3.7755" } @@ -18,6 +18,7 @@ import groovy.xml.XmlSlurper import org.gradle.api.JavaVersion import org.gradle.api.tasks.testing.Test import org.gradle.jvm.toolchain.JavaLanguageVersion +import stirling.software.gradle.ModuleLicenseOverrideFilter ext { springBootVersion = "4.0.6" @@ -550,6 +551,7 @@ gradle.taskGraph.whenReady { graph -> } def allProjects = ((subprojects as Set) + project) as Set +def moduleLicenseOverridesFile = project.layout.projectDirectory.file("app/license-overrides.json").asFile licenseReport { projects = allProjects @@ -557,6 +559,15 @@ licenseReport { allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path configurations = [ "productionRuntimeClasspath", "runtimeClasspath" ] + filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)] +} + +tasks.named('generateLicenseReport') { + inputs.file(moduleLicenseOverridesFile) +} + +tasks.named('checkLicensePreparation') { + inputs.file(moduleLicenseOverridesFile) } // Configure the forked spring boot run task to properly delegate to the stirling-pdf module diff --git a/buildSrc/README.md b/buildSrc/README.md new file mode 100644 index 0000000000..bbcfb0de6c --- /dev/null +++ b/buildSrc/README.md @@ -0,0 +1,168 @@ +# Dependency license overrides + +The backend dependency license report is generated by the +[`com.github.jk1.dependency-license-report`](https://github.com/jk1/Gradle-License-Report) +Gradle plugin. Most license information is read from dependency POM files, manifests, or packaged +license files. Some artifacts do not publish license metadata in a form the plugin can detect, even +though the artifact has a known license. + +This directory contains the build logic used to provide narrowly scoped fallback license metadata +for those artifacts. + +## Files + +- `build.gradle` makes version 3.1.4 of the license report plugin available to the custom build + logic. The root build applies that plugin without a second version declaration so both use the + same classpath. +- `src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy` implements the + plugin's `DependencyFilter` interface. +- `../app/license-overrides.json` contains the actual module-specific fallback values. +- `../app/allowed-licenses.json` defines which detected or supplied licenses are accepted by + `checkLicense`. + +## How it works + +The root `build.gradle` passes `app/license-overrides.json` to +`ModuleLicenseOverrideFilter`: + +```groovy +filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)] +``` + +For every dependency discovered by the license plugin, the filter builds an identifier in this +format: + +```text +group:artifact:version +``` + +The filter applies a populated override only when both conditions are true: + +1. The complete identifier, including the version, exists in `app/license-overrides.json`. +2. The plugin did not discover a non-empty license name for that dependency. + +When both conditions match, the filter adds the configured license as fallback manifest metadata. +The normal report renderer and `checkLicense` then consume that metadata in the same way as +metadata discovered from the dependency itself. + +An override never replaces a license that the plugin already detected. Updating a dependency also +does not silently reuse the override because a different version produces a different identifier. + +Overrides are temporary fallbacks, not a permanent license catalog. If the plugin starts detecting +the original license for an overridden module, the filter automatically removes that exact entry +from `app/license-overrides.json` and logs the cleanup. When the overridden version is no longer +resolved, a newer resolved version takes its place: if it declares a license, the stale entry is +removed; otherwise the entry moves to the new exact version and its values are cleared for +re-verification. An already populated entry for the new version is preserved. If no higher version +is resolved, the unused override is removed instead. + +Because the report aggregates several projects and configurations, multiple versions of the same +`group:artifact` can be present at once. An override is retained whenever its exact version is still +resolved. Only when that exact version is absent may the filter treat a higher version as an update; +version ordering then follows Gradle's own dependency version comparator. Overrides for dependency +versions that are no longer resolved and have no higher replacement are deleted automatically. + +The filter also records every resolved dependency without detected license metadata that has no +override yet. It writes a placeholder with `null` values for `name`, `url`, and `projectUrl`. +Placeholders deliberately do not affect the generated report until `name` is filled in. This makes +new missing metadata visible in the source-controlled override file instead of only in a generated +report. Review and fill or remove every new placeholder before committing the resulting JSON. + +## Adding an override + +First verify the license from an authoritative source such as the upstream repository, the +published artifact metadata, or the license file shipped inside the artifact. Do not infer a +license from the organization name or from a related artifact. + +Add an entry to `app/license-overrides.json`: + +```json +{ + "com.example:example-library:1.2.3": { + "name": "Apache License, Version 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0", + "projectUrl": "https://github.com/example/example-library/tree/0123456789abcdef0123456789abcdef01234567" + } +} +``` + +The key must contain the exact resolved version. `name` must be non-empty for the override to be +applied. `url` should point to the canonical license text. `projectUrl` must point to the immutable +Git tree for the exact module version, using the commit hash at which that version was introduced: + +```text +https://github.com///tree/ +``` + +Do not use the repository's default branch or another moving URL. See the existing entries in +`app/license-overrides.json` for concrete examples. + +If the license name is not already accepted, add a suitably narrow rule to +`app/allowed-licenses.json`. Adding an override and allowing a license are separate operations: + +- `license-overrides.json` supplies missing metadata for a specific artifact version. +- `allowed-licenses.json` defines the policy enforced by `checkLicense`. + +## Updating a dependency + +When an overridden dependency changes version: + +1. Verify the license for the new version again. +2. Run the license report so the filter can move the old key or add a placeholder for the new full + `group:artifact:version` key. +3. Re-verify and fill the license values and the version's immutable Git-tree `projectUrl`; moved + values are intentionally cleared because a license conclusion for one release is not assumed + for another. +4. Regenerate and inspect the report. + +If the new artifact publishes usable license metadata, no override is necessary. The next license +report or license check removes the old entry from `app/license-overrides.json` automatically. The +file must contain only overrides that are still needed. + +## Verification + +Run the filter unit tests: + +```powershell +.\gradlew.bat -p buildSrc test +``` + +The tests use `com.example:example-library` versions 1.4 and 1.7 to cover the missing +metadata fallback, placeholder creation, version migration, preservation of a populated newer +override, automatic cleanup after license metadata appears, exact-version matching, and concurrent +resolved versions. They also verify removal when a dependency version disappears. A separate `1.9` +to `1.11.0` case verifies numeric Gradle version ordering. + +Run the normal backend license workflow from the repository root: + +```powershell +task backend:licenses:generate +``` + +Then inspect: + +- `build/reports/dependency-license/index.json` for the rendered module, version, license name, and + URL. +- `build/reports/dependency-license/dependencies-without-allowed-license.json` when `checkLicense` + reports a policy failure. + +Also run the backend quality gate after changing the filter or its build wiring: + +```powershell +task backend:check +``` + +The override JSON is registered as an input of `generateLicenseReport` and +`checkLicensePreparation`, so changing the file invalidates the corresponding Gradle task outputs. + +## What not to do + +- Do not use an unversioned key. It cannot match the filter and would make the intended scope + ambiguous. +- Do not use an override to replace valid license metadata published by a dependency. +- Do not add an empty license to `allowed-licenses.json` merely to silence `checkLicense`; that + would still leave the generated report without useful license information. +- Do not exclude a dependency from the report solely because it is transitive. Runtime transitive + dependencies are still distributed components and their licenses remain relevant. +- Do not edit generated files under `build/reports/dependency-license` or the copied static license + report by hand. diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000000..25eee5c0f5 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'groovy' +} + +repositories { + gradlePluginPortal() +} + +dependencies { + implementation localGroovy() + implementation gradleApi() + implementation 'com.github.jk1:gradle-license-report:3.1.4' + testImplementation platform('org.junit:junit-bom:6.1.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() + jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED' + testLogging.showStandardStreams = true +} diff --git a/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy b/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy new file mode 100644 index 0000000000..df0d76158c --- /dev/null +++ b/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy @@ -0,0 +1,173 @@ +package stirling.software.gradle + +import com.github.jk1.license.License +import com.github.jk1.license.ManifestData +import com.github.jk1.license.ModuleData +import com.github.jk1.license.ProjectData +import com.github.jk1.license.filter.DependencyFilter +import com.github.jk1.license.render.LicenseDataCollector +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.DefaultVersionComparator +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.Version +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.VersionParser + +class ModuleLicenseOverrideFilter implements DependencyFilter { + private static final VersionParser VERSION_PARSER = new VersionParser() + private static final Comparator VERSION_COMPARATOR = + new DefaultVersionComparator().asVersionComparator() + + private final File overridesFile + + ModuleLicenseOverrideFilter(File overridesFile) { + this.overridesFile = overridesFile + } + + @Override + ProjectData filter(ProjectData projectData) { + Map> overrides = loadOverrides() + List modules = projectData.configurations + .collectMany { configuration -> configuration.dependencies } + Map> modulesByCoordinate = modules + .groupBy { module -> moduleCoordinate(module) } + + boolean overridesChanged = false + overrides.keySet().toList().each { overrideId -> + ModuleCoordinates overrideModule = parseModuleId(overrideId) + List coordinateModules = modulesByCoordinate[overrideModule.coordinate] + ModuleData currentModule = coordinateModules + ?.find { module -> module.version == overrideModule.version } + if (currentModule == null) { + currentModule = newestModule(coordinateModules, overrideModule.version) + } + if (currentModule == null) { + overrides.remove(overrideId) + overridesChanged = true + projectData.project.logger.lifecycle( + "Removed unused license override for ${overrideId}: " + + 'dependency version is no longer resolved') + return + } + + if (hasDeclaredLicense(currentModule)) { + overrides.remove(overrideId) + overridesChanged = true + projectData.project.logger.lifecycle( + "Removed stale license override for ${overrideId}: " + + "${moduleId(currentModule)} now declares a license") + return + } + + if (compareVersions(currentModule.version, overrideModule.version) > 0) { + String currentModuleId = moduleId(currentModule) + overrides.remove(overrideId) + if (!overrides.containsKey(currentModuleId)) { + overrides[currentModuleId] = [name: null, url: null, projectUrl: null] + } + overridesChanged = true + projectData.project.logger.lifecycle( + "Updated license override from ${overrideId} to ${currentModuleId}: " + + 'newer dependency still declares no license') + } + } + + modules.groupBy { module -> moduleId(module) }.each { currentModuleId, matchingModules -> + ModuleData module = matchingModules.first() + if (!overrides.containsKey(currentModuleId) && !hasDeclaredLicense(module)) { + overrides[currentModuleId] = [name: null, url: null, projectUrl: null] + overridesChanged = true + projectData.project.logger.lifecycle( + "Added missing license override for ${currentModuleId}. " + + "Set 'name' and 'url' in ${overridesFile}.") + } + } + if (overridesChanged) { + saveOverrides(overrides) + } + + projectData.configurations.each { configuration -> + configuration.dependencies.each { module -> applyOverride(module, overrides) } + } + return projectData + } + + private void applyOverride( + ModuleData module, Map> overrides) { + String moduleId = moduleId(module) + Map override = overrides[moduleId] + if (override == null) { + return + } + + String licenseName = override.name + String licenseUrl = override.url + String projectUrl = override.projectUrl + if (licenseName == null || licenseName.isBlank()) { + return + } + + Set licenses = [new License(licenseName, licenseUrl)] as LinkedHashSet + ManifestData manifest = + new ManifestData(module.name, module.version, null, null, projectUrl, licenses, false) + Set manifests = new LinkedHashSet<>(module.manifests ?: []) + manifests.add(manifest) + module.manifests = manifests + } + + private Map> loadOverrides() { + Object parsed = new JsonSlurper().parse(overridesFile) + if (!(parsed instanceof Map)) { + throw new IllegalArgumentException( + "License overrides file ${overridesFile} must contain a JSON object") + } + return parsed as Map> + } + + private void saveOverrides(Map> overrides) { + String json = JsonOutput.prettyPrint(JsonOutput.toJson(overrides)) + System.lineSeparator() + overridesFile.setText(json, 'UTF-8') + } + + private static String moduleId(ModuleData module) { + return "${module.group}:${module.name}:${module.version}" + } + + private static String moduleCoordinate(ModuleData module) { + return "${module.group}:${module.name}" + } + + private static ModuleCoordinates parseModuleId(String moduleId) { + List parts = moduleId.split(':', 3) as List + if (parts.size() != 3 || parts.any { part -> part.isBlank() }) { + throw new IllegalArgumentException( + "License override key ${moduleId} must use group:module:version") + } + return new ModuleCoordinates("${parts[0]}:${parts[1]}", parts[2]) + } + + private static ModuleData newestModule(List modules, String minimumVersion) { + return modules + ?.findAll { module -> compareVersions(module.version, minimumVersion) > 0 } + ?.max { left, right -> compareVersions(left.version, right.version) } + } + + private static int compareVersions(String left, String right) { + return VERSION_COMPARATOR.compare( + VERSION_PARSER.transform(left), VERSION_PARSER.transform(right)) + } + + private static boolean hasDeclaredLicense(ModuleData module) { + Set licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses + return licenses.any { license -> license.name != null && !license.name.isBlank() } + } + + private static class ModuleCoordinates { + final String coordinate + final String version + + ModuleCoordinates(String coordinate, String version) { + this.coordinate = coordinate + this.version = version + } + } +} diff --git a/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy b/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy new file mode 100644 index 0000000000..7536d08af0 --- /dev/null +++ b/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy @@ -0,0 +1,265 @@ +package stirling.software.gradle + +import com.github.jk1.license.ConfigurationData +import com.github.jk1.license.License +import com.github.jk1.license.ManifestData +import com.github.jk1.license.ModuleData +import com.github.jk1.license.ProjectData +import com.github.jk1.license.render.LicenseDataCollector +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Path +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertFalse +import static org.junit.jupiter.api.Assertions.assertTrue + +class ModuleLicenseOverrideFilterTest { + private static final String GROUP = 'com.example' + private static final String MODULE = 'example-library' + private static final String VERSION_WITHOUT_LICENSE = '1.4' + private static final String VERSION_WITH_LICENSE = '1.7' + private static final String APACHE_NAME = 'Apache License, Version 2.0' + private static final String APACHE_URL = 'https://www.apache.org/licenses/LICENSE-2.0' + private static final String PROJECT_URL = 'https://github.com/HubSpot/hubspot-immutables' + + @TempDir + Path temporaryDirectory + + @Test + void keepsOverrideForVersionWithoutLicenseMetadata() { + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenLaterVersionDeclaresLicense() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenOnlyOlderVersionIsResolved() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITH_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITH_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenModuleIsNoLongerResolved() { + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData()) + + assertTrue(readOverrides(overridesFile).isEmpty()) + } + + @Test + void keepsOverrideWhenExactAndNewerVersionsAreBothResolved() { + ModuleData olderModule = createModule(VERSION_WITHOUT_LICENSE, null) + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData newerModule = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + new ModuleLicenseOverrideFilter(overridesFile) + .filter(createProjectData(olderModule, newerModule)) + + Map overrides = readOverrides(overridesFile) + assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(olderModule)) + assertEquals([APACHE_NAME], licenseNames(newerModule)) + } + + @Test + void movesOverrideUsingGradleNumericVersionOrdering() { + String oldVersion = '1.9' + String newVersion = '1.11.0' + ModuleData module = createModule(newVersion, null) + File overridesFile = createOverridesFile(moduleId(oldVersion)) + + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(oldVersion))) + assertEquals( + [name: null, url: null, projectUrl: null], overrides[moduleId(newVersion)]) + } + + @Test + void movesOverrideToLaterVersionWithoutLicenseAndClearsLicenseData() { + ModuleData module = createModule(VERSION_WITH_LICENSE, null) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals( + [name: null, url: null, projectUrl: null], + overrides[moduleId(VERSION_WITH_LICENSE)]) + assertTrue(licenseNames(module).isEmpty()) + } + + @Test + void preservesExistingOverrideWhenRemovingOlderVersion() { + ModuleData module = createModule(VERSION_WITH_LICENSE, null) + File overridesFile = createOverridesFile( + [ + (moduleId(VERSION_WITHOUT_LICENSE)): [ + name: APACHE_NAME, url: APACHE_URL + ], + (moduleId(VERSION_WITH_LICENSE)): [ + name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL + ] + ]) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals( + [name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL], + overrides[moduleId(VERSION_WITH_LICENSE)]) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void addsMissingOverrideForModuleWithoutLicense() { + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null) + File overridesFile = createEmptyOverridesFile() + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertEquals( + [name: null, url: null, projectUrl: null], + overrides[moduleId(VERSION_WITHOUT_LICENSE)]) + assertTrue(licenseNames(module).isEmpty()) + } + + @Test + void doesNotAddOverrideForModuleWithLicense() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createEmptyOverridesFile() + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + assertTrue(readOverrides(overridesFile).isEmpty()) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + private File createOverridesFile(String moduleId) { + Map> overrides = [ + (moduleId): [name: APACHE_NAME, url: APACHE_URL] + ] + return createOverridesFile(overrides) + } + + private File createOverridesFile(Map> overrides) { + File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile() + overridesFile.setText(JsonOutput.prettyPrint(JsonOutput.toJson(overrides)), 'UTF-8') + return overridesFile + } + + private File createEmptyOverridesFile() { + File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile() + overridesFile.setText('{}', 'UTF-8') + return overridesFile + } + + private static Map readOverrides(File overridesFile) { + return new JsonSlurper().parse(overridesFile) as Map + } + + private static void debugState(String stage, ModuleData module, File overridesFile) { + String resolvedModuleId = "${module.group}:${module.name}:${module.version}" + Map overrides = readOverrides(overridesFile) + System.out.println( + "[license-override-test] ${stage}: module=${resolvedModuleId}, " + + "licenses=${licenseNames(module)}, " + + "matchingOverride=${overrides.containsKey(resolvedModuleId)}, " + + "overrideKeys=${overrides.keySet().sort()}") + } + + private static ProjectData createProjectData(ModuleData module) { + return createProjectData(module as ModuleData[]) + } + + private static ProjectData createProjectData(ModuleData... modules) { + ConfigurationData configuration = + new ConfigurationData( + 'runtimeClasspath', modules as LinkedHashSet) + return new ProjectData( + ProjectBuilder.builder().build(), + [configuration] as LinkedHashSet) + } + + private static ModuleData createModule(String version, License license) { + Set manifests = new LinkedHashSet<>() + if (license != null) { + manifests.add( + new ManifestData( + MODULE, + version, + null, + null, + null, + [license] as LinkedHashSet, + false)) + } + return new ModuleData( + GROUP, + MODULE, + version, + true, + manifests, + new LinkedHashSet<>(), + new LinkedHashSet<>()) + } + + private static String moduleId(String version) { + return "${GROUP}:${MODULE}:${version}" + } + + private static List licenseNames(ModuleData module) { + Set licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses + return licenses.collect { license -> license.name }.sort() + } +} diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 5c6680661f..ab79caf9f0 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -17,6 +17,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 54458667be..4ae0ee81e7 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -30,6 +30,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 9b2655987f..6e679b97c8 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -31,6 +31,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index bcf189e9ee..14f9e934d0 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -23,6 +23,8 @@ WORKDIR /app # Copy gradle files for dependency resolution COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ From ddc0baa41daa863c8952f071a5a275cf33f51f1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:50 +0000 Subject: [PATCH 092/262] build(deps-dev): bump ip-address from 10.2.0 to 10.4.0 in /frontend (#7278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
    Release notes

    Sourced from ip-address's releases.

    v10.4.0

    What's Changed

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.3.1...v10.4.0

    v10.3.1

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.3.0...v10.3.1

    v10.3.0

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.2...v10.3.0

    v10.2.2

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.1...v10.2.2

    v10.2.1

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.2.1

    Commits
    • fbb8db2 10.4.0
    • 45a2b11 Validate the byte arrays Address6 is given (#217)
    • bac8810 Keep the package loadable on node 12, and enforce it (#216)
    • 9b3d848 Add a security policy and a README section on security posture
    • e84a7b3 Order the README API reference Address4, Address6, AddressError
    • 015160b Collapse each class in the README API reference
    • 34061a8 Pin checkout and setup-node to commits in the release job
    • c5fae5d Pin action-gh-release to a commit and move it to 3.0.2
    • e0ef048 Replace CircleCI with GitHub Actions
    • 5e3ceb7 Add GitHub Actions CI across Node 20, 22, 24 and 25 (#213)
    • Additional commits viewable in compare view
    Maintainer changes

    This version was pushed to npm by GitHub Actions, a new releaser for ip-address since your current version.

    Install script changes

    This version adds prepare script that runs during installation. Review the package contents before updating.


    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ip-address&package-manager=npm_and_yarn&previous-version=10.2.0&new-version=10.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7db904ddf6..a5abc659e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10774,9 +10774,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "dev": true, "license": "MIT", "engines": { From 7aeec93032a952c78cf10e77804e71052df1dc38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:01:20 +0100 Subject: [PATCH 093/262] build(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.2 (#7273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.2.
    Release notes

    Sourced from softprops/action-gh-release's releases.

    v3.0.2

    3.0.2 is a patch release focused on release reliability and compatibility. It reuses existing draft releases when publishing prereleases, supports replacing release assets on Gitea, hardens streamed asset uploads, and provides clearer release-creation diagnostics. It also includes TypeScript, coverage, and tooling maintenance merged since 3.0.1.

    This release fixes #795, #438, and #803. The upload transport hardening covers the historical failure reported in #790, although current hosted Node 24 runners did not reproduce it naturally. The diagnostics work is related to #786 and does not claim a reproducible release-creation fix.

    What's Changed

    Exciting New Features 🎉

    Bug fixes 🐛

    Other Changes 🔄

    v3.0.1

    3.0.1

    • maintenance release with updated dependencies
    Changelog

    Sourced from softprops/action-gh-release's changelog.

    3.0.2

    3.0.2 is a patch release focused on release reliability and compatibility. It reuses existing draft releases when publishing prereleases, supports replacing release assets on Gitea, hardens streamed asset uploads, and provides clearer release-creation diagnostics. It also includes TypeScript, coverage, and tooling maintenance merged since 3.0.1.

    This release fixes #795, #438, and #803. The upload transport hardening covers the historical failure reported in #790, although current hosted Node 24 runners did not reproduce it naturally. The diagnostics work is related to #786 and does not claim a reproducible release-creation fix.

    What's Changed

    Exciting New Features 🎉

    Bug fixes 🐛

    Other Changes 🔄

    3.0.1

    • maintenance release with updated dependencies

    3.0.0

    3.0.0 is a major release that moves the action runtime from Node 20 to Node 24. Use v3 on GitHub-hosted runners and self-hosted fleets that already support the Node 24 Actions runtime. v2.6.2 was the final Node 20-compatible release and is no longer maintained or supported.

    What's Changed

    Other Changes 🔄

    • Move the action runtime and bundle target to Node 24
    • Update @types/node to the Node 24 line and allow future Dependabot updates
    • Keep the floating major tag on v3; freeze v2 at the final v2.6.2 release

    ... (truncated)

    Commits
    • 3d0d988 release 3.0.2 (#818)
    • 7e13ed4 fix: clarify release creation 404 errors (#817)
    • e6c70a5 fix: replace existing release assets on Gitea (#816)
    • f345337 fix: publish existing draft releases as prereleases (#801)
    • d8a89a2 fix: upload small checksum assets reliably (#815)
    • 45ece40 chore(deps): remove unused TypeScript tooling (#814)
    • f6b913c feat: improve release error reporting and test coverage (#813)
    • 15f193d chore(deps): upgrade TypeScript to 7 (#812)
    • cc8268d chore(deps): bump actions/checkout in the github-actions group (#810)
    • fd0ed1e chore(deps): bump the npm group with 3 updates (#811)
    • Additional commits viewable in compare view

    Most Recent Ignore Conditions Applied to This Pull Request | Dependency Name | Ignore Conditions | | --- | --- | | softprops/action-gh-release | [>= 2.2.a, < 2.3] |
    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=softprops/action-gh-release&package-manager=github_actions&previous-version=3.0.0&new-version=3.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/multiOSReleases.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f5a99a0ef2..ebf2585bb1 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -902,7 +902,7 @@ jobs: # instead of silently shipping a broken auto-update. - name: Upload binaries to Release if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ needs.determine-matrix.outputs.version }} # Don't regenerate/append notes on re-runs, and don't force this into the From 5c319f13cbbc72c48dcad93eda7a36e6cf44a4aa Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:13:30 +0000 Subject: [PATCH 094/262] Fix pt-BR download label mislabeled as "Baixar (JSON)" (#7043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - **What:** Corrected the pt-BR (Brazilian Portuguese) `download` translation from `"Baixar (JSON)"` to `"Baixar"` in `frontend/editor/public/locales/pt-BR/translation.toml`, in both the root table (line 32) and the `[fileManager]` section (line 3577). - **Why:** The generic `download` key flows through `useFileActionTerminology` (`download: t("download", "Download")`) into the shared download button rendered on tool-result screens (e.g. `ReviewToolStep`). Because the string was `"Baixar (JSON)"`, every tool's Download button showed "Baixar (JSON)" for pt-BR users — implying a JSON export regardless of the actual output format. This mislabeling was locale-wide (all pt-BR users, all tool downloads). Session autocapture confirmed the confusion: a pt-BR user on `/convert` repeatedly clicked a button whose text was exactly "Baixar (JSON)", then abandoned the flow. Nothing crashed — it's a confusing label, not a functional break. - **Scope / verification:** en-US uses plain `"Download"` for this key and pt-PT already uses `"Transferir"`; no other locale carried the `"(JSON)"` suffix on the download key, so the defect was isolated to pt-BR. Only translation values changed — no keys added/removed, so translation counts are unaffected. Note: I scoped this to the mislabel — the exact symptom users observed. The report also mentions the download being a silent anchor-click with no success toast; that's a separate, broader UX enhancement in `ReviewToolStep`/`WorkbenchBar`/`downloadService`, so it's intentionally left out of this focused translation fix. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Translations (if applicable) - [x] Only a value correction in `pt-BR`; no translation tags added or removed. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr) from [an inbox report](posthog-code://inbox/019f655d-bd60-78c2-ba59-98c23243ed57).* Co-authored-by: posthog-eu[bot] <226701856+posthog-eu[bot]@users.noreply.github.com> --- frontend/editor/public/locales/pt-BR/translation.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index 6fbc440d20..689d72b58c 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -29,7 +29,7 @@ customTextTooltip = "Formato personalizado opcional para os números de página. delete = "Apagar" details = "Detalhes" discardChanges = "Descartar alterações" -download = "Baixar (JSON)" +download = "Baixar" downloadPdf = "Baixar PDF" downloadUnavailable = "Download indisponível para este item" edit = "Editar" @@ -3574,7 +3574,7 @@ deleteAll = "Excluir tudo" deleteSelected = "Apagar Selecionados" deselectAll = "Desselecionar Tudo" details = "Detalhes do arquivo" -download = "Baixar (JSON)" +download = "Baixar" downloadSelected = "Baixar selecionados" dropFilesHere = "Solte os arquivos aqui" fileFormat = "Formato" From 94fbc74271a44f4817672e71580df45f5f8c156f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:20:17 +0100 Subject: [PATCH 095/262] build(deps): bump the uv group across 1 directory with 3 updates (#7287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv group with 3 updates in the /engine directory: [cryptography](https://github.com/pyca/cryptography), [aiohttp](https://github.com/aio-libs/aiohttp) and [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator). Updates `cryptography` from 49.0.0 to 50.0.0
    Changelog

    Sourced from cryptography's changelog.

    50.0.0 - 2026-07-31

    
    * **SECURITY ISSUE**:
    
    :func:`~cryptography.hazmat.primitives.serialization.pkcs7.pkcs7_decrypt_der`
    and its PEM and S/MIME variants no longer expose distinguishable errors
    or
    timing when unwrapping a ``RecipientInfo``'s ``encryptedKey``, which
    could
    act as a Bleichenbacher oracle for callers that decrypt untrusted
    messages.
    A random key is now substituted on failure, as described in :rfc:`3218`.
      Credit to **@X1AOxiang** for reporting the issue. **CVE-2026-69247**
    * Deprecated Diffie-Hellman key exchange over finite fields (FFDH).
      Everything FFDH is deprecated, including the types in
    ``cryptography.hazmat.primitives.asymmetric.dh`` and loading FFDH keys
    or
      parameters with the key loading APIs. Users should migrate to a more
      modern key exchange algorithm.
    * Added ``xof()`` class methods to
      :class:`~cryptography.hazmat.primitives.hashes.SHAKE128` and
    :class:`~cryptography.hazmat.primitives.hashes.SHAKE256` for
    constructing
      algorithm instances configured for use with
      :class:`~cryptography.hazmat.primitives.hashes.XOFHash`.
    * The :mod:`X.509 verification <cryptography.x509.verification>`
    APIs are now
      considered stable and are subject to our API stability policy.
    * Added the :doc:`/cobblestone` recipe, an implementation of the
      Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP
      chunked-encryption specification
    <https://c2sp.org/chunked-encryption>`_ for streaming
    authenticated
      encryption of large messages.
    * Parsing a Signed Certificate Timestamp list now rejects encodings that
    carry trailing bytes after the list or after an individual SCT, instead
    of
      silently ignoring them.
    * Added support for using :class:`~cryptography.x509.Name` as a field
    type in
      the :doc:`/hazmat/asn1/index` module.
    * Loading a public key or an EC private key now rejects DER where the
    ``subjectPublicKey`` (or EC ``publicKey``) ``BIT STRING`` declares a
    non-zero
      number of unused bits, instead of silently ignoring it.
    * Parsing a CRL entry's ``InvalidityDate`` extension now rejects a
    ``GeneralizedTime`` that carries fractional seconds or another non-DER
    form,
    matching the strict encoding already required for every other X.509 time
      field.
    * :func:`~cryptography.x509.ocsp.load_der_ocsp_request` and
    :func:`~cryptography.x509.ocsp.load_der_ocsp_response` now reject a
    request
    or response whose ``version`` field is not ``v1``, the only version
    defined
    by RFC 6960, matching the version validation already performed when
    loading
      certificates, CSRs and CRLs.
    * :class:`~cryptography.hazmat.primitives.hashes.XOFHash` is now
    supported
      when building against AWS-LC.
    * HMAC (and therefore PBKDF2-HMAC) with SHA-3 hashes is now supported
    when
      building against AWS-LC.
    * Diffie-Hellman (:doc:`/hazmat/primitives/asymmetric/dh`) is now
    supported
      when building against AWS-LC.
    </tr></table>
    

    ... (truncated)

    Commits

    Updates `aiohttp` from 3.14.1 to 3.14.3
    Changelog

    Sourced from aiohttp's changelog.

    3.14.3 (2026-07-22)

    Bug fixes

    • Fixed the client dropping only the first Authorization, Cookie and Proxy-Authorization header when a redirect crossed an origin -- by :user:arshsmith1.

      Related issues and pull requests on GitHub: :issue:13180.

    • Fixed error message construction in the C HTTP parser -- by :user:bdraco.

      Related issues and pull requests on GitHub: :issue:13222.


    3.14.2 (2026-07-20)

    Bug fixes

    • Fixed :py:attr:~aiohttp.web.StreamResponse.last_modified rounding a :class:datetime.datetime with a fractional second down.

      Related issues and pull requests on GitHub: :issue:5303.

    • Fixed resolving localhost on Windows to fall back without AI_ADDRCONFIG when the first lookup fails, so localhost still works without an active network.

      Related issues and pull requests on GitHub: :issue:5357.

    ... (truncated)

    Commits

    Updates `datamodel-code-generator` from 0.56.0 to 0.64.0
    Release notes

    Sourced from datamodel-code-generator's releases.

    0.64.0

    Breaking Changes

    Code Generation Changes

    • Self-referencing fields are now quoted with --disable-future-imports - When --disable-future-imports is set (no from __future__ import annotations and no native PEP 649 deferred evaluation on Python < 3.14), self-referencing and forward-referencing field annotations in regular BaseModel classes are now emitted as quoted forward references instead of bare names. Previously such annotations were left unquoted, producing invalid code that raised NameError (Ruff F821) at class-evaluation time. Output for the common case (with from __future__ import annotations or Python 3.14 native deferred annotations) is unchanged. Users who snapshot/golden-file generated output for the --disable-future-imports configuration with self-referencing models will see the annotation change from unquoted to quoted, e.g. children: Optional[List[Node]]children: Optional[List["Node"]]. (#3387)

    What's Changed

    ... (truncated)

    Changelog

    Sourced from datamodel-code-generator's changelog.

    0.64.0 - 2026-06-14

    Breaking Changes

    Code Generation Changes

    • Self-referencing fields are now quoted with --disable-future-imports - When --disable-future-imports is set (no from __future__ import annotations and no native PEP 649 deferred evaluation on Python < 3.14), self-referencing and forward-referencing field annotations in regular BaseModel classes are now emitted as quoted forward references instead of bare names. Previously such annotations were left unquoted, producing invalid code that raised NameError (Ruff F821) at class-evaluation time. Output for the common case (with from __future__ import annotations or Python 3.14 native deferred annotations) is unchanged. Users who snapshot/golden-file generated output for the --disable-future-imports configuration with self-referencing models will see the annotation change from unquoted to quoted, e.g. children: Optional[List[Node]]children: Optional[List["Node"]]. (#3387)

    What's Changed

    ... (truncated)

    Commits

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- engine/pyproject.toml | 2 +- engine/uv.lock | 302 +++++++++++++++++++++--------------------- 2 files changed, 152 insertions(+), 152 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 8cbb94daf8..29f2dcbdf7 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "AI Document Engine" requires-python = ">=3.13" dependencies = [ - "cryptography>=44.0.0", + "cryptography>=50.0.0", "fastapi>=0.116.0", "pgvector>=0.3.6", "psycopg[binary,pool]>=3.2", diff --git a/engine/uv.lock b/engine/uv.lock index fd10a81182..4f0ff52acd 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -41,7 +41,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -52,72 +52,72 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -473,52 +473,52 @@ wheels = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -538,21 +538,21 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.56.0" +version = "0.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, - { name = "black" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, { name = "genson" }, { name = "inflect" }, - { name = "isort" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, { name = "jinja2" }, { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/d2/86c94a2836ed42231653a7ddaefa0a5bc23418167a876bba7376c96b3a35/datamodel_code_generator-0.64.0.tar.gz", hash = "sha256:9c592900a00b20e416494273c22435f5a9aef6ea8c7b9190747522a60497a1cb", size = 1316440, upload-time = "2026-06-14T17:24:50.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/23/94/71338e2f0146ac10747a5537b3a1e45256e66b7c229869eb0ee787111b41/datamodel_code_generator-0.64.0-py3-none-any.whl", hash = "sha256:b7cd8bd41a312aa997aec6150670bad781847c5b674f17e4d70e78208a0fb990", size = 374698, upload-time = "2026-06-14T17:24:48.809Z" }, ] [package.optional-dependencies] @@ -632,7 +632,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=44.0.0" }, + { name = "cryptography", specifier = ">=50.0.0" }, { name = "fastapi", specifier = ">=0.116.0" }, { name = "opentelemetry-sdk", specifier = ">=1.39.0" }, { name = "pgvector", specifier = ">=0.3.6" }, @@ -800,7 +800,7 @@ name = "ffmpeg-python" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "future", marker = "python_full_version < '3.14'" }, + { name = "future" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/5e/d5f9105d59c1325759d838af4e973695081fbbc97182baf73afc78dec266/ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127", size = 21543, upload-time = "2019-07-06T00:19:08.989Z" } wheels = [ @@ -1346,7 +1346,7 @@ name = "jsonpatch" version = "1.33" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpointer", marker = "python_full_version < '3.14'" }, + { name = "jsonpointer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } wheels = [ @@ -1444,15 +1444,15 @@ name = "langchain-core" version = "1.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpatch", marker = "python_full_version < '3.14'" }, - { name = "langchain-protocol", marker = "python_full_version < '3.14'" }, - { name = "langsmith", marker = "python_full_version < '3.14'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "pyyaml", marker = "python_full_version < '3.14'" }, - { name = "tenacity", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "uuid-utils", marker = "python_full_version < '3.14'" }, + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } wheels = [ @@ -1464,7 +1464,7 @@ name = "langchain-protocol" version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } wheels = [ @@ -1476,7 +1476,7 @@ name = "langchain-text-splitters" version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core", marker = "python_full_version < '3.14'" }, + { name = "langchain-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" } wheels = [ @@ -1488,20 +1488,20 @@ name = "langsmith" version = "0.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version < '3.14'" }, - { name = "distro", marker = "python_full_version < '3.14'" }, - { name = "httpx", marker = "python_full_version < '3.14'" }, - { name = "orjson", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, - { name = "requests-toolbelt", marker = "python_full_version < '3.14'" }, - { name = "sniffio", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "uuid-utils", marker = "python_full_version < '3.14'" }, - { name = "websockets", marker = "python_full_version < '3.14'" }, - { name = "xxhash", marker = "python_full_version < '3.14'" }, - { name = "zstandard", marker = "python_full_version < '3.14'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/26/b72987d947278f63ec1e85f01ce85ca7ab2621c7efc0845d4a3a8e5d5dfb/langsmith-0.9.1.tar.gz", hash = "sha256:e5eb905224d156bcece4985285c55b51fffcb06c9353b2c4adb42e1c48b0d05d", size = 4557557, upload-time = "2026-06-23T17:04:23.233Z" } wheels = [ @@ -2879,7 +2879,7 @@ name = "requests-toolbelt" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests", marker = "python_full_version < '3.14'" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ @@ -3343,16 +3343,16 @@ name = "voyageai" version = "0.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "python_full_version < '3.14'" }, - { name = "aiolimiter", marker = "python_full_version < '3.14'" }, - { name = "ffmpeg-python", marker = "python_full_version < '3.14'" }, - { name = "langchain-text-splitters", marker = "python_full_version < '3.14'" }, - { name = "numpy", marker = "python_full_version < '3.14'" }, - { name = "pillow", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, - { name = "tenacity", marker = "python_full_version < '3.14'" }, - { name = "tokenizers", marker = "python_full_version < '3.14'" }, + { name = "aiohttp" }, + { name = "aiolimiter" }, + { name = "ffmpeg-python" }, + { name = "langchain-text-splitters" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "tokenizers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/16/1b46b3cd401e1717a68197c1fe336d7bb4e0a1833f8105e1738f5b1add05/voyageai-0.3.7.tar.gz", hash = "sha256:826cd97f97223f42b5babc5c459c9c80f3a8215ce5c0e007b0b276550f790d24", size = 26485, upload-time = "2025-12-16T18:43:05.26Z" } wheels = [ From 8476d3cdec02ce59a51b055c8ade1e4405a322a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:27 +0000 Subject: [PATCH 096/262] build(deps-dev): bump eslint from 10.1.0 to 10.8.0 in /frontend in the eslint group across 1 directory (#7274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the eslint group with 1 update in the /frontend directory: [eslint](https://github.com/eslint/eslint). Updates `eslint` from 10.1.0 to 10.8.0
    Release notes

    Sourced from eslint's releases.

    v10.8.0

    Features

    • 2fee9bb feat: export ConfigObject from eslint/config (#21082) (sethamus)

    Bug Fixes

    • 6b8d2f7 fix: escape reserved characters in rule id in html formatter (#21129) (Francesco Trotta)
    • 9091071 fix: prevent no-unreachable-loop crash when all loop types are ignored (#21116) (Pixel)
    • e23fafe fix: prefer-object-spread add semicolon when adding parenthesis (#21081) (synthex-byte)
    • 20b5ad0 fix: quadratic-time regex in prefer-template (#21096) (Milos Djermanovic)
    • 8b6f6c0 fix: apply ignore configs to computed methods in class-methods-use-this (#21094) (Pixel)
    • b2c608c fix: NewExpression with parenthesized callee in preserve-caught-error (#21083) (Francesco Trotta)

    Documentation

    • 6ddf858 docs: fix broken Specify Parser Options anchor link (#21106) (Minsu)
    • 784dfbe docs: Clarify no-eq-null description (#21120) (Park Harin)
    • 7ec733a docs: Fix typos and grammar in glossary (#21095) (Marry (Subin Yang))
    • 92bb13f docs: replace quake link (#21108) (Jung Hyeon Jun)
    • 68eb4a5 docs: fix broken Specify Globals anchor links in rule pages (#21103) (Minsu)
    • d28f697 docs: replace Code Climate CLI links with Qlty CLI links (#21099) (Jung Hyeon Jun)
    • eccc68d docs: correct --suppressions-location option description (#21093) (Ga eun Lee)
    • c5963f7 docs: Update README (GitHub Actions Bot)

    Chores

    • 4fbf46d test: pin webpack version to 5.108.4 (#21137) (Francesco Trotta)
    • 2d063e2 chore: update HTTP URLs to HTTPS in JSDoc and comments (#21101) (Bo Hyun Kim)
    • eccbe7b test: add error locations to no-class-assign (#21123) (devoil)
    • e7d1e43 ci: bump actions/setup-go from 6 to 7 (#21118) (dependabot[bot])
    • e9d66d0 ci: bump actions/setup-node from 6 to 7 (#21119) (dependabot[bot])
    • ee225b6 test: Add error location details to no-eq-null rule (#21117) (Park Harin)
    • 044a627 chore: update minimatch to ^10.2.5 (#21107) (김채영)
    • fb09aa8 chore: update ecosystem plugins (#21115) (ESLint Bot)
    • 5abd878 test: add error locations to no-proto (#21114) (Gihyeon Jeong / 정기현)
    • 9715887 test: Add error location details to no-div-regex (#21110) (Park Harin)
    • a746ec6 test: add error locations to no-new-wrappers (#21109) (Gihyeon Jeong / 정기현)
    • 8dde645 test: add error locations to no-ex-assign (#21102) (devoil)
    • 13ab0ec test: add error locations to no-label-var (#21098) (Gihyeon Jeong / 정기현)
    • a99906f test: Add error location details to no-delete-var rule (#21105) (Park Harin)
    • c47e8dc chore: add missing backticks to languages/js/index.js (#21104) (beeen)
    • 0174428 chore: add missing backticks to translate-cli-options.js (#21097) (dongkyu lee)
    • 3d36589 chore: add missing backticks to serialization.js (#21091) (이규환)
    • dcc9312 test: add error locations to eqeqeq (#21090) (Ga eun Lee)
    • 2710b18 ci: Add explicit permissions to rebuild-docs-sites workflow (#21089) (Marry (Subin Yang))
    • 5d2f866 chore: update dependency prettier to v3.9.5 (#21086) (renovate[bot])
    • d584e31 chore: fix failing ecosystem test for eslint-plugin-unicorn (#21084) (Francesco Trotta)
    • bf3eda0 chore: update ecosystem plugins (#21079) (ESLint Bot)

    v10.7.0

    Features

    • cf2a9bf feat: add errorClassNames option to preserve-caught-error rule (#21032) (sethamus)
    • f8b873a feat: max-nested-callbacks option for constructor callbacks (#21063) (fnx)

    ... (truncated)

    Commits

    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 65 ++++++++++++++++++++------------------ frontend/package.json | 2 +- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5abc659e3..300a409229 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -119,7 +119,7 @@ "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", - "eslint": "^10.0.2", + "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", @@ -1961,13 +1961,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" }, @@ -1976,22 +1976,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2023,9 +2023,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2033,13 +2033,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -9286,18 +9286,21 @@ } }, "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -9319,7 +9322,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -12905,13 +12908,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" diff --git a/frontend/package.json b/frontend/package.json index dfcfa2842c..1798abc6b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -141,7 +141,7 @@ "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", - "eslint": "^10.0.2", + "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", From de93a1b5e2685f2a6022f54ed1eb888c9b5de469 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:33 +0000 Subject: [PATCH 097/262] build(deps): bump org.sonarqube from 7.2.3.7755 to 7.3.1.8318 (#7271) Bumps org.sonarqube from 7.2.3.7755 to 7.3.1.8318. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.sonarqube&package-manager=gradle&previous-version=7.2.3.7755&new-version=7.3.1.8318)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 31fa6b1bdb..de0861d8fa 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { id "com.diffplug.spotless" version "8.8.0" id "com.github.jk1.dependency-license-report" //id "nebula.lint" version "19.0.3" - id "org.sonarqube" version "7.2.3.7755" + id "org.sonarqube" version "7.3.1.8318" } import com.github.jk1.license.render.* From ad8830b6459a74fd4f6b3b3cb479b97ae8dc9df4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:38 +0000 Subject: [PATCH 098/262] build(deps): bump com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9 (#7270) Bumps com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=com.sun.xml.bind:jaxb-core&package-manager=gradle&previous-version=4.0.7&new-version=4.0.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/build.gradle b/app/core/build.gradle index 33aa679994..5e90672f75 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -57,7 +57,7 @@ dependencies { // veraPDF still uses javax.xml.bind, not the new jakarta namespace implementation 'javax.xml.bind:jaxb-api:2.3.1' implementation 'com.sun.xml.bind:jaxb-impl:2.3.9' - implementation 'com.sun.xml.bind:jaxb-core:4.0.7' + implementation 'com.sun.xml.bind:jaxb-core:4.0.9' // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) implementation "com.google.code.gson:gson:${gsonVersion}" From 74c53001cf4e652d96d2948d0b1058a827b98e24 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:05:24 +0100 Subject: [PATCH 099/262] fix(saas): give auth-bootstrap data fetching a single owner (#7194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The problem Three pieces of user data (pro status, avatar metadata, profile picture) were being fetched from four different places: `initializeAuth` on mount, the `SIGNED_IN` handler, the `TOKEN_REFRESHED` handler, and the post-upgrade path. On a fresh login the first two both see a session, so everything got fetched twice. It didn't stop after login either — Supabase re-fires `SIGNED_IN` on token refresh and tab-visibility wakeups, so a refresh that emitted both events cost around 7 Supabase reads. ## The fix All four call sites now go through one `loadUserData(session)` that is idempotent per identity. The guard key is `user.id` + `is_anonymous`: - not the access token, which changes on every refresh and would defeat the guard entirely - the anonymous flag matters because a guest to authenticated upgrade keeps the same user id, and that is the one case where the data genuinely does need reloading **Per login: 6 fetches to 3. A repeat `SIGNED_IN` or `TOKEN_REFRESHED` fetches nothing.** The tests count real calls rather than asserting on shape. ## Two behaviour changes worth naming - `initializeAuth` now awaits the full load, so the initial spinner also waits on the profile-picture URL. Net login is still faster, since an entire duplicate pass is gone. - A tab-wake `SIGNED_IN` no longer revalidates entitlements. That revalidation was accidental rather than designed — `refreshProStatus()` is the intended path, and post-checkout is already handled by `CheckoutContext`. ## Scope Supabase-origin traffic only. This does not touch the ~20 authenticated requests hitting `SupabaseAuthenticationFilter`, because those go to the Stirling backend rather than the hosted Supabase project. That is a separate problem and is unmeasured, so it needs measuring before anything is optimised. Remaining items (a double `/api/v1/team/my` fetch, an effect keyed on `[user]` identity in `FolderContext`, the `portalAccess` spinner flash, and caching the auth filter's per-request Postgres round-trips) are tracked separately. ## Verification ``` npx tsc --noEmit --project editor/src/saas/tsconfig.json # exit 0 npx eslint --max-warnings=0 editor/src/saas/auth # exit 0 npx prettier --check editor/src/saas/auth/ # clean npx vitest run --project saas # 75 passed (20 files) ``` --- .../src/saas/auth/AuthProvider.test.tsx | 339 ++++++++++++++++++ frontend/editor/src/saas/auth/UseSession.tsx | 165 +++++---- 2 files changed, 427 insertions(+), 77 deletions(-) create mode 100644 frontend/editor/src/saas/auth/AuthProvider.test.tsx diff --git a/frontend/editor/src/saas/auth/AuthProvider.test.tsx b/frontend/editor/src/saas/auth/AuthProvider.test.tsx new file mode 100644 index 0000000000..588507d59c --- /dev/null +++ b/frontend/editor/src/saas/auth/AuthProvider.test.tsx @@ -0,0 +1,339 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Session, User } from "@supabase/supabase-js"; + +/** + * Request-count tests for {@link AuthProvider}'s data loading. It used to fetch + * pro status, avatar metadata and the picture from two places at once, and + * Supabase re-fires SIGNED_IN on token refresh and tab wakeups, so it kept + * happening. These pin the call counts. + */ + +type AuthCallback = (event: string, session: Session | null) => void; + +const rpc = vi.fn(); +const createSignedUrl = vi.fn(); +const storageFrom = vi.fn((_bucket: string) => ({ createSignedUrl })); +const getSession = vi.fn(); +const onAuthStateChange = vi.fn(); +const unsubscribe = vi.fn(); + +vi.mock("@app/auth/supabase", () => ({ + supabase: { + auth: { + getSession: () => getSession(), + onAuthStateChange: (cb: AuthCallback) => onAuthStateChange(cb), + refreshSession: vi + .fn() + .mockResolvedValue({ data: { session: null }, error: null }), + signOut: vi.fn().mockResolvedValue({ error: null }), + }, + rpc: (...args: unknown[]) => rpc(...args), + storage: { from: (bucket: string) => storageFrom(bucket) }, + }, + debugAuthEvents: vi.fn(), +})); + +const syncOAuthAvatar = vi.fn(); +const getProfilePictureMetadata = vi.fn(); + +vi.mock("@app/services/avatarSyncService", () => ({ + syncOAuthAvatar: (...args: unknown[]) => syncOAuthAvatar(...args), + getProfilePictureMetadata: (...args: unknown[]) => + getProfilePictureMetadata(...args), + getProviderAvatarUrl: () => null, +})); + +const synchronizeUserUpgrade = vi.fn(); + +vi.mock("@app/services/userService", () => ({ + synchronizeUserUpgrade: (...args: unknown[]) => + synchronizeUserUpgrade(...args), +})); + +// Imported after the mocks so the provider picks them up. +const { AuthProvider, useAuth } = await import("./UseSession"); + +/** Surfaces `loading` so a test can assert on it rather than on the container. */ +function LoadingProbe() { + const { loading } = useAuth(); + return {String(loading)}; +} + +const USER_ID = "11111111-2222-3333-4444-555555555555"; + +function makeSession( + overrides: { token?: string; userId?: string; anonymous?: boolean } = {}, +): Session { + const user = { + id: overrides.userId ?? USER_ID, + email: "someone@example.com", + is_anonymous: overrides.anonymous ?? false, + app_metadata: { provider: "google" }, + user_metadata: { full_name: "Some One" }, + } as unknown as User; + + return { + access_token: overrides.token ?? "token-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "bearer", + user, + } as unknown as Session; +} + +/** Total requests the provider makes per user-data load. */ +function callCounts() { + return { + proStatus: rpc.mock.calls.length, + metadata: getProfilePictureMetadata.mock.calls.length, + picture: createSignedUrl.mock.calls.length, + avatarSync: syncOAuthAvatar.mock.calls.length, + }; +} + +function renderProvider() { + let authCallback: AuthCallback = () => {}; + onAuthStateChange.mockImplementation((cb: AuthCallback) => { + authCallback = cb; + return { data: { subscription: { unsubscribe } } }; + }); + + const utils = render( + + + , + ); + + /** + * Deliver an auth event and let its work finish. The provider defers with + * setTimeout(0), so a microtask flush is not enough: without draining real + * macrotasks the assertions run before any refetch and prove nothing. + */ + const fire = async (event: string, s: Session | null) => { + await act(async () => { + authCallback(event, s); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + return { ...utils, fire }; +} + +describe("AuthProvider user-data loading", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + sessionStorage.clear(); + + rpc.mockResolvedValue({ data: true, error: null }); + createSignedUrl.mockResolvedValue({ + data: { signedUrl: "https://example.test/avatar" }, + error: null, + }); + getProfilePictureMetadata.mockResolvedValue(null); + syncOAuthAvatar.mockResolvedValue(false); + synchronizeUserUpgrade.mockResolvedValue(undefined); + getSession.mockResolvedValue({ + data: { session: makeSession() }, + error: null, + }); + }); + + it("fetches each piece of user data exactly once on login", async () => { + const { fire } = renderProvider(); + + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + // The SIGNED_IN that follows a fresh login must not repeat the work. + await fire("SIGNED_IN", makeSession()); + + expect(callCounts()).toEqual({ + proStatus: 1, + metadata: 1, + picture: 1, + avatarSync: 1, + }); + }); + + it("does not refetch when SIGNED_IN repeats with a new access token", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + const before = callCounts(); + + // What a tab-visibility wakeup or token refresh looks like: same user, + // different token. + await fire("SIGNED_IN", makeSession({ token: "token-2" })); + + expect(callCounts()).toEqual(before); + }); + + it("does not refetch on TOKEN_REFRESHED for the same identity", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + const before = callCounts(); + + await fire("TOKEN_REFRESHED", makeSession({ token: "token-3" })); + + expect(callCounts()).toEqual(before); + }); + + it("keeps loading false across repeat auth events", async () => { + // Guards the Landing -> HomePage unmount: toggling `loading` on a wakeup + // would tear down the tree on every tab switch. + const { fire, getByTestId } = renderProvider(); + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + + await fire("SIGNED_IN", makeSession({ token: "token-4" })); + expect(getByTestId("loading").textContent).toBe("false"); + + await fire("TOKEN_REFRESHED", makeSession({ token: "token-5" })); + expect(getByTestId("loading").textContent).toBe("false"); + + expect(callCounts().proStatus).toBe(1); + }); + + it("clears the initial spinner without waiting for the avatar upload", async () => { + // syncOAuthAvatar re-uploads the provider image on a first login. Gating + // `loading` on it would stall account creation behind an image upload. + let releaseSync = () => {}; + syncOAuthAvatar.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSync = () => resolve(false); + }), + ); + + const { getByTestId } = renderProvider(); + + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + // The picture read chains behind the sync, so it has not run yet either. + expect(createSignedUrl).not.toHaveBeenCalled(); + + await act(async () => { + releaseSync(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + it("refetches after a guest upgrade, which keeps the same user id", async () => { + // The upgrade path is the one case where the id is unchanged but the data + // must be reloaded - hence keying on is_anonymous, not the id alone. + getSession.mockResolvedValue({ + data: { session: makeSession({ anonymous: true }) }, + error: null, + }); + sessionStorage.setItem("pendingUpgrade", "true"); + sessionStorage.setItem("upgradeProvider", "google"); + + const { fire } = renderProvider(); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(1)); + + await fire("USER_UPDATED", makeSession({ anonymous: false })); + + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2)); + expect(synchronizeUserUpgrade).toHaveBeenCalledWith("google"); + }); + + it("does not drop an upgrade that lands while the guest load is in flight", async () => { + // Coalescing on "something is in flight" alone would hand the upgrade the + // guest's promise and never fetch the real user's data. + getSession.mockResolvedValue({ + data: { session: makeSession({ anonymous: true }) }, + error: null, + }); + sessionStorage.setItem("pendingUpgrade", "true"); + sessionStorage.setItem("upgradeProvider", "google"); + + let releaseGuestLoad = () => {}; + const guestLoadBlocked = new Promise((resolve) => { + releaseGuestLoad = resolve; + }); + rpc.mockImplementationOnce(async () => { + await guestLoadBlocked; + return { data: false, error: null }; + }); + + const { fire } = renderProvider(); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(1)); + + // Deliver the upgrade with the guest load still deliberately unsettled, so + // the guard genuinely has an in-flight load to reason about. + await fire("USER_UPDATED", makeSession({ anonymous: false })); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2), { + timeout: 1000, + }); + + // Let the abandoned guest load settle inside act, so its trailing state + // updates do not land after the test finishes. + await act(async () => { + releaseGuestLoad(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + it("reloads for the same user after a sign-out", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + + await fire("SIGNED_OUT", null); + await fire("SIGNED_IN", makeSession()); + + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2)); + }); + + it("keeps the initial spinner up when SIGNED_IN wins the race with initializeAuth", async () => { + // initializeAuth yields at `await getSession()`, so SIGNED_IN can land + // first. Marking the identity loaded up front would then clear the spinner + // while pro status was still in flight. + const session = makeSession(); + + let releaseSession = () => {}; + getSession.mockReturnValueOnce( + new Promise<{ data: { session: Session }; error: null }>((resolve) => { + releaseSession = () => resolve({ data: { session }, error: null }); + }), + ); + + let releaseProStatus = () => {}; + rpc.mockImplementationOnce( + () => + new Promise<{ data: boolean; error: null }>((resolve) => { + releaseProStatus = () => resolve({ data: true, error: null }); + }), + ); + + const { getByTestId, fire } = renderProvider(); + + // SIGNED_IN lands first and starts the load; pro status stays unsettled. + await fire("SIGNED_IN", session); + expect(rpc).toHaveBeenCalledTimes(1); + + // initializeAuth must now adopt that in-flight load, not short-circuit. + await act(async () => { + releaseSession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(getByTestId("loading").textContent).toBe("true"); + // Adopted, not restarted. + expect(rpc).toHaveBeenCalledTimes(1); + + await act(async () => { + releaseProStatus(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + expect(rpc).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 087fa99b65..101ed58d06 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, + useRef, useState, ReactNode, useCallback, @@ -247,6 +248,78 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetchProfilePictureMetadata(); }, [fetchProfilePictureMetadata]); + // Refs, not state: the auth effect below has an empty dep array. + const loadedForRef = useRef(null); + const inFlightRef = useRef<{ key: string; promise: Promise } | null>( + null, + ); + + /** + * Sole owner of the per-user data fetching: mount-time init and every auth + * event route through here. Idempotent per identity, since Supabase re-fires + * SIGNED_IN and TOKEN_REFRESHED on token refresh and tab wakeups; pass + * `force` for a genuine reload. The returned promise excludes the profile + * picture, so awaiting it never blocks on an image download. + */ + const loadUserData = useCallback( + ( + sessionToLoad: Session | null, + opts?: { force?: boolean }, + ): Promise => { + const user = sessionToLoad?.user; + if (!user) { + loadedForRef.current = null; + return Promise.resolve(); + } + + // Not the access token, which changes every refresh. The anonymous flag + // matters: a guest upgrade keeps the same id and must still refetch. + const key = `${user.id}:${Boolean(user.is_anonymous)}`; + if (!opts?.force && loadedForRef.current === key) + return Promise.resolve(); + + // The second of a concurrent init/SIGNED_IN pair adopts this promise so it + // still awaits the load. A forced reload must not: the guest upgrade + // would be silently dropped. + if (!opts?.force && inFlightRef.current?.key === key) + return inFlightRef.current.promise; + + const run = (async () => { + // Off the awaited path: a first login re-uploads the provider avatar. + // The signed-URL read chains behind it because reading first 404s and + // silently falls back to the provider photo. + const avatarSync = syncOAuthAvatar(user).catch((err) => { + console.debug("[Auth Debug] Failed to sync OAuth avatar:", err); + return false; + }); + void avatarSync + .then(() => fetchProfilePicture(sessionToLoad)) + .catch((err) => { + console.debug("[Auth Debug] Failed to fetch profile picture:", err); + }); + + await Promise.all([ + fetchProStatus(sessionToLoad), + fetchProfilePictureMetadata(sessionToLoad), + ]); + // Only on success: set up front, a concurrent caller short-circuits on + // it and returns to a still-empty state. Also lets a failure retry. + loadedForRef.current = key; + })() + .catch((err) => { + console.debug("[Auth Debug] Failed to load user data:", err); + }) + .finally(() => { + // Only clear our own entry; a newer load may have superseded us. + if (inFlightRef.current?.promise === run) inFlightRef.current = null; + }); + + inFlightRef.current = { key, promise: run }; + return run; + }, + [fetchProStatus, fetchProfilePictureMetadata, fetchProfilePicture], + ); + const refreshSession = async () => { try { setLoading(true); @@ -312,23 +385,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setSession(data.session); - // Fetch pro status, profile picture metadata, and profile picture using the session from the response - if (data.session?.user) { - // Sync OAuth avatar in background; fetch the picture once the - // sync settles instead of guessing with a fixed delay. - syncOAuthAvatar(data.session.user) - .catch((err) => { - console.debug( - "[Auth Debug] Failed to sync OAuth avatar on init:", - err, - ); - return false; - }) - .then(() => fetchProfilePicture(data.session)); - - await fetchProStatus(data.session); - await fetchProfilePictureMetadata(data.session); - } + // Awaited so the spinner does not clear before pro status is known. + await loadUserData(data.session); } } catch (err) { console.error( @@ -374,58 +432,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { setIsPro(null); setProfilePictureUrl(null); setProfilePictureMetadata(null); - } else if (event === "SIGNED_IN") { - console.debug("[Auth Debug] User signed in successfully"); - if (newSession?.user) { - // Note: we deliberately do NOT toggle `loading` here. Supabase - // also fires SIGNED_IN on tab visibility / token-refresh wakeups - // (per its docs: "SIGNED_IN is fired when a user signs in OR - // when the access token is refreshed"), and gating the UI on - // `loading` would unmount Landing -> HomePage every time the - // user switches tabs back. Initial-mount loading is handled by - // `initializeAuth` above; downstream fetches expose their own - // null/loading states. - - // Sync OAuth avatar in background (don't block other fetches) - const avatarSync = syncOAuthAvatar(newSession.user).catch( - (err) => { - console.debug( - "[Auth Debug] Failed to sync OAuth avatar:", - err, - ); - return false; - }, - ); - - // Fetch user data in parallel - Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - ]).then(() => { - // Fetch the picture once the avatar sync settles. - avatarSync.then(() => { - fetchProfilePicture(newSession).finally(() => { - console.debug( - "[Auth Debug] User data fully loaded after sign in", - ); - }); - }); - }); - } - } else if (event === "TOKEN_REFRESHED") { - console.debug("[Auth Debug] Token refreshed"); - // Optionally refresh pro status, profile picture metadata, and profile picture on token refresh - if (newSession?.user) { - Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - fetchProfilePicture(newSession), - ]).then(() => { - console.debug( - "[Auth Debug] User data refreshed after token refresh", - ); - }); - } + loadedForRef.current = null; + } else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") { + console.debug("[Auth Debug] Signed in or token refreshed"); + // Deliberately does not touch `loading`: Supabase also fires + // SIGNED_IN on tab wakeups, and gating the UI on it would unmount + // Landing -> HomePage on every tab switch. Pinned by a test. + void loadUserData(newSession); } else if (event === "USER_UPDATED") { console.debug("[Auth Debug] User updated"); @@ -454,14 +467,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] User upgrade synchronized successfully", ); - // Refresh pro status, profile picture metadata, and profile picture after upgrade - if (newSession?.user) { - return Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - fetchProfilePicture(newSession), - ]); - } + // Forced: same user id, so the guest's data must be replaced. + return loadUserData(newSession, { force: true }); }) .then(() => { console.debug( @@ -484,6 +491,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { mounted = false; subscription.unsubscribe(); }; + // Empty and load-bearing: must subscribe once. The closures are recreated + // when `session` changes, so listing deps would re-subscribe on every auth + // event; every call above passes its session explicitly instead. No lint + // rule enforces this, so do not "fix" these deps. }, []); const { t } = useTranslation(); From 86d4a344767b183a395dc12495eda85fb10958c6 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 15:54:44 +0200 Subject: [PATCH 100/262] deps: Upgrade posthog-js to 1.405.2 (#7115) # Description of Changes - Updated the frontend `posthog-js` dependency from `^1.268.0` to `^1.405.2`. - Refreshed `frontend/package-lock.json` and updated related PostHog transitive dependencies. - Removed obsolete OpenTelemetry and protobuf-related transitive packages no longer required by the newer PostHog version. - The existing PostHog APIs used by Stirling-PDF remain compatible. --- ## 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. --- frontend/package-lock.json | 46 ++++++++++++++++++++++---------------- frontend/package.json | 2 +- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 300a409229..546e2ef705 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -70,7 +70,7 @@ "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", "pixelmatch": "^7.1.0", - "posthog-js": "^1.268.0", + "posthog-js": "^1.405.2", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -3154,12 +3154,12 @@ } }, "node_modules/@posthog/core": { - "version": "1.39.3", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.3.tgz", - "integrity": "sha512-oR+B8Q5O61N+W2+HVOBG9dbxAT/+OVxX+XvpNj6KYgptN2EB14JiKQ9Rm7DNpErNTLV7WApZEK/URkZgldOxfg==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.44.0.tgz", + "integrity": "sha512-uE+mdKvetxNQC6gWQf4MHIH8bGt+JN6z7ho0A4G3t9G1VGQTx9wHYHNwkKf3gzi+1oAXS9ej2VVY4pjWUU2PWg==", "license": "MIT", "dependencies": { - "@posthog/types": "^1.392.0" + "@posthog/types": "^1.397.0" } }, "node_modules/@posthog/react": { @@ -3179,9 +3179,9 @@ } }, "node_modules/@posthog/types": { - "version": "1.392.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz", - "integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", + "version": "1.397.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.397.1.tgz", + "integrity": "sha512-W/LpWbKVaaUnfZKuFuHa+Dg03D+fC87cM+PQbG+59JcSPW8F0JcBtSoXmpfrqbpuxUToMo+gktutrUkAb/KQBw==", "license": "MIT" }, "node_modules/@puppeteer/browsers": { @@ -14053,17 +14053,17 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.396.4", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.396.4.tgz", - "integrity": "sha512-PycBmwKQD1T7YFYrGRb8rjQET/UVnexgUy8gVe6UBEhwHXEIhZF4na5VakJbn4zu1wg4tzjt8r7PA4VLu6bDjg==", - "license": "SEE LICENSE IN LICENSE", + "version": "1.405.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.405.2.tgz", + "integrity": "sha512-KPbbAX4EKM8UTU13gW/fZHyonfVee+GIj9QyiXtI2N4ooku69eZzfgk3CxQOUcnuvCz6xaQaUlvRmIsROxTCvw==", + "license": "(Apache-2.0 AND MIT)", "dependencies": { - "@posthog/core": "^1.39.3", - "@posthog/types": "^1.392.0", - "core-js": "^3.38.1", + "@posthog/core": "^1.44.0", + "@posthog/types": "^1.397.1", + "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", - "preact": "^10.29.2", + "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } @@ -14082,13 +14082,21 @@ } }, "node_modules/preact": { - "version": "10.29.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", - "integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==", + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } } }, "node_modules/prelude-ls": { diff --git a/frontend/package.json b/frontend/package.json index 1798abc6b2..f48fad8571 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,7 +67,7 @@ "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", "pixelmatch": "^7.1.0", - "posthog-js": "^1.268.0", + "posthog-js": "^1.405.2", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", From fa2eb6712445c741ff7b5167420ed5b28505bd96 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 16:56:12 +0200 Subject: [PATCH 101/262] deps(frontend): align dependency scopes and remove redundant ESLint packages (#6991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes This change reorganizes frontend dependencies by moving development-only packages into `devDependencies`, removing obsolete packages, and updating several development tooling dependencies to newer versions. ### What was changed - Moved runtime-independent packages to `devDependencies`: - `@iconify/react` - `globals` - Removed unused TypeScript ESLint packages: - `@typescript-eslint/eslint-plugin` - `@typescript-eslint/parser` - Updated development dependencies: - `@iconify-json/material-symbols` → `1.2.83` - `@iconify/utils` → `3.1.4` - `globals` → `17.7.0` These changes reduce redundant dependency declarations and ensure packages are classified according to their actual usage. The main challenge was distinguishing direct dependencies from packages already provided transitively by frontend tooling. --- ## 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. --- frontend/package-lock.json | 716 ++++++++++++++++++++++++++++++------- frontend/package.json | 14 +- 2 files changed, 594 insertions(+), 136 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 546e2ef705..6f19db35b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,6 @@ "@embedpdf/plugin-zoom": "^2.14.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", @@ -62,7 +61,6 @@ "autoprefixer": "^10.4.21", "axios": "^1.15.0", "d3": "^7.9.0", - "globals": "^17.5.0", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", @@ -89,8 +87,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@iconify-json/material-symbols": "^1.2.53", - "@iconify/utils": "^3.1.0", + "@iconify-json/material-symbols": "^1.2.83", + "@iconify/react": "^6.0.2", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", @@ -115,12 +114,13 @@ "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", - "@vitest/browser": "^3.2.6", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/browser": "3.2.7", + "@vitest/coverage-v8": "3.2.7", "dotenv": "^16.4.7", "dpdm": "^3.14.0", "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", + "globals": "^17.7.0", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", @@ -142,7 +142,7 @@ "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "3.2.7" } }, "node_modules/@acemir/cssom": { @@ -2170,9 +2170,9 @@ } }, "node_modules/@iconify-json/material-symbols": { - "version": "1.2.63", - "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.63.tgz", - "integrity": "sha512-R4PS/l8K6j+dk2P2MoYFLJgfbZ4YDo6XjCOpX6b1tvX+BhiSpSOjc1b6cnb/mvWe+JWBKlt4pcvPNiAijFLPnA==", + "version": "1.2.83", + "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.83.tgz", + "integrity": "sha512-4I2rfNlaoyn4zIcdJDxUMuPV1pVp8Tgwy+eJvyAZuOpmPtOsniQ8Dug6wzRD5s9KLyQA3smFvuBphVdYG7NWQA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2183,6 +2183,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.2.tgz", "integrity": "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==", + "dev": true, "license": "MIT", "dependencies": { "@iconify/types": "^2.0.0" @@ -2198,18 +2199,19 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", - "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", "dev": true, "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", - "mlly": "^1.8.0" + "import-meta-resolve": "^4.2.0" } }, "node_modules/@inquirer/ansi": { @@ -6433,16 +6435,16 @@ } }, "node_modules/@vitest/browser": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.6.tgz", - "integrity": "sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.7.tgz", + "integrity": "sha512-gIzazUkbQfv6T1rJHOLhMMKQnplKAvvQ7QNGaFwI6oCsp4z2aSDZCojGpX3QX3+MYsvJdyy/8BRIYVEbAkMkEA==", "dev": true, "license": "MIT", "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", - "@vitest/mocker": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/mocker": "3.2.7", + "@vitest/utils": "3.2.7", "magic-string": "^0.30.17", "sirv": "^3.0.1", "tinyrainbow": "^2.0.0", @@ -6453,7 +6455,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "3.2.6", + "vitest": "3.2.7", "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" }, "peerDependenciesMeta": { @@ -6469,13 +6471,13 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6496,9 +6498,9 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -6509,9 +6511,9 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6522,13 +6524,13 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6537,9 +6539,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", - "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", "dev": true, "license": "MIT", "dependencies": { @@ -6561,8 +6563,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.6", - "vitest": "3.2.6" + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6628,13 +6630,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -6643,9 +6645,9 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -6656,13 +6658,13 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6671,13 +6673,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -6686,9 +6688,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -8078,13 +8080,6 @@ "dev": true, "license": "MIT" }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -10193,9 +10188,10 @@ } }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -12962,17 +12958,14 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" + "engines": { + "node": ">=10" } }, "node_modules/mrmime": { @@ -13716,18 +13709,6 @@ "pixelmatch": "bin/pixelmatch" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -17409,9 +17390,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17427,6 +17408,490 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tsx/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -17512,13 +17977,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -18097,20 +18555,20 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -18140,8 +18598,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, @@ -18170,15 +18628,15 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -18187,13 +18645,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -18214,9 +18672,9 @@ } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -18227,9 +18685,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18240,13 +18698,13 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, diff --git a/frontend/package.json b/frontend/package.json index f48fad8571..bc01367cef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,6 @@ "@embedpdf/plugin-zoom": "^2.14.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", @@ -59,7 +58,6 @@ "autoprefixer": "^10.4.21", "axios": "^1.15.0", "d3": "^7.9.0", - "globals": "^17.5.0", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", @@ -111,8 +109,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@iconify-json/material-symbols": "^1.2.53", - "@iconify/utils": "^3.1.0", + "@iconify-json/material-symbols": "^1.2.83", + "@iconify/react": "^6.0.2", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", @@ -137,12 +136,13 @@ "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", - "@vitest/browser": "^3.2.6", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/browser": "3.2.7", + "@vitest/coverage-v8": "3.2.7", "dotenv": "^16.4.7", "dpdm": "^3.14.0", "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", + "globals": "^17.7.0", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", @@ -164,7 +164,7 @@ "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "3.2.7" }, "depcheck": { "ignoreMatches": [ From 2cf6db99ceb0b8321f29f4a4cde18dbb4baec1e0 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:47:09 +0100 Subject: [PATCH 102/262] Fix timing-fragile Valkey rate-limit boundary test (#7302) # Description of Changes Fix timing-fragile Valkey rate-limit boundary test --- ## 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. --- .../valkey/LiveValkeyIntegrationTest.java | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java index c26528a715..0e6cb2f4de 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java @@ -298,24 +298,44 @@ class LiveValkeyIntegrationTest { ValkeyRateLimitStore store = newRateLimitStore(factoryA); String key = "boundary-" + java.util.UUID.randomUUID(); long capacity = 5; - Duration window = Duration.ofMillis(500); + // refillGreedy tops the bucket up continuously, one token every window/capacity. A 500ms + // window left the drain loop only 100ms before a 6th token appeared, so a slow Valkey + // round-trip broke the count; 4s spaces refills 800ms apart, clear of any burst. + Duration window = Duration.ofSeconds(4); + long refillIntervalMs = window.toMillis() / capacity; + long drainStart = System.nanoTime(); int firstAllowed = 0; for (int i = 0; i < 10; i++) { if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++; } - assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially"); + long drainMs = (System.nanoTime() - drainStart) / 1_000_000; + // Refill never pauses, so a slow drain earns extra tokens honestly - allow exactly the + // number the elapsed time can have produced and no more. + long earned = drainMs / refillIntervalMs; + assertTrue( + firstAllowed >= capacity && firstAllowed <= capacity + earned, + "initial burst must be capacity (" + + capacity + + ") plus at most the " + + earned + + " token(s) refilled during a " + + drainMs + + "ms drain, got " + + firstAllowed); - Thread.sleep(window.toMillis() + 50); + // A fixed-window limiter would hand back a whole fresh capacity at the boundary; a token + // bucket hands back one token per refill interval. + Thread.sleep(refillIntervalMs + 200); int secondAllowed = 0; - long start = System.nanoTime(); - for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) { + for (int i = 0; i < 10; i++) { if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++; } assertTrue( - secondAllowed <= capacity, - "token-bucket must not let a fresh full capacity be consumed instantly across" - + " the boundary; got " + secondAllowed >= 1 && secondAllowed < capacity, + "one refill interval must yield about one token, not a fresh full window of " + + capacity + + "; got " + secondAllowed); } From fd1c955648c8c2ba28d382d1ba0db01b260e8c93 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:52:13 +0200 Subject: [PATCH 103/262] refactor(ui): redesign VersionTimeline UI (#7162) # Description of Changes I felt the old VersionTimeline was a bit too crowded/not very "good" looking so i had a crack at redesigning it. Mainly aimed for: - less info - less crowding - more spacing ### New image ### Old image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] 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. --- .../public/locales/en-US/translation.toml | 3 +- .../core/components/filesPage/FilesPage.css | 174 ++++++----- .../filesPage/VersionHistoryModal.tsx | 16 +- .../components/filesPage/VersionTimeline.tsx | 275 +++++++++--------- 4 files changed, 256 insertions(+), 212 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index feb42149f7..ea4f0399b9 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3977,6 +3977,7 @@ refresh = "Refresh from server" remove = "Delete" removeVersion = "Remove this version" rename = "Rename" +renamed = "Renamed" renameFolder = "Rename folder" resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)" save = "Save" @@ -4079,9 +4080,7 @@ count = "Files" folder = "Folder" labels = "Labels" modified = "Modified" -name = "Name" size = "Size" -toolHistoryAtVersion = "Cumulative tool chain" totalSize = "Total size" type = "Type" versionHistory = "Version journey" diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 9de4a276aa..4815a1b31e 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -956,178 +956,204 @@ like a commit graph; clicking a row's summary toggles its expanded detail (cumulative tool chain, full meta). Long chains (> 6) collapse the middle behind a "Show N earlier versions" button. */ +/* Version Timeline styling */ .files-page-details-version-timeline { display: flex; flex-direction: column; - gap: 0.4rem; - padding: 0.55rem 0.7rem 0.7rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: 0.5rem; + gap: 0.6rem; + padding: 0.5rem 0; + background: transparent; } + .files-page-details-version-timeline-label { display: flex; align-items: center; - gap: 0.35rem; - font-size: 0.72rem; + gap: 0.4rem; + font-size: 0.75rem; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--c-text-subtle); + margin-bottom: 0.85rem; } + .files-page-details-version-timeline-count { margin-left: auto; font-weight: 600; - color: var(--c-text-muted, var(--c-text-subtle)); + color: var(--c-primary); text-transform: none; letter-spacing: 0; } + .files-page-details-version-timeline-list { - list-style: none; - margin: 0; - padding: 0; + list-style: none !important; + margin: 0 !important; + padding: 1.1rem 0 0 0 !important; display: flex; flex-direction: column; + gap: 1.25rem; } + .files-page-details-version-timeline-row, .files-page-details-version-timeline-ellipsis { display: flex; - gap: 0.6rem; - padding: 0.15rem 0; + gap: 0.85rem; + padding: 0; position: relative; + list-style: none !important; } + +.files-page-details-version-timeline-row::before, +.files-page-details-version-timeline-ellipsis::before { + content: none !important; +} + .files-page-details-version-timeline-rail { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; - width: 0.8rem; - padding-top: 0.45rem; + width: 1rem; + padding-top: 0.85rem; } + .files-page-details-version-timeline-rail-dot { - width: 0.55rem; - height: 0.55rem; + width: 0.65rem; + height: 0.65rem; border-radius: 50%; background: var(--c-bg-raised); border: 2px solid var(--c-border-strong, var(--c-border-subtle)); - z-index: 1; + z-index: 2; flex-shrink: 0; + transition: all 0.2s ease; } + .files-page-details-version-timeline-rail-dot.is-active { background: var(--c-primary); border-color: var(--c-primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 25%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 30%, transparent); } + .files-page-details-version-timeline-rail-dot.is-ellipsis { - width: 0.35rem; - height: 0.35rem; + width: 0.4rem; + height: 0.4rem; background: var(--c-text-subtle); border-color: transparent; } + .files-page-details-version-timeline-rail-line { width: 2px; flex: 1; background: var(--c-border-subtle); - min-height: 0.6rem; - margin-top: 2px; + min-height: 1.25rem; + margin-top: 6px; } + .files-page-details-version-timeline-body { flex: 1; min-width: 0; display: flex; flex-direction: column; - gap: 0.2rem; - padding: 0.25rem 0.3rem 0.4rem; - border-radius: 0.35rem; - transition: background-color 0.12s ease; + gap: 0.5rem; + padding: 1rem 1.25rem; + border-radius: 0.65rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; } + +.files-page-details-version-timeline-body:hover { + border-color: var(--c-border-strong, var(--c-border-subtle)); +} + .files-page-details-version-timeline-row.is-active .files-page-details-version-timeline-body { - background: color-mix(in srgb, var(--c-primary) 10%, transparent); + border-color: color-mix(in srgb, var(--c-primary) 40%, transparent); + background: color-mix(in srgb, var(--c-primary) 5%, var(--c-surface)); + box-shadow: 0 2px 6px color-mix(in srgb, var(--c-primary) 12%, transparent); } -.files-page-details-version-timeline-summary { + +.files-page-details-version-timeline-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.files-page-details-version-timeline-tool-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--c-text); +} + +.files-page-details-version-timeline-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding-top: 0.1rem; +} + +.files-page-details-version-timeline-expand-btn { appearance: none; background: none; border: 0; padding: 0; - margin: 0; + cursor: pointer; display: flex; align-items: center; - gap: 0.4rem; - cursor: pointer; - text-align: left; - color: inherit; - font: inherit; } -.files-page-details-version-timeline-summary:hover - .files-page-details-version-timeline-chevron { + +.files-page-details-version-timeline-expand-btn:hover span { color: var(--c-text); } -.files-page-details-version-timeline-delta { - font-size: 0.82rem; - color: var(--c-text); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inline-flex; - align-items: baseline; - gap: 0.25rem; -} -.files-page-details-version-timeline-delta.is-origin { - font-weight: 400; - color: var(--c-text-subtle); - font-style: italic; -} -.files-page-details-version-timeline-delta-plus { - color: var(--c-primary); - font-weight: 700; -} -.files-page-details-version-timeline-spacer { - flex: 1; -} + .files-page-details-version-timeline-chevron { color: var(--c-text-subtle); transition: transform 0.15s ease; } + .files-page-details-version-timeline-chevron.is-expanded { transform: rotate(180deg); - color: var(--c-text); -} -.files-page-details-version-timeline-meta-line { - display: flex; - align-items: center; - gap: 0.35rem; - font-size: 0.7rem; - color: var(--c-text-subtle); + color: var(--c-primary); } + .files-page-details-version-timeline-expanded { display: flex; flex-direction: column; gap: 0.4rem; - margin-top: 0.45rem; + margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px dashed var(--c-border-subtle); } + .files-page-details-version-timeline-toolchain { display: flex; flex-direction: column; gap: 0.2rem; } + .files-page-details-version-timeline-toolchain-label { font-size: 0.65rem; color: var(--c-text-subtle); text-transform: uppercase; letter-spacing: 0.05em; } + .files-page-details-version-timeline-ellipsis-btn, .files-page-details-version-timeline-collapse-btn { appearance: none; background: none; border: 1px dashed var(--c-border-subtle); - border-radius: 0.3rem; - padding: 0.25rem 0.5rem; + border-radius: 0.4rem; + padding: 0.35rem 0.7rem; margin: 0.1rem 0; - font-size: 0.72rem; + font-size: 0.75rem; color: var(--c-text-subtle); cursor: pointer; text-align: left; @@ -1135,11 +1161,13 @@ border-color 0.12s ease, color 0.12s ease; } + .files-page-details-version-timeline-ellipsis-btn:hover, .files-page-details-version-timeline-collapse-btn:hover { color: var(--c-text); - border-color: var(--c-border-strong, var(--c-text-subtle)); + border-color: var(--c-primary); } + .files-page-details-version-timeline-collapse-btn { align-self: flex-start; margin-top: 0.2rem; diff --git a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx index 077924155d..2bef30c763 100644 --- a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Center, Loader, Modal, Text } from "@mantine/core"; +import HistoryIcon from "@mui/icons-material/History"; +import { Center, Group, Loader, Modal, Text } from "@mantine/core"; import type { FileId } from "@app/types/file"; import type { StirlingFileStub } from "@app/types/fileContext"; @@ -99,7 +100,17 @@ export function VersionHistoryModal({ onClose={onClose} centered size="md" - title={t("filesPage.field.versionHistory", "Version journey")} + title={ + + + + {t("filesPage.field.versionHistory", "Version journey")} + + + } > {loading ? (
    @@ -111,6 +122,7 @@ export function VersionHistoryModal({ currentId={file.id} onAddToWorkspace={handleAddToWorkspace} onRemove={handleRemove} + hideHeader /> ) : ( diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 47495fe61b..049fc2365a 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Badge, Menu } from "@mantine/core"; +import { Badge, Group, Menu, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; @@ -8,16 +8,14 @@ import DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; import HistoryIcon from "@mui/icons-material/History"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { FileId, ToolOperation } from "@app/types/file"; import { ToolId } from "@app/types/toolId"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { downloadFileFromStorage } from "@app/utils/downloadUtils"; -import ToolChain from "@app/components/shared/ToolChain"; -/** Small label/value row; shared with FileDetailsPanel. */ +/** Small label/value row with crisp flex alignment and colon separation. */ export function DetailField({ label, value, @@ -26,9 +24,31 @@ export function DetailField({ value: string; }) { return ( -
    - {label} - {value} +
    + + {label}: + + + {value} +
    ); } @@ -60,7 +80,7 @@ export interface VersionTimelineProps { hideHeader?: boolean; } -/** Version timeline with per-row tool deltas and collapse-when-long. */ +/** Clean, spacious version timeline with minimal clutter. */ export function VersionTimeline({ chain, currentId, @@ -69,7 +89,6 @@ export function VersionTimeline({ hideHeader = false, }: VersionTimelineProps) { const { t } = useTranslation(); - const [expandedIds, setExpandedIds] = useState>(new Set()); const [showAllCollapsed, setShowAllCollapsed] = useState(false); // Newest-first ordering. @@ -113,15 +132,6 @@ export function VersionTimeline({ return [...head, { kind: "ellipsis", hidden }, ...tail]; }, [collapsible, showAllCollapsed, ordered]); - const toggleExpand = (id: FileId) => { - setExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - return (
    {!hideHeader && ( @@ -135,7 +145,10 @@ export function VersionTimeline({
    )} -
      +
        {rows.map((row, idx) => { const isLast = idx === rows.length - 1; if (row.kind === "ellipsis") { @@ -143,6 +156,7 @@ export function VersionTimeline({
      • @@ -152,7 +166,7 @@ export function VersionTimeline({
        -
        - {formatFileSize(v.size)} - {v.lastModified ? ( - <> - · - - {getFileDate({ lastModified: v.lastModified })} - - - ) : null} - {/* Kebab on every row - the original/active version also - needs download + open-in-workspace. */} - - - - e.stopPropagation()} - > - - - - - } - onClick={() => onAddToWorkspace([v.id])} - > - {t( - "filesPage.openVersionInWorkspace", - "Open in workspace", - )} - - } - onClick={() => { - void downloadFileFromStorage(v); - }} - > - {t( - "filesPage.downloadVersion", - "Download this version", - )} - - - } - onClick={() => onRemove([v.id])} - > - {t("filesPage.removeVersion", "Remove this version")} - - - -
        - {isExpanded && ( - // Filename + full cumulative tool chain. -
        - - {v.toolHistory && v.toolHistory.length > 0 && ( -
        - - {t( - "filesPage.field.toolHistoryAtVersion", - "Cumulative tool chain", + + {delta ? ( + + ) : ( + t("filesPage.versionOrigin", "Original upload") + )} + + + + {!isActive && ( + + + - -
        - )} -
        + onClick={(e) => e.stopPropagation()} + > + + + + + } + onClick={() => onAddToWorkspace([v.id])} + > + {t( + "filesPage.openVersionInWorkspace", + "Open in workspace", + )} + + } + onClick={() => { + void downloadFileFromStorage(v); + }} + > + {t( + "filesPage.downloadVersion", + "Download this version", + )} + + + } + onClick={() => onRemove([v.id])} + > + {t("filesPage.removeVersion", "Remove this version")} + + + + )} +
    + + {/* Quiet Meta Line: File Size · Date */} + + {formatFileSize(v.size)} + {v.lastModified && ( + <> · {getFileDate({ lastModified: v.lastModified })} + )} + + + {/* Show filename ONLY if original upload or if name changed */} + {(isOriginal || nameChanged) && ( + + {nameChanged + ? `${t("filesPage.renamed", "Renamed")}: ` + : `${t("filesPage.file", "File")}: `} + + {v.name} + + )} ); })} - + {collapsible && showAllCollapsed && ( + + {/* Active Scale Display */} +
    + + {t("scaleSettings.activeScale", "Active Scale")}:{" "} + {currentScale && currentScale.ratio + ? generateScaleLabel(currentScale.ratio, currentScale.unit) + : currentScale && !currentScale.ratio + ? `${currentScale.unit} (custom)` + : t("scaleSettings.noneSet", "No custom scale set")} + +
    + + {/* Calibration Mode */} + + + {/* Reset Button */} + {currentScale && ( + + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 14a308e0f3..d5d39c4c0c 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -26,11 +26,19 @@ import StraightenIcon from "@mui/icons-material/Straighten"; import LayersIcon from "@mui/icons-material/Layers"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import StopIcon from "@mui/icons-material/Stop"; +import SettingsIcon from "@mui/icons-material/Settings"; import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud"; +import { RulerScaleSettingsButton } from "@app/components/viewer/RulerScaleSettingsButton"; +import type { MeasureScale } from "@app/utils/measurementTypes"; export function useViewerWorkbenchBarButtons( isRulerActive?: boolean, setIsRulerActive?: (v: boolean) => void, + customScale?: MeasureScale | null, + setCustomScale?: (scale: MeasureScale | null) => void, + isScaleCalibrationActive?: boolean, + startScaleCalibration?: () => void, + cancelScaleCalibration?: () => void, ) { const { t, i18n } = useTranslation(); const viewer = useViewer(); @@ -118,11 +126,36 @@ export function useViewerWorkbenchBarButtons( const annotationsLabel = t("workbenchBar.annotations", "Annotations"); const formFillLabel = t("workbenchBar.formFill", "Fill Form"); const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure"); + const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings"); const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud"); const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed"); const isFormFillActive = (selectedTool as string) === "formFill"; + const handleStartScaleCalibration = useCallback(() => { + startScaleCalibration?.(); + setIsRulerActive?.(true); + if (isPanning) { + viewer.panActions.disablePan(); + setIsPanning(false); + } + }, [isPanning, setIsRulerActive, startScaleCalibration, viewer.panActions]); + + const handleCancelScaleCalibration = useCallback(() => { + cancelScaleCalibration?.(); + }, [cancelScaleCalibration]); + + const handleApplyRulerScale = useCallback( + (scale: MeasureScale) => { + setCustomScale?.(scale); + }, + [setCustomScale], + ); + + const handleResetRulerScale = useCallback(() => { + setCustomScale?.(null); + }, [setCustomScale]); + // Filter languages based on available voices const filteredLanguages = useMemo( () => @@ -234,6 +267,32 @@ export function useViewerWorkbenchBarButtons( } }, }, + // Ruler scale settings button - only visible when ruler is active + ...(isRulerActive + ? [ + { + id: "viewer-ruler-settings", + icon: , + tooltip: rulerSettingsLabel, + ariaLabel: rulerSettingsLabel, + section: "top" as const, + order: 25.5, + render: ({ disabled }: { disabled?: boolean }) => ( + + ), + }, + ] + : []), { id: "viewer-rotate-left", icon: , @@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons( formFillLabel, isFormFillActive, rulerLabel, + rulerSettingsLabel, isRulerActive, setIsRulerActive, + handleStartScaleCalibration, + handleCancelScaleCalibration, + handleApplyRulerScale, + handleResetRulerScale, + customScale, + isScaleCalibrationActive, readAloudLabel, readAloudSpeedLabel, isReadingAloud, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 5e5d08dbb4..78535b41ac 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -176,6 +176,9 @@ export interface ViewerContextType { registerImmediatePanUpdate: ( callback: (isPanning: boolean) => void, ) => () => void; + registerImmediateRotationUpdate: ( + callback: (rotation: number) => void, + ) => () => void; // Internal - for bridges to trigger immediate updates triggerImmediateScrollUpdate: ( @@ -188,6 +191,7 @@ export interface ViewerContextType { isDualPage?: boolean, ) => void; triggerImmediatePanUpdate: (isPanning: boolean) => void; + triggerImmediateRotationUpdate: (rotation: number) => void; // Action handlers - call EmbedPDF APIs directly scrollActions: ScrollActions; @@ -310,6 +314,10 @@ export const ViewerProvider: React.FC = ({ children }) => { register: registerImmediatePanUpdate, trigger: triggerImmediatePanInternal, } = useImmediateNotifier<[boolean]>(); + const { + register: registerImmediateRotationUpdate, + trigger: triggerImmediateRotationInternal, + } = useImmediateNotifier<[number]>(); const triggerImmediateZoomUpdate = useCallback( (percent: number) => { @@ -339,6 +347,13 @@ export const ViewerProvider: React.FC = ({ children }) => { [triggerImmediatePanInternal], ); + const triggerImmediateRotationUpdate = useCallback( + (rotation: number) => { + triggerImmediateRotationInternal(rotation); + }, + [triggerImmediateRotationInternal], + ); + const registerBridge = useCallback( ( type: K, @@ -638,10 +653,12 @@ export const ViewerProvider: React.FC = ({ children }) => { registerImmediateScrollUpdate, registerImmediateSpreadUpdate, registerImmediatePanUpdate, + registerImmediateRotationUpdate, triggerImmediateScrollUpdate, triggerImmediateZoomUpdate, triggerImmediateSpreadUpdate, triggerImmediatePanUpdate, + triggerImmediateRotationUpdate, // Actions scrollActions, diff --git a/frontend/editor/src/core/hooks/useMeasurementManager.ts b/frontend/editor/src/core/hooks/useMeasurementManager.ts new file mode 100644 index 0000000000..f6f6b9d6b1 --- /dev/null +++ b/frontend/editor/src/core/hooks/useMeasurementManager.ts @@ -0,0 +1,293 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, +} from "react"; +import type { + Measurement, + MeasureScale, + PageMeasureScales, +} from "@app/utils/measurementTypes"; +import type { RulerOverlayHandle } from "@app/components/viewer/RulerOverlay"; +import { + loadSessionMap, + saveSessionMap, + validateMeasureScale, + validateMeasurement, +} from "@app/utils/measurementUtils"; +import type { StirlingFile } from "@app/types/fileContext"; +import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; +import { extractPageMeasureScales } from "@app/utils/pdfMeasurementExtraction"; +import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog"; + +// ─── Hook: useMeasurementManager ────────────────────────────────────────────── + +interface EffectiveFileLike { + file: Blob | File; + url: string | null; +} + +type ViewerFile = StirlingFile | File | null | undefined; + +interface UseMeasurementManagerProps { + currentFile: ViewerFile; + effectiveFile: EffectiveFileLike | null | undefined; + rulerOverlayRef: RefObject; +} + +interface UseMeasurementManagerReturn { + isRulerActive: boolean; + setIsRulerActive: (v: boolean) => void; + pageMeasureScales: PageMeasureScales | null; + customScale: MeasureScale | null; + handleSetCustomScale: (scale: MeasureScale | null) => void; + isScaleCalibrationActive: boolean; + scaleCalibrationMeasurement: ScaleCalibrationMeasurement | null; + startScaleCalibration: () => void; + cancelScaleCalibration: () => void; + handleScaleCalibrationMeasurement: ( + measurement: ScaleCalibrationMeasurement, + ) => void; + applyScaleCalibration: (scale: MeasureScale) => void; +} + +export function useMeasurementManager({ + currentFile, + effectiveFile, + rulerOverlayRef, +}: UseMeasurementManagerProps): UseMeasurementManagerReturn { + const [isRulerActive, setIsRulerActive] = useState(false); + const [pageMeasureScales, setPageMeasureScales] = + useState(null); + const [customScale, setCustomScale] = useState(null); + const [isScaleCalibrationActive, setIsScaleCalibrationActive] = + useState(false); + const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] = + useState(null); + const [scalesByFileId, setScalesByFileId] = useState< + Map + >(new Map()); + const [measurementsByFileId, setMeasurementsByFileId] = useState< + Map + >(new Map()); + + const restoredFileKeyRef = useRef(null); + + const getStableFileKey = useCallback((file: ViewerFile): string | null => { + if (!file) return null; + if (isStirlingFile(file)) { + return file.fileId; + } + return getFormFillFileId(file); + }, []); + + const currentFileKey = getStableFileKey(currentFile); + + function persistSessionValue( + storageKey: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, + label: string, + ) { + try { + saveSessionMap(storageKey, fileKey, value); + } catch (error) { + console.error(`[Measurement] Failed to persist ${label}:`, error); + } + } + + function readStoredScale(fileKey: string): MeasureScale | null | undefined { + const storedMap = loadSessionMap("stirling_scales"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + return validateMeasureScale(storedValue) ? storedValue : null; + } + + function readStoredMeasurements(fileKey: string): Measurement[] | undefined { + const storedMap = loadSessionMap("stirling_measurements"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + if (!Array.isArray(storedValue)) { + return []; + } + + return storedValue.filter((measurement) => + validateMeasurement(measurement), + ); + } + + function persistScale(fileKey: string, scale: MeasureScale | null) { + persistSessionValue("stirling_scales", fileKey, scale, "scale"); + } + + function persistMeasurements(fileKey: string, value: Measurement[]) { + persistSessionValue( + "stirling_measurements", + fileKey, + value, + "measurements", + ); + } + + const handleSetCustomScale = useCallback( + (scale: MeasureScale | null) => { + const fileKey = currentFileKey; + + if (fileKey) { + setScalesByFileId((prev) => new Map(prev).set(fileKey, scale)); + persistScale(fileKey, scale); + } + + setCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [currentFileKey], + ); + + const handleSetRulerActive = useCallback((active: boolean) => { + setIsRulerActive(active); + if (!active) { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + } + }, []); + + const startScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(true); + setIsRulerActive(true); + }, []); + + const cancelScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, []); + + const handleScaleCalibrationMeasurement = useCallback( + (measurement: ScaleCalibrationMeasurement) => { + setScaleCalibrationMeasurement(measurement); + setIsScaleCalibrationActive(false); + }, + [], + ); + + const applyScaleCalibration = useCallback( + (scale: MeasureScale) => { + handleSetCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [handleSetCustomScale], + ); + + useEffect(() => { + if (!currentFileKey) { + setPageMeasureScales(null); + setCustomScale(null); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + setIsRulerActive(false); + rulerOverlayRef.current?.clearAll(true); + restoredFileKeyRef.current = null; + return; + } + + if (restoredFileKeyRef.current === currentFileKey) { + return; + } + restoredFileKeyRef.current = currentFileKey; + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + + const storedScale = readStoredScale(currentFileKey); + const savedScale = + storedScale === undefined + ? (scalesByFileId.get(currentFileKey) ?? null) + : storedScale; + + setCustomScale(savedScale); + + const storedMeasurements = readStoredMeasurements(currentFileKey); + const savedMeasurements = + storedMeasurements === undefined + ? (measurementsByFileId.get(currentFileKey) ?? []) + : storedMeasurements; + + rulerOverlayRef.current?.clearAll(true); + rulerOverlayRef.current?.restoreMeasurements(savedMeasurements); + }, [currentFileKey, measurementsByFileId, rulerOverlayRef, scalesByFileId]); + + useEffect(() => { + const fileBlob = effectiveFile?.file; + if (!fileBlob || !currentFileKey) { + setPageMeasureScales(null); + return; + } + + setPageMeasureScales(null); + + let cancelled = false; + extractPageMeasureScales(fileBlob) + .then((scales) => { + if (!cancelled) { + setPageMeasureScales(scales); + } + }) + .catch((error) => { + if (!cancelled) { + console.warn("[Measurement] Failed to load PDF scales", error); + setPageMeasureScales(null); + } + }); + + return () => { + cancelled = true; + }; + }, [currentFileKey, effectiveFile?.file]); + + useEffect(() => { + if (!rulerOverlayRef.current || !currentFileKey) return; + + const unsubscribe = rulerOverlayRef.current.onMeasurementsChange( + (newMeasurements: Measurement[]) => { + const validMeasurements = newMeasurements.filter((measurement) => + validateMeasurement(measurement), + ); + + setMeasurementsByFileId((prev) => + new Map(prev).set(currentFileKey, validMeasurements), + ); + persistMeasurements(currentFileKey, validMeasurements); + }, + ); + + return () => { + if (typeof unsubscribe === "function") { + unsubscribe(); + } + }; + }, [currentFileKey, rulerOverlayRef]); + + return { + isRulerActive, + setIsRulerActive: handleSetRulerActive, + pageMeasureScales, + customScale, + handleSetCustomScale, + isScaleCalibrationActive, + scaleCalibrationMeasurement, + startScaleCalibration, + cancelScaleCalibration, + handleScaleCalibrationMeasurement, + applyScaleCalibration, + }; +} diff --git a/frontend/editor/src/core/utils/measurementPreferences.ts b/frontend/editor/src/core/utils/measurementPreferences.ts new file mode 100644 index 0000000000..c339ed64f8 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementPreferences.ts @@ -0,0 +1,24 @@ +// Persist calibration unit preference across sessions +const STORAGE_KEY_LAST_CALIBRATION_UNIT = "stirling_calibration_last_unit"; + +export function getLastCalibrationUnit(defaultUnit: string): string { + try { + const stored = localStorage.getItem(STORAGE_KEY_LAST_CALIBRATION_UNIT); + return stored && stored.trim() ? stored : defaultUnit; + } catch { + // Storage unavailable - private browsing or quota exceeded + return defaultUnit; + } +} + +export function setLastCalibrationUnit(unit: string): void { + try { + localStorage.setItem(STORAGE_KEY_LAST_CALIBRATION_UNIT, unit); + } catch (error) { + // Storage unavailable - preference won't be retained + console.debug( + "[MeasurementPreferences] Unable to persist unit preference:", + error, + ); + } +} diff --git a/frontend/editor/src/core/utils/measurementTypes.ts b/frontend/editor/src/core/utils/measurementTypes.ts new file mode 100644 index 0000000000..caa47ff876 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementTypes.ts @@ -0,0 +1,45 @@ +// Page coordinates with absolute page index +export interface PagePoint { + pageIndex: number; + x: number; + y: number; +} + +// Real-world units per PDF point (factor) vs. architectural ratio for display +export interface MeasureScale { + factor: number; // Real-world units per PDF point + ratio: number | null; // Architectural ratio (e.g., 100 for "1:100") - display only + unit: string; // m, cm, mm, km, ft, in, yd, mi +} + +export type MeasureScaleLike = MeasureScale; + +// Calibration result with full context for audit trail +export interface CalibrationMetadata { + pdfDistancePts: number; // PDF space distance in points + realDistance: number; // User-specified real-world distance + scale: MeasureScale; // Resulting calculated scale + timestamp: string; // ISO 8601 format + unitUsed: string; // Unit active during calibration +} + +// Single measurement between two page points on same page +export interface Measurement { + id: string; + start: PagePoint; + end: PagePoint; +} + +// Viewport area with its own scale (for multi-region PDFs) +export interface ViewportScale { + bbox: [number, number, number, number] | null; // PDF user space or null for entire page + scale: MeasureScale; +} + +// Scale information for a single page with all viewports +export interface PageScaleInfo { + viewports: ViewportScale[]; + pageHeight: number; // PDF points - used to flip screen-y to PDF-y +} + +export type PageMeasureScales = Map; diff --git a/frontend/editor/src/core/utils/measurementUtils.test.ts b/frontend/editor/src/core/utils/measurementUtils.test.ts new file mode 100644 index 0000000000..33cf3f5c2d --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { + POINT_TO_UNIT, + calculateCalibratedScale, + calculateScaleFactor, + convertUnit, + deriveRatioFromFactor, + parsePresetRatio, +} from "@app/utils/measurementUtils"; + +describe("measurementUtils", () => { + describe("calculateScaleFactor", () => { + test("calculates real-world units per PDF point from a scale ratio", () => { + expect(calculateScaleFactor(100, "m")).toBeCloseTo(POINT_TO_UNIT.m * 100); + expect(calculateScaleFactor(50, " cm ")).toBeCloseTo( + POINT_TO_UNIT.cm * 50, + ); + expect(calculateScaleFactor(12, "FT")).toBeCloseTo(POINT_TO_UNIT.ft * 12); + }); + + test("rejects invalid scale ratios", () => { + expect(() => calculateScaleFactor(0, "m")).toThrow("Invalid scale ratio"); + expect(() => calculateScaleFactor(-1, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.NaN, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.POSITIVE_INFINITY, "m")).toThrow( + "Invalid scale ratio", + ); + }); + + test("rejects unsupported units", () => { + expect(() => calculateScaleFactor(100, "px")).toThrow("Unsupported unit"); + }); + }); + + describe("convertUnit", () => { + test("converts representative metric and imperial values", () => { + expect(convertUnit(1, "m", "cm")).toBeCloseTo(100); + expect(convertUnit(12, "in", "ft")).toBeCloseTo(1); + expect(convertUnit(3, "ft", "yd")).toBeCloseTo(1); + expect(convertUnit(1, "ft", "m")).toBeCloseTo(0.3048); + }); + + test("returns null for invalid values or unsupported units", () => { + expect(convertUnit(Number.NaN, "m", "cm")).toBeNull(); + expect(convertUnit(Number.POSITIVE_INFINITY, "m", "cm")).toBeNull(); + expect(convertUnit(1, "px", "cm")).toBeNull(); + expect(convertUnit(1, "m", "px")).toBeNull(); + }); + }); + + describe("parsePresetRatio", () => { + test("parses supported preset ratios", () => { + expect(parsePresetRatio("1:5")).toBe(5); + expect(parsePresetRatio("1:100")).toBe(100); + expect(parsePresetRatio(" 1 : 150 ")).toBe(150); + }); + + test("returns null for malformed or non-positive presets", () => { + expect(parsePresetRatio("2:100")).toBeNull(); + expect(parsePresetRatio("1:0")).toBeNull(); + expect(parsePresetRatio("1:-10")).toBeNull(); + expect(parsePresetRatio("1:not-a-number")).toBeNull(); + expect(parsePresetRatio("bad")).toBeNull(); + expect(parsePresetRatio("1:10:20")).toBeNull(); + }); + }); + + describe("deriveRatioFromFactor", () => { + test("recovers the scale ratio from a factor and unit", () => { + const factor = calculateScaleFactor(100, "m"); + + expect(deriveRatioFromFactor(factor, "m")).toBeCloseTo(100); + }); + + test("returns null for invalid factors or unsupported units", () => { + expect(deriveRatioFromFactor(0, "m")).toBeNull(); + expect(deriveRatioFromFactor(-1, "m")).toBeNull(); + expect(deriveRatioFromFactor(Number.NaN, "m")).toBeNull(); + expect(deriveRatioFromFactor(1, "px")).toBeNull(); + }); + }); + + describe("calculateCalibratedScale", () => { + test("calculates a calibrated scale from a known physical distance", () => { + const scale = calculateCalibratedScale(72, 1, "in"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.in); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("in"); + }); + + test("calculates architectural ratios for metric calibration", () => { + const scale = calculateCalibratedScale(72, 0.0254, "m"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.m); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("m"); + }); + + test("rejects invalid calibration inputs", () => { + expect(() => calculateCalibratedScale(0, 1, "m")).toThrow( + "Invalid PDF distance", + ); + expect(() => calculateCalibratedScale(72, 0, "m")).toThrow( + "Invalid real-world distance", + ); + expect(() => calculateCalibratedScale(72, 1, "px")).toThrow( + "Unsupported unit", + ); + }); + }); +}); diff --git a/frontend/editor/src/core/utils/measurementUtils.ts b/frontend/editor/src/core/utils/measurementUtils.ts new file mode 100644 index 0000000000..e35645fb2b --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.ts @@ -0,0 +1,398 @@ +// PDF point to real-world unit conversions + +import type { + Measurement, + MeasureScale, + PagePoint, + CalibrationMetadata, +} from "@app/utils/measurementTypes"; + +// 1 PDF point in meters (1/72 inch) +const POINT_TO_METERS = 0.0254 / 72; + +// Conversion factors: units per PDF point +export const POINT_TO_UNIT = { + m: POINT_TO_METERS, + cm: POINT_TO_METERS * 100, + mm: POINT_TO_METERS * 1000, + km: POINT_TO_METERS / 1000, + ft: POINT_TO_METERS / 0.3048, + in: POINT_TO_METERS / 0.0254, + yd: POINT_TO_METERS / 0.9144, + mi: POINT_TO_METERS / 1609.344, +} as const; + +// Valid measurement units from POINT_TO_UNIT +export type MeasurementUnit = keyof typeof POINT_TO_UNIT; + +function normalizeUnit(unit: string): string { + return unit.toLowerCase().trim(); +} + +function isMeasurementUnit(unit: string): unit is MeasurementUnit { + return Object.hasOwn(POINT_TO_UNIT, unit); +} + +export function getUnitFactor(unit: string): number | undefined { + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + return undefined; + } + return POINT_TO_UNIT[normalized]; +} + +export function calculateScaleFactor(ratio: number, unit: string): number { + if (!Number.isFinite(ratio) || ratio <= 0) { + throw new Error(`Invalid scale ratio: ${ratio}`); + } + + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + throw new Error(`Unsupported unit: ${unit}`); + } + + return POINT_TO_UNIT[normalized] * ratio; +} + +export function generateScaleLabel(ratio: number | null, unit: string): string { + if (ratio === null || ratio === undefined) { + return unit; + } + const display = Number.isInteger(ratio) + ? ratio.toString() + : ratio.toFixed(2).replace(/\.?0+$/, ""); + return `1:${display} (${unit})`; +} + +// Imperial units +const IMPERIAL_UNITS = ["ft", "in", "yd", "mi"] as const; +export function isImperialUnit(unit: string): boolean { + const normalized = normalizeUnit(unit); + return isMeasurementUnit(normalized) + ? (IMPERIAL_UNITS as readonly MeasurementUnit[]).includes(normalized) + : false; +} + +export function convertUnit( + value: number, + sourceUnit: string, + targetUnit: string, +): number | null { + if (!Number.isFinite(value)) { + return null; + } + + const src = normalizeUnit(sourceUnit); + const tgt = normalizeUnit(targetUnit); + + if (!isMeasurementUnit(src) || !isMeasurementUnit(tgt)) { + return null; + } + + const sourceFactor = POINT_TO_UNIT[src]; + const targetFactor = POINT_TO_UNIT[tgt]; + + return value * (targetFactor / sourceFactor); +} + +export function parsePresetRatio(preset: string): number | null { + const parts = preset.split(":"); + + // Must have exactly 2 parts and first part must be "1" + if (parts.length !== 2 || parts[0].trim() !== "1") { + return null; + } + + const value = Number(parts[1]); + return Number.isFinite(value) && value > 0 ? value : null; +} + +// UI dropdown options - shared across components +export const UNIT_OPTIONS = [ + { value: "m", label: "Meters (m)" }, + { value: "cm", label: "Centimeters (cm)" }, + { value: "mm", label: "Millimeters (mm)" }, + { value: "km", label: "Kilometers (km)" }, + { value: "ft", label: "Feet (ft)" }, + { value: "in", label: "Inches (in)" }, + { value: "yd", label: "Yards (yd)" }, + { value: "mi", label: "Miles (mi)" }, +] as const; + +const MAX_SESSION_ENTRIES = 50; +const TRIMMED_SESSION_ENTRIES = 40; + +/** + * Detect quota exceeded errors across browser implementations. + * Handles: name "QuotaExceededError", code 22 (legacy), "NS_ERROR_DOM_QUOTA_REACHED" + * + * Note: DOMException may not be instanceof Error in all browsers, + * so we check by shape and properties rather than type. + * Note: DOMException.code is deprecated but kept for legacy browser support. + */ +function isQuotaExceededError(error: unknown): boolean { + if (error === null || error === undefined) return false; + + // Check if it's a DOMException when available (standard) + if (typeof DOMException !== "undefined" && error instanceof DOMException) { + if (error.name === "QuotaExceededError") return true; + } + + // Fallback: check by shape for any object with name/code properties + if (typeof error === "object") { + const err = error as Record; + + // Modern standard: check name property (works in all modern browsers) + if (err.name === "QuotaExceededError") return true; + if (err.name === "NS_ERROR_DOM_QUOTA_REACHED") return true; + + // Legacy support: check deprecated code property for very old browsers + // Use Object.hasOwn for safe own-property check + if (Object.hasOwn(err, "code") && err.code === 22) return true; + } + + return false; +} + +// Load entries from sessionStorage +export function loadSessionMap(key: string): Record { + try { + const raw = sessionStorage.getItem(key); + if (!raw) return {}; + + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return {}; + } + + return data as Record; + } catch { + // Silently return empty object on parse error + try { + sessionStorage.removeItem(key); + } catch { + // Ignore cleanup errors + } + return {}; + } +} + +// Save entry to sessionStorage with quota management. +export function saveSessionMap( + key: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, +): void { + if (!fileKey) return; + + try { + const existing: Record = { + ...loadSessionMap(key), + }; + + // Delete first to move fileKey to end (maintains insertion order recency) + delete existing[fileKey]; + existing[fileKey] = value; + + // Trim back below the max to avoid pruning again on every subsequent save. + const keys = Object.keys(existing); + if (keys.length > MAX_SESSION_ENTRIES) { + const entriesToDelete = keys.slice( + 0, + keys.length - TRIMMED_SESSION_ENTRIES, + ); + entriesToDelete.forEach((k) => delete existing[k]); + } + + sessionStorage.setItem(key, JSON.stringify(existing)); + } catch (e) { + // Quota exceeded - try clearing and retrying (handles cross-browser error variants) + if (isQuotaExceededError(e)) { + try { + sessionStorage.removeItem(key); + // Retry with fresh storage + const fresh: Record = { [fileKey]: value }; + sessionStorage.setItem(key, JSON.stringify(fresh)); + } catch { + // Silently ignore if retry fails - data loss is acceptable + } + } + // Silently ignore other storage errors + } +} + +// Validation helpers + +export function validatePagePoint(obj: unknown): obj is PagePoint { + if (typeof obj !== "object" || obj === null) return false; + + const pt = obj as Record; + return ( + typeof pt.pageIndex === "number" && + Number.isFinite(pt.pageIndex) && + pt.pageIndex >= 0 && + typeof pt.x === "number" && + Number.isFinite(pt.x) && + typeof pt.y === "number" && + Number.isFinite(pt.y) + ); +} + +// MeasureScale can be null (reset) or valid object +export function validateMeasureScale(obj: unknown): obj is MeasureScale | null { + // null is allowed (reset to default) + if (obj === null) return true; + + if (typeof obj !== "object") return false; + + const s = obj as Record; + + // Validate factor: must be positive finite number + if ( + typeof s.factor !== "number" || + !Number.isFinite(s.factor) || + s.factor <= 0 + ) { + return false; + } + + // Validate ratio: optional, but if present must be positive finite number + if ( + s.ratio !== null && + (typeof s.ratio !== "number" || !Number.isFinite(s.ratio) || s.ratio <= 0) + ) { + return false; + } + + // Validate unit: must be non-empty string and exist in POINT_TO_UNIT + if (typeof s.unit !== "string" || s.unit.trim().length === 0) { + return false; + } + + const normalized = normalizeUnit(s.unit); + if (!isMeasurementUnit(normalized)) { + return false; + } + + return true; +} + +// Reject cross-page measurements +export function validateMeasurement(obj: unknown): obj is Measurement { + if (typeof obj !== "object" || obj === null) return false; + + const m = obj as Record; + + // Validate structure + if ( + !( + typeof m.id === "string" && + m.id.trim().length > 0 && + validatePagePoint(m.start) && + validatePagePoint(m.end) + ) + ) { + return false; + } + + // Reject cross-page measurements + const start = m.start as PagePoint; + const end = m.end as PagePoint; + if (start.pageIndex !== end.pageIndex) { + return false; + } + + return true; +} + +export function formatPaperDistance(distancePts: number): string { + if (!Number.isFinite(distancePts) || distancePts < 0) { + return "0 mm"; + } + + const inches = distancePts / 72; + const mm = inches * 25.4; + + if (mm < 100) { + return `${mm.toFixed(1)} mm`; + } + if (mm < 1000) { + return `${(mm / 10).toFixed(1)} cm`; + } + return `${(mm / 1000).toFixed(2)} m`; +} + +export function validateRealDistance(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const num = typeof value === "number" ? value : Number(value); + + if (!Number.isFinite(num) || num <= 0) { + return null; + } + + return num; +} + +export function deriveRatioFromFactor( + factor: number, + unit: string, +): number | null { + if (!Number.isFinite(factor) || factor <= 0) { + return null; + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + return null; + } + + // ratio = factor / baseFactor + const ratio = factor / baseFactor; + return Number.isFinite(ratio) && ratio > 0 ? ratio : null; +} + +export function calculateCalibratedScale( + pdfDistancePts: number, + realDistance: number, + unit: string, +): MeasureScale { + if (!Number.isFinite(pdfDistancePts) || pdfDistancePts <= 0) { + throw new Error("Invalid PDF distance (must be positive)"); + } + + if (!Number.isFinite(realDistance) || realDistance <= 0) { + throw new Error("Invalid real-world distance (must be positive)"); + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + throw new Error(`Unsupported unit: ${unit}`); + } + + const factor = realDistance / pdfDistancePts; + const ratio = deriveRatioFromFactor(factor, unit); + + return { + factor, + ratio, + unit, + }; +} + +export function createCalibrationMetadata( + pdfDistancePts: number, + realDistance: number, + scale: MeasureScale, + unitUsed: string, +): CalibrationMetadata { + return { + pdfDistancePts, + realDistance, + scale, + timestamp: new Date().toISOString(), + unitUsed, + }; +} diff --git a/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts new file mode 100644 index 0000000000..3c40e3209f --- /dev/null +++ b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts @@ -0,0 +1,215 @@ +import type { + PDFArray, + PDFDict, + PDFHexString, + PDFName, + PDFNumber, + PDFString, +} from "@cantoo/pdf-lib"; +import type { + MeasureScale, + PageMeasureScales, + PageScaleInfo, + ViewportScale, +} from "@app/utils/measurementTypes"; +import { getUnitFactor } from "@app/utils/measurementUtils"; + +type PdfMeasurementObjects = Pick< + typeof import("@cantoo/pdf-lib"), + | "PDFArray" + | "PDFDict" + | "PDFHexString" + | "PDFName" + | "PDFNumber" + | "PDFString" +>; + +function asPdfArray( + value: unknown, + { PDFArray }: PdfMeasurementObjects, +): PDFArray | null { + return value instanceof PDFArray ? value : null; +} + +function asPdfDict( + value: unknown, + { PDFDict }: PdfMeasurementObjects, +): PDFDict | null { + return value instanceof PDFDict ? value : null; +} + +function asPdfNumber( + value: unknown, + { PDFNumber }: PdfMeasurementObjects, +): PDFNumber | null { + return value instanceof PDFNumber ? value : null; +} + +function asPdfText( + value: unknown, + { PDFHexString, PDFName, PDFString }: PdfMeasurementObjects, +): PDFHexString | PDFName | PDFString | null { + if ( + value instanceof PDFString || + value instanceof PDFHexString || + value instanceof PDFName + ) { + return value; + } + return null; +} + +function lookupArray( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFArray | null { + return asPdfArray(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupDict( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFDict | null { + return asPdfDict(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupNumber( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): number | null { + return ( + asPdfNumber( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.asNumber() ?? null + ); +} + +function lookupText( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): string | null { + return ( + asPdfText( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.decodeText() ?? null + ); +} + +function readArrayNumber( + array: PDFArray, + index: number, + pdfObjects: PdfMeasurementObjects, +): number | null { + return asPdfNumber(array.lookup(index), pdfObjects)?.asNumber() ?? null; +} + +function readBBox( + bboxArray: PDFArray | null, + pdfObjects: PdfMeasurementObjects, +): ViewportScale["bbox"] { + if (!bboxArray || bboxArray.size() < 4) { + return null; + } + + const x0 = readArrayNumber(bboxArray, 0, pdfObjects); + const y0 = readArrayNumber(bboxArray, 1, pdfObjects); + const x1 = readArrayNumber(bboxArray, 2, pdfObjects); + const y1 = readArrayNumber(bboxArray, 3, pdfObjects); + + if (x0 === null || y0 === null || x1 === null || y1 === null) { + return null; + } + + return [x0, y0, x1, y1]; +} + +function parseScale( + measureDict: PDFDict | null, + pdfObjects: PdfMeasurementObjects, +): MeasureScale | null { + if (!measureDict) return null; + + const fmtArray = + lookupArray(measureDict, "D", pdfObjects) ?? + lookupArray(measureDict, "X", pdfObjects); + if (!fmtArray || fmtArray.size() === 0) return null; + + const firstFmt = asPdfDict(fmtArray.lookup(0), pdfObjects); + if (!firstFmt) return null; + + const factor = lookupNumber(firstFmt, "C", pdfObjects); + if (factor === null || factor <= 0) return null; + + const unit = lookupText(firstFmt, "U", pdfObjects)?.trim().toLowerCase(); + if (!unit) return null; + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) return null; + + const ratio = factor / baseFactor; + return { factor, ratio, unit }; +} + +export async function extractPageMeasureScales( + file: Blob, +): Promise { + try { + const pdfLib = await import("@cantoo/pdf-lib"); + const { PDFDocument, PDFArray, PDFDict, PDFName } = pdfLib; + const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { + ignoreEncryption: true, + }); + + const result: PageMeasureScales = new Map(); + + for (let i = 0; i < pdfDoc.getPageCount(); i++) { + const page = pdfDoc.getPage(i); + const pageHeight = page.getHeight(); + const viewports: ViewportScale[] = []; + + const vpObj = page.node.lookup(PDFName.of("VP")); + if (vpObj instanceof PDFArray) { + for (let j = 0; j < vpObj.size(); j++) { + const vpEntry = vpObj.lookup(j); + if (!(vpEntry instanceof PDFDict)) continue; + + const scale = parseScale( + lookupDict(vpEntry, "Measure", pdfLib), + pdfLib, + ); + if (!scale) continue; + + viewports.push({ + bbox: readBBox(lookupArray(vpEntry, "BBox", pdfLib), pdfLib), + scale, + }); + } + } + + if (viewports.length === 0) { + const scale = parseScale( + lookupDict(page.node, "Measure", pdfLib), + pdfLib, + ); + if (scale) { + viewports.push({ bbox: null, scale }); + } + } + + if (viewports.length > 0) { + result.set(i, { viewports, pageHeight } satisfies PageScaleInfo); + } + } + + return result.size > 0 ? result : null; + } catch (error) { + console.warn("[Measurement] Failed to extract PDF scales", error); + return null; + } +} From 82e1bd62a2ad166312656b657f35978dbe59970a Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:27:23 +0100 Subject: [PATCH 111/262] Move CODEOWNERS to review teams (#7325) # Description of Changes CODEOWNERS now points at review teams (`maintainers`, `backend-reviewers`, `frontend-reviewers`, `devops-reviewers`, `all`) instead of individual usernames, so membership is managed in the org rather than in this file. Ludy87 and balazs-szucs stay listed by hand since outside collaborators cannot be team members. including the deploy and demo-comment allowlists. --- ## Checklist ### General - [x] 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) - [x] I have performed a self-review of my own code - [x] 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 - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/CODEOWNERS | 36 ++++++++++++------- .github/config/repo_devs.json | 1 - .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .../workflows/PR-Demo-Comment-with-react.yml | 1 - 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 14f6ca750f..a2e3241c65 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,18 +1,28 @@ -# All PRs must be approved by Frooodle or Ludy87 -* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh +# Review ownership is assigned to teams where possible. +# Teams can only contain org members, so outside collaborators are listed by hand. +# +# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01 +# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/all - all of the above +# +# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs + +# Default owners for everything +* @Stirling-Tools/maintainers @Ludy87 # Backend -/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs +/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs -#V2 frontend -/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs -/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs +# V2 frontend +/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs +/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs -#V2 docker -/docker/backend/** @Frooodle @Ludy87 @DarioGii -/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 -/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 +# V2 docker +/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87 +/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87 +/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87 - -#GHA (All users) -/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs +# GHA (all users) +/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs diff --git a/.github/config/repo_devs.json b/.github/config/repo_devs.json index 8b0bb97a81..597a84dade 100644 --- a/.github/config/repo_devs.json +++ b/.github/config/repo_devs.json @@ -11,7 +11,6 @@ "LaserKaspar", "sbplat", "reecebrowne", - "DarioGii", "ConnorYoh", "EthanHealy01", "jbrunton96", diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6b37029f90..cd99f6a4cc 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -86,7 +86,7 @@ jobs: fi fi else - auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") + auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done if [ "$is_auth" = true ]; then should=true diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 3826897a39..d1e2000b82 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -54,7 +54,6 @@ jobs: github.event.comment.user.login == 'Ludy87' || github.event.comment.user.login == 'balazs-szucs' || github.event.comment.user.login == 'reecebrowne' || - github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'jbrunton96' || github.event.comment.user.login == 'ConnorYoh' From 2c74b5bf81be0853e0e8503402834e21b7508a42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:01:07 +0100 Subject: [PATCH 112/262] build(deps): bump js-yaml from 4.2.0 to 4.3.1 in /devTools (#7323) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1.
    Changelog

    Sourced from js-yaml's changelog.

    4.3.1 - 2026-07-31

    Security

    • [backport] Remove quadratic complexity from !!omap duplicate key detection.

    4.3.0 - 2026-06-27

    Added

    • [backport] Added maxTotalMergeKeys (10000) loader option to limit the total number of keys processed by YAML merge (<<) across one load() / loadAll() call.

    Fixed

    • Restore umd builds back to es5.

    Removed

    • [backport] maxMergeSeqLength replaced with maxTotalMergeKeys for limiting YAML merge processing.
    Commits
    • 86e91b8 4.3.1 released
    • c3cc4b0 Backport quadratic complexity fix for !!omap
    • 33d05b5 4.3.0 released
    • 663bfab Drop demo publish, to not override new v5 one.
    • 1cb8c7b Add v4-legacy tag for publish
    • 02f27af Restore umd builds back to es5
    • 8be84ed Fix es5 compatibility
    • 59423c6 Replace maxMergeSeqLength option with maxTotalMergeKeys (more robust). Ba...
    • 6842ef6 doc polish
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.2.0&new-version=4.3.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- devTools/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devTools/package-lock.json b/devTools/package-lock.json index 39db3d2046..394237a684 100644 --- a/devTools/package-lock.json +++ b/devTools/package-lock.json @@ -894,9 +894,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { From 1867c8f285e92adf7fa7dc3f50c41b05cbd572e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:01:11 +0100 Subject: [PATCH 113/262] build(deps): bump js-yaml from 4.2.0 to 4.3.1 in /frontend (#7322) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1.
    Changelog

    Sourced from js-yaml's changelog.

    4.3.1 - 2026-07-31

    Security

    • [backport] Remove quadratic complexity from !!omap duplicate key detection.

    4.3.0 - 2026-06-27

    Added

    • [backport] Added maxTotalMergeKeys (10000) loader option to limit the total number of keys processed by YAML merge (<<) across one load() / loadAll() call.

    Fixed

    • Restore umd builds back to es5.

    Removed

    • [backport] maxMergeSeqLength replaced with maxTotalMergeKeys for limiting YAML merge processing.
    Commits
    • 86e91b8 4.3.1 released
    • c3cc4b0 Backport quadratic complexity fix for !!omap
    • 33d05b5 4.3.0 released
    • 663bfab Drop demo publish, to not override new v5 one.
    • 1cb8c7b Add v4-legacy tag for publish
    • 02f27af Restore umd builds back to es5
    • 8be84ed Fix es5 compatibility
    • 59423c6 Replace maxMergeSeqLength option with maxTotalMergeKeys (more robust). Ba...
    • 6842ef6 doc polish
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.2.0&new-version=4.3.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 500 +------------------------------------ 1 file changed, 3 insertions(+), 497 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6f19db35b7..762cee13c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11150,9 +11150,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -12968,16 +12968,6 @@ "node": ">=10" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -17408,490 +17398,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/tsx/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", From cff6549a40340b01e8be7211af5be3ab34ee3ba6 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:36:46 +0100 Subject: [PATCH 114/262] fix(frontend): keep file persistence working when IndexedDB refuses blobs (#7314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Fixes the WebKit nightly failures ([run 31067620195](https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/31067620195/attempts/1)): 8 tests failed on `stubbed-webkit` only, and every one of them logs the same thing in its trace: ``` IndexedDB add error: UnknownError: Error preparing Blob/File data to be stored in object store ``` ## What broke `storeStirlingFile` stores the `File` itself in IndexedDB, so multi-GB uploads are persisted by reference and never materialize in JS memory. That came in with #7175 (`data: stirlingFile` replacing `data: await stirlingFile.arrayBuffer()`), which is a real memory win and worth keeping. WebKit refuses blob values whenever it can't write the blob's backing file, and rejects the request with the error above. The rejection was only `console.error`d, so on WebKit **no upload ever persisted**, and everything that reads the bytes back behaved as if the upload never happened: - `file-state-across-tools` — file gone after navigating; the sidebar shows "No files yet" - `compare` — `FileSelectorPicker: upload failed`, so the slot stays `data-slot-state="empty"` - `classification-grouping` / `classification-heuristic-upload` — the label backfill and thumbnails read from IDB (`not in IndexedDB (likely remote-only stub)`), so files land in "Recent" with no category headers Chromium and Firefox store blobs fine, and PR CI only runs the `stubbed` (chromium) project, so nightly was the only gate that could catch it. ## The fix Try the blob first, keep a fallback: - `storeStirlingFile`'s `add` is extracted into `addFileRecord` so it can run twice - if the value was a Blob and the failure is `UnknownError` / `DataCloneError`, re-add the record with an `ArrayBuffer` copy and set `blobValuesSupported = false`, so later files in that session go straight to the copy path instead of losing the blob attempt every time - deliberately narrow: `QuotaExceededError` and `ConstraintError` still propagate, because a copy would fail the same way and retrying would hide the real cause - dropped two internal `console.error`s: every caller already reports (`addFiles`, `FileSelectorPicker`, `zipFileService` collects into `result.errors`), so they were duplicate noise Every writer goes through `storeStirlingFile` (uploads, the file picker, zip extraction, folder automation, `IndexedDBContext`), so this one seam covers all of them. The read paths already accept either shape (`new Blob([record.data], ...)`). Net effect: Chromium and Firefox keep the no-copy path; engines that refuse blobs degrade to the pre-#7175 behaviour instead of silently losing files. On such an engine a very large file can still exhaust renderer memory — the fallback warns about exactly that. Fixing that properly means chunked storage, which is out of scope here. ## Verification Reproduced and confirmed the cause by A/B on a branch that predates #7175: as-is 8/8 pass on WebKit, and applying only #7175's `data: stirlingFile` line reproduces the exact CI failure set. | Check | Result | |---|---| | `stubbed-webkit`: the 8 nightly failures + `classification-heuristic-upload` | 9 passed | | `stubbed-webkit`: `files-page`, `page-editor-rotation`, `encrypted-pdf-unlock` | 32 passed, 1 skipped | | `stubbed` (chromium): the same specs + `files-page` | 35 passed, 1 skipped | | Frontend unit suite | 210 files, 1797 passed | | `typecheck:core`, `typecheck:proprietary`, eslint, prettier | clean | New unit coverage in `fileStorage.blobFallback.test.ts` pins the contract over `fake-indexeddb` with `add` instrumented to count blob vs copy attempts: blob path when accepted, blob-then-copy when refused (and readable back), one attempt only for later files, and quota not retried. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Testing (if applicable) - [x] Frontend typecheck (core + proprietary), eslint, prettier, the unit suite, and the affected Playwright specs on chromium and webkit all pass Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../services/fileStorage.blobFallback.test.ts | 146 ++++++++++++++++++ .../editor/src/core/services/fileStorage.ts | 57 +++++-- 2 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/core/services/fileStorage.blobFallback.test.ts diff --git a/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts new file mode 100644 index 0000000000..d8530af026 --- /dev/null +++ b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test, afterEach, beforeEach, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { expectConsole } from "@app/tests/failOnConsole"; + +/** + * Regression test for the WebKit nightly breakage introduced with the + * large-file OOM fix (#7175): `storeStirlingFile` began putting the `File` + * itself into IndexedDB (persisted by reference, so multi-GB uploads never + * materialize in JS memory). WebKit refuses blob values whenever it can't write + * the blob's backing file and rejects the request with `UnknownError: Error + * preparing Blob/File data to be stored in object store`, so on WebKit every + * upload silently failed to persist: files vanished on navigation, Compare + * slots never filled, and the classification backfill had no bytes to read. + * + * The service now retries such a rejection with an ArrayBuffer copy and stops + * offering blobs for the rest of the session. + */ + +const nativeAdd = IDBObjectStore.prototype.add; + +/** What each `add` attempt carried in `data` — the blob path or the copy path. */ +let attempts: Array<"blob" | "copy"> = []; + +/** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */ +class FailingRequest extends EventTarget { + onerror: ((event: Event) => void) | null = null; + onsuccess: ((event: Event) => void) | null = null; + + constructor(readonly error: DOMException) { + super(); + queueMicrotask(() => this.onerror?.(new Event("error"))); + } +} + +/** + * Record every add attempt, optionally failing the blob-valued ones the way an + * engine without blob storage does. + */ +function instrumentAdd(options: { rejectBlobs: boolean }) { + IDBObjectStore.prototype.add = function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey, + ) { + const isBlob = (value as { data?: unknown } | null)?.data instanceof Blob; + attempts.push(isBlob ? "blob" : "copy"); + if (isBlob && options.rejectBlobs) { + return new FailingRequest( + new DOMException( + "Error preparing Blob/File data to be stored in object store", + "UnknownError", + ), + ) as unknown as IDBRequest; + } + return key === undefined + ? nativeAdd.call(this, value) + : nativeAdd.call(this, value, key); + } as typeof IDBObjectStore.prototype.add; +} + +/** + * A fresh service per test: whether the engine accepts blobs is remembered for + * the process lifetime by design, so tests must not inherit that decision from + * each other. + */ +async function freshFileStorage() { + vi.resetModules(); + const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] = + await Promise.all([ + import("@app/services/fileStorage"), + import("@app/types/fileContext"), + ]); + const store = async (name: string) => { + const file = new File(["%PDF-1.7 stirling"], name, { + type: "application/pdf", + }); + const stub = createNewStirlingFileStub(file); + await fileStorage.storeStirlingFile( + createStirlingFile(file, stub.id), + stub, + ); + return stub.id; + }; + return { fileStorage, store }; +} + +beforeEach(() => { + attempts = []; +}); + +afterEach(() => { + IDBObjectStore.prototype.add = nativeAdd; +}); + +describe("storeStirlingFile — blob-value fallback", () => { + test("stores the File by reference when the engine accepts blob values", async () => { + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + + const id = await store("by-reference.pdf"); + + expect(attempts).toEqual(["blob"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe( + "by-reference.pdf", + ); + }); + + test("falls back to a copy when the engine rejects blob values, and the file stays readable", async () => { + expectConsole.warn(/IndexedDB rejected a Blob value/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: true }); + + const id = await store("webkit.pdf"); + + expect(attempts).toEqual(["blob", "copy"]); + // Readable back is what every downstream consumer depends on: rehydration + // after navigation, thumbnails, the classification backfill. + expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf"); + }); + + test("remembers the rejection, so later files skip the doomed blob attempt", async () => { + expectConsole.warn(/IndexedDB rejected a Blob value/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: true }); + + await store("first.pdf"); + attempts = []; + const id = await store("second.pdf"); + + // Straight to the copy path — no repeated blob probe, and only the single + // warning expected above. + expect(attempts).toEqual(["copy"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf"); + }); + + test("does not retry a failure a copy can't fix (quota)", async () => { + const { store } = await freshFileStorage(); + IDBObjectStore.prototype.add = function (this: IDBObjectStore) { + attempts.push("blob"); + throw new DOMException("no space left", "QuotaExceededError"); + } as typeof IDBObjectStore.prototype.add; + + await expect(store("too-big.pdf")).rejects.toThrow(/no space left/); + expect(attempts).toEqual(["blob"]); + }); +}); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 4f82af3a8f..40f62642f0 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -63,9 +63,27 @@ export function legacyDerivedFromTool( return undefined; } +/** + * Can't persist a Blob/File value, so a copy would work? WebKit reports + * `UnknownError` ("Error preparing Blob/File data...") when it can't write the + * blob's backing file; a refused structured clone is `DataCloneError`. + * Narrow on purpose: retrying quota or duplicate-key failures would fail again + * and hide the real cause. + */ +function isBlobValueRejection(error: unknown): boolean { + const name = (error as DOMException | null)?.name; + return name === "UnknownError" || name === "DataCloneError"; +} + class FileStorageService { private readonly dbConfig = DATABASE_CONFIGS.FILES; private readonly storeName = "files"; + /** + * Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the + * blob path avoids copying multi-GB files into JS memory, so we try it and + * remember the answer, rather than pre-emptively degrading everywhere. + */ + private blobValuesSupported = true; /** * Get database connection using centralized manager @@ -132,7 +150,10 @@ class FileStorageService { createdAt: stub.createdAt, // Store the File (a Blob) itself: IndexedDB persists it by reference and // streams to disk, so multi-GB files never materialize in JS memory. - data: stirlingFile, + // Engines that reject blob values fall back to a copy — see addFileRecord. + data: this.blobValuesSupported + ? stirlingFile + : await stirlingFile.arrayBuffer(), thumbnail: stub.thumbnailUrl, thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined, isLeaf: stub.isLeaf ?? true, @@ -160,6 +181,30 @@ class FileStorageService { classificationLabels: stub.classificationLabels, }; + try { + await this.addFileRecord(db, record); + } catch (error) { + // Recoverable: re-add as a copy, and stop offering blobs this session. + // Anything else is the caller's to report. + if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) { + throw error; + } + this.blobValuesSupported = false; + console.warn( + "IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " + + "Very large files may now exhaust renderer memory.", + error, + ); + record.data = await record.data.arrayBuffer(); + await this.addFileRecord(db, record); + } + } + + /** Single `add` of a file record. Rejects with the underlying IDB error. */ + private addFileRecord( + db: IDBDatabase, + record: StoredStirlingFileRecord, + ): Promise { return new Promise((resolve, reject) => { try { // Verify store exists before creating transaction @@ -174,15 +219,9 @@ class FileStorageService { const request = store.add(record); - request.onerror = () => { - console.error("IndexedDB add error:", request.error); - reject(request.error); - }; - request.onsuccess = () => { - resolve(); - }; + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); } catch (error) { - console.error("Transaction error:", error); reject(error); } }); From 408f9ef1488cdd6a8f70e8dec122ad2e92f9318e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 7 Aug 2026 14:49:19 +0100 Subject: [PATCH 115/262] Fix `any` type usages in frontend code (#7326) # Description of Changes Continued effort towards removing all uses of the `any` type in our frontend code. This PR fixes 10 more folders and removes them from the exclude list. All of them were really simple fixes. --- .../components/pageEditor/commands/pageCommands.ts | 1 - .../components/pageEditor/hooks/useEditorCommands.ts | 9 ++++----- .../pageEditor/hooks/useUndoManagerState.ts | 7 +++++-- .../components/shared/config/SettingsSearchBar.tsx | 2 +- .../shared/pageEditor/useFileItemDragDrop.ts | 8 +++++--- .../bookletImposition/BookletImpositionSettings.tsx | 11 +++++++---- .../components/tools/shared/ToolWorkflowTitle.tsx | 3 ++- .../components/tools/shared/renderToolButtons.tsx | 10 ++++++++-- .../core/hooks/signing/useSigningSessionController.ts | 5 +++-- .../adjustContrast/useAdjustContrastOperation.ts | 5 +++-- .../core/hooks/tools/convert/useConvertOperation.ts | 4 ++-- .../removePassword/useRemovePasswordOperation.test.ts | 2 +- frontend/eslint.config.mjs | 10 ---------- 13 files changed, 41 insertions(+), 36 deletions(-) diff --git a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts index 1f80b940cd..db9385fc37 100644 --- a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts @@ -686,7 +686,6 @@ export class InsertFilesCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; private fileDataMap = new Map(); // Store file data for thumbnail generation - private originalProcessedFile: any = null; // Store original ProcessedFile for undo private insertedFileMap = new Map(); // Store inserted files for export constructor( diff --git a/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts b/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts index a790e94085..aca8390d64 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { BulkRotateCommand, DeletePagesCommand, + DOMCommand, PageBreakCommand, ReorderPagesCommand, SplitCommand, @@ -24,7 +25,7 @@ interface UsePageEditorCommandsParams { selectedPageIds: string[]; setSelectedPageIds: (ids: string[]) => void; getPageNumbersFromIds: (pageIds: string[]) => number[]; - executeCommandWithTracking: (command: any) => void; + executeCommandWithTracking: (command: DOMCommand) => void; updateFileOrderFromPages: (pages: PDFPage[]) => void; actions: FileActions; selectors: FileSelectors; @@ -145,10 +146,8 @@ export const usePageEditorCommands = ({ [executeCommandWithTracking, setSplitPositions], ); - const executeCommand = useCallback((command: any) => { - if (command && typeof command.execute === "function") { - command.execute(); - } + const executeCommand = useCallback((command: { execute: () => void }) => { + command.execute(); }, []); const handleRotate = useCallback( diff --git a/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts b/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts index 86fdf347d2..0e11aa1e80 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { UndoManager } from "@app/components/pageEditor/commands/pageCommands"; +import { + DOMCommand, + UndoManager, +} from "@app/components/pageEditor/commands/pageCommands"; interface UseUndoManagerStateParams { setHasUnsavedChanges: (dirty: boolean) => void; @@ -29,7 +32,7 @@ export const useUndoManagerState = ({ }, [updateUndoRedoState]); const executeCommandWithTracking = useCallback( - (command: any) => { + (command: DOMCommand) => { undoManagerRef.current.executeCommand(command); setHasUnsavedChanges(true); }, diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx index ea1819dfb2..397347dcf6 100644 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx @@ -138,7 +138,7 @@ export const SettingsSearchBar: React.FC = ({ const translationPrefixes = getTranslationPrefixesForNavKey(item.key); const translationContent = translationPrefixes.flatMap((prefix) => flattenTranslationStrings( - t(prefix, { returnObjects: true, defaultValue: {} } as any), + t(prefix, { returnObjects: true, defaultValue: {} }), ), ); diff --git a/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts b/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts index b51b90737a..c1f5d9aaf3 100644 --- a/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts +++ b/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts @@ -111,8 +111,7 @@ export const useFileItemDragDrop = ({ if (!element) return; const rect = element.getBoundingClientRect(); - const clientY = - (source as any).element?.getBoundingClientRect().top || 0; + const clientY = source.element?.getBoundingClientRect().top || 0; const midpoint = rect.top + rect.height / 2; setDropPosition(clientY < midpoint ? "below" : "above"); @@ -121,7 +120,10 @@ export const useFileItemDragDrop = ({ setIsDragOver(false); const dropPos = dropPositionRef.current; setDropPosition("below"); - const sourceData = source.data as any; + const sourceData = source.data as { + type?: string; + fromIndex?: number; + }; if (sourceData?.type === "file-item") { const fromIndex = sourceData.fromIndex as number; let toIndex = indexRef.current; diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx index fab1f3ff5e..1fbf447a21 100644 --- a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx +++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -14,9 +14,9 @@ import ButtonSelector from "@app/components/shared/ButtonSelector"; interface BookletImpositionSettingsProps { parameters: BookletImpositionParameters; - onParameterChange: ( - key: keyof BookletImpositionParameters, - value: any, + onParameterChange: ( + key: K, + value: BookletImpositionParameters[K], ) => void; disabled?: boolean; } @@ -214,7 +214,10 @@ const BookletImpositionSettings = ({ )} value={parameters.gutterSize} onChange={(value) => - onParameterChange("gutterSize", value || 12) + onParameterChange( + "gutterSize", + typeof value === "number" ? value : 12, + ) } min={6} max={72} diff --git a/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx b/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx index bc5d362666..60f7448f7e 100644 --- a/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx +++ b/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx @@ -2,13 +2,14 @@ import React from "react"; import { Flex, Text, Divider } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { TooltipTip } from "@app/types/tips"; export interface ToolWorkflowTitleProps { title: string; description?: string; tooltip?: { content?: React.ReactNode; - tips?: any[]; + tips?: TooltipTip[]; header?: { title: string; logo?: React.ReactNode; diff --git a/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx b/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx index b17cad7c61..d63230914e 100644 --- a/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx +++ b/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx @@ -2,7 +2,10 @@ import { Box } from "@mantine/core"; import ToolButton from "@app/components/tools/toolPicker/ToolButton"; import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader"; -import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; +import { + getSubcategoryLabel, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; import { TFunction } from "i18next"; import { SubcategoryGroup } from "@app/hooks/useToolSections"; import { ToolId } from "@app/types/toolId"; @@ -15,7 +18,10 @@ export const renderToolButtons = ( onSelect: (id: ToolId) => void, showSubcategoryHeader: boolean = true, disableNavigation: boolean = false, - searchResults?: Array<{ item: [string, any]; matchedText?: string }>, + searchResults?: Array<{ + item: [ToolId, ToolRegistryEntry]; + matchedText?: string; + }>, hasStars: boolean = false, ) => { // Create a map of matched text for quick lookup diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts index 8fb1ddd5b6..e114a16f9d 100644 --- a/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts +++ b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { isAxiosError } from "axios"; import apiClient from "@app/services/apiClient"; import { alert } from "@app/components/toast"; import { fileStorage } from "@app/services/fileStorage"; @@ -333,8 +334,8 @@ export function useSigningSessionController(enabled: boolean) { pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf", }); - } catch (pdfError: any) { - if (pdfError?.response?.status === 404) { + } catch (pdfError) { + if (isAxiosError(pdfError) && pdfError.response?.status === 404) { alert({ alertType: "warning", title: t("certSign.sessions.pdfNotReady", "PDF Not Ready"), diff --git a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts index 18981b58d0..858ab8c7ef 100644 --- a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts +++ b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts @@ -13,9 +13,10 @@ import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; import { createFileFromApiResponse } from "@app/utils/fileResponseUtils"; import { getPdfiumModule, saveRawDocument } from "@app/services/pdfiumService"; import { copyRgbaToBgraHeap } from "@app/utils/pdfiumBitmapUtils"; +import type { PDFDocumentProxy } from "pdfjs-dist"; async function renderPdfPageToCanvas( - pdf: any, + pdf: PDFDocumentProxy, pageNumber: number, scale: number, ): Promise { @@ -26,7 +27,7 @@ async function renderPdfPageToCanvas( canvas.height = viewport.height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D context unavailable"); - await page.render({ canvasContext: ctx, viewport }).promise; + await page.render({ canvasContext: ctx, canvas, viewport }).promise; return canvas; } diff --git a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts index d6175e2b8b..d912cc4ede 100644 --- a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts @@ -210,8 +210,8 @@ export const buildConvertFormData = ( // Static function that can be used by both the hook and automation executor export const createFileFromResponse = ( - responseData: any, - headers: any, + responseData: Blob, + headers: Record, originalFileName: string, targetExtension: string, ): File => { diff --git a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts index b60aa09762..8f2953afde 100644 --- a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts @@ -85,7 +85,7 @@ describe("useRemovePasswordOperation", () => { const testFile = new File(["test content"], "test.pdf", { type: "application/pdf", }); - const formData = buildFormData(testParameters, testFile as any); + const formData = buildFormData(testParameters, testFile); // Verify the form data contains the file expect(formData.get("fileInput")).toBe(testFile); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 256bdf640a..43fe732a4e 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -284,28 +284,18 @@ export default defineConfig( ignores: [ "editor/src/core/components/annotation/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/pageEditor/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/pageEditor/commands/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/pageEditor/hooks/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/shared/config/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/shared/pageEditor/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/bookletImposition/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/certSign/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/pdfTextEditor/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/file/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/signing/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/adjustContrast/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/convert/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/removePassword/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/tools/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/services/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/tools/annotate/useAnnotationSelection.ts", From 37a48aa7a700fc0d9e33a8dad87da4a0857b3e3a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 7 Aug 2026 17:02:11 +0100 Subject: [PATCH 116/262] Replace ESLint and dpdm with Oxlint (#7330) # Description of Changes Smaller scope than #6689 to try and get this finished. Replace ESLint and dpdm with Oxlint, a TS linter written in Rust so its performance is dramatically better than the existing tools we use. ## Speed improvement - Current ESLint run: 13.76s - Current dpdm run: 3.59s - Total time: 17.35s - New Oxlint run: 0.90s So Oxlint is about a 20x speed improvement. ## Differences When I last tried to do this, we could recreate our rules identically with Oxlint, but that's not true any more. Oxlint has no current equivalent for ESLint's `no-restricted-syntax` rule, which we were using to ban usages of ` @@ -90,8 +90,8 @@ function MockChatContent({ maxWidth: "82%", background: m.role === "user" - ? "#3b82f6" - : "var(--c-surface-sunken, #f3f4f6)", + ? "var(--c-primary)" + : "var(--c-surface-sunken)", color: m.role === "user" ? "#fff" : "inherit", borderRadius: 10, padding: "8px 12px", @@ -108,17 +108,17 @@ function MockChatContent({
    What do you want to do? @@ -149,7 +149,7 @@ function ChatFABWidgetDemo({ width: "100%", height: "100%", overflow: "hidden", - background: "var(--c-bg, #f8f9fb)", + background: "var(--c-bg)", }} > {/* FAB button */} @@ -249,7 +249,7 @@ function ChatFABFullFlowDemo() { padding: "4px 10px", borderRadius: 6, background: - step === s ? "#3b82f6" : "var(--c-surface-sunken, #f3f4f6)", + step === s ? "var(--c-primary)" : "var(--c-surface-sunken)", color: step === s ? "#fff" : "inherit", fontWeight: step === s ? 600 : 400, }} diff --git a/frontend/editor/src/portal/data/Ops.stories.tsx b/frontend/editor/src/portal/data/Ops.stories.tsx index 8e59e5f5ef..502791e2d1 100644 --- a/frontend/editor/src/portal/data/Ops.stories.tsx +++ b/frontend/editor/src/portal/data/Ops.stories.tsx @@ -27,7 +27,7 @@ const STAGE_ORDER: OpKind[] = [ const STAGE_COLOUR: Record = { ingest: "var(--color-green)", validate: "var(--c-primary)", - modify: "#F97316", + modify: "var(--color-orange)", secure: "var(--color-red)", store: "var(--color-purple)", alert: "var(--color-amber)", From 59ed4f5fd117cac60997cc3148c02c09e0743f41 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:14:56 +0100 Subject: [PATCH 134/262] Fix automate unrunnable tools (#7311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Fix automate unrunnable tools ## Problem - Remove Image failed in Automate with `Tool operation not supported: removeImage` - Its registry entry had `operationConfig: undefined` even though the config existed and was already tested - The Automate picker only filtered on `supportsAutomate`, never on `operationConfig` — so broken tools were selectable and failed only at run time ## Fixes - Wire up `removeImage` and `pageLayout` operation configs (both already existed, just never registered) - Exclude `validateSignature` (report tool, not on the operationConfig seam) and `scannerEffect` (no frontend implementation) via `supportsAutomate: false` - Picker now also filters on `operationConfig`, so this class of bug can't reach users again - `overlay-pdfs` returns 400 instead of 500 when overlay files or mode are missing - Fix `new URL().pathname` Windows path bug that stopped 2 test suites from loading --- ## 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. --- .../controller/api/PdfOverlayController.java | 20 +++++++++++ .../tools/automate/ToolSelector.tsx | 8 +++-- ...tomatableToolsHaveOperationConfig.test.tsx | 33 +++++++++++++++++++ .../core/data/useTranslatedToolRegistry.tsx | 11 ++++++- .../src/core/utils/toolIOCompat.test.ts | 5 ++- .../src/core/utils/toolIOLabels.test.ts | 5 ++- 6 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java index 7f17738d98..1d369d282d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java @@ -61,6 +61,7 @@ public class PdfOverlayController { int overlayPos = request.getOverlayPosition(); MultipartFile[] overlayFiles = request.getOverlayFiles(); + validateOverlayFiles(overlayFiles); File[] overlayPdfFiles = new File[overlayFiles.length]; List tempFiles = new ArrayList<>(); // List to keep track of temporary files @@ -120,10 +121,29 @@ public class PdfOverlayController { } } + // Both fields are declared required, but @ModelAttribute binding leaves them null when the + // caller omits them, which would otherwise surface as a 500 instead of a 400. + private void validateOverlayFiles(MultipartFile[] overlayFiles) { + if (overlayFiles == null || overlayFiles.length == 0) { + throw ExceptionUtils.createIllegalArgumentException( + "error.overlayFilesRequired", "At least one overlay file is required"); + } + for (MultipartFile overlayFile : overlayFiles) { + if (overlayFile == null || overlayFile.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.overlayFileEmpty", "Overlay files must not be empty"); + } + } + } + private Map prepareOverlayGuide( int basePageCount, File[] overlayFiles, String mode, int[] counts, List tempFiles) throws IOException { Map overlayGuide = new HashMap<>(); + if (mode == null) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null"); + } switch (mode) { case "SequentialOverlay": sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles); diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx index c5c3998bc0..ad7bb33a6e 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx @@ -34,13 +34,17 @@ export default function ToolSelector({ const [shouldAutoFocus, setShouldAutoFocus] = useState(false); const containerRef = useRef(null); - // Filter out excluded tools (like 'automate' itself) and tools that don't support automation + // Filter out excluded tools (like 'automate' itself), tools that don't support + // automation, and tools with no operationConfig - the executor resolves a step + // through operationConfig, so offering one without it fails only at run time. const baseFilteredTools = useMemo(() => { return ( Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][] ).filter( ([key, tool]) => - !excludeTools.includes(key) && getToolSupportsAutomate(tool), + !excludeTools.includes(key) && + getToolSupportsAutomate(tool) && + Boolean(tool.operationConfig), ); }, [toolRegistry, excludeTools]); diff --git a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx new file mode 100644 index 0000000000..461c07ebdf --- /dev/null +++ b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx @@ -0,0 +1,33 @@ +/** + * Registry invariant: the Automate picker offers a tool whenever it doesn't opt out via + * `supportsAutomate: false`, but automationExecutor resolves each step through the tool's + * `operationConfig`. A tool that is offered without one is selectable in the builder and + * only fails when the automation runs, with "Tool operation not supported: ". + * + * So a tool must either carry an operationConfig or declare supportsAutomate: false. + */ +import { describe, expect, test, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; +import { getToolSupportsAutomate } from "@app/data/toolsTaxonomy"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + i18n: { changeLanguage: vi.fn(), language: "en-US" }, + }), + Trans: ({ children }: { children?: unknown }) => children, +})); + +describe("automatable tools", () => { + test("every tool offered to Automate can be executed as a step", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + const offeredWithoutConfig = Object.entries(result.current.regularTools) + .filter(([, entry]) => entry && getToolSupportsAutomate(entry)) + .filter(([, entry]) => !entry.operationConfig) + .map(([id]) => id); + + expect(offeredWithoutConfig).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 16b69d6983..0a39da2fb9 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -50,6 +50,8 @@ import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/u import { signOperationConfig } from "@app/hooks/tools/sign/useSignOperation"; import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation"; import { removeAnnotationsOperationConfig } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation"; +import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation"; +import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation"; import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation"; import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation"; import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation"; @@ -526,6 +528,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { maxFiles: -1, endpoints: ["validate-signature"], synonyms: getSynonyms(t, "validateSignature"), + // Reports on signatures rather than transforming the PDF, and its hook is + // not on the operationConfig seam, so it cannot run as an automation step. + supportsAutomate: false, automationSettings: null, }, @@ -755,6 +760,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.PAGE_FORMATTING, maxFiles: -1, endpoints: ["multi-page-layout"], + operationConfig: asRegistryConfig(pageLayoutOperationConfig), automationSettings: lazySettings( () => import("@app/components/tools/pageLayout/PageLayoutSettings"), ), @@ -967,7 +973,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.REMOVAL, maxFiles: -1, endpoints: ["remove-image-pdf"], - operationConfig: undefined, + operationConfig: asRegistryConfig(removeImageOperationConfig), synonyms: getSynonyms(t, "removeImage"), automationSettings: null, }, @@ -1196,6 +1202,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.ADVANCED_FORMATTING, endpoints: ["scanner-effect"], synonyms: getSynonyms(t, "scannerEffect"), + // No frontend implementation yet (component is null), so it has no + // operationConfig to execute as an automation step. + supportsAutomate: false, automationSettings: null, }, diff --git a/frontend/editor/src/core/utils/toolIOCompat.test.ts b/frontend/editor/src/core/utils/toolIOCompat.test.ts index c0e9d9efa3..b3fb117705 100644 --- a/frontend/editor/src/core/utils/toolIOCompat.test.ts +++ b/frontend/editor/src/core/utils/toolIOCompat.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { validateToolChain, @@ -24,7 +25,9 @@ interface SharedCase { /** Shared with the backend and engine, so it lives at the repo root. */ function casesFile(): string { - let current = dirname(new URL(import.meta.url).pathname); + // fileURLToPath, not URL.pathname: on Windows the latter yields "/C:/..." and + // resolving against it produces a "C:\C:\..." path that never matches. + let current = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 12; i++) { const candidate = resolve(current, "testing/tool-io-cases.json"); try { diff --git a/frontend/editor/src/core/utils/toolIOLabels.test.ts b/frontend/editor/src/core/utils/toolIOLabels.test.ts index ac6b4c155b..98c529e677 100644 --- a/frontend/editor/src/core/utils/toolIOLabels.test.ts +++ b/frontend/editor/src/core/utils/toolIOLabels.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { TOOL_FORMATS, type ToolFormat } from "@app/types/toolIO"; import { @@ -9,7 +10,9 @@ import { /** The en-US `[toolFormat]` block, read straight from the locale file. */ function toolFormatLabels(): Record { - let current = dirname(new URL(import.meta.url).pathname); + // fileURLToPath, not URL.pathname: on Windows the latter yields "/C:/..." and + // resolving against it produces a "C:\C:\..." path that never matches. + let current = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 12; i++) { try { const toml = readFileSync( From 35a861f4f80f2816b22ec57fef2c1a0a6b9906b2 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:42:44 +0100 Subject: [PATCH 135/262] feat(editor): move endpoint availability onto TanStack Query (#7285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes > Stacked on #7264, sibling of #7283. Independent of #7283 — the only overlap is two additive lines in `core/query/keys.ts` and `core/api/config.ts`. Either can merge first. ## The problem `useEndpointConfig` kept its own cache: a module-level `globalFetchDone` boolean, a mutable `globalEndpointCache` object, and a `resetGlobalCache()` called from the JWT listener. Which consumer mounted first decided who paid for the request, and nothing invalidated it except a page reload. ## End state One shared query for the whole availability map; each of the 12 consumers projects the endpoints it asked for. **251 lines to 101**, same return shape, no consumer changes. | | Before | After | |---|---|---| | Cross-consumer cache | `globalFetchDone` + mutable module object | query key | | Invalidation | `resetGlobalCache()` mutating that object | `invalidateQueries` | | Per-endpoint check | own `useState` triple | query keyed by endpoint | Behaviour kept deliberately: - **Unknown endpoints and any failure still read as enabled.** This fires before auth settles, and disabling every tool on a hiccup is worse than letting one call fail later. - **`retry` is off for the availability map.** The fallback *is* the answer, so retrying only doubles a request every logged-out visitor makes on load. ## Desktop is untouched `desktop/hooks/useEndpointConfig.ts` shadows this module entirely — no shared code, so core converting doesn't affect it and there's no half-migrated state. It's 482 lines of orchestration rather than fetching: dependency-ready gating, `tauriBackendService` and `selfHostedServerMonitor` subscriptions, a 2.5s timeout retry for backend startup, a legacy `?endpoints=` fallback for old servers, and SaaS-routing optimism that rewrites disabled endpoints to enabled. It also has no test coverage to convert against, and it decides whether tools appear at all in the desktop app. That's a different job from this one and wants its own review. Next PR. ## Testing 9 new tests: projection onto the requested subset, one request across consumers, unknown-endpoint fallback, failure fallback with no retry, empty-list no-fetch, JWT invalidation, and the three single-endpoint cases. `task frontend:check` green: 1677 tests across 192 files, typecheck on all five flavours, eslint, dpdm, prettier. Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- frontend/editor/src/core/api/config.ts | 30 ++ .../src/core/hooks/useEndpointConfig.test.tsx | 149 +++++++++ .../src/core/hooks/useEndpointConfig.ts | 282 ++++-------------- frontend/editor/src/core/query/keys.ts | 3 + 4 files changed, 248 insertions(+), 216 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useEndpointConfig.test.tsx diff --git a/frontend/editor/src/core/api/config.ts b/frontend/editor/src/core/api/config.ts index f0ba730e8c..94caba2c82 100644 --- a/frontend/editor/src/core/api/config.ts +++ b/frontend/editor/src/core/api/config.ts @@ -1,6 +1,7 @@ import apiClient from "@app/services/apiClient"; import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations"; import type { AppConfig } from "@app/types/appConfig"; +import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; /** Unauthenticated and unreachable both mean "assume login is on". */ export const DEFAULT_APP_CONFIG: AppConfig = { enableLogin: true }; @@ -23,6 +24,35 @@ export async function fetchAppConfig(): Promise { } } +export type EndpointAvailabilityMap = Record< + string, + EndpointAvailabilityDetails +>; + +/** + * Fires on app load before auth settles, so a 401 must not trigger the global + * login redirect. Callers treat a failure as "assume enabled". + */ +export async function fetchEndpointsAvailability(): Promise { + const response = await apiClient.get( + "/api/v1/config/endpoints-availability", + { suppressErrorToast: true, skipAuthRedirect: true }, + ); + return Object.fromEntries( + Object.entries(response.data).map(([name, detail]) => [ + name, + { enabled: detail?.enabled ?? true, reason: detail?.reason ?? null }, + ]), + ); +} + +export async function fetchEndpointEnabled(endpoint: string): Promise { + const response = await apiClient.get( + `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, + ); + return response.data; +} + export interface FooterInfo { analyticsEnabled?: boolean; termsAndConditions?: string; diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx b/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx new file mode 100644 index 0000000000..59010dacd5 --- /dev/null +++ b/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; +import { + useEndpointEnabled, + useMultipleEndpointsEnabled, +} from "@app/hooks/useEndpointConfig"; +import { + fetchEndpointEnabled, + fetchEndpointsAvailability, +} from "@app/api/config"; + +vi.mock("@app/api/config", () => ({ + fetchEndpointEnabled: vi.fn(), + fetchEndpointsAvailability: vi.fn(), +})); + +const mockOne = vi.mocked(fetchEndpointEnabled); +const mockAll = vi.mocked(fetchEndpointsAvailability); + +describe("useEndpointEnabled", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reports null while loading, then the server's answer", async () => { + mockOne.mockResolvedValue(false); + + const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), { + wrapper: TestQueryProvider, + }); + + expect(result.current.enabled).toBeNull(); + await waitFor(() => expect(result.current.enabled).toBe(false)); + }); + + it("stays null on failure rather than claiming disabled", async () => { + mockOne.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), { + wrapper: TestQueryProvider, + }); + + await waitFor(() => expect(result.current.error).toBe("boom")); + expect(result.current.enabled).toBeNull(); + }); + + it("does not fetch without an endpoint", () => { + const { result } = renderHook(() => useEndpointEnabled(""), { + wrapper: TestQueryProvider, + }); + + expect(result.current.loading).toBe(false); + expect(mockOne).not.toHaveBeenCalled(); + }); +}); + +describe("useMultipleEndpointsEnabled", () => { + beforeEach(() => vi.clearAllMocks()); + + it("projects the shared map onto the requested endpoints", async () => { + mockAll.mockResolvedValue({ + "ocr-pdf": { enabled: false, reason: "DEPENDENCY" }, + "add-stamp": { enabled: true, reason: null }, + }); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ "ocr-pdf": false }), + ); + expect(result.current.endpointDetails["ocr-pdf"].reason).toBe("DEPENDENCY"); + }); + + it("serves every consumer from one request", async () => { + mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } }); + + const { result } = renderHook( + () => ({ + a: useMultipleEndpointsEnabled(["ocr-pdf"]), + b: useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]), + }), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => expect(result.current.a.loading).toBe(false)); + expect(mockAll).toHaveBeenCalledTimes(1); + }); + + it("treats unknown endpoints as enabled", async () => { + mockAll.mockResolvedValue({}); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["brand-new-tool"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ "brand-new-tool": true }), + ); + }); + + it("falls back to enabled when the check fails", async () => { + mockAll.mockRejectedValue( + Object.assign(new Error("unauthorised"), { response: { status: 401 } }), + ); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ + "ocr-pdf": true, + "add-stamp": true, + }), + ); + // The fallback is the answer, so no retry. + expect(mockAll).toHaveBeenCalledTimes(1); + }); + + it("does not fetch for an empty endpoint list", () => { + const { result } = renderHook(() => useMultipleEndpointsEnabled([]), { + wrapper: TestQueryProvider, + }); + + expect(result.current.loading).toBe(false); + expect(mockAll).not.toHaveBeenCalled(); + }); + + it("refetches when a JWT becomes available", async () => { + mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } }); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf"]), + { wrapper: TestQueryProvider }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + window.dispatchEvent(new CustomEvent("jwt-available")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => expect(mockAll).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.ts b/frontend/editor/src/core/hooks/useEndpointConfig.ts index 4615164787..488375c853 100644 --- a/frontend/editor/src/core/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/core/hooks/useEndpointConfig.ts @@ -1,75 +1,50 @@ -import { useCallback, useEffect, useState } from "react"; -import { isAxiosError } from "axios"; -import apiClient from "@app/services/apiClient"; +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchEndpointEnabled, + fetchEndpointsAvailability, +} from "@app/api/config"; +import { qk } from "@app/query/keys"; +import { CONFIG_STALE_TIME } from "@app/query/staleTime"; import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync"; import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; -// Track whether we've done the global fetch to prevent duplicate requests -let globalFetchDone = false; -const globalEndpointCache: Record = {}; +const OPTIMISTIC: EndpointAvailabilityDetails = { enabled: true, reason: null }; -function resetGlobalCache() { - globalFetchDone = false; - Object.keys(globalEndpointCache).forEach( - (key) => delete globalEndpointCache[key], - ); +function message(error: unknown): string | null { + if (!error) return null; + return error instanceof Error ? error.message : "Unknown error occurred"; } -/** - * Hook to check if a specific endpoint is enabled - * This wraps the context for single endpoint checks - */ +/** Whether one endpoint is enabled. `null` while loading and on failure. */ export function useEndpointEnabled(endpoint: string): { enabled: boolean | null; loading: boolean; error: string | null; refetch: () => Promise; } { - const [enabled, setEnabled] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchEndpointStatus = async () => { - if (!endpoint) { - setEnabled(null); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - console.debug("[useEndpointConfig] Fetch endpoint status", { endpoint }); - - const response = await apiClient.get( - `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, - ); - const isEnabled = response.data; - setEnabled(isEnabled); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchEndpointStatus(); - }, [endpoint]); + const { data, isPending, error, refetch } = useQuery({ + queryKey: qk.endpointEnabled(endpoint), + queryFn: () => fetchEndpointEnabled(endpoint), + enabled: Boolean(endpoint), + staleTime: CONFIG_STALE_TIME, + }); return { - enabled, - loading, - error, - refetch: fetchEndpointStatus, + enabled: data ?? null, + loading: Boolean(endpoint) && isPending, + error: message(error), + refetch: useCallback(async () => { + await refetch(); + }, [refetch]), }; } /** - * Hook to check multiple endpoints at once using batch API - * Returns a map of endpoint -> enabled status + * Availability for a set of endpoints, projected from one shared request for + * the whole map. Unknown endpoints and any failure read as enabled — this runs + * before auth settles, and disabling every tool on a hiccup is worse than + * letting a call fail later. */ export function useMultipleEndpointsEnabled(endpoints: string[]): { endpointStatus: Record; @@ -78,174 +53,49 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { error: string | null; refetch: () => Promise; } { - const [endpointStatus, setEndpointStatus] = useState>( - {}, - ); - const [endpointDetails, setEndpointDetails] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const wanted = endpoints ?? []; - const fetchAllEndpointStatuses = useCallback( - async (force = false) => { - // Skip if already fetched globally and not forced - if (!force && globalFetchDone) { - console.debug("[useEndpointConfig] Using global cache"); - const cached = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(cached.status); - setEndpointDetails((prev) => ({ ...prev, ...cached.details })); - setLoading(false); - return; - } + const { data, isPending, error, refetch } = useQuery({ + queryKey: qk.endpointsAvailability(), + queryFn: fetchEndpointsAvailability, + enabled: wanted.length > 0, + staleTime: CONFIG_STALE_TIME, + // A failure already falls back to enabled, so a retry buys nothing and + // doubles a request that fires on load for every logged-out visitor. + retry: false, + }); - if (!endpoints || endpoints.length === 0) { - setEndpointStatus({}); - setEndpointDetails({}); - setLoading(false); - return; - } + const reload = useCallback(async () => { + await refetch(); + }, [refetch]); - try { - setLoading(true); - setError(null); - console.debug( - "[useEndpointConfig] Fetching all endpoint statuses from server", - ); - - // Fetch all endpoints at once; auto-fires on app load, so a 401 must - // fail silently instead of triggering the global login redirect. - const response = await apiClient.get< - Record - >(`/api/v1/config/endpoints-availability`, { - suppressErrorToast: true, - skipAuthRedirect: true, - }); - - // Populate global cache with all results - Object.entries(response.data).forEach(([endpoint, details]) => { - globalEndpointCache[endpoint] = { - enabled: details?.enabled ?? true, - reason: details?.reason ?? null, - }; - }); - globalFetchDone = true; - - // Return status for the requested endpoints - const fullStatus = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - - setEndpointStatus(fullStatus.status); - setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details })); - } catch (err: unknown) { - // On 401 (auth error), use optimistic fallback instead of disabling - if (isAxiosError(err) && err.response?.status === 401) { - console.warn( - "[useEndpointConfig] 401 error - using optimistic fallback", - ); - endpoints.forEach((endpoint) => { - globalEndpointCache[endpoint] = { enabled: true, reason: null }; - }); - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - acc.status[endpoint] = true; - acc.details[endpoint] = { enabled: true, reason: null }; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ - ...prev, - ...optimisticStatus.details, - })); - setLoading(false); - return; - } - - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - console.error("[EndpointConfig] Failed to check endpoints:", err); - - // Fallback: assume all endpoints are enabled on error (optimistic) - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - acc.status[endpoint] = true; - acc.details[endpoint] = { enabled: true, reason: null }; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ - ...prev, - ...optimisticStatus.details, - })); - } finally { - setLoading(false); - } - }, - [endpoints.join(",")], + useJwtConfigSync( + useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: qk.endpointsAvailability(), + }); + }, [queryClient]), ); - useEffect(() => { - fetchAllEndpointStatuses(); - }, [fetchAllEndpointStatuses]); - - // Re-fetch when auth state changes. Core implementation listens for the - // proprietary `jwt-available` event; the SaaS no-op override means the - // cache simply isn't invalidated on Supabase auth changes (today's behavior). - // If SaaS later needs that, wire it up inside saas/hooks/useJwtConfigSync.ts. - const handleAuthChange = useCallback(() => { - console.debug( - "[useEndpointConfig] Auth changed - clearing cache for refetch", - ); - resetGlobalCache(); - fetchAllEndpointStatuses(true); - }, [fetchAllEndpointStatuses]); - useJwtConfigSync(handleAuthChange); + const key = wanted.join(","); + const projected = useMemo(() => { + const status: Record = {}; + const details: Record = {}; + if (!data && !error) return { status, details }; + for (const endpoint of key ? key.split(",") : []) { + const detail = data?.[endpoint] ?? OPTIMISTIC; + status[endpoint] = detail.enabled; + details[endpoint] = detail; + } + return { status, details }; + }, [data, error, key]); return { - endpointStatus, - endpointDetails, - loading, - error, - refetch: () => fetchAllEndpointStatuses(true), + endpointStatus: projected.status, + endpointDetails: projected.details, + loading: wanted.length > 0 && isPending, + error: message(error), + refetch: reload, }; } diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 6c6bacd552..a7a68ea256 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,6 +1,9 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { appConfig: () => ["editor", "appConfig"] as const, + endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, + endpointEnabled: (endpoint: string) => + ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, users: () => ["editor", "users"] as const, From 0ff4ef629cf294bf474700ffb505f748bbefb4aa Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 10 Aug 2026 17:05:05 +0100 Subject: [PATCH 136/262] New Pipeline UI redesign (#7202) # Description of Changes Supersedes #7144. Redesign the Processor New Pipeline page to use a graph-based interface. Far from perfect at this stage but I'm pretty happy with the interactions on the graph itself. The bar at the top needs some work to make it prettier and more clear what everything is for, but I'd rather get this in and do changes in a follow-up PR because this is big enough on its own and leaves us better than where we were before. image image image image image --- frontend/.storybook/a11y-baseline.dark.json | 29 +- frontend/.storybook/a11y-baseline.json | 38 +- .../public/locales/en-US/translation.toml | 75 +- .../useAddPasswordOperation.test.ts | 9 + .../addPassword/useAddPasswordOperation.ts | 2 +- .../hooks/tools/shared/toolAutomation.test.ts | 15 + .../core/hooks/tools/shared/toolAutomation.ts | 22 +- .../src/core/tests/stubbed/files-page.spec.ts | 6 +- frontend/editor/src/core/ui/CodeBlock.tsx | 2 +- frontend/editor/src/core/ui/NodeCard.css | 87 ++ .../editor/src/core/ui/NodeCard.stories.tsx | 53 + frontend/editor/src/core/ui/NodeCard.tsx | 81 ++ frontend/editor/src/core/ui/index.ts | 1 + frontend/editor/src/portal/api/http.ts | 15 + frontend/editor/src/portal/api/pipelines.ts | 52 +- .../editor/src/portal/components/AppShell.css | 4 + .../pipelines/DestinationPicker.tsx | 65 +- .../pipelines/PipelineDefinitionModal.css | 26 + .../PipelineDefinitionModal.stories.tsx | 48 + .../PipelineDefinitionModal.test.tsx | 37 + .../pipelines/PipelineDefinitionModal.tsx | 42 + .../components/pipelines/PipelineHeader.css | 133 +++ .../pipelines/PipelineHeader.stories.tsx | 118 +++ .../pipelines/PipelineHeader.test.tsx | 200 ++++ .../components/pipelines/PipelineHeader.tsx | 301 ++++++ .../pipelines/PipelineInspector.css | 34 + .../pipelines/PipelineInspector.stories.tsx | 71 ++ .../pipelines/PipelineInspector.test.tsx | 60 ++ .../pipelines/PipelineInspector.tsx | 79 ++ .../pipelines/ToolPicker.stories.tsx | 28 + .../components/pipelines/ToolPicker.tsx | 84 +- .../components/pipelines/graph/GraphEdge.css | 180 ++++ .../components/pipelines/graph/GraphEdge.tsx | 109 ++ .../components/pipelines/graph/GraphNode.css | 140 +++ .../components/pipelines/graph/GraphNode.tsx | 183 ++++ .../pipelines/graph/GraphPlaceholderNode.css | 44 + .../pipelines/graph/GraphPlaceholderNode.tsx | 30 + .../pipelines/graph/PipelineGraph.css | 79 ++ .../pipelines/graph/PipelineGraph.stories.tsx | 207 ++++ .../pipelines/graph/PipelineGraph.test.tsx | 455 ++++++++ .../pipelines/graph/PipelineGraph.tsx | 379 +++++++ .../pipelines/graph/pipelineLayout.test.ts | 147 +++ .../pipelines/graph/pipelineLayout.ts | 184 ++++ .../pipelines/graph/useChainDragDrop.test.ts | 91 ++ .../pipelines/graph/useChainDragDrop.ts | 230 ++++ .../src/portal/mocks/handlers/pipelines.ts | 23 + .../src/portal/views/PipelineBuilder.css | 446 +++----- .../portal/views/PipelineBuilder.stories.tsx | 26 +- .../src/portal/views/PipelineBuilder.test.tsx | 451 +++++++- .../src/portal/views/PipelineBuilder.tsx | 985 ++++++++++-------- .../editor/src/portal/views/Pipelines.css | 17 - 51 files changed, 5310 insertions(+), 913 deletions(-) create mode 100644 frontend/editor/src/core/ui/NodeCard.css create mode 100644 frontend/editor/src/core/ui/NodeCard.stories.tsx create mode 100644 frontend/editor/src/core/ui/NodeCard.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphNode.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json index fa08401d09..fb461fe6d2 100644 --- a/frontend/.storybook/a11y-baseline.dark.json +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -1828,6 +1828,21 @@ "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [ "color-contrast" ], + "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Multiple Selected": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Nothing Selected": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ + "color-contrast" + ], "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ "color-contrast" ], @@ -1845,6 +1860,12 @@ "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ "color-contrast" ], + "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Incompatible Preceding Output": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: No Matches": [ + "color-contrast" + ], "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [ "color-contrast" ], @@ -2235,9 +2256,13 @@ "color-contrast" ], "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast" + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 91db120c19..df46eb9dc4 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1404,8 +1404,7 @@ "color-contrast" ], "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast", - "scrollable-region-focusable" + "color-contrast" ], "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ @@ -1977,6 +1976,30 @@ "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ "color-contrast" ], + "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ + "color-contrast" + ], "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ "color-contrast" ], @@ -2294,13 +2317,14 @@ "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ "color-contrast" ], - "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ - "color-contrast" - ], "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast" + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 0fffc04dbc..4f70f2b813 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7800,7 +7800,6 @@ title = "Pipelines" newPipeline = "New pipeline" [portal.pipelines.builder] -addStep = "Add tool" back = "Back to pipelines" cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" @@ -7813,59 +7812,64 @@ inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" keepEditing = "Keep editing" +moreActions = "More actions" needsConfiguring = "Needs setting up" +needsDestination = "No destination chosen" +needsSource = "No source chosen" needsUpload = "Needs an uploaded file" -noSources = "No sources connected yet. Create one to use it as an input." noToolMatches = "No tools match your search." -pipelineSettings = "Pipeline settings" searchTools = "Search tools" -selectToolBody = "Add a tool to build your pipeline." -selectToolTitle = "No tools yet" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." stepsNeedSetup = "These steps still need setting up before saving: {{tools}}." -toolSettings = "Tool settings" +testRun = "Test with a file" unknownStep = "Unrecognized operation, kept as-is." unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?" unsavedTitle = "Unsaved changes" uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}." usesDefaults = "Runs with default settings" +viewDefinition = "View definition" [portal.pipelines.builder.diagnostic] -fan-in = "Combines every file from the previous step" -fan-out = "Runs once per file from the previous step" -format-mismatch = "Needs {{accepts}}, but the previous step produces {{produced}}" -output-uncertain = "May not run: the previous step's output depends on how it's set up" -source-mismatch = "Needs {{accepts}}, but this pipeline's input is {{produced}}" +fan-in = "Combines every incoming file" +fan-out = "Runs once per incoming file" +format-mismatch = "Sends {{produced}}, needs {{accepts}}" +output-uncertain = "May not run: output depends on setup" +source-mismatch = "Input is {{produced}}, needs {{accepts}}" undeclared-operation = "Can't check what this step accepts" [portal.pipelines.composer] -addTool = "Add tool" +addTool = "Add a tool" cancel = "Cancel" -chainEmpty = "Add a tool to start building your pipeline." create = "Create pipeline" editingUnsupported = "Displaying these tool params for editing is not supported yet." -moveDown = "Move down" -moveUp = "Move up" +editSource = "Edit source" name = "Name" namePlaceholder = "e.g. Redaction sweep" noToolSettings = "This tool has no configurable settings." -operations_one = "Operation ({{count}})" -operations_other = "Operations ({{count}})" output = "Destination" -removeStep = "Remove operation" save = "Save changes" scheduleEvery = "Run every" -sources = "Sources" -sourcesLoading = "Loading sources..." trigger = "Trigger" triggerManual = "Manual only" +[portal.pipelines.composer.runsEvery] +days_one = "Runs every day" +days_other = "Runs every {{count}} days" +hours_one = "Runs every hour" +hours_other = "Runs every {{count}} hours" +minutes_one = "Runs every minute" +minutes_other = "Runs every {{count}} minutes" + [portal.pipelines.composer.unit] days = "days" hours = "hours" minutes = "minutes" +[portal.pipelines.definition] +subtitle = "The pipeline as it would be saved." +title = "Definition" + [portal.pipelines.delete] body = "Delete \"{{name}}\"? This can't be undone." cancel = "Cancel" @@ -7883,6 +7887,37 @@ connectSource = "Connect a source" description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes." title = "No pipelines yet" +[portal.pipelines.graph] +addFirstTool = "Add a tool" +dragHint = "Drop on a line to move it" +insertHere = "Add a tool here" +removeNode = "Remove {{name}}" +showError = "Show why {{name}} failed" + +[portal.pipelines.graph.add] +input = "Add a source" +output = "Add a destination" + +[portal.pipelines.graph.run] +done = "Done" +failed = "Failed" +running = "Running" + +[portal.pipelines.inspector] +multipleBody = "Drag any of them onto a line to move them together, or press Delete to remove them." +multipleSelected_one = "{{count}} step selected" +multipleSelected_other = "{{count}} steps selected" +noSelectionBody = "Pick a node in the graph to change what it does." +noSelectionTitle = "Nothing selected" + +[portal.pipelines.inspector.status] +completed_one = "Finished the only step" +completed_other = "Finished all {{count}} steps" +failed_one = "Failed on the only step" +failed_other = "Failed after {{done}} of {{count}} steps" +running_one = "Running the only step" +running_other = "Running step {{done}} of {{count}}" + [portal.pipelines.kpi] active = "Active" paused = "Paused" diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 77958629b0..28e8e1baa6 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -147,6 +147,15 @@ describe("useAddPasswordOperation", () => { }); describe("addPassword mappers", () => { + test("falls back to the default key length when the stored step omits it", () => { + // A pipeline step saved without keyLength must not deserialize to + // undefined: the settings UI calls keyLength.toString() on it. + const restored = addPasswordFromApiParams({ + password: "user-pw", + } as never); + expect(restored.keyLength).toBe(128); + }); + test("round-trips backend params, including the flattened permissions", () => { // Baseline differs from the configured values so the round trip fails if // fromApiParams drops a field instead of reconstructing it. diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts index d68154c9b1..4e843c8da7 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts @@ -48,7 +48,7 @@ export const addPasswordFromApiParams = ( ): Partial => ({ password: apiParams.password ?? defaultParameters.password, ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword, - keyLength: apiParams.keyLength, + keyLength: apiParams.keyLength ?? defaultParameters.keyLength, permissions: { preventAssembly: apiParams.preventAssembly ?? permissionsDefaults.preventAssembly, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 467e3ea5f7..e8d34ef9ad 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -153,6 +153,21 @@ describe("serialize/deserialize round-trip", () => { }); }); + test("a stored step missing fields falls back to defaults, not undefined", () => { + // Mappers echo absent stored fields as explicit undefined; settings UIs + // then crash on things like keyLength.toString(). Defaults must win. + const back = deserializeToolStep( + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + registry, + ); + expect(back.params.compressionLevel).toBe( + compressDefaults.compressionLevel, + ); + expect( + Object.values(back.params).every((value) => value !== undefined), + ).toBe(true); + }); + test("an unknown endpoint is preserved as an unmapped step", () => { const step = deserializeToolStep( { operation: "/api/v1/unknown/thing", parameters: { keep: true } }, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index aab9e1ab10..b32f783b06 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -291,12 +291,22 @@ export function deserializeToolStep( if (!match) return unmappedStep(step); const [toolId, entry] = match; const config = entry.operationConfig; - const params: ErasedToolParams = config?.fromApiParams - ? { - ...(config.defaultParameters ?? {}), - ...config.fromApiParams(step.parameters as never), - } - : { ...(config?.defaultParameters ?? {}) }; + // Mappers echo missing stored fields as explicit `undefined`, which would + // clobber the default underneath; strip those so defaults always win. + const mapped = config?.fromApiParams + ? Object.fromEntries( + Object.entries( + config.fromApiParams(step.parameters as never) as Record< + string, + unknown + >, + ).filter(([, value]) => value !== undefined), + ) + : {}; + const params: ErasedToolParams = { + ...(config?.defaultParameters ?? {}), + ...mapped, + } as ErasedToolParams; // Validate against the generated endpoint set instead of casting the matched string. const operation = resolveEndpoint(config, params) ?? diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts index 56687b2217..4c6e714ec0 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -173,7 +173,7 @@ test.describe("Files page", () => { await gotoFilesPage(page); const cards = page.locator(".files-page-card:not(.is-folder)"); await cards.nth(0).click(); - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2); // In multi-select (2+), plain-click ADDS instead of replacing. @@ -198,7 +198,7 @@ test.describe("Files page", () => { await expect(page.locator(".files-page-card-selector")).toHaveCount(0); // 2+ selected: checkboxes appear on every file card. - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect( page.locator(".files-page-card-selector").first(), ).toBeVisible(); @@ -488,7 +488,7 @@ test.describe("Files page", () => { const cards = page.locator(".files-page-card:not(.is-folder)"); await cards.nth(0).click(); // Drawer stays closed so the second click reaches the card. - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2); }); }); diff --git a/frontend/editor/src/core/ui/CodeBlock.tsx b/frontend/editor/src/core/ui/CodeBlock.tsx index cff078057d..2d66bd2353 100644 --- a/frontend/editor/src/core/ui/CodeBlock.tsx +++ b/frontend/editor/src/core/ui/CodeBlock.tsx @@ -70,7 +70,7 @@ export function CodeBlock({ )}
    -
    +      
             {code}
           
    diff --git a/frontend/editor/src/core/ui/NodeCard.css b/frontend/editor/src/core/ui/NodeCard.css new file mode 100644 index 0000000000..7982e2581a --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.css @@ -0,0 +1,87 @@ +/** + * NodeCard — a selectable labelled tile (icon badge + title + sub-line) on a raised surface. + * Shared surface, selection ring and content layout; feature-specific state is layered by callers. + */ + +.sui-node-card { + position: relative; + display: flex; + align-items: stretch; + box-sizing: border-box; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: + border-color var(--motion-fast), + box-shadow var(--motion-fast), + opacity var(--motion-fast); +} + +/* The whole card selects. Re-assert the tile look over the shared Button base (which otherwise + imposes a fixed height, its own padding and an accent text colour). */ +.sui-node-card__select.sui-btn { + flex: 1; + min-width: 0; + height: auto; + min-height: 0; + border: none; + background: none; + padding: 0.625rem 0.75rem; + text-align: left; + font-weight: 400; + color: var(--c-text); + border-radius: inherit; +} + +/* Mantine wraps a button's children in its label element, so the glyph and text are laid out + there - a gap on the button root would only space the wrapper, not what is inside it. */ +.sui-node-card__select.sui-btn .mantine-Button-label { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.625rem; + min-width: 0; + overflow: visible; +} + +.sui-node-card__text { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0.0625rem; +} + +.sui-node-card__title { + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sui-node-card__detail { + font-size: 0.6875rem; + /* --c-text-subtle does not clear 4.5:1 at this size in either theme (axe: 4.39 light, 3.66 + dark); --c-text-muted is the next rung up and does. */ + color: var(--c-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Selected: the primary ring. Wins over hover and the warning tone. */ +.sui-node-card.is-selected { + border-color: var(--c-primary); + box-shadow: 0 0 0 1px var(--c-primary); +} + +.sui-node-card:hover:not(.is-selected) { + border-color: var(--c-border-strong); +} + +.sui-node-card--warning:not(.is-selected) { + border-color: var(--c-warning); +} diff --git a/frontend/editor/src/core/ui/NodeCard.stories.tsx b/frontend/editor/src/core/ui/NodeCard.stories.tsx new file mode 100644 index 0000000000..acfe28f71d --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import { NodeCard } from "@app/ui/NodeCard"; +import { ActionIcon } from "@app/ui/ActionIcon"; + +const meta = { + title: "UI/NodeCard", + component: NodeCard, + parameters: { layout: "padded" }, + args: { + icon: , + title: "Compress", + detail: "level 7", + onSelect: () => {}, + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** The default tile: icon badge, title, one-line sub-detail, selectable. */ +export const Default: Story = {}; + +/** Selected — the primary ring the inspector points at. */ +export const Selected: Story = { args: { selected: true } }; + +/** Warning tone — an amber border, for a tile that needs attention. */ +export const Warning: Story = { + args: { tone: "warning", detail: "Needs setting up" }, +}; + +/** A trailing control (here a remove button) sits beside the select target, not nested in it. */ +export const WithTrailing: Story = { + args: { + trailing: ( + + + + ), + }, +}; diff --git a/frontend/editor/src/core/ui/NodeCard.tsx b/frontend/editor/src/core/ui/NodeCard.tsx new file mode 100644 index 0000000000..451cc8823b --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.tsx @@ -0,0 +1,81 @@ +import type { HTMLAttributes, MouseEvent, ReactNode, Ref } from "react"; +import { Button } from "@app/ui/Button"; +import { IconBadge, type IconBadgeAccent } from "@app/ui/IconBadge"; +import "@app/ui/NodeCard.css"; + +/** Border tone. `selected` (a separate prop) overrides this with the primary ring. */ +export type NodeCardTone = "default" | "warning"; + +export interface NodeCardProps extends Omit< + HTMLAttributes, + "title" | "onSelect" +> { + /** Glyph shown in a tone-tinted badge at the leading edge. */ + icon: ReactNode; + iconAccent?: IconBadgeAccent; + title: ReactNode; + /** One-line summary under the title. Any node - a plain string, or a richer line. */ + detail?: ReactNode; + tone?: NodeCardTone; + selected?: boolean; + /** + * When given, the whole card is a single select button (aria-pressed tracks `selected`). Trailing + * controls stay siblings of that button, never nested inside it, so the card holds no invalid + * nested interactive elements. + */ + onSelect?: (event: MouseEvent) => void; + /** Controls rendered over the card's trailing edge (a remove button, a status glyph, ...). */ + trailing?: ReactNode; + ref?: Ref; +} + +/** + * A labelled tile: an icon badge, a title, and an optional sub-line, on a raised card surface that + * can be selected. The recurring "node" motif - a step in a graph, an item in a board - lifted into + * a primitive so its surface, selection ring and content layout are shared rather than re-styled per + * feature. Callers layer their own state (drag, run status, ...) via `className` and `trailing`. + */ +export function NodeCard({ + icon, + iconAccent, + title, + detail, + tone = "default", + selected = false, + onSelect, + trailing, + className, + ref, + ...rest +}: NodeCardProps) { + return ( +
    + + {trailing} +
    + ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index 45212d8038..b3e2ca6ac0 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -8,6 +8,7 @@ export * from "@app/ui/MethodBadge"; export * from "@app/ui/ToggleSwitch"; export * from "@app/ui/ProgressBar"; export * from "@app/ui/MetricCard"; +export * from "@app/ui/NodeCard"; export * from "@app/ui/NavItem"; export * from "@app/ui/NavSurface"; export * from "@app/ui/PanelHeader"; diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index 5c8afcd2cb..2ec00e9818 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -212,6 +212,20 @@ async function localForm( return unwrap(res); } +/** POST a multipart/form-data body (file uploads), via the localBackend seam. The Content-Type is + * deliberately left unset so the browser writes it with the multipart boundary. */ +async function localMultipart(path: string, body: FormData): Promise { + const res = await fetch(`${localBaseUrl()}${path}`, { + method: "POST", + headers: { Accept: "application/json", ...(await localAuthHeader()) }, + body, + }); + if (res.status === 401) { + onLocalUnauthorized(); + } + return unwrap(res); +} + // ──────────────────────────────────────────────────────────────────────────── // saas — hosted SaaS Java, admin's Supabase JWT // ──────────────────────────────────────────────────────────────────────────── @@ -302,6 +316,7 @@ export const apiClient = { local: { json: localJson, form: localForm, + multipart: localMultipart, blob: localBlob, }, /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index d6088d06ff..50bdc173c4 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -1,4 +1,5 @@ import { apiClient } from "@portal/api/http"; +import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation"; /** * Pipelines service layer: the backend contract. @@ -122,7 +123,13 @@ export type PolicyRunStatus = | "FAILED" | "CANCELLED"; -/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */ +/** One file a run produced, downloadable via /api/v1/general/files/{fileId}. */ +export interface RunOutputFile { + fileId: string; + fileName: string | null; +} + +/** A run's current state. Mirrors the backend `PolicyRunView`. */ export interface PolicyRunView { runId: string; policyId: string | null; @@ -132,6 +139,11 @@ export interface PolicyRunView { /** Human-readable failure message; set when status is FAILED. */ error: string | null; errorCode: string | null; + /** + * Files the run produced, present once it completes. Whole-run, not per step: the backend keeps + * one flat list, so nothing here can be attributed to an individual step. + */ + outputs?: RunOutputFile[] | null; createdAt: number; } @@ -198,6 +210,44 @@ export async function triggerPipeline(id: string): Promise { ); } +/** What an ad-hoc test run posts: the steps as they stand, with no source and no trigger. */ +export interface TestRunDefinition { + name: string; + steps: ToolApiStep[]; + output: OutputSpec; +} + +/** + * POST /api/v1/policies/run: run a definition against one uploaded file now. The builder's test + * path - callers force an inline output so nothing reaches the pipeline's real destination, and + * the pipeline need not be saved first. + */ +export async function runPipelineTest( + definition: TestRunDefinition, + file: File, +): Promise<{ runId: string }> { + const form = new FormData(); + form.append( + "json", + new Blob([JSON.stringify(definition)], { type: "application/json" }), + ); + form.append("fileInput", file); + // The POST returns the identifier as `jobId`, but it is the same run id every other endpoint + // (fetchRun, fetchRunOutput) calls `runId`; normalise to that here so callers see one name. + const res = await apiClient.local.multipart<{ jobId: string }>( + "/api/v1/policies/run", + form, + ); + return { runId: res.jobId }; +} + +/** GET /api/v1/general/files/{id}: download one of a run's outputs. */ +export async function fetchRunOutput(fileId: string): Promise { + return apiClient.local.blob( + `/api/v1/general/files/${encodeURIComponent(fileId)}`, + ); +} + /** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */ export async function fetchRun(runId: string): Promise { return apiClient.local.json( diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 152c45f7c5..1657b56bdf 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -26,6 +26,10 @@ flex: 1 1 auto; min-height: 0; /* scroll instead of growing past the viewport */ overflow-y: auto; + /* Hold the scrollbar's width whether or not it is showing. Without this, a page that grows past + the viewport (an editor panel filling in, say) makes the bar appear and shunts everything + sideways as it does. */ + scrollbar-gutter: stable; animation: fadeInUp var(--motion-enter) both; } diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx index de1187c3da..864668b764 100644 --- a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -1,15 +1,16 @@ import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { Button, Select } from "@app/ui"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { ActionIcon, Button, FormField, Select } from "@app/ui"; /** * Picks the saved source a pipeline delivers its output to. A destination is just a * source used as a write target. The value stays a list ({@code outputIds}) because * the model supports several, but the product caps a pipeline at one destination * today, so this renders a single dropdown over the same locations the builder - * loaded (filtered to writable types by the caller). Creating a new one is delegated - * to {@code onCreateNew} (the builder navigates to the source builder, prompting - * about unsaved edits first). + * loaded (filtered to writable types by the caller). Creating and editing one are + * delegated to {@code onCreateNew} / {@code onEdit}, which open the source modal + * over the builder - mirroring the input row. */ interface DestinationOption { id: string; @@ -20,8 +21,10 @@ interface DestinationPickerProps { sources: DestinationOption[]; value: string[]; onChange: (outputIds: string[]) => void; - /** Leave the builder to create a new source location (navigate-away, like inputs). */ + /** Create a new source location to write to (opens the source modal). */ onCreateNew: () => void; + /** Edit the chosen destination's own settings (opens the source modal on it). */ + onEdit: (sourceId: string) => void; } export function DestinationPicker({ @@ -29,25 +32,45 @@ export function DestinationPicker({ value, onChange, onCreateNew, + onEdit, }: DestinationPickerProps) { const { t } = useTranslation(); + const chosen = value[0] ?? ""; + const hasSources = sources.length > 0; + // Mirrors the input row: the dropdown-plus-edit sits in a field, and "Connect source" lives on its + // own line below rather than inline. With nowhere to write to yet, only the connect button shows. return ( -
    -
    - onChange(id ? [id] : [])} + options={sources.map((source) => ({ + value: source.id, + label: source.name, + }))} + /> +
    + onEdit(chosen)} + > + + +
    + + )} - + ); } diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css new file mode 100644 index 0000000000..7f1984b4b1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css @@ -0,0 +1,26 @@ +/* The code is this modal's entire content, so it *is* the body rather than a window sitting inside + one. Framed, it drew a second box inside the panel's box - and the panel's own header already + says what the code is, which the code window's chrome was repeating. */ +.portal-definition__modal .sui-modal__body { + padding: 0; +} + +.portal-definition__code { + border: none; + border-radius: 0; + box-shadow: none; +} + +/* Traffic-light dots imitate a window frame; this code already sits in a real one. */ +.portal-definition__code .sui-code__dots { + display: none; +} + +/* Line the toolbar and the code up with the modal header's text. */ +.portal-definition__code .sui-code__chrome { + padding: 0.5rem 1.125rem; +} + +.portal-definition__code .sui-code__pre { + padding: 0.875rem 1.125rem; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx new file mode 100644 index 0000000000..4800689d7d --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@app/ui"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineDefinitionModal", + component: PipelineDefinitionModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const JSON_BODY = JSON.stringify( + { + name: "Claims redaction", + enabled: true, + inputs: [{ sourceId: "src-in", trigger: { type: "schedule" } }], + steps: [ + { operation: "/api/v1/misc/ocr-pdf", parameters: { language: "eng" } }, + { operation: "/api/v1/security/redact", parameters: { terms: 2 } }, + ], + outputIds: ["src-out"], + }, + null, + 2, +); + +/** Starts closed so the trigger can be exercised; click through to the tabs. */ +function Playground({ initialOpen = false }: { initialOpen?: boolean }) { + const [open, setOpen] = useState(initialOpen); + return ( + <> + + setOpen(false)} + json={JSON_BODY} + /> + + ); +} + +/** The definition as it opens from the header: JSON first, cURL a tab away. */ +export const Default: Story = { render: () => }; + +/** The trigger it opens from, so the closed state can be exercised too. */ +export const FromTrigger: Story = { render: () => }; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx new file mode 100644 index 0000000000..32e55e3826 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { render as baseRender, screen } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const JSON_BODY = '{\n "name": "Claims"\n}'; + +describe("PipelineDefinitionModal", () => { + it("renders nothing while closed", () => { + render( + , + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("opens on the JSON tab", () => { + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText(/"name": "Claims"/)).toBeInTheDocument(); + }); + + it("shows the definition alone - no tab strip to choose between", () => { + render(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx new file mode 100644 index 0000000000..adcd19f5f5 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from "react-i18next"; +import { CodeBlock, Modal } from "@app/ui"; +import "@portal/components/pipelines/PipelineDefinitionModal.css"; + +export interface PipelineDefinitionModalProps { + open: boolean; + onClose: () => void; + /** The pipeline as it would be saved, pretty-printed. Re-read while the modal is open. */ + json: string; +} + +/** + * The pipeline's definition as it would be saved. + * + * Pipeline-scoped, so it opens from the header rather than the node inspector, and a modal rather + * than a panel because a definition grows with the chain and needs the width. + */ +export function PipelineDefinitionModal({ + open, + onClose, + json, +}: PipelineDefinitionModalProps) { + const { t } = useTranslation(); + + return ( + + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css new file mode 100644 index 0000000000..f559e42795 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css @@ -0,0 +1,133 @@ +/** + * The builder's opening section: identity above the rule, actions below it. + */ + +.portal-pipeline-header { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.125rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); +} + +/* Leaving the page and saving it are the same kind of decision, so they share a row - and the back + link is short, so the save pair always has room beside it. */ +.portal-pipeline-header__top { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +/* The back link is the shared Button restyled to a plain link, so re-assert that over the + design-system base (which imposes a fixed height, its own padding and an accent colour). */ +.portal-pipeline-header__back.sui-btn { + height: auto; + min-height: 0; + padding: 0; + font-size: 0.8125rem; + font-weight: 400; + color: var(--c-text-muted); +} + +.portal-pipeline-header__back.sui-btn:hover { + background: none; + color: var(--c-text); +} + +.portal-pipeline-header__identity { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +/* The shared Checkbox aligns its box to the top of the first text line, with a nudge tuned for its + own font size - that is for the label-plus-description case. This one is a single line, so centre + the box on it and leave the component's sizing alone (overriding the font size shifts the line + box and leaves the tick floating high). */ +.portal-pipeline-header__enabled.sui-check { + flex: none; + align-items: center; +} + +.portal-pipeline-header__enabled.sui-check .sui-check__box { + margin-top: 0; +} + +/* The name is the page's title, so it takes the room and reads at title size. */ +.portal-pipeline-header__name { + flex: 1 1 16rem; + min-width: 12rem; +} + +.portal-pipeline-header__name input { + font-size: 1rem; + font-weight: 500; +} + +/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ +.portal-pipeline-header__save { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +.portal-pipeline-header__save .sui-btn { + flex: none; + white-space: nowrap; +} + +/* Operational actions: what you can do to this pipeline, kept off the identity row. */ +.portal-pipeline-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.875rem; + border-top: 1px solid var(--c-border-subtle); +} + +/* Destructive, so it sits away from the rest rather than next in line. */ +.portal-pipeline-header__delete.sui-btn { + margin-left: auto; +} + +/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the + backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ +.portal-pipeline-header__result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.875rem; + border-top: 1px solid var(--c-border-subtle); +} + +.portal-pipeline-header__result-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--c-text); +} + +.portal-pipeline-header__result-icon.is-ok { + color: var(--c-success); +} + +.portal-pipeline-header__result-icon.is-bad { + color: var(--c-danger); +} + +/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); + it may wrap to keep a long backend message readable rather than clipping it. */ +.portal-pipeline-header__result-error { + font-size: 0.8125rem; + color: var(--c-text-muted); + min-width: 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx new file mode 100644 index 0000000000..41c73a5ade --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PipelineHeader, + type RunResultSummary, +} from "@portal/components/pipelines/PipelineHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineHeader", + component: PipelineHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** The name and the enabled switch are live, so the section can be seen in both states. */ +function Playground({ + initialName, + isEdit, + initialEnabled = true, + runResult = null, + ...rest +}: { + initialName: string; + isEdit: boolean; + initialEnabled?: boolean; + runResult?: RunResultSummary | null; + saving?: boolean; + testing?: boolean; + running?: boolean; + canSave?: boolean; + stepCount?: number; +}) { + const [name, setName] = useState(initialName); + const [enabled, setEnabled] = useState(initialEnabled); + return ( + + ); +} + +/** An existing pipeline: everything is available. */ +export const Editing: Story = { + render: () => , +}; + +/** + * A pipeline that has never been saved. It can still be tested against a file, but there is + * nothing yet to run on a schedule, clear history for, or delete. + */ +export const New: Story = { + render: () => , +}; + +/** Paused: the pipeline exists but its trigger will not fire. */ +export const Paused: Story = { + render: () => ( + + ), +}; + +/** Mid test-run: the picker shows its own progress while the graph shows the steps. */ +export const Testing: Story = { + render: () => , +}; + +/** After a test run: the outcome and its files sit beside the button that started them. */ +export const WithRunResult: Story = { + render: () => ( + + ), +}; + +/** A failed run: the summary is here, the failing step's own message is on its node. */ +export const WithFailedRun: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx new file mode 100644 index 0000000000..2c5552be07 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineHeader, + type PipelineHeaderProps, +} from "@portal/components/pipelines/PipelineHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onEnabledChange: vi.fn(), + onSave: vi.fn(), + onCancel: vi.fn(), + onBack: vi.fn(), + onTest: vi.fn(), + onRun: vi.fn(), + onClearHistory: vi.fn(), + onDelete: vi.fn(), + onViewDefinition: vi.fn(), + onDownloadOutput: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineHeader", () => { + it("edits the pipeline's name and enabled state", () => { + const handlers = renderHeader(); + fireEvent.change( + screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), + { target: { value: "Renamed" } }, + ); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + + fireEvent.click(screen.getByRole("checkbox")); + expect(handlers.onEnabledChange).toHaveBeenCalledWith(false); + }); + + it("offers run, clear history and delete only once the pipeline exists", () => { + renderHeader({ isEdit: false }); + expect( + screen.queryByText("portal.pipelines.detail.run"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.delete"), + ).not.toBeInTheDocument(); + // A test run needs no saved record, so it stays: it is how you check the steps as you build. + expect( + screen.getByText("portal.pipelines.builder.testRun"), + ).toBeInTheDocument(); + }); + + it("labels the save action for what it will do", () => { + renderHeader({ isEdit: false }); + expect( + screen.getByText("portal.pipelines.composer.create"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.composer.save"), + ).not.toBeInTheDocument(); + }); + + it("blocks saving until the pipeline is valid", () => { + renderHeader({ canSave: false }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("hands the chosen file to the test run", () => { + const handlers = renderHeader(); + const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); + const input = + document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + expect(handlers.onTest).toHaveBeenCalledWith(file); + }); + + it("will not offer a test run on a chain with no steps", () => { + renderHeader({ stepCount: 0 }); + expect( + screen.getByText("portal.pipelines.builder.testRun").closest("button"), + ).toBeDisabled(); + }); + + it("shows why a test run failed, not only that it did", () => { + renderHeader({ + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }); + expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); + }); + + it("runs and deletes from the row, clears history from the tray", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.detail.run")); + expect(handlers.onRun).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); + expect(handlers.onDelete).toHaveBeenCalled(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + expect(handlers.onClearHistory).toHaveBeenCalled(); + }); + + it("leaves the page through cancel and back", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + expect(handlers.onCancel).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.builder.back")); + expect(handlers.onBack).toHaveBeenCalled(); + }); + + it("keeps the occasional actions out of the row, behind a tray", () => { + renderHeader(); + // Running and testing earn a button each; reading the definition and wiping history do not. + expect( + screen.queryByText("portal.pipelines.builder.viewDefinition"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.clearHistory"), + ).not.toBeInTheDocument(); + expect( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ).toBeInTheDocument(); + }); + + it("opens the definition from the tray", () => { + const handlers = renderHeader(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click( + screen.getByText("portal.pipelines.builder.viewDefinition"), + ); + expect(handlers.onViewDefinition).toHaveBeenCalled(); + }); + + it("shows no run strip until a test has been run", () => { + renderHeader(); + expect( + screen.queryByText(/portal.pipelines.inspector.status/), + ).not.toBeInTheDocument(); + }); + + it("reports a finished run and downloads the file clicked", () => { + const handlers = renderHeader({ + runResult: { + status: "completed", + completedSteps: 2, + stepCount: 2, + outputs: [ + { fileId: "f1", fileName: "claim.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }); + fireEvent.click(screen.getByText("claim.pdf")); + expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ + fileId: "f1", + fileName: "claim.pdf", + }); + // A file the backend did not name still has to be reachable. + expect(screen.getByText("f2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx new file mode 100644 index 0000000000..25bfbd044f --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx @@ -0,0 +1,301 @@ +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; +import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; +import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; +import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; +import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import { + ActionIcon, + Button, + Checkbox, + Dropdown, + FilePicker, + Input, + Spinner, +} from "@app/ui"; +import "@portal/components/pipelines/PipelineHeader.css"; + +/** One file a test run produced, downloadable from the result strip. */ +export interface RunOutputFile { + fileId: string; + fileName: string | null; +} + +/** + * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files + * plus the step it stopped at, so there is no per-node output to attach to a node. + */ +export interface RunResultSummary { + status: "running" | "completed" | "failed"; + completedSteps: number; + stepCount: number; + error?: string | null; + outputs?: RunOutputFile[]; +} + +export interface PipelineHeaderProps { + name: string; + onNameChange: (name: string) => void; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + /** False for a pipeline that has never been saved: it cannot yet be run, cleared or deleted. */ + isEdit: boolean; + /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ + stepCount: number; + + canSave: boolean; + saving: boolean; + onSave: () => void; + onCancel: () => void; + onBack: () => void; + + /** Run the steps as they stand against one uploaded file, without saving or delivering. */ + onTest: (file: File) => void; + testing: boolean; + /** Run the saved pipeline against its real input, delivering to its real destination. */ + onRun: () => void; + running: boolean; + onClearHistory: () => void; + clearingHistory: boolean; + onDelete: () => void; + + /** Opens the definition (JSON + cURL), which is pipeline-scoped like the rest of this row. */ + onViewDefinition: () => void; + /** The last test run in this session, or null if there has not been one. */ + runResult: RunResultSummary | null; + onDownloadOutput: (output: RunOutputFile) => void; +} + +/** + * The pipeline's identity and its whole-pipeline actions, at the top of the builder. + * + * Split in two so neither half gets lost in a single crowded row: what the pipeline *is* (name, + * whether it is live) sits with the actions that leave the page, and what you can *do to it* sits + * below the rule. A test run is part of building, so it lives here rather than off in a corner - + * its progress shows on the graph's nodes and its results in the inspector. + */ +export function PipelineHeader({ + name, + onNameChange, + enabled, + onEnabledChange, + isEdit, + stepCount, + canSave, + saving, + onSave, + onCancel, + onBack, + onTest, + testing, + onRun, + running, + onClearHistory, + clearingHistory, + onDelete, + onViewDefinition, + runResult, + onDownloadOutput, +}: PipelineHeaderProps) { + const { t } = useTranslation(); + + return ( +
    +
    + +
    + + +
    +
    + +
    + onNameChange(e.target.value)} + /> + {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch + would imply it applies the moment it is flipped. No description - a second line beside + the single-line name field leaves the row ragged. */} + onEnabledChange(e.target.checked)} + label={t("portal.pipelines.builder.enabled")} + /> +
    + +
    + file && onTest(file)} + leftSection={} + > + {t("portal.pipelines.builder.testRun")} + + + {isEdit && ( + + )} + + {/* Occasional things - reading the definition, wiping the processed history - kept behind a + tray so they do not compete with running and testing, which is what this row is for. */} + + + + + + + + } + > + {t("portal.pipelines.builder.viewDefinition")} + + {isEdit && ( + + } + > + {t("portal.pipelines.detail.clearHistory")} + + )} + + + + {isEdit && ( + + )} +
    + + {runResult && ( + + )} +
    + ); +} + +interface RunResultStripProps { + result: RunResultSummary; + onDownload: (output: RunOutputFile) => void; +} + +/** What the last test run did, beside the button that started it. */ +function RunResultStrip({ result, onDownload }: RunResultStripProps) { + const { t } = useTranslation(); + const outputs = result.outputs ?? []; + + return ( +
    +
    + {result.status === "running" && } + {result.status === "completed" && ( + + )} + {result.status === "failed" && ( + + )} + + {t(`portal.pipelines.inspector.status.${result.status}`, { + done: result.completedSteps, + count: result.stepCount, + })} + +
    + + {/* The reason it failed, where the failure is announced - not only on the node, which the user + has to know to click. */} + {result.status === "failed" && result.error && ( + + {result.error} + + )} + + {outputs.map((output) => ( + + ))} +
    + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.css b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css new file mode 100644 index 0000000000..0fc50597e8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css @@ -0,0 +1,34 @@ +/** + * The builder's right-hand panel: the selected node's settings. + */ + +.portal-inspector { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.125rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); + /* The builder caps its columns so the page itself does not scroll, which means a settings form + taller than the viewport has to scroll in here - otherwise its lower half is unreachable. */ + max-height: 100%; +} + +.portal-inspector__body { + display: flex; + flex-direction: column; + gap: 0.875rem; + min-height: 0; + overflow-y: auto; +} + +/* Names the node being edited, so the panel is not just a nameless form. */ +.portal-inspector__title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx new file mode 100644 index 0000000000..ff4c8f3d21 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormField, Input, Select } from "@app/ui"; +import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineInspector", + component: PipelineInspector, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Stands in for a node's real editor, which the builder supplies. */ +function StubSettings() { + return ( + <> + + + + + } onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") onClose(); @@ -104,36 +104,48 @@ export function ToolPicker({
    {group.label}
    - {group.tools.map((tool) => ( - - ))} + + ); + })} )) )} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css new file mode 100644 index 0000000000..a821b9910e --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css @@ -0,0 +1,180 @@ +/** + * One wire between two nodes, plus its insert affordance. The graph positions it; the wire is a + * 1px rule centred on the column with a filled arrowhead at the arriving end. + */ + +.portal-graph-edge { + position: absolute; + /* A wide drop target: the wire is a thin line, but a dragged step can be released anywhere across + the row, so the whole band between the nodes catches it. `left` is the column centre, so pull + back by half to keep the band centred on it. Line, insert and warning are placed absolutely + within. */ + width: 16rem; + transform: translateX(-50%); + --edge-color: var(--c-border-strong); +} + +.portal-graph-edge__line { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--edge-color); +} + +/** + * Arrowhead at the arriving end, so the chain reads as directed. + */ +.portal-graph-edge__line::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 0.1875rem solid transparent; + border-right: 0.1875rem solid transparent; + border-top: 0.3125rem solid var(--edge-color); +} + +/* Insert: the shared ActionIcon restyled to a small dot beside the wire (not on top of it, where it + hid the line). Hidden at rest - a solid plus on every wire reads as busy - and revealed only when + the pointer is over this wire's drop band (see the reveal rule below). When shown it is solid, not + faint: off to one side on the canvas it needs a real border and glyph to be seen at all. */ +.portal-graph-edge__insert.sui-ai { + position: absolute; + top: 50%; + /* Beside the wire: the column centre is 50%, nudge clear of the line and centre on the row. */ + left: 50%; + transform: translate(0.6rem, -50%); + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: 1px solid var(--c-border-strong); + background: var(--c-surface); + color: var(--c-text-muted); + opacity: 0; + transition: + opacity var(--motion-fast), + color var(--motion-fast), + border-color var(--motion-fast), + background var(--motion-fast); +} + +/* Optically centre the glyph in the circle: MUI's Add icon carries a hair of bottom bias. */ +.portal-graph-edge__insert.sui-ai svg { + display: block; +} + +/* On a warning wire the insert sits just past the pill, out of flow so the pill stays centred on the + wire whether or not the insert is showing (it reveals on hover like every other wire's). */ +.portal-graph-edge__note .portal-graph-edge__insert.sui-ai { + left: 100%; + margin-left: 0.375rem; + transform: translateY(-50%); +} + +/* Reveal the insert when the pointer is anywhere in this wire's drop band, or it has keyboard focus. + Revealing is not highlighting: it comes in at its resting weight and only goes primary once the + pointer is on the button itself (below) - not from anywhere in the wide band. */ +.portal-graph-edge:hover .portal-graph-edge__insert.sui-ai, +.portal-graph-edge__insert.sui-ai:focus-visible { + opacity: 1; +} + +.portal-graph-edge__insert.sui-ai:hover, +.portal-graph-edge__insert.sui-ai:focus-visible { + color: var(--c-accent-fg, var(--c-primary)); + border-color: var(--c-primary); +} + +/* A wire with no slot of its own (either side of the placeholder): line only. */ +.portal-graph-edge.is-plain { + --edge-color: var(--c-border-subtle); +} + +/** + * The pairing does not make much sense. Advisory: the wire still accepts drops and the chain still + * runs - the order stays the user's choice. + */ +.portal-graph-edge.has-warning { + --edge-color: var(--c-warning); +} + +/* The note and its insert ride together, centred on the wire, so a warned pairing keeps a way to + take a fixing step between its ends. Grows to its content and may overhang the band, which the + wider graph column absorbs. */ +.portal-graph-edge__note { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.portal-graph-edge__warning { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.0625rem 0.375rem; + border-radius: var(--radius-pill); + border: 1px solid var(--c-warning); + /* Matches the shared Banner's warning treatment: tinted ground, amber border and glyph, ordinary + text. Amber words would not clear 4.5:1 at this size, and the tint is what makes the neutral + text read as part of a warning rather than as stray body copy. */ + background: color-mix(in srgb, var(--c-warning) 12%, var(--c-surface)); + color: var(--c-text); + font-size: 0.6875rem; + line-height: 1.4; +} + +.portal-graph-edge__warning svg { + color: var(--c-warning); + flex: none; +} + +/* A blocking pairing: the chain cannot run in this order, so it must not read as the same gentle + advice as an odd-but-workable one. Same shape, danger tone - including the wire and its head, + which follow --edge-color. */ +.portal-graph-edge.is-blocking { + --edge-color: var(--c-danger); +} + +.portal-graph-edge.is-blocking .portal-graph-edge__warning { + border-color: var(--c-danger); + background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface)); +} + +.portal-graph-edge.is-blocking .portal-graph-edge__warning svg { + color: var(--c-danger); +} + +.portal-graph-edge__warning-label { + white-space: nowrap; +} + +/* While a step is being dragged, the insert is beside the point - the wire itself is the target - + and it would only clutter the row and collide with the drag hint. Hide it until the drag ends. + The wire itself stays at rest until the step is actually over it: lighting every wire the moment + a drag starts is noise, not a cue. */ +.portal-graph-edge.is-available .portal-graph-edge__insert.sui-ai { + display: none; +} + +/* The step is over this wire and would land here on release: only then does the wire go primary. */ +.portal-graph-edge.is-over { + --edge-color: var(--c-primary); +} + +.portal-graph-edge.is-over .portal-graph-edge__line { + width: 2px; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx new file mode 100644 index 0000000000..7f7dcca5d4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import { ActionIcon } from "@app/ui"; +import type { LaidOutEdge } from "@portal/components/pipelines/graph/pipelineLayout"; +import { useEdgeDrop } from "@portal/components/pipelines/graph/useChainDragDrop"; +import "@portal/components/pipelines/graph/GraphEdge.css"; + +/** + * A note on the wire arriving at a node: why what flows in will not suit it. + * + * `blocking` separates "this cannot run in this order" from "this is probably not what you meant". + * Both are shown on the wire and neither refuses the edit - the order stays the user's to choose - + * but only a blocking one stops the pipeline being saved, so it must not read as mere advice. + */ +export interface ChainWarning { + text: string; + blocking?: boolean; +} + +export interface GraphEdgeProps { + edge: LaidOutEdge; + /** Add a new step in the slot this wire opens. */ + onInsert: (index: number) => void; + stepCount: number; + /** Given the chain's new order as original step indices, and which steps the drag carried. */ + onReorder: (order: number[], moved: readonly number[]) => void; + /** A step is in flight, so open wires advertise themselves as landing spots. */ + dragActive: boolean; + /** + * Why what flows along this wire will not be much use to the node it arrives at (encrypting + * before an OCR, say). Never refuses the edit - the order stays the user's to choose - but a + * blocking one means the chain cannot run at all, and is coloured apart from mere advice. + */ + warning?: ChainWarning; +} + +/** + * One wire between two nodes: a directed line carrying an insert affordance, and the drop target + * that catches a step dragged onto it. Where the pairing does not make sense the wire says so, + * rather than refusing it. + */ +export function GraphEdge({ + edge, + onInsert, + stepCount, + onReorder, + dragActive, + warning, +}: GraphEdgeProps) { + const { t } = useTranslation(); + const { ref, over } = useEdgeDrop({ + insertIndex: edge.insertIndex, + stepCount, + onReorder, + }); + const open = edge.insertIndex !== null; + + // The insert affordance is shown whenever the wire opens a slot - including on a warned wire, so a + // bad pairing can still take a fixing step between its ends rather than losing its only way in. + const insertButton = open ? ( + onInsert(edge.insertIndex as number)} + > + + + ) : null; + + return ( +
    + + {warning ? ( + + + + + {warning.text} + + + {insertButton} + + ) : ( + insertButton + )} +
    + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css new file mode 100644 index 0000000000..9bea07fd4b --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css @@ -0,0 +1,140 @@ +/** + * Graph-only extras layered on the shared NodeCard tile (see @app/ui/NodeCard): the config warning + * line, drag dimming, the remove control and run-state glyphs. The surface, selection ring and + * icon/title/detail layout all live in NodeCard. + */ + +/* Amber carries the tone on the glyph; the words stay body-coloured. --c-warning is amber-600, + which is only 3.18:1 on a light surface at this size (axe) - readable as an icon, not as text. */ +.portal-graph-node__warning { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--c-text); +} + +.portal-graph-node__warning svg { + color: var(--c-warning); + flex: none; +} + +/* Lifted out of the chain: the origin dims so the drop target reads as the real position. */ +.portal-graph-node.is-dragging { + opacity: 0.4; +} + +/* Steps can be picked up and moved; the input and output are fixed ends. */ +.portal-graph-node--step .sui-node-card__select.sui-btn { + cursor: grab; +} + +.portal-graph-node--step.is-dragging .sui-node-card__select.sui-btn { + cursor: grabbing; +} + +/* Remove: quiet until the node is hovered or focused, so the chain stays calm. */ +.portal-graph-node__remove.sui-ai { + position: absolute; + top: -0.4375rem; + /* Logical, so in RTL the remove sits on the card's trailing (left) corner rather than on top of + the leading icon badge. */ + inset-inline-end: -0.4375rem; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-subtle); + opacity: 0; + transition: + opacity var(--motion-fast), + color var(--motion-fast), + border-color var(--motion-fast); +} + +.portal-graph-node:hover .portal-graph-node__remove.sui-ai, +.portal-graph-node.is-selected .portal-graph-node__remove.sui-ai, +.portal-graph-node__remove.sui-ai:focus-visible { + opacity: 1; +} + +.portal-graph-node__remove.sui-ai:hover { + color: var(--c-danger); + border-color: var(--c-danger); +} + +/* Run state: a status glyph on the card's trailing edge, inside the node. The state is carried by + the icon's shape as well as its colour, with the wording kept for assistive tech. */ +.portal-graph-node__run { + flex: none; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + margin-inline-end: 0.625rem; + color: var(--c-text-subtle); +} + +/* A failed step's glyph is a button (it opens the error), so re-assert the plain glyph look over + the shared ActionIcon base. */ +.portal-graph-node__run--open.sui-ai { + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: none; + background: none; + color: var(--c-danger); +} + +.portal-graph-node__run-label { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.portal-graph-node.is-done .portal-graph-node__run { + color: var(--c-success); +} + +.portal-graph-node.is-failed .portal-graph-node__run { + color: var(--c-danger); +} + +/* Running is carried by the pulsing glyph alone: a primary border here would be the selected + treatment, and "the step I am editing" must stay distinguishable from "the step running now". */ +.portal-graph-node.is-running .portal-graph-node__run { + color: var(--c-primary); +} + +.portal-graph-node__pulse { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: currentColor; + animation: portal-graph-pulse 1.2s ease-in-out infinite; +} + +@keyframes portal-graph-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .portal-graph-node__pulse { + animation: none; + } +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx new file mode 100644 index 0000000000..c19a7c15f2 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx @@ -0,0 +1,183 @@ +import type { MouseEvent, ReactNode, Ref } from "react"; +import { useTranslation } from "react-i18next"; +import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; +import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; +import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import { ActionIcon, NodeCard } from "@app/ui"; +import { type IconBadgeAccent } from "@app/ui/IconBadge"; +import type { GraphNodeKind } from "@portal/components/pipelines/graph/pipelineLayout"; +import "@portal/components/pipelines/graph/GraphNode.css"; + +/** How a node is faring in the current or last test run. */ +export type NodeRunState = "running" | "done" | "failed"; + +/** The kinds this card renders. The placeholder is its own component, not a node variant. */ +type CardKind = Exclude; + +const KIND_ICON: Record = { + input: , + step: , + output: , +}; + +const KIND_ACCENT: Record = { + input: "green", + step: "blue", + output: "purple", +}; + +export interface GraphNodeProps { + kind: CardKind; + title: string; + /** One-line summary under the title (the source's path, a step's parameters). */ + detail?: string; + /** + * Problem with this node's configuration, shown in place of the detail. Distinct from a run + * failure: this is why the pipeline cannot be saved yet. + */ + warning?: string; + /** The step's own tool glyph; falls back to a per-kind default. */ + icon?: ReactNode; + selected: boolean; + runState?: NodeRunState; + onOpenRunState?: () => void; + onSelect: (event: MouseEvent) => void; + /** Takes the node off the chain. For an end, that returns its row to a placeholder. */ + onRemove?: () => void; + /** True while this node is being dragged to another place in the chain. */ + dragging?: boolean; + /** + * The step's place in the chain, so a multi-step drag preview can find the other selected cards + * in the DOM. Absent for the input and output, which are never dragged. + */ + stepIndex?: number; + /** The card element, for the drag adapter to register against. */ + ref?: Ref; +} + +/** + * One node in the pipeline graph: the shared {@link NodeCard} tile carrying its glyph, title and a + * one-line summary, plus the graph-only extras layered on top - run status, a remove control, drag + * dimming, and the "why this cannot be saved" warning line. Position is applied by the graph, so the + * node itself knows nothing about layout. + */ +export function GraphNode({ + kind, + title, + detail, + warning, + icon, + selected, + runState, + onOpenRunState, + onSelect, + onRemove, + dragging, + stepIndex, + ref, +}: GraphNodeProps) { + const { t } = useTranslation(); + + const runStatus = runState && ( + + ); + const remove = onRemove && ( + + + + ); + + return ( + + + {warning} + + ) : ( + detail + ) + } + trailing={ + <> + {runStatus} + {remove} + + } + /> + ); +} + +interface RunStatusProps { + runState: NodeRunState; + title: string; + onOpenRunState?: () => void; +} + +/** The run glyph on the card's trailing edge; a button when it opens a failure, else a status. */ +function RunStatus({ runState, title, onOpenRunState }: RunStatusProps) { + const { t } = useTranslation(); + if (runState === "failed" && onOpenRunState) { + return ( + + + + ); + } + return ( + + {runState === "running" && ( + + )} + {runState === "done" && ( + + )} + {runState === "failed" && ( + + )} + + {t(`portal.pipelines.graph.run.${runState}`)} + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css new file mode 100644 index 0000000000..dba96072c4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css @@ -0,0 +1,44 @@ +/** + * The empty-pipeline stand-in: a dashed node in the row the first step will take. Dashed rather + * than solid so it reads as "not yet a step", and full width so it is an obvious target. + */ + +.portal-graph-placeholder.sui-btn { + width: 100%; + height: auto; + min-height: 0; + padding: 0.625rem 0.75rem; + background: none; + border: 1px dashed var(--c-border-strong); + border-radius: var(--radius-lg); + color: var(--c-text-muted); + font-weight: 400; + text-align: left; + transition: + border-color var(--motion-fast), + color var(--motion-fast), + background var(--motion-fast); +} + +.portal-graph-placeholder.sui-btn:hover { + border-color: var(--c-primary); + border-style: solid; + color: var(--c-accent-fg, var(--c-primary)); + background: var(--c-surface); +} + +/* Mantine lays a button's children out inside its label element, not on the root. */ +.portal-graph-placeholder.sui-btn .mantine-Button-label { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.625rem; + min-width: 0; + overflow: visible; +} + +.portal-graph-placeholder__title { + font-size: 0.875rem; + font-weight: 500; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx new file mode 100644 index 0000000000..0ecee1bfc7 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx @@ -0,0 +1,30 @@ +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button } from "@app/ui"; +import "@portal/components/pipelines/graph/GraphPlaceholderNode.css"; + +export interface GraphPlaceholderNodeProps { + label: string; + onAdd: () => void; +} + +/** + * The stand-in for a row the pipeline has not filled yet - the first step, or either end of the + * chain on a new pipeline. It sits in the row that thing will occupy, so the chain reads as + * input -> something -> output straight away, and it is the thing you click to fill it: a full-width + * target, rather than a caption pointing at a small plus on a wire. + */ +export function GraphPlaceholderNode({ + label, + onAdd, +}: GraphPlaceholderNodeProps) { + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css new file mode 100644 index 0000000000..c9aff67012 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css @@ -0,0 +1,79 @@ +/** + * The graph surface. The canvas is sized by the derived layout and centred in the scroll area, so + * the chain stays put as steps are added or removed. + */ + +.portal-graph { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1rem; + /* Grows with the chain up to whatever the page gives it, then scrolls rather than pushing the + inspector out of reach. A short chain still hugs its content, so there is no empty canvas. */ + max-height: 100%; + overflow: auto; + /* The canvas must sit below the node cards on the surface ladder so they read as raised off it in + both themes. --c-surface-sunken is the only rung darker than --c-surface in light *and* dark; + the legacy --color-bg-subtle collapsed into the page in dark, and --c-bg-raised is lighter than + the cards in light. */ + background: var(--c-surface-sunken); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); +} + +/* Sits beside the wire it is describing. Absolute, so appearing mid-drag moves nothing. */ +.portal-graph__drag-hint { + position: absolute; + margin: 0; + /* `left` is the column centre; clear the wire's hit area before the text starts. */ + transform: translate(1.75rem, -50%); + white-space: nowrap; + font-size: 0.75rem; + font-weight: 500; + color: var(--c-accent-fg, var(--c-primary)); + pointer-events: none; +} + +/* transform has no logical form, so mirror it by hand: in RTL the hint clears the wire on the other + side rather than reaching back across it onto the chain. */ +[dir="rtl"] .portal-graph__drag-hint { + transform: translate(-1.75rem, -50%); +} + +/* What follows the cursor when several steps are dragged at once: a copy of each card, stacked, so + the drag shows what is actually moving rather than only the card that was grabbed. Cloned nodes + keep their own styling; they are inert copies, hence no pointer events. */ +.portal-graph__drag-preview { + display: flex; + flex-direction: column; + gap: 0.375rem; + pointer-events: none; +} + +.portal-graph__drag-preview .portal-graph-node { + opacity: 0.9; +} + +.portal-graph__canvas { + position: relative; + flex: none; +} + +/* Nodes are placed by the layout; the slot carries the position, the card fills it. */ +.portal-graph__slot { + position: absolute; + display: flex; +} + +.portal-graph__slot > * { + flex: 1; + min-width: 0; +} + +.portal-graph__hint { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-subtle); + text-align: center; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx new file mode 100644 index 0000000000..c16291bd9c --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx @@ -0,0 +1,207 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PipelineGraph, + selectedSteps, + type ChainEnd, + type GraphNodeContent, + type GraphSelection, + type GraphStepContent, +} from "@portal/components/pipelines/graph/PipelineGraph"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineGraph", + component: PipelineGraph, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const INPUT = { + label: "Claims intake", + detail: "/srv/claims/in - every hour", +}; +const OUTPUT = { + label: "Archive bucket", + detail: "s3://claims-archive/done", +}; + +/** + * The builder owns the chain in the app, so the stories own it here - otherwise adding, removing + * and dragging would fire their handlers and visibly do nothing. Everything in these stories is + * live: click a wire's plus to insert, the node's X to remove, and drag a step onto a wire to move + * it there. + */ +function Playground({ + initialSteps, + output = OUTPUT, + /** Start with neither end on the chain, the way a brand new pipeline opens. */ + unplacedEnds = false, +}: { + initialSteps: GraphStepContent[]; + output?: { label: string; detail?: string; warning?: string }; + unplacedEnds?: boolean; +}) { + const [steps, setSteps] = useState(initialSteps); + const [selected, setSelected] = useState(null); + const [added, setAdded] = useState(0); + const [inputEnd, setInputEnd] = useState( + unplacedEnds ? null : INPUT, + ); + const [outputEnd, setOutputEnd] = useState( + unplacedEnds ? null : output, + ); + + // Placing an end leaves it owing a choice, which is the warning state the builder shows until the + // user picks a source or destination. + function addEnd(end: ChainEnd) { + if (end === "input") { + setInputEnd({ label: "Choose a source", warning: "No source chosen" }); + } else { + setOutputEnd({ + label: "Choose a destination", + warning: "No destination chosen", + }); + } + setSelected(end); + } + + function removeEnd(end: ChainEnd) { + if (end === "input") setInputEnd(null); + else setOutputEnd(null); + setSelected((current) => (current === end ? null : current)); + } + + function insert(at: number) { + const label = `New tool ${added + 1}`; + setAdded((n) => n + 1); + setSteps((current) => { + const next = [...current]; + next.splice(at, 0, { label }); + return next; + }); + setSelected({ steps: [at] }); + } + + function remove(indices: number[]) { + const gone = new Set(indices); + setSteps((current) => current.filter((_, i) => !gone.has(i))); + setSelected(null); + } + + function reorder(order: number[]) { + const moving = new Set(selectedSteps(selected)); + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moving.has(original)) + .map(({ position }) => position); + setSelected(landed.length > 0 ? { steps: landed } : null); + } + + return ( + + ); +} + +/** A typical chain. Drag a step onto any wire to move it there. */ +export const Default: Story = { + render: () => ( + + ), +}; + +/** A pipeline with its ends settled but no steps yet: the placeholder holds the first step's place. */ +export const Empty: Story = { + render: () => , +}; + +/** + * A brand new pipeline, before anything has been chosen. Every row is an invitation rather than a + * complaint - nothing is wrong yet, because nothing has been asked of the user. Click an end to + * place it (it then owes a choice, and says so), and its X puts it back. + */ +export const NewPipeline: Story = { + render: () => , +}; + +/** + * An order that will not do what the user probably meant: OCR cannot read a file that the previous + * step encrypted. The wire says so and the chain still runs - nothing is refused, and the step can + * still be dragged anywhere. + */ +export const OddOrdering: Story = { + render: () => ( + + ), +}; + +/** Mid test-run: finished steps carry a tick, the current one pulses. */ +export const Running: Story = { + render: () => ( + + ), +}; + +/** A failed run, and a step that cannot be saved: the warning replaces the detail line. */ +export const Problems: Story = { + render: () => ( + + ), +}; + +/** + * Multi-selection: cmd/ctrl-click to add a step, shift-click for a run of them, then drag any one + * onto a line to move the whole set together. + */ +export const MultiSelect: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx new file mode 100644 index 0000000000..c94bd512f3 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx @@ -0,0 +1,455 @@ +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineGraph, + type GraphSelection, + type PipelineGraphProps, +} from "@portal/components/pipelines/graph/PipelineGraph"; + +// The nodes and wires are built from the shared Mantine-backed controls, so they need the provider. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +// Deterministic i18n: keys returned verbatim, interpolation applied so aria-labels stay distinct. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, vars?: Record) => + vars?.name ? `${key}:${String(vars.name)}` : key, + }), +})); + +function renderGraph(overrides: Partial = {}) { + const handlers = { + onSelect: vi.fn(), + onAddEnd: vi.fn(), + onRemoveEnd: vi.fn(), + onInsertStep: vi.fn(), + onRemoveSteps: vi.fn(), + onReorderSteps: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineGraph", () => { + it("renders the chain: input, each step in order, output", () => { + renderGraph(); + const titles = screen + .getAllByRole("button", { pressed: false }) + .map((node) => node.textContent); + expect(titles[0]).toContain("Claims intake"); + expect(titles[1]).toContain("OCR"); + expect(titles[2]).toContain("Redact"); + expect(titles[3]).toContain("Archive bucket"); + }); + + it("shows each node's one-line detail", () => { + renderGraph(); + expect(screen.getByText("/in - every hour")).toBeInTheDocument(); + expect(screen.getByText("s3://claims/done")).toBeInTheDocument(); + }); + + it("selects the ends by their kind and steps by index", () => { + const handlers = renderGraph(); + fireEvent.click(screen.getByText("Claims intake")); + expect(handlers.onSelect).toHaveBeenCalledWith("input"); + fireEvent.click(screen.getByText("Archive bucket")); + expect(handlers.onSelect).toHaveBeenCalledWith("output"); + fireEvent.click(screen.getByText("Redact")); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [1] }); + }); + + it("adds and removes steps from the selection with cmd/ctrl-click", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.click(screen.getByText("Redact"), { metaKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1] }); + }); + + it("cmd/ctrl-clicking the only selected step clears the selection", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(screen.getByText("Redact"), { ctrlKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith(null); + }); + + it("shift-click takes everything between the anchor and the clicked step", () => { + const handlers = renderGraph({ + selected: { steps: [0] }, + steps: [ + { label: "OCR" }, + { label: "Redact" }, + { label: "Compress" }, + { label: "Stamp" }, + ], + }); + fireEvent.click(screen.getByText("Stamp"), { shiftKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1, 2, 3] }); + }); + + it("marks every selected step as pressed, not just one", () => { + renderGraph({ selected: { steps: [0, 1] } }); + for (const label of ["OCR", "Redact"]) { + expect(screen.getByText(label).closest("button")).toHaveAttribute( + "aria-pressed", + "true", + ); + } + }); + + it("keeps the drag hint silent until a drag starts", () => { + // Only the silent half is testable here: starting a real drag needs native HTML5 drag events, + // which jsdom does not implement, so the visible half is checked in a browser. + renderGraph(); + expect( + screen.queryByText("portal.pipelines.graph.dragHint"), + ).not.toBeInTheDocument(); + }); + + it("clears the selection when the canvas itself is clicked", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(document.querySelector(".portal-graph") as HTMLElement); + expect(handlers.onSelect).toHaveBeenCalledWith(null); + }); + + it("does not clear the selection when a node is clicked", () => { + // The node's own handler runs; the background handler must not undo it. + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(screen.getByText("OCR")); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0] }); + expect(handlers.onSelect).not.toHaveBeenCalledWith(null); + }); + + it("marks the selected node as pressed", () => { + renderGraph({ selected: { steps: [0] } }); + expect(screen.getByText("OCR").closest("button")).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("puts an insert on every wire, reporting the slot it opens", () => { + const handlers = renderGraph(); + // input->OCR, OCR->Redact, Redact->output + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + expect(inserts).toHaveLength(3); + fireEvent.click(inserts[1]); + expect(handlers.onInsertStep).toHaveBeenCalledWith(1); + }); + + it("holds the first step's place with a placeholder when the chain is empty", () => { + const handlers = renderGraph({ steps: [] }); + // The placeholder is the affordance, so the wires either side of it carry no plus of their + // own - two ways to fill the same slot would be a choice with no difference. + expect( + screen.queryByLabelText("portal.pipelines.graph.insertHere"), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.pipelines.graph.addFirstTool")); + expect(handlers.onInsertStep).toHaveBeenCalledWith(0); + }); + + it("drops the placeholder once the chain has a step", () => { + renderGraph({ steps: [{ label: "OCR" }] }); + expect( + screen.queryByText("portal.pipelines.graph.addFirstTool"), + ).not.toBeInTheDocument(); + }); + + it("warns on the wire arriving at a step it makes little sense to feed", () => { + renderGraph({ + steps: [ + { label: "Add Password" }, + { + label: "OCR", + inputWarning: { text: "OCR cannot read an encrypted file" }, + }, + ], + }); + expect( + screen.getByText("OCR cannot read an encrypted file"), + ).toBeInTheDocument(); + }); + + it("still allows the odd pairing: warned wires keep taking inserts", () => { + // Advisory, not a block - the order stays the user's to choose. + const handlers = renderGraph({ + steps: [ + { label: "Add Password" }, + { + label: "OCR", + inputWarning: { text: "OCR cannot read an encrypted file" }, + }, + ], + }); + // The warned wire shows its note and keeps its plus, so a fixing step can still go between the + // ends that do not suit each other - every wire takes an insert. + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + expect(inserts).toHaveLength(3); + // The middle wire is the warned one (Add Password -> OCR); inserting there lands between them. + fireEvent.click(inserts[1]); + expect(handlers.onInsertStep).toHaveBeenCalledWith(1); + }); + + it("marks a blocking pairing apart from a merely odd one", () => { + // Both sit on the wire and neither refuses the edit, but one means the chain cannot run at + // all - so it must not read as the same gentle advice. + renderGraph({ + steps: [ + { label: "Extract images" }, + { + label: "Compress", + inputWarning: { text: "Compress needs a PDF", blocking: true }, + }, + ], + }); + const wire = screen + .getByText("Compress needs a PDF") + .closest(".portal-graph-edge"); + expect(wire).toHaveClass("is-blocking"); + }); + + it("leaves an advisory pairing unblocked", () => { + renderGraph({ + steps: [ + { label: "Add Password" }, + { label: "OCR", inputWarning: { text: "OCR cannot read this" } }, + ], + }); + expect( + screen.getByText("OCR cannot read this").closest(".portal-graph-edge"), + ).not.toHaveClass("is-blocking"); + }); + + it("warns on the wire into the output too", () => { + renderGraph({ + output: { + label: "Archive", + inputWarning: { text: "Nothing writes a folder here" }, + }, + }); + expect( + screen.getByText("Nothing writes a folder here"), + ).toBeInTheDocument(); + }); + + it("removes a step from the node itself", () => { + const handlers = renderGraph(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.graph.removeNode:Redact"), + ); + expect(handlers.onRemoveSteps).toHaveBeenCalledWith([1]); + }); + + it("every node on the chain carries its own remove, ends included", () => { + renderGraph(); + // Two steps plus both ends: an end can be taken back off to its placeholder. + expect(screen.getAllByLabelText(/graph.removeNode/)).toHaveLength(4); + }); + + it("takes an end back off the chain", () => { + const handlers = renderGraph(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.graph.removeNode:Claims intake"), + ); + expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input"); + }); + + it("deletes every selected step with the Delete key", () => { + const handlers = renderGraph({ selected: { steps: [0, 1] } }); + fireEvent.keyDown(screen.getByText("Redact"), { key: "Delete" }); + expect(handlers.onRemoveSteps).toHaveBeenCalledWith([0, 1]); + }); + + it("takes a selected end off the chain with the Delete key, like its X does", () => { + const handlers = renderGraph({ selected: "input" }); + fireEvent.keyDown(screen.getByText("Claims intake"), { key: "Delete" }); + expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input"); + // An end is not a step, so the step remover stays out of it. + expect(handlers.onRemoveSteps).not.toHaveBeenCalled(); + }); + + it("ignores Delete when nothing is selected", () => { + const handlers = renderGraph({ selected: null }); + fireEvent.keyDown(screen.getByText("OCR"), { key: "Delete" }); + expect(handlers.onRemoveSteps).not.toHaveBeenCalled(); + expect(handlers.onRemoveEnd).not.toHaveBeenCalled(); + }); + + it("moves the selected step down the chain with Alt+ArrowDown", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // [OCR, Redact] with OCR moved down -> [Redact, OCR]; the dragged step is the reorder payload. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [0]); + }); + + it("moves the selected step up the chain with Alt+ArrowUp", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.keyDown(screen.getByText("Redact"), { + key: "ArrowUp", + altKey: true, + }); + // [OCR, Redact] with Redact moved up -> [Redact, OCR]. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [1]); + }); + + it("moves a multi-step selection together, keeping it as the payload", () => { + const handlers = renderGraph({ + selected: { steps: [0, 1] }, + steps: [{ label: "OCR" }, { label: "Redact" }, { label: "Compress" }], + }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // [OCR, Redact, Compress] with [OCR, Redact] moved down -> [Compress, OCR, Redact]. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([2, 0, 1], [0, 1]); + }); + + it("does not reorder past the end of the chain", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowUp", + altKey: true, + }); + expect(handlers.onReorderSteps).not.toHaveBeenCalled(); + }); + + it("leaves a bare arrow alone, so only the modifier reorders", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { key: "ArrowDown" }); + expect(handlers.onReorderSteps).not.toHaveBeenCalled(); + }); + + it("carries focus to the moved step so it can be walked several slots", () => { + // A real reorder renumbers the nodes, so focus has to follow the step or a second key press + // would act on whatever now sits where it started. Drive it through a stateful host. + function Host() { + const [steps, setSteps] = useState([ + { label: "OCR" }, + { label: "Redact" }, + { label: "Compress" }, + ]); + const [selected, setSelected] = useState({ steps: [0] }); + return ( + { + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moved.includes(original)) + .map(({ position }) => position); + setSelected({ steps: landed }); + }} + /> + ); + } + render(); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // OCR now sits at position 1, and its card's select button holds focus. + expect(document.activeElement?.textContent).toContain("OCR"); + expect( + document.activeElement?.closest("[data-step-index]"), + ).toHaveAttribute("data-step-index", "1"); + }); + + describe("an end the pipeline has not asked for yet", () => { + it("offers to add it instead of naming it", () => { + renderGraph({ input: null }); + expect( + screen.getByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect(screen.queryByText("Claims intake")).not.toBeInTheDocument(); + }); + + it("greets a brand new pipeline with no warnings at all", () => { + // The whole point: an end nobody has been offered yet is not a problem to report. + renderGraph({ + input: null, + output: null, + steps: [], + }); + expect(screen.queryByText(/warning|chosen/i)).not.toBeInTheDocument(); + expect(screen.getAllByText(/graph.add\./)).toHaveLength(2); + }); + + it("asks for the end when its placeholder is clicked", () => { + const handlers = renderGraph({ output: null }); + fireEvent.click(screen.getByText("portal.pipelines.graph.add.output")); + expect(handlers.onAddEnd).toHaveBeenCalledWith("output"); + }); + + it("has nothing to remove until it is placed", () => { + renderGraph({ input: null, output: null, steps: [] }); + expect( + screen.queryByLabelText(/graph.removeNode/), + ).not.toBeInTheDocument(); + }); + + it("carries no warning onto the wire that arrives at it", () => { + renderGraph({ output: null }); + expect( + screen.queryByText("Nothing writes a folder here"), + ).not.toBeInTheDocument(); + }); + }); + + it("shows a node's warning in place of its detail", () => { + renderGraph({ + steps: [ + { label: "Watermark", detail: "logo.png", warning: "Needs a file" }, + ], + }); + expect(screen.getByText("Needs a file")).toBeInTheDocument(); + expect(screen.queryByText("logo.png")).not.toBeInTheDocument(); + }); + + it("reports a run's progress on the steps it touched", () => { + renderGraph({ + steps: [ + { label: "OCR", runState: "done" }, + { label: "Redact", runState: "running" }, + ], + }); + expect( + screen.getByText("portal.pipelines.graph.run.done"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.graph.run.running"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx new file mode 100644 index 0000000000..7afb3a9f41 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx @@ -0,0 +1,379 @@ +import { + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + GraphNode, + type NodeRunState, +} from "@portal/components/pipelines/graph/GraphNode"; +import { + GraphEdge, + type ChainWarning, +} from "@portal/components/pipelines/graph/GraphEdge"; +import { GraphPlaceholderNode } from "@portal/components/pipelines/graph/GraphPlaceholderNode"; +import { + NODE_HEIGHT, + NODE_WIDTH, + layoutChain, + reorderMany, + stepIndexOf, +} from "@portal/components/pipelines/graph/pipelineLayout"; +import { useStepDraggable } from "@portal/components/pipelines/graph/useChainDragDrop"; +import "@portal/components/pipelines/graph/PipelineGraph.css"; + +// The wire renders it, but callers build it, so it is re-exported from the graph they talk to. +export type { ChainWarning }; + +/** + * What is selected: an end of the chain, one or more steps, or nothing. Only steps come in sets - + * the input and output are fixed ends, so there is nothing to gather or move. + */ +export type GraphSelection = "input" | "output" | { steps: number[] } | null; + +/** The selected step indices, in chain order. Empty unless steps are what is selected. */ +export function selectedSteps(selection: GraphSelection): number[] { + return selection !== null && typeof selection === "object" + ? selection.steps + : []; +} + +/** A node's display content. The graph never derives copy - the builder owns every label. */ +export interface GraphNodeContent { + label: string; + /** One-line summary: the source's path, a step's parameters, the destination. */ + detail?: string; + /** Why this node blocks saving, shown in place of the detail. */ + warning?: string; + /** Why the input will not be much use. */ + inputWarning?: ChainWarning; +} + +export interface GraphStepContent extends GraphNodeContent { + icon?: ReactNode; + runState?: NodeRunState; +} + +/** Which end of the chain: the two nodes every finished pipeline has, one of each. */ +export type ChainEnd = "input" | "output"; + +export interface PipelineGraphProps { + /** + * The chain's ends, or null while a new pipeline has yet to ask for one. Null renders the row as a + * placeholder to fill rather than a node owing a choice, which is what keeps a brand new pipeline + * from opening on a pair of warnings about decisions its author has not been offered yet. + */ + input: GraphNodeContent | null; + output: GraphNodeContent | null; + steps: GraphStepContent[]; + selected: GraphSelection; + onSelect: (selection: GraphSelection) => void; + /** Put an end on the chain, ready to be configured. */ + onAddEnd: (end: ChainEnd) => void; + /** Take an end back off, returning its row to a placeholder. */ + onRemoveEnd: (end: ChainEnd) => void; + /** Add a step in the slot the clicked wire opens. */ + onInsertStep: (index: number) => void; + /** Remove every step given, in one go. */ + onRemoveSteps: (indices: number[]) => void; + /** Reorder the chain to the given original step indices; `moved` is what the drag carried. */ + onReorderSteps: (order: number[], moved: readonly number[]) => void; + onOpenStepError?: (index: number) => void; +} + +/** + * The pipeline as a graph: one input, the steps in run order, one output. + * + * Layout is derived from the chain (see pipelineLayout), so there is nothing to lock, nothing to + * re-tidy and no stored positions - a node is always where its place in the order says it is. + * Dragging a step onto a wire moves it into that slot; clicking a node opens its settings in the + * inspector; the wires carry the insert affordance. + */ +export function PipelineGraph({ + input, + output, + steps, + selected, + onSelect, + onAddEnd, + onRemoveEnd, + onInsertStep, + onRemoveSteps, + onReorderSteps, + onOpenStepError, +}: PipelineGraphProps) { + const { t } = useTranslation(); + const [draggingIndex, setDraggingIndex] = useState(null); + const { nodes, edges, width, height } = layoutChain({ + stepCount: steps.length, + }); + + const graphRef = useRef(null); + // A keyboard reorder renumbers the nodes, so the focused card is no longer under the cursor's + // hand: without moving focus to where the step landed, a second Alt+Arrow would act on whatever + // now sits at the old position. Set by the handler, applied once the new order has rendered. + const focusStepAfterRender = useRef(null); + useLayoutEffect(() => { + const position = focusStepAfterRender.current; + if (position === null) return; + focusStepAfterRender.current = null; + graphRef.current + ?.querySelector( + `[data-step-index="${position}"] .sui-node-card__select`, + ) + ?.focus(); + }); + + // A wire carries the warning belonging to the node it arrives at. + const arrivalWarning = (nodeId: string): ChainWarning | undefined => { + if (nodeId === "output") return output?.inputWarning; + const index = stepIndexOf(nodeId); + return index === null ? undefined : steps[index]?.inputWarning; + }; + + /** + * Clicking the canvas itself clears the selection. Anything that is part of a node, a wire or the + * placeholder handles its own click, so only bare background gets here. + */ + function onBackgroundClick(event: ReactMouseEvent) { + const target = event.target as HTMLElement; + if ( + target.closest( + "[data-graph-node], .portal-graph-edge, .portal-graph-placeholder", + ) + ) { + return; + } + onSelect(null); + } + + const chosen = selectedSteps(selected); + + /** + * Plain click selects one step. Cmd/Ctrl toggles a step in or out of the selection; Shift takes + * everything between the first selected step and this one. The ends of the chain are single-only. + */ + function selectStep(index: number, event: ReactMouseEvent) { + if (event.metaKey || event.ctrlKey) { + const next = chosen.includes(index) + ? chosen.filter((i) => i !== index) + : [...chosen, index].sort((a, b) => a - b); + onSelect(next.length > 0 ? { steps: next } : null); + return; + } + if (event.shiftKey && chosen.length > 0) { + const anchor = chosen[0]; + const [from, to] = anchor <= index ? [anchor, index] : [index, anchor]; + const span = []; + for (let i = from; i <= to; i++) span.push(i); + onSelect({ steps: span }); + return; + } + onSelect({ steps: [index] }); + } + + /** + * Move the selected step(s) one slot along the chain - the keyboard alternative to dragging, which + * pointer-only users cannot reach. Alt with an arrow, so a plain arrow is still free for anything + * that later wants it. The moved block stays selected and takes focus with it, so it can be walked + * several slots in a row. + */ + function moveSelection(direction: "up" | "down"): boolean { + if (chosen.length === 0) return false; + const min = chosen[0]; + const max = chosen[chosen.length - 1]; + // reorderMany's slot is against the original chain: a step's own neighbouring slots are no-ops, + // so up aims one before the block and down one past it. + const slot = direction === "up" ? min - 1 : max + 2; + const order = reorderMany(steps.length, chosen, slot); + if (order === null) return false; // already at that end of the chain + focusStepAfterRender.current = order.indexOf(min); + onReorderSteps(order, chosen); + return true; + } + + /** + * Delete removes whatever is selected: every selected step, or an end of the chain (which returns + * its row to a placeholder, exactly as that node's X does). Alt + Up/Down reorders the selection. + * Scoped to the graph, so typing in the inspector's fields is never intercepted. + */ + function onKeyDown(event: KeyboardEvent) { + if ( + event.altKey && + (event.key === "ArrowUp" || event.key === "ArrowDown") + ) { + // Ends do not reorder (chosen is empty for them), so this only fires for a step selection. + if (moveSelection(event.key === "ArrowUp" ? "up" : "down")) { + event.preventDefault(); + } + return; + } + if (event.key !== "Delete" && event.key !== "Backspace") return; + if (selected === "input" || selected === "output") { + event.preventDefault(); + onRemoveEnd(selected); + return; + } + if (chosen.length === 0) return; + event.preventDefault(); + onRemoveSteps(chosen); + } + + return ( +
    +
    + {edges.map((edge) => ( + + ))} + + {draggingIndex !== null && edges.length > 0 && ( +

    + {t("portal.pipelines.graph.dragHint")} +

    + )} + + {nodes.map((node) => { + const style = { + left: `${node.x}px`, + top: `${node.y}px`, + width: `${NODE_WIDTH}px`, + minHeight: `${NODE_HEIGHT}px`, + }; + if (node.kind === "placeholder") { + return ( +
    + onInsertStep(0)} + /> +
    + ); + } + if (node.kind === "input" || node.kind === "output") { + const kind = node.kind; + const content = kind === "input" ? input : output; + return ( +
    + {content === null ? ( + onAddEnd(kind)} + /> + ) : ( + onSelect(kind)} + onRemove={() => onRemoveEnd(kind)} + /> + )} +
    + ); + } + const index = node.stepIndex ?? 0; + return ( +
    + selectStep(index, event)} + onRemove={() => onRemoveSteps([index])} + onDragChange={(dragging) => + setDraggingIndex(dragging ? index : null) + } + onOpenRunState={ + steps[index].runState === "failed" && onOpenStepError + ? () => onOpenStepError(index) + : undefined + } + /> +
    + ); + })} +
    +
    + ); +} + +interface ChainStepNodeProps { + index: number; + step: GraphStepContent; + selected: boolean; + dragging: boolean; + /** The steps this node's drag carries: the selection when it is part of it, else just itself. */ + moving: number[]; + onSelect: (event: ReactMouseEvent) => void; + onRemove: () => void; + onDragChange: (dragging: boolean) => void; + onOpenRunState?: () => void; +} + +/** A step node plus its drag wiring, which needs a hook per node and so a component per node. */ +function ChainStepNode({ + index, + step, + selected, + dragging, + moving, + onSelect, + onRemove, + onDragChange, + onOpenRunState, +}: ChainStepNodeProps) { + const { ref, guardClick } = useStepDraggable({ moving, onDragChange }); + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts new file mode 100644 index 0000000000..d03e6b3c42 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import { + EDGE_LENGTH, + NODE_HEIGHT, + NODE_WIDTH, + layoutChain, + reorderMany, + stepIndexOf, + stepNodeId, +} from "@portal/components/pipelines/graph/pipelineLayout"; + +describe("layoutChain", () => { + test("an empty chain reserves the first step's row for the placeholder", () => { + const { nodes, edges } = layoutChain({ stepCount: 0 }); + expect(nodes.map((n) => n.kind)).toEqual([ + "input", + "placeholder", + "output", + ]); + // The placeholder is the affordance, so neither wire around it offers a plus as well. + expect(edges.map((e) => e.insertIndex)).toEqual([null, null]); + }); + + test("the empty chain is as tall as a one-step chain", () => { + expect(layoutChain({ stepCount: 0 }).height).toBe( + layoutChain({ stepCount: 1 }).height, + ); + }); + + test("steps sit between input and output, in order", () => { + const { nodes } = layoutChain({ stepCount: 3 }); + expect(nodes.map((n) => n.id)).toEqual([ + "input", + "step:0", + "step:1", + "step:2", + "output", + ]); + expect(nodes.map((n) => n.stepIndex)).toEqual([null, 0, 1, 2, null]); + }); + + test("rows are evenly pitched down one column", () => { + const { nodes, width } = layoutChain({ stepCount: 2 }); + expect(nodes.every((n) => n.x === 0)).toBe(true); + const ys = nodes.map((n) => n.y); + const pitch = NODE_HEIGHT + EDGE_LENGTH; + expect(ys).toEqual([0, pitch, pitch * 2, pitch * 3]); + expect(width).toBe(NODE_WIDTH); + }); + + test("the canvas is tall enough for the last node", () => { + const { nodes, height } = layoutChain({ stepCount: 4 }); + const last = nodes[nodes.length - 1]; + expect(height).toBe(last.y + NODE_HEIGHT); + }); + + test("wires span exactly from one node's bottom border to the next node's top", () => { + const { nodes, edges } = layoutChain({ stepCount: 1 }); + expect(edges).toHaveLength(2); + for (const edge of edges) { + expect(edge.x).toBe(NODE_WIDTH / 2); + expect(edge.y2 - edge.y1).toBe(EDGE_LENGTH); + } + expect(edges[0].y1).toBe(nodes[0].y + NODE_HEIGHT); + expect(edges[0].y2).toBe(nodes[1].y); + }); + + test("each wire opens the slot it sits above", () => { + const { edges } = layoutChain({ stepCount: 3 }); + // input->0, 0->1, 1->2, 2->output + expect(edges.map((e) => e.insertIndex)).toEqual([0, 1, 2, 3]); + }); + + test("every wire between real nodes stays open", () => { + // Ordering is the user's to choose: no pairing is refused, however odd it is. + const { edges } = layoutChain({ stepCount: 4 }); + expect(edges.every((e) => e.insertIndex !== null)).toBe(true); + }); +}); + +describe("node ids", () => { + test("step ids round-trip through their index", () => { + expect(stepIndexOf(stepNodeId(7))).toBe(7); + }); + + test("the input and output nodes have no step index", () => { + expect(stepIndexOf("input")).toBeNull(); + expect(stepIndexOf("output")).toBeNull(); + }); +}); + +describe("reorderMany", () => { + test("moving one step below its own place accounts for it lifting out first", () => { + // [a b c], drag a onto the wire above c (slot 2) -> [b a c]. + expect(reorderMany(3, [0], 2)).toEqual([1, 0, 2]); + }); + + test("moving one step above its own place lands on the slot as given", () => { + // [a b c], drag c onto the wire above b (slot 1) -> [a c b]. + expect(reorderMany(3, [2], 1)).toEqual([0, 2, 1]); + }); + + test("the wires either side of a lone step are no-ops", () => { + expect(reorderMany(3, [1], 1)).toBeNull(); + expect(reorderMany(3, [1], 2)).toBeNull(); + }); + + test("moves to either end", () => { + expect(reorderMany(3, [2], 0)).toEqual([2, 0, 1]); + expect(reorderMany(3, [0], 3)).toEqual([1, 2, 0]); + }); + + test("a set of steps lands together, keeping its own order", () => { + // [a b c d], move a+c to the end -> [b d a c]. + expect(reorderMany(4, [0, 2], 4)).toEqual([1, 3, 0, 2]); + }); + + test("a set gathers from apart into one run", () => { + // [a b c d e], move a+e above c (slot 2) -> [b a e c d]. + expect(reorderMany(5, [0, 4], 2)).toEqual([1, 0, 4, 2, 3]); + }); + + test("a contiguous set dropped back where it already is, is a no-op", () => { + expect(reorderMany(4, [1, 2], 1)).toBeNull(); + expect(reorderMany(4, [1, 2], 3)).toBeNull(); + }); + + test("order of the given indices does not matter", () => { + expect(reorderMany(4, [2, 0], 4)).toEqual(reorderMany(4, [0, 2], 4)); + }); + + test("moving every step is a no-op wherever it lands", () => { + expect(reorderMany(3, [0, 1, 2], 0)).toBeNull(); + expect(reorderMany(3, [2, 1, 0], 3)).toBeNull(); + }); + + test("nothing selected moves nothing", () => { + expect(reorderMany(3, [], 1)).toBeNull(); + }); + + test("ignores out-of-range indices rather than injecting undefined steps", () => { + // A stray index (negative or past the end) must be dropped, not carried into the new order. + expect(reorderMany(3, [0, 9], 2)).toEqual([1, 0, 2]); + expect(reorderMany(3, [-1], 2)).toBeNull(); + expect(reorderMany(3, [5], 0)).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts new file mode 100644 index 0000000000..10fadfb6f8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts @@ -0,0 +1,184 @@ +/** + * Geometry for the pipeline graph. + * + * A pipeline is a strict sequence - one input, an ordered run of steps, one output - so a node's + * position carries no information that its place in the chain does not already carry. Layout is + * therefore *derived* here on every render rather than owned by the user and persisted: there are + * no stored coordinates to drift, nothing to lock, and nothing to re-tidy. Dragging a node is free + * to mean "move it in the chain" instead of "move it on screen" (see useChainDragDrop). + * + * Everything is a single centred column, which makes each wire a straight vertical line. When the + * model grows past one input/output and a pipeline can branch, x stops being constant and this is + * the module that changes - callers only ever read the result. + */ + +/** + * What a node represents. The chain always has exactly one input and one output. `placeholder` is + * the stand-in shown when a pipeline has no steps yet: it occupies the row the first step will take + * so the chain's shape is visible, and it is the affordance for adding that step. + */ +export type GraphNodeKind = "input" | "step" | "output" | "placeholder"; + +/** Node id: `"input"`, `"output"`, or `"step:"`. Stable for a given chain position. */ +export type GraphNodeId = string; + +export function stepNodeId(index: number): GraphNodeId { + return `step:${index}`; +} + +/** The step index a node id refers to, or null for the input/output nodes. */ +export function stepIndexOf(id: GraphNodeId): number | null { + const match = /^step:(\d+)$/.exec(id); + return match ? Number(match[1]) : null; +} + +export interface LaidOutNode { + id: GraphNodeId; + kind: GraphNodeKind; + /** Position in the chain's step list; null for input/output. */ + stepIndex: number | null; + /** Top-left corner, in canvas coordinates. */ + x: number; + y: number; +} + +export interface LaidOutEdge { + id: string; + from: GraphNodeId; + to: GraphNodeId; + /** + * Where a step dropped on this wire lands in the step list. Null only for the wires either side + * of the placeholder, which is itself the affordance for adding the first step. + */ + insertIndex: number | null; + /** Straight vertical wire, from the upper node's bottom port to the lower node's top port. */ + x: number; + y1: number; + y2: number; +} + +export interface LaidOutChain { + nodes: LaidOutNode[]; + edges: LaidOutEdge[]; + /** Canvas extent, so the scroll container can size itself without measuring. */ + width: number; + height: number; +} + +/** Node box, and the vertical room a wire plus its insert affordance needs between two of them. */ +export const NODE_WIDTH = 260; +export const NODE_HEIGHT = 64; +export const EDGE_LENGTH = 48; + +const ROW_PITCH = NODE_HEIGHT + EDGE_LENGTH; + +export interface LayoutChainOptions { + stepCount: number; +} + +/** + * Lay the chain out top to bottom: input, each step in order, output. Rows are evenly pitched and + * share one x, so wires are vertical and always aligned. + */ +export function layoutChain({ stepCount }: LayoutChainOptions): LaidOutChain { + const nodes: LaidOutNode[] = []; + const row = (index: number) => index * ROW_PITCH; + // An empty pipeline still shows a step row, filled by the placeholder, so the chain reads as + // input -> something -> output rather than as a bare wire. + const rows = Math.max(stepCount, 1); + + nodes.push({ id: "input", kind: "input", stepIndex: null, x: 0, y: row(0) }); + if (stepCount === 0) { + nodes.push({ + id: "placeholder", + kind: "placeholder", + stepIndex: null, + x: 0, + y: row(1), + }); + } + for (let i = 0; i < stepCount; i++) { + nodes.push({ + id: stepNodeId(i), + kind: "step", + stepIndex: i, + x: 0, + y: row(i + 1), + }); + } + nodes.push({ + id: "output", + kind: "output", + stepIndex: null, + x: 0, + y: row(rows + 1), + }); + + const centreX = NODE_WIDTH / 2; + const edges: LaidOutEdge[] = []; + for (let i = 0; i < nodes.length - 1; i++) { + const upper = nodes[i]; + const lower = nodes[i + 1]; + // A wire's insert index is the step slot it sits above: the wire below the input opens slot 0, + // the wire below step i opens slot i+1. A final-only step closes the wire beneath it. + const above = upper.stepIndex; + // The placeholder is itself the "add the first step" affordance, so the wires either side of it + // stay plain - two pluses for the same slot would be a choice with no difference. + const placeholderRow = + upper.kind === "placeholder" || lower.kind === "placeholder"; + const insertIndex = placeholderRow ? null : (above ?? -1) + 1; + edges.push({ + id: `${upper.id}->${lower.id}`, + from: upper.id, + to: lower.id, + insertIndex, + x: centreX, + // Exactly node-bottom to node-top: the wire meets both borders. The arrowhead is kept inside + // this box (see GraphEdge.css) so the node, drawn after it, cannot paint over the tip. + y1: upper.y + NODE_HEIGHT, + y2: lower.y, + }); + } + + return { + nodes, + edges, + width: NODE_WIDTH, + height: row(rows + 1) + NODE_HEIGHT, + }; +} + +/** + * The chain's new order after dropping `moving` on the wire that opens `insertIndex`. + * + * Returns the original step indices in their new positions, or null when the move changes nothing + * (dropping a step on either of its own wires, say). The moved steps land together in the target + * slot, keeping their order relative to each other; the slot is expressed against the *original* + * chain, so lifting the moved steps out first has to be accounted for - which is done by counting + * how many of the steps that stay put sit above the slot. + */ +export function reorderMany( + stepCount: number, + moving: readonly number[], + insertIndex: number, +): number[] | null { + // Range-check: a stray index would survive into `next` and then read as an undefined step, so + // keep only positions that exist in the chain before lifting anything out. + const lifted = [...new Set(moving)] + .filter((i) => i >= 0 && i < stepCount) + .sort((a, b) => a - b); + if (lifted.length === 0) return null; + + const staying: number[] = []; + for (let i = 0; i < stepCount; i++) { + if (!lifted.includes(i)) staying.push(i); + } + + const landing = staying.filter((i) => i < insertIndex).length; + const next = [ + ...staying.slice(0, landing), + ...lifted, + ...staying.slice(landing), + ]; + return next.every((value, i) => value === i) ? null : next; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts new file mode 100644 index 0000000000..1816f1a0ca --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createDragClickGuard, + fillDragPreview, +} from "@portal/components/pipelines/graph/useChainDragDrop"; + +/** Stands in for the graph's rendered cards, which the preview clones out of the DOM. */ +function renderCards(labels: string[]) { + document.body.innerHTML = labels + .map( + (label, i) => + `
    ${label}
    `, + ) + .join(""); +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("fillDragPreview", () => { + it("stacks a copy of every dragged card, in the order given", () => { + renderCards(["OCR", "Redact", "Compress"]); + const container = document.createElement("div"); + fillDragPreview(container, [0, 2]); + expect([...container.children].map((c) => c.textContent)).toEqual([ + "OCR", + "Compress", + ]); + }); + + it("leaves the originals alone", () => { + renderCards(["OCR", "Redact"]); + fillDragPreview(document.createElement("div"), [0, 1]); + expect(document.querySelectorAll("[data-step-index]")).toHaveLength(2); + }); + + it("copies are solid, not dimmed like the cards they came from", () => { + renderCards(["OCR"]); + const container = document.createElement("div"); + fillDragPreview(container, [0]); + expect(document.querySelector("[data-step-index]")).toHaveClass( + "is-dragging", + ); + expect(container.firstElementChild).not.toHaveClass("is-dragging"); + }); + + it("skips an index with no card rather than throwing", () => { + renderCards(["OCR"]); + const container = document.createElement("div"); + fillDragPreview(container, [0, 7]); + expect(container.children).toHaveLength(1); + }); +}); + +// The drag itself needs native HTML5 drag events, which jsdom does not implement, so the guard's +// state machine is exercised here directly - it is the half that decides whether a click selects. +describe("createDragClickGuard", () => { + it("lets a plain press through", () => { + const guard = createDragClickGuard(); + guard.beginGesture(); + expect(guard.swallowsClick()).toBe(false); + }); + + it("swallows the click that trails a drag", () => { + const guard = createDragClickGuard(); + guard.beginGesture(); + guard.noteDrag(); + expect(guard.swallowsClick()).toBe(true); + }); + + it("still selects on the next press after a drag left no click behind", () => { + // The regression: native drag usually emits no trailing click, so a guard cleared only by + // consuming one stayed raised and ate the user's next real click on that node. + const guard = createDragClickGuard(); + guard.beginGesture(); + guard.noteDrag(); + // ...drop, and no click follows. + guard.beginGesture(); + expect(guard.swallowsClick()).toBe(false); + }); + + it("guards each drag, not just the first", () => { + const guard = createDragClickGuard(); + for (const _ of [1, 2]) { + guard.beginGesture(); + guard.noteDrag(); + expect(guard.swallowsClick()).toBe(true); + } + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts new file mode 100644 index 0000000000..f80949d077 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source"; +import { + NODE_WIDTH, + reorderMany, +} from "@portal/components/pipelines/graph/pipelineLayout"; + +/** + * Drag-to-reorder for the pipeline chain. + * + * The chain is a sequence, so a step's meaningful move is "somewhere else in the order" - which + * makes the *wires* the drop targets, not the nodes. Each wire already knows the slot it opens + * (see layoutChain), so a drop is one call to reorderMany with that slot; there is no midpoint + * arithmetic or above/below bookkeeping, and no free coordinates to store. + */ + +const DRAG_TYPE = "pipeline-step"; + +interface StepDragData extends Record { + type: typeof DRAG_TYPE; + /** Every step this drag carries, in chain order - one, or the whole selection. */ + moving: number[]; +} + +function isStepDrag(data: Record): data is StepDragData { + return data.type === DRAG_TYPE && Array.isArray(data.moving); +} + +export interface UseStepDraggableOptions { + /** + * The steps this node's drag should carry: the current selection when this node is part of it, + * otherwise just itself. Resolved by the graph, which is what knows the selection. + */ + moving: number[]; + /** Told when this step's drag starts and ends, so the graph can light up the wires. */ + onDragChange: (dragging: boolean) => void; +} + +export interface UseStepDraggableResult { + ref: React.RefObject; + /** + * Wraps the node's click so the click that can trail a drag does not also select. Native drag + * usually swallows it, but the page editor carries the same guard - cheap insurance. + */ + guardClick: (action: (event: E) => void) => (event: E) => void; +} + +/** + * Tells a click that trails a drag apart from a genuine one. + * + * A gesture begins on pointerdown and may turn into a drag; only a click belonging to a gesture + * that dragged is swallowed. The clearing has to happen when the *next* gesture begins rather than + * when a click is swallowed - native HTML5 drag usually leaves no trailing click at all, so a flag + * cleared only by consuming one stays raised and eats the user's next real click on that node. + */ +export function createDragClickGuard() { + let dragged = false; + return { + /** A new press has started; nothing has dragged yet. */ + beginGesture: () => { + dragged = false; + }, + /** This gesture became a drag. */ + noteDrag: () => { + dragged = true; + }, + /** True if a click arriving now is the tail of a drag rather than a plain press. */ + swallowsClick: () => dragged, + }; +} + +export type DragClickGuard = ReturnType; + +/** + * Stack a copy of every dragged card into the preview container, so a multi-step drag shows what is + * actually moving rather than only the card that was grabbed. Exported for testing; the cards are + * found in the DOM by their chain position. + */ +export function fillDragPreview( + container: HTMLElement, + moving: readonly number[], +): void { + container.className = "portal-graph__drag-preview"; + container.style.width = `${NODE_WIDTH}px`; + for (const index of moving) { + const card = document.querySelector(`[data-step-index="${index}"]`); + if (!card) continue; + const copy = card.cloneNode(true) as HTMLElement; + // The originals dim once the drag starts; the copies are the drag, so they stay solid. + copy.classList.remove("is-dragging"); + container.appendChild(copy); + } +} + +/** Makes one step node draggable, tagged with the chain position it started from. */ +export function useStepDraggable({ + moving, + onDragChange, +}: UseStepDraggableOptions): UseStepDraggableResult { + const ref = useRef(null); + const guardRef = useRef(null); + guardRef.current ??= createDragClickGuard(); + const guard = guardRef.current; + + // Read through refs so a reorder (which renumbers every later step) never re-registers the + // adapter mid-gesture. + const movingRef = useRef(moving); + movingRef.current = moving; + const onDragChangeRef = useRef(onDragChange); + onDragChangeRef.current = onDragChange; + + useEffect(() => { + const element = ref.current; + if (!element) return; + // Any fresh input on the node starts a new gesture and clears the guard, so the only click it + // ever swallows is one trailing that same gesture's drag. Keyboard counts: activating the card + // with Enter or Space produces a click with no pointerdown before it. + const startGesture = () => guard.beginGesture(); + element.addEventListener("pointerdown", startGesture); + element.addEventListener("keydown", startGesture); + const stopDraggable = draggable({ + element, + getInitialData: (): StepDragData => ({ + type: DRAG_TYPE, + moving: movingRef.current, + }), + onGenerateDragPreview: ({ location, nativeSetDragImage }) => { + const moving = movingRef.current; + // One step drags as itself; the browser's own preview of the grabbed card is right. A set + // needs to show what is actually moving, so the preview stacks a copy of every card. + if (moving.length < 2) return; + setCustomNativeDragPreview({ + nativeSetDragImage, + getOffset: preserveOffsetOnSource({ + element, + input: location.current.input, + }), + render: ({ container }) => fillDragPreview(container, moving), + }); + }, + onDragStart: () => { + guard.noteDrag(); + onDragChangeRef.current(true); + }, + onDrop: () => onDragChangeRef.current(false), + }); + return () => { + element.removeEventListener("pointerdown", startGesture); + element.removeEventListener("keydown", startGesture); + stopDraggable(); + }; + }, [guard]); + + const guardClick = useCallback( + (action: (event: E) => void) => + (event: E) => { + if (guard.swallowsClick()) return; + action(event); + }, + [guard], + ); + + return { ref, guardClick }; +} + +export interface UseEdgeDropOptions { + /** The slot this wire opens; null for the wires either side of the empty-chain placeholder. */ + insertIndex: number | null; + stepCount: number; + /** + * Given the chain's new order as original step indices, and the original indices of the steps the + * drag actually carried - so the caller can keep the dragged steps selected rather than guessing + * from the prior selection. + */ + onReorder: (order: number[], moved: readonly number[]) => void; +} + +export interface UseEdgeDropResult { + ref: React.RefObject; + /** A step is hovering this wire and would land here. */ + over: boolean; +} + +/** Makes one wire a drop target that moves the dropped step into the slot the wire opens. */ +export function useEdgeDrop({ + insertIndex, + stepCount, + onReorder, +}: UseEdgeDropOptions): UseEdgeDropResult { + const ref = useRef(null); + const [over, setOver] = useState(false); + + const insertIndexRef = useRef(insertIndex); + insertIndexRef.current = insertIndex; + const stepCountRef = useRef(stepCount); + stepCountRef.current = stepCount; + const onReorderRef = useRef(onReorder); + onReorderRef.current = onReorder; + + useEffect(() => { + const element = ref.current; + if (!element) return; + return dropTargetForElements({ + element, + // A wire with no slot is not a target at all, so a step dragged over it shows no landing spot. + canDrop: ({ source }) => + insertIndexRef.current !== null && isStepDrag(source.data), + onDragEnter: () => setOver(true), + onDragLeave: () => setOver(false), + onDrop: ({ source }) => { + setOver(false); + const slot = insertIndexRef.current; + if (slot === null || !isStepDrag(source.data)) return; + const order = reorderMany( + stepCountRef.current, + source.data.moving, + slot, + ); + if (order !== null) onReorderRef.current(order, source.data.moving); + }, + }); + }, []); + + return { ref, over }; +} diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index f4c085ade7..0158d1e168 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -69,6 +69,29 @@ function seedPipelines(): StoredPolicy[] { output: { type: "inline", options: {} }, outputIds: ["src-contracts"], }, + { + // A chain long enough to overflow the builder's graph column, which is where the graph has to + // start scrolling instead of pushing the inspector off the page. + id: "plc-long", + name: "Full document pipeline", + owner: "ops@acme.com", + enabled: true, + inputs: [{ sourceId: "src-claims", trigger: null }], + steps: [ + { operation: "/api/v1/misc/repair", parameters: {} }, + { operation: "/api/v1/misc/ocr-pdf", parameters: {} }, + { operation: "/api/v1/general/rotate-pdf", parameters: {} }, + { operation: "/api/v1/general/crop", parameters: {} }, + { operation: "/api/v1/general/remove-pages", parameters: {} }, + { operation: "/api/v1/misc/add-page-numbers", parameters: {} }, + { operation: "/api/v1/security/add-watermark", parameters: {} }, + { operation: "/api/v1/security/sanitize-pdf", parameters: {} }, + { operation: "/api/v1/misc/flatten", parameters: {} }, + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + ], + output: { type: "inline", options: {} }, + outputIds: ["src-archive"], + }, { id: "plc-onboarding", name: "Onboarding OCR (paused)", diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index 128e8d4a1b..464d3fad55 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -1,3 +1,13 @@ +/** + * The builder claims the shell's view rather than lengthening it, so a long chain scrolls *inside* + * the graph while the header and the inspector stay put. A chain grows 112px per step, so it passes + * a typical viewport at around five steps - and if the page scrolled instead, clicking a node near + * the bottom would put the inspector (and Save) off-screen, which is the one interaction this whole + * layout exists to serve. + * + * The shell already makes .portal-shell__view the scroll container, so height here resolves against + * a definite box; capping the columns is what stops that view scrolling at all. + */ .portal-builder { display: flex; flex-direction: column; @@ -5,6 +15,14 @@ padding: 1.5rem; max-width: 84rem; margin: 0 auto; + height: 100%; + min-height: 0; +} + +/* The header and any banners keep their own size; only the grid absorbs (or gives up) space. Left + to the flex default they would all shrink together and squash on a short viewport. */ +.portal-builder > *:not(.portal-builder__grid) { + flex: none; } .portal-builder__loading { @@ -13,254 +31,38 @@ padding: 4rem 0; } -/* Header */ -.portal-builder__head { - display: flex; - align-items: center; - gap: 0.75rem; - flex-wrap: wrap; - padding-bottom: 1rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-builder__back { - display: inline-flex; - align-items: center; - gap: 0.25rem; - border: none; - background: none; - padding: 0; - font-size: 0.8125rem; - color: var(--c-text-subtle); - cursor: pointer; - white-space: nowrap; -} - -.portal-builder__back:hover { - color: var(--c-text); -} - -.portal-builder__head-main { - flex: 1; - min-width: 12rem; -} - -.portal-builder__head-actions { - display: flex; - align-items: center; - gap: 0.75rem; -} - /* Two-pane layout */ .portal-builder__grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1.25rem; + /* Takes whatever the header leaves, and pins the row to exactly that. `minmax(0, 1fr)` rather + than the implicit `auto` row is what makes the columns' `max-height: 100%` mean anything: an + auto row is sized BY its tallest item, so a long settings form would size the row to itself and + then resolve its own 100% against it - capping nothing, and clipping the form with no scrollbar. + `align-items: start` still lets a short column hug its content inside the bounded row. */ + grid-template-rows: minmax(0, 1fr); + flex: 1 1 auto; + min-height: 0; align-items: start; } +/* Stacked, the inspector sits below the graph, so there is nothing to hold in view - and capping + here would nest a scroll region inside a scrolling page, which is worse than a long page. Let the + builder grow and hand scrolling back to the shell. */ @media (max-width: 60rem) { + .portal-builder { + height: auto; + } + .portal-builder__grid { grid-template-columns: 1fr; + grid-template-rows: auto; + flex: none; } } -.portal-builder__flow { - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -.portal-builder__section-label { - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--c-text-subtle); - font-weight: 600; -} - -.portal-builder__empty { - font-size: 0.8125rem; - color: var(--c-text-subtle); - margin: 0; - padding: 0.5rem 0; -} - -/* Step cards */ -.portal-builder__steps { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.portal-builder__step { - display: flex; - align-items: center; - gap: 0.5rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); - padding: 0.5rem 0.625rem; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -.portal-builder__step--active { - border-color: var(--c-primary); - background: var(--c-primary-tint); -} - -.portal-builder__step-main { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 0.625rem; - border: none; - background: none; - padding: 0.25rem; - text-align: left; - cursor: pointer; - color: inherit; -} - -.portal-builder__step-index { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.375rem; - height: 1.375rem; - flex-shrink: 0; - border-radius: 50%; - font-size: 0.6875rem; - font-weight: 600; - background: var(--c-primary-tint); - color: var(--c-primary); -} - -.portal-builder__step--active .portal-builder__step-index { - background: var(--c-primary); - color: #fff; -} - -.portal-builder__step-text { - display: flex; - flex-direction: column; - min-width: 0; -} - -.portal-builder__step-name { - font-size: 0.875rem; - font-weight: 500; - color: var(--c-text); -} - -.portal-builder__step-note { - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -/* A step that cannot run on what the one before it produces. */ -.portal-builder__step-note--danger { - color: var(--c-danger); -} - -/* A picker entry that cannot run on what the chain currently produces. */ -.portal-pipelines__picker-note { - margin-left: auto; - padding-left: 0.5rem; - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -.portal-builder__step-actions { - display: flex; - gap: 0.25rem; -} - -.portal-builder__step-actions button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - padding: 0; - border-radius: var(--radius-md); - border: 1px solid var(--c-border); - background: var(--c-surface); - color: var(--c-text-subtle); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.portal-builder__step-actions button:hover:not(:disabled) { - background: var(--c-hover); - color: var(--c-text); -} - -.portal-builder__step-actions button:disabled { - opacity: 0.4; - cursor: default; -} - -.portal-builder__add-step { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.375rem; - width: 100%; - padding: 0.625rem; - border: 1px dashed var(--c-border); - border-radius: var(--radius-lg); - background: none; - color: var(--c-text-subtle); - font-size: 0.8125rem; - cursor: pointer; - transition: - border-color var(--motion-fast), - color var(--motion-fast); -} - -.portal-builder__add-step:hover { - border-color: var(--c-primary); - color: var(--c-primary); -} - -/* Pipeline settings (above the operation list) */ -.portal-builder__settings { - display: flex; - flex-direction: column; - gap: 0.75rem; - background: var(--color-bg-subtle); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); - padding: 1.125rem; -} - -.portal-builder__settings-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 1.25rem; -} - -.portal-builder__settings-col { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* Input and destination each span the full settings width so their row has room. */ -.portal-builder__inputs-col { - grid-column: 1 / -1; -} - -/* The input row (source + trigger + optional schedule) and the destination row. */ +/* The input's source dropdown and its edit affordance, in the inspector. */ .portal-builder__input-row { display: flex; flex-wrap: wrap; @@ -273,58 +75,23 @@ min-width: 10rem; } -/* The connect-source button trails to the end of the row. */ -.portal-builder__input-row > button:last-child { - margin-left: auto; -} - -/* Inspector: heading sits outside the card so it aligns with the operations heading. */ -.portal-builder__inspector-col { - position: sticky; - top: 1rem; - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -/* Once the grid stacks (60rem), a sticky inspector would ride over content */ -@media (max-width: 60rem) { - .portal-builder__inspector-col { - position: static; - } -} - -.portal-builder__inspector { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - padding: 1.125rem; -} - /* Tool picker */ -.portal-pipelines__picker { - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - background: var(--c-surface); - overflow: hidden; +/* The picker fills the modal hosting it rather than sitting in a card of its own: same surface, + same radius, so a bordered box in here would frame nothing. Its rows carry the structure, and + they run to the panel's edges - which is what lets the search divider span the full width. */ +.portal-pipelines__picker-modal .sui-modal__body { + padding: 0; } +/* Holds the shared Input, which brings its own border/focus ring; the row just insets it and rules + it off from the list below. */ .portal-pipelines__picker-search { - display: flex; - align-items: center; - padding: 0.5rem 0.75rem; + padding: 0.75rem 0.75rem 0.625rem; border-bottom: 1px solid var(--c-border-subtle); } -.portal-pipelines__picker-search input { - flex: 1; - border: none; - background: none; - padding: 0; - font-size: 0.875rem; - color: var(--c-text); - outline: none; +.portal-pipelines__picker-search .sui-input { + width: 100%; } .portal-pipelines__picker-list { @@ -334,7 +101,7 @@ } .portal-pipelines__picker-group-label { - padding: 0.5rem 0.75rem 0.25rem; + padding: 0.5rem 1.125rem 0.25rem; font-size: 0.6875rem; color: var(--c-text-subtle); } @@ -346,7 +113,7 @@ width: 100%; border: none; background: none; - padding: 0.4375rem 0.75rem; + padding: 0.4375rem 1.125rem; text-align: left; cursor: pointer; color: var(--c-text); @@ -368,63 +135,52 @@ display: block; } +/* Name over its optional note - a two-line item, so the note reads as a sub-line rather than + running straight on from the name. */ +.portal-pipelines__picker-text { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + .portal-pipelines__picker-name { font-size: 0.8125rem; } +/* Why this tool cannot follow the step before it. Advisory: the item is still pickable, just dimmed + and captioned so the reason is clear without shouting. */ +.portal-pipelines__picker-note { + font-size: 0.6875rem; + color: var(--c-text-muted); + white-space: normal; + line-height: 1.3; +} + +/* Muted via a token, not opacity: opacity on text drops the contrast below the floor. The name + still reads as de-emphasised, and the icon (not text) can take the opacity. */ +.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-name { + color: var(--c-text-muted); +} + +.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-icon { + opacity: 0.55; +} + .portal-pipelines__picker-empty { - padding: 1rem 0.75rem; + padding: 1rem 1.125rem; font-size: 0.8125rem; color: var(--c-text-subtle); margin: 0; } -/* The back link, step row, add-step affordance, tool-picker item and step - actions are the shared Button/ActionIcon carrying bespoke styling. Re-assert - their original look over the design-system button base (which otherwise - imposes a fixed height, its own padding/border and accent text colour). */ -.portal-builder__back.sui-btn { - height: auto; - min-height: 0; - padding: 0; - font-weight: 400; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} - -.portal-builder__back.sui-btn:hover { - color: var(--c-text); -} - -.portal-builder__step-main.sui-btn { - flex: 1; - height: auto; - min-height: 0; - padding: 0.25rem; - font-weight: 400; - color: inherit; -} - -.portal-builder__add-step.sui-btn { - height: auto; - min-height: 0; - padding: 0.625rem; - border: 1px dashed var(--c-border); - background: none; - font-weight: 400; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} - -.portal-builder__add-step.sui-btn:hover { - border-color: var(--c-primary); - color: var(--c-primary); -} - +/* The tool-picker item is the shared Button carrying bespoke styling, so re-assert its look over + the design-system base (which otherwise imposes a fixed height, its own padding and an accent + text colour). */ .portal-pipelines__picker-item.sui-btn { height: auto; min-height: 0; - padding: 0.4375rem 0.75rem; + padding: 0.4375rem 1.125rem; font-weight: 400; color: var(--c-text); } @@ -433,9 +189,39 @@ background: var(--c-hover); } -.portal-builder__step-actions .sui-ai { - width: 1.5rem; - height: 1.5rem; - min-width: 1.5rem; - min-height: 1.5rem; +/* A quiet way into the chosen source's own settings, beside its dropdown. */ +.portal-builder__input-row .portal-builder__source-edit { + color: var(--c-text-subtle); +} + +.portal-builder__input-row .portal-builder__source-edit:hover:not(:disabled) { + color: var(--c-accent-fg, var(--c-primary)); +} + +/* The input's schedule row, its number field, and a builder-owned muted line. These lived in + Pipelines.css and only rendered because the router bundles both views together; a code-split (or + any Storybook story of the builder alone) left them unstyled. Kept here so the builder is + self-contained. */ +.portal-builder__schedule { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-builder__schedule-count { + width: 5rem; +} + +.portal-builder__muted { + font-size: 0.8125rem; + color: var(--c-text-subtle); + margin: 0; +} + +/* The space-between button row shared by the builder's modal footers. */ +.portal-builder__composer-footer { + display: flex; + justify-content: space-between; + gap: 0.5rem; + width: 100%; } diff --git a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx index 95d2987e52..c204721c38 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx @@ -22,10 +22,21 @@ function withRoute(path: string) { const meta: Meta = { title: "Portal/Views/PipelineBuilder", component: PipelineBuilder, - parameters: { layout: "padded" }, - // The builder reads the tool registry (for step labels + settings UIs), so - // it needs this provider to render at all. + parameters: { layout: "fullscreen" }, decorators: [ + // The builder sizes itself against the shell's view - a fixed-height, non-scrolling box - which + // is what lets it cap its columns instead of lengthening the page. Given an auto-height parent + // its `height: 100%` resolves to nothing and the cap silently stops applying, so the story has + // to honour that contract or it reviews a layout the app never renders. + // Matches .portal-shell__view: a definite height with `auto` overflow, so the capped desktop + // layout has something to size against and the stacked layout can still scroll. + (Story) => ( +
    + +
    + ), + // The builder reads the tool registry (for step labels + settings UIs), so + // it needs this provider to render at all. (Story) => ( @@ -45,3 +56,12 @@ export const Default: Story = { export const Edit: Story = { decorators: [withRoute("/processor/pipelines/plc-redaction")], }; + +/** + * A chain taller than the page. The graph column scrolls on its own so the header and the inspector + * stay where they are - if the page scrolled instead, selecting a step near the end of the chain + * would carry its settings off-screen. + */ +export const LongChain: Story = { + decorators: [withRoute("/processor/pipelines/plc-long")], +}; diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index e06025889e..24d215a99f 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -7,6 +7,8 @@ import { } from "@testing-library/react"; import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; import type { Policy, TriggerOutcome } from "@portal/api/pipelines"; import type { SourceView } from "@portal/api/sources"; import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext"; @@ -61,22 +63,63 @@ vi.mock("@portal/api/integrations", () => ({ createIntegration: (...args: unknown[]) => createIntegration(...args), })); -// The destination picker just selects saved sources; stub it to a button that -// picks a fixed source, keeping this suite focused on the builder. +// The destination picker just selects saved sources; stub its three affordances +// (pick, create, edit) to buttons, keeping this suite focused on the builder. vi.mock("@portal/components/pipelines/DestinationPicker", () => ({ DestinationPicker: ({ value, onChange, + onCreateNew, + onEdit, }: { value: string[]; onChange: (ids: string[]) => void; + onCreateNew: () => void; + onEdit: (sourceId: string) => void; }) => ( - + <> + + + + ), })); +// The source modal has its own suite; stub it to the two things the builder +// depends on - the record it was opened on, and the sources-cache invalidation +// that follows a save (which is how a new source reaches the pickers). +vi.mock("@portal/components/sources/SourceModal", () => ({ + SourceModal: ({ + open, + sourceId, + }: { + open: boolean; + sourceId?: string | null; + }) => { + const queryClient = useQueryClient(); + if (!open) return null; + return ( +
    + source-modal:{sourceId || "new"} + +
    + ); + }, +})); + // One editable tool, Compress, so the picker and step settings have something to render. vi.mock("@app/contexts/ToolRegistryContext", () => { const compress = { @@ -129,9 +172,41 @@ vi.mock("@app/contexts/ToolRegistryContext", () => { fromApiParams: (params: Record) => ({ ...params }), }, } as unknown as ToolRegistryEntry; + // A tool that will not run on its defaults: its validateParams is the same predicate its own Run + // button uses, so a step for it is "unconfigured" until a language is chosen. + const ocr = { + name: "OCR", + icon: null, + component: null, + description: "", + categoryId: "recommendedTools", + subcategoryId: "general", + automationSettings: (props: { + onParameterChange: (key: string, value: unknown) => void; + }) => ( + + ), + operationConfig: { + operationType: "ocr", + toolType: 0, + endpoint: "/api/v1/misc/ocr-pdf", + defaultParameters: { languages: [] }, + validateParams: (params: { languages?: string[] }) => + (params.languages ?? []).length > 0, + buildFormData: () => new FormData(), + toApiParams: (params: Record) => ({ ...params }), + fromApiParams: (params: Record) => ({ ...params }), + }, + } as unknown as ToolRegistryEntry; const allTools = { compress, extractImages, + ocr, } as unknown as ToolRegistryCatalog["allTools"]; const catalog: ToolRegistryCatalog = { regularTools: allTools, @@ -215,8 +290,44 @@ describe("PipelineBuilder", () => { createIntegration.mockReset(); }); - // Choose the given source in the (pre-seeded) input row's dropdown. + // The settings of a node are reached by selecting it in the graph, so every helper below opens + // its node first. Nodes are found by position rather than by label, because a node's title is + // its current value - it changes as the pipeline is filled in. + + /** The graph's selectable nodes, in chain order: input, each step, output. */ + function graphNodes(): HTMLElement[] { + return screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-pressed")); + } + + /** + * The control that opens an end of the chain, once the graph has rendered. A new pipeline has not + * placed its ends yet, so that control is the "add" placeholder and clicking it both puts the node + * on the chain and selects it; a loaded pipeline already has the node, so it is a plain select. + */ + function endOpener(end: "input" | "output"): Promise { + return waitFor(() => { + const placeholder = screen.queryByText( + `portal.pipelines.graph.add.${end}`, + ); + if (placeholder) return placeholder; + const nodes = graphNodes(); + if (nodes.length === 0) throw new Error("the graph has not rendered yet"); + return end === "input" ? nodes[0] : nodes[nodes.length - 1]; + }); + } + + async function openInput() { + fireEvent.click(await endOpener("input")); + } + + async function openOutput() { + fireEvent.click(await endOpener("output")); + } + async function pickInputSource(sourceName: string) { + await openInput(); fireEvent.click( await screen.findByRole("textbox", { name: "portal.pipelines.builder.inputSource", @@ -225,24 +336,147 @@ describe("PipelineBuilder", () => { fireEvent.click(await screen.findByText(sourceName)); } - it("always shows exactly one input row, with no add or remove controls", async () => { + /** + * Add a tool. An empty chain offers the placeholder; once it has steps, the wires carry the + * inserts instead. + */ + async function addTool(toolName: string) { + const placeholder = screen.queryByText( + "portal.pipelines.graph.addFirstTool", + ); + if (placeholder) { + fireEvent.click(placeholder); + } else { + // The LAST wire, so repeated calls append. Taking the first would insert each new tool ahead + // of the ones already there, silently reversing the order a caller asked for. + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + fireEvent.click(inserts[inserts.length - 1]); + } + fireEvent.click(await screen.findByText(toolName)); + } + + async function pickDestination() { + await openOutput(); + fireEvent.click(await screen.findByText("pick output")); + } + + /** Open the header's overflow tray. */ + async function openTray() { + fireEvent.click( + await screen.findByLabelText("portal.pipelines.builder.moreActions"), + ); + } + + it("greets a new pipeline with places to fill, not problems to fix", async () => { renderBuilder("/processor/pipelines/new"); - // The input row is a fixed part of the form: its source dropdown is present from the - // start, and there is nothing to add or remove. + // Both ends offer to be added rather than complaining about being empty: the user has not been + // asked for a source or a destination yet, so there is nothing yet to warn them about. expect( - await screen.findAllByRole("textbox", { + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.graph.add.output"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsSource"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsDestination"), + ).not.toBeInTheDocument(); + // Nothing is on the chain, so there is nothing to remove either. + expect( + screen.queryByLabelText(/portal.pipelines.graph.removeNode/), + ).not.toBeInTheDocument(); + }); + + it("warns on a step whose tool cannot run on its defaults", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("OCR"); + + // The tool declares its own mandatory parameters, so the node says so without the builder + // knowing anything about OCR. + expect( + await screen.findByText("portal.pipelines.builder.needsConfiguring"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("clears the warning once the step is configured", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("OCR"); + await screen.findByText("portal.pipelines.builder.needsConfiguring"); + + // Adding a step selects it, so its settings are already open. + fireEvent.click(screen.getByText("pick language")); + + await waitFor(() => + expect( + screen.queryByText("portal.pipelines.builder.needsConfiguring"), + ).not.toBeInTheDocument(), + ); + }); + + it("leaves a tool that runs happily on its defaults unwarned", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("Compress"); + + expect( + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsConfiguring"), + ).not.toBeInTheDocument(); + }); + + it("only asks for a source once the user has asked for the node", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + + // Placing the node is what turns it into an outstanding choice. + expect( + await screen.findByText("portal.pipelines.builder.needsSource"), + ).toBeInTheDocument(); + // Still nothing chosen, so the pipeline cannot be saved. + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("puts an end back to a placeholder when it is removed", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + await screen.findByText("portal.pipelines.builder.needsSource"); + + fireEvent.click(screen.getByLabelText("portal.pipelines.graph.removeNode")); + + expect( + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsSource"), + ).not.toBeInTheDocument(); + }); + + it("edits a node's settings only once it is selected", async () => { + renderBuilder("/processor/pipelines/new"); + await screen.findByText("portal.pipelines.graph.add.input"); + + // Nothing selected: the inspector says so rather than showing a form. + expect( + screen.getByText("portal.pipelines.inspector.noSelectionTitle"), + ).toBeInTheDocument(); + + await openInput(); + expect( + screen.getByRole("textbox", { name: "portal.pipelines.builder.inputSource", }), - ).toHaveLength(1); - expect( - screen.queryByText("portal.pipelines.builder.addInput"), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { - name: "portal.pipelines.builder.removeInput", - }), - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); }); it("builds a new pipeline: name it, add a tool, an input, a destination, and save", async () => { @@ -257,12 +491,11 @@ describe("PipelineBuilder", () => { }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); // A pipeline must have at least one input source and one output destination. await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); @@ -291,13 +524,11 @@ describe("PipelineBuilder", () => { { target: { value: "Broken chain" } }, ); await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); // Extract images emits images; compress only takes a PDF, so it can never run. - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Extract images")); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Extract images"); + await addTool("Compress"); expect( await screen.findByText("portal.pipelines.builder.stepsIncompatible"), @@ -307,6 +538,21 @@ describe("PipelineBuilder", () => { ).toBeDisabled(); }); + it("says why on the wire arriving at the step that cannot run", async () => { + renderBuilder("/processor/pipelines/new"); + await pickInputSource("Claims intake"); + await pickDestination(); + + await addTool("Extract images"); + await addTool("Compress"); + + // The banner names which steps are at fault; the wire explains what is wrong where it happens. + const note = await screen.findByText( + /portal\.pipelines\.builder\.diagnostic\./, + ); + expect(note.closest(".portal-graph-edge")).toHaveClass("is-blocking"); + }); + it("allows a chain whose steps line up", async () => { renderBuilder("/processor/pipelines/new"); @@ -317,10 +563,9 @@ describe("PipelineBuilder", () => { { target: { value: "Fine chain" } }, ); await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); expect( screen.queryByText("portal.pipelines.builder.stepsIncompatible"), @@ -352,7 +597,7 @@ describe("PipelineBuilder", () => { expect(saveButton()).toBeDisabled(); // Both chosen: allowed, and both are sent. - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); expect(savePipeline).toHaveBeenCalledWith( @@ -363,6 +608,130 @@ describe("PipelineBuilder", () => { ); }); + it("creates and edits sources in place through the modal", async () => { + renderBuilder("/processor/pipelines/new"); + await pickInputSource("Claims intake"); + + // Connect source opens the modal in create mode, without leaving the + // builder (and its unsaved edits) for the Sources page. + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + expect(screen.getByText("source-modal:new")).toBeInTheDocument(); + expect(screen.queryByText("pipelines list")).not.toBeInTheDocument(); + + // The pencil beside the input opens the same modal on the chosen source. + fireEvent.click( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ); + expect(screen.getByText("source-modal:src-in")).toBeInTheDocument(); + }); + + it("cannot edit an input source before one is chosen", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + + expect( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ).toBeDisabled(); + await pickInputSource("Claims intake"); + expect( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ).not.toBeDisabled(); + }); + + it("makes a source created from the input row the pipeline's input", async () => { + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [SOURCE, { ...SOURCE, id: "src-new", name: "Scanner drop" }], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + fireEvent.click(screen.getByText("source saved")); + + // The new source is the one the pipeline was missing, so it becomes the input. + await waitFor(() => + expect( + screen.getByRole("textbox", { + name: "portal.pipelines.builder.inputSource", + }), + ).toHaveValue("Scanner drop"), + ); + }); + + it("makes a destination created from the picker the pipeline's output", async () => { + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [ + SOURCE, + { ...SOURCE, id: "src-new", name: "Archive bucket", type: "s3" }, + ], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + openOutput(); + await screen.findByText("pick output"); + + fireEvent.click(screen.getByText("new destination")); + fireEvent.click(screen.getByText("source saved")); + + // Created from the destination picker, so it lands in the output rather + // than the input. + await waitFor(() => + expect(screen.getByText("output:src-new")).toBeInTheDocument(), + ); + // The input was left alone: its node still shows the prompt. + expect( + screen.getByText("portal.pipelines.builder.chooseSource"), + ).toBeInTheDocument(); + }); + + it("leaves a new source that cannot be written to out of the destination", async () => { + // A webhook can be read from but not written to, so it must not be picked + // as a destination the dropdown has no option for. + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [ + SOURCE, + { ...SOURCE, id: "src-hook", name: "Partner hook", type: "webhook" }, + ], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + openOutput(); + await screen.findByText("pick output"); + + fireEvent.click(screen.getByText("new destination")); + fireEvent.click(screen.getByText("source saved")); + + // It was not made the destination... + await waitFor(() => expect(fetchSources).toHaveBeenCalledTimes(2)); + expect(screen.getByText("pick output")).toBeInTheDocument(); + + // ...but it did arrive, and is offered as an input, where a webhook makes sense. + await openInput(); + fireEvent.click( + screen.getByRole("textbox", { + name: "portal.pipelines.builder.inputSource", + }), + ); + expect(await screen.findByText("Partner hook")).toBeInTheDocument(); + }); + + it("edits the chosen destination through the same modal", async () => { + renderBuilder("/processor/pipelines/new"); + await pickDestination(); + + fireEvent.click(screen.getByText("edit destination")); + expect(screen.getByText("source-modal:src-1")).toBeInTheDocument(); + }); + it("runs an existing pipeline and reports success", async () => { renderBuilder("/processor/pipelines/plc-1"); @@ -401,9 +770,8 @@ describe("PipelineBuilder", () => { it("clears processed history from the header and confirms", async () => { renderBuilder("/processor/pipelines/plc-1"); - fireEvent.click( - await screen.findByText("portal.pipelines.detail.clearHistory"), - ); + await openTray(); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); await waitFor(() => expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"), @@ -424,8 +792,7 @@ describe("PipelineBuilder", () => { target: { value: "Watermarked" }, }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); // The tool's settings upload a file, which a stored pipeline can't persist yet. fireEvent.click(await screen.findByText("upload logo")); @@ -450,10 +817,7 @@ describe("PipelineBuilder", () => { target: { value: "Notify only" }, }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click( - await screen.findByText("portal.policies.operations.discordNotify.label"), - ); + await addTool("portal.policies.operations.discordNotify.label"); // Operation chosen, account not: still not saveable. expect( @@ -516,10 +880,7 @@ describe("PipelineBuilder", () => { }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click( - await screen.findByText("portal.policies.operations.discordNotify.label"), - ); + await addTool("portal.policies.operations.discordNotify.label"); fireEvent.click( await screen.findByPlaceholderText( @@ -530,7 +891,7 @@ describe("PipelineBuilder", () => { // Saving needs the input's source and a destination. await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index d788fc4f35..ecc249d88e 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -1,19 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import KeyboardArrowUpRoundedIcon from "@mui/icons-material/KeyboardArrowUpRounded"; -import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; import { ActionIcon, Banner, Button, - Checkbox, - EmptyState, + FormField, Input, Modal, Select, @@ -47,11 +43,14 @@ import { deletePipeline, fetchPipeline, fetchRun, + fetchRunOutput, fetchTriggers, + runPipelineTest, savePipeline, triggerPipeline, type Policy, type PolicyRunView, + type RunOutputFile, type TriggerConfig, type TriggerInfo, type TriggerOutcome, @@ -61,14 +60,27 @@ import { DestinationPicker } from "@portal/components/pipelines/DestinationPicke import { availableOutputModes } from "@portal/components/pipelines/outputModes"; import { type SourceView } from "@portal/api/sources"; import { useSources } from "@portal/queries/sources"; +import { SourceModal } from "@portal/components/sources/SourceModal"; import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; import { useAsync } from "@portal/hooks/useAsync"; import { useQueryClient } from "@tanstack/react-query"; import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; +import { PipelineHeader } from "@portal/components/pipelines/PipelineHeader"; +import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; +import { + PipelineGraph, + selectedSteps, + type ChainEnd, + type ChainWarning, + type GraphSelection, + type GraphStepContent, +} from "@portal/components/pipelines/graph/PipelineGraph"; import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; +import { BrandMark } from "@portal/components/BrandMarks"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { integrationStepConfigured, @@ -158,6 +170,11 @@ function buildTriggerFor(input: WorkingInput): TriggerConfig | null { return { type: input.triggerType, options: {} }; } +/** Whether a source can be written to, i.e. offered as a pipeline destination. */ +function isWritableSource(source: SourceView): boolean { + return (availableOutputModes() as string[]).includes(source.type); +} + /** * Full-page pipeline builder (route: /pipelines/new and /pipelines/:id). Pipeline-level settings * (sources, trigger, output) sit above the operation list; the operation list and the selected @@ -206,10 +223,7 @@ export function PipelineBuilder() { // A destination is a source used as a write target: only writable types (folder/S3, filtered per // deployment) can be picked, and the virtual editor is already excluded from availableSources. const writableSources = useMemo( - () => - availableSources.filter((source) => - (availableOutputModes() as string[]).includes(source.type), - ), + () => availableSources.filter(isWritableSource), [availableSources], ); const triggers = useMemo( @@ -223,9 +237,23 @@ export function PipelineBuilder() { // wire shape stays a list (see save()). const [input, setInput] = useState(blankInput); const [steps, setSteps] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(null); - const [pickerOpen, setPickerOpen] = useState(false); + /** Which node the inspector is editing: an end of the chain, a step, or nothing. */ + const [selected, setSelected] = useState(null); + /** Slot the tool picker will insert into, or null when it is closed. */ + const [pickerAt, setPickerAt] = useState(null); + const [definitionOpen, setDefinitionOpen] = useState(false); + /** The last test run in this session: one file through the steps as they stand. */ + const [testRun, setTestRun] = useState(null); + const [testing, setTesting] = useState(false); const [outputIds, setOutputIds] = useState([]); + /** + * Whether the user has asked for each end of the chain yet, distinguishing "not offered" from + * "offered and still owed a choice" - the two states an empty sourceId cannot tell apart. Only a + * brand new pipeline starts with either false; anything loaded arrives with both ends set, and + * choosing one places it, so these are just the "clicked add, chosen nothing" window. + */ + const [inputAsked, setInputAsked] = useState(false); + const [outputAsked, setOutputAsked] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [seeded, setSeeded] = useState(false); @@ -236,6 +264,39 @@ export function PipelineBuilder() { const [deleting, setDeleting] = useState(false); const [pendingNav, setPendingNav] = useState(null); + // Create or edit a source in place, instead of leaving the builder (and its + // unsaved edits) for the Sources page. + const [sourceModal, setSourceModal] = useState<{ + open: boolean; + sourceId: string | null; + }>({ open: false, sourceId: null }); + // A source created from here is the one the pipeline was missing, so select it + // on arrival - as the input or the destination, whichever asked for it. + const autoSelectRef = useRef<"input" | "output" | null>(null); + const knownSourceIdsRef = useRef>(new Set()); + useEffect(() => { + const target = autoSelectRef.current; + const known = knownSourceIdsRef.current; + knownSourceIdsRef.current = new Set(availableSources.map((s) => s.id)); + if (!target) return; + const fresh = availableSources.find((s) => !known.has(s.id)); + if (!fresh) return; + // One arrival answers the request, whatever type it turned out to be. + autoSelectRef.current = null; + if (target === "input") { + changeInputSource(fresh.id); + } else if (isWritableSource(fresh)) { + // A source of an unwritable type is left alone rather than becoming a + // destination the picker has no option for. + setOutputIds([fresh.id]); + } + }, [availableSources]); + + function createSourceFor(target: "input" | "output") { + autoSelectRef.current = target; + setSourceModal({ open: true, sourceId: null }); + } + const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -273,14 +334,6 @@ export function PipelineBuilder() { setSeeded(true); }, [isEdit, policyState.data, allTools, seeded]); - // Keep one tool's settings open: auto-select the first step whenever a pipeline has steps but - // nothing is selected (initial load, or after the selected step is removed). - useEffect(() => { - if (seeded && selectedIndex === null && steps.length > 0) { - setSelectedIndex(0); - } - }, [seeded, selectedIndex, steps.length]); - const sourceType = (sourceId: string) => availableSources.find((s) => s.id === sourceId)?.type; @@ -339,38 +392,65 @@ export function PipelineBuilder() { }); } - function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + /** Put an end on the chain and open it, so the click that asks for it also offers the choice. */ + function addEnd(end: ChainEnd) { + if (end === "input") setInputAsked(true); + else setOutputAsked(true); + setSelected(end); + } + + /** Take an end back off, discarding whatever it held so its row reads as unfilled again. */ + function removeEnd(end: ChainEnd) { + if (end === "input") { + setInputAsked(false); + setInput(blankInput()); + } else { + setOutputAsked(false); + setOutputIds([]); + } + setSelected((current) => (current === end ? null : current)); + } + + /** Drop a new step into the slot the picker was opened on, and select it to be configured. */ + function insertStep(step: WorkingToolStep) { + const at = pickerAt ?? steps.length; setSteps((current) => { - const next = [...current, newIntegrationStep(op)]; - setSelectedIndex(next.length - 1); + const next = [...current]; + next.splice(at, 0, step); return next; }); - setPickerOpen(false); + setSelected({ steps: [at] }); + setPickerAt(null); + } + + function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + insertStep(newIntegrationStep(op)); } function addStep(tool: ExecutableTool) { - setSteps((current) => { - const next = [...current, newWorkingToolStep(tool, allTools)]; - setSelectedIndex(next.length - 1); - return next; - }); - setPickerOpen(false); + insertStep(newWorkingToolStep(tool, allTools)); } - function removeStep(index: number) { - setSelectedIndex(null); - setSteps((current) => current.filter((_, i) => i !== index)); + function removeSteps(indices: number[]) { + const gone = new Set(indices); + setSelected(null); + setSteps((current) => current.filter((_, i) => !gone.has(i))); } - function moveStep(index: number, delta: number) { - setSteps((current) => { - const target = index + delta; - if (target < 0 || target >= current.length) return current; - const next = [...current]; - [next[index], next[target]] = [next[target], next[index]]; - return next; - }); - setSelectedIndex((cur) => (cur === index ? index + delta : cur)); + /** + * Apply a reordered chain, given as the original step indices in their new positions. The steps + * the drag carried stay selected where they land, so a set can be dragged again without re-picking + * it - and dragging an unselected step selects it, rather than leaving the inspector on whatever + * was selected before. + */ + function reorderSteps(order: number[], moved: readonly number[]) { + const moving = new Set(moved); + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moving.has(original)) + .map(({ position }) => position); + setSelected(landed.length > 0 ? { steps: landed } : null); } function updateStepParams(index: number, params: ErasedToolParams) { @@ -396,6 +476,21 @@ export function PipelineBuilder() { return entry?.name ?? humanizeOperation(step.operation); } + /** + * A step's glyph, matching how the tool picker draws it: an integration step carries its vendor's + * mark, a tool step its own icon. Without this every node falls back to the generic slider glyph, + * so a chain reads as a stack of identical cards. + */ + function stepIcon(step: WorkingToolStep): ReactNode { + const op = stepOperation(step); + if (op) + return ( + + ); + if (isIntegrationStep(step)) return ; + return step.toolId ? allTools[step.toolId]?.icon : undefined; + } + // Steps whose params carry an uploaded file can't be saved: the bytes aren't persisted with the // policy, so a later run would send null for that field (see stepRequiresUpload). const uploadStepLabels = steps.filter(stepRequiresUpload).map(stepLabel); @@ -431,16 +526,22 @@ export function PipelineBuilder() { .map((d) => stepLabel(steps[d.stepIndex])); const hasIncompatibleSteps = hasBlockingDiagnostics(chainDiagnostics); - // What a newly added step would be handed, so the picker can flag tools that cannot take it. - const chainOutput = useMemo( + /** + * What a step added at the open slot would be handed, so the picker can flag tools that cannot + * take it. Scoped to the steps *before* that slot rather than the whole chain: the graph inserts + * anywhere, so what precedes the new step is not necessarily the chain's final output. + */ + const precedingOutput = useMemo( () => - chainOutputFormat( - steps.map((step) => ({ - operation: step.operation, - parameters: step.params, - })), - ), - [steps], + pickerAt === null + ? undefined + : chainOutputFormat( + steps.slice(0, pickerAt).map((step) => ({ + operation: step.operation, + parameters: step.params, + })), + ), + [steps, pickerAt], ); function diagnosticNote(diagnostic: ToolDiagnostic): string { @@ -451,26 +552,21 @@ export function PipelineBuilder() { }); } - /** The most severe diagnostic for a step, rendered as its note. */ - function renderStepDiagnostic(index: number) { + /** + * The step's most severe diagnostic, for the wire arriving at it - which is where a note about + * what the step is being handed belongs, rather than on the step itself. + */ + function stepInputWarning(index: number): ChainWarning | undefined { const forStep = diagnosticsForStep(chainDiagnostics, index); const diagnostic = forStep.find((d) => d.severity === "ERROR") ?? forStep.find((d) => d.severity === "WARN") ?? forStep[0]; - if (!diagnostic) return null; - return ( - - {diagnosticNote(diagnostic)} - - ); + if (!diagnostic) return undefined; + return { + text: diagnosticNote(diagnostic), + blocking: diagnostic.severity === "ERROR", + }; } // Track unsaved edits: snapshot the form and compare against the state captured just after @@ -505,7 +601,6 @@ export function PipelineBuilder() { !submitting; const listPath = toPortalPath(VIEW_PATHS.pipelines); - const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}/new`; function close() { navigate(listPath); @@ -517,12 +612,6 @@ export function PipelineBuilder() { else navigate(destination); } - // Jump to the source builder, for when the source you want to read from or write to doesn't - // exist yet. Inputs and the output destination are both saved sources, so both create one here. - function goToSources() { - attemptLeave(sourcesPath); - } - async function save(destination: string) { if (!canSave) return; setSubmitting(true); @@ -550,16 +639,69 @@ export function PipelineBuilder() { } // Poll a run until it reaches a terminal state (or we give up), so a failure surfaces. - async function awaitRun(runId: string): Promise { + async function awaitRun( + runId: string, + onProgress?: (view: PolicyRunView) => void, + ): Promise { for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { if (!mounted.current) return null; const view = await fetchRun(runId); + onProgress?.(view); if (TERMINAL_STATUSES.has(view.status)) return view; await sleep(POLL_INTERVAL_MS); } return null; } + /** + * Run the steps as they stand against one uploaded file. Output is forced inline so nothing + * reaches the pipeline's real destination, and the pipeline need not be saved first - this is + * how the chain gets checked while it is still being built. + */ + async function handleTest(file: File) { + if (testing) return; + setTesting(true); + setTestRun(null); + setRunResult(null); + try { + const { runId } = await runPipelineTest( + { + name: name.trim() || t("portal.pipelines.builder.testRun"), + steps: steps.map((step) => serializeToolStep(step, allTools)), + output: { type: "inline", options: {} }, + }, + file, + ); + const final = await awaitRun(runId, (view) => { + if (mounted.current) setTestRun(view); + }); + if (mounted.current && final) setTestRun(final); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } finally { + if (mounted.current) setTesting(false); + } + } + + /** Save one of a test run's outputs to disk. */ + async function downloadOutput(output: RunOutputFile) { + try { + const blob = await fetchRunOutput(output.fileId); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = output.fileName ?? output.fileId; + link.click(); + // Revoke on the next tick: some browsers have not yet begun reading the + // blob when click() returns, and revoking now would cancel the download. + setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } + } + /** Explain an empty trigger: parked files outrank blander reasons. */ function emptySweepResult(outcome: TriggerOutcome): RunResult { if (outcome.parked > 0) { @@ -669,95 +811,257 @@ export function PipelineBuilder() { ); } + const chosenSteps = selectedSteps(selected); + // One step selected means its settings; several means there is no single thing to configure. const selectedStep = - selectedIndex !== null ? (steps[selectedIndex] ?? null) : null; + chosenSteps.length === 1 ? (steps[chosenSteps[0]] ?? null) : null; - return ( -
    -
    - -
    - setName(e.target.value)} - /> -
    -
    - setEnabled(e.target.checked)} - label={t("portal.pipelines.builder.enabled")} - /> - {isEdit && ( + const chosenSource = availableSources.find((s) => s.id === input.sourceId); + const chosenDestination = writableSources.find((s) => s.id === outputIds[0]); + + /** How this input fires, in a few words, for the input node's summary line. */ + function triggerSummary(): string { + if (input.triggerType === MANUAL) + return t("portal.pipelines.composer.triggerManual"); + if (input.triggerType === "schedule") + // One counted phrase per unit, so it reads "Runs every hour" / "Runs every 3 hours" rather + // than the ungrammatical, untranslatable "Run every 1 hours". + return t( + `portal.pipelines.composer.runsEvery.${input.scheduleUnit.toLowerCase()}`, + { count: Number(input.scheduleCount) || 1 }, + ); + return t(`portal.pipelines.trigger.${input.triggerType}`, { + defaultValue: input.triggerType, + }); + } + + /** Why a step cannot be saved yet, if anything. */ + function stepWarning(step: WorkingToolStep): string | undefined { + if (isIntegrationStep(step)) { + if (!stepOperation(step)) + return t("portal.pipelines.builder.chooseOperation"); + if (!integrationStepConfigured(step)) + return t("portal.pipelines.builder.chooseAccount"); + return undefined; + } + if (stepRequiresUpload(step)) + return t("portal.pipelines.builder.needsUpload"); + if (stepNeedsConfiguring(step, allTools)) + return t("portal.pipelines.builder.needsConfiguring"); + return undefined; + } + + /** A step's one-line summary: what it will do beyond its name. */ + function stepDetail(step: WorkingToolStep): string | undefined { + if (step.support === "unsupported") + return t("portal.pipelines.builder.usesDefaults"); + if (step.support === "unknown") + return t("portal.pipelines.builder.unknownStep"); + return undefined; + } + + // A run reports one step cursor, so progress reads off it: everything before the cursor is done, + // the cursor itself is whatever the run currently is. + function stepRunState(index: number): GraphStepContent["runState"] { + if (!testRun) return undefined; + if (index < testRun.currentStep) return "done"; + if (index > testRun.currentStep) return undefined; + if (testRun.status === "FAILED") return "failed"; + if (testRun.status === "COMPLETED") return "done"; + return "running"; + } + + const graphSteps: GraphStepContent[] = steps.map((step, i) => ({ + label: stepLabel(step), + detail: stepDetail(step), + icon: stepIcon(step), + warning: stepWarning(step), + inputWarning: stepInputWarning(i), + runState: stepRunState(i), + })); + + const definitionJson = JSON.stringify( + { + name: name.trim(), + enabled, + inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], + steps: steps.map((step) => serializeToolStep(step, allTools)), + outputIds, + }, + null, + 2, + ); + + const testSummary = + testRun === null + ? null + : { + status: + testRun.status === "FAILED" + ? ("failed" as const) + : testRun.status === "COMPLETED" + ? ("completed" as const) + : ("running" as const), + completedSteps: testRun.currentStep, + stepCount: testRun.stepCount, + error: testRun.error, + outputs: testRun.outputs ?? [], + }; + + /** The editor for whatever node is selected. Undefined when nothing is. */ + function inspectorBody() { + if (selected === "input") { + // Nothing to pick from yet: a dropdown of nothing helps no one, so offer only the way to make + // the first source. The trigger has no meaning without a source either, so it waits too. + const hasSources = availableSources.length > 0; + return ( + <> + {hasSources && ( <> - - - + +
    +
    + + updateInput({ + triggerType: + value && value !== MANUAL_OPTION ? value : MANUAL, + }) + } + options={triggerOptionsFor(input.sourceId)} + /> + + + {input.triggerType === "schedule" && ( +
    + + {t("portal.pipelines.composer.scheduleEvery")} + + + updateInput({ scheduleCount: e.target.value }) + } + className="portal-builder__schedule-count" + /> + changeInputSource(value ?? "")} - options={sourceOptions} - /> -
    -
    - - updateInput({ scheduleCount: e.target.value }) - } - className="portal-pipelines__schedule-count" - /> -
    } is no longer a row to a screen reader. That row control is then the + * keyboard path to the same action, so nothing is lost by leaving the row itself inert. + */ + rowsContainControls?: boolean; /** Rendered in place of the body when there are no rows. */ empty?: ReactNode; className?: string; @@ -42,6 +55,7 @@ export function Table({ rowKey, onRowClick, isRowInteractive, + rowsContainControls = false, empty, className, }: TableProps) { @@ -60,7 +74,11 @@ export function Table({ className={`sui-table__th sui-table__th--${c.align ?? "left"}`} style={c.width ? { width: c.width } : undefined} > - {c.header} + {c.headerHidden ? ( + {c.header} + ) : ( + c.header + )} ))} @@ -76,6 +94,9 @@ export function Table({ rows.map((row) => { const rowInteractive = interactive && (isRowInteractive?.(row) ?? true); + // Only a row that owns the whole interaction takes the button role and the keyboard + // handling that goes with it; see rowsContainControls. + const rowIsControl = rowInteractive && !rowsContainControls; return ( ({ : "sui-table__row" } onClick={rowInteractive ? () => onRowClick?.(row) : undefined} - tabIndex={rowInteractive ? 0 : undefined} - role={rowInteractive ? "button" : undefined} + tabIndex={rowIsControl ? 0 : undefined} + role={rowIsControl ? "button" : undefined} onKeyDown={ - rowInteractive + rowIsControl ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); diff --git a/frontend/editor/src/portal/api/fileRunEvents.test.ts b/frontend/editor/src/portal/api/fileRunEvents.test.ts new file mode 100644 index 0000000000..0e1729a891 --- /dev/null +++ b/frontend/editor/src/portal/api/fileRunEvents.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Contract tests for the api module itself. The component tests mock it wholesale, + * so they cannot catch mistakes in how it talks to the http client — a + * double-encoded body slipped through that gap once. These pin the call shape. + */ + +const json = vi.fn(); + +vi.mock("@portal/api/http", () => ({ + apiClient: { local: { json: (...args: unknown[]) => json(...args) } }, +})); + +const { applyFileRunEventAction, fetchFileRunEvents } = + await import("@portal/api/fileRunEvents"); + +describe("fileRunEvents api", () => { + beforeEach(() => json.mockReset()); + + describe("fetchFileRunEvents", () => { + it("requests the collection with no query when unfiltered", async () => { + json.mockResolvedValue({ events: [] }); + + await fetchFileRunEvents(); + + expect(json).toHaveBeenCalledWith("/api/v1/file-run-events"); + }); + + it("serialises only the filters that were supplied", async () => { + json.mockResolvedValue({ events: [] }); + + await fetchFileRunEvents({ status: "NEW", limit: 10 }); + + const [path] = json.mock.calls[0] as [string]; + const query = new URL(path, "http://localhost").searchParams; + expect(query.get("status")).toBe("NEW"); + expect(query.get("limit")).toBe("10"); + expect(query.has("kindId")).toBe(false); + }); + + it("never sends a team parameter, because the server derives it", async () => { + // The server derives the team, so asserted here to keep a param from creeping in. + json.mockResolvedValue({ events: [] }); + + await fetchFileRunEvents({ status: "NEW", kindId: "UNKNOWN", limit: 5 }); + + const [path] = json.mock.calls[0] as [string]; + expect(path).not.toMatch(/team/i); + }); + + it("unwraps the response envelope", async () => { + json.mockResolvedValue({ events: [{ id: "a" }] }); + + await expect(fetchFileRunEvents()).resolves.toEqual([{ id: "a" }]); + }); + + it("tolerates a response with no events array", async () => { + json.mockResolvedValue(undefined); + + await expect(fetchFileRunEvents()).resolves.toEqual([]); + }); + }); + + describe("applyFileRunEventAction", () => { + it("passes body as an object, not a pre-serialised string", async () => { + // The regression this file exists for: apiClient stringifies `body` + // itself, so a string here would arrive double-encoded. + json.mockResolvedValue({ id: "fre-1" }); + + await applyFileRunEventAction("fre-1", "ACKNOWLEDGE"); + + const [, options] = json.mock.calls[0] as [ + string, + { method: string; body: unknown }, + ]; + expect(typeof options.body).toBe("object"); + expect(options.body).toEqual({ inputs: {} }); + expect(options.method).toBe("POST"); + }); + + it("forwards collected inputs", async () => { + json.mockResolvedValue({ id: "fre-1" }); + + await applyFileRunEventAction("fre-1", "ACKNOWLEDGE", { + password: "hunter2", + }); + + const [, options] = json.mock.calls[0] as [string, { body: unknown }]; + expect(options.body).toEqual({ inputs: { password: "hunter2" } }); + }); + + it("encodes ids into the path so an odd id cannot break the URL", async () => { + json.mockResolvedValue({ id: "x" }); + + await applyFileRunEventAction("a/b?c", "ACKNOWLEDGE"); + + const [path] = json.mock.calls[0] as [string]; + expect(path).toBe( + "/api/v1/file-run-events/a%2Fb%3Fc/actions/ACKNOWLEDGE", + ); + }); + }); +}); diff --git a/frontend/editor/src/portal/api/fileRunEvents.ts b/frontend/editor/src/portal/api/fileRunEvents.ts new file mode 100644 index 0000000000..8b52c7d3fc --- /dev/null +++ b/frontend/editor/src/portal/api/fileRunEvents.ts @@ -0,0 +1,120 @@ +import { apiClient } from "@portal/api/http"; + +/** + * Recorded policy and pipeline failures, mirroring the backend `FileRunEventView`. + * + * Two properties of the contract: the server sends i18n keys plus an English + * `defaultTitle` rather than rendered copy, so a client can display a kind it was + * not built with; and each row's `actions` arrive already resolved, so rendering + * buttons needs no knowledge of the rules. + */ + +/** Where the fault lay. Widened server-side ahead of the kinds that need it. */ +export type FailureStage = + | "INPUT" + | "INTERNAL" + | "OUTPUT" + | "BLOCKED" + | "NEVER_RAN"; + +export type FailureSeverity = "ERROR" | "WARNING" | "INFO"; + +export type FailureRemedy = + | "TRANSIENT" + | "NEEDS_USER_INPUT" + | "NEEDS_FILE_FIX" + | "NEEDS_CONFIG_FIX" + | "NEEDS_SERVER_FIX" + | "PERMANENT"; + +export type FailureScope = "FILE" | "RUN" | "POLICY" | "SOURCE" | "SERVER"; + +export type FailureOrigin = "TOOL" | "POLICY" | "PIPELINE"; + +export type FileRunEventStatus = + | "NEW" + | "ACKNOWLEDGED" + | "DISMISSED" + | "RESOLVED"; + +/** + * One button as offered for one row. `id` is a plain string rather than a union + * because the server may know actions this build does not; the renderer skips them. + */ +export interface FailureActionOffer { + id: string; + labelKey: string; + enabled: boolean; + disabledReasonKey: string | null; +} + +export interface FileRunEvent { + id: string; + kindId: string; + stage: FailureStage; + severity: FailureSeverity; + scope: FailureScope; + origin: FailureOrigin; + remedy: FailureRemedy; + titleKey: string; + descriptionKey: string; + /** English fallback, used when this build has no translation for `titleKey`. */ + defaultTitle: string; + /** Raw failure message. For an unclassified failure this is the only detail. */ + detail: string | null; + policyId: string | null; + runId: string | null; + /** + * Opaque reference, never a name. Only the owner's own client can resolve it to + * something readable, from its local file store. + */ + fileId: string | null; + actor: string | null; + /** How many times this same failure has been seen; repeats fold into one row. */ + occurrences: number; + status: FileRunEventStatus; + statusActor: string | null; + actions: FailureActionOffer[]; + createdAt: number; + lastSeenAt: number; +} + +export interface FileRunEventsResponse { + events: FileRunEvent[]; +} + +export interface ListFileRunEventsParams { + status?: FileRunEventStatus; + kindId?: string; + limit?: number; +} + +/** GET /api/v1/file-run-events — the caller's team only; there is no team param. */ +export async function fetchFileRunEvents( + params: ListFileRunEventsParams = {}, +): Promise { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.kindId) query.set("kindId", params.kindId); + if (params.limit != null) query.set("limit", String(params.limit)); + const suffix = query.toString() ? `?${query}` : ""; + + const response = await apiClient.local.json( + `/api/v1/file-run-events${suffix}`, + ); + return response?.events ?? []; +} + +/** POST /api/v1/file-run-events/{id}/actions/{actionId} — returns the updated row. */ +export async function applyFileRunEventAction( + eventId: string, + actionId: string, + inputs: Record = {}, +): Promise { + return apiClient.local.json( + `/api/v1/file-run-events/${encodeURIComponent(eventId)}/actions/${encodeURIComponent(actionId)}`, + // A plain object, not a JSON string: apiClient serialises `body` itself, so + // pre-stringifying would double-encode it. + { method: "POST", body: { inputs } }, + ); +} diff --git a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx index bff13d0c4f..e63ea925c4 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx @@ -71,8 +71,11 @@ export function ReviewQueueTable({ )} {d.sensitive && ( + // role="img" so the label is allowed and the icon reads as one thing: aria-label + // is ignored on a bare span, leaving the padlock silent. @@ -136,7 +139,8 @@ export function ReviewQueueTable({ }, { key: "actions", - header: "", + header: t("portal.documents.table.columns.actions"), + headerHidden: true, width: "3rem", render: (d) => ( + ); + })} + + ); +} diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx new file mode 100644 index 0000000000..1458a1d64d --- /dev/null +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render as baseRender, screen, waitFor } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import type { FileRunEvent } from "@portal/api/fileRunEvents"; + +/** + * Tests for the list: the states it survives (loading, empty, no registry, refused), + * plus replacing a row in place after acting and re-reading when the server refuses. + */ + +const fetchFileRunEvents = vi.fn(); +const applyFileRunEventAction = vi.fn(); + +vi.mock("@portal/api/fileRunEvents", () => ({ + fetchFileRunEvents: (...args: unknown[]) => fetchFileRunEvents(...args), + applyFileRunEventAction: (...args: unknown[]) => + applyFileRunEventAction(...args), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + // Faithful to i18next: a known key resolves, an unknown key falls back to + // defaultValue. That is what exercises the server-key-then-generic chain. + t: (key: string, options?: { defaultValue?: string } | string) => { + const known: Record = { + "portal.failures.kind.inputPasswordProtected.title": + "Password-protected document", + "portal.failures.action.acknowledge": "Acknowledge", + "portal.failures.empty.title": "No failures recorded", + "portal.failures.occurrences": "occurrences", + "portal.failures.runReference": "Run r1", + "portal.failures.stage.input": "Input", + }; + if (known[key]) return known[key]; + if (typeof options === "string") return options; + if (options?.defaultValue) return options.defaultValue; + return key; + }, + }), +})); + +// The list reads through the shared query hooks, and @app/ui needs Mantine. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +const { FileRunEventList } = + await import("@portal/components/failures/FileRunEventList"); + +function event(overrides: Partial = {}): FileRunEvent { + return { + id: "fre-1", + kindId: "INPUT_PASSWORD_PROTECTED", + stage: "INPUT", + severity: "ERROR", + scope: "FILE", + origin: "POLICY", + remedy: "NEEDS_USER_INPUT", + titleKey: "portal.failures.kind.inputPasswordProtected.title", + descriptionKey: "portal.failures.kind.inputPasswordProtected.description", + defaultTitle: "Password-protected document", + detail: "The PDF Document is passworded", + policyId: "p1", + runId: "r1", + fileId: "f-1", + actor: "dana@example.com", + occurrences: 1, + status: "NEW", + statusActor: null, + actions: [ + { + id: "ACKNOWLEDGE", + labelKey: "portal.failures.action.acknowledge", + enabled: true, + disabledReasonKey: null, + }, + ], + createdAt: 0, + lastSeenAt: 0, + ...overrides, + }; +} + +describe("FileRunEventList", () => { + beforeEach(() => { + fetchFileRunEvents.mockReset(); + applyFileRunEventAction.mockReset(); + // The dev-panel test stubs import.meta.env.DEV, which would otherwise persist + // into every test after it. + vi.unstubAllEnvs(); + }); + + it("renders a row's title, run reference and raw detail, but no document name", async () => { + fetchFileRunEvents.mockResolvedValue([event()]); + + render(); + + expect(await screen.findByText("Password-protected document")).toBeTruthy(); + // A run reference, not a document name: the record holds no file identity. + expect(screen.getByText("Run r1")).toBeTruthy(); + // The raw message is shown, not swallowed: for an unclassified failure it is + // the only diagnostic available. + expect(screen.getByText("The PDF Document is passworded")).toBeTruthy(); + }); + + it("shows the occurrence count only once a failure has repeated", async () => { + fetchFileRunEvents.mockResolvedValue([event({ occurrences: 1 })]); + const { unmount } = render(); + await screen.findByText("Run r1"); + expect(screen.queryByText(/occurrences/)).toBeNull(); + unmount(); + + fetchFileRunEvents.mockResolvedValue([event({ occurrences: 14 })]); + render(); + expect(await screen.findByText(/occurrences/)).toBeTruthy(); + }); + + it("shows an empty state when there is nothing to triage", async () => { + fetchFileRunEvents.mockResolvedValue([]); + + render(); + + expect(await screen.findByText("No failures recorded")).toBeTruthy(); + }); + + it("renders nothing when the server has no failure registry", async () => { + // A core-only build has no such route, which is not worth showing a reviewer as + // an error, so the section stays silent. + fetchFileRunEvents.mockRejectedValue(new Error("404")); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector(".portal-failures__list")).toBeNull(); + }); + expect(screen.queryByText("No failures recorded")).toBeNull(); + }); + + it("renders no heading at all for a caller the server refuses", async () => { + // Reviewing is leader-only, so a member's read returns 403. With no dev panel to + // frame, the whole section goes rather than leaving a bare heading. + vi.stubEnv("DEV", false); + fetchFileRunEvents.mockRejectedValue(new Error("403")); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector(".portal-failures")).toBeNull(); + }); + // No section, and specifically no heading: Mantine puts its + + +
    +
    + +
    +
    Stirling PDF
    +
    Draw Signature
    +
    +
    + +
    +
    Connecting…
    + +
    +
    + +
    + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + + + + +

    Draw your signature above, then send it. It appears in the Sign tool on your computer automatically.

    +
    + + + +
    Stirling PDF · signatures transfer directly to your desktop
    +
    + + + + diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java index bfacb4d503..dc9c9538cd 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java @@ -49,6 +49,33 @@ class MobileScannerControllerTest { when(systemProps.isEnableMobileScanner()).thenReturn(false); } + // --- shared-endpoint gating: scanner and mobile signature share this API --- + + @Test + void createSession_whenOnlyMobileSignatureEnabled_returnsOk() { + // The signature feature must work with the scanner switched off. + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(true); + SessionInfo sessionInfo = new SessionInfo("test-session", 1000L, 601000L, 600000L); + when(mobileScannerService.createSession("test-session")).thenReturn(sessionInfo); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + void createSession_whenBothFeaturesDisabled_returnsForbidden() { + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(false); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + } + // --- createSession tests --- @Test diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index ed1b61e098..8ce96c2552 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4993,6 +4993,34 @@ uploadSuccess = "Upload Successful!" uploadSuccessMessage = "Your images have been transferred." validating = "Validating session..." +[mobileSign] +clear = "Clear" +invalidSession = "Session expired" +invalidSessionMessage = "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code." +penSizeLabel = "Pen size" +send = "Send to computer" +sendAnother = "Send another signature" +sendError = "Could not send the signature. Check the connection and try again." +sentMessage = "Signature sent to your computer. You can send another or close this page." +tabsLabel = "Signature source" +undo = "Undo" +validating = "Checking session…" + +[mobileSign.photo] +fromGallery = "From gallery" +hint = "Photograph a signature on white paper, or choose an existing image." +invalidType = "Please choose an image file." +takePhoto = "Take a photo" + +[mobileSign.tab] +draw = "Draw" +photo = "Photo" +type = "Type" + +[mobileSign.type] +placeholder = "Your name" +previewPlaceholder = "Signature preview" + [mobileUpload] description = "Scan to upload photos. Images auto-convert to PDF." descriptionNoConvert = "Scan to upload photos from your mobile device." @@ -10254,6 +10282,7 @@ backgroundRemovalFailedTitle = "Background removal failed" hint = "Upload a PNG or JPG image of your signature" label = "Upload signature image" placeholder = "Select image file" +previewAlt = "Current image signature" processing = "Processing image..." removeBackground = "Remove white background (make transparent)" @@ -10267,6 +10296,17 @@ saved = "Select a saved signature above, then click anywhere on the PDF to place text = "After entering your name above, click anywhere on the PDF to place your signature." title = "How to add signature" +[sign.mobile] +createFromPhone = "Mobile upload" +description = "Scan this QR code with your phone or tablet, draw your signature, and it will appear here automatically." +error = "Connection Error" +expiryWarning = "QR Code Expiring Soon" +expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically." +instructions = "Open the camera app on your phone and scan this code. Keep this window open while you draw." +pollingError = "Error checking for the signature" +sessionCreateError = "Failed to create session" +title = "Draw on your phone" + [sign.mode] move = "Move Signature" pause = "Pause placement" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 6fd23e5510..81db0564b8 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -9,6 +9,7 @@ import HomePage from "@app/pages/HomePage"; import Onboarding from "@app/components/onboarding/Onboarding"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); // Import global styles import "@app/styles/tailwind.css"; @@ -42,6 +43,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* All other routes need AppProviders for backend integration */} string | null; + undo: () => void; + clear: () => void; +} + +interface Stroke { + color: string; + size: number; + points: Array<{ x: number; y: number }>; +} + +interface MobileDrawCanvasProps { + penColor: string; + penSize: number; + /** Fired when the canvas goes between empty and inked (gates Send/Undo). */ + onHasInkChange: (hasInk: boolean) => void; +} + +/** Padding kept around the ink when cropping the export, in CSS pixels. */ +const EXPORT_PADDING = 12; + +function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) { + const { points } = stroke; + if (points.length === 0) return; + + ctx.strokeStyle = stroke.color; + ctx.fillStyle = stroke.color; + ctx.lineWidth = stroke.size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + if (points.length === 1) { + // A tap: render a dot, which a zero-length stroke would not show. + ctx.beginPath(); + ctx.arc(points[0].x, points[0].y, stroke.size / 2, 0, Math.PI * 2); + ctx.fill(); + return; + } + + // Quadratic midpoint smoothing: each point becomes the control point of a + // curve to the midpoint of the next segment, turning jagged pointer samples + // into a pen-like line. + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length - 1; i++) { + const midX = (points[i].x + points[i + 1].x) / 2; + const midY = (points[i].y + points[i + 1].y) / 2; + ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); + } + const last = points[points.length - 1]; + ctx.lineTo(last.x, last.y); + ctx.stroke(); +} + +export const MobileDrawCanvas = forwardRef< + MobileDrawCanvasHandle, + MobileDrawCanvasProps +>(function MobileDrawCanvas({ penColor, penSize, onHasInkChange }, ref) { + const canvasRef = useRef(null); + const strokesRef = useRef([]); + const activeStrokeRef = useRef(null); + // Live styling for the stroke currently being drawn, without re-rendering + const penRef = useRef({ color: penColor, size: penSize }); + penRef.current = { color: penColor, size: penSize }; + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + for (const stroke of strokesRef.current) drawStroke(ctx, stroke); + if (activeStrokeRef.current) drawStroke(ctx, activeStrokeRef.current); + }, []); + + // Match the backing store to the element's CSS size × devicePixelRatio, and + // re-match on resize/rotation. Strokes are CSS-space, so a redraw restores + // them at the new size. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(rect.width * dpr)); + canvas.height = Math.max(1, Math.round(rect.height * dpr)); + redraw(); + }; + + resize(); + const observer = new ResizeObserver(resize); + observer.observe(canvas); + return () => observer.disconnect(); + }, [redraw]); + + const pointFromEvent = (e: React.PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }; + + const handlePointerDown = (e: React.PointerEvent) => { + // One stroke at a time: a second touch while drawing would scribble. + if (activeStrokeRef.current) return; + e.currentTarget.setPointerCapture(e.pointerId); + activeStrokeRef.current = { + color: penRef.current.color, + size: penRef.current.size, + points: [pointFromEvent(e)], + }; + redraw(); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + // Coalesced events give the full sample train on high-rate digitizers, + // where the per-frame synthetic event alone would drop curvature. + const events = + "getCoalescedEvents" in e.nativeEvent + ? (e.nativeEvent as PointerEvent).getCoalescedEvents() + : [e.nativeEvent as PointerEvent]; + const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect(); + for (const ev of events) { + stroke.points.push({ + x: ev.clientX - rect.left, + y: ev.clientY - rect.top, + }); + } + redraw(); + }; + + const endStroke = () => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + activeStrokeRef.current = null; + strokesRef.current = [...strokesRef.current, stroke]; + redraw(); + onHasInkChange(true); + }; + + useImperativeHandle(ref, () => ({ + exportPng: () => { + const strokes = strokesRef.current; + const canvas = canvasRef.current; + if (strokes.length === 0 || !canvas) return null; + + // Crop to the inked region so the signature stamps tightly, clamped to + // what was actually visible. + const rect = canvas.getBoundingClientRect(); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const stroke of strokes) { + const reach = stroke.size / 2 + EXPORT_PADDING; + for (const p of stroke.points) { + minX = Math.min(minX, p.x - reach); + minY = Math.min(minY, p.y - reach); + maxX = Math.max(maxX, p.x + reach); + maxY = Math.max(maxY, p.y + reach); + } + } + minX = Math.max(0, minX); + minY = Math.max(0, minY); + maxX = Math.min(rect.width, maxX); + maxY = Math.min(rect.height, maxY); + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + + const dpr = window.devicePixelRatio || 1; + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = Math.round(width * dpr); + exportCanvas.height = Math.round(height * dpr); + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return null; + // Translate args are device pixels; scale args map stroke space to them. + ctx.setTransform(dpr, 0, 0, dpr, -minX * dpr, -minY * dpr); + for (const stroke of strokes) drawStroke(ctx, stroke); + return exportCanvas.toDataURL("image/png"); + }, + undo: () => { + strokesRef.current = strokesRef.current.slice(0, -1); + redraw(); + onHasInkChange(strokesRef.current.length > 0); + }, + clear: () => { + strokesRef.current = []; + activeStrokeRef.current = null; + redraw(); + onHasInkChange(false); + }, + })); + + return ( + + ); +}); diff --git a/frontend/editor/src/core/components/shared/MobileTransferModal.tsx b/frontend/editor/src/core/components/shared/MobileTransferModal.tsx new file mode 100644 index 0000000000..7a153c59fc --- /dev/null +++ b/frontend/editor/src/core/components/shared/MobileTransferModal.tsx @@ -0,0 +1,161 @@ +import { ReactNode } from "react"; +import { Modal, Stack, Text, Box, Alert } from "@mantine/core"; +import { QRCodeSVG } from "qrcode.react"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { useMobileTransferSession } from "@app/hooks/useMobileTransferSession"; + +/** + * The QR modal shell every phone-to-desktop transfer feature shares: session + * lifecycle, QR code, expiry/error alerts, and the fallback URL line. A + * feature supplies its copy, its public route, and what a received file + * means — the scanner converts to PDF, the sign tool routes it to a + * signature source. + */ +interface MobileTransferModalProps { + opened: boolean; + onClose: () => void; + /** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; + /** Called once per uploaded file; arrivals are untrusted, validate inside. */ + onFileReceived: (file: File) => void | Promise; + title: string; + description: string; + instructions: string; + expiryWarningTitle: string; + /** Interpolates the seconds remaining into the feature's warning copy. */ + formatExpiryWarning: (seconds: number) => string; + errorTitle: string; + sessionCreateErrorMessage: string; + pollingErrorMessage: string; + /** Rendered under the QR once files have arrived (e.g. a received-count badge). */ + renderReceived?: (count: number) => ReactNode; + qrSize?: number; +} + +export default function MobileTransferModal({ + opened, + onClose, + routePath, + onFileReceived, + title, + description, + instructions, + expiryWarningTitle, + formatExpiryWarning, + errorTitle, + sessionCreateErrorMessage, + pollingErrorMessage, + renderReceived, + qrSize = 240, +}: MobileTransferModalProps) { + const { config } = useAppConfig(); + + const { mobileUrl, filesReceived, error, timeRemaining, showExpiryWarning } = + useMobileTransferSession({ + active: opened, + routePath, + onFileReceived, + sessionCreateErrorMessage, + pollingErrorMessage, + // In dev the backend-advertised frontendUrl is the backend origin, which + // serves no SPA — the phone must open the Vite origin this page runs on, + // so let the URL builder fall back to it. An explicit server_url still + // wins, as an escape hatch. + configuredUrl: + localStorage.getItem("server_url") || + (import.meta.env.DEV ? "" : config?.frontendUrl || ""), + }); + + return ( + + + } + color="blue" + variant="light" + > + {description} + + + {showExpiryWarning && timeRemaining !== null && ( + } + title={expiryWarningTitle} + color="orange" + > + + {formatExpiryWarning(Math.ceil(timeRemaining / 1000))} + + + )} + + {error && ( + } + title={errorTitle} + color="red" + > + {error} + + )} + + + + + + + {filesReceived > 0 && renderReceived?.(filesReceived)} + + + {instructions} + + + + {mobileUrl} + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx index 76557d2b77..64ff82cb9f 100644 --- a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx +++ b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx @@ -1,17 +1,10 @@ -import { useEffect, useCallback, useState, useRef } from "react"; -import { Modal, Stack, Text, Badge, Box, Alert } from "@mantine/core"; -import { QRCodeSVG } from "qrcode.react"; +import { useCallback } from "react"; +import { Badge } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useAppConfig } from "@app/contexts/AppConfigContext"; -import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; -import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; -import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; -import { BASE_PATH } from "@app/constants/app"; -import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; +import MobileTransferModal from "@app/components/shared/MobileTransferModal"; import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils"; -import apiClient from "@app/services/apiClient"; interface MobileUploadModalProps { opened: boolean; @@ -19,47 +12,6 @@ interface MobileUploadModalProps { onFilesReceived: (files: File[]) => void; } -// Generate a cryptographically secure UUID v4-like session ID -function generateSessionId(): string { - // Use Web Crypto API for cryptographically secure random values - const cryptoObj = - typeof crypto !== "undefined" ? crypto : (window as any).crypto; - - if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { - const bytes = new Uint8Array(16); - cryptoObj.getRandomValues(bytes); - - // Set version (4) and variant bits per RFC 4122 - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 - - // Convert bytes to hex string in UUID format - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); - return [ - hex.slice(0, 4).join(""), - hex.slice(4, 6).join(""), - hex.slice(6, 8).join(""), - hex.slice(8, 10).join(""), - hex.slice(10, 16).join(""), - ].join("-"); - } - - // If Web Crypto is not available, fail fast rather than using insecure randomness - console.error( - "Web Crypto API not available. Cannot generate secure session ID.", - ); - throw new Error( - "Web Crypto API not available. Cannot generate secure session ID.", - ); -} - -interface SessionInfo { - sessionId: string; - createdAt: number; - expiresAt: number; - timeoutMs: number; -} - /** * MobileUploadModal * @@ -73,371 +25,103 @@ export default function MobileUploadModal({ }: MobileUploadModalProps) { const { t } = useTranslation(); const { config } = useAppConfig(); + const convertToPdf = config?.mobileScannerConvertToPdf !== false; - const [sessionId, setSessionId] = useState(() => generateSessionId()); - const [sessionInfo, setSessionInfo] = useState(null); - const [filesReceived, setFilesReceived] = useState(0); - const [error, setError] = useState(null); - const [timeRemaining, setTimeRemaining] = useState(null); - const [showExpiryWarning, setShowExpiryWarning] = useState(false); - const pollIntervalRef = useRef(null); - const timerIntervalRef = useRef(null); - const processedFiles = useRef>(new Set()); + const handleFileReceived = useCallback( + async (received: File) => { + let file = received; - // Build the QR-code URL the phone opens. It must land on the public - // /mobile-scanner route under the app's base path, otherwise the phone hits - // the auth-gated catch-all route and is bounced to the login page. - const mobileUrl = buildMobileScannerUrl({ - configuredUrl: - localStorage.getItem("server_url") || config?.frontendUrl || "", - sessionId, - origin: window.location.origin, - basePath: BASE_PATH, - }); - - // Create session on backend - const createSession = useCallback( - async (newSessionId: string) => { - try { - const response = await apiClient.post( - `/api/v1/mobile-scanner/create-session/${newSessionId}`, - undefined, - { - responseType: "json", - }, - ); - - if (!response.status || response.status !== 200) { - throw new Error("Failed to create session"); - } - - const data = response.data; - setSessionInfo(data); - setError(null); - console.log("[MobileUploadModal] Session created:", data); - } catch (err) { - console.error("[MobileUploadModal] Failed to create session:", err); - setError( - t("mobileUpload.sessionCreateError", "Failed to create session"), - ); - } - }, - [t], - ); - - // Regenerate session (when expired or warned) - const regenerateSession = useCallback(() => { - const newSessionId = generateSessionId(); - setSessionId(newSessionId); - setShowExpiryWarning(false); - setFilesReceived(0); - processedFiles.current.clear(); - createSession(newSessionId); - }, [createSession]); - - const pollForFiles = useCallback(async () => { - if (!opened) return; - - try { - const response = await apiClient.get( - `/api/v1/mobile-scanner/files/${sessionId}`, - ); - if (!response.status || response.status !== 200) { - throw new Error("Failed to check for files"); - } - - const data = response.data; - const files = data.files || []; - - // Download only files we haven't processed yet - const newFiles = files.filter( - (f: any) => !processedFiles.current.has(f.filename), - ); - - if (newFiles.length > 0) { - for (const fileMetadata of newFiles) { - try { - const downloadResponse = await apiClient.get( - `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, - { - responseType: "blob", - }, - ); - - if (downloadResponse.status === 200) { - const blob = downloadResponse.data; - let file = new File([blob], fileMetadata.filename, { - type: fileMetadata.contentType || "image/jpeg", - }); - - // Convert images to PDF if enabled - if ( - isImageFile(file) && - config?.mobileScannerConvertToPdf !== false - ) { - try { - file = await convertImageToPdf(file, { - imageResolution: config?.mobileScannerImageResolution as - | "full" - | "reduced" - | undefined, - pageFormat: config?.mobileScannerPageFormat as - | "keep" - | "A4" - | "letter" - | undefined, - stretchToFit: config?.mobileScannerStretchToFit, - }); - console.log( - "[MobileUploadModal] Converted image to PDF:", - file.name, - ); - } catch (convertError) { - console.warn( - "[MobileUploadModal] Failed to convert image to PDF, using original file:", - convertError, - ); - // Continue with original image file if conversion fails - } - } - - processedFiles.current.add(fileMetadata.filename); - setFilesReceived((prev) => prev + 1); - onFilesReceived([file]); - } - } catch (err) { - console.error( - "[MobileUploadModal] Failed to download file:", - fileMetadata.filename, - err, - ); - } - } - - // Delete the entire session immediately after downloading all files - // This ensures files are only on server for ~1 second + // Convert images to PDF if enabled + if (isImageFile(file) && convertToPdf) { try { - await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); - console.log( - "[MobileUploadModal] Session cleaned up after file download", - ); - } catch (cleanupErr) { + file = await convertImageToPdf(file, { + imageResolution: config?.mobileScannerImageResolution as + | "full" + | "reduced" + | undefined, + pageFormat: config?.mobileScannerPageFormat as + | "keep" + | "A4" + | "letter" + | undefined, + stretchToFit: config?.mobileScannerStretchToFit, + }); + } catch (convertError) { console.warn( - "[MobileUploadModal] Failed to cleanup session after download:", - cleanupErr, + "[MobileUploadModal] Failed to convert image to PDF, using original file:", + convertError, ); + // Continue with original image file if conversion fails } } - } catch (err) { - console.error("[MobileUploadModal] Error polling for files:", err); - setError(t("mobileUpload.pollingError", "Error checking for files")); - } - }, [opened, sessionId, onFilesReceived, t]); - // Create session when modal opens - useEffect(() => { - if (opened) { - createSession(sessionId); - setFilesReceived(0); - setError(null); - setShowExpiryWarning(false); - processedFiles.current.clear(); - } - }, [opened, sessionId]); // Only run when opened changes - - useEffect(() => { - if (!opened) return; - - createSession(sessionId); - setFilesReceived(0); - setError(null); - setShowExpiryWarning(false); - processedFiles.current.clear(); - - return () => { - console.log("Cleaning up session on unmount/close:", sessionId); - apiClient - .delete(`/api/v1/mobile-scanner/session/${sessionId}`) - .catch((err) => - console.warn("[MobileUploadModal] Cleanup failed:", err), - ); - }; - }, [opened, sessionId, createSession]); - - // Start polling for files when modal opens - useEffect(() => { - if (opened && sessionInfo) { - // Poll every 2 seconds - pollIntervalRef.current = window.setInterval(pollForFiles, 2000); - - // Initial poll - pollForFiles(); - } else { - // Stop polling when modal closes - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - } - - return () => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - } - }; - }, [opened, sessionInfo, pollForFiles]); - - // Session timeout timer - useEffect(() => { - if (!opened || !sessionInfo) return; - - const updateTimer = () => { - const now = Date.now(); - const remaining = sessionInfo.expiresAt - now; - - if (remaining <= 0) { - // Session expired - regenerate - setShowExpiryWarning(false); - regenerateSession(); - } else if (remaining <= 60000 && !showExpiryWarning) { - // Less than 1 minute remaining - show warning - setShowExpiryWarning(true); - } - - setTimeRemaining(Math.max(0, remaining)); - }; - - // Update immediately - updateTimer(); - - // Update every second - timerIntervalRef.current = window.setInterval(updateTimer, 1000); - - return () => { - if (timerIntervalRef.current) { - clearInterval(timerIntervalRef.current); - } - }; - }, [opened, sessionInfo, showExpiryWarning, regenerateSession]); + onFilesReceived([file]); + }, + [config, convertToPdf, onFilesReceived], + ); return ( - - - } - color="blue" - variant="light" + description={ + convertToPdf + ? t( + "mobileUpload.description", + "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", + ) + : t( + "mobileUpload.descriptionNoConvert", + "Scan this QR code with your mobile device to upload photos.", + ) + } + instructions={ + convertToPdf + ? t( + "mobileUpload.instructions", + "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", + ) + : t( + "mobileUpload.instructionsNoConvert", + "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", + ) + } + expiryWarningTitle={t( + "mobileUpload.expiryWarning", + "Session Expiring Soon", + )} + formatExpiryWarning={(seconds) => + t( + "mobileUpload.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds }, + ) + } + errorTitle={t("mobileUpload.error", "Connection Error")} + sessionCreateErrorMessage={t( + "mobileUpload.sessionCreateError", + "Failed to create session", + )} + pollingErrorMessage={t( + "mobileUpload.pollingError", + "Error checking for files", + )} + renderReceived={(count) => ( + } > - - {config?.mobileScannerConvertToPdf !== false - ? t( - "mobileUpload.description", - "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", - ) - : t( - "mobileUpload.descriptionNoConvert", - "Scan this QR code with your mobile device to upload photos.", - )} - - - - {showExpiryWarning && timeRemaining !== null && ( - } - title={t("mobileUpload.expiryWarning", "Session Expiring Soon")} - color="orange" - > - - {t( - "mobileUpload.expiryWarningMessage", - "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", - { seconds: Math.ceil(timeRemaining / 1000) }, - )} - - - )} - - {error && ( - } - title={t("mobileUpload.error", "Connection Error")} - color="red" - > - {error} - - )} - - - - - - - {filesReceived > 0 && ( - } - > - {t("mobileUpload.filesReceived", "{{count}} file(s) received", { - count: filesReceived, - })} - - )} - - - {config?.mobileScannerConvertToPdf !== false - ? t( - "mobileUpload.instructions", - "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", - ) - : t( - "mobileUpload.instructionsNoConvert", - "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", - )} - - - - {mobileUrl} - - - - + {t("mobileUpload.filesReceived", "{{count}} file(s) received", { + count, + })} + + )} + /> ); } diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx new file mode 100644 index 0000000000..c8607d55c4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx @@ -0,0 +1,176 @@ +/** + * Receive-flow contract for the phone-signature QR modal. + * + * The transfer session's upload endpoint accepts any file from anyone holding + * the QR URL, so the modal must treat arrivals as untrusted. Images become + * draw/photo payloads by filename prefix, a signature-text JSON payload is + * parsed and clamped field by field, and anything else is ignored. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import apiClient from "@app/services/apiClient"; +import { expectConsole } from "@app/tests/failOnConsole"; + +// Render the English fallbacks (the test i18n instance has no loaded locale). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +vi.mock("@app/services/apiClient", () => ({ + default: { + defaults: { baseURL: "http://localhost:8080" }, + post: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { enableMobileSignature: true } }), +})); + +const mockedApi = vi.mocked(apiClient, true); + +const SESSION_INFO = { + sessionId: "s", + createdAt: Date.now(), + expiresAt: Date.now() + 600_000, + timeoutMs: 600_000, +}; + +function primeSession( + files: Array<{ filename: string; contentType: string; body?: string }>, +) { + mockedApi.post.mockResolvedValue({ + status: 200, + data: SESSION_INFO, + } as never); + mockedApi.delete.mockResolvedValue({ status: 200 } as never); + mockedApi.get.mockImplementation(((url: string, config?: unknown) => { + if (url.includes("/files/")) { + return Promise.resolve({ status: 200, data: { files } } as never); + } + if (url.includes("/download/")) { + const filename = url.split("/").pop() ?? ""; + const meta = files.find((f) => f.filename === filename); + return Promise.resolve({ + status: 200, + data: new Blob([meta?.body ?? "fake-bytes"], { + type: meta?.contentType, + }), + config, + } as never); + } + return Promise.reject(new Error(`unexpected GET ${url}`)); + }) as never); +} + +function renderModal( + onSignatureReceived: (payload: MobileSignaturePayload) => void, + onClose: () => void, +) { + return render( + + + , + ); +} + +describe("MobileSignatureModal", () => { + // clearAllMocks (not restoreAllMocks): the hook's unmount cleanup still + // calls apiClient.delete during test teardown, so implementations must + // survive until React Testing Library's auto-cleanup has unmounted. + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hands a drawn signature to the caller as a draw payload and closes", async () => { + primeSession([ + { filename: "signature-draw-1.png", contentType: "image/png" }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + const payload = onSignatureReceived.mock.calls[0][0]; + expect(payload.kind).toBe("draw"); + expect(payload.dataUrl).toMatch(/^data:image\/png/); + expect(onClose).toHaveBeenCalled(); + }); + + it("routes a photographed signature as a photo payload", async () => { + primeSession([ + { filename: "signature-photo-1.jpg", contentType: "image/jpeg" }, + ]); + const onSignatureReceived = vi.fn(); + + renderModal(onSignatureReceived, vi.fn()); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0].kind).toBe("photo"); + }); + + it("parses a typed signature as text, clamping unknown font and colour", async () => { + primeSession([ + { + filename: "signature-text-1.json", + contentType: "application/json", + body: JSON.stringify({ + text: " Reece ", + fontFamily: "Wingdings", + color: "javascript:alert(1)", + }), + }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0]).toEqual({ + kind: "text", + text: "Reece", + fontFamily: "Helvetica", + color: "#000000", + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("ignores a non-image upload instead of setting it as the signature", async () => { + // Rejecting the upload logs a warning - that's the contract under test. + expectConsole.warn(/Ignoring non-image upload/); + primeSession([{ filename: "evil.html", contentType: "text/html" }]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + // The poll + download cycle must have run before we assert the negative. + await waitFor(() => + expect( + mockedApi.get.mock.calls.some(([url]) => + String(url).includes("/download/"), + ), + ).toBe(true), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onSignatureReceived).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx new file mode 100644 index 0000000000..82deae8b5f --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx @@ -0,0 +1,155 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import MobileTransferModal from "@app/components/shared/MobileTransferModal"; + +/** + * What the phone sent, routed to the matching signature source: ink and + * photos as pixels, typed signatures as data so they stay editable text. + */ +export type MobileSignaturePayload = + | { kind: "draw"; dataUrl: string } + | { kind: "photo"; dataUrl: string } + | { kind: "text"; text: string; fontFamily: string; color: string }; + +/** Fonts the sign tool's text mode offers; anything else falls back. */ +const TEXT_FONTS = new Set([ + "Helvetica", + "Times-Roman", + "Courier", + "Arial", + "Georgia", +]); +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; +const MAX_TEXT_LENGTH = 200; + +interface MobileSignatureModalProps { + opened: boolean; + onClose: () => void; + onSignatureReceived: (payload: MobileSignaturePayload) => void; +} + +/** FileReader-based (rather than File.text/arrayBuffer, absent in jsdom). */ +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +/** + * QR modal for creating a signature on a phone or tablet. The phone opens the + * public `/mobile-sign` page; the first valid arrival becomes the signature + * and the modal closes. + */ +export default function MobileSignatureModal({ + opened, + onClose, + onSignatureReceived, +}: MobileSignatureModalProps) { + const { t } = useTranslation(); + + // The session endpoints accept any upload from anyone holding the QR URL, + // so nothing here is trusted: images pass as pixels, a typed signature is + // parsed and clamped field by field, everything else is ignored. + const handleFileReceived = useCallback( + async (file: File) => { + if ( + file.type === "application/json" && + file.name.startsWith("signature-text") + ) { + try { + const parsed: unknown = JSON.parse(await readFileAsText(file)); + const record = parsed as Record; + const text = + typeof record?.text === "string" + ? record.text.trim().slice(0, MAX_TEXT_LENGTH) + : ""; + if (!text) return; + onSignatureReceived({ + kind: "text", + text, + fontFamily: TEXT_FONTS.has(record.fontFamily as string) + ? (record.fontFamily as string) + : "Helvetica", + color: HEX_COLOR.test(record.color as string) + ? (record.color as string) + : "#000000", + }); + onClose(); + } catch { + console.warn( + "[MobileSignatureModal] Ignoring malformed text payload", + ); + } + return; + } + + if (!file.type.startsWith("image/")) { + console.warn( + "[MobileSignatureModal] Ignoring non-image upload:", + file.type, + ); + return; + } + + await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result; + if (typeof dataUrl === "string") { + onSignatureReceived({ + kind: file.name.startsWith("signature-photo") ? "photo" : "draw", + dataUrl, + }); + onClose(); + } + resolve(); + }; + reader.onerror = () => resolve(); + reader.readAsDataURL(file); + }); + }, + [onSignatureReceived, onClose], + ); + + return ( + + t( + "sign.mobile.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds }, + ) + } + errorTitle={t("sign.mobile.error", "Connection Error")} + sessionCreateErrorMessage={t( + "sign.mobile.sessionCreateError", + "Failed to create session", + )} + pollingErrorMessage={t( + "sign.mobile.pollingError", + "Error checking for the signature", + )} + /> + ); +} diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index 648ada1c47..4aa9f8ffd3 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -34,6 +34,11 @@ import { AddSignatureResult, } from "@app/hooks/tools/sign/useSavedSignatures"; import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { buildSignaturePreview } from "@app/utils/signaturePreview"; type SignatureDrafts = { @@ -116,6 +121,12 @@ const SignSettings = ({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); + const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobileViewport = useIsMobile(); + // Drawing on a phone needs a second device, so the QR entry is desktop-only. + const canDrawOnPhone = + Boolean(config?.enableMobileSignature) && !isMobileViewport; // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState< @@ -625,6 +636,71 @@ const SignSettings = ({ [onActivateSignaturePlacement], ); + // Route a signature made on a phone to the matching source, mirroring + // handleUseSavedSignature: ink is a canvas signature, a photo is an image + // signature, and typed text stays editable text rather than baked pixels. + const handleMobileSignatureReceived = useCallback( + (payload: MobileSignaturePayload) => { + // Receiving a signature is as clear an intent to place it as drawing + // one, so placement goes live even if it was paused beforehand. + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + if (payload.kind === "draw") { + if (parameters.signatureType !== "canvas") { + onParameterChange("signatureType", "canvas"); + } + handleCanvasSignatureChange(payload.dataUrl); + } else if (payload.kind === "photo") { + if (parameters.signatureType !== "image") { + onParameterChange("signatureType", "image"); + } + setImageSignatureData(payload.dataUrl); + } else { + if (parameters.signatureType !== "text") { + onParameterChange("signatureType", "text"); + } + onParameterChange("signerName", payload.text); + onParameterChange("fontFamily", payload.fontFamily); + onParameterChange("textColor", payload.color); + // Move the draft mirror in the same commit as the parameters. The + // record/restore effect pair otherwise sees them one commit apart and + // ping-pongs old draft against new params, wiping the received text + // and looping until React aborts the update depth. + const nextDraft = { + signerName: payload.text, + fontSize: parameters.fontSize ?? 16, + fontFamily: payload.fontFamily, + textColor: payload.color, + }; + lastSyncedTextDraft.current = nextDraft; + setSignatureDrafts((prev) => ({ ...prev, text: nextDraft })); + } + // Activate directly for every kind: the canvas-change handler only + // activates when the data actually changed, and the auto-activate + // effect only reacts to state transitions - neither fires for a + // repeat of the same signature. Fired twice because the first shot can + // land between the receive commit and the ready-state settling, where + // the placement effect immediately deactivates it; the second shot is + // after everything has settled, and re-activating is idempotent. + if (typeof window !== "undefined") { + window.setTimeout( + () => onActivateSignaturePlacement?.(), + PLACEMENT_ACTIVATION_DELAY, + ); + window.setTimeout(() => onActivateSignaturePlacement?.(), 500); + } else { + onActivateSignaturePlacement?.(); + } + }, + [ + parameters.signatureType, + parameters.fontSize, + onParameterChange, + handleCanvasSignatureChange, + onActivateSignaturePlacement, + ], + ); + const hasCanvasSignature = useMemo( () => Boolean(canvasSignatureData), [canvasSignatureData], @@ -980,6 +1056,29 @@ const SignSettings = ({ if (signatureSource === "image") { return ( + {imageSignatureData && ( + + {translate("image.previewAlt", + + )} + {canDrawOnPhone && ( + <> + + setIsMobileSignModalOpen(false)} + onSignatureReceived={handleMobileSignatureReceived} + /> + + )} {sourceOptions.length > 1 && ( b.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); + } + + // If Web Crypto is not available, fail fast rather than using insecure randomness + throw new Error( + "Web Crypto API not available. Cannot generate secure session ID.", + ); +} + +export interface MobileTransferSessionInfo { + sessionId: string; + createdAt: number; + expiresAt: number; + timeoutMs: number; +} + +interface UseMobileTransferSessionParams { + /** Session exists and polling runs only while true (modal open). */ + active: boolean; + /** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; + /** Called once per newly uploaded file, in upload order. */ + onFileReceived: (file: File) => void | Promise; + /** Message shown when the backend refuses to create a session. */ + sessionCreateErrorMessage: string; + /** Message shown when polling for uploads fails. */ + pollingErrorMessage: string; + /** Host the phone should reach, when configured (server_url / frontendUrl). */ + configuredUrl?: string; +} + +export function useMobileTransferSession({ + active, + routePath, + onFileReceived, + sessionCreateErrorMessage, + pollingErrorMessage, + configuredUrl, +}: UseMobileTransferSessionParams) { + const [sessionId, setSessionId] = useState(() => generateSessionId()); + const [sessionInfo, setSessionInfo] = + useState(null); + const [filesReceived, setFilesReceived] = useState(0); + const [error, setError] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(null); + const [showExpiryWarning, setShowExpiryWarning] = useState(false); + const pollIntervalRef = useRef(null); + const timerIntervalRef = useRef(null); + const processedFiles = useRef>(new Set()); + + // The QR-code URL the phone opens. It must land on the public route under + // the app's base path, otherwise the phone hits the auth-gated catch-all + // route and is bounced to the login page. + const mobileUrl = buildMobileRouteUrl({ + configuredUrl: configuredUrl ?? "", + sessionId, + origin: window.location.origin, + basePath: BASE_PATH, + routePath, + }); + + const createSession = useCallback( + async (newSessionId: string) => { + try { + const response = await apiClient.post( + `/api/v1/mobile-scanner/create-session/${newSessionId}`, + undefined, + { responseType: "json" }, + ); + + if (!response.status || response.status !== 200) { + throw new Error("Failed to create session"); + } + + setSessionInfo(response.data); + setError(null); + } catch (err) { + console.error("[useMobileTransferSession] create failed:", err); + setError(sessionCreateErrorMessage); + } + }, + [sessionCreateErrorMessage], + ); + + // Regenerate session (when expired or warned) + const regenerateSession = useCallback(() => { + const newSessionId = generateSessionId(); + setSessionId(newSessionId); + setShowExpiryWarning(false); + setFilesReceived(0); + processedFiles.current.clear(); + createSession(newSessionId); + }, [createSession]); + + const pollForFiles = useCallback(async () => { + if (!active) return; + + try { + const response = await apiClient.get( + `/api/v1/mobile-scanner/files/${sessionId}`, + ); + if (!response.status || response.status !== 200) { + throw new Error("Failed to check for files"); + } + + const files = response.data.files || []; + + // Download only files we haven't processed yet + const newFiles = files.filter( + (f: any) => !processedFiles.current.has(f.filename), + ); + if (newFiles.length === 0) return; + + for (const fileMetadata of newFiles) { + try { + const downloadResponse = await apiClient.get( + `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, + { responseType: "blob" }, + ); + + if (downloadResponse.status === 200) { + const file = new File( + [downloadResponse.data], + fileMetadata.filename, + { type: fileMetadata.contentType || "image/jpeg" }, + ); + processedFiles.current.add(fileMetadata.filename); + setFilesReceived((prev) => prev + 1); + await onFileReceived(file); + } + } catch (err) { + console.error( + "[useMobileTransferSession] download failed:", + fileMetadata.filename, + err, + ); + } + } + + // Delete the entire session immediately after downloading, so uploads + // sit on the server only for the seconds between polls. + try { + await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); + } catch (cleanupErr) { + console.warn( + "[useMobileTransferSession] post-download cleanup failed:", + cleanupErr, + ); + } + } catch (err) { + console.error("[useMobileTransferSession] polling failed:", err); + setError(pollingErrorMessage); + } + }, [active, sessionId, onFileReceived, pollingErrorMessage]); + + // Create the session while active; delete it when deactivated/unmounted. + useEffect(() => { + if (!active) return; + + createSession(sessionId); + setFilesReceived(0); + setError(null); + setShowExpiryWarning(false); + processedFiles.current.clear(); + + return () => { + apiClient + .delete(`/api/v1/mobile-scanner/session/${sessionId}`) + .catch((err) => + console.warn("[useMobileTransferSession] cleanup failed:", err), + ); + }; + }, [active, sessionId, createSession]); + + // Poll for uploads while the session is live + useEffect(() => { + if (active && sessionInfo) { + pollIntervalRef.current = window.setInterval(pollForFiles, 2000); + pollForFiles(); + } else if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [active, sessionInfo, pollForFiles]); + + // Session timeout timer: warn under a minute, regenerate on expiry + useEffect(() => { + if (!active || !sessionInfo) return; + + const updateTimer = () => { + const now = Date.now(); + const remaining = sessionInfo.expiresAt - now; + + if (remaining <= 0) { + setShowExpiryWarning(false); + regenerateSession(); + } else if (remaining <= 60000 && !showExpiryWarning) { + setShowExpiryWarning(true); + } + + setTimeRemaining(Math.max(0, remaining)); + }; + + updateTimer(); + timerIntervalRef.current = window.setInterval(updateTimer, 1000); + + return () => { + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + } + }; + }, [active, sessionInfo, showExpiryWarning, regenerateSession]); + + return { + /** URL to encode in the QR code. */ + mobileUrl, + sessionInfo, + /** Count of files received this session (resets on regenerate). */ + filesReceived, + error, + /** Milliseconds until the session expires, once known. */ + timeRemaining, + /** True inside the final minute before expiry. */ + showExpiryWarning, + regenerateSession, + }; +} diff --git a/frontend/editor/src/core/pages/MobileSignPage.test.tsx b/frontend/editor/src/core/pages/MobileSignPage.test.tsx new file mode 100644 index 0000000000..84bcf1b736 --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.test.tsx @@ -0,0 +1,100 @@ +/** + * Session-state contract for the phone-side signature page: a missing or + * expired session shows one clear error instead of a canvas whose Send would + * fail; a valid session shows the draw/type/photo tabs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; +import MobileSignPage from "@app/pages/MobileSignPage"; + +vi.mock("@app/services/apiClient", () => ({ + default: { defaults: { baseURL: "http://localhost:8080" } }, +})); + +// Render the English fallbacks the assertions read (the test i18n instance +// has no loaded locale, so bare t() would render raw keys). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +// Branding components pull theme preferences from providers this page doesn't +// need for its session-state contract. +vi.mock("@app/components/shared/LogoIcon", () => ({ + LogoIcon: () => , +})); +vi.mock("@app/components/shared/Wordmark", () => ({ + Wordmark: () => , +})); + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +describe("MobileSignPage", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + // jsdom has no ResizeObserver; the draw canvas sizes itself with one. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom's canvas has no real 2d context (and logs an error when asked); + // the draw canvas tolerates a null context. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the expired-session error when the URL has no session", async () => { + renderAt("/mobile-sign"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("shows the expired-session error when the backend rejects the session", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + json: async () => ({ valid: false }), + } as Response); + + renderAt("/mobile-sign?session=stale-session"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + }); + + it("shows the signature tabs once the session validates", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ valid: true }), + } as Response); + + renderAt("/mobile-sign?session=good-session"); + + await waitFor(() => expect(screen.getByText("Draw")).toBeInTheDocument()); + expect(screen.getByText("Type")).toBeInTheDocument(); + expect(screen.getByText("Photo")).toBeInTheDocument(); + expect(screen.getByText(/Send to computer/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/core/pages/MobileSignPage.tsx b/frontend/editor/src/core/pages/MobileSignPage.tsx new file mode 100644 index 0000000000..fd4034e0ba --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.tsx @@ -0,0 +1,554 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + Alert, + Box, + Card, + Group, + Image, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { Button as DSButton } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { useTranslation } from "react-i18next"; +import { LogoIcon } from "@app/components/shared/LogoIcon"; +import { Wordmark } from "@app/components/shared/Wordmark"; +import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import UndoRoundedIcon from "@mui/icons-material/UndoRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; +import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; +import { + MobileDrawCanvas, + type MobileDrawCanvasHandle, +} from "@app/components/mobileSign/MobileDrawCanvas"; +import apiClient from "@app/services/apiClient"; + +// Use the configured API base (e.g. api.stirling.com), not the page origin. +const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); + +type SignatureTab = "draw" | "type" | "photo"; + +// Ink pigments, not UI theme colours: they are baked into the exported PNG +// and transferred to the desktop, so they must be fixed literals. +const INK_COLORS = [ + { value: "#101010", label: "black" }, // theme-allow-color ink pigment, serialized into the signature + { value: "#1d4ed8", label: "blue" }, // theme-allow-color ink pigment, serialized into the signature +]; + +const PEN_SIZES = [ + { value: 2, label: "S" }, + { value: 3.5, label: "M" }, + { value: 6, label: "L" }, +]; + +/** + * The sign tool's own text-mode fonts, so a typed signature transfers as + * data and stays editable there. `css` approximates each for the on-phone + * preview; `value` is what the desktop's font parameter understands. + */ +const TYPE_FONTS = [ + { + value: "Helvetica", + css: "Helvetica, Arial, sans-serif", + label: "Helvetica", + }, + { + value: "Times-Roman", + css: "'Times New Roman', Times, serif", + label: "Times", + }, + { + value: "Courier", + css: "'Courier New', Courier, monospace", + label: "Courier", + }, + { value: "Arial", css: "Arial, sans-serif", label: "Arial" }, + { value: "Georgia", css: "Georgia, serif", label: "Georgia" }, +]; + +async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + return response.blob(); +} + +/** + * MobileSignPage + * + * Phone-side page for sending a signature to the desktop: draw one (the main + * path), type one, or photograph one. Reached by scanning the QR code shown in + * the editor's Sign tool; the session comes from the QR URL and rides the same + * transfer backend as the mobile scanner. + */ +export default function MobileSignPage() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const sessionId = searchParams.get("session"); + // Landscape phones (not tablets — hence the height cap) get a compact + // layout: branding hidden, tighter padding, shorter pad, so the canvas and + // the Send button fit on screen together. + const compactLandscape = + useMediaQuery("(orientation: landscape) and (max-height: 32rem)") ?? false; + + const [sessionValid, setSessionValid] = useState(null); + const [tab, setTab] = useState("draw"); + const [hasInk, setHasInk] = useState(false); + const [typedText, setTypedText] = useState(""); + const [typeFont, setTypeFont] = useState(TYPE_FONTS[0].value); + const [inkColor, setInkColor] = useState(INK_COLORS[0].value); + const [penSize, setPenSize] = useState(PEN_SIZES[1].value); + const [photoDataUrl, setPhotoDataUrl] = useState(null); + const [photoError, setPhotoError] = useState(null); + const [isSending, setIsSending] = useState(false); + const [sendError, setSendError] = useState(null); + const [sentCount, setSentCount] = useState(0); + const [justSent, setJustSent] = useState(false); + + const canvasHandle = useRef(null); + const photoInputRef = useRef(null); + const cameraInputRef = useRef(null); + + // Validate the session up front, so a stale QR shows one clear error rather + // than a canvas whose Send fails. + useEffect(() => { + if (!sessionId) { + setSessionValid(false); + return; + } + (async () => { + try { + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`, + ); + const data = response.ok ? await response.json() : null; + setSessionValid(Boolean(data?.valid)); + } catch { + setSessionValid(false); + } + })(); + }, [sessionId]); + + const handlePhotoSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + setPhotoError( + t("mobileSign.photo.invalidType", "Please choose an image file."), + ); + return; + } + setPhotoError(null); + const reader = new FileReader(); + reader.onload = (event) => + setPhotoDataUrl((event.target?.result as string) ?? null); + reader.readAsDataURL(file); + }; + + /** + * What this tab sends: ink and photos as image files, typed signatures as a + * JSON payload (text + font + colour) so the desktop keeps them editable in + * the sign tool's text mode. The filename prefix tells the desktop which + * source the signature belongs to. + */ + const buildUpload = useCallback(async (): Promise<{ + blob: Blob; + filename: string; + } | null> => { + if (tab === "draw") { + const dataUrl = canvasHandle.current?.exportPng(); + if (!dataUrl) return null; + return { + blob: await dataUrlToBlob(dataUrl), + filename: `signature-draw-${Date.now()}.png`, + }; + } + if (tab === "type") { + const text = typedText.trim(); + if (!text) return null; + const payload = JSON.stringify({ + text, + fontFamily: typeFont, + color: inkColor, + }); + return { + blob: new Blob([payload], { type: "application/json" }), + filename: `signature-text-${Date.now()}.json`, + }; + } + if (!photoDataUrl) return null; + const blob = await dataUrlToBlob(photoDataUrl); + const extension = blob.type === "image/jpeg" ? "jpg" : "png"; + return { + blob, + filename: `signature-photo-${Date.now()}.${extension}`, + }; + }, [tab, typedText, typeFont, inkColor, photoDataUrl]); + + const canSend = + (tab === "draw" && hasInk) || + (tab === "type" && typedText.trim().length > 0) || + (tab === "photo" && photoDataUrl !== null); + + const handleSend = async () => { + if (!sessionId) return; + + setIsSending(true); + setSendError(null); + try { + const upload = await buildUpload(); + if (!upload) return; + const formData = new FormData(); + formData.append("files", upload.blob, upload.filename); + + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`, + { method: "POST", body: formData }, + ); + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + setSentCount((count) => count + 1); + setJustSent(true); + // Reset the inputs so "send another" starts clean + canvasHandle.current?.clear(); + setTypedText(""); + setPhotoDataUrl(null); + if (photoInputRef.current) photoInputRef.current.value = ""; + if (cameraInputRef.current) cameraInputRef.current.value = ""; + } catch (err) { + console.error("[MobileSignPage] upload failed:", err); + setSendError( + t( + "mobileSign.sendError", + "Could not send the signature. Check the connection and try again.", + ), + ); + } finally { + setIsSending(false); + } + }; + + const header = ( + + + + + ); + + if (sessionValid === null) { + return ( + + {header} + + {t("mobileSign.validating", "Checking session…")} + + + ); + } + + if (!sessionValid) { + return ( + + {header} + } + color="red" + title={t("mobileSign.invalidSession", "Session expired")} + > + {t( + "mobileSign.invalidSessionMessage", + "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code.", + )} + + + ); + } + + return ( + + {!compactLandscape && header} + + {justSent && ( + } + color="green" + mb="sm" + withCloseButton + onClose={() => setJustSent(false)} + > + {t( + "mobileSign.sentMessage", + "Signature sent to your computer. You can send another or close this page.", + )} + + )} + {sendError && ( + } + color="red" + mb="sm" + > + {sendError} + + )} + + + + fullWidth + value={tab} + onChange={setTab} + ariaLabel={t("mobileSign.tabsLabel", "Signature source")} + options={[ + // Same order as the sign tool's sources: canvas, image, text + { value: "draw", label: t("mobileSign.tab.draw", "Draw") }, + { value: "photo", label: t("mobileSign.tab.photo", "Photo") }, + { value: "type", label: t("mobileSign.tab.type", "Type") }, + ]} + /> + + + {tab === "draw" && ( + + + + + + + + {INK_COLORS.map((color) => ( + setInkColor(color.value)} + aria-label={color.label} + style={{ + width: 32, + height: 32, + borderRadius: "50%", + background: color.value, + cursor: "pointer", + border: + inkColor === color.value + ? "3px solid var(--mantine-color-blue-4)" + : "3px solid transparent", + }} + /> + ))} + setPenSize(Number(value))} + ariaLabel={t("mobileSign.penSizeLabel", "Pen size")} + options={PEN_SIZES.map((size) => ({ + value: String(size.value), + label: size.label, + }))} + /> + + + canvasHandle.current?.undo()} + leftSection={} + > + {t("mobileSign.undo", "Undo")} + + canvasHandle.current?.clear()} + leftSection={ + + } + > + {t("mobileSign.clear", "Clear")} + + + + + )} + + {tab === "type" && ( + + setTypedText(e.target.value)} + placeholder={t("mobileSign.type.placeholder", "Your name")} + autoComplete="name" + /> + + + + )} + + + } + > + {sentCount > 0 + ? t("mobileSign.sendAnother", "Send another signature") + : t("mobileSign.send", "Send to computer")} + + + + ); +} diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 72c7d7c5b6..2dafb3d07a 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -34,6 +34,7 @@ export interface AppConfig { serverCertificateEnabled?: boolean; hardwareSigningAvailable?: boolean; enableMobileScanner?: boolean; + enableMobileSignature?: boolean; mobileScannerConvertToPdf?: boolean; mobileScannerImageResolution?: string; mobileScannerPageFormat?: string; diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.test.ts b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts index 8890399b7b..024eeaf54a 100644 --- a/frontend/editor/src/core/utils/mobileScannerUrl.test.ts +++ b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts @@ -9,10 +9,51 @@ */ import { describe, test, expect } from "vitest"; -import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; +import { + buildMobileRouteUrl, + buildMobileScannerUrl, +} from "@app/utils/mobileScannerUrl"; const sessionId = "abc-123"; +describe("buildMobileRouteUrl", () => { + test("routes other mobile pages (mobile-sign) with the base path", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "https://app.stirlingpdf.com", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + routePath: "mobile-sign", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-sign?session=abc-123"); + }); + + test("configured URL with subpath keeps the route un-doubled", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "https://host.example/app/", + sessionId, + origin: "https://elsewhere.example", + basePath: "/app", + routePath: "mobile-sign", + }), + ).toBe("https://host.example/app/mobile-sign?session=abc-123"); + }); + + test("no configured URL falls back to origin + base path", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "", + sessionId, + origin: "http://192.168.1.20:8080", + basePath: "", + routePath: "mobile-sign", + }), + ).toBe("http://192.168.1.20:8080/mobile-sign?session=abc-123"); + }); +}); + describe("buildMobileScannerUrl", () => { test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => { expect( diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.ts b/frontend/editor/src/core/utils/mobileScannerUrl.ts index c705069986..b44dcc358e 100644 --- a/frontend/editor/src/core/utils/mobileScannerUrl.ts +++ b/frontend/editor/src/core/utils/mobileScannerUrl.ts @@ -1,10 +1,10 @@ /** - * Build the URL a phone opens (via the QR code) to reach the SPA's - * `/mobile-scanner` route. + * Build the URL a phone opens (via a QR code) to reach one of the SPA's + * public mobile routes (`/mobile-scanner`, `/mobile-sign`). * - * That route is a public, top-level route. It lives under the app's base path, - * which is the router's `basename`. If the generated URL omits the base path, - * the phone loads a path the router can't match, falls through to the + * These routes are public, top-level routes. They live under the app's base + * path, which is the router's `basename`. If the generated URL omits the base + * path, the phone loads a path the router can't match, falls through to the * auth-gated catch-all route, and gets bounced to the login page. So the base * path must always be present. * @@ -18,15 +18,17 @@ * * With no usable configured URL, fall back to the current origin + base path. */ -export function buildMobileScannerUrl(params: { +export function buildMobileRouteUrl(params: { configuredUrl: string; sessionId: string; origin: string; basePath: string; + /** Route under the SPA base, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; }): string { - const { configuredUrl, sessionId, origin, basePath } = params; + const { configuredUrl, sessionId, origin, basePath, routePath } = params; const query = `?session=${sessionId}`; - const route = `${basePath}/mobile-scanner`; + const route = `${basePath}/${routePath}`; const trimmed = configuredUrl.trim(); if (trimmed) { @@ -35,7 +37,7 @@ export function buildMobileScannerUrl(params: { if (parsed.protocol === "http:" || parsed.protocol === "https:") { const subpath = parsed.pathname.replace(/\/+$/, ""); return subpath - ? `${parsed.origin}${subpath}/mobile-scanner${query}` + ? `${parsed.origin}${subpath}/${routePath}${query}` : `${parsed.origin}${route}${query}`; } } catch { @@ -45,3 +47,13 @@ export function buildMobileScannerUrl(params: { return `${origin}${route}${query}`; } + +/** The `/mobile-scanner` QR URL. See {@link buildMobileRouteUrl}. */ +export function buildMobileScannerUrl(params: { + configuredUrl: string; + sessionId: string; + origin: string; + basePath: string; +}): string { + return buildMobileRouteUrl({ ...params, routePath: "mobile-scanner" }); +} diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index ed36815bfd..de828c9f2d 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -15,6 +15,7 @@ import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; @@ -59,6 +60,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* Participant signing — public, token-gated, no auth required */} import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); // Import global styles import "@app/styles/tailwind.css"; @@ -83,6 +84,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* Admin-only route-set (the portal): its own top-level shell, mounted before the catch-all. */} {getAdminRouteExtensions()} diff --git a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx index b8691df30d..503087cbe4 100644 --- a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx @@ -34,6 +34,11 @@ import { AddSignatureResult, } from "@app/hooks/tools/sign/useSavedSignatures"; import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { buildSignaturePreview } from "@app/utils/signaturePreview"; type SignatureDrafts = { @@ -116,6 +121,12 @@ const SignSettings = ({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); + const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobileViewport = useIsMobile(); + // Drawing on a phone needs a second device, so the QR entry is desktop-only. + const canDrawOnPhone = + Boolean(config?.enableMobileSignature) && !isMobileViewport; // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState< @@ -665,6 +676,71 @@ const SignSettings = ({ [onActivateSignaturePlacement], ); + // Route a signature made on a phone to the matching source, mirroring + // handleUseSavedSignature: ink is a canvas signature, a photo is an image + // signature, and typed text stays editable text rather than baked pixels. + const handleMobileSignatureReceived = useCallback( + (payload: MobileSignaturePayload) => { + // Receiving a signature is as clear an intent to place it as drawing + // one, so placement goes live even if it was paused beforehand. + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + if (payload.kind === "draw") { + if (parameters.signatureType !== "canvas") { + onParameterChange("signatureType", "canvas"); + } + handleCanvasSignatureChange(payload.dataUrl); + } else if (payload.kind === "photo") { + if (parameters.signatureType !== "image") { + onParameterChange("signatureType", "image"); + } + setImageSignatureData(payload.dataUrl); + } else { + if (parameters.signatureType !== "text") { + onParameterChange("signatureType", "text"); + } + onParameterChange("signerName", payload.text); + onParameterChange("fontFamily", payload.fontFamily); + onParameterChange("textColor", payload.color); + // Move the draft mirror in the same commit as the parameters. The + // record/restore effect pair otherwise sees them one commit apart and + // ping-pongs old draft against new params, wiping the received text + // and looping until React aborts the update depth. + const nextDraft = { + signerName: payload.text, + fontSize: parameters.fontSize ?? 16, + fontFamily: payload.fontFamily, + textColor: payload.color, + }; + lastSyncedTextDraft.current = nextDraft; + setSignatureDrafts((prev) => ({ ...prev, text: nextDraft })); + } + // Activate directly for every kind: the canvas-change handler only + // activates when the data actually changed, and the auto-activate + // effect only reacts to state transitions - neither fires for a + // repeat of the same signature. Fired twice because the first shot can + // land between the receive commit and the ready-state settling, where + // the placement effect immediately deactivates it; the second shot is + // after everything has settled, and re-activating is idempotent. + if (typeof window !== "undefined") { + window.setTimeout( + () => onActivateSignaturePlacement?.(), + PLACEMENT_ACTIVATION_DELAY, + ); + window.setTimeout(() => onActivateSignaturePlacement?.(), 500); + } else { + onActivateSignaturePlacement?.(); + } + }, + [ + parameters.signatureType, + parameters.fontSize, + onParameterChange, + handleCanvasSignatureChange, + onActivateSignaturePlacement, + ], + ); + const hasCanvasSignature = useMemo( () => Boolean(canvasSignatureData), [canvasSignatureData], @@ -1019,6 +1095,29 @@ const SignSettings = ({ if (signatureSource === "image") { return ( + {imageSignatureData && ( + + {translate("image.previewAlt", + + )} + {canDrawOnPhone && ( + <> + + setIsMobileSignModalOpen(false)} + onSignatureReceived={handleMobileSignatureReceived} + /> + + )} {sourceOptions.length > 1 && ( Date: Wed, 12 Aug 2026 09:06:05 +0000 Subject: [PATCH 150/262] =?UTF-8?q?a11y:=20empty=20the=20grandfathered=20S?= =?UTF-8?q?torybook=20baseline=20(1,058=20=E2=86=92=200)=20(#7309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Empties the light Storybook accessibility baseline — **1,058 grandfathered violations across 846 stories → 0** — so a new violation fails the gate instead of being silently absorbed. Also burns the dark baseline **812 → 56**; every entry left is one `main` already grandfathers. ## The defect, repeated everywhere A colour picked as a **fill**, chosen to carry a white label at 3:1, reused as **text**, where the floor is 4.5:1. It recurred through status accents, filled buttons, form labels, Mantine's light and outline variants, CSS declarations, inline styles and the generated accent ramp. Three systemic causes account for most of it: - **Mantine's semantic slots were never bound.** `-text`, `-outline`, `-light-color`, `-filled` and `-dimmed` all default to the hue's solid fill. Both resolvers now pin them to the accessible ink for the active scheme. - **The tint ladder was compressed.** `--color--50/100/200` pointed at saturated 400-level primitives, so every "tint" background rendered as a fill. - **Text was faded with `opacity`**, pushing already-muted copy below the floor. Each site now recedes via ink or surface, which is what conveyed the state anyway. ## Dark mode The colour resolver's dark half was empty, so dark fell through to Mantine's stock palette — and fixing the naming violations unmasked the contrast sitting underneath them. Both schemes now share one slot map, since most slots are written in tokens that already flip. The dark-only fixes: `--c-text-subtle` (3.0:1, used in 478 places), the error and section-label inks, and the accent ramp's text step — which light reaches by mixing toward black and dark has to reach by mixing toward white. ## Also - New `--c-*-solid` tokens for fills that must carry a white label, distinct from the `--c-` values used for surfaces, borders and icons. - A `data-user-content-preview` opt-out for nodes rendering a facsimile of the user's own document — WCAG governs the interface, not content authored through it. ## Verification - `task frontend:check:all` — green. - Changed-set gate, both schemes, after the final rebase: **366 stories, 0 regressions**. - Full sweep at the prior base — light **1,447 stories / 0 violations**, dark **1,448 / 0 regressions**. The dark re-record was confirmed key-by-key to be a strict subset of `main`'s, so nothing new is grandfathered. Roughly 28% of what this clears is naming and structure (`button-name`, `label`, `aria-*`) and has no visual signature; the rest is contrast. --- frontend/.gitignore | 1 + frontend/.storybook/a11y-baseline.dark.json | 2483 +--------------- frontend/.storybook/a11y-baseline.json | 2641 +---------------- frontend/.storybook/a11y-changed.mjs | 25 +- frontend/.storybook/preview.tsx | 59 +- .../public/locales/en-GB/translation.toml | 19 + .../public/locales/en-US/translation.toml | 16 + .../shared/config/configSections/Payg.css | 12 +- .../shared/config/configSections/PaygFree.css | 2 +- .../config/configSections/SpendCapControl.css | 4 +- .../config/configSections/usageMeters.tsx | 3 + .../src/core/components/StorageStatsCard.tsx | 1 + .../annotation/shared/ColorPicker.tsx | 8 + .../components/fileEditor/AddFileCard.tsx | 2 +- .../fileManager/CompactFileDetails.tsx | 2 +- .../components/fileManager/DragOverlay.tsx | 14 +- .../fileManager/EmptyFilesState.tsx | 2 +- .../components/fileManager/FileInfoCard.tsx | 16 +- .../core/components/filesPage/FileGrid.tsx | 384 +-- .../components/filesPage/FileOriginBadge.tsx | 4 +- .../core/components/filesPage/FilesPage.css | 35 +- .../components/filesPage/FolderThumbnail.tsx | 5 +- .../InitialOnboardingModal.module.css | 2 +- .../onboarding/OnboardingSlideShell.tsx | 214 +- .../slides/AnalyticsChoiceSlide.tsx | 2 +- .../onboarding/slides/FirstLoginSlide.tsx | 2 +- .../onboarding/slides/SecurityCheckSlide.tsx | 2 +- .../BulkSelectionPanel.module.css | 5 +- .../shared/DropdownListWithFooter.tsx | 7 + .../components/shared/EditableSecretField.tsx | 14 +- .../shared/EncryptedPdfUnlockModal.tsx | 2 +- .../core/components/shared/ErrorBoundary.tsx | 2 +- .../components/shared/FileDropdownMenu.tsx | 5 + .../src/core/components/shared/FileGrid.tsx | 1 + .../components/shared/FilePickerModal.tsx | 9 +- .../shared/FileSelectorPicker.module.css | 2 +- .../components/shared/FileSelectorPicker.tsx | 1 + .../core/components/shared/FileSidebar.css | 6 +- .../components/shared/FileSidebarFileItem.css | 8 +- .../src/core/components/shared/InfoBanner.tsx | 2 +- .../components/shared/MobileTransferModal.tsx | 5 + .../ObscuredOverlay.module.css | 2 +- .../shared/PageEditorFileDropdown.tsx | 5 + .../core/components/shared/ThemeProvider.tsx | 6 +- .../src/core/components/shared/ToolChain.tsx | 4 +- .../components/shared/ToolPanelHeader.css | 8 +- .../core/components/shared/UpdateModal.tsx | 1282 ++++---- .../core/components/shared/WorkbenchBar.css | 4 +- .../components/shared/ZipWarningModal.tsx | 2 +- .../config/configSections/GeneralSection.tsx | 36 +- .../shared/config/configSections/Overview.tsx | 2 +- .../config/configSections/ProviderCard.tsx | 2 +- .../shared/filePreview/DocumentThumbnail.tsx | 2 +- .../shared/signing/CreateSessionFlow.tsx | 5 +- .../sliderWithInput/SliderWithInput.tsx | 8 +- .../wetSignature/DrawSignatureCanvas.tsx | 13 + .../shared/wetSignature/TypeSignatureText.tsx | 19 +- .../wetSignature/UploadSignatureImage.tsx | 2 +- .../PageNumberPreview.module.css | 2 +- .../tools/addStamp/StampPreview.module.css | 2 +- .../tools/addStamp/StampPreview.tsx | 1 + .../addWatermark/WatermarkStyleSettings.tsx | 17 +- .../tools/addWatermark/WatermarkTextStyle.tsx | 9 +- .../tools/automate/AutomationImportModal.tsx | 15 +- .../tools/automate/AutomationRun.tsx | 2 +- .../BookletImpositionSettings.tsx | 4 +- .../modals/CertificateConfigModal.tsx | 4 +- .../certSign/panels/SignControlsPanel.tsx | 4 +- .../certSign/panels/SignRequestPanel.tsx | 2 +- .../certSign/steps/AddSignaturesStep.tsx | 2 +- .../compare/ComparePixelWorkbenchView.tsx | 2 +- .../tools/compress/CompressSettings.tsx | 7 +- .../convert/ConvertFromEmailSettings.tsx | 5 +- .../tools/convert/ConvertFromWebSettings.tsx | 10 +- .../tools/convert/ConvertToPdfaSettings.tsx | 5 +- .../tools/ocr/LanguagePicker.module.css | 2 +- .../components/tools/ocr/LanguagePicker.tsx | 2 +- .../tools/pdfTextEditor/FontStatusPanel.tsx | 4 +- .../tools/pdfTextEditor/PdfTextEditorView.tsx | 2 +- .../removeBlanks/RemoveBlanksSettings.tsx | 13 +- .../replaceColor/ReplaceColorSettings.tsx | 17 +- .../tools/shared/NumberInputWithUnit.tsx | 5 +- .../core/components/tools/shared/ToolStep.tsx | 3 +- .../components/tools/showJS/ShowJSView.css | 2 +- .../tools/toolPicker/FavoriteStar.tsx | 4 + .../reportView/SignatureSection.tsx | 2 +- .../reportView/SignatureStatusBadge.tsx | 43 +- .../components/viewer/AttachmentSidebar.css | 2 +- .../components/viewer/AttachmentSidebar.tsx | 2 +- .../components/viewer/BookmarkSidebar.tsx | 6 +- .../components/viewer/CommentsSidebar.tsx | 7 +- .../core/components/viewer/EmbedPdfViewer.tsx | 2 +- .../core/components/viewer/LayerSidebar.tsx | 2 +- .../core/components/viewer/LocalEmbedPDF.tsx | 6 +- .../core/components/viewer/SidebarBase.css | 2 +- .../components/viewer/nonpdf/JsonViewer.tsx | 2 +- .../components/viewer/nonpdf/TextViewer.tsx | 6 +- .../src/core/pages/MobileScannerPage.tsx | 2 +- frontend/editor/src/core/styles/index.css | 2 +- frontend/editor/src/core/styles/theme.css | 48 +- frontend/editor/src/core/theme/colors.css | 51 +- .../editor/src/core/theme/mantineTheme.ts | 129 + frontend/editor/src/core/theme/primitives.css | 30 +- frontend/editor/src/core/tokens/base.css | 2 +- frontend/editor/src/core/tokens/tokens.css | 43 +- .../core/tools/formFill/FormFill.module.css | 2 +- frontend/editor/src/core/ui/Banner.css | 6 +- frontend/editor/src/core/ui/Button.tsx | 8 + frontend/editor/src/core/ui/ChatFABButton.tsx | 7 +- .../src/core/ui/ChatFABWindow.stories.tsx | 4 +- frontend/editor/src/core/ui/ChatFABWindow.tsx | 4 + frontend/editor/src/core/ui/Chip.tsx | 20 +- frontend/editor/src/core/ui/CodeBlock.tsx | 10 +- frontend/editor/src/core/ui/Drawer.tsx | 6 +- frontend/editor/src/core/ui/Dropdown.css | 2 +- frontend/editor/src/core/ui/EmptyState.css | 2 +- frontend/editor/src/core/ui/FormField.css | 6 +- frontend/editor/src/core/ui/Forms.stories.tsx | 3 + frontend/editor/src/core/ui/ListRow.css | 10 +- frontend/editor/src/core/ui/MantineForms.css | 2 +- .../src/core/ui/MantineForms.stories.tsx | 3 + frontend/editor/src/core/ui/MethodBadge.css | 2 +- frontend/editor/src/core/ui/MetricCard.css | 4 +- frontend/editor/src/core/ui/NavItem.css | 12 +- frontend/editor/src/core/ui/PanelHeader.css | 8 +- .../src/core/ui/ProgressBar.stories.tsx | 9 +- frontend/editor/src/core/ui/ProgressBar.tsx | 5 +- frontend/editor/src/core/ui/Select.tsx | 4 + frontend/editor/src/core/ui/SettingsRow.tsx | 20 +- frontend/editor/src/core/ui/StatTile.css | 2 +- frontend/editor/src/core/ui/StatusBadge.css | 2 +- frontend/editor/src/core/ui/StepIndicator.tsx | 6 + frontend/editor/src/core/ui/Tabs.css | 4 +- frontend/editor/src/core/ui/ToggleSwitch.tsx | 4 + frontend/editor/src/core/ui/accents.css | 30 +- .../src/portal/components/AssistantPanel.tsx | 6 +- .../components/ChatFABWidget.stories.tsx | 6 +- .../portal/components/DownloadEditorModal.css | 4 +- .../components/NotificationsDropdown.css | 4 +- .../src/portal/components/ProcessorFlow.css | 4 +- .../src/portal/components/ProcessorFlow.tsx | 6 +- .../account-link/LinkedInstancesTable.tsx | 6 +- .../billing/PrepaidCapacityCard.tsx | 1 + .../components/billing/SpendLimitCard.tsx | 1 + .../portal/components/billing/WalletMeter.tsx | 1 + .../src/portal/components/billing/billing.css | 32 +- .../components/docs/GettingStartedSection.tsx | 6 +- .../components/docs/PlaybooksSection.tsx | 2 +- .../portal/components/docs/SdksSection.tsx | 2 +- .../portal/components/docs/SkillsSection.tsx | 2 +- .../infrastructure/DeploymentsTab.tsx | 9 +- .../components/infrastructure/ModelsTab.tsx | 7 +- .../procurement/ProcurementAgreement.tsx | 5 + .../components/users/ResetPasswordModal.tsx | 8 +- .../editor/src/portal/data/Ops.stories.tsx | 4 +- frontend/editor/src/portal/theme/base.css | 2 +- .../editor/src/portal/theme/mantineTheme.ts | 46 + .../editor/src/portal/views/DeveloperDocs.css | 18 +- .../editor/src/portal/views/DeveloperDocs.tsx | 11 +- .../editor/src/portal/views/EditorAdmin.css | 12 +- frontend/editor/src/portal/views/Home.css | 2 +- .../src/portal/views/Infrastructure.css | 6 +- .../editor/src/portal/views/Integrations.css | 4 +- .../editor/src/portal/views/Pipelines.css | 4 +- frontend/editor/src/portal/views/Policies.css | 17 +- .../editor/src/portal/views/Procurement.css | 14 +- frontend/editor/src/portal/views/Sources.css | 6 +- frontend/editor/src/portal/views/Users.css | 8 +- .../src/proprietary/auth/ui/OAuthButtons.tsx | 10 +- .../src/proprietary/billing/MeterBar.tsx | 4 + .../proprietary/components/chat/ChatPanel.css | 12 +- .../shared/ChangeUserPasswordModal.tsx | 435 +-- .../config/configSections/AccountSection.tsx | 2 +- .../AdminConnectionsSection.tsx | 6 +- .../configSections/AdminMailSection.tsx | 4 +- .../AdminStorageSharingSection.tsx | 8 +- .../configSections/LoginAgreementEditor.tsx | 2 +- .../configSections/TeamDetailsSection.tsx | 14 +- .../config/configSections/TeamsSection.tsx | 8 +- .../configSections/apiKeys/RefreshModal.tsx | 2 +- .../audit/AuditSystemStatus.tsx | 2 - .../plan/AvailablePlansSection.tsx | 1 + .../plan/FeatureComparisonTable.tsx | 2 +- .../config/configSections/plan/PlanCard.tsx | 2 +- .../dividerWithText/DividerWithText.css | 9 +- .../DeleteFolderConfirmModal.tsx | 2 +- .../watchedFolders/WatchedFolderHomePage.tsx | 4 +- .../WatchedFolderManagementModal.tsx | 2 +- .../WatchedFolderWorkbenchView.tsx | 10 +- .../watchedFolders/WatchedFolders.css | 6 +- .../components/workflow/ParticipantView.tsx | 4 +- .../editor/src/proprietary/routes/Login.tsx | 2 +- .../routes/login/LoggedInState.tsx | 2 +- .../proprietary/routes/signup/SignupForm.tsx | 4 + .../onboarding/OnboardingChecklist.module.css | 4 +- frontend/editor/src/saas/routes/Login.tsx | 4 +- frontend/editor/src/saas/routes/Signup.tsx | 2 +- 197 files changed, 2368 insertions(+), 6641 deletions(-) diff --git a/frontend/.gitignore b/frontend/.gitignore index c0c467073d..9ab1c65091 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -49,3 +49,4 @@ test-results /scripts/dev-update-test/screenshots/ /editor/src-tauri/tauri.conf.dev-update.json .a11y-scan/ +.a11y-acc/ diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json index fb461fe6d2..0967ef424b 100644 --- a/frontend/.storybook/a11y-baseline.dark.json +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -1,2482 +1 @@ -{ - "editor/src/core/assets/Brand.stories.tsx :: Logos": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ - "aria-required-children" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Disabled": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: No Appearance Set": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ - "button-name" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ - "aria-dialog-name", - "aria-progressbar-name" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: With Expression": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Empty Input": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ - "label-title-only" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ - "button-name" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ - "button-name" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileUploadButton.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileUploadButton.stories.tsx :: With File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ - "color-contrast" - ], - "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "svg-img-alt" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ - "color-contrast" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ObscuredOverlay.stories.tsx :: Unobscured": [ - "color-contrast" - ], - "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Compact Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Empty": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Enforcing": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/toast/ToastRenderer.stories.tsx :: With Action Button": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: With Selected Image": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Embedded Hide Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Custom Placeholder": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Jks": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Pkcs 12": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ - "button-name" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ - "color-contrast" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ - "label" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: With Words": [ - "color-contrast" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ - "button-name" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ - "label" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Dropdown Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Unstyled": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ - "color-contrast" - ], - "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], - "editor/src/core/tokens/Tokens.stories.tsx :: Typography": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Justify": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], - "editor/src/core/ui/Card.stories.tsx :: Accent Matrix": ["color-contrast"], - "editor/src/core/ui/Card.stories.tsx :: In Context Metrics Inside Card": [ - "color-contrast" - ], - "editor/src/core/ui/Card.stories.tsx :: In Context Product Grid": [ - "color-contrast" - ], - "editor/src/core/ui/Card.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ - "button-name" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], - "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ - "color-contrast" - ], - "editor/src/core/ui/Chip.stories.tsx :: Playground": [ - "color-contrast", - "nested-interactive" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/DataRow.stories.tsx :: Single": ["color-contrast"], - "editor/src/core/ui/DataRow.stories.tsx :: Summary": ["color-contrast"], - "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Dropdown.stories.tsx :: Align Start": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: With Divider": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: With Trailing Hints": [ - "color-contrast" - ], - "editor/src/core/ui/EmptyState.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/EmptyState.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Single": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Free Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ - "color-contrast" - ], - "editor/src/core/ui/NavItem.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: With Trailing Badge": [ - "color-contrast" - ], - "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/SectionDivider.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/ui/SectionHeader.stories.tsx :: Collapsible": [ - "color-contrast" - ], - "editor/src/core/ui/SectionHeader.stories.tsx :: Static": ["color-contrast"], - "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ - "color-contrast" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ - "color-contrast" - ], - "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": [ - "color-contrast", - "label" - ], - "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Stack.stories.tsx :: Gap Sizes": ["color-contrast"], - "editor/src/core/ui/Stack.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Empty": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], - "editor/src/core/ui/ToggleSwitch.stories.tsx :: In Context Settings Rows": [ - "color-contrast" - ], - "editor/src/core/ui/ToggleSwitch.stories.tsx :: With Description": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ - "color-contrast" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ - "aria-hidden-focus", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/DownloadEditorModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/LinkAccountFooterItem.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Reauth": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ - "color-contrast" - ], - "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Bare": [ - "color-contrast" - ], - "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: Managed In Stripe": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: With Card": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Unsynced Only": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Breakdown": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Unsynced": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Offer Nudge": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Three": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Two": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: No Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ - "color-contrast" - ], - "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Unlocked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentOverview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Available": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Multiple Selected": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Nothing Selected": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Incompatible Preceding Output": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: No Matches": [ - "color-contrast" - ], - "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Pay": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Request Paid": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Sign": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Upload PO": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Schedule Call": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage Maxed": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment Pending": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Delegated Create": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Load Error": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Preset Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Ready": [ - "color-contrast" - ], - "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Choose Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ - "color-contrast" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ - "color-contrast" - ], - "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ - "color-contrast" - ], - "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ - "color-contrast" - ], - "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Sources And Destinations": [ - "color-contrast" - ], - "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ - "color-contrast" - ], - "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ - "color-contrast", - "landmark-unique" - ], - "editor/src/portal/views/Documents.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Integrations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ - "color-contrast" - ], - "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: First Time Setup": [ - "color-contrast" - ], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ - "aria-hidden-focus" - ], - "editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Subcategory": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Custom Return Url": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ - "image-redundant-alt" - ] -} +{} diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 0245e3101a..0967ef424b 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1,2640 +1 @@ -{ - "editor/src/core/assets/Brand.stories.tsx :: Logos": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Multiple Files": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Compact": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ - "aria-required-children" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Shared With Me": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ - "aria-dialog-name", - "aria-progressbar-name" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Small": [ - "color-contrast" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ - "color-contrast", - "label-title-only" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Custom Fallback": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Custom Placeholder": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "svg-img-alt" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/OverviewHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Large Size": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Minimal Links": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Frontend": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Encrypted": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Multiple Files Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: With Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: With Tool Name": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/ToolRenderer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Text Stamp With Preview": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Invisible": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ - "button-name" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ - "color-contrast" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ - "aria-input-field-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Azw 3 Output": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Custom Area": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx :: Automation Info": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Custom Render Dpi": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Flatten Only Forms": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: No Data": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: All Passed": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Hidden Title": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Custom Empty Message": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx :: Invalid Input": [ - "color-contrast" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: All Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Unknown Type": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Last File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NoToolsFound.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ - "label" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: With Help Text And Number": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Admin With Shared Delete": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: At Capacity": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Poster": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Sections": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: No Method Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Empty Value": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Missing Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Unsupported File": [ - "color-contrast" - ], - "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Csv": [ - "color-contrast" - ], - "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Hidden": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Tsv": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx :: Invalid Json": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ - "color-contrast" - ], - "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ - "button-name" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], - "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ - "color-contrast" - ], - "editor/src/core/ui/Chip.stories.tsx :: Playground": [ - "color-contrast", - "nested-interactive" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Space Between": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ - "color-contrast" - ], - "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ - "color-contrast" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ - "color-contrast" - ], - "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": ["label"], - "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Live": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], - "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ - "color-contrast" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ - "aria-hidden-focus", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ - "color-contrast" - ], - "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Without Lead": [ - "color-contrast" - ], - "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ - "color-contrast" - ], - "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ - "empty-table-header" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ - "empty-table-header" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ - "color-contrast" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ - "color-contrast" - ], - "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ - "color-contrast" - ], - "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ - "color-contrast" - ], - "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], - "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ - "color-contrast" - ], - "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ - "color-contrast", - "landmark-unique" - ], - "editor/src/portal/views/Documents.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Integrations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ - "color-contrast" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ - "aria-hidden-focus" - ], - "editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx :: With Errors": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ - "label" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Sso User": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Load Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: With File Metadata Columns": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Enterprise Plan": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Free Plan": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise With Total": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Simple": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Current": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Popular": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Redirecting": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Completed": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Expired": [ - "color-contrast" - ], - "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ - "image-redundant-alt" - ] -} +{} diff --git a/frontend/.storybook/a11y-changed.mjs b/frontend/.storybook/a11y-changed.mjs index 5ff7a9e633..f6daef5dbb 100644 --- a/frontend/.storybook/a11y-changed.mjs +++ b/frontend/.storybook/a11y-changed.mjs @@ -10,7 +10,8 @@ // embedded interpreter runs on Windows, but sed/grep/sort do not exist for // developers calling tasks from PowerShell. import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { readdirSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; const base = process.argv[2] || "origin/main"; @@ -40,9 +41,25 @@ for (const f of changed) { continue; } if (TEST.test(f) || !SOURCE.test(f)) continue; - const sibling = f.replace(SOURCE, ""); - for (const s of [`${sibling}.stories.tsx`, `${sibling}.stories.ts`]) - if (existsSync(s)) stories.add(s); + // A story file does not have to match its source's case — tokens.css sits + // beside Tokens.stories.tsx. Deriving the name from the source and trusting + // existsSync silently skips those on a case-sensitive filesystem, and on a + // case-insensitive one feeds the scan a path no result will ever match. Read + // the directory instead and compare case-insensitively, then use the name as + // it is actually spelled on disk. + const dir = dirname(f) || "."; + const stem = basename(f).replace(SOURCE, "").toLowerCase(); + let entries; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!STORY.test(entry)) continue; + if (entry.replace(STORY, "").toLowerCase() !== stem) continue; + stories.add(join(dir, entry).split("\\").join("/")); + } } // One line, each path quoted: the output is interpolated into a task command, diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index e9d7e7f3d9..f568b34347 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -19,6 +19,11 @@ import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; +import { MantineProvider } from "@mantine/core"; +import { + mantineTheme as editorMantineTheme, + editorCssVariablesResolver, +} from "@core/theme/mantineTheme"; import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; import i18next from "i18next"; @@ -199,6 +204,37 @@ const withLocale: Decorator = (Story, context) => { return ; }; +/** + * Applies the Mantine theme the story's component actually runs under in the + * app: PortalApp wraps the Processor in SuiProvider, while the editor wraps + * everything else in its own ThemeProvider. Getting this wrong is not just + * cosmetic — the two themes carry different neutral ramps, so rendering an + * editor component under the Processor's theme drops it onto Mantine's stock + * greys and reports contrast failures the app doesn't have. + */ +function StoryTheme({ + isPortalStory, + colorScheme, + children, +}: { + isPortalStory: boolean; + colorScheme: "light" | "dark"; + children: React.ReactNode; +}) { + if (isPortalStory) { + return {children}; + } + return ( + + {children} + + ); +} + const withProviders: Decorator = (Story, context) => { const tier = (context.globals.tier as Tier) ?? "pro"; const linkState = @@ -214,16 +250,21 @@ const withProviders: Decorator = (Story, context) => { // the portal's base.css keys its reset/typography on. Give portal stories // the same wrapper (and only them — the scoping exists precisely so portal // styles never apply to editor components). - const isPortalStory = (context.parameters.fileName ?? "").includes( - "/portal/", - ); + // `fileName` is only injected by the dev/build pipeline — under the Vitest + // runner it is absent, so path alone would silently drop every portal story + // onto the editor theme (where portal-only palette entries like `amber` + // resolve to nothing and render unstyled). The title prefix is the fallback + // that survives both environments. + const isPortalStory = + (context.parameters.fileName ?? "").includes("/portal/") || + context.title.startsWith("Portal/"); return ( - + {/* LinkProvider must wrap TierProvider: TierContext derives its tier from useLink() (matches App.tsx's nesting). */} @@ -241,7 +282,7 @@ const withProviders: Decorator = (Story, context) => { - + @@ -274,6 +315,14 @@ const preview: Preview = { // any violation. Context is left at the addon default (the document root) // so it resolves under both the Storybook UI and the Vitest browser mount. test: "error", + context: { + // Nodes carrying this attribute render a facsimile of the user's own + // document — their stamp text, their watermark, in the colour and + // opacity they chose. WCAG contrast governs the interface, not the + // content authored through it, and the controls that set those values + // are checked normally. + exclude: ["[data-user-content-preview]"], + }, }, }, globalTypes: { diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index ae494e9582..a91d52cca0 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3001,12 +3001,15 @@ tooltip = "Runs in the cloud (included, no extra charge)" tooltip = "Pick colour from screen" [colorPicker] +hue = "Hue" +saturation = "Saturation and brightness" title = "Choose colour" [common] back = "Back" cancel = "Cancel" close = "Close" +codeSample = "Code sample" collapse = "Collapse" confirm = "Confirm" continue = "Continue" @@ -3023,6 +3026,7 @@ refresh = "Refresh" remaining = "Remaining" retry = "Retry" save = "Save" +stepOf = "Step {{current}} of {{total}}" [compare] clearSelected = "Clear selected" @@ -3185,6 +3189,7 @@ title = "Compression Method" [compress.settings] desiredSize = "Desired File Size" desiredSizePlaceholder = "Enter size" +desiredSizeUnit = "Size unit" [compress.tooltip.description] text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." @@ -3824,11 +3829,13 @@ shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" +sortBy = "Sort files" sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" storage = "Storage" storageState = "Storage" +storageUsed = "Storage used" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" @@ -4168,6 +4175,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first." open = "Open" openFile = "Open File" openFiles = "Open Files" +selectFile = "Select {{name}}" selectFromStorage = "Select from Storage" upload = "Upload" uploadFile = "Upload File" @@ -5127,6 +5135,7 @@ activeFiles = "The Active Files view shows all of the PDFs you allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." close = "Close" cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to." +dialogLabel = "Onboarding" fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." @@ -5563,6 +5572,7 @@ 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" metaCategories = "Automation · AI · API requests" @@ -5642,6 +5652,7 @@ automation = "automations" default = "this feature" [payg.spendCapMeter] +barAria = "Spend against cap" capSuffix = "/ {{amount}} cap" metaCategories = "Automation · AI · API spend" resets = "Resets each billing period" @@ -6045,6 +6056,7 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] +currency = "Billing currency" subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -6280,6 +6292,7 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] +actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -6624,6 +6637,7 @@ 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" eyebrow = "Processor trial" @@ -7184,6 +7198,7 @@ sensitiveTitle = "Sensitive — access required" [portal.documents.table.columns] action = "Pipeline / Action" +actions = "Actions" document = "Document" product = "Product" status = "Status" @@ -7481,6 +7496,7 @@ rolledBack = "Rolled back" rolling = "Rolling out" [portal.infrastructure.deployments] +loadAria = "Load for {{name}}" msValue = "{{value}} ms" throughputValue = "{{value}}/min" @@ -7526,6 +7542,7 @@ disabled = "Disabled" [portal.infrastructure.models] heading = "Models" +loadAria = "Load for {{name}}" msValue = "{{value}} ms" subheading = "The model catalogue and routing that powers document processing across your workspace." @@ -7892,6 +7909,7 @@ paused = "Paused" [portal.pipelines.table] name = "Pipeline" +open = "Open" sources = "Sources" status = "Status" steps = "Steps" @@ -8783,6 +8801,7 @@ unused = "Unused" [portal.sources.table] documents = "Documents" +open = "Open" source = "Source" status = "Status" usedBy = "Policies" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 8ce96c2552..69211b6360 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3005,12 +3005,15 @@ tooltip = "This operation will use your cloud credits" tooltip = "Pick color from screen" [colorPicker] +hue = "Hue" +saturation = "Saturation and brightness" title = "Choose color" [common] back = "Back" cancel = "Cancel" close = "Close" +codeSample = "Code sample" collapse = "Collapse" confirm = "Confirm" continue = "Continue" @@ -3028,6 +3031,7 @@ refresh = "Refresh" remaining = "Remaining" retry = "Retry" save = "Save" +stepOf = "Step {{current}} of {{total}}" [compare] clearSelected = "Clear selected" @@ -3190,6 +3194,7 @@ title = "Compression Method" [compress.settings] desiredSize = "Desired File Size" desiredSizePlaceholder = "Enter size" +desiredSizeUnit = "Size unit" [compress.tooltip.description] text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." @@ -3833,11 +3838,13 @@ shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" +sortBy = "Sort files" sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" storage = "Storage" storageState = "Storage" +storageUsed = "Storage used" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" @@ -4177,6 +4184,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first." open = "Open" openFile = "Open File" openFiles = "Open Files" +selectFile = "Select {{name}}" selectFromStorage = "Select from Storage" upload = "Upload" uploadFile = "Upload File" @@ -5169,6 +5177,7 @@ activeFiles = "The Active Files view shows all of the PDFs you allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." close = "Close" cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to." +dialogLabel = "Onboarding" fileCheckbox = "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." @@ -5605,6 +5614,7 @@ 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" metaCategories = "Automation · AI · API requests" @@ -5684,6 +5694,7 @@ automation = "automations" default = "this feature" [payg.spendCapMeter] +barAria = "Spend against cap" capSuffix = "/ {{amount}} cap" metaCategories = "Automation · AI · API spend" resets = "Resets each billing period" @@ -6087,6 +6098,7 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] +currency = "Billing currency" subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -6322,6 +6334,7 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] +actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -6666,6 +6679,7 @@ 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" eyebrow = "Processor trial" @@ -7559,6 +7573,7 @@ rolledBack = "Rolled back" rolling = "Rolling out" [portal.infrastructure.deployments] +loadAria = "Load for {{name}}" msValue = "{{value}} ms" throughputValue = "{{value}}/min" @@ -7604,6 +7619,7 @@ disabled = "Disabled" [portal.infrastructure.models] heading = "Models" +loadAria = "Load for {{name}}" msValue = "{{value}} ms" subheading = "The model catalogue and routing that powers document processing across your workspace." diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css index 581f3af671..9cbda9b6ca 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css @@ -95,7 +95,7 @@ margin-bottom: 8px; } .payg-planhead__lbl--free { - color: var(--c-success); + color: var(--color-green-dark); } .payg-planhead__lbl--meter { color: var(--payg-accent); @@ -242,7 +242,7 @@ background: color-mix(in srgb, var(--c-success) 14%, transparent); } [data-mantine-color-scheme="dark"] .payg-hero__credit { - color: var(--c-success); + color: var(--color-green-dark); background: color-mix(in srgb, var(--c-success) 18%, transparent); } @@ -459,11 +459,11 @@ } .payg-gate[data-enabled="true"] .payg-gate__chip { background: color-mix(in srgb, var(--c-success) 16%, transparent); - color: var(--c-success); + color: var(--color-green-dark); } .payg-gate[data-enabled="false"] .payg-gate__chip { background: color-mix(in srgb, var(--c-danger) 16%, transparent); - color: var(--c-danger); + color: var(--color-red-dark); } .payg-gate__label { font-size: 0.8125rem; @@ -486,11 +486,11 @@ background: var(--c-surface-sunken); } .payg-gate__tag[data-variant="pause"] { - color: var(--c-danger); + color: var(--color-red-dark); background: color-mix(in srgb, var(--c-danger) 12%, transparent); } [data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] { - color: var(--c-danger); + color: var(--color-red-dark); background: color-mix(in srgb, var(--c-danger) 18%, transparent); } diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css index 9e89fa38eb..af391f0aeb 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css @@ -298,7 +298,7 @@ font-size: 1rem !important; } .paygf-explainer__icon--free { - color: var(--c-success); + color: var(--color-green-dark); } .paygf-explainer__icon--paid { color: var(--payg-accent); diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css index d0ed711434..b6b72f504a 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css @@ -10,7 +10,7 @@ .scc { --scc-accent: var(--c-primary); - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent); --scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent); --scc-chip-bg: var(--c-surface-sunken); @@ -24,7 +24,7 @@ [data-mantine-color-scheme="dark"] .scc { /* Chip surface/border track the neutral --c-* tokens (base rule); only the brand-azure accent is tuned brighter for dark. */ - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent); } diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index abfb0110ca..8811e537b2 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -60,6 +60,7 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { = ({ {storageStats.quota && ( 80 diff --git a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx index ec854426e6..bfc53d5252 100644 --- a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx @@ -51,6 +51,13 @@ export const ColorPicker: React.FC = ({ format="hex" value={selectedColor} onChange={onColorChange} + // The saturation area and hue bar are role="slider" divs; these are + // their only accessible names. + saturationLabel={t( + "colorPicker.saturation", + "Saturation and brightness", + )} + hueLabel={t("colorPicker.hue", "Hue")} swatches={[ "#000000", "#0066cc", @@ -73,6 +80,7 @@ export const ColorPicker: React.FC = ({ max={100} value={opacity} onChange={onOpacityChange} + thumbLabel={resolvedOpacityLabel} marks={[ { value: 25, label: "25%" }, { value: 50, label: "50%" }, diff --git a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx index 94d2e35e47..5bce53410e 100644 --- a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx @@ -161,7 +161,7 @@ const AddFileCard = ({ icon={icons.uploadIconName} width="1.25rem" height="1.25rem" - style={{ color: "var(--c-primary)", flexShrink: 0 }} + style={{ color: "var(--c-accent-text)", flexShrink: 0 }} /> {isUploadHover && ( = ({ {currentFile && ` • v${currentFile.versionNumber || 1}`} {hasMultipleFiles && ( - + {currentFileIndex + 1} of {selectedFiles.length} )} diff --git a/frontend/editor/src/core/components/fileManager/DragOverlay.tsx b/frontend/editor/src/core/components/fileManager/DragOverlay.tsx index 023bb59d14..04af539346 100644 --- a/frontend/editor/src/core/components/fileManager/DragOverlay.tsx +++ b/frontend/editor/src/core/components/fileManager/DragOverlay.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Stack, Text, useMantineTheme, alpha } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { useTranslation } from "react-i18next"; @@ -9,7 +9,6 @@ interface DragOverlayProps { const DragOverlay: React.FC = ({ isVisible }) => { const { t } = useTranslation(); - const theme = useMantineTheme(); if (!isVisible) return null; @@ -21,8 +20,9 @@ const DragOverlay: React.FC = ({ isVisible }) => { left: 0, right: 0, bottom: 0, - backgroundColor: alpha(theme.colors.blue[6], 0.1), - border: `0.125rem dashed ${theme.colors.blue[6]}`, + // The prompt below is the drop affordance on its own. Tinting the whole + // region and ringing it in dashed accent reads as a second, competing + // surface, so the overlay stays transparent. borderRadius: "1.875rem", display: "flex", alignItems: "center", @@ -32,10 +32,12 @@ const DragOverlay: React.FC = ({ isVisible }) => { }} > + {/* Muted ink rather than the accent shade: it has to read on whatever + the overlay happens to sit on, in either scheme. */} - + {t("fileManager.dropFilesHere", "Drop files here to upload")} diff --git a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx index 59b42ef954..890906abda 100644 --- a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx +++ b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx @@ -103,7 +103,7 @@ const EmptyFilesState: React.FC = () => { icon={icons.uploadIconName} width="1.25rem" height="1.25rem" - style={{ color: "var(--c-primary)" }} + style={{ color: "var(--c-accent-text)" }} /> {isUploadHover && ( diff --git a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx index f43df3d86c..4f94155d4e 100644 --- a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx @@ -86,19 +86,29 @@ const FileInfoCard: React.FC = ({ }} > - + {t("fileManager.details", "File Details")} - + {/* The viewport is focusable and named so keyboard users can scroll the + detail list once it overflows. */} + diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index ce9be12ad2..7bed49bd0b 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -906,35 +906,47 @@ function ListView({ return (
    + {/* Each direct child is a columnheader: a role="row" may only own cells, so + the sort controls and the select-all box have to sit inside one. */}
    {onSetSelection && visibleFileIds.length > 0 ? ( - { - onSetSelection(allSelected ? new Set() : new Set(visibleFileIds)); - }} - aria-label={ - allSelected - ? t("filesPage.deselectAll", "Clear selection") - : t("filesPage.selectAll", "Select all") - } - /> + + { + onSetSelection( + allSelected ? new Set() : new Set(visibleFileIds), + ); + }} + aria-label={ + allSelected + ? t("filesPage.deselectAll", "Clear selection") + : t("filesPage.selectAll", "Select all") + } + /> + ) : (
    @@ -1091,7 +1103,12 @@ function FolderRow({ className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`} >
    ); } @@ -1254,35 +1275,40 @@ function FileRow({ isInWorkspace ? " is-in-workspace" : "" }`} > - {/* Checkbox only shows in multi-select mode (see FileCard). When the - checkbox is hidden the first grid column collapses, but the row's - CSS grid keeps the columns aligned via the named template, so no - empty cell shows. */} + {/* Each direct child is a gridcell: a role="row" may only own cells, so the + checkbox and the actions menu have to sit inside one. + + The checkbox only shows in multi-select mode (see FileCard). When it is + hidden the first grid column collapses, but the row's CSS grid keeps the + columns aligned via the named template, so no empty cell shows. */} {multiSelectActive ? ( - { - // Toggle this file in/out of the selection without modifier keys. - e.stopPropagation(); - onClick({ - ...e, - shiftKey: false, - ctrlKey: true, - metaKey: true, - } as unknown as React.MouseEvent); - }} - onChange={() => { - /* handled by onClick */ - }} - aria-label={t("filesPage.selectFile", "Select file {{name}}", { - name: file.name, - })} - /> + + { + // Toggle this file in/out of the selection without modifier keys. + e.stopPropagation(); + onClick({ + ...e, + shiftKey: false, + ctrlKey: true, + metaKey: true, + } as unknown as React.MouseEvent); + }} + onChange={() => { + /* handled by onClick */ + }} + aria-label={t("filesPage.selectFile", "Select file {{name}}", { + name: file.name, + })} + /> + ) : ( // Empty cell preserves grid column alignment. ); } diff --git a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx index a4e9b6e3f9..3d94610e0b 100644 --- a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx @@ -32,12 +32,12 @@ const styles = { }, cloud: { background: "color-mix(in srgb, var(--c-primary) 16%, transparent)", - color: "var(--c-primary)", + color: "var(--c-accent-text)", }, shared: { background: "color-mix(in srgb, var(--mantine-color-orange-6) 16%, transparent)", - color: "var(--mantine-color-orange-6)", + color: "var(--color-amber-dark)", }, }; diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 4815a1b31e..368bae2521 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -339,6 +339,9 @@ } .files-page-list-row.is-header [data-sortable="true"] { + /* Block so the hit area and hover tint fill the columnheader cell that wraps + it, rather than hugging the label text. */ + display: block; cursor: pointer; padding: 0.2rem 0.4rem; margin: -0.2rem -0.4rem; @@ -741,7 +744,7 @@ height: 5rem; border-radius: 50%; background: color-mix(in srgb, var(--c-primary) 12%, transparent); - color: var(--c-primary); + color: var(--c-accent-text); margin-bottom: 0.25rem; } @@ -980,7 +983,7 @@ .files-page-details-version-timeline-count { margin-left: auto; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-transform: none; letter-spacing: 0; } @@ -1112,7 +1115,29 @@ .files-page-details-version-timeline-expand-btn:hover span { color: var(--c-text); } - +.files-page-details-version-timeline-delta { + font-size: 0.82rem; + color: var(--c-text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-flex; + align-items: baseline; + gap: 0.25rem; +} +.files-page-details-version-timeline-delta.is-origin { + font-weight: 400; + color: var(--c-text-subtle); + font-style: italic; +} +.files-page-details-version-timeline-delta-plus { + color: var(--c-accent-text); + font-weight: 700; +} +.files-page-details-version-timeline-spacer { + flex: 1; +} .files-page-details-version-timeline-chevron { color: var(--c-text-subtle); transition: transform 0.15s ease; @@ -1120,7 +1145,7 @@ .files-page-details-version-timeline-chevron.is-expanded { transform: rotate(180deg); - color: var(--c-primary); + color: var(--c-accent-text); } .files-page-details-version-timeline-expanded { @@ -1185,7 +1210,7 @@ justify-content: center; gap: 1rem; pointer-events: none; - color: var(--c-primary); + color: var(--c-accent-text); font-weight: 600; font-size: 1.1rem; z-index: 10; diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx index 39554a21ee..928a75eb3c 100644 --- a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx @@ -151,7 +151,10 @@ export function FolderThumbnail({ borderRadius: "999px", background: "var(--c-surface, #fff)", border: `1px solid ${accent}`, - color: accent, + // The ring carries the folder's accent; the numeral does not. + // Folder colours are user-chosen and many are too light to read + // as text on the white pill. + color: "var(--c-text)", fontSize: "0.7rem", fontWeight: 700, display: "inline-flex", diff --git a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index f9439dcdd2..58108b55c1 100644 --- a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -325,7 +325,7 @@ .v2Badge { background: var(--c-primary-tint); - color: var(--c-accent-fg); + color: var(--c-accent-text); padding: 3px 9px; border-radius: 6px; font-size: 12px; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx index 49d94662eb..3d077301eb 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx @@ -101,7 +101,10 @@ export default function OnboardingSlideShell({ ); return ( - , because only Modal.Content lands + // props on the role="dialog" element — the slide draws its own title, so the + // dialog needs an aria-label to have an accessible name. + -
    -
    -
    - - Stirling -
    -
    - {showProgress && ( - - {t("onboarding.stepOf", "Step {{current}} of {{total}}", { - current: stepIndex + 1, - total: stepCount, - })} - - )} - {allowDismiss && ( - - + + +
    +
    +
    + - - )} -
    -
    + Stirling +
    +
    + {showProgress && ( + + {t("onboarding.stepOf", "Step {{current}} of {{total}}", { + current: stepIndex + 1, + total: stepCount, + })} + + )} + {allowDismiss && ( + + + + )} +
    +
    - {showProgress && ( -
    - {Array.from({ length: stepCount }, (_, index) => ( - - ))} -
    - )} - -
    - -
    -
    -
    - {hero} -
    -
    - -
    - {title} -
    - -
    - {body} - -
    - -
    - {backButtons.length === 0 ? ( -
    {actions}
    - ) : ( -
    -
    - {backButtons.map((button) => ( - onAction(button.action)} - variant="tertiary" - accent="neutral" - disabled={button.disabled} - aria-label={t("onboarding.buttons.back", "Back")} - > - - - ))} -
    - {actions} + {showProgress && ( +
    + {Array.from({ length: stepCount }, (_, index) => ( + + ))}
    )} + +
    + +
    +
    +
    + {hero} +
    +
    + +
    + {title} +
    + +
    + {body} + +
    + +
    + {backButtons.length === 0 ? ( +
    {actions}
    + ) : ( +
    +
    + {backButtons.map((button) => ( + onAction(button.action)} + variant="tertiary" + accent="neutral" + disabled={button.disabled} + aria-label={t("onboarding.buttons.back", "Back")} + > + + + ))} +
    + {actions} +
    + )} +
    +
    -
    -
    - + + + ); } diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx index 23566a5527..aa04911832 100644 --- a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -50,7 +50,7 @@ export default function AnalyticsChoiceSlide({
    {analyticsError && ( -
    +
    {analyticsError}
    )} diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx index d684592a4a..0b1f5bb8f2 100644 --- a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -125,7 +125,7 @@ function FirstLoginForm({ icon="info-rounded" width={20} height={20} - style={{ color: "var(--c-primary)", flexShrink: 0 }} + style={{ color: "var(--c-accent-text)", flexShrink: 0 }} /> {t( diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 6101605772..7f3e9f12a5 100644 --- a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -26,7 +26,7 @@ export default function SecurityCheckSlide({ icon="error" width={20} height={20} - style={{ color: "var(--c-danger)", flexShrink: 0 }} + style={{ color: "var(--color-red-dark)", flexShrink: 0 }} /> {i18n.t( diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css index 1ecd05808b..59729a79e0 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css @@ -222,7 +222,8 @@ /* Error helper text above the input */ .errorText { margin-top: 0.25rem; - color: var(--text-brand-accent); + /* The brand red is a fill; error copy takes the theme's error ink. */ + color: var(--color-red-dark); } /* Compact error container for inline tool settings */ @@ -237,7 +238,7 @@ /* Two-line clamp for compact error text */ .errorTextClamp { - color: var(--text-brand-accent); + color: var(--color-red-dark); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; diff --git a/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx index 0816ac9a14..4def62470d 100644 --- a/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx +++ b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx @@ -135,7 +135,11 @@ const DropdownListWithFooter: React.FC = ({ zIndex={zIndex} > + {/* A real button: Popover.Target stamps aria-haspopup/aria-expanded on + its child, and those are only permitted on an actual control. */} = ({ padding: "8px 12px", backgroundColor: "light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))", + color: "inherit", + textAlign: "left", + width: "100%", opacity: disabled ? 0.6 : 1, cursor: disabled ? "not-allowed" : "pointer", minHeight: "36px", diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.tsx index a515a46cae..cce50f90a0 100644 --- a/frontend/editor/src/core/components/shared/EditableSecretField.tsx +++ b/frontend/editor/src/core/components/shared/EditableSecretField.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useId, useState, useRef, useEffect } from "react"; import { PasswordInput, Group, Tooltip, TextInput } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ActionIcon } from "@app/ui/ActionIcon"; @@ -33,6 +33,7 @@ export default function EditableSecretField({ }: EditableSecretFieldProps) { const { t } = useTranslation(); const resolvedPlaceholder = placeholder ?? t("common.enterValue"); + const fieldId = useId(); const [isEditing, setIsEditing] = useState(false); const [tempValue, setTempValue] = useState(""); const inputRef = useRef(null); @@ -67,6 +68,7 @@ export default function EditableSecretField({
    {label && (
    , document.body, diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 38b20ee1d8..8b84af29f1 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -54,7 +54,7 @@ .sui-dd__item.is-active { background: var(--c-primary-subtle); - color: var(--c-accent-fg); + color: var(--c-accent-text); font-weight: 500; } diff --git a/frontend/editor/src/core/ui/EmptyState.css b/frontend/editor/src/core/ui/EmptyState.css index bf8250bc82..bd3d7cf03e 100644 --- a/frontend/editor/src/core/ui/EmptyState.css +++ b/frontend/editor/src/core/ui/EmptyState.css @@ -22,7 +22,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--c-primary); + color: var(--c-accent-text); } .sui-empty__title { diff --git a/frontend/editor/src/core/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css index f5cbe2d5fc..792845c255 100644 --- a/frontend/editor/src/core/ui/FormField.css +++ b/frontend/editor/src/core/ui/FormField.css @@ -14,7 +14,9 @@ } .sui-field__required { - color: var(--color-red); + /* The base red is a fill colour; as text on the form background it only + reaches 3.4:1. */ + color: var(--color-red-dark); } .sui-field__control { @@ -29,5 +31,5 @@ } .sui-field--error .sui-field__help { - color: var(--color-red); + color: var(--color-red-dark); } diff --git a/frontend/editor/src/core/ui/Forms.stories.tsx b/frontend/editor/src/core/ui/Forms.stories.tsx index 0d14cf3392..dff72b6da7 100644 --- a/frontend/editor/src/core/ui/Forms.stories.tsx +++ b/frontend/editor/src/core/ui/Forms.stories.tsx @@ -182,6 +182,7 @@ export const Slider_Confidence: Story = { step={0.01} onChange={setV} formatValue={(x) => x.toFixed(2)} + aria-label="Minimum confidence" /> ); @@ -203,6 +204,7 @@ export const Slider_Retention: Story = { step={1} onChange={setDays} formatValue={(d) => `${d} days`} + aria-label="Retain artifacts for" /> ); @@ -262,6 +264,7 @@ export const FullForm: Story = { step={0.01} onChange={setConf} formatValue={(v) => v.toFixed(2)} + aria-label="Confidence gate" /> diff --git a/frontend/editor/src/core/ui/ListRow.css b/frontend/editor/src/core/ui/ListRow.css index 836dba4d8f..019b679f5c 100644 --- a/frontend/editor/src/core/ui/ListRow.css +++ b/frontend/editor/src/core/ui/ListRow.css @@ -36,23 +36,23 @@ background: var(--c-surface-sunken); } .sui-listrow__leading[data-tone="success"] { - color: var(--color-green); + color: var(--color-green-dark); background: color-mix(in srgb, var(--color-green) 14%, transparent); } .sui-listrow__leading[data-tone="warning"] { - color: var(--color-amber); + color: var(--color-amber-dark); background: color-mix(in srgb, var(--color-amber) 14%, transparent); } .sui-listrow__leading[data-tone="danger"] { - color: var(--color-red); + color: var(--color-red-dark); background: color-mix(in srgb, var(--color-red) 14%, transparent); } .sui-listrow__leading[data-tone="info"] { - color: var(--c-primary); + color: var(--c-accent-text); background: color-mix(in srgb, var(--c-primary) 14%, transparent); } .sui-listrow__leading[data-tone="purple"] { - color: var(--color-purple); + color: var(--color-purple-dark); background: color-mix(in srgb, var(--color-purple) 14%, transparent); } diff --git a/frontend/editor/src/core/ui/MantineForms.css b/frontend/editor/src/core/ui/MantineForms.css index ebedf547f7..29cd64413d 100644 --- a/frontend/editor/src/core/ui/MantineForms.css +++ b/frontend/editor/src/core/ui/MantineForms.css @@ -55,7 +55,7 @@ /* Pills match SUI's Chip component: small rounded tags. */ .sui-mantine-pill { background: var(--c-primary-tint) !important; - color: var(--c-primary-hover) !important; + color: var(--c-accent-text) !important; border: 1px solid var(--c-primary-border) !important; border-radius: var(--radius-sm) !important; font-size: 0.75rem !important; diff --git a/frontend/editor/src/core/ui/MantineForms.stories.tsx b/frontend/editor/src/core/ui/MantineForms.stories.tsx index 08e2b2e179..e8860c1f30 100644 --- a/frontend/editor/src/core/ui/MantineForms.stories.tsx +++ b/frontend/editor/src/core/ui/MantineForms.stories.tsx @@ -446,6 +446,7 @@ export const Slider_Default: Story = { max={1} step={0.01} formatValue={(x) => x.toFixed(2)} + aria-label="Confidence threshold" /> ); @@ -467,6 +468,7 @@ export const Slider_WithMarks: Story = { max={365} step={1} formatValue={(d) => `${d}d`} + aria-label="Retain artifacts for" marks={[ { value: 30, label: "30d" }, { value: 90, label: "90d" }, @@ -494,6 +496,7 @@ export const Slider_NoLabel: Story = { max={100} step={1} showValue={false} + aria-label="Opacity" /> ); diff --git a/frontend/editor/src/core/ui/MethodBadge.css b/frontend/editor/src/core/ui/MethodBadge.css index 9b138ed389..1cc89c02a4 100644 --- a/frontend/editor/src/core/ui/MethodBadge.css +++ b/frontend/editor/src/core/ui/MethodBadge.css @@ -15,7 +15,7 @@ border-color: var(--color-green-border); } .sui-method--post { - color: var(--c-primary); + color: var(--c-accent-text); background: var(--c-primary-tint); border-color: var(--c-primary-border); } diff --git a/frontend/editor/src/core/ui/MetricCard.css b/frontend/editor/src/core/ui/MetricCard.css index 8c7adfeb00..520342c65a 100644 --- a/frontend/editor/src/core/ui/MetricCard.css +++ b/frontend/editor/src/core/ui/MetricCard.css @@ -83,10 +83,10 @@ font-size: 0.6875rem; } .sui-metric__delta--up { - color: var(--color-green); + color: var(--color-green-dark); } .sui-metric__delta--down { - color: var(--color-red); + color: var(--color-red-dark); } .sui-metric__delta--flat, .sui-metric__desc { diff --git a/frontend/editor/src/core/ui/NavItem.css b/frontend/editor/src/core/ui/NavItem.css index 8b9949eab5..9fd39f3eff 100644 --- a/frontend/editor/src/core/ui/NavItem.css +++ b/frontend/editor/src/core/ui/NavItem.css @@ -20,7 +20,7 @@ } .sui-navitem.is-active { background: var(--c-primary-subtle); - color: var(--c-accent-fg); + color: var(--c-accent-text); font-weight: 500; } .sui-navitem.is-active:hover { @@ -76,17 +76,17 @@ background: var(--color-red); } .sui-navitem[data-accent="blue"] .sui-navitem__icon { - color: var(--c-primary); + color: var(--c-accent-text); } .sui-navitem[data-accent="purple"] .sui-navitem__icon { - color: var(--color-purple); + color: var(--color-purple-dark); } .sui-navitem[data-accent="green"] .sui-navitem__icon { - color: var(--color-green); + color: var(--color-green-dark); } .sui-navitem[data-accent="amber"] .sui-navitem__icon { - color: var(--color-amber); + color: var(--color-amber-dark); } .sui-navitem[data-accent="red"] .sui-navitem__icon { - color: var(--color-red); + color: var(--color-red-dark); } diff --git a/frontend/editor/src/core/ui/PanelHeader.css b/frontend/editor/src/core/ui/PanelHeader.css index 02f32c3707..f8dafd2303 100644 --- a/frontend/editor/src/core/ui/PanelHeader.css +++ b/frontend/editor/src/core/ui/PanelHeader.css @@ -46,7 +46,7 @@ button.sui-panelhdr__bar:hover { height: 1.75rem; border-radius: 9999px; background: var(--mantine-color-blue-light); - color: var(--mantine-color-blue-filled); + color: var(--c-accent-text); flex-shrink: 0; } @@ -154,15 +154,15 @@ button.sui-panelhdr__bar:hover { var(--mantine-color-blue-filled) 18%, transparent ); - color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled)); + color: var(--c-accent-text); } /* Dark mode: the subtle gray close button is too dim against the dark rail — brighten it to a clearly-visible light grey (near-white on hover). */ [data-mantine-color-scheme="dark"] .sui-panelhdr__close { - color: var(--mantine-color-gray-4); + color: var(--c-text-subtle); } [data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover { - color: var(--mantine-color-gray-2); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/ProgressBar.stories.tsx b/frontend/editor/src/core/ui/ProgressBar.stories.tsx index 7c8b386dda..8c88bc039a 100644 --- a/frontend/editor/src/core/ui/ProgressBar.stories.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.stories.tsx @@ -6,7 +6,12 @@ const meta: Meta = { component: ProgressBar, tags: ["autodocs"], parameters: { layout: "padded" }, - args: { value: 0.5, height: 6, thresholded: false }, + args: { + value: 0.5, + height: 6, + thresholded: false, + label: "Docs processed", + }, argTypes: { value: { control: { type: "range", min: 0, max: 1, step: 0.01 } }, height: { control: { type: "number" } }, @@ -42,7 +47,7 @@ export const ThresholdLadder: Story = { {Math.round(v * 100)}% - +
    ))}
    diff --git a/frontend/editor/src/core/ui/ProgressBar.tsx b/frontend/editor/src/core/ui/ProgressBar.tsx index e6ca30c0e1..cb11a2982a 100644 --- a/frontend/editor/src/core/ui/ProgressBar.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.tsx @@ -10,8 +10,9 @@ export interface ProgressBarProps { /** Optional override colour (CSS gradient or solid). Disables threshold behaviour. */ color?: string; className?: string; - /** Accessible label for screen readers. */ - label?: string; + /** Accessible name — describe what is being measured ("Storage used"), since + * the bar carries no visible text of its own. */ + label: string; } function clamp01(n: number) { diff --git a/frontend/editor/src/core/ui/Select.tsx b/frontend/editor/src/core/ui/Select.tsx index 242ddf9740..c4861a7752 100644 --- a/frontend/editor/src/core/ui/Select.tsx +++ b/frontend/editor/src/core/ui/Select.tsx @@ -46,6 +46,7 @@ export interface SelectProps { id?: string; name?: string; "aria-label"?: string; + "aria-labelledby"?: string; "aria-invalid"?: boolean; "aria-describedby"?: string; required?: boolean; @@ -74,6 +75,7 @@ type PassthroughProps = Omit< | "id" | "name" | "aria-label" + | "aria-labelledby" | "aria-describedby" | "required" | "disabled" @@ -106,6 +108,7 @@ export function Select({ id, name, "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, required, @@ -128,6 +131,7 @@ export function Select({ id, name, "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, required, disabled, diff --git a/frontend/editor/src/core/ui/SettingsRow.tsx b/frontend/editor/src/core/ui/SettingsRow.tsx index 65f48cddc8..8dbf9600ab 100644 --- a/frontend/editor/src/core/ui/SettingsRow.tsx +++ b/frontend/editor/src/core/ui/SettingsRow.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { cloneElement, isValidElement, useId, type ReactNode } from "react"; import "@app/ui/SettingsRow.css"; export interface SettingsRowProps { @@ -23,17 +23,31 @@ export function SettingsRow({ control, className, }: SettingsRowProps) { + const labelId = useId(); + // The row's label is plain text beside the control, not a
    {isLoading ? (
    diff --git a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx index daa066bd70..921de50853 100644 --- a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx @@ -93,7 +93,11 @@ export function LinkedInstancesTable({ }, { key: "actions", - header: "", + header: ( + + {t("portal.accountLink.instances.columns.actions", "Actions")} + + ), align: "right", render: (i) => i.revoked ? null : ( diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx index b70fbb5a26..c7ab0e8704 100644 --- a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx @@ -72,6 +72,7 @@ export function PrepaidCapacityCard({ 1
    -

    {t("portal.docs.quickstart.step1.title")}

    +

    {t("portal.docs.quickstart.step1.title")}

    {t("portal.docs.quickstart.step1.body")}

    2
    -

    {t("portal.docs.quickstart.step2.title")}

    +

    {t("portal.docs.quickstart.step2.title")}

    {t("portal.docs.quickstart.step2.body")}

    3
    -

    {t("portal.docs.quickstart.step3.title")}

    +

    {t("portal.docs.quickstart.step3.title")}

    {t("portal.docs.quickstart.step3.body")}

    {playbooks.map((p) => ( -

    {p.title}

    +

    {p.title}

    {p.blurb}

    {p.steps.map((step, i) => ( diff --git a/frontend/editor/src/portal/components/docs/SdksSection.tsx b/frontend/editor/src/portal/components/docs/SdksSection.tsx index 1a0e72933e..331117af68 100644 --- a/frontend/editor/src/portal/components/docs/SdksSection.tsx +++ b/frontend/editor/src/portal/components/docs/SdksSection.tsx @@ -32,7 +32,7 @@ export function SdksSection({ sdks }: { sdks: Sdk[] }) { {sdk.icon} -

    {sdk.name}

    +

    {sdk.name}

    {badge && ( {t(badge.labelKey)} diff --git a/frontend/editor/src/portal/components/docs/SkillsSection.tsx b/frontend/editor/src/portal/components/docs/SkillsSection.tsx index ef34705c43..e373fc25c3 100644 --- a/frontend/editor/src/portal/components/docs/SkillsSection.tsx +++ b/frontend/editor/src/portal/components/docs/SkillsSection.tsx @@ -19,7 +19,7 @@ export function SkillsSection({ skills }: { skills: AgentSkill[] }) { -

    {s.name}

    +

    {s.name}

    {s.blurb}

    {s.ops} diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx index 8e96e4d997..41a9b5c561 100644 --- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx @@ -65,7 +65,14 @@ export function DeploymentsTab() { width: "9rem", render: (r) => (
    - + {pct(r.load)}
    ), diff --git a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx index 6d097b33af..505f41ac24 100644 --- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx @@ -77,7 +77,12 @@ export function ModelsTab() { width: "9rem", render: (m) => (
    - + {pct(m.load)}
    ), diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 318f23aca5..7aee96c822 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -144,10 +144,15 @@ export function ProcurementAgreement({ {/* The tray scrolls, not the paper. The paper is its natural height inside it, so mid-document it runs flush to the footer with no grey beneath, and the tray's bottom padding only comes into view once the buyer reaches the end — the page ending is what shows they got there. */} + {/* Focusable and named: signing is gated on scrolling to the end, so the + tray has to be scrollable by keyboard as well as pointer. */}
    {loading &&

    {t("portal.procurement.agreement.loading")}

    } diff --git a/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx b/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx index d1cfe9e739..48b32d4398 100644 --- a/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx +++ b/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx @@ -140,7 +140,13 @@ export function ResetPasswordModal({ } >
    - + {/* FormField's label wires up to the row wrapper, not this + input, so name the input directly. */} + - +
    @@ -131,9 +134,9 @@ export function DeveloperDocs() {
    {hasToc && ( - +
    )}
    ); diff --git a/frontend/editor/src/portal/views/EditorAdmin.css b/frontend/editor/src/portal/views/EditorAdmin.css index 1ccd0d63b1..34aa81d69f 100644 --- a/frontend/editor/src/portal/views/EditorAdmin.css +++ b/frontend/editor/src/portal/views/EditorAdmin.css @@ -96,11 +96,11 @@ } .portal-editor__target-icon--blue { background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-editor__target-icon--purple { background: var(--color-purple-light); - color: var(--color-purple); + color: var(--color-purple-dark); } .portal-editor__target-titles { @@ -192,15 +192,15 @@ .portal-editor__pairing-icon--blue { background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-editor__pairing-icon--purple { background: var(--color-purple-light); - color: var(--color-purple); + color: var(--color-purple-dark); } .portal-editor__pairing-icon--green { background: var(--color-green-light); - color: var(--color-green); + color: var(--color-green-dark); } .portal-editor__pairing-titles { @@ -331,7 +331,7 @@ padding: 0.125rem 0.4375rem; border-radius: var(--radius-sm); background: var(--color-purple-light); - color: var(--color-purple); + color: var(--color-purple-dark); } .portal-editor__token-row { diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index 732bd617a5..236966c2fc 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -129,5 +129,5 @@ } .portal-home__quick-row:hover .portal-home__quick-arrow { - color: var(--c-primary); + color: var(--c-accent-text); } diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css index cd532daeeb..660bd4a69f 100644 --- a/frontend/editor/src/portal/views/Infrastructure.css +++ b/frontend/editor/src/portal/views/Infrastructure.css @@ -113,7 +113,7 @@ .portal-infra__export-error { margin: 0; font-size: 0.8125rem; - color: var(--c-danger); + color: var(--color-red-dark); } .portal-infra__export-actions { @@ -373,7 +373,7 @@ .portal-infra__attestation-link { font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-decoration: none; } @@ -429,7 +429,7 @@ height: 1.75rem; border-radius: var(--radius-md); background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); font-size: 0.875rem; } diff --git a/frontend/editor/src/portal/views/Integrations.css b/frontend/editor/src/portal/views/Integrations.css index 9d90d70b13..d006fcbb84 100644 --- a/frontend/editor/src/portal/views/Integrations.css +++ b/frontend/editor/src/portal/views/Integrations.css @@ -66,7 +66,7 @@ .portal-integrations__filter.is-active { background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-integrations__filter-count { @@ -243,7 +243,7 @@ button.portal-integrations__row[aria-expanded="true"] { gap: 0.375rem; font-size: 0.8125rem; font-weight: 500; - color: var(--c-success); + color: var(--color-green-dark); } .portal-integrations__status-dot { diff --git a/frontend/editor/src/portal/views/Pipelines.css b/frontend/editor/src/portal/views/Pipelines.css index 6fac70672e..f4bc729b4d 100644 --- a/frontend/editor/src/portal/views/Pipelines.css +++ b/frontend/editor/src/portal/views/Pipelines.css @@ -62,7 +62,7 @@ border-radius: var(--radius-md); font-size: 0.875rem; background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-pipelines__muted { @@ -79,7 +79,7 @@ .portal-pipelines__caret.is-open { transform: rotate(90deg); - color: var(--c-primary); + color: var(--c-accent-text); } /* Table skeleton */ diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index e1a821ee27..fedcbbf835 100644 --- a/frontend/editor/src/portal/views/Policies.css +++ b/frontend/editor/src/portal/views/Policies.css @@ -56,7 +56,7 @@ .portal-policies__setup-link { font-size: 0.8125rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-decoration: underline; text-underline-offset: 2px; } @@ -70,7 +70,10 @@ } .portal-policies__card--locked { - opacity: 0.7; + /* Recede via surface and ink rather than opacity, which would fade the + card's own text below the contrast floor. */ + background: var(--c-surface-sunken); + color: var(--c-text-muted); } .portal-policies__card-identity { @@ -165,7 +168,7 @@ .portal-policies__card-cta { font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); } .portal-policies__chevron-right { @@ -297,7 +300,7 @@ padding: 0; font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); cursor: pointer; } @@ -378,13 +381,13 @@ } .portal-policies__activity-icon--success { - color: var(--color-green); + color: var(--color-green-dark); } .portal-policies__activity-icon--warning { - color: var(--color-amber); + color: var(--color-amber-dark); } .portal-policies__activity-icon--info { - color: var(--c-primary); + color: var(--c-accent-text); } @keyframes portal-policies-spin { diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index b721768952..556fff726a 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -89,7 +89,7 @@ color: var(--c-text); } .portal-qb__req { - color: var(--c-danger); + color: var(--color-red-dark); } .portal-qb__field[data-invalid] input, .portal-qb__field[data-invalid] select { @@ -98,7 +98,7 @@ .portal-qb__error { margin: 10px 0 0; font-size: 12px; - color: var(--c-danger); + color: var(--color-red-dark); } .portal-qb__row { display: flex; @@ -138,7 +138,7 @@ .portal-qb__discount { margin: 6px 0 0; font-size: 11.5px; - color: var(--c-success); + color: var(--color-green-dark); } .portal-qb__opts { display: flex; @@ -179,7 +179,7 @@ color: var(--c-text); } .portal-qb__opt[data-on] .portal-qb__opt-title { - color: var(--c-primary); + color: var(--c-accent-text); } .portal-qb__opt-sub { font-size: 11.5px; @@ -342,7 +342,7 @@ color: var(--c-text); } .portal-qb__lines li[data-kind="DISCOUNT"] { - color: var(--c-success); + color: var(--color-green-dark); } .portal-qb__lines li[data-kind="INCLUDED"] span:last-child { color: var(--c-text-subtle); @@ -516,7 +516,7 @@ align-items: center; justify-content: center; /* Holds a CheckIcon, which takes its stroke from `color` and its size from its own prop. */ - color: var(--c-success); + color: var(--color-green-dark); background: var(--c-success-subtle); } .portal-hero__live-text { @@ -897,7 +897,7 @@ margin-top: 0.75rem; } .portal-proc__error { - color: var(--c-danger); + color: var(--color-red-dark); font-size: 0.8125rem; margin: 0.5rem 0 0; } diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index 132c137c89..bb39eae86c 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -86,7 +86,7 @@ .portal-sources__caret.is-open { transform: rotate(90deg); - color: var(--c-primary); + color: var(--c-accent-text); } /* Expanded detail panel */ @@ -226,7 +226,7 @@ display: block; width: 100%; margin-top: 0.5rem; - color: var(--c-primary); + color: var(--c-accent-text); } .portal-sources__chips { @@ -414,7 +414,7 @@ } .portal-sources__type-card.is-selected .portal-sources__type-icon { - color: var(--c-primary); + color: var(--c-accent-text); } .portal-sources__type-icon .portal-sources__type-svg { diff --git a/frontend/editor/src/portal/views/Users.css b/frontend/editor/src/portal/views/Users.css index 75591cc8d8..ae11d3b88d 100644 --- a/frontend/editor/src/portal/views/Users.css +++ b/frontend/editor/src/portal/views/Users.css @@ -265,7 +265,7 @@ padding: 0.5rem 0.75rem; border-radius: 8px; background: color-mix(in srgb, var(--c-danger) 12%, transparent); - color: var(--c-danger); + color: var(--color-red-dark); font-size: 0.85rem; } @@ -279,7 +279,7 @@ } .portal-users__link { - color: var(--c-primary); + color: var(--c-accent-text); text-decoration: none; } .portal-users__link:hover { @@ -337,7 +337,7 @@ background: none; border: none; cursor: pointer; - color: var(--c-primary); + color: var(--c-accent-text); font-size: 0.8rem; font-weight: 500; white-space: nowrap; @@ -492,7 +492,7 @@ background: none; border: none; border-top: 1px solid var(--c-border-subtle); - color: var(--c-primary); + color: var(--c-accent-text); font-size: 0.8rem; font-weight: 500; cursor: pointer; diff --git a/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx b/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx index 3644ed91bc..6ffb202cd3 100644 --- a/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx +++ b/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx @@ -24,6 +24,8 @@ export const oauthProviderConfig: Record< }; // Icon URLs + GENERIC_PROVIDER_ICON come from the shared oauthIcons resolver. +// Every provider icon is decorative (alt=""): the button it sits in already +// names the provider, so alt text would only repeat that name. interface OAuthButtonsProps { onProviderClick: (provider: OAuthProvider) => void; @@ -116,7 +118,7 @@ export default function OAuthButtons({ > {p.label} @@ -142,7 +144,7 @@ export default function OAuthButtons({ > {p.label} @@ -169,7 +171,7 @@ export default function OAuthButtons({ {p.label} @@ -210,7 +212,7 @@ export default function OAuthButtons({ {p.label} diff --git a/frontend/editor/src/proprietary/billing/MeterBar.tsx b/frontend/editor/src/proprietary/billing/MeterBar.tsx index ee03d62885..5049b91294 100644 --- a/frontend/editor/src/proprietary/billing/MeterBar.tsx +++ b/frontend/editor/src/proprietary/billing/MeterBar.tsx @@ -23,6 +23,8 @@ interface MeterBarProps { meta?: ReactNode; /** Hide the fill bar (e.g. uncapped). Shown by default. */ showBar?: boolean; + /** Accessible name for the fill bar — what the meter measures ("Spend limit"). */ + barLabel: string; } /** @@ -40,6 +42,7 @@ export function MeterBar({ statusLabel, meta, showBar = true, + barLabel, }: MeterBarProps) { return (
    @@ -61,6 +64,7 @@ export function MeterBar({ aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100} + aria-label={barLabel} >
    , because only Modal.Content lands + // props on the role="dialog" element — the modal draws its own heading, so + // the dialog needs an aria-label to have an accessible name. + -
    - - - - - - - - {t("workspace.people.changePassword.title", "Change password")} - - - {t( - "workspace.people.changePassword.subtitle", - "Update the password for", - )}{" "} - {user?.username} - - + + + +
    + + + + + + + + {t( + "workspace.people.changePassword.title", + "Change password", + )} + + + {t( + "workspace.people.changePassword.subtitle", + "Update the password for", + )}{" "} + {user?.username} + + - - - setForm({ - ...form, - newPassword: event.currentTarget.value, - generateRandom: false, - }) - } - disabled={processing || disabled || form.generateRandom} - data-autofocus - /> - - setForm({ - ...form, - confirmPassword: event.currentTarget.value, - generateRandom: false, - }) - } - disabled={processing || disabled || form.generateRandom} - error={ - !form.generateRandom && - form.confirmPassword && - form.newPassword !== form.confirmPassword - ? t( - "workspace.people.changePassword.passwordMismatch", - "Passwords do not match", - ) - : undefined - } - /> - - { - const checked = event.currentTarget.checked; - setForm((prev) => ({ ...prev, generateRandom: checked })); - if (event.currentTarget.checked) { - handleGeneratePassword(); + + + setForm({ + ...form, + newPassword: event.currentTarget.value, + generateRandom: false, + }) } - }} - /> - {passwordPreview && ( - + disabled={processing || disabled || form.generateRandom} + data-autofocus + /> + + setForm({ + ...form, + confirmPassword: event.currentTarget.value, + generateRandom: false, + }) + } + disabled={processing || disabled || form.generateRandom} + error={ + !form.generateRandom && + form.confirmPassword && + form.newPassword !== form.confirmPassword + ? t( + "workspace.people.changePassword.passwordMismatch", + "Passwords do not match", + ) + : undefined + } + /> + + { + const checked = event.currentTarget.checked; + setForm((prev) => ({ ...prev, generateRandom: checked })); + if (event.currentTarget.checked) { + handleGeneratePassword(); + } + }} + /> + {passwordPreview && ( + + + {t( + "workspace.people.changePassword.generatedPreview", + "Generated password:", + )}{" "} + {passwordPreview} + + + + + + + + )} + + + + + + setForm({ ...form, sendEmail: event.currentTarget.checked }) + } + disabled={!canEmail || processing} + /> + + setForm({ + ...form, + includePassword: event.currentTarget.checked, + }) + } + disabled={!canEmail || !form.sendEmail || processing} + /> + + setForm({ + ...form, + forcePasswordChange: event.currentTarget.checked, + }) + } + disabled={processing || disabled} + /> + {!canEmail && ( + + {mailEnabled + ? t( + "workspace.people.changePassword.emailUnavailable", + "This user's email is not a valid email address. Notifications are disabled.", + ) + : t( + "workspace.people.changePassword.smtpDisabled", + "Email notifications require SMTP to be enabled in settings.", + )} + + )} + {canEmail && !form.includePassword && form.sendEmail && ( {t( - "workspace.people.changePassword.generatedPreview", - "Generated password:", - )}{" "} - {passwordPreview} + "workspace.people.changePassword.notifyOnly", + "An email will be sent without the password, letting the user know an admin changed it.", + )} - - - - - - - )} - - - - - - setForm({ ...form, sendEmail: event.currentTarget.checked }) - } - disabled={!canEmail || processing} - /> - - setForm({ - ...form, - includePassword: event.currentTarget.checked, - }) - } - disabled={!canEmail || !form.sendEmail || processing} - /> - - setForm({ - ...form, - forcePasswordChange: event.currentTarget.checked, - }) - } - disabled={processing || disabled} - /> - {!canEmail && ( - - {mailEnabled - ? t( - "workspace.people.changePassword.emailUnavailable", - "This user's email is not a valid email address. Notifications are disabled.", - ) - : t( - "workspace.people.changePassword.smtpDisabled", - "Email notifications require SMTP to be enabled in settings.", - )} - - )} - {canEmail && !form.includePassword && form.sendEmail && ( - - {t( - "workspace.people.changePassword.notifyOnly", - "An email will be sent without the password, letting the user know an admin changed it.", )} - - )} - + - - -
    - + +
    +
    + + +
    ); } diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx index 7212169e6e..4f132eb399 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx @@ -604,7 +604,7 @@ const AccountSection: React.FC = () => { {t("account.mfa.manualKey", "Manual setup key")}:{" "} {mfaSetupData.secret} - + {t( "account.mfa.secretWarning", "Keep this key private. Anyone with access can generate valid authentication codes.", diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index 2ad95525a1..f788551551 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -658,7 +658,7 @@ export default function AdminConnectionsSection() { href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner" target="_blank" size="xs" - c="blue" + c="var(--c-accent-text)" > {t( "admin.settings.connections.documentation", @@ -687,7 +687,7 @@ export default function AdminConnectionsSection() { "Allow users to upload files from mobile devices by scanning a QR code", )} - + {t( "admin.settings.connections.mobileScanner.note", "Note: Requires Frontend URL to be configured. ", @@ -698,7 +698,7 @@ export default function AdminConnectionsSection() { e.preventDefault(); navigate("/settings/adminGeneral#frontendUrl"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx index c8df9eaea3..c62125be3f 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx @@ -168,7 +168,7 @@ export default function AdminMailSection() { "Allow admins to invite users via email with auto-generated passwords", )} - + {t( "admin.settings.mail.frontendUrlNote.note", "Note: Requires Frontend URL to be configured. ", @@ -179,7 +179,7 @@ export default function AdminMailSection() { e.preventDefault(); navigate("/settings/adminGeneral#frontendUrl"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx index 6ab2a0fef8..dfaecac1fb 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx @@ -254,7 +254,7 @@ export default function AdminStorageSharingSection() { )} {!frontendUrlConfigured && ( - + {t( "admin.settings.storage.sharing.links.frontendUrlNote", "Requires a Frontend URL. ", @@ -265,7 +265,7 @@ export default function AdminStorageSharingSection() { e.preventDefault(); navigate("/settings/adminGeneral#frontendUrl"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( @@ -317,7 +317,7 @@ export default function AdminStorageSharingSection() { )} {!mailEnabled && ( - + {t( "admin.settings.storage.sharing.email.mailNote", "Requires mail configuration. ", @@ -328,7 +328,7 @@ export default function AdminStorageSharingSection() { e.preventDefault(); navigate("/settings/adminConnections"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx index 9af89dedad..23b879f974 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx @@ -269,7 +269,7 @@ export default function LoginAgreementEditor({ {loading && } {loadFailed && !loading && ( - + {t( "admin.settings.legal.loginAgreement.loadError", "Failed to load the agreement for {{locale}}. Switch language and back to retry.", diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx index d3702e7f80..76ee65abe0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx @@ -350,7 +350,7 @@ export default function TeamDetailsSection({ if (!team) { return ( - + {t("workspace.teams.teamNotFound", "Team not found")}
    {currency && onCurrencyChange && currencyOptions && ( 0} serverReachable={folders.serverReachable} selectedFileIds={selectedFileIds} activeWorkspaceFileIds={activeWorkspaceFileIdSet} @@ -1656,39 +1692,6 @@ export default function FileManagerView() { ); } -const SearchField = React.forwardRef< - HTMLInputElement, - { value: string; onChange: (v: string) => void } ->(function SearchField({ value, onChange }, ref) { - const { t } = useTranslation(); - return ( -
    - - onChange(e.currentTarget.value)} - placeholder={t( - "filesPage.searchPlaceholder", - "Search this folder & subfolders", - )} - aria-label={t("filesPage.search", "Search")} - /> - {value && ( - onChange("")} - aria-label={t("filesPage.clearSearch", "Clear search")} - > - × - - )} -
    - ); -}); - function Breadcrumbs() { const { t } = useTranslation(); const folders = useFolders(); diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 368bae2521..aa9d8248aa 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -34,6 +34,33 @@ gap: 0.5rem; } +.files-page-header-search { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; +} + +.files-page-header-search .super-search { + flex: 0 1 24rem; + width: min(100%, 24rem); + max-width: 24rem; +} + +.files-page-header-search .super-search input { + background-color: transparent; + padding-top: 4px; + padding-bottom: 4px; + font-size: 12.5px; +} + +[data-mantine-color-scheme="dark"] + .files-page-header-search + .super-search + input { + background-color: transparent; +} + .files-page-breadcrumbs { display: flex; align-items: center; @@ -79,28 +106,6 @@ flex-shrink: 0; } -.files-page-search { - display: flex; - align-items: center; - gap: 0.35rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: 999px; - padding: 0.2rem 0.75rem; - /* Fills its grid cell; the cell's minmax(...) clamps to a sensible range. */ - width: 100%; - min-width: 0; -} - -.files-page-search input { - background: transparent; - border: none; - outline: none; - flex: 1; - color: var(--c-text); - font-size: 0.9rem; -} - .files-page-body { display: flex; flex: 1 1 auto; @@ -1376,23 +1381,6 @@ overflow-x: auto; min-width: 0; } - .files-page-search { - /* Shrink hard so the search bar doesn't eat the whole action row. - Users still see the icon + a few chars of the placeholder. - `overflow: hidden` clips the input's natural intrinsic width so - placeholder text never leaks outside the rounded pill. */ - min-width: 0; - flex: 0 1 5.5rem; - max-width: 6.5rem; - overflow: hidden; - } - .files-page-search input { - /* `min-width: 0` lets flex actually shrink the input below its - default ~20-char intrinsic size - without this, the placeholder - extends beyond the parent's clip box and bleeds onto neighbours. */ - min-width: 0; - text-overflow: ellipsis; - } /* Upload becomes an icon-only square button on mobile so the action row stops getting clipped. Scoped to `.files-page-header-actions` so the Back button at the header level keeps its visible "Back" diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index 51138d2ac3..fb73be8655 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -1,19 +1,48 @@ -/* WorkbenchBar slide-in/out animation using CSS grid trick */ +/* Positioning context for the viewer toolbar's reopen tab, which hangs below + the bar and so must sit outside the overflow-clipped wrapper below. */ +.workbenchBarShell { + position: relative; + flex-shrink: 0; + z-index: 51; +} + +/* Little pull-tab shown while the viewer tool row is retracted. Anchored to the + bar's bottom-right edge (where the retract handle sat). Small by design - it + just brings the row back. */ +.workbenchBarReopenTab { + position: absolute; + top: 100%; + /* Right-align with the retract handle inside the bar: the bar's right + margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own + 6px inset. */ + right: calc(var(--nav-gutter) + 15px); + display: flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 0.875rem; + border: 1px solid var(--c-border-subtle); + border-top: none; + border-radius: 0 0 7px 7px; + background: var(--c-bg-raised); + color: var(--c-text-subtle); + cursor: pointer; + z-index: 52; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.workbenchBarReopenTab:hover { + color: var(--c-text); + background: var(--c-hover); +} + .workbenchBarWrapper { display: grid; grid-template-rows: 1fr; - transition: grid-template-rows 280ms ease; } -.workbenchBarWrapper[data-hidden="true"] { - grid-template-rows: 0fr; -} - -.workbenchBarWrapper[data-no-transition="true"] { - transition: none; -} - -/* Direct child must have min-height: 0 so the row can collapse below content size */ .workbenchBarInner { min-height: 0; overflow: hidden; diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 96eb6b8d6f..9f54e224e8 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -1,5 +1,8 @@ -import { useEffect, useState, Suspense, lazy } from "react"; +import { useState, Suspense, lazy } from "react"; +import { useTranslation } from "react-i18next"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { Box, Loader, Center } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; import { useAllFiles } from "@app/contexts/FileContext"; @@ -66,18 +69,13 @@ export default function Workbench() { const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null; const { addFiles } = useFileHandler(); const hasFiles = activeFiles.length > 0; - // Custom workbench views (e.g. Watched Folders) manage their own content and may - // have no workbench files, but still need the bar's view switcher so users can - // navigate back out. - const isCustomViewActive = !isBaseWorkbench(currentView); + const { t } = useTranslation(); - // Enable bar transitions after first paint so the initial hidden state shows - // without animating (landing page on load shouldn't animate the bar up). - const [barTransitionEnabled, setBarTransitionEnabled] = useState(false); - useEffect(() => { - const raf = requestAnimationFrame(() => setBarTransitionEnabled(true)); - return () => cancelAnimationFrame(raf); - }, []); + // The viewer's tool row can be retracted to give the document more height. + // State lives here (not in WorkbenchBar) so the reopen tab can hang below the + // bar, outside the bar's overflow-clipped wrapper. Scoped to the viewer. + const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false); + const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed; const handlePreviewClose = () => { setPreviewFile(null); @@ -219,22 +217,39 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* Workbench Bar - animates in/out based on file presence */} + {/* Workbench Bar — always visible outside My Files (it hosts the + global search), even with no files loaded. */} {currentView !== "myFiles" && !customWorkbenchViews.find((v) => v.workbenchId === currentView) ?.hideTopControls && ( -
    -
    - +
    +
    +
    + +
    + {/* Reopen tab: a little handle hanging off the bar's bottom-right + while the viewer tool row is retracted. */} + {showReopenTab && ( +
    )} diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.css b/frontend/editor/src/core/components/shared/AppConfigModal.css index 46033eaf97..2953632d98 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.css +++ b/frontend/editor/src/core/components/shared/AppConfigModal.css @@ -1,4 +1,28 @@ /* AppConfigModal styles */ + +/* Deep-link highlight: pulses the control the super search jumped to + (navigated via /settings/{section}?focus={anchor}). */ +@keyframes settings-focus-pulse { + 0% { + box-shadow: 0 0 0 3px var(--mantine-color-blue-5); + background: color-mix( + in srgb, + var(--mantine-color-blue-5) 16%, + transparent + ); + } + 100% { + box-shadow: 0 0 0 6px transparent; + background: transparent; + } +} + +.settings-focus-target { + animation: settings-focus-pulse 1.8s ease-out; + border-radius: 8px; + scroll-margin: 1rem; +} + .modal-container { display: flex; gap: 0; @@ -173,16 +197,6 @@ padding-top: 1rem; } -.settings-search-select { - min-width: 10rem; -} - -.settings-search-option { - display: flex; - flex-direction: column; - gap: 0.125rem; -} - .confirm-modal-content { display: flex; flex-direction: column; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index b5b3224985..12faf6eea5 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -29,7 +29,6 @@ import { UnsavedChangesProvider, useUnsavedChanges, } from "@app/contexts/UnsavedChangesContext"; -import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; import { stripBasePath, withBasePath } from "@app/constants/app"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; @@ -46,6 +45,8 @@ interface AppConfigModalProps { /** Section to land on when opening. Only honoured when urlSync is off (URL * deep links win otherwise). */ initialSection?: NavKey | null; + /** Row anchor to focus when opening on a non-URL host. */ + initialFocus?: string | null; /** Host-specific sections appended after the build's registry sections. */ extraSections?: ConfigNavSection[]; /** Registry section keys to drop, for hosts a section can't run in. */ @@ -67,6 +68,7 @@ const AppConfigModalInner: React.FC = ({ onClose, urlSync = true, initialSection, + initialFocus, extraSections, hiddenSectionKeys, }) => { @@ -150,6 +152,35 @@ const AppConfigModalInner: React.FC = ({ [navigate, urlSync], ); + // Deep-link: /settings/{section}?focus={anchor} scrolls to and briefly + // highlights the matching control (used by the global super search to jump + // straight to an individual setting row). + useEffect(() => { + if (!opened) return; + const focus = urlSync + ? new URLSearchParams(location.search).get("focus") + : initialFocus; + if (!focus) return; + let raf = 0; + // Wait for the (possibly just-switched) section to render before scrolling. + const timer = window.setTimeout(() => { + raf = window.requestAnimationFrame(() => { + const el = document.getElementById(focus); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("settings-focus-target"); + window.setTimeout( + () => el.classList.remove("settings-focus-target"), + 1800, + ); + }); + }, 150); + return () => { + window.clearTimeout(timer); + if (raf) window.cancelAnimationFrame(raf); + }; + }, [opened, active, initialFocus, location.search, urlSync]); + // Backwards-compat: external `appConfig:navigate` events route through the // same switchSection path so they get the no-flash treatment too. useEffect(() => { @@ -185,9 +216,10 @@ const AppConfigModalInner: React.FC = ({ const runningEE = config?.runningEE ?? false; const loginEnabled = config?.enableLogin ?? false; + /** Resolves false when a dirty-state confirm kept the modal open. */ const handleClose = useCallback(async () => { const canProceed = await confirmIfDirty(); - if (!canProceed) return; + if (!canProceed) return false; // Only unwind history if settings was opened via the URL; opened via state // there's no /settings entry to pop and navigate(-1) would jump to /files. @@ -200,6 +232,7 @@ const AppConfigModalInner: React.FC = ({ } } onClose(); + return true; }, [ confirmIfDirty, location.key, @@ -214,6 +247,24 @@ const AppConfigModalInner: React.FC = ({ void handleClose(); }, [handleClose]); + // Cmd/Ctrl+K: hand over to the global super search. The bar's own shortcut + // is inert while a dialog traps focus, so the modal closes itself (through + // the same dirty-check as any other close) and asks the bar to take focus. + // Settings results deep-link straight back into this modal. + useEffect(() => { + if (!opened) return; + const onKey = (e: KeyboardEvent) => { + const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey; + if (!combo || e.code !== "KeyK") return; + e.preventDefault(); + void handleClose().then((closed) => { + if (closed) window.dispatchEvent(new Event("superSearch:focus")); + }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [opened, handleClose]); + // Left navigation structure and icons const registrySections = useConfigNavSections( isAdmin, @@ -413,11 +464,6 @@ const AppConfigModalInner: React.FC = ({ {activeLabel} - diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 126ec6fede..590f59fa7d 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -95,7 +95,6 @@ } /* Centre each row's icon in the narrow rail (no side padding/margin to shove it off the edge). */ -.file-sidebar[data-collapsed="true"] .file-sidebar-search-row, .file-sidebar[data-collapsed="true"] .file-sidebar-action-row, .file-sidebar[data-collapsed="true"] .file-sidebar-cloud-row { justify-content: center; @@ -139,55 +138,6 @@ color: var(--c-accent-text); } -/* ---- Search row ---- */ -.file-sidebar-search-row { - display: flex; - align-items: center; - min-height: 32px; - padding: 0 8px; - gap: 0; - cursor: pointer; - border-radius: 4px; - margin: 0; - flex-shrink: 0; - transition: background-color 0.15s ease; -} - -.file-sidebar-search-row:not(.active):hover { - background-color: var(--c-hover); -} - -.file-sidebar-search-icon { - color: var(--c-text-subtle) !important; - font-size: 18px !important; - flex-shrink: 0; -} - -.file-sidebar-search-close { - cursor: pointer; -} - -.file-sidebar-search-input { - flex: 1; - background: transparent; - border: none; - outline: none; - font-size: 14px; - color: var(--c-text); - margin-left: 12px; - min-width: 0; -} - -.file-sidebar-search-input::placeholder { - color: var(--c-text-subtle); -} - -.file-sidebar-search-label { - margin-left: 12px; - font-size: 14px; - color: var(--c-text); -} - /* ---- Scrollable content ---- */ /* This is a flex column - action rows are fixed, only the file list scrolls */ .file-sidebar-scroll { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 742334015c..f74e96220f 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -33,11 +33,9 @@ import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { AppSwitcher } from "@app/components/shared/AppSwitcher"; import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import type { StirlingFileStub } from "@app/types/fileContext"; -import SearchIcon from "@mui/icons-material/Search"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; import UploadFileIcon from "@mui/icons-material/UploadFile"; -import CloseIcon from "@mui/icons-material/Close"; import AddIcon from "@mui/icons-material/Add"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import SettingsIcon from "@mui/icons-material/Settings"; @@ -102,8 +100,6 @@ export interface FileSidebarProps { onUploadFiles?: (files: File[]) => void | Promise; /** Override the Google Drive handler. */ onPickGoogleDriveFiles?: (files: File[]) => void | Promise; - /** Override the Search row click (e.g. focus the /files search input). */ - onSearchClick?: () => void; /** Extra action row inserted under Open-from-computer (e.g. New folder). */ extraAction?: { icon: React.ReactNode; @@ -155,7 +151,6 @@ const FileSidebar = forwardRef( onOpenSettings, onUploadFiles, onPickGoogleDriveFiles, - onSearchClick, extraAction, toggleAriaLabel, toggleIcon, @@ -168,9 +163,6 @@ const FileSidebar = forwardRef( // Classification off (non-SaaS / AI-off) → never show the per-row label chip, // even if a stub carries labels from an imported PDF; keeps the row plain. const classificationEnabled = useClassificationEnabled(); - const [searchActive, setSearchActive] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); const nativeFileInputRef = useRef(null); // State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates. const [pendingViewFileId, setPendingViewFileId] = useState( @@ -439,17 +431,8 @@ const FileSidebar = forwardRef( } }, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]); - // Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a - // stable array identity — avoids re-running the grouping memo + backfill effect. - const filteredFileStubs = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - return q - ? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q)) - : allFileStubs; - }, [allFileStubs, searchQuery]); - // SaaS groups by classification label; core returns null → one flat, recency-sorted list. - const fileGroups = useFileSidebarGroups(filteredFileStubs); + const fileGroups = useFileSidebarGroups(allFileStubs); // Workbench membership as a Set for O(1) per-row lookups (see renderFileRow). const workbenchIds = useMemo( () => new Set(state.files.ids.map((id) => id as string)), @@ -459,12 +442,12 @@ const FileSidebar = forwardRef( // must key by their unique leaf id rather than the shared lineage (see renderFileRow). const lineageCounts = useMemo(() => { const counts = new Map(); - for (const s of filteredFileStubs) { + for (const s of allFileStubs) { const k = (s.originalFileId ?? s.id) as string; counts.set(k, (counts.get(k) ?? 0) + 1); } return counts; - }, [filteredFileStubs]); + }, [allFileStubs]); // Per-group expand/collapse, falling back to each group's default until toggled. const [groupOpen, setGroupOpen] = useState>({}); const setGroupOpenState = useCallback( @@ -473,29 +456,6 @@ const FileSidebar = forwardRef( [], ); - // Handle search activation - const handleSearchClick = useCallback(() => { - if (onSearchClick) { - onSearchClick(); - return; - } - if (collapsed && onToggleCollapse) { - onToggleCollapse(); - } - setSearchActive(true); - }, [collapsed, onToggleCollapse, onSearchClick]); - - const handleSearchClose = useCallback(() => { - setSearchActive(false); - setSearchQuery(""); - }, []); - - useEffect(() => { - if (searchActive && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [searchActive]); - // Handle Google Drive const handleGoogleDriveClick = useCallback(async () => { if (!isGoogleDriveEnabled) return; @@ -854,58 +814,9 @@ const FileSidebar = forwardRef( )}
    - {/* Box 1 — top controls (search + open / my files / cloud). No title. */} + {/* Box 1 — top controls (open / my files / cloud). No title. File + search lives in the global super search (top bar), not here. */} - {/* Search row */} - -
    e.key === "Enter" && handleSearchClick() - : undefined - } - > - {searchActive && !collapsed ? ( - { - e.stopPropagation(); - handleSearchClose(); - }} - /> - ) : ( - - )} - {!collapsed && - (searchActive ? ( - setSearchQuery(e.target.value)} - placeholder={t( - "fileSidebar.searchPlaceholder", - "Search files...", - )} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {t("fileSidebar.search", "Search")} - - ))} -
    -
    - {/* Hidden native file input - kept outside the !collapsed gate so the "Open from computer" row below (always rendered) can fire it in either sidebar state without a silent no-op. */} @@ -1138,7 +1049,7 @@ const FileSidebar = forwardRef( {t("fileSidebar.library", "PDF Library")} - + (
    - ) : filteredFileStubs.length > 0 ? ( + ) : allFileStubs.length > 0 ? (
    {fileGroups ? ( <> @@ -1250,29 +1161,24 @@ const FileSidebar = forwardRef( "fileSidebar.viewAll", "View all {{count}} files", { - count: filteredFileStubs.length, + count: allFileStubs.length, }, )} ) : ( - filteredFileStubs.map(renderFileRow) + allFileStubs.map(renderFileRow) )}
    ) : ( - !searchActive && ( -
    -

    - {t("fileSidebar.noFiles", "No files yet")} -

    -

    - {t( - "fileSidebar.dropHint", - "Open files to get started", - )} -

    -
    - ) +
    +

    + {t("fileSidebar.noFiles", "No files yet")} +

    +

    + {t("fileSidebar.dropHint", "Open files to get started")} +

    +
    )}
    )} diff --git a/frontend/editor/src/core/components/shared/TextInput.tsx b/frontend/editor/src/core/components/shared/TextInput.tsx index 06147fb99b..7d927bb011 100644 --- a/frontend/editor/src/core/components/shared/TextInput.tsx +++ b/frontend/editor/src/core/components/shared/TextInput.tsx @@ -36,6 +36,13 @@ export interface TextInputProps { readOnly?: boolean; /** Accessibility label */ "aria-label"?: string; + /** ARIA role override (e.g. "combobox" for inputs driving a listbox). */ + role?: React.AriaRole; + /** Combobox wiring — forwarded to the native input. */ + "aria-expanded"?: boolean; + "aria-controls"?: string; + "aria-activedescendant"?: string; + "aria-autocomplete"?: React.AriaAttributes["aria-autocomplete"]; /** Focus event handler */ onFocus?: () => void; /** Allow the icon to receive pointer events (e.g. when icon is a clickable button) */ diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.css b/frontend/editor/src/core/components/shared/WorkbenchBar.css index b324fd8222..f805f69721 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.css +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.css @@ -2,11 +2,17 @@ /* Horizontal toolbar at the top of the workbench area. */ .workbench-bar { + /* Flex-wrap layout that reflows by content width (measured in JS, which sets + data-wrapped). Roomy: row 1 is [views | search | globals] and the tool + icons sit on their own full-width row below. Crowded (data-wrapped): the + search bumps to its own row, leaving [views | globals] on row 1 and the + tools on row 3 — three rows total. `order` drives the reflow. */ display: flex; flex-wrap: wrap; align-items: center; align-content: flex-start; - min-height: 40px; + column-gap: 8px; + min-height: 38px; padding: 0 8px; /* No left margin: the file sidebar's own 0.5rem padding already provides the gutter on that side, so adding one here would double it and leave the bar @@ -21,12 +27,16 @@ /* ---- View switcher (left) ---- */ .workbench-bar-views { + order: 1; + flex-shrink: 0; display: flex; align-items: center; gap: 2px; - flex-shrink: 0; - order: 1; - height: 40px; + /* min-width:0 lets it shrink instead of forcing a wrap; overflow clips labels + on very narrow viewports. */ + min-width: 0; + overflow: hidden; + height: 38px; z-index: 0; } @@ -34,7 +44,7 @@ display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; + padding: 2px 8px; border: none; border-radius: 8px; background: transparent; @@ -86,27 +96,47 @@ height: 16px; } -/* ---- Center: tool buttons ---- */ - -/* Single-row: center sits between views and globals */ -.workbench-bar-center { +/* ---- Super search ---- */ +/* Roomy: grows to fill the centre between views and globals. */ +.workbench-bar-search { order: 2; flex: 1 1 auto; min-width: 0; display: flex; align-items: center; justify-content: center; - gap: 2px; - height: 40px; - overflow: hidden; + height: 38px; + transform: translateX(var(--workbench-bar-search-offset, 0px)); } -/* Two-row: center drops below views+globals */ -.workbench-bar[data-wrapped="true"] .workbench-bar-center { +.workbench-bar-search .super-search { + flex: 0 1 24rem; + width: min(100%, 24rem); + max-width: 24rem; +} + +.workbench-bar-search .super-search input { + background-color: transparent; + padding-top: 4px; + padding-bottom: 4px; + font-size: 12.5px; +} + +[data-mantine-color-scheme="dark"] .workbench-bar-search .super-search input { + background-color: transparent; +} + +/* Crowded: drops to its own full-width row between the top row and the tools. */ +.workbench-bar[data-wrapped="true"] .workbench-bar-search { order: 3; flex: 0 0 100%; height: auto; padding: 4px 0; + transform: none; +} + +/* Two-row: the tool row scrolls sideways instead of wrapping. */ +.workbench-bar[data-wrapped="true"] .workbench-bar-center { border-top: 1px solid var(--c-border-subtle); justify-content: flex-start; overflow-x: auto; @@ -118,21 +148,68 @@ flex-shrink: 0; } -/* ---- Right: global buttons (theme / language / download) ---- */ +/* ---- Tool buttons (own full-width row, always below the top row) ---- */ +.workbench-bar-center { + order: 4; + flex: 0 0 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 2px; + /* Symmetric side padding leaves room for the retract handle pinned right + without knocking the centred tool icons off-centre. */ + padding: 4px 36px; + border-top: 1px solid var(--c-border-subtle); +} + +/* Retract / reopen handle for the viewer tool row. */ +.workbench-bar-toolbar-handle { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--c-text-subtle); + cursor: pointer; + padding: 0; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.workbench-bar-toolbar-handle:hover { + color: var(--c-text); + background: var(--c-hover); +} + +/* Pinned to the right edge of the tool row. */ +.workbench-bar-toolbar-handle-retract { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 28px; + height: 28px; +} + +/* ---- Right: global buttons (print / download / save / close), row 1 ---- */ .workbench-bar-globals { + order: 3; + flex-shrink: 0; display: flex; align-items: center; gap: 2px; - flex-shrink: 0; - order: 3; - height: 40px; + min-width: 0; + height: 38px; margin-left: auto; } -/* In two-row mode globals moves to row 1 right side */ +/* Crowded: globals stays on row 1, moving up beside the views. */ .workbench-bar[data-wrapped="true"] .workbench-bar-globals { order: 2; - margin-left: auto; } /* Shared action icon style - applies to both center and global buttons. diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 55ca01d307..241d658bad 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -1,6 +1,6 @@ import React, { useCallback, - useEffect, + useLayoutEffect, useMemo, useRef, useSyncExternalStore, @@ -33,6 +33,8 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext"; import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; +import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; +import { useEditorSearchScopes } from "@app/hooks/useSuperSearch"; import ViewerShareButton from "@app/components/viewer/ViewerShareButton"; import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; @@ -53,6 +55,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; import CloseIcon from "@mui/icons-material/Close"; import PrintIcon from "@mui/icons-material/Print"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import "@app/components/shared/WorkbenchBar.css"; @@ -68,6 +71,10 @@ interface WorkbenchBarProps { currentView: WorkbenchType; setCurrentView: (view: WorkbenchType) => void; hasFiles: boolean; + /** Whether the viewer's tool row is currently retracted. */ + viewerToolbarCollapsed?: boolean; + /** Setter for the viewer tool-row retract state (owned by Workbench). */ + onCollapseViewerToolbar?: (collapsed: boolean) => void; } function renderWithTooltip( @@ -92,9 +99,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles, + viewerToolbarCollapsed = false, + onCollapseViewerToolbar, }: WorkbenchBarProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const searchScopes = useEditorSearchScopes(); const returnRoute = useSyncExternalStore( subscribeFilesPageReturnRoute, getFilesPageReturnRoute, @@ -115,6 +125,7 @@ export default function WorkbenchBar({ } = useToolWorkflow(); const { selectedTool } = useNavigationState(); const isCustomView = !isBaseWorkbench(currentView); + const isViewer = currentView === "viewer"; const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; const terminology = useFileActionTerminology(); @@ -435,34 +446,47 @@ export default function WorkbenchBar({ })), ]; + // Reflow the top row by content width: when the views + globals leave too + // little room for a usable search, bump the search to its own row const barRef = useRef(null); - - useEffect(() => { + useLayoutEffect(() => { const bar = barRef.current; if (!bar) return; - + const MIN_SEARCH_WIDTH = 320; const measure = () => { const viewsEl = bar.querySelector(".workbench-bar-views"); const globalsEl = bar.querySelector( ".workbench-bar-globals", ); - const centerEl = bar.querySelector(".workbench-bar-center"); - const viewsWidth = viewsEl?.offsetWidth ?? 0; const globalsWidth = globalsEl?.offsetWidth ?? 0; - const centerChildren = centerEl - ? (Array.from(centerEl.children) as HTMLElement[]) - : []; - const centerWidth = - centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) + - Math.max(0, centerChildren.length - 1) * 2; // gap: 2px - - const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding - bar.dataset.wrapped = String(needed > bar.clientWidth); + // clientWidth minus the two side clusters, the bar's 16px h-padding and + // the two 8px column gaps flanking the search. + const available = bar.clientWidth - viewsWidth - globalsWidth - 16 - 16; + const wrapped = available < MIN_SEARCH_WIDTH; + bar.dataset.wrapped = String(wrapped); + // Centre the search on the bar rather than its slot — clamped to the + // slot's spare width, because the shift is a transform (no layout) and + // an unclamped value would paint the pill over the adjacent cluster. + const slotEl = bar.querySelector(".workbench-bar-search"); + const pillEl = slotEl?.querySelector(".super-search"); + const slack = Math.max( + 0, + ((slotEl?.offsetWidth ?? 0) - (pillEl?.offsetWidth ?? 0)) / 2, + ); + const centred = (globalsWidth - viewsWidth) / 2; + const offset = Math.min(slack, Math.max(-slack, centred)); + bar.style.setProperty( + "--workbench-bar-search-offset", + wrapped ? "0px" : `${offset}px`, + ); }; - const ro = new ResizeObserver(measure); ro.observe(bar); + const viewsEl = bar.querySelector(".workbench-bar-views"); + const globalsEl = bar.querySelector(".workbench-bar-globals"); + if (viewsEl) ro.observe(viewsEl); + if (globalsEl) ro.observe(globalsEl); measure(); return () => ro.disconnect(); }, []); @@ -471,7 +495,7 @@ export default function WorkbenchBar({
    {/* Left: optional "Back to My Files" + view switcher */} @@ -524,27 +548,50 @@ export default function WorkbenchBar({ )}
    - {/* Tool buttons - second row, only rendered when buttons exist */} - {sectionsWithButtons.length > 0 && ( -
    - {sectionsWithButtons.map( - ({ section, buttons: sectionButtons }, idx) => ( - - {idx > 0 &&
    } - {sectionButtons.map((btn) => { - const content = renderButton(btn); - if (!content) return null; - return ( -
    - {content} -
    - ); - })} - - ), - )} -
    - )} + {/* Global super search - always present, even on the homepage */} +
    + +
    + + {/* Tool buttons - second row, only rendered when buttons exist. In the + viewer the row is retractable: a handle on its right edge hides the + whole row; Workbench then shows a tab below the bar to bring it back. */} + {sectionsWithButtons.length > 0 && + !(isViewer && viewerToolbarCollapsed) && ( +
    + {sectionsWithButtons.map( + ({ section, buttons: sectionButtons }, idx) => ( + + {idx > 0 &&
    } + {sectionButtons.map((btn) => { + const content = renderButton(btn); + if (!content) return null; + return ( +
    + {content} +
    + ); + })} + + ), + )} + {isViewer && onCollapseViewerToolbar && ( +
    + )} {/* Right: Global buttons - export group left, close anchored right */}
    diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx deleted file mode 100644 index 28d5a1c3e0..0000000000 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; -import type { ConfigNavSection } from "@app/components/shared/config/configNavSections"; - -const mockConfigNavSections: ConfigNavSection[] = [ - { - title: "Preferences", - items: [ - { - key: "general", - label: "General", - icon: "settings-rounded", - component: null, - }, - { - key: "hotkeys", - label: "Keyboard Shortcuts", - icon: "keyboard-rounded", - component: null, - }, - ], - }, - { - title: "Workspace", - items: [ - { - key: "people", - label: "People", - icon: "group-rounded", - component: null, - }, - { - key: "teams", - label: "Teams", - icon: "groups-rounded", - component: null, - disabled: true, - }, - ], - }, -]; - -const meta = { - title: "Shared/Config/SettingsSearchBar", - component: SettingsSearchBar, - parameters: { layout: "padded" }, - args: { - configNavSections: mockConfigNavSections, - onNavigate: async () => {}, - isMobile: false, - }, -} satisfies Meta; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Mobile: Story = { - args: { - isMobile: true, - }, -}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx deleted file mode 100644 index 397347dcf6..0000000000 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { useMemo, useState, useCallback } from "react"; -import { Select, Text } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import LocalIcon from "@app/components/shared/LocalIcon"; -import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; -import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import type { - ConfigNavSection, - ConfigNavItem, -} from "@app/components/shared/config/configNavSections"; - -interface SettingsSearchBarProps { - configNavSections: ConfigNavSection[]; - onNavigate: (key: NavKey) => Promise; - isMobile: boolean; -} - -interface SettingsSearchOption { - value: NavKey; - label: string; - sectionTitle: string; - destinationPath: string; - searchableContent: string[]; - matchedContext?: string; -} - -const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = - { - general: ["settings.general"], - hotkeys: ["settings.hotkeys"], - account: ["account"], - people: ["settings.workspace"], - teams: ["settings.workspace", "settings.team"], - "api-keys": ["settings.developer"], - connectionMode: ["settings.connection"], - planBilling: ["settings.planBilling"], - adminGeneral: ["admin.settings.general"], - adminFeatures: ["admin.settings.features"], - adminEndpoints: ["admin.settings.endpoints"], - adminDatabase: ["admin.settings.database"], - adminAdvanced: ["admin.settings.advanced"], - adminSecurity: ["admin.settings.security"], - adminMcp: ["admin.settings.mcp"], - adminConnections: [ - "admin.settings.connections", - "admin.settings.mail", - "admin.settings.security", - "admin.settings.telegram", - "admin.settings.premium", - "admin.settings.general", - "settings.securityAuth", - "settings.connection", - ], - adminPlan: [ - "settings.planBilling", - "admin.settings.premium", - "settings.licensingAnalytics", - ], - adminAudit: ["settings.licensingAnalytics"], - adminUsage: ["settings.licensingAnalytics"], - adminLegal: ["admin.settings.legal"], - adminPrivacy: ["admin.settings.privacy"], - }; - -const getTranslationPrefixesForNavKey = (key: string): string[] => { - const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? []; - - const inferredPrefixes: string[] = []; - - if (key.startsWith("admin")) { - const adminSuffix = key.replace(/^admin/, ""); - const normalizedAdminSuffix = - adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); - inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); - } else { - inferredPrefixes.push(`settings.${key}`); - } - - return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes])); -}; - -const flattenTranslationStrings = (value: unknown): string[] => { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - - if (Array.isArray(value)) { - return value.flatMap(flattenTranslationStrings); - } - - if (value && typeof value === "object") { - return Object.values(value as Record).flatMap( - flattenTranslationStrings, - ); - } - - return []; -}; - -const buildMatchSnippet = (text: string, query: string): string => { - const normalizedText = text.toLocaleLowerCase(); - const normalizedQuery = query.toLocaleLowerCase(); - const matchIndex = normalizedText.indexOf(normalizedQuery); - - if (matchIndex === -1) { - return text; - } - - const maxLength = 84; - const contextPadding = 28; - const start = Math.max(0, matchIndex - contextPadding); - const end = Math.min(text.length, matchIndex + query.length + contextPadding); - const snippet = text.slice(start, end); - - if (snippet.length <= maxLength) { - return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`; - } - - return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`; -}; - -export const SettingsSearchBar: React.FC = ({ - configNavSections, - onNavigate, - isMobile, -}) => { - const { t } = useTranslation(); - const [searchValue, setSearchValue] = useState(""); - - // Build a global index from every accessible settings tab in the modal navigation. - // This does not render section components, so API calls still happen only when a tab is opened. - const searchableSections = useMemo(() => { - return configNavSections.flatMap((section) => - section.items - .filter((item: ConfigNavItem) => !item.disabled) - .map((item: ConfigNavItem) => { - const translationPrefixes = getTranslationPrefixesForNavKey(item.key); - const translationContent = translationPrefixes.flatMap((prefix) => - flattenTranslationStrings( - t(prefix, { returnObjects: true, defaultValue: {} }), - ), - ); - - const searchableContent = Array.from( - new Set([ - item.label, - section.title, - `/settings/${item.key}`, - ...translationContent, - ]), - ); - - return { - value: item.key, - label: item.label, - sectionTitle: section.title, - destinationPath: `/settings/${item.key}`, - searchableContent, - }; - }), - ); - }, [configNavSections, t]); - - const filteredSearchableSections = useMemo(() => { - const query = searchValue.trim(); - if (!query) { - return searchableSections; - } - - const normalizedQuery = query.toLocaleLowerCase(); - - return searchableSections.reduce( - (accumulator, option) => { - const matchedEntry = option.searchableContent.find((entry) => - entry.toLocaleLowerCase().includes(normalizedQuery), - ); - - if (!matchedEntry) { - return accumulator; - } - - accumulator.push({ - ...option, - matchedContext: buildMatchSnippet(matchedEntry, query), - }); - - return accumulator; - }, - [], - ); - }, [searchValue, searchableSections]); - - const handleSearchNavigation = useCallback( - async (value: string | null) => { - if (!value) return; - if (!VALID_NAV_KEYS.includes(value as NavKey)) return; - await onNavigate(value as NavKey); - setSearchValue(""); - }, - [onNavigate], - ); - - return ( - setQuery(e.target.value)} - placeholder={t("portal.search.placeholder")} - aria-label={t("portal.search.ariaLabel")} - className="portal-search__input" - autoComplete="off" - spellCheck={false} - /> - - ESC - -
    - -
    - {isLoading && ( -
    - - - - -
    - )} - {isEmpty && ( - - )} - {!isLoading && - !isEmpty && - Object.entries(groups).map(([group, items]) => ( -
    -
    {group}
    - {items.map((item) => ( - - ))} -
    - ))} -
    -
    - - ); -} diff --git a/frontend/editor/src/portal/components/docs/DocsNav.tsx b/frontend/editor/src/portal/components/docs/DocsNav.tsx index 1063d5e98f..2f6258fca9 100644 --- a/frontend/editor/src/portal/components/docs/DocsNav.tsx +++ b/frontend/editor/src/portal/components/docs/DocsNav.tsx @@ -8,7 +8,8 @@ import type { DocsNavSection } from "@portal/api/docs"; * path ("functionality/security" is a child of "functionality"), so sub-sections * nest under their parent. The root "Overview" section is static (always open, no * toggle); every other section collapses, and only the branch leading to the - * active doc opens by default. (Search lives in DocsSearch above this.) + * active doc opens by default. (Full-text docs search lives in the global + * super search.) */ // Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible. diff --git a/frontend/editor/src/portal/components/docs/DocsSearch.tsx b/frontend/editor/src/portal/components/docs/DocsSearch.tsx deleted file mode 100644 index 49bdb3101f..0000000000 --- a/frontend/editor/src/portal/components/docs/DocsSearch.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import type { SearchResult, Segment } from "@portal/docs/search"; - -/** Render highlighted segments, wrapping matched runs in . */ -function Highlighted({ segments }: { segments: Segment[] }) { - return ( - <> - {segments.map((s, i) => - s.hit ? ( - - {s.text} - - ) : ( - {s.text} - ), - )} - - ); -} - -/** - * Docs search box + results. While a query is active it shows a ranked list of - * matching docs — each with its section, a highlighted title, and a content - * snippet — that navigates on click (or Enter). Arrow keys move the selection. - */ -export function DocsSearch({ - query, - onQueryChange, - results, - onSelect, -}: { - query: string; - onQueryChange: (q: string) => void; - results: SearchResult[]; - onSelect: (docId: string) => void; -}) { - const { t } = useTranslation(); - // -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover. - const [activeIndex, setActiveIndex] = useState(-1); - const listRef = useRef(null); - const hasQuery = query.trim().length > 0; - - useEffect(() => setActiveIndex(-1), [query]); - - useEffect(() => { - listRef.current - ?.querySelector('[data-active="true"]') - ?.scrollIntoView?.({ block: "nearest" }); - }, [activeIndex]); - - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - onQueryChange(""); - return; - } - if (!results.length) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setActiveIndex((i) => Math.min(i + 1, results.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setActiveIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter") { - e.preventDefault(); - const hit = results[activeIndex >= 0 ? activeIndex : 0]; - if (hit) onSelect(hit.id); - } - }; - - return ( -
    -
    - - ⌕ - - onQueryChange(e.target.value)} - onKeyDown={onKeyDown} - aria-label={t("portal.docs.search.placeholder")} - /> -
    - - {hasQuery && ( -
    - {results.length === 0 ? ( -

    - {t("portal.docs.search.empty")} -

    - ) : ( - <> -
    - {t("portal.docs.search.results", { count: results.length })} -
    -
      - {results.map((r, i) => ( -
    • - -
    • - ))} -
    - - )} -
    - )} -
    - ); -} diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx index ff4fb4b6f1..28e84691fb 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx @@ -23,6 +23,7 @@ const FULL_CAPS: UsersCapabilities = { seats: false, manageGrants: true, removeScope: "org", + listingRequiresAdmin: true, }; /** SaaS team-leader: invite / rename / remove-member only, no org group. */ @@ -44,6 +45,7 @@ const SAAS_CAPS: UsersCapabilities = { seats: true, manageGrants: false, removeScope: "team", + listingRequiresAdmin: false, }; /** A full org: one org owner and two teams, each with a leader. */ diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.tsx index 2e78a6d4be..4367656d0b 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.tsx @@ -215,7 +215,8 @@ export function UsersDirectory({ function renderRow(m: Member) { const access = m.portalAccess ?? "none"; return ( -
    + // data-member-id lets deep links (?member=) scroll to and flash a row. +
    diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index ba0676ab6d..b5ebada257 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -7,11 +7,6 @@ import { } from "react"; interface UIContextValue { - searchOpen: boolean; - openSearch: () => void; - closeSearch: () => void; - toggleSearch: () => void; - /** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */ mobileNavOpen: boolean; openMobileNav: () => void; @@ -32,7 +27,8 @@ interface UIContextValue { * modal pick its own default. Cleared back to `null` on close. */ settingsInitialSection: string | null; - openSettings: (section?: string) => void; + settingsInitialFocus: string | null; + openSettings: (section?: string, focus?: string) => void; closeSettings: () => void; /** @@ -81,7 +77,6 @@ function writeSidebarCollapsed(collapsed: boolean): void { } export function UIProvider({ children }: { children: ReactNode }) { - const [searchOpen, setSearchOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed); @@ -90,6 +85,9 @@ export function UIProvider({ children }: { children: ReactNode }) { const [settingsInitialSection, setSettingsInitialSection] = useState< string | null >(null); + const [settingsInitialFocus, setSettingsInitialFocus] = useState< + string | null + >(null); const [linkModalOpen, setLinkModalOpen] = useState(false); const [trialSetupRequested, setTrialSetupRequested] = useState(false); const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link"); @@ -101,16 +99,8 @@ export function UIProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ - // Opening any overlay (search, settings, link modal) dismisses the mobile - // nav drawer so overlays never stack on top of it. - searchOpen, - openSearch: () => { - setMobileNavOpen(false); - setSearchOpen(true); - }, - closeSearch: () => setSearchOpen(false), - toggleSearch: () => setSearchOpen((o) => !o), - + // Opening any overlay (settings, link modal) dismisses the mobile nav + // drawer so overlays never stack on top of it. mobileNavOpen, openMobileNav: () => setMobileNavOpen(true), closeMobileNav: () => setMobileNavOpen(false), @@ -131,14 +121,17 @@ export function UIProvider({ children }: { children: ReactNode }) { settingsOpen, settingsInitialSection, - openSettings: (section?: string) => { + settingsInitialFocus, + openSettings: (section?: string, focus?: string) => { setMobileNavOpen(false); setSettingsInitialSection(section ?? null); + setSettingsInitialFocus(focus ?? null); setSettingsOpen(true); }, closeSettings: () => { setSettingsOpen(false); setSettingsInitialSection(null); + setSettingsInitialFocus(null); }, linkModalOpen, @@ -152,6 +145,7 @@ export function UIProvider({ children }: { children: ReactNode }) { setReopenSettingsAfterLink("account-link"); setSettingsOpen(false); setSettingsInitialSection(null); + setSettingsInitialFocus(null); } setLinkModalOpen(true); }, @@ -166,18 +160,19 @@ export function UIProvider({ children }: { children: ReactNode }) { setLinkModalMode("link"); if (reopenSettingsAfterLink) { setSettingsInitialSection(reopenSettingsAfterLink); + setSettingsInitialFocus(null); setSettingsOpen(true); setReopenSettingsAfterLink(null); } }, }), [ - searchOpen, mobileNavOpen, sidebarCollapsed, assistantOpen, settingsOpen, settingsInitialSection, + settingsInitialFocus, linkModalOpen, linkModalMode, reopenSettingsAfterLink, diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts new file mode 100644 index 0000000000..4be356bab0 --- /dev/null +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts @@ -0,0 +1,442 @@ +import { createElement, type ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: ( + key: string, + fallbackOrOptions?: string | Record, + ) => { + if (key === "portal.policies.defaultName") { + return `${(fallbackOrOptions as Record)?.category as string} Policy`; + } + if (typeof fallbackOrOptions === "string") return fallbackOrOptions; + const labels: Record = { + "portal.nav.users": "Users", + "portal.nav.policies": "Policies", + "portal.nav.pipelines": "Pipelines", + "portal.nav.sources": "Sources", + "portal.nav.editor": "Editor", + "superSearch.group.processor": "Processor", + "superSearch.group.settings": "Settings", + "superSearch.group.tools": "Tools", + "settings.email.smtpHost": "SMTP host", + "settings.email.title": "Email", + }; + return labels[key] ?? key; + }, + }), + }; +}); + +vi.mock("react-router-dom", () => ({ + useNavigate: vi.fn(() => vi.fn()), +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: vi.fn(() => ({ + config: { + isAdmin: false, + enableLogin: true, + }, + })), +})); + +vi.mock("@app/contexts/ToolRegistryContext", () => ({ + useToolRegistry: vi.fn(() => ({ + allTools: {}, + })), +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: vi.fn(() => ({ + tier: "pro", + })), +})); + +const mockOpenSettings = vi.fn(); +vi.mock("@portal/contexts/UIContext", () => ({ + useUI: vi.fn(() => ({ + openSettings: mockOpenSettings, + })), +})); + +vi.mock("@app/data/toolsTaxonomy", () => ({ + getToolUrlPath: vi.fn((id: string) => `/tools/${id}`), + isComingSoonTool: vi.fn(() => false), +})); + +vi.mock("@app/data/settingsSearchIndex", () => ({ + SETTINGS_SEARCH_INDEX: [ + { + section: "email", + anchor: "smtp-host", + labelKey: "settings.email.smtpHost", + labelFallback: "SMTP host", + keywords: ["smtp"], + }, + ], +})); + +vi.mock("@app/data/settingsSectionRegistry", () => ({ + SETTINGS_SECTION_REGISTRY: [ + { + key: "email", + labelKey: "settings.email.title", + labelFallback: "Email", + keywords: ["smtp", "email"], + requiresLogin: true, + }, + ], +})); + +vi.mock("@app/data/settingsContentSearch", () => ({ + findSettingsContentMatch: vi.fn(() => null), + buildMatchSnippet: vi.fn(() => ""), +})); + +vi.mock("@app/data/processorSearchIndex", () => ({ + PROCESSOR_SEARCH_INDEX: [ + { + id: "users", + labelKey: "portal.nav.users", + labelFallback: "Users", + path: "/portal/users", + keywords: ["members"], + }, + { + id: "policies", + labelKey: "portal.nav.policies", + labelFallback: "Policies", + path: "/portal/policies", + keywords: ["rules"], + }, + { + id: "pipelines", + labelKey: "portal.nav.pipelines", + labelFallback: "Pipelines", + path: "/portal/pipelines", + keywords: ["automation"], + }, + { + id: "sources", + labelKey: "portal.nav.sources", + labelFallback: "Sources", + path: "/portal/sources", + keywords: ["connectors"], + }, + { + id: "docs", + labelKey: "portal.nav.docs", + labelFallback: "Documentation", + path: "/portal/docs", + keywords: ["docs"], + }, + ], + // Tests run as an org admin; per-scope access gating has its own coverage + // in the stubbed suite. + isPortalEntityScopeAccessible: () => true, +})); + +// The roster is fetched through the flavor-resolved usersBackend (the same +// path the shared users query uses), not @portal/api/users directly. +vi.mock("@app/portal/usersBackend", () => ({ + usersBackend: { + fetchUsers: vi.fn(), + }, +})); + +// Keep the real (pure) assemblePolicies; only the network fetchers are mocked. +vi.mock("@portal/api/policies", async (importOriginal) => ({ + ...(await importOriginal()), + fetchPoliciesList: vi.fn(), + fetchPolicyRuns: vi.fn(), +})); + +vi.mock("@portal/api/pipelines", () => ({ + fetchPipelines: vi.fn(), +})); + +vi.mock("@portal/api/sources", () => ({ + fetchSources: vi.fn(), +})); + +import type { CatalogueEntry } from "@portal/api/policies"; +import { fetchPoliciesList, fetchPolicyRuns } from "@portal/api/policies"; +import type { PipelineView } from "@portal/api/pipelines"; +import { fetchPipelines } from "@portal/api/pipelines"; +import { fetchSources } from "@portal/api/sources"; +import type { Member, UsersResponse } from "@portal/api/users"; +import { usersBackend } from "@app/portal/usersBackend"; +import { + rankDocsResults, + rankPortalPipelineResults, + rankPortalPolicyResults, +} from "@portal/search/entitySearch"; +import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults"; + +function makePolicyEntry(overrides?: Partial): CatalogueEntry { + return { + category: { + id: "security", + label: "Security", + tone: "purple", + desc: "Protect sensitive documents", + }, + config: { + summary: "", + rules: [], + scopeLabel: "", + fields: [], + defaultOperations: [], + }, + policy: { + category: { + id: "security", + label: "Security", + tone: "purple", + desc: "Protect sensitive documents", + }, + config: { + summary: "", + rules: [], + scopeLabel: "", + fields: [], + defaultOperations: [], + }, + state: { + configured: true, + status: "active", + sources: [], + scopeTypes: [], + reviewerEmail: "", + fieldValues: {}, + backendId: "policy-security", + }, + steps: [], + stats: { + enforced: 0, + dataProcessed: "0 B", + activeFor: "0d", + }, + activity: [], + }, + ...overrides, + }; +} + +function makePipelineView( + id: string, + name: string, + trigger = "manual", +): PipelineView { + return { + id, + name, + enabled: true, + status: "active", + trigger, + sources: [], + steps: [], + output: "inline", + owner: "alice", + }; +} + +function makeMember(overrides?: Partial): Member { + return { + id: "member-1", + name: "Alice Admin", + email: "alice@example.com", + role: "admin", + status: "active", + lastActive: "1m ago", + ...overrides, + }; +} + +function makeUsersResponse(members: Member[]): UsersResponse { + return { + summary: { + totalMembers: members.length, + pendingInvites: 0, + seatsUsed: members.length, + seatLimit: null, + }, + members, + roles: [], + access: { + tier: "pro", + seatsUsed: members.length, + seatLimit: null, + }, + mailEnabled: true, + emailInvitesEnabled: true, + }; +} + +function createDeferred() { + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { + promise, + resolve: (value: T) => resolve?.(value), + reject: (reason?: unknown) => reject?.(reason), + }; +} + +function queryWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); +} + +describe("usePortalSearchResults helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockOpenSettings.mockReset(); + vi.mocked(usersBackend.fetchUsers).mockResolvedValue(makeUsersResponse([])); + vi.mocked(fetchPoliciesList).mockResolvedValue([]); + vi.mocked(fetchPolicyRuns).mockResolvedValue([]); + vi.mocked(fetchPipelines).mockResolvedValue({ kpis: [], pipelines: [] }); + vi.mocked(fetchSources).mockResolvedValue({ kpis: [], sources: [] }); + }); + + it("ranks configured policies under the policies group", () => { + const openPolicy = vi.fn(); + const results = rankPortalPolicyResults( + [makePolicyEntry()], + "security policy", + (key: string, options?: Record) => + key === "portal.policies.defaultName" + ? `${options?.category as string} Policy` + : key, + openPolicy, + ); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + key: "portal-policy:security", + group: "portal-policies", + title: "Security Policy", + }); + + void results[0]?.onSelect(); + expect(openPolicy).toHaveBeenCalledWith("security"); + }); + + it("filters policy-backed records out of the pipelines group", () => { + const openPipeline = vi.fn(); + const results = rankPortalPipelineResults( + [ + makePipelineView("policy-security", "Security Policy"), + makePipelineView("custom-pipeline", "Nightly OCR"), + ], + "nightly", + new Set(["policy-security"]), + openPipeline, + ); + + expect(results.map((result) => result.key)).toEqual([ + "portal-pipeline:custom-pipeline", + ]); + }); + + it("full-text searches the bundled docs, not just their titles", () => { + const navigate = vi.fn(); + // "Tesseract" appears in the OCR doc body but in no doc title — a hit + // whose snippet contains it proves content search. + const results = rankDocsResults("Tesseract", navigate); + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.subtitle).toMatch(/tesseract/i); + + void results[0]?.onSelect(); + expect(navigate).toHaveBeenCalledWith(expect.stringMatching(/\/docs#./)); + }); + + it("forwards portal settings row hits with their focus anchor", () => { + const { result } = renderHook( + () => usePortalSearchResults("smtp", true, { scopeIds: ["settings"] }), + { wrapper: queryWrapper() }, + ); + + const settingHit = result.current.flatResults[0]; + expect(settingHit?.key).toBe("setting:email:smtp-host"); + + void settingHit?.onSelect(); + expect(mockOpenSettings).toHaveBeenCalledWith("email", "smtp-host"); + expect(usersBackend.fetchUsers).not.toHaveBeenCalled(); + }); + + it("fetches only the requested entity scope", async () => { + vi.mocked(usersBackend.fetchUsers).mockResolvedValue( + makeUsersResponse([makeMember({ id: "member-2", name: "Alice" })]), + ); + + const { result } = renderHook( + () => + usePortalSearchResults("alice", true, { scopeIds: ["portal-users"] }), + { wrapper: queryWrapper() }, + ); + + await waitFor(() => + expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1), + ); + await waitFor(() => expect(result.current.loadingFiles).toBe(false)); + + expect(fetchPoliciesList).not.toHaveBeenCalled(); + expect(fetchPipelines).not.toHaveBeenCalled(); + expect(fetchSources).not.toHaveBeenCalled(); + expect(result.current.groups.map((group) => group.id)).toEqual([ + "portal-users", + ]); + }); + + it("reuses the in-flight query after close/reopen instead of sticking in loading", async () => { + const firstUsers = createDeferred(); + vi.mocked(usersBackend.fetchUsers).mockImplementationOnce( + () => firstUsers.promise, + ); + + const { result, rerender } = renderHook( + ({ query }) => + usePortalSearchResults(query, true, { scopeIds: ["portal-users"] }), + { + initialProps: { query: "alice" }, + wrapper: queryWrapper(), + }, + ); + + await waitFor(() => expect(result.current.loadingFiles).toBe(true)); + expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1); + + rerender({ query: "" }); + await waitFor(() => expect(result.current.loadingFiles).toBe(false)); + + rerender({ query: "alice" }); + await waitFor(() => expect(result.current.loadingFiles).toBe(true)); + expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1); + + firstUsers.resolve( + makeUsersResponse([ + makeMember({ id: "member-3", name: "Alice Reloaded" }), + ]), + ); + + await waitFor(() => expect(result.current.loadingFiles).toBe(false)); + expect(result.current.groups.map((group) => group.id)).toEqual([ + "portal-users", + ]); + }); +}); diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx b/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx new file mode 100644 index 0000000000..b839d980d6 --- /dev/null +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx @@ -0,0 +1,321 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { getToolUrlPath } from "@app/data/toolsTaxonomy"; +import { isPortalEntityScopeAccessible } from "@app/data/processorSearchIndex"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { PORTAL_HIDDEN_SECTION_KEYS } from "@portal/components/PortalSettingsHost"; +import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; +import { + assembleSuperSearchGroups, + rankSettingsResults, + rankToolResults, + useSearchScopeFilter, +} from "@app/hooks/useSuperSearch"; +import { + PORTAL_ENTITY_SCOPE_DEFS, + PORTAL_DOCS_SCOPE_ID, + type SuperSearchGates, + type SuperSearchGroup, + type SuperSearchGroupId, + type SuperSearchQueryOptions, + type SuperSearchScope, + type UseSuperSearchResult, +} from "@app/types/superSearch"; +import type { ToolId } from "@app/types/toolId"; +import { assignLocation, openExternalUrl } from "@app/utils/safeNavigation"; +import { usersBackend } from "@app/portal/usersBackend"; +import { + assemblePolicies, + fetchPoliciesList, + fetchPolicyRuns, +} from "@portal/api/policies"; +import { fetchPipelines } from "@portal/api/pipelines"; +import { fetchSources } from "@portal/api/sources"; +import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl"; +import { useTier } from "@portal/contexts/TierContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { qk } from "@portal/queries/keys"; +import { + buildProcessorEntityGroups, + defaultPortalEntityScopes, + isDocsSearchable, + isVisiblePortalScope, + withPortalEntityDependencies, + type ProcessorEntities, +} from "@portal/search/entitySearch"; + +const EDITOR_GROUP_ORDER: SuperSearchGroupId[] = ["tools"]; +const SETTINGS_GROUP_ORDER: SuperSearchGroupId[] = ["settings"]; +const PROCESSOR_SECTION_LABEL_KEY = "superSearch.group.processor"; +const PROCESSOR_SECTION_LABEL_FALLBACK = "Processor"; +const SETTINGS_SECTION_LABEL_KEY = "superSearch.group.settings"; +const SETTINGS_SECTION_LABEL_FALLBACK = "Settings"; +const EDITOR_SECTION_LABEL_KEY = "portal.nav.editor"; +const EDITOR_SECTION_LABEL_FALLBACK = "Editor"; + +/** + * Cross-origin editor URL for a tool. Only used when a separately-hosted + * editor is configured — the same-app case routes client-side instead: on + * bundled deploys the backend serves the frontend and 401s unauthenticated + * document GETs (the JWT lives in localStorage, so a full page load carries + * no credentials), which would bounce every tool hop to /login. + */ +function externalEditorHref(path: string): string { + return EDITOR_URL.replace(/\/$/, "") + path; +} + +/** + * The portal bar's filter chips — every lane the editor offers except Files + * (files only open in the editor) and Pages (the sidebar covers navigation). + * Ordered to match the dropdown's section priority. Lanes whose data source + * refuses this session (the users roster for non-admins on self-hosted) get + * no chip — an offered lane must be able to return results. + */ +export function usePortalSearchScopes(): SuperSearchScope[] { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const isAdmin = config?.isAdmin ?? false; + + return useMemo( + () => [ + ...PORTAL_ENTITY_SCOPE_DEFS.filter( + (def) => + isVisiblePortalScope(def.id) && + isPortalEntityScopeAccessible(def.id, isAdmin), + ).map((def) => ({ + id: def.id, + label: t(def.labelKey, def.labelFallback), + aliases: [...def.aliases], + })), + ...(isDocsSearchable() + ? [ + { + id: PORTAL_DOCS_SCOPE_ID, + label: t("superSearch.group.docs", "Docs"), + aliases: ["doc", "docs", "documentation"], + }, + ] + : []), + { + id: "settings", + label: t("superSearch.group.settings", "Settings"), + aliases: ["setting", "settings"], + }, + { + id: "tools", + label: t("superSearch.group.tools", "Tools"), + aliases: ["tool", "tools"], + }, + ], + [t, isAdmin], + ); +} + +/** + * The portal's results provider for the shared super search bar: files stay + * editor-only, portal entity results are grouped under a Processor section, + * and the shared tools/settings lanes sit under an Editor section. Portal page + * routes themselves stay out of the portal search — once you're in the portal, + * the entities are the useful targets. + */ +export function usePortalSearchResults( + query: string, + active: boolean, + options?: SuperSearchQueryOptions, +): UseSuperSearchResult { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { openSettings } = useUI(); + const { allTools } = useToolRegistry(); + const { config } = useAppConfig(); + const { tier } = useTier(); + + const trimmed = query.trim(); + const { scopeEnabled } = useSearchScopeFilter(options); + const requestedEntityScopes = useMemo(() => { + if (!active || trimmed.length === 0) return new Set(); + const enabled = defaultPortalEntityScopes(config?.isAdmin ?? false).filter( + (scopeId) => scopeEnabled(scopeId), + ); + return new Set(withPortalEntityDependencies(enabled)); + }, [active, scopeEnabled, trimmed, config?.isAdmin]); + + // Entity data rides the portal's shared query layer — the same keys the + // views use, so searching warms the view (and vice versa) and the client's + // staleTime/retry policy replaces bespoke fetch discipline. `enabled` keeps + // each lane's fetch behind its scope chip and the active-query gate. + const usersQuery = useQuery({ + queryKey: qk.usersRoster(tier), + queryFn: () => usersBackend.fetchUsers(tier), + enabled: requestedEntityScopes.has("portal-users"), + }); + const policiesListQuery = useQuery({ + queryKey: qk.policiesList(), + queryFn: fetchPoliciesList, + enabled: requestedEntityScopes.has("portal-policies"), + }); + const policyRunsQuery = useQuery({ + queryKey: qk.policyRuns(), + queryFn: fetchPolicyRuns, + enabled: requestedEntityScopes.has("portal-policies"), + }); + const pipelinesQuery = useQuery({ + queryKey: qk.pipelines(), + queryFn: fetchPipelines, + enabled: requestedEntityScopes.has("portal-pipelines"), + }); + const sourcesQuery = useQuery({ + queryKey: qk.sources(), + queryFn: fetchSources, + enabled: requestedEntityScopes.has("portal-sources"), + }); + + // Loading only counts for lanes the current search actually requests — a + // fetch left in flight after its lane was deselected (or the bar closed) + // must not hold the dropdown's no-results gate open. + const loadingEntities = + (requestedEntityScopes.has("portal-users") && usersQuery.isLoading) || + (requestedEntityScopes.has("portal-policies") && + (policiesListQuery.isLoading || policyRunsQuery.isLoading)) || + (requestedEntityScopes.has("portal-pipelines") && + pipelinesQuery.isLoading) || + (requestedEntityScopes.has("portal-sources") && sourcesQuery.isLoading); + + const entities = useMemo( + () => ({ + users: usersQuery.data?.members ?? [], + policies: policiesListQuery.data + ? assemblePolicies(policiesListQuery.data, policyRunsQuery.data ?? []) + .catalogue + : [], + pipelines: pipelinesQuery.data?.pipelines ?? [], + sources: sourcesQuery.data?.sources ?? [], + }), + [ + usersQuery.data, + policiesListQuery.data, + policyRunsQuery.data, + pipelinesQuery.data, + sourcesQuery.data, + ], + ); + + const openTool = useCallback( + (id: ToolId) => { + // Link tools have no in-editor UI — navigating to a tool URL for one + // lands on a "tool not found" panel. Open their destination directly, + // matching how the editor's tool lists treat them. + const tool = allTools[id]; + if (tool?.link) { + openExternalUrl(tool.link); + return; + } + const path = getToolUrlPath(id); + if (EDITOR_IS_SAME_APP) { + // One SPA: swap route-sets through the router. The portal tree + // unmounts and the editor mounts fresh at the tool URL, so its + // URL-driven tool init runs exactly as it does on a cold load. + navigate(path); + } else { + assignLocation(externalEditorHref(path)); + } + }, + [allTools, navigate], + ); + + const openSettingsSection = useCallback( + (section: string, anchor?: string) => openSettings(section, anchor), + [openSettings], + ); + + const gates = useMemo( + () => + config + ? { + isAdmin: config.isAdmin ?? false, + loginEnabled: config.enableLogin ?? false, + showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true, + } + : null, + [config], + ); + + const entityGroups = useMemo( + () => + buildProcessorEntityGroups(entities, trimmed, t, navigate, { + scopeEnabled, + }), + [entities, trimmed, t, navigate, scopeEnabled], + ); + + const groups = useMemo(() => { + // Section order: Processor first, Settings second, Editor last. + const settingsGroups = assembleSuperSearchGroups( + { + settings: scopeEnabled("settings") + ? rankSettingsResults( + trimmed, + t, + gates, + openSettingsSection, + undefined, + PORTAL_HIDDEN_SECTION_KEYS, + ) + : [], + }, + t, + SETTINGS_GROUP_ORDER, + ).map((group) => ({ + ...group, + sectionLabel: t( + SETTINGS_SECTION_LABEL_KEY, + SETTINGS_SECTION_LABEL_FALLBACK, + ), + })); + + const editorGroups = assembleSuperSearchGroups( + { + tools: scopeEnabled("tools") + ? rankToolResults(allTools, trimmed, openTool) + : [], + }, + t, + EDITOR_GROUP_ORDER, + ).map((group) => ({ + ...group, + sectionLabel: t(EDITOR_SECTION_LABEL_KEY, EDITOR_SECTION_LABEL_FALLBACK), + })); + + return [ + ...entityGroups.map((group) => ({ + ...group, + sectionLabel: t( + PROCESSOR_SECTION_LABEL_KEY, + PROCESSOR_SECTION_LABEL_FALLBACK, + ), + })), + ...settingsGroups, + ...editorGroups, + ]; + }, [ + entityGroups, + gates, + openSettingsSection, + openTool, + scopeEnabled, + allTools, + t, + trimmed, + ]); + + const flatResults = useMemo( + () => groups.flatMap((group) => group.results), + [groups], + ); + + // loadingFiles doubles as "an async source is still loading" for the + // dropdown's no-results gate — here that's the entity fetch. + return { groups, flatResults, loadingFiles: loadingEntities }; +} diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index d5428b1853..13b22d6941 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -1,7 +1,6 @@ import { assistantHandlers } from "@portal/mocks/handlers/assistant"; import { authHandlers } from "@portal/mocks/handlers/auth"; import { notificationsHandlers } from "@portal/mocks/handlers/notifications"; -import { searchHandlers } from "@portal/mocks/handlers/search"; import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines"; import { sourcesHandlers } from "@portal/mocks/handlers/sources"; import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; @@ -21,7 +20,6 @@ export const handlers = [ ...authHandlers, ...notificationsHandlers, ...assistantHandlers, - ...searchHandlers, ...pipelinesHandlers, ...sourcesHandlers, ...infrastructureHandlers, diff --git a/frontend/editor/src/portal/mocks/handlers/search.ts b/frontend/editor/src/portal/mocks/handlers/search.ts deleted file mode 100644 index b62e8bc910..0000000000 --- a/frontend/editor/src/portal/mocks/handlers/search.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { http, HttpResponse } from "msw"; -import { QUICK_ACTIONS } from "@portal/mocks/search"; - -export const searchHandlers = [ - http.get("/v1/search/quick-actions", () => { - return HttpResponse.json(QUICK_ACTIONS); - }), -]; diff --git a/frontend/editor/src/portal/mocks/search.ts b/frontend/editor/src/portal/mocks/search.ts deleted file mode 100644 index 86d332de70..0000000000 --- a/frontend/editor/src/portal/mocks/search.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Mock quick-action catalogue for the ⌘K search palette. The QuickAction type - * lives in api/search.ts (the backend contract); this module only builds fake - * data for Storybook and tests. - */ - -import type { QuickAction } from "@portal/api/search"; - -export const QUICK_ACTIONS: QuickAction[] = [ - { group: "Jump to", label: "Home", hint: "G H" }, - { group: "Jump to", label: "Pipelines", hint: "G P" }, - { group: "Jump to", label: "Sources", hint: "G S" }, - { group: "Jump to", label: "Documents", hint: "G D" }, - { group: "Create", label: "New pipeline", hint: "N P" }, - { group: "Create", label: "New API key", hint: "N K" }, - { group: "Theme", label: "Toggle dark / light", hint: "T" }, -]; diff --git a/frontend/editor/src/portal/search/entitySearch.tsx b/frontend/editor/src/portal/search/entitySearch.tsx new file mode 100644 index 0000000000..1d0a7cc489 --- /dev/null +++ b/frontend/editor/src/portal/search/entitySearch.tsx @@ -0,0 +1,393 @@ +import { + PROCESSOR_SEARCH_INDEX, + isPortalEntityScopeAccessible, +} from "@app/data/processorSearchIndex"; +import { + PORTAL_ENTITY_SCOPE_DEFS, + PORTAL_DOCS_SCOPE_ID, + type SuperSearchGroup, + type SuperSearchResult, +} from "@app/types/superSearch"; +import { rankByFuzzy } from "@app/utils/fuzzySearch"; +import { + assemblePolicies, + fetchPoliciesList, + fetchPolicyRuns, + type CatalogueEntry, +} from "@portal/api/policies"; +import { fetchPipelines, type PipelineView } from "@portal/api/pipelines"; +import { fetchSources, type SourceView } from "@portal/api/sources"; +import type { Member } from "@portal/api/users"; +// Flavor-resolved users backend: self-hosted reads the proprietary admin +// endpoints, SaaS the invitation-based team endpoints (the admin ones 403 +// there for the always-ROLE_USER sessions). +import { usersBackend } from "@app/portal/usersBackend"; +import { + DocsIcon, + PipelinesIcon, + PoliciesIcon, + SourcesIcon, + UsersIcon, +} from "@portal/components/icons"; +import type { Tier } from "@portal/contexts/TierContext"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import { allDocs, loadDocsNav } from "@portal/docs/manifest/registry"; +import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search"; + +/** + * The Processor's entity search: users, policies, pipelines and sources, + * fetched per scope and fuzzy-ranked client-side. Shared by both super search + * hosts — the portal bar imports it statically, the editor bar loads it on + * demand through the processorEntitySearch seam (a static import there would + * pull the portal into the main bundle). + */ + +export const PORTAL_ENTITY_SCOPE_IDS = PORTAL_ENTITY_SCOPE_DEFS.map( + (def) => def.id, +); + +export type PortalEntityScopeId = + (typeof PORTAL_ENTITY_SCOPE_DEFS)[number]["id"]; + +export type PortalEntityItems = + | Member[] + | CatalogueEntry[] + | PipelineView[] + | SourceView[]; + +export interface ProcessorEntities { + users: Member[]; + policies: CatalogueEntry[]; + pipelines: PipelineView[]; + sources: SourceView[]; +} + +/** + * How many results each entity ranker computes — the ceiling the dropdown's + * "show more" can reveal (the component shows a small initial slice). + */ +export const ENTITY_GROUP_LIMIT = 24; + +/** How long a fetched entity scope stays fresh before a search refetches it. */ +export const ENTITY_REFRESH_MS = 30_000; + +const PORTAL_VIEW_BY_SCOPE_ID = Object.fromEntries( + PORTAL_ENTITY_SCOPE_DEFS.map((def) => [def.id, def.viewId]), +) as Record; + +const VISIBLE_PORTAL_VIEW_IDS = new Set( + PROCESSOR_SEARCH_INDEX.map((entry) => entry.id), +); + +/** Whether the flavor's portal nav ships the view an entity scope targets. */ +export function isVisiblePortalScope(scopeId: PortalEntityScopeId): boolean { + return VISIBLE_PORTAL_VIEW_IDS.has(PORTAL_VIEW_BY_SCOPE_ID[scopeId]); +} + +/** Whether this build ships the in-app developer docs (so they're searchable). */ +export function isDocsSearchable(): boolean { + return VISIBLE_PORTAL_VIEW_IDS.has("docs"); +} + +// The docs manifest is static (bundled JSON), so the full-text index — the +// plaintext strip over every doc — is built once and reused across queries. +let docsSearchIndex: SearchDoc[] | null = null; +function getDocsSearchIndex(): SearchDoc[] { + if (!docsSearchIndex) { + const sectionLabels = new Map(loadDocsNav().map((s) => [s.id, s.label])); + docsSearchIndex = allDocs().map((doc) => ({ + id: doc.id, + title: doc.title, + sectionLabel: sectionLabels.get(doc.section) ?? "", + text: toPlainText(doc.markdown), + })); + } + return docsSearchIndex; +} + +export function rankDocsResults( + trimmed: string, + navigate: (path: string) => void, + limit = ENTITY_GROUP_LIMIT, +): SuperSearchResult[] { + if (!isDocsSearchable()) return []; + return searchDocs(getDocsSearchIndex(), trimmed, limit).map((result) => ({ + key: `portal-doc:${result.id}`, + group: PORTAL_DOCS_SCOPE_ID, + title: result.title, + // The matched-content snippet (full text is what makes docs worth + // searching), falling back to the doc's section when the hit is title-only. + subtitle: + result.snippet + .map((seg) => seg.text) + .join("") + .trim() || result.sectionLabel, + icon: , + score: result.score, + onSelect: () => navigate(`${toPortalPath(VIEW_PATHS.docs)}#${result.id}`), + })); +} + +export function withPortalEntityDependencies( + scopes: readonly PortalEntityScopeId[], +): readonly PortalEntityScopeId[] { + // Pipeline rows must exclude policy-backed records, so they depend on the + // policy catalogue even when the user only scoped into pipelines. + if ( + !scopes.includes("portal-pipelines") || + scopes.includes("portal-policies") + ) { + return scopes; + } + return [...scopes, "portal-policies"]; +} + +/** Every entity scope the flavor ships AND the session can actually query + * (see isPortalEntityScopeAccessible), dependencies included — the request + * set for an unscoped search. */ +export function defaultPortalEntityScopes( + isAdmin: boolean, +): readonly PortalEntityScopeId[] { + return withPortalEntityDependencies( + PORTAL_ENTITY_SCOPE_IDS.filter( + (scopeId) => + isVisiblePortalScope(scopeId) && + isPortalEntityScopeAccessible(scopeId, isAdmin), + ), + ); +} + +/** One entity scope's fetch, for the editor seam (which has no QueryClient — + * the portal bar reads the shared query layer instead). `tier` shapes only + * presentational fields on the users payload, never the lists — hosts without + * a TierContext pass "free". */ +export async function fetchPortalEntityScope( + scopeId: PortalEntityScopeId, + tier: Tier, +): Promise { + switch (scopeId) { + case "portal-users": + return (await usersBackend.fetchUsers(tier)).members; + case "portal-policies": { + const [list, runs] = await Promise.all([ + fetchPoliciesList(), + fetchPolicyRuns(), + ]); + return assemblePolicies(list, runs).catalogue; + } + case "portal-pipelines": + return (await fetchPipelines()).pipelines; + case "portal-sources": + return (await fetchSources()).sources; + } +} + +/** Assembles per-scope cache values into the typed entity sets. The casts are + * sound because fetchPortalEntityScope keys each item type to its scope. */ +export function toProcessorEntities( + values: Partial>, +): ProcessorEntities { + return { + users: (values["portal-users"] as Member[] | undefined) ?? [], + policies: (values["portal-policies"] as CatalogueEntry[] | undefined) ?? [], + pipelines: (values["portal-pipelines"] as PipelineView[] | undefined) ?? [], + sources: (values["portal-sources"] as SourceView[] | undefined) ?? [], + }; +} + +type Translate = (key: string, options?: Record) => string; + +function policyResultTitle(entry: CatalogueEntry, t: Translate) { + const category = t(entry.category.label); + return entry.policy + ? t("portal.policies.defaultName", { category }) + : category; +} + +export function rankPortalPolicyResults( + entries: CatalogueEntry[], + trimmed: string, + t: Translate, + openPolicy: (categoryId: string) => void, + limit = ENTITY_GROUP_LIMIT, +): SuperSearchResult[] { + return rankByFuzzy( + entries.filter((entry) => !entry.category.comingSoon), + trimmed, + [ + (entry) => policyResultTitle(entry, t), + (entry) => t(entry.category.label), + (entry) => t(entry.category.desc), + ], + ) + .slice(0, limit) + .map(({ item, score }) => ({ + key: `portal-policy:${item.category.id}`, + group: "portal-policies", + title: policyResultTitle(item, t), + subtitle: t(item.category.desc), + icon: , + score, + onSelect: () => openPolicy(item.category.id), + })); +} + +export function rankPortalPipelineResults( + entries: PipelineView[], + trimmed: string, + excludedIds: ReadonlySet, + openPipeline: (pipelineId: string) => void, + limit = ENTITY_GROUP_LIMIT, +): SuperSearchResult[] { + return rankByFuzzy( + entries.filter((entry) => !excludedIds.has(entry.id)), + trimmed, + [(entry) => entry.name, (entry) => entry.trigger], + ) + .slice(0, limit) + .map(({ item, score }) => ({ + key: `portal-pipeline:${item.id}`, + group: "portal-pipelines", + title: item.name, + subtitle: item.trigger, + icon: , + score, + onSelect: () => openPipeline(item.id), + })); +} + +export interface BuildEntityGroupsOptions { + /** Host scope filter; defaults to every scope enabled. */ + scopeEnabled?: (scopeId: string) => boolean; +} + +/** + * Ranks the entity sets into display groups. Selects navigate to the entity's + * portal route (deep links where the views support them) — the portal is a + * route-set of the same SPA, so this works from either app. + */ +export function buildProcessorEntityGroups( + entities: ProcessorEntities, + trimmed: string, + t: Translate, + navigate: (path: string) => void, + options: BuildEntityGroupsOptions = {}, +): SuperSearchGroup[] { + if (!trimmed) return []; + const scopeEnabled = options.scopeEnabled ?? (() => true); + const groups: SuperSearchGroup[] = []; + + const includeScope = (scopeId: PortalEntityScopeId) => + isVisiblePortalScope(scopeId) && scopeEnabled(scopeId); + + const users = includeScope("portal-users") + ? rankByFuzzy(entities.users, trimmed, [ + (member) => member.name, + (member) => member.email, + ]) + .slice(0, ENTITY_GROUP_LIMIT) + .map(({ item, score }) => ({ + key: `portal-user:${item.id}`, + group: "portal-users", + title: item.name, + subtitle: item.email, + icon: , + score, + onSelect: () => + navigate( + `${toPortalPath(VIEW_PATHS.users)}?member=${encodeURIComponent(item.id)}`, + ), + })) + : []; + if (users.length > 0) { + groups.push({ + id: "portal-users", + label: t("portal.nav.users"), + results: users, + }); + } + + const policies = includeScope("portal-policies") + ? rankPortalPolicyResults( + entities.policies, + trimmed, + t, + (categoryId) => + navigate( + `${toPortalPath(VIEW_PATHS.policies)}?category=${encodeURIComponent(categoryId)}`, + ), + ENTITY_GROUP_LIMIT, + ) + : []; + if (policies.length > 0) { + groups.push({ + id: "portal-policies", + label: t("portal.nav.policies"), + results: policies, + }); + } + + // Policy-backed pipelines already surface as policies; listing them twice + // under different names would read as duplicates. + const policyPipelineIds = new Set( + entities.policies.flatMap((entry) => + entry.policy?.state.backendId ? [entry.policy.state.backendId] : [], + ), + ); + const pipelines = includeScope("portal-pipelines") + ? rankPortalPipelineResults( + entities.pipelines, + trimmed, + policyPipelineIds, + (pipelineId) => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipelineId}`), + ENTITY_GROUP_LIMIT, + ) + : []; + if (pipelines.length > 0) { + groups.push({ + id: "portal-pipelines", + label: t("portal.nav.pipelines"), + results: pipelines, + }); + } + + const sources = includeScope("portal-sources") + ? rankByFuzzy(entities.sources, trimmed, [ + (source) => source.name, + (source) => source.type, + ]) + .slice(0, ENTITY_GROUP_LIMIT) + .map(({ item, score }) => ({ + key: `portal-source:${item.id}`, + group: "portal-sources", + title: item.name, + subtitle: item.type, + icon: , + score, + onSelect: () => + navigate(`${toPortalPath(VIEW_PATHS.sources)}/${item.id}`), + })) + : []; + if (sources.length > 0) { + groups.push({ + id: "portal-sources", + label: t("portal.nav.sources"), + results: sources, + }); + } + + const docs = + isDocsSearchable() && scopeEnabled(PORTAL_DOCS_SCOPE_ID) + ? rankDocsResults(trimmed, navigate, ENTITY_GROUP_LIMIT) + : []; + if (docs.length > 0) { + groups.push({ + id: PORTAL_DOCS_SCOPE_ID, + label: t("superSearch.group.docs"), + results: docs, + }); + } + + return groups; +} diff --git a/frontend/editor/src/portal/views/DeveloperDocs.css b/frontend/editor/src/portal/views/DeveloperDocs.css index 314cf17eef..6487a9afff 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.css +++ b/frontend/editor/src/portal/views/DeveloperDocs.css @@ -45,137 +45,6 @@ gap: 0.25rem; } -/* Search */ -.portal-docs__search { - margin-bottom: 0.75rem; - padding: 0 0.75rem; -} - -.portal-docs__search-box { - position: relative; -} - -.portal-docs__search-icon { - position: absolute; - left: 0.625rem; - top: 50%; - transform: translateY(-50%); - font-size: 0.9375rem; - color: var(--c-text-subtle); - pointer-events: none; -} - -.portal-docs__search-input { - width: 100%; - padding: 0.4rem 0.6rem 0.4rem 1.9rem; - font-size: 0.8125rem; - color: var(--c-text); - background: var(--color-bg-subtle); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-md); - outline: none; -} - -.portal-docs__search-input:focus { - border-color: var(--c-primary); -} - -.portal-docs__nav-empty { - font-size: 0.8125rem; - color: var(--c-text-subtle); - padding: 0.5rem 0.75rem; - margin: 0; -} - -/* ── Search results ────────────────────────────────────────────────────── */ - -.portal-docs__results { - margin-top: 0.5rem; -} - -.portal-docs__results-count { - font-size: 0.6875rem; - font-weight: 500; - color: var(--c-text-subtle); - padding: 0 0.75rem 0.5rem; -} - -.portal-docs__results-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; -} - -/* Hairline divider between results for clear, calm separation. */ -.portal-docs__results-list li + li { - border-top: 1px solid var(--c-border-subtle); -} - -.portal-docs__result { - height: auto; - padding: 0.5rem 0.75rem; - border-radius: 0; -} - -.portal-docs__result:hover, -.portal-docs__result.is-active { - background: var(--c-hover); -} - -.portal-docs__result-body { - display: flex; - flex-direction: column; - gap: 0.125rem; - width: 100%; - min-width: 0; - text-align: left; - white-space: normal; -} - -/* Title + section share one line; the title truncates before the section. */ -.portal-docs__result-head { - display: flex; - align-items: baseline; - gap: 0.4rem; - min-width: 0; -} - -.portal-docs__result-title { - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); - line-height: 1.3; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.portal-docs__result-section { - flex-shrink: 0; - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -.portal-docs__result-snippet { - font-size: 0.75rem; - line-height: 1.4; - color: var(--c-text-subtle); - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; -} - -/* Subtle match emphasis — coloured text, not a filled block. */ -.portal-docs__hl { - color: var(--c-accent-text); - font-weight: 600; - background: none; -} - .portal-docs__nav-group { display: flex; flex-direction: column; diff --git a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx index d2071011d8..354c6359c6 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx +++ b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx @@ -25,7 +25,6 @@ const renderDocs = (ui: ReactElement) => describe("DeveloperDocs — markdown browser over the generated manifest", () => { it("keeps Overview static (open, no toggle) and other sections collapsed", () => { renderDocs(); - expect(screen.getByRole("searchbox")).toBeInTheDocument(); // Overview is static: its items show, and it has no toggle button. expect( screen.getByRole("button", { name: "Production Deployment Guide" }), @@ -50,23 +49,6 @@ describe("DeveloperDocs — markdown browser over the generated manifest", () => ).toBeInTheDocument(); }); - it("searches doc content (not just titles), shows a snippet, and navigates", async () => { - renderDocs(); - // "Tesseract" appears in the OCR doc body but in no doc title — a result - // whose snippet contains it proves full-text (content) search. - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "Tesseract" }, - }); - const hits = await screen.findAllByRole("button", { name: /Tesseract/i }); - expect(hits.length).toBeGreaterThan(0); - fireEvent.click(hits[0]); - await waitFor(() => - expect( - screen.queryByText(/locally hosted web application/i), - ).not.toBeInTheDocument(), - ); - }); - it("follows an internal doc: link inside the rendered markdown", async () => { renderDocs(); // The Getting Started body links to the Migration guide via the doc: scheme. diff --git a/frontend/editor/src/portal/views/DeveloperDocs.tsx b/frontend/editor/src/portal/views/DeveloperDocs.tsx index fb62b8566c..d32666e1dd 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.tsx +++ b/frontend/editor/src/portal/views/DeveloperDocs.tsx @@ -3,25 +3,22 @@ import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { Button, EmptyState } from "@app/ui"; import { DocsNav } from "@portal/components/docs/DocsNav"; -import { DocsSearch } from "@portal/components/docs/DocsSearch"; import { DocsSection } from "@portal/components/docs/DocsSection"; import { DocsToc } from "@portal/components/docs/DocsToc"; import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc"; import { extractHeadings } from "@portal/docs/headings"; import { - allDocs, firstDocId, loadDoc, loadDocsNav, } from "@portal/docs/manifest/registry"; -import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search"; import "@portal/views/DeveloperDocs.css"; /** * Developer Docs — a markdown browser over the docs manifest generated from the * Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted - * from the repo's folders + frontmatter; content is the repo markdown, and the - * search box does full-text search across every doc. + * from the repo's folders + frontmatter; content is the repo markdown. Full-text + * search across docs lives in the global super search (Cmd/Ctrl+K). */ export function DeveloperDocs() { const { t } = useTranslation(); @@ -29,24 +26,10 @@ export function DeveloperDocs() { const navigate = useNavigate(); const contentRef = useRef(null); const [navOpen, setNavOpen] = useState(false); - const [query, setQuery] = useState(""); const nav = useMemo(() => loadDocsNav(), []); const fallback = useMemo(() => firstDocId(), []); - // Full-text index over every doc's plaintext body (built once). - const index = useMemo(() => { - const labels = new Map(nav.map((s) => [s.id, s.label])); - return allDocs().map((d) => ({ - id: d.id, - title: d.title, - sectionLabel: labels.get(d.section) ?? "", - text: toPlainText(d.markdown), - })); - }, [nav]); - const results = useMemo(() => searchDocs(index, query), [index, query]); - const searching = query.trim().length > 0; - // Deep-link support: the active doc id lives in the URL hash. const hashId = decodeURIComponent(hash.replace(/^#/, "")); const activeId = hashId && loadDoc(hashId) ? hashId : fallback; @@ -61,12 +44,11 @@ export function DeveloperDocs() { [doc], ); - // Navigating closes the mobile drawer, clears the search, and resets the pane. + // Navigating closes the mobile drawer and resets the pane. const onSelect = useCallback( (id: string) => { navigate({ hash: id }); setNavOpen(false); - setQuery(""); }, [navigate], ); @@ -100,19 +82,11 @@ export function DeveloperDocs() { {t("portal.docs.browse")} - {/* Layout columns, not landmarks: the search and the two
    } is no longer a row to a screen reader. That row control is then the - * keyboard path to the same action, so nothing is lost by leaving the row itself inert. - */ - rowsContainControls?: boolean; - /** Rendered in place of the body when there are no rows. */ - empty?: ReactNode; - className?: string; -} - -/** - * Minimal data table primitive. Columns own their own cell renderers, so the - * table stays presentational — callers pre-sort/filter and pass the rows they - * want shown. Rows become focusable buttons-in-disguise when `onRowClick` is - * set. - */ -export function Table({ - columns, - rows, - rowKey, - onRowClick, - isRowInteractive, - rowsContainControls = false, - empty, - className, -}: TableProps) { - const interactive = Boolean(onRowClick); - return ( -
    -
    @1xwyF0kT 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

    Db{N7mM`WF3*S{atXQ$q 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 literal 635305 zcmV)TK(W7xP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Rl2O1411=CI;07*%9M2MzEDio-RgG8B%?IcR_FO{THm8uZ56RBL4 zcql7z#ZD@5B~^AR6~&3Ih_XZyoNQ4FEy)tCfwRP^M3Eps0(bbn@0@pceyo0;etPYF z&IMUI1iyRE`|iE=TD`h^Jx_P9wf%qbWiOw%u>1)(b8~Yub2EPKW@eFpM*cm&sb81N z*U#sN5%YeZkI-8Jjr*;wc|_o=aL2|`VUZ1J-z?0+%|ix~{l{Msk-gF<)_;7x zvHxUms)MT-+U+8MHr(|QVHOr1wzTSoVa^HTV*{6Y{yw|q{A%r0pHEwaUl)br-#HJC z*~pK_>_$Y&d3MIBR3+Y&y4R!KMoH0SoMGm3Z00q0F(tPMhLYi9A|f{P>IS~@h?L6) z-HQmQy-Y-EM>b~8C%I!F=038K@Zlj7WcXuXR$@vhhdz5#QkgP~h}bsw)bFsoVa}+# z+wkz&h12j6Zf@grI*l>h-A}{Eu+uQlP7XH@x65U_T(<49UAEY^ZQGzRyU7-XfX+z& zL}PNY0`?Q#o=OlU5ij{|NjS~@$k`VFzn(rmF-ZDnt#kh%2IY*VZ2 zmM(4Ae*z;#=OZYG(x+8QMwb;Z?PbNCO~^L&>Rf_r@T*eQFL1fCJiXcYu+-8`xAj*WmY(+U?$=T6Z|n@G`;8Ts zLMUAK<+7G$+{+u5+cNmC{5ZuL!-5AOtGSbtU_=pE==cLlc(?4R*Z z4SB0hhCFoXupfPRw?CYJEW(t9#tt5KAlq9+belU}s0o169Z!4nQQUds9V7T`r{J@D z(3`6|^eWe!wh#$!ljraQAZskv5&m7Te9&{<4+|e&(y1;)8LLm;x=V=6*u-J_A z6c)B=f2jtX5eLiXT2&f~K~J3sxR1y?T}-c9t>IU=< zclq!ER&8$XW75lU8a_^DKE|-&ZswcF-|#73x}trjPr z0?aRHPpPs&(!L3nDNH{*2zA8WfcdGbTlFV= ze$|F-_4cy+ z?Y8ylypmzM1xIt8xLshjYj%O4+GGz0AB~T0Vijmj&hqp{ycNmZ35ZWp`dXj6w1$UO zL__?ZslrcZ!1Yllf?*Mzd8WdG6nQA+V&m5Klrr$cWdh^XvF*Wk(dzw zSx@xkiGJ(jmi``fnc~qUdpGF>R1i&TT3lZbpJ2EOff)=qdeGA2gT)qWUa9ept-4t^ zVCXTZUA=WO!dw3hHl~>G#Gj>yeuFS~4 z+FZ=fVlRrsKQ5z)2>-5^KbUB`P!~L^5~D6lz{lJp7Ewf=D% z6}T=4o5`b!`c7Q$AZ&u(4Ios(d-DWgQxLc}`R7Lc&@N6~%?FBtlN!Qlc-lfSBTPCj zm|T`$xri@hyc6*rxqC3X`Fkfk1qzrjEE}=RJCeXE(8S_62{*~Z&?T@4G}rljio3fH z8$R64J=}($Cf0U8jq~|D+=iPc@7q9;wg}s{Z8E%w*pdZA1nB){VC*Rn*m?;KeIvsX zWhy@JtCy+(0kS}9@1n0(>}2)RqQo$<5-n^S*E9k@FJbzLjuCn@2|;9NWMykuC%eOvy$2)FrE_S)`(`lSetI&+I zE1kLHN5eHWvK8voDj~rFGn)HC2)laq?$vT|+@GdQxBXUNiwo=8(fUxl&`Jnb9Hv^c zQ!2%jD`z-li3bo?6@LY{3tZV{_7;k^?{?Vk_S=5;qTn~Xb+Fym=4izl&P8ky?}Eze z(<5p-zi4bWj)p0cw~i@e*BUgn!nqPG%3-VEj_K$siGyymYZjO*AJ!t(tcjhK`x;mg zpenO0zC;o85yx2kYWq1>s=L&v3)%M{?k;3k%TIQh-u;|KzZDW5cC;;=-6!o=3Txi! z0K@JZzfBl*O}A-E*lR+XM+~l^cRWvhNC~}WxkUuz%VY1lZ@havw^cv;_!O!Ox;x;y z(gkG&fT~>GcR*Ymi};SZoz2VeWzlX*z|~f7a@TTn)P@rmhZEz@*b|{#`GB zaDFr`eH1+Lg4D#u%=MW-kSem8psZktjz40XEo81>0@rOO#}dvZ$_C!6)CFSr$oH7r z2shhUW3|n~z4=|4PDr`d7-kV0@^ABr5ix2&MXKqPH^S+v{9w7KmdJ4?XcUCDOcRA6 zvNt76K}DuKvg6E%)ruW%Ljco+-%}!H0aKu`Lz1PO4CA*U9BT=>0dr;#!H)y-Pc_(P zf;G%6HUap7Y4ZG4==PYZXxr@E9N`9Z?V5ixOKNWB9_~If8N-qu%z6`Kcrf$B-Q2b; z6P>e9rJCA2%B8@~46FR?6bphvoHsnqwiLYCOh@~m*BPhtIG@ai4|=!aX$%`l^(M29u$jl(BDTvVb=J(yHk%vS9RtzaM*TCnU9EEnUrJfCx*Hp`}W?^J9(3!e) zl&^XjjML&A+_0QgOYdSt>UTTSG{C3InJOER+*)+#N0+KBj7}TUG3F}8S91&!3X$1h zfCHYo)My%G=1|?H!TGF;umAx}5e{pOcED%hnD}99k#8ojUIXi#+Oa4y2bA zNK4Yw3P19*SbnoOctjY15@Q1u_-gY=B-ZU7EP|3V>vQ`^Z zsznU|-NhMhApAlYCi%*oc8-W6~jdyW}%3R$~;FjIpG#qbvCnt$Fso^ zq#R?+1k)J9hll$XHqNKh`FuK`eQw(`ALB%OE{?Uu7MJbjCbn>(l`Wzs3~3sUSxYZS zPHVDU!YxGrYU7)iwHk~y9BEV!>OVju-5zq9U03M=d^eNRA;<+P?Q+AZCtH*W@?lhh zDKAml?ow#B;{M6@2@2JR4A2ZFkST)lW2-}>AJFZ3Wn#|ZK`<7)K+ z0_tuS9%DXj+cr!3&w;GIk<#PDiJ7;m+H$i~N6m+tvz3vK!728z@WAfiX(8kE?2R|< zIzIOUU;3eM{bOJJP2Y6?x#w@5T}N!E^V!@o-oSPCV2@CP59QFPk+l;j2fpTfkm1)4 zYl;fSx&G0X&bvWl^WsYlNFx<39M|sdAisL${nLdVxdNEG3a$=mVXZ_0A{#ndpWfQM zA=`GraBMLwgYyyni&u)mrOj*||V} zs``Z6jCWxe`zsa=T;-;!alomTo#9fS<*V3R;mhvnxAnnnr9=C^EyT7*9$WJXbXp*y zn`G#ftz&&ho@iV}&sX!-9*G(C7y_P(A;wPseXo%_q3@7}2rAJY18O;Vttn;dMMFrl zWOPFEIe2U!$;;aQFxMQB1KpaBQP%)Tl>geIk^lrFlc~9x4UgdAASo=}5;Re2>RV78 zMLa7R6qalQB0%)d&+aD+w(Wd%|1?f>2xGVpcZ7*zjM!q^BDS0D<`NrErLoEX%)&Rb-0q63qgiaZ zWwqJ28|U7NXf$)k#Afgy1eju`NsHRdc^`oFb9dT+`hW_haXN$gGg0Q)l}Bd86kDo* zxqL6|zBCPlPXaHkiqw;nZ`2B!gO9=r9yYZhwhazpq%pe&lwrFSZ77AJ!tB|iBhzax zaH{B)6qn{c(8{CBV#^F%X1F7AU#uh?9V#k}N>~4UYS;*bos&=R|Wl>1e!(Ki9a2cn^tZ!;t-sXrc6UWV_)Lwj zi-oW{T{6DLCs*f-;1KqG)g}d*_S7H^xnH;p@C~ZiznV38XwEW9L@TGW|%F1B6|<~ zP@fm(EX%<1)iIA=zvSf97l%A{nQu+iwnp}6LE)++!p|lPh;$k8v(PJh-xddQNC@}# zpbt72Z%qepKtG^!ASC>T7u91lS6mjQs<(PN_vKNoWTs8pV&*02XkFDJDHBl5Jvemf z6Gr-Xz4Ad=DBGyJ+j1lM$Ayk%m*gNxuLD67O%3<-e~1Hv2W5pw2z=H%*4(I`plJV4y!bgc5nPFQHkE<-L2F#9C3WNTq zsH}xW9>!AHJo4*={4NQP)aXpdCF`!ni87ghtsMbLhd~L0EKX=QlO5ED<`Zge&AbF= zVc0&%ewo=!$?-%SO$OLh6`yCMxH!-`?Y2$9B{Q7rnAyCrFdO4E&SxKfI-kZkn_I*- z+)r2cBBtVv&0?OqIS=3*!!5SBY-VO#%-YSs2{7z17&Wzpyq70^;ktGJ1%Ytd7eYCD%z1@|Sz~@$g(nF2KGWo6cx-)crmUs%ihGBFO%i1Oz|C>bqdIu`Pq3Y_zpmJDmRDvt(L zDc;$LvQ`8vNmh6lBFb`pSW9xQeI9{R(V;4MNPk`?C~P&#NX*I&m0kgI7E!k5r=U5z ziWB;4OG0LD!|cgxuU{^gFZhbD{_5}elfU!pzW)5+!OhLJUEhqWdww1k)hWMYBC6_`*SKY9X6AQ1LQtl!PZvj2DB!MbhP~smuVIDCj8N?v zb3wP|WFx7Qh5oIY(-@?8DYa27I(EnG-6p!jNxwZna93y4w5*xw8iu%C-Yb+P8?OiT zAay-=RVUj&bG2nbjGVdMs4MowfG2VHuCimHZH+zNJxNLhclw}QxlQOoqSCsSxn3UB zUC(i6?lg_PyI9Do-P&f|w2-KM`^Q~siR5GD#Kqo~91HZWt4A@gHtA@?{Wj`*H2i*H z<6;tw;8cKSv{>KK%GSbe%av88w`=6UsmdxqOA6_Qz)~~N2{U3B1Gco45K0)D6di?O zTmuUDBjnEvokpt&EQl!B`-J=gGNUYDI1pp~OX3LelN70PvQbHCKescgc^ohr|tY9R|1gI|tP!_R7DAWYEW>h|gEbMYZgpx;} z*{cG+E%BQHc(ki^wxLaz3qg*x1{%xKQP3ZH17-oh!`TT<}ZVn7v$Mvj>U1*t~m@mQn;2ik)#2v5OJ zHZB0+dySG$5GX_AQw~$!Mj6doRJ%Hzc1&-TUWzl(dd^~MOhV1Q*66pJ&s4Os%qV0l zs$?RYnF+Qi?^)f2_zAfCNCXXQl4Ev$oKCTA*RQ>9ZtwZBFaPkL_?O=Qbze6gKD>GQ zENmO6v!8}jdJTezoTzPUSXD$RL_^8~IyB*;D(kW}hg9k(Q@H=ac#j``ReH1MH0vtf zO5kdNoO81W9k<}Wg=5H~unzCdXyL6;@fHx-B`oDASe_PYFXt-PvK4aoakuOK>P(qi zTjo~+wzfjskot@D;<8DjxvvJiPVE5)J0`k|cHhpyq)7eEChM5OEz=?_{gwt5UL3X7 zwkav>vAcAt^%(QNQx1!!7JICI^pxep1L)tK*Di3eA7&fSE!-Fw51;Pm0_*l7-W|cP z()0G|Zp!vwNt62Uy}MU(LbmNqbR*UbCq4Eq1aGrmPn>rk$(rQUrfX%POB{R7D6wC-+v(@sEAz z<%*(OM@K7)XC%6jzQV6WF1^;cR%c)owO3+3BMa1d*A5i6l~yGbgvF_6sNuk~-b}4Z zV0Og~m>`g(fVY-R^$Qs^K;0?~;Q)-;o<(*7g9}HKPqk1HR54IwX4O1a-i6UxCVOaJ zvrKT44FObTWIZtn)UzRn)^6onizpOTbgD6hL`}u5b?@xe##*ra!o$plkJD+Ku1=@n zK4RE#_i-NQ;p2pZ`Yp_2+a_`zZrdd+F1E$C#kPgTrF=}-nBwpVycN)G#E957Nl01+ zH3IKp>#%>DRL8iCODF(E#5oM6C}biLB}qXCAo3~#{*;5CeITifd1 z&4tFSh&{+y6*AB~W&d4OD}js_Lq`g{Z(z~tt2MV3DhbXxqAE^iS&2rACiU`xCOa!T z^qdvEtnLN4E5eMh#qLgaM;2ggBgDO4E$Ui1e2kNAmnW~jIyQgrSAEr&ee1XW&Oh*t zr+fFVpFVZ7aX$MP;uS@Y8@6u8WQVl~0wi41QIQVUnY6=II%-^sIV4GMx52my7qJs1 zkR6;G;6`WES*KTP*vje_k z45}Z_Wsgn8`jGW0w=lF8{g65hF4zW;Qqt>`?ns6mAnFtdp5e;3*JNP=ck;>5bZuna%TxZNNL3_eZDhTwyNd6WBnG^ z8w#cKL(iaMx30#AeLEfi*G{v&CvVoeZ~vCz>$p3s@7=_7ZLtdn(pN4(b}IFB-RBKxaSTutIP3W+p&Wa|y!6DCAIO za(LX1PGuDsHm?fL<@A8=9HU4^S()}9x6LWv#5koqu=IUqC5P3_+vZ`LyKf;5+kFh* zCZU{(%Q4L3bUJ&!PW^PccOGL5ALBHUqHS^6BHT|~L|krgVx61Cwp~V;jS(JkiHM7u zqoXKrUA1e-76N2(c4}!Q+j2VOk<%n_uza&@f^-tAsZqmN@t0?xq^e@?pF_Pvh9K5T z+#hRT(8-=S#}(#wB`3N~1Weai5t?MT)%|BCk(H7)lWoliG0Ykc*eG|YX<$Eapp8^t zqqkz?kRuUAkh*37!TR8Y^QNhjz`_i~`b|=+N1#egT8Ilo4oJdr;dFwA|L7!xm!=PN zMrcBuEB0(_&Ykqwx-nX9PWGMiYY~L5NEivaG<=jAG&4uOCts21GIH5#oU-&Ry0%g@ zHyh&=7EeF(>KLal{OYgz>hJv1@BKZ$*Y7{Le)7cK$N8KsDsrOG4<|!35TWFdk9A$m zzOxlFXQ0ux(C(nsA-2-VH@#gPL0h(G7Zsb@p2lExZ@~y%$SH_hV&Q={V_jv|E8pZt z(TsGAqg#h8vGbjGxGU^k*J`(y0WO{2O%ry_^{4C})RD%)1=AXJ#;j{q5|M?%wkwqi8sOo%tTsS^8PmG$@=Ob~{#Kn#6GA z20~Wnm93N9TPb@FeQ9~!6S#YX`kWo-ob5Dhycfmd77i2RVhe35d&idfTE5*2XYJN! zPw@27cf@FJYi%cl^^(sbq_raYC1JR<(@KH*HMv-%$YF~=Q_9BlebwU3khFj7m6t7v76=y>INk2*nT4272^_4#I2)fy zkd#-FnbpQzcZ-wKT zRyYi-T0SL*xS5`r0_rFXh%(75N_{F3x6c+iW$(2eGzyu96X|vROf7KINw0Jz zML&;7u}nqgwy-U5ry&8BdlPL^h%rv?zIlvs&bm!^Kc7zHv_-^lv*GiV8CAoYN7O`w z#g?dei!HX9HJlqJ6?}%p7{g-=@!@l$V6Oi!5b-=KUvKZC&DfR0r; zC8w>0j&9D^ybm7~Rz)J9i8Z@PH&c%_sSj0^YFwgx4Pf)^ms&f`w_YEGkYA}G(-sWB zP{Alb9{p4Me(T)f^qY9^g)whEV;aKX=8HAFY{=;Ya+q)+p8GkC2d}^j6(nF-4Odma z))kPJTuJbpUpWE8IRHZlY^g5@zP7}u9J#>{fvyey*I|r!6if-e2Pf39x?dul=Hk;f znT*1zFainq<2&&t1XC9uHb50d0^`P&9J3>YH@c2qxT!tj!_G9STSz_Bfb4!re6;dqZZP(A0FSYp5 zot#a3wR^-WA0-b9L|$$@x^fY3;|4nzS48e`fFk+*BbFJ_nUfuJcad;?P@}0c8ZtBU zJK-zJ*ILBt)n4GcpF8NpItvnKT-uLYZg=Un9br}-i|>W6Jv=0?hl8o5@OCKtrZl*u ztrJVATl{n#`|_aWt9E(Q^P0F_q<@w35pb7PR@C^=Ob%5VEn@->GJdPQ%%&<{xwE@Y zt&e4*CJ7v|4EeBtxisgFk=`;FhlZnyu@LX$HnlTLKxBF7exwE@d??sm0NCjHLtvRgR*-LZlnP?6vmfR={E`jSsdrJ; zUZ7B^{aVN=aB=R~OaAP%B#78O6L&M4=jJ}f={!!u#>p)v8Jn$d5p(lmzEdG0EH1Ha z5q1-1hBH}^?}Jb_fl84tgqU3zVKJg)08W{JD79X9pe4;kiqt`C%1u~oMS_L#aXAqK z$&6Oog2K?53yp{|l8!~;R5y!xWa`HHKZLnx>|Tmq5Mq9eEKp_}nISGxPsiG_{YEXy zg;6RFje!rgg9DOSBHO$}FaVH(^5JueTU$8!B<9Nl=k&p>rdAH@RrVrFt0(f1r!#{} zX*yg812)L7Q57oW0S%FbC~wAQI!Q~C-YO-om8AiAIwg86_5np-TIZSk4}8sv55jqj zJpR=Oq5(*cR@{Msq#Ra#4#o8HHg3gy>%S3|w6DA(=YhbL^beeqmPjW#&yq&lV~mJ- zq%DFjFo%pN-CTUwz2_gtW&7zL`Qd-`H-GGR{`3FbSAWN!{=BdFazCA)zVXHw<8(gT zs1;^Cg=8*kJoS2EgR)4~_0G?usIwv3XBeE^y~hsaG1k7^f^O@gXbn2`qAsu_L=v~O zwj$Rhjc|C2YVB=T-SLRqevbInZ|&zzAJoZeej-@#xBu(~%Qiu@EyTw&e2bJ~rz%Qv zKFf|3N8QUqMe$n^?;W=s_H945yIXkkVR6k`q*&5Eu(CT$>`jG$r4h1#DoLB-3f^pV z#}|B-mU3HzJ+rhXua5+HXS=`k&Me^@4JG`Rer~g-f3`oj3~(=*H#L*^tUp{IDG~qQ zTF>Y8JHK(;Lzh7jq#Z79w~Yl#-V}UYQgeNbT~}Zo-~FMNQ=q|xGmG7Lq=&`mNEO5m zNh4rVAkY9+c?@PY%-qKL>U26Am_vbb==EjWA|h;yZIV3Owr$%s&mh4j zdK2C9gZA7=WGN*tVH@7&*k@JcOlA@tFPGAUQy1V}?l2=t1q`n6ObY zJDHtfp2|542|dpK;qV^jw@lX?Pb|)bU|?5PMj(cPkCjZuR<$!x`esn!PTa7*=CHiz ziQ!P3>V?-V;;!i=TK=2~!Rt_WFccO+4{B& zLyH=uOABL<(w^CV9SKWf6T8lr7^Ud9;wy*gwVoiNe3~5Utl=Z1j!Be4BVQgA5OoV^ z$s?%qOP5qVmM2=Dsnr(h+Qu|ig2(f&tSR2|$lECEK8hiDQB|_w2Tzr1F}Xg2P0m^Z zYCBCn8fL+rWg;*;zH31pb&TlD4GRjB&nOMX|Q9r7 zNV_Vxe5AnI)L}`mcLhG2(7UX!=LxO1x%jc>c|=@?Yk`SPrCZ?2iPc%>H*f6c4QTIu z=iv{2Gxy2MAcx=?WE~Eltqq zcAj9bl;jhg=3CJdxDonG_LGXUZ5uQ<5$ZUA}1$r3Ja)BqsXayTS!v_9r?nGI@z;e9z4a6Xr6m+96`QW+N!uj}U9Opo9N0pemR zf8sJvm^-QeHDYZO{oVf{0KvtrZbLS`PP!qmrrHY!D|5iimX14sN^E6itI#C7RuMH5$DD($OTC||kw6PofgVQc#VT^>;;o`gm9 znn9^K@L2Gom%C<~R9+*}a-E_iFe;66l`bjr+(h>7SIWL+6A3RwZpp{4O!5#fb`U>@$K9o9 zU?no^vks&FI}9r8L+kb`mL8a(eD+gcPhX7M7#Xc=n(yenRcTA0dxuuL==QWtN1aQf zLcm>@4%%4`let|Rk69w-1RDjN(qy7<&Cw${@$r$Lb&w*_6p>6a+hnCG!(Cr1I;VCx;xTEpXlHH%F8$l1CAZI zMIzv}$u3bEvw=Z`lU;CQ;>_J+TV8LB$oIf5G)p=fTjZxXaYYF+lCUl2d+dcnGl^nJ zSCA#?+Jc3kHptW=HtyRNNI`8Bbd`TiQoGqK)?{-bK6grhRhn2PeRa5ARX_S(_Sj|; zBsc1iy!t5{_i;L%{B**s3?Fv7I*-%Y+}wwqzy>3>EoRF= zTkyEf&4}9qP&7wn!-4iDwn{#vXZT~Lis2anoE&@-vAK;*m_$`{8CVp^i^&ts%rzAk z9>X1_vF4?ETsgAcevIVIXxWxS4aeTEL1Z!~nXXDscCoOqG0eg@;%UUE3vZ?_*TEuM zAQM=>$hIIZIipscj@O9Rzy{By%SJN}F8F>U7q0=*CLH=_ zsu68qm}Xoee2kngNMJr#XXa|H}XL%S_Q;xtd6T$opPN^V^wvY3{cAe91~X& z>@M))n-fs^H!TXi9fM_G|2YTji(ujr*gAxzF?kh zHxe>4o}_4Hr{tqKVq=(~lJL&&YwZE)G`!Ceajnifu#ik2JI<%elczVYzwziTFMi24 z{J~eg79jpGJCuSAN+S7?;EK6rr zk?wMmg}U^Z*4^wtiJ9;2(3TxLd|dxljvQ^8#3|MRDhgcm!NeCIl2xTZzKng(rA3C$ zTRN1yl`v~lU2Y}eqg>)~s#f$}0t2~LbMmz5+s3}1cvYvh8~wvwRD#%}5kka&@a(8w zE;3(*Q*qliBS*b9-|L2STc71eGvbGG?vD`Bb$k_ZFl#!q;xzXCUyX5}DwMHkH`-VG zLsoAmX5GN9|{E)T2w6YuJ+((dgs#e$7$93wUU+kV_f) zvP>*uQBR9i4nDzE96Z;&li(`q^6!4-Wg-n1F*ZIUZr6Ze3tnkdyvI`jV>}9kkOeZ0 z<48L(`>}u1R00c!@*%BO08XQ{iSXbAbpKRFloBtu7{F|6n1Id}0IS(7)@)#Sb#M)c z;)K)NRM%M|idQ1eO_L`sremCjolfU*^3zDZ;A4ORhRw~CljL0@W?iXowr!V7a`IWg z8Cz^I_pfcSjrkx)-I)=L!d-BKPWDaeu2^gfDjP<9GAfDH-@kCBRWGw7>TfHH|v_fL}ZIFn8n;8RhX8rzaB~;j3Y^)SQQ6w zcwmBYJ{AT+R@c1`ExDtSbqSv(pxkvy>pBu9{?N~8o7syUUx^|HW_ z7NQNL0MLH4#;+FAbrkYf%(R>iRo5!gl0$};3v|?t4km^eOyaAHkg@4B%s!)sFvq=*@e>J#dfkUY1%aw!MZhcXQ%`m^$Uz2m6c zE2TLU{yxxi+qBB>2O*U=C2wYH=Iu6WcZ(}H_}M@7Zn}6|KaXy|TPAl6ZArE5f6uHH z_uhAxetWoKACp>CVzImGOc8=Lnsb}D_Wa*=q+PwSj!|0^hE0;>Dilp}P(}rpBm848 ze~_>Xt7z!U5EKZdZz|O5wryo;WDc?6NCm)oLwpnaM9k(9kx~sGF^aKxz5y?DOcT(6 z#mPyJ48Z5bp>)phMQ+l8WD@pl@;(5U^GGnKqo@%le3QSiNzBY*zM#&`e2kg@a1XQ7 z>FlSmZ5KB`oz8wbjniqI&!_WnKY?Pox%oK7wr!V7T;`4-zigL?E#vQD7G{^rW}C%8 zT6VLom=flf`xSi_S`M>$dW_rrgr)W5WPaeFGyJ-|AVD{^8NY`S3Mwe=o>+zu35 zG5^5kRI(Rxn_~GHKQK@NI(cp;lx?!XmdV7Vz^sfz)=uQadA4RFTq%S+OQs0q9K(i< zYUF<5c$8re(7w| z#%3GnV=+F5hlO6lmsKIGyh$RdM|K7z)3W$BJ4#U*5AYG!md9pslIXas41y~G^>bmI zA&xV2LVIL3(pz!ig5A^BWQPK(rB%o^@KOOLh#6jy2~3;y;eI~dJiWPj?a6bW_pT3p z^rNr*kw5and%j@1xw(1v?0j`^T%E!sM-3N&PL~o(!8s!Wb>6FhWLf{kB+K2AC2L?` zlRtR$KK-SjVGsSSJ*Zt-A}^cl;OsDjf0*Yft!Tt-VU>HBh&gYBuq+eJ%F-$xXz5IU zu5j?rf)I)t?#E&W9g)uL@Z~X!GsyxiBw0Y7_ZxlJ>@uP^C4}{eU7oN>K(jhsO1^7w zUfTR52OUb_6-A%xNgRpw=D3SHS@vhK|Zp+@Nr1}rX=*R<4AA+&boXEEK!Kl><~ zi1aY3buhFeLU_#amLh_6J?Izju51$|g}6;2b%9_UM+xNaiTmdszJg`lcq!vu_p^VXE$IckJZru=neVk*KEBrSAZRT~5 zbPa^Ccuj>NQv4~qgvmz2NpqoLFCd0$n_9101>RyH*uJvBTHHyiw7oPKS1-fsogkIl zpFA>YSsX-8PBG44((Nj&ooD4FAK(tXNOHvyRSLPKQk0h`{lK*({6UKf6%y+pC+$Sh zVm_ic7IHz;NHKJjiq?TvqSu@O$pu$h4?d+fDUx}k-j(43>2Q)OJ!;dP(Q2%nSw7Bq^&|`2tZG)3Vo2A0?S*qG1Sc z(4sXXTG~-EX|9S660P?M(+LkWyDqS)FqWrm+mfu_V#Cafu?-S~JoC*JR-KTuwVY+Vi6Ic3TmN zQjYy1cYvt&gud_vdDOSdFXOGhup9HZ1Q9KpM?}HXiQ09rHk~#e&x!S zbbs0r$6Hy`A>_Ix1K?elW;@;@a8oP9tu(fy?(V14)z$s;=@cU) z*3*-3Svs|$7}74+*Bj<}i!CmjZJYUs*bx2C6DCQkS^37zl$CQTPqOG_$e&XNQ8_=@ zZ`eH4y%JhfJ&h<5Z8Bw({DjbF0#Nj*Q7KR%d9OAzcA%f-HNa8x0MhyRs&))h{m6l@ zTcOOA$*6i5fo?6yA}J_WokD}l2E!jNbTgo4z_rjSIy{$&kOMl!m}|vlq@jREiEOnx zy|QOoilc)X^o;@#VNr%o3i^8D!YHC~uJ#jVBbNn$1WUF8Ta-*uX#w3*0cNd2&$8u7 z9JZRT(uE@ERjrxSzS=6<%+83LZITK3zH(ip&GIn34FdFZfHV{(WC|QgzUY0geDs^Y^rL^|xp#f;W!vKVnUB*r zpT+aBp{AuBYD?!AAcRnf)hJ|-g511j-t>wcA+&yHUD*-FaYbUc|HJ&ry7NccY>!)r ziZrTiE>6Jo5qA^9!y6BuxD)6O%pN?#lm_5KO^(!ukbtgMi`W+$aX=E}i3cF@PC{6$ z^=?AA;7--jJqncAr>XMbar?RHOXI;TuVcTR5H12xUGiHLKpDw@V1`NE@oTmGF-Up_SLX-EJBIs1MhD1ivLhNVnGdR%m6p zD-94&Qh`;NbLsW&xd()Bm4B{M2XuPh?C$3IC){;otSj2PZQeGZ4ISkC0Swkwp+Sind2YZM!^~{V zSJoBmi~w8ZVU?L_uwtQLl@;%3Nw)c>jOJnMU{m$}G2eYrjF%+uz(WKcY7FC)URTCs zmR%Nqo%71XiH3pXH3~f|RX2vXKrRwN%MqGU@ha|lO`S>-JG0uD8DLzONzEtbc`lpV zjLl5d&&o*X7`#>I=Cf`1JQhqc!`PkI92VZQ01eEMY*TExJ8t%jX3UUN7P;0W2+LxG z03+2p2zHfHGrBKu(6&lzY7h>nIKF}YhZ#?k3Mw?&7aflt%V||2q4ZJsI0saDduOPk zB0zK^jHO`zc601eDLu4+(gIpR>r2rjoDQ241qVCZO!>nvtf~m@4oXlKAtm+Ie*#^z z%m9bY>Irij<22l#z47Go}IJQzcebpB~alEpwm|JIh|J4z+Z7I9Myx%^gy}NR;O3nv|wh*3U z6m>TgZk;|r7PsQR-8F@lI?!wgyK#IWhGrF>TCUi=4Z;@Uy%X9&ro6McI{`+dbZ&ZaoR#VR~nGF#I9pTe_p9%hN+wll?Uzvh?F?YF2OSr*yTAsB@UcoUr;C(krq~?Ns8>b;uA7 zH2<)ZhqKe8OHI!6FKGk3pcen7pNG6O_C65xVNEJxd{NKu#&`>nuG@upnVMU4Nw%EF z(zlxHB>*G@e3wivdoA2H|Eqq75hJtiy~UcGLcm~`D{rqtN4H4UZ&@k0kH^0Ydk6;z z4#YQ8ShA@`%^^lTt&W-evhJr43?i*vv`j%IrYZb1^$fv4QKk%?xJ)m6i2ai}VhUK5mCM z((^iL_`G`yS}f~iCKKkG5jH7Z+euO6lD-V~vsC&))U^_kZah{^*x{^B=zdws%~vuP-+@ z=X+N^P8IK}or2SX5lm#TAnV{IQ%LQtpH_gnj5-uxt;@Wqp6cLTd5rp^e!ZPeG=HSf z2ZLK$E_zI&zH3vsubNwfK6`w#;7~Jl(CZrEb}WR}_%iAv=r6!Uq|vJRzyr!+Q>6Re zy0*25w}aqThIAAmoz1SpJeom1S983zX}8?O@kvD$(3Ge|yFe87FiugQD6-e%TaW13 zF$2^|T-2|3?`}eP2VoQIn=V1z(Mkw&=3AQ!^OPqlfG&AbEG^V?7{Rb+;3A-dtGx&R zl~gPaK?3;fgGU3;ZLS+pFYjGzQ|!8N3)wj6Cj_$8ZwnW}LI7Jpq`w6Kdq@6qr_s`f zCYI`c<9DE73&~%URdr^cQ5QGiw(F@H(mL@wXWkuRW2eC>Xow41w%?r2yXsr0d#Ak; zJ+GL~Lg%czeM+rHlVTQklz)pGoo4&7S6(*kJOgn^hOk9gAU8EN4I8COYyc}WN4POn z11PA%$h&PQ8sgv z)V9UN@Pg(!me@9nc^+pHmE=pxddfE4%`Rn5JcqK(FbE|BTiF<1hiOfD6y3u9!1>2E zQ#E{wBgestTr@qkVRKG=dNEMMEpaT_AnAj7ZK&al%`R*TO(3r{O23QAjQKhdS<+Ll28()yvsR5<>2j{q8+6sOEHzL2nJ+of zJk7v>9TAS43)tm+xd#z047?s3lm~(tn(1>iCFl0knLUoo3>6u1GanhZOEs?2-qPS+jYP1y_?-Y%Uw*(GTN+ZQ= zhix2(YG_3<1M}sGC{|H#E7`9jXQGxP?$H)I3!@jzAS_q4ff0aF*9~z#o!n11ufJh8 zmv?;8`(OT+Kl(*q{|6qw^v;{7&my*bm%X|I^uH~UWhjX2NK8xZ5VkDEwS*T7{9SOT za4Lz$3iuG{&={HmN&>YWhvtn&8TY;&?km%rmBS_E6Z`eb)0A?w0UDN$eTC%F#dp-% z))KPFP`oKyDSCp)Pv zqjN{VQ?1}hT|X8|fL41^@ev`^4hQYk*pup_%rfVF;eOVyq>8uhQxwsMPz7H5rb5)! z5BQZ=h)g=OFa&SBTjadBwSGaYH2Br&kUhfA2>fUGBAn)B2hOTG*l} z=Su6esyJ!|>DI53>24t#=*FUikj#!7B?`NiVtH9m*nxfX;d`^`QOwox?vtg$6>GiO z5yB6>>}IiTF3UXsQ*i+tR*}o4^Kw{JE*JqsFo7$Fi#WQ)B_f%g`xqln6>#^{FdJtS zaGDc&jX?A_%(rce*c{lxY>REXTtdnQ5vCqh8b@;nP))$a0`0a&IxddDRv(A}g_31B z`G0tGdgNZ9Fd$u}^Qc0r^RdmH=P-8V;qwJ_Ac4Ty)HrZEp19aq3q{FpiosMPU~n@Dqstl7sx`hr=|Lt# zAWZR=h%qMR@p>@>_QEnwhHip8r0og`QPYA?mZA3?V?|MXAchq*F{iUiU~O_#PRzZ0 zDaD#fA^ehHOkt8<3N@WX#*q~>IdPZi=!YLZaoS?6nyOj29o2M6H@3B6! zx>XA$nsRf)y9x0(55%NpVJb7+Nw;qio1t1Z%=4(>DO`8orl!xgq=HD|;7^iKEbm44 zl`2V1X`7Bf(Yuf$Gw!~uJ`GL>eT2+MMhdp+q@rmIGn*u_E$Qdw!BLlsR^;3sR`8bU za!q%xnXDBnf6I-;tbp%{U^mJjBE~e6jpCIlgwVz~4>y1I+S8ls>vw+f`@j5KzU7Pm z+aJCE!V8z{>+Nzm-Mcrpc|#Lwkt*B{|7^lZ;V0>QsWZYu7-Pc7)hX>)<>k}5av=$8 zMq*-ymh*~!V=V#ltsns@PvY zsqy@EA@g{8-Non1LM0@Sjt6mJ3GeH4EHYs^O6$f#FORfBXONL9>#e1DkI4UT`-h|` z_VXx7sq{hQq6KQb*0wI2mXjg{;-TPtP)A*&FiB}N`Exw#L$y!l)zJ7lgL|7LZ;(w- zIid&|R{09{ITv)5*nFC@7;1e8NmG;Tae?LmI!B7dV2e?iHml{%o-@|5Xc3ts>1y-= zfv|eeOpBBXQ(L)Gh*aUzb4`(`8L*HE7jL|)G*D!sF`Ab1Y^vBdZ{6m-%?inR>;xf= zFQ0qeUPEqI>ak|G6ad#3QCg{UP&t^T_qhU3QK~-os0tsnGIeoN!Q)>Hgs^OtNhBnf z=0}^GIo{kZAQ=eU+(xp*xna+T+la~fEMn&MY@FO}z8TNm!Z!Dlj}hCpZCh@s+Aedp zZPs+gHqYZksT->FW=_z}%m-D9G1|h@ggK0?B#@;VL%`)hm>U9#60t+B1GIUF>LZvw zNr}%n+i(JcncIli3|X7$eRz~q?ONmFUkAD4jd)mLzye}p3l1o{**4}`62*!Fke6DN zZE$cB@emfTv5=|Ku!k=?_h5-AG`&%gu}Q5 z^=v~`Gqm;7R^33Tq{2<5-j1@+LR7eRU|3rZd>2i^*sDx;<_2u5`J6VLhVB)3U&TkxNJ3i}$QVsR zkt7gto|KxV=7l7zaTJqM-AZw1;EG<(v+f}yewpzm9dZb{$YE~ne)8&TTU_4xr62tA zZ~3zi%6Ii0V3oXoh9a+uK|+k&jPziV@;X}=7^QPb!@cNcAH3zc95 zzIVA^kmEK~Ogoeds^V7_jjd2?1t9z&OIZ^n@ zZ7$py3~(RjV?@N|a*0duXTEG9E|;)*^kx_kK4bhbDz+UlbA9L*;wh|Mm1u5q!d}Hu z!>P5C3FlCc6J{AYk30rOj*gsI$+AO1fEuwl_bE#ZM}#}I1Nt|gxfyD)q=2o0#X@M! z+66{5FBwHVVJjlmF<#k?}AEYDbnNHZ4r1zJpjt<;)4gb9CYQ=K+~R}_ZXFt>S@67|q2=0M3P zRHFg}$mKO3jVOeJ2^cfOwP5GrC|ob_K8934SSQ7k6AwqBwk3NWFx{-E!@Qcg9Cl3S zEfpK-C>U7GI&4{DNE9(5HT|ls2wjC)cuEzZBgo^m<}A$poH*#Gl7mzmj@A+QKw`Aa zMgjCM?&wKghulSa4d;iye{nc|XT&{1n%jI-F`)SbGE^nRCN(5a**M!oN zx8b$8fcQS75nCEr7Z!cy8iYRFx4m?rV3TqN4UO*seV1-MeqKX}B0@MOa%Rovsgdia z6-HYnERDcU2$mK+bj0YSAQ+JjvW`CXgRN^a%s9NJ0)Oa5)wCYSaK)hNb+!F<4qPX9 zMd3#o<~{ITH6%TOwdGKl-2q(g%DKb{RHRtL@c*hFJ(XRb82O{-k*X_fx7I#Xq3g`tf#ch^)#Ht z*fblR1%1dVG;2#`sVoEx)16%%B>|&k8P3SVBF*x63BeOvF(Wb(R8%^69Edl`wlVoQ zPqFH%dq%492ygsovo z*|8qFm7WHr?#LG-!)_#rjwx4in>J8js!G^6TbZhn(+i}k&XT6(B-msa=^}7oQ$@T( z#^5%$2iI=Qz<7MvFrOzhnaPB~rRLPm%SbtbkD9WQ0!{~?RLKe)fyI|03BoWLy9&^a z(&je-E?We=U4R(DtWz+X+t8dn=1jr(SP0-LT(nE$p%Y3P5;cIP+AI?&y%JAi>;~ch zQkx{wEX^zxes@xqA5U z=H_O*T(ALw$wl)}<)TO>W?gY=8cOYpqCY%Uxe}4Ty7(vx zxexZcdx(LQyVLINd$sm(KjeC58}nU_%%4#2x?@`v0=uh?mXPJ{>zyQzW@@=Wc8#oU zbKaL`_U-#^?e`Yr;IlW$(i}*={T0+%8aKbYt;Lpis;>F16S))G@vN*MU7S7F$8|Wz z#h|5@J?gfoz0A9=Ek{gjC%pq3?69C3^W)ZTnUwv|ynw?UKS$q&9awrk600r+D+{B* ziA60$uy$MH;`XT?q8s`bt_15Nm4nLO9`kzbKy#K4W5H#&Nl(^je z?zyg`U&PYZeroG33;ZoVuD>`QB108hKhWh>Cap6>7K`YsLMmZT?jekgA61kVTy+cc zFY97Ks$K7(b*N0=hH|fb1qZFlTpi#Kw96p&}3VaYXO~ zxx4JKq(CA{JwW^pM zSJ=n|Q65L*IJI>O5+&kwgkXS>0g-aM(;(r8uz*?JEDzBrROdB`Og$lp!rs-1qmZ`O zl+G--w%HeTY~yk{U?}%Z87#r*z>zZvR!ypN-!|G+?O-+5?wZTk($Q6 z0K`j%aF*DXelS}Db7FE8&!WyEX`ZoOEUnZu+EnU9E68l`96^jEdr4mA)a#k=L&S@>x)iQNmlb^l) zwat0^K^;+Tp)gt3Xw^*;9D0xN=M&bLyziclU)z)XgkszeGh&?=5|7({yw6kU(@h`Ll;j{? zET1pw&DMdwB`d^JV)U)bfPz{caRhS}7E%ffG`DVxL50qy0`uF(vi~`f2Ie4$A+6v7 zzCYx9yUAKFBF~BCqSbSn+2O?3f{UqXu56#juEnSk4~%2~kSfvYQ(dR|bOA_~PdcMP zRD-wHESb?KG+=W)vrU-$DeK-2{n+UMZKE#-t*XLrx6oBic!X*&XR{LT+`9~V)6WQM zlf49$mg*8cWEBd}`<*e`%IUkCoqqp2U$PRWbDNT2AdAgjeAi1i*Vj)!{n~J!y9g(q z$^SbIoD5UDgyvQeix9J=PsU8tP&0!NgOsTxmlqC)4=ou@5AP(O6jTBi{3~loAX8U0 zd13Rl^xWU-dB_0|NpSvT;EE8BJ(-s$0kJWFa_1AaNola5%gsTUBy(pZ9TZSb#{^w- z|K-T5Grcj}%wf!a_%Oo;C&wL2a6r<9sG)?)3?!G(G6O|m-4v5qOfWvk&W(rGWIf@) z%#pQ>nQ0{T0btCGg%AXHxiC{)CMx1$w{zwl)8?bhXl6t!`vcL^u4TiP=jj+$X~DXM zlfc#l4B|{B@e7hhtZYl!-#R1CIWsdGjAZe4O zL<&C@iKbBtRg21q?{gp6vxsfkaA%Nko@+WYb==h~5uS|4K!40F&yO8k69YjA6oKdT z8?*mSl}m|Zy6o&~t^N2xWQ7@7hmH+dAEw<`o#51ixLhpa-opoeb^i6A`}v>xz90A} zKk@e;Joosm@A-n$z5CnEWxH%+Oo&`Q8QTlNCYt&6|D^3ZQonkIlM7Y!WeTVnbbjT_ zLv9DS>I+tc#cxKQL))mM-YA}=4M~147bySoe@Hse{p}i3a_F)|yUX?E2nt{km1b;I z#qEl>96qSY9E-1tw+YRP0DJFmEW$4dj~!!`R)=ffTsoTwKtl;Gw|4mxah39yK6G_( zI+)|BKLB4PtuN6_&~1cAQg3cwi%ez^`>rXwZr-sVIO-RY3LT;nH#x11O~; zAaEZNAj5~0fXhm2{@t&MB-FnBRwrhX$+8+JW*;v_D`+G=O&)5 z$Y(&BE@M}Qv6&BxEnfo2;%FSaF&ExZ<5CJoL(G}lykIlTf$!&NRnfZr2ivl6CAq&L z{{U}aw2?*7rGwTcwK;ic02IdA9nrZ~0zwKupOwX$oMOmErE3+1(_{;5AJL+m6{)f^ z(Mf(4xKX-067bUyT=q2q5eLPeTW8S%g&%k#&H@$Z4fehSK*4UXH8Xv8U`>nZOb;hjCytwE&1;d?9Rj+K=Fh8B_Cf@k;XRhv_ z-}ifd_m_S9cYML`{ynFA_b=Di7BSA}q@%K5``{;)2hXRIEa!!fdcRjhBYzz3OEk5A z`F0;;u3!x;QGdjt%IYdWIr`K0AgLa@BFSP>3+~SED{rrw=pnRggRIsYICcdC}^6saHZ+4GfzJg%Nc6Qst zR8?Yc8eK*!yO3|yA4l&+t-kgD+~bqV=DnkMSb_p+d5>jw7S7dy zH`&s1Q|4Sps;08whZPvKwFBG=r>&7cb*)q9zpOrMnv`8|77ES}Phb1am-<6s00=VM z7LVTY)@M(j#?8&7X^5j!bMWt^wV9L48lC*q-E&#yvjAPN_=`-k!_Hp0+kDkw8 zQe@tMzDNR+N_l-QRDaf(Ax|p;Hbe)wXv|pUOe8QqMxAF}Dmyb;IcNnga##aslbSKd z#(o-QlTsbj6VY48lrU0K7m&0A9y(9BphbC84DNT*hzwQM>cAPJjUv#^`aoG~>E^K2 za*hxg^1N~gct@&jfpz6+yd1Og%2#OIUP4#6L|11QCt4AqncR}IiD+0Ad|spzWP2r}rB$&7o+3@Qi@6=79MQSphMy5M5C zZ8sL%{YMYn{a1eaCx7C5zxP-E;ZNLq^zd!(eedb&-nMOVxn#ae7yny`q1TEsBO34~ zO8^)mZzD7MaP-*yz=rEdwb$R6TH*4+Y!X%zb&2Xr_zAsZ-Ht?D{-r};AL<8QS|`U# zsZCnOJiC>otr*sXf#j7bCF+cuHxSJLoxss14wP5O*6B6x_>zcRFrsKe39a zP76D1*?T#PPrjA)rKVVkn7U81(?{O*(!44uN?F02cC#(+zwrEabJ=b#v+k*=%xouS zIgz9|+OWJb@ue5^wr)sC3|({`Fi;0h8%aID0d(R}jJsM;lDQypQWKu55B^mgVOAhm z{^n*eFs?i?kcX=PD;h%eWydLfa~}zWN#wBlVM|NdC&nPqopxoXs~I7iv3d}GIa*qHt(?;lF5 zrPMD8S`=_1@J~(0i4ti;M9o0R!e*+s!8jH_#yS4%w}Ve3gFi(8pDBgW2AiG#8L6Vi z*{@|m!lrYYQEY>C!l_F9EA(So*HVqR51B4)%9hJjWMOnp8O?*L+yG4AkQ%R|J&2(I znsj;q3Z?3+v&UIVkF*> zzwk=1JLT!c6NtB{v;zW&PA=^154am6T?6f<98icIWxeU`3v&1bkPap*p3RZe>MKsb z(#*sbC%`;Fe5~T{_+7sW3x6!ZowAgg6p4_qf97^OJP1~s!)lXmpUKI$0%IK zCXii`C`BB)R62?OQ3q&<;($fK3&@RF%a72W?IPI+aP|OltdFS1TkeQz z&A?uRx7EEpJ{6C~6#OALTQZZx0n(YE-D&87P0;Nm;mhpGW|&EK6US0=fFL%vR|^tGU#JB+6sovg!!se$U7m&B@Hcld4; z%5v!m^?!@D6!>0ta2;J4sxyb2N2isQu1ix#Gk!Q(#LCM=RVMJTHOQVdOCqj9@C``q z%(hg857JWy+r4vh)I^QOJvapp8r!n9a0emdIr}YnrS!86FAfVT6v@^8q1K=;KR5yc zk5fXWH|ipnV@8ABC92LEACIJF3wr!quzU{ zfko?@PsZl11YSVmZeE(EQ3(4vtaJYfoWf3D`_enC$V?RWOP}JgZ4VwlXJI$j&wQ|~ z2@DwTefttD;VApTss?=l zJETYWPZ&p)^Ieh9B+RKw6y!)*<{3x&9^#UTSTBtO_6uK3Iw9uOF^)Tbw%gH^w zrZ_WN6`m-`1UPVQ$VQ!&Fg1t)R+M>yd{;aQka%60s#i~Oh2!d61I&DkU@ZoTO6HP1 zUyyFvMG1lpqb9IbLId}~a=~Q^F zAmDM(w7FV6plRF~34H|hHf*%5H8N~W1u-u2Q?GDdCU#r|}6Yb{(VA%=Wvk;tf)}@BcJ|gS#CG zbrd4Hrr@5*!&^ZA0vK(5P%XQN48(fPoiM%ne*pTui5FRD?N`(_qVx+gt$$dQvvx+A z4?m1DaCx2%xuG-t^=Jz6lM;BYdI-m?;+>x?#j9|x$0Ai|3{qXonakHW@40Yj7c5QI zcB9z!F&*8(Hw!{N){Ultt9l?pv#;dSmiNf`j^3IsB1?7Oju9PjhI3!&m2!`+Q8)24?C}_M?5)P zj^`i_5JbVr;Ub!VFy z^VMz1F+o%Ji@aq4n6OA>E6m(jRGTJ_VWsefBY!xPP)^6{J)JXXVU`vb1?@{VOr;?iQm1%y(N{F)ktWk`8=hzegIeVFLSdEQrhnw1(M@& zIdD5z%P_Vn3Tj|wPwTunA!(J99z5s6Cdi$r6oPJ*n0#DV@Wm7JmY}p}B`;Js@}IQK zIlrj9!Pr3dxL}cDI}VYh3&93vTSkiV-Y78{r6l?^cvOtBWo&gTEKkD+Ov9w-1vAgo z3zj@BA8;GK5xk2`W6%_a)M}?%SG*Hjb%7Ga^J1ZQG9D%b)y9~;z`2d3QJQ9@zJb36 z;yP#mVAh1b7QHCN;Pc5kX~nb)1-D)gcIT;=sAksPK;V}J+Vd9o80FDNsxJ3pLA*HJ zhcUC9kM>WwhpG>r{TU>}^vl@tC!{|u2cwAUsMiDy6kre3qs}dfW5AKaPPCWM<(z`4 z#oKlia+bsh|DX=RfCdFMRIjjq_={Tw>b>*J((E03TF zkMV-PuLsG1_qQ?STik&84dqMdWdB0I0whzfJ8EMoGT?BG%Y1YkCm+|db^{l&?8Z7j zjI}+n@y2RX;FSz-Ge-1=aK-{XrEgu?1d1WzK_-86x2TryRBOp^H=&6@9RI4`78wvE zS`(2MM?x@J^6|f22w;A{S7uq49bwSgMYiyq#-Q2|aJpK4JLtJ|;H~qTqcuh?!n?t! zZ&b~V0?xb)%Ez%OCQxNUZ$otntG;vn?fj#RwQB0j?k>iN|Z=TAZSrb{;BUT3DpqQ>XPxq1$rLRYAL?geEnIbvha`@3&fTCEg_h6 zakbKm`g^}rCQSbFCbXsVx#kUcM4qYWAA9BH0?B!3DwKIGg9 zx^2kVia|KO!s1_>WsPtCA*c=9zqn5RC%2_#Ef?QsH)lDzVn@`&4inzphX#NW*bV`6 z9q^Dlf8+q)x*LEmfGZQAAd)XdBRqB8v7=XZqc~pNymRqg%ZM)8*$gRB-L;xcHQKpSkcH{GJQ@|s5B4ML5?D@g zh}J7~sFq9SIgUp}FTBWZFY|uX5Eq$%R24Qxwd1!;Cp4!hM2$UeQxFquluN#@=(+w2 z7?8(?^hM9y(o4LPuD$juL|J>bekgR3?yYmF>8%bAGun^8`fxN^zPrqBX{UNyf>E4ZeTuM++bCJ|4v>4);Y`6V3X|{pzHYNrP44~m{o^AY+VY;4c0+Vgm$ApH za!vk?u!*7A0YV`N0>An9MO?4#iJk`_W%*Ai1ZUU9fYaLOkO^uKwPxC7&x5Ma3 z?ZvT7TZt#By`ubrD104ZU=@y6ouOh%SO2SuZ&F;ok$9g?8I6NkEAQw0)i*m zmJVOoNB^iI6PWVwnKSF}OthVEQQN0ftI&dRS6aSe=FrKmiXaG7k2aj6h|)J0N44gh za?!}jYa18zd{bi#^9_gDiV94v*-+X}wJuAMq%jb`D89(GBK`~uKy;0g*><_ab{XSTCp(uxo2;-M zJP59Swi%Bfk84IZ$+eyMBAu&wAtDzw=P;w3>urpH1Vx zl^-u9&cu9nPq@k+ak)-YyYfH+T*51mTZFH8r+BX0`Bi`Df$**T^(w2SQ+xQof`+5v zv^G`gcAY#4OO%G)3-rRQ3NZBHC20XxTjfkg?YG>!pP;qS43L^{xt;B< z-Im~dlUubfWl;w|yO@`7NO?+r(&CWqY`dvEIJo4yKlDlx2Tn|E#$SiW&E?TsUL5xx zJbU$3K;!h1T!<-tuBO!QS;SmuIPp%KR=J>`HHsGl825`*OUx?>qeDq0l9dykU|z7N z&g*+2wWSFKJd9vgycT)^LGLx4dX7)DNYv~6!bG})w?T&$MUWBjRoH~t4PQ!vQaI|= z%x$wudgiF+K{BysY5}J6?EJ*%5&051&3wY*%KY`68R|i@jsX)t-<%iV@?7#*saOU@ zE{G725`!jzWZdnHAp|Q|!z!P)ZP>+;90I(#^@nHG>gT1Z6wGwMu>r)Q??`pvy#kSk zAZVhAQ<^Zy4*V}CXuj)Uz+;Yz>lNh=aRY=(4P+ycJC$r>K&A5KX%u(`J!1?Wjo`F5 zW4I`16m@XXOfqB@Cb(QcO(L;oYgdj9QvHma31QS-7hPsI59QT197qd{eqJ;mXhmhJ4$wv zsDn+k0uU=fltj3+xS45?4a-C}Wx{-DzVt+EegyrC4>vvzUqk|Z zH}`QGBZi;DUVrV45B$Mz{P3Up&UbvtmzvpjeKTM1ipUa|kmfEhZq>V8;2hpxte51b zJbT@^%fpDc3ceU%*#bjX=g=3A_H#SFFE4tWMdZZ{21$`ZYfT=@sN8bW&i=f6IYzaf z>5M@h4TosEUKYP>HNHB?Vni}Men2^Plv#xb=nS9T~MXW+S&_mSLzr` zQ0oMtKXCxBtf_bGZ{)XxSia6h^X(ekQL!@@x8v&iJPEHB30zdD9SW%ao9mR+CMc6U z%&VA07WjE1tmH66LTi_zh2B?=+9;IXDE89p@M>{p^Fo=?^V3J(`4YZlI;EY27ZKxp zb-s81^6XijV38tWGNa0~l_vTai9Vv{j4+1Fq2u)j=vhYUwd7Vgq6sYmoT&=RmdvSS zB)>|6!wq2-y*6xsW8ZKfLtRf$h0cxYSDg%+mBG$dCHsQx*AzFm`}JT+F%mFHBmukS zK>%`&1=*PBIW|-{=&0pZV+>U%(l8w;Z?=pCV_y|O77#uvD;G#B>ul26s$8AlIm*c} z%K1X-+4xLt>G3l-_=))0v^3Pfp6h5Vaw{=l2MzKrNEQE8PaIlMCwL@u{G@5PFnGx#KtI9o2r+luPhWi=1;DQtIYVJm_iq z3`i!WjR_E&tUf!mBTdU>0FFsP%E+ldEdkir5vZ7~t(!>M~@H6ZTb|$CrNuui&njGMqM^A2Dvz=rH$_zm-Y)lst za42-&-SUO(fu{^O1G=MSI_T)|ECtQ%4i~Z<{-C0u8?AalrCQs}c^DQ~k#N}&jk@At za}*9)GXCQF&KZ~8sZ{Rt-(i=_Wt_&N7vJ*aH-6)n{^sBO>F@okumAEdzVNy4di2se zeT?nqQfo&;7}|N2wzHF_KQJR%B-cyy5z!y#3Of#ONb{B%IpP=&^k1sH|5EbpcMY(Fz9i+h$wSkKkWjS#t${T%QHXFGMijk!}cbq&#Hf$&NM^wBOCOi zPKgbmK>&+2`RS^jGIpU-d`&`r4~Y}*RNtCu`~E3Kq9hEom}x2fP6OAYbdF|u)gq{9 zqk_Ip`;o5eO4`urN^bqQ59=ILL3VzZKtbU+Iw6A+7T_Oy<>kuWOkol{&Pm(i{NTaE z7hinx=~su(b3TIh3bF*?L~ut?;;pzBk>E@mf)#948AuFTSdDWWI0(eeczR&24UV_W zJG7bEi3x7FUoIkv+-6E0F#e#9BX)}s6sE@om7X-X)QH_9{wYFg>Eqf^ zhJXb&t~ir=6-P<&%aJ|YgMsr9B?SX)Ue29c%RFY<$#IUKRaR>Tp*xQGBi2 zv*0WcR)z5qPUWH3h+?jgi6cx>JAF!5VYk_1+oU`L<`WJrnerK!k_d~~#D&V*9lgM# z!aQ5O=Aom42pw@4WKPmW4+L#DFDJ;H1CwgBPMT8cALblRilv(&WC)!gL2|Rrra2k% zu||`@9CHy5hAk^(WwGI6Fli?5gWcdOc0|);Gc!x^D?C&7ixD}7Tl-ucS6e9M=8`=5ON-Ctm4H`mw0hYzP#>WYq*TOlxqOt@gvLL{U&<`4Lam{=EIep2WkdOX`YI2TKU*O8CDP>|XG@z&Pzg$g5H5 zpheozr$l<#WVr5a&q4y+115;HEqdHTi{=xhv$bhA{g{2 z@hel=pQ8H!>F`yYs(u<;qs;3w0HEF6waMnxEL+sLeja97+k$_<_YQ4vu~muZl{8lc zkEoB>h*IBuAG8I!D*^O@Wk-9s)*>ESUzhgMQJKqSw+1=~*%!)bt?q0@_862%`5ZWz z`G^pZqcd2(cQJ}ZQL?8Mf!5?QJ|wo6?{$`UPT*Ee=+cd2>(iacl^C2CRrT6VyP7TA zEoRuCD~Lr4j8>H>#G@`pfudDdUpfI6Oo0)?2~94ZFXuE6=a|{)Bky`gJ=G0wg_fb3 z`RR1;;iH>pPaFFbQ>zd*!3y#M27sG^V7$Rilm-WtgK|S<26!u+)qIv$p2ExVW?Eqk zuOn~JEKl#3AUecZ`~ru^F*rh%cF)~d^N}u7<=O&$3MG_J(Irs$3?EaYD2AbF6QCji z$t0%>L?lgUy{nj)8?a~&ZPW_{14FIWCc_>j2Q%H*iFqm@Eoa6UlU|vcaME>`yl9zI zp7xr(WVWeBxQpBKQZRSuk6VNdACdr4jRT~rJ$#N_u2&P(D|}WT+i;%Hl%sJs9HLF~ zSz<$UHmPz>f0SjU*aK=nGw_-_FX=#SDj|aT4(Y7%a!!{c0$Q6U#qTiOks8a)Zqe%@ zv*PF#$N&rkS#b;x86nwW3T;LK@qVJA1@n|)e&fiFmM>xus9Y-z0B)clYDvt?9O#uM zkHMr)pNQV1#*Rvu)?-QnXb!=oJg~Rc(!v+cG*Ws%5kAzrmaO^GLL96+mySX0Evmhs zT`@WQCoJ=XCS^yn-@{8`%=A5l+Mq1S0qN%;^1bqt?a^h34$kB7tStgHgPb;*xJ(1< z+AXRpccRzRZNath7^eugINiVhbKjR_U{?1mh@AU3%2^YoWqXA(U~{bxBGCxYIprCojru4M<3P% zb?i-)!6hv?E+JUHbol65e)yH(OTY7azoSjD37xMu?b7jw1cbW^bFpd{C=Px?KjM5nf|iBpm(G-ml33u6N&bdx!nd zh;lt^qS#HAiwg5UwF;?C9colt&I?t`Iw{}YVjt4+Zq&ZJ4d7t?go9Y?)%W63Srvra z4f7}paA2scIMg^y5eleoUZN8!mW+82|LitPMmcG$#95TbPvhR>$JcK>$u)UEW)8-H z60+ea{+XGWfzvP`Z76bWLIapLV2pN%;_UCn$z^dR*7-m*r&P+5fjgGizSQPR#G;D2 z9s3%5j4D%`K(0vKtcoH8$9RBW*$p+71Sj}-06EQZCI2B9c5bXPGqd3pn~sJVAWkdv zPbSO679*?kx!h0*T&R!YoDXObLtdPbc}BuOOj=Gfotu166)MPdGTfRr>k zlK>M8BDnl`OdJe(o7Jfo2o|Kz$Ce|b>N#XUvmM5WK~<45EiD;W!>RoFV!VNSNEKYv z7#nN#hzL1~2$4GyB8K*p6G3qs8pN~(Wds-NcoT?OjA2`C)AW&}7}$lz4l_UqlVc7h zHChNV_Uy8X5>C;Tkr*x=$Y?W1Wrlg#?FzyI;=J;|LPA`n*qJ*WMn1s5W zH89|_>jEaKsAxS~X_l4M;xkCiTVQd%6v1jZ&dyeyZIUPlS(@q-VGR30Gchg`=n&M$n4XK$GcFh4)I|LpZQY}?E{E*C$K z)8j`^KJ(g7{ooJ%><@iB-gx74-uJ$%$B$Y&3aHe)$|9I= zMks=lud&cPw4f#d=*92V8I?`czPb#A*pSF^B6j1O2$TxzYzJKcsQh@~KQ z*&ZvbwT=SOb+Qb zd&T!%$?3q!{cu8$F-8vE&?{9y$NEqLr+TzCqNaPV%#vP&%@F37wNxsiCP|Q)gzrSX zyo$*jxZUjZk(b^{g9I+FV*vB!;SU}^zJBAWn{Nr7i1HmIR5-qotvCk)2&kP6_N=m6 zSYrs!1?ttQnWm zmZA>u8B1;|7n&>~!2MJ1=}IJFQz#o{gf->B!qPneHzQW1u%N?u0A+4eY^r9ZWyM3- zEYYJ{PB`72w}8TWeI=fjP;8|0W12fV$HpZ)O@LX3>SoSWzE@qSt_ITQ;6_p-LkwIT zur1K0Y9uz9pUL)MbPIXQH0SAK$@;C%=BZ(2@2ajs#^;a?$%B;z)d4q-Nl$;5LRApj zmJkV(!b!N{mE&Azvk00H9j$_OLK5VglQkQVNg^I0I?v|;Sy zgf6#{6vvS2x_n5*!eI`mG00+foP8D6RKe(_r&xpnaq;GXq4bK1CeySg<#Y3lU*wXN zPJAl7)aE%SosJa*LxAc*HrPmNQ|pXQLa@He#FjsGPoK%v0G0sLOv*?3hxCO@m!rzm zG14)EG0s}$1=Dz~WwipU!*+TnuXPB%F5>FpgX=e**v*ZP5oTe&T`s57c=Y)3YoGk& zPyN8hf9i*RATF1;e*Wj*f9|<4#&&ZfpoPx7&?oxf;%;jh(D07m+`ZgjZy>lTLAsdL4tm(KKigXRWnE!5uAx4I7c;f(k3 zF6EA)qLbYm>;G8(-fv%Ev`e4c1W8l-F5S&zp(}KZu0_j<3R=tMmWl2{KJoS&9#EiH zODXEVN8CzqH&ROvPQSl*S_-|bG4bf%>?=u>@%|mkDq(? z^jV?#kPAHD1i-WS0-bT3HIVX%2@|uGF)EUW%}-(o1@lQKDTbT~ITQwfaRy%VAdbQw zwQA9Tw?zN|fB;EEK~&Gdinf%QVz!`ym;&3bY*_q{f>49L?sT9to0Wxf;)|P(y;hjZoe}Q;}7m zm&p!#{`qXaSAjl!I!NRCQNlynwMu|at%#bhyrrTf6Ys^ejoLU{*HP6dswj3m%In~L+*V@?zG1lFI4mb$ zNlx^VzK4skShcrt7H3Rvf^K;QN?Gz4+%Spdu!3jI@l8G?tFz;L-t>)yUHK>%M#x%A zSR?MB>FP3@C&V}c3s|+F@1pjiS5{hJ^wzu#O6$zU<4)1>3SU{UZoI8HW@4&@stGYh z&@_6i5)RCm!f%TnA8uZrL@@yvM_!mOG)X&;h&bJUaC!P{dv-l_W*TEeY;ieX-Mjzz z@f)A~R z6ec;pI)rfx%!SGL^5LBWapg|8N#uaO?xfUIC7Qx4ZFYmG5ApGmq*~iq^ufe7j~~~B zs}yLRwcSKi6yp||`|Xe8Sei2QZ?QY6)2I*~anhzPI7w1Kq`vkk-DK79?dHL4B|y5k z`duI1LM=pGi?Dj$jSB%I++7uIv6TKvP19}DjwPayTOY}>0!cQMdH^hC<TZRo+lUnSR9;ahXcbidpb!eCqpQ8|uXiu8xc;}- zp0E){L;EugK7^0#+{oSh^pSVH#6z?I7HeD%aXona=;rz+Vx#lb2!PM}u+-ryX>yrA z0?{Bl$qnD^U)5qk#rm@hU=nf*BKh)`bHfp{2&GS4nZfN6HwbFXN-yrS&CNedRst(U zg$zPfx)KWjqz0Zf;T5@EzW~QKu#AmqOy;EXgof-yQtLYM)VW&mOnUr<5d(CM0Zdh< zSmR92A=*S~v6>JqPHvsGm1ATIDF={S&34EjrjyRoE-(??KbVQr6mK5Z9LNF6kmyq^!Mlmugk3 zJ6XQW1C-LwxeXIJuQjE4T^&zq>Ka{iQBFl-sUb_Evtgu2=|Z7j0Q))^nr)p_95G<6 zXcJ~TFCAWSl2Daov9$%Xxel9%K|b6$K@H4(pkf+f3}+oJy+$b(^&b)lkbRULxx3r& znxEQGYdJtErN%g}D0Pj(WFDZmAy9_9XhyOerspg0M5JF?C}QKo7?YSE?zc!%+JGyM!;HU!&RLAfNfr zQM$$enb(Ezw={5zIN!f_d3JO2^r@X@hbrK`#kR$EzPft&`0*Q`{N&I4zz_c8AO7KS zxp~_cz3=ML<4Fr$ETQV8eI7(3$az{icS~^fDI&|K1;@14EMp-M? z3RYUB74L!qR!mP4ba#clSs5V9D13N%G`E_-da|Lgk9@L?&-G8;5(~=%cpX>{)*><* zPi~7F&nhB9;~-GSGS(bg2L|R+!cW5#1bbm3}Z{p-(S>C`aYv0s0?M7(s;8 zgi3|+T494?a)h=kDRnRR!7eS-8s!umOcm_(<2y{df{R-&kFu(ooKh0i!vbne7Zn>; z_$p)w1X?9=v@_8Mlf2PdZC5+SP-l`E92rpn*LWKQy@M7t&$wV12(AAk?QC{ek{ ztsNo45p<%aJwe5g?suJ+u5)2lqZF5u^BB8n67JCCqkY>Scr&Fjl{6P1>AWgIxV&Nk z&Xwd_UC^9ODNLvp(`Y!@@qy(N4n~%W`PvB}P2w1452iZAcK^|%OKf(tMe8tJYCr`m z=9@s)5-G{z$D(eXNIXqghrUz}#tqDjU$e-lu`E5k!59VfD1H`A^tJr0`i)y~cxefR z{y|;#8XMrBUN3t_b2ViZhjP%YnRvrQRLNrDBGluhZ3@;O^7sN#rn4i%MmGpV>LH>5 z2)P%jsH@ErahAKC|Jj`9(=?;$nJ6p+II09fmW&?x0(27$rlwhRj&mdCE8ug!83N^Y zDeKCh68WHCA$fkVJlSSyGI5QVG|!|)S6JQUSJ4-aA>YCg(Bs--Wl1lFtU+be-3GM4 zwU$&Qt5xH@vRpF9h8z@_G>VX%JC4QAU?L=%hpC|}u=WwUPZKYZ8R`(Nl)Nm(ER7`R zI!`VIQ6U4%>CRJaeH$`GQFT<=V)87vk!DAU!Z=fYp_+*~vx@noI11J|>5Y(HR!KXw zi8bxTcS<<|%3sLd5e8_UhX{fuC-ec<>`@g@n9ryqxn#xBM0HM6DYLsxXVr{5h+3wY z2AqBEa;$`A8H|r3wD%OX>OXSdKp2-&+utgWYe{TZSl4EqJOkUw?RS#}B69mT}gSq22a=QMAtU z0mquw1aPbgZ#o^kHx9q*cS*|#(B-gl1CXldxE6!i{2cHoLA;@kW)|EXCz_BqN<|K3V z0?yw=LO0)EP;X(JS-knu@r2cZT}$L7ZZ$4p%p^7_+AXC{*cyBh0Qx+4jY{~2FSLNF z<3N7POvyare^hb+9ykn_0O=v$3+-wOU#cDYhcOhJb1_k5W|Cu@7&T9&6s7~@U8c$& zXCGCcL@ZnRM0^{`v;y5m5}2axXT7Jmc`OYcGyB7YW9eMmWF7tKZG#Ynhw@w`o>)nT z#H745m4B2yXgPNKsTX=|JTnvTQjtxJh|sVrGzu459jTsWB5ATtU0@Oss2zP6VNEp^ zMqz8|9P^-5WA6Ga)y;nx%`iczG;j2pYHjuU6oiWCiCnV5d^Lra<*RSeC+X=|F0@MG zZQ>x6BswnWUvDk~oW*Rz~n?cC0natWxba{?Xygn4-^90X+I zQP)Kd6#;l;M^!9Dha?>y2l_Z)PmcqfR;w$gm@V==DejkzZQHIMKi;mdpM2^!PUo{x zOCmgMOXz|U@UYX>d5qJuS6|zn*-Ib#;D`U@w|(Fbee>zz<1o8Cdp1rZQ=;9A-S*?< zy3_-f1j;GyGje6*A~DFSulnUCmd2N6*NtIX>bXnT@S=UQs{E@DJOXM)>x7#yE3-Hf zoY#0@$kqv=9vKf;yUMn$>(WCLspi~7zw)R{dkK3V@*-XnExG-*C88U(!loi)Xg!C4 zLUipw>($cporGh#ri@Vz#_ui5in){L?ev?Aw5q7<6IENhAY6T}Q|7%_71C=uy-xiO zuebNgpuJ)!VIiXatnA!`ScJ(Ng2+}*8HL^&}IaBJW%DJQhao1eA<9a}8Jw1kK*Btp=riREB z-_$gd>o^#YD5OdFNK7i7D2T1G;|^zE{-Msvlx+--2vxZ&YI5`S+%QSYML=Zk32)}V zUwY{wYAw)+P(}0BSyl+_Vh+?=_(Sn4E8Cq@x5c01Yg3%atqezecGTuYQ}t32HV z)Vh1&vPHdg%HW}`*5{^AGIcN(>8D+VGPU#~Ugzh1v!jbz{4sYA7nhm6SAq1`2B zvD#W7)`dYXj@`W>3UEB>2E|^dXPxDeo=66(j0#d^Xm#S5bTP0%l+<>K!V>NxUwZpW zpolpyG&*X!;N%R)Ny~QUon_#;>3EwyxTiazK#syOR!{UrIXh`X8;kBOKZf#Gv7^%K1(jhrZ6SYs8Wq?3%)|M05^qUtWzkI}@t3s+_w?Dl0BvaEqb2 z$5HRs7((fp#349aVRm`)3_4{{cQ@N;Bv}E4K)UQu%D}S(y0m=Rat9*uDv4KKknxb=^Mw5=J%u+L z;q_*k*Qy<5l0Xc36|)P6VA#2pyrN zE@A3Aaat)b&&WYc3c`-qQm?ReF6$4Oqh#tVB5O7`avH$ssRLWYP<3K7ird^$Vv?Lu z^;SjI;{MsYNOqow*ji2cEH(=;huQGvmQ|_~Nsp-b_Dqe`cCNw!M`F znwf1|4w7h{e0OdMW2G2#b9KYCnOZc`UXYH1KN-2Va-&VpQ<ofB`ss?-&C>k=b7i;ju;8YVU_8>tfU{0U3SMVb;Sl8l zD$(ier$ZsDn}9s9S;I3BQFqBNH%~$X6Gez=$E9;wT;XAxx#ufT!YsDS`QgI|d*j!B z{dE5no2;X5wq?q(UQHx>WyTmW{MoCoUM|~vzUr$!{GH$Vfp7RGJD<19C2nrU)m6;_ zRD8htIVLO(FgGEKwIh-Hcula>ZIrBsiuorCoLsM5Ahonsgj-S#B4JLKuOIf#?DKNA zqY#omu6IY}I=AqarHAkvC9c#`+GFpb9sC_j)v-6LEt>r&!H zx;#}swBiLLt~TkGm9Q+SGZ~MCyrfO`mm(4R#lPK z;IKIKu!glN3ED!_S9A8Fh}LmSxtT zs1mA!$ljAJsD!(vuabmeOq8P4?}?*-}(BX|J60AU5+_8VRPncD~k$lh}C(7Fbv+y+LULL)&3itkxXmPuP;0M#l;? zEGi44H=;2B9joL67YN-+P3aK{p&)a%Ab2I`EU>MOQG5tbiZd z(@3FHpo8^3f|Rx#qBR%#i~+GpykoF|A;)Fr*?hT=45bN$ zz|!MZq^wXU1IJrZ&18d{oM^?JxE$T@AR{-$f>VOXT!(8`jYi`JJ>^VXnm z3$yKVIX}1`KA!ygub<9W1r(ww>Kv2AHn9?ijnm28pT73GS-khdU->ov%76F0U;F$0 z-o2ZfYme}8a)j!+St6i~m}yzEc|tFYpVyb4$eCJrALjcunpYmOl(L}kj?65$oUX9J z`3BCV(rEYG`l9zxcGVigGoj5dZoC3{+K5P{{C4q_q7QQA`e$cNNER1lY2w*sW)7KB zO^ded2UAuuFrnEChUVA-*>svZ5SRboF)N)2fdZtzUcJBo5ey>U^%AXIri7%pMF-Hr zW2@VTB)P)3y~AzPdVNi_5F&2qL7(VoFjKy&MRjs14rM==(!F9zow1o&E7adZ5iyzY z)>!*CnRx7)@6>@e9Z>Er-;8f$tFBEWjM=AD2tTmyj_d5Dv8e8@tL?t|P1pJ63i^oF zpM`jnlhv6&IsMHH$om%M0)+&wD5>p~Pacl>T0G2dnuI?##}3iaWN1M>4#E{Z8`l|G zh&kKDp_kf>Sp6!f`GnI)-tkgK#3$7lP0kKcVLPJ;~D^yj%;_TWOFjSg|p?n22S>J+YGcV$h9{8H<$aH35 zq1bbz!$*?xWO|eZ*J=0gVNqRWN(GYexokL639iMfzZSYjZvVqxs zcf9(19{$EC&_Ej%IzbeKkCcUn9K#wSRj{#+K_qUC=HO4J6Sv|ZH*}p)I`D`Of4OHL?2$CzyjE`ob3B%h>Ff^N)^qF zpj0euW>ETG&J;vW9Ez=jYa1Wq-BUxX5xHxvh?U21pCy)66_kyPjMYNr2;8BpDU3>% z*7oIkO&(HbK+l|UAugUTXGz~HE-R=|V3lsZ<>RFE9I9+W2+~*6iA$)WB36?#CP#L6 zNo7kY%$SSZu|GX$AJxS*+jwW|4bY6o<5B)36=G4CHGK7@hT3rEcmELV&zk0KOo zm6A)l8_wzAWmMQqf0lhY-a-%Bph}9O& z^XSp#NB3=*Jl$H4)OPb9F8}PI%O|@hv>1P(1~HG4x=2GApkWleOs8voa4w}QfNpYL zhEI^^)^Buu=zh{g*qD}Ju?Yry8}5hH+Klfm=De&l$L8{16ES_6Ok&M!yJmTW(?L=s z$Ygk@-^9SSbO%71KFmtcbF!fjR1B`f(qv5sV&&NVz!qx~9GAf43pVPs3gj{#`X$Ik z??;49WTn!kDl*Uu`U3b-*p=oSVzX|>LwDC`DHRugbW+k4^)`*_y0LtsmbKg;i4z?fOLqTp<|UAnMWi4__2g_+A^TfJ-J`C@w#6_`I?D z>`a<@mnn6!Y|y{!b zuk2eX3EV5&AW9;~0pJrZBalP_q@dXF$hwtQMRbou4hu(-Ho@61Hr7axuCSGGNVl_w zkXp)8H@V{dYE>sSaZ9NgrBh(Jkhiy@mHBNV=(-OuzIxR%7x)h|L8#!Fg;&FElbS`H zmk{Saj}^%$SnbJ1ss)EaFi;R?V<{&@;IjOG$qN4;)P%(3!A zuU?(6zUcS;{;&L3{yU%h)n8-gH`mv0=3~s4BE)nz5>uj6?es&PU;WfV(H#wD4BZU9 zo;R<^qrR!r(N9165D4wI4*NQQ>hOY48|Wu%JSlYh1pld1-aL z

    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�sO7Hg>DAZN`o2)iY5DqdzJM9x5OC3zP(e~T^sHUI-jL>6J6$yJAELfxcdu1$dXU`ZjC%xi5M zGzc|p3@AvYr1H)IaEludrWQ&}IREj_r*e>R|0cO}Ap%&z%9}N7OecK@rbvsQEvx6X zGACsC)h&=L&pg)>5Q*%w?ElBxZKdYZN~aX+M5^3(Ob$(CjWN);#@6Hpk*1N7Pfg;Z z<7t^KbjsArBrSC;yh&D;;VoWWjzbaJ*DP;5XAS9n$-C{7i)?Xp>z?&$MCZuuR%CYX z3&5zy^CP?jZFJk++|2rXW}W5^qh@C&hH^Af%WH_w1{mbb0^c(Q&=gZyvAa-* zSd0o2cW^WMasu?^FigE)uWCMK83Bd28r$w?hVw^bvT&&Phi&7iz zKmR~4hY=4yVH#I_mN(RT@#T-8TNbS2KrAi&U?b02Q2$?1!a*$lyBODwa>JAHednx& z?LVUpk{Y-+lI$Ki8wyJXUJQ~NlJ-T*M5}BVFl9`-risQ?Pc3Dnv0Ph8QmkXbtXJt^ zwbOlI%8So3YP<6%p^#I(7Lljnx0%-2Q}c<-KLGDiPgX|%OjLbFC!AccJkJDo?kexb z>3Jh*0S(|iT+UajKxL2j10+RHS=(?latm$e8ut5z4M=81i?}O$?SN|al9a!w)au9~ zIG#u8Rf{n{BkD2}c%UQQH-i_=3gyqc_x6W_fh892SiczJQf6F>C#{@&#&hKeut;?q zY?$h5D0V-S)7s&*!4&s@JcsWPA6$UA>UW8Ie&;5{ZTju6=vsNkVMv9SEI+sFSRreh zPzdMcyh<3b3xBC>4UDHxPhIVUeg9asdmp~5@4EqcEZKR8wR?#lj9&&)yLHuy3sQBa zh80^Zt%Tq&*|uGCf}_6tvs zR2cI`XQuI-E@fUrRDBNWP1UfMtuHXI|5l@W$st(@DDRZG)@uDsS5gWsNAcb8g|v=SW+0WN`E zZ%hr7+!B6St%DZc3ZA#cdQk35yZ0Rx4I5%6Y2klB+bby6!h`EG)t<|}Y6Lrq3v+3S z5-L=A31eV@6_LC-F=~=CP2V&$NXz8isu~@4)2J_9uN7>%`rcV%MiP-c^h18h-8s!m zzIAV)S6H5X$0sUKox!Nbpkb}4sU4w*4qH1p>OYfhW#WeotoSKQv#Jtb-nqvkOu{9R zYc^bHqe@I?chbL1l`1U{JvCXsu-Vp`nVz?K;Zz3aI4b$Sn4hNsox=$>fW(R?;Fq9E zVuMS>YRc&Yyo8;-#KZ8206g#1U0C8*Z~5Lx;%_TV)vbf-W_oXM-F2$&S{^xG{kc)X zDv&0>&Ub9a<%)A_=FUAY)ci8LN%ZqIkCM~LUZg~H|954HHU6}!CN&by{_eA3RE4mp zSdza2Xu0M((0&tvWXb_Wd=TI}LXcJ2Q_P5X@eP}ep4HCllF{vH>Y_IonYNB*dan~x z-oev5&CM_Z-4=c~NvXKk31bL1D_SUCar*FRE|d)lNX+Tkc<+v=*6`PKvwHbG)tMKy z0QQhuy1TTxb24!RpR!r5R|`Vg38Nqdwesbz5wD9!gfy5~jH0S%?wp_vP3SvrB>pUY z;Iou!bF2o=+46GJoCv7Vywp)=oLkyi_&wZZCG>?VhDnC7z~3W+x9;ir zBSXDFdTJd!G*wl)zue

    xK65@}V2$GD1eA60_%NZK@l+4FUp`m+0o~)pOoN zjO$&bgR@s0uEDg#la&xrDOP9$tCLg>EU~o8e_^{COX}_Kiw2;mC2J&zgh8$2HjTH| zl>^R=v)aefev{JVZ}*x;H1JB2^&cXW2n|K6btXZh{jCnS2Jq7LZA2SGGDh@t8Fzm9C!GMq=Jp#N0XYSMcknAKnRNl#`$GhHTZnPfZpGF= z>L^|1iL7r!@RVg>9f}Q0=E+AT)ftq7c__)h2R#zZ+l6mw5Gj)D3f^%bau(dwnl;Ze z?|hIZdK3k~CN-4saT|mI#Io*O_~m2mwPieq*-_}wt8j{kVat2iL+79uX;>t*juK>q z)=^}Lq}C%qy+Ah;W*asEylJuRg-z0ii)%|rS|WVa`dd>?J_!eS*R0F+lA(=l2|e}p z`6iacOc`vaURt0OUuc;#4JbFhybZOSIh~)3DIR3&@sWWGu`CTP1GF1%aX$0^DZrRG z!Fy!H{DPHs49hLB_}CQ{VUIC%p&fXN zBZ@d4&kd_+JEZ%6WI2DTyZVd{*om5J?L#9gW&6#X4w?WIx7Ps9GB67;#gHDRccr~~ zNW!B!i8N0`dB&p|l-bb-_z?u$t=&cjZ`<-(ql>OuMpS~8?(H|uz)P;jP^l4k^Ty{O z+8is*MbO4(ISZk^&THHyn~XB~d5!PYokOJF9RH~~_NhJQSjXmFE6CI;p~^q6F%Wv7 zzl0a3$dK6AeSwuLgg|ivC3-&?zhUALLIOc-`HF-jZNNwf^8&1(s7|}bIrp7)?%eP+ z6sshPOGK`Be;pN@Y)E!PZS=&1?V7Q>DRM3SfEbvFAz<8h%FCPJ6U?>{sYH&VX5gq$ zf+s;eA=gd2J9h;;AIHE14AQnM?h0sG5eO;1Q{A_wYE03(drl_-KMMwqTZrVEeA1$h z*-cMXU&(W|Qnz%NU66p(bz%>uVHgAeAktzs(dF?G$<`ckh0i2C2KleWAMMVhExH#_ z(;<8{|233Nq#BlPeA0&2yGaK?OUU0a)TcjO8x?{O_nM!H z1|xxphf-ld1B~<`fUKn{j{*?p<#wnW2rPc&?n`mfy3(p}zVJN$l35ZFxvLyO1Y-)u zXw?PldKYw}8{)DdI9j1c+k~!Arv2#eUp^^iaYa4MDPIJ0?V-@)4y8S}CM4xZIy0*9 zZsp#2k6II;008@z#B{ee6XXjoQjcfw=;;ZYhqG8oPgUpCmHK-hD{Ti5!mU;no&_HDThF^kfI&iVa6zn)+5~PBj?p7co7yx zatj#{njfL)J=>YMNwoe=|8Edt@suxLX`inwvjxdQ6KRolyT))EQNQP{EztKGzo<6IHglCNvuD|h=P79c-&y!+%81r z94qB^(Y+lc6vXR~c0wF@?!3cbuJ0KnjlX>Jx$p0=Q(nx_s_tXSz3j>{c!)EkwH5jt z$!FFt#`T7`J8gLQq5=)lp+ot=3WUDe0Dj|k%fL*Xr#GY}FP@}6%%5nXJ4yF4dtXaIGgaOb`v$H^Nzy5+EKa9fV8()}Jna@sC z*DLy@utSA@YKo_@HBJs>?wi2ouCE#cW%vDcKQ;fe!F~f8!-m#G5U2yezkZN3j{Cc` zzP^=E(Il~Ccr1hjv)XNRg9{S2=RhD-i**|G!$pzw-*M-82d=JgN+d?482^@`BvAKT z(*EvtB2^XD!YP{?5B0lNf2T-1f!<+#X~lrN?ym86s=;(tgxF+UmVar{075S#17bTE zRV(+L@ZTGN~~!z7Jr%(ET>Z1tR^+#A$h z)ioDNQSD#ZZ5K^*MvGu$yc5UEgaW$!X;*FRjma@f^?gu z!t~JT4ag{wCQun{u@x<($MEmHju)xJpY@AOr2Qk1W7H70OtlaM*)TjLT|(hFBNJSf z!gW+05%os+^qkcr3<&>~(F>4^i5k!BB$@1LwQj%d7Roi7qnG(RA+8)z!>zHfB&SJHCzq_?#zJp=j(@$FLO(>&CwNNtwOD>9jT+(-{lK9sW zYC>o+IU~)b9J=ji)v3wB^Qy|J(^OYq*;n!i&CEBQnCC%LYYw)vy`BFnWRN$`#*d7< zwWk%gQ1!vb9bP}%NYn~~E8bDWR-A_&$VN^HL$WX45-2lC z*Jhf)I`Z|M$SRx6z9H~wTpBD+j)MA-Z6gH4tciVUcDL|i!$dgUPH1x=-xEDIAy|pp zd3YNqW8y^p;rMx-MW7eRQ(=V@W8%-Ns99An4;JS3~@LBjd={7^9mkwd;r|=M$+=BOA>cGik1P++;|9Myep8b+o`t_c&|)Op zbptS*fJu{YxF#qdEYYa!R%dvfneuvi7yp28UCN}W7+4lNV8Q!jt#CY##*Rrui>PTn{yP%tT*7V|H%ElU+`m>FTS+Y>j3+ zUUZfyzFaCsTWc^a?I8>;Qjvz0`i}qh?G+uqKj@#=*w4`GG3>lXMrI9}F9#qjI|jFM zNV!dt&sbi+7@}>(wB9|j%TnX3a1zs^Rp9p<8Tz5qJEZeQ0xg$-6$0uvT!>>5032E~ zEi39ML~KP0`;TgnW)C;Hb}od}18#M z)|TxSFR{w?ME?xtI_j}{E$smQLJAt5L)wO(x{^^&4PTd8^RQ}+{N3Pbt$K{`kueTp z{n2@AxpBrz{$AmvAO}o91=y|ibk8%_>BfKg*}i!>k%UOR|DF||qN*Cw(UkG%Nu+C4 z-#jo&`yt^doWa7DB+YOGx$RoG#}t?iX?4&X6wd>^Wb#K*rPSmaE3~Btiq; zCBCQS3Xgh{wnK9GUz>s##NKJ4Og0D|G`$i(^jq7kCoU~1wb1c*>4BbhXt0Ly=)WFJSAqXI{?W7Ecc zQ4e^|lFQjJnA3&E{KJ^me6PrSRDvFZ&ZLpv^*b+7tsHSL{#XFMiOHwmEY=AXS%8mq z4^zHbTz{YOyIFDB|N7p#zjvs-&GP3T5VIJZe8rz%!Woyhgw;;3e+g0= z)YWV13Xqis9Dzz&v&KW~#)G#Xy`OdS@%AjUEI+6(3PU2BVI6;E$n4$o$s(1g7s|+w z?d=Yp-yzPL5^y%pzn2hUqBaMb)Vl!W&YCknB+G{$UeiTHABWt@Mx#VlxFX9fxgj~k zb1cK`u6ysVQ`KkgKMlI-b-=H#s2Li{5%`9h<4SI(gNBwAB}g8<1mRksWQE~n6M5TC z==Mh&Rzl2XHsf-Rfl4)ZiNm#i9y9vr0Zv`dN5jhu5efN%>aGdllcuB!T;~|mtyHuO zi`a+t4eRMr1KQxITgdc1=Yg*{Cg5~Y{c87xYz^mqh=n6I7j)MeSn z5BY@5XD$^gZib!$>2htc-}>;|fIbI&_cl}&2X_>S8J7xWb&cp7+NmTm!|%p!TrPkl zZRiMy!BvaX6FpZIZH0Dh8KTh`du;M7pfY@91#*LewYuefv6iDOG)dLWDO&U0@lo>I ztO;K($E8wwK&{ZddYRkD1RURbPyFWGZN!jh*bcHEH2m|QaYt!mVnF5W?;3}9sb~TD zsU5CnYZ`7wvE>yy$a<=D%mn2vM!3hFnbtA`*5%pWS4b$C{!U(#ajN>Uxa~FHq0&A5 z)wBw{1_v*q=)SO#sLm%fywRBCF>+d-N$QsO+%4r2o$^G;@aj^tHnRrr_j@7v+c}Xa z;zx=<1d3Fa-PHl?4sKXNcG)8~3(HEqNP$E}ke{C5&!7H>Yd@_8BcB{;23*fU($1DC zPS*@x;-vO9`e^H7Xi)<-Y3DC985f0BgYICh_k#RHuK|n+BS$FNhcEmpEGj3{5yFS zz~`Egpc(l@$X#X5@jeA9A*oh(lwW zFK*ZkM{g%!usr!=YmSNgm3mv}$(y%``GnzwCP1p*NVMzgVl1%r7gBUgE5<-dn&u8+WmsglgkDc zT+9mb4S0yaYoWOR>yy-GRc|5QXVykip!g-{7c)pciRC-~v?o$bGilbtG^;zrZylN> zIXJyS3o<9Z7PGnfqWysvF^`wAuQ6~yI7TY4=ys57X(KM;_jW{!^B=h4VAZ%FKK!^d zJ4mmT8)&rVDaO>=13{mU6x7WY#g(+Pfb6o37z;xW`q5+R4TAk9I?sfO|A_aK9q7CB?H24^oD_175v9yIt^5Ily z(&MWZ&~FZ?t^)^40-QmW@^!Id=it@s!RHwPsh@;smM^U72oqE=_26DOq%nPNjKrRSR=v9RYI$}c(E%M7OcUMMNDBLFKGU3PQPngHjB3OiyNS3$A58j`C z!@j1@32j^`^S$;Rbd9F~*p?=bEGjYcO4izt5=4{>qz$AqXRA9`lgefo^XK+C+84>Z zCDrv@Gt$=I>k7h}z8S%ygY6toTheMws629?iEx2M(Md>J^EJCv>hcp{7DmAYjy1j*_+Bgja_0;FZ@b;1nsXXN> zE4wNOQqPJ94J={N717HMtJTpn#ge7x0jGMj*a6$?nwwTE5u&VHiOeD=(iRMFnfLwp z!_RLd25hQY43Cyrq-Ul+_j*Gx;DPnQ+>A9pFAvyM%R}|!sjhqeu&dAGJIWMLbyZcJ za)#*qp^VESw$APT87F7m`l$vnqB5(qv&6~Y3;DYU9bcN{Dv^bcm%oFlE2~Uw^$n-) z@=7vj-$i>UN)m2Uvf(tLP$cSJkP#zn@HCVPOH8SzL3XG{1L2 zp~KhVrC{AMYK)dl)-FagU#;g_BJZxb*XoukOhDO#At$+%Xdw@Y(VW`7-96jk{~}E) zBnJG)6fprCsbtOfGMPMVLm!{llk)U#7e?dfDL^~^0=^K|cdg7LGD1KS2vK=52M85n zgf=vPzXxizqmR0@SDQQx`(xu)Y|Pl=p8B1!S-)tYJH4pS*GN^8|I1!#YR}vVH*I7F zAttDt-|h!---qc1qMQ*A6Rm7ftV-WwAv5y=Zr)J|<=RHhHtr17Hjkuny3f&G>`yEX zXbr=~`$g?`KJ$Dho=-6FJgU04ugqJ|aLGs{RgbnX;my^Zv}(AaT?^=FDMB{4^=#n+tIkjV$5}eU0_eGAX zj~;lmENbn@^pmjBvZ}kV6?qxrB>xndSC=rPcz4xJLK8-Y8p?*%h4r~Z++FLyz6^XF zaHLC{yrRTKbk^FED7y+ddfti7!xIZd+A&DvbhbubJD4d`(A5#`(Mx*?f2-;MGWg(kYw%@=N2!*nX7dkbYP?(d5 z(rTnRr8L`6Y9FyfC}ts=Hp@?ZWY3-7{plAj;ZKO2+M8?MOGpG4>RV75YhWrYSjY~d zaAcOXNtiHJ#z!z6u^EAzKU-)g_2`Z|l#U`Ytd)d~nj=fFOl4)$0gVb^NMDr1b~)b8 zuJj%Q4afGGBSKItk&@mL|E`$*N?;|MwwPyCulzVHatJ&WpyCA?fu?Axbu_d~gg8)4 zEs|BvTmN4#1>u$3yq1|t*eT=(KxFbfij$cJdJ&Wqc>7kg?rOHWFO!S7{{Y< zZ*RaZ4ZQtDaCt)WPAlS!rJN0P#cdvj&-u0vsLDMq-)hEw@;8ZsHi&+lx8o1&hWkm| zk@-c)c`zjXJ39rew(vi$e#BKKOxhKG=8H-M$Y?1Ok~qjgNaF&g#NCP*GP_I|EyC(gRWQ}4ijOl8?aUtuYp`VkWrwq7>HtP9 zTE7^llw&jH62C0EOc6MktMdZpm+gC0DbPuz$}Ez7`rXubdiQx+nt|IWD9PwOBy!Ky zlyETVFrZ3#990%tK?McGjv)JB1Oxhb6iMG1542Gb&WWL|4dnK<3mV+M#T9p4HAuOG zMMmQA>uoY19c-jNXE~LiMP;x7 zh+Q4seQJ1DT~jkEe{sXa%l<}*Qj3o2hx%v?j5Kv6_W~t1O{kXyCX#bl%$CSd(!bTU zG8#;4hOwy2+11=@eHqqH;Xzck!@>9OCsx*ER4-XM&iB|H;fVM0Vb&G_pCWmZ2&D=+ zows@-B3Us8ZgfOAUwZ|VhknTCm7VV1 zv5}*u_^_v281mo`@|D~N9~ND-BX`y?@|>FQ?^KOsXet`jG*)=bRGSKsImpt9o)IO3 zT=Tn1++}X`fJjkjARJyw6L~copf8siU+KIglvNRAl;J=(aeXi{p4)f~_Xc<(>X{H! z%(U54D`SCO~Jd{oM`^1cxe8$*mg{ z_a7#gBzIW=;=caGExU=@T|+Fi!M2WES1G~z@V)G%S>(OQdv;iGOriQSS`?5dFA&0G z^NBRpIuk2QT5H#&K2Up`sxw?~0uSafjpPQjoZVmmIq02msdFfE$uT3LOYb%_7L!nP zVG6I|Msjw^sfOtnXOn7X=ti~28}bP`T1#Mtv(W($muZ2W`T}-U$l73{_)hw@-vu3pG)=Bwtr(F*>NOA`NDpr zV3Bo_XNohd`R$o(_1!i1{5bb-x91oehS>q3co^WArO%)fve;KL>{H{WN;}Tnb~Z3; z6!`bE?EM^3DAP0fKe7n!`B6+*%z2nvh@+8YWqGBAchy@M*}?Rr2Z`R(zh%XwaoPdl zo<0-0liUs2=Y8nV&I!x>Od^PEqxjL&O3^RX)#mnp2(lcjD1*xWdBhM@_p1_+awVyA zWNFux)QC)8K8aESe>!)>{AnGw17a7R@7ROJr7d(ttd6U}RsLT_9aSe54n)}1N4sct zYnpo-d#)?LPzspJ3tNQ+{2Xw=zc?wdVzCeJmF`k9oL%~=A!13bh`J)uFJ;^`@)9{B z*NOr6qN4SKkz)_pSCJ<$W?Zy#lbS6=9yDCdaM76wpr$M2ds_h_++I#bmxSaLH0A7xOj5#!|#ZtHs$kq|FQ$Ob*6t z*b?Mm#xudIncj{Wsga^63Y5<&9M0wl5Il{`vnxB)XbepHFS$MOF$C99fSxI=A+#wF zh@zo~2U1rY?y_>9+*oWRwP(e2lcsvsW5TIAwm4OWt+LL{_x4VLm zT&yWJoKD1a>1J8PVhwMbI4`n z-M(%ukK5Z{MFLA!`~w9p%^^dK9#-%y_Cnl=H8AkJ^0MaczInZ-bGE}^T#0Jc%Ia0c zel%HcgWbx1Bz>y`iH%1DY+eCPHJEE9J!2ba9rqH|bXg4E^wRI6IG~&ETjZ)n2ZEZ> z#_ZN;nr&8cGZ`z5FC0>R6YfZepiKtszJhWf5`ikxEmyazm}#IZ1o_>^FMH1jIOMcl z61P);(`Q8CCiY{?pPOrpzM6W;q=jR4Og9O7|#cxMhyDu{k5iZ88@NY<=i zJ2sASLI^i&pLNJvgK0Dtpn<4Fg zC-AH$W`8LG0bx%ZV&ZAc{h77vA>Gr?^rB7ryzGP^oP~8xZG)7Z-imxpYkMQp6wFj4 zBH0haOiDfyj;a`zeMt`mD5Y9%Ws1zzML~ua4JwmWJ=Hv4>wip$CKp z!dB(F4=#n&0D|}ym$?!Hua>AFkcl$Li|Azx;g2TqASY~~1)`6l8X1=+#~5VFLEnke zo8$DzYU9DXrgueT?J|~O{5p2l=N{GSG69Sj{spyh86Zxri2k2W@`7pXIbh#a)7G3O+Mf}s=H>+X&u{jl{q-F zRb#_^C!dGGo6afz?*L%2j=t&tMQxp7!x-TZA4U2n1)-S0fN1pK1DnRaGOj4kFW@J| zs>o&AH!@h@Ay3bxKoB8(hbX&I>RAfs;WEi#Z@!;5vi(5`yJ^z9Izp7Qa2~^p7z(zp#f94)9kJbvzS_ZR4DU9K%CyYuadEPmySG~ zXd3OVJGZ7!>ltSPtM`((k4Hi^hHRCAcHyvt2Ar*We0|6i3Mr(;Uc<*sf=)&aNUq0d zFAhIpuaPjjloYqxdMk&51l4e*W5!2&*$exYWv+uKTUS$!A|jHF8y*&{?*i0lpw=Mu z{7PtAu;lDrB&q5WJm>6^zN%_9a-QxBk)ou7F+9AONJAu|W$ubWaLe zP)bp@X&5be5>t9|N6i=cwkBJOn$-t3^;)NEWe+5gq-Nq6#gWk>c_(Z`RG3mvptT7| zhQ1Os3~LwaBjk!03JwGrB@=kV?G^y^c|@5LbxUF>B98J@0SfZV>-DIFtIAm+IFg!y zeHR;bB|Dqbn?sIWAY#Cpxg>cNVHEA?&oa2vI?Rd6cvnCTdm-3qE-KNI{yBzjP@rj9 z8;NEqL8A(Dc4tvdN|+WMn>4StzkrF4KezDQU3a#A1mK#9(Sv<8V^y;`P>W}*oqWg@ ziIcCKrB_C@Iz^WJb#UH6=@8l0!^iZ0=>;zw&}+n6)c(tZ>pW19OlER zWLB~2#Xf(sJ#$FfltO=qlFP>>$GSLkN`tXy4?tovq$%~>d|VnSK>&*Q=2|z37}4l^0~dDdW+oPc)~1BtdWDRc&4^}`G#{6f)*v>(D*h)xa?wC1P| zuiM|5gF-FfNz2Nfk~9}spo)6c;!xekDeJDqsH!OgXy`0ml`_{J;EN*-8-Q$jBEtn1u_abBCK;TXq@M-rGSA9lZ48 z+0(j6VmQ@pKS@4e0yL6?N(;OsfR|^Hs|Vm&3n|-M`*4-v^uwPhKyjGoPIQ;bc9m`2 zo%u^3rU8tj%McHCye_FN&!(0#Z_U{`AuEWbbg-+q2v#||!zy+r?YR(I$-K!gd%F5y zE0?+Vex0vBC3RCyz?k%8yj9P?_ZwHPjcDL`Q!*}7kG#Q)z?lLqtku-Q%o|5TDg{&v0f*u~^o6tSz>rytiXfRtd1kY7V0w9UId!?EBKcG$p;(D3_OC z>?mDCfyV}fBM+B4KScv77^b{Onvv5FyqQE3`x$&ciY3AnCel=evnEWSVKi7`ppZy^ zF;DYJ>+;>In%(Ry;E5`o>f~UwoJSLUpz2yi16=fOo1BOJ4J#q!Lu{^BBG`s!j;QFA zf|er@W0V=A+tG0Z`5 zthne>w@ntO8cEtD>4r7c|H~PY9>h!O9HlD^9Q6 z^HfknTM9W*eSfgx2-hpi;_3|=HoU|y@C1y+&(-P*DzkHj1UHb`WP5P|7OqiXR zV6qaoTh=tO{a0ylGtxJcTuM1D;5<|;fs)0>&|}Y{VpiItoa5L=UON1tSk;2s!{*Xu z0LR09>T3}R!)-Y(#WKvbzIEvgmO~`DhJI|urqJ@vwp19~o>RpvnIuq2wOCt`Ek1h=^1*PXyKI#gw#M zON&IQp|JkR8cVC$V&lwF5lI6=o!%j`tJ>S0tJq*$5=ce{L_>K_FkVPHMKj{<$i1`-8(mp5zHr2c!=v|_RK?nYLn z;Z?q0J;u^lIB#Ii3Z&;wgWdT_x;;N5-xS7pp>qjVJ9{#61Lpy~ghm8{lxm;z{6NcNRXGWTQt zYz)^jHaVXpl-IM&zT&iPPLY)sXWe`=htitj&VSN{@wm_l!+@u%<~EPjBEJt<>8iP0 z+^&`+Ma`C^si(oJL-OycUEcF_zm6?k;l#n$j+p5Shnez5>me`KjP^~{WV*lp z_y71GNE}KyvbU$yO%0zz$mgf%s50c}d1(#j=~$?E(dP469bvpt7NxiN@!$O2+*$4L z18d;`ZLo+)hzIS{r(0HiO8Ic{lb42B!iCnPmPV<+o@T9h430#1{sQ`wsdBXf! zCL@yE8E7Twuz1Kpq#wqA&{4U@?*e@38ThstMkACtSLOJ+j^;6;WgfLg&=$(=hIXWp zl~z0x;V2K9pfV(#;yM1#1*~EzT|TRkpk6V1u!H{rLXpe>UY3vlC1lU9;$(g^D%5B`XLXewxaFiD{b>(sG+`CP0uo&%x~uXPhFp zG#`dFuDN7-D-l=PHV3{PXmHQNK-152%6Ulk!2_6zOJN@eQNx^%7J5Y- z&S6p^`qT`5&4ytGU-$~AqnM_VX*0y0);CJMXLI1Yg?~L3fAp}R7whltYThKGc;E`t ze)|qE98){p=#>?#>0U-!x~8)jk~6lWx1W2KJP2M9+ms|R&v*SnWaOhkNU9pDijQ3_ zE-|!eG)3OWM(huSW)=B=7q1_9v_L}Z)PhCf1+xbX5lAKxb&e?M{^4Yb)nuMj!1Z^F z1`5{<`2Dh^^q;0ebIL4f?ElkIHlcr)a&D(OmDL@C_UuX`NjaK1j)po4Xsnvu3Efh` z5|7W?E<4GFd_;+5Iuu~GYmGI=M$NNI|0QJET(|CsFHcckkh{x%x>egiW9O5suVm?1 zUtGM@>6%Kir1LS0cBYSo8(Od&QKDkZ7!`-u@d(F)#O}w#0xA_$T-Daix{L#3Udeem zzORA6RC*fPEJVU%=SZ+4ALX=KbH`pUk6ymG=EI(Fkb_Z6dfQ`PUHrRBDTfC(; zhKx{=1oJz%7x|64)EJUDSZOpgJ*6IG!R$=f5g-eQV#=O2#Q2#wKgCvZDL%tWpYwXh zV{7-2ZY~iZoQ5gB9I2xKN6> z1T_s+t9kH2bC69+NsKL2~7(qlGGbsZ&QOzHLeGrLy#j`8RvZu64oe=4)e6(5*=m>V#slkyb5tu({7c@a4K zwgzCw=n@(_Ur<88L&#_b{b70*vs$b}pPBjiNa|^%7K#AD@0y~#o;(T#VXpjvzn>yP z*AErrGgM6q!O|)hkyZE3K5O#1eMn=BgK3j>jxBqI05pW zVcHHNW0Yu_$1uBB_CaKIE>}F-kcO#nrnRrlb67KLC&{cd0h#tb_`Z&`O)+hbA#>Sm zaP^IFkxG~&`xiBX4~COqJ{T0G@Ri*rp zqR>@5R#MM0fv0$a8H}A#a8q^}=rk?*WPh37#=ArY0@XV35#6R!n-kE7B2l7)lNy-L zJ)e|@9(0IZWl0`=7a9CM=rg%aBXX?l&A-4LU z3#M@vW>l8gRV0T}bt_k9y3*VeR~3KfbLZ?igj}x}>bHVgtG*`u78=Ir3%&Vxl+2Uz zp^>9IFSIs#>%E+58fO)#qFk(WuHrL$vhXUer#0E&Cyh?87U5tO$V}VD=~v~^c1#^M z@F}ZSaNYQIno)qP6dkU9h%N_fSeh10)$WCYBw- z&9VB{9Ix$+m41R*(Nr}VDAQ1GQj~C8W3S|(e&}7KpPSM)>fEJ#$I0hp~T8m%31(> z;06OJf}*G(%|JjKqEnW)LTDs`LS)0xJaH!+Phb@^g^m|4riACgq~?G|R%g#0Y}@B| zs^I73L82IIm-!KuWdDl*sJix<|g0vmZk|h3)*Wma*2_U1_Lu56T4k5DV2=<0%Vvk`)!lWfFQ+4*Y7ep*8 zEp&nw5sXFTe6L7K!Ql5*XD`h`!;S!i+OjwDjcJR18)`Xl`er~TG?VdwQyLyQQZE$5 zc&0ZLIjJ$?g$#0tc(Aa}82lGE-st`dKyvY8+%QeRi*k)C#u_1`aIFUPnfrhoh#jg9 z?;$i*=M$RS^Npk_X_$A}e#_Uo9ai=HY9Ui%LWakgK!~Ba6?=eW0-P=!D98M^Ybe2n zm)VjWdHhf1dVsR!wdD)ZeNd2-K&fEKAfN3SWgksbQt%SoeoR|K-NlOK zIrsZJNPy&>2Q*}52EPnb4E0fd@v=I_g9H!yq76>HJH?Kp9L$nQ`I(mJRUL?Mv4(6754F~grecM}kMs`3)lp#^qqJig`#L}4CxJgq~;H^K?Q%|%YFwjH~#r97|FN}dp zzmBu~wAaURh{x_1%QBMcrFPuG@`!-1$gF!c z_8ZAc$Ztw!P^dZt8mZm82~|hKic5S(V4xdb`8pbsHM5h=j+4avTEMUFntKsElnn0O z51VHK7GqGhZ~1Jl1Q=p9-XJksx6!FewFPiVvDhe^dDpOeKnTuo$vpCEkKww&yY z?RP}N8j4X@*a^*XPhHuW(}ObyBfZ_Rf?3?#ULd|@&9UWy?W_GmS-r%MhfxtF_IB6F z@SC0}x8Ery&vy3~EU-Mg^GrQ4i~Uaeng$UzYV0IzkYI!q5!}HdN(2Mte-X_`5!6v|^N1YQ#L#?w}AbXG>Hs z9koDBd@KUBUaN^@_H!>f)f{ETt?VwDl_GHLrr@=mN?6qe8gPcQ+B9s6;c{Elux6&` z^bufb-vS%`y}KrGk2!UlJ(If@IIVY)*fpnk$K~*dXXp87>aHm^+!6|f=}%UZQ4D`EtZb( zmQPAZm>~@baz$gNO2#1Mr`!!A1Sd!OXIEp7dE!g^Deguqm4~|O)?uhU!lAv;#s02A z65MoF`92+Z1&Yn+rhadQP18~g_?6wQskWy}ZK`!%2;@P&UTR>7Q*u~?t&0eK5m2u? z_FP1WG@@dxw&FKfuk^8a6DMqR7xNotS7lKuy)xfBujEK_J(Scq#`9RW{pdU~8Xhfu(EPtx$=YQ1#j$OFZJ+ zjL@f3p6{MJU%&nK_2c*Y;}1H%lL+hrB>6|w&XUUh=7h|;)3A-H<0^n*2v{vz6U8v` ziD;H38HXGWf|JR|_;bL$5L8^a8LK$_ zq;i#EG2>kCG@o+>(}LV$4BK+|(T?VH&GRq^1^S`3W@O46P7DR^3>PCbn5yCbM6YNAE|p zw;x$nYsLcM2Uxz3N&Aq4JTNM`U7V6*eb~1y_QcnRFKzU$-6tfPYMiWunz+)P+@U$f z#fES`T?lhCi8PJIqvju2zN9io5S?P|n3*2#pipidXo?E}GwMrS*!<)~K8gsf+_dYk zyNch3{WTKKO`D()Fzr*t01??BgbOJ3>FPS?g!`Ac_?TUiuu;C9ny=q~zyI=7H0~8m{=hx z-eo(~Y)j)nXe}~vQ0kVC`1v@N^4kF#45*S)K*S>nNPKxx3B?*>*Xbi9-%Vw*ejud9 zE2(hGDRN~L<8bKjQC?_neOB1X*VMj1^-8KiF`$!6IRCHib0&5Jj9!b1N zi3Z%z7LdLe0^b%dICTI={Sl#*5J>vuwyX49aTC`5w89I>xSvEb_bI3?uv)E}e)0o4 zUrvx8rBP8_TU1PGx|8{|Abl)bNt;&g$B#Hg=u@yOOCd$(fiRjjAQbIViaHEOd{geP%4QGg8Dwj6hI2$468eh$s2+7= zBQA4}^7w_gV4uzvOcX0z2pcdRY`cgTJ#x~T)q7;8j=~CUbcyH|3zVYXIx;JTL3eP> zgOYJO;&F4&Z-!H0wX`NT)OZ-X7*^Exi*za0U+m2e7DP%N>BwC02%;VPlmMASl7%$% zv=t4!#}CqPnG1#si6yMqMNouN+R>HSng)uUC~q)dapo|pm4?6-NF_HqD|wtF>?kRqpemn26spg& z2j^sAsiK2g@??v8#Ap#KU5Kl~|6AEl@a%dzmQL6VD>*jn{M&;%hLRwm1U8yZki zW#QZ8~kVE5y9IBvt70f-j-D>cox$ zA_mbWps<*{_!{Seiw=4pcm_f+5blFvH9!Pss^;Q=zyNjgYV;K^vKMLlE zd@-!V_MJaA7OTBda-JRY4OBIKoia^~HNE{%-rk|*(kzX986K)?DA0@LD?V+=VO2+7 zIS>w-;1+?C!HLK%`shy#>wG)Yi9Q?@J^@Ev^;ALs%#6?ciEUIcwlT|ou2>d~9KXJUEeDEAVju%onzu3m@Fs`$>ilVZ(8$TVWonAOe9o)21?qtYV? zGHVt=r@q7vkTTM(C?b}pAxSw?v3JVFW!t$CItc>H63d!OM#-qCfryHmRQ<%Mjk?WQ zU_^gP8v8^)WFO)!uD=%Fs+`5O(yG;d`kWCQ zj0Su}jGX<^EK2v_AjzS46Gu92nj_O?1T4ilkvK9qeo?+P>Uy(FCmAZ=Lm2--%IXa?tN!Gu`D} zQc2Db5{X1KLYHngmB(<85;~JuHlGyBJ|j=OGip09EcJBPLS?3)G1RNK7P-|q~CjkKEwN4Ln7-~ z#^lKlBvPd?>dPe}t<>TpWs3p~5KnNNRM(f=va;xxXdM%(VVD&mwH)xI{1P|zExF8E zsUSy!AD{#yf1Z?{ zvAa?ph9F~BLeFmxQ6hD;hB)1Kc_Wfp8lG_J>aYruI7l!pe-bV1@fZV>GZU8{!*a?e zb+ZOTUR^ag8=6PLqcTmS7Fv#19v+R=+czInZ8=hg#fmt;JnvxFg?YGWvTf2E@?n{a zk$MzewJdXo`BFbd)ZSsZd|xC`+^8WdQBISufs@SBAzML(qsO$}GB&2eVI=g2r=K&T zUWz06T#CV=NRa#w7P5Ct#+#n0b|D-R07tr%%j7~`BNnKg_;ohzvnaY{Dqlwo)$;;5#A!6+az6ALnsm6!XlnS#fTiEW7P zh<7r~=#&a^Ct9tvy2uhBeJ57ZdV(r8rEC{~)%Y6nSH;{tf*{w*D3i3(5CwvWmWH4j zM1|71J0ZIRgPMCQ@HNa}d!tgzMEmMgz=uRgQ~WgP6YDP=KV~C<21J zL*hl~Iten_TVvu<&hy~7viLR&Ve5~g3Tx8dgJ5(E5nQhi>*A6xE4Ql$xS>O%Z5$Ak zFkslk8DRLdUctLx@+x4*Mr$NaA14Wx!HXOWDXfUH*s_Y@;uq{2W!4b)QWr&0<3)ju z$o8wqAdNy)420Ys%AvD{B(G4X!6Ti~btA=4!WLwS&zET~caxyYR2Pn>a>30JRD3yq z;U--3xJ-e4#ST-d02#`Pe^9!w3foNeF=6xPXb+h@!qUDb7O4{iOl+?`RxaY!6H)r?lq z75$2sLxiDq6mhEwRm@YX>)GZd@_WlwWu;#)Z95a}&(QgUV{@`e6nPBY5P+Lag0e*w zWstMntC?!MlIRYxnt3Gk=>t~9?p=0{O=uuM1Nfe_P@zkNUGO+LsXH~JtAneS#YA`V zB&Gpm2M%d>YQ;RKVeQBT88zDofm~c`Ambr5-RB9klu2mYyPmnQtkKtDaynK+waXHX z(=(93U!15$OxF_&qM7Ls4u&d$R$T2!vhQhEOl4xeo;x$U!MCrd_?ob;M9J{19>Y#@&8G;wlCXOlD~f6s}fpvkTx(lAC%dj97PQ+4^sIO*b< zP?C{Tc!@FwM(F6&gpl~uM2{3OOiC<>sILkIY+B^C>F>f2ymMse89Ut5#y~`fgSo1V zRaMqX@NuzZOfg%ZBExg=Miw@fsG(nwgkpzjPRJcqF!q_c>zbjs0Vkv#oL0QP>T=U2 zxP37QM>8{pg)IdleME8L-U0O-gws`bcU-yR_kic4$3dbXj*r2Pz&BI9tyK&RZo=H5 z@c_B;_2NdTVlk^GpcR{Q^yYMT?E{N{lzh}v+5;;>%tIDa$~VcMKD&ESr2WnEN^@TT zpvTqH#M~Hx8E+kI+`~c!?T@POsTzKOI5W17z+n>WIf?-Egu~^|E&gRpuW)_SO#_B7 zP!MG%3~Yah-s5XZP7{p82nE>|a004Er+Q)-mtUTk{Rv$~M@+Gtr0h3&RykVkOG_2v zgiR|DBa8?p3THd39`4kKR$QV~!V)-ZSD$K>O01@!lWPm=JAWFBP1r>o)hsbW0!~bm zhC4C@XphWOT0{nnc_#+^TTZ#Vrtpn-k;T;E{%7qW6-#PWw$cC32+dtWm-{Y9y0+twHzf5^vUinrJ`g8~ z#1kfgvP^6^MPglL5VT&pxvnZcPZTrR2q|ESWk{OzXd9^wLKJ6?3u$+aplgNsb+fMRP zV9vQuG;OM+{L}npd*e!)y7M>NTgPY$L5%&zlbw})_(NlsEHA$+w}fIPNzK%pSwa#k zxkpO4RL-bzsEC8M1PBI!<217BqwYAF-hu&35uKskr6<=H>!=ZZq7>Iwc@H#k#iW!SGI|!$~5fSsD*k!GAMQNc$Uw zNA_eHA22v0X+YtsZ99WJr_@{k_-8a0#5bb~QJx^FMi-eP1Me7c$Sw6wL$-~i+q@w^ zupPav30>DZ4|ODIOV^UGnUz0@;Ettj$cu^!Nx!_|h~J_P8A$?{ILA7asO_YR!*JMq zD06_WimgjM0{C}nPf|@8kjR)tgHWl?B!RZM%;$(dhw|?mj@-dAjSSdwz5L(uHX^-x z{sKUh7bq3sxH;B58HnOvJqU4jW4!6}$P#=9JeHU_Ku`G=>SG%JpNv%<|Z?oak#+#T=FxYTt19_csOtnuKbJ-839d*941jRF$TUEQ}TyWyr|h zyb1z|9)D|9(Xp(5-6!s3KZ8OU<7ZN8~0~bqi z1AEa01eS!vIj8fP1VAt9o8Ks1ak7NA2*E&4qqtFEbD-Bg}9pHK1sv{D%k0_;ifVEq`x~th`iHEl={;=oT*F zHDRpMP;6mUY7m~;P1@t_gI-tE`AK#{)L57MbdVxk8IMXKaIAG;ka<9=CXAkzkai z=!MqOXQt-t@`;Pa$gZln->3WQ>+61h!%j#6A18JteaBFn+Ez7o9q9HTGatj#DRn|y zcs9mh5U15}K_EEAW0!V_Zn>`2Eu#yISCCf@kI=x;5#8BQImK8K~I%*`B1(9{BZnDF7P{lPi~TU-pS+ji z{`5f5GOwG%o7Q8AyBYDV>VxgekH($vjo`3L%IzPTdy@>2k_jEAId(K4<=rDdNeFce zl=o>uG}<8vi3^M5b(MC%Rxn^P=)yPY{vG2?ud+M!6|F&)Tm;4yL8B2qbS$onelEPi=j&#OU$3~%s`@h|wK~CIZSr=q0F9-9tIk|q{AF}pzqPZ^ zZFSb-@K(Ef*}Z+fG^&!%w>;3}4g5{&7_9i_8zi{;Odk{|7O6X1kr|aG%@IcFSmeP6 zW{>0?MM8Kd+|w9I%_^vNPz|PX`wu5u4)97xNjR=hyN|mUXJ<-1M+Yd^yiDYFjCOT>wL+Nqg|h%96PgE7;5uMIyOBB{b1gW$`Dz)* zVLpj#vtWZ7j-IZ`8Vhd<)`|CILjgOE65@O;1Oad=PVv4SdK1GsJc;Q~c><@p?(Xf% zIxT8N9RE^8#eeT$&r6b+6-EwB6UK8(%Qu2U9Ek7aN`1K_kw)Mqdn$-0EB0QQ>}Kzg zK5MjbL=MlF2%K{Jyd{pRMS>n@9xT|p9j84xc+95$7T1RLaHlXM^P2wz5<6tNDk1?! z+PlOK=`PBO>>mloz+DSo$WA1(H_tRGG;kqN=*IR8I|S?q3I}Q4v+Mg30=t4>ZhY;f znoiTn8D-6k$at^S!sMD{Cfk4`VP=>C(QbOBwHh<`EHms-oo*}%^Ya1<*)l5dV~0f& zSBGZ`3+=8WlPB9jDfM9Prbs+0-mCFfQ9|FWYBbF_XQ!T3B75W8+xkTpMKJ=NrBV#e z_Da@570D@dfv8*T)VOLM7!af`H>hDO;CgkGq{%fyICp=cMJ+Xjqx~Xe3WlH#iH(ye z-mDlA;Y0jW80^oVGQaMkK24)s*V66H2tafSIHVh!IrGk(kN-{QtJ5N zF`MQo6dNo0+zd}L=lE~{V=?+rk@YO_aRMOtduz3~<;TYgWGqYr6aigM_Si8`XsZzh zqS1Mi-d9Nk+Co*fZBd(~DF7VI2XE=GSI4WG?kyRx3Ri# ztf57Jl29MY_sRtOIDLiznhB{IPGy;UN{S&*Vc14tVZun&YnOei5=s7~A$vw?(GcHU#&Ce-vgP zQ5TQnb{RDd^^t5i-!tF0PH*;3nOURAG_wK@M*c@>AmANus=Ib;L+V>ojb8kef6M2M zI^ED35cHLE5(6iXOkxygt-Nd4 z8S?P3-eK;ZxfJ7y;)E&HDH?YrQtaz1(Q)Uiii)+cPjWP*vP7~!pPz(BS;G}}d6~PE^k_h^2 zA7!kbx|TN9RS}j(=T6f@pvVqo!6!GCvkGF@K&8vOIE)uQ&21_v@r8a=ed_Sglywl- z@Qs&j)NA1uX(jtV?+nFgqDoYQ@I+Xg8jCoLYQ>Cd$APmMs?d=TZL&I}g^*lOP-4e$ z$+=gFPCpZRH4zQghe$XvbCE!9+kzg_U@W(W0fI41aX)U#^ucwSayf9w{n)h3V{9}I zU)i&GS9iRsLhzt%1YCGy7Eo_{5pUJ8%C;w<2X9=XID~Aj+f@?5no)o3VUEWIhVzIf zOQ?KYq5;?S9hPXRZcMGqJAsb~9u3UXr;qi&CjDdAt20^2^t{kHawEwCut2doX)JId zfI6mQi>a3lfLjb^1ef}a0D_MlU@__KD-p;J}K zZf+8yFE7VhpJrJZ!Cpth>y-T|G#tyt_@Ew}=gZJf%Pix`-N?CaU!~2_YSiwfI-rb!f$0Rvr32=RV_9{F*lI=kk?tVcJ)3{=}nCWclpQF61 zg!Lsd+?znPQW=dhW&%H)U3sw$KTVq9;#Bg2t9Knu z7!o2YYgyzk@DKDaaFU`jBaH9`1FaM33@RE1*10?Xncs9rg1|$0Cp8ugCFB@@8;s-^ z#d-cHQt=n}$cC~Qs8o?{7IqlEY*WhOlU=sV5uGlS{;a=<*x@l)o9Nyx)YOi!kxt^A z0C~g^;l)X-`>N4({e4lNw=aE?_JkgZ<5qCWSXc3wERFCCJXJ~5j%K=sRzoOWQgI_? zSGp~5#c6XXf4P84LD+XLwaWB|8b)TaS13;{3NUpsDuALcZVaT0@IC$Ag`?W`K81;{ zg;go8Jy)$R3&?oh(_cHR0>y0}qa?8jcg3YPg`045TAFbdY+~nf4@W#a_5nd~pfs|*bqDnyw z@eksu?5c?BV9n4-{#dN9DeR3ZjxJFav&Ejnyb$wsg1$x+?+z5mpp`|jc!lHytutGT zYh6tC)XW44s|!nvW-d47Qqez>d>(?y)R*H(r6F*0`HsG#)RKpoP*z-;KEBn41D=px zoV2NKv-v8sQIf5MTWW=G9?uTk z*TsV_CAloQz^`uIzL41tcHnRfIt;D6RP1RXkmOyt@wUCTrjSnn&={u6w%b&6UNbYL z$hI5tluNmOqIF^mLxKGytHqqK8gww^*;t`c8Z24!fKH6Wt=de9Kaj>&BXb_HvGq1d zjzM&0QDuGEOs#k`GKaCcF;#U=BpP>WzP}F^vr%iVcqlIx>k)RPXo<*guB#FV0|8!) z6p8D>W~KnLZT2jwUm<9i=>z|cq-iNW)QyZc#77!}Ghdc=h$w7Dff<-6N0IACOa8`r?C4?@lj-NsLxkOL&z(-3h;2 zEgTsg6M*r1DO_`7NG+^eA^a2z%GF4JXrdX@neWhaR8%SPIMi{99;!^TJwa8og)w~n zcr0jm7spu|js^qDN{M`a#oqPKrU;&(1W1@!!>K!Sb)r@ai@61t*WrM>@ZFEnsg1#> zcQU=2YP)zkLJ;W_wFAX?r#`3Z6!twUIU@KSa;&L7=3rBG?`$hLTyK^v8VXn@GyE3NKz-jjmQF?738YO8EUwlUT8H=qhGZZ&LM1#0ijPFM9grxgT?Cixuk21#V@%0<(5 z&rH1|sa6xq45obS& z>P{W4c{Sp0kYHV!14XpvPSG0+PSx%*Iu1~eE{W;svQA~(uVNh)?>hX^unY(Lx(pLp z3^$6;VvmZ|naV>bnUy*F^q$gQEUDNJHPlRBy0NQrS1lvK3THeHGD$KlQdo8-q$2W4KM0;DZtwCy6rsun4^Ubf|E4)!pQ5KYNkY zba8=z$fDLd1N@YhU5kE0c%;}g#SD7T6Pg3{YdG65w&F zScr|n7v`PMs7N$ah_Q$YmecHj-Kjn}Soc6thEEOKvZiE0wl~b#(5Kynfwk`stIlR? zhMQ7kq$wP2u9__?NG;9h4PBIxom!$)!zPqN53hdwO-)Rqtr{7{ z8NMmhK&kV}6qrJa;jOJ|$F(iGMtcWotQ11WPzre`7gW2QQ91^mTA{uULJAl4?zAg3 zY!)glWBgyat2`lZLK4-qas}EYb4j-BigrDLTy*FK<5|_ z+U1H7E?85DQWq_Rj)~bYc9)|7WpVU)L^F~!bp=ms!mkn2GLj!J-$c$ ze8h~rDggtgj*UQ&y^HJcOhXpIY2JuWx|r#T(=}21kJQAv=3(bq`7DPz)rA>Mf(l|6h*jifWsNAaPXmO#%DgJEk zy+ls60$Ks!b(`6vNlZoDJ>6#@HA+3RWmRO*D_3i|8r!y*i z#h)W=%wrAEU+5a*R5@u?=6N)QdapYqw9_qKquTPsV?9^@^|MemQ3+CxQf9u$u$;t*Qbs}DT}JIpRRo0?pd@K0{niX?V-tDGC_6z`Vi5-bIY7q0 zE^jRHQfiMXcjhkHA|Ly~i7X;3GLr(53WyQ7==TZc1*Usfv|$P zamJj}wtbmVd)_LC9hj7$T@B>_n1;v}$YktyROmA{(Kgys=vePRVEu#A7UyqrtnF#K z)-txRdwQEFCY)5pU0mr+ZAc=8AyFzA-)jGXn^4a%a~Aq!=uy*+MgoxDC4thTpg*<) zL-&X0bX-xI!ZVC9be~aJbdMmY*>bM3bu`i^d~L2(EOAdES5TOqLFw^uD!7hY9ybwN zSRF^rO3_v{8$&nV7X%~X@=(>_^jdqV?N zyhF}Gazz0}Or_BPk5S1+44(%GJNjJMfRVZ60x_7?&{GO&>G9#q{<(%HH6f0q@ zlk8~Vyn@pTfieifBD4@A%w=LAmEHLh)M91>gOV840A3vbWrmvlFhoW9Z5yhHCC**X zNb{zQK$Dp#@g&nAtf~$_TGdoq!QjQ=qu8sDiZ?VX1i4(bpw4@Bht`}(#NopO4e%Aa zDEX{hngt{Z!wPix?o~NcA+!L{;qJ(-H7uQ96UgaYG8Lb}GOK z=!d*c<YTp2PrMh_vT_7PMk>EV_o6`e*$6NU3B3JK=PW>>EI{3k(TPW* zdQMCw3*SS+#rnvD(^2umG+|{EoPU8tt8eu5QpstemS?3sAN0RfbL*=X$g~DC8XVx6 z_o@Lwd77m~J~=lAm-|XhmIk4OsaS$`?AB;DNw(ptMYc(EH_NTfcpKIiua&q!%+?%H3phYn6jQBJPWnUCQ zgr3m|;hdssH_4c8Y?j7lqYBPVo=TrK0bRI1%uofehii~k;2P#a!|-yU;SQb7DW8Oj ztebY#^w|^UhaBOY1CNvM!VOLpi*`ZU<$F0aTqVQg7wry_i3!e~4miIZPVWQOBTSLO zG4Bw~2xX!@+oBATAeIpzcRM=pMuIc+0itgE@mmx~2C0iyvxL?{0sWD=dj$%OCjM!U zWGTC%mu56P>TuGEH<&V-k)E{*X&+SSeVBT?c{e$JYZxskISg-x4rg*AXWk-ZfCpQV zaz^+Q>A4cNirL0G2 zG!b))87Nw7wF7%$k@Hg-c3VX`JEMIKC z0(G%-lIhc`kbe7y9DYj)ADR-IwT(orN5vasqauA>)lS_8Lw4`IgC$BMheqX!zcfT6 zACA7u(Rv}(i9@(eF5k0tOAZwWe~5E4rFA20#%BgFENDmkrAbuRspETTycUh84ma+V z?%FZsQlaR&&yX{FqU!3O7}c(_S}GDl;Sowf_th5<6EI1YD8x0>ao%Rk9N1omfWTPa z8OBGiA|WhvYgYS$M_{Y3vAlzl1%CpI2>FA~1PQ@j#hj2eM5$=JW#}0*#lpniG~_5A z!D*izt(tLwjrnnC=*P!DH7l0DIQx{Dp3m4eNTL0Z83LG)%=6=Y=J%Qu+`0Jb9nk1w~iGg5CuIvfHvT%%(YiL8j+yS{=_A7;7XEi3Jocj)A zS(;EcCa?SOaQUM8(BdNWFE$g{pB_rtW*#3-**1p|3bmNQCd2pG_dXhWv8E+lo-4D3 z;a4G350cJHx8I`bJW2ze2UQXCgTf)9x`Diciqzw)Gg?7j<|)0qV((HsOy7gcYHNJ2 zN0woO7bixktUrdrr0j!0jr&?r&?2+8BNaq>{Xbu{sSNq2-K*IITe;vJ~MIyTkpC^V1kia%g<5H_-NhnidIDpI$~ z<%VIzgvjl%5SodZe?u5Eda61qyMSH%C0`;QIRWKH6 zx<$%ebXNxh;=GXrQ^lN%%|w6?ZqljY-}=I{3kJw*WgOCrue?Jgu~LfT+#10)A*UFY zTnK?vV_=pWzq*6D@5(R^g^eN1s`+ywNaxsc8dWQ3#SD9fA^UwAhST;G*Y0dkbuY(D z?*&TCK`YO8SFCl`%g>gE!Z;8}1Cu6R_Cdoia<-;75_!K3l}oleQBf0=%WXRu3K|w6 zA~F~k^M3|^vg~*$x+ejb7aY@=CtKHSK(?x5wT z!|?*$@!OB42q2ZG&FVu-0BCjfluvooB7`@erC`UB1SJDCXL8-ha17?|{<%~^H2}5y z_V)er{`kAzEebqzQr|hHtou|^F>2z0J}|o`Q1^yzSID}6i8$<>dpdeI&Jgu=Jh8|&pO*U!&vTZcU z;4rRGE68#P^oJ@Je8r!*|r3MY+nbnAn|Q0XkyNeih}x~K$?Em)F~tR0P?+nOeq8Ww=QYZpSQ-j&hL zDMzVUfO{PgWUHTpj^dBSZ>dY8=s~rO;ypF!#K9GhR*_laNQz6ZD~%d+p5P0EgD9E1 z?#_(YzH3z%mK&;Z_l}*Gq4(**;VV-*cRotunrSXw=qf6e4z0vJfuy=VwEzxBPoq`Yxzp`;Cxg#++h+}Si>ZnZkZ2unq2cbE{3EG#vGf6-qVC*>>+gEz=rT{ zm7os2c~ZYrOy%M)CArPBfu2*2cOQF2X7ztxG@=s(BsiN^En2s}PXH#7D;5=?>p8Tpv+e~$xwXoBHF|gO-4)d(Ch&Z8{ zKvW6$Zl7jKv94EyR^nBMv<773Cw z9_|S*R#!|bOJ#Fb$Pw6G76d`%@NWjgy3dtwHbM#**^rRaAGC8l^Lz!Xrl+@-5u$Q} zbE=W7F7I-jKO-eVX_Cs%D$H{k6R|T>cxn!WYL@;WryxqP=%-UIs{57!EoVx6a+k?5 zc`qeq+&mmJ%r1B(F@Uji&V)Bm&A@>M-Oa6W7cp+Hz(u#wnmZ;MT!FguZiIptAX2N) z+t7H&QT*gY@VwV<8kC}dHhmIP;-z<+x?=tl!?ma^1~*lGm5vp6`c-2}?d}!B-UN&{ zp5t(S6oOzI?tj}I?K$Y&C%G3xFded4X-tHUR`8PJGfEvAu}WfvjyZ%gMHn&>6fK!Q zHTRq6`AUKe3A);uk11F)|L7Q zb%?3cDsTA`a(HxeBAsOhJ~;R5gyZB7f5?Llh^EXcrXsyMGotcI`P4BxdeDa8Ja@qoYh_h8x9F zVIvfWmgD2>DB0DcgiFmUrPOL%Z|W1hFy$_c-b~>%7-w@;vxaw#P~z6!eL5S!1YGK+ z5*(#`%*66Z2Y3Ig@>JF#_iCc1FwiWrIKw-tJXW)*WfQ|E{T{-ek|vE>+f1~`=Je>V zGbXG)_k}Miw1v5E!>9Z}=t(A{Wt$X11qltBr*hdjI8zD+2-Azi44c*$p+*4qrTh}} zc#7@-D`Xs>!5L@UEJ%TLd#wkV%2dTp86=q+-)g2B8}dV>;P~qBz|Gk?$D7sj=*9vW zk|mwPlenQyoGNajCG{GAz8un`_9J)cnu%=-7k?1})S}6?M!dKC_fGp<&v9RPb^ES0 z&9h}m2{;`ixav>1sp9f2gHE?ra3~5>FVo)- zDlF&@JKAZ|^$2CIWH=o3Jghj3L5uR5lCJ(Tv}$3@Te6utO0N z>PO5_N+qU|*sMO9*$nI5-h=<&vdZT|U;F@KCNl$qrW1m^s0BYEkw&cXPEz(^^iaxt=%) z3deWIOPk__`js@!l7>omq-XGZR2!htlf-u|vY*n3F+V6L-PE^M&6%otj0ze!)mN?n zf$&D_7#i--R3L;jgK*{mji9(cCI$eSxUj%Qq)W>u`2fSiq>!AEtVlcIq>{eg3LJ@4 zF5=CQ0`exryK=T=0#bZ=PhsUItu$E)5)u@BipsVelpIyo_)LUs(~lU5w6i=YWs+O8 z=u6PS*hb)ax}JE;HX3Sr?U8TF8xAUgI7T)-%m8x3L=ia-E%VMCmI0s#j~^wn*iM9# zn&bOQpHf^TZizG3=%e0Pn3wSb=aLb)*s6S6<(n?=5re9FD|AXS zu$Sz?_U5-3NigpB?pEpyhGC6TxeWg2I4BL% z|M6cn>-4g+o0JlcCM$`gGiCKmYLriNwB1TT0h9+jsi-0dcl^{Jb|6BB5C9_=&P0Oa zM(d@yj1&pBdKUALlE(uWK(zf+1u>$S`O(ptHCD`TuN0*QE`x^~0jS`x0O_~}qi4#k zet%miO^a395c>$Btg8ot+qOwi1GurO?2tBudGO(Txc-rXAn44KG<4O}-MuqOb>=cC zR&?7i>cJ|5DKk`bfDF8qb}Dg%@!Z+3)Uc5~kjV$7brDD$Zc_Wvs7MdqSh6$O&+D*a`9Iv~f*Co-Wwx$f8=yrN| zq?JQ+Q5mV)RX$As;v{WV+poWg|cx^deX2srdg*r)3oZ0Dw&Z{BrG>UIIqzvRpy1V zqF^4GjLpkKEk^sfx2z-=)6!ZMth-5r zFz>f!?l+FwU99s$CxtMEIO*BelMf~oo;^oYW2Is)2OSo_<&kKoEchyZl>2H?s2zoB3Pnm>SJ_&qM) z_pfmHM;v-?6$vHKf-Q@cafCH+EX{R!=)Udc{oS1Vv?78`y@LCdlkL zd`eJg2^js}Wsckcrv@7;mx76nnRgTdJ`oF9{IMwlt*8r2u(p4Mnhq$}it>Um>42*z zp{G6AlS-KwAC>T+Lh&x2bN@hq0(T?;iG!YqPs`DV!^y*V#($_T!9~9h_j$p5QkHI; zW8&zoD2oljWT=!nsj|(2ND&g$YzNbUS`jo>-*3V@r&t7*twn$vpz;{wI)_Yau<%qq z6j+rt-gMBfN53l8=vdJ1jD0``gfBw4UZ#<9v3-GQNNJGd`V$pHXQr@&$!Q8R90-cV z#BJgORkkTUoZ3& zZB;F1MM*rw$Xz&jtgq}P4^Nd>;=`T;>NjT?5Ct2s31w{NbL-iI{m>?BN@%*85IjT- zgmLKfbe<-o4iF?S$H*Zbw((C#zD@P+)3j`c<$}mmB~_JpGPN8DE?S|N+G-fOxpedS zR4I8mdFNpd;3!bYqee@&Ced#4N2$WhkE5(BwS|GQT1kuw1q8-uVwmDNJ^Ss8)_uXS zEER!czwdop_`XfhQThk)tf}3*kHWwL?OAn(U4J2j@zsX)c#CFE_E;0#;KU%KRyD@P z7Sj&5&LEwffML!#<5V?k3er~F_oI-D>{19abK8OT@ZZb-yt(U@geGRQ2-3a#GVWj= zicY>&Gh)(+uvm&1r#&x<1(;>uUCalhq3|T4DSB@(FZ#=#$RaM_+=&g~9b(O!(RH}U zWJZp=dLOmP-}z9|&x#3?4YED%6q3%d%pY!v8NM-dK|iiD6j~3m;kp+GjZ(8&dtUNW zQ&>aDNIDUYk=jYilE#C+aMJsVx#Aj*YPOs0C)lw6aGpI{o7GriN~yLrVuDccYR_Mt zZxvz%V$C&Xm|PCK+xvIy^3ycdh?tfXd9A8fQns@O{laC3oO#D1V<4Y9)K&xk$6CMhcwNj8G5Af zWBMjaZ;EA-Lr7mN9yF$i%t82q%dRigHwr&Z;i=`tEAres=j*Vs#UcjP+Q*#% z+vNFDK;XvC?HBQH305ucme>5Q8dcQq5@D9hqWN$1QVBf)6H)dX45yL<*ThgnT(g5UpYhU_~)jSqW)| zOWi2^G7lzqEn7<|4Hy#fslx7&c5Wg7B^ywvs@Y}catq`bB@r`nBk^AZT^Qky5>-?j z^cd(z%uILh88ak|%ibN*fVMu7$&Ch($rH_g=vIM#60Rg4?&RI6(L#A9{5j-p*Jv^x zMmGAV8tX_ciz}VQfQzA~krW8S+9g$0^AXF;{h{Z%_{JBfNlstWrqa;FAnhG8YE&tw z+E>0sPR`Jd!|sCcFiY$aG(|Lie(6aNVeo0}pV-P&$m5+i(mZ_OiTd)+9NGDVN$s}w zJ2y!92zIxox7LagJwz%E^SZ%uxfAsuKaWDBx5Twg2n4mZOywWND*6w^*;puZrc{!N zx38(HJAO<&%mvHk2fKwUh6Sy{LR?de!)pU{A8`VcAR=Tjm$D}#Z;SL)|HyZ83yP+4 zis@P^RK_!=s0Q@-R5q)yp^dj*n8G$EiY>TllYl;N9?L_SEDBG>7%6QPXmRG^FlyT@ zn>3`AC`%yPwkzCnC>fgWf5`=MUPRQ_@v**3v1Tnv3cuDLut!t35*F&(_YTIh-&kVW zDTdWK9JUu_LporcS#o_Wi>H+!;hdnGvIU)jB>(|`K3$LD-0=|*#L2wQTaUb=#n^d5 z;Y5oyI%n!}H4kE8l}epHw8Nuj3u&7E9|`@AZj@ksBNc>erI{*in@TGt45tE^1vlp&EmTF89+H8QvlS_uRRs z-v7YFqjMRaokQNI9h4k^lH8Gw=LBLN(s;1Z^gNOCBp*~?su;lrg@ExjP z*M!WBhyFg;NTw)$Mcgwljt6_ZV%xw8w^#7-|lw2zk6b?5QrH{wzhYU0_M~;9LLzh@W-+BIO{aymM zu{>fx%5&>@1Q-^Cw9_2F2ke>KGyS#M!;#ku(x-vu|9tX0_cx>haO?Jb7hos<UxzwHfX^?2m@#xH$7pvRK6h($G*BL5AWi`k*;IEdq!IrP5do$FbU5gzxNhuhD zv)B}a(0=s?^=U1UF$i|bQkCe-+(0rBJr}UsZBM~)o9R+lhK?sG^eu)k;O2(4t076# zVD>zfcZL+$#_s)26sIzxa)^_-563ruBnQ>^q|9-RQda^SgT4VAFE(Hs2+vG@70f20 z0_>sfUKqK<)NiY!-ru~J;nayXBxsz?T@=+B5p}uCjjU5Xl6~$(prngxVgPZFepu(& zjwc+48?v)lcH|`P7QIM|Rp%J-pWQ_3&dl6%zN(@L(B%Lj0G)w|yitsO1??{yUc&XW zNL4&uBm_)Np9%*BaZy5`G&mlr9_&T?#I@puRLjQ?a9sP7&Xj3$=86VhV$@U26jf$C z$V(LQ)>%s~X2d4GktNcDVx8j*7N!o{!K1f7$k0^mSVUcS2n%|l&REWPXce1d#lcto zc59k*?NIDoFSFhFgso>?Kj^8R_NJSO)H34r5ELbJN?P(vX{1H>0 z67h7z=CLZz>djbvuy{ndpbWok{UAj*R*33UnwRm&@n&T^_zuk(dh(&vK>XqnIq+rF zv1_>M>`4#lYA5?17%95ACkRW!K3s?1VW%#~RmEAx6Y-w(8`DSjRv5xBo>L}jv#?O1 z!OSHDwEv2!6YCRS7}aH&4~mpv7j7k{uAX842sj!M&eX9QJs+xIMPE=hwV@kQAVXi9 z=zfs@VWMJNR^$lIid38h#j^%tt{rJ&u~9V;Y?L%`%s5j*5weiNrT}^&WGG&YK&BPV zWhO%tM`0COI$a4lv9qT|X&TMffBv8T$55JRFexK23@(nQ5R@)z+;MWHj7~`tx8Amt zX;<|*gk^HsU3VkaogaVw>rCB0{%8s~$qQ6NY`lmpniTWJ0vxn;On?i zJGqEAugT1T5Rh{|(VVAga^rjL-nY%w8rr{@_4|}2Q35&`X^vk_0CZ~&INhII$~3_kBth#;ecH^lI^Pv_ENbg(4~Gv!qw9!;aKO8~(N1^I9GqX5#35cHwSk?T zcb@|@ruN&)DKOR*PAx}6l9e{TU&Pr#VuY#j5~w-!sQOC1MkiLsEaCaU8p8BbwNeIe zJ;Z%IX@?ed_)lqcAKuiR08jUcS>~UZ8+3>J@oi&l0q#s1y!%u|Wx-Q|?&+EP+h70s z=Rf`P{OynaI=r`QpR_c#P#Xt=NJUgF27E`<P#!J>QA|9r=f?z20<%3B81vA!p_5 znf9wAIIi14+O4jzf0H&@QIvOGN%mVtFylm&GbbRSRFh1(obi$2b2@jtsFfKw0^*6l zZh4sEL^`>LH$(^wz066?E}*oeq>Ac%_~8E}sWeL0_}H94im2Egs2*~_GrQq2;ek|> z(jWRUF0?U-=u2udBq>H^GU#nAV)7=Q4fPJ|uu?h|Ruu*0GQR5*pkyzu%$t-Qd1F1# zl9^En5}AKfllQhOqtXN$4If_YiIY2IA`t_jcL*ELXu09A72L!!ehQ1h@QPxq=NbKY>k(dUR0>{*I5FUs3=Dm-UbE#c?= ze~J<`o=%fSv3=6JMG$~j0K*gk?`W7r!K-^@No zX2Hgps_PcEZQ_M)F(z4Xyz1i4lT^M)Cn8ceH8DAJOXXJrI)+?88LWwXs63%vb6O)@*d69>=px z_FW`cI`H8J5S@B-JZCZM<57aGB}*-koF=oq@_UVnoQd^9jAFCCXc4YyT(A_a8PQw> zZzF2CY>oRpN}GJ%>-7UD#zWoYqA~)_JX2wdU^L{g5j{ahAf-+1%=+hFR@uT2`~W1C znXW}rOW;>MPGTPEYz~jFiisgv97@_iB^4ygxZubFexr3 znfe(}kK^T81Hz0EO)vzf(9vEI=}l~TW#Jc1W-r{Dl3C!o_2+#5{%`)xz4!Z{{`s6A zyZolRwCN5dU!^*2j~2Vg8X$ofX0wO?LQ%mGFa%1D!NbyvDPUFId#C%5;gU%#qf-sq z+!1fDoa$LXYzv~D*sNvbc*YBOlupABpjRivIVYtq#;C@y!*(eu?n6uaAqYit3zNBM zf*tM=BGICk{15&$+vJ$Xk;6~);dP*iQr4P1Hyp7-B_Z- z-qxE%{><1jpWVmyBy!)U;f~mi(bz*J8S#rzqXQ`&n2NJc2koi?&~a9$W|#NXZkZ1O zsWdXfSct&s%*tiJVe&`TcclxOBZ=bMwgt;dOIih%t2oC%-0Y>0KvFG8mOx%Zr)agn zNvP`G)%dA0IXQ4!!$S+jHO^pN63cF zJxht=P|sjT&2VKJX9_EFYj~a^p}3~!)EO=(1gQiv1KEQy3e+}GxSCaD`6K&ylB?`T zx1T6)pjcK4N^Gul4kvEWKq*Zg~Wbm%+K2I-Vy zXcY5{$O!RzJ3+i#Tj2{F%VNuDM)wj1mi|Z_=|yL;IIqn95THimnzgbWB6UHjb2%w4 zj)1A%e+6Cm2v;-x^)(A9vRF<%lLk3~v_Z==QheQQcQw``J9ySBHp#AU zC{*#ea|~8d#i`Pv{$H^G056qEL_t($2Ww(%`i|&}xpIe_B1){x837%m{3w;=F~-yp zBk7}`2Af(mF2j!mZ8!cdxze#Dr31pm-bXqwB6(|X#nnc*Jq#PDP+RW~M;5|mbgWSi zr{WrY0OM5BUFzZeWKvG(*@2;yM&!4r8XKCgenkiTNo1(n?FjdZyQR?{FpNR71u7>d z!zjoO)~d1voybwt>@@aLj5*pOwVt+^K1EUDlnN)R`(BgoC#fJtQVJD2&_nwVhF7Pt zhLBPG-Gfn>h8!iR)8#W$h_QRcE4D=*P{lwZGAe~8{TS_jQCB2_e>qyp(daXK6%5M1T)Mj)nlfBp!ea12V@iG z^BpruS75Owd7T0lw{TZ5ASxl;Cdh!iGS6HD2oM&uYY(oClo|odpZj&;m6m(JikZ#k zMaBPk)`n1XSTo@*aHhZrd@ea|X+6zhtDeS2S#;3mJ-~1@r2F%8zL?aWuI|&n{r;D! z`!E0Z{{z!+3;LMHRwW=Zg}Cg=KnKjS{3`b@HKnu-7?6AP~ogxbkA{<~? ztnsKG&dJs8VWb<%=NV%HtJn_Nqre!770em39)k{-WHFp+sLb~%@*KrEbz>0jL%$>6 zLi-=?O;{kKHpz0%D<%#WQ4?WeV1T7*scvrS!p$~Pw}Lbx^=!<1Eie(3o0gd+R5A$b zMg)Rj!7WPI@rh8VsnW{dhqzb<5lD{gxxN6 z#IcNP#RA@ZNgy$Y_nJKj@|fL@;@{4K7Jr?v@CnRc2e{dyD7lMox>4ZDFxJ3ShENh) zoP7~Bn5*Hw#UAo<&^gxxj!s!bf-V+W)a^x)V%_oV%9Mx`K%!veolUt(k zbD5)q-0lrnr={vnycCH-M+QO4)o0rTH$8Dj5=~M4TNp{}XKlszh=&XJs;cLBV}*LBWr9`b&hm7$9R36l-7nn)M0r? zAsh8TQ>3xrsBTC_$7yWhyRbfNE}$dNddNBT_2Zl$U%&rWKYsuE?YFPL{9XU{``7P( z`SF*()H!{=&Tn7+Z5h$~Q}N-#WGBqGYXiIn~>Nm&ovAlAcuf)OSVT)UczMoZj1y6SPi^|-y1kXlD? zxui&(!cefk9}79Eqlxg4?THC-?l4_iXfoEN34~8d5BqVTGX}GpMIATDOW`SV^g-1v z#c9;4rVe-Qtj>_x<{i+*kcJ-X&j}*=qz+8o{H@A&1sd&fp52HBfFW}{VF>FJrzsrc zHP%eHwOwkQat@&LVk>_w z#B!4c>aPe(iu{2Wezzve69mOXI89&zh!Dk7)e#I1V_%+b`UY8%2@{eRc9a_NmOJYQ zuYrfuZDH1^1z;`RPSlt34Zg!U+1%X$I-?rM;I(F^0bi`kN*6fJm%9JYzyD95EZue^ z5Hi+HXqfzIF`r<=0-^z(nV7o7mXcVL4F|iY%E}<2uZ6hPGu89`{q@(s{{CE@*`s+BPfGXfRziOOA%#iTHv9e4ad`u~q9!gpPdA%ehcEMbM7s;DtGiIk})j2KKZ*Fj{FKJ z_mD|Jx07PVl$v+YZ79GCuuGsF;EqJAv6Uv#g0I2C9(w4CLSIr?~q zAU75utnGhz&!Y(iI&BQc_IrdYkM;*9FrDQCKiZ%c0u)fr?f5aag*9;v`EKf&cPXiz zk6~Z70azL^?tB>cdyR02c{A8y*GW@*pP40dVYerij8U33U2k&J7U(G zfd*F%>K%3!s;+*n3|D=Gm|^E=*48jzPTJn#)vjxPZ(NP9I@qzQRJAQ`uYp$rSp>tp zJzf3PJ?9i`(GGUymLuy?X< z)yFD2>WVE+MU0S1L=++;g@|Wx`Mjr1*GprVfaGU-hzXBFvuYR_z{spkhLXoPFJRmv zbfDnHUh-S{Q*$)l&Y>&9l30{R$U5lz?oD5%@j*7SOWRumh#$LC;`R)QTjcQS-&H?q zo_)gyTWK4E%QmYNgvByXNl`Rkz{=f2ZC@Sq#ZaUR<0k4;9OL2MfhXJ1n&pff0I@E( z_n}h_Qf-E}BNDyuN5M-fyGD#}{5IRj{LMacVS%$0r(5Bw%qOY)sPLGRRg90#D`~03 z=jXE0L;vTD$vW6PSF$Hq+g1A_E!-`-ycqoXW9=50=enOw* zSI#^>^G#UgdzJTdK^iMZR=zT7q=2w3ALW>iqR5A?!7zY(3^uagwvTGU8dbzfqYb|d z0+Id9z6<=uS@Z+&e9?F$H`>d^T8$R-bWil>Xi=zno>t8TN(t~oSU40JQIh%{4_)cAQd+s;-3h&h1zy0yIy5F$Jdu!&Z7woEOp7VPEx~Ea8 zo}Rfp(T+-yga1)6pi+9zbgd}|M8A!iT&HTwG)em_9;)rt$+aRudX{>0SUQ&=j36;u z@Th|EQJ73Mdaab<6O~7i$UQrHF0Qnv#ldKwltAXsEIG?NPzV_DMW+v9*}B7FiJn!P znz)drh3&%V-r}R8fJ}1_szXXJ!zeXsU|^7a^;UGpJCo*rrKI=c=#i65Q!zGKiznB^ zSrm%cHHj6X$3q+_4NJS#-8+zka+J0l5T&HLMPiJ3-rT0e+=u9-iaf^ z0&H!TKr(?+?#_vAt#qn|rbr{H6Fte_yk4z&;?pZD;3XyozzG#Y>$N*Ly_py*3{vuor1LE@gnt6sO*n_9(e+MXyhT8=n7$V&n}hq@9kZ)?GHG!;YcMV2o^P- zjw&pyEjvF4DCz`s(nch?Dp>m)c~koC*gqr-i?Nx9Pj~l!`TPIG{xeaKDQ$G7ykQ5> zo72~M36S8j{bXLtQ5qlCiJc?09=Xy2na;wbzVH0_%isOw-~YS6{lh6f#Du_feHY$K1Ur`$*+ z^Y};9G#F+7q-0(ZXt4n@4{K6Yqa^?%yqgT+Rs-XbM}*kdqzU@n<=)OGALr1Z$T{8x zyG#1GqLp~z7jY^fO`M9cOE-RHliQdGKDf0de!`?Uf2TKF8Q|-t+z`{8iB=nct@Bj9 zoVdzg3E-{A)FMqxnE2_}N0FQ^8JPzsR<7~H;TW3ETR8Kihd<%d7Hz(mBVdk!u$Xe& z1d0oc4!M>df`FDQLh(>GMFVBTHa7YzwJzTsC()H(StBqu(N^%u$j920qd)tD?bzM% zs*1DZxVUn_u3^5!>GbVzZ(~kKjm6P^t|+&V8N?-wqN^e4wn;TcC(Age6frHRL{%=# zm;Pa2bHCPISf}T71op`u(bHeOCH<*;s=L4H9JZaK6XuNOe3{_Aclx3S?)&@ue*cLV z-$jaZ7sLoDh}*l@b>k$EInsj+J{~^Aj$C3QRv<2KM(1ukp@0o+m$t|AeGq zmfaq$5ggNiwvgf_2_f}9eu8n2y=qpbQ&Lgo{1U{5lP)@Egqx>hl20zjcRsA=Qs68y z0li2$f@Trf^w2g9a@eq{4Pvbj;c92EN%}?G%t>XFai*9C(hT0Tc+}R4jK)&fPd* z*J#I?r>2L2uCbtSNgJgBP}D{n&T4C){A?i>IS0|3$t@;6s?lRO()%o!D8{9gY)&<+2zIYMC=*ufrPrVRTdM--P5i>)2wy9LI}e$mU+XOjOhdtmF~+ znq5h4h;7{@gV+PSw z=<9^OoNJOcDKLrLGHuUu>jK9k4p;DRht_ZxjpbI^i^r@;RgUtHg|hE zOxdU(X;U74=XIS`OdNo=MX^GW;$GYbXK)?dic^Za4#fs{hryi|AKY5pp*R$0iaRaE z-Q8Z3m%QZN1u-w2|+m@N{y;a6*91YhYc^`)tY zv9j|Rr%j8aXSw5bi#?4;QRy35yci17*tM+`q)rBDn}TD1c09kOkC&x|96sI5D{|z_ z_EAz_J2PN60UW5osX|mNEKKAF-R`wf|g9J8XD#?#-%COyi`6x&t1S zWWW8%k1xE|pt~vM&c=*T+!q01E^Y5mM%-#_1)p{xQ6(yv!{_5gsnXV>mJ?LYlYTs! zm02KXkK22m%d*JhTC_+SdqYn8*f_Fg?L-Y=#NeTdM*o`h%ePE0r5{B{H-1ONrn1P9 zsBu~(o=#qrxvMyjyra{1m&pO+INDhqR83l9E?OceLD{>)oX zMtgbo`@RI)it8e9Cw3)xMtgBL%z1uICd32el@72oWWdbVMW3EA7!y5W~)H+`m_B~aFnp{IglLEF zuQ91XvKfLmOTt~XPb=2tf~reHq$|KUMZC94R;k2ziVml0<1%rd&TY`jP91FA_lo+r z)H&1(f^YKuC~H^^>wl?9T1)1?d{FbDg6Mx}J`fPgVu$WilIWccf9CG$fH`tiQ8F>> ztj0Kl@8}tSF|tfrMH+Uxj^r*aQ~joT=WRt-rhZr(?l!rgtNZ$^UYr=$zFF>a{dB@p zJpfJ}^fckd>v4MPAEY~X+gTeV&q!w@Ww%`7hxu5F%aCdI@$DEw{Gkxi-2mqkYsy<1 zPx@yvho)7rW2ZzGq*{IDL+>8Tr7wI{AEC34dQ6|C`euGM8$Fe1#tN%h5|)@hVNE97 z6+^1*W3&KN_kn4=l6-YCH0fCP>zt=7D;`uPtWMmE-CE;#%MBP?DCjncgH68UV_1J& zcO}K}Q%~k6KKz&%l4PFvh}g)NZ?x4nm)+lJu$5iLj>|5I!TanYfb?F2Dm+%{iPff; zLR*`!wKs!*nkH&mkW-UCAcL=%iq)c6H8XdBg*mhl$>?<$Jv>F#zV|M}XBSP$*)mb7 z^AoN);e7MwtO=v!Z8Eac&Kzx;mvk8bV1B;euv-#t7dyE&G;aJE>vy+GO4@s~3-qCcjD~Q6! zbkeh8Wg+VTsr`jz9wi$}e((*3=%Z3=P2=*r;0YGCD&&PXGNQ7}1O5ZNX|gTDQ?qJIjqj)2|bs!~GGzGPN)my&vDJe7EGT|? zMF_sjp${-3SiD`Y6gCa$en&U9p*Jk=IR1y|uK5*%n6@Y}52`6hPvXY;^0w*s(+;xIZo zEC61gW&lmq2}Agwi%Q#E=-NN?N4!=aa54q$O8Q~sF`5k;A%2p_)?Q{xwNKEE%r=iEYH`%y{Wj3;Q$wV5OLqO?tY{AKRkBHgdp`mg!}9-CPV`^F`^U0CW^?qPw>|0 zyevWYG@rzsBgoziL)oldhd1yO(H*6Ra!6!7GmA9COsmqYS$;){;rp+X!Y!CkK2KQ9 z%r2(xtGy%68ZXH%bavnE-{qNdmw`@iVL{`!Rc%_PHkCVteyPx!D4=BcB&R;|lnN7%Z)k$+Uaq1xnPZp#}PMv;*0;~MPnMfG$U`!!Td1luL z9z_@VtUg}gq&Xg%mlYi_{e^x36Ls=*Ad6z>Aj>ujdy_YnU^|?yw;ukTVALoa8G65` zfFU946956uinHx1Y&!xRtNmfKb%TbS9i~SlE&`=@bG%FKz!i4{^h*BD5*RL>n7>#) ziq)}6X8ewL4Fg}4RqP!;0aNptFUOCynMy28#zYi*xhH?4{jYQabSFAE4_CrO{t5@3 z2m73}!NU^eU?FRdg!NyQ-|^G+4oNHJ2iZ*pTfn@b+|ZWj7LYPT(s_PL!0sEya8}GuKJ~=8U!K$#jtRw`1($5oLYq)nF~Ot* zo)}}TQ}a$LbXJOiC0lq;-4PUR8oKJYiCuFCkue97)$Cu>y3eDlROjNuirZcM|H#|y z5?AqnS-*&j5u(DZILmej+~0+(q4ejh{Qk}A$!2gok$CzsB3A*IvN>fi6}z}+zOFD+ z9wke<$18eA>oQ-aUTFLIm=zx;ThfCX&0XV_q;fXB^MGz7wFS0f7nh5#(Ub6uX(ig? zV9^7nn^%XNrkUpb8MvXKsfe)nvusV)Vh15eO@y43fW9hIE1xUsi5^IQW)w6`rnqWZ zBmy)VaYQgY{0!7cJ>)jkGFR<0z+*dDNc;Kjz4o%ViG}SJP+cRcQUn$+Ap>L29%1Pk z0I>>;4Dou6J)7N!MbV;SN&HlV?7@xznyoWNbC$9-SOF6-Y@5T~gfAV;J_O+7TOF_J z8d->Ec9VsZLobMz=2iEH<7x5(!7GWEi$LDYjBpC?^)`{^Se|4wJ9h26dE zB_|+AsL$khTTIr3v6!l5g3J7R1~tNW;Y+QMYp1$|51DcSHsAov;?g*nta(fBW?-D< z*&5X9hTXW=-t6Q!A!LFK7q}9f=W(;srKjAu8Q)_*7JA$=e!TVcQCGuJ-W|F4-+r9u9p4JYDYBaRNzj)qMgLi{ z*^^+Wjv%vS2=8dG{TtXVw)vwOL3*_N+mr` zve7D8r-q9owL;^=Jw>T3P1$JKs)Wl@(?>B9{r2&!8{=JubNmcmn&D-SVCFYG{%3UD zf43`CzG&6_>b$NLrVkujoQeq=ZYn56Db0`ZXy9kMW>Oo832pMhFFewvv|I>eh>?;~ zA8rB@iQ)@(^~3WcNAhkqLPv6ecQuTx$$JM`GXYcboq+|ZKIS)jc$Oq4Xzbv9S)lOJ zSPJZUw9^AyiwBClq=?Tm*(M1Wi>=ZH&Ca$uE23$|j9|s5*m|wWU+cAFOMU_5M5SoW zf0IZM-7u~+f&eBQ7na0hy^p|C7Madyu z|6GQ0OIY>Gk17MoP!rQDsm<$O=cq-ot#yikuD3m!JQfU^dLCeN)?PGocLT*fl1-(f z7)}STnZ3w2N_M7flpMx4^?u}tE2IoZt&k8kQ2n<5zuyF{*1W7P5yfz!6={xED7q`2 zh>z$-RQ?PgFRvtgv=KwW@h4JSj*PDw*hV5`7VK$Yi4}RhNja!c zbwG%(2oy8R&g{+E+gj(>6m zR=rf~Q#n4dF{{c@P>`Hy7U^eDPk%u*ot>5ec6o46IraC$$#gC4<<=(v1vxi`%)RW1 zv1Woo#vi+mtbnG%KpzzFv7JnEikgUDOqNuQ{+VrGgD0SH<09ceKH-{_ zuIl|?QBnEyq9~?f2kOl$H{PMb3_9y#){6@!~%DqN`kT9 z!TUMY=Z?EL){Ypw?nyBv>$)gY({1CWXA;K4F?7~4a(vJA{O@}!|GsRHzewOUGRT@{ zy@oYV1oB4-fT=*Z=Ng=1-%A0rCU8@l)`uGh++(Rty>&tCKEaVGK5Aw958wVm%dk)S zbN=im4>l*kphQZKg_V4` zZtC%k*X)(>_|JXG=f(pb&0cXsZcw0?xN6GVcCBCq zvbv$?$&UbmztM!l>TWR^#-SDZq&!S#w0k^jLeCL2{)accS)F$hm$@u(IXy6b4>KfW z0iRPSjA6L;HTZ}bg&UVyz_n3kFAycLw4->B(QuSPQ%T~azj$rffm2Tz&9OC^;fx@9 zD-po}&Wtr*(fjn*A=D;or^;bwzDQpsEkJnv0x6e^z#$}%{ier;C!HV$ zbAL-AXU+z~?ci&EXElPlvvKq$R5{OeGFhpGPMh88Ev&83QKPIGy`;1*HAd6!6akc8 z-^uaTqs#?JwN_o(u2x4<@-V(2yqn+I1X6ig&1~nr4Cp>}Ez4@Vb-taX{vA~9D}Fo4 zIn#N2)t2_{+d=k{7JtXs!x02H9LOkf;vTGiftidGj315AzJOupi>Gpss_@OtU~k=s zED~?BIr!_%QX8Yv)gK3Z#6X7UV2%Ktg^iE<>MON%+U>_YX#*2G&x`wah%-olCd1{S zyjlWvi#(Q?4{-^k1T8UeR?q_i)Vn4MXZ~vWtGb>2rU>sl;VM*Jx#bim?l}`{laP0w zBV`7LvzimAgb~M?b!mY%f*kleUw(XOZXw!a-FX^eFm(RwYVKjK%1b3Ei_XZ;qNBKT zY@uu;G%*tIwa4^ZYh_)SAR&JmjKeF=oOtE4tCh1f)jJ2pQ5>ukwNcSA$jr_QThF;E zE~P6LDMm3+|GB3ipvs%TH}RuWZk<`av~kbm1M{7oft9jJy}6qwtKvZ2E@qvTbcI7H8Fhh-pPYwUf3Yc|tD! zHu+ysW&-LRU{Q9&pn_#)1Al~Ib)aw*r;y&C?oAkdnl?dL^u{MLXK`v)NH%#Qam9r{ z0&=(6M28iT=NJoMAbydj;H9|griVuUY6VbTo^JD}!iFrhhzZT`>N9Gswj#R{ZeCyc zca|9li>65P*y~HpKRwRiqe|#HLu5UhT2j!jZ6?d7iQ^{bR!F}tYs!3!XMim%8^2Ba~uHuvm}+AH=rCV!@t<_Sdf z_gxcjLPBYPC-Ij`1dSwCQbAR0j33A>8S~E43Kc(;uxPu%-XltG+M9iSXIv1Ng+YhA z%6WpK0XSQ_Xs1^2Ai;2xR^A!?^>-Jls2c-Pw@hip%BJ-`v>iBVH6i77?@J&~Wc9of z!lSk|@fI;pDufQH*6xC{U6%D+l&8hPq#IK1jl+cA6W-`W_P@lt47b$pWzz&M3SD2eKj%}%(~=A(ooT}Uaa1< z!Yg!mWlYF+4tSQCsc4&#BYJEo=R>SonpDang}lX2Q)N+}BRP-UrI}uBO8NP!^ebUB z#h~^{;a$Sd0p5til-j(PL)5o{@Z8Kcw?{wE&C$%$ipq6g7?;0t z6qsmi9akzfA>9z?@HhRXSc;*U#!G5BHIFZTZ?^_jX$J<#s(+d~W2DjLMPm_H-V{9P z5b-}RdgEwA$fN7!>oNNkjVl-_gl=T4dokgldIf!;i zD8mz7ppb;|MNLxbGt-fk31rMTEoYhBIjQ40E;~nc&akhE_uk%FNCgFZZ<@^pRW;25 zvtV&&y{q5sKPH=dK3d@<(%)I4`zR>`KKmp&-fqSX7%yN^Q?uA7L1s|F&5`V$JQ{8+ zE9wutsG4|m%?ejG7mc8fwP(|pmmsIYuF^_C6|s%U>w{6O{kS0jK$|vr@vYE6Z+O$C zMC9|_D)-x$^?>f~*OT9`Uc0xyU+$nmMv)dgO=%mbRdEUcypDtANbvF(oi^{*m!x=rF>~24`X#m^L!3iu7ol0^DKwd{hy^wKt+4NLUE)m2d zMM4&T)g}DIp3n3eNaeHXq+{9dbGr4jkMTPOnBjGlnXo@e`7=>%RZ1?fe|n5$5n8{E`L^ToJvSCTGmMw%DfvXV88_3jx)FV z4d>N?c6Y-?B1&c-(5i{gS`?@ZR^@4u_^48JllUFX67MZVmnz+M^G`2kt9v7)70@d3 z+AqaQNl_D7d}tG8I}=d>PFEEb>be`NFGe{e z_JhPZt4@F&BWRoUa#|iKrVm3IQ&EDO*L4)&6i(6N1G>?uusyK6<+aJYlsI&;PbZ4? zsHay$#O`my7_r3g`5~8HNF~+Giutr%e}@%zE?wVO@Zoc^G|Z%a!kE1h+it=U9}29-a?KgYY7HeNKV`LH_GU zwuF!SsOqcEf+%9{GT4{$&!%^OUWBqmT#crwehFPomO7_Rrmpo;1Khiq8d~2DfNTS_ zB>ODH=5`OSggqvmCff!(JuVGzC&#PARclT#25tj`bXC{(MQ;OtpMq>pma0h6#B5i( zCxy#!w6n+)p{^nQaxzUuX|mkEB#@%^eeG0h97C|fWOayIEaD_Dz^%E&OWhemX1IAT ziCKT|q?sHlUuQ+*yA|OFGuDAXflU}jGI-&725VuKUYtp1jPTgU)XVr8gX4(?9xI#l z_-bj*Gn^p7)&Bggr|75;PYg>!;mmC{TnMhA*XMtku72f-L;wpS@w*yAo*l}VSvmlIM0(T71^L&An#9xqv8$UD`KiNbg2-^FMk5rLe~?GI%u&na|U>KB8S z7|UMXt+=spr>mzc${FV6GD*2({0Y7M*z7xKbl~3w^|1SUt=CFk|AaiM7=ToOOYQxg zzq`ltfYQ27=)WrR`LeY-d$^;YDm-UPx25NMoXBDj5yfR4Wka98>@{zUj(Fw{wDaje zYWi!Cua<2vJbPj{9oB7a6xemYkMDX?nTQD2_|EUhc_eqQoa}4LJy2$P5wHFgLU~U@ z|N2eeO)kfJ0Cso(_yBu)34iZb1&9z(*cl5nf>tJ^?mvZV_&7yiZLP{||6fgAWJxW z*;%1)o4$JwWSD&nEe!#vN;|UW&I0{Qc$(Q*0)Uf-a0vkH9OCBz2uA_1;0Azp768IQ zIq$7?p$1lui>4;v;P5GED#-wVX9{g%XnT8PDffE7(c!U<&PybDsT;U2J$zHR{nq8Y zv=a*T!mnSMU*3|xS1w?8FX3~cS(1&qIsT#j#b<7fwEq5>kC>%5EO!CpgT}T(cv9h@ z+fn%zR?+Mia!Pk(v92!WWB+i}MyBA!%LHTmN)jXL-TVa)6`ZT%ZMa|gYV{k>*H z-$L&%QAAs6#y=kW1Ai74OpU7d!je?%q`cJ3x+?fQ*=?S&+i(t5P_C{IQzQ^Y@`#w! zs=z~F*mdp21u@kHFZ0FnGQ8PA^rO&-tSZN>szvMmp;J9cArFFVqJ{$J`8)V#PWDx& zuzRwfxuw_Zs5rl6uT=c=nJBWOy!I@9CHoi=Rta+WU>dfDVs%RHt>zh9Zt zel4I%vU!$-O3W&hFrfq_Y@FL3yP2u)Z%n-ZU3aFW1m90L(IVq7KY4D$x!+=xauObw z{!XGeNsjYW&IEU8vyYrojp$!}*D55MNUC1X=I__JdW=QH(=7E?@aNDv8C#=SHS?ht zvPmc!KGw3Ytt`&qpq1$+y~w?zzfO+R)(F#{G(b9GDCJdaXU&J9wjH)P85RsjUEhnZ zP63FT$$w_$*b{m?jJ`bXe)}F{Nkqz-nMF8(rg5W@ zTI|xYI5zg|8LgcojE5O3%L#W`S(ZA@f8_;9R<=iqzXM~G!jZXxvf}dy(Esow#oSL0 zS=yDUx^u+zT=8|bj3U?i=(#?4XvO_FfWRs{ajKasd`OXsd(_d+5jrbXc)?G%_lKU+ z+skhXSUB#rd!216ka*9eg9ueQf9HbPB7ALMdALm;QMWmBJ-9k&k>yl(@9c|-8Jrfk zLDJ`vBBMR(XRUsIB$9+kQcdJ5wwlOi$Z^+oRik1RIP%SP_Gf0$?CZl^{dHZcagP$e zfR_n5_oT^>P=uZo+_%N^#!e^nZ|{ePEsfkwtgen?L5R(qlt=iafGBy5P{lFh>J-9g z>I)@j)c6=T5QP&k<1xu5vL~p~xCy4DzLOV|NwU5{@AxW8TPvzmfWav-(_yRF8wxHtIDGms?}mk*Vv~;h?N<+<8DZegHesd~Q9{ z_X#Xr6(5s%1iSR(PSpb*3mV-MLS|a!@Bv3Z8Vx&=@MmI=mCl91zQeEpTod-yE2D|= zrY!18=i~A5H`~yFLJ_)RYW3IZgQmNf;G-;rj*k23hshn(Lnwcoi3Zun=z2L=+TMR?A%7tI6V-cpOnZmIYcI zCcDW5eot|s=_!~`)bk`A{a$@$Jtuf*>_?yQgPpXMbhtnzQp~ zs(02p3y@B7b+w6Wv(md8d&!DaI!+QL*fwFyU$#7#$^Ynx*O6vpeG)_gl3+@a6LNbw8;^XUo zjpaW6DG0LMXdMmjm*?nb=wQuaa91tSR5F5838Xc2= zIwN|kmKDqKw(hXOZA2q?A{fwjQt|r$jDvsvRH@sSRT?`+=+P$PR1^ueG^4*s1Mt#r);@^ z%9L$V;K&=dw3rW*IIIg}ybUL$zC&CA>2si*o@fCxXojbouYboIW0c`R`&O|6z^lgez`###{O=NW zV}M#Ap%0=qmm7{RQjY?!Cezq|X?o2j(z*|J_o#}DTeB7+f=QN>bH@$?dz)4#{;OyO ztdSe>!owFX1W~qwM=vz<6UyS_-*FQ(cW7&`4pF|G(!>=lb={ON zyL8Fwa0OeEuwjPSa-cR~tcU#CUNuHeuI;Hu$*l?AW4`H9Pe|^3Aho#9YO(8p!w0`5 zi!+h;q@aZ0U%hQ1IH3*t^9P(eNyEGEPLn^nPT+YnM@E;#x8&Ajplq61zIo_yDA_3u z&wJIVaDo(V?||O_FN-{F@5OM2$C}LUZt&W?b>mvRc-Br>5X>O)VeH&a_%#ZpwLW!t zoA=r>H}4b_5vQE%3gzB9e0XJd6-H>KMr)kCSJmIot&acF*0w#>9L=tpNEi+Ldqj>? z1WZl#PYmRa-mcL^5tfBr3_R_Q*^-8z3=bVu1n;-$j1BRqupKoh#%dA6^r_t3!`cMJ z&G<-_WMnG#4wN&B2!?PS)^l>Yo7?%A@JJ-1>;?hYZpGxf?i62!g39@DQ=|f5h7_Rq#X`r0rE{&s?^}|{9?H}R z+<-?al7)$|7CIieW%m_39(BMlZVRqpl^7;A#sLFz#&+4$6Nd$mskoRTY9%Z_Yz{(H zx^aR(-V-+hR&M;X5?@97%@pwW8AP9UUA>HD&T>?5co@+KFbOA z1y(jj>w>92eXPlmp&?&=tbO{JCGv^7ouDAQX+TjoSNGH51vpl}|151udm$mrG0qX?qsvyl+73YE-S zt%le1g9{~yx>sO>7JGvopUf}zx<~^Yo!a{LA%9BvEg=(P-SS4QFKfM!JC}Sh#}yYL z_YHp^(QC?^>NzKfB4mg*}t>&sJ(Ax+t;AFX0VzTQ)SB{n#DiE93QA z{hjQvtkTVAqf4A;ZdqLvF;qFCMUqi7pK|Y5KCgA!Ric?xOYxFHn@5_gWr!zV>Q&b>R!t>BhdoT z-CG9TuHR37ZH?2Hmgw4jKk=-wT89lSzW`|-cA_n^;wz+{6w$=6mZccA-XH(ov5?R; zjQJMzIQ=gqT6%x5-SD2#j3(Na`)5~C&drlbBeg#KSQ$Oaa`nD!@V%_jOq@I-6w$$# z;hk_inc}TC)#b_qM-%@+&EX*plSa3_(w)%YAB(!lvK{xs;FwKptRN06i_H_6zVbV;81@5Z-+pvmmenb|L-s@W4a7= O0O<3VOkNwg#r+3R&!6-F literal 4260 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 Q3m1U1hNh=V4BVpr2TTp5$^ZZW literal 8794 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 literal 6246 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 diff --git a/frontend/editor/src-tauri/icons/32x32.png b/frontend/editor/src-tauri/icons/32x32.png index b77a386f81b3bd6b4646b8e84403632edcfefd2e..1844dfceb221370206f933cb78327537fa14893f 100644 GIT binary patch delta 1252 zcmZ{iSx{4F6vsbY5F%-HrdB)F5>Pe~xLF7o1PLXQ6xp#x5rifXDJlq|BFkI`2$()PDeohX9xl1%Ofvz|NS`cE3Xa z=neWF4Wyw37yuz2UqB@0;qke6ywJ|>Dv_82L8uf$P%Z?aDe9wU9+Akmuz+(3gggQP z?eTE9=7fYl`1~w0vpfJU0z;LsT5La@3m_yAK{M^GMVbZgIm77m(9&<*zD!S zMTJB%P+dJF7B@ski#VLOlarMpA)<(g*Uz3Qo0^`MmEEAzr#d@Fo141}3O+9_soL9L z$mJtVO|={jy5aOad&E&ugEckxnaqc2X?!ay$%P9e&CQ=be5eQx?hp!VSgbb+MG2i= zfBrl?(%9IVl$5n^U;Wv$lN}vb92_LEu~lJVLR;HHXXo;upleiB7J67L*WWt;Y@k0{ zMZypoqU}PkWW&Q~p!bWXhx-q)7@RdKws093+9%lo2cES#7F1V$cXC%`HHRjv6I59yygKm#?1100j&mpL{;QM%+?p=`Q?j& zH%CNWyoWQC;_47rohGETc=O`_>&N>D0!-bJujh+WJo=^ zHgzVP;vik>h^*I3&gdzZ_CI!+ae?{tLlGUC(dd^cT L^L7`zg{J)r#&}QU delta 1318 zcmV+>1=;%T39br|8Gi-<0047(dh`GQ1ocToK~#90wUWhVkk&Me$RIPrJno}8XYL&YW`>!;o`lSO@%{ei@tt$8=zjp@w}9E{_kZq^vRyaL z1`ALD2)tUe1fZ)@_9RU6`y+#czY?1voi#OIxmEQif+kE8|EIa6WHE7F|N6+?yFZH` ze7?_06y>+1XJY@aO6oUpHv zKw=P!&WSB8A%E&A>_x$aV0d(D3h(aO1K)uIh|kVKDDz9veM8sDb7-ZdX%WOEHWfip z#YPL0&4og!J#+{=Po09dq5?)Tnf92T(PJ^FE*Ca8HDS%Vbubc%`TKyNYA@KNqCih1 zpvU9*^7LtJ+P9BAwoE4k|MQWxT+%d;sweoE3xa` zIaJrxvDb>G&BKL2JQ6|0h7EYXxf!m~Qcex*`$Y?os)}$hi0W%F-p!Fo#$wPso{Vd%`G6T91R_&Y*tB;qc64;WU0%*`N{6ZI{2Ub< zrDbz-Gk>bLZsin^04Fj<2@p~;fENC+wG|(?xAS$XW`f%a`NvgOh7Vd=u)eMi@o<=< zLE=e7Q350tTRhGSd~xCg-fL`xWf+{xm%Vb1-`uhV?y@o@B9U}jmNRv^Vxz6qc=jw_ zuc_fk(DhtKg0+&8oL(>BAPYS5>>1wh`_Xja0)NBF^PF%3Qoi%_+%YxN#qi`LZ=X+( zA5V2Js`Qt-TSmB)IR$2mh`Qv85cuTSG1RrTrv8yyXAZbi$w6u=z;AfRZaDq{Lz845!yuTkVuQz)H(q@M=+iZ8h^!Lxe0D{BA3?=~! z;2sa6!5{{^yD>dF3iqm2sV%&cR&3gxk9z-;{(j6neG0|pO78#%PQ8C>WCVB4pXZ+7 zDlg9nFGzsX`C>COcyj9&=Y2swEemu+Eq{8fw-*mCUE)Zp)DyD6^AkV|+i7`x?HY!! zUPV=HEqlJW*i=o!gFpbIH*cn=<8rTQGB-Qr6)klC;zj&&@F3= zz8;sNd=xhfLrAIc%vY40TZk%taZfJ@Y*~g|QIv#X+>|Ep9bKf)CCfAoXPV^-=}O_m zqGMvqwxOZlwzLil>h($ diff --git a/frontend/editor/src-tauri/icons/64x64.png b/frontend/editor/src-tauri/icons/64x64.png index efb2a136f330acf01d3d035251bbf834e24dfe08..d758a26bf3a22e891351385b7d4b8b652a4e1afa 100644 GIT binary patch delta 1897 zcmZ`)c{J2(AO8(g$bE6S_eS>?DSMbjh6zKL=#^d4t?Zf+E`?XNeq(9uOo~t$%Oxgc z9~s#hm1W8pp&EP0*cma#{k`|E_n-Hi=bZ2N^L&=)oacPc^Nh%FCCGB1FaQA0Xwgeh zf#XfAOaP!dU7YWJ2*L`U7WP&E5UvaW#5e%hg|>+E01$!zfJHX|z-9x$(ZHuob_M_- zWN3TI(F_uRIGq3K(+|*SG7|X!g(9O+X-MP&0HkSaLy!sp=}2TM0+EJ5r2f-@Y5yo7 z0HV>5G*w$0qBC`LpdMO}pdb_~4}*bZ>1Z^>CaS8Eb##(7G#+3ujggV*&>3gXCa9=n zU@(gxKQ4@nBq%GVXlX$q8T$J7Po6Atc3z&GJUBR*U@+2kbXrqV3NK$yQd5hQm#0NW z?t)ueg8luew{J6XxbDo%@z<{@Ha1D>>WOM;_9qhihtBRaHqE8UjB5 zKp-gd^kmo74ZVEHq*8}#YqM~;c@}GbZ;uucF;ZXup}hQkc{%CSsgfHvHkX!?)z!(m zy6vf{Y>29;Xo`+bR8h$>HD%=Hjx>M`?_a#=FDm-W*_l~Z_MS=|ZEAupryP%uKYjX% zg~j~vaEhj8V^q|~#>N+T{Lb3ibYCAyQ87bLuj}DMdTcDGySuNTV7RVMAP{gTCi)5s zA7Zf+Jw5sM_WOMP#^NHgwDdz|<$XoPnxLSa_4U@Iq~tSaW|_>5g@u)=DR7I+T^t={ z)zr-N_m94Mm1k>9_44BH?tba$s0s+!US1xmtbFd`^5W*r)#>SX6iTjz#kaAs2fDfg zB_(@2-lx{qWe(^2=H}=2_NJJak2KmN1A|U7IaN!mCo8L&M5+r79jdBgRacj~yAKo> zKee;#f8#5Wp@l zg^Bz7ME?0*!I5FRGQq+L;f)qZ1@o{pH$`KHBV8aM5`59hOk`2?Uq6Uz{-O{S1C6uD zMN<<;=g#7h1y>n!S&`Ltaf8A&_|0kS8sAWdT;rvoIujbmjG3M9iL2|?`guzC{H?+Z zKZX;&HHapOI90rM40mO-zwowbTZi1!tKLX>xhPXOywNaSN}qw~Nyk{H^6T~CR+=R) z^}bFo|9B-dtPm%4K7_!k=Wv=7Vsg{yjv!o@JOoul_8d+S?dNB0p1(uio) zLBn0=OR7e~cgBr;KGz=oi(y6V8XKeBttk?9JE{O4TmB@n`jaf6(bd!x5l&ZyE&F^B zzjGZ0L~}n;EVhePVOl$VwM4c3VES+{o|z{@Zb5ezb<|I^Po3Sbeqs zOG`1#BXFHM%e@wj@d1!Ed&hdg3DN7%VSn(JLIz?bFiUOMtO5F9K@xq&JH9rxc#}x1^*QQc0onN~pH; z_;Yn(5iDN@^x_KIQVnNId6_+OV7u@;Fz{pm1b>+gm%Lj5gEey@ACV1SDsrb zfGhL{=bYp*4`ZQ5X^=2?SJ;;Ie#OivNiEu^F>*oiN4T&p-X5pdFesNzDk)1ysD^og z{DrUNjqPWE*nky@R4wM^(8?dXsQ$^rkEr##uIyZu*bL|!FsU)?Z;WfpX`iZho){9l zY{vlc4E4DRA{@N*lf`YC5XE{^$$@qkoNYd|BO}1tNu`45G#lQgdQj%r6?oE>8}>VU z%atjF6B&G*`9b}DXr^4$FIwYE`v7Xp%iT9 z9PH&D?CpW|yyFd30EIy5Y9jPBk!VLG3X4Qwb#yck2rTp$g&)}euLJ8J;N^2O?Ef2P SRLqwl18~vI+O+b#Tg=}|yPZ}5 literal 2519 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 XjP(dVh=+RR00000NkvXXu0mjftwAt= diff --git a/frontend/editor/src-tauri/icons/Square142x142Logo.png b/frontend/editor/src-tauri/icons/Square142x142Logo.png index 8c9659228ddf1e9f63e0278ef6e08be4fdf0c2fe..27e766be49cefcf43c37acb94c50999cbd514475 100644 GIT binary patch literal 3140 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 literal 5006 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 CY7902 literal 5218 zcmV-o6rJmdP) z3vgA{y~h7*?RAoqgdid+x2=y-#g3v7Qb8PPQ1te3eL+p3cPMq7>5S9X5h{9Xbta_s zb$wjaR>yH##%pN@Nfi})^;S_RL?D_3FVlgXX=g} zmkKFo`$W?P(V32vCb zkVw=L`Dq7hS#`DZ!@4>TA3su2aRUi4-w|S*l9IYTkK&+7YhMT{B1uOW0d(S`r#L`IQm8j!P zYLCa=Bv95n6{a)*8y1PEnCCSdipRfu`{vF227;>(B*6%PDl4NSe1BDmQWti_<8dKG zBx`?ym9f%V$IBv-NT=(*)h6XMYHW-Jm4ejo>*J9YR#iD5aJcV3UTUGT>LoNVXlTF& z7y&C_ruX+90TzanD2ClXe*C<0rGC#kY9apR@9R!8LZbcL7?!i6c7m2=80R91r&8Xh!2U!@n?O zq^c@ezJLFgV#gWV?V<3>1yC!iqY8D+BF7P3p7;L26HXXMt5^Fl^b8L^7}3^tNz`%9 ziDR0^LS;2bffXDm&>U^5Suh0q0to zxCgKUhQJd36%@0^BBO5o)t8q`NcgB<{^sTvbNhZ7NObsZwE3`U(ad#pF zb?n_s7yRG{^shBF++u0Z<8IM+hrA>&tpeD%2R9z4ao1i;->9mh*ntBKeI`e#7OZqU zw5~3?_{JM)+O49Sw_ii%20 z87d?EluY8iW82>QQ)T0BXvOHyUZui^|Hllz=V}troh( zTvhrlSc5R>cu0|Gl)n3$-_Uv2T$5;}Ofl(>!~O~893~xR9sVlU_h!L522>bH4Ie$4 zW{(DR~~874N&f1B~wj$OAAf9;Rd?=jyw2a8RMs9ViH+KA~b%+3_4@-Wa>gI zWmurlg4H{j(A~|I^zYqu7fqZqhll74l>rY;NXe~I@$lhPIcE-y95aTx+S`q|x8AZ~ zr4~I89H5esBk8IKAEdEYT#*3Eq+~*SJHrI102e+t;oka_1uF?v=e~V3>a4Tqs)rt; zQ_nruIFsJh-p<|Lc~@V}-5~oZL!p8Uc;#{*CjnGvO`lFz-G4u~1g1bGQj-bSTtj1~ zP9+aTP(wn4M^o-!y&MS-MknLL7hZooU4G}Cd>!4N5l}gOlL_6ZPZ?&?2U6}|!5o|P zp7^lg$%L*vTZo|wGCsV2Kb4IdMb|y?1Scv@DJ9TRk_krK-w>n<4Js_h`pSd}^!5u`ha{7hJG?Jc( z4_|rz{WRjV)67XG2*LMaaxx(u%Ka;yMg?6+M@vheHGak<6XJ2c7n73->5vLzeE8Da zZZj`FEECCuqS2$N;zvI+I+>6TsW8WfO)=>Zren}0Uvkq;?59jlCZt0uG;91$J=MJU zFdgpV{$<=aiVh#nWd+oz!VDE8!?4B=8*$8i_F0C?6qDYgWG(8%(@$rpQ2q!zR8U_2 zg~19LjQo)Dtp39vdNS!I_^JMc=nLOp9v3}_B7@%ALo0fky2kHVyV4YjRKCWCzf)Ul zTzuFn55WrJE+>r{V_tmNs*hCOtnq7Y2)O>M4fG zl=!gKz*2d)#t%F5T0dpHl*!42(i2bYyT-48MppSOl^5}0>!GOK8voCzMl<}v_M}++N$29%++j(`0$?@TIh$qQ~;1>DQ zjT;#(CmIdDXw3cP_px$9Zox4GnD4VTw&@nYHgTZ@c^yiyna3H|MXKHRT*Q_J&zO)8E9{(x1WBRzIgXt z?h4tY8*qQUWx+}{>HGI{C7n%rh9_x78ZE_`f{zGpv|A2!IOYXDC~MI|L18r-~mIo}%x2Mvd+ zaKTENbUeIIo13|kZdx)y!lXwc^v-k7F<6-7252TgKNzV(HtD<8uBC%WCZOvx0H_|5 zj`(m#2b=Vk_uk`H%T$v-kO~T{)c7zaY7w7Sm`^hZs$wJ)cJ5?9)dA=+^2?J}s^Gv1 z{7SIKFEHuGtnL->GkzljSpv1GVD-c zSDU(@{*;>j{AY@ua6;mNnE;jNajS$bdHs`5CNk+bw9}AE|Cd!LTdZJTh|jlfrRRSB zbIzofjT&W4*P@g0;eAPyeyF7-F+G##tyE~i;&*$pd)fNJ3-s!5f6H+fbT2rF(!}_% z*WGQ@YUy-Ih0dfq4y}6N0ow4#Kk|GZ9-0XmB|SiVnCB!z232Ul3j7r6QZVV5{=ne_ z+(H;|Mxf5|8oyjfg$1g%Pd=fSZo7>i+OU&PW}jt(v;k}Uav>E4lm6+ZP4wK)e#Rp} z)1WF!#)q5!@|T>g@ymr&zMFK^fBv_ohPyjnNMHg~x}W&45az^jvLKb$CjG$&sUd07 z0hviAT}2{1HG?gUOpOm`Qh5bduw^$)`icb$XvfNxT=X<0PSR(M-^Ptb$A>d1m2XhB zY~N1*^Xp&p4lkuAoy1+AHfXoJj+8Zi#i*nk(2C|l=7W_y{1Q-ov~C@@RCss+RK_F| z%F1|xJ9UkpE>Nh92P^14;7{K9>tE^RJMUyag)I+Ffa-|&aD6>(U9sY@N%smDDx<*) zOgbLWYxms4X-d?8c(XGD9$KIHaD9CuK8(n-pL@TTYblfKQ>gnq_lsX}=DTFX2yP)v zcxb)X_;Gx=KuvnyfYp;s*tU&U+;R&S7fVNuG@|Rv*EN2H4OU>%m7@1weU*!zovp20 zPB6`+=i3^;f(5JRgnB8dX~`0L^{%_vqz7k08xKz|G@Y!da zp)#SB>a)gg=~CV`FKqGQaDkO98KlOCz;Um);#(s z7Z_1kG%A^(6r1#IFTHeljh}_eOt1n!g+&do-*+E1KK*o$NjG6+l^P%3g!u4=4ODEv z?!)=2tbm2q2=9ED^yhE7DG?t&51rNW`ck zLc%7Mv5DHUGTtue!+-pPKQ<*!!hQ-S-E|9QjbF~d;>>ua{7Z7?--I|xVA8Qi7S2V+ z-U)@U#xI)^sbFcM_mvQ1<63K+-kiSwiy5sHOKaX8uXWch+VJF)d>A-PyM-z}!X7yh zA%v^-`>9>w9wEe7k!V&zcPcpkES%W&@rDhY^K!8fhm8-XRf0$h_B(n{&;D3K>$Nhm zi*WAys|*UKxO+HZ9*2RO(vpTmU<6-+$f13oS%wH)4Ze+7oZ8P``h2BOxRh z0!#RmL2`uH;A*`aeI)uy3aKnm8TVJa!BP)c^EYj3BduR8avU^ec8Fv(B!L;Q1BRHT z)ICUr)=#?y&>dQ3Ktf2c1BQZ23{ee{Y#T9RZOrqwN2J8y&^lYIK?$sY8L$I}z!I(} zTBoWiS-X0*D;?)v^pzQ^ms*8W?61HM7y?U_TC=Jqa`4}aCQNvvM5(EruImXQ_?nef zf6`ieB}zG+uDfRa#*LTaS{)I4^d3L8tfofeqHvsfuJ3z}5WM4fzrWF{;H8N)7y&C_ z2JC>L;DtGC-fL_9W!2TrFB==1VxG646!~1O4am1x87Zw@FalP<4A=of!I%3?-_%r9 zDfE>ORa87ROsSu=$K!D!L?r7sZe^^r*70GHNTl6$|NEBa=IaAyQopAUSU4hbA+A?e zMo0MmsuHCx?67cIr9f2{i9|YG_pLT5r%_{LY$4_ed)cNwHLKtoY8x95#a#D0U7ptf zI(4F3w4ST5rvqKE04Bf&7{M2MLk0C)46P;dQIEKN^X7fAX!PB%yr#lh2B=_!!4h*_M=KE*Y{nI z=+^m7O%LM>M}vxv1qOZ)o~x#&M&cKGLR{kd{z{AnB2r2&M`-QZvP5{KQ=TcFF`hM^ zIi5Wj01IH^Xi(8mLVXxc{;;l&o5V*dDsCVl<~u@+Q&Lj5=MmDUpc`SY#2NA73k-%) zI%&K)izkFc6&(}GnEUgz-a=Y0y1BV|ac>5Wr6E0hG=ISept`!+nYv@gr9#TtKGAeR zbfzOE`N<8`T$_2y3n_&2gdl|Ukf+i_yCl&{?fXmDoPG8ib#--KP$@W8E0wC_YE^pI zEgqRPX}IsYQ+(gQOb|^aAw~+KbL)F8Nn{>5_Kbr#Dyy`toNOwqqLfiqWMqrT zb|^_k*}vr$L+qxeZBVcb-i9U%}fng5IhJ7f>;dC=vhDz3{ClCqz5DH z>r9j2LF=Ytq60ydDNMUgbl_dk`HY1L1ciw~5a9*{ZG#~K8G-`kAZY0_1YsXR5GOvX z)?5Qj&|f)gpa&gLen@piX%Hl!W~irQ6*9V%=TT@@IO>{t@uFR5*WZRewHJozFy)N?^$Ie2NhxdfB$UBkRs%0v%x+fOZq`v+ zJ9Aifd(VjEaypp_VSaRew@pasLCD3j@2w|IUzAEqmzPw3ado(__`?2a>3nLJ%Grb4 zL#3r^8(Gpq-3Q_l%`mM6H@L1@?7e|}i2=c5gJ%te*i}$2p>c7fFv*#oGj;daCN(%F zcX0`8LlyV5Sn-!M<~bt*H-1pQ@_@!<+VMumr);6_;rKWV_k+9kU$1%Eupt~_X9wCw zigVT|zpT={jKW$A`6roreR~+ehh0xU;i$=^$ufx5MI)J3Z&%zy?!Cz~e0XlKvJnQ0<}j#-BVta&6M$KNycu1e{zH*8086(#0UjG6eW zzPK)iw|N$=WeRl*Aenq~4b6+qjqVRX%(Pd}%4;q7{#BAj#94by;|XiTqnxxiIfzr| zm7Q9zu`>Az*Jn84tl%uwXHfS<{{H3S-SFl2q8u>P@=2#V3iCzLtJs>w<0%j8!Z7j1 z$oM0O=b5XZM9+T8CX=IT<|$p=Dn7DqbGKWi)3Zn_IyF-A;H^#vYX*Bbj*XB(#14@$E9B)P-M(H*T2K*jZdF zJ1(_ts^e3+-V`2qoU@#B+V=LC$#&%7Z)S`NAH9NF^?{fXhgLI)m2o$Fg5JL`tbT^G zvrS+!S>o`d{iesLFz;Dpu6$2xQ(^wN1R348FH~PuWx$({K6HhNPAC36pL3p*q@+wU zosZp@>vXW}CE5`sNd(=%@HM%3p^s?kYXmtwXP0u8_+jiWpElPV)bAJ16XwJQY4c!Rz5#gma z6-112LtS5)-i2Sa7uNjzObr^ybAfNlnEW)0>yl!0m~}YRL`RAx*x06vhv#a`q{jY~ zuV1y|b9Q*?uM)DsqQen>wZ4715ZhYq66nQFWLaSKv10oT2Zm~WZsE6nP3Ak@uMgSD zR$dR^ygNP|9Gi6@B1&s~g|P}hVQQP;ZkE7G89plFu25UM>N_#Y{Ea>aFPB=`n{-We z3**y8mNGaH*eckq5j!oUwLoB@)tQ}%unPCJNKX1e#m%tdV)E!x`aGH3{99o&EosC^2c^{h^fyP1dL%+^**f{bsOqO0m-d?VK^Ej+ zFt^WE)f(Slf9!01fxpQ0Jll>a9~-vc@83oXx+6{$*_nS7pKB

    !7&o`7zv!y~Mh& z#S-$kB0z1?%qOvFE2u>rK&3po3D4jz}gEyS3y$^NGGma0Q`x6i1Ba`LI; zoUBZTo7m|YW9}-YI6DbldD3T*Ughz_?MGB)c8s9Jp!fZpwVVQY)}`5TZPdMvu~Jv% zYp-RGjVNKVzis+Qs>sl8^*%T>X9p9KAnnWO#x)cOYdJ6QWOe(9v!G8n|0IQdX5DAG z=QtvP2<1FcXkT>nv;4>JtAY!8VVk}dGtNJXSVGk4=InH8GF8~vdxzfs%Y{;^?y^`daX#c+DBYC*H+1h?RM{-BwiRg7BWzWLX@`7PlGUg zBnD4r?UwnU z2i1-`*Yqc|OOyXTJ{hn*sWa-gkR7i^dkCza86G%s(P#lFz`rKuSyd^F4`^t0u+Zg8 z(K2nicrhTiE;}Bp?(uy*7>+^Rlfe!3ZVzg120Q(APlS@*wi)}HM7$5PC2RnP;(IiAZ&ihE}7--n|Ucn8}H9 z$MS9EJB%#)!bdN|C$&Gju-JiNJl!#*-5IzvuE4ihRC!0 z9DDdc4Hq^k`luZtJ+L=msq}U5 zbnNnSsaqN?4lJBLe?|Lf*VG8}E{`IOI7z~}YYNU#s7wS2SH^r{@QPy3=egUQFKBr4 z;NpVELeWUiHybwCDvz{nF{yT0HUeB{)aM8G;cbnUmVno9)STxlN(gyd%D}c>Zv1>( z64k@c^Yf=^2bvx)DDfU$22@D6r!>&(d(}D(=076Sc*tFz^mzKp=*~xxfU39Z$Naf~ z5N-$-7j_thMY&7N%vW#|(%g}MH20LUmlmJAKO;(AA`{iGE>FLHdu`#x{4f_cMb+i~ zhVOs-ZP)b}h=+&uDNs0=ui%H5>WDc+CYeVAk!u9)u>1dQjOO-v0---vsn)eM6XsDW zbgbx;^aE2QK4}}XgNZuB96&6DmY9m@a^W-oqC`xh&my2^-4fp&JH}@BM!|VrjsUk? znf=AaXTJ4Fmp1tta`^s(zW2&r)AFSB-$42wkIdt>J3Q2Lp)6@};;Fg0^`h-C>|lS| zD{B@4Jbehg$)v-3>lV3S;qj;2YB<_SvOFpC!HJ?ghniC2mLCKxPa&9@48H$>u3J=| z)U@+wff?xCMXEvUOR|JCjre^oGl-!vx-h!yMshKlZ(b3BFI-CKC{$yf)Gkya_Ae$> z9l;lVPv{^5Y97tYEqDa$+0kLP+owvfxeyv+FcP9y8c&w!ha0F8mp3V-QU>MeU#6Z{ z-h#RxaeW?qy6Ez_(+QBGfm0YfeAQ;sn4DmwNYB;?!$12zY}|X8T@i)zgJrd2bANjv z;h0n>XKk+^n~6bs=`5JVAUzlcLI&LD`mC!;^hacI{~O3}Gs2+NQeetQ@JG%egUc^v z+ZX^NnuJvB4F-~TR(e92i_Hm&n)EMGU6iwpncXRII*>y>H<3B(5TChS0*nr5=j@}V zH%kc$qWG%Mhv_CE%+R{mt=s;ihp#G4l35Gdrarn;KWnQbz z_;!)+QWfogd)1O*aR7NJj16IPk2q_?fQtd*{@D(M#OBI@^Wc-lZb(%bhj^+EVfZSh z%vE_(u{VtJWpQ9RFiDT`-7ROJi+|gzh7VgiFoB3gSrI@rctUKl1T&SQ_7+0!KYp^l zHWM`0m@n!xOZ-Rc2S7>kA~qM_PZ1Giuhah!j|Uz++?9PhSpvjdjy%?L*%aR3Erbtf zm373z%HCc;L=Qh;K*xYhES%Kqq=>p{dqj!KhFT0I?+Ez$@i4YNyr#ejJ_+QTc6B6T zqg{OcVZG*HP3Y?B|F+J&6cWzu`4*J}P@^Pj%e^xq6;f_1D_IsI)M1)S!WMlE#8(vVRp_6}U&BIF`xP1KSIH$|b*vSwy zGHjQ^UqrkpdcL7>1ucsqM2kwXIA!taIuQkyz63ihcf<()=&{dCt=|&xL&q z4{dQP4rGD=WGP47>gH3^ZM%#-hs+$Z0T5ice=^)1Eo%$D{X0^TUG0EU0IdLFe`B-bRROvh zU%E^&l3r~G-rWqzYvHW=!U3g4#IT&m49{%S{zo95iY?5`T?-Qr_6u1NNl7hw{b*PB zQ7h$qHDzMY{uHYe4$5fnnkqYvElY1&bw8BWMBn({1+7AHF^Z;ZYaSw1mCc>679eT*?8 zX|J=k?<1cu|B&JrX`34Bs6iQf7w!hiVPDbu%Yj@95;1L)n^>68W>Q=Zh{ zG;bB7_9GB0%L@EGZKy-PVR1YqbE1D@eaKn6?lvbJXkIM4DdP2F_E(dYLLu zb>xCZlqUZq%ZRZ6oTseVjCIY4iEi?;1+lBmt*2DAZftaNSoA1-$&ZqLidh2#-m7cK z+5YphK<80Yf3V*33m&!9w4i;_sovip`Sa@dTPNnschn&C8<8omS_iBNk(nm8LaD(OwSv3!IFQhQw)x?s$*Z zU!gl0)x8ahlqTKy&&nJ) zA@4)s+sx!^<3=ExG@yz!KTN>rz`mPL(|F_TLkkdCj{-=WpPv;H%`SD`?Lz1e6cE_TCFcS>`S&Dpw8s__o za{9^`_(>Z~9K}yFo}Sd>fG0=x^HS#(PmR0-m)p#TCvry1D=~Qb(NVI?xpTnZ{t@PZ zJSZqn`M}OTTRE0SOtK~@9tR#n!TQcgxo=bRu7l{~ve~2PY6*O6u-;UmD#i9fw|;-W z%~=xMl`-4t#6;t!YXEGMVkaj@B7FBXmR}!4+N=6p@^p~6P00l)WJ>ynxF=*tNw?47 zJDF9)=m6ML;QW+o9TAI_0Hw|c_ws)ZKjOn2k|$BKh^+L9^|I#$;Ih1Qp{(bgbWRrj*%!~r4_sS#I*%8-&Uh3OpEx}D#Sq-V7**>i1YH~fNjqEaRZBT*5#9f)T&n7N{56OLd9G^d`8sw{L^k zBa{CAgvQ>ksfkO}#G>1{YwF?SsC^>^$7%uLSQx{^)zz=CLz+n`L zP?Gk>c8Z1-Lk-xvhbd`KhHBkInf5X@+~xo53mA|nxcxMvRfH*N5Dde83&zK1qDemx z(-^JB4Bp=@W?L{9w-EAnltH5WmhYc#W($Lp#vkX%T1CSF%dfm0H0YHW|M`_1@kL<& zmp`CP5pnv%q^Fp<*iw2}OI*_UBZpJ6h6Bgh?Ir|NL+>Am74PJqkcdzhIqO-OM z;@_%x0BzelgQhDH=y^p5FV;*LT^`W|+p(&341bnIR;F7P2dja$X0W*^I0A-Iyb0R~ z1G?pETkjP5$B7~>CAS8SbMfYVxm&DvME;%S%82Gu34OchjhYl~rQ~Kbcndny4<;-A zMAoPCzj9Bv#+o1rim6<1d%Kbk^bQh?jfO}}rETJsQInOd76Q*<47h&)XmUcHf=vAy z4c{&9_4)Vo0_lSUkB1rKjLm%u# z8M@^+v#o4L4@^-IauaLh7J%EE>pa%gQp*yj+y!gZ5|b&X!!S3ay2F{>h2U;?xy%6T zpdvXb%7os#^r{CX9c>mUhEUa1+gMULwm3f1b~Aw-tgP(oKcdi3 znMMaLxqmjPGV7yBM$HCd7rRao$*O~cpnULr_LL|rOJ1Jz)W%%4(+LoMD$!iq$n)Kz zSgGCX&l!QD=V9G9k|ml6x=?qgONp!%qA~rM2#}OjMdKkZ@{<=oR09wcB}p-kiLg)p z6H*`!&T4$50&a^AO0%CCY#M@-s2%XttUb(PRyO9~2mn&}{y|(9Jtm({JV` z&a(3D+#Tzuz?kPOd&49_o$z}}2wWf3v0F5QNkS4yPpJ|nT~SbUAOvz^M7e=$~s}Ag9><6V;R{Ue&oxYzLz%EC5?xt z8CWdzeV4dmNFL`nnO0-tZ_OEA-4px3MQ$(7zm)yJ2B*=3o24yJUi~Cn2Zlk-AKC zTq??3iv-sxj57gklRA{Z@V)!@fl_!2!qog*rTM&9=CA&^Bx{3b^C=Vj7*TNuq2na! ztgI{3UTT;n6X5x&gWubN4N=OTLqO7|GpevyBo92ee=;L54Z}_z@ZHD9-plc*`yh8$ z=;NeM9*n3eC-nFLgA8bNh2PceqNC zL%1Bcj!Y|`%wRK&M!94iZH{c!cK{tHzx^W4{N0B!_Fs%QHcM-Z$?8s$s-O{rI23K{ z!l=+#Dke&+EQO#`Vr$@DhnzlBCgo0G15X!|w+SP4z41!h()bgDM(G$~CyW6zkE)Tlg z)7+Kl3UlZOUkVw3w)#qgjW*v6%S8*QIYVp5R`5!D&fL)v8ch zkli~mx``@J%O^ilyO4|!t&Sp%@j#m`T7ku)544?VvK$eIdea#*JTtwkK})f}^VzE7 zh2nQ#xu+w%Z4=U*BuiSB3qilL0=0TR@DOxsT3MaeLo<>7VY8cPquPJHP}Jsv7z3cTsg8ba=1MDaVI)CH-$_`d^>gtMdTf z-WcF)$A|WWAm~`YY1;r7r+}+iXTPi90V&8SsL0A;WaSmDN)9@2&KuYLfP)d;AA}#bHT`3|+?}}8Vic$j#gbvbslis^h4WUaBkS0hI zA|2^Seg8hs%=^ywWhOI8Hn+ETw|o2B|6RDQwi-F|68|_K{c|#UTV z`qR!1a|2H=Zv$_GC(TcO`%i678kzs3?UQ@8bG5tYE6)xX^M5ba&|JI;1rCQ3=g+P? zCS3dbJz`=8P0uHdl*Kr@6VX#IOqCw#ngnW+x&@D$FkrWcbDZZPaGm7ScN1Y`s?|V0NQT_B$qxaq4sjH%56cjcW5dET$yHc@?|66!|u{Du2#3 zP4ecCIEI z29Cpb^1k`*?>X4p$BF#-(Vf|=I!zgD`<`>3<>Kt~Z|!3BXE9J43KBQfv_w^XcFlMi zI(myCdyHYSK^FQ3l|=qWHeJ1Mv4&=sYCwrLZXB%}`-_{c+ef1z;=R#TwU$=KuhYlB z;(QT8cyZO8XhY%sKr`&o1}2=2E`_hK1OEQgr$?HRJ@+aS=}lTy^=%l0EUPI|)JjZX z8*{2I3Qdis#P3_kD?KzrBUER8;d?_7v@5{_vmE%XT{-wPM%8|Bu!=!x-umHN&N<;L z&048eC?m){*p2;+({{hKhCiZ4Ht!gwxs|g(MS3OE!K&VXl;J^!m#4D~rF%(g{8d*N z6d88x^&0-A1hMF`iX>3jT@Hz5XM2{G7~E`OU1;&tME;F0_tC*OUvUPsS*2rCGPE?z z$thNd!KPGT&5Vxbc7+{?o4>7mX_T(XrFOQW!D6cTICw8qsnzrQAaa}Xc|wIR$MNwo zeWxychT7IH?;IAt}6bVm6ZSL2Z#!@q-Gi-!@vNhhM$4h!4+!k4J;sQ`Ch@ zOVgNZD&$GndL=VY@fV3e^7)@8%&2yvkt4?5F7q>YV{@5yU=t=3!zI-(+{VW1vbpOk z3TB#vWSpvYPt#H`EZ)PsrJl<3sVxCe?!6ps@*$AwAn;XzUzSpYOJdJwonB*QpBX1da zbb0ty#nKXOC_y!?GE5G#0#zD(sZ;Luy7~C^tJ9v};jml)a*~&;@OG_U+N%iV4kJuC ztnd-NW7%(fkRKZA%*rw#KH@{E)x##Z6!W#hwVXa-PqOB^ehOKt2$Tdt| zcm0nI4nGNWFQ&{kY1O{>27%NgrUab`omBHfKeiT9$LeI=_AD!xAQag0%+v;hRHfM| zO_Ir4cQ3d?uoJ%<8dYo_oDszj&X4IEOUkmYIoMqa4%vXdKx%*~sIl{NHhyuB2F6*K zv90-5<69j;@u%1$1~3SOHNm7W|2PRr433ZaEVOJ@`RGs(1iDs+4I`}maz@o%vs2~T z)8D7|@>tf^wt;BLuIaj~^N5$?1Pfz?aBHP>3M60CSB19BpAB6d=*tL7TznDW6( z7H3`TPqqAYbUN`olAZ}OCX=n~bSSn&9`)K@mdyo2rR2vOz!3xIjjI;WzsY-`k8oo* z|NM;P>Nut2S+}ZNtTrp^hUC%ZOMV-tBr$lC^tt|zV0Z`;)8i@~r{i(9q$q`Jm{=by ze7NyxV?skR=B=4Ban0YmY=xgW`Ng}MBAssh=Ri6d_9aH zn~txFM;G3}hy4#U;Nor3!82rE77PLn8SeOxYj`eqUfwd$N>}FX@{d>1(IdyqLAX;T zw?na>fBjFHJW`t>Mf`WY#2eH2ug72PV0$>qY|dP7FUFtOAQpk`0hFtf9skHux+M?gGJ8sNRL(WPP>{J zq`!af*Rs}YpY%6B9E$W&7XzAvfbPFq-FqYL#1!8o7cV)@TdacK&ZlDUdBX4Tbk%Xs z&pIfF_JpeR=^LiZhy^d9+Vm*`ck;qYaIi(z;e>qKom(iPq2&i*LnjWs)15Dyp0thg z^f(bj}9&b^LxW>yHXQ$EW5kn50B^(xJS?sD4 zy8$b2T_z%ZYLK=fCyyq_$AD)MVHX#}4&T>K7j<5k`w9##Q}KrBw6C*E9d4FKGr~M( zxNNWrfWo2WGTWFur@%{FsQ}y~+QUMVD;0K6oY?fY#LmAkcX)hBxJ6fh)_qpw653Wl zM{chTixXz?El7Q(4)J@nhmw?WN}knY@|eNOKDH$c#=p;bT{YkMB6gom@bmAx9uI0~ z&K!K>k}a`W3tZXiVjeg~#S>qBUIOB01h+`1!~Y~Nt?c>73V!-)cZhU!4!sbI&%P^l z5+zb{(z3<<yyQrJou1SRm-Yy=7be`kkGbmw~6jyTN6GN-EBYlxmu=h%B#R zG1bhsruS;j-z}kzdP1F4dT%Bz#~S6&sqRSF0;{Q?gpG$xn)y~zF>7y%?5D{ssNM^v zqfi$jYAC?YD_Gu)-iUJP35O-5Vr6CRwz^3|6XXxm)|cdR(y^teCDhpP^2uwd)1r*^ z@xTiS4_iz%Q$W^Cj`fq7R4i9Eqsx;oWp<2EW?{$T(_BVfmZ_L1JBs>Gu0piaGk+Y+ zd3rUU-GWf4`>G~}gF|M>Qn6+{%;DcQ>R(J8@l{8qW97=3M`-`^2U^gt_||{Fcb6nxg$UelR{oAL?Kbml=3Q+_HMHDDO;R z`&>kZ$g?Yvrzo}B{b1ET1MjJr13qS7G935Eki}2;7#$5MIO%|&!!SBnQsy-)GQjpD(_`ew@nlqq5(u-O zAz49tTqhaD^cZfzCAh$M%=9<}6#NUzs5^wd-NU@Qng3)&x_b4+e|6a^j8Tdyk*ymT zjydF9WFjISzE#y{LoFpDQ>ny4nH~eZLo+JyF#EGn+%$Y(A)9Wd$3^ap)7)GNu9`?9 zQQ+r!^@34!m)PJPD05CXirPkj_)AntFc`zrXcRu8ecOB94Gq>Ra8C^z)3WxMDHh2m z1rro^+bO$=ZBNQyve_t1puub5qygDf1`t+cp(uQ_7dE_}%xb3`Ee9t>t91 zkm49Z^HQ)PEi$PZ13NzlRr#-wOc>cqwZ}_lN)a6$O;}SfkU*KMN6;oUORlF9ykRs| z3D48UK~;>nK|qF$Dp>*gUt@Y~Qt)$(z|MB{}~VA+ekYYLHk z84y9S`U~abyGUUfB3iD;SUY9%2yjnSNj`D#laDxI8wE9st9v78C9)E4)DrDQ6gPn) zxpbdI&Epv2CacbPlIG&4GcUN znmbg~a{TTC2GK&a^NI<8%t^?M?=x+VbJM4w?9?85pP*9|`LZ=SMs9<{)5n()q8;=? z**xIpswjW}k;{XolejD4-?HzBw8QK1TObU6LLic_YbhdSHs3crASfiEMBYZCWOn$r z79Tv}_g2Y8!2D*rek{my{YX1@W(Kz8Qqwhnzb~NR?R!xJxam zSs|gB{C%i^=gVCfL@xRh!ICgE(|H&zwIkY%!V@M1zKjA>#|`39biy(&xlq4K`J}a6 zFR`HRk>fXxCbv-lGcr}U-UmhkVw8qvB0PNzwNruV1Vw=%Bk|p+u4*tVoE6Cg%p)y6 zX)T!5mK6#ACx6BO(Iyp!lcKiqF-#cds%+8lsxML8pgJM~uJ9RGe{vnKHXzRoC4zbo zxs2+>U#Rdg;h0`wSohD16JUiyW zgODsOt?&C7;%9{KLIR9gZzYB9 zf+r1-{S`%T>+{sFfbX(%ikr83PsAK=4Qy<^n@MDR%>(SjvVH*Es0>?I7VLfC#vR^O z`gSdUlP2eP;bOZB8zth)0MB$DdR`A#7_4l1jB9mKFYu&V%rh$!3*3U;_6QAEZ>v3J7lUM zy;unY!ER37$JV%+`r@_co2>_oG=Os0^=GHTG&m ze3Q7^G{l|$+?pP!54N@Hpy?z6w7y> z+%|4EZOlIh{dr*Ho*R|j$PbDMe7ZfxXDt$p1nNKUq=wAyyE zAP&Yq;1oAAJjAfv*pJiiL_~b}6jUcDPDl5>-diXn+dui_)#6K4I=7ckNd0#?@9ut}`A10Zs%@YI-@KXX^o9ZQ*A&Ezd z5CY=WhKUS+NI-K;Ai47LWTd&VMtqf^&qZmm{iyFAIY5YhS%9i78*Gn5pmFDdK7~h! zb7_YuivwaYC`;PmR|Fl-qo!<-VVVRWH(qfwpHBauQXcGtwM?($(bIx5#L>GzsDqYJaPhF416+ou>vWYp8Mjn>SO%7kuBZyPQ=FZD zGMq8YTl{@^Ss&=^wT;5pLBF^7(ZDKLV!6D41XdqD!u43rbdm!P15OKK{MJxK^-w4S zBmIEQ$9s}@0!Fu#z|HbkYyd!tQ4b<}_Y414$>j_AS&VJqw(9-ls%@F!bG9hZIWKRUf zfE~Pi;6$G9w^mzdnI&SRY;dkRzP=4wU>gxK0C~zM=AylQ0B&iU@V$Hk?`OszG@k1} z*B~b#{y4cYd}kP%v6{7z5qM$x<9z(#n31j^<(EOudjMwNCjvH(ya`_<;Ek*Oc{rG1 z<^6J4Ds}_tlLsIUzceY2PdDG#M4Cb_`rbSYf$tpw!&J;#U}@VA<L?aXOe(Z93l5VcVVL2<`|Au*#Nq4IJsUBr6)#_RI;lab+? za&X5HWFFWBPE4nAR z{9j1o`nHi3Q0#3$x8r+r_2IujA{Pa|`m@taZ0CawWAuw&LF58XUNl)w3{(VKPXSVr zLWA#^lC8IRjWO!(ehA}!K@QO-dB^yzb{Kshkt-bLfd}AD59h+hPw#c;EQVFvFw}yJ zWfaXz0L|3n;1nnJ-WBaz&<>POCrvPecm7vEedGinWT+AVz7|C#tbN1Qog@Ek0Y>De z^`J-0iWsEZ?Op-F46^PIP_{ZAK=<iJ>8o6I@!+mUB3Kbddz>HUp&lKflXWmK*xSW_M@T5rTtnVanV$` z>xuIlBI;K#g#)I?G6vuPEjKV`KUmV)_-OOPy`!no06P;}Bfm3c(kL1D;2WgTEG->0 zlfAmy+T+c!x%b#qie=G3LCozE^cp=8^w?=|aLAETBtL=$fT|0z9n+?7uR3o0MG~tH zQf-HRjcvkQjhuOThxlK;>{y*$NTo8ES8$C8H$qEB4#!9=2=l?J(9x0P>T+=2yZD24 zh;*Sy7yy4grgbkSE{8;p#;;Ccl=*&_Z_vZ`BKaSc$x74!ri+&x%CR(QKrfvC9W8Yn z=Qso+{arN;kazzzJ`*z&symo6)k3gLPjichA};KWiY5Llq(TqxHi?_azRKQ*^Kxl- z)APVpFNs%vq6yz$VpT7tMp)`q{b2?2BdA1}wwi1iq%&swVaYEz$N@ zFFP(v1RT)#?Ckex5sPF1#7`@de|^>$^si<(5QS#qiCTvG`m85~-OVp5&JLE$9Qj9{ z$z;$>nMkeE4*#Qhm*%v9oK4%TIk-PXS`7!%i-4h;YhcaN^Gx z17UCiyf(ZKb=xDAFKo@EY#n>U1J7D(6mH)8vh#0PAbN%^$4{HVI0JUx7(K z{u5O)sELlIL@hlVW5(I;MR#7_yLyem0}``VNRrlp_8w3K^;TmBdl?j5>+C5yJs15O zmIt^B2K>0?hIXzr&`g^Yt10VMujO|{}mww`{m`%W_R zNC~Jv)B!G@VAP!!dy56CEB`D26+Z$nZC!j?z559tky~&k1_aClwh5MyL2D@Se&r_pxV3ogSAJnqzftDKC^J8wpEZMhyXy-jYX>c$te2A z;exLn>mA}Ok9&xwbXIde0pvRyg%tk0W*bByhSSuB z+{Uw04)U|UU?*zG3-USLljvLM{}+-!s6@hif6M_?ze?8nOu9XUkrv-^g&OKAXG2y3 zqX5R-&-0o`{D0#RSDYiRwm`-krWOl+Zrxie(SPqXPcKT&d(H)C-byOc9%lJ(O$a}7 z5QH^r%Ge74U3~VHkZMl=ikIAPGZvjeD|<6QdH;NJ@{iL8Gd@8eVz_nHW%!VOq|cL* znBJXHQp!JiC17eN1L;N;35S)JJM^6&_cG(+TT?g4NrNN^!+Lt_O7^~h&#&4~$3wYt zqJUI89CC00O#G<4PGG=}m@VyL1QaLCP0*C0_Tf+J&k-HDJV)^`Hc|K}+R-6=YMM0w zpBAb^Nnm-C4Zgt-(6Q?5 z-PtR~SzZR8I4{UpQP>hfNKt49b^X`bWU~#9SOGJv1RL;brj@GD*ZA>Sdc<#3vJMI&jBp(TX zO0_MQDm_6Kg0*wP;9Be+F*6UwN!QZ{@O$V0TF<}BEj__swlp_Uh22GSE2{|_Z2mj5 zQ&ZS(h&^%Z!_qXWtAL+PoyuR&*rEgOg8AH78Ld+)PVk1eaEkZ05DiF|`kLG+l*8vD zCnMblc!Ix{tl@O!Gjxd|B?bxeR?t;2Ror1}7KcnNi`Lt zucOh@<-q!(x$|%&$1ERr|Le-Rym%ZK#L81a_20%7Yy;UTN0?t*oBU4@>J8()O{5@U z(h3GdrO`m3-M@p8*XC{^#$)Q0hR2fH)p6rp4s;+^rhkV@{}hDZzMq)*y!%-BDLnqd z-esqnkAJINHs%*8+lMlG(3i!zQq8$0AM;ZQ?@%SnNGCPbVtwFDbW9#n`+d?RZ<7%e zM>a*Y`^c)2@#CF=?2H3G#NivZcoQk7sZjU1sx1#@`nwhGAW)d01YKK;Tc+Xf#s)t` z6;om`l_`VW_i32oxZZWY(j!6;t45*pskX*flhhKXQpQF)nSeGg;BY9AyLo5ywCgJv zq}#RG>t&h6KpNfBlINK&VqAL`_DSi|lJ3~uwH6GbHT}swe!k%ORKkH|1UL=%R`G8a zlczmj3@FU5s&jehw6o-czhv1kMd1W?>S{|)Zu~a^1^$*MUz5(7$~H4SV#r%wmUE=9 zDz_$3S%OjWtz99UKVVH2>AU2xlRxhYT#Co($*vXK%vyZ0p*rl!&4dCEsejx7{ka#c zPWW*(_sGDr&Evd?c-3_0p5q@WNhCqh)`HxuckCYMv>i0z@NNx-bI zZ&TLDQ(kRV3gMKKq0;J<-!EtN?$+!W;_C$@>r`?$dVsj!>Tm0pExIir_a}0$ndP4@ z&Xn8_uNmlnx6C5rf9@$8_^Wpp{cFfAaT8)fpV1H5z{`CS9tYaMgp$tO*7+2Y#A zE{@LM({Rt9JD&@hebP;?R#byqqt9-5x)2 zp=O%kq1l^#vnSqJ`20bq-Xeu2PeG9vP6!0jowu=7*?Tpnb=0`m*woQ;i=x(bId%6K z!Qv!j);bj)J|ZLykAHRO{*aWdwjD3Gp}Uv9Yr`Wm@Y=%TFe5F!`+?+2opk`1U+$r! z(<|PT14QBRC-h3!GtswrOKTG)fbHg$(&y%rYG zHh#>U`HhTGxO*RzO`G99*OWBHa{77Kz&WdXZy$a&Yg|ej^Q!ty;B@)-zcG5SGJJ4G z!|OO&2YdA6vz>Qo7L(8XA$HnW7wYJuzRk@Jjzkmv zmvg^ElJaY|T1fcrzTXDL}oY?$ywrfw3<@~86~wBbWkN<(aH zBisq&+^)(z5g~p&9{V=4MXFIXg!rf-P|HjNM?fru-GM|A%%ef7=w}3fZzL4{G#*-h z(^%@@aVh7>>@I{;0g)cv3`2BQsACKjTAN-8>sv4CBC^kHVifQmzdL)^6sBs@ztneH}H8r(W+K1}7)=zb( zjARRh(bEf5m6nqJI5Ae*O8fsV1mOw!Ml2uW>q3-04eE_GXA2v4jg|&pFY1xh5g^9Bj8hR?ZFLLR8ELSQ#HQypF~4NTe(WfD)@f@v#+xKAX*{Q4%NtP_x*M^kInx#URUv|b4GH7gLtrSsHj9pnOyZA8~M931N zjK)%A%QDHnzh}n(dEWPVuIs(Jy5@4e-*e7=?&WiT?sI?dTbNy8V-aA1Kp<>zqf3?$ z2#qS`KNABOS$Iz<0Y6Z8m?;bbsZ3_w`-dLb&MK<+9) zAioh1h~^6jR2fZ?|Bl}nIA%D>#kqBICZ90k7wvkv*bl;w@goKD}&X?q&} z&hp*uCb7F}9qS?O>FKisvimLW>vwL@|6=ld2dh#=i;gwFiRZwxavQJGoK85yNSX@G z)(AlxS)Q>M-BN%fwQC2J<;WXxv66Ab#~jgEU#A9Zq_IJkve4qnEpkG-yOOTNija2S zKL^j(Zg6qOC0%G5-CS>4K0E`L;$?}`&$IZSKW=3VaJw-J`+La}@HbV55$56HAFs84 zi;WGDXeMTJ&SnWXpRj2^|GEmT)2UICbjGHg36uN&>9GJa8ruVP(eCH8RcPJLP*L!5 z3TJYSQmxz1&36O>AwREnBO5Hu7A99_qqDxw$HvCCs+(Dyzh`5SNRCz46L3MAI3qQC zGz{Z%RSokJIeED-%6sv#x%ubXLTB(_3#EAZFdy_&x`;uEtE&RDS6cP=42lb91X(#w zQPyj=74R5|6t}ioWqY(bYmk{Ud~)}_!?j{@wo1`iUW6=v%Jij+SRQHTK+m6Q5>P&P z^iZ$6nJd!lu#EmoQGGGzP@dFKnG~%GZ=Sz=kjTlA zpiUk>iCebd^)k(Aqpixca1B-6hfTd^hA}wW*tB!MmhEG3(ct=(`P~=htJ$;7fzj7S z*IW{-g5bhx38hYI{s}rKcq@*~v#*@{4@|_GzTwJcg ztY5;(dsQvd4QhjGb$2*?$KP2wFMd*(z43i;*ku0!^9xu?oLI-rDwS}f`~DGzY117i zGRLov{C5wg78qeh#<{L{{lU>;7W)g*rt70*&)UTq@pEM6e2v|53tsrNV`2Qr z*XWV{p5cpC|C@>FK;v%-{1F!}rs$}gNSUq&e{;@O3pnoviQKH+#7-v}y&m30yjb1T zLhEMDG4J(@6(i03nV%{CUfY(EZPPkfn=j4F6C3oa8~>WN>fV~!S6H4^y#+I{7}@yY zW^Z+4(Du=)`}4`T`3`k~TV+khSO>E1UTuBHgYgeDE>1ebv5V%N+1oX>uSJio-55j` z$Ei1k5N8L?J^eMsm@-gd3t*qN?T-e&o8g6VxN+XK?kf4yZH(7$>4-5ktj_xsI$V?L z+5RYLdmuQdQK;#;U$w{@7$GMOeb+1+B|3NB>&f(`Fo|Cr5w;uYGrtNCuP7@P#%eSj zXBZM?>f-W!!aG{Ft&@E>zSR_UfxUiglb(Oq#`+pWg$eUltNzu@*!g)KHG@&h)R#7F zsWm$(q;RLi)rV06P*~gtxqZjI+cBJx+52Vhf4N0VcUQ`8^5}^&VOSJ--L%4WSrme` zaTm_S*;NnkBHdZME^OspvWSTNj*P&H%GGx8qYa+HhN3^r>S_K1+*u2@`j$zCLG7zInNFJ2%&7cN?(~ z)r1Gv*Hf*pIXO9TrA+74o@2>R5M`47RIsh=7hwlRuMM5OVSA%LOSq&!EEg>L=?8Y^ z^XGgA_&RK~zx`2UVb|ZUiJWl0cp50|m1UQa5r7AknmbcDe6Id#X&9L{l%_v@oM>mU z6)rzTx()Rpz)Ku%_Z6Uw`9N|>{r63aZnHX1`10vb zus`Auz=(<~4@+OFimUWH5#%)}%=E0XCHR(s7?aYL=JztGmrANHP*SfVhOFZLb#g}9 zO=;gRvf_mDZx3Ca(k@X{EoHeAsak|}$kIofrb@u4_a-#Mb;CKNbP-ILq#z@AU?oe9 z!#bqd^cu0zF;&{`&}Hc=pBKb`gV<<0xYl;cv-zN_WPg?4C`#PiRpD#tuj|%X2JQett#KvpGeV6U0^ek(ExY%?u-xtRs^v z7HNa*CT{tSN^8gEx0ZOVh$!>K;$(%AQ(plH*Qx+qk#kuK~$NfXU-WgY1Z5kUysT$*dL%%X6 z6MD5;khr$B3_|jE#P1Rlr7L1*Re&~FV!wabT7LGsXoL&d0Pmk%>Hotj-#_#NA^l7| z`RJsE>aMc&Q`NB+)tbbhG*N4-w5u~x&D7P4nVgJt(#7v}xR;dW1VeCzQbTk8{XZKt z8-n)KP1~mo>%(Y~TNwS&3}}5jiVpeE6tlRs{Aw~Dq8h~}?K)~9vN8rCVe&}7y&mZ7 z2VFZ-!rxds0%g2ZI0xwJ=g<7jLCX7G;_2cOTgw5A_QB{)(tt;cK^;V}nSrlG;^y*W z^C+MBzCXTT8W?(3l`|7`UWvI=G_5%R(Je+0M9>j)x!c)tX9RDQ_I3p>8z zxgyO+RQ?Qq=K8lNO(RLDfgX5#wzy@=^09gO_U$EXfg)e1M)k&Z7cMx{a43_>0|08-+WH6sfF_sTET|-D#q&&s>)F7z|BlQq>rX5f^zT94<@3S`fyY zgBe+7nziBh5~?(%=v_~CMgXU^F6g=eg#F109sXqdu({)uNh_(jRjH4Mz7Cw_;c@lm z2E@T|Wh^E}`S`<;&(vr#{)m$QzQSBACx{&RB;@v(%s_f^QW`BB5 zPwqs8=L7vmwx5L3jz1Kn1(w|HK-s%=6rF3nZk2YQ9C5dUU0{CsJS$aHlL>R+C2YlV ztVjx~Aa-{6NsaioCKR-({`|O4Dcc<6L9&pp$?ou5v1f|iA1R)Llbgb?-wjy}M$>v} zL@yo2w+<}^43K<$un#FhsqVI;cK1&<^cJl3dA%=}=Tmc;6wU$qjNkGd`rC{>cpq`t z!gwwwx2GIH)ep>;uVD02q@?8B-}^j4(?jPV!1PH`-vKGH6Kr5J2yWj5Q!U^4biN=jhk-f}9*jOs zMGJ`Po3nwjr$QkA*;4fp`LZ5G42Y@>c}p9x-MuZ}i~o1v-+$=#=cb=42C-7tVMC!$ zr_)hie}jXTa_Fi@cAPzu1mA{o78U{Lqe0d_ATxld0b*4jJT`Bm#%y!EaE=rhp(9pI z@z7HiBXzZ-oIB1^XKXwmN30lTz^Ld&gA69FjQk4vFNt@Tw=O9$Qs2YI*%h=#Q*FXf zF(OY8{Et4WZ=g{EZgZ3X7i@Mb|L-F#Ze6maMe^`aMx83?!*waiDj1FBCq`@@>{3Pz zDqK5IxPj)AqHZ1bmN~*YLqY(_B}HmGYNCI*@obXh^AAx{5*G zhYNgzT-4xXy2X&E=w2i+gnx^w!gIUP`=R+Q10)R`*MLo>yHtfx*aT}GqQVN0vt!yc25)GQ@w zalaw7uP@m~%Z|vPH@SGs^pLz(e-3RcmX|ax)E!-?#}) zx!Sq|EwP8L7Hf(tUb3IaZ@c02Gt@f$1++<6_|=nT-xtu(TYBZ0x~6G#bi9Y-o5ds3 z`NxwxQZ1_Wh@-v_e8;Xj?X4s~BtWF@WqskkaA9qM^~BuYh4NO3PCBot{e1un0}EOx z%YxP?ilcA6PGaeaK__?-)n|tY4R>N9?ko#6q9&m`o}m|gZX=?`?=7iNSec535O%SI z1}S9^Tbe1#`4SSTXM#a@XvQbZFL^xufKyA;6OQ&vfT5$!1kNO78y11s@uV#L|!@n$} zF5k!6`$ULf7x!?VU=Er#EjJ~PlqVJ@!<}bgYT6}Hp$H86^vv(gQ|Ks{$7nOp%9D>i z-d0voH)@rCe^E{(S#H;p;*}*s4RgeeC4lPRzfpm=`y%kxIQJ3J|^OWS^ z_GV%BROG6l0C|CKh(&?<-~wkZ!juiEldADoh1o1C|9+5C!bBmJ zl(?0=DH53o=a+Sju4Ih)w4^mU<1-2p135OrXm=+k& zd8GptPi&8SMQ(j92M==L{%&MPuRHj2KUN?VG-9lN zM#d*SGyvZUm6&z+I6ny)CX#)F>lLT~>J|cz63#JvKn~V~PYY2Y_~@WNKM73>P{F$} zCPQT6Q5JL?w&ZnP>B!RiM@}g9IcQPjINXI;ue&m^AnT!hny3Q?i|$A zccJwho0cxCGV{*PQ7&-1z+5p^{l|r!io&=7i|z;H_Q!o6Ch-%GFCPi&rcpA#GA0ge zpiaPlT%sa(?_8dJ8%ms-GLV5+2T|w`5}i#sSon;;I)XwZ!Ah-<`)KoyCl9`w>X-*~ z36xX3ZD>jj!EY7lnf3#OshgQ#l-IiLl|;;SwrMJ` zIXzs-gGQR^bX0okMq3=dF>qz=_f+zr@#ciNUU7L5aGfYQz^q?O-_ikDvv+P`w!AN= z=o{mym!Mo=326*M?gmo;o#F_vG|_NN!KI~FmlI;_v_)99(SRDzAiGUx)I(DXNF?pq5ym)H*k}+PjN})M|N4Xc6u$TnDV&f1P650+C909Q28t zJxDA8Ttkpr*ESRfM_=+$Ztcz8%$|3ez?Z+*H?^@Dnp_#(Xgu8 zBw-&2emF8G>pOG6af}?sd;mC+92~htseaMp%KbiaLzYrxOC~sv04@v)w)g}tsvElr zH0Vr>XDcy}E@v9Bb@PKFOo9IhX!(!)p6T6M|I!ULzb}8r8@rX5A+@g(d@rnwourcZ zsl1IMbG=tLPDN(2zqNlp_>~7?oHYbuYyL~6b2=pwC^oBsE9jc}yn8nUm=Wht7dw(Q zRpZD`lJJ?6Z14JXcM=^(Dq}XlO-xHhMt-DjFau6E z=;4veK7s#P=`@l~7w^a;RWahSdlZ$G_0s8%CVO)yWog3?OmTk&8syw79<|?A4*tz+ zdhD15x=-ZjOvlrHm^SHEySTn;N(rX5mn7huavFk{^pzN)73t>)TGeT}P$wgNl+QJE z-QayI0cZt3+dC}^yG12@e=0KJV2`s)U@V3~aFxS-c@=N4YNfI@GDTP z3g^J+ZfU*20O#jyb~{}$=Gjrim6OcZqWYa=%;WbyddrsyaRFF#3V#npcC4A=L4b;X za>Yc6aS|^U)qAl*E{Ve5oj>h*US}kE_{jssezAYTl&T>b)oW3~Cq}?8wqRYkGe_{x z!L7~OAQ+aaI5Rslv$UoBMWRBQIP($EF4KeGUY4%>pM-!EP@z@h6s{%5+9?YQt}+CzzlYQRFopn zsQM_=LzFoi8;DCB`tyYQDH*J=n#_<$+dz+ zC4;&nI)vc92w)C@J25dfdaG*7S1a15rtW$B?yldNKy{(HB4C$m2%P=DQ-S+CPvM9a zd6#oXI;W#s8}}|Bc;Z6>se%T18RO$yBAj!4v522pRfn*9#BvI97$}eAv!pYZ-9tDg~f&6hYb=)cr?OwF-pYXhx(v_giJ+f3DrU{8Qqc zLZSgs{+DIb;Da3~oG@}^Wo*n`#}Hsr`k^1XUNow-jk0TBZ@MOd)B`sCUsUCTL=><_ zGq!PRaB*eKjh<4LQve${8Soc3H{dVGw{K-1M%WEB1AhY0LrXlyKsqrvSdyIqrxvmW zfJ9|!8uHA$upb-Khv1?S-utAcSLryl`-yW)wW{EnsVZnRg^MD`=B5?6?;5&kdq08_ zI`pcCg{DIXsK_fhPWa1uqjkxSfViL6O3SsVkfWsOm2F_LytAsw3HkPQ*xr7h$`;is zQ35G>%eS)-VQjZ_;^d#OPkzyNLkekezTC$+K+denPX#yE9s}7dgp_VZK@bveWI98A z&(=oCMhZ=4(jM2YhoKn3K>>`VJo9x=I%AM&brGxg85FFSEF%N@e#X?CAYT3=*#dI@ ztqC!2PjJCb@3NgW8iuSw(;%u(5!-inJd{L=z~v^eHI@-4-F+fJt5Wee(u$X*Gl2F~ zzD{#6dN+9IV{)WPt31I-&R30ITjU~J)r^fbmdEFTK21ADBieK|EGN0}ahQ7L2otjC zaNVh7WcsuX7Pz`CKQi!|m=Sp1*UR^8I-ym81Ief!dH1f{21gFFi|eXrj|RQ#_Rq}b znaz-Io>khy&{0r`quEow<{kf)scf6QaZh_8fYv@nqVzidE`M18Cp3LU;I-05HRzto zP1^}LU(rPCTDpcFKP3f)6{g7c1#h8WDh6NbDpGDgAn2$}K;3yUqSaCZpe>@fu^Ad| z$~PJDtWr>52MLyX*@Ru@nXriaSI@n8-N-Ud0&dCb9JBPoJw~)sV%2-;fxcW4n_HmQ z$hw=)k`CyV2+STrD?l?tvcfa-(2CMT0gYh^cC1rKaSS^RwuIUvsuasUFoB1Ou0y;XU2z7;%2Tjj< zi{4*76~)^5_f>Je#DF)DByrp>L(k7G@ErL|36YkE1|v$>^6ls96UnwIAQD@B27_@r z6KKRWfF9`$*TtCY+Laj5;x_Z*FC*SJ1i!uAb^=2FRbul+d9ii|g!2}uGGVUneG!$0 zmgs9+`uq7jF~|^!wc_8^W6F&cEw;YV4?0~hdh_!6RFvOJg4WcBdL~xI0!3`|EkP(u zMzgN{=IJ(~Nm{em z6q~)X^T14uP^h%wC{y;56IwbN!dQ~zu*^H@y$<@-hKk3QH4pNnWP3tScM@eOZ}6?M z2>jCK;+iz?O$r4n7Pvmi5~lU9%Du)u5iJdwff8#+HQV#p-qm zoU_mKqrmw>^-Dqb6m(aky4RmmwEAN+*N~rPCFENi=#X;J`p1Y$$rlR;;K?#w;+y#& zOtInrd_Lr)9|f&b9xrPaM&;qNH%zPc)c*M_Uzm3B2Uedms3SYWGq3i~FYI!?Lr;LQ z;w)$zx|x|Ki?Q4}z?S6Ia{5wwy*fSHo1pdkvM@p{Q4=GY`Gs4+8T8?Kf4w(2HLGA~ z?o;pU8P@%^`k&A4;w3_AJq<96P-)P~A1ThaxRki?n)6WW9@3=h>a}mi=&uXm3O(Dq z!X<-(CjIHFb)sLqhTiZK&D7t@j6Xy>FQ2Uejdak(d&9{) ziS8t?AnhJ71{d3mJpFd^o>I^DvtVI=&mt(hG}N{>0n4O-O=5v9l~{e}GsV`)DIC86 z4}J?`xU}ie%fEh^O>x8JX5xU+PHX>{@?D#Y?3lMS)ir}7Wl}rLOURpvS#0Nr8+TCS zzJOakj+<+3j$S0%BKj~jQk|N*CpHSA71*99JK=JM?XC6`JbO`81I){ye3vG z$A&lBaY>j+;j|WtQ}0990d3~%Tij%_j#b{DQ9neeQj<8(mZk1>`~=IY4CZ8EkWd0roT;G?uW7r(7_Y!KE>#=ZFv#f$Ed*p zJxSu>o_un>{hMr{Eb#Pl{t*E4_sGzB=ETf>Hsi5#I>=1ks^#4ro@pJKX3;q93 U_;vQmGME5?8=74z(??+b51BGl`~Uy| literal 10678 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 diff --git a/frontend/editor/src-tauri/icons/Square44x44Logo.png b/frontend/editor/src-tauri/icons/Square44x44Logo.png index 51f826b8912cbf57dc2db6f76954e1443dea2ea7..a7e08945ed1a70096ba94777139826cb68b5b4ed 100644 GIT binary patch literal 1493 zcmZ`&c{J2(82(W=Qf{b_?vb^z`^{pTlypW$_VqH3bIct>27eE+=9M{;tomX=bI0suhT28(fl zvfkm5JOV{+Tm=$JM*}SFEdk&K<-`I07YM5murBrh5Oom%ViN&i54wu|3IKnC0I=)} z0O%(Gpl~~{+1VTb#E&}JxmiJk2M-=R)YGFP5L6^G9S*Mm05S+>A(0d~94g3gI28n; zIun6_AVh$eP=t0i3I)|qjf|ic1=1oADLOhNEv*tSuXIRgXh=3N$Uvde3=EPrG=|vh zF9QRQ&CPQxEhz{@hLI5o2Fo-v8*gkBiA21&Zz(9$BN&WsZQWN|TIBAYqOJYt;>BDn z_Up)qP$1y-^ptpdeqb;P937L?)ysT*cGlMqc6U>Cby)!cqjhz|FJ3%zc22x-p*b!t zO<%t;CT4)i>}N7ZYHNFoi$%i2MKv~lm6bJCUq4t`Ily8~G&hs9wdq*wyTZbex;mPL zg0CFMp`2`g3(vaB%RcyL+smVW_IAAtq*bb5nS5@MCdtki{Bgv%7L~Hda>p z8H^NN-Qnu$jpgNR3yZJA!vcX|xTa=%ZEdopWumF6z{#n?&+q%(Tya8b=42To)Oc3XRXmXsEiHfyd|B+U{&@yr$8X#>NU=T$0q( zUZ$lr#m2I4-TK(kk*cG^&B&l#zWh&862sT`k)~#*sc9e)c4)5C5iSm{fVcu_OnAio zv&(_-?4pn~BNX7Ru782rVt?3LWAvIohBF}`5n*F*C9y0ieNtLc{;e_$vQw1}#?sC7 z9c^USb?E9DiSKVzokCqy0&fH|=)eax7m`Ylo=Vp^pr3>5D5B}`Vp4e|shOg?vkjt> ziO@n)Z%?&F1PA%-o}jI-FZxV z+1C5yu(QbtAbTYEuGmMdDGPg6B{R8RasT|N5j*Uf=@h3%alHTJ;PphWxZuFb*?BH4 z1h>HnnGyFE!~cC_M*e4gWCqw75t?%~Q^w!i=h8AQU+4J#Zjz78Ab@w~^OxMYvU2jd z%X?R)#JFr>T!*Tr-7B?*mUCNxM)=|kqrzKGe!D3de-xZqODC%ZQ#MC`{VnnCVEnP< zE;%X)Jtgd9@Wihm3M8|tx3!RB>oR&gg0q@@(;Vz}uT{B3{CSh&@#|etVe19M*d2vnrRgpE^WoMB zk*2sp8&6RyQ?b(i6gd)gT+f~x-e`iHN}Z+4wTNPC9W+lU$0hTvDG9RElc(V$ICS=> zC1fbyj7v@2<1XND%8l|0-)|Ta6KTJHt2&b1=)r35n=q!!%6JTSnF#FmFkUa^TJu?W zPo>W{SAFtt5nN9#36d#Af1RCo3|d7_5d+qJ{es%=fBRL%_QY)4cJCWy?^FqOZvi1x zxm}*KvRE~{RC$h;)tR@TkQ4vCo9Jz9_I{lX(rPHNW0*;CH4N6EkXXrC(C;O(=GqI$ z8VdE7&BY)$YerakM)=_)h-ktcB9s6Gh%nX%P4(eOH#h;`8hXjC)l>>%-%{TsE()PyW delta 1758 zcmV<41|j*?3*!xt8Gi-<0016@S^)q62C7L!K~#90#hG1b6jv0-|M$+!Zn8-vZBkQO ztc7cr~UpDQ`ik3xQDR;O-kY`fNsV7SPrL=7G}EYLBMXYeKve zwk()R;Z>R@1Pm#O<9^6AcWiHOKS!+W!SDS3N}sMb1x*uS%Q7K^6tB*^Y>IxAvJ5FT zucl!%5~=#Ct$(dqd|z7nh9yL&CPY!#vMeElh76dH63VjD{+v?EG^CWKQhidXg)#!{ z@M_wd8G{vqVZ&o%DUavh_*ilCnpDM8XB-*%^F0;#}kgHn+OI1$e%qM%eHJmPGKReaM)?6 z(%sIr1Uvrh84BjjL&bpuD4IV%8f;FABYgu11p=7m_oHO>YGmi&#e7t8*JdH{Hz$>>$N#^;9s{6 zzQRI;Mn{uMz%^hy6XX^Zq3XyH4lY_|Ckfa_X>D$KIZ9Tog5mQaGB%bLET)=!3F`Ud z$9UWC#}`daQCQPV3Tv4r2V+@H4F@5mxpZOE_HaD7`unkX!v-z}9-oh^6BP>Q4+;MB zdVg^-7FJbpabVd@0X8jQJGiKN5$K1TH}n5_@bavlKKP(7`x}tbRBL zPv~LtM!YRdC%WxyAOs%YzKwrkp4AM)5rF2*L@znR8rj(#*lnjzWAN@>WaZ^?RZJf^P2=uN2rjwzu3f`OUmxdfM?F$E2XM#s z~8l;8Kd=Xcev>J&NH+kKoD8of2%?9%c<{8h;*kbf9_b zR^B0#clk-pORUkfs2c|E{PGL_Y-wTbrW#z+0%q^UN^|G=^Z2Q@mSHGo(w@xm#tegl z>5o`&dC9A>Ytykwf#X|sO~cQ1b@=u8ab(Sy5#6dgc*?F^!Qo;2-q?sI-QApDrwf`A zunkK)-Ind!@%N=m{A7kaZh!BN|2N(=SZsFdYix`L7wOf7;D1VBLLBt_yEP$- z&1fQx=4BzG`!-FRv|}S3lQ+kQhq=sV2-MPq5T;T+QYm%LkW#3LnLoD7{BjZi`WQ7k z8H5F~#Td^?9jFhQ=D&3CK?ficp;NVZv!5AQlh}xnSml@P?E?U|c{NSY5fcsIBd7Gf z)qPN~W6jvH7sc5&Vpo-PSV!kyfpB=O5aNEeu4{S*4rq|35d*Oh6R{B^u}V4xrDIV# zX&ep)mxL|rTL{sWiL;M1jJ7Xb#6nEOMvTPzAE>RiWLD(l0ssI207*qoM6N<$f^ttp AT>t<8 diff --git a/frontend/editor/src-tauri/icons/Square71x71Logo.png b/frontend/editor/src-tauri/icons/Square71x71Logo.png index d715318c5d9324fad9033f3e7abad6842b993a5b..1d64d7ad39f886c6ec3622f7ec7edc01e2a1789f 100644 GIT binary patch literal 2305 zcmZ`*c|25m8$Xd$k{7o^HDg{Qkm-Nsrw^<+(aajq@)DSn53qbtfA5M{CTF45%K(a%D(<2b@ljj=Tdca z=7)#(_V#Gn+C)XgBor$8%$dCF*Vh*pH`#>P#_$+U|XNy^I6($b8Oke!VUE{C)I z^Jk8gRc~=|Z)s_=rY1N`91PZ(o6BXhOFcXWD=P#2np0ASYijyROZ#8Eh&g-qxufIuj~{d_w*ASI z!RqS1va%>CsrvBnJUhFdqN2g7DlV6spr|-hU7dnPe{N~13kw@zFk)n6o>*HijEuZb zO&w-1VxiCyH#ZKOon>P3Ix;d1gDG%w+FD&r(b6jS_8zLLYNSwxYHE6li=$;^Dgy%8 zySr}_5@vdOqNJsB?d(>*evOxxZ%I#QvseQa6}wwoy=7&cd3kdK1KFmg>q|?^pFZ`M zmk-z0GAWceS=ll#uVMn>b8G9)+S+(SLr-C0&gIJ+OG~YEdZwY__lb!{GMT8LFx}bN zU0BF?_;8ZREW+axfq{mYn4R_YhUn-Ibb9r@d%3o@{S_63I9!^p z?pq?U!q>Op=1r=mW_wOfsk?hKjTS2_`;J5+D=R;DaGj=kr9?X3HQ-s42ZQJD@e7$?m^W*||$)ldjo}F)Cb4y%s$L6>k?%)wm z2i~31gTlo#Jn$?yS3pn1eAb>NqgPmT2A5YSyidn71?8C|)wVcD-CE4D{4cb74Lt0R zk3;-jhiFBkwA$p0ciJxZQ%(v=Nj(54TATGUy!jzw2?>-e9pyMFt?Mm|0c(~0ewAxK zZ@~#OaY+^$XFpxB!scH%DqyyQTt~xgFzuRIZLS@tE*|6o%}G050_vz441muz*F}q? zqXR7Iyx&gD7PEZmC`AtKhPF_LQ-#$DUL{$pc}BFrw@9NyS5N(Ie|w6WbxGVL{9EP6 zc3-=L1Ll~a+I6?;4t;bIzP^Sa|cj29J_kCFuk40%^xttn3z zLLo7=6OJ!F@EgjP1c3`dPLqYyMo83b)zChCr8A;@+naUZ$4mLz(|Mr?K8~(>I!@E9;e|uivHcaU8C7Ucqk#8O`99=@-I!Z)}#B`A75M^ zmCOqEyfD+P&4N5~CCusCg^ONu9=Py`)w5Q4#L}$!l>P!_gm3Eu>^uej0I^LS7*$-< zEf3AnXJSil2dSuFAx`dzB!d7}?1|jQ!3J$rxQzdHudSFuZ#e(WtEGo9ZP$b}WzF7= zjyP+yP7)CXJ2e#sMH!95>D$3nmx!kl<)r(6iCE+?^N;3gOgf0rvTd%m-r)C9+o`qu z9@RTO+2!n@w$k98Qto1n#;yM@CDmEJ)s9)Uv4>=@#^NKO67Eknaz$*}bcMP|QSSW? z*u?_pEttVEb5H-(>C=)aUG(Cfkwyb*lnwg6L~uP!`AhL+w+;r%u()z}mAq-hoIcH= z&ai1GTO;4>?w)>;Csv@c?&HGzC@Cbtz2$LUOj?vX$ug)iQ~Q{GvYOS(t4PPj3aNjW zvUbpKN8^MM4T_xGGu&nkK8A$L$UzhW3r0o6(1=g4t{S27c>!_o` zRV@U@>Iq2VDP=_L3qkg?jEV-E72T3YcdCAyUzLmiD&yhNPRW$jK3&GB3gJf#2lw9iM; zv?BL|cdyI$3D*lTUUO&(!V{sm*)7acGh$?PoaMd{b(W>V`x2S^0U|bR@$@P54l|kK z{AZ|F+V4zu&C-fU!nLd9FRp%Vm;@F9*;=R&gWjm_ZxlCbW8JftjNDprkErU>fvGe- zVtx(iz91DQU?#q$K|00bC;XxM)9oK`)8|z9&>?P|oCY2|m*C=Ux@4T$_uW{ji_?8S zsLK^EVD?s~lWXVyBh}Vu7oWMlSpLRstVUO5bCJvc8=p(TdZr)Xf-uOI2;Xr;vek&`e*qc!N=Ft(=F)#6Q*It%OC+T MF|sf$*LRNi2eyMlI{*Lx literal 2797 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 diff --git a/frontend/editor/src-tauri/icons/Square89x89Logo.png b/frontend/editor/src-tauri/icons/Square89x89Logo.png index 8a1fa5068ac0ba5530c29ba57fb9d3b33a894e8c..7017222db08536851835b8bc27a08c736ca57cea 100644 GIT binary patch literal 2563 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 KYrN?7+kXK38QnYp literal 3313 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 delta 1932 zcmV;72Xpv`4v!Cz8Gi-<00168h_L_w2UbZ$K~#90&01+}R8@go*$jbyxy7f-E+QkzPktjP=A0B`~qyQsCZfjmMWzl zRlrzR@y7!HuPGq}Bya&j>{phx_4THvgZ+IM$v5k!PcIo|+aE|FULmh7rH}wIY)lFP zLkRNAc9r@xVi;@RJ#wUjF{}gR*oul^k5c=?nl>$JS+0uDfE)&qxX~0Dgb*c$A-Zh) zcfGgwi7^u<^nb{A0XOQp9}|cU%d#MZ6hdgj5CoZ^2^N6ph{a+>x<0MXG&jCbQ{#xu zlP1j#==$DX$8iz>3T{G5np&FQT))cEg^;%E&Xr2amm!EMLt{eI5bN%SYguu2rZq?m z`oA<8nxp{XRr>q5<+>cQ{JTBS8L4ULzH$Zk&Yq3iCVx(Z)!j|G;vYT7fwJwJz>v?D z>(ZOYq!eOIp-U_|Lk>sV+AwYDQarzPD}tq^a2>}pDnd$FJv|5%7b84s6kOZB5x5}V z34?SwSqoRJhM*LTMlte^JMhTzC{j}ts4AtbOo?X%g3{=rXP)6Pc5iRSAo2nv6{)>aiHsT? zMSpMd;>FA*D$$C-`;a>SKzvB;HIB?ED#F8+mAG@p3??;wD$Kn}nhtU;@KsU{}5*|t*G8~0bejORf+BE?8MZ?ix~tjf+z|(g6O$=6{q&? zL;HmbaT(&_oT7=-H0Xf<{@lGA*IHWmeJVg6k~HpgF0vtkf^CL4Dy=3@$0W~zP_IOj+gl|HrcVW=7SKpeCiZCLo=)yhA#*s6hhbK%c$SJ9g#C< z5G*c^ukUjKl1nO4iN4U#z(BNM@cOe9gLv&hGXBcx)2RF63&gs*c;mqjL_vTgL696j z`|GcWUb+N56!MM&jUf=B5YGPc3xAI9+Qr{N=|u`49m^|_B%g@XXAU0ZtsHj7CzYsr zQOY&#*ug+3wFpQiDV-M}$#fwQSI?ehK2Tcu^1*SShr_Uc`{lub@obc-rG|nt8#;Stbbt?OD-=TVri4S`HsZfKn;(ELYRB zC3Vxc-{OZgYq(1XOG?n&(SP9yf@YogQ&(M$NOLo9gXRN3#z2y!rWDw>aU)k5lJwm8 zd?G>lPtFqv0%PVGMA|^eQ9ZHD-LYZ?H@eWsksP6vS}Z|ont4j2<2!e9TKV;rf&t-L z9|+*$i4)jWRfQ|f%?Ov3ai8#Pa;C|9w#UBv4$TJ+K%=Hd8w~{j;eQPzF{4yvPVU)* zAJ?x(Pb3mQpz#4gvrZC)m1{%8e@(7HAd*1XUksvLDP`)v`U-p3u4UQ40?r2n$!$`s zjWsp=N@H>@t!bFjNocDv}XdGR7U!)F&>q^9lvV37ODk!{_6EaAHE$*QAA581A}rPweurBn>L*w9_VQBcY*(sj)RK7V}|TB zQev7Wvq33h=rqS!QzPTM|k`+fJlNf-uPb1Ji}^+Jf(G`dkRjITL4K)2(% zTfR$tZQ1k#? SCQSeU002ovP6b4+LSTX@#HMcm diff --git a/frontend/editor/src-tauri/icons/android-chrome-192x192.png b/frontend/editor/src-tauri/icons/android-chrome-192x192.png index 217812412012b85eff2056a3ab7bb7b584b840f4..55c165df4b65eaf144485b54c093a2eec23c508b 100644 GIT binary patch literal 5599 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 literal 6246 zcmX|`2Q*yY*RV$$qC~F=BcituCE6%Kkm%7xkLWcFLe%JjMD*S}Ll`wi?=pfg>M$XQ z-b-e9C%^x)d_r-uIOK?6YsIuC^KlDGMn80HDxNSJB7ayZ@aeK-^V-$(0xY zU=q?$Q8Mt)*~<+`p8454gun`c$;4{#({x_w5fD;2Fr|2FNO!5cOz!910@QQJTUDu> z3UA1T$&Ee#DN;1(jrZ%OQTK}|pEbF)rv^Sks)%xXN|gXJfX$beg0=6yTunwsQA;+ zka>FNPbU)e-Wf1fT{XV#>+6n2=%l4v{pR!iHv2Bl$k|7|62FwL)huF`<-X7{Bfzz< zpryxvRIc^`Iv_Vn-<+;nM!Ax4-__CoQR-lj4L|~LO6rW)ndSBCX?|<$Z+mk3behfl@lN=F#!HoQHjVw~n3Q z^9vrV&e@JVE;0+*meM|3Hg6GDORtb@aCyA|KzGSviy}8R;z_l>%Fvt~StuI?Js@+e zWnB~zB=94s-8A-?*I%4#RXnrbxNR)zy}uctLkX>!A1cy2KH@5%Z%l~p`ca@eEXVNi zW`BQh(8^j<*U)(LY>>nNw8ZueTC@AncCo8bh~*=H>8?DSP)SOwY4(TyBFvmMI-#IN zR;j(KJLWVtHde!|N=uJOCCV=pGk-wvd#T zD#YKsH-D6hlG$^3M1lo4ANh;N$@aM7~kdOe*%=S*k#pxBCaTAH~#*N5mINrJG7qc@2 z5*HN*;?zfKT*IoiT!MF)K|Kt-4OtEhB@Sj z)*{9AFeYerG^0tV*#J=o5;jy2rH855ex0Pn|Ht9aj>H}y@5$ocCXi5Rm_b-F*MDXA zLs{SkJ{WQ|fD(e%P*5ZKNX(2v!X7U4)3Vp{to)p5dN@y@PioF5L2-|!g4>)*s;JrA z(X@>CFu#)R^9%r5{X*c3_7D2=Pb=%VL6h#2?w5atDz=*_tfBghOaMk(fnq|qobFK_ z-x?v(EH+-snOOqooo?#p&kY~+@gDYLj})G-aS(liNwSEu2eHnG{1ez_2{m$dt!a3n z9U27x<3f>JTkH8_rc%TjDWbVJWR~`U5b3Cs71czcxT7J}&bhL}dr8$N-t+O9)5h(Q zGG)&fVo-@fu@VW7BE`_qp=LNIr`BEG(r2p>7LO3RpeUO~DtT{0V2&4X>2m3<=e~Wq z(&g5VjLXa*TTv`M8+p>;PlRSVRbdDDtb#;(~Q|47IMj9gtl5Ku!jB_M)kGGk07} zcfD3f$aB9my;p}Vm=T}ftjsVwFV(qekv4g^T90sD2E^upX4io{Q^>M!)1L^N zLsH1%VvjIi*bDcr%vYcwEpbLvxil&6wV}b_>%r28YQ0V6`B9&K4SgktJglu1x!nC~ zgFf9prB8Chx_1Vxtg$;lDDM|oUtII#m(YUhdiG;HLbA2rc@ityt**jE1JWbZZ0%i_ z;UIqZF6OQf1el6n_&4LOGbDx&xNjvm?qGEXm(SR;xG zl`DfoP9^60KY%=3ZJ2lSLgcS*U*(0!#NfTIO1Rq6qZw~b0s|ioeY9iTWhuL}=HBBj z3heNoqGdmN7$i@9mzOBYM)7Z>s z*TpG(6!r?S_Qwj#Z-k9;i`VS0r%BC8B(USwiSn>`FM*XN>2c@x4|<#szRY)l0+B)N zzB(dF)R=+xwA#Z}{gHXl;Z+GnCCK+c zDkuZ9-k(8}*0bMWx4xfnyR9>Vz0(NKC+E&j3sG-6^Sv*Pk80asAcaulrUsJlWqY_q z0^YF(h9pU6Ji15tZb`2HuaB4oh99Vd-4bRn?FlbZE0Rg^QHuF8@su~e(-uvL8ZjLu zh%Nhvn6tqO;lEMea)HLD<|KSj26yc3eaRhc1PXe@Sep_Q&n4gK2TWynL}V`46LsYD zqlCW3N4nD1!R3wSxBHmEZHnZp_wZFu(;MuMs@mReAGA@Fl+ICo)Ys^%IC!5ln$a;> zNoEV+i&*}yEwk4X)?@-4e~hGvu1wrSGmEXFMn{n_p*J-1c=%Gu3}nZ;yG&3!E*>Kv zeP*$gT!HMvD}%3qzhOQ5hO(mOrJ`ygczYCi*h*S48yz^ ze3N1bH$KGnpZKu5>BL+H>Z~v+;tV3i>RA4-kon*cNcADC>FIBhCem=GT~h1|_SF9> zAS5|>gf{H8$zXhYfD)6s!0Ytx|FxNqhRcMDBpVGg*I@BwhK%E!FOS|FXTC^FzLcKW zqV^^;PyMf9U`;nshdwzs;XFVWBb9QJK=$a1wej#nf-TA9^Ii}JQ1|}Q<_;thBNEC^ zUP6zFJ@`qBGbKFasQEw5s~+TV$`bI~Uc!t9f6-WwgZF4v{(T?nNcLSorMuOdc(NpM z1}t}{yqm^~SrKjaBe7UbhXSICDG#p>mOJn|?4vB@%lnGw15y|0alb|nUATDIJ>0!r zC4iEWKE72VA|Ihb`c$$pI^Xi~_l-XLz-Tn}qPACz;8 zE`sbzl2GsBpRt=2QVATnh+F5kHc3kW%m!p+b(qx!Zi2mOB7>t9t_ZsvT}wWDkO!N_ zqE3bl`yO-!`>(W#0ZO;Bz8CdGtf|3oe0L+h5DNAam2!u%Xc4c-By zWKN$J+g+L4-2I)1-%rV3cE0rhJWWCmxn2%=z_obfc{w5INBrUE1jGei+xk+cM?<k4=l1;dDfR=-b4M&c%FedA{d+K^ z-H+Z_%?V!)pVB9VwA7lZ3JdR9%F`-v09U^ng366U&i6Y!PvakT_m2yc=G#C!+hM;_V_=Te!T?AZ%Zvv3;w+^oNOA$KN{Qz&R zSD4zr=RM{3B%u@#WiBqy*9bO8%dVWRDGkbqf0%I*K`wv0Cy(mP^ya7uw_rWF(A#ZA zKM^*6CJSJR0AaDOWAH8uKGUX{8z4ZpvfB2{X>NUL9-VlyRsoTi5V`Yp4xByg(LMV- z9^Z(DE)Ep*#s>wFhEaG2wI)jCV4~JPL-3(HqM`R=KmyVY3CtdKHpU~{?MV(u3IyeG_} zqk^GK0BN7Gv@ne%Sv0CESztT9_#donxp-JYM_NChM8L5^MkBF3!e5IQ3~&r8bUR8p z+o}AVvKUthPI5l~=17-iTvyT>=>p9r#VKzVdTcK0+nK;z?)jwi5PySzYoF+uTFy4E zi)vx2Z_t#fUy`@Kc7gU_|egGF_yb^p;4I}dBMA+U$cw+Kl}Y9aRJVLACc*Ps^-7Wrx2|(^zVqvo`e+3s9 zPL89Ptgc3n{@#JWuedwq{Df0Qe~ltU+k9OQERlzQQ7?(pKcXF)0_|}F!=_#nmod3; zT&~bFW$s=$CTsHZv;fa#`6ny}$G?K{x}kN6*Hm6%U4?nX@Hz`9KvKx`DF^xs6Y29s zTW)Fsb^O_b0$nKZ%>b*Pu7tzL;o9WOQePRCs2pA3Imu3=pjH2d<3`ts@BSccz<#XYTWRF`|I}>#J}4eW z1?)?kTx6prrKKs}4y5M44vSC44=7L+!# z<*Tc{t;B&Ms@;QUwZeK}kkIM$D~?TfBhr zRR0~gmExLTO;FXk-0{`s;qjvoW->@L<>tn5e~P)rB10zU7Dj!Qf?aQ<&2PuZ#B0&b z2i>QQLBgc00Zn@-osrt0e?4k68DZfQm}M38X^HJLbvsL27@hR@uL&%n`LEVk+J-&u z6C1+WcI&>~23M^MCZJRhTu7|hBKrD_?rQ-&KglgVv!ApIp47Adr|s=p-<(x6&Ub6* zzQP}Pxxg@2pVTb$+`Agp5T3E*e6>p8f3_0n#PRkBY7_J-&%iC-1o5!P{TVdeXyj^J z_F!@}>8(357L;OMTIypqum@AEgFXrUP_OA0|ELq-0`d=awwv5^czEM3_O5FSf*1K3 zB9=s70vq@k&v;V3zPkc5v7(eUs!y|i5^u5_v7EBN0QO)_&0R0yWNdC#X^5%TUHRo)#AzR&&|^nQY=K7f<6ceNMChvKv#vWaVn??PS8`_U3Lt0m9BBNWn^G2~E;XNSxi z?*{zi)WH1*3Yjj89F~e$SG$O}#h_4*wat}uD$Oey zU1WsNk$6_Uxa*EE9s^`eq6gW^VDG5!nDH0=3i}zK1>mYGhgRaIFO&DD^U4DULCMcy zO`3`nYT$ZWiCpyG^2dHdiMhgxZ%HDEYXk0&lJ#m7K$Od3d8D)TrjzUNc;!g&{ik~w zRE}c^!$1`lr?(WL^p!q2GXScWJ6twHUP^po#Rz?>$C3Kn&Pd2ZWo?`;Q-uaN$pJ7n}t(^K;i{YW|SAGUBMzt z{>rN|SdoH7&AwFc-RYKWWSQxY?e;txKlRndYx6W}NY1simey^eS)G>N&*aBICM&~I z=bar^B9XanRz-b$d`8_me_OY#<~u^=?;M{KWLO$+3fEUX-V~Sk>DTJM3>bS1mZqT( zy6a5W9G8W~1sYGPs;V?+ym0}}yrr?)l{LcmfTY9y#jyC%(dQdF%&W^cJ~H3sX9S)7 zURhWq4PAm+0M}Z+hK7Tkzkg%(5FHaG6GW3)oKM&1S-Se=EZ{O|zHtlAO2hOOBe=4RTs_47t?sgCN-M-HZpK51J_Yx!K(UyCH;mN{Y9?BF@!QyFOcmn^K)!P==t3bKw7s{#y>8FMjECWel zN6Y3g!Gpuj0~I+BzOwwI`waPxn2}Mh%1KbLeA_%O!B;dW1NLxYTBV^;VEf*`R4DJX z*JRGtp|+&+B~=Tr&mSq7aK|KOhPk7GTCJv_u=3{e{Zq?=k)b3*TQj0EjoTgPZ_fnI z)P0rvSb}stv;*~jJPO}sW1LC2$H-!2ApX(?Oa|z(a?E$FR9{_cID)B7BGb4~zPgQ$kl| zrnC+``#oV1Q8Q`Y-57O4*Y*F?^X!UUN4KNNY-!PIKYVci4*(jf+A4KVt;7Eh#R?In diff --git a/frontend/editor/src-tauri/icons/android-chrome-512x512.png b/frontend/editor/src-tauri/icons/android-chrome-512x512.png index 8abdd8ca3a5ce9b38c8dacf8da23b33579d948f1..1b043a72ecf61239602c8d7ef580c661256191ad 100644 GIT binary patch literal 17810 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 diff --git a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index ae8588d81cee0db2fc01015a9aac33d8c839e53d..bb04f26bb74391b479ad5698c259a5159e57e628 100644 GIT binary patch delta 1651 zcmZ{jZ9J6u8pm(R8mTR*t(J3|SdU{E_dUimyJAE}6J?CW+S*1%btr3SY3UY22E!;L zPkA^V@-R(fj2#tLQg#_(^0a4CYc_=@DS4PNGtPa^oAc_txvtOecU|BAo9p-IZB=dN z?t%3I0Pyl`c0}67NU|Rp0GjiRH4Fnpqfb$2egJUU8URvQ0H8*!loHGH8gob`nD%o~+WmM{5eLZV0yjPN(tXf)9^!L{@7$eQiPj23v z`t7&YganyDu&&ch4-ZR{k|yrmlkxeBb8~~YZ>OWtLk$fB)z#Pc?_ZpqEu~OqMn{+C z=YFJ$pc1v7bs=4{Y z++3|j^IoAq?)Q+x854`AhlXyDNE(&uL0VddrDan@#I>)!;-5WRNuy<0Sk#1sNUN$| z%VhZkLRWTnUwL^6g_4cM_Lh_&=PrR=TnZp)q_;Z=36EJE%|8&>Ky7%X(`~OY>DS&? z9nv)EX4R$I{!PW7>-j6VNag^&aH@|NvC^=13=xKLzJA_@bEqwwjY+)4RV0J@X1-oz zI;EF4rV8F3u)}b{$KJ8N{#|MpaEcfh4*tQ+r0>9xHyTg_rL|jNV+<&YY8HWE;*O|b z`HI`xo7B*djAGvxN6nwEm92H=J?hvB7y(q74651m!D@hVG;D*#11mH{4gDug12-Hl z2rZt{URRp89M3G&Aq>*SsKmpmv&HO$Ch$@rA1X~>}9#7wHV3s$QQl_#zhd2lCxUZ$ZPmV;)N z*RTh!6`{8zr{#Bui9^hxzb_DbHr`4^fr_Ci%ks*DM})<*(-|FDx*o%1K$=zkNYD8y zx_3evV6xrxvi_B_fp*)g#XetF#iOhs6-5%1^dWyD*y+ceTp$CyXCm(4Iy zr&?bpz+}Z6zLAM-jIMiu7}g!z+~?b4q?DZ@K6I1ue&1%?zI^pc!DsyXJ?3+N(%VS$ zoYZE)KW;_+z<9jG+A)POYfap}?UVRr*U(u@#m)=F0Johep-J7v7qTa$T@^8pSB`MmwwKvd44)RMn2jbYjyHnxX~pPyH- zxEBu9)rsrVP3iVDh! zr)AZH^DefZ*>&E{dgz|3e@DokRNDeT6krwS9TIn%5qE}kD)tQ001yWfus9;t8KOIb vBxf86kH_F}Bpi-+d7Xy*xc>ps7f+vyNc{i6D;uAA1OR-!{k;U9CsY3oV$%{~ delta 1938 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 literal 5332 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 delta 1938 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 Y8%4qK-3P9<{{R3007*qoM6N<$f{|0AgWo zY7hDw|Exm-;NIL^b^toT026By0AOSs=3s@u_>7Oay)^(tsQ^Gs0s!oRP|N}V+=cTPy?AZ(iB3VTx-`xBgn|*L_Q0e0X)|05DRO;cev$n?La;yCP3970^ z_VxvqmI=T7(h?iX-QO?w@*02ktk}UJMNMshMmyl~QqPGA9|FE?s85+*? z^kjeqXlS%0CJt3qQSIy!6&1fvOpuL@dvbCnnwo|xD~GD9f3B}{Ih?`r^5L4A*7$gW znp%aA&saml$kV6EP$-YX;qiD)_wSE4HV#);r>d)G>gjb6iDQk8znhve^YU2b<)aM^ zXVSaehYu&3o3r%vUXe)EI9!~p zY+q4PnWtx=we@QpDc;^I_ychkd%yIWg5xw$DC8f-c} z$Jls*&Ca*5__?-rz~xTAe7Ui(u=ME@yRPniOUwMgz;JEt`{&P@6bff|m$SF`v7>_? z60*I#y!!cbAC;PCZoV)&`nj*~U1{n5&dy|e`(Rnw*5cyQ)YMlNYlg{exN~QBV`H+d zjm=<`W3dHSuCOXA$rmqb|Jfoeq9+c*IHeDVPm1A79dwM!~ZTn<;zd5O#6`EfXo zJpQxGT!B^-P90BXrDk|9*XWWGDC6_KVrGg?uP=821Ca;|YqZGsLy#j7;l&;$QveXw zv@kVs#7(t-*u8%OeM)4tAx?MxtN*8jlq&azh?Tj1|~p@*cG5;^StGhg$nmdL6_XOJ4p7Q^Gr_Anzs0YMm7H$GfE~Wc+S;rLXLbo4@fz zv)^;h2dN`()+RoBBRP><_N!6N#j+}<{dZkKV zj9*l~dqgZ%%Kf-hnS6X`ul;#-G^YQRJN34a%jLs91Ko)EhDpY$2D0(B*{I@z<2|k# zxk{}ku<)iN_gQg`N>%K~x9F<*3~?+R^DJt>?dc#|1lc8|JX$V{g`c?Q?DS*Z@-Ymj z?kVj@KNYh6blMgQ#$&SLcCZ9GMV3h1!ct8|gHnYrVfxzxH*#t^=%%8nXqxaWT=mA} zvbZwgsFXC(S5t;42k|5v7d8n%Wz&;}isBi>oOigh-i1R&abdA<3q5iqGhUG6S%L0L+4d&ljrX6+^T1-|eeI;3cHXS3urQzl!Bd`dw^ZelGLE!|_El=WoD z*(RcUU+kSdo*{J!Wk`2X$~G1KoIpyzMR;u}4LpV|vxCYkCP&i?ixYaUL9RtBm7tH^ z%I6034i%*`zRlb_8KHOqX44!BcS7O318FA59K<2lDcLwyw?Std&bHg&hqBNRj#-dL zUx-}~AC83Bw-cs@V;bdDm&CN7QLWu&EfO0!F%OE4D3om3BzReLM2=upHoosX5wXPg ziM3zTXq>5`uf#_%uP(DCH;cQ`c-Cmf^?cDE$A4TM&z{Rn*7aa$^cTxLTuc_PNm&i6 z;nh>H4JR#T(DDP4Tx6g^LezPBgfZT||;&nFBSM9_y4Tet1-c-FV3 z;PEV47`vV-@|Z5Z*Da~wI+ZBr#GlbA=@^S=Y3u%76joub*>z1|-WY~%qCVj)iFkjV- zhX{TISY7iWRRLUo{TJTY=`pQ|Z=Hsk$(1{Q#4*ly`1VWJPGf@OV~#t=Z!b)XnwS{%0COVDr9^osZ5iFr>k| zu4gspP5T6qhgT^(Q=)#>o)Rk5@LcU!aWDAu-h<%lj()RrW7Ze^>uOOixZhy(YmB%@ z8~b+a9Lz3w9j*Sv#NaEB;8sKxxNG$>bn$YG6ZiUgKA(JE%Wl(q$SUSl*nDWuL9be^ zia&L81=`kez+Cg9xh~yl#1i6<_$N{CAlP?Ru zgvx96r9h~@FlSnC3UL%Cf65-ih*j_~D~^y`AHMy-dc4cX!|dpmTq!%!%5j<6=3<>}Xw2f))C9k-@0TgxAKmY&$ literal 3902 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 M07*qoM6N<$f_TPbpa1{> diff --git a/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index c8fea55b19abfe79719839003f86e6b3d7d35b43..fba443fccd440100ddaf659126b264090357e3d4 100644 GIT binary patch literal 1576 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 kprDF=9*_PPxEgfH-{ literal 3437 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 P00000NkvXXu0mjf9Q&%e diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 2e982171a88dfdf1eb89212ed0043312e787b323..251b991a03325ccf66f783a1a8ada4885285ff52 100644 GIT binary patch literal 6129 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 literal 3437 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 P00000NkvXXu0mjf9Q&%e diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index f43d6907cf20550af6adcf90e0cc7e4a703f5e87..3459a810d46d08fcc3dfcc5f55192dab41576c2d 100644 GIT binary patch literal 4542 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 D?COX# diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 9afc95d61f9e5f3937d3940b24178e1c1ecb25e0..e6c0d49b64e95ba53b7c029542247ae1a89bbfd8 100644 GIT binary patch literal 9638 zcmZ{KcT|&2^L7FeB1Hm%6r}`EKzi@eqBH?%A}B4PsVLILLX#4ZB1H*ZX$mR|C@Mvi z`XC`R=_-N{I*0)=Qu6H`p68tRkMDQHlVtDRduL{6uDNz+6V6x~v$F`Wz+fbI%^&9fC+1EjDgXhe-9fUXTV_Mjiwj_yX#}i`Imj|X0tmuO1k3B zsHpNw@9hpVA0bb@%iFx&bDeR1s`8MOh}}io{R!bd{fMK}T~|+F#-CyCS?teRyFen2 z;0_z{mV9}s8=34EQ0Kh zNyO3V^X&z~JzLuon>0xlmRQ3_XC%4*KOg-L#YHk#eSQAq6iMDqq}|;7yOH`z={Lp< zAM9`cwJh+x_D5>D22KK3aYO2(W*dK38^0Jbntb{lVwT%>(A>Y>@|cERDd<*NF>Yh{ z?6T)l!pg>?aQ513m(L@;KVP3Z>B!csi%BbzS#W9E<;;P9RKXP-Lc5p*yjTA?@|HAm zpL_Om*~QEul}nt_PB9Nl;6~u}rlus;iKbU&*12WYiGlt6_33WDI-S&S&_HE!7-3bb zVy;DM*KdD2gDJYmlw`G#t5$q!n9Aj9b{J=!qK}h>Co~3zMrbx%CWa;* zJA5pNR4Chqdgty#Vrh{ZE|wzMJ})~dY-EfyIFpc~SXN;xYk1}KpcZb9oer~2_zeHVdl04MI8r<%3iD&B4 z*b>WHOPt6PHU-Plr>9#7EDrBgEMjnIkZ3$oh^ zXk-QU&GpM~CJhU}%x~&>bz1r#V;fgxSBMeuAwBf@=J-m8+tm7y>hVugr_k>!l^?{T zi3fq7efCup4$kxCo?1*!tR7)GWj<*UkW{s!am)y%GSM;1hue}}W76Vic~Y^EABjd7 zu)^AaBt;b!@^iXv+qx}ex1aG>PAw+lln@3Kx%{efHH%`Xjg^Zf?M)rd@QY&{`U5PxP)l)VH84kZe|(SCAwc-Xpn(z)Bu8Yo4-S5cA{;hEdAtk5K1F?gR`GO} zJ27@E&Sf=S1IK$l`6W}ykUq+zJo8B4zRtE&FG4%>zFNr~xz9iT#l6A$R4CHm-bP7< zt>7cnN&ayi3Fjiyk(1gxyB%Cfx6`I|m>u||MOf=e?+`dwg4Na```6~ggKA|J&zG;= zM9j*BYEGqy$tOOD@N@4>^G2+RMPVErPxGHit4B+qK(gp$m?-_{?r9-k5%)r(L+CGbw-yr zp$C9aBlhesr@v8fPYDfqzzS}A1ch+*@Ys3myI8GOjQ3!ke4jyekn9u+N`&s9&r z&tZ(2BYh$~Ei(T2QuXZyIbodD2iYbn${)Q^_kE~GTM5NrGV=?{mOgVw&Z&r0ESMbC zZ^(=(XXrd3Pt27t&+1Pbc76P!(nU3;b6|jp8{V48Cgo^xU!0y_X7t8eco6?Ekxgaz zN?wjz2&=2xz7H7not%KT{@M*lP(Eed=HkKI!82y|?sFKC1;aBVKOQwM4)u*PGP6;p(QZhQDc1e6KY7(j zn#<>Elfvt+oOjH64sYGcm0)(cm3Hj!-xY0n1PYG%2k;d%xd~ (k@Htbb{v|HEOw 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 PzmIe@?`u@Ro(BIPE@kg3 diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index f43d6907cf20550af6adcf90e0cc7e4a703f5e87..3459a810d46d08fcc3dfcc5f55192dab41576c2d 100644 GIT binary patch literal 4542 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 D?COX# diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index 496f9c774837cbf7750c8d5c637cc1b17a3678ad..02f73102aa0f3d6e85d8d38946094b4ca4353a59 100644 GIT binary patch literal 5810 zcmZ`-c|6qL*S|9+q)0+YsE8~zmh59ll!OXdMwaYl%QAK|p=685zD8L_c8#y55TUHm zw~z*rhHRB>?9ZL=_xZh^=lSD#&1>e)+;h*l?{ntf_c@;veZ}wsJDUI-0DxWZ;`wWE zY-YS!nc?TPuWl@GKsjp}XaP{2uy4z55Bx1+fAN|D06`}KAVvbP0jG%50QjK+_-PA( zYB~Uiy|Q0kRfhuRoB9{dgI&fe|8;RP06g#Y&TE+je3{R|x#3J3+x7KCFb=GV> z@xke4$DQogpGPSQJhuXE&+j`YkUmp#kx5?AMDlkFQ-R=KQ@w_#cLYa*N8TD-%8)Cl zi=JHOq7Arad_zZ_;xg6eN;-*-;z;v0WC`lLvfwwjs6OXD=su^QG!i_#E4FPD04774 zUhIs$3`rFgP;mK>uVw#+DXH%1o%t`q4!iPs9X)yxsq;(qM~%rwtEd}@V04}pCt zu+J}GlVir(Cu_Bj$|)}@@CD0tn@99bHk`y6#or#>0ZDalKeu>VHT>ydwqN7RUK~)t z9(9oBxoTP`#nuvoY~{xCd~)QO5VX_Lq7;Y}kXYqX;SzVR#xC1Ap+eHnC^FMA9R1}; zXyF_4trUz@@E~HJt-QNRyJ#5dGiAv{y&cK;2<5X+T3BjYJmXXxJZj82$z4@Z)AFr&$qX~ z)RpyAr?{&95qauG`^kau*xj>bQ|kwcmx69weW`Y)hb|gIDtK`z%)*c_`$Pc=tvoh3 z=W9)H)VHn*8x!xcG`~7Jd&QJX!?aW@{pSaTNS-N7p7iZK5hB!8`p;Ryp^2>!NK0fu-Bnz35T!@G6}`2x zlEv#T_U=}2*lTUmC-SMR@8ss^t_%-|vhZiclt@}Su0PzPorwQr-TpTxjmF-aEE*<> zOOqCd-;VY61@aq<=_L(}toV%5qC{netFd$~G`S~pd!{OuBzXO9 zsiA>3vD)2C?TGn=pK`^L{A#IIE!LF39GRMnT~QSr)RSK)=1W&5NU*)5UrWh4_E3T? zyyjJy?jga%sl#dt>s} zZV0yg{L1#Xb3OhnVoO5OSThN>8ybJiI&G!DRhHpWju1CC#E<{T5&p7$P;V2_x<*k-@?Nz$SE+AB<-$(fS*m;;#TyEM2913847Yk`I9#C-!3-+xZ3K!&x3J zkK9KRGc%FkX_8)u|F>{+;KgC%?%p3y=~ekt4iuI~UQM|!1$>zB8h_h$rqv`&lIgAl zcJj8|5k`RRNTpbioA{mxpO*K;^`bRfX{^T>>2P{H(GC7HLqGlkwq zR=mu?{_SlphV>vYSr7W2U*OQa;BONmdt)mcaMt0U<127clNA3+69QH@IQY(=6f;&p z`dWUD=;KyNYUwY|YwVsgJc4I#R(-Onnln^;=fr&qMOG-mURj_`jfH~~n8;^QALR<^ zPo8=z{UjEtxpC`0+`PIc>kg?&cu`Q92!(O&H&MWJCk*>4=&Xh^e$Kl#%X5g6SU2om zR@VBh948w{N9*2{h0FNn=fyyYcWhJOy7w#Ju~53N_rTm*`Df>LJ9$vkvvA}a`Xn=R zw?$L^+BN8rStfPf)O0%V`#~7~mbo@hc@R!w^GqE+y11BkIt)qtDOcOYmudFAx!*ar z;pvQ((_k+!cdCE(QeaakgtM@kuYGgFJ2nsGccxQcs?xtM=sWq}l584&aSF_YjhW$M zBN8z8e+O(O4flvabC(7c>XZdpE3M;=?nluyW=BxOWn3hn(T$Bi`qt`$D^py%wMt`g zX-Xb%X~Llj^~^98ITm!3<~=OVgj>BnL}f#Wpl**h z-gl4?4Ok`FUU9Hkn>PuG+XU{J{7vtcIe^e~8nm%FQ28->&=N;;#?J%IFLSDb{IzQM z=?`Du8Ud5s;gtsJuLdd%Ury7oj4T*dQrWC`ds8}`egjF|2!*Qe=Ifoi7wWp)kRWkk z&Cj5l?ugc@G`v*<9XWWcoD>WQVY#n%cg;=a&vp15(UQ3ahZEyj+c*^U(h`3Td?#{ z?bnaqoFe++vZ-T0QmU}H#Qq5|-wwNNz>XgaK69V)vio*-tQ~ZOt!w%enSPj6Gtmd7 z993V$Og(@NS+8w77Y!YljLW?i7l=#@tx;S4+c=$%uh~VK{FhT2D5;7Q(gN?o$n=+E z3C2uJ_hkPC!3vt|;&QD#@p}O&&~bT)pQz4{*>m<^;Se{GUp^>CCJdP$Cm-a-+H5Kx z#0rqM{c=HW%+1f`7(d24Lx2C^k@}I=Z+`+f<@9R2IJ5-ed^m^^d2R?XkFT2<8#1-1H5_OTCT&G zProGU1+Q=`{xB9{+vFU3E}dG2@cMnc{lHPlUUx&J<~R(LI!>AwgoZ+BS6bqPkVH^2 z{lAAQgC-kewXtLT2h5CO5=^F8` zZ9;>#egCLQ6w&L=OoyckqkssAN$IbBO=81jDUJO1#H0R?uWs&nsIhc;SlHtrwA{^) zar913hKpf5wIhIpKZxvvQDP;=FoN~Jhf`u;j9>&Fa1ej-W1b0o?vs!2m_!jvz+)X< zBfUD$ia3}EGPdu*3#O}URNb~qC^pKn&^+F~gVJ1gUgj>G(o5Lo=YQknU#|=g?CJUC z$G3HjWSzE4V-Mfis3f$FY_8h*?uOlYo42Pq8WSveh;dZSa5yqQ<{s~EeIDVVe}do} zxawOD6U$pJg6A3C;Gpilqp_w!K&$s#S$SIpW%2Y#T<$qC#wHgNTdf_1{z{d3O1}Wf zaseK~-^vq4>UJ?r@2D4C5m&hDwzg=~q03h@*J-!#V+7$=DwQv~?bkyWcOz&zDCk%3 zn+SLhS#KP`M5g}yndJ8Bu@Ld+t@BFma)djN_Afr~UdT8k{tIuGcIu>78&)V`^1D=} z?I6|P`Q~j&USjSAp(jW48q%p1r;pvkei?~<vgu_}P~raez9w zt3>Owzv;#4>F>99_z#2DqubDs<9Gi48${rarIFi!6DZ9`Jsumw1yxQ; z0&M{VEabYX&C%d~OXCxfjP1PsT6vS@X&pqCnC`~PDnC&2UpP-Y>YuKL34fuE(%k!Y zcRS5yVzII@COLSG%^h#VzJJ`WNqD0(ZLAEFn1Oxr? zdHW9(uJvD#50!UZ<^)shn%CVXDHjrsH>==B1I14=v$WCqk83e#_y{ub(n)zWjJS0e zzjg5;84i{m`m9atlA2Yx15{OH64?&CW^4?J^+@jPnUm8bw!D#e>e{FjH#q*!0;I^c z+8&!6wW!wz)QOPK^UFx%f0AiyCMEO;<#iud1rdEs(LIUiT+nCNo{a7sh0 z5@$iFiPH~ZwE*)cREvX z^1$XW7f~u#CMF%?;^rsLB~xK}&^qheD-lLPdO7MEJ^T7y6!3Zxu$ZNU)zuanzLkcz zal-2$Y%L)_Yv4oyn*-kFw)?%one2~lt}t86Fx)_c8H-)YzQZ5Z=#v2+>)s#ddbQAa zM7|(QdPF2Lv9Wz?{etkfsrVzm=X#WEUX0a?0SlN1{!!)xGlWI))>c?vx#!OHYHvMb z9b>v`p_*hJejcU|?d`*PH7M)n7O> z?M$A3lg%|CiDrmc8OX58_-+Ntr%$nxJ2w%DDqm~5Pv^h{W~M~>T-ydKTUZUcECuj7 z;Pv-3*Ez>>u)w=wi|^x2(AZWWo0(>qA=ngfyj;ZWK3><);-CVbF;6+u_)Z;XC0&bi z0}^nf^g|%SId*a~z>fMde0fs`L1g@ADvVt3^PQDJtf$8v>K}tEx(5(wXrg=i)YD-Z z1#kj`;ai+7ga^SE559L!zh86fUjT8-%eJqYuuE=_7*_ZZ@6!5>V}`w-00-yg54AeT zaT4f$45u=cK!aRR3CzELkm%Srp{pRDVaX75D;8cr{Rf!O`{`=6f38>eULwqDw8#DS zxc>;V6mP7$-GR9bkNO94U?n(FJ(MjMG{+1Wmu-o8Jf4)PUXTD%cxuJ~)ZMk)wS=~b3Tj&>*?eppFl5G6aO4_1b+gI`^L93J@4`Stq`!$(8x z)}J)Kgbvx!!3+GD@RYo26DV^Gl(X5le0^w3`{c~NE`+85`wqR##t!n+jiy};^*eWE zQflK^XFc-OPz4Fv;|{g>h=67Ckl)~qfSJb4lCi^g_9RZ7(jPnX!y55(w*EXUMw;Gk z1kt~Ga5d$F^CmoZH#TG%45~VO%HEhC*AyBjl_5e8oVyO6Sr0A;c=YVmWIWkwreZ}; zTsEy>Ru<_JR(xqScxhc%Gu3A9L!F6c>Y?Sft0@NWy;}xWVh1j_p6T+}G2sdOvbR98 zl1IdKeT3pPu*RE<|ND)Q#uKBj!cZ7|Ru=dR7ro?V^%DY;^&EldredfmG#wCu614+7imI9yaX8qcC zT)lD&$_|5=j^R$Ct*-jp{o8+_&i23AB9T z!t&yr36iXllX+j!Vy88`Esx5!ZdSfg+B@Vr%XJV6$(4$;y$QWTC9o0Z)C${APM9~}Eg$<`jW$dEaV^zJRr|s?S&jvy+1ohRKrY=4xkqnPB zcyy|$hC&amdEQgDlCc*_fNh=!q;pC(pEyw_!Y5IJY9k#0?l`lk*jz5(+6L9q5EN?ljNfKpIVd?BPzA6mbkNgahOth z-m}UCspcVmY9#SGQc~Wv)e3PkBJQ=L*QhIc)d6-yVPmu>B@0bHq{04g+ce_7`Q$Dl zp8COt*)V(#wt2;TbKx(5oJU?HINe6u2W%H5?r>WrO7yF zj;-3_@o@RfZqhByVum%tVhI+mDD=#kt`Pm4;1kpnpJm(=y`G1U)rlmI%DK_I8R)191QqpQ29_>IwD zWhEYZDizjZ8pe z7Ol`gCkA=?6sefTa!2%*D_|$WtU}Fcaq>9y^l`Nk<9sP2z+TskXq>=#CplWZ70`uZ zTX;!pr)!|+lS+}F+P7QG4C-)ShAQR&Sm`<22O&-r34-x{%|41|qpJj>17 zLe~LIbnRFU$MPn7;Trjs#=*g|5+xHmyMa|pbN9u2@xbbuMyS5twj&yz0sAo*vk{T{ z+Z0Xra6UFm8Z^+52BXrV9_A;c2_)B^SQLWH0O5PvjQ3 zfc?M)rE|ZIG7f%BD~(`fu1!^X_~;nw=iwpe-$`p_Ii012(J}tT=2(R+^^V_AL`!Xy zmPhapFz2VHI9b2Dv1~f&>y>%m@_3XUAa#8%wVm*oy3pP9_C>dL(#3PN+trUKa)<=; z*FTJdudY{i`bwH?e|n#G*lLXCJ@;cptRJP$PEyyczN6?XMmLtDdfA;m<uh=0&iYeKmeLDx#CWW6`v<8y z_9%5Zx$(Q1N8m2%u3xoVOLV5S?whzTMQ+1~bc6a93nsFtm-b`6wLV=Sq!?403>YLx z?PL}8+{9`$MuY1v_ZFT$vyyV|!7Cf>eqYy>hok&9j>Gyj%2988boAuL{srY^DfvZD zYyo0L41-@z)uYsZZlSBxMQsWidA~0`twJ(TF5VDd?N~Lp&FA}Ja!;*ZrM*A?*lBg`^uigS2@V8q!hBK{ zmt^JaiK?NY@)x_YEzLiF{yKmEXyB|#&!JMa1aS>*t12Abs2vLJoP7)~1$fP8Y@i=b z*bA#2uo6@#%64j*>SV(AuHB#qARYSyfS(?QY<7Y*)79XZa?@G5%xrf+b2Nt#2iIou znG-k8tIVeSF-}!ajy53SR68?ho5B}mp-X$kLs;=rDvHk`Bhq6MSyv5UsrD_T_^%0+ z&m)@^x%tdoMXP0{EgGQ2{K~|R*C0cVX^;L&jnoerMNR0}*VO|`cL3D8p71w^`m)Y21WZzPFoib<;#=x_(+WO+*&Lu!C>)BDawM@gSRul z)YLYy4n=baC6v?9W-hCT#yyif;A3`-b>;=gG8D@8C$9n3PQmeF((|G6&wvDal=Zi} zG#T&M;w+V&GI@PQS{xoh3EsMhgkgzIU@OJ&F5OuB9EF9Q;hw`FMNUj`UBP zPpX5{4c4qMl~jXIpp)}6jnankeHPf)i#P~XBR#%&;V0rEQE4vyHScdhX>pv3!5LST z566I*FjkytMbv~O$hvnr@&-tRsb4yD9K_OX)81-Vq-x^$hPmA7o{nc|(`grXeby@D zD`*fx^6l9k>9Znqb8_W+3*vt1gPK%W5$d41*GNmf7-Z#()y&2mMr?cey#+S{lAYz@ zsx3+Z{cRouwzdv|?v@a*Ndqb(r`^qI>JWDIlDwOaX0&}gFGbVMFIqO_B}u0nhvNTa zFV>u#EA{}sF$8jP^0(R}>eo(I9d_|p0&?uL1s`)WZ579^32j`V0k$Np2|2c!3=F=B z5_Q$6B279asbzbI_8Ej_`-Ogv?QQU_n+8R5Y&seysg0?DlC|IJeEe|Ht@t5KOk%9P zT-uyeQ?VO z;*D!PFp-G2YkvZ?3I{gRmm2fiN`tQ7rZ+%;ze=tS2wXv7 zw_}zB(l*NCwm)QK47BaWen;>{+D;~NRjG~nDkcN{_t9B1k)U?NK{ySiPNJ*~?m9)r ziYx7PR{R$hO7RwAaHWTd!qJ-JXw%{t1fH=0f#Yg}J5r1}c{aPgJK+7c8%&e~iZ3`k zp3qMf&$4};5oiT9(J+0FYTAy7ZM_Kan9Pg4XqlcWwR|b+)Wy^Gqu*iMchk8aoyG7E13$Ga1}ei(sp~7A*e?DH zPp9Gx5T0PGirYW)Tlt`bc`dc*dJqhRs6u5z47_cI+r4C;Ord!t$OtJ+_mBP1M<<79 z-huhI)Y7pU{)kt1!`sLuAq=pg3zU4R<%<-z z128=PI=;5^ibT}Ccs6PooKPb*ELkQ*u~y8iQvWl*WJ`FUC&wQbM%)rSw3k^7wJ4$x zTkS|4qMhX68LxR-MFGdWeygsT%n0aA3bK@F#0z4`oz6HEm@$_q#a-rkl)t){1b5v; z^M1Dxz3+CQnaC4K2%4c|S^f1ViAxUidYAg$m-g)>c5(C|o=#5)I#2hGAQOU*Ns;HW z{V0oZ8!9rD-0EbnAxbr9m8e|9^YH7+gwTd9*ih2;lRF(F{su%yW1w5NrzGewjQo-^ zA-@yM2(8y#TAQej7tvV)LP9>1|GGRR4c7)g@hQ}tcPfoyR5hn~c1m=#W*kOm$zrZt za75l{3T)3>f3m1ur%}MAYT|ZqARlYNM}28w%fpRpfurPA7v#DDk~uYziWn$kiOP{; zSd|yg%dbHdSs*t@aH%rtR~7_7&+R>U-^84C3Id1UzIf-r2>YHXi=Rn?J5~XDC}PGu*sKzj?8%5nAC`u{$uU zOROZ*J#F8fG5j<)m&o&wNc=E%vHvlKFGzHAZt`Fi(J`N?v{)F*%UM;Y=ChD+XDi`A z6gORGoTNIY?SPv#Xb^WkT+a`xT+i39xrV0;<=sKD`kp=_;SP^Z8UqxSyj)y9)>+-o zzrtDN#8lz5u~89O?K%5H+!=tnDRAL^w2AE%>wjZ4KHdU0u+$A)bEs@O|0U3wAwQXcib!-23#NZUsz zp|{Bky3NV|7t3l{yZB6d7amz>DG^%Rx;$c8V|pfpGo*%uzrN|muM@O0`d5~G!)7H3 zC7P8U(dv~hBOAXx)+bB*@--y0|lys^u1fligMfg+ZLGK^ivX_2SuIzBY z5%>Che9i(f5!2>mv_M1jp>qA{My(blHAb?*!UWKLwE3#>I_yB_;L_0ca8eyK6uFQT z-LczwdPwLmZ*TXyEx~m4RIprUle(_{;#H6mEh(yxlnGa_%$9flS9??g`ZOXN`x#%$KhE?4&Z2b7$0%BbW2Jq za81M+;HH=Cdf-zIQ$UmyM|hT4l7mE9v1~wwbc{&5%Y@v8N;jdN+HBqN2ap$jD#|2RiSjvjcXa=~HOyrUAzk~9jmC}{B3TLT63 zn^atO@cGv&-Y1<8kl|@a=6kterPh>vJB=H}hI)sHdo$j^)hqMptj3>up_^-k5V)PH z_)srZWUttO!=K;zl`C&_7D`sB+1-PBZW?^VE>b%Kpmc%0(VFv@=bh}iIbmUyHV@IP zt->-P-@+MkD0|P|&Y%CZyBw+TZ;M~(9+|Bk`Ca|DSJ2QuOQG~f-qZ9Iq`mh;yN6@R zV|5jFhY5}QdAa`sYg9HJ){M<%zUCX#v=}?wksAN625h>3Fzkv%GxjYd{ZyUL?wdP)yb0Qmj6*ZAO51XY#ZZ8=_r+ zw*_xRPLt~@pGQYbi#xfzZB6=mJX>BgR8En#15H903igm0xBX0&UU9NV(RojWg(>5< zW@qK!7jCIeD;iN4C1X+Ct)1~qxQ9Uz6ESvx7aO^mRZ z3l&gP$!h;A7{h&!~@_BG$^y^c0cj8(9s!3FF4Lb|a5} zJ>U0#b|ngh5Srz1+ znK^TMx-oGs5Qb>B>Y{Fv$E;=s`3t!%^<3*wSmpMEDTwJ0;>W984N3b9+2o)3U93#V zjCG|dO;fOuS00g2(^SPl4lVF+`CHF;a?BbUhFE`EJE&DkHWex1-NT7y{Q6) zqu^|zSTwKTR|fw19Srd?x%4?GS=Vc%>=B*!V0Hyx9vP(FpvtFm%>8or;7>>q%U}F= zfWO&o1mBUlWyiqr?AT?u8O;rt!eeb?9|qN=5&gPwW{pfV=WIfSg|HYZ9qYs^ng-O( zU)RWB`kH1|dcGfmPvfXx#}El#u<>y+4N0GIda16b!W&AnKn{-~i}!?OT+R!Me7by9 z+T61wgk6>T*e&un{g8mT&d2xzm3F-^zql=3Fr+sJucDs^-lW*D4;Da`n2NgU*K0&w zs@Xyhnj7|cRKJ8gJi32D@NZf8)chh~ArbUDolYOGoCY)>FW->tih@=Q0Q`jt!Cjr= zR5|g3H7#ky>%-KFEAlqB`A61hsVUrA(x}3cKswDjRMOx> zyqQ-yc#1msDw%>&9G`-NOSdgj1pwnjv{Y=!PjWb&PZJyXs5_&qZwqOthr^b)*#^3` zi%Ymxos_4#07l?DC|_r5$fpK}4u?-wA6Xe#KsPip_CJ_0saV;bGp;w55d}tyRcikI zZEO1A){56eb>4UYY+qeKU)W`oL0u4Is;6+0n|!@?>-}~`S(zr8nv#lyi_a(j=n#9< zbG~Z{UJP`;sQ5))1L!fa1RC>IRb+X^`dv)Jw(Dd-1@WxXK@27WbLk%8yC=M|l;T!l z6LgbaawKi9GCIiq^Aup-i8brwiU7B8Qe(!YvzM*ypO|YmCQi;h71o~E0r~s%^~-3U zJh@w|tH5*qCHN!QBm%vgPt;KNFJgj^A$) zON`9_lV$vlUz+QGb|uV3iLkg3TD$|_3TNv~LiiimPTNQcSET4|wcWN6UN-Bcn!m<; zXbGmbHv5@=tGl59wxHAaXd#(2pLCUPMgk$RGY($;<0;(!<~Oj5D$NiKPq6dBF3(T> zT;O)pYX;N3zFd5DdfH{)o%ux`!QX@shbELylr)B091BkIHglTCp34!G4VdOA4fIJw+=qUSrBfx*)ER<_C0?xT=Ytm z^^{uj=qq?tW2n&)&)d1Pi+6-S`BZ$rSW?Ky$8-0aib5LIi$7vrfNAbWT0FuVX=Z(? zo|mZ6cVcG*1>1nHHHBj@@^d*^NjX0k#>7Uq1oAUD0a>B_w#y9uX%_-V823+5Er|ls- z=Ok`{IH9&<);9SroQdU^_KYe4j_-c};)24O|LCv(kxd;M*JV`OoxyF diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 2005b9e93ccbda6fb22968ae1c910b2eea66ffd6..e51cd3794698c226681a562752a87ae5d1372393 100644 GIT binary patch literal 15183 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 S+%)_EB`>RdHB%b<;C}&R#T+OA literal 17272 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 ZHyCbLxO5@qy{_o>%8D8aWpWm;{~tN-GZ_E? diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 496f9c774837cbf7750c8d5c637cc1b17a3678ad..02f73102aa0f3d6e85d8d38946094b4ca4353a59 100644 GIT binary patch literal 5810 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 diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index ac71f6e49d369922faca7df8945cf67110b77136..a42c0f75f0f84fd4b6e71fbb3b0adb6b54d5ebfd 100644 GIT binary patch delta 1094 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 JPDHLkV1j$^HH`oO diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@2x.png index ac71f6e49d369922faca7df8945cf67110b77136..5045b9a17fad670b6d30e49fc5321f4b4fd3ab42 100644 GIT binary patch delta 1094 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 JPDHLkV1j$^HH`oO diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-20x20@3x.png index 562e04004b1b85f7bd2c054b6f734a00abea3ec5..2417dadd8af5d4319dbd329d12c9d69f328214d7 100644 GIT binary patch delta 1482 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 BKF|OF diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index aba79035c5c6e7ae5ab16377f62d1bd808eef138..97cabce4ec87be78d4ef80e11cca2b80fd995c67 100644 GIT binary patch delta 1459 zcmV;k1x)(e4Y~`E8Gi!+007oyx*7lg00d`2O+f$vv5yP zfP?@5`Tzg`fam}Kbua(`>RI+y?e7jT@qQ9J+u0nt!QR7H+4F^@AdjxsWjG&G4R zDU2;GkTf)fAt9`2Xr)_Qi6|(KGBS=ZFpV%UgdZQde}Dh~|9`D&YKJ8yiz_RTH8qen zHjFMVh$kn^qob;1WQZmvnny>&k&%==J(4&$*}A&@`}?bAW}i?{(WP2=#+aDbx3`NdEdT!g$C{eu(9qw+#DpLqqgYt);^N-I z!qBLw)2^)P6%QBjI1DdWq_ z&!(o=wzio>MBBZ+iz+J6s;abhcAid7s9|Bii;JF5PtT~RoJ&i_mX?)1K9M&!yoQFb zZ*QkwUVplQftyK5rd?gdm6hn!)RsR#(5tJ&l$4@XRhL0Qlsr6-(Qupq0004EOGiWy zm9h;i00007bV*G`2kHn73=J$ded>|`00QPoL_t(Y$L-eZU(#R{2k<>Z2JJCD$%LGh z7z=fxnL{!Mm{Y-PrI@9XrIlSQQ;RNImUhwZ_kZhO+mH1zj9PG%XWYd=5*uT#WU9ysVE znV)kVsscAuYm~<9VUKXcOF+4r+B$HHI>p5fd%drr(VWugXmyj?Q>VylJ=QGwTg)k< z1AoUsbvm^;T2Hh|a?qTr0Z9-FgHA23_LCiwLO?mbvn$zBqYP%dBT-2qpql~pfKGdH zpS?e*N>hsIfEth{Rcfzc1_lLLF{jAOo&s&!O?ujSW+>Uxlrq*~B^6MrVo*BLrpcWt z=juGG-c2yjQCU_9D0lAs7=$2;j!(qRDSt*6CY!Qll19v_8qh^UK>t9Q;p~9ODaT&Q z4v3g?wyEiyH8<-rjGEDsD_8T$Fw6`}85+N~mD1O@1eB6DqLN>iipS^4JtYud0ijqv zc~dgVXk=cDaetaR*QWoYGTOAgPfPaSJ zZq-6CEd{ylUc!qg0vdz+4U1^XWs(U>x)>Y9%tLS{`*$HdYy&inM{P!ZgZpZGI- zN-HBzS(N`|$fhF9JTt5*3oDD|s}_oY_P|*Enjs*6^tr=}D03PP!NLolVhG6hlE>Q~ zNCv9@yt1wL3z;Wq<8=|*;cp^Mdw-y5GB8{l)#=*co42coBA{-ltF^sLENN60hTd}y zM48i=2wk0gIo7EZjDFZYAR`rH(7-wwN~e-f{^$dM&+<8r%itz2bi%A{FD*Yie571Ubkx+=EK8Dh%FK!-Cnt%Q*bLxBH z#Cr2acP}RVN<%D_ei`xdua-(}c!DZ642PY+|0EDqP|>sA0;OsT{*c|RocarnpWkrS zfk;>Y001R)MObuXVRU6WV{&C-bY%cCFfukRFgPtSGgL7$Ix#RhG&CzPFgh?Wm3seD z0000bbVXQnWMOn=I&E)cX)I=W05UK#HZ3qXEip4xF)}(aFgi3eD=;uRFfimNNA3Us N002ovPDHLkV1h;ZwUht= delta 1750 zcmV;{1}XWv3)>Bl8Gix*003^;-G2Z82BJwsK~#90?V4L?6jvC>&uwNevm2Wx5vxL4 zrD{txM%!u>G!&KADy>mLB#I9{1oTBv5D`=`;zKBkctOMm$wT5pHdwq+@?b5EK}_4F>c*x?c1`v&+1Z&nr<|RX(vmrQ$wUeC|Cl|Q^E+q0|9|&==j_5C%FDsb5dt$x z2+SxUFr$RPj1qb~(?Em(gfjydVw_S{s_RoHo@QJK5yombym0;c50)*{RTT<}`KOHQ z9pFNQAcXPZ;m`N(-G2G9d)6#!nt~062pO7&Bniv19r1MGB8*L4*W&T7PoLg&@+1Jj zOr-={3lT~s5`VMi&tF+xO7)xI)51zfG8*+37H+MpTeNW_H4H=uciZHooGX<` z%wDo&#qQkzAye1w7Un!IB18>`=PqBq`OKM;#fzz7*n*}}8Ks7U#lmy@{MSYu;Kaq*5TS4t}?7^M(nTTloAgsFIZ zetG$4+kdu6Uav7eE)D=EasO*9(?mj6Myal83o9!>-Ll2hbt9R~o^?vNd~N{LNaP=5 ziCIxPLm^bQb7$$QRaznetzIG*<%w&V+R(I$nwrYPhbN3>&k6uAl1bU;`*Q#O!qQSL z9?umte_U=XO=FCGQ(L>Vsw%UwAoqJ=X=&-2HGl8To0rO$H6L87Vkt#MRhKT!Ei1D! zYc~J5XyeA%;Gmw1u_l>4x6y>ehll5tmsdA5%q=TprkN!xNJopf!eyguC$kbzA{<_} zefy>pCtQ9%GfhFlvU>`cnn+H@T2oU~dE|(-RXMXdq0G1lV?CJ!0Ay*bcZrJ#(PA*z*s=T>!KtP`-9r# zxDX*N7F)1xT}4d|-_(4K#nP;izP?a zE{|ks1uKMqbaoDPccb(fLAl_XE)eB7#QPo*qmE3ZmOy4FWcJ2 z1_rRpC5p)O2a?x2+}+*L*qBNrTm=O@SK7$otYon!$HrbhdZdQK#O)R=m=6X(_J4T# zTU(z50$7sxczaf-anr10p-`y3-O#iQtm%^+mZZ+6rvBDe$>V|PKZ<8?2sa(90K$=; zo`GNxVvHq8#A=$v>(%4&$2V>acX!Jk51WV!FzvWJ&83@)*Bu=rJw4d%{yQco7o$}6 z`9?#b&YL$!Lm^pFvUdn^gw;qU`+r+o<3mHl;}Olx_#$LQ8G80C*x0DYf(I`m{Rv#}5&lJxui`_FFQMuhOS<-(f(l^)MbY1WR0hQPstj4@>8X}j^1 zpX3n6Pnw##nwyEsHIdhIR9@wZQeFcv{<*I1&aq?GC9Wt~O_R9YhNiV$y?^?mt&O+# zjAe5P;y^H10ieZVKb<<&)!ZyAikOv}rrYn2^z{WB8q|>y+3U??Q}e{N`rYB@&+i;N zHV_Q@X3y4QF>%Fm`Te0_uqzPIRW*YVoy1KSQbfp$hYx=|d^j;OqLh^IuN%ZwfRN|+ z?)9{^aAOIL=p^n$up;zZeSdvRZ7r5%cR>NQzcgl)Vp*oT-Wdq+VDR=8GX zWv1CuTl?txb)~r2ib%nD;&yA%XnTD8)E-*BuGg z9B?5*U4cMLZLOY6y3-eowycz5jFBXD-@R)Yi+|bVG;0RjUoT$#<@|Zcd`48iJh zjgO8#y?JvW800OBL&nMqmv>Bx5^8Vneel5L^YMFJLHsdBWLXV|AK$nU>F*c6gsBVK z{#t~wjAfl4atuQ$Do-+XU5!MfDOZ7v5*^$#&TSPAH5>*Iz6Dm`Kzq&@KV(l^uuj9_ sC$HWrKmGq}k?9lyGfL=P1LUT^0KWLNr!OIbj{pDw07*qoM6N<$f^HFDRsaA1 diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@2x.png index aba79035c5c6e7ae5ab16377f62d1bd808eef138..7942561423eef79ec40069aabbdf8e415e5f4efe 100644 GIT binary patch delta 1459 zcmV;k1x)(e4Y~`E8Gi!+007oyx*7lg00d`2O+f$vv5yP zfP?@5`Tzg`fam}Kbua(`>RI+y?e7jT@qQ9J+u0nt!QR7H+4F^@AdjxsWjG&G4R zDU2;GkTf)fAt9`2Xr)_Qi6|(KGBS=ZFpV%UgdZQde}Dh~|9`D&YKJ8yiz_RTH8qen zHjFMVh$kn^qob;1WQZmvnny>&k&%==J(4&$*}A&@`}?bAW}i?{(WP2=#+aDbx3`NdEdT!g$C{eu(9qw+#DpLqqgYt);^N-I z!qBLw)2^)P6%QBjI1DdWq_ z&!(o=wzio>MBBZ+iz+J6s;abhcAid7s9|Bii;JF5PtT~RoJ&i_mX?)1K9M&!yoQFb zZ*QkwUVplQftyK5rd?gdm6hn!)RsR#(5tJ&l$4@XRhL0Qlsr6-(Qupq0004EOGiWy zm9h;i00007bV*G`2kHn73=9!=4wu3J00QPoL_t(Y$L-eZU(#R{2k<>Z2JJCD$%LGh z7z=fxnL{!Mm{Y-PrI@9XrIlSQQ;RNImUhwZ_kZhO+mH1zj9PG%XWYd=5*uT#WU9ysVE znV)kVsscAuYm~<9VUKXcOF+4r+B$HHI>p5fd%drr(VWugXmyj?Q>VylJ=QGwTg)k< z1AoUsbvm^;T2Hh|a?qTr0Z9-FgHA23_LCiwLO?mbvn$zBqYP%dBT-2qpql~pfKGdH zpS?e*N>hsIfEth{Rcfzc1_lLLF{jAOo&s&!O?ujSW+>Uxlrq*~B^6MrVo*BLrpcWt z=juGG-c2yjQCU_9D0lAs7=$2;j!(qRDSt*6CY!Qll19v_8qh^UK>t9Q;p~9ODaT&Q z4v3g?wyEiyH8<-rjGEDsD_8T$Fw6`}85+N~mD1O@1eB6DqLN>iipS^4JtYud0ijqv zc~dgVXk=cDaetaR*QWoYGTOAgPfPaSJ zZq-6CEd{ylUc!qg0vdz+4U1^XWs(U>x)>Y9%tLS{`*$HdYy&inM{P!ZgZpZGI- zN-HBzS(N`|$fhF9JTt5*3oDD|s}_oY_P|*Enjs*6^tr=}D03PP!NLolVhG6hlE>Q~ zNCv9@yt1wL3z;Wq<8=|*;cp^Mdw-y5GB8{l)#=*co42coBA{-ltF^sLENN60hTd}y zM48i=2wk0gIo7EZjDFZYAR`rH(7-wwN~e-f{^$dM&+<8r%itz2bi%A{FD*Yie571Ubkx+=EK8Dh%FK!-Cnt%Q*bLxBH z#Cr2acP}RVN<%D_ei`xdua-(}c!DZ642PY+|0EDqP|>sA0;OsT{*c|RocarnpWkrS zfk;>Y001R)MObuXVRU6WV{&C-bY%cCFfukRFgPtSGgL7$Ix#RhG&CzPFgh?Wm3seD z0000bbVXQnWMOn=I&E)cX)I=W05UK#HZ3qXEip4xF)}(aFgi3eD=;uRFfimNNA3Us N002ovPDHLkV1mluwIu)m delta 1750 zcmV;{1}XWv3)>Bl8Gix*003^;-G2Z82BJwsK~#90?V4L?6jvC>&uwNevm2Wx5vxL4 zrD{txM%!u>G!&KADy>mLB#I9{1oTBv5D`=`;zKBkctOMm$wT5pHdwq+@?b5EK}_4F>c*x?c1`v&+1Z&nr<|RX(vmrQ$wUeC|Cl|Q^E+q0|9|&==j_5C%FDsb5dt$x z2+SxUFr$RPj1qb~(?Em(gfjydVw_S{s_RoHo@QJK5yombym0;c50)*{RTT<}`KOHQ z9pFNQAcXPZ;m`N(-G2G9d)6#!nt~062pO7&Bniv19r1MGB8*L4*W&T7PoLg&@+1Jj zOr-={3lT~s5`VMi&tF+xO7)xI)51zfG8*+37H+MpTeNW_H4H=uciZHooGX<` z%wDo&#qQkzAye1w7Un!IB18>`=PqBq`OKM;#fzz7*n*}}8Ks7U#lmy@{MSYu;Kaq*5TS4t}?7^M(nTTloAgsFIZ zetG$4+kdu6Uav7eE)D=EasO*9(?mj6Myal83o9!>-Ll2hbt9R~o^?vNd~N{LNaP=5 ziCIxPLm^bQb7$$QRaznetzIG*<%w&V+R(I$nwrYPhbN3>&k6uAl1bU;`*Q#O!qQSL z9?umte_U=XO=FCGQ(L>Vsw%UwAoqJ=X=&-2HGl8To0rO$H6L87Vkt#MRhKT!Ei1D! zYc~J5XyeA%;Gmw1u_l>4x6y>ehll5tmsdA5%q=TprkN!xNJopf!eyguC$kbzA{<_} zefy>pCtQ9%GfhFlvU>`cnn+H@T2oU~dE|(-RXMXdq0G1lV?CJ!0Ay*bcZrJ#(PA*z*s=T>!KtP`-9r# zxDX*N7F)1xT}4d|-_(4K#nP;izP?a zE{|ks1uKMqbaoDPccb(fLAl_XE)eB7#QPo*qmE3ZmOy4FWcJ2 z1_rRpC5p)O2a?x2+}+*L*qBNrTm=O@SK7$otYon!$HrbhdZdQK#O)R=m=6X(_J4T# zTU(z50$7sxczaf-anr10p-`y3-O#iQtm%^+mZZ+6rvBDe$>V|PKZ<8?2sa(90K$=; zo`GNxVvHq8#A=$v>(%4&$2V>acX!Jk51WV!FzvWJ&83@)*Bu=rJw4d%{yQco7o$}6 z`9?#b&YL$!Lm^pFvUdn^gw;qU`+r+o<3mHl;}Olx_#$LQ8G80C*x0DYf(I`m{Rv#}5&lJxui`_FFQMuhOS<-(f(l^)MbY1WR0hQPstj4@>8X}j^1 zpX3n6Pnw##nwyEsHIdhIR9@wZQeFcv{<*I1&aq?GC9Wt~O_R9YhNiV$y?^?mt&O+# zjAe5P;y^H10ieZVKb<<&)!ZyAikOv}rrYn2^z{WB8q|>y+3U??Q}e{N`rYB@&+i;N zHV_Q@X3y4QF>%Fm`Te0_uqzPIRW*YVoy1KSQbfp$hYx=|d^j;OqLh^IuN%ZwfRN|+ z?)9{^aAOIL=p^n$up;zZeSdvRZ7r5%cR>NQzcgl)Vp*oT-Wdq+VDR=8GX zWv1CuTl?txb)~r2ib%nD;&yA%XnTD8)E-*BuGg z9B?5*U4cMLZLOY6y3-eowycz5jFBXD-@R)Yi+|bVG;0RjUoT$#<@|Zcd`48iJh zjgO8#y?JvW800OBL&nMqmv>Bx5^8Vneel5L^YMFJLHsdBWLXV|AK$nU>F*c6gsBVK z{#t~wjAfl4atuQ$Do-+XU5!MfDOZ7v5*^$#&TSPAH5>*Iz6Dm`Kzq&@KV(l^uuj9_ sC$HWrKmGq}k?9lyGfL=P1LUT^0KWLNr!OIbj{pDw07*qoM6N<$f^HFDRsaA1 diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-29x29@3x.png index 017432e9116806c62e133bc603167d252ae8b22c..58c87d5ea758b07898909977db36417873bf843c 100644 GIT binary patch delta 1862 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 diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@1x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@1x.png index ac71f6e49d369922faca7df8945cf67110b77136..882b9b5d2ff40f1119bf4c88c5f72dc691ede8fc 100644 GIT binary patch delta 1094 zcmV-M1iAZx3D5|T8Gi!+006rnNM8T|00d`2O+f$vv5yP zfP?@5`Tzg`fam}Kbua(`>RI+y?e7jT@qQ9J+u0dY`FR7H+4F^@AdjxaEfGBSxM zD2_5RjV~{VCMLv`l&fZDj4dsYG&GAWEYYm2|NsBBcXyRNJ%5ZYE|4`fh$knLIy&Xh z(Ek1Xt7T=1Dk_yeKF64t)32|RI5>nLAfs4V@8aV8`udzpOV6mN*R{3GqN0*HIfWu3 zvU79z^78%s{D~jLM##(WxCg9>DSkpMMaS}H=t2bw|soBZf+jRN2~w< z00Cl4M??;9y)aJz000McNliru>Ie-C3eRb`mhu{H4JLA?oEhsw%SpmiYB6+`f3p^&Aw63>Qj% zKrdgJxLP(U6%=+essI7Jc743;xh@L38KuBM4Q|X8>MjaphI9hn^eQe2ql^g7FEmgX zXGHK;6NO1e2z?yVBqN%5JA{opDadk3hGfuk<*DDT--{t1TpUe1AFB7;(s@hG&U1EOb~5|^2MHAW^{TzM7iPRx*#5U1!xFrCJC@^_Ok0e7&B2rj~pyke37 z7I(3X5axcif7yDlM)vQOKUhY*kG9smDd1l_)BCGLGAz2uI)sx$ej1P5`3JVWWj1yS zQ||x(03~!qSaf7zbY(hYa%Ew3WdJfTGBzzRI4v|42!RQZ8Gix*000A=FFF7K1V~9lK~#90)tFCc6n7NI=l$k4Gdueyf7orL zHA_RKXem+CTte1E@lY_-OQaXAClLk3n`ci^@la4f#G@XJrC@Ro6){>bAt@w_m^7O} zElqagg4vxvJHJ2Wx6`Jxvwt$P2@QShVOie3&+onW{k^xd@PEU=fV3Be_Jk_^@4;1^`KtD&;cBax)L{+FdXp#3-$l%bClUdj|&v9@PW{ggB+laoqFm4j9QYtyCDL zC$3-5T)NcoSj4tvMfqZ6M2*E5rQXzWn^cUg4D8T0iR5OPd2mP&nf z`gDBXzTcid_kShb4j5rv%I7~F8v6Fut*&&MI*zO;|7KGqRLbS#fdhkQ&qfl7^2SDe zo5O)cOn~yn#^F<^zPWZyOC%Vj$RjHN;G9=XvoDkR^5jX*xno&ghjze1N`w&G9=>?- z>#J8KN#cwlSr&EPa$t;D*5T37zD&k3P2qqWBXB?}l7D4l+nnGAy-P@J$T?!n+5&s*fA}Ua7;4@RceuOLn*Zg>e#j@ zhN|*lLw_5$C#41lZ8DQXfKdP``7B_TUh^jb2$k~r{R0EvkB^I4SSO{X={93?VN#vd zE)xJ^+d-tX71aIOS~goM6cENfhARu)sPUd`04O1PSJ%jm8?vHoOXZvc03+m&xw-t} zBGxp&17ld%9mANPm>`A$7<;QyTSm@7mL0=D9e*9IrQ}kHWv#q=Ra#$HbbV`ZdZ|XC zFgrf}etB7o$9+lH^^bEvs8!G00wQDvpd}J3FJI12OaMaSSasBJV7I&=No(2cp9>3skWWqS zE`P#`GXL~xZhBf)6!FuiwiuzoZh1Llzt7ARmX>fN;=8`0sga0d7{5Mw@@{TUjYfIP z`;7xps|w#-TbsUn_v8NlZKRaTX^BL`@`eMulWQ%T{qfwnNM~o?@UYkfp6Te9QUI#7 z4VZJcZ3~l=Q)6RhvDn$$+n$s{fZd@3vVZ)`{rfNO+`+1<=(_lc+Ln}pf?aCGFn+#y zbLQd0SSkeo6~ky-N`b(P5V8LYg^4RyR$sr4_w+DAyep40rbMHmq_jPlGp2WUufKWo z(}fGA_4QaPC8%BpO4VreeJ&SDO51=r=Zdb+K7Rb-_HBD}Q;WxaRH#a6>DjY|$#BU~ zQmQxb{UhK$dLUVrRn@B$0B|g;=itH4bb4`W3ahG+QuzJfywsE`?o{%P2+pyl6<1gD zZ{LaySbbuUc3{Z=K{aBtpf28hw-c(=#$)OZ6P4Nq@0RTu_%8r-r?z$juipRw002ov JPDHLkV1j$^HH`oO diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index a99090af6023932d2b953102b38d97f7005e95ae..ac1f2800cd71cc3e0c285ef0c40ee40d786b4f59 100644 GIT binary patch delta 1802 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 sjtPMr69PFV1aeFWrc*RR910 diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@2x.png index a99090af6023932d2b953102b38d97f7005e95ae..cb2137c6b138df4801f40525190d3270cc12c04b 100644 GIT binary patch delta 1802 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 sjtPMr69PFV1aeFWrc*RR910 diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png index 73e5967abb4f9b87baeba2def9a715d7e793eb71..ffdac97f0123d8a86e20f0a46b7650e59b6a0922 100644 GIT binary patch delta 1897 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 literal 33019 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%0DSmlSk*e@JpvH8RGlF_y@vt{)|?JCj{mmjjiu~D z)XoBlnz4P6rla&~DCd9zpeT)jg+TB3@&INnBcekWmzWIwd;E%?#{ODWM!0=IfX{t} z%!Zwd)pZdYs3;YkVc-&wNuVg;Chz%LR-_SlShbE_4DKJ5JE318#2_L(F^#H|8JjwtG!P<1p-%5BX-vSkMIKBhw}5d0ngFL7bl<0|2O~ta^P+y z;39zX^$e`Q<;HZnZ+w>r9M=WrIEDj8AM5pFv;NleGFS>S z#OSprW_+{&okKQ3w~iTjC^m=Umk=)tnXcPs?*Z4l-2{&0pRjl8m>AFM^96Y0nZkb$ z@GY{y#W3*PhX+qm-$G~$Y85lfW{an^LB{Ts5>9^np diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png index 73e5967abb4f9b87baeba2def9a715d7e793eb71..d1bbbcdd09fed5d8d9bf27917b555212089888f0 100644 GIT binary patch delta 1897 zcmV-v2bTEc673F<8Gi!+000iU#^3+|00d`2O+f$vv5yP zfP?@5`Tzg`fam}Kbua(`>RI+y?e7jT@qQ9J+u0n1QKR7H+4F^@7bk2ExkEG&vD zDv&ibha@DQP*AaPafvA@k25ocAt9nwRrvGs%b}r!ARvx1GJk|0AGdvd`SkSv|Nq66 zm4zZAj4m#WEiJ~Em;V0#|Nj2Rn3#$wDVRb+qgPjuG&G7UE7Y*C{`~y>`ud3{Cz?k` z)32|yb#;g*Cy6L1mOnq})YSd^`=?%B(5kB0y1K7zZKPRQ^z7`7FE6xqcGk1A(ygu5 zwY9KsZ~680i+?LCyoQF?wzko%tkJ5f{{8)xKR?Enme{wq(W|SUQBlvRsMWKxsbgc- zw6wQ-d)KwK(yXkJHa5YHjhaYE%b%a5SXj`hsjO&dxqyI@IXTz2x0^{x&ZVW)v9X09 zAkCwr$eEdjA|i+;CYwk|=+)JN8ymQPe}*F?rd?h5@qh9C{QS6meb~6Tx`Bb|*4ECZ zrqiyj&!?yA*VpFI(BQ|%`SbJJyuAGR`StAVyoH7B-rmx#uHwnbyMu%3*w~>{Q^Jst z;Ks(2Iy#LoFp)Ji%b=k6^75-@X2g?|pi)xRw6xHusK=U`nM6dicX!jUu&7~SoJ&jT z*VoLUp?~Jm(u*xE)v>XTFfhQ4joZDwyo7|qk&(E5eo_gWV*mgE0b)x>L==+W$u$4~ z00(qQO+^Rl2n`Gj2ZGunlK=n&pGibPRA}Dq+WULcR2abVq-&5J1R|W$*{;$#YH0-o z4Wt;Bb}XzbO9vFTF_a;q69q&;op{6h{eJ&*O@GpE&F$ozv%G!!gL(Jko~NIu&G*UM zCJ}^Wl1V0+WRe;1h~jYE)JS?Lvb61y?W1T2?v6)y?%FL8|1Zk}VG(+4&t9C_M}$#! zY!GrPJNh`z<;RIIDm<}&1ipac1bPxr9>7Exm7Xdetbhj~Ri8eD4_ki(SQaZso;iA~ zihsbfdiFUyiHR^$(#KDzwUfZGoz?TUOM+9HbZSbkPXWVDR=9zQFe*%+o~f$_Fx<^5 zk4dmB7S7Bz>nb$d%Lb{R0x;4R zGfVxq3k3(V^24aKe7WgyLEvCkUf5nn=8D&afJ3tKz|Jx%`&&gAeLDsi?9J6Yv(I2yin8yb$zY7@Q# z4O6lj6im%(KrlV4!N3ez4FqP%Y7j6}R`4)mR^TveR?skWR=_ZORklfW{uSIc$D}O8J?H+jRjv4_oU3`(V(_C5E?%Vfnl_TKJJr-En zF^=!^9o~0VIuWj^FTJ~{(61><`^FnwJ>&Nb{dUCXTt0C;on^R|X~7&p+gWwY^$2&x zJW>%^uAy?lPFCwtT{SN+m)K4oJTNsYV=1$!v7WTpVR}|Gm*g?#voteI&wr|QaYA8U zORO+8t9r9@p~{pM7fjEpcK%!?#q)iK38rOb>a~TomJ*<0T2{I_e>SafezRnN8M2xi zl{xQ!&|!wGX3rEjvSNg(S=DDwPZ#)qq|sn{R#T@WzO0zw1|=(J87W)(H5>)!s97;D zBQDtb9`?Qwxdhpl5f8kEcYhvxFt5WbBMx{CfAHb;k6Jp+GGd1t_~TDX4;P3q=Q3i2 zbCcMpWIxr2u)(>E7-1``2RCFS5@DnD8Q(HugV*rqUu;9NK!UBa#+Pj^mKzFNS$%cY zz5q{v?PZiz;$DOg!gf}x&K-wv*j`3eA@0ri0Nl;0C=lWL*WYYm8GljXURDATHkQ8q z4&r{658lX%1aEm6QQ*z22(Z51{64L~+&FZ@{j9=ahUF%cOftzN^M9KE0M2Uokrf5YK>z>%C3HntbYx+4WnwyGa%Ew3WdJfTGBzzR zI4vHXLs*z_b$k;jnYImxKT)BurmR8GK$czGb`}>y#d@uQh6pXr&Hdu3r+)qFt9CvQU|2q2 zSpE(wfe2aC(@!)t?LBegmyL}MbrqH8uafb`7a2IZH*2 zXei50J})cEAK!nUQ1(3EbxXd$R}6sAHqCW)b-#b-opp6}UP*&m>=6wSDl9A{*RS96 z@yF}8Z1G(eA_N|Bto^?PH!cO!tlz%GGlN7|U;PU&{OO~Q^y+Htc@QD$ zld|hL&+Oja@ZyVZFgukz?+XbU1fSN-WVUv6ynOJWnn+;J19T^4S$6YzC6Rcxt?h}% zMmv|2e1A1J=16q)Ns@$ppE6|#9m~>dYn$5Isy1#cEGz(7#;cq61p^Ib+0N&cSnRcT z-`%o%HyOymjUBZU1cTPeEZR?l&W z`Y2s1!hnWc)aN!>@QlF~p&vkUvY}!OhlBHQhsua+GgZgB- zu&_|Iabr_kn_gS%*ks8EB3T-0Zq=95iL&$PBGiG*WWbpMM2fQGEqS6q*{?yp{Y zY4fgKbk!#eXvkiDo^3-#dA7B+e*5-r1EOpO6PqxumR@o8r1`U_h_lKiLiTW@J)63Z{ z6qH!(QFpqC4H~YfKE`7$Bx9K$XvkiD;SUmCeS(DH6iF%}0L*OmxsHxMA3v_8QY_Rb zO3<27UnPPri~1@Kbfmr&LwQLSsqg1Puf&Pe$6-WA>f-@AQXdb{Ayps7VY|seYg2sy zxP=1UuMCw(h9OlSCPf_p;O^jH_U27lQ9@GPl{lh7FkU>z3Dv3(2h)Y;I8%LnKi#?| zt163Aa0Cs2=emjN>Q@dQ4$rC&2b68ooJ^+|G8rWv$G%Vb6uz%$TFJlu$iol@yNR3k z?tOaa&ZRvl;h>zGnfdX;1#4!8KIPz&)l#X6bb9j26{Kk;4DFI;)qpG+%jKRfq-Dyp zATs&-a}?IXgC{8OCY`mXM$^ z7xk%zF*`Qack*OmeqLQ2Wg$W1W%Z5s_PUFUOw=bynqf?&)7QTK+IJiji>>|?f`P_- z)Tb!JXU>d$|GkWmtg2K_Ar5HFUVWa2;&CUJ8|>`7GcZ85wg%{_99#_0n2Y)pUAHos z{?5+X@o_Dg#GXg-_yT~&tkoA>7fK?L9T^$u?6fV5I%NSsW5((u$Co5E9=~tR3l>$J0HFP%`W6l_?)|DOtVeWxW^}ag#0f@KT-ZVT%j!FK=+M6g1`^fPFQKsy9D3NeXbE%3esxLG<9C?7|qdqm6 zoV$Jd>X%>6O--q~9-e{B3G~mYPokA&_1#&n;>fBm9FsCX&^*)!AySmlix;nVck`^` zLIj$J`V<8KFchedltg7+kCXv45B2#=Rop*5|9oa_jNB=Q7%>yjJk;lTYBKrXDvteA zsL+AtqdqN_n)u-dio4&r@_g%vBr@(8XPA|2%i@ug8u#xg3kaQ#Fix zckYC_isJ$rV_8w|4iBTC#G$6)x0000pKeV-px3^2Yy)!K>lOuQ zLj$LxVx+b<1BIII>#GV18m_A1wX|@ns(Ldr-ZwTToIIK9;86Pe@1yngnWs+G-Mslg zU7hLaDV&{cB@!P(q0jEzd6kv*zPXuOQPD-ECg|woIXWil>6Kl%GE!IfnZsd4Mh@51 zw3Es2o0_OPI$74%eKcCWtLso%S-GDd&Bmtr?%jvSk3aSDT3K97)YVNxB72gP#~T}) zV`6@-uI6Acuj%x@Cr_v_*l1lH&DxqjI@(`QFven)`1ox6_%YkxU+U{S^X5$>fiT|C zkZW)MnagdycaPWFy1cM3R9d?H_3NrYAX;Dlv!i3Are<|<@%z-&P)SKT3N_E+RQUUE zZEjWu1^rrH{f18;2*x9)z5G>8jamvc(`uoS8 zJrjKTG|%N`Sy{0nBG!NWm>U>)sII=bx>^|!(3_cA7aB^@)Xc$Rli~0z@QX*1J0x}0 z(GCOI_uQK%A+li(&RE$6ko-RRUExEw#S+gu2MqcGVdDEo(WMc0W!a?@4})LWX8E1J zm>A~w&w)|nDp0vT*0Go}2y2`@gvMZ9+h=2P2a|nnwSAqJ45ZoQ_D@*>V%Jkm=RpUT zJM=GaMY%8b=YL5vWQ&dsUQsU1yZuh*e?YF>b1*j01FLz%%w*?9;@*ly?7a7wVTboz z)0kVs;tOo!<$75a{mh;@@6XjhtDS@X2&J-8vH9I6NaAIE8|+@ESFl>-@V3v(uBKoM zyfpd(0_I+o_Pf%xB8!O66=R&FB!yk{Pk1j#NkU-jLZcVl81+|(&wH)L5zI@_yKW_^ zGQ7)DRcaq<7Qt|_+Udg z->m0wh&!Y~p(qqbPxyJ6oL?yi(=%7v4ji-yxNU^exS)9O1PIJONr9V6hU*Z`KZ$SItm zu}-*RD(&jb=@7C)YOfr7yvS#}0<1?p+%oXEsYQ>AS^k45;bbz{a-3oRPXu?1{{ym+ z+JHhKB7Tk%&@%eAlG=|1AqJK?!3&G6Xs>kbqO-W1C@E}w zC;nkJ-7FO?IF{?fiMkuSW^*K2-5JrZoTQFrRX+Q4S>|hDmQ>eFKuNs&2R~ROtB=?f z%)JN{-^MCQ_>Hjo(@nQ+l_vdQzK8_SV1YFYbhP3pJ$2g2O#IlC|B+}dP0GW2!ExTg0;d1o2~q8@dXWhGxL3Nr)pwln@-H!r`UL4d;? zQ?TBsFr!|f&DB>+ai?db&hSASD0(j;@AFh|VDsgbNCX%$(EAd+XD*87_x*Hv+K))Q zG+w#qrtK}L_WE}PS*JYMabgPb<`J#ZTTtG!c$4tOHxktO39$`fag~sst&50 zShEhA-&D-NTT?9W!HJL_*po{g$H|2hGI%T2U+yGnGM5evWY2TZO9VJov8P5ctLWDp zK9Cc;^in28C05Q#ZWLnfANUV>YuD>_R=Y+#Vl9J_BrR@T?dI2>LoPD1LyXlli*o#- z>J^-)Vfb9BOUgrKIv+&XlKBGB)R8|{dBA}IG9{-F^Gk<4!FlHCGZe-$f+Sggmm#VC zafd$?ugv&X35RBfs>EJI|Ez;ssdm+>K8R7|0tb0~P)qVJx5zn_R*>r1AiE_GNwEE2FOjyTn}xF zGD@Oqjd|qQyaXX1oVR?GM4JmalCXm7<1Y1wPw6VfC%HhBL$WcV?wpaXh9$dX>fjo# zJbenlOk9hDyta!9$OrCmX4M)~LZzP=L=$$aFr32FDi1i`8%aS<2!W8rT@np4_{0Yf z74%EPyrB#1tuipDt`=o^B6G`}c6dTo+|roF*mnKKyy6>ISHyn0Zrfo!U!)l+8ed$y&@2!jO~VcZ^TYD})40Xo zD$^v7=*Pt ziAi$btgx-D{cYf^Xse zq_=9nJ5i#*mQtPnB2ipziK@QIw!1X!agCYPsmr`JXm@GE<66VcyKzVslMS3bSjB|f?kC=HZW?4^l4L2kWWjj7-fH4+@kw$Ou zFj2)Uzw0BupLX`rgok3#-2*o-xO2F)nNq(M;gcG>PJ-9I+METAjsWl3+1}!ex%(r@ z>Q-(4a-8hEFO%!zVo2Uu~SE4Kd&45+M2L zHN&u;Vg6Ub0#JUp10)7u3^zvV!_D=LOfDE1qm1Av1Of(!qaVy>3NH~k;JKb(7?d!KvHInU>O&hvauij{>4CrB6s0020VriM0*z2T3s zF)`MtDDmtoJBmKeto)fQ> zs%4UUR^dG+?vgz2rdIkAahI6g>D}rTGKrE6;w_aOz<-tAT>aVkn$p#P?YcGNM{f1m zdKmbfiyxG?n_m$4eQxjf)1P?f@0jqdgOa`D6CfQ1$Cp|?W{K#=+sN5+^$)&)oj2lB z_xm>hg&5v+TVH3B~K}vC3ZxF=YX|B z7VuW_(vOXzok@=~`ugkk{opH54IfXrx`9=;7WAw}jMq89X-P?eS+1fYjQZy5+li+K zwMwDNv z7FT=wtR7R(^*Gkssi~01z^JO(#VEamqhqmrouzX>eEncP4-fyhGP!B0tW!L->{DztR5J~qr!rGt??T@t zv)6me*MZ-*=Kuf~>;+gCP6;L9&2OzBELWi3*?tN+RBzheoZr~#DUGDXeM8y|GIwz2 z0-ROf`ngH!Q`yf2e6-`(@TziYDgT~Q^elq@e5WWj*tPic+EW3qS%g{j~9P5?PFWU&D1mTw6r&FajO4T`i@dF%;I7oSGHPgrTVW(V-&IyPQ?ELV?&EJ zN3ya4#{MfU##Qi#Py~j*(QZGJ{!vqf5O)>3p-rri4jh$kI8JG*9>_<}s$v#f`2b2> z2-SSGu-%3%NH)ddxezdm`C5KLH@KSz0zU@;bsHL#rquhuZEPz@JJey2r-c_6Kw4$=0co93w+I7#No1@jXjM zD_hpYKmxHys9mx;6F8wz)N&QW!k2Gl4;mxrxT8oZQU?BL)J$sogNiU4PqXW)3s*;g zjk*GR*BH$1^Zl0RnTJB0EWMm}58Wsy>eWz1l97SLoO{^!ZtlI3Xi+7>Nh(3Np)2EW zwV(1Px|npK+pS*Y{8n8?L`kv%jGkXu5?c=e)M#Jfu14Dw&8_^GdW>(v#@k)|~RM=&{srUN{(58>-~TOmfe2DkM#Z zKMy)P!Cul}pe4hkKo=}02Y9u9?wP883O}3u@uc6g$5QPaKPE9(N)#Rue!4sTFwpJ| z;^Z=m?V(ZY99-Bud7e5-fEx5}sDhXRv0?yT;+Z8YYZw;$5~YfSs47tj6AuRm6la@h zond>!4oEOAufjBohszQsU!d-jo>1N?jqGT^;orZ<;xXFa7m{j8mi)er7SX?#k(Ro# z`^}>o3@)8o?tiArvU>v>+|vX#AJYd4`oNNwbK8O%C;#a@raTDNuFaTuFKm>HRiC8_ z3)tM8ccT(6q-8Fm74>m0zY2(7Jeq$n>TxO5yxE!xo5>#R1uf$R-bVn+k?`a_EutpMmaD=chr zaIjCmO?C=!ex|s0_u=?THmk&f$R*WIHMYq1Sy}u)jZftFY1+sm<{Xw%^78uj>bC*! z#x@XD=2MnufcEk1RqfaF3WRb%P0v8`(aFmCMihn8`X%)= zBUB~XZ9quKiIiSk`RbO<7P8AjWR0Pkuhn#*w|*pjI&`v4U!bmF zJxJlx)7MLU03D+6(vJ{YxYNwLU+*ct(=pOA+Y{DWi7EJ%^>t;J*1iJsKK@znd}#X3 zrkTb^4m}&!w4lgq@%;g6YF*6CE0|-YRob_CstuLUJ!0Q(l@o~6J&Dw<3v@a z$!+qGJ?ylnW4(=3Jxqvdi99 zT8qc;^FFk+I4WO(bVm0mkV4>=KjtWyLsinAU{g_PrvZ?;_R?*QBL5)111qO`6zw)cTGIFHexdRLm9y~rT}co8;H&EWRj16KJzo!#Ac<=(=?>?~!J>3TYr zCz{@HzdSbPb?a>4zT*C&pOw~QtA(Uv4Y@4O&wZ;8QK-rG8^beZ2)cS1)bs9P0W`fj z5!l$peI_GxJG~Q_y~X7i=4 zdmHl$-bL9$c_Y1e_STlJp#^b!4oZKiXvIeuUD?sx|idqPQFAffuLSG5F z)f_lR-FUUreQIduZc=68poT+u>&h0WlBY0ZCwu;GXF^6n@$@ zs}4j=c#$1!)TqMy)_*KrM8`Q=M5LYC&Y7wxr6cq5cMT9yN9N~BegLzjpY5Y7J=`RnQOgdfpz*KwP11YX+21%i zEckR;FXZvO;2LOtvOYc+GXJZY@@~LfUYMbb$}(*heEhZCY1!Bv;_<;*b9hi?&Cpf% z1l%tTxp>aYckXO{`4S4F<6ud`R{;RhyGJv`U?ugUk%NO|$=*`#TR)P6eB;@XCRK@< z2E|hRoWQes%t`Go+Tm`lc5lLS5OHs1%qznTg|75w(Ji5%h!lG}pP^6s96)P+jL@1e ze9R#|l&%|Aw>zjAm8U#aUr#G6l>@m7+Ai2RnBB+XpDkHkxVBY1TkmsLcnxvgKlr+8 zkpw9i9@hT8E~^6sFYn{cz(Yfp$ST~Z=^mOcs?nWG$fmVx+t=RS((LJ{2VeB*=5Vd? zObM3w^Am3ZbMhF9?~543dcn?`cXjLgBaLDu!8M7b2fZD?_vYW+3#e|NyArO>P8)j? zgvC>;37O9{(3P}3_uIFZFQHg;MI{+h3{e6<-Www9Z>8BCu1$au%X-oH+UAhfx?R$j z*XIBNK^Yto@$6*LnyMd_FOlnDI6B6&*D{gH^=NYEp`W~Zkv$uG3ch+5oiUSU?6vB| z0a>;pcHLauJNempp7i>af4T8XmD))d{m6r+HaQl*|1r%CXwSg#XT!VGY;U-K6orR6&jE%nvm z+R|d0ro!*v7ltYPoyG{3zX&3>k@F%xmo$2LL+lm^2U2$>mz&>psR)D1fP74ggmxHx zR4;u4MmG=EIXw*XN@z%j(M|KH>p(s`dm&za%u-sWMj5nDIzSgb>590;sHw#?n_m{P zKaZ71#KbKb{7mlLVbQD5+rZ&GCMth;H&CVS#&otkGOm$h)s> zvNh1hBS@Z^arb7WI6V^(af~uDoOd!kUQR!pz+W&z5Q* ziaX|j{EW4@EXW-zS|P&{)vu3av*k*?{Qogf`Ny+!pPJtVsYlW}8PhiaX=Gtojc|?o EACWeSasU7T diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@1x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@1x.png index 57f300b1dd4f735bc8ba7ee653086ac078e00bdd..5e18a2da142403d06a20b49b9ad045615850e346 100644 GIT binary patch delta 1740 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 P00000NkvXXu0mjfZj4ob diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png index f8c9f0aeddca19b77bb8955234f547871f9da756..618fb3b29d6875adddaf786a8c1347e59908cd0e 100644 GIT binary patch literal 2539 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 diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 7d815970aefa357932ed6f99dd8e3c2546d91c45..e796d944b5451fa2a927ebce781d81effea6f36c 100644 GIT binary patch literal 2718 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 CG%i#C literal 3864 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 KuhqSA|9=3ZhKW%C literal 5097 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 N%}q}es!iN({|f^G=biun literal 4422 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 diff --git a/frontend/editor/src-tauri/icons/mstile-310x150.png b/frontend/editor/src-tauri/icons/mstile-310x150.png index 9419850f143ad755016985a4fdaae968441ef01a..9ef3b02fe7e1a59e41de9bf2b07693153d565ee3 100644 GIT binary patch literal 3166 zcma)9XH-*J7mYZMC^F;1k^yu;kxm{72~`DQs3Hgv2gCqkL6jbfK%_VlM+gWZLTE}G zst{lR;X#10&_qh;QdL3%h&Yr`B#`fdf9B7(mTzUsT^ z0)Y@UGd08`5JC_FvEwJLLiXdX*JfTfY@EDb2dhR0Io1Sy~2wZV=RCEx;>G<9{r0rO6@FIg3Zn5q@*ZC#UulRY+Ktn94<;(xwo`*lh5aW z{TeARpJHOtQ&Ljy@BdIyVhS(QAHvRR91Eu6;U-bUc|(tn3}ewr~hSV*HutZ0lOG&E{!mUX)zoyVukQ~@$?CAM2Qo6b4i2mH^Ba7Abx6oyb#=|X zd(-{>9l5zZ3`S>O-ps%Nr>pCIbMxBb;xLoRUt8nN&N9j5&tqfV493US*4q2`X9fo| zFI;#T7x%uYiKe3ij)tnP-jb>jm&m{@9jbJs)HveRh3^&#nO+FgV7}W^WW4izuErw)c)(XlPM>?La*d;mIK>p zl!1x zh?)$BLuEXT?pIy3XBBsOO~@@tHNMcUmNDe!1&z)4yRG@z;y>+_#HD%&7jZ`>ZEAfk z#Eind?n8EMt>(8D6N_oqXV9*bPF|ybC)4QN8iMOg)a{{B_Z(zZn54XF&S!txzk3{9 zJGr{!S(jIf7@j8qc$9Y_%;{jwCy_hjzFwxQf>$3#R*ZQkwSp8sKk-(nssB>1Dp9;n zhNnDs6G^OP;sQQp)%}*+T0Cpa$0~_379`kNyO<~{UveFC_;YD@%&VWpUlNw)R9Eii z*vcb`X6=dfCA#i+QM`0%x_9Xc)kKQKj3uLY z?4K0dSC#h5oS9)Oncx{iu!ah#qy-zbUuA)VCUF;xYc#f8X{K$_=Vb;?(R0VRPE+R% zv%wUXWCm1#+?PhyQVXEB{tC;%Q`Fy7M}L9A(|0nOddQ74tCQERm6HhvJ7w>%Ig?q< z9TiXOc)bRY5;tCx=?R+BjrjSf{+%6l&cHzd?hg&+fFvtX(_QvaMm&tfIe6Fggy6p-x=*)qU<2PI)mU)UI8y02 zKu$~!d6VP=G<}})&b{Tkm1za_5fycbv2{murB}t&Egj1U zQL!T*=sy`5I>F7Y$_eHy>Pa;2>(%skASrlT+;ykG`YJR2v*0wQ!Pd^T1`4LBcK7UC zj4}yPF(ZKsy{#P67hZcg?1a`y4dc1UMDGt@j&a$Cwyk-tPNNK+VDlzts^zHG@+_+( z%jr^Qt1{Jmo=RdTTfG4LJ;PSB1K9#)$QY$xZZRSghA58+tX()bpWc??%%mx-e%kv| z#Fc0b(c79vV;e|Hj@?^N6WeY~ARCxV&prg2A-mXFcF_H%lCYkQ;hvX-y-sIn6g*rZ zr6E@5(LmYbYj&_cTn?E)G>aLzGcNo|-x-ShFpgvbj7)^xnJa&u4z@cm86PdRHeveq zEQIbV{~u+JjS{HsvS=jEFsu$B&esiiJyE!Sv~(2eX0vA2@hE=`9NH2t2Q zAZK+eE^6CgOJ4+hqGOZi6;hK+6@f>Nz*z*(6`>S@P0J*wrSu)qM#=6Isl}b5OP{C`}61 zcTec*83j3svI2tcjOrFE&Dm?rDM)Wg%31WqU~~FiiIn#(?Soq{XOlY3sy*`}7U<=1 zfxofb=E%q5@fQFmJ!yW~sT~OAlH~zbFx&yK)Nzl=-67KX7DC@%+wGU1+pp=LKZ_db zN2O{9DfW4e*kr#ea_MQZw{)}w4Pmz9#&1_nbD_Mb%1?!L%l#jV9jl)IL8Iey+${dN z=>^sQlhXUj!1!CE8NNiR_3{5)n2^Ry+wMgk6GO!Xj%BBK8F4lVZqtk#zt-NKR~fFv z@#5kF>s9782bY4`p8u*kSGSC9cRI;2si2S;3euXwuT!!6#r5BWwL`7j-S7;`a!}Qm z+y3)x@5ocN)6IdWJw0w-*<`}AhyT-SLxP9}C5xTOr`=pQ_oN%5dfg2B9vAfFb}E>l zu({_`b!2%=d}|*3oelmwX=f4zJ6A}Ki`xC}uYrrKpOKy4b!WdDI@gFdzzv~>R?|SC zwNMzW4Mt4|gVw>}l+kD%G&&(z06qx*ADlN&SkrLv8sAu3t&5k_cCW)P(& zLm6Y2_%MbnWgSZzvhRENyxq^e-{l{;^V_^%uh%)xIj=d7bI$v*jg{#yM<7Q)AkZ)7 zW|!?iAYOG4i0A0xgTRxwN1q3PKqCF-mw&$&@_d#fRD`xC9a+ZM508sGu%AHoNZFPmff*CA)ut3GCJw&@LrS@8j4 z{^3vewR1nfpxqOW#o_pSlPzhamFC2k~Xqca^Sd*KushF^z`p=;`2jt~s z_|I3dAqFgZSR7?zbktPl{ZvAh?^HhFCSYK>GflN?7`#WT9}!U31Q*4+qahs#!roGG zldS$%?`#R=S`J0&iIkT%9bxUQyCKQ7s!l`>5bE|PsmTWAAQO=nRpS{05H&D7Q z$rCNlsEy3aNL!iP9mKLw@u$?i#XfD0;qRGM^u!lpG~y^fa7bca2C;R1hR60wJ5$V@ zh{eC|h%Gje4+^Tt3PT2cdKjyLslu_lTW`obdkw-It>ZsfP$Sr}*-6JDCg5nDukO#^ zRShKi(t4WdMZ&_9(Nn8{S#F}qUEl1N$Y)*HFVeJ zrU`AxUN*Mxz6{Aqkq*@@IU9z%MNwkj&1BAB2ALD|XnjG4jmp{Su zWmv{K=yRD}-s3PPCs1Q%5MTfB7ip&Mnw!hP2p`QOl~*IE7jBM7|w+jljgFFPgZsh+a zj(VXF#KiRz1@0oem|U7yIb4;B?~U(k3w}kvI0bKT z>=J07ti5cwvMXv+A_dD1Q{P=GpnjtbuUEVK4a*{_7p)Qh=TJer7GL+U?T&R;dxmUg~}_j(VOCrlBz3LrF%R++b@7Dt}m zH{a@IdR&EGm@YcMoHd-V`1rBA9|whWH&5!C4xs4jytf`zNW? zt^`(IYCQJJrIEj2y!lK!EAK_+mpH;ky+pn-qRg=^d}jJ7n=FdzY;AjebDbH|v^kMZ z$p6*;%h-YKwgj+^IT<@aR4X2N_widP?Viq2*w&A;?{aBw+nQaA^t^#L^0y|wzPa5X zpx7|{Jp4`qn=D6y3^nsnt^9dZlt*P3*UFj;U(HC9?`fy`)uXJ;6oLk(Ay%oGN(Ze*t*RO2{X)fh3wy!OGgb#1mIQ&-~&Y`OJ( z_~JKrYb%qLP#o$-KA*&kry{;~xwSS}f#87;&j>$qiodsNjlIyV%t61kNV+NOC(!+L zgm-3L#JAtj<=$O#Jk?qc8%oS0NC((Q5CbMYpY+2cpcj{0>FsAlQIVmd=tv6- zhP@el;M%HX-Q=nwNsJISRmd%}M%Ig?oRto%*V?4{U$xO&EA46-dFoqZ^}C6H#KT7; zyn>bjx@rPdt1~4m1pB^j>u|pId11 zTD*xA>RJ9^uT7gpi2=+1?$0xIwcba&#WAZ%LOlWNMiM_HgLlR6{_7$*0RUlvsX!Tl ze)otfki`p6FG`@COAo6XsaY_z6k&2ZbwWheWp4hRqvkQm@(fT(O-u!=xs6lWjIK~C zt&IhQ_95#zADi1O7@GZj5+iUN@#Y7gq2ma{t&XTP|MdX6)_A%apy1+!Y;HbZ z-`rL!3rLef1sUvx9}oYYycosONcwn8?Nw<^R&J+H=A=36s%hn{k5$k?3~;qnD2vkJ z-tfJoy>Z%FU)ic+spSqK)xsFDw=!P*_;JWUlc6llaIs<1lKXr{u#&Hi-y}kDke$V< zU6@0tE7uT-k6u0Z$S8l(BSQ#Ju)cB`<}*Ajqy?%FMLRbj7z9Eewo74<5=&7!$TYNI zW|ww2F^@*3pJLR;_IU{e*J{Z`-wStM19r!>J`_=}tzPivr#hcY_2*fOu2aJuNK+Cr zZl%*>w!fCNKOr#3GtZV@bE@a>l{4_F`!(y00y@&c!awSyP8NazqNR@T8k-47NM64W z78F%LJO3N`{o%=+7;plwmbxZis(+O2Nrn+yd6buW**^**ohjnWyf8qrs%6=fxam48 z?FV~KCuNtfSDK41$jDJ1U9*m~8fMLZr6sYYbxnpm^`s!ZuOSy$JV*kL0@JadCi%0b z+4QCDkUQ+mjUB&xqMN-pxILimq!Hf0WXum3=~?hX-Qrxl#@ksdhE`5qRiKwaacH1- zU#T+c?gVidf$jM`4fhJKq&FQ{BX3p0qlMFb@k8ZU1axQN5$kiOPuWX1q^Bh63>kk$ zwtV_+?^>(YmxsjNnQJIiSorEx$hWx)s@_H4HGa>A1aP;xVQ#9HnhpxmuTyHUJ$*Bs zWjq zIr@mzM>QKZhDV*;DYAQ%ud5?I?N{~wxsMdt(6Gf+0`=lLkd^%+zEi~9cQ${xd$eU- z%}%WvXHI|XYm$uzSiHq&I0EOBa4tYFTtr2&H_USs9;C07f973P060||vpsG9no{Co zx~ec3dk3eW2EGuoJ!+7e#vYR z-!f@-`CS*!J7>QR`x_g)_jO8OXF0T0A6C;m4jgs2V7%E}b{WXGEM9vk-6BO^c>AN? zM8i(m;Mpp$kA0ccOA%k&R7KcJk%cM0a4M7!)+c4w$^dDo*Nx*1c9+RbGUUxG$VL+3 z-zU>J@5fx-My!R=k&}I?-5$-$jQR1bu{MPI$icg8o!=(xX)5sykG_IeUS?$ueqmdaH?}$R`h8Np@&ICbchfLDfn z_%E+Q7M{RbUVL|TbN1ttko>&@s23uZc{$ki6q3W6%!_?`EN_)Vf0}UhQy6TygwXH9 z4^=+vXRamzQ7{$(98?P42Pee#+11N9zB`euksVH-DeGm*g_Hw^c?hp! z!fI#j>LWbUQ%GHH`**Q>9DqH7SXDIcve3f!8cSRKFT88$9=fTVk2#byw{_$gZT z&jk>$aQeZ+(&d@?xf(;?tp>$g+jraTrBO_dJDWTYXmsY2D0nOeu_Ju1soC4vpsCWJ z^CN{oxjL7Y^(=OR9NGdDwOYVmGPG)A-i3Brt^Ma*)S;FG;I7%GwAq*}3x=pDclN8$ zU{F>H>E|1f6 zO7!gjiB){XCA0AZy)IJNt&#s#NpUjPueF8kfZ5DincS;{Ocr-mYYOd*99kR{wN8yv zu-l*)qLt&7Ad|bxv59HF97#B@wJ+~mdg2P@CU&V;sFQnf?|TY1a+2`J=X7na+&>)z zeeU6U>qP(S-q|Ls+ytB*;FzYpVmzzogd?BdImWIc2s zlW#)b99WwG(|-sk~Hkz!IAIhcWEbTRAUnJ4MR%x(Dgq(@EUbonn5K z6AYt3`t{d=UtC`&vbF%g4g~YJ(%*4SMoqdeI0r;n@niG&>kSKPEMIfR65e6 z*kf-S&?TAwm*jLlYJIi2P4cNzFnLO`dj}^CD3-$RPA)*&2Iq>yHU4)R zI-+JH14OY{+S>4f;aGka^CqTp!$p)mt^>>Wdudq{KV;VG%PneoZqQy9q(6av=gTR> zHQxCwad5wy^W6ArtcIn_1rI7td_Xyrg;o7n3neDv7ME;>+`qddns}2l_1C+mdk7IY z!F_R_l^>&d%rrtKA81s8tb!``23zTBg}Sz4@FtE)i76yr`;3Qor(|BS2+H|gE29>~ zuJqdb|KqS_i3Vx+XvGj((1fNGhZ%C$o*2iMhE@H~G@R)XRVRXWuEm9lOsG44MTy=1 zgR0af*S>7rxzuru=AeGn6&TEAuZ#z;MY4->cP*aIEm^?6cEaw>7(o|VG9XiA-8Mgh+y0*tgvppc!U zAX<7o>wvA8ZW)Y3bMhaK$O@8^clPI#cy@tan%s9~RI93j;5pV9?223)0^T>YpL(Kn zYG|^4m+!_x}Sr>kv5r diff --git a/frontend/editor/src-tauri/icons/mstile-310x310.png b/frontend/editor/src-tauri/icons/mstile-310x310.png index 7cda72eb7e05bf322d644b7c5f008ca07090e0e3..044a032aafd131ee8f4e1173a38256d4488fefa4 100644 GIT binary patch literal 10701 zcmeHtc|4Ts`@ibb>Bx~%nMydPNNE;ZWbagtqX}inHYl=Hm~3NLCxo$_$V{>o2T5@l z%h*eXm~a@8Z8RAB(u`$nGrspTobx&V{J#JF{`*#5nrEKpe(vkKuj~DOU)Oy{T{F}Z z-g|7XfPjFo{;!vf1q5~?p`Y)+16Q71c{Bum{m1!dgP#QiisFSfZtVu2e{}fO*g!xa z;FN&CgD?SsHE`*{gn)pLynw*eO#uP*X95B+Y-;6oP4LBc_GrD!0-Mm^7d5$w0s^N; z^)LTy>en|rc=z|TrQk2DN3%cCYA63_mUcTud+P zGWB2j=)X%-p-5PbqmZ}n2QKF;ks zyVvS_EpG1orH{Kr>9rwPAE=!5YFiC*3Q^LhOE*lU7V_HXH8(Hr05kl*KmV%-*rN&W zlg{c?crsgz+IUh0wzV53yRwa`jmDD;HLjHfviiRS(iW4`rC0BJJpaWg;p(!q{t**| ziDAOT8L@anZlGpgO3t(j;Yx^upEM)R|37 zyTEl1B5iSqlRa5eqwh&p)u&f$Q0-rsMpu#GYv7+;hy7Kvwg}}|9vt7hY0Ej)oDyv5 zOHR$*brAsGI_;%XA_RHaM)1GnXebn?bb*T1Ud52Q2WKS5-dVqbQ%U~GZ z>gbz1w6lLPZW=LhI((q`%o?7sMj8?e0@rw{E7FHq)kYo%8w$BfT7 zA$r#o6~|_bZk|#hUg;o8>ä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 E1#6^sKL7v# diff --git a/frontend/editor/src-tauri/icons/mstile-70x70.png b/frontend/editor/src-tauri/icons/mstile-70x70.png index df178bf964606c80cf62953f967eff9899f9756e..23f118d9f1bd0f720905630237e2803238f48e75 100644 GIT binary patch literal 3328 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 NObpEoo}YJ$`VZoj0agG2 literal 3419 zcma)9`8U*!_kYirFoWzd3}Y)>WqnGFT@8vf6w1Dihs_HzsPxZfDmc6$GKgWStx0XbD~gx!-s4Y*OA?Uqg_9 zx1W$K{<@oYdD6ZH`6QXV)eY?#dX|Qy11bvTcCW2@OpC!W8D7FxuonUyezHF9pQr)# zjG3tmzH_z>_f_uYK0vbBLq6xV7;S9}VS_s}!E0?iMaBOo1f^>|T%5qQ^T2CCBie3) z8pW*{k~hz-Aq5QnOL`)T`Rrksa8>q#=N0oExbKSR;6zny9bd=n+h6*7zA(HRS2~eU z7p^qf`3j>vJ2By)9*teSLW@p;n0QS=E_n%;$4fKFP3&(ABD6b z-ov##2*%`vc!%8H`Iv7LV^5hq!pXoq=ejiO(d49mP*?arKlY{{MN7*+@*;}17xbJ5 zMHpRsGhI(~r~3bDTy@>6E-CrjLBYln=)aZ6IJB`3bC0d-fD;SygAva)d@5V7ipZ2U z(98UFO%Q{Y5%W@5Y)dQidIYk1*+hJV`TPCy@-+?oskmV5o-m+mHTVvTKqIaq#yzRu z!XOSVu7NUo-ltiwDE@_U{5ATnd{7)J$SElLxugVTB&xi1BJSYfdzdDoSYi1JX?8|ocHhD(;IlbR450<9beG56AS3*pVlI3_D=Ar)KpG26+i#zLTNh+T=Zzr(0rqOOGy-Vn?g`$$Sn_O?8XVb? z={Y4LXb042O^BL3@tDvyfgG9lZ|6D?j}IP*T$nY31DBdGb{CYU*xM9<7K%RC~==)zYOJtZ#lFdr0gkj-F=}fn9TrKVa*?-<# zkoGW1-+gMUNr>XXzo~aS*<##@W4c~RX#3<(bPh15z@n(sCG5<;^PkjVR#wrbpK0+h zf(jY7baX6mXuqP|&vux8rWq(ra}cd|uuV|B#v)^>7`IpA-DjR%Argg4gB1}}`LD(?f456ZcX z`rZ);N)Xq#_fC#YfB9wX?fpbUe2tOtrpzjCv!*Raj;EdfW;?$WNWEquO}Z3ixWJ^k zuVgFVR*f=JE$Eb%CP1}((wQ1;u4^mtX@H+sKU<0AGxN}pi28N9)MJRFEMwDmh%kER zw%7m$}Wz-YnHaO$f5SyyLgdvAR0E zd9WKlgW4|PhjkY|H6MmUuy@9VdVh$D#?yatBn}Gze0aXnl??ge`rD#*q!?YRUO&#F z58E4kKdc!ENRz(hu?BtA2{-!gZHqh@y@YDm=mu(SbdMWR&I^G)#yXLvI(9w^Hzan~ zbQhQlcHTMpaH~ghIE0@1b@;t-i(N!%NX?U;9%~mWkJ@n5n4#P?709_`>GsVjmb(Ac zB#f^$r<@3MGw2BosMCuW;VNkTBt+w>^A)d+=!S(aJoELl0>KPWZxmGuTFr$z``8o@ zU;0St`um(S>EahMQJAD4FPv^fhCIG8pw@RtI{f#bUd0pg>&h2HP({io4Cm`CF)1)5 zMfuCN5^H!fNcupOj;_rTgM!14IIUabuOAJl+GCpja&fHqy6pq#MHDOI6SEk1TynL`jG3Mg|`Uh$CgK6z~G8H0nurOrn)SwI#`Q zDFMMuAmNmC0`_6X6KHegQpx?sSR}L)8|6G3M0mz;ZLo2?K-h59Hu| z|F^mPiK*N0OaGmFJr2En1IFiKrnLuJ1zHF{n0;Xd5&yc`2EVa^AIrW7M3&pZazgHV z-ygwGm!na(0OEcQqmfX|%z!k$8f9$p@j7Y# z)cGY;)+=0=gnp#S=7CMuSNxd;#lK|Y_@2;3&hu&?l)y(@V~J!-(e7=VD}gT)0`9&{ zh@rd-J%wvG1NLf(n|bmlV>~;@%i}!naVt<$ z1`kRXd-HF)j$!cQyyJi`GLRwTmF^b;=m40|dxM9Az`;gUPEHupl=y-#`52k-|G=ek aCbDC;@yezEU&GPU1Xx_OHmf#qi~S#Kl8q+- diff --git a/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png index 3cee859e7fbb07d04cc88b4760556bc2b43a30ca..ce40d97faface38dd7f2f0ef54e4e71ce58a8af8 100644 GIT binary patch literal 364411 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ԉE@3M{no{{w55zlRN(DzN51o{=zPA z+EQxw>D-ghl8&+Z;=8?c!l~4j_TYB(Ow{|44_S>o`Yeca`o??I09^e9T^;U zP_$Q&*nPHX#ErV-!{UK=T#qu-6!$s2>Hq73GE&z!FoS(4n)OKsVB}5d+bqFOz~$|@ zP7-vQp3fbJUjnBF|5z||J;mXaP#>A5kXtqrPSlKyjFTIKKLkGh6PdZe*Tz{$eUd-G zP5~K#!y-(3Pos~O`K=1Z+e%IiZGjW@yO|bnn&t!tb-`_;iE5!IvV)L@`(!`G>D6#E z6lFix{~AHCuLo5uw?rgz0u@e!I}uLZz(@_p@}loX`Kl>(kUpARYI80*em*bN?cnxV zFqQKgc(;ZCIk@=MO_Mca@HY$(B5*x#egc(TJ0;liGKh^U|A6u5dbE7*xVVj;5}ctw zlLd0fi{a)wB6`Kytlc4fm-tmNBTW&#N9jJ=I;VRAyJGFOk@)(&SAyb7?7{akHf>-A z1#gqlgKE~c|HHFwh0QFuou7c=ozv{Vo%4DlTNj|TI+7^|-NXTaQNVdK?qB$=3TtkI z49JvlnN@5HMvYXwXMe2wbXu-Q>2Q^glDKdd*Vgsu)Svt|dgm379;8$;3s|$lhROf6 zNQu(IRsks?R8#c$vHvExVGpkDZ#8KzyHBVX%VSdvaJRC7y=lN67Qva3#15Iv>~n)* z*_wgnt~p8{Tzk7bGZS7RurZt;%kFI7m={zD&E0+3Nk|e zZ7jpU=4(pVRdF3^pTH}8TH*Q*kZOUBY(fOL*gziHw!)3HM5oAs@E*jZ*Tlv_z9QeZ zDU~npg;TES3}l`}KYBFPpeG&2UE~&J%wy58>ud;^x=e zudMq9JVTgc$21VhMs?s!qB?>A!us70%R|%VEzwsXGzXADiO(=6$xuCD4Rf?R@ms0TgSLnhm$|mZAxZ(YyrEM5!zD zfg7;w_HRnfafeHM&RcCc49B1#6+MTII6qyEW0jMMtHpYsX24SwG0qtfybbz$`2(k( zCR``gt@bBw(0owV9XX@jgA~MS$4_LN210qR8Om$OaV^_2WxL*V)CBy-!Zp+SHu{Om z=b^t9jTeOTBE_U;kbA@Nk}F&sVev;a&(nTQ-8VMFlgD704ir6rP77EQNFD+}VB;U) zfpJJDu=K#qseNt~L&pP;PSq@l6BZjCR5&X%sRi7L0&YS4ggYV5Ht_WDSmiRKtdS=gX@0G#BPPg+i2=pi<}4uE$J#3%zN5n>3#+uvfet$oD~X zRm2SgVI6JsGdosh z!{<`?n$g}O3{Mz1x;|k`@cJmv2C&`FEEZo?TK$G3cpDB^WLg1nl?xWE;I`LuPKQaM z^Z5IvYouQ1jQt!jo0Av1iNu4ZxkNvZBmUu8WHfJ)ZUT;Wxc{ib3jO*C_?6QZ9;;HB zNw}pXUiq<|vtl+&J3*x*h40mKkl?F1U);|vF}!ZK2%7v;K_!`3HoTp@9i5vTN792P zvJIk%Bc5jRTl|dlpjHI`T9V?A8bvZ{fgoU(3llb+DN4Mbaa0M3*bolw-_v9!%vov& zL#%ah>3c5UqNE4UD0(dZcpvmIRgh_cHyvN>1{Q1hdzeLj;~y{Vd=zkAR~9#bW)qj1 zEw@f9O9!2Ve_28aEQJaRB+<}5)Qj`}{gB1~=(d~Hc3Io*<#TT5uI%rT=*G#MjFTlb zSgh_{{J9{)qlxf(;)kA`k{^0(y1U@FO$K%3?NlCTAAEe38Da$BS-vWXf0|w6`i2O5 zY&8|>C9a5>$1*;uR2oA_`WMtYdbhFH+Qq$wp0y#OMk}96Q*@(ZZuE5g5QE9%l6flK z%5_b8bDwtc?BFv*RYlWWfz=#eX21s zdICX3q+Z+)R*Gmnhc{mezdX)5V^E#Vl5tW_L51BO$Gfyf5*;86S(6Jv&>ncx|IpnB z>XWf&f9e1+$u}`mt8rwuslL9x8qecUsg!Ooe++lPKR?LC%EKgMlGFF3(gXDQk;->_ z;UoN=v7*U-ov)S1+SDh9hnolF^QH#MsUVMAo+LP4`a=lVs+GL!moE?33JMBR9}q`` z+v)jdb|+Y+?2AgfuAO)draA2BpRmq^4uMb!Bo%ye+}_!)aTkHeU1*~LQx-t7@?p(G zZJ-1d!~e{2YTc1YrgktdXAEX8>=eLPHQ*!+w0v+preM?@pC1c<&WL*H;>7^v=%`!& zBGn|!7w{%B#l*yhWa5bA{Akv;wL8jMhOGIYrp{5FVfg%dU{`0=5p!#CD-rzZuhj~* z%yu5TofrIOcOC7*9RiO<;ANOpo%pKbj?}?VPA@{AOilbdCjQ#M%{8_M=TBElSGPZ@ zbW8?#sKv3-=B`DqLz=Wi>>y0JeO(;x^gm}Qh(u*W@E13$TgN{j2P$7Y@{GLlh@gh| z(w&~Rzk>a|>9)&VT^@Dc8)qJTB<#t@-`EXZdOJSRu|ZRCT*=Af<|_PDg~I^Y0uM`jmooGe9*m+Q>J z<`Qt9=U4wCxYC-WJlV=uih5b-E@3qHhel`e`H4_QRj!kG?|##2qxX)R#HU`$5{MT7 zs{m-*`7M{u(E(084E9flrYl7vr>dIr z`Ze9pnE+03PszUtub~ zr<`qjjpmByV6rfFQ@>pbJ13){l9amqQ_I3WmzNs$n5DDVc5Zt{Bt z`MU2H3npZOj>yO}-U3J<3Ik=ZIIR(1snGgVkZx*^ozd0;DyMrO+!R5*qO#_`v(R`>n; z_ty+qdYDu(`jkg~Ed1_fJe$D<=>Dn?>?nqtwR@?z0; zQqhh2j^SV6iP@ml3^32e6WUSm+8jCn?1vS%gh8`{SEl=ulYpLBoGaiyS%enjfgsbb ztd=!nA|}hfsN5MU*9yn!!G%7)E|h-|ap9YJvj$w^0EI#^&^IvP{d;jg;{|-LtoBxs zjU(87O+7uCs!&ZfDZNz6s{M#46oJC13qY|MP}|d+_58oxY@{;m>|KQfP4K*igk9vG zLNR9s17==rnv@XVE4ilRoNvd>qcThfQ8ac@>ej`%JI4G)JTaU$2p`;m_)x4)uAezy3u{oabKL@1K`a7iRi(5C6f+rRj$*lhs~- zNZeG#{&mt|Sx%oj8^^0%T3U)TSx)XO9_FAyqMCT+zg4CiR7dgeJ;*D|T{Ol2z_PV_ zB1vE=YrFz}IT;BwGktzELxG|xc54lC{T})S&Ecz-?g&>m{)1oG!R}#5d7A7(3gx44 zVRi+sTwj@N!u&T_qp?pz1<5W-YPf)~Fzzeo;g5T$1Ri(QU6qP0P5D`78C)%hR=94m z{1HYh(X^2f<<{Md7FU>f2d;{31S>P*LqjR}8&(bn(0+nZ2q6Y6ckz{+=-Rg690xb5 zo6!-f8!VjuIS!UnW3Qd-xSNgSGT_c+*fdy%u*mf<imU%{sRpK)7LWLL{w&`QR51n43dgJ^9c&iX-8!3`8ownxT7h#2Ku10&OQjNb2X* zl6h?ZjcCCrsMS~Rcqmg1jC{9+==JY!AUHD?X1ki6sn7m->mBtqVbI=37u43?j+@AJ z;7k}C4qcunDBSySW`k`TAn0FFL#e?IoJFDmHzS3-V3thg#69)Lw8ALI!;NQt9GS(w z9Z+IU04F?A4;RU7dFhI_^%CxX43v0XM^+xq)Uss-r-9+*=R5A}&!yF3cRrCLB=u{DLdVdEOT3eo?7yhpgUu0w4eD8_n|oi~GCBV5@W(qM z9LI(ad~^j45p7^zZsPVvM(l0?`P!YN)+xC=(ZKxD8Jn?kY!LT@uK&y7t?4JH>tE2c zFPMMTZ4Wg1@M8!6`v?oT5RA<}o0mt%LP_{`-11Sm z3>`Xw$1eJia03L9_F!Gi=x`+?r`pz`eQUSS6B4)L6$_F#{&g)I{n7WZ2p$oeHOm02d^HaMN zw<1*fT$~=;WpP#@W0L#u1;v{*>rdD{&zqqWX7D6x4>fQ-j;h}t5p2VxA2uLXQ9iS0 zszkpz{>bqtZ6+#&wu~o|9?$C!cq{n_HTnGe2$~FxwF<-`9Aw$9VZ(paHK!hVYchGSjB= zCY!`Uo3tmnGO8yyz8^2(Ej1#gKM(v>eXa> zHSvJ+qGf`QGPu!L0W| zxf}FlxHvz>>-y7S&sew_c_N!sTi_@9sDoEYZsrV4Dt(ILy(c#>4bpl}3}Js2h3@+i zj_c}S=zi;uN8=gWZkyRT{gexL;@t|Sp1SuvA8Jj%lOrBsY45Y+ zCMCmXI>JyJP9iFmjE`o$wg1wJLxN)7(UasV`rY{a*g(6E?N8< z$?)ZJ_A+@p^*wm(V;?zTY6mypXD`!6)|oj@Q6hOHYSG3DGCuCWeCauZlU<=5Urku&5mvF#F{cRfuU&cn!sb&kTzCq%qg{<1Rx9x(VGg&7Ng>lk}FkKiI zW_@kUmw4=(mno90-=`T67!0*hbY^a=GlB8a!IIQnv0j@~+-;|N=;%PC;*TpYrnJlY z+}64i0gk+wK}8Z*v3WFWh6=%-1c}N1Ac8Y}S^OEC z9DuLs6TD}8aB$^_rBR6oOVx&Bjt~x0KXzbwN#%VjPLT8*hm=2;?O+v_+$P-_6l(bt z1(RCWd+#*(M&-#L4|6B5;^)u#*_;je$kCasWwcVe|QNt`QeFohZC!tLCk~Q#7@F+9=u}9hDu}mAWBuSq~3Qq|AW_Q%xK$ zIycO4c)id0F~VXEEQ3}Twa!~b@ut)Yjd><8rVkPTCxDWp;^RRoC6hnIfD^jT@OMQ} zB%Co)j33IK(N}>R_B>N0g~<~cC_2(5@t5vep*UP*jEf*p1?{2rIuqGZrV(3x`}%KSRCwpn zVDwg=hwl>k>POjm;Y-3<&yIEux=GNFs7tOR>tEedJx0&h5OrW>)vl9ela`pJZM@11 zocqU~UKU<+9w)bflwTUB5Q5uG#&s}R|ES*=!I!ftj^acN%EHJ6WUtC^p=qJ9mw(QR zkP6%zyz=N8a^4O1XrVd=lq#acY>`MrdN4~uUs~T|nCL;k{=)Pys;O^BHqQq39427p z9?u`4SG%rCV(J`d8oYnwsJY!&j}l_Dc~{Pn;16bB)2c81lar*_Wcqea;T)KUoSifi zl~aWrV@bFl@dCG15ukCe)h;TuOWM3D%h1*a)`rng&?9{Sfjr9<`7;*FzZpI-qQ2Q8y@Pdwyl>2V2~ z^U|Rml^TG-2s85k(4i4O=$fPzoQ^HEnFX5|lv_x42d756Kc!)rdwq!qhyO-r&wqcs z+r1Rr&Q*pk3?4eNI#S7RkPmtOjIwrc-4v|Ue(Lu_Q>~4z4QSh9EUo)g#zMz-mOblb z40h!uTQiyWY&yfveC*L|V2W}F!^+>b7cu*&J`D~d>}u_w+|*b4kvqtH7W-IwTpFc{ z9NfW-UY0x=DBZ>*;C2zU=X;+vggtpnzO$npL%{IDvaU39!(ftgUKAFsu-$cCVQW>5 z0vtSirq?{RQ@y4{`>Eh0|9YxX-z6i-nz_&vrfk(!skyEU+Ol8+d=d+g(a$T!%=wQA zoqB#}E(52CTTV47dO_r$0CzEW-t7w*HK_>Q=#o9In-$qqILW{jQLK$5rRw;JG5EZ` zHOIy{Y#De1u83U()V;e2gS!&*fEh*17>*N>iaHS!`l`u(h7)(#-j}E!+T=r}sw)*| zh|8+ualIFB?8109@i3A+0y)K@Z+0@cRo4x@TBP_PNhl{ga_lB1l(y&e>)Cr(+J5jo ze9CCfl(yx)lwh1ggO3qf>q2)I&SWsCXd+*E9^?qn;{XSA%DoGBt4)=stNH}T+)Lw1mLsgh3)N@z1p=v~u zyWBA%A{WPu#Lc`V%5g6)E=~KG2I+hDh91}3Ggg5=NxD!7(~^5o13?|NQOq04uG4he z&)AZ_zs~ATu9{|r`wM$Cuiaf>^)14XQqvC!1AS(>O$v0#78((v zB`p6q<-lVL-c{x>K;1aC1VH^Rck76~8y(V^UnPyos&1Y!-!ENvD7fyiyPzF9AkKka zWcexAQ$f{|)%eaDp1_Eey3$3hu;}fcl_RPDl8Q|8;w2z0K>B0zx#$g$O?;gcfx6k^^jp!VW z<2>VpwOuAEvgwC;Vc0GosRf@C9NXax1K+8kakksJIQp}Re4&u2Ol3( z8Q!v1xe0-ue{M3onPR zOPec78)JY`We`nWsb(KVl*haP7 zbxw?Zg;FW~`;vc$wI!lPNv56iEnI0z%Qw;cA5&Z3{Pa}qR`o0uFD0dBmgm^mb=+Il zWs)cI&EIGAtmE)(UU3b44H#r$dMJ5yq4UDPNJ$D;gM zZ)&%l@Qpq1szH9>6h=sz9#We5SPExTt!ox4NLF?BKYeTlkC6_EPP2i)ax1v4%;%u^ zVej|{Wx^YiT8L70raJ?Q;%2NnuEOyl2;;=E5SFE`i-(|6OkWN2h-J=qW=BQ>n*9ALUtHuo+u9^=3MH%Qnk;ib#&Aamn8o$=tBRM@&c-*BlOHJLkSXpKSrO zo9YAJCH??R3s4oDaigh;=5M*mPvCO9yk%18jZ0c|gg>8jtfiLt^RvZ=ts5e2L5tdJ zoQ#e%8FwpOT{?GpsE488z7NSCNBE*!~w8MV#HT_G=G_POSnn8*Wwrh5so>jGgZlquP^)6=IqBV(?pJFLr zjUtZ*^Fi0Q%`eyXi^s1k{Rewp#S4J*IQ_-dXxI{SWK>X5px{)=jB^JxM4W(jl>;0p zNz4lMY)}rp-sjXY4H!So+N_7cEI3V_GubkX#8N2_A{C_mNu3brkDV^0Dbxg_Tdvc&&yp-Gu*wa^>#yuXaf;{vt!-w@b{k?o&;p9chdN~n>}51I#z@4y zlS*Jn_AESnCh!o8WlEkr5gOJ`G{h8`$M8L%o_;&nQMc ze)j3S35x?OD2__w9VZc= z6aR&BB2ICF6*V)m&xehu;Mt)%#hmRJ z$>}BUrI6t))0<}yqC1G$vYhsS9&#XMQ_=qHt6@oZjBY?!14O9#g1iaHR|Qb7wM_J1 z%ge}(ECXy~13lfHsrUJd_s5F`rNj6f=-504D z*!EXR$B)H}wHm%G&MdxeCiYNkIVLVL#+nsV9T`JBA(_OGvRbHi0AW*P%)rj3604Xw z^%F6Quk_m%ah)T)2lSgAWz2n9|$VHe6vuOBfVvIudy%J5fxVbbB;QkJ^$!i`Q3 zL@bo(S*>|6#ZO>BFX3YK`d3o*Z>#4FE`VnS4p2u{s|I#@*8Y`!Ee9jk`qTekE{Utd zc7gqxHm;vgEvMLb-}__F2`&NFm!3-Uu0>u4vqVm@GEQfLx*25bJ|;6GKD$rpIf(1) zbMOVh886zEBXBbvsY*>T>p*^V zS2>iS-)A}sr*0g_D{&MMt}lVq-?JfZ91@;pGjK0(f6kR>Wsqo`6Qqp1wBLN|#g1K8 zL8B&sSp~yD`Rlg*l<&{L-+4zOVxObiqB}7k+6+Um1%u^27g$`?#LdqVIHdi?`=F@IOv|QyIW=Le@-x;>p*3aJ=G+vy?qFpA%tP# zwq;&AQK7{AYwbd?E_5&_=!5O+4P==<^i zUfT?DS&zU;r_hLoxb_nyW+8Q(pV0JdDY1EGI@AKp~ z$vT9dM8P6XH6NNY|44>y?$yreCa47768yRBx(5K=K0M5N=xVJwF6K5+I){R%pq-S) zPd@?hb?v85l>wABDi7Wsyl`#hxoT2+36;dC)peb61=z9ecdquehuU9w$78bVM}|9+ z)xF&`+}`P2gXKm_czf!`?NP$pi4mTfn;h8&L$d_+wszwUO$CX@jj?tKuwB1QEZ~R< z9j^&{8-zBjyCw0-xPsifMT+aE<-b|f_uiih7sgEeP0ru{K35Qy@=QAOyBX7Y_eMQA zA8z_a+?hrCRHF}5j9Wt^PGvT7C1)#oLR-Qz-WGTfD3)9UwklKN?%q#VyP=F&VRY<> zg~6VXmU<#NO!o(`^PB-@QU7J^YxRGlbs58$ZVJVk$=bBQx=h&3W^MM9UaQx3FGKOV z^2cO@I~T(JUq9!BYKhN=iwQH#z+7NvS=oynEvA@)Jy~zp_R1SAaYslNvf4``#fQXdN#_c{Et1@W46=X~-a$Q8t z@~(~Gu13S#MjG-;PN^8!v#eS z;#~C!)xLt#IAwO|)#Yera2kVENiHXkOfw}04M;?3ef!~pxaG>1vbnY`vD0z~XPi`Ub=cf*tNR;$=tR~{S6CwSq=9V2kIxr~tbUpXlDj@#eA^oa4i zk_!zS2=EGoP#}KnoC3s4K!7j?~FVy|@e*3b3a~QGxo4RC>MfLj0IjJ{9!(ew$(}&yr43sBW6>*>S zohg+2y0SB5lEEvkGTW!0C&JTV2e3%k#blu%rB|{i37GAEJ~)?1l0z47fVhRLJ(zfF zhVF=SX-K$GZLIQsb2fCGY4sv5j}*Dm+uc^U+mOKV04thvB?>yQ*W6O~4o*K2=i5_z z)BO#4_)s2>2IBcl#M0&n5Llmj@h2ffu=nhRw#2XDEg+n`^aa%Zzw?FD1C^7p?IUR~ z{Mi*6Z-4q5c#zwHEtYrBt3d)AA}*WMtK!*_oJme5a@1XGC=XBWBpP~bYk02utEc3iMqdB?p0A~UN`rg z=E#~XURt3)3GRhHc`O((q(tmtKK&J`kGr^n&LJGqW^}md zkkG%{Mz8ioip4=a(DMQ7F~7zuR|*e~a53Z8>(KuR>A$6}R0zMvSZP(fsJ`iq9PEwTLOxUoTrt ze+9SRwMvdK5Yt~f1O1-r;EdrB(f;qDZyW?yE_fWSo=J|<$C6aB%BPO4aepDEH0d#n z5l`Q`@u~C)&d}YAkq+jwX{O&L@(JZ%D+}&zQl0PBFxFYC^lQgFt3woD5e!IfpJ_OUdZQH6FQ?|xTvvkD(rs${0=47# zFTdNS1b@?EuM8P2;kU7NT5ZBOkB`aw*?P;d13 z8H?d>FYQsIx1vhSPZ#G?I?wqKMKFm#9uQE|-9-mhaT<~)qN`O#>4b-S{ zK}aA(xo{xU1$REL1YSC!8`XZqVNdtJx+*GtQgKswQPK6L`N(tQX=YG8`u8tXAN90l z5O(r&&Yyr~^~cOoec$+(!y<7i-=>(_lQ~X187C~dD(YpZ!y<*HDMkEr9I^BIm~rYM!9N^S*Kka5~7Ws6rUtn5qJZi z*3d62epu$1Ht1tt83Z4Dt}(SJ%&A;n@ZNZO-N~@@-Kt(vehT)4IN@b$&1JUPvP)y4 zPuTmsqX0;TFi4F6?8JJ<6MfX5Kh4smwSBdh78_e9i86JAgOvx? znW8M9H&hBF7Wf_KDm~No87l+6^y%M3=%n}HfZJ(#=Z?=pX49H;(T|s76i+TKIuo5a z47$Yp;@VJ=CSIpDR{%Z&Jl@SyYeWXJ2(Uxw5_TvC6X?13GE#ftR3=>rj?jw#Wj`05 zYIqTSLvhFF=e5>IxBfHdc`LU?Clr?M>nMmXPo;hhu*Labb^c>+h0+N>XO@bSA*vzX zU$k~!#PNjimY07d$3$)#-ZTn^s3p{4#=nT3{17GEXK_|-e{bI@RuOu#nZ&9DzS>GP z_`l2x-wfm~`|r1_yq+4X;DeL*+QY)MJ7ljh>?JqVxgFH?T9w>tfvc%u7;maV(mY{m z(i~e*d70nLu8)U9KnfP*hmY#xFT3|mjWjm$1LTdqn4t9d%*=Ek@}p;pzLL%TJ8fWs z%0$R^qc7ln4oE+_xMj1ZbT#ke8$1IHHf(+=NM`Xz8WD=Y8XgDYmwC{P4Wq;nX2^q; ztyK1G4+q-jNxW$m?+#iEw4|pN@p6M%wFHPf2x_#dIu?P#F!p6b!HB|c1o=>F{xvxE zYo`vjA&b#}iG7fkwnpLxC~Qe|g;?+X?MW@S ziy%k|S_cSC6t9NbdLXHl!b7GZ;9&pzNz>PoDccyDzk^l(7Y&we0TI546rHJHbYwsk z64~y)o~*S?nl1Sbl3K}4VA zFXCrVLI?U%i2+xs4GMYqfdJD8Iu0)}^ z$MJzl8=kFuLlO|*yYh~S^64b#CrtNB)xr7!LmH(td`{>7iXIUq`oKwjAg)%bGFNH71{$9bF|A9|V8GiviKR+NE5>6~Y zDA8z%KLjbx|L=c)u!L7OQiu2sum0F=kp1CmtF{M}|0qTZ4M?yYfaHDk|9h>|x))x8cNs!(DZ;p{qN#msk^idJ~7=55sXL+eXk5N5&81>g~2@?Z1LMd(R;$#0Scz>t(pfrP< z|2NR=@KlQShVu&{{7_b-5(x14fy|8XuwihT?hna^a3M78z!d2V4kIwi(2lQGsiB}! z7MOS&D6Fs{CWi;#op>s5@5!q<>DAy#Gv_CizlL^mADZIoBtYjr5L-DM{Wc_}jP63x z%>x3xr`NIHA5=x`H<6@PFNoNVw4AV?YFUk>rZkM976y5RI5 z$8r$M^T|Dcrl;cbpO}{USSHBjlhygzzuKDTaQ)3}Vndpsj&$onrax zb`Wg>ruTO3`T1K}%c0Uce*d^za(2)-Wk+mBmu{T;vom}6TEI+TRD<;jVh(^&^L5-8 zVDs=-?I7lyi@mN3D3(~rDW)L$Z&wW1alekdGrABJQG5eeHGD2 z7ifZ~VQy`X*yb?I!Jk>xeeJID0JA1AsP4QiW!>+A;n*DMc9^~8Sq(<#k*tI~&}4jKNwqY_eNrrh?MsyU6D`@gl@ zA>8usF@*#%P&XY1FY(S`h({R*YlpZ69(1J80(_cu(4@I2JCrcc@V^h$yNKsmTmE4- z?c*{a)oR42!$WA0DOzQK`eD*1jNaFHX9X~^F?1d9o^|JgpYB2@Loi7d&4n6JSzMD< zO2)Nuuim?7R7;jmqtIS73qc#w_zD<_Q^9z9?@DZjk#He;0C_+mrOw=#~gKGl7?uRtotC{7UtJ1%T z{coU)jnBFwk~c3Yd|6#w_uxhoi3}e|g5R>BvEVY!{#-h3%zIM0qZ>j^B@e>>6WVxa!}x7cog2@sCKvfCqak}A%Y=B4LK20wy~HE2O00Rd!*uH0?A^aJ9u z;8XgXQF+jeOP97-@J{~U2r+@cO9PpkML7+d`!(A;AJVg+h*8@6I(payI%cR?nZ1BN zmGj>;I#7mzy9+1X(`^Y^0=m!q{`YZ|Qg)<@5Uv zGd%O5yxCVnn4@47E}Hi8cqdX-_+@i{5bX*t6s37cs_TA~{6nYrGMR}hG5hta4AKKv z)V+S1eAklrX}zs;2pOlIu+{cj;f&;FHIMEMbu=1h+L=y z!LSd2BtFoe_8Lr+@nV-M-0VEuWb6s zKWC!7M%`Nefp9M!HNHNwdPak(+q*RBDkrba2gb{TVw$oN|E&OHIztrFWVVmD{HXgK{Mn_g7nRl0Y4`KIiBSINMq2n!O$CV5y@(2M^D zYWNJG+-5?ZKsRkB1cJA1xOQ?5?RXQGKntO1#P~O;w+D*Ar=JJB zjZX*`W&4dj45NfB8{6^yyTULlE0F{@$>|+W!b51Dm zt9G_Y%D{R5hKF1027lVW_uj3%S|y1WqV?-#a|YZ^UhpHxJ{O1dRlrV$r3Wrw=S@xQ z6S%V6kdy-}?imOKZFbU=`zBx~vCxB;7r z!2nP6ms^e(+?2rFNkV3L(Wd8hzd#lBy0=PLv7tcAcFxCci0Z(pUz69&lSSdrl$HoG za*q|5RwJ`J&3wGS3XLv$=>q4c496~ zRN%nYLoJ&pq-y@UGN^$@enu#x@1@xq1C?T}p@0$G3sd4ZucpQBEryGI?b3C z1;OG|%N})0s^c;92)0%2B zT7dM>hemCpm2RPzXU%LA7ElGYFiEOY*T;88LM#zn?5C>=^I5etr#<`Wrk1zz%r3Sx z{`~XS<@(|SZ8#C(fC)6Zu`BbS2!Y&1rUZfj4g4;3;a|^0If)D>%}20><4P z*tg|~8__|M#syD^OYt9WACQ+>GkCM&)*2P-${Gqmd1FBTMe7hr<>#=-L7faHJ zLiO8}0m9BiXcELtL17Lws%}6bW#q1+j($jAB7^{e>got!c9x3&ydFy%Elv}dT$Ntj zDgA=`AzCM+=!3~`4~%L?t^KXj!4TkqM@b1D8U`ro96dIO8k_ziW+aI2&k}Nj@c!BA zV-IIP`>@WjNnkop`&(Reb7YFXLZOhlxz@AXukez5H3iWz@Ec3 z=F0Rw6_5>Zgy5c5#g|XXp$EKAK*3jW&2DlR`Q0HP{LqnkwOyYz6(!;NhYfvsvVa!| znMbM7pM9z-uD?z$v}FitK3i#K7q!cGoSf8hHw?vP!X}gdDx;Lcmk_Tq@~$`#A(>4s zG$W32`DNzp$L#Z5K*lejhy!4>Q$dh;w!P;hslBhe`#Vn#-kT{ zf`Z^@7#Szh-X2Prcs|i+D{l{oC|qUSi`|8Oh!?QWzy68|W7qEerk9jBfj7?vCnbgC zH~`A}M~z71Y1lesL%1Q*RV>cY?u({YWt_`#ff;h#??BsH5+4xM+slJI2TyGGG_b(| z4XZVWKPaKckC8(_^`z!;n7;d%ip<&vot+ASX8r2l#}Bt`zGRTJevPs}^^3Js&B5(4 zi&F4r8PHt?%Eqh+%iW~m#;e>$OWe-8u&0p-DQYG?_WobcLf8TrxNzvHlvh+ptzDHCq}~;b{<&ApRH; z^4JLC8QGB34jd!&P=tj&dsCf#_-ST?G?XA9zwpjuY|8g@+ifTr?GD6tEHj0`I)P;b zUDr%40^Sk$?^sEJuUFY%0gRClzvYZmru%8hq0aavXg>WN&{(Tpxp8^Q#2C-$zd^>l zUGGMMd?tqRoZAWv6F0l-63;8L>L;b)+pVk_*!?Pq*gKp0T}1XoRBuA_0UY1QN1LAj zQZoA832h+1Mdmn0MksrH0_vzeFC{Buqdo)ZUwRIR0XEqm9nm1p^wPh{uHgT3SnM4K zu9_KbE$ZcJg2NS7kIeV5CA^uq+args#7a^A+YDFw={Kf6*((blkA zQ4o3+N_(wgK|qtdc4SK3H^k96}WMhjwsuv=+SbWK=YejvutS53I*AfKk{D z(w!iWs96F=mQ0mgGkU!NSgI)v$_UZ&iN>JCkM}?MV>mM-z+-z9H-gxP8BsCNeB@`Q zQxTd|f_ggX=b)9`oJF^`aRb@FbcMR|b;S38mQZO`RnV#eUq@ZpfbX}NpDYNc9%xfV zHE#`bV9u&bdEP5L7cQpkUjS2dni6z9B9rSD>ytq8kOzGXY>Bb@<;$hLqXv7%INWY! zL(+!f<^bUEeXQH$45i1T3o1Y}cY*AUYY>bpE2R)J z-m|4O?>SAb%pPzyAT%7WiJ%}0+P{WO9qVJdiQ)|>lyfl#lyPT%BR2XE@{(n<2N zu{1UYwa$E&LG2@}!z!|9(iysOG>D~tS#c?_07lh1yTQ3ohhf@r=K^u{B_)-QtD8pn zCghaNw7f6~Z$zq5Py1lafZ-P3K;<9OQnao9hYS-4_nbZ9!SlQ60GWH236*dTgoLT3 z17qFpAJxC4;fVk4!0qFu+Ym9aP7jiZmF!T_`si^v_1v4+rXXXf67*N8q?5;2nw@{P z>)@hZgVu!vwdn+ReurTTu%Ny1elJFowoe>PNhuh{Fm{9_D!M^ zafsrD+rQT{yfuA7+{eqr)KZC6>DA#(H2AU3u8NrjFrK}TS8g7o{^ci(kQkxM=n8+* z8kw+J1VA6-2Fh1JC~tTLu6+*`#oHc6VSm7&hxav|*68uZl%_UIA4*?o_U}>Y7q_SC z6RnMW$|AfG3&Ggriveq@gm(Z9kXTv0Wql)UqrE<)Kd+q|KaU1Tp+~X!d{248HlriW zszwShI4(9My>!=e4pyj^x}bKsPmIzSwN^I z8UlG*xD52*vE)#ufc)sQZx}D_JmT+YyCCJ8DYySko6hsJeBjhI&9Za8xBhps3=FNu zP{!qhNDer)^%kjXavPK1xxp6kIiB_IZ~QUp=^0i$^cU8{o~v>-Dm!mW4aIH24+gTP zQQ_Q|E_T&Ju_Wv^3~U7v2RQ8g>Y_305;ho4|zKRP=< z-pO;ZAFm4tp{J7F#=I+GJ4_;)lQWs0!S&=NjF07^p7zkNy}=5P^Gqy#h=TYp7-$O5=fT)2mH|nT-N_$G4JxHnD41 zwOy_n?Uu7HYAIjI>{fCJ|Luy}D6{-L^Eg)38rm2sU(|In{+lCCwF6gi3EX=nij9NY zn&9H$9LEF&n(j*s4~1G(n^LnorO=j3$3SDFZ+PA5`Oweyrl}=V6U`8>ebBKl+fsAr z#U}u&%1TtFoY9w5y``PZ&|x8{tCI_p1#3N0 z@YUv~<^UG8Z{)vk-1#h?E_iHGyIGTr&?52jin`JRZE;j78brLG=cxV5+zm%Nh^$3> zZ8%WWDk!j>!9cMSP||h~5M35n-IMyV+wz526;eYG2leOMATNZP_9Z(;(nh~V@B`cc zh@zMRRTQo9;6q+WIoubzf%n}LL4>pLG~ItEt-hJzihhOzoX^fB5Vm>Fm!32tQEdih`KO2w7#TTy^>c;J2xS<@zrjrGi4sGU9)ijs?_gUc-N* zDqfX?xeoDAt(^_c=Xg9Ux(L12yG!blgKl0en`22O{{ z<0Ys;2C!mScQKFtsB6S~pqJq%a#$gMR6(T{y!(2OoD*{A)!zH47!5{DjN?@FRrMkF zGSBpyaCROrlcePI;U~b=;-7y6O?mC=uD)}>np7r@>QxYr+mux+eDW^O6lLs&)yn^} z^F^ov^d^di+!(<)5A~VuI4F!9ML!e|bXZ4=g8TY>46s)1Qz3HXDo?l0LCb~Z!N!h{ zSn>~&8D-A&8qUk>$mFOv(eS6BSwFM|ZFFEbYVrCAIDY-8))V^AZn-kNE2#&3+@0jo zZ>bEEEL{M$vuuGI7dhT490IOv+E1!yJhwnP@*_EMmVC`>UHeyc_$gH*@ z*K_Czf4QwfM#A7wUYgEtvuCBf?y<8fXt6%o$UN5MYeLcoJRC=5CEu*olLZ-K=jYlsy90gD^m%^&9rxifQ7o8h0!`+d2ts@(5%OAbtPht0NyidMI(XpC zos~btOQIp4!12=)u!7aKU?ws69qBbKqU{llR$g02yRHaNJbeH^G(ryxk$ zyT22F2Y{UqZ2q<&W>JRktjZZ4Zn`r%(&wzuzIqdh?^fe}2(`!{cwG~6+W|$L`(Eia z94N?P&&6cw&q<_!HgoTTe^?L#GQ5BoN6-i!_&0#F!M{~c2d5qTVTXPb)f51diM&J~CPW(%&9t}@u(_U+B3820$oK7XTx2-I;!9#>X3@$jiks-+#0u7TJgK^V(ZxPHhyD1>%gwq+d+9%IfHnevt?Pob&~AH~xjOIaKGo`*zeg*7%eKj1n75{6EuHqy0O2Z#vAjaQ8ry1ahKQ z?+%(t{T3xT;yji4#|s=_$y1+aGh$HW0YmB8z_2?qBV%dY%&P-qMoN?HyGfAwuAc)3 zn3wnw&`F?mSbz57Ci2H=Jou=u-<0+NQM-<9BA85V(*g$;^K(tjRSpm4bmu9qCak&l zp=_ETO#UPPaB@U>)BYzwIjMS>ybeIc zs9Fq;_s#9QjJ{2apxXg}jq@V#Ki9hlkW^O_oB_8e@OV3p4fq5>GQ>JW`@dZZ3}y5S zhstYBONCJEp8+L>{GQP&_<$oq{~PB5p^HxsXwc2vU8aA{1H0EezaDekj7KcBRT@@! ziShvRQht8kLO>O;3A>}h0(rKA%J~Qkd6}+jwt~<(*_nfovma*}jb~I5D^mSI-^swd zkZFZmijvO?xvT8DN-Z_sa)pmX{+XWWzSY$mp~^B*(Pl(1rXj5Lg7;=fhQ9H(VpZpQ zO44g(jlb%gzDlp@yp2NMJ+*i)tS>r59%K_CFk$BWes7gVo!0xcn>3(^n zq>S#Y?}8sa+Vi*;KB!deYpu`|@uI8Y8Z3tSCKwW|*qO+}Zpz|g+4A^ba5Wt1qayX~ z@)k)Qmck0XSj$j?=V;epqb4UN^cOJ5-oN_NFqu6apG`zmeE~n1_QcTiQDS0( zvXMX%M(ctfr*wN4oVH6q_2rr*kj&-Wz9Bp0<|~Dv=tr@S1I6z{Z7!NT;Kt&;m4Wtr zn;6=@R~X8grb{I+mBqp^IVwu9Juy|2n`7MUPekUHk_@bY+Y0d<<1vK(3edAZo>i|` z7=@grE^uzEFg+#oKcoDb?dFsh5*j*J_a|m?nQW$O`+dh;S{%z(qTj3pG9)A<#^~;I zT~Q=)Ktw~T8*J=iW&~r*ClAl9{*i?K0LQ+8qKn`e`2X=PS~lB4JRfg zCua7%HQW_J@E6DPD)-BD^#@Ox5czl(qw6+(LrAdnFb>#1uGE;kB6=8&MbP3b_>CVd5ei z*bxr^!6Hix$tB#}wBI4k5;?25lvULvwv+S$elb`)mU&Vr88IE6A?Sx9F&q!Z{F7>YQN(04iChkk;O#sb=2Ic1@-;g)D}VmYaAnXbEGC;~H8 zczEE=u|Bbq)xCbFU@4n-7~U~D)Z|K(DKy)<<1LCn7i_l4{tS6UGQ#9y$>+pjYQ#ssSH)x%3!5eC9dG`EO(qRw zZF??Kvg+j)%4qe=TK{b`!q%?5q_1**bW6)xKjHqFIg~=7C@?%!wy`^{Y?uswu_r>s)6DNlzQlM{4ojq& zwwKMWExirB6$et6;-v=YKFe64$?=1Y9+=INZ=Hd%RaoaH{2H#7MtTA5t0*-?Qr!z5 z3b@@G8ykCa7kr}g1*OAKR91$&5%Q9el8QpV%FK;?S8!cICwet08qYy`W31YFa85*? zQ?~N?kcRo)QPeZsCwu4*o;Ibzt^xkkpGT3GXy{54+*ND4IQg^?U+e|N?%Wxdmy%-O z*#eN;Cl73RXM`*7ALQY$cqY%YKq~LrcCf>0N20HYb0aUt;R)F!!5^k=G5v2To1L8Q z>FDgGCL@d|jg-iho$sqn|gni{2w-8-WiA|YMO z{z^l~Mt$&JD^W-`UY$!5_73)%OIj5}0fJ1x@MNV(i$VA$x(Z8g;27@{T`wXHlzz%FPop#7KudHn2%x4|Gt*ZR0=QM*q)6Irv~t$U>J2bZRnX0=;L>|DpT!-Gk@87EQN)r zr4c)l6fBUzww0wNS2nlBQM`VX#E1VyiBg+?BvN%rv52TebN@_E3zisj9Mh1}3bXQuj(xkZi$f?Tj#EgrWn;K2(VIowFb@uaCxXYa=}q@sKuH*m^g z`lSO!@p{EsqfvMcjF$Vqt_9Ol8crxwthj>6YTX#ooQ5L!8-;*D)(X(teG>5TME>s^ zJyl`tiU1?dZ3+!EH&PH$y@?SPo}Mb+E7CI$pcnJns)Y}6M*6DA9xp1l?j2x$zucnA zcntm4;fOmrKOymP?snbKcNLWncktQFPh;?dCOR4JGA!;7!H(|}KXtePPS2@BKwtOq z!Cd6b)nf1-+YbL?lH%eG3k|aC-PR^J+G0m7`*l2APLeG!< zV%_$b;{9(4G>4-R29fWZQiDK1MDZd&_9lXYQJ7mK81-T@JzloXThFU=v`V|_3pPA~ z`RbNRC)_{sw$tBc9oQtW(_xE-EbcF|^A9Gy%l78eRP5v_NwMNbeFP9^|C3fEtv0oBM@Rh6DxWF(I7sZz# z>~L(r0a`3z1;&$lp}I8lXok;xjoE0r|95M+_I;8263s+PO%gRU@#QZ1;H{(g!kgt; zH6E$Eb>tU*n_wthe!^jowp3^ZOcoA2+vlS1Yf@F=)N}Z-CiQOig^!BaIUoaJ&~LsJ z#f3e>HJ!2kC?Y4e?Z`Op$~Y$NM*;7BejT(8hru7MwaE&VNC$)#6@IWo`Qn~!eu?o< zIJkheRQ_Jo9v9(2W{M$oWA$i>xXnnn0*31d$^i)iL;0!=Zxvd5i4 zxHdi#+m1Y~#B`9jNSj47=ZJg1)p=bru2*8-{TQt2zQf=dk#Vyq5@uP5)skU3gY5Rs z1RkldJz+d=PKeWn$A4dr9v4Nj`4g;7l!-(-WW<6?+gmXi0X`(awHRPl=r|DT!BRJ2 zfE~;jWpfvbfSQ?^c^(tqAokxfle;Lti^aFN?z*~cO2WXY$RqA8!A<0(pXHQjb&!!i z*Rv3-d*ZTNKav10;Ju<9-J?G7JZ=&G?O-i)LOMd>(6BkARhi4wk|%@cDoa^?u?Sdi3Ibe9=TAxScL)XY z15LnnrE3%mDlKuBtG}JMJ@M+Kax01jDYan=X z{=QrT4$@1?IuZqPKhCh^E=zE%fq&!)uv>imqnw<$xE0}x?`ayZvK{4%hI-8k$q0=4 zoCFdxJyd=owLGw9F{p%Y?MfJu*kRft@#~BL;dz4|7`KkS;6z>;FS{r0YhP}X|8ke3 zq*I+{N0_#1rb=7+vqq<#S9aTTww<6)Ma-iu-50$s;1&~S?8OzG-stTu{%B@LW?FRs zeK7~HQINowUyi|-&l2~>_BUq!Is2>Rx_(Tu`{D8Z!mbjZd$Quyjt`07TQ=JX@NIL) zYueANd*ohrfTI*NylogF(=G@|DiKOZPYsBicm?iBC_23LXhb#P& z`{R3da&^FFM-}!QtMyKL5Cj`z$2K-$xDbGd|e=0Qp0 z<|DOg5Pi$PzCgR}SoP1Lchhi%O-88uEV^V}iI$?Zf@wNbl$S19g7>&<&F>7p2IIH& zfA_m>n_?A8$JLWMl5l0~zLpQJh&bH3$DE^)c!ypr;NIOI?>F15O0J5m`cy;PjX;?I z+($8Z8aBfAAGXepP_8B=;s|+hgZ( zoTYK~i9>b@f|HXo8boWpYZqVKL)(#*asrc8_y<+_-9Xmkzjw-n2o;YGPG-(d&Md?k zzJQ!S84VM*>iD4`QG>K#G4V0*DzKpJ_Zbr9UU`Ym)i2CWV2qVph{`s zWuj=|?z_5s)E6Es{3WCYdgu$PXmzZXA30skgy33?aR1rBXx`i{a+z%s1xke!mGH3D zC;R;+4cL2;8dO!+ul=I>GJ79-+QqXBX4qGvjY8j@$kTxZpUrUOv9iE>gKpJW@n#0BJvmGWptIRNjykR{p~j7w=RDY z*EgW@!DQjJ(XcU6Girr|+#5@2Wric7-AbnK@iAPJDr{ye&s-md^lE_@*c%h283PV-=wUFLBSI+YJreBvEjTg&qe$82U`3%6F&-kmw@+s9dVDzUU$W6(KqvQMt63W zS#Uq6QRG0%7HKLK&RWpGL(}xu0}~2jMyC1ql^%G9J=%aZ^#^ zE+C7y%q(~N8)EF>{>|UPHkoPKgdA%TJ^uZ3|l$vU^5BxP@Wm#ESnC4~$)mZQ^3=%>B1iN@$nBNtA*#~x{x|=}i zPi&Pln?q+r*sZ*JxRvR6D@d!1BFuT{lLf)yyke?w2gC{af|4i z1QX@V<{azUfqZ!074oUgeBZk(1~6(G?U>t~wG_QWZ07flm*_16jc95< z-}18EW+{UFZnY!|j1kGsk}f|GV}nFo1XA#d31yYGAWn;@{% zC3bT~_2@jGhKa&RVRl!y+89AB7>?2#yhq$?G;za*HBrdK>jq0C4yM}+HaKYJGO(~j zJiZZM@U?!ZWO-hJWV(j0hkrjiE)TH=wayMW-99#iae~18pkP>MB3hd#6ac>pS-MUyqaPLz zQ^$f#)N0odp_UgaRiBWh?E{0-12s2+G#xlBt`F437%j39S=7BPj5C7x`5PO%jH)!K zYL`)FZMH@0VgafjK|r1rTEQL9oy7b&KIo8_m-nbj$&ig&ydxeO(=qyL+;w=3V7*&k z;NzuWw4O%TRq~WMVtP`}!7Tr`OFU$7E3~ej1uVi@t1}Vn0l<^u+{e`9N#N@Gvb1?$ zZa+ZghbwVc#1%ZDp!BD%`y9w0se>osVkQ4lz|HmLr(v>^;8Rc^fTB!~@7f^f@xhu$ zJY?i9{jIYRKP*(|+De8#!rBw`5t;nKcP;Z*!~qO)rw4NxC!=SF$g>&PI-wJppD~CV z`8flNA7I@^?fucM0-zX4Q~cYI7^fa7QDG9!5}7F=J2dVd!vEj`Tbt#L8VdZN;TE?I z$(sF&7(4PfkY8F-TpTlW;Ww8}|5XHrh~w-R24i{F?|ZtvlG;E)Oi0jr9vVbI5$#AV zeEH1TM1uX<6)o4LT0n&<+3V(nlN+^LTuc7?#HDU|&`9{53T-5~-ayzluI8JD6FasEdX?(<5jS2 zOMj7T4nxru!A)}^`oB6SqYHlWhmJT(<=!1mQa0`Jn4*q=uDHCf6pf7hNe?x2>lJ=U zLK*cMNog=i-6+X7GC3ku!zYF>Wkj@6k{dZ9joEDIA48VZ$ood0ipt#dYXr5^E(Asx zgn=1w#L++n_nHyLjs(+LA6uu=Rv{6D?O)zvQfE$0e_I4rn=QL;sQ^F_^m;$?OY>l~ z?h6iLWkT*g^eVtJC18txxgtHT%!B&z*Pz8H>>E2x*i>!>(qWVmTd$P$t8eD;%gn^? zfEw!wRheAC&$sS>JQw7r-ryR~05o043%($UnK%=ruOv;_dvFx*0}uWc5O66g{)G1h z@$ecoE&n?VAt>DkiQtM_v(4ZyPy*=w!t~(6RH&gU&=(|f)j6Ny6eW<$0J>Z9p8aR+ zikWl=b=Z8#*ZkAX7wyHqg)I$r`{71>lSNOOMv+Fw$0oMc1x8b`u_H6 zA~;XAx_mo$T|O6F#u+{X2d7ajH7x!1?ZDc?!lE8F#dtB;O@D;qrE9bU6sXlegaY>Y zRWMB%#s^KY*|57_Klv01{zw+D?(*e^j?B~h{ki^2qU9~bCks~QV3$CGDtarA2m^3w zn=?f_7njP2N?iPY$ji@JYt-mCS6=i(f3w{KfD`!MZ)&aKON8w^up__4RaEIJtV{&TEn2sJFm6`NM;h^{jA%B?`0lI-nk@V%t{9o?_dW|y zk0gmh&6Znul?}#9b)^o`$36Hn-e$GLjZ+c&Y5d|@F>^~8(EdHY@^caxtxJHAO8V$% zUee@f9+-l;MDm!egIjqYB55=o<*=SC_6NPavcn*RL?46pN0avsu<|~=Ec*v{Iry%0 z>+HsYIK8JD7fqHCBI7-{s8;GUl%%}wNQ;Zd<+{gQ@#m93POS~=MRsf@?fk0yY;|}W zz-eE@lzE|Qk{x`W%|ujI3Swv7F9FgqWtqNtTySV~t$}AY;bj%W;om^28=^O$0xJ<* zQ!h9irwONs?ln6pDGpwqsJ#l}jSqM4XN6L)xs43nn|JF}nmoR=XBqR*fLu}IfhZ^{ zxVPIF7y6Jk$tXh#f{KQQLH&@QP+1BPh@M>;st;?%yJdvFU5OqtXd=HM)Hst)mPX{P zFu&}O%v*8=R!$@bz>q${@m!I2fK70yMcI?&mrWps7+=Ddy@8=jtqG=BF9x%a2j8XA z*492AVLjVqJwrG)o$L*S!x5*ocIB>uN|}I$4!El*{MIx7oaYp{;XU4-HqCR#D!u>P9)Dm!0a!p9b{iRpy)QFUzhRa3{mzqC-Aq`TnR#-Iw zUGKuSp%<&3Ff2AAYn6~4$zUI!*nrVn%G&it0+m-(-^4rrVKO4NxX))0t(Xx^UQ;6Un}rw4-YqZep|;WAVk{ z1~;FU7$~Me(FDM7H^Ep|MuLrTr`xDd9y9fT)pT7xWkcb8k$j0OBGogszw6g|Ij^() zJ(_q+-8kJZSD(S`-qiNp)cy04-NHio)7Y9xyU~;9Z%o#I*7|NUtX3SQ?kKiy(zf(? z;aI#66k<8pC0aQSIzO3{(E>DESM3D;Bktyg(G`)4vKcJ(h54!{^3eZ8< z?0G2+u{RqR{2Wg5s?ZSH(4qyxt%B zC_?{ClH;|R)3xxAKl0ez4`(RiB8QWh_P_t)9BOCZa`)YLKTDM{@;9?TsBb!|Kl^ZB zv{TrH5#NF#Z`JPO)=EYI)V7*u*N0kojRx^WzyrcC`kiF}OeZts%7Og0niSFdp6IR9 zhX3=wIwi2%j(d^ZDM zQ|T{h@97fgoik29ie6l|7$@1USWP~n(|eUM8`admwJo>pb$r}DgKf{tWB96;I_{=b z9qEj4SSU>kotBqB^{JR%{aEcb<>=F|A>6aM&)77&8*KbWpkr%c-b*H}^)(VJ);O~@ zwO%7DM$u(my)S2Bmt;RXhw+hoZlBvT4O_ny+L{A%2nVN@@Ic(}KFGjU2%hH5w^xHn zGXCJlB^5#2*J76ZNlXoITg@i-0QTkjh7#-5&WRno|S~( zCqA@7xuT1Uxc1g1$sFpT;G&|$s4*a-QUZH2Ks1TlFF%p% zmlNARY1lNJzVm)Nxnnpj18r^JIQp9E`H`IC$C@nrV>+MDo9@gvOE)xg{MRb!oxc>CW4kjv@7q{07XXPpk5y|ibp zQ-=23cph7>6?5vx8DoW1XHcI67=SnEKsARk(<*xhY==$ z86~sMr)lGoZs@e?6{88O>6EDjoyo@J{XUQZdsEQk8(Gk&t85UdS?%6yg?qq(G60^5y!jvKG<`&^M zIe^L)iy=ZmyTnW=vFG~5U>DGf`gIpB5w`4e(!^tkSl=sLefGPAuW+I2irx|>zQwAe zKxnL`C1413fepWPIvDy@xog{Z$CVvP_|6&#s964MzH~*rs%3i`e{nguwm@lP%LoB- z7gDxOXgt`N0Owt{4KhHvWccLCKYrEzK|nG-z^hi|MlLe}^%3?~6aIx~E}_5t!TC>3 z1f;tudN<)!7ag$Szf}j-vB?M+Dc82uE~ZDx;%I`yOzK`k<=}J01(5Go#@Qr%5#^oTa};uR8oIZi^oVKsADIAy*N~r?y+7ssu3A5v@|% zx@n~X)_9f)x~*W)RVt4I+`oL=RRB-Ix)S#j3DWal?nsJxqch>ky3 zw{=g1h+`!`b9yEwBv@V%q&^6W<-P9{qBT1>HS{sIh;7*W87BMzc@emOeRe>aP4wTl zABgnXp#m^+01qQZ#%f^2l}a#j(6L*^#0^s-kydF@awju|vKhzA7-D5UNU!{a$XL9Uv`qf-{K5YMC((V4Df{NOWYQxIswOkz>yuNx)ngOQqDiW$J6D>p%n5sv zJDk4l?Nzwas)2bUZ0&mKZk9-&UH=lLB&AI~bMqRAVv?{L!f(Oqi4e{K{?kZU+qzJ+ z8KaX=5w|G{DhVclB}Kk}ul%jL`jj;=3X#6|VxToWF)wsv*YNf`)@#vnGBQjE0D7n6 zU!NX(cA+lN;RlP}HNWf=#Aq#a!DC9qK?P}S;Qb-)D=%`b>u)wK3x^0d8brLVPV|1j+oyWiF{ z$LN@`CVe>NCbZ3fb)AZkI7&YKgm#gRtc@Ihp186x_t`sh^7YLo(iNQX&y})Gr{WHY zFW!GgDEF8a7X-Z;d{nQ7K{@&@f&&9_hDKm4y#jkmWTpnPDS%kK3pM_6eA>m?3lHJWN5lH+#GEW%YkCGx{d+Ih3mH6uihv0+;36V3^*q()nq5 z^5n@uXl~+V9@$Uh-v)qCusfoFBv9W_b!_tWbh{G8AyCu5Dg2 zV*|K+Y!{|lnL`isSu=j=TSAA2(M7TsAlDcE@~C!BUHbbRmO;Al;_yAw^r6^<1J<{~ zqIbsexjbIvmKaUJGv96JJ$IAT_iod6FRYb}1v56cr&nL4*LmA53{8@+$WQJ&EMWGX zD+V&KvNLvjk@&RvhRgd_GPaZVOVCBOtd1pGO_DNVb?6?CS#NJ=E}B|H>l)Mzh7W%V zIyNt-IPu#KTG2JT!IYHu0LfLLa)_?y+a)6MSL08uI-m3$6$nbp@yJ%PFXr^ZV~7U7 zztZ34&cyTpn!w-)!`so$Yg^oab8?<1H+R8rMBPh1p4O?;Wef13a>)8@+x@i%0J~rc z819S*TB1l4LDX-I*3(#o>)`4Qg%}I7z9DKqa*YcFojzwrGiPdB>Li4|PSAwYs~Jwh zCM`ZL{!atlEO)mTpM6b309fF>h7_XFX^>i3T_Gc=p#9fo`~QmK}j%btLCO+-TCcwNC(H5ynUp4$O1c?l8NxiKf(Lln%EQR8+MZQVV2@e zdt8(M@AU`94{r*;RpM1AJ?%Rf_ATnIZJUx#p1-(d($u0ZwvfLm?daHA+behLsKX`} z-(ctMn=>G3{01+Zaxy1DdgJ}mT18(UBCDFkvN!fJEKQ`%P5`YW82h^5*alE67#kVe z1Hdukir2z1aR>_nKahs8HNElH5QHX`y#U_)KyJ`$6;AMW^+|JQ;`0^0noLP{WPl2A zFnJLQ+U^xY&1+thEvVv%CSrwvV{RP!XuT=sJdnvB=EyTh5E-~;>Xt75W1#pgIB{Y1 zS|AueiihAn)*jC5W&>Z|(6(CBA5lZDMJB%O;8?_z+s;^k$}bydXU)Q!EJ|FwjYA9< zl=>ufnst@U|Hlv`xe7)_J_W!o&-)_KA5|8eobM0M=##C_ol>_t&tFxlv%3 zZ|m~&y{${T_B8G=zarAjPdekbd;6==jV3TeP1XKy2i@o^$v-+i-ab@Av=wWWRB5qi zn9;e8&Fu|buVl(ehwm-sn^Cp&6N@|VA(Su5i zIHvjF(%RLeD8zRTp~Lb1HkcsqI(n02IqR2O|Tjg#YxGyqB2%*nbtE7u&1 zEvqZsj6M{kTCSLI$=CYZc^CS;&+bkh{wQWQJ7zPDduf{UY1+l-drDU#hu_mIU3H?j zP>-cE!k%GaI=6#5IgaIJGRog{NSgT4%^(Kg3Z}>L4D^pQ&q}QBTv4JIfmH*hMd^of z3!1PWZfV>VveD|d8P8JD3rE}DANn=DW@6Up65{8)WG8o1k4;4!WK=54Y{bRd*$o zEkS}62uQZ~bG5+3QeD2x%|8kC?bOuNTCZ^t=d_1+oF)Ig;fY>+)&v^xjQb9t9Sx{x zea}E*tBMvkR@+>gRpXs-$Ol*#FLI~8s}O$|N7tCXbm;zeQ)tzC*OcjqFmnTHZ;Q}h zfwEUZh#T%d{exHG1PvS9wIAD|86;s-r(aULW9tw$iD&%!KWTfAc4KRNlxT@<;d4V8b#O z8oVrai^((8T%%1fcq^Tk&#YovE&+P+O&+RjHxg5>#gXKkMc%Dc$Ld?c^ueNkS@<#j zG2|4<1QS-zeeY8Pubk^+e>t|l!|ZLEd9sUBIlVRWbPrLwGHj3CwOg=Mju{mRe9qWA zlWG!(49)m$SAzd7Q?tf<)M#UyIA1HilU3VfEI)DBly)jC$7na=K4e{JP^7+4Ba1jp zjlHqTLb^K>2qS%KH$cf2>I4;nX-hE*U-+C%swtv{5E&5x5Y~P(6_Zu?vUUk)tVHA2XM4e(f;DoYCH<50Be$^5BPY?WOSsPieIaGD z!|?WRxn(J&x@zAR-8Dahoz8Ehi!iF8UTV1jFF6xbOgk~$DV+qw$`6+nxF@Mhwu99^ z5%CFvlqS9RZlDUfvYf>W6ch(_s-VXzzeQ4Ir)`U+XtehFUhVXd9X6m}h#7hVv=_$b zU%hp^U)~oUjgV1~k43!95kua_n}3=nR43@n*Dw5+3h-PKC{ZfGgwH7p0_-Kn$;~W} z@xYv<&hKO2ltwmB4o@**Im70Z^@-OhoUsy2V znfqxf4Q}u_v;f`TYZHFUUZ5~fAxR^|yq#jL7IM&lPu{3?1 zZ1nWDX6cW3CFefb0qVYlnVL1g-K^U!UK^)B;x;)HdLc*Um0k@7w*pj?4ipBz+b_Ul z;GAJ1Xq(~f2c@tqsdAOKiPiRd**m(z0bP9CB-*slBJv^3=oO#djm>& z@p)KjgXs4!3kRh<+32=bWQAvlsSH#EW5v6c=N#&tW?M|pn6T$ zl-%GHq*QpDOI!I0G{4w3=x2_xx6b$zsR*MMKedc^M={S`y=VgUIH)Q)WcPy0W**=W z-eZFSbDLfH6GD9Bm#E|ipZ;3~l&ze2;1kM$VT>P8+<(s}_aK+-g4&7+JWl2#p zr-#N34nm)knCPR0Wf(~nEJ40{H|Ua8$CxDS)g*4`)|s7$M-pxYrP`D#k5@e+cpLoy zc&ks&ILr*-dvW+z)q7)m^ZWMIqi&Rc$d1z&Ju0T#aXK@DuDz!V3ud_~RhO&hT%P}#7e<$2Y5ZFN~Us>jmr$g_TU2-9O|g76;MNN068!;wJF;0*-N4=pXp zg@VvVmROdH+QNq95@C%H_XGf#pA+`aMH-?=LUr_p#6ao{KHY2WL(Pz_W5clStp}Zn zX8x8Px(~WL7c>3otk4B41&r;1o-o~e-;-u=5N}(xwWK!&&e`N~ z_?DN-mm%)yU9`s>fVN&}IzZXY@9od7(a&0IaY!3Y$_oS)A{eb3@vlX(u&Ae5;7%0^ z9mws_^;GpLkR@zCFia#NdNdcn)y4Nt5AlgvzrC8^E%h@cN{6+-#gmcb%oCwL%l(Xd z^BGsKDAP-=I!a|I(Qa06%-mCJsAY9b{33XKdVs_FVDSa^2|l(qp-9wr%j&)F~rI;aFe9i9CR4! z#H%Fcc}n)mcLv*=DAe(Ru?T}$_Gon|YwUtIv7Z;dHsd0&%jFh4Wix9SAqoQt?g=Pv zpm>lmT^L=!qJc#{!)Vb})unqYid^#3smhqCN>2PL98jc5@#WX6{`CUVXfc4sd$6w<(l+RX03)Q>nD3_6fKy-; zfxG79$KiJx31w}!5?>Yd2$;qAyY@LgIDOxzNPkDza_~UJZl>~A`v-HXP zmW;RBcaM6jQ;t&l2O2)X%gkw_x?@N+XV%NL*JbFXkKQ0iA;zX**PUxOO{>_{mk zG*BoF$aMhDXpx#_$F*3SQUBMJc*4vmb*^d(h`=8T(8&a@%VaJzVRK0CNt&cp_>*_m zP<{G>CSA&PIUFhzkzI-QQd{h{%W2DJ9c*geQJWLy-4RP~Vp0%-f3161Atod>q3SOh zd^X@15O%I`^jwxe8YqZiiP8O&4exF@LGllp_V~D28>3AW8{mnUn-tuL=tP_tsa^;69zBlk1L^V=Ki zuD{1Q*QQr_S64Hh5KLB-RbH)jW0)%zIvN|ye_nqy)m|y0^Hj_@&eGuPNWy#1u|*)= zQIO$WC%W*0tni$S4ERQ`fa)dUa5E4g4=XJ^}!;7Wag)&6ZDf+!Ot^p7+% zbp1F6z6(=*Hbb~|2P{_{P@35=;R=5_b<(7ERetu;9{gzKlZ205AkqD;jvapuqv3j3 z&;{t?F9^UjoyBb)__F!no`V3Qq+lV^#{{ta1=-CA&t)bgnZ%h%kZ12*b`!wa8w49Ft%bmG8*r9^IjiwbdzU(t<%g!C~#E! zr}ZUy!0=S~>*%Si<(nlZc5~VEv$LDE@aVBoO?m$4>&EwwWO!t+F%gF3`hQ!c^ZWP} zUw+aOOhJ<-bl&TcQul=2aB-0;!T;McU1_;vDPMfWW`eemmz&z9x!P|ymSbGxq61Zs ziJrFGdn3+(O0DI|s`xv%7)ZR3X zDU9!Y$Cc2a-zx zB2lacShTDCtS4SQ#FwrxK@2*{uCj)PZ1mU~Zp>SbL*05ukgbVn~>BT!#GcpvJD`D3<4 zwlWGfB@6{(rTNbZqx_~{A8J+t=`#1fYZz4Fdq5}(hy~+-G6|MTd~*qBpige@d4DuU)iqZP7T_g?!OvMkoP~%gSxpyM7wljC4pr=cfHQg!g0L6tu zmd;5ap>C~slybg%I_g~Dz?<=gU6`>Xm>(ntOu1$QOAT0xFVahrO zV`9P#K>Qc@HeFcAOg9N=S1fHhdaO$>UXZ4g&=<6N)MKN7zVX@X+`(+N%&Qj*iU*jU zL1jtYuMh`Vvm^|(C?I*tJ8EGct8 z-vps^_{S^cNFg)lcWt1-JM*K^TTR|V)GH~&4m~$-Zc(2A2_A+mSZ|wzpu~If20q(G zwh-(|VP3bw&wRHc{nlQ_dS5*Z@4v5hn#>jGX=p7q&Pf)UDJbg(T4V&nhfI>`po1WeS}a8WON|0LRvbpF ziwn{K1%Ol2_HL^HpUr6mm{J#bO_QgcXjI9QKJX$TYTl4bz&23PsiyH2;>$hL)MHs% z7wG?c-h#_Qw>&>2<|PKyrW2TP15s7@8M)>o-}*_Q4a3ahi63pZ0ixMXT~UeJbXB!V zO%tnviEQChA>L{gw#AS3~@EdsJ^D+#wsTiLu8#!t9 zpS|#Pw##*g9pD*YFQUb-kxS{UN_IxfzWkZ^myYar^gE+#K)Z2)n3)3!y9ThwKKp-J z0jcJ6tcRo2vnaDsaDU`(XCpo8_w7nxWAM z^k<%?Ly6r3%!6g{k7q;lTA*PJf^^)wfUi{m1?BVRerPyO;^Q3HwigI~Gh?)X#M~a_ zy?0_!7et@kd{R^U@p0LLdu}Mub!Bo^?<-PGO7qHI8(Sb1rnib=7JFx|<>E#5YW+A^ z!2##XW*e&`rS2Mq&D`?`S&g6I`)g}%TX~MJU&jPYGHWOSrN^T*gor6ioE9{GzJ^|xT@BQDz%1`rH3KVoRj&@mxOOdqzOl&GQOR4&;-ciRo{zUH$AS zm-s*vL^{noOWYvRZT!Tt?3UI5B7MMp^cYj_xLragq)jA8xkbf*V~~ z%?4ftOyeT5h2VNW|AsA)yy+jKC6m_6#cd zht9DDf9)~TEIF?)R@g|wb$D6TORUg*kARYp;a|~e(ip9Ov-M;Qh*wIZu?*lU02Vd{ z$#@!aBG62SeT*|6l#cI(qFBS};R-UK4D~Fobzp7_;?-IPC>&4m10M^&Vkwq$ero|cVrnvgFXsdf(WXVP zU2oe9LqU={S`K8vCrLjh7`qz@Va9n`@M|~ME*arZZt!r|E8o~w(R>j=GJ|Bt8Z4y1a2|A)xR2pQQsNymtc%#tlsggCa$ z$Os)}q{trGB$1u0L&ix%2$Aejkz}Tf>S)~G^FHA8&9!=-21Gn;Z!eUmlU~sX!_In%q)zM$&<;!$F z207AP@lfJl13e}t!05_tG*0Zj*DP(*c)MTIil7~WfTLgO=#oMsaj_?Oqwg2ZSbm*GQ(_$COVpr!=Jk zxl$YwSPE`Lo4dnn@-X_{z9gZuxA|FVsUpu#jost_7HAY&*2-H8CF7~0u;i!{t2r>lM+bsH6`@Z%Ft^!HQwfc3dYm<^mGy)(#`Pq=n_CP8ybc=vP9ZY~IdDF@nQ0=6hQ^kk?W+)WlPHoeAn6D~_7pM8@-&47Xt1Y*qCZHgnHALJ`; zJD`XuU10DST8XH+GL?Akb~h)zkHiKnY^SYlHE4UhbvN`!O6=LaSaT=BW%A2yw_U7f zb>UvZuA{6rm=ggar!jTx+d^olRVCG_0cX&obV1jHOEbo9QmY{NfUVxEbiQ3O2heMw zNO}M4+(+PiitDB4!C*dRFmVZ#!+w0mV(@b3CcFySQ!Zstp0||>9dCoT4IIN2Xd6CX zN=-e&0$9?37aO-4gul+Z3AMa;M^ZzVxQ_F|B>8JMYPpC-t-s3`#aR zAMNYQgl!=G&T^uv;^0UKHv?bTDVv-s$G!E!A`Q*P1cd&}|kf*}?(wf7I7 zU|oZkBn3NFvS4`S0!KG#a9n*Gl;V{~A8)FW9lfXlLsr`DFZbS_=mH(vb_XjKN%1dV zD$Dg|a2cWKPQDfh(oSA34Z7Umt*;GACPYo=E-Ff#R-F@+;=*l{kcBw>X^uY1p!T?tLrWeM;d60?=ninHlm{mH&9<-+NJd>J1zJw}8LL zvY`N;V0M=zCF;&Qbel(9WO_1va03T8%IIwcEW0xsQ#kC@uS4@Rkyd=++`7l;DE6WP3?*RN|;JHQVgn%{cDDG6qHu8mXXawI7 zg~)RxNRo~+lThUd$Q2s*&HbI#HK-y)zUHJShU#ug+opp!MG1;1UXq=D3HB{WkRc^1 z5VK zoXX`tPw$K2I-pl6Hhm)+Unu?`=lI-2m2DVi$9O{wz;+CLWJ31WgTH2j6$t($v>bva zS$qS^0brxe$}iZlQJBCUp2&BL8jvWjh*4jE&i z6r(jv%_$X22sH=pTdjF7=h_ZSfyPeYN(fPARGHr>CUaXP$RRzj3|g@JhuCSS%q>}N z@s>ODV%P!ECfJGN;Li8xH=DYeOnbHA-tDJjl|s=f6A${&ozIoS1+ZTs!2kRxjw~Ib z^o7TQa>XK}-3Bo!Fbx7o##nfGlN!1{-=>Ly;5{|^IJLNGS&QIad6ug zlV23eod;?N z+{%5al&2oH6PM1B2@-bH_?ih|C^f#qn+jTXMD2l5BkdsgX~Or;b?TzKIsI?=O*-Fk zX&WY}a_7AWmMM!;WWeRU`*FB87xT7EFRSR?jWX?D4ETEi70S;WQdq9YDGh0sQKxQ4 zhEZhleVAvM;7H-~WCRh{!=1hG0Z&@qS;>8;Eb-hhGYxAL)K@EARzs@$N@ShENGwMc zjZ-*B|uLp?{^ga-!;j1?VKl;e-!}k~qhVgqT zPsVTx_#w@Y63HBHyinl*d8FSDp7;!o1&e>>bYegj?s`#N7PB)rs`h}M;L%o#k{)$r zVlvjGABMvvVA!>Rw_p`Rk?6pjL6W*z!FA zh_*-u4zHxEJF`avubw>L3+|x$rEbpQBXlcUCP=BEqFp3Faagf5;GlHoLEgW}Le&G* zy=!;Sew`E111THKqxmL0um6jJ(|^IWNnnf1k@b-@v@StYA-Ol#&cm7Z7#?vcT-<^) z;>Ki-Dmasp-rZfMkD}Nu4TS?#YZ>Yn7#ix5GmfNJwJ-|frt!H~Q?S}?UVE5$l^&j^ zX*&ADlAFX@)}6%iVDzG(@l!1!TE=Yq3Oc+%DL-a1he`0>8?!XNm7PA$jahIdVRDP_vb0Li^HYP+bHh>NO7i&WX9#Jd=O6^EjK&c&(-I;l>OcIbv z6v9f&IES{VR~#QpsNI+-CU@+^6MdJ`#{8{9%bU76A1(%z_X@HE^l4d3r~)dmmFg|D z0n4Gf0^akw5WODz&PI-BaD1_hTek|kiQ}@d{r)IL#B=)-{nxtM!YY;AI+jen(>&Z| z6?_U|2IWLBstM~J)TiAmFNN@K&`XdpB9Tq8_?;ZAR{j}H$&oJSE^tK(D!|unl8Oz% zxDT6;ivW-hP5{Jd07>Td z`edUg!<}G&Mps>v`L3XxBY?e{eSH2~y_$yYpM3ptly2iAPfdZff2;MAC@tbR19%a8&&ZVgCHb*JR08r|;^= zqMPki5Kf>0oKOUW7KOG0id1|!O7nevR63<;kCrt!!(=E;Tl0g3eirSThxD72BVKGi zTFW@DpK9A6boUh3vygJQpKh*k9$zC(ZViWj3g8bgHy$VGjNN&V#>cRqG^b?VIO{Km zh&rKcBcBzQjS_B{03IHmHMjU+<8`XkZQ1`SjuT}ZEuFh2zjj@HHzV*9 z2n%DnEZ1zw%IfbM&pN+b)5~$rxrBN5La@N@1O?m^b@ry0VX&1dc^21KSlOEoga#>>@APG+Y}=;bY*ft!z8( z1F$yv90M2aW#X1}qFae-I%Tr-Tt9f=8v416#M8)-meD@}RYg|6>n2n)NaJEnm)70O z2X)Fi@27F-o+dW7nLmH7Hj zMWdO0OR`#=@l$nMrUS>^x@h21$~t1IzbpqT$5bqPK9e*fmEbMwnWYD`n%-RmQ#DDG zRWK~Dv{oLM(wfN^w!cdFmBNChAV|BaNe++U>Ees;-n(4ED8PNw3qJI}x{NAj63S)> z7|sf~h5vfbJD0B1B!36!KL{hjkX3qwzDz+Cb8d$zUBxKpu76ou2yqzX28F$au-DC- zH}i84YIiPLy5<(!`N3IP3%lq?Y+VHVdtGZ;T|e^D;5*sMW-C%NzYhz=h zvhrBEz|}9afocO!f3-~>Re$g;A*u2*)-Ijyw>XVX=`OB-)Bl;y-069z`{(RS{F(d@ zzOZTwqklr{jL=jnG-TSV5BPx1D*?pS@i=XkMUQ}xonirgmJm(C9>u59`S4<%V|-mL z^U)rqW7C8*-iw8}#&YAIY`*h^?zh%*u3KLNRJ2u+i?Fj4C;e5895+EU~B zI3ZQ>&+J;Nn~#-~94w(3s`A6n5a|6SKA5mLw5I~!5V6>6=Qd!6k}B~)Egv9!%Q zx~jyp!a+Tc;b1{Njz$}hdVGxMVWGFk^WGbi8Bbu2MBQe;XR`Ma168eILSZb;QKm`d zw!akOyrEl9rt3D46-#{3u(l-PUrROxQs0djuJuz|PiegjPhYud19tQc@`P;YSS-q;Fj$|8w zXAP4I*>nc#EF&V@Qu}%<-gq`LiUb|qMYoB136!swOofs`$bdN-kf6QThKyDf6KM15 zY6;QH`g>~`Y)=_HUWrO0QSx<~B%U!Q?zx4u!BF(cxvHG?SC0$sxlZH2Ag;O_+E34B zqyvEo1IS7niurA-x8MkIermxI{OQNOq`Q_GcJN)s_cuB$KYt`WzW6nC*a;6Z{o&IC zljRO{5VHm|pnC<0(%!Z>;;s6XQ3Lg=Ls*(yc=t@+mbQlZ)oZRn1n-SbQAhYi3l1u^ zIYF57cflvjk#Dk+*H!f4nOF}wa;h}9TUXy0 z9>Hwb%htQ1D9mjp(guv{3?x<-G)Q>S*x%+8nG-8xV=lSZ8DPWr)Pko)3q$r;*zE$P z);w^X6R=Mgy{fiwljq^h&>75EyR*d=3JhG3 z2F`_4o&rwSh9k4DoD4N!u`l`p#QXYQtU={97D#eoJV6e`BR+gM=tmA$uMJUqYoGU#rg?dC@#nk0o5TWzm5KQnS?biAS7?n8KoQY7AdZZ%6&{`{Lru z#VwP!`E4K$+0E5$1kRj_+&$Y37ncw#>Ar!=XcDO<{&vv7@t*=Y1$O&L+F3bS7b;I# zWMqzfED^&kKR<70?Li8SBy?wLVYur*SJMY$JXD`Qu*+3ez-4($Qi48!U&NjY7Ut&Qy;Tl(&uw#E zg*K1!&Wmj6T_@$}skE9R%;U-~_i*~U3w{=K$;LwZZ1enht%z>Ad4=~a!IBjPin)#N2RAvM0O?KkYn!+YT4q%T22v`b78{he4p$9$&{(%Mk9>+oHFh-r`ip zF`xhe%89hLvnsD#6Q(lq1uxu`sZ@l0qqLS#5TCI&X79lQ#_%F_(0pS^8$e>)PTh2s zt>`x)l0gUbdWAdjZW*@Vs1ZAH++-n+z?~hS0(A+It?Dom6q`!|M4ZVN1^mRQ&sa_H zo+ps747a=%b5cJ*;1>aFyG+ORM$15_HW1@c{-(}pnq*?Ke*DcF=`>z($vF$Dk_pmV zP>}j)OetF{Cnd<#x+GRb<7-F4W-odBv4p%0|*jA9V=M76^ zZJ$iiX0g@0YT5CK4yQXz0<$4a%VFSDHx4sw(|-)x^z#T6ju3pXz+SgZsxL-m1U)0vUxw};r2vpMymqSQ2$D{I_5p7i0YOD z+Ihe|$%2q^2p2ukiy-+Y;<%Xc5p{$5f-~=Km>la)q0hf$10C)2K3G)?AOsrTJFjg} zt87N|mct98YQ9UK_>j&_AO+cvfqG;8ABX#w9wLkQmOEmOq+sJ8uI(HNdvZgi=+aBI zpE|tBkg7qIn-ZMybpl`~j0F4_i<|f7zcm9M?x4~7yLCLVu5J2=({ag?L_THnrgv;t zzAGwC8oNDC*dyDMAo1;UwTRMTtaM`lKhw)pWxroPa)8_74RKt5+p_+sjaxrH+xwGq zRQ79!f;+v9X40HZMp~~kbtcL`p=yPO$c$)Bj_BywKCE+UCv|)Sg z>w0_4UF^Hm$SLZhM@7@WtZzST^|*y{CXcCcZ^A8kNrQ^{_lOyJ~r^`8N@#dxNl z^U`Nld5DF88K>mlxK7X^s6z$s{<{?ll9RCpX>3iy%!7{~Z!6%wd+OpiHaZ1yAXnE+ zES+YR=XqboEO#EXl+amw+<{|7#fPaR)hQs41ZnP`TWYOJyh(Xx-;U-DE9d*Jy2Y^A9wIW1GV+*SJi= zj%q(kI3uec&W3F-hGI1WU2y2nRjWYPpwXN_r*CWn+J0$sawVNPDXI&$G4x4k1@>~{Laoq zs;pplH;@Q}Qh;Qs9~4j1MDz4PmYD!({E+&p_BX6G(k6PzkmkAwIjh+gbr!$I71@IO zGlA!lcj-FQaoVRbYD~Z6BXXky<`3#9V8W8II?y5s+5wgToliMQ_*|>^_k${7IzYLA zbD`_SeAdnWTxkBiH>gj}*Z}lwqEnh{a;+wQpUv*bkY9I~+{ZjaQn2^y->n?0{(O1$ zi-XkbS>(pZcGjxo))s%=`Eznrx5>Z3eEr7Qvh2s#`{e?x@pcRKM@zc;{2bIz{Rxv- zmYY&&(<6)kqFNF$UG(N`(lEs3Q@7k#+}ny#DL5-}PGAoB3HubR4*)kRBfqsAT<@kY z%gf^$#AgM`>AFHVP=i2Sie1SE!Y^`JuW@1z#!MIo#4m=-4)4?W^EK(tZcPQ;hU)?Y z&`up1JQ|;5*`ZLAPtNnV3Hr?2>J-mB&YcVnXkpS^;{r=M(MVF7N7CNaIx8`tW)C$M z+C?pB7lZmCmdYep`SX+z?%V2H?{*Cyiom%aek(6}*xqJVr(*5G7Ecm=FFh00X^RmS zuJLNLHG0~!%6U{$wLVNV#C57UFW*M-wKXuCeVjlghenT=Xr66%HN0mrdKXBEbr!%! zsczvX3gB)HSug|-r(f(hK_&AQb03Of7bGo5LG1|OSfB3=!hqspFtjw{0PToqP81TE zRgdJL$Q@%)?ErNMaXOHi4z4RtMAI&o|LE?H0sS!)^gn$J0-Id7Sm_Rf7h_QBL+z@= z3P;IJMP`y?8lTA0wn+-Mnmy>duxj5PTCCeu)%iHf*`F5OoF+`cjXiCjw}#VIj;K{L z8J{{je9q4^P5(kei#HW%$VB7l)Rh9MO}Wp03-()9!6WJKgU+hE`&Kc`mC_&u#GG~}N2pt~Pr&*=ZXFI~dA zIpvt<_0Cd^s(xgn$CwbN2M0~fc~nv%aTWSeUEqsAma`8Mu?ql-rho1e0aw7U`mf3* z!~2qEv(oHX;e%U@_!>Ois|y&^&*yPb7VJF|De+vz#Ko_zwX+^AdqF;I67L(otSVCtS{pZ5ZiScWpQ*Z4%J z`fh~pZKZx$v5RFF3&N9_jV$=wTtkLwU@^x(g!Ja=QWKEgCi`g`^Bn2k<^e0go{n-- z(Zs^t;A8*fI=xHfpKb|B*?qMqu`x~S4LwQw`{*I3B5rPns~1}}>Jx8e zI$KwsI9J-88MHHz&L{uV)g@%&;Jp_~^=fyP%FA2)K8dToIC?l_p+2g?B&e^7G_-5p zP#|30PJmMrqTZFai?8FYRF@E?)?gG5*(E2Yp7#Po*{m{zqKrKV!3whrT2(Y6yP+WCbqr+fLbwsk z$y!a^kq>N?LVLcRH%t93jNbPdC%?XDFM6`3K~~X{)3dHu<1e6B>Ou6?C0eer*Au&- zTRjg`x^^MApS^3+7tg92_`9AVGnN2W>&s+iYxM6(T$qmw;j1C!a35R<%l$d{sQG+u zLX4a~-&BZd!Y&%7)$3;Lxo{$FVj31H*xt|-o-#bH`|{(ZD$@hS+@ztsj+f<0ZmME@ zuB=!pA$!mbHBWGutnPN5)9O2vv{TrZ`KI&~Szq0yXh1!E2o!+T5_!AeY;I6fm^}Iz z1hO_pvc4rA&`d3wZcf|H652xeth)iE`c=0Hi1H;wpTk`q*ELskGprJf()=6Ln3^jq zKQvYL<@>12=DK+LO0-Ifb@tHJ%Ur1xO-Pu3*b|-osWwIG{NbvJutwo$E}#A9N-}f4 zC1nmzoJ_?oY}Tddy9$Kb#-+KQTAi`EoM$s?rb=Zh!G|uiMa-Gs*CzZ4I`5qJw-lTa zPFaMrY}e)K|8BVqH*~U%mg0>Xz^*s?IQggfXAZCTxzumkWMYHnx)5ef_)O4QBl|JHZovWRduB$~t?)jOgzwo+p zPUiwbpMchhzl@|!WQA}#E1HcO$^)=1M;)bPdvkq-f#XXLr*KetL3-S^z~hWjPP~P2 z{S+}~{hSxt-}D<1e|eEakY+r@r~i+>jaW#Ity}d}6LGVhFtrW$s>*1=$J@7!mG6VI zAO*|59Z{Kcop$c|GeOXg)gs+70yA%XRPYUq#D}=|_)i(h1Xi>cvr;k6_l^SBh6N5C z@`nH(hfkUB;djepH(bvCeU})zX3dye6(JPC{e@N(8DGyUjsWcc2Y>$CTWs&*c;8RZ z!{{|SDPxzrmIGMYe!WE3Tub8=i_euKlv~wR+(ydMFL!4bTQgRLz3tIwUqn>#`_-%>uUZNH>m17wg3-OkzfC3cbH8Nh zl@BQET@gydO>r2Rn2Z7)29j`6ovjD4;U(# z?wcuC&@<(e<|?Cx1GZXS__Aa^Rv67aj|*20_+mfPHkF_h3?L+AbPhg=GG=Ib^R>F$ zs?mTrP8(+-`f^&aeqbyY@BXbV_*#={L7Qk-U!Z%XbMCtdELvi%1}MIT6d@766s;*o zZ+_NbdqR|vf?f6OsftWlSjHW~$_v^R?Wp~)5hDnGg!fYDJ(D4ekHkrilNo|6t%>47 zK*{~%Hy{%qLyxIL1$aSk#*u|rE6+4M4Xs=f0@7%!a2RjJM;bC3YqqpMd~BLSCGsQ^ zF@SUamKfeqCbtSbJkKmZ+k~ z2@Q1YhpznU5&O!ffr7JLOac}+@0X6iIDg{<%qHb&^72gjXu<<1b~c%P-L7qs_X_k~ z10)jsp4~+euj&E1&Fji*^%w4l)0_3Y2l%vG{Yd5Pr-h#_GtXK%u@y_dfAYjc19)5M zbd%X;YWs6%oCloX`bQ%b**RIh-?HC6e%CTk`|Re*tB=v}2U@VYdN>w+3`{Z3=r_F7 z_pQp?({veI<5p*la*M#AX0@rKTl$^1Htt2vWC<)v2BCD^mxH^$e!0hHY^aHF;DQO6 zi&sBQS4@flgz%m$QH2;Iyu*u#EtC3m4y2-euR(fxPrca>7AX}SQozAlA{kg2$jJ=S zxITCPxNr{z;RiN!S<$4B^6x{t0o1%1w0jeRL5@U6uq&baVf~kR_1y1YH_#wCqP)vz-`4t5Vr;x3f)_Mt5tBWrfRLRiZqgy zy=XfHW8#ryX2DkQ>&#(>ARpbqLCec9&bQYtb^sHy=w%Zlse6 z4*^~!NbcQ{AxLOK_CmQfdMcSyTL(>kC;mG=@sDMhZ4@Qc8YExe%$eTIKt_fUh|)*) zqoMs}CNaZ)?7;x1U=AR4NlRa*ozP`tM^5N5M|?$&nzT(8guaNibgf1K>y!M*FeC`G zd9==u>ErdIqmL`%9sP5%6DahM2^T>#APX=}x+S5?ncYAc-smb3wVZ?ooK5 zEXnu%=EQgH!DaxJpS^fTZOT07F%P_?%Aeei_W#-#N=0+vrynLKeX2nh+8Y?DM{Gvs zlMxmXug=@Ti`QrFY;qMKj|4Phhnt)r95~nK*zms2-S4Ri#HX9(}jR>L=wj zXdvK&Th*y;eM``kVxaJ4@Zof9()l$qcZJ9*?jzcRlO{?~8D&m!*ll*E^NBbfm6>Od ztp^MN0;$u}bD%YF^WE8UhZS&;E0R~R*mV-b8KD#`;|GF9A6zvdrW=%8PqgPAym6{K z^K3G9f#`GnB_aeU(?#7E2wxtxH4K~5rUs;YfBGuykKoAIOuI|BjdJ0@i0SrGrYNqt zUbRyhfd98b^}+4Pg7yzQ!3?c%c6SEo6)pA*aZpiH>9B( zvEC@D$=QbOrsEudipM=AWTg z1js9>Clij}idvI(a-Ai7Bf$JM?$fmEbp(?EqF`vN(hioUx6H|&@EBgk za<`k2Jk27I2>!%&Cj=?+T(kju3n`C0FBsm^r$excI&wy1%VA?uIR?3Y#cX3I!d}U+4KqsXBb_OyfZ0BY2!*qF%SEVPJOR#@l;)q2u^AW_WwFh2Y2!SkC5cx6DN_#Ryf zaWB_;)t8|ouOR!{x5`{WSuQQy{p8ytBDm?vmTXw|1IxuuLZTy`jn*lp3ZG4`A{0zQ z3!MR#|1>0iJb;aRlf!6?X#pn^v5;dG1~ZUudj)bAmW*W|F~woWIpiyuG;J;N$1_IX zH?59?jM{48`@-c{q)8v}^t>QIvoXPed6If${so057wD**OW<{Vs6NlIADmcfMbCtA z7$x#eV?s1KWn7Mcwka4!ssb&IQg66*WQ4_{2n|tCtOC0p zhkaYk>EUc5_=uASpuu@i)Ep=V%_rPyqdnh%v3LWjb}E1mD3v~QsI6MS?yb#KtTpML zfF`YGGj!H_wwYs1IBD_E-<;A2OZpZu3|rCEyQSM7W|$p zy4Is8v?92+2$1Q19>NpAD0&?|;lwvE$t@w199%M-6ID7(LPGW(!x{h?H?C%}cXA-# zxeSea5F~9V!-RYkFW7Ex*IZK!`o+d76(QVYI8VgaUjkw6)tk-wiD;8q*W_WUzMTn< zrYaSg+sT?rX0UsZWpvdaF(Q$#I{NUlFwG>>U#)$lf`rp9Fw^pox(Bkx_OZ^8F76KZ6)^OnzEWmJr#rG-^twRiO@lNNJ@w0F1?=EQ&>2dO-{sC~ zK1>7L7;1*`R4zmy>qI_UV5um%J0CnaS-eFdI8l2e2mQJq_~2H@xBmP#mbdeIu3tF? zPYJw4!F(^^pl1&ngZ&g-?h_RDl?BbENa>`qa%h-=`XIG!eOcZXSH*zd-$!$9yPex+ z4KYT!p?D1V5{tgXEGc;n_#f488v+Nb8x1T_RU8+T5Po$?Q=(_e{ z#A@KfyZ;I|PuygY$#0TQCu#pVD~zK|J6p=9U{b4msdzsdGrBeezV58lT#J>%U6|97 zY&!E;sBE60iHBYKhP7-ZT23Mk8%CDp|KI?dc9P z@|F%WtuhF1LROcE|Ik)81Py<95ixR+_EJA*@|oGi#WE4qjDb((3%!aUTTQ^%rf|b_ z#`p==xB5sPf5EVpjBWGj1I@tT?L#2;@aew=`~{9o02>qGqRu-KCYFtj+zz41`@;1A z*29&zOAIIcV znlt(6?*iDVPC~=Yf6Fe4T;__zE`4|%8`u+y!sy?wtprU`+QN|y9X10c_hFVgr@}Jk zOU0Dr2F@f7w(ALg*-O^Jz?+N=Z=DKs*>BxVUAkZe84AOO=1~R%PZ@_dGf>bA{u;mU zF>S0x52sBS_tClMqb3kv(M7g?m4ksJ8|yBNJ78l1fT)4i7tRbjovL>!*R)Pxe2f>P!Ggs+&ewrxqgo_=@dbFXiBucI@S5{#@msPR#L$xOXUE_9=C(wD%mO zU2Y|WLqGlQ#$Fzf=$(SCerF7Gorw)@HW2bO?PVy)TW&AegqdUSgfsvc5loQXFO~0u z*(yfZDCiK#3Oq88Bh^;M;GxYz_!m)Wzn zv_T;ncVO-~;yT$~h*SqvW+eVQn1U5`*F#)BfERUsRDA&FqII}wuT~x4J32j;&NpR> zi`6G~98jczTN$7^(2R?!?iO@)B~raksfh2(L{|C3JNZ!dMf#; z$Tb;HA~LE$0)C}9&ghae^j;n=A&(sDyi<8jr20O6n!)WPg3Ix)X3-1?Yw`!@JTuBR z4%j*qwR)~eH;%5w!!MicMkF9NBFq$wTyD^Qrp6zQs6>KMz{BkZnp!FrdjFzZ^DWt) z^jhENfE;gBbQ`?8BIk8;Z$1;?hn$o>M*kf=XTb;3^XO=5A>3%p*uq=$q*Ll6a!W`q zH{`BnM7*H?jB1*} zQHcK3c}Vo{2D)zTFJ1R!cYHn2b!Rg8S)q^4122ta3kd4ZUu^DwQz|Y#oIPPx8SqfE zJ2OxaM<@XyH~0xMhT>*-0Co3#`5DY zURLJLs-l4Na`&UST>R}6Z23Li&soO}=Wq4g7kd%dm3o{k_(Y?t>?gKDSFa%9xyKp^~Q((wN(@Gyu!5aL$p^#wY^5rA`Ep0I(h z*gx6BUC=W?C0zY1!`A8w^%P{_qhGtgngKfL$Z#4xP|77MM=yL}*_HefsImS& zvcXm!>BKR`gKBk7iTyG_gm9BtrXNXY*Knr?abAh^)RS0tIEjNT;G^&xsh=(;WCYYc z|HYU$KjDH|sGj|1R92l6D7dP}T!BF{Yw?}ch7Dqh*&hID@$#LHYpY4d^1K(6&U(ER z;g|ei=7AES1UTq}w+)7?B%oeWYJCFjL6G;K5zS;QEDOgtPNw95P8O;(TH?hSXnkG? z*0BKRJ2c1gg*NEq9?;xVYd$R_$sIZc3hf9DXvqG`P#=Mzl^?XWE8$>k*-NXqobTJ| z^uywBzd!1LhYs>MbdZz`-84gjsyD138d~-yhKyw8r;13arc)8}Oc8*R&Tf|taU zBWvqU*gH%JX;Z3fkvk;K9%5ypK}mqs8<2f)#idtqzgir z#U*d4HUGu;T4M_7)!uev;z%b{nkY82tMT2V#|V2wGBl4X<{AO=q@odx-4w$q*!EOX z(HabaH>y;GaYt%_5z>6mhU_M3usdWz_FmPq7eDm_b{Vz5$qHH}Yp!Duwk5wA?Lidm zn83z!O;QR%^>qSZ6{HIG5MKdD6w)Ap#73zCBW&M~z+$>?utjCv>IT31c;RL3S580d z$U$J}IIAcTUL5mEj+c$NjW+`efjHxETAh;*x%c*m)a=(L)mdl)R=fJ{NAR=#F7Vek zA532LDRq4bGFgB3E?~yVF6|4*pbA7|cv75GkNae+XgG)#cUhl+dRO0Im|{ zV{3hcQSh1K-#jxJcfmEwMK|@A55#fZkLd-GNc#}j9l$jnhiuG$?+O%JkZe3*I;G8d zR33+lAb2goolL8uL)+ z(9`YK{SU5Rh4yRLb}2hn?sFo{BH1gzy9jAsq1`3V(CIY**NRHe58r2jA>VqphR^VE zJI_HS;)!99cXERw{P`!K(c7Zsi`I%o-PJ)%kJp`a6VD}m9NQMQnP?U)3?i?2Qb?0W;yf!)8G zk(gb5{5s?I2e^F|^%4W`p_QO=!07!}g>jXtj7OO~Wde=w^u+$QmyZg6Lmnr|s8xB|@u+q&yKt>fI^( z1KLmf$fL3f#|D|!U=gJSP@d%chMVY)riMMZ0n4F$&?2npJp8(T^YXD9e!xiiFj z>ZRY;FN*~QXT(m}rM1;I_MRNf`t6pAU?p46g{-lF%G1x*%X#E+UdsZ_2N{RQDhbhU zBY|nfcQps43DibIgXX6WvM3grth1{~Gl3yfdag6cn!aEBNaWiNa;TDp*I^n#7gva% z&^47OTT*`ps_F+FDrcNQ`4bS|Mo!%r!TUjgM31Z%%UkB8kUby3+W{vZ_6LU;-B$B@ zRRj;lMYOv>*XBe2y&o<| zzZfo?j3k?xeKo6sHCGg;d{VLZ8?vVqyv;qB^qrg^2Z#U&VFU8BGwiKn-JB&+Q%IOM zNtZ`CcpD-nwFUux>n8ZR2rYu&4pN(xz5p+umxCY~3Fj`d(gpBOsM!)+6pF)DY4Jr^L+-5Z^iddEm&z~82l!B(Y< zJI9bh(%~J*+n0e>D^RDk$xlQ709}-4MvT6;Z{+pwoj}aaT>XV_tIlJW4O4}FtCjGi zO4`XF$#0`G?z#J?oEZZm+KE&uj-!VTkAW}WXBfW}1OX^r3Sx|?wfSjj2pIx`i?p>X z>46xQHxc_z5WT#tXn084tlH<-8#v`~UTm!LPw(dTaBiNfNf4KhOrUT9Al39`^;OB6 z6iQ~R2RnwGs$$M_X$D-lQ1;hV%kpEl9UKXM%tSnGxM^|;J$3G@yW8>3UOeW{=Z9aRylPR5=% zIezQqX|vFya-DsG?qX3d+T12@_Fk4eixaqk7reiI{m#2zuX>xDb|Q@2>tR0L%(1&# zVb_hH)u{ym19SqKgys{i zU|JdwD2)Xuvsn%T4z*<7GTU|5z}g2m z^Yd{XrC*OMIC#S9C#vcyw{tR9C`>cHWzSTrq)NM0f2M_f?|Go{OkwyPMr%#MpnZecyCl6?$APUh(*Az_aoY=RYcoG*+ey1YUA=zy67yV8Taq$^V zB5zK?KXtbi5{01;+><@dM(C*4Cm>;#VD^h$Lvhn)0xH5!Q&{LBnJeq0oCL1W6LRX2 z%s;uVXnY2iqlbwF);YzGuT@m3L=@)6*fQ%sg9a5~mi|4R zVEH5A2b_stdb$Dr3v{o(3d<8y@P4YJ@fX|`Nqc`V!=7u?LAGDTe;zaZdVZYSJQ}S( z1||X#$=-{*8)tbkBw)D<~LH}t_Ac`WNfqUX@w-Fc*w@EJw^P|eFH)7X=y>lT}T58-R3Y1 zu}G!fmB?#`#1D2E{o=yEjscivBCG7L{f2Hsr~E}x5TR!P+G{AQCxKqEIL*fP>Ni0p z1>6A-RIh?Ied&t7e=Z)4en{TFbn)fF5;gd>f^O}+&$({2&Q5z_vbum}8x33k*2J=F zMuPQ@91bOL_16ah4dZ#l!kx}9OG!!5t2uFtF7##jZSn;=1bgktN6MbjX{qdH7&jnT z4J`$;ua`I`YKL6GqZ~#j9&10oi` zp7V*r3PAK9cVFs0#wiif2D0I@uc0^Y=Ur`5)=MZFH_H#>h+hm4Sf#I*wt)=@aN=#n z+z+q>q)E{6lNk*jM0#`?k6cx4N`WFV#NCF5Jlk^Gjm59;GjD zc?AQI!;5cDm^H(Vfx1zc*rdr0&j07~62W{4ws%ggj<;-}Jr4yt2l>sb2!SS7X|wl(u@as`rCTJB^+{7O_ z^zN>8i9b^L;b|D(!c`yQZTJ}jzc+v&TQhQee%e^_C^jq1x%Ga(?vOOfPI4Et`=0Sr zGqjU%rt&P<0xj~v=TEDmOYBJIi?ZM_x9qLAq<}0QqLysUP!wc@5P!!eu2~!5?7(@A-;h_>f%G$9Z0wcHae-D5LArJ&Epw5ZZ85iyzPTGVw{Dl~4tHP^gf#(ctOC>b$OG zVo^GZs6aQf{k{(u`koQGgIOC^cSV31DEx?jvd=rCY^=DM=E@p8@dK^fc*cvHBFQ_^ zj|iWhCoZ{>$NJLy!${z<(n31wMcnnq^2rLZq2iols?dqgVBvsmA=5Fp;h3g*&HYva zxMYX7<+T?5@!wHE+?*t8_9mJEKKo=%35sS|>6HFL>thP(ZSIz>cd3&gfd?Qmdf8U+%yMP#TP29Zx@ePmvDN zHMOWCHgp*ZD-W7y9vjcxv(lKL<>C?gz`;JrL@GMt6lOQ&FB7J0yC@i#X1Ctc#|gm$ zNFe`b7RmFyQCCML-fU}Y{A2>|x1^m#*Ny1MZAwB8CWoi(+7_HUPI_de@ulHqJs&lX zSXnVo$lB0XliH_hU4NqD5_ZvbcwAiQS4dXaThr$QsV$zL-y}=`2#fj~X$c&QFP%^c z`$U2?)PiEGk5u7akfcq{1i}6K(WPLU|8N9-@$2i2r6W`(9S4nk*w;Dl>uX1uDPn9>f+o&1| z&{GGsj9iBC)>BLukvTgj^c3?i5>`+^T8M*(&koW{kL+vHv70w4Rr+5<<7@Xg-rI?xd*Kxh2X8pH_lGd6AJh5SA+=U<)I#J zo7d;HoWY3Oe6aD2A7NQkrbmGSTjd(TYq1Q?SwG}y4O+WIYcsq&>ijnk42|27*8Q2n z;efQl^6w2DmX7b8kl51-i9PdiTk(YnvH`=-3Q)sf@nM(H(l%)Nf}zrS3dAVsg+2A4 z6vJYrspUE@{WYPfR`9O8zKmo+Faoz)_~b`<5VM9fVZImOvCVwqHU-=oz0a7ZG8*L} z4?V#5O4KS>r9?ENnEg4CovgR+WOw!H=1O^7v-~>__l{UhZv8tWhrr%McbH5&$EStO zeu?6&D~z3l^1QwWxdvGvBwy(ExG2sjR&m8Jb31zC)cKl`ghIJHhV`LpS`s)6h~7=D z^XRqB4qV=%C{!_<{TJSX0W+1F-h6MDz_nimZS+);jbt+QuEnMc61zi621>=_C7V9j zBCd}>Yi>Dd0sXEug;8uyC!7f<))$SnL;Xtk5_Uew+YqYndHm_}!Z`7|j$%^5%+kE=VJRL5V0+s|Nhjm<2 z+x*Wf2u$h8&c3hn5$=F<`pMuW^3EYSzFmRq@lz-OZs#&5966>=8#MlIYstr>Ow|TI z+BOheZo5Cl6an)DepYz0|DK<^w(}-!;~?x_PONtuNZd6}QP@q+vI*$X-r`Q0Rg*-o zwC3Q`AFvBV#AQk7i3eY?j0cL>0{VOiZ8#n1J#dy#yrhf;X^{hmqz{M5?k+jIaKzKa9hY661C+0qT z-{(N8S6a71>NDitu7h{!{E7TcvK%ISL5jwmrWY&4x*DbeJ-7yv}wA z6a`1iAGm_LeXdHkKXU}=-T^QQ+UOTGredKzeeiui-pNZLG?e;Z!T<)1!Il5LYdN`| zE^V>XlMW1Va{M?dwP1V zT6`x|_HfEf)WUV}5ixUjG_Kh#1MtKriaYdYgeb^Gvtz}%{X`zul7N?urLEZ!Um%_j z?dzlRlluGzF8l(}Z^pc>9{dfecJYV+pcq?l^8d%wTZcu}b^pVIpr9aO5YizSASH@) zn~1G|Fes%oisZl`h@g~!N~ekeh_uuUigb6EAU)Io!vHh$TZjAmT<_~2_vH=FoOAZs zd+oK?`oxN*CylCPn1gUpZRUSkZvTuh8H(v@-vhVn!4S#u%mjd?P&+45ylB@%s^)D_z83Svwa)#;O|*ZiIeF-k`Ui zSHgX#2X>T+d-9*vqf7ttaKT6SuNgFEhC)L4a-Ih^_gA5i44vH;H8nN**0A0CXr)Sz zJyFqTFbXp^r|!u+2?(63V@AK(Xxr?^;YF#g`eljHH^ z!ZKFGfgVpOqOD_hUkj@l51YRSdk=lk&BSBnW*kg@^V9VZwA@+NAiG8qP0K;PwI94l ziMRW&kgk5=yIHwEDRaYGTvcIUM&~ng8C{}n^3&7!CzVy3m@S3OS z|1|sqR1@v!FEt~~-VE5lu07z4#+SP6Hh?zz6+>QwM z9%6za=tX2VH#VQi$MZnXS?lfE@3Yc-dK-O^^meD5{ns7iZUH@Rv!c4Ntd}oj{#7X|7sD|o-*+3vVlMX%pnXpUOp`*xdpLyV z022}`94yObCQhG>5J2F%!XK{$J$dJ*Z&??6^UXbCfw zZ^?3eU~?Z1cFdd8AHq2&-hpCe_>K#i5|}nJjrQw^Xii)XgOeM_ck>erAwr|f?Fltb z`2QV8*nh~5AH93b{QM{*-S`FiF`FS{3Dn^Vg|QR^W4jr_~T#n zLd!z6?%UT_uf8kXfa7gR6gd*ArT}-E2zLFr$K&c)Z5SOBy;Ch$qMpjfV1G1MsGj;- z`(ydJBK=p7ar=-s+myXQmh{;IZC1E?Uzh*wm6|G?F!X z0_n6)1D;1d8<_S|DjQ1PX(@772<|`i~`xypi@euniIDehHpIMCF=`#FiZ^asjHL=h^=Eq$N zIL93vUORmuz`L1^Yr!11UAz(HbwYn>y)O^R)cJEOGkn%OBc-!^ixA~a(D4L$zT7vU z-OK`XuiihRNVDOv1jX`qWUV}~6$T-%E41WK*G{f*f4`F<+_@S^ zFW3Y{{%gTF1JniUygn7*hhY4k`e99-=ar7056KNnI_pDjKvP4vqnQ|*c6_qcXR7L( z+8(XGbXW1whn2J^J>p96VQo=Ez!eSskSYHwyA5nxq>6g!E7)*6gx!ABpY4xf%eBei z01VF_CvSM};|E?1u7IoaH0@;Gt>-BUWgCBk@%*oJ)DFU2=7FHlQO|8$fEPmKQcxn` zx#1}k1O@;T5~%LW) zX$D;-Mf3(Vf6IF#4Sp|sAciP(x)|;U*v%b;t^kDs{-cS2srtNLkLYP6#o1hX0SIU~ znk3Fa={W0Tj>ZWsD-)!D+|A!XxTHQ8y5p6;l0B=`*$rw^ir48RSa&R(K>*9- zxUx9Y9`awyeAo#cY|^8_5i(BtGysZ#o!mM=_YvvAra~FWx&u` zWV&MXL)9(<*9u_21M`R>{7oKn>{S4`f8Wi#LSl*@XHhy;9~ki3Js zaA)VY=E{o)BPE|^h~_~T^^=j*nUyL#>1;+HC=9)~>AoO-#S8eEGVj=dtPxx&U7`2w zg_FdIE{D;^%rFpo)NL))6s>fJ6!R;`Q|P2VNbZvBQPseK+$d@S^$~%`{%MqJ>cFK) z--~(wHTIi2!|1`Bdm%U0)REWE7FdZtqppsQdHSDnupRmrY~3l z^7I7?@lBIZnv4^vI;O8a{US@l%p*``e0fo3=iFp=GyI({+txLJOl=StQ-yAI+Q@%g z+surCzI_dh!-ux%&=S^>tT89|RW!8$OAg0UiOJ~5z6_0P;fQ_lJ10eqHM}`dSfc<_ zaQJ(m9t=)o5M2e%AU}iK?E4;WS=@EHZFr19=*m7}GlhbqphX*_@Zs*wj({!UCSoy% z%ozniaOAy+d-N}Hmv83#(=N4KhZ#IvZ%Yg6M6rGa{Q-rOgld6-!lny|bMAkd%*wvI z^wpqp1_|b$y1;MQQezf4R8ZO-5-90f>_J`o=3{t1I283$ijOt?W#w3DOQN#dJ*qi_ zU&fz?#qH+B?Sn>{8a-ZgPNKfHYZhr2*8QTS4;@dpf9ZacVJKH|?L<1FcK|tgI=_Uu zK;Z-Eu8)p;?rFSeba#vMijS&5I2xjlV@ip%Qp|kw2sv?tETO*D*K(>>@{Z7#vBBb} zft6!RLtw|=gUmXZS@N)Kkpu?(t~IiR)*%~jxxtAa8XAf0a@Y)1JH>|09AE zvaf;2X*vIjuj{9G2PY@QK3=lyzmNosIHU&iXBp(UXw;V}YX}E`H~PfEd_7#R4Q|xN z%0y;c?6odN#_G&hg}q0Kx~*qm|T1@DOU4kxZw`ULy8dH`g~-}v;nX#mck;iVc9`S0n(5a0B5v*LxO4= zfR-@E6TzYPfUa~a`f#2#Y2oF3Gz{b*`b6xhMBr5XjFA2XblWIa(7f;!QQL>SG2e9V_Hi-KO!02ZXRd0$zbygt@h~yI$=Kg+A)_F zZ_*HH4(yo!N~;nPr3@j z%xfQG#8r!y0$#t7@jGRlqBeQ>vk-F9H|q$E$PLuL2PICIhpO5EIT>9x7kWCzpP^ra zCp+WrC|!|=_=1~cSCreXjr+$AZEic#)*Wd#_Z~ZFrzd&p6txf8fw1iR0$gDYv;#$6Sv$&>cW$X43LeQvg(5D9JN@2}qtHg}Yr8<{oB)k5+RlbQocTSxg=v3D37 z2X~JqT|2hwfg(}Ja4~8_v@~8c&qOnOTtb~(7mMt2ZjV*zM+NlD2I)q-FNkRsoV@tN z?tccED8)!~$)$XQHH~#d73@x`<) z+5is#M8-0{mZyG+V)N@36GQY20SOEV)++68A&60adFdbF$~#fKJDc)XCwS)XoyU<< zoq9`0aAtI^Kg`+=7HzpL43gusahtQ920klM@>+kGJa-FvueQMs99=7BBXuC)xwzf#UE#X3kbyJ{XRkj3 ztg~kPnwKQ&Ux&z^f2;w^gKoew?sAes)%z6dGB$1>@h#O(WzVu+pm(C-H@IkjgP~A? zUCw2f^)vm2lwb3Zp(Hjr`VdEkVR7K;MtuY>>=|7^HYus(kPJiVo>^M*LkKrdR}lQ8 zd-;HXtTgL$ivuMh%H2E%s>RQPgww)H7Epmin05v$Izo5{YHD*$1GQHEI$2cML`>3!R@z6X)|Rxo&ht_ZRYC?j7$i8lsj)hnPu)@iKBN z@a-xyd;CU#o*mBl*G0V?-f&7PMbe_|emzmPxpCPI0#?iQJt5*|3mt9dY>#Tz=snx- ze!{Qa=^AM>z)6pMu2;)AlB|p+j`ZDeV=2&HY#6wykX@&4-t)bE*>l`}twe9go7?-Q z!grc4VX&G~v}xEi?MPf?4DnLfTB6HaB~N!6_fx0g-~EdEb<74C2E9pwyuM>mi9p#YlwOBY|9y2`UlDAL@|)Y-4e zqf8E@{}4jK>*cX`Yc~K^?c#@dVEf(2Sca#?M>ETGssQB>q$6?`A~#2Q63=cY2@V;I zs`m4<)kc$a1NTiQX{rje>^R%=RE)`Hc`ARQLPl{?z6z-Xmz$|>Psl9Ee6V3uEo{2# zX(iF%KE|`1Z1u#v2k4E5E>s__ai6>lz+$K=2Vg|w8l@2JKh_E{eNawtyFJkW$J~A7 z_D>Vc5*}bOvvv1uCvHg(c!wy{({Oxzo}wKq{ihLV9Je4)N6z0ndnj?d&|h3XPUZF6 zhJW{f6LDx?K)&gZLy#OFNAjQGs#%e0)Zlkf%Y|J_=Q=HF8v~ei!-rZVe5)5RH{9{( z=!)3o3ZKSCHJ0GX#i6gBn0VP8yj!?bGO`luA;{LdQ1i2zGU*|;6}L6yILtN||I(Gm z`*A5NzoQy`-pG;7*U6PCTq8a1FG=x)AMNin!6<;^Z|I;&NF6k=x&`U2q{h) z`(=U3g%Y7q>=o9k@#P1oTMa944q4$zY{D{%nmv+wn(ca{C(F`;I`WiAV+`5KRn6Oy znlqU5>`)QJza-m*EHZL3Mmbt>fsTo#{GP=aWfrX;Q8j}?Pc%j?-Y)o>7l zXdA>BIkPFj@xIDHGx3^<>0-dZVS@29zk%a2hc#ruUjQJF@}qsy;oPbJFRr#rwHMKm-3RHKSWZ*doP?n9?~z} z|KQ;6H^=@n?=bk>ajW5pBD4TL?~RF9pd%VeX}U^Su4*&Bol)42t{FT@q$S1Dj2Ghx zZrHr>c$Jk7Gl-)~Qb+=a&MH1B%>l)qSy8L3vaGJa=!@R?vh!XppAP|d8;;hp&IZ5CHg;> zp(zQd{6x+k`&@HO&hP%x0P!RFW~o84_oeO?)1xHJ93EabFM2$+nmtoDWTNCZ@6c&c zr&c5GOIp$mb#+o6vE?`FpL)7l{!iJ6eW+3*rt$FtU*%aGn|kwL&vtxKm#e+MORo1~ zLS@;W@7)JvTPWuSJT^$x6aDA7)Wg)qYmV4&^WoXLzqj;lw$EGpm=;YvE{sw~6Nekm za5I|~d3~{EXv$E;L)v~b7^+b9H_H&LSJ)i5h4MEJK#QPMv95q zTbRs!I(5meQi20CZ5Q7roL_JF&o=>h(gtC{H7eLl_VtyTO#YLjE#{K(1b3g1KC3(f`>9Ea zW!QcnzS8Uxb9?hY?dqf$N_|q-Q09IJO8=g4)g7f5s+%yrc!sA!q9C`b01h~cAeU?a zJ~&szw4Vji%~1HAjyZ-ou#Qwf-`}UTTW!-{`kq;B_>9-%Anp!^5K&FNU{2Wn)UqU; zi{PL&a4myTzMfMW1p9f<6&c3S67y#k6Dhraauy^wRK9d27Dq`%&Z?PXm8Tv~w3^Hx zam#rc(=$GzFkiD6Tu_zO`_ zlow6(Mu@oL4vauG)vaVOZ=ih0ayZ;RbIfLwk|=q??ob6kN^wT3?^JSUT&-o6u<5QJ z?}89_yYyqSMwxE}ig}KT!wFr$*)MU){n=G$I)VgVopa7H_+jY%hNY1;rI+1u!imm& zyWjZU+0({qDPs2W*++G@W4xMD8OeV23!`&9ox7eiD!gu_l{!*e@bW!T-CMS~FMoD? z;4ic-S&KI|Ui>>}I1n>B)i(atuV$fjjz`4Afo|#X{rw`P0u=?G-uTcUu5zm58{W#> zdsC9c(6tg^lZ)77fH)!0)S*zRCsTZU_hbPmR}Wk=?X5kW-K0M`Khb!l5~mWJetV^= zF?VbL&Q%^u_M?5)$skbc-@l*;v?;K0)a$GZtm+(bIx(fs-You45(aOz)G%trl868VD|H6c|#$VEZMg^xqJ^_eNE zlS6tEx;PTZ%k?-s5ozC(X~qMDl@N!b#Fj#QgZodo z>muId5eE-k;rif0+muW5z}n_kUWgM<+uUlr3%lX&if5Uo|E_^Gfd%voESC^8@7Hxd zo(t9f`d+dCVK)K&9|S3e9Rm{x zQ}v|qX%Hk)SvmtWJv+zYpc@^R6OO!YIul}5 z9cHXiGC%X$S$&NwyIzXlmV79w#c&0h&vS1Fej(Z|73+{fUJ2$z^_++Hm6T571?cC5 znEn||1>r^vtn=0A! zz0|;m-&J7Bw9ms~%=`EE+dX!q6rMkV^IK+Cy2|u}ZzdO6ABq4qz z@Y&Ggt@x2}Dn;IIKlO3>d-5S)Ub;bz$U38|7%xzB`8FptQAeK9E2}u5(S6ePh``{& zZRFkVbe5r;eEoj{UjIV~9p$tuFoI^p*PWL7SS5nd0|tg&;)|HOXPTjtKDAlK+5~SM zxQDQQ326){r5^b4ajrJ;B%jS0FO(g;&50lmNR1-;UZL>nA9dO(?{`{YCe zB28eFauG_op4PbOhoQG#oU-i3q+YyOBzbScLYp>Z5X|{LSCIaVB&3)aHG{`n0WcG8 zlWJsqZI2z?Q*em7FG5x+a$ek|cST&kd5K`P`4wMkxacLXJZaUx>eGxnH8nm}(l@?& zmbYiz{_#(>foQd-s)8de8|4cEWefgc3N2Lwb?c?EQF{uh|Li|d?xEwhjJ6_2=nH}= z_G_$MBYH}8nFcq#8E^0vm6My)?_S>9)t*O!tfsb$&r zJ-PWbD|1?W$QHhCTNR?QGZT-dbd zPPS!hur0Th`sUh^Q@aouQu}~6(to)QZzPj*r4p}#@Me1F*vqZEU_fop2fBj**XwP{ zH9xZwa1SIF7&L{@P6a}@0NIzC?%#j--~ncEBnpf*+1r+efV4cu>Y;7<;qN;!-S@Cd zc#dgtSoGM`tUc?0rT?dK|F+w}h-aERy$z4T*;Y5Zx*QwXhrM=O@e?p1%JPb^6jbx$(ykIZHF{UWT~ z%_TNI`ms|^wL?oJqSpuNUP;2}veyzw1>9gow_(#VwnO1}ZS?oJpktRy)cQE%Fh3+? zxa;0&%u7 z&oQaoSU6I&C^?m_{F!W+L9$8;9BNT0B+kqk4?G?Y%u2M1H(BWNENf5*40S!LA{su| zIh03k9PA(YsOnI|Hs@U8U(0Eff&{iJl6(q#v$uW1kxs2ffr<^p)3yhpasgA?6wO@qfKBL6zU>?-?SdLc$3gIK7+ z(5JkBU7cT2+3Fq?VPt9eYJ!TMaiG2Zy61PRz&%lkssxq?Os7q7>vc_!iG z!YJbcHuE<&rAu~*cOX9x&#QYAKAZZ#KSj%fwt=^#d2=1+B5YBBjKzPcz^>>Hd;yyV zk>_=+!pFX8C4kT1tC()NrZrHIr?wz+^%?I0Y!;ADdGxg7uh-UmUkRnGxze)r|4$-G zip#ZO{SKT(L}5G~u<|=x)rp|b;wkzAP5{|O;w&PP2tR+Cb!W`A?5?tV@{$(_`ppeC zK(f>_*GlocSo4e?^j;#vFtu6@fDthk87j5^)jm~dmYVPV`iSTOJ>{gEo@0@Slg4Wy zQ8{twqJGjh*nT9Nx4)tgdFRyhk;lKZ@$p;B=Fh}$R8DPwc6J!>c-goZ>K)j7F7ST8 zs!n_#r#Y`&vo(P++cnnSx`u^^&}a3S^k#r`illDgp;kC`_7CY-mENL30`x24iyBerJ^Cjdbl@@;OMT~Lv zY+JKK`JJtIC{ZnIfe;A0h%|ehE4|&xQknerl(Q2#YIYmLcG=Dg!p0-U{c;u|S;DHO zMZ1oHQooi`q5v`p;aig7%RHr`#Oo{~uYKM;m7kQ8Wzsm}1zxV?XVd*f2+|$sU4UeR zEMyxDzKNEP9@R$ho6}|ShAFAuy!1gKw$D@uUHg3T9i=@41J)+lTf?D09<7IaLq7D~ zsR5BzY%+y`o5rTXFRUhf0IAE1~K_-TzikDkLQB03N+upr;`pSXo!bi`Wb3Yi#+^T55 z|8u(0mrpnHdNoR)iejT67miD~Yi)Gw#_qD0D0q7Afa3w~9Co{=dJ<}ES2cGqZYHXn zWx1vZ_W@2QKhM2B?kQOE-rqIzhKAuS<$DL;_Mt&ENi64z1Yq!koiW%XUZzVAHFaH5vRt@(-k(QER_L{asO2AjvKrA)TNCczrke zHU#a3xGBs#Isis?zrFm~BD*B3sltdOg+%CKLub-hDKPZV7(o%(B|E~liuD-iQBGVe z%fEq_n4Vy+3xoM?2P$UQjS|EqO5}2f>{B0F{l3=8-Iwrx%`{Lkj5!mzjNs$0&b}=? z|2S@mu6BB_+53po+awlCuBA}i=TNLwQZQA#=A3#M*4n3qHc7BZ?lNh}(k~k-q>!yK z>QB*Ben)>}^;kmTkhE_|+xh+Wr?Vfdhi9P;^)6I*XX_2fIkePbP8H(*QZ9|x7iOQk zx}jG1)G6{4NXP|OiG+?=KxLSH?Dx$TeN~Hf8k*~vFb{N*CK)n3kG}c8QzeanQ1sZl z<=+>C$a85+DW)fj44m8hLDb${oWtL!@M#Z>ZDiq# zx`&A0Bm9u}yInS%=#S@tViAJwMuP)we=(J56SROO4jmWP&-3tIrdD>wq2+d^q|wsXCYBR;7c#B3e>`iEwv z6IGzv1S!gS^Rz*yQ{w`OJ_;=cB#U#Oi7Ji6v$oQUY~D#P&-C^G-U@5}u%-T>)GaT( zf5?txxhlM-eL%x6>3FD#$l-HizvWD{T6IS^L;{8z?EEdtna-rr&%f5GfuwS}*x9Lb zoQ7C#v%h`DlI$33NEs~NXVMedRp=7L&3}vYgCtf2sguhSu`LQ|cLihSw4R#GEtefx zKT8^$yHT{hOpIxXb_CTb6+NgKbSnKu&B~cb7TB89MVl50-ggbzx(nW?QHUsMAeh=m z+?P;(nPDTstCj$l$P7{XYtiGw|&ttJzazUU9_OZq=}WZJ7hsi9-alZB6RBBaga zEUeD3NZse=I4G?3@}T@_@`vR9pL!m9_R|E6h8qWuX*F$!2+|9IJk_bR)v4>7N$aVh zijIs8E_Yjq5L8_ZzY*zEDTR?cAnXcPwuI;cwJJ^Qx>*- zKkZb@NgFj$qTx#6a!dJU<$Bo$v0!blV{q~H)7(;5A3fK){@{JBOa^PNMH$nX9HMRe z`0Y(li-vaKcV7B`;x{LtEh|*3!@_(&fZE5FxLbRUN8na^T?|wFK|RT*hOsoaneCBo z$7oU43>4fIL7u?g9qYetR{MlR zd4ElGi#FnCJ3Db;*owfe4HSXxa@CZmd$8u&FM{S$wo>(T@x4feq$x@$h?&_+V(NAeY(Xz)+Rar=kh>nW8s*++`yb3#_4yhm`9^)Z>}v9P$KpJb(Zjy zO)|%brzWK+Ph8HnsQ9TEo_LWb%6RtdYeS_ri`K7=7N4gbje4(rC_E>qp`4tqM{dTIc--CwAdTM*p*tHpBNHg9Xi zlXDVa=0Dl-=prv!IsHu1>@yYHs5LdRb$+r#yu9gSNuqOent1%i_jmsZ zsUS&27dIpk$?tQ7m{yebye}~H&(F12t^8QGvG|?n3ZvY!JyIw>F}I6oG&$9jEE zM)z7c+uH2=E3#_eLTph3$dB-bI9$lzgAdp;Av^rmFwUBqj(pZ9Aite}Kg>$+nJS zu`P@h3pFHGF(WJqkbfaOb7mEo_dGC(*qt4aK(96^R;OLv^pd@yQ+#MZexXY#^{J?J ztxxRN=J$2r_oi?mIcIKlK%}qYwXIS6x2+J@9)9Zu$5J&~7`oCf`!Zf(t_Nv>c@OxU zr7jeCPdyXdzL&jtj$DylS*?L5+%I$7An)gqp6cscXv>lwJX16mL$UC(YT}fLB#5xz z8{svrDn|4K0VAl;LemVV{e!}t=GpJoAuVwiYf73GEU6=2M68d`Kwd$n!B)IUjs5=m zZ2Lj3M(;)zsXX-kYRzPO{jiJ!n8;#BVKh=Yx5j}`^w9%*5n3#;^lI^O!2K|)3uY`L z?XFVv&6$0G0-1N;q_1Ft#b(7Hg-GSxu#k_Co-ALUTei()Bx^B*WG#f=CB!+@W1hd~ z7aa%hoMT6H*FexKgPDt$V|+bPs{UmVZ}i2yIr5PB(9(s(b&>G5!7guWe*e)^{B<<< z`^>IJ70xxDTi&O}>oe30I&4edJeu5<|KP@K4tL%fTKiV&H|eV_G=~z6Ny<>YyuEY6 znqiVjG9htJ;rpK~VfNEy(op02`d^L9goafCMA`Ji^BIjaODfyHQwEF!d5=EvpQ8MI zchNCY_DAT!r~1E1wuO2QIoW5F&oQIC-;y8hbpYt4(6q)Ra~DSTU4UX=`55ap5p<06 zg-B;OP=7KNL%;nNT?B0XM?ykpo=zfXrYYs508q-g52Tl}8LM^u(h26pK&V;W*)%lno06N%Oh?tR;uRDM!lHP+bak2`%u34gL*4I ziyNZgy1vWs&$omdv#)Dx6(q_#54T*ejc(eS5BJ=r)pH}i*32$j1Z6(%iQx6EL;qPo zp1)#Xu&5J?;b9o<#O&oG{|=!){Ngp;g3L_!L<3muDY2 z*CUydRzq_%1Q9m48Pye>?H4j4KLsegcMbm%8vhe41f^FXp8~-UqsL-ocp*yqxePmy zBr6+1WP;E0=VUO~^60zcmG}|J-}x_&-8rUqVIo-nMAJ^2kPs`J556}F<9`T5*BwK` zTOi}`b#A~^6b&HMYvxurAtZ&+pD~KMhTMeTsH9bfKz7&3c5RbomH>GiIq9o`?KaY@x)V{4Gmz7slIQ_Sg_n*dHL67x+>qn4R zXx57@{-O;FTUS@_-tO&TIP%ElgXV#u{p&AJO_<32`cynx70hFxI4sD2#GFrB{AH%K z*J;^8?fDJsMEb;(;9`DwEurRnI;@k@C&=4~f@V^kD2+dB@>Kl~~2(EsBr!!Og60 z(gSEe*Clsy9og@l+UA6+dJrI;>`k_$1mH=e)gH&eRy5vO)ZLpYWn&?lwy92QSu2+I zxei1vpUeOKXD)sue@lqLeSIGqM_M0PKSyaw-o8j#<2}1tgq^7Fd8FBpxWTnh8Zah&zlLn;Jiloii zG}#$6?bB0;0avCjqtPSd)0pQeTYoV;ykKgOR=E$|sFxs?#wzke34JV{&%tZs#-8I^ zE5Bahx8XkOOiIlKI5EecZp?qG5ubQ-+!%Adb*LhGXnoPNXpN~N`l4K-^udD%DK~H4 zEYHi!GZA-XIXcA7P(ZX8^gDNClSRBKwG=b*c7}7kH5E%ip~FUO1qwDAhkptDCaE!K zUgHgO)6tP=Dho?QA5D8b)0xBocjq4{t+UEh$#s?UK%z98n@?8-*Kp9yD^{-_rQG;> zYY!u%;5pVDbIXNjIg@)v`+oc4NqOoSco@9hPEYQQHWcB=`pEeCv8OlY9Oz%a!#=f0 znQxhO^d`oW<}qzb!Tij>WQw!SA1-@D_!Fk8rBgXRK2AT6F}#02--2^#w_nPcE$fM= zO7|0d&f9wL@nY()^qS`Xi4L2$g^}GhC0YfyoUGm+UlwpT3{=K0m9uGHE1hN7yCJ8b z(4~Cl%$W;1heeJo&dq60F*ESXV1(0Z+XIpVXhYth&~hplYtiZo+U>O0(8lVkrBc7YlFW7*8hLrQCa zr+sHWQL6WQKY08YJ>YpEQM!<5`S4*{a!^7uR|OZ7U%%b1$u3ov@a%^Mnx$po&4unX zjDXklHN2~@bj{pa(y(P&aAKEL+q$ETc$8LwKJFq%nU)b^oeUZU?a#u4x4xXF%^}xcyB8E8?1sA4P zaMjO$rU+m(=eZd)39AORgi>yiwjnygx=%^hUkY&_%vtXtn6o`|-i%QM?$68cqFZHo zw=#*A&d$r7jg42%a;$}ghqJL?uWHOrBZ5qIFImfQK8Daa+01pIPAl-ClMHYBujXig zMf$kn*xSr~rP*D6ECra?gh)B~GBVO@j$xh)eKDc#{W@4FWB4n{->B>}r_3uiG4$CA zE{tA5_vV@P)*N@X!L0P09w8?Q3kVMB^J1rLWF&bM%qB zv=ubiBM30ZeMG*ou(Ixq!adq*GPMIgeGa|(IX&I;-)KsKIOWIXR0ve->U$5;;WS8N z-fNeFNiD1}#+W7)T4vdY(Tizh3&w|`fXwagmnc2)3>VW&zKF~u_o;@_(NR$gR)^>U z);%~C%v5k+P*l3@Zy3{2W`-dy=P72(=IHv3jSUnVs}2J!J9wdN7j&(4r@an~Po?;~ zqi_F+W*xHK1Ch~m3fAvY)5USwGb6YGTIi1{7}zM;)>yRV6mv}&N1Gytuqz7;9_=5N zjBbweAK;&ANWIMYH*{$%_?(C!Oi|W*e9C7O6&0;OfhFpPt+R6kbL!62JwrY}F%MIB z@llrOyuu^4#f+9BcfrIe0VS7guF-~N_r%4DE_XZhme($6ZbrDSXrd5@vYw8KPUmGE ze-T%^mF_HKEJxl$b{Cmh0)Zk_$t7|m9ph8baVwKgU`d2I)k0iY8I4;CkjC&yA7|iC zAO?&vbKKSuzQx6nC`Zo#F;!W=c972ZIqQaZ*EfS{{`pjGk|!N5pJt0mud-g@gg1dZA>}@|{`Fyse5tf38=^Z8|ZVDHz3cLIewiz*w(Ay^tOSm@j z2|NG2#=;Jp5F^RU{0M4K2@Hy{)UY;luXf;U!7j|X;pCd=oWTCgf)P6R=R!=mPcyR~ z)(A+Xv6a)PTj~{!+zbQ*^^IR_)QgUej>kd#Pb-iS=QQ8j*?DX6&mSH2s6HGHSL5a7 zr5^N)&Tl=NG=ilumY0_cilx;sGBVbZ<6p(C3?3Vkp8E_=W8OgAGzj7F<_|QO!{O&)*jK^`V zSM~MR4z&3O@q3jO6_NZA(u4E^m^162N4A?rwjawibBUaA!L>e^?R;->M*rq#A}o1N zPfxYgh0;ap!Loeze09n+;Qhrpyf6F->$~z>Ti43W>R=}d<%_zgqj?Kfsledk8+nvW zt18az#H8vC@iI5o7P!eL7>UsqGdBK)9`D^=u<@H8OPV0WBqkC>(gZk^&omP=Xalr$ zJjJ9jOtY1`3hav!${`N#H$i+G2VV1geR-N0iOP`*V`Qv{L57{_{~Na*s-BjCK}(>8 z@F8Wim9=##Cl^;ASqmitf%d~*J{0$~>4>tmahY55LpJ`np1n(-ywL+QCO*`w=F$cK z=7#r`&6`2K;lfg*>*b{UuYk2Y)qeo(L;iPYMvL8LadV6fyP@vp=B92R86de6GG@fA8M3P+IfM%uEszG36Ci9?mNZZVOTaX=5Gk9>R~XMkXc{EPET{ zCz$rZu$LQ+_rO}OAqa9JH#dCtoyqXo!}FiCmz5xP0 zTx}Zs2Y7IA&iyPeE%llt6pal3V&P#Jil-9rlp@-G4pn^}!Rf`tWNtn_j0I?M1tm)B zh$$BcF8m_=_B2;UUm-ClqpOh=>~mwaJQ3l~VXH5*Xp+*^DIMyRt8Q*277p*A+%yG7 z+|sBSE)$9HOi}LeCWnX578#W}n*I_{I+V>ZdU*?1=w^hWa{dVHUs&6N%>5Hw%IK>c z*SUI>fHVA>4gKA;e3Pd!QnPhv4i=+<4?ZJft~e9ePo*JsRzoT}*p&A0j_&po`hp1&lUvel&OQ*4h$bv9 zVjKU6HMu-{rb-LMZUpwo+}xw-_8CjbrmbNIy1Ld9Uh!{fV6n76t*x!o@TYZR!XkMZ zbMC94>qsyU-_=K11{XH8bPnKD9_|TbbAbsl1fLp&d^34l&D4geL_%&$#j;Q_{^A9l zN??k5?;=Zi`lpn}8GGlzSe_H{{8m)t_Bu!$S|8^DJRFz~ z`<1noRf&E@QF{1F0Ex_DW8{~XtxeU8H#v@1;sR~!A1c7 zv9+}_)(0*b52mK%L6Bi1o!y6GaXGsfohLOceGKcfk}^7o(N$GdH{}ecQ`uPG*6=18!HSBBjh#W9Q$^E+ z^Og_36jSYj!M=a@ZqJO{h&O3u)G{!od0C?b2k=C&?0F5uaAYC92?+ zAPrVLefqS`vD2)Y{-7!H#9`CgBuRUjyrCLs)ujD{$zq(k-)BOsjuBsUz^x{yIni6gQ6W^YZeFrKv4oghfuh5J9q9A&3v(hfgLuE}NbUR$aY@=G}RK#r%!3 z2U<8)Q*&SVM=9muv6yu+$fK+w_%;3__(Q!{6dB$FZ3lHu>+7_zx7oq3baZCiaKyy; z7)}3_AJk|hPdLGGROj@0ofe30*q@(2PigC%J_iYc-hXF{{~eFyT(1ZgHgiGRa(%J4 zw>L%jeE9nMx>{RDhx`yc;}v*Df#8jwvBx$;bxz}(zi{OPD_d7V`ij;3d>uIY)1i9|ff3r|wAY7?sMa-?)SCeF>z zXTtBQO-xKaznU&ySXkf@*)&7Wk@sgbbSQ(L%YXc+XpRoVnw7?ks%pYnZo;t#xd`N9 z8UR=U3x^Qms9Dk=Gkp7PkxTgHP{(civi(BGGC_5M7h9T_Cx0}}1|FRGiq+QUrueHz zWpK!=uBl-R`qHy11w!Y144SQNar5-P&o{iEWK?Zg(0NU#mY;&t$kbEUJzmqbcpM65 zsivE{YtrZ|)2y|ZFhxh7PEb(l# zO#B{ABU)cFrH@8NmiD5ntEV8Pa^UUg=ukyYk1C3*B$9gfz`)jXWQ-QPXJDo)D=W|8S1;pNpTgQ5W?L@wO}Vo3 zH`vid;pjqSD{rKip~YGUN?6(@B_RSi&l;CzEp6I4#UTaq$VQ#T$8uY0CZKmpPi}&`#6fW)8p8G+1u~%OUihnvg5<|`;{CVqOr@;)GDr5 z(?>-%V_eGF+k#^gsTViN;_>ZZTMaUBmubiM?3q@aD;=RMsM98ZJ2|DKq@<>& zLxhQc5k@~Dh%bJ9%Tz4(XxhycW?nc&2z^;4F{4;IXDQBi$^;#3v(RlucYj#J3f{HR z?vwrakh_WOIcz?<#cip4AEZ?+<6jagbGlghRP}V|;lL~h8xIo){EwgvuJ3|w z)K5mL^|!Sx-#CB1W`~hYwFaJ~2Qy=tHo-?|3*@IjDoMJ0b?VjhL739gu(W+TQFVVX zYVHneb;HBM5}R9F5rm!Bp&Mo0ISrN~K zAhp^BE)mb`C1~VXhvCBQSlS4UM9W_EUi2zj?tUH?j3bpV4pHIExP23@m<;K_#!nt0 z8=XV1)^2?_eS_^AfN@QqT1vxlEg-J4Q|o2o`tOC}HZ z_HH<0r`&v|ngq8;8EFFY&i{|8uK;Vhd*7!d1ZfHB5|9pQMuP}~7zjw0h;(;%_dps2 z3_`jF6Qw~)$x#Ah;ON+Z5&zG8-}n3bdu?8NIsBZRxS#vE@8_H|Zs6M*v=_MCfsL`B z&&oxDw>;I4`8m(I+g9v~;wFK4bkDaF%S|4sE40W7Q#cg^adZP8HaJqtT7eI%zSX}M z{JY!A!NI}H(sIQhQ%B}e9tM6^QzMKF5wk_D*=q$K>R6mlB6b(rj-~;GDZVWBd#SP` z^Y{YTrHpzBJxzq3K2<*+1dbrnr%YF~k4C~7*8=kHFfjOexw(zUGfV8lv*_gsaqzKy zq9hB@a4$*FXCNYb$tdOjg*+mOqI3ScykkX*f#Q#T>ZP!*xj1uLqlcdXs}X2AIyQbY z8h{qrq1FCVc62J$gbsEH-a9RAWvU;4#6H;D%S$k5L!$UB(c)s2-r@{9PD8*O{-W!& zSETE(S0v;hyEX_eP`ow__wOni8y;Ri{M%W;R|A9k5oe$aSP*as)w$^==(N5b77Uz$ z5)!?4EnsmrgiwXiwVS%Mgf2sVTuB{VHU^BAB2T4&e|fPTeBJ^_tP~?oRLAPJ4~ES@ zk6#_pFZI=9vSHsU5AUa87NAv5uf02iY7Q@t{yy+}F%-RgnLP$HH?;2EyS1O{$FGmf zud@7ILnGo&!%%0ykY(wnN>M3}#jC0x6Hpn?kW!s6#aK}|DJ6~4zrxCU<&UwUcw_To z=#M!%tJ1NdCuyJ{#4ow-Nj9oNu?WvcOoKkGFTH;k!;i% zknhc3T)r0jCHG{Q^J>tU{NuyMFdOnTTW?RphG z9&p%oA)W1;uTM06=Cu+synA$gcgd7=Db}XjLa9;c%I5~_4BL7HGHap2OI{WK!^QqN zo9>?fIU*A5U;E-SVt>mJ&1J#s3PU&Q>zH5bgLky{t}cTa?yPXx*FA<{^$q z**Y~63l8^foNn1&_2E@N&h>^qrVF=4vP5Be^K5v^Dt|Bwu&s}hGh8d9_0zPOGxQjS ztn;~Zf6EGj_VAeydy2xvs692^_n`@4=JY2I`ug==7l_4gvA~(k`OV;RaSqHBHGf`fyy<<|(y|T$ z-S%5lVhJdYll`S1&(NvAAM`1H2E#ygfV)En$(1a96$tQjaiS)D>WO#2ndI*Z_|%On zpUiywrW(81pcF*Zf|-`#l(UOV_l6QAl)k^eD@mmR#I}!vwns#S`39SvUv4Da;f&*u zdm+xh`?Qg=^pZ9^4Q3cM`qI@(I)i@p3Qx9zv-I-W+zH3(10iCYFvsD`-JhFgXp$XO ziAzL%?2Jq4cE~ysh=+Bw)mr&x zwxrJylGI_Ldtn4DtfZtQG755XK>CZ4HS(QjhF}Eo8HSieUjtLQFb-rrX$W%JDD(S$ z_EG=Ibl!oszp-Wd{szseR57h`tiBReYqh81^`kS~)8ga1vudHkz?pJ|Ncd3(jLr`- ziMUsr2}7Fg?nUZnEowQCY^UlIx9-PhoWM=bc!yBQ*YgA}^b0xTA;g`dOU*%L>rGct zXoX?nBlHe01YKFEQ=UX0WU35KeGHs|0MOGz+#M0Ou|(V~eSmT5Q%-PUnE(g`62+Pi z^762zFJCNaq6e6NjoucBwGMeMXQ6=e(+WqIg6bsEnlFV7V+jNY0RFLz@JL(z+ne5y1y$|cb@)Z!{>ME1@tcilYo!+_A8%}xbCzGc*UC}ix2`mqv}p?= zH96J3b`7=IZeH{Pubb@J?D+@P-8tbe)xnO-ch zkE;wkf;qPVCJmdIMbs#cN*m8^q`IWG`Wkk=srT46=MxdD*J6qL-AgutT-4Nm8OOJmmM&wp`g_^4*z#lH5|jTVMpv2-^c> zimJcFA4uG=>DAa<|MH*ev{VM1rlJZn=LP@;%=?<7eAln2UbZUrDX91)!}ld9%zSBs zXYZoFbNEb3gleMyOsvQI@O&JVtylw137zZLX*P4w`Q5zwE7V-%M)S$ql_w?mc5|{T z7rGYWdVMxD44F}@p$`yJ+TFPx@48ozpNor%TwjN6`e>j0zDLCQXQNe&nyBfg)mC77WMuAk(>~U6pru!fUhN;S zr9mAZD@+0FAc>hlPF|j-VQu~E3iPo;{3VHb?#+yns0iAjr`m5AHzDiGeI14Rv^(%C zoAcB)gLeI#w2l?2KyYxqQ|I^*diOhTPIvz;#cRW{I@^3D<7Sr?pBo;+u6s(g89P_D zb4TQzO59DTmUp&F!QzcjIFfBvYZQmaOgPKVCFEBt#Bt-&oV~#*UcxqKt&i|O?Idpm z;3KH_Q6*kR1A{HGr9{R|q%heGj_7<0yF-7)RHgx|^-_{TPMcGxDNA zzeAkYLbmP`!G|Ij*pNFrg{yCg@GXm^X(mvXzH4M3^6i$6ZGxgkaQDAy?;D79JC9+& z2j`{UKh9G3mp5?MQ=jDc9E-VQQU_s&Yq6{^%-I4UhBIfd?n)09|GXAOHWFtW%X|>$ z?h#k%&H-DC)AIQk)Co3(x}_AddGN4#5fp1!8#)JCLLTwKM#sBoDj)3|Guv7T?|7T?Z6pPSn-ab;rk(yo4*Ov#5LBqm}mguwi(4 zYpq*_PVEsO8K)#C-@5Z=flFO-VrB+`pNn`zM24WL$QXS~zhbjKIhX2R$p%*cp(Ns} z#rph&{!GRWtwng#fIKFmG8{vG$r3;=vI9p66}{X)AGe8d`RT}{@Z_RyJUCq|ZWvV@ z%5)8Z7UQR~ah7!l)6JuKQjf&yAnS{}Haw|t*Z%$<@SxU6U>;W=^^jAEClKjG-r}w@ zN+<`V5fO6+0LGYYu8_O9OHy~@o*yn&w49&WyMx0(EN5DbbzuTpi&d~9i74I1gthPS z;?}mA0^!(Rnp-W_VnjT@W+Sb?oETvW_X7}uxgmoBHyqp&=~ z#Lnb4Gqa_lYt-lrpN{M&|CZlJL7ffT%|c57_s@*3ng>qsQ=cB8G=8@QAah(|eSs6V zz@ZpS8QpwN{mj>8ttI3O8uW{1XhHB^XO|r^rYN*}yOS#5*2CR*$KKS)O8ehv7u(en zy?ogzAjvUa^YNdQF9~+?^Ye=eG{0yS6R)BF4U!K!`o!wTA^ZX0gbGc76YJ>X)BMKX zz9JCFb6uUBoE{%TbtwVO=U9a^AR!LqL>Ob-aI`UCv<{J`(zhIYs^_rtI#FX%q9Q%| zq~|i|7gTpi59DLX(zdIGkNyGU(!cD#_y9Ev@5eX{t`VZ+TT@eW6kYvI4udWcM+Vnz zX*xlqV;T#wLAyuQ%>r&P!&;11=LzV~9<;tcqc(b$p6z7s-S!E3H*vYr14lQGk1n!D zkV;WUy&S&>kZ;)S=86;nAL8K*secOnU+D_}%=s@^B94dwu%y^KA_6qIad$SN;d4{e zCwbokqtSwtJc7I8Bx+eN6uk6->6r1+p8>cVkE5*a>M8^@UT8B=Bt_Jr^orcC_`c$I z1%$~CpkeR93KY_K(2Wo9Zqv`>|2AhSVlw@PCY(QIJGeKGU*$;m{fuv z$zW~vfT%c`h$>N0$_YU#?L<>s^SCJShxyb74;gc{`CFJ}6CsePlO^wu-06qS9-WK3 zzAeKSu&2|P1~fIrX5ewu=~y6mhh$g&z;~-lXl}I82^xiJ@*6z_{j7(*o#8q$f42g< zwS(^pd)TrZsuVXoskj~cOQH5z5UyBup@fBx2<3z)Hnvbs{QOUjB{P>vOcNJ)eSLiF zML~6dW8l`w{O}(Y>q?6h077mWumni?paTgflKatVlkDRD*8q8m3nmG=_~M<5U86a@8MV)gVBp%#)Y ziaU!NjcsgQB7u3nN~6L1-a#+hQvApQU!Kj{yHCTNsbPkGzn$e1UH7xVQciK=`XJFLR~?Q_|F#ReuQI41H~N0|nk5?klsslK=w+^v|V0 zjV1eOh}Nt4%^PcOR$x+eyTTkGVFJzx`_Da9^);*{1Q-}MwUVg}M5w%J3^*ReLGb4( z;0unHhcoePFO7`chDzV#+hY^w2Ad+$8*2cXl2M68`FC$`(*p$DZ?)ZVb?}qD>dPX{ ztVdo)-)0W{ug@QtYo2lYbF*@Bszr33sG^^qT%%_0qn~Ea#3R-hd_Oze-DH@r83$1! zNMT#9FE`uAQCUuJntG4WdmVqEdUj(@B}Mxt-wkP!fK3^Vllp8YB% zps$v!NnP#Gw=YbZh-|MN6nb$`AAMeB_a(I)2{O3G`X7Khun+u-i|5GSL?Qzh)aOG> zDl1>TRY{8H&gTH6BtzY+BzFoKQad6tr8iF!inz)UBSD70)Zlhu1NeVT*~qmxa2KX(scA*x8ChW#D3%K(--~y|Wxm)82`zHhRJXJFy;Y z7T@vQ9RuzI^l1uExIR0Bv9;^(wbOTELfu<}%}~A$Y>)Nyb@sk5Lp?%CsFB+ep%wux z0JOs)_Uw(|G+(UDBznFLy*Pg+4}vd%-@| zJ5AK!2I(mYk;*vL6%pA#Yf*1G@b6mecXw5qum+s_e4_hk196U^h&a7dat7J4hwAvY zrlMKpw~`NV_kx3iGqYHZL3ZRgS0%XO*i{cCOaPyLpF0~eBy;oPNdGNz_=;ROs2C!w zlmjtW*nL`x$(jK#0=EWUw~q(#jKP+mp-g*+ylTieOkn*sssam1@r7eJY8w3kEd?8Z8t@ z2nShGUDWjBS_)D;R1&-O7=wl|hMSs77>z1j( zv>4~e7WLi4Qiag4ShPoe{ZZ+q3F^)%=3A(FhayUNOk0@E#hc9zv#gx4;yhdw*;-6- zq{=8+Af|O&!I_YpRTQKw>s9AM&uJ|#KonOK9UV>1DR-MMLe71*f%pdnr86h#=t4D| z4$y?tlI*bdGZ4x0!mvr)W^0oFPa+(%QPyJxt+@hWk3 z0Wmj=Wd$<;$ULp?H*|9c|MdS@K+@RftwzT^9LtB}mm)j83!uGnZ2PWHQM1+42RXbZ zi&~aU+s#>uA#>D^FQJA2P~c6~YL5z?fFIo*0)qt(RG9o#j2SfB4>1E5ppa(5I_&kqX%r{#^~JA9_RY9U&%b&kK@-MzQ7>_?{q{DtpvbmLKF*=;#V(?| zJp615FDsp>z4GQS$@J@@#`q#Abgn8Lht&@%tg1OJOzeoyYu zJVlpWU4twl>$@@yY4n_Lxy!U|ir1n)?$9`WmdIQ}DP&5BTgKKRG!_h(p!y5j0f!*I zSPlJwVYr=#{Kd%yPouDFWRR{a6EA#iU}yEj&tee4Q*=ZVRBQ+1W3{QDLSc{tmt1TX zAPRr^w&26FffG2&A5;DXdN~tmVRP+&j+qPHi(=ImOwDAk*R}hB$qgTN0KByUGF6`r zH}c47SOjP8`Hw2)StDE zLJLj%l`MP;Lpz~0P>?)}>df$oAIvXA)(aXq=W^}e95CvEQG?OWcNvalqAy)Ax;1LD zwt?!q0-^G%828$-@5>0FOVFf@4_22e7pCjcgV?BuE6An(Ow2_%Mo7)Z#>NMfP2k0m z$=Rc=YP%qO*x&{H-f@f4I*mBpIA4FjD<|%pyYRkz6Q_ktMPn9Kt-JZG>MP4+eYgG_ zidoNInC~t3zq<+^=r48Ryfo+4*p_o*s8Ys$Dh4wYN|CML25e7Y()eKyU;PJX0pWhxv)OY3E_5-e#xwyE#U%h#OI$c5{6^g?BqWc}2pcnmufo$>p2)nR2 z!oOeV?%nfSrrYqyS-@Ob239C33mLD!dG=ul7|&1E1Ly8BfvVPyCmCahW!wjB?gW#u z3IH4(Y$Qv4DXLZ%uU4_NC9~K+?ISD0c8{?(NZtI z_6uTJ7H(85b$)%)0xh&d#!ep;XD-ApT?s0lhzJBKs=;{C#&e#eofRHzLpx4QY`(CW z@XINIfV#jl#Svlf+??_5P_rIAF1H$$jTU`-;~j49ypy{r`Z4TejFf73Kc~e7^yXWJ z@qQUhGXd>gDh{k@W{7ts>BHfW2j>E;I!k?RspFh`Iz<_!zN=nem-Vi@2Dl~trUfAUM_7Lm)q{&GGH%RV|LLmJpSX_RO@xnHWeUmcP^Pk9V za4j6z)ayI9^Idd{7{Dd)+PunfTY;?8n#CsHCEdX>FHc-)(3AE^U8N)(afR!7MG1+g zMtt~Eqjm+B+%xGkzYnDpw0CO-W=Iq@=cM9~-@Ax=ItC4aG03Q0i*_Erj!WlTi~0cD zml_%#E-DUHvak69M>Wq&`v$f_^sl{-W<)w_oB}U{pZkPf1)rmZYTk z9G6%|P_oqdPbpH6m3Axd;#gg#M@IZ4Q6uczXEM+=MT-w+lG@Q$r4n>!tAj@fTKS~F zGqROes|Ky+b;pAk66Y6lmxsa8(-h86=Ylb4wgB_+U@Luqqa(8yq1QmNZnX`H#1(BYspJx@e9! zN`;$|Kqc0{;2$7Q`QyugwLW)H9Tx+~Z3}CjWTP~LNbOe3Q&$mVIPI94J8z8FEjK4d z#u>7)v7zA$Z#Bf_@B`i4VGKKd6duxdzqY1$NR897pCsh- zcHL$0=5h@Bxr5Tc4!I(9ybEoSKQ0xG`uKjkRD-4m@)YI4as~6`DmdH|@OtS%G1}6S30wcpy(cv)}{Y9(Kt+T>no$kkar^8H$j$zvmGsk_%9Jc6r7+?xd# z@?LC#b18;n-IX&}-qj9H4oU_jb1oX(XJQu!UUYnDmlN9o=wz+cq|?51rhnNd6;(`D zcn({1EH;>!H#|4FL6}z`ULjatYsM=;1x%F3^2x9NK`g7Q8a!;S!Ix@9G>Mqpz-+3M zva6NKR@9r5&EJsQ#tY$-?AKs5E$MtTJ&aj#7T($2chOqf(c5k=#!KB=j)5T7DyL47 zgy@gHj62hhCh=^yx9*F0hHvA!tYd6|TU+c(9Oy;W!vX8dCtp#0U&W65o+xG3EwXY~ z<$D~}v?ccI6XG|IC$kLXpzqWKuVNE6cLz%#DJE1Fdy$OqXP&7V>!3(?vY+flSM46E z2z6Rqqn0on>hdoqo*fcWIG!x=!InB9dP`ju_1(`Q#al8IbaC8~>>rZ*BmhMh$g94D zXyHPXDZOa>Ylhr6Jpr27z8tw_KDbsx88f6pxNI-t)tE#%skl&K89tBP@u|yA-=lO? z!HIw9WON8JG1amgCA*di^jExeo`1t#s1-2?BkGX*sEXM$UwX6Zdbr!-b-5_~oiFTx zI6y${1^)psa+og-7QnQe7vw&G=#yN5NDRD|D1@-n&+Ave=s&F!4dM;P7Os_WN!kmQ zi~*%G*fw`x?#snJs3#7ji6|jb2*Ldu$SE)##Mn>1JBD6|yz4dZD~iH?n7AFoX8p5I zJPG^!l*!o}*eVJF#i~7atXfKrk$FC00*wFxe+E7cIE45+Nz>;j5Z53mHdKa#I3Uc1 z4p<3P1`-Js`391~!U=RaWt(tK+QQfB6M3EnUd>b*uf{Yc<}{n;O+OjtC-XT* z);N1oEyd*NSz9R4B8~1vcenvR*>y()_#CW}B*-yq61x1Z2<1_8~$d0irPIvQ`H z>#!$p!H!=>UdIlvx&uK)9*vb5pruOe)z2S^x(hqTTzFSz-AluCD*je`A zumHm6pEi>MBx6_WE_l@ktY}U(e>xVscirs%1JX{bAtH@ySMvRU<)5eC$!r~iOO*iA z5qR)p%;ix$cCL`Gof6>Sv)l>3pHNob);5+`lnv7{I8Gs813iA-3I?}ptEO?U`|ulU z`73M6qJ=n8&g2Q)?%-9>E=Z7tsiuACo#b=LUs&=TWvnDK6(3hSb;(=e=|k9I>jPNF z{k(w4b9?*Izw_=obyEP7#N843_tAb%cwynJt#1vU87Gv1vC}B8E7SMp*KQ)}BA9wa zEc-q&PJWKE+7dJ#=gn>XW_o4nFs&PDKl!#|(#gRkT>Y`KJXDT&PdaVNhRyE>i{FOO zlwytpTc%viE>eKQS=@^{PuP)s2w zs{Kl*q|y$(8u z0RxJ9?`hCDK2X-PbYRNv2=)`DCm>k;KGbf06OHJPk0#yzPEOrsKIm*BXZw)-@w=+Z z@`W9n4&#Te2;Xr6IcL?~+?=ovH@08XT4Y+dj{N9EP4RtWz!}^l``fL@tqqn1_b_Ln zXSbFAK{NIXiA3ylV(mKx9HAQkh77gwnHT{m)qzLPR&&b2P5o*67T$KRVgvfamuVJ> zd&FTrzNy=12fopAc4M$bI=Ju82fRO6iyP`O1_~|?FHgmorv4NHP^YfI81fkph#SW^ z{mjR0*hC6=q2X*VvG{#l($9*+5EtjMGPxftg`S2AELbX+c1_wp(#O($|DCC zDIajK4-M;k`vQFi6(xW055`BJj@M}Z4(p~E5za>LkY<9$ggg(;$){h*^(`Uk(=L=G zrjX$_w(^m}i9@l=4|Q~%ytu^m^es9c*WMHKZIcHNHUC_ki6+l0QF~BCArtzLYsv># zh0HqV)70Iv!TGC>(z2$mTUd|^W_>k|0ICzQ-&iq=?H#i=Y80eALY{&X!R|n@b_-`j z?1@A|S>M9K=dSPNl((d$Uk(3e`qpc(+O>}xKL3Vn#(b^wPMJjc4TdK=@It)Ji5bFsqx2|4ej2G-CCUe*HN6m6Bflfe9)3*4~jnLpTtrHrBi?UQuK z9kZSrs-~n-ewm*VtEX-4qH-G{DAd>ua7;yJ?}B3}MiX4vnsn&(IOczJToz)x+wD8^ z++KJg-7>zwtQxQq;kh1^6V`t@4K}#5Zl=xx7}rtW6v7BF2X@yW+%VZyM@@rrAcW_< z+ni<{UdSeT_{=RsQKWV`Q@+>taifP>6lcAOPrI8Wcr%HA%gLbQYqs^rPEsxgy(ngh z^6~fM$xnf+WXY7X!)U!b@ERr29?_?-iV;IIbz*p!>!gy?+;Y$n#_ zqGw|F8k|^`Mj@{>V^?P{g!9YWWZ^2ci%UTf?D`v)dUEk_Jk`Ab261P~@n`WDpoF5-i2pYOiKbqYhi4XekshO(FiOuR zNSnBIgn4XtU_s~6YWYA|R#bHKcHrARnpeg(hS>xH!)G?@i4Y}Rlbp}#9@Fk)@}ot4 zE(YeD=1$<0f^pe#)2%+3<=Xpcq0ln2sF@ILJ!^8ZpS2j}d@ zRR6K}d1-EO|HsK3{H&d@bzM{o2YeC2g{Xr|J;=3jW%Bq%+`8WLw&~Br@8CbXp>UN( z)jeGs-`TR91S+R{D**=OVqP5knTxEQq7<${MA*#7ye|bSiT!U8vDc^D3Iv3z0#S`+ zS&RT@gYpA*^?aI==PBfy>R{9?F%%OQO? z8_xHn4cUXwB>dPQbTLZQg@(_Lwk zZL+?9Z#iE&2g9TUf^CtuB;t?E4~ui=qSsagr*4_Cam znKcjdAf?>sN=4M}t_BqKnMbe>U~a|zM~{fxhuJ3nvT&FFTR6Ey+@xkfMBNJoH@%!T zEzu8=oIaJ3i|z_h%;crS{307e@q_oig{x8|+_=>x|GsBxlJ;>A4(*iJ@3@X0cXEwW zskx81ck3EJwH>S7S@yJB*)r3sA;%ACKW6hi5x8h+1rd0?6=uHIYQ4R~FiIt^aiRTH8yGEBWv9^g(?02bQqg_rJEvCril?G%rSaUzxthy?NhEsl>kR_XdqmT z*&j2xlVC$Mc{<*=S7D6e8X`+`m$Owb$cy9sSz41DA<*kN)M$F>eYsTZc5F<3^6=fn zohyvQ%WFd*5o21j>~qE9o(~f*=l?)&y!(1w6U*8HSwwIwcO(CU5GOo{MqS%Y<=cz* zG#y`PB@sCdEl_n0^WY2UQFT%u7@PRi$pqiSjpJlj=XY>d)?!9x&qfOhoSn!uN+!+- z;5!0cjeL#dccln;hBu?|o{<1QbWd!lS6%ZMpAcKeZISUO3|z;y3_SW^;S@Nq^IKZr@A|2%0r zPbnyA&YZ<{@UT76_&jYQ*f)C)`gnA9SUx9Wf!loY# zvuB#9eH#2kdu5(I!~MX37&?=gM%~3IA76-$X>xzGEaVBSLW}MUOWO3 zlbw9Cr92#cLK{rS^>FJHyTdb$nTGCva)%PyqWq1zXP|gwqP!`o!p3rKK@zt&rW?tR`-BRJ)$q+h{HxO_1{uQZe zKTm^@IYBW6jW4B!Ap_WA$~1h~4lPL8cV zK$Zr@YGnj3Cr}cddeH zu|m~_;b+6U0+*tNsVw>YL$ua!MGt9rYgW^dP==zwytYwV*5{sc1~R(GFO!IHL~-wzZB79!zu%LTwk&@r zKwLF*EPB|qGF-k+=JaEcp@{UVbuk9e#(=!H6V`YiJui-HasBkk#gWv;tzf z+c%3ho)0Gy+Mb2N>AR*?FfC~S1#F>Begf(&)7-5 zPa)SfVedklw=A%xQ6DcOUHh`4hl-0mpXrgkWs-MS9NK^~&9+eR3}b%LNt+@@+P~hk zD|+uM3sw5Gf{TGIV0d^{g@y{{KEwULP$zbZU;|==S5SYL%`HnnZ+Z>r3O>?`0zzwV zXQ`e(@nQWlu+NNZq8kAd20uV`eNEED{oTs5@f%!LN%ShbWAv*77wB3Ff|u$sH{KoiAO&v9^gz7`sSCitsR+zIRd#!=y#F;~j?yDw@hMmDmOL`7@&C{z%4*c1)(ow zpaRbhw^jueQhzDKOVfb6Tcxw1pWjrT&Wy53FiE88bO}qs5wzA`IMQAG!xxG z8-89kIc2$_vU8G$0D)m)|7y8_uJ(-E7^{nTt%K10cwZ&oeVB|Zn|W9u6z%s#vyF>A zR=AWk`B2-wayZ&dnDgNX%6s-fo#S&7=|qW%U2w}VIsF`LRnv6tff&!|8=tNMG-s=N ze|*x~#MT43<)?7af7 zE~|D<#|PcULK^)F)k4woORZ9L2jappPVZgDnCb5jCWB)~rj!QktPTo!!SbwF=LR;Z z|0}1?9~H;E24HKv?tG+0F^tNE{&^U0^;)`5BJ0!eki?P(4fO>6xxwF$5|DOVE*~|? zgN*BL>=?-Cjr1A!MyF5(GNPMh3VG&wph~m{pB8|v<0+Q1JT^> z^nt;vRd4iv1iSXswOjtyxoV~Fkuu{`W({SsAqBR!oA?&J+L+`|RCy_qd|zjQoOzjq zrZ<6W(L*|e)RJMO%CA$5y4~M$bSv`p`GM^M5BKW89UcRlXtcOm4s&knL15t71%YxZ zj*RnK#2h}b-(^{q@)upP_payMn2OW?#@H+ADn3^#vmQtjN>JC379Qa1f)(!Q(#8$hJU zrN#zmWG&YlE{D{*TwezJ+KY{YVh z(Bl8&9OGTrb;{TrG{Zwo);aSWP6x{U_bc{wB|NA?!6Eq{ zrSV~$Ozvg%Kb<;n5f$uHBa^u&fWJ%i$oVYFOfYptY6>y|>?5G7n3VcrU?*{LheG4+ z*@1mX<&XBH*8=S}+sG$`*3+x4#8y%HuY2*uFF@`~YVK9}?#xl5&GZ$mt(Te$X56d2 zHzxwG5bWE00zKK;)h)~#Eh%NBgg`b091Q;ijw2`Gi}SBa&rW>v(Oq^xN1I1WEYacE z^Cy51Y_anTu3?2)C^KPyNUjN;OH^2chAjNgx8IzL1w$Cu3C+&}OCKk0G8;-`k zxg2{2vk?41^#!i_1QO~q<0QlWK$(SpXaL$UnkPdN$$(#{S~C1{0pO4Zg}x;w>phu; z;N42*uODtN^YbjdEMx22qVvF5){p(DM=Us=%~2Icmrsx8lx0?AtJ%BkwlEoZZNyy~ z0$ytg3X}XldjQbvW_$1edE|ql(y|sEfL{&)fO#}k|5DYbi{Puyci*-wmTuRwJk?HN zoubIF?!+Bes~H{KuAcdPLGXli4UFg4B4^Iyo~gI|VEQ^M$4>9LPB(vIqBx{c-Ipkl zSEBVCVFMMoO!>gDS|9j6i4uq&CQv^3O>pYZM(vK_ON=hN58BA#^m{KD&92Ki z{R()pp!JQzmV>Or`rGI`$cu{cTimZ5Ugql(QCyI+$WfjB!22=W|kN&*0e{qlh((j`5$Q@TEa;9>W^|IHZ2 zJ1^8Cfb`jMZ0sZ*PgJK;9}0O^-L>ZXFiZECt~)VL)FsMhFhXlx8jnS6QfNg>;b7gz zI92B-@<5b@?I9|7cF@PCV}KmY_vDS$i)eD6M)ee(?_09&3xW%6tF4ArZ`7T$TJ@xK zu9hUnZ6vf=PU~Kqik_XR#~-y%`TMiI3^FVD@I%u3cWML6YCGA|sdB|i5sBgq>E!dZ z-j8vd(tllEz>QTqqPVG*(HVJR zL+qG1cASnY`hzc^nlkzqCo4a}Gsy5S7rT{Y>yg|YcjHm60&c&FklMt!2yvR#x^3?| znk{h`$69jw<>$ZA^|XaTHSg>e+l>Sj_>>8GzYRw+3wvo-uc_kz=itaX#NP;uw`t1e zb`*#dqx;~Lcl1)bta)|YO9EI6Z|Q`WA2z^T`rJd77JSLw#Ah?{neeSvdzPkgwcX!< zM`GwZ@6t7KpaU~DPh{M!hoU6-eR(v__#^LQ+DXx*{h6|g%4f?c#b%QqW@4XNPv6|! zw_XsL<6Qbl#^79RFsnkBK>oCP!NHp4Hjda8;;<0wHnP+oP-7urt%X7>aZ#m5{NEH( zCH9~=2S1TJQjsl!^&D~b7@vVg!sn&#$i3w`>OTSe%!Ok#-_Hy#qN&|NGmHW0HD7=XLb4l_1tEfiDz|B(tn<%`?kh~q-8A8js4&(t5c7MY|0y$1{8zm<@G3#d~^FO zymh$zcwd}kcWDXleQb7%n$hw6S`QDW>jFVXYXoLy=rb=s@U?c>ilSjbH@MoUrr5Eq z3rE6b#F&Gy8j`8`O#sG~$?YPU7bmX-!~gIzZxfh`YXQm^KL!3)w90X3i8o*RCaItaxtU&DIi_Y)!wED7Cu93^gl zRWCp@3la9M&sgX_jk!l=d2INt^2|dlsL&hwz>Nzx*-UzrS&Gq*|0}(jpI$t2d`_?@ z&mEe)og*gN*O$&%nI0z5CGgrz#>(l1F0HZhTBP*Y?efuK^mD$qf+;5tn?4yHrZCtA z#NK3&!6cBS8HmSJ{g(emu;8@4%PHW)pDjCedbEZ2HkMVbS%<#oW*2a5^W*EjIoHNn z=l_v;%HuEBb+SNR#7h$-xK5g5vot8i7hm)zg-75WAmhvFiEPvQMIXrZ47VBB9~qQ5 zXdPI*jmNj;JxDKMwIgdv>)TdE493&XsEsM>T{WuJ$b-Ryo_V!bY5S5!(lfH%WfrDa z4~P7GwR(FNv;P%WfY3oSY=4nwn|&^0U;P_-Tdue89o~n8rcZiT;AIeyyH%3$46Z1I zH8M9*`|V;5ZgMB|SDVwY_oxhvw>WiyD8ICbag5Q%D!ZfjIiW?>+Q3#y8~fTy50Z~X z42i2X^41SJ@IVe(A})MyE6n(0;RPqPe(f_wVy*%aS*H<89rhUg`*>8=;kosALGGZ; zz&IkOzf}pF@K{MHDF9A3A6(U_{O5-Ohu@-8_5Sl)Hp4?g#uTGIDWl$OqO$1& zI4opFOEXJMuw$inAYQmQjpq1gBmcb>0-+2!@#S!=MR&I&)Q| zU!6_hmIbw#Z7pa;j0%90l%;D9OfsF?|MiL1DqBgkS(zN_oMK!OkT?{-jP2yb5}Q{5 zMlXw)EYkN2UoA%COiUx$@p-&C*qS?!%{L322ulIS9o3OU2joXZq7L!0n!0UfTvq$5-g&2A%JpczZ zaHc=!>-YYyLmridT-W}8RDE|i)&Co}krm0xAw+iJ9EXHt6(OTgR*rGZknFufI0qRy z$CfQc_Bys>&+P2IkCnZikKgnBzQ6BtU0s)dy1Fj!-k-F-IrZIWh`lL~`f#HG zNd$eouKIUZaV6TW!L0yo#GLE~$=e6A?hYH}xYr}5wD%;Fn$gb;2OP@Qn;Fc`j9PvZ zr1O$E#CZ{uqqZ(jX56VH1#A3JEMD(q`oEoS35EXy#|K30OUv<{IW+?Yuhhorz%-J* zc**^=M)ISY_6~jN>DQ)3|eL5$?Vr&TjIlFovH-gIyhRA#P|lrqg|_yS-9rFF)e&$uJ@ngOrz8dmGA@f z=_o;Kmc}y0$=QGS4>5rd3bW(A4|S8Z$(YogrlW<%1cJZ2GU}AZ4tOmSYcQE*6(d`jshlOi<#DdrXvHp_3Vgm3QUhNMHm!>ky+GpJePaC z%UO;u;9iPev(aw>$-wAT#BOL>{dvgg`e-(DPJw3#3AJ>qc8iXG(x84a|H>d zqK7^)<`LbY&<6Ck`0Vm-4i8j^vpC zL$FIJvsF0Y%j;ec6Po+0QAgYT#E5{}H2ifc;wj`CavNDy(|46z_(I)@wN>gGO**ev zH~5Er2ZTf?(yHXGw@O&=0}Xpb<#h@Es`mSI!1%=Z`wz6l4PCKd6=v~5CUKjh{w&?H zWU$)#@0U2MB1(MP8O~%Pm)~a?au~Drv&ZjK+7zMsx~?p@!!lYZoYdh(IC_%m%=ZIp z(dIaEvYQNGo6rFFLKN0MXe^}gQRH4Rn;aek`4fl!bbe-PT-?^9VN~>+4?LlDu_ML% z@4rL1$eJ0vL+6nAj|j5Iy0$Ld(z5W3ia(V0CK3!OF0MV8R`Y{Jb!`@~E$>V8-tyhAKp^if zNTDWA;7LSLPgq025jO0Yv6XqbFf7irCfN>D1TQdorZVk_)cI~kHzMy?J8BF!!JccJ zZOK*YHw_qcI+j?H$=o>q^(FACSIxTD8Ei>CI=r{v+5;c0O47?gPOE9|S5)cS0FW^Z z#jqZ-^@l!E-ELvX>QqC(aYWFmV@~;=dR&b^&B`94N!(;o_@&FT&GM9ZfpBX}qj;?# z5cNd}O9H3q$PICMNQQl7mDR!%oFq5NXxqDoW^R(BX%V9~RUHT|O~M|I-$O6){tkaV zsS|3ll_qs{%_rQRJ!;RdskF_30&$bi&kW~8{`cnJ*xF9zyY7Ub{g4*}e*vmnLTvYL z1k24r&Ef*2=w3v-_G2p*`cR3Ot|;3urIkCPDN5DEbgzt3`;`04&NaU=drJzwzxPo8 zh-X?4DvWhIGeTvl5I9;)% zycuHEs)tzgzU@bqq}-okTIsAWK4ibI1O>yO?g~hIwW{s>1Kv+iYp$-7hT`?Q`a23= zc0KF+@il+0XNPhKtD%38w4Pzg&b{vKtkeVa$sZ{nUkm}jN_@s$1$ZOfnIcFHw43DeQFVWcf8?Q&NtS!RnkI z`ZOC}x-rzRscT@-7%`YrjZI4^P0Si`C4&V5AL6eD;_2;deb;f!r~44nfF~hh+A{K( zFyyrPbu8Y(28Q>FIy65An zf?__$2M*}=<&~fCKkOPHkGs0^x-L$C6ai&tZj?W0>qt^;K&}`gF?7%nQ-E=UDkxWV zVef^1zLq@};w$Q35?a-}{QLov?5c6VsR)0tYDRlUEz8GN}s0g3lAk7mWHH=H+G_GXF zL*Vh^H25al?hr$Jz0qes#YguSa=U)3Yv%7hj(x^RW(yA8|IO(5;AfQ@8?Hi0Uts{7 zi2<4O^^YsrE{fF*P7!0mLlBMgXh;6lW{U4Jt)iS;Or-R z(@nEUv!w}cwQk6~ZAixQN$%M*3_1`p{2d=0rDr_GJ_VMtgBE~%e*+)-Q1#k)dj@v$ z*u;DsE5z!=Y*n0 z&A!NyE2bAHe$mL!%P&i))637}1AbUFP!LA*#G@|K9!@>`=ByG0nr*Yjb&8rRyD(m&H=oDf)u$4a< zT6z13H0^API9fy5g&U%zX*X|{Kuu39+C$kL*(jd(KaJj$k-k!%Uh#ck=;O(XXMUGM z^v2ZD%znkJ&FBC2PEV4qN>vlx@UP4GeL$CB0Nbl(b=mUeRm--5e$%5@GWFyZTesB3 zV|Yh-Prh2W=M3h?9{)-egMPy52TAXfx5td#ePx3P$suRI>~O5f4d-+|l_-yv3$=t= zqDlFr?QnkL zr$IA(aTq4ydrzrTV4n5@raEX7b@Vm+j|?-p)#hng(}mcPyL>~!Sx9i|CvY$RkeKaujjB&^V0`98euD-y5 zvbx$n#p1p>MQze2IwH%<22*8^Bp!Wx$fHFVlYgs_Hr=?Pt5|ey!@0(kwq?)Kd(L4l z-9E=l-^ut_7xu84bL2o{)6IA_x{}(2iRRa0%nGF)H5A@SSGody+d9n7vr#Js{dyF& zxRcdy0cVC7Y{mtVyPN7zs~q>n51${* zO+#B3;xfUq2t3IG&DMY<=FTy6so$W&W+C7E9)CfKUtsZFTWku;#O;`Dk3^X8@&T&I zUdpM<6HZad7s}x%gT(Wjlw52^br*3Z2{Kx~@nJ=Vv(nZo{+4V_Eg}fq^)%`}X_W%* z6+Y;eLUO3{^mWGB=TnJ`v{U^Z36_5f*!@dK*Vj>| z6ry|d<(C;nJgCY7ABkM)l6r16?AYK2HFYPUc&>gRA$#mZro(_4x$9GyZEUs~o%9n%-eO)$O4TadaN}mzd_egdXd?ho+;62b#CnGufBB zH$Qo?W-%f=;+XZ!&OULB?)4_=xpT&sFuX4Rz&>9t@B4}vuCde-o#npLrxm>_09wuK znY@$nxR_L^mXRkcqy6zQ1|NE!@>sCddxi-w=7`y>7X)f;p)vUW)#~j}hE-iW!E@b% zd34RflZ)r6pHKD?lT5$P{nPeCp&>MD- z1w~ips0t4VeCyImYLh=v*8n*xd@?`!M9a7ATw!yTwe22@c3}pG-VhAJ(Sv@p8{FBj zgBr05RBA9=-uDacWENQVqRJdP5V5GOo>sDcSo0$ryVV6+h_%1R=k*S2WUe5G+H%`z zCEz5Bp7fp-GdD$rZ-qe{f~JIsL@BnfWayGcdosXg{a8HokYnQ_Oi+zSC1Jg%#Z_zR zbvA=ioB)$jIyj^f<6Jn{F1M*m8sprE?u)=juW)tKK}}N#TkW$1Rt8L5#y3_B4Muqx z6*^lP0jpCrq`rbrZ{zWBmfPrbMAITMd z5_gz(c}cpO?vSHwK$G%{3SkSfcdDm_aO>miwx9>s;MI@dj9alaw=vJ0*6cbmuo_$q zh&ZSTC}zmH9NpnY2-n|%b00yniW#Q2Y&s_I=5;19*8;!w7Nz+UTySxoV$7Xf;T%@AQJ4C z-G1y`0DYLlG7gI&W<%nOqOf(fo7Lt>iQt8Ht8$)YjcA>=-{li`Oi+m5)Xh8chfOWO z*IM6}q$tGbjmn&tOq7kMXrX(8v}sA1A&T*~#ef+~#CnUFTXJP?13Eb)m$UQjJH@4y z;efj$jqt+T8yF@n?7z_d7c776c^)J7A)>pc)z}?+wpofrgoPv7*amLCDB=rrysb0{ae-YxV+Z2@?~8-XySrTJ|N;{ zXCrN<-LUOw_*_-c+b_4Wf_-vQjdi}cp_|5Tt35ip;mn0kDP3@b|KxC3-xcX4)p{{6 z_wDO*Ugx(i3z_b6<97&~3&tudFnqyR^o1Da-f4 z3pgO}S}pBS$yo>B2Zc_-KKn-#T#cQyxTW#&d;{A0uv`Q*tC$l`tEmNP$vClV^%wFn zRKc+qN3grJ=-`=TbXw6j=@-EyeH2u}Sp+G+j33It8p%x9+cB0BcfvS?aj2)k-Jb*} zKbWSs70ai9@CGAyX$4={pQnxz#wZZI#A{9){`Bzhqj*8!6xbH^hPRx*{yH}ZhpX5p zNXP!Smjl8oPZS*PUTZEsUUPhZ>>{6ymXp4VHTle~QA@CeVdbWh;AC%QKAx|F?Q!6i z@>ZR6zd}uP12u@>siY!!S+#!?eUV320lIF`hS*p)cl~homYq`20yZw|-Dp?Vqqh_9 zwQ|8MlqaSbf3Q+h)Z6k2d9ZdMA_yk%d)J|i$gS$`4FQohNp_Pa2a7P6!U`MbXD<4% zq4k$f^a@6W@i>gT5A+1;e;81TttPzS$nT>4K|Eenq))e9i)dRzH(fiXz{`IF`~`x1P-2mc3z{io z(?Zm*6P3~{;N*I-w2(McqIHL+bB$a-7+s05e$g-N7Ig>Dp(dzcbI5A_`o89csri(IYcgrYKc_Jj5G!T7?G-wOhkV9{1% zT0hw)MqtUc8b-Isgu8UctsV!UMtae!Opuh3k58`xd^X}O{*I=m3|uXC`kAn`n+E%N3a8r&dn>Z z6fpAM1YrOFM>tb)Ud>$_*2?-F=)6ZhJ@}5LULC2`z!Pa=q}}u&b9#PETfuFv(&3iz zHdxR>NGxpVgm*h%M>M9bSZ6YnaY4xf*A=oKfwYg|1_w6ErwT+7n;gqwe4zF@tdeI~H)+!|t zix;^eLa1bQGV{CEfx}RHMvxN4#8^2Xy6)`!ywad}e}CS=aoXmyeqYK+@j=1pO-d#* zD+iu%e_?{(y!Da6$&Z1)u`VU^G@D4EZ@p0ZzzM%})9mEWpz!JG+Tfu`T(Z>R#TPp{ z-NSn~#o;Ox@mrt9X||u^8^5W`w55m-0vFX$t)vp2F+#s+G$@-*Lg;N1`gDnBs-h!2IG(Mu>e$c(~-!Hs6oJIIn(fuTnDhN6kPdT z+bo|7Buia5a0oW8ISc0g&gdmn_~{9wbZHun{w#J{^A`G1%Pc#Zvz)_RK^AayJ$+NUH3DApanOd5_ z;d*Pz#5QRx94udS4c}TYHx_I@ib?JT;Wj*e7JOVr_-SMzxZjMc1yyr4UEpN-?`B= zhiO@|Z`Xv0`MJ3iE9gdAx3okkuNJB}ICA}$kdGD@`278SbMNb^~ls`}B( zNyx2w#6v?I5Z?miP(uC%Kcs!$yZr#@+CxGBqxC=)0N7+fWuD63U9}y5{wO|~3f8M! zaZo(5k*-zjmX2BBieTA^mJhGmRj^jiOo~7;V{)Cvi;`XmN?d@SiVI8;WWQ>5sj^w3 zozlhVBz#9GNkMy;)+R=l;L5`v>f6i)uI#b&WuD&mZ3~r&4Q|*}-E}F;Wg*jJz=X65 zkQ3`&Q;aU`cAszfN;B&q{O%QAth@K%CqdDDXRPyyhv>@6$&;^BK6N5v z)T2|~%tG=;9I4u>m6p-WrCwW7{&8AeOns6$g{}6)6Eq7xLPogaH3V79@c0qKbLog` z6H(+A+um6y#(;sT&_e1q$P`53%7HD?i=@RjqRF}48p=j%`>5?=KE?b?ncmy3`ThE& zw%a(C^;1+;fy7&ycR+|2OT|pZwHtV*LgL@oRQ4hxOi^f6=R2ePU^s8}pw;gZDp@Tl z@$WAqH2NA4b~XIk`3Yxia@`Dspt1zlTD#IHDb@}jlujYn;p({;_0*lGMK(1s3~9FN zhJ(^i>sk1HIDgR;69w$%0NkH!pD%mn`3oeh90q;>s^A0L^?3lqaV@xbvldU^6v=Ru zkg01Lw`obu!jrI2!|6rle``&uO1GHpnHc z&m5U=!3S@;jZ@80Oj`uBVnv=c>lnrWmB#Hh!x*cid9l1U_>e?E_%lIi ztbn1f$BXTjP-m$Q-s&!b5%Jf3hmF9y)gHmH)<1u=rFGX&aJe=`+-Ogym|Q$W9pshv zUizak33Ag&uS|KGM3Y{U`VT7xzX||3ZsseK|5WB^nR5*Q7Q?eJvFo+Ik(aea`SghpnU^+HuVong4`q!3a*6~FPG@^5)=Py z=Vl!eB%u)n!&i0F<^3e@^8Ar0ts8>o`bMrvPR)vV3pLv^1M5jF97Y zH-la#5|zh!iC;Yn$?%$gnW-{=^}X+6(&{kF&W)-KU3qHG&&OY3>n|q*iU4)ZDNCIQ zu-x>T#s@#v>xTV)11p1p59}fl{$O#hymzY(s+BsNfkj4HS`eoa(#rIYbdy|+e(v5W z3k{QFh3q!Qf^ZPN@}~Pm>p8}!8IRum(wW?HS(vmrXW6AN(Rn7fFCQ2R4qoUDe(YTR zm6IYBot9<4) zELIfHDbP2@p>)iLlG2Lczcx-_Qu@5bI{iO;u?N~pZXmyRF$;N0CZ^Xp`|?|=YyiqC z6J<8wGO(4Nxgmd8lLU&XdThnN?W$fJ6YvcY0-~ihd5?42O>Z^Dpw~P)8UY_fk@_ol zlF!c7=zzb#B|Q(r=W`*b)f&_mu0S|2L!5)b2i?Qb68d{?P(t|FINYDSMt6OG+}%^R zXUi1oJL5#Y_0ti^|E8JEl=?W|tS@>qwNCxE{z_By=8*p7@;RonQF+io32>XQEEu{Ud@^`b_ z-R98=nxH2Ac8>EmBV-)cB2Uv_lm&=gyDb!LbJvs3pxDBfW!G{sxJ&y?4D|>1&dogc#x2K z6t=x0;MY;3wJz_9V7k97_I4WG=hw+}a6*wXv_bKf?h{IMnS69jbbbG{ryACFa@JFt zJ(G|)Xy|GDm{36B(UVQKxQZ0_;2G9+t9!33=G!$&1XoKM`kWg=c!kyijdLrLvt{v` zQ%Ty0XxprWm2UY#sapL||3qGo=v$waZ;dqo0n zlY>}IdKfAjYce7MP`#=2#ybrwM2~TgB zn5sLA1#~m3_9~#eR@q!IJ1yUxF=&q2l2Dy@{mBaSOWrhA0swjCgy9S&H*6U=NsL(F zQZIfp2`~%Hm__U&cku&PYEE&dC|klDhN(3tvKGX!l?RXNuv)@HY51ld&O?3F+WG52 z`J&*m$JCm2QI2R2+z1h^4#zs2VrDN7^pyI-LeHbAjYp!`WyA1!Pn^fs5?ir*tzN`h z)3FzBi+R1TgOKT?NdYy4htrt!GwmEUm4R$KY&P}8hciN`h@m`2kSdCm1 z3$P#9P{iANyQfA?KHyG2b0CfJr5sfy8T5!=Eo%ozF2%z5%7%T$FY5W?ro;JFgxOTj z%lSRN#R*$l*BB=4zC=ox;m|LXnCGL(ZE=q>NM~yMjW4}J|5YVPX`)PB0XUB3g-C`a z;I*E9AKP*mqI2)graSe3RNa2iH-sN#oNwZP z?Vf>aq9w^*%vtYYu3TIpPctL7GlNV)`lLTLPQKlr?vPD!e!WD+{S)dnuvxcfjC5+$ zkF{kWgq4d$@T(qZ*;Idllk+F{a;`lz!LzC{qF{p|PKKTsnu3se0xu#@C>fa4dG8nC z)qLl_JAmK1WLtawQFvNFVPFf8)9u{CXNjJPCZQGx{@3~C{=BjWT(wTQLqL=f9wsp2 zrfUQ@#2;EPA>VSspIAq{;n9%Qds}|isM4Bpw9wjpA_8oaF<5mklBDZpkNiFJ zm;I|-w76@PslACiMyKbmg{dt_+}~SQOdmWU^mj-t;4^oJD}nN|Q=avj-JBF?Vga6x z_3C$MZVB#?LM4LzI_{H$Mn@fg!Tb6=d%sqb=rMd}nBB|vhkWeym8&-c`wAPrs(-t% z`U`}ocUa}+ZT zVe_bd>=OeDH|kyxxsjGQoIlJ>W5ge9oj#QKswe8O+_!1NMI8V-9@mDNq!`ulRzt*G zLNm~l9c#=@44)CnW9B)HUlZ(VLhgYIN9C7nQPp*E*_}X}VOT5$Dy+zgtI`eZl8?vH zZ`eh3^;8Fe9}@2?Qeyk&D?%@UlxF{CF?+MH<>mB86$lp zKMT4ebqw4fe#(-N^1?og1};KjcT==|DTAo;J(ku8dVIZ0GMTPV{t)~0LidOVB)BZK zRH~Z7BuaLs@D;f)!(&7lrWCrYqhQ^nYwAU^hvXkrVbw>q)|r z1cj(Pz!-Z@TKpC8)_x~6{*l-#w&{e}RX#CcP+;lF{<>*H zJ;k4p%ufT1#($FtEZvX0daN>U{LsW6KPe=CA@*wzjHbZoQ&SaDG}z>T-HfS9zGl+H z6BLEMVxzKxfT|^HPEQQACTLM?@Q^AlGI{J{!&R)tFii2c!Hzkes2&qoWB7*jk*KM~ z*UDs9*6r(;;Aj8E>4oe6uTlfN2~$^O3P3qC1X7jO03)6Ss8YkJvND;p5ll4aW&h-E z+y%M_5W_`HY{MtP>zEL(VixN3QLLE3UI@qSY8#IM#iz1KEVZe{`&Pt){pFhQK2z_T8K^ti{3(ZoV{B{z`y{1{;{%F zfYA%OBH#q2hHsj?HwVO3zi=A+77U3{?sSi=qReN;WM-4JRvOu`BJG;cSeM4JzGu-@ z4d=&DVrUbQiIhbiQOf3%E|c~>vt$r00K`m`UzzoIUG5*t1`FDr>kKlzi<2CM89#M| zXitl+)EI=}aeE>su5)9UZait2@f-vK+Wtk)18G+)sSd#XAt?YfGZlomxM$-|t6m)a zslPbkzMH0&W#8jU)PgY^@=X9&RYL@srU42(IU#6bH=5H*SPkf_em=G0{|xaL;~Mld zJ_YNybuE=&QvkQzS%2UW^8y>Bv;|^6FKbYs=42H6`h7wMO&^kAYjf_lb@`NF!&x+69i@4TT z;&Dfm6mc%aI-`rN#y$RvH^C195p@%kh~vU7$5=+Iuge+>)$jprE= z0JW&xMIqE#<@ZV1!xcfDmPVtm-m0uZQfEElR-EszZEe1TkI zftb%_teMm@CjR~iu~Jc^aI^^8a-IvYcg(yh2SMW6golUB00NLbDUAA+lv4+@K-fY{ z!Kl^a$LrV79cf$Ft&aeV!rsE~RAdi#PenMDV^Rp|v1)Of+mWlHg9$#$a-Z=_nR}KF zczb6Q%Br1TRZ+Eua%zer^BzRBmVgd-Q7Xf&gg%-~mm8OQQS(o@eV`a$=`#HBFri1Fb4tsc6_b_}) zfVcr;3~!n4W>XoNi5~g;&M}12TKm}HdEI9f8yF?rCi2T4>IgJ*%h2B}&SC3eMc;$J z?=P}e4Euern)_B$Q%`s#Q`YQR~C{i^;2|S$Fs;QOj;Qv9@pJ+ z@}VOlOH7Mdk{d*57@LYb`)0vZnA)_J$b_Sv6AcSDA-54P7aY2n$3wr~ zCYR7X9QlyNl_`5g?i5)+aUDBRKk*MK#kPunAuRpjk`X9br92%esQ#$wpssTzg@~!$ zYNNZ%`^~gz!{2SS^1ti;{GQF*2@Yk{T&exNMdfBZia=wWJ-8kZzL6lB#UJg$o-6v~ zym^XX$QNgUY`RqOGZSnw?<9k@2fIJmg=b(Hs)ww?WV=LgF4Rzy?3$o6{UJayt-S2l z;fL6C1VE3ESt1AA>CF)=UQk4}fyL3A!#b1pwweGdS! zL{U^aRU1VQc}+VU7hfB+)GA#&?6W?!6wy#JINw(S+Nq4TO&WPCk>;pnSYRldLo&Vx4{AV)QfHQJ1epZ%| z++Z>UI{ZUx_$B?FOf8|=bbdGcjMl$;E}WQ$O=;f; z#3<0Zv*fUBqwap^EnXO&b6d)F3}zEsbJzi!<+!VA>L2pJY9x|F_ji zTc1>pX!@8_1LrqNuFK6lKymvH5Q6+sp)sqD|KSqcXQK-jr!X2|gG7wd=2BPIO3??6fz^iH4-_JQ-nC$< z*)%;zZ~H}=BslGeU^ET-T)Q9XuOJM*YaLVpgb-KDe*)=nd^Xf5$@i4AgqHq)-d1#@ zJpw{rN|Q4@0KQn<+~=T*FCz)lzE>C8JIw@7%pKTNW=VO@`~6&)v~OQ4lOixSw}Chx z_MA~J!J<_^P$A*{{=_t!AqIu>kl!2G5{!CCCgH9V-kS$Cibn{^htuH#FNd;$_1GaC zqAuO{LhF-{sZH_(?$j?H)aVxsPoj&ux6*Ae43PJ#0U+GO7c~$UGlarGrBHc#d(We` zz~GhjDbQ%mc%;aZr#Jr!M18$2opI$q+P5c&g47FIv!7eV=kr5hg7@&2a9OIf&&wN& z{Kr@Hlt9SUfT)E4y?yrf-ozqPE+*yJO%mdg z@yEBWe2*({@ZebDYa@;ESNQQ#exH7@t>&_%dA#tUcT##r`QkRveZhQmVkt7IlfeDZ z(Q$gay&Dn0-e0j1+>v6p#FH-mww4C!H$Y-75O&ByovKOyGvmXLh*f7Y)i;0IlQFLv z<6!#(D}3siTE$~f_dsB-SrND8ClquaLgRs#hpJ(f5^!Oqp=|L4A3h}kyYXnQS#FvR zK;B_&T8O}dtUCxq0`WOemm2*$190~!_nyy?Q{Zu}{M7u!P(`}!Kdgi1#f!46sT=~ zs8W4z*XfWhG=L&FXaRnri5K7Sk;L9zf+jIRAW66SJodKHO*?K1xb57;^4LYWUOzB5 zQra1`NH6^*{3-IBf%(f#7FSYX0jb+)&~>JB7c0Z>wI*L*N@*|~@71?CZ?&7LT%>=R zE17_x1QrQ_xA{F>_CpmbZVz1Q19|Al)lJJ+f)-kjLmUV9f78W$tJ>#+bjLwW2pU!o zcsG37BLZA&?mq^KT~cn5SP{@5Fp!HGfVn~)8ptJJDy3+Ej6W!jf&77~R3PlD8Fr!% z?9c@ua=jE^*?J-c^0>!AkS2U!9HuEtzDuIA9-!{h+#ZpAix|>$(xm=rL&__(80h62 zS~k-)TQAbJ)5i|$f6(D0^?E(^&T**Msb6n8Hy%$xZ;3YTIv%%*1G}E2NlF1*Ry|UZ z?8K3ynn6NUZ-dtNyv_HVlTtB=ZDsb2g;ZdO8k)$F0|0CHJ_j9fG*hCNw@Z%I{MkSa zXx3@9TFg!SHtoHbw7*p%i~6MAXM5tjX-9${De z!)=P3zS>P)c11q+3rIZMaRneUjHkf32MQ7IHP?Sa5-!Q4Zq97GRq>m&Qdm|cjo8$a zyJGaq3Q(9)GeFTd3ec;WO zPm?Lpk6bgLf>Ygo$ml6mgTH7Nl5??VzLM6QEr>0b$ZyvNy+C@~QhH%eRxVmZguR0@8Tj|6a<#T&?`5$hxKYF^c@CJU z4e2~rv)P-M$Lp|UV-jtRGO=w#)EPy%%!JTl3sB)>p-h7j8}j@@R-5S|vel;SZ8noS z2~{vKPo9H5Bo8DUHxj$)G7jB^kH*N&Bqb7BHWD1O$6fbJ(=`42=@m9hcrRKb&8WD0 z&ypx?-h!B^62*;g?wYW7_6MNET3WgP7HnO^JWyA@Qh94IlegYS+ zUnn5vL}}hUfxs`pexQ9{RzSBNgx|f=!5rI}npfNh0$!=2UQSAHvPHq%7Jg^s`L3x#Hof}2_^c+8Q14;@8(Nt;8>*X%wgytNnD)4ErCG<`ZvUfAGzTHmAqx!twRhOSpZc1VDu#knf_ypk{&Lq(lR|;LP;>tFO?mJi|8!Lxqc>7ew*U@8?!b};4fi@ETxsfd_uuP2KoAmH4vSy-tVsORuEp znIX7=MR6y4W=IiyrwJ-HU&G^T#Nfd1@8q*#m)i9~2DB>M6MjtF z{M6m+aC1CnyZ-8Orq#EGs??%(KY810g_=EjpFQxC=6%v9F4BS+{Ju-?>%WIUAabsY z#ak~6nf|G@Dc2eAP6R9YoyYT^iJhnx#MnC`zNk#<2-18`2GXOP2^5?V0al}18X^jD z+WA&oojj7-01U5H(hgm4D~dANu>Ucl`uP*Ba9mq>UbvXno~7HI%e6G*BbT_-xLT5u zLG6bmaO-Y*)hITlaQN?m)!X`mZeetP8^N(dLzlJzT0~M=>9XpP(O-Bpnzwk<(S{8v z^!n|G;=!M@J{>SYP#|He%p~SY-bwlAqKrZ5PT2*JMFps6AT8Y+ystm7O(~73iuiwHw1-jMk26S ztmbyz8E){n+ApmJrLmqW0Q^NgU=L0f;fUUOgCC9-SlKduf8?5V;R^+awmpdK&C|N$ zirpEQ>%z5gNf^8#HbgE2W%#O4KW-zi*ME(3>)?KK63g>Ww3~V-k+|X4KDHIfHP7oY zc);#yu$N^%tK9Pp#gy}EqflD%Q>YhkFOJ^3Z&j@boYU>dpHQ2=^Lhxs!9s#WvLb1n z@An!Dw9yqL?k-{~#wwrV8)@+i7JOft*U7$V_eZh}B|3I%&|`CP3Fy0w;g>rXadT3r zi}MJ4C;vlo!^)WX8`Q~*S8KotNHWLXls!sgy;xPaIugSBR7DM>!x4E60480M=Idj) z-UoH;k1tEI&++`Sf5&&x0I4ZgvM)I6p;WtU86sBZx1XbI z0)(aayhI#$6eNeD9%l{!Hu-Ej5^A`^XgtDv?5vB&*f8eOeD+J0cEkVB64NyQgfsF_gpK+?zg z#I@J5z)J3r9ZNN9H>s__9X@N5ByxfCass8BZCUPE#e3FZ;dkZiq*3CVOMPx(a?P z^8Jr@r8#P>rpDk#TG1>QKnM4TjTpq2`9Y$BAVfP&k`V64?abH|Szwe3b!_X>ip~{a z6phQanrOKTdudHH+O@>F;aZiDd3pdcXlRk7Z$Ocd8j3Bys_qB7;{akng<@QbQcrk? zhR0*jr5@l;gnVpZc>2T;mu_Pc1Llpl5ziNKAZZ_DM%&XdehI_7T)mC26||Z^JpxSs z$q06@dP&R-@8pUDNxgbnv!LjFjUfYupP%1}EsQ1LFvsxfz>t+Eoic^#epJKt>fzTD zmkojzALwYv-hD{OPX135hT6S@FMIY|;(80>^^LioDBxoxbDN=wS&=jTMQj@^|5|1= zf5T{p6c%CDGTtfPR5kdM3l%P^x(k{DTdV&VnXE%G*Ny3Xqo~`7+GZAy zQ=kO~JFn7nEaxyvLvuZu6M&GP7`mh@`re{8(ZMskhuP24>WbsvgCN+H0mm((iHM`x z@UK>9>eb}Q@)IMm@BK~hmc$Gx{w`?-L-Or^Xsdv}GS@qhAnof*?@H9#7vIllQ+aCg z>x}Cij?HKDn+!@@~Z(d$dUX}%=d5)%DjHh0N7gtdWgUEnI zItJ2$PLN>VlW}80c=s%&_;`gm1B1S>-pXG=Rt;C&_Xuhf*XJ6|bPd8pkhLd>$rQ$v-?s28sCprZ87iX+Hf21?X_NTQAgQq>O8LU9G7F6 z@-n8UJ-EI!^9~|C@GF&b^x#z!7#%>!t@L%(wV16{hM_DTA5E z$2;;)4T64i?Q&!W3FBr~;!I2|qzg`F?k1bI&-K62FgLP`X2>maf zZ)aqNrK6v^-m?h{3r|U3?nYkC92e3D92RbEZMB(OSkx%CL<0NWo+vLg`sV=)0$IHV zQhvt4Ag;K{6Vkf7Y?fE2CDLaliE=~Aa>+JDTyuWAc)S1xL+S{4zR;MxIps2sHol-< zZUQqpHPX_>n4r#7u~>T(MyJplPEcDQ6pDGRjN!d~p zXu}JN*Qzq~M4|iLti!m=MN}Faabc z#k{d^nT>O*W8j$#MzRVov=L1Z#f0-&PqR-#bk7+SIJNT2Gro63g>Cvo^`flE?O+6^ zH*p}>AGJXre-K`LXdHl_oqQO$;pBgB5ktCE0FvS@bF{Mhe)Q_<@2ijV^?Ia#>T#1* z`|9lLYJ;UQA*O#BIy8}Lz}fWNzSa4`TP0Bf*g)vvQN1F4{`qPLv^$I^dp3FobT~C~ zLke=&i_$weo-Rgd0k?=HdOP^yMF1v)S_$Cs06}^Ds>!UzJ>QIX)usfqSOSKD{iX}D zpi1aq1r?H6I3|k;gU&6wHL&&TYo-TZTt8NhjuEe=jY66h+oC#EgAOwkZ3)jm9qdz0>zU=kJ`(`My41C-1!Pz0ZA~=iZCABn^8R zN5=e(h>%)ap7LV^F^7c%#qRHsGvw&tPz_cr?$ZF-SW`$j=jV{R{nZ*(1!d)**Uf$y zk|!r8#ob&_P~qTk29L8Z1Wbr1Ine$mu*`8RX#3~OhPw=mD=6IpznBNs;E3hGs!FOE zm2K8NlEr(7a6vxFSyj8}*Eh0!QW1#EOceOIp1byC0x~9LnEG@Xs1o`y2@oV}_#r~hb!AHKs|%RgGJmEc zzC{NGSF8eCcAY?C{;DCe8JCc7y!P<#em#~Jj}G@Hj)&|VrlqGZW~HTxSEBerow!xO z1|sk8A=b~fCP_Oir0?Hvtu-}i9D;BBTL1I|GpizBe0=;dliA>jRXhh(B*GuGUvez? zX=#ciEoTEk$Khk(E56J5n4U?W+M(7)d+rR8pHl5WBbGSLuDw&F#asK13h2yI?KfUm zd$fFikywzJ;Wpj!swp*)-YfjuCN+xeERHPAed7 z${(zg$|dQKwL5X3mJ_GhQ6TYz0~zE*P0KIl;W_KJ@=IUzK#L%|f2jxh;ro?DYUAeb z^0$uC$OKR*a$W>p|LXgN;tRo5K%lYrZJ743Q^0T?AV}*Diq=UO9n+QVdgs(G{IRJ3 zVE=Sdj?Lsqr5YZx`4Okl!@4S6;(doX8OCo;4(y_!@%E7d^3;~z8GcdME zjJvz}H>Wv0RI0}&L*Pu`W@8CHCMM=@Vq&7rA2~KPmRwL3ZLe|nK1=gkp1tSWHu=|X zn{8GbG(i4VmEJKDP?$X=LNH;kBKmrIdd_nlGc+{Z>g?$FxIe=)&gQLAugxKmw5(sV znwxjNdWMMq9i2g$G~o4|P959)woz?$C-3bQl;bkISgPPN+?lnW%cmp}Z zmK0~lc1=2qvOE%{*;mt|#Gboq{xKj^Jmt}3|KWoE?U)#J8tV3;S zxaiyLq5pazcw(m%O!ge5`-(n(-MagrkyFfT#lAGgNi_EBI$8>IM9JFj&pqT1W2R7j z=6~jkFF_02hrzGeXh?_!<)6rscfxoLT$ypO$(NOOZ6YENwEeQQRpT1pAI+X|*=8FD zyJ=jg3=zLYlnj#;sv|rH+pWR=V$9+G*r6~jTC?OfG&E?_mgz>n3ju-rX^7p&eND`j zGxP6)-kwhC=(}t5QiWR*vqg;U+iiTC!%Zs66?i;{e$H-@S%TNts7-Cw zz%8)ptYS)k9@EK@qq?#Lrqr-mnbcj(MBtqt`Wc|&(eaf?S}gjun{*LSG6sOZt8iyD zTeYo8$zL=nc2He9LA){7p1%-Twu+a3S|j#EpC6JdwbP6Kc5uRc8|%F}%#k`XnFDKq z@0a^BjIw{JBNTgid5t~YZ{96Y{cn%T(^CYBS^-Sq3KH4t_BTYJIph_8#z-vAGBOn4UG85}H|FYTa9E?a7DN{AV{Ho`QuspPRSR52gaI(fu23s~v?{r9uJ zFo`Hc6$5DtctDxJNfC!wWkN<>qPrw(cpBn7yE2fK#>G-oBR_-5V;B6Z^0+QC3nF(K zl`CnN1uU_rtp{MFW8n3~lzpYTtl zjEu}xdHJ1|ZNhe4NHi@*OYi(X&vg<CCBYy1E zIDAO7bNn%*favV}nW%j?P}>rkP_ZSpZ^vUM@)S{>Xh83PgMJ7N=*J39bur=u%;h}L zkHj3Dm0PQ!QkB|o%*j{=t8Xb;hyW#>ERnGYN^=u$!zPWvqe$KTmj0_?aMQ53m`8!v_^_vpF>pENdDm4(L;r9G~ zJeptGflRQ!q#_eJf%H25^ZtA3)hCB{XzQ#n86#^OKSvR355Qa`ZFj544z&V}rXYm! ztaa6E@YPHEQU43iU&ey>g^#hb$8j6mJ3BkO(?U#iHT%w09Wzb(SuK#$5`_8KQRhGH z>}vbP9X1I6X;3Bo%V&FgdqTo67)*elpMO^5DyaFUMXs`TLEqKY``y<}Dvw`k_O0W| zbyLR|@M_x>s*maMEX=@&^R#YxFh!*qCP+9@rF`USSd{}#3}1q0tMxCvrbe+nT3&T8 za73Tz8mKvnUpeb?#b$pKBs6|nLVG3!Q1yrY#YQ?2MQe6%n>n_Z_gn6J(RFBgw3R@G zRIjq;UP;z)k^U3UPZ*Mt5S=0qOv+=8H}+BbJp5#-GLes9C(sPL@6t1d{4>=Re>h5h z!Y<9_JfIt!-U^L<=9;zyN;MG$IonxH!}P=U>JB^UbK!BDo14nG{~jMp2!-X)-`aY- z7aXH8{8Z+DIBDV*XC-anNTD#_n{t&>fUYDA`nqT#3GRtorR6t3J3{y zjNk<6*gfx6li7U^Q9ihtWtXIEj$h~z%zwLw6E`rmGZae{c_cMJa(lN!Elq}@@&_qb7)J1iZ?R( z@v$#t&F7?X*7?Rnbi0@XS^tXXTv+@mrI(rsGl^)kTgQ#;epDiB+^17l0$QiSDkg`T zScX~8)Me#6nWwlou3rZhF^E{k8-ucJRt=mD9Y)~~WhS6FpFPxAX*qo2FBD|PAkhDJ z*N!D_=Z_m+>zVy3-k?-EG;w^JUi%C)XnQ22uE*7&?i7!7d(~I=qTly6_D(WTp0GK1 zo)g3iC^cN8Tg0E1y7qo2gQ z>xQv&?9eLzgPw6=K8^3R%HpDIiZvMV!) zqB2ErGYT4C2AM8U()Uiy^Q+iYq*QW+CBke_jko5Lt(e$dP2}ZmvFO~Es3}q3YAXve zHlEdUiuCQfZynzdP%Odls(k8;Sl_8#d%<3gp6(UnF_ zxxaeU9x?gnOD+2}$5!mI(FtSn^0G7ZD}G{P;$Q3z3=Dipq@jV2;06h;iwwui z^UP}gdsY0)n6#{{@`3wlkB)*fVp+tbB~hYQ%I#uj-0fHx1ug$6Fs~aO5Ec1+kGWGh zQ?#Ai;SUuWT1xP@S07J!Iw3rrY4fMDM)fTyC+2|PbV=0wcV#IIP@u;6Bn|%goZs-( zKj(KJ8As~)0}!O$uxs)mV;;$1?Q~g`u7d-3;yNMvi}Kb-e3jttN7>6Z;zYR^v}Z(SA*YcVTJG~IFsq1IB_pQX;9mZ|LZyP}f*LoTjYQ_#$5gQ=r7;2ztxnD&^#X8`HV(Zy( z&OLXqyYSqOy2jg`c^rT~bny4=92_{V-m?Hn3ZBP2wzal?1wrxh@+!Wm;}zjy6jW9y zfqrRkUUB{phm5p79ECi zf2p@I_pY_jH4PdDKEN(9JE|+|MjfnV2Cw=z>F-QJ{hPyKqd!as-Kt(%hl{9#CEwk< zs@v448Pwm75cG$`xt9vL|Gpz=N%gPM1|sB-=yG>OI3k7sI_#IqtHBK`d<4u}Q)DO7 zLDE3SLuShoaPKm2P|_3F47c@?=CNW=Iv{)}V^2@<$?#(Kj0@!8V=~yR*qToLZL=ni7SnQ;LCQ7jmu!7 z-i%f8=8g-ybgX|LGBx&Vpnh{d#DBcq?a#=_p81FRyu;1` z(Sr}ct8<&GhaLR?&YKbdYwZGs;^MGpXDHN{=)N)3f2$Iu@n!V%nxSxXaIw-m>#T_fc;OyS1LB%{QNX^1c7PlVTl0EWGH{O8@4hHjc?M z&b|KH%|QB8n$|J8EAwBWO9STz2M64=3_zhY!!|6DEMeOnU0uF39q2^ja;gAWp7-Fe zk~6~WsrbRFAi!)zwY>s0Sv{jI<7f% zpUJIvvZ$?GmQyF~Cgm6S5Zs|9F@pP$e}!TcLPNHz6m4p1S_kw29F-{-Kvx$!)XqEy zd{IW*@5at=dD?@|jGkuzr=D<(gF^)%ewmI^G@$fOW$YgT(+m`C$D=F}Wl*9|`LxFs zOjc$Kwaal_)N_(lIcN>KN_cBo=gUqV8Pr?#HwFVr5KS@Foa8pGcv08xb_W%HCnE)k z7E*lPaN=^=R8*iapl4bJ7x&`lvRNb*LT6qcG@*LNFe_~TZeR#?>|p;o%l)mgKYObC zw`phSUX+Q{W+GS^KH3A0DYx%)OrZrH+j<-zp!)P@4}9vbaVPRYEtsujSkV*h712_) z=WUODVfUXXZP_pqXRzL=zBiz&XniO5p3(urqX^K)Zg6~R>XaD?9rOv|Kp z%nY}`Yi(-nXy#n*=L};zY;FdSN8aa;5%pzD(pySzFZs7llDf2q8E2RnP=G~ew#}?8 zEk0{(?ok9IW6dgN7@d-~>nMqB&J37Qe~y#U~GjQw)SWkRXxo_nZovMkbI82?**L@!zgLOiRhxD0Twn}fBy zFrNMVY!`I+AgMXtS0pb4aCx9l)bdy$*eyMG zlER>?W-U0yyBX}(`eWEQ0ra5WET-Tg;Qva|II3u;n7to+C>^Fs?x~&lRlg?~MUVV*Gu}v5!5X=5Y*SSa{)}LahFbpmc#d!R}@S#QNJUmrqzJVe{{I zCgoQ!+Sw<#?99AvlPIYqbY|O6ME*V){0CCT`}}=T7U}$LQOIq^l!r_JB?jXK*f7|0|Inbird#ds*CB~)OIbUEJ-r~8QOcbw^Aa3 zv73%v8@I4otEA>OorSP)FP4j+(%h=En;D{s{wIQT^xs(-nko&qm0r)E(_D$pou)%= zRs5lLXZC_%Rg7k^Xd9IV>XqYg9J+ISM$3Q*1?(s2m*y)gWUc^_`!e(A@^CiNhL-s1 zGZ_xPOMl-08e%25S0FpEn}pL$?6L@k)udLT?sW2rzb;>BH!<99AGN6TDzwYw?TMPG zsynDUuZVS12Zdl2mkWHjiW-F`>(8~LL5=(>A_`%bi2xYImV%y0omNw|-neg-R`SXBNTuq=xl`W5-fiIrRarKJq7jMH=0^1GiJ5b^u8|Eh_ z`~7ONGQ-3lcR1@0sGsij9N5kY(1N7lbkPt-X|(T$YQ!aEj(#q|%ld$se7AahlV(MK zu%o`+vPYr|l8n(}NjFf1{XFA(yTA3kZXbLIS)EIG-fsT|6ps@P_zX}7nZ0y`^mFvLu|IT8moPRgLga)}(>3+Xd(S%FKKix*wdY8^ z&1MZ_(Vq>rIrlpH%%c|Muus{%78h}uxo@oD%Na(&gU4L(vQqSa`2D(u3MTBo_;ax8 z7ZRp~YH7kDF|U`$DaofEo(2q;2)Jnka7b#a;2+LGP^agwO|-Kd!|>A(OL}JJvPjNq zR8$m)S0oE=HT0E8PM%Zw0`$@siH9$e1O6Hh^43Pjs3SZ(JZk+t(CHKH)uB>XAB)#m z>5rx=|G<1H!RzhXy|2#9A5L5uPF!{PefjoXQwFQJ{h9l99CLcDzY!s`Zgkb4PBhs8 zKYCrsZ`QJo-N~)`@8x|5N!6?P3#0>2oeiS1fLvC*tSmNOxE&J5pP|og#*DrF>+W-f z-GQmIUUlw&QlnARtYs&=cfWN^=*>#Ywr2~*(nYVvt$`ISac&x{ihEunj@{YH;#r6k z!LwwFJ$)Ax%&FY@HaI8;u$VQPoS&c1sl=>yGq4d)Ll*2P{pD020)WB)Uey{V?HTP< z4K%VeypICU=KKTNodIlzQq(Hs3-p?LDrUVQ&YW>YYu5d#TCn$*vLJ+UN*ECJ3`bM@ zsJ3EKc_YmpbwicN{`YguOo(C6%YMXrgYAaJ%sVFE7JdtiRUhjC)bNc7J&_5~4MUQPu~D`gpMT5(ffD8;O5D@axKe5yU*S;&80UXca(lJ)=N zMhuO^GFWLK0>$AtjT>HJMYRk)8s7b;rxBqctFAFc!&e{SImbb@pz09Zblm7azT8^? z5^G_~st5;7_WZ)tJh&98jLLSi<@{<_#+4+V(pf zs5ewiskRN`yQLGgeA{e@$JHdRwe53I_O2OZoor_oPQylTAm zfK?E3~O3UqhzSB;`nw6~Es>~@(QoA9uPQ@>ppXC))+t#4y zSMg2pr$=N${Y|S;WgdDS-jmhtTlPq7j1RbkO}Jt$sn8=n_3j<$-21n|U$=@a--j{n zzC@TT82fc>ERNIr&777Yr|}uzjtB%TDl>coXgX^!dj1Ifl(3MH(3u=MxTh`o{L^Q^al)SxvBUS?-N~&z zj#Vm?4=>_x4oJ(2GJb=P@9Xia|Ljgo@u@%GyS;hvuMZpSJ>;!@b|rCH#@E@~+dDd1 z>qse@(|eqf_vOp>zsDEzzz)!$&&tkbOSL6EmPP2he*v$GZTZ9c>%+`eWtLoqea354~#g4{SDH)NmLr>iCfNB9^l2#ZuEfc6zHt+5XMEXd zfJmAaM+p{jwxZ)8V{vnm6j3D^-8(+q7b}y= z3F51vRY?wskyr2BV@-au=$X`Dc?4C#4-q&mnS}rz(9#9{j}HboX!UFJD0pcubXK3E z1x4WGU3HCwt*tE;IvRPkhT#XCU(?XgzWyj`4!CXC85qJjs=y9*(o-ozeS@X>QuWWr z{Eo{OCOX|I zlx7%Jqi)Uq(UXj;pftxlzmT{RVr=lmRL>smW*3M$HGzY&eBN2Nd!79OEF%nc(`8kx zrovAUIE{t505d_m6{mx?0oBC>4*;ZJMgrwDWd#c83A*prhTCOfCUZ&s0}B`-_%*1d%~~wNOJ-|BsmN(&(H#ufxb-oiCFT0lhr^QC^)pl zLaytnL%xp4NHboKgy6Y$t1JZ5yRL0+&6i|3QAR-n4|sGa;sA)ByaY~L^6B|td6bZ7 z&QU)C-;QS+@g^AZ3Ou;M)|`p)f|st;)4|o3hotttemOi|S8<@|%jkU<&aFhE*n4`G zB}}x_95Cu7;*5g#Y2*7t12uQKJ&z6Vn@r*!+Hs-V8lkJ0RuS^=`ONd*3KQc`neImB z#=y9PSOtESE!0?bFus+n`^DRfhWkpcJ=XEsfd>fP5>k3$(Dv(H|978DI)N7BY{d4z zc+}x@6cgt0jrJcFx9iM46v{0sU?%2EGs>&0AKH@nYNsKjje3gw%?~3&?+Pz-E5A_e zMK6{?AI(Gfjy&!Ad^Mm0knOIj0;;^4Rn+ECCs0Pa0SlF+P(eAFz;) zz-Q}Uv|Tg_X=)FFfcHLA}$i!9se$NFRFgoK`n)BJioonIqwQ?+4%T4pVB|g zf{#FB2A|%IcPclNKb79SR{Ak*%4O#}m}GQ2V+g6Dn-OT0;zMBFriCj(I>0dU*1jpo zB94AeP9N5o(_}`7G0&S6zwoOA9Y9$}-|D4rD)|Q?%-9ptp4Ax_7Jy+oXgUoZ^sBch?BB$NA(S4r9Ux+BP*MWW zpZ1psd_b3Osr|HP*Og4?%SZp|@2c%%rD;gZPSSQ1mRJO^KpN^CJ&IziYg3vqQI zc6~4&=d7GPXGk7hnZ!|zy_v^S!DRVaEk5vM&vE&1Cjt&0&?#AsG>YCGWIgA(b{a4v z;{d}~MrC5jR|nr8Vx1bc%wum?mGQ6Ec}>l`>_5Q-VXhVc3`0Vi@H+UAgm>9d6cG^f zz);dpJnl6-0rb9fNZ_c#wOsK)JBjjwX7P6Gi*1>RQEHh~4hgi!z?M4YyA-*B?wt1+ zPalEDT5WWT_O)7{)2;ixTnN8&gA{MCUGKmk>PO?QN%5lGG4IEpR6c;& zK?}i;>s~0K?AF?Srb2S!Jd;vzWYux|%Vz^nK+`z4f$ zcf#_qS}?@XFZb6kD>jtf9NQr=l$Kk>X$rlN#St}Le$!-O!7TD1AU;WzbM|Ji=NHeh zu~EQ8QR8K>=Mvrbl(R5?s1oS&rBwS1hAGPLNA=yUHUh1dxfYiL5`z#0eN@kjUc~9% z>t^D9vjGMDw`#uPLk_%Dk3XQ_`F4k*DUXT-u3p#gwX69129_UM=aJm1U;;|z)JDJT z<Ln+jXm5&wv8#CxIZ!~T43h@qnu_*k=o12AsxRWhfRX4HF#3@U`gY5l z6hGg2Z}o?dfzOLjwB4+w|9eQ7@QaQTEfn!8_Xr?MQWhqcvr|*&g+ax83VbnP6bMvR zCAU8^zD+^l2%0oV<74Q2!9`>t(uyKRRP+QJ2!eBZQPJa=VG)|&Y?ZVNa>C&duo1nB z6BNne;8J1+be5nDkXDttBF(wp-Q(+{C|QkqI#_<&%*hZF)849U;fSCbzZnGH;j~mi zmA#P!fsvL3-n-_$GMBLHRS)(~)@DORCqrc*B5h>`anE7|3xRY5keF+LmA-JKjNV8i z9(C|2M3Bfx$txajH>Xk;i}z|}#x(&d8w4oJtlP-#6ttuBzVedq`~LpT`vxA%uVT*J zQfd_S>nDy#26t3ieRJj-*F@>c2Y?nKCZrP>m{7)bZZSNxdpX#w{Y<$cf@B2vWlfwy z*Y$;P8^=j73-A)o+!4#yEfD7M@`Dmo^TT)s2!NxbEir&8z|nru3^BCI2YyN>3jsKJ zvfp18%lhp>fJ07RI=;|^voT~0e+9%C8e7~r`Y5(UD;h}mxrbD3)n6xTuj)gnPY)*! z&s)CwItOr@I^5Tp<`%SLX8;1#nLe;s$(yxWwrz?$eKzwbV5^MfepI6@zF_a4%DGEcF4SP3QEIU+Z1D@@TkK zl=S%a{BB!!;fWpGJQDEaULtn3xQ^QBH4H%2vn;>piKD7JGFPu&rAcq6(PTb?wG}uo z-%!Lk&=~dzO@2cK*`;Kl0yV~ssS<|FYOC(mZ`ALVkM-Og+j;U=8)eq-csPMr?EX{! zvR~C>)@}7+VDMze*!Pglw;@)C8^ePehu=pB{(W=j;pW%^=q5RAWZQq1iGV3-X#~-V zTJX`xV36RtKh;1f2kph(Z7Nb9KJ@)|kwsD_JuV?-Kx~pk!d)C8K>ZZl+Hys`0j?O@ zY#eW1x-rBv-dt@3d|&CAf2MVem;5K@PjidDDXGFvVX7K_a)MNB_HqymwcGhed)xz0 zKfe@B-81m5@94f1iCjnfG=n!Sksw^S3(;En&K_DYXfUSN>G7>}KZ=~yabVIn5(74N znLf|w6*J$~8+ep=B`K1uI((2b2p`;S?2TWRu!ymBQ7r%N*&o-yPMR4KT%?}hBt(7f zwE|0iA7UZ^hE5ki9wzvt+lBvg3wrgyY{@tzc*<3(6hJXPdHPhlLv5UW3S|}EaVwX+&rxadC-?P134S?x>4rUb z=xr8)`}ENJ1x#WIezSJ!^in{~rxGZrKjYBNf=A4Kbv89|n_S8WeA2b=e0a1u1g!|` zXj;4s42K|Us-1n2;WCd=>ux_V|2vmwPsBeJP|6?61U-(OV6v9k%e!s^bNvF;(cL7v zRKQDaO~XUxMXQHARq1bI*b&t6AopZZyzpB+N&viCey>B%(!=U9I#D z`~_7^e!wg*S|Top`8xM|a~EkPPG-~dzx?z01~pDWHOPrx0{WAWVp2%oLR0j-?;o zV76ZL*__32pnhe(lR~Qc*flm&V zx`JE4X1(=W^HBO4yLZU=zY20}*EZPB+74`Y&e7t@IzoREi5z^t-%KZaWk4(`2oaE% zPM%05KRlljO=a+e<4Ay=Rw$!HDWUiX8(Uk3Kz)bsK~f`n{p$CB zqNKAB2`746Xck!--$}-CanU$WZB+Fen#d5%N@0R%9ac3T-Um)I%&&N5DhZE`)WLtqY^~!&ys&ue$qf*y|uGSc6M|m4ENjzH;$Y81W{vK_{|`O*IPTlMhRRzzGECVD`TBsnDvkwwzh>$f@t`L_LYUA`|Y?`EWd&7%ovT9JenTdy*_) zqQT`yXz{oQd{bL{)v$3m`4Cpg__`Ld=D2QfR89MlYh<+Z@(?g204#ESjt#;N=?L-! zxQM37z>h1^kR=#w0IJ1aBF%N2iGWC-Vf_S(!P6ad3gEJ~5b>+B21=cdT&gms=br%z z{rR_{G)tL77sfRCq=4b3xb?FyqY(LjhKbNmb~xzpX4#ADd?Q}OIu-$t&zs^3uwuT; zw!aS@*Ty2Is!L>1?o@+XVMaz?jon5e@2Wum&Z}$5pKInktJj8Bqm_b`oz@d1I$L5F zs;kkhQX|DZm7|@pjgh){G@qNxC{KMH(V%C%!Fd`&e>R%s$ceKq_m8)0>Ph&<~xZ^YAs(5SuWo)o3j&RP4c%B8-SLIsGVpMX&s%jKG@n0$` zD1k;IDz>A-pvo)z0jB~{?3g3R{E^1!Jqv3~M7kkq@F>P?Z~AT1CY)l=EsugP1)JS~ zNlOcB{RqY1?1taUIwoRCPfT6KpAPk6eb_6?()W5HD2?NK>TiZ1lF=w4x%DsF#=T$_ z-FLukN23fA026eM> z`T3Jd`T7z!B23OlR3*RXV zISgLH80#Lva6!E8o{O6g8*k4BOww#gNy(F6zkXS^Wq$mjCC4`z%oTIRYV5G-T(+dw z`^Qy$GM0?CJ-afYb@U8XpZ7(V0$!_~S(CJ;G+%?lE>QL5cQplsG8@x z@O-nme6wF~P|uf{G2H1~Qi{E|tw(RY@@rfG;su&mQWAvOHuzVAZ7BAzeU#xo!RNQf zcKf?I(Ke?h_WiTK?yay~8E0tt2aGn1z*<@-ApzN)zr#C>P{Yn{6pNOSj-o=Jf&NWj-H53y0{57^g|G>uJeb zyo6UjKOB=&3qzdbuoepXZRS6jCPLZpLJ+-z5P1XK-LNq2ml$7zGjWyHLhOX|d5ge) z<_G?m8zludZXqS)yR6^eNNeukd7-e786|VYSJJCdkqKoX7Xw3&&uH-04kbNA{M?+} zC+&vUvFePbzBL=e`qb%*B}gxr&#PM^sx=0&VF)o#$^K3C_7tzD0auEG=dhJl18#MD zf7s|zR~{V0Ome%QiJee~z0D=(IB(puLygsrwEEV+$ss4L;vX_r_Bj~!nif%K^f`%V z&AHW{MIj1;;zkkZ?BlsZMx%X$WvvtK z+Mv{{Zfk%>z3>};t&tNySZc8vQhF`#79qr8=g#?F26RJv7()Qj!&(~O$515`gy>kX zC1M@vI*yp1kB2a$eapJ=NMGGQpXZi50L>*r?l?*h)UD$Y$4vPBAg}Ursf7UjpdAg1o%E;(~&L>$YSG24d04A=)WZ z+63Ghi8C>D>j*7D^5H?Ck8HsTXSvT%|N}j?y7;Ugu{5#8EQ<`G>EthE-M;-CK2Wzt7~Y=utXO_vwwZ`AQP` zReQcuJxOzk=pkxjwZOYMrW&i|7{BGM`ywkqU=3? z6TFw(il|kiF~Q7x+>lQ~0sC%aVQ#^W^I@msMx93mK@^EqL~#1%687)3|A04`l;m^!H8MTvF|uO2z)|B%DBB zCD6QHdK2_b-57l87++@}q5ss#>Co7c9r`xR`6A%-LuRqJ=`373q=*+#`-+l5zfHFf z>Z}4ckpAF)Kb8An3Ee4sJb@)}lHHN9o9aAZ7laWoH*DOaw{Cp1AGMe5V6qCGdZN${ zXLng+Tf(QaONRC)FRVh9T}K1mPvw#f0Ty0bG_GLD_EZjP3x`@{J8pN|ki)o(^stJk z^S`+Sqjk}+Ax1SUvpj!xqhSc<#tzN?V+#!#8$3AyTAL0-xrON)$ZkOzeibvxPL(nBD6PDIw}} zQM*Ah1If4kqqe}^)P-m1zkBJfCq8AgzKX&x1R?kN!|{E*S1xU{Dbj1V2+lvA4%UH1 z;&Pu_-aTFgaLre@&Rnw0PlRI496mPHt{(OKhxEAoSki?;kRl-tk9Y~6!8w4hFLJW7 z-Z4~rpU?iA<(HS<6u%LL%&vmudCL#*jpqi0-ee+lZOPz*7mr$l=sNrJ4b?6QlKldb~zh{Si`a{TaK(!(B)024@* zEtvz>lO_etTJaML^GGs0zonGBr10@HU-Nw2N5cry{3VFi<3a44 zImGJ+b)!=Jz0_i0+D4FaEA~SWx6+lFed&{w68yE7$C3$nFXu{KWyBoA;^PL>bz?>^ zn|tLH`-S9}??)e$3b>UV@fP2N)q;#l*-}zP;n$X)e+aT1Z`V>p!D9un(ioeq0;h2-km_va>E788h;^ZMHMH zFFjj5u;M52sYS2_CVeG<9WOMwj*eLpT?lTSk=0B%HlA(e4&y+1OE}5YZNDAYn@y)= zTUcu5yibjHHq~7rzU)5r;?(IqP@D}H}#rSPj_ z?KWFv3!UFfGYb$$~P&_+jYD+cyhvtzo(1f@zuFExjL=c~JQOe8f6y3+ z>SEGywju4(n-)1ruvv*!?Ke}s=}|@d%_K=&S>4AsOQLi`>0A9a_OUR(3uw175+v@%*&nAjmAbqgKE9Ag z4V^Bwfa-^3y%3TP`NHULV~MDI!jDl%2C*JR$3ks-(KdE_s1A{>dW~skkUFOVl-z1wN~Sjd z0`CjGTJI;S{{5;`jL^dT53ndUi4;3~SHr+^1$R2cz0Ni}<6mMm@uEqK)4?&w z@D5*shV{5!O02NR7XcrYGtvEXUvG9Z(lvr(eytpt!)~yV`uOa2`&qS#&!$F3)^pgf zRu!PLZcsPGzpxS>EQRZAnFNJvKTGVBrjt%@fG*E_zIvuNiWMI+QS_8NX8DbSFC5`G zbM(TA+Zz^kY`_k;v9Y|mxaf3K_p;pO%PS8bJeW7pjiG9eV?*(`;{;8Ic5_m6L1}j} zoR$c4epI!#fVKv3$=AE^=3iXv&f=f2Fd!0Cb|p6sX0|1c60bgBURW9XL(9%OfJ-2P zEBy;N@sxE7W>ibXVuT)jRh#(plpJ6n znKDu<`dUeBr*;}c!C>s^Pz9`@be|ez`dx^BpebPnhanI)`VZ|Rnw01$q~M3PJ6zq;LS z0&C)Q&ehjo8Uu!Uu&^L#f!g(ubnR(i%<_E%gbKpAa!+T2vNF_2cz8n&eiThkPfy>X zWo|zopUZ$KTZQk0%kk^F%7D747fIgL?Un9i@A9t7JWR5FVyJ%(hllUjHeja~ZQg># zImy&0p@%K&P^m58ZY+V zIx{}=Hxn_WkmX4jUosxY-Z=X{*d};#;Oo=lZc|;Lewf)@6ma{Q{4<5fR&au@BjVRt z{cfqBrfYFcI8jPoO-)VqIJR*JsW5TogiGe0SJi?#|2e%X*kRm$GsnVt&p4vq374tH zbqm9{r`bt3E{uZC+X|292O{Jl@ik)x3&~K0mB#@v^kdS2oxvU+U3C9=N4d5z_f8q$K2#;O z62Sx*$gDa~L$myosPo}$^O!1P4tchR30<8n9U%E;r}WLXJ!wOePH|3FlmEB>^;N

    #ghINNn&VhQIlsR}p1FFYSO(Zh%Q&4RDSg*F%PidlKJl9Tx2~ zg;qER64M2GH zlu?YY=jSNR5@o8N2q>>iJK2mcb^EXC5>Z2$iSAr0%7GhUX2_vhK(>%NUV}rHR0kO|f zrspEe;w6~K|p+SSoTh&hc z1(jY$3sL4vOYIhr-Fj*dxm9N$E`_RvW-%kA*lS9JJS~=j6}nDWMM-^5z*lml_*B~w z_09-bF39)&J9AelfMqIn3H1yiDvyY`E?*AlgecC&VSRaJQsR3dqdO}%Nwb8Lf)C+|53)3a5W_gU`rh}l3S&D_&qFqU_3F?F zB@Qd+<UR%#dU!TZxDk6b6I# zLdY^?NwP$i8d-}ZEwWXJF?O;gSxT7fTa0Xj)bIIrzn_18oz6X{b8nsQ`~7}Bm&f)R z^Gngh8*7V^1obmiJGD}%97fA#H1^EB!6sTxI=h*B7L@PXW0~#S*ZUR|JS#vwePThs zc$d)*Y2@zq@>g7Bbp446#aiobetYRVc$SF*8_-31LPY((9+;WaahPGLpsNfb*O>9+ zeJS+<`tSQPc0F__3kVQ2o)g>Buqj!M7pqceW-bzC_?Y4j(;rYXdMY-;)39z&Qy)l( zh#cJktLO;jq~g&{tk2u$m8rv$9MJI_i`>bl|Jd+1sKqX!D<7w0LDpeTkCGX#VZzpX6rqxxs6qk^u$c1maYm?c+C8DkI5C4)}4GRg!^H$(UJo;a2tXMQ^p5l(bXmHv+O*VR% zkkIvMtQ`O3>yX@DUqQ!OtvRBgK60@2hav=cPfI_^Anocsl#l7{&d$g^+9nx_8LGiB zW7kU}YLEMP{?NnB1}p?>hs$>45D^;R}XK+E`|Z)ssrvnCA?w!Wuu@i_Q0a z*DTQ7-kze(8jz!(yu#a?eqW^4#xUmS*Tm8|k0&v6B(I5RhR#a}Va0-}Af1`=U~%lu z%;h^+RmR`G`$d%_g@hbE5(KFUbtl{?W`cUz8#mPjywXI=hNBF3=O!(}`uDxRKXn%@ zJAJGo!N1U9`PYshSt<&$Ct2A}MAsEY8)m(Xz@B$+`nY>T#%F)dbko`w;C}Lwc<77> zJmT}kb4kkxZuFjQ^MwC;8Ag=8cJ2}ziWpu!PR~l3{^SDy%Zt<3U;S{rXDGys_bfXy z<0BV8gQo_==R&03%@^Q5{G81cI(u=#1r5Kznv73A-ot3Nrhi>2{`2y%5$h_6?2?n6M~HlK5}|rp{d7UU932(6H6KH?(J!lEBQ9u$QLbA+l~pvYJMJ9%+)l^;`$Oi zQgMmRh247lRFgoZU8m=59t(oGSt@V4Q9Qxm^`u{)$Dx%SF6#~zb!#=OQQ3fDtaDRK zE(|xA2k6WY7gXlWl9x8?))M5(Gn`raRQ(m$XCs#%Q&!ZTJqp22l`1jfx>@l-#H)JL z7FGs<5UkJ#@?yu|5AJfYPVxL24vNNoYdl7+o6hN2+|jpzcE#+@7jG0)Vvq1R`6{xv z4c1UwRAvH|%(d_qYa!3?)iqO)WDkCAAB~Y9^)|jsLyV3fT|13YT^DxT#loow**2co31CIRMI(qiU$H#sC18`edgE1Lt zg${@@rRDof&ztjDaiZToAx-0U%(3!hL+GdFH-pPk73Q${AutyjM8nRqEN(Q*{$bJYTXU{h;rYXexJ6o*!CeQxG{V0SA1;=rLv zwKw5rji$|st&e}6d&#zUfCFJ-B~zY^T--CsRoGYP6jccurmVen@jYPy4|}zGr1iyo ze`DQpf*z^MSK|kxKu1?1Ig+MsP}>%)j{l>VIOD3pPE8Yx`Q9}Re)y) zMS8NM+0!@4Ka%Zt!s?E=0pJ2(+L4(_-OUT`a^Rqn*@0>6UuR?x@T}Q;M`-{n4riqe zLL~jXD$_1#Tb9J3J+%jM;PA_p`-?mFga#;bB74j(;hAkrPD`aE5OetoE(S6^x3`Y- zCl4GfM#c^Or<~@9P8%w5A_fRkxLKTO!SEA8Cav?h^`mFQ%hOdb*5e?DzC zN$uBFQE$r$4sT}1**?9O^DGRU+#7cM zB$FSFGJ$vt93{qFHxK|dfzXRTFtB=>QtON7suTHs9aA)5DqG$;*Fxlyv5YT?xH21P z67sO<37Ofwa6S&X{n_K(*eBRAd#*C`9zkc9><=Gq0lFKxn4?VLcr#U&b7*tVqnU!; zw4!pFk(?``QA0x`1)gj!#L+Tf;X{Y)I;+bwPYoj8&qJMolI5jyLcHS*an|9;=d{Ii-mE@NESr^JnKS_?hAdHlMHVb=1QpOg?? zdF$jK`0u;2O-I*rf0`;@VDo)#ih$0mQqZS$>WU{htw4ljlK8Yqskb}Vrc8xPcpK%U z@Q(V$&riNA$uc|sjK0xR-S#8X1XN}{MKtmjh zM*oIq!|Fj=eMNemY}N+L>ia^oiQOw?WG1i^7)?pxXn%Z+P<(J{r0`99kgt}%D2~pO z;#lq3%xbAWD|$1{z;kS?q`RY2+*1%bmgVJLic`byXR3JJ6Ehry!1vwV{pS1!hnH{P z&Ywl8qC;}0!U_&1ZkM>Z3jix2SQ7Jo!EgLn1n=eDrJpUw?a+18rCX75;ceh=Y?@PW6wGg=DeTH`l8#c~-idm7|BsGvG>AzyBq{W`!$O^Od7* zX5=${U9%ECc$WJs1(xb4J3pxJDbdsp$NnU%{xdw9JzM=Wl42@{NK##a5rX8O0hERK z3Y|8qPsszVE-AY#U7|$#-`yA@XZ;_jl23XU*K*F}L+fRx5$h$P;%x- zZHzGg1f;@#Pi-RxqPUnr1ot&vBAuq{iT-ik5!iVTS`C4K63u&Rc6+J|ojBMe*=R?y z!@lqHV+XqPFawUC?3)oOVw#49LR08DRku03IH`08=jQ9zu(~W2y$CbT>j~FABG^%X z6OBCzz9x290VNfTAYEC^$X_wWF(f^vYNwI^!U!W?)TQ$8@6MFqou-+8zS=XyP)PT~ zu_P`pCHc$`bEIfXPE6;r^I2xrif7bl8M-nCkh)?>!VSgp>AD3#*RWs<8Lz~idcRm- zx*|gqlSyWB`_T}WGk`Us)h|<%qLI7aRW1XtGrV35KN{yM(a5T7NibS)7N)r5i$7lN zW@!{ml7D;bvxH@emXwAM*3(B$btiw5UcU zD>UgDbnjnE;iIF({!bYLxt_Hwvxcm2?B&FU*f({{zk#$bewn#={jkUt2FE#8JPO6W zTG`y})%;iby36fA@Y!$;yv`TUSsS%uD)NRNHXnM}W+HX&s0b7^{^y^YU3JtB1{;y? zOPNYsazsS1Oyk!WvFoLTa^}0q8?aSOJ2i^av;1cJj(rF1Bd&EhRF-loAJb7xbqy`& zHCwXbSultvO!}IrN60t*U5XQxljd$1GCRzrrGm>eyZ##ACP7LTZokNoG z6|1jYLz*tvV36Q>Zu1|@GygVLgU-y*#&+d=b;$V&N`hjmPc$+20 zq_YWgR?qE*pA~zMPnr}@1%-^nX3MqTJsQh}QO76zq~4pEj+fFoimAqQbP?Dc%$VcY zM)LZUyeg?v3kt-?-1@~teC9IKO&)C~d^_7yk&KL7wWl~0gC5)$kjqa*|G;)n5!ae0 zee1zeKRLQcVi&bfv)o62dJT3tT zg^Y5I5ziQ?$i7^**UTS8S|(YI#35rT^DjE%YkQu0Zl_39Vp;1x4_;h3VV(J@)L|!O zGErdFH-UjBF8eL@fR|IHeYmhsRZ_5`ASrqdyBK79p>95sYltxX(LCY zZoq3~t}E0fPK}WY3Ju$Vb zcQ%{Z(LAe#_H1SvT3~Mu${{HQ+RGgt(c>>k@+^B@aPTUX64KGK+eYeT+P8aFm#^UQ zLuW&=0|Nug%y^uSSsI`)hOgN1d_Lq7_<_<9zW{Z)jsjn{nEd~C)FDR{cx>|_djSk) zHQVPFj=lYF@NG}!qU#~&#cMwErd(7Fiw)i<>hBp)nQs&R8_y3redwwMU>xJfn|7wV;e-ZWTn zHSmN8yPIC5Y{MJy$vf<8m-Qegf4RcpK#SMFw@2(s+o7*5bk|>XFRf}sbi*PP0KEJaXsM|G4jM88@?$3Q#PdE7ZPA5 z=_D}YqwKI>H=S2g^)l-x4F)$8)Etu+>sOcWh&^NAG*bLNskFm=sXU3lN<#fNiVa!kLxVZsg_(StFWv{i!IuQDUA7jsU7Wa#*R4}2C? z76G6LYKpIG5`_d9)1O}lS&^#GYZ&i)$$=Zw?Bo|GW{C`lZ~lCaw|CX+XBXVG8CowJ zzZ&>Uy>)Bz+~Kep7DP{Od6(-Jz5|cEynK&82<1dHye?|iU{F%1M~JM}uB$L{wJ_{G z6Wb$^(%k}Hf{yWrR9c0KT99NXAnxfxqK8$7%l+%T?=RMSFTVg~iBD1Lx;x_|qMQ$hOi=4m(}*# zYlje4hw53Z>8y~ST@G@(iv&?iu zWV+VCq;LMyK?7|0sAe%l>1$z5L}z&NYzJ#n*PwRtVArALfZfMKe9QTtY1SpI-V|;! zOgOQ19t1kKhD%L6h? zKZo%oyVL)J$G(@)#(w?#-98Xf-F1}O9wChcGx%WMOV?7_Ptdn{1WPDuO|m8rOO=K+=rYEP;|&WV%4LhQ`DOs1I0 zrQ3p=y()JG7j_bS&ut`KVU2)_lrN~Vhdw;hC8{sbvs|Nz-`8+qKfB|n)#Z};nM)jw zjNJC0YB566d^Ks>zeLZ`64dY#r8|wzu;KHA){A%Vz+Wfd78)#Hkf~S!v7-|+`879j zZ8I(vQWZoWL7ePVU|G}I-|Mj;emqYLZP6E)4Ogy&bHT$5MxxL57pOOeDUydBimX|l z7k?_=uvPVMt(j1YaTb=36=9l@QrT&oxum5w<(F4^E7{w_l?vd^$x)l(V7Y$Y=YD*y zszC}PFNhZTH4>JtZN}{62U|+V9N4ZbAgGMA1q|cQ4x#uX{UoZp1=$PX7JTu4H#ctW z&w1aXWhlkd{e|nU)p1*Z9$#(=AAt%SfPk&l;h?p-g-Gu(?U`+kXXP2MN}rsHybyae zB{fNb_!EVQuW?e)!T$4;5#i%T)z8=na!m>QPwKeqVX8@As78xqc)6U#Pw_mO<4y|P z^3m-SOjP&Nd?t;xYvy5WCbI%Fs2V2uci^6jHAx~qR?OlBPh6 zgKYJDor65jo6Pkc!eEg8W>wRcz=%`vBIrGT&@#oKHD)H8&88erP-zmXxy~?nn6mD` zPNC&vG!0vgWA95FG(oZTS`t{Uq~q@Iyz5Oyma%EsT~mGV@bBP+_OKh|ew(N{+0ms> zbtW`k#GMcS@38|)_F_N1W&n3XSe~*Y)7MH^i4i|1LLOZ#;A{L&!E~*8%C*-z^VR04 z^nb8qs_Elp9>ThsVsCEWXk)o8z! zYm0}wTBV&`Ca-Zj0B~;CwWvI*hKqr%X!f9iUQ~^s(+uBvm6Iy9M*AO+=VKu4i^?K` zP4o$lw8uJKd?XUIhprz7TbEaLt24>Kvs{~Ta7{m#dP4S&V9(j?#j#~l+*xU&919^x zS(V`}@s~xNpd`)J2=0s(P8}o>MHLB4eL4HSL^m*GV|LZxCZZ#SRPqn^GaQP$ks9>k zm~1+BS1Vi#%;@mr)>UTbPir-gzO}fslr~CJY~w~8)W-dAZUKdVz}QD<1hH=d0c@x+giDh`mpRU=q+fumkwk%_6Si>DaPoy ziy)vtmr?jPx~3gd)N{Nr?N|wUx??P#->B(HYL4!RryQI8zWyqT#ZeL?HXlmBU08`u zAZ}8TjUl^wPpCV<#$E2vH#NWTB^DESH+^rWIhRIQ(xP;CBL(5yec@DZv|LZ@k`}i; zOosf#Wc`a&knA91h81Vu`b#~LWux-B0 z6F;ql;Um6VueJljb$YB4+z-wU{}#Xg3!PJA#0h9V2cCxo*WiJfE-n$XRFOD8`aSg- z_p80Z9;|6{KJkq>8AN6*2>!0Yq+Q={*js11S0;d0bi?&Q$g+OXJjPyV-4qcBIw03A z=F{r2K6+e%=bDx;1Jd2w`{5y#L)7fr#Tz$nT)%wzdo~as5{U7*&=my#A7T0iRn#%V zr{6zy@}*%Ml$DokSfn85?b{BYEt1b%uKUj8==4-=pBNSGk0sX~2|1s$5*S(mi;TKC zeA*exF43eK^DPBRcR2Qj;$Z>#>0pypiaQ!l=qNYA(Gc4id?VSLE6rV)18-h_ec0bA z5KDO8p$8UB;;xuOY9ZspvwaD;mvM`-4~JU zHRU;6vQt`iI4|wvo^&ip*W<~wKYs4k=F)d>p))c#x0|&jc@GC{z2ANC=*z|a^i1o8 z$UP%Kznt5$S@I?>i5H8t7J3iDy`-?t3TWT;AoOkM-#8`P&BxYgGS6X__3_3JEQ6!F zg>dnc?-7>0UCfS6mKO<)FBa?T^e#^mpH^zj5CzR@z3rRyND3}b@fhX!hn!zNzhxVP zCw`{ks-fIFmfQUOon2kU=N}t*LKOi$P~~wAd=CqSxTtCH+wd9>-OAc})tx{XG2qj@ z`&JXchPpKrQaG~p4jx)*uKwz`j_|44!fGHZ{KPy_T)TJmOF0t$P~+PE-IUjS6<6Pv z7IG4&td9S>1n>3i@f;027;|{uyp1xKCdr$&k1k_A1(P_vI>TPijHwyNa(PTd3$n@- z67oY7B^ABN^zmI5H{*8siqmWCuoX%EUsm6YxWbIWr+a3OZ5!}k9Q8=2dlKY)(4C#{ zuva{pF~S=DSmv4HP^KAMQq^CS(xZy> zSvp^9)Xi=@ebn{9pFjFH<59JXrq|?WpC}PYAW%`$%Nu~?QyXKwZ{6z6UNjo+Gm@~p zHgFLXaiTDg2cYO8Cq!>hTQ-lx18VQ>@%{9kU|Ts~zoq62hu?^h{+kd{St_7s(43EV z?l@R5+6;>#I zi=qy~vz_V={7q{-2PaG)f_XV;iQv9Dg}r{9p71NiZ^Y)vy0a<^aV@YkLHD9Rtd3VB z>tpSs77GOG{guoe`bUW-Kd?#)?h_pKu{r5j3djejP;ibQ7dG`QHbPVv2;yceAsO2l zui4|sZ)F9EM?PeVerU(wMQ^}@1s}Z!E zv#a}E_7c@EW_ntPsyPlRN^yU>HFjCyyW?aL7vjUDCy!`xB4SLzw^@-)O5+e)@IO{W z19`%NOfxvjh9F%$M@1b2tAQ{X)y4iw%)5sObWTsA>>z|;B&K0O^;?{d z9Y#eU@4RYriV6wr_;U}f;L?rG;cn|*uB)&4TuyHfE+f5NJ@WJPyIPFv)tOOZp7n7k zt~Ji;s8{A)uTrD>>0uSm@81$c3dHX;2k+T8>Zy_(#^6;ce3)V+c1u{`(-=fQRVGyF&fsN9;X;MKkVUS8AR}iSnYki4&9QZ&>FX@eU zcK3~hRM`5q-v~Kajv&E^iQ$pc$VcRf2t$i5yqAMW1%j|3bX8=DfkHy49td3lxr2P> zKmr(^2tIA&4#f#ipB}jhYYj|F3lk;$pwWS|^>|U51qCT~61U6L5*v|47)wc-TSPl zVI^j>Ho7ef7S*KIf;qLCN8%c1PJCvuu*+ z^|@rVkBroH%r$$g!pel*PO8b00@z}o4um1Psy{)@~h);kW(Sy@g&e~AWg{R z2t5rNRWpgk=esJ>o(6K-V-s7)A~rHaSG7(IU|Gphn>PGpDUoyjb#U}#c`;d{KEm3w zQZ$xoKNvvwGC4-AQ$F4Lm=vnSPurnk9%mPCG^54T7sdVP=+1I@{F<&o#I=72zsR z9?{oQ1^DkVt7YHAVS-XJa7u13^@%mUB~f^ zZ^N?l-B8JVj<08M(|+qoGvCOVk)VaW z5N=T&ebnIi!2&JbXA;ou&0ya2<-*${ErMRndI&q6#s69rQj|MN0 zHt#b6cZUS0j?i7)iA`*ur4a~{i5kCx7DB=RL}?XmPQ$*RmmXLf_MkbZ-zyP-4+lT1 zgKsj7dles9Hu`6oVNbx<-P_U5ttrmW>ZYhu-=I}oky$iMlA8D&htmk|0*gnkR5 z-9~s%7%L{HvwOdHd1CzT-nc|Rj@$omHO7nVWip$LRyL^6+IJ$VQc;A%bT%0<)n*}F zIO=4J+2YTtWNBl6ukW-P`)wf>(l7SocB4s-59LS8O`6@1>2EPMB*{vw8IlHw@j@{P zpT{~7s@Ny3oG?Zw!&NT4@Wj~PFJ+tM{}gTi0^7i3zoe%q_(h~+Y-zzxnIfdzsp?;9 zIC}rShyiN03@V?mug}NWjXXAzt!Z8k1qhi;Cwo^)QR}_Pt)EYRMKIdDcinZ^Rk%+$ z#il_FFvzt}RT+F*3pMD2nKt?i_tt@eizD_vaAkp6IMl25v@Wp|GEjFu`V9ap3}diO zdpPt1^8(lG@(He*^0}tLW^uI8RzSUh8XaC%T6IxK`)+4M346tZMYuI@s5epdY^T@x z6TTIT^LRGNdWUub-?m)w!l--ErIqeCK2rSBFe$Pvin25gm1NOk_8)UwuEoS*0 zV6%r*RegGTdrfC;)O#Cm1hJu9ay{+mkj~n?)57$X9ZPF}#cr<+L^0yF44z;B9b6{p zZ2sVI2X*wA?tL(TKZBc5Oy%AkmFwQxdf;!;vbrhaS)MKB>vxmIVkUsm(Qq~Uuif=> zGuGQHNkzs@R?Mzf*OVJ#fh**p>BnLkJICy>%y{#%e4U4-c$&)Lo0Yz-H7AF_$|FF{ zx;C3EMmfE+Ch5j7@%amDhMzD`yNN=acKYdvGTfJ&0*6&G^~_E-ylQC539vT1E;4>pv<>5XZ(tXkBgr>r8lv&wU z``oe(77-jcFpvelncM0kvMm%>yahevS9#XltFDrx2^ZK&mk*f zovE?`gXu*^Y;DOc$vcG`<=^J>hU6I(a>Rr7R6NM$8Bi3wxae9Y5#2L2=r5lPSA!#e zXgask){Y`-OwWbJO%OW9QKD%D(PKic$|9#+WB(pN3Sg&4&ljo=F9E<~Uk)f#3JLHa zU+uu7&e8J^txz|S{4_EC+dVjfEY~cbQv-naxB#G5r8E4e__G+wXRg|?Qpdr+{kP`i zR<35F>X`&uy#}6k=tKW%ps1#dX5t!y2Te5N`zrF}Ova&3?HbanoZ$xF7_+BpT~!w; zs==~PI3|6?Y06Eb7Qc^?sn(SLtHu+YS`UEhv7E}9hCjX%f{K7J>$v{@fNeQ=H$HTyIN9xTo5NJ z{i1vdRCAJweZ7a!$X&xz4_JNmWFa%z@%3J~I^DPswKj^EfMHSWTV+l8IA_y|RdO1~ zMV1F%aFP&PqFFMY>-&8a(7dmbP~3)c9eYI5S2-XkuctqbawLCt^s3 z0o(nE>nZ$ygwJrnnEE;i92ll7NHW`fZ_F8PVjim2_|IU3;y6D~$HO~W7b-aL1J2&d zQ^yY#^(g@cj6k@(?_aPKuz~t9j9m4{p;;&iTk!c>(G|d7aRj`Ja3UNwLHCvPnwz~mAy+PpTOew85eOUk3xYQ!8g_NMzvrj6e38{<0Ws>$ zko{k_of>H;*<_EfC{8T8tH+A(R_EUp+$^+e8iUBhw#|rXKH(ouuC+Z;;G-JlsEWH@ zE_mnTLAR>H_K(F4_b>S4)qJ-xLfXQKjc+c`N1{-5dv#?OkOuRzorL^|LV|N?0*v4IIxiPt!d|m)H^|T%j3>pBRk2Q-(^1E#EXcMA8|rsk-2eN zC;4pU2Dp4)V}f^?y8EH<&JD1Cqn5EpV2)zV`I;YtT6g4@Dy%n|5a9&rS6}xp|9fp` z;@L@!VG^8+s)krB0e&S?j0nL%yZ<27kF_HC=@Zp!!lt4jhkdF1CsYT{| zO%%{>_}Oy$)22R+g&}4tkReLMU^QB1Qu)fd(CbB4(T^gnpC5v-I({Yx99hFhby)#N zn_)a974>=?f(prXmwKb8g5-S2doOm&_~RJIi6LwIJeu3~VA8$6rpIMTff*dE*7(qq zi4fOAW;TiFekMk(8N~GXd4FFtn~1(4x#v_Np<)AQI)@@LUg4u8iDfe9PaSh=IRCFT zmT_V$V?dBdA&q?6B4)&{vPC=IuF>^P!}<^trHUc=g7s8miZw$0w++GH@me*NESMu* zrkCCQ=+(5Aa9`xqac{XCX7bg&!MkWysk9sjO;1FjPH*3R#E+<*ev8hv8b#fcX6pFs z1Le{ursTigVw0VdACKPK8deuS5Z$wfqV?icZcq9akC>X83SI?$0Oi&sHWDLu@kX9C zop=2g$E9I@MQ*Z&%|)8yVHZeWa3`BM$63EMUz*-2;K|e5(i}4r-uN?y8hMQwmm=ZS zaFKGc91>Zd(v|ic%YZUW|fiRLklZ{l=6wena@yeF&c__0N`jJ-^Foe&Q?htF3Upq<d@OCb$vcph3!uVcHCyNGxI1B01*FkU$}CSOD=s@3ksU?q?jWB*V$q7 zUv0fW*}kbOLh5I~t|b76dwI6x!l3BtEb@h0V#=#D94k1f+-Cmx)My9iBm81hq3!I& zC13wcQeR6GQ@YF`j{5Ib_OAs&2_8I(_fbt7;mlHp0 zBR<408a;mxgPlwoR*g3B2qfRCs>df<0;hul9E+|ezhhOp!s2LnD;&EeN^?oa3NY-W z8Kq&fydlF%c;ck$-<*p$tLw8)%W1ReN2v_%oImM35^vm4WF2G2cmX$7N&5~h%lNA znYaLt>NaGFaQQA!pGDsODr;xGL+Dl(C?ddE=9naPOAUku%9VhTbbfEi?GL^IWmDuOKT06*o}Z(*tfM z!~NajTNdy7D{re;SsPm(^=%8bJr7wl5onM9cn}@m=H4zENtNr;LuC_8)tq=fC)CyT zHR^ApD?d<|Zlgr2b`n@ANl_x2kznk32>Q%J_E`Vca)%15Lc)rd+O0}HqgWOO&}82a zvk<=HEPRrWfy?B?1jkMC9YH!zaZ@3H`b|CtUxoiGNZ_N|8Ckc5##j@5iiEifJo_7J7#dB z=7++!q4=d>j%vMqe~t{XKO`r+QdO?_p_MG#BFfSTyHjPz^i z;vd)lPJe{ew1rl8@F*44hrjEPL)^|#x58BdVASSN$J1B|LK=8x?o{WK@fSg0RPB+W zz}Fc4_*Ao2oNjsxel+Q6pCGHll1J}>-I}Zhv4n^KSv!omMy#kfhSPhO<7$hX@SfN` z7|s|!oftp20=3vmEbr@8R|)mq`AR-9a3PEWs(5_dsyv4gPEL86Czu8FQxajfNin=LYc=tf23; zbq71oDyitb^hl8Y@bWhQ!207nDg=5>8Ew5j9kBhx5H@f9#r1JZ^^=h$cH*ux@qeoj zcUHuKIan6vEWk<`kpSe0C=|%Ep?kU!ha@WyGT)GI5c!4r5eK@v>(jA75c9mrf_A;k zQw?CB<(DlQ;c98~uXp_(h+XfuZ{J?PeKQ8fXe{8Kc&bCXt$mQX=Z~yRHd01xkWWJ+~NeY(j6p~7?Q#|N!hSgFKPOeq+ zQbpi$Zmxqf=hW-W;9DRQ00-j1u*S&NVIYZ1p8aLkI1|J#+0?Y@T+NYTo_l0U!RK(R z1kv^$NRZ#}G?MYz7YiQy++oa!z~#<0Afynp6E&oLq4H~C?z;!DC7eX3PJ$raGv47} z{5K|k<-$9lpSv`_m*$Du`1gABr`JvzFxmT#+90RMH-0Mpx|GPk;FifGaPBpxrIh*7 zyw+H``zJ#!&!#tJi>{^2MD^H(_(ouaWgHTo1c=`pI99fA%{#~Ia10CXRtVNpg!FFD zOg2K>Kxo*VDO&e}xS!9DT|sC92pyrZBPEO!#TP|nOvZM7R4&f(Q5~F`APnlye%p&*^`B*XZt*2UP1PQL9Cp+ z&QG;7pjhLCq`03JTu}PL~lbf*eTH1T+_h+_Loyl$2LbT=SYQ` zK};#wZ}+{Y!34}osT&PaqDt%2^y6AxZ*h+_w(qXRXot^{4z@NQ1=vWH4OcykN!;x8 zAtzUMtsZEVxgfrL1wJ%|U?+-}kha^ZKkHyLg=2FtgsD8~it<=Sop-ATck@Hr67JQW zen6rToo&yH$AXsP5O-0A_J6zWu~j>ebmTLF^uO9g{%j{TL;R2PdMD4T`Q8HXnaHz8 zwR~Q~b`MNvyYvUSt`l!u6wyqa&GG1R>Gz;YL0de!&b$jgM1>qvTdT1J= z@(0xnCbQU3<+%ZkC4eco+~KaEmwKc&_VnLYpbhu>w=Nza3NC8xooJalJ!Ja8qmtSG z(+(B5lV>IHng~{y%>4Y8lS~W{xsR4rS_|n<){++M4I%iR_Zv66kmkxlTrV-N97*oB z)vffY9M^Ds$wE$BPyB~ik5rHoblI(c``IWX4Rx{gg=FE0LyXBhAa|S84`5`&vfpr0 zh)YcwO zn5m!r2zInsrrOw7ZwQ5uqwc7f%XTwXF=^Vpx#85Kn-%V#u{5KSSlxfw2gf7<>tqZb z8XSpJhE-7kp}?>H2_eKiX1y)*40;EX^WjF=m%lFT@dQM%Dmyn9eGj)PQ9$`!?$f77 zK+>Ca<}S!7BH&&_&GYBZp{hIB_F*a7LfK@lliv&L5PgL*XiwTnsD>S6f*xxe%hjwF9Ycp=c-*5~^lyPcD#VHl`9Io~3j@8?x)0Ud-;Z7DPCLn=_hVocY`}+Lv1Mp@aE|jE_RWoURwsbQk*z-brWb za-Pf7t{GJoTDTon*8RpfyjUT>v@(3tvho8%i$|Wuzwwy*^Unw8DGW>tp!2D)du%l0 zWl_0XWWN|o@IMaZ&((0<1|mCh6QN-c8XrRBuQoj#N_rV1)Y5K@l5-+E>TM^$Abj(q zav=dF;?A0UxIW;Y2SWLu4GqSgWE*=!puN4_7$h^$hi0bl zmN7z{OwHYzq=|Kh|Aq5FH)ewU1=4@eIdKYdIv_BAAUszSwWZBG1H8SW?vhKoze=$5 zX29d0g2u5^4E#q=Wi8hGu%o2rK$@4`347*Tn|Tgwj11i~rGED0X#1U`xz%m%zd3JK zrd4NPG)&BC;f?4p3^>}TSGi!yU4QO~T>kPq@eT#K1BF-nIh_*w3Qr6ZpT1jawy3`U zjU{B?j?({f9bQZNmaYB0MsL2Y!wQG4GzA?Om~fM!Fw=UHDb9Q|Mxk#&JWtipJNYOL zyBHs-AjQ_|7qcf-DIg|8iNYe{JK1zFAdvy5qmRinl5>8Y6@m-7Z(o;z5zeBjw1GU5JmH2==6FAXY^PMx((&hrzWk zgP%VafW5&y4a|D`YyL+TMVy1#6w%E{*{0dMxD{ngw*9N$LL} zH&Lzi(c&xH&;S3BTi#m(r1g6>39?Y2mH~jxuK%H+_xUlYbCMA6WZ1;x-j7gN~7H-o!iVp}5EqBW#_L!kwqabkL0OXWQVPUB5Rpqy>Gr>Ff=A zbW_4!sTf~pn_b+T7Q4-o-{2PrfWbDeNbkr&j7>=;)~{5+D8(qQng#C7LI~O0^A_th zrf`D~B)BgqS7IkZPg+EXo54>4zWa2rQWT$N!^%gGp@;>_Y}gp|U+sF8s zHFV3GF+Y~+<(LU5G&dI2R(;?V^f3Cx*IR4venn0lraDlgFG?bbsNTz>xv`988m3{aWToCfUWA{H$>*!mtHB_)WmtNS1#!uGpf_E8^ zQ&Hj#cVP#=kprchsY3(HH#vfB?VVHq!`tVaI+m}@Y3-VlXPGjX*rPUUJeglg##WYWQ z@E~)nI-Q`p_NnpZ&9lU6D!M=f=KEYKhj-}%xF_qq=bC6C6B}8<+O3ndjp;puo(NLH zK&eGJ@(qT)YzP}d%?yd$x1o((tX`GQZRxojp(sdCmLl(*OBqvvN9p3TD~Pm!u$q(1qQbY?`4ccjruo5O3nHoUoO ztT|7$%gm#s__!=g^v7r3>{8LOQ}ggZix_@*S?&wwg&1M$|0mQZcUT#AU@ROaV-^+MWIZ_zfird{9-Y@KK77Y&i6e+ zLQsPtfcXAm&MTPA(Lb%3>+Z11@Whu@5N=9<@^&OsbA=)=FPTm1Wjr^K255vx!NY!3!WXZ|=BsEgz5 zVGF(w`S(i3y{2i0J;E%XVGRo%?m7S9b)G@*jCHyw*+>^X7-ku9u#_#`NX?A9Qc78= zPM{uPFbghF-%EcDV})J}L$^+hAba*vztQB0>U%S`y)BWgMR1D)hk<%yjaUC_=%xd3 z9y_1kNi0hGm@}HW8|t~bc_uvjto!NF`=G6Zypn2)>c>$~0REet{r<1_QrQJpPw4^KeXx}l`)VycbM*(^L5*( z{^Od_*-LH#Uj*51ggtI29hnUP``)nHy3~xwUWA~Dx9hDNE6o?JRZH)ptIe%xFyQ(& zLeZHtdC~Rz>bv{G*Cp2{1XcQ|#JP$t18rW1lj)DVeWo+TXiB6P^ZS z?079RIq@0lTTdB+NwDbM^Jly|aj=hJ+|IW{YI3A{_Pxqzr?z~#oZRaYlW^T;Hn z=>Vr)n!K-rQ$D?zN}?x$&-2$~V;YoI`Ler@_kRAdsV~B{_`XV<0n5RsemA@y?BS;- zT8#FO{iW;fp0r}Esqp(%jy&4^`bwOR3~h{|=Y#IX+m_J(ym6bIVld4=RO_)Gu5bV? z8y~N)++2WdjM=C_WsmJ>vctV=Vo4cs&P;famK$4cudtcmm`ShU-rqK|E7x{5nc)*T zP$j@YkAf;Xqzeh}=fle!eCJzCI!5xr+|058%!5gAxmzWcpOTxuoAreaC1EP4GcQ&^ z)Kv~$?(cROCq=q^NXLTC46WQ(CWx1=UY#BNZ}v4jv<(j`8?Y{H!J06_^3uT~gvO9x z$3f!^$8J{4OWUWUMTwgpNnv7B>G&hoGzNN1U&Y)+)Fi8#BsiDuWgqyZ6n}Nd6cY8Q z(V~O_mRxO%%oQ!Q`@39ZJR{PuN4~5&vnIJWTu9$`1nHXeQ5Rxf7gmhgeCo>jEIx8u z4f(G6?Cqa{+;BfLWH$Lja1+rk4cqbto+zuXjw&Og^Yh>+G|(rf*aBc__K%~`>_ zc@E<hJ&MC6KJ58e@O$24C%)j4d>)h7@rQ#N5t}r@w^D7o&Fl z?!yAonp;c={EPGd)deoprF96+^83G)fl4!)eJ9bmUQJ>Yi(24q5^S={->1%kKW-Rn zX}h?SYsqG|&%~<=+}SfSkQ6B*w5X*$4r!$uV{pJ~51NF3eM~0F!KItSf$fFfKs>GO z{rY@fx=5Sr=47xOE7s)aiV?R1B-+)_dQ?#xn0ga%DEI&W-%eRW zM%hY3sw#&An9?lw|Vrf5(n)OUpny=~>zu{lhKL&O~iJH>; zHJ+;<&i%wRs+jSE+h{6#gOIt;=pc{I1(!IIB9xo64;KuQV^FC;>ytLQXaKMOWt~=C zcq*MHVcLqE3QLD!^#_OU)b?)uu9K4?g8cx9Pzh*XcLELa>__LILMh}JxUZkv*wfQ< z6<(FhD@^iG=O;=Z`Hk;?i^%g>u%EAkK?H+GCw?1VP)3uDWqxkiZaG1~6PuYRH`Kb! zmHUU50X%R~HDBE2cb!>^-K8eR)@h(7*Q4%OU>FUHM(l)YE+xcB7} zreGnp;q*=3RKIS&!~hISo(Tnfb&qBlf4F^Q91%~Em(vmU;Gb-9DbfENb1M+zo+<-P zS1H!`G9mePByxR;V4!LeVH1qP& zh~gtAF;0t?V`W^O?+yn^mSUZa!`2e zJLqEbwFagBSn+Z^Xcn2XR_lv;<6F~jBag6sn+`)wV+;+PNmr-)AQB$DctAJ_s}@OA z;UdPj=^#&Z=1g_-rN+ijG^8p}=>5QRQXPJyx;*k3gZUp(sB-J){tV+08;Fs92dT>k zuVd)0%cV)S4Dku*AGJRiJcwLiZY`HfcqCq2e`*G_<~WkYwVM#Rf-IyX#Ja{jJ4L*C zWV~AQ6^XnizMTa5-|Suk9%#TozFM=o!K5gh`cbN1Pz82&i&~Gy2QH6#mhtJl7q;2) z;9w!{HlwYQ+i~*~y0Pz0;>%DJSL((O%1ceUHZnZ8#w#BD7VNIg5|GFariFOwxbe=K zq)kGDFm~4E?`pL_UucEZ#GcaB$j($uvd_76&fM6;!NtBs*Zxc?xKb~yY8ZN@WfKXX zX}07=WmOhZlAHVwL=)>3ton)Lky?z#NV!*&i*-_&kU6O5p>$~7)7#=7g5mhX{kXTq z^4iX+r@!vDXvyWkX?j*f6@>=Y0yy>@uj>DXa}~62zvI(y=wx2GPdsG-0Ynw8I*9lF zH8kw1SY%EBC9nioNy@Q6{K%4p8ckcfe&`?KNf9X5g`e)d?3H3@f@Fzz68LXfY<7Ec z44w@8k^OHJ9uw9?KC*pB9K>Bg6H7jg`LQ-y>h+nns5j@m$0gn&Y@N#{39H@y^^{fn~L9BM4Cgzy0zpbAq`W z%)V{S;PQfLfONqYYY*#3f3m$s)FNJZ8WeKHHjn0kxxAuP7$z9tDfDtzP7p)34_4X- zxTs8ltzO4C_~F=PQt)z;orUa<@dQGQm|9#+&g8E~X{n7CG z$;(ICw=02W`a@`ZAMIEm8Bz%r&Ac)|WZ7k<>c!QPb79vB9vP_2X@@RNw)F3lkHbNQ z7$L?<%QM&eWhOkMqG#Cou=QznuXc!%=!%Rp?^y{1{R_3B9DoHW zpHhtl)rD#eg#)J3B_}z{sZA?&J$r_4QlR^K$bxP41=AS|)|hNBxZGm`QUm3HLL96K zLlqydE0+tggEoF}f&Iy>u7VjFrTw%NXWR=YGytwgwCTXD=dr#0oSA`9(%c+E^LY2f zcViY?ZGotejNinQ4WQfD7R7{!aAi?~+3){9)FJ4VB9=4*Fqz(+fRSAIrDvuKOP%-c z3oXjP|Z3hQg2{F{z~CYL+T;m%0QaS424c*M5{%@k>e`u$PSqQ!-{IYHVEHfx!` zjcIjqZ1NsiWX{+hdbXqw>buecFtT2BW&tiX8B}Q6YF0M4*?Mb& zi5MGzxgqmF6h~rHbUD#2A)m5mDGRE+I&^QBtXEVmDqR(7p{X4my#jMA^n;DDsed4o z2;5GQzx8SiUUaYpsN9K>Ztq~#HPi(v6q>5#8pZjjSr6EbEDFd zu7@9bTQWrW7>wDPPT%6B|6yLQApSkKC19cE$(Jv7cFOiCQR}@FsGXf40i-Ia_jr(8 zWa}SYKBaK+NE^VZ5N%eiqF%;GlG<_IHXs36&Q6$Z;BD0iy&O^UW2YMS(6N2bmPlP` zIV3K3xb*utRJl!jFuI@Hs-agHZ02U$KDCd-XM0IY?0Ds@zM>E}F5Zm^LT7OCQLEuv5R>l{~n{ z8eto3Jk+_6NA5~>A#H~8I7NwCM=P}bYvFgf$0<9(eYk9GS>K-vy1bpi4LZXE@F z2PH%OqDieT+Odw%x2Es!s9`f-1^@UQv(fct-n49U5%n9X|K1P}WDnq)!DE?JL%70# zIXPU-z1_TEBdM^UE0FZ8ZBj6cpmJb&4pF4i&oKn0VlL-l28gHu-chh5n=d7ujYERr1j^uV7OobN44-3TYGD#hLM!mh{Q>tR%2!8%K= zsi}%__O035_@GZpsaSQ!;chLz8mp-ZU`{6-DL((9uozG>Pmwbhgplk(n4@&J=g{0Z zM2{FiS%?7b--L)n9i4M0DgE1hNPgQ}hw54B%u9GfdhU%p+OyoyoIJ|%#ef~hRB#XX z5Su7T&b@MKtSm(%Mmh()bPskMoJYm##7fnlnUNN*7Z}8)afXq0)7-w zz*WKroT6`@$U2QpIv7qJUQf7NRJ)!W12qMWa(I$h7K z*Iq=6D*lN=v|V_Ki)CpSVWT#y^^0QPH~%C5K8D{jql);rR3e}Z>%MfoZlCX}YMnoK z;JWnoL;c!GLQXv5$GZf_|MRd06@Ftv)`r1I=n}8VoG_H#9){a+pbNDG<8}hFB}7jA zXLSUg2(htkkQI0U!+V5@V7YePI_4wC+B$(DjA8j}KqDsjpEkopa8N2!8VN^QwwSu)eg4}^)L$$s{rkS&{F9(kOMp=aR1$&7 z+wlUQy|7wcYk?G25E3yO5wT65V~*+;=1;T}jOF4S-K=CkAuFX3guHZR8;8=@{qJ+9 zbX}pJT$_#0dB4!$US-y6u(?lmocdwWSOcIBcK(h#i55|~A1ii}92e&L@PhSP!qY4A zor3^E!M2VW3f2Mw2bA1g_>A+PZ}m`*>1XrJLu;(<+Ql4c+Y|_ou zUMO72Ln#w5Oz4l`kf)0JghO zjAdI}+i&QYj>0YQANpgE{d1)G>=7rfn};8>`!Qy_m1md&i4PXpbOm?fRILS%n0#~Y ztqJ@$;44Ll*9(gZ#o}y9^1iKt=S}irrt#o{A8BEs?Pm#J8;^>f{}XRAe#V+D?acW& zTzM(AA;GRAB#sed^Lnz)|4K)f-h;z~NL4M}|;d z-)jgG%e+q4>BkluG_7|3tJVkn=+GAV@%cX1d|fb3UESLlW7+njNdf%DW4__kRenZc zs2wZErkU<*>bNxb6{`FYOJqn&ZdFC`@im6ri4x5zj0XkFIU+eAQ`7(WPOi`TrrnnT`snipX&ud;p6QIDl^Ow!JCb&U#pPq zIYKyvIILph?N9cED|GX!$l4rP8#s%X#V?N>cBzK;=z3{$O<&MmNE{OWrY zS%1djC5Lq*7$VEkvNI&>Q0Hq=5#l)LEpyY9Y8n8>A#Om5sWm+9gW1&>QiAr9iVl)xtc?O6ckgO%Tk!<8CBdejBL6uZtCZPvfhh}B#T(lmtg zs%>(_adV$VI0!~NY9gaWBS-kF^1rPF)|?0ziIgPA;iz!|B^=g^va|8e&X6Dhw!W#* z?TkZ(l|x6SdelBcmpQY`!;v@s;JjMrGM+70rPOUaty$jD8v<*CF!STT_~ck7AvCIv z%o#{`(oB*Ye1OQD9SpFGC zyzX4q&w@mt+;W6o5Fz#!u2C~&Cg5cZ91m1w^1Vy?zyHDo@zc7g=OF*PD$tJ{T<~a4 zu^_4i*S;2x5IrKeOgxCBFKYjSeHBK!X$6`%mIF-}C}69Gj8;ge#^^jm1a{_w}~qu~uei#Co@@1lQcWgM#VF&4b8`=KM|g z;Ab56G+^+Tmy5x26(7E~sR<6`Go){@cQ1;=v6A+aBIh-M=n|1pJ%949-nsl%sLbn7 zr*2iZ!Lqoo8I3GqRBH$@i4w%e!JzuQsliEM7VB-JHW+hqj1P}*|GdM66bu5i`k!D2 z1fPf??#@BHdF>ZFYECAJ%~eIc2Eu{G+{nu8Fy7zO4%wyisFT0(E^Xm}6dTCM!~*VH zV8mmWXYyYk_1!GiHJnRCmgyman=*?O9Xb-dVFMgn7$twTO2t4?E_bT<%X$aKi#Ro`-F86X1RJxyJJI@EB2u~BA-9|Z8O=ka8AUODz2oV& z9|@}3ixk|WRdVC5E1;k~-Z2BObiinzLffj<@rFJwHN8T`)Y5E|KDIgt+l6#r704a} zKX9PoVv>rO@1Z?rOktQV4$G-mD%=`qwnxiVt&+hRQ~Lrj$DJ%68UFejB6CcgPf$&$ zp&Sn8pDFtQ2J=5T?cs9f8;Z4r;^Eb$fwP}BCUJ-Ea%Hr94`Kr)B_s}yf%c+`{E{e0 zTD47>_s-}AwI6-FN*4K%Va!y_<-$6uq zoqDFwuVz%XN(WkCZ#tonem(_{CQFo~|IImd!3k0&u(6e@O?7bMmDiaynpn zarxJqZP>#>PAmchNHAu>E;Sn!!{+7R$Q5?6kn*|RK_){7*?l!D0ZbUkSI}dqpZhNg z?C#yW*Yr;u$=ZuB!C(#^R13t;4~?tDG36GWnBAS)#eZXxoPL?f-5k4R{0+xp-TJsA z<%34Em^?hkF7W~b5Dm`?{spDlOp+5SaPl@VUziO!fih}R253P4JrNSce1`(fySfqu&pce$p+xtYPefSklQb^fmzzJ_2 z?8aCD#6V7-XpE^%guNoayB@{|kUZmkrv``wP@@Z#JnPjw(--oX21o$2&*d*OWf;XbulVa@NF zmKF@*#Lg$kzJNj1nz8?|M{Uy;XdUTg97t@M8cbdFl>ryCJ78|&DvK2DGiM=sn&>FVT59zka$*vGFqQ>lPyxT&X7*j?Tt*M_;iK&XLTR) zXzjg;hdBU}mdVf_+Q=HDZ8V18!A~)6-#FHh8Da<*UM@Aj3oF#T@ptVzCl;yrt{rd9 zZ`dS^uik~Ft=RU5EU{sIC-q$K)_8Si?_a&imXATdywJN;gh;E#X{%O19XTyL<6VY8 z+s^u6N-^-zrxo*suD*;K3$f*XN0s6ri|WRva%N5T`@!K>Gpf~lV}d^M#YBywxXm1(wgf%lhaY6vEzm6j=*WaFQnLRk0$ zpZFUth9HW)H?lB9)LY=X5;}EUngSD4dphtAwgn0?KGJPEf=i|?NObI*)qkN7cO8E~ zY4QMJ6hipsfpL7+Rzc3*LjEjl>cfu4tjvKgx-tF^8?G$Anw-_^qGh7JWLdEk zQx(P}fcK&6v$dR*C7uxp=JkTU(1Z<8*g!Y5u zj?)Ytcci99ayWu9ntPtVHmCnh-(0Eoml*bRyO3V}X?R_vYZ-0FggQs{q0b@`P%>Ukc*=)y(&kuszr^{MQg=CEO=kA6&c2aLHBv3hdj}@d5m)m@l7WJ}N0GX&n6D zpg@!eP%vHvxqZ|~oS(PSNLG__pWl?z82J@r+Kk|{t31W@6_ z-XUwyBpz4uQdr>4&U7>aP> zD%jVP4WE9R=U7|=Nc_(Sz<5Y;2KW!ZN_O&^&X`64=i>Q`LPG*iL#gF{Wt*sCw(*F6 z4?FBCUFllL-kq^jS&WL+ZxN*m&KCdW-NS8dd)@?$WGcl^g4Eub#LBvGB zNdpD=DX#Vr&MGAx5b$`7aPoM*L-GxB5**L|NJq4Ex5>yB8J)Exll3gon5Lg=>2QyL zyb5Ahn#O&vD;>%{YvuQ?U{5KK79ybiY`@V5`7jXYYR-T&vVVSNznt>87gx=}4a}I; zC_dS65Od?aUOP(jtI4|QL*Y92$7k15a6w;REla87YM?-HYE&paPf(ur3CGR(Dge$d zcP%SI4LdLG_4X-4>)Vd%OD>dg*m_pML>JLQjN~k=<2TZ(HCNN~W<$;)eG%>ZDJEeS zjKRbmz$$zUkcZnSkdSod8h!&KFMnHdr#3Uh0q|4KYY*IGimo!rrm`L&WuslAYR44& z>`zRu4M1#{ztY-^a4~HNtuYW#0U;hgdsM$$mFdFMa4|R9`+x~0*E!`_@s{xq_p9t) z*6UZuM5ZK26{y5ekAeaJtcV6GqyFBH?JK!wrKo?TWpQfCGQ*&U6-Ws#Io~?&4&|vJ z49@?Dh&-?WA2Uobfep!nNAFVW>4VA%M(O|Cc4*Y4ZbNScfjbVI_T$^Ao@>=(q8LW= zh^zgYMzAenV3t>MZFK<)hdBacIAF#WJ&*da2FJ5Uq3tbSra_)V=(Ncw^t+y$Pc~V8 ztZC)6cGxWT89L|}>>^H4g*BCKms`1iS<%|y;i~+qM@NiwFVEjoq8l49WP{8Q2+VJR z`i^w}*S5QVkoLd|n8zv~ASS=Po9mx2$~mKrpj>f@z@HF_c8skYf%B3Sc$E7#js?5m zNEgIu=C)}^P8Y6>d-Zln2&*j78qeb14e(GCL5qBu8ShW z{^>s}cSsDCF(ZFzRy-7l@f{PPtaSMBr1{LCK_Fj5cf;PMWD$wP|Ipua6CB3?uL;5^ z$!#?S?W8bpB#R*H^PghqumOZf*r#B)1=Vz=PbD3>0DWo(o9N_!8}^bmUnb~OfeyX9 znq1`YDaKP`>PbxUJYV%354*$iBJKA9c>V!Td(=~pAZ=`Ra@-U#R6d4e4ukfP*K)>=af;2tR_{aT|I~OBgw>DRp(c ztKJ|7Nm7a|3gF>)xX@sGWwiPX_+SyCr~0b|M-$+m0!`cIIS14B>HlV(P1^-7gn_bq z!x}SShjemEfQ-Xa$zXtPWuU--c!(XXbCEDA z&{qEVVRhi1Fa10WA}XQM*$*dM-j6vj$x9!jgn$g$qL+v3oVwz z+AJgH3wxby=gj#x~1*x3{GL*kdYSpjHC*KQhj}DxgsLSzNcrIUO%D(k|>AlxLh{cFuw+UCl0_f#5 zM^Gw8K~|TIXz{A|Xh6z)1HyHxe1!Sc)Naf3d8@W=~|A3JUo z@HX~mMoN;NRKJZ?Y2UwnDsfr~Hi{_Fiww~Ah-6DCkviV@sXF!99k`*@DWvuBiiNuK z8IL16SGx*)cgv+|R_Ea+>eU{Q`@X$Iz6Igwg-_L6#m`TUz$NY^(FT|Lm7)g|pCs_s zTbqS1&y6(B7GSoj3dlYMn4?)5%P4U_QMh?Vi~Q|Cnd)n?y4P>5mOVFCJKfF8y<0WH z^OeZfz?**i#aZmus(qB00W1;`#c8|r_@y^69!-7DHIJVL8|K2?+=q68zCY%qm7iEX z=4he?@=E)9P;cm@zUQM$UcU$rU98sv z55P1_XUBubrUYdFhT4)YK1_=i7onZH5LAD;BS<0YyUGQI&>90*rolRb;@oHDS^|2) zEBT@=X|aDFgYRA+wwA(Nndd$D>^0W0P<^H(h@o+)x@s@==j}>W!9+QP2hb??FKf2d zk8l0f?cFOu<#6pU43C^vt)AKj%e#Okdj|wIwo3KTilA$;b?N2&`YhHqiSsD_8(W`l z<*byLO6aWYyur<*!aR%a!kuYZ9rV6s{)3ls0$iukvt@Mgq6UM3UQkA&38Ep5G|)5b zwFJeh=s+0Fnd!@g1p~MP=6>r`VRSAwFBaSnpEY2*+e(^`J5qH1!=K_^^9V;c$?W|! znX{H5a4+whiu)v_Ft9&!u7~mONUEB=v5f3mp+==!&fIdb1*Q$IDvO>wTJP=37P3TN zkNPdFcJice{Lwyq{~r!-V}(pEt+dT=hu%z5aJcp8|kxmLvZEZKMTzanBb- zGb~PNa1m9LIdVewh!~oABUzw>Ss9P>!FVCT>lPOecz*lPwBlOla)Ntcrg4g?W<0Vu8p^;jj3IT*h_Udu8aZW&i* zW0Hqn9eoV8KK+tkA+hbKztnPzXjsY8@5lSNROTSa^vnwZ8PS>q?BKF1+ww;)|8KCO zQe@DLUD1dF{9gLJw_XZn_0=TYIJ2IRJH&#S@*T-1)LLBRB_B?hx#uDpvG<5;&HGuy z%j-!%smqLb77ecK+xHwC`b-OP<_v#cxJ6e45Gr(@+ryBEd=AZUtaBL4;;bKDjTKVe zfQBbx!UXC<7tf{KoQQp28j_RkM+|xxKUZ(H!2o?4f9p#2=8KEyk0$7kVi5ANoj5yi zPkwF(?>q=!I=j2OFC$#ek2NUYfWh<9hFSF}UlZqQ)a+cFQC5UsbfRrDj0B}$!@P$Z za)WwSv>0?fAROg4grlhE`VwsWG6^bePMd?DpJrt`2>U~Er~LV4fK|x?u&kmQGU$bZ zc$YnRn|XrEa4*#AH@}_I$w#?8!4EXXCV%QMpW;uZ7w+qx3_6{DvhWN<&n(I=Daq68 zI7>!g(Zh*ox8tVqxvq5A6#@3E_vstpka(L}{EFXldo9{4?vrGTWW*!dJVvg$Wvz=6+?KminWCS&a6Q<^-0YYU}Ml&yEa%j_s_H2yrJrVCe#uV z2Y%Bny;xnb8o&HgvL>EY+Wdv- zJl}M>FYT@Uq)NX`iLCVqRy%j$rQq?^y0O^$#VMKfxrqy8yVba#NWmo4nB*khYl9HkU*!U5fZ+6k z1c%p!22-A8f;wKi#N;Cj1LRSeO`rxm8)wC#o%-8b;cL$#@o}V_*d5{Plt>?Vi52E6 z(PRGKfEh~}-4ix^mjBc1W>(Jue<13tTIuepiAtFA&QK>TjoS2n*OqguF3ta04QqoS zOpTX?`;PMk21}2|T^q&XD~A!F>)G83sLMV+KK~iiKqb{*k!(vU(%xcsF|*_Cdb(P6 ziqpC%KM}-c@EsG@pA{8n%o#rNJDPRlM9c2Mn_GDa#aX5w`dqQYaCLv2OOfQvf73r* z1fbkkeF{in21>(!_wIGbnfY`NYl+b7Z{L3V``@4{5zcG-=uPA-`O@^olIx@*-hBQ> zW)Haq=_xZ)k%YUFNa$h)_~?JF^d1;^DPQGsNK!nsTP;*hn9V9`mNhcvw=0+k1zKTw z5_!7XXDKMjau%S;O z2{%tzfj!S}pZ650hz~V{zj8_X?ylQ2D)%Z-`eYu)&H5Tca3vzGBKd7H-LGN@AiQN) zaFtbZ!Tl4;d!b`!BBTU2=M?TM3ScQxt+RFQY4rClNW z+_{Cld#*aNIiI|_Gfb?%zsuFdZhYw8t4n~%96VZA^>n0VJFgA)XQe>HDc<&TM?z{L zY$nc|DxMUEp;VTs8?48XN5BMGltSg^;}0Swjm@&!H^mkfP1w-6HiuZ1nD*-Fd&p z?5eRv-=8w;99ch%MmlexmGrVydP8WC=h@MO{%W$JwWvHt;tAS+mj@+l zQLfA}g=u-0-Fecqu4P1v8Jvrq7RZ15PH`9N3~FfQ2@|IOQ6ax|S`Am2Vn~!oHd)zc z8}n|fR0JsTdq*};HL78+cOQb>g#C>hJR1vAL|INey<_f32*#XracOc>mg|czRy4-$ zrpkJU)hXA5ySEy8&u3inIMkr2DYfX%?zTTorzMrLUxOGu3X6DuMPglm?NV&HKgG6c=J&*YsSsYHKK4`6ZVA%KKIO@Zpko zj2cJ^vc8q&BQ5{=RcSCU_NMvSXzw?a3L-d0ioX3k`NRUFH)keZ%c&dFz=0h#!^T|b zZ2{Cg63IA*X9BtCoYtsw7ix+o(I6kolcd5T=XRN;*v5`=enp1K6-J}{`~5S3qI=g# zmBVJ|0sbBPjMx~KHQ|-V=th02KXWXFOForrrLMa5M+;+J=eya z7;Sm#J^VcoW6n@F$@jou$B3o;5zGE$!L7ju&+ps`g}1%Cz8^6x4_Thtb#~$aOz?#6 z9{&de7+8f$JlvK9ao@fC_-3oh`Q2J}n287fK^tB2>fInx1Yl-&nm0G3rxip8V_-~D zoLNAFcF=NhSvhY78o>Mg1z~Bkj*w@STlIh16accHTroZ@&Ht_#*7S+TLYnMT<&qgi z)J9rs3;=>4*4r2(HVLUy9$ca3bA3C8v>z`Lh1t1aly55Y`=K*ratnJVzlY(@nig{_ zOZnq9o&O*lx$o~RD1;`pA?=t0D>>^6VozIfb++S=st^*2A-P3eC+-eQaIW1y)d>Vu zU7>Cvl-uHoySd(*eu$TZot?Li_wPek(Vi)N)^R&Rv;y!C27T0`VDh*t$U_0FR*RzU z4U6P@Z#%fJA?B7l!Y!Wl-JuN*K$Zt5RIJaBoGi3=(=O?Mp6I5l*-%;RID1o86cX9{^zD%0l8SO?Y`Da5* zHFlI1)6e=K-k%22@+(28!6uj$=;v-j9tK(7W>T}dSyebS^b zO{8X|jK&@C_}NBtTWmlX_=;{S6rB_{?^}eQb@sWn<8I}q*Dk>?X|J2pN=MW+`bBoF zTAf>6yS7+$W6P&^7x7e}bj}hV{&M3=kv96{1!Q(loOzJak;owT%i~+t-*qIlOYJ;O!{ zOz3-t+2_47d|4wlR7jEmH~K)zNaZ4a&1@$;JhgGL3iFXqtt5>SqMbyGIN%N5m;z<9 z$CiowEgqj}^&Oh#aUJ7WziRB(zI4*-!c!J%4&nt#u&zHqdMfWa$1gYUS>^!jS4OsWY`c#-BHm=%D8f**nwq(0Bf~bpJ{CSM&M=p4JqFO>za#iEy5uuG&vUGcRXKM zQ~UpuSV4t!r@XJw>D8M4>DnMb*uHG1JK3IqQX1F)fluj^%n8znVv^>@%(Y0{PJ?%I zt!Kyvdx_oOzpLjZ%$Vwb^nkFl`5&J!yVVYD&omV&tPCa2$c_M_a;^xdEkW5Yv$y0X z`$~Uod==l#R3ZFV?3c?5>PjBH&m&z6aikdWqqlx;P})|(O!)H>802yXeK(cToU>~OPrK^xjXCg0;cLVAJWAO0SR78g4t15^ z=gVo7fZBw#PA9a9iaA7*x)E?$#0&?Z6?Cad5@)4)Dse2D1GME9!``p*$ChqyR>I*W z6{rlVZ37HSX2sd{Uw%Abwo($AS{C%<_tIso4ek zlMRdG%Olhf<1phDuHX)-`DK;ebPzYbUwWapz2WZ{;ew{1+OHbfB7X$y-~HD+-H7;+ zKco>D(&Sz_+<92-bBEB|#1`3c)NP)NMX0rLxr8GDm}T3xHYCNOR?UIsm-;#!X#qIVZvK*+TVk z&^6+c^c6-+hYd2-=<51b99;bwnd$Ws8?_SIBQ0m5#MD4ubie9zA-H1)RI!jHpsQdJ z0TNxERY^BsC~`LNw1-Wr2T1O`c@z(LD79IK0+r{}>2+x^93IIh=($%?zzMs+N`OV( zrx$>%@TmHFtPnz6rTeT59}k{Jjai%mYdr{iQ?Y}L&9-QosMF1*WAu?RfI^Fko03jVa3M&Tb645hqxH$`Vb<5{O!Vz{5aCpq9&&D zCl(#!aYTOikk*IpP z{-*-6GVI<8PxSw=Gk9Az_J28u-rE6l9$%+<5XG=+J;7qWO){xykRh6T$e0phOM1N= z@@{N!!`7B`oH6M*UZLfwcFs#uO(4N^PFU5NqvjxwaMI`bU`67z5zu#2U8}7ZTFT^I zh$$_fW27ij!DaH@ZW+@LUgU>`*wuz0;gaXEfm7fkF<;(OrkvzadC&#dvK(;q zQ`1pukwpN^>6qj}(hj~C5I(*=gVsUv0=lzZi3zSRk-;l`D`?hB$aj=YYW$*`bJI_* zWAkCIVEmk+^~Vv;*eh$uxaNGu`%K^GbG_zw8{>Rp=21kuNzHMd4_9CQgWDlmKqQ{g zQSDG5!1BNa|{PB+#o#637dz&zO1UdJ>8q91)rt_9ZXd4j~R7ry-5 zIl40WB~1gpym6HY-#jG4cO3nZek&>QbKy~x$yIKI=k^K7w?hbpL){CY)eJLs6dAH; zLT0l>iv4>QQ}hX015)$|+TjPcHeA;mOw5&-nmTZw>Z$`JD;~zNBjt@NT9goOzUbM8 zu9t%=fw5!C2`Np=)`ytYDnFZyGjglg3@o2$*zB7}DIWYjJ%loOma2@^QYVd)gq-)? zzRHDqX?CrGI!+?7z2Uju{pEUO>BsLD1=aWuqv`dq1S_G8U)Lxon4mh-pt{@dAC3#g z6cPcb0j0hHcpe%G!ScH6KJhaE2^C)44`A$DkvVor=`)eg1Wy4qSsX3y=Gnu0ydfq| zaUtq{%|S9yT&H%-`<|I{9hi$g<=V<1-2452@VIB^eH)hLJ^_HTpW@{pa#h^jS;;j* zY+G6wC88pPkBkSJqk~;iI+~Hx(xm=JUm%~(6cov7D%f(R=RjyruU|2gT3hQspKmNN zhV)x6F+`4YSiHgsP8oJhI0h0TPGaR;^9E0a-r(K~eVP>QQ`r5fAH}%)Q-PK17az)}3^IV4oG^2~2h`e;1f{8B(nUdC^fqWMqqtbp& zKj3L5mTH!KOgha3L<1)-R56l(Eb}=U2n0K>vRs%4kMTOovcru&&jIR_AwniyL9y}_ zuH0(Tl+PhzF}XyY)0}S0$n}6Y)i2*&eCGAT1x~l>no9K@_d<(Nti-1BSQ-*QyRkJI zjSHFa6vwU#1rmauF$()gwb%1|5Fay79P=Pvg4XR(EeZx|u$#tm2uv06HOzgCT?1ir z2LGf%QLKZ=Ng);vpZd(E!rWKS4}Yf~YbJBDW4f0EV@|h?M7mTaf!a$zJ^Rj}@chU6 zRz__wiRUD{U3Q}%t6BErG1n)>OmA9TES3|dZqkV)L}U&x@s|M50g2w(7EDBi4m%>rv^}Uv!M4T zXekhh@qZVBQCk+$oJETgO(BGFi78QxBH{C=p`tF(xJn^QsQN|0A$+q|FjC%)z(BT2 z!7ShO=-SO?ufm__CplBUmi{>Wp!n*{4V;RB^9grYtvuTSe}ne%$j~}G1GFGaSdsa9 zzex+4WDw48@4Z*mr1ex&4QJ|WaSzKV34@E4=`ze#x@J!8=#HSvy|DNHemK2YVcZbIlTa z0MmXYZc9Q=tf|(>&$-GmjdX4Er^NKDg@(K%E3W|erV?6+Ba9|JEg?%y#NiYcJUU_6 z=u&qXBM5J6@VCkOrW%w8FmDL^80Fb`egMYGEBVzq_i95*%#n_+yagRXAO^n$QehVB z&{R0=#9b|w=gWnNq7N)*y+9UzX=dQkFZbrpPwe+a&v%Ufx&g@J_x&8=fyKz;7g6{t zN#RF|AGz{Pz|for-m8|UU;RLy6t%caxbVSj-g^3a4FR^ARg0xal^bzG^2kHEd3IvL zD)H(u-dr}iF@z7NLLdme$a1^d+I`37c```GCJmk&zZtb9&3kyg5WPfB-H!;Jo>rmF znr{V--{pE5gz0}CYAme=|6u;Yr2zGV1{mjaG+)l?^OXXj)m4MbmzohJ!sc=*H4Dkl zMSSrG@cojApb(1?!yTQkG7%F3<+2!B8QiM5jP0UlnI2S8?$^1{cCbgL=W?iqHwEmn z5^b}+-)Od&G_66Q-2zM)%G4-ZR9=HyWjk86r^0&7|CquVSfF(19&(KeeG8saasvu-8`5P?nH77DU{a^Eohvy;)UZK+$dOhSUaC^)kJQ5EGKozUsM2Er$G z^9e1occWgkG{}TT0`R)=kgL=Fh~KyIV4DHb3~UtcR(@0Zfa6%-aXi6>(rnIuosSC(Zk`*B7N zBxMKm8bo*ebn=#B2h7R*dW*P~!xkd^Qoui`!h2@l{487OOJV9+VV!49he zFkT67n)(WjA>G~=eMRf3Pr7&X?DgFGiMtdjP8WSkc>;X9U3>aZefJJI|CSZ|#fN)+ zc;2^Vgibx9bYs({g+?^>Lh$@Xp!@u?lKIu@?LiO2gV!JI7M-l>Cg;hNt6A6u!U9Mx1rj}zjBgEtEpy2ySRSH^Yf#09exqGM9YiNK z3LjHs;nsGCF-vRNV+O;7QUGOx)daX|VAgVEAy!@=37Pp=0+G2l8UhK3V$iZ1SzviSNx-_$|C z@xdQ}em~5J{g(vDM0{M;0|*~g)x#y!tdtXqv%WBLm;HdOHs@~%W4}DPK4YrwK~4|B z^lM^RkH+|{{b*r&B1@N<^hL8?6+d2*mlE%gH6QPNzuKwNlv<_uHHsOJu4n?~RsO(nPO=TpxmA#@ixYT@(N$%>Y zkKvSVy>y8>RS0P8GS6@e3wAN1%Vv7YJ3C_t)jXU!sJxHuQwcaCj8#irLU zL5miN?rYSCFQycNF{?ADhbw`mbdq}={hDdiEQhO__k+KW3=88!UFO6 z>h&V9rJhnpl#Yl@1(YcapcHXykb5?_{3i#yaTt*<{3eZjJ^XyyYwpKz2|0qe5U0hs zx%d`)bah?ytbM>9KVmg(WOU?@ayk?Z&*~!8+cgst6VUOck|8@R&ViZS62Od=8^eEl zaKGlg;g%#l(B(vs)9Z86d0|tFAIB)dqC;!8>|r?;6Zk?1QlL06a?Sb{QbKAt(+(Pe zzLb8;C?o8dg}fpMG+liUMGn~QivnIF%|dvf==VdVq<0?lzh8QZa@KaKJ+zxdQg-yraKO76ctvI$7z+a4cW^eV3hGPe4bA@C2KrsaMlvD%i@u3)#-U9 zW*ev8$oo~ngB$!=nVdJZ22Gfv#5lD%eRG2j18SFRZIVUMRob^7H7F^ePn11B!CE^JRd|xFa&qayJWkCxnCv2Jb&L$j(xj8LSEqK(ejfm zAhymAMbz+fGuzw%*EuufO9pl=Xz9=&Ehmt8;EB8Pa2rEU1oFqnZ{UIPb}_fJlp4Q^ z_B7dfFk!7?97KHVZLWURDQ^#QYR-?Z$y|H#0Ha{{U`>NQiHMYjXx?(Pdsj{05LT93 zp;(tXiXR`xs=Yo}!cer;3EcEYTY-{qR7L%$!E0nPRb=k|N*yU6bMD25Q^ z{9W0S?5I&duw}N&fB#zHI^pXd7>b}2K_IE0f{}GK>^R^FY|4BBbt>t(4Pr&mUE^jX zql4v4SRN@Ha|wLsf(aK9S@vO(zoB{XBw1$mp^DC$w5pZ~1!Ittb z`m^ozLHsoQ*jWGLRg*b0^u9}9xG(SQOt_9r1*e&s8?EinkYM86P3ov4R0PB$tE5%Y zasMW`RUf81EXNue`A;yg;Yh7DyuI@O zvDF-u{%DjuvOfx*J2XKBi~q4<+gp0?C+0L@eMjPzu}mO@OwrO5VR4q@STy9Datm;& zm4|OjO(>2k6(CQ|TLTMHUd6j!tj2Yht$9r^SPyQbY_<(=RJl}H51Nhop6TV3gFONQ zWdw-dkq8=4Y&kF6Ns^lW@HdFQf7YbX!+0X{4|p-6Xu5IFc`C9#M5`_Oqy}QnoL0xM z#Ly1T-$YMF-V`TsXdS@UR$WfF#;AtjH)>-6uS;`*@Q4L``M2#{>t*22OppMso-q8p7T$)l~U z5t*Op!#;nSb|s069gV=eJYVFRz6DdmB{vB*F9?WPUn~9ZWZMQ_0?u7#*d-Wkq{eM- zdV2&>7RF$4Fu&@8tOaJ)eBoaKzl`xpgQ3PvovoPuD--Wd~P`s{jFy*-eLa*>7TdgsuH1I z`}HGAp}o9*cf-!Ta&)r|`!*mg9TCl1U;w3m;-0FNQ?G&Otkk?m?YS;+^jlyQVnriA zY>U4$4F0aBpV$?w%5CL2Xl5<+1IIx${HDbal#jZT0>G{Wf6N%MfOKkjKIj*V=tuv( z87BZS;BKjWYp6#$#V%cJV&5rdD~E*%a+d!e+=D8fge|x77_Kk7uN{2Ty7K5)*hmS) z)fOASu|$8o2C0YVKfEc#(P*@vFrw#$RZ;hfq#HtaJ8}~BUxM*}yvXfI1_@m*oN%_l z+`N2L5BO=@+qr*YT32Gz1sKXGQ0yP;giUij`j(}N1ED7Uu?YBxTfa6z*adUmT8l=RL(%--sD7NJB%g3Cjv^7PSGI=&* zTJXjQ*a)@yTg#E8S&mDC*@7N}FJn%{@pVu5*C8+QN!~n8Gqx%JB11d*=$UpsuP=d^ zf-(`~#&0^0Oiofb!T(1{=>CuI?2@r_7NBi{kdJ%gerq^Vg4eKO{U%YgV$A!uO!9kTSme(SyURcd!8eA*deD;uD zM{(wiGM}oQXw<3`_smZj(1e>+e4{b||2iNDy$yZ%DU_y}=_uKC7s{fJlbgVR++{bH zb3v7bFa5c!;3L)U$c%Nfnn*DOOV?u?#aH=NbEibND~v>%v!G)Qntke&LfFNbr#c!E z=wx_keHM!WOn5dD9r^%QLTao>&9-=0IEUg1v;L!9xC#H!5eknnNp9mIHTn{#E!TI# z9OBeWiWmO|jAD8;9*sN6T6G)&EnQ%VDIppem_CQDy-r#PGzy9^PG|b3I!E zSiMs@PEe|Ref`8iocI{9vgH3mV?FjF&VgZAv1*(aUpB=!8YOfwqQv%s0WVCLF*o(G z*wnTzgUa%=lTynId_t%4p9=q;l`A{kc)@W*bYtQS1!3d$bdXi?+rZ3*EUOhj<6h*G zAsymSeWG(gf<12x5x_f zM2Zj)*OL;4M#(W-pnsKNtRq26zzt0U9riYQ3IDls-c?KeMbNqKG;XeIJv$K5Zo@Al z<npI-a$;&RY<3bzHsTUgah=k%n_Hclc1@TeSGF1o zo|>XIUcdnhvSXAoz>~Fi1r61rgr8AQ{nLkmr&2Wq^CXRf%STA9Pz~r9XerX3^^c1u zfit13RDX0C8hQ_f#dI5im=(K6I%54fS zsGrqj*+YhbE_1Zq;>+eoB)W0InD}wJXntLx4lmC`oqOL#@W03P*D5V1Y)*!=4EJGF z+ZK&lAO9ovy5(oA$z|s{?r8zq0|#byVk`xZXS-qYbyEb!?;pIf8>P?-fz1qZD^q=n zjq|~HIGoKu2CVXO*(T?@j_nZ5$TnHAHNx<+Y zI?z}^xl}k00DKu(Zv>145YWF)#FJ$F;lb*(_grgb4_|YItw>ptqK;V{cp7#I3H|-JUk3rV61;*VRG`l_08pv%jk&=#k zzyFiAZFZ&S=uz4&bIVNq@8q#<|6hs%rlv4%-;fs5siX&vs^9_9bi1gm$hbr?ab(OL zN3kn)Q>*Ma;#ZW2l60?PF~Oo#uuQG07GA0SvS}5;or^HHVO#$Fp4MYUl(8Q|n!?xo zb~#@S)Dgfe`2W#s6k+zV2i}F(k1hkmO?dhAfqQ~H6~!uPdvAB!dCm+laBnE38%Jn< zc0;Fa5MF~}=(wDs!A^L^=zTN>{W#HF`tsZ358rZn)rI{_B>)E#{lh&JcSCY7e=Un7 zUFQYc&Yl}GWByxWe8(l0=y1O04jTp#4J)zduH-eIrWi+l3_=Gp5$|s|t`fyQn+)`^ z-KUW#{*C^yE-TiIPk*m5rC(09M?#12L8T@4!V_ETCMbqpAFTQJ-QKh6y&Y!$s@n_x z<@^J>LCQu!Xl~_bR_?igE|fDkBL=1B6`*J^p@Y)&#FReu1x6_!sWXYFLEV=5`btc0 z#04XEC!%y&x)(q7)<5K;GwY`}Xu57Z=0EM9Nft+QNOx6l}z*#cYtQsO^lOhavHdBhWPZi4OqWyLPNezOia3k+yU!?MLGYFwwRIT{ z&W8%9S7zGOA8zd_R_rl^+{QIO854uLWFk~5K^S&-!tCvdJ98p5GJ|Wyp%)&HoUFi{ z^{nCsqSaM7ZaIaE7HF1Q1Ig|9^rmT9>bm4$M6&o61{}ns{DcX^+`g4!Me$E7F@~_1 zZQuE;IO|aKmP&qK0KL;A|AOTu6gDJt7PZA*yCEXfljeb80+6;yG{s^HaE?C<48$rQ z9vJ3s@v&GI;mkHHD0WkN?O-M0e=yWX3fx5g>NPFG*}QAy3kJ49Sp5*;Eh%T>#H0xF z^PmhJ|A=ttr8P#I0IXD?tOafAbkR#P*k4%otbJdii@A^ER`z)s{m9Ah3$9rr2y3*( zQd>Ai%D0hWx_1BD2=nv9-$K_Y6(yy>KY1hhWLv;Q%!Te#4&KQvt$%*ad%!_)UFz5y z+oiu0(g}b(oK&zNUH$y0>L$VZgLE@}m3e(L80w+2UrzlApYh2gg23AnSdUJ(FOZ}H zb~BPhQW(#`e#7~(T1l6a+OmQqY42S)8(@mym~{R&$}Fv>Q-pKc&u$!Vg1M^-I-ba` z)D~H_i3*vCiW%T6N=tdXtsN8l`RT>W@u*56{ndKCVFm1F=>1B%bns7=@%i) z2ULTiC0m)tq>T0@v`>SL#~$;w!g$w}g;37GFUltv+6t3d7>vMnQ!vf+HMy}V$%^yv znlQxo{^1c1LIhI~h)~2OEsbj^ubQjF%rs~_<9+6%b|30aQlm={Z$Tod9Z>oZl+A}zv0MOfs^XLEx%6cwTn zlPolK=Un1du<_&KGaqbi(C)2$=vHC``UkgvJV*bB@VIm5SN5Yj2VOPsG_Dlk zJUoSuI9Gvi(57Y_jMJ8eKiAy~dwkm7NCnlulJ2cmnCE4Cpd}GT|D))4mmU9#3;k;c8TBsIsM| zN3EC|n`dS%!J{CB)Df6(XW0gJH5;QGM*pmO?q%B(Kb|nxb={f5xq-_G@O`>R#1pN) zXeIDaj#NswICnVepE3VSFZ9{{G7}}^`JP?LLUI5Ry!^vDdzf_i&ViO--lnMaKhjg$TNRa+BnnvK1kP1*3O{HAX~`>%W`}Pq?Uzv}C1r9~kFk#5VRA}Q z98r=<`GBBLL(^BVSTU|Ldes(A%PKh-c|Fs>9RwwfNdRjS2WCo72ah_!t9F_qgxG)R zQL}_R6OKL?jNCp3bPl$rouxR*(KSMig}e*}Kfr+r+M_E2O~yYvE8D+T&`wWVN^k6K zw7SW+JF;R}0xxmZ5wuIBbso4^YXMyafE}C;gA#~aH~Cjre*4?cLL>NJ>PxqE)O|qU`_J@P8;$n)#w63GV(3H4(}LH5`CmE zQ}FCD2|5gY3chG?FBtu1L}~HMkLzNur+>C^*{VpCz-{$^=33x)O3|`_5x~$U@Y)W% z?f>g60Pg4e&s@r^+O4BaiIjotTh}S(B>$h|(x2_~Ndn0fWpf08AF6NaT_lV(KV}*1 zWKRwyJf}ksQeC9c7}zyA8O*ZHeEe1(l2|73aOy0cq6Vi!V%zSoPyp2BHDxt` zwxf(cMqh`ARe5mYS5xw0+h!IWaS&_plX6&qyvcjxOTS;E*Q3D{i%_6c9o%_8&Jmt5 z{)ZwM0Ro4N1)5>>^4^Ou^d^hn7hqq-G%EGt=__7VVzPi45jSpo{vLZpg7NdLC}{-`gCzf8YHTO_ZGX%; z68}UI?sNZp?Eq&552yQ$S`TqWk5+8O4GA!LYf^1R*Ua3%QtE4~H_u}u%ijo$dATu8|A7}Ks)lH>CQo>rLU_S z19;qaRB8e=HlLP%8KL0X4nS-N^v61aE)-xXUeyB!oTfwPeB$Rm6<>tx`wdu%y=ri< zqp`f*@KjV@@y($h()Ta&Bh84ZXl+hGzLXA*uNVhm*jT+>vM(=)@`jTvb|b z=k+4`Q(>BrdQi@7IK@uJ_p737hE-8aG7Hj3OyIiA!uBuCiH6NAeeFcR%!Q-qEm-`( zzntF)hDI0tPaJdOc@gv+s0EQoV#~xVMFAFRJNbg^1 zQW;2-YnHm(p|rLot-N3I5ej*)1>2M>v{qBvp(WhnQzI7jUECiV$c%lsA&XYlz`WiD zACHZ=RR{G>t}3&QBX>M%A(C(VHR^P{2v^)Ip!Yk)qTlPk5vfm8*b5uh{AhxyJ5JWS zdiq))a~OD~#w8aJqGY}^8?&s?Vmg=4@EviwQi*9{_m#e&=Q!SEFs0u+?-5;rIfpgv zXnh&=x$H)fniDv~b^{?{>1R`dctG}K5iXzN>He_#MxIDBcjyVaKKxiSD}N`C5{~Lz zrJecXQ?K=|ZGK?yjdC2-2XWbfZ=MPCL%?GQddNhtl8+xhTJ36p3_j1~P9+@L2iy2g zP2Gqh&j3h@!EHztdjPJdBqqj?S%U991=*NqtxPF*oV*C-4$D;2_AhahCTcQ)+%X~1 z5lVt;xi<$BCcAJK6F-mQ&&TDJ8(GGkP5YahPBXN9X12mVeoYjJgW#1St%{5~p{^#T z%@Cg;C?~6_bo&tX6@kN3pRe*UuvB27k>9HlLCDw}*~+E;Z^m^0w~%-{L8({U0?2hB zi2U2Mc?3K&V9;S)yhIJnDX)Oi_LqhN)`y;DPTH-&M^$_FY-ODyW~yncx(*Qb5(K=f z4GPb{2Fn``VjM3Y`E=l=^|Q2)!_;qMWYUc@q{5{qAMi&v3q+!)Lr0;fs4qP3G^1N8 zn)Jdz1J#^aT{GRmoVfDR+!#6tF=#{~U8S2ZSPVJ3y1F*?7gbZ}3=?@bfq4u=PpZ9; zSpR~&BsRvxSAY`zRZG|&VDHB0BMkkZln~pc>I(`W>eufd zkQ$w5)ZLB=NIpq?(UniWax8v#mr79mNo&sKbcXU3I#eCOsyPfclXp#j_mBe_f1_tt zvrBE92YFHQTSv-YM-I9P1{sPs0@;dJG~!k1s1qA?nv;FrZz~BTcc3f{xLz0czTud9 z$LH#D>tOst!9@xjb_CFbzbr%acqtH1W5Oh79g9u8B_xCyz+0jlWn zs6MC|N=;^kbFQ5NpbLd)rzQUqrV>p4=R5?>~xka-58SWa8NA zerwyaJfzT#&Pai9Pw13bUV68&%|-w%7KR|M{gm^p5gW}qzE3k#(LQ9T_ zw}YR(U7h|Bd7l%B-Y4&{1Yo4W^-6|9(H3WsU#BSKju&@@PXVSsT05~T*ey|cqL4&q z(8oM4Nu-nMA)mQ)Oo=Ae2p2!*5cvhiB9q}E>(RC{`e=$hwpSEGo&RMGM)l_270{t4 zRL%;RHzkO8hZQaS2l1h z4q&3+--qmy2;e*RpY{Xj?ojLW98=^50GF57*-vmb9E4~8g1Y9b%E6%m)p zJ$;m7r64WFj(S$lOA^lO3-&&IJoPDVQ%Ujhy>Ff*K&MwLYihe+x4t3uT*cePT9r}K z_Dl2i(gksj?Q37_SKAOP^&|3rHJ9%Dr#!Lc$rXC+Vb1iBbV@ajBjO*YON?JWEeMgw z|B_03F_reAJcyNzpf~?`8yTtll834Tg4W5~QO*+7+{IU}o4(t|aL&vyWq+-4k) z`e%|{_)o$n1p{pdqrh^;rGC2a5JdbUxL}lj;6#zEP#8j4#H15nQ}Qg*AYfX z(G6oZCoS>3F>$Jk9&>@h4;0e38?VswB4~8&2Zm5ePXm5w&-Q0m&4^6|K74)G_fZ-2 z$J?nQbdw1PmJ2DS>n*DWX$nDTr8%vW)ZE=OBV*0SJXpS#Df`4X@AA3sVfS6P($sgd zuN=k%FRJcqgzVZMc9`?KTitb4kyq3Qgs49%RI5#V3sB>J4%4j((aP`ojy5Fb>uXzB zNX^btuU0>o9On;{DIw8;7^m873g0K{ebRU2j~E!D_sjl?AY20)#R+!yKCX%fzqD$fI z*&Zrgzg71+ZoK#KNUJFlOWTlmh?Ug`2)!kgzx}jM+l71jK>LhO~4>#g_)p zHTRGb*w?mV<9Ov3pelO#0WN`kIw1eesgdAvbJyusSYT-;Z$?#Y?HXLMv$G;)KczXn z(Jpi5#>aW(eCSm$g^Pc@qDAF`OoaV}5&bHd+WgY{yjSBbprLBLjWJ(*{8YFro|9;x zpPy(=c(Ul1*^UC!LMHImNPlJbvhUPnpi{*nQpIJt^f>;L%cKsiH?gz7$a$b--ewAx zd22}c{(epCW!M!u(6Cq83c^~=wY3+Z;>`_2y*Fs#hGY;E zCbzX3{Fw`dh$aDHm#?qol&-94P6G7PyXIx8%B4hu(jr{v56fdLj~puf zguzhIip;Ev^?SR%v8BGHOfPM-)BbMw$9qL7w<0z4Sq~OS_V}~GS7)uFf~ByrmJm(6 z=Gxz1#GJdL4c?~ir9ZkhtphUJCt2&wAgh(J+qTMTYEw#8Uh+6H-eht_QOEzaL7?^P zByq;|n>Rbp@v%U=cjT;-bdpZ;tjIOcj4 zc5ho8G8Tu@box^KHptTQg=0s$TD7XrT)*LHD}zwr5^4wkr14fc^gu|x?<%+9BZ}=` zGMoq;UPNRxT8hgtd+zo*5dZy>h@9>RN&$SWvUHtHCW{7cMyPK@>?hRln!SBYswNL@ zkFybx!u9Q_b_Ls_-W1|aJu?F?*_rn3qe1EG?6$xdGk){))w7dGPuc@^y1_}!%#Q{= z2GrH)OIb4E_UAA4R6Tpu(?b=(@f0)19k;8TYD-_0V&dctyT$E!CL+4&(5CKZ(u#H_ z42k39n+Iw(#ObB$HhRZy?)qnEVqDL1)^Y_^4_)meaaHsu65jMLes@DOH75L6%kj6c zIQe2R=eWV3S=drz!ix6qfBUG2{m%6wWq>Co5~NI7yUz-E z4xr^-`DK`JT;Ck5Yw?FI-j4q{et{s`a9xS6lTIK8JSSfGIaJP9{QGgrFW!RC@W6cn z-f7~b)<=9$$r4?Ka;b)OT{@6UC5HnRtJNp5pQL)fuFboKY-d38JH!u`9n$wawyXpt zte;C{qxHM}G#D%cH|7ks`g+2f8}IjML*xt1PW54Te_*Qxe28;DKjhs&TH%^Me(3#c zNRR)d0|!Wmp=AH9wVKFecdNiF91>e@-(A0J4^QXcx^e~d1lpC@+@gKDV=OGb6TS~G za2mayk~7tF9&ffh;wk(s5RqJA8i|&a6-269hL2WYJ)(BeezZHv)1k^YIQdnZBI;U$ z1YP&X2xprTEL_j79-@^I6&1VFR!caQ0C8gwnsBdzXx{TfoI}n|v{}0LmSTm{KXoQSrtge-0_C;h@LJM=j7N=kN~E2nnSle9W1$C#R0b z390hd5_puD_yuG<#h%&^ab1sC5x;Vjnj6NcO}z(o5UFv-Fc7D09ig|+OeIr5{$rkg zYG9Xg`kqV}J~wF7X(CJfyIC{kkC{CbC5!X(e;j|xd)i;4nYo^jb(O#*RAC-Dl*_Db zfo+j>X;b>uZrsfGcE6KMJrG?_gUlgYqszQjnKOX%(7UEfKMmk&@pOB#T z)b?g2TNX!fp7SD!w9VF(xEr&-BqXtM<23%0$L?SB{@*;PKdVcpwhs zL@hT_BWx`k>*g%lQb=y}thGt?thU@qNkCCgG0y~DATxhP37+EVi*ej~w!+ME^H#iK z@WzLd#Xl8c(pygjM7M5L7qIx{lisXFgY%F#k4TGjAL(g0L0{Xd0&}lEUgZLzB92!z z1I5(m3fdS8G`=jMa4DxV&yxmg_>_q9zPDZr*E;% zzhKsp|JqQ8Li|c@r^=RDXCH5JduD!An9C>lan^C!dn_)^4{%bwThI8|HP3mB)P|i# zV%O(>c2{~%wL7z+cFWktz6@_y4{r}|Qr7zn9Hjvv;NY~@MF}vFSf9nA>g&iZ@ivXo ze`!@IyIUs$tr@Fue0PCM)9E)1C9=i~kY;CjHEH*p2)Q&df8H#gLb7omN{&1c(Ij#B9Vk@K4$=?WW&wK6~6M8;0m@_C@5MoF~PfI}(XGXX5 zcfd91F_k{vo#g0B;!B`c!S<{*A*s;xM9X13p7+fu4*7rjZp=#9wZ+pbVe|&gj4uqD zS!76Bpjj&{=eRY^x*4?jJ*NGZ{V}gdjmlV$(kod5r)aDSN!qwRe8ji*-53iq1jfmT ziw7F|RZ9R)5G1kiNf?@~gEw}&!yAxNf#X_Vzd3Ri?F zkw5D!#et$T$-KgAX1^WFF4bF2svO>3P>f^~T3lENHbEBYBA11QwLY#yDe6o0{<>!R z^+t0{%u&Pd35O4Fk3?~vX_h{m_^3pH28_Nrd30NB#*B|A>mcN4a9oMh&2e8g&t6}- zWKC~iE%<>oTWwh%ovHC z?0%$zXZBX)~qE(u?f*kwWdR)2xD;&0^WFHJAdDCE4KHk z;#H|$Ud}kdm2vyQhG+M*9!CB7=3_vcMq`Fh;|T?G3*Et&S!&+{Brch6dfi$G4h!ql zE?tlJ~(N z?Q86c__v-*@i5)=&~~;gN?MvrHeMj&xVAjuW$z!wWHEo8%krYa9xSB^XJkjBlrXsu z#fDZiam}|>xvw{Tc_qr8e5dN4%5TF@q33*K8t$jpvJs~VCOd2{bGc$Ly zDl4~72scO)cjsOAPljACH1mBgv@^mm3=(C(+&6DXxX#UD2A{Fgc?bj zJKlK6)Ft6JT8YO$oYVT*m+ynhmbG*rkR0#MddwM&gj&Hki%Zitp zB8q#Bo*I>qC*jhDK+XBP8{@o#@-a82{macbnMT3H{BA)zu8(=Wa-#hFGt{iX36Hkb z>M}n~28`1q;V^XbmkUV9idWc_6%H@Qtz3FNdCZVK;GNyz5|sR0+Pv+ABaKauAXs$N ztmi}%BI4I7@3%ht^py8->HJ7+QQrfPhczc|zFkvBhkJ@n2Wo5-!}d#qgd+gDf*EtL z>Sp%#Q{91vBfNl|uIqSYR$Pzl zvWn8Eo`gum&H7z`Bj1!G^=O<>A?5 zKYl)klctgey|^YGMmeUvxzXGRt7ym0LJ|@*Kf(T&MAPi9(5$))ubs+;*?3L+OsRBYNvAQ z=hLD(#h$QNViKuaN@Vy3(HebEvcSRzku9=-@>Cv@2$jJ;G5M<~j{kJN4jg34jaE-oJ zcBLd;-*NWgkK2|{3{2W{@}C+V^JnqraI^LdGw{2`z%mn}Oa-7=OO{C4&s=vt8!zD3 zk$$nqa8t}lwu$d|+th7dco4?_z43k^o#2QtFOb)P6TGYQ2B=uVjB&FbRxeurWEDuK zu7C%t3a0*5(MlA_SJ`nC?_H5E$3;wWe;RLUvmKVl=I~070?B2I@{6w}Y@Y2F^mDKN zhgCtJ`Nh%1>@&u?X#?6q!iJh>I4cfOrBu%UT`J@i5@g-RTzfO`HP`M9CLH(g5!sqIt^2pka8N?Zf*|?>6f%G%LCUKiGA7AZDK;kKfPo!6?*9iM^|#L zzx1UZ^M|wL4W70A!F0c>F94-|I>cD=Lt7K0@l>m|U1E4MYs!bab!q5xPx!I>y6Vj` ze*~M}@7G{|^zj}Fsx}D~5^IESzV$w}b7*}UhDe^VW#ORM_Zx|Y;DusZ ze6%xWpsP#PYq~SDW^ZqMF@zAZTx_OKQiaw03DM0ceA>f%qRz^2SDkew5dx)Z8X6i4 z$`ui|5>;vlHGtjED5Z+eT&==^GnDke?im1Yg5l*bfG4w}zLl-Mb)-3|2!80{y^+Jg zSCGau8-FQr5|wb|H~B`Ba?1Nj!@$}}MAhl~K`G`&Ob)-eG>Y$Ej$QOm5|c%TX5ex;Z@)uUyrVU*-n#N=QyU)cPhtU-g32=v!k`U>~+B z4Te#g23OMuKvoeYI?%Od!bWE1bVEfE+m@nq@yqMz;5d!1)$7$ezdQKAbfJk;1yj&V z;;enu$|e_Wj3Y3O`_y&gI~P5|E}-TDlnGO_=B$X^rLm?}p6P&FVx*dxyI-6YdoG2R zNX|JFFb-a4F5A8It%N}plQ>#(uM9K!P|JAfjD7&doK_S}^71T|J>LFf`f)KT9QD5R z4NJ@Q#{=SS7lu+F6=zT$(weFwbxn=nQO*AI=6WZmQJQmHnsf7TY3Nbjn`0{Z>+f8H zNc)X7{x{$DZtan4_9a1iwZ*3yi-HPFI@||Ua5;a0;(c=zo2oB_RnCwasBx=GME2J+ zJDDv~?Wyjv0|Cq8RX+gUs)F?7)d=?}t9^-eTF|L%t@z6)ZU_luIfZn|PhB{rI0pncb%O&_{Ps<~FJ03zH~pza zn=Q{g_ENt6+{S{XDHX1h!}Msf=itU@hrcZD2I-LZCl|x_h)5U&@*$ePR$R?NRGEL= zl}2D^z57t~_TgHo-(W~z`K36un)C|OoPVf{E)Dob$Mf4TL`T%Z~Y{9=1Toc@j6=ad9HX z@l4k@J8 zelvWcBH7~DvNj-fuKv7pDy8x78s&f^>tnaR?`OmA<_H#P(WMkFsz4FYEW@sa>=KQf z<`)ofra@kIJYoh%TM$hOrE^kb=0ds4RgRZ1;okrL(cQg5J0Tm)KhnxPKR@pPa<66b z{@(C@26t_tnc-s9N_!bwR@cx_dTD9tIBQdPVMLJ;CG{&ZMHU^rlZB?k!6UU)j_>{m zuy8rfJDPmmTfQ}KsJ&1#73xru_|Clx=O(7$T~pgNaOd6=z3x5KRB)GEI~7jypKAtEJWGa?kqbUyoTI7hMvU6Oi$<*~zibuLRksp5ma(q8S{t^%~NZJ_#nyNKZ&&T z2oh=SiR1LoK~zY+Ooa%G7TuoDFyg0hG5MO!cEG>syW1w%d#a;Gwi`L9BN=Ug$m0PM;rOWC> z^Kq|bf2;`DF>Dv0Tn+R9-Y%GVIw$>g>H_Nd)dt3WIToq> zthC$gpoy31WH3^OVD7mgi3~x9*~$xbC?+uy#4O|nR7RzmVaby-wk?) zDQzd5^5#(U$G5t}-Fpc{OKfuifRu^RvVS4zHe;_zQZ5m?PB?T;ja-zqo=a}vFhuigI*V-x|SzP}}DwIG`N zzP`Th|L?y8dk1T`7_=A=^0g-M=Chk2TX`W{y?8WnBb&0|@NMQuVM@r?ti|@f-V1qc zEl3YpWP$8rmJOMw#1RnZEmcv%Y(G_AwtyZ(e{2cm$eikZ_pcB~F8NQknKax|vhfQj z;RZeUt$79J<$uM8x-6Y_DCMp1dFs0}`yg8xz}J^!ss!R?Je(+fjP3P#QhRayg5|k- zN?1VxcY4;dxMgcAcF*bPObc1DE&6dciI)wgG?pVR&gbrJBWD!d*lps{9z|kk`_ z(ai8>?`e9;H)n@7GD0+e7NDkm%g4;6w>+$Vqi0R3GxZY*6*OoRWdFFP+s&;vIfQSI zFxOvqt7enTGU7W$?>wN5_Dockkv+ zHDiS;0yqDnWh1Se>34r-?EZX5=~C?86mB>SW0OIztE+=(oj&n+S0jb1T8bzqH%BM= zq3zA>FdBvu%YqX^qmZ_uA-qWTVX(~8$BX8-=6s_9T)onFoe4j7n~^}}aA|HuDu?W5 zJBa@D^6kLv%bh4DJ!GHmCDp$nt8`|!%)S2huYHd*@}0o8|FvRSiEU&5J9X#bC`|+k z4o9V-O~qlI>8#Fqj~pwJX1$)2Gsd{6dH+nv04$_Aqf|0bU&9If&~v+MYmP=>!6;HC z`Ohp1G>dr}`k|-llHCM7PCtZ)!&_l3f!A~>#oT$H6W{pV+}&~h(O%{P;(2AK&+%#{ z8ke@de8gJyF_zbHOIkO!}4W};~8>UYZt4g zAgt@$H31%NQZ3cpjJ?6B^W;+Q`a%VLrWW8&OX%q6?5VUdFEDVYj%ws<#@a`+t-rV? z+~7j>07u#Uf|QVea+__08=g()qa;%Hk{6N7E&cud@ZM!R!|!AkuJ~WE*>E=aXN)Qj zWKtdVhC0-$JcooE@Gy&>!KJwa&o?G`?MBF&Jw+;zs=?wYI)82F;EBG6D&79yMih33 z558HQHe(ZI#gHv+zz=1Ob7%ttp#{r>gZmSVLkpxhoQ#}~TytiQ6By4{HGAv*^0P@| zV&9#okIpo!Qs3f~?>=5EVX$-MzU&MQ_Z6t++dS2|e-i;UFA2C3Kg4-fx8>!hcYun} zAI0_E0KQ^7>-Y2|t!&9Q-AL=D@B7vjSf(JA59~Hu22W%|`y0k5llCZgAku>9y;iaJ z3mx1AL4FTuiwQikDk@A|U$r9sEu8vuLXpe(_=o@{Ej3XH?v=i5J(HEh$0GMFe;ME47m& z$iuV2?3%00!ak4*p6i0-`z6~L?!Sr@3&fz*iGM@WSePwAm=>&uTsDCd)_!(vYk1=^ zcJndXyvlERf1|gQC|~|-yfm0pn)}a+f^R?w`R@jqw0Fbfcg`NB-Q#mn4{m7Dw&d30 zw;RO2kFn)`%VYb?9eY33x6dI61o+XI@Q#hVQMnqS6@xFVO&>IobN)a3V4B1pt^ifv zx&ERcXe$59Ku=$BJM%fh@${l4oaIIwCPOS2@GdicJc9px8+mnSEWHTEDr!L65ot){ zRm+CpP_nF|N>1gfZQIm+_H4RHs+&1ZnfLd)+{i@!vuoGakCWTh)%*n5*z{kbL;gC@ zvp?gks#=K&X&AAgEBx~eZ@<;`mroM&|>MJl%^eR?E|5(yaLs~bp5&1vJ#z?QJSW*ctf9L3HbEY(8Jy(IuK5ux ziw_$d)tnmyfhn5-iat?RPbFFmj%>b`|zf!dRT`2 z8O6VF5#ZtG7KK=-jl+7dzni;JIs4+W>2)UI5{BPsN@21pJA0g`@NjSV9s$K(A1a6NJ&QS_8Az|tneBGD`8H)EMY3hr zd0Y4GvXn0zFQ2z>8P0yNEg)WcNC-a^Q_Zr-;hI#+_#Sm`=y>^lSBvNRf}G#Ns3QwO z+iUaw)OfYsR+T7uEw$wg`gh0%@xk`HD(8uy9RoahT5feO{LdNQDuuwstVMQZp&|Pj zVmLYPW8hLnJ^vRg^=1T@`tRP}^?XXiSHD*E5+RlmajrXwx-d`^S>y=krYfasB^f!* zu4(qK^R&MeMQ1{!v?RYrS`0E~TkpHl7l5f9RiJTlNoy^Fn?wD{boQmJ#FV&8nn!ojo>M>S5p%@qXGpTok<2{KsB`bBLkS`wIdz(`{I!?vQ zpE5RMpoZ-M(h%QO*Y-A^{=bU*xx^vvE=twtUDr~besyN&vPaIsNb6(&i#MC~s|(Oa zqbkfew~mxqNIwiFFw>=T%|2It-_9lNF(-vc(T?Sl%PU#bPNVOf6=9IQCsW!LDOqfg zp=S!>yTK3TjR6}3tk_8F=mc5RSN#xsNvkS{#(GolQWQve$er; zU-+a2!LT!#RK;mHsOv&LvO9K%Dg|@L{TX3|`ESn1=0MGtdF<7ea6Q8I{OO4t1{u9I21i##KtO~`=(|&@ud9u-xq`$_uf5b_i{Oa(YRI{jM(2MdaW!s`U;x|7JSR zh6j_R!-{#v7muK|aUuKTMVFfT_PaV{4Q@7-t$iw8Qw*Lj+}rBdtqR_aQGfHkk8jpL zB9Wcsk%^e3G!GRFYVcHO?*wDq&~byg{>FMGi#CVf6&7x6V0h1`nvB{qFuppHh5Dir zq=J4iC2q>C?DtUX$k+)L3>Q#8UW2L?IuWAMRm#QAPvwo{xcE)`n4{1i$@juhOkhyk zui;-lU6P{7bGWm1p4vCwx)0t;_fh8d1k2TaO1Q}KUl*1lSG@QenqJi(Q8$pXkzI2# zoAK;6+YT%|{4cgHJ{bPGvmN5Rv$t;`b7L*_-uCL`U6EF={PJ{yb?Yg!^o+P_p$B*0 zc4!S5ZSkseNKFx_xi?Z&?#t?%U`b(U4D9hH` zFDU!1f3)L|c-eyfgaVo_^lRnbk=EIm`l;_@Yq|uZ3IB{HxP82(rgm?Q1^GGf^qn|8 zV3``wTJXa$zWVUd)-x6S;mapN?;$M;$Hl`0=7ohDrE zAUEs(DP%hhRlX4UoO5MbQ~GDyCAe%OXjGYQFScrZ^R5|rneAR%HeKWpD*bYsPV&r} z)5BNF{Nfwb>^7++J5XMn?#M0D?5Dy>Sj#!5p=E9cjFt=2b3C)G+Qnn73y<*BY`YTQ z)p)N(?=`&_=*8~t40pTkEZMImG{qjAfP9C7vb(PK>9%b;vNtnR6W)yY$kyhJ9u7TC zxTk6HS}}el$Auq^_;Rk&Rve%9GZqdEiqrvtpf#nlilN%iVSEjs_i?{hbXGg7{&>;dhrMr%`+`mq3A8cpF+@B zY>btWwL#a{Dis?g4-fsf?UII!-u$VKw_P&9-xYV2&c9=LYJJnpI{nQL`;nZS$~DyOJ}~4k3BS(j*C&bN8?Bu=k)|h8I5fSG zrYu7Tf)V(ZffJGarz8ES|MIHSBPMyQn3`f>pCyC!vR7SC90$DyOQO+(L*zWR&JnRv zH&&X(ze`)1NG5Mi6D=*c*gEN0J3j75hkV2lGKcAjd_2m0- z-GayRy z!8zYX#V35#K*)^>{dA^csYlI)2q!;!s z=&vx)UH0C7<+v7IL0{NdPXImOawy)j%ACI6BmdPK{D{9sUTajvOn|ab8OER#&(pb* zrMB@;9l=z*&U;ehEb(pc$K_!P~--LQlQqL4xv2MGUCKi={qT zp!u8Ip>Ms#O21%(99$BBixUjdVXUlGqX)DX%norJYa^L#S)m(Ax;{=ZU+=%eb$*koUJ}W^Fp?`+gLG?tP=G z(SD+uk6+EI^7nz5sn5+hefbrKEJ{Y5Uq89AG5GK4mDhj5u|q{9=_y%UQUn>br@roA zKM<86y7640rHfyA!5qmUw;qVVoEM4gmyGl?M;7TLi)fzX)>~MGwfNp*r_bV+>aCp- z@-Z>kI6)18|HsvNhg1Fk|Nq!RcFwV9B%EWFEo3AjlwYu0eUFJEm&Wr(X8`@@sf zWfz#Uy$4Q|7P|I55_ONWSwhq|B5<tz1mSR) zSChlkE_2yM%p)q3Mk$$s*^34AzRA7acc-WXn)B1{sGtVC0sedSFbQ-zJN=G@2GMABUa84c4xErqr0jZ@3tjy(CA@)kh*c5ifc$W_`<%@@O2H z46;w5iyQ)@YBa0{!DcY2m61!B4}jDcQdT&+hA!sb+rcd{v5yc!Jj zbeWE=z9lrScX-+tL0mpOc(7noG^KSm`K+T9%K3F9N4iw~m2k;{yaerE-Y?Uj9WBfV zH1q1K%AT-!gAQN}ke;iTpD?Qpj5C&+3;G-KH0pVFljq6NW%%X!b_22OifEd|o8H;> zB$XYuiQ34qvxyg5w*nd~q&|H*Gh7q(o2|g9%!XTJ^nQTSC|Oi%056IY)q|U?h>_jO zE0z9A8Faq_w!Hs?y@^Rmj$KQCXz~CYAhL)nZbCXFi@A14tMh8FP&X^Y4zXFX2y2sh z=XUY6ASHi+D?3bB$L1Q#ZbD0^)a&b>ieB<$K)LA~jimPc%br$^ZX@&wlnS4E7FwNi z^g5mysaj^@X}(|kb~IE*S7k@mdUOxzehe!QSaE!#Uq+XyeGJ>b^Qw%$QnS9PZHe9} zXb@+N#Dl#}(H3944$9dV#W$ts8%z7cqmv9 zrbyeqseMFflvE^;dCb%H=DK(()#$!6o0F6D*qx0S5W88tsy+iA(l?gjL zU`D;F93awi<_KT}OwFj-Mlxo^e&wa-qF*gjJBsmm=yB<6hjJ|AoRz6CF@}E4N@ohbX9lRaCY<<1ndCTEgl_(X6 z2DQ*~EtF==Wi*D`cwJ@MSfXkqr{1917`LMm_3=`LbTH1H8$VI;8<4N8uTb_uyq|!e zq*)1Jj0l>-%~>?e1F1Tdn(L+$>~Ff!Re^GG)`Z;ki?J~3LJFkoz3H{QaRXag=yT5+ z`_X>aul(grmcb>xsttjeIvA8QvqE4bRJPcJUoqyCb-p^1!N$(~Gq-hgv?K$4B#T>_bW8BHjoe zypLG-aRDt`Z)T5ucT5)DMe@G)y=d~YjZfoHSiv6X6~B#u1Ewx9^icFOLu_%qBx zlZj(!oQv38l^uOH^nS7HF163eW_2Tm-vax&tm$*`-YDgI+2sc3`N*?iX77L>r8iEIjfdH~KwN?chucFA~b7k|+>C#zJP>GWY6D2GSaT_y(5 z%*g#@uw+>f&^JmSxq1bMrhw;*=ELVS2HI=sRC`6s)I7_uz#w1va9!pWqf99lo*%3Xvy zP;@*XkFP0u>ph$WWdD!n2Y*Y$byp`ITL1l9M8a1s%67ajk4P(W zu9P`YiC%$W_drO}IZylJ#g-z5*17s9VUnVUiLk`~T7)lB?1Yw6D#rqz7N40~r97*^ zMo2}C%WvOI^3>loG%B{8w36q^+v8G-Vv2?`hecHs3~q8o97j=Gu}3ojVczPj)U585 zP!h>Cx1>W12lC&S4GSL#F{0}Nx)w5C_0nKAj#cK{l_TaEu>X|;d;qk1%jr0ZIQ;7yy zFZsKzxQL_ig`mxK`c5~~V!zli_ktr`U8Uj5P{r+|NZWcG;>nc6xv4yrOm{zkd1S#&%^>(y+Z)g&*dMVz#D!!?~nfch}gc?e>uTkR*$l>j3f7Ry2tziT}g zGI&L223sc2cSy7UH&R<=$=x1wHgWwNb=m*&>Ve7s@5*g@4$_RJD$PIqq=_7~Lbe-n zM=^=TK|KjLK;z)bg2L+*D`^gfjOlh+`k4jHS#fp3729FJKcRfAD=`g+{!to_LrZF6b5%y3e4=}K*%>Sy5R_C?ZN?J+fIE}e+=6X*s!spW!HGVyUy$l5gcXPF6e54vvmi$Lu!;7?SStc$f#!FLV=Iw#5oBuU=tG-98Mw z)tMbw$k-U(E+^$EN%y=bx^)v{- z8SA>?4bEE>Gr@5-{z^8@MIUc;*k+>V8te5=8d@gBlnj487HCkfjSc)PDRCqo zt4-Xzlq0PwfYw(KGY5l}vy2P89BO=&Af`yC~3ar^I6 zS1QwE$kl_h7Ca-IIF+4O!T`7kB6QCrskZ|MuI_lD9O4TsQcYJ6`k`5AyU zl2$YFR0H#MAVuQYn;FVWH4-FSt$g6$cr|2~#fH3feDu)`f2S7fc4WAB)4Y2=R*O^w zRx5ibSz*gG)5XD1s3Y7sk1ea2s~jtVecCgfNv2J4qDt z?!|KR&XCX{iPhXpAOsBvy7mJl7GcWfw{a2A`G>#CBu=dd;gQ4w-+KmuD*GOvj>gW- zY;$Qbhe<& z^N__nk?fw6^ZZdF?#`&mrC09Fw-$W>{~F~s*MWzAc_6)C_lsahchmsL-s2fbtO)4( zb!IW(;pVlY$muMZyooUVZJ1H)u9DTa6zSH7<{H^uXeM9vk#cN_F{;*n+bvRg9a0@L zF3%FKN9L9j2Ms~2cxBN20baW^11;QHk?mZ3^Y1sBw;O)Zipd`Y zLWIGi5wVwO^LeQ@A&P()uO|sWiT^qy0sHeWHA&sy_!Jn=gDklwyzeWKvVFvqUE%PS zRw>}K6YTi!S%ZRm{^EkFlnx%#!my_huF?A}a_?b6*Y(U%3rPdz*>~N4obN~s|GAu( zeKNd>_ccJ1bXP}IBCOdKFfiMT)FR)=A-}ztTHp~o*_~FbHL~niC5>D}3+wf-6Lv;+ zU$k7+tZ{wmlUKAjUTXjVDg42EmeeW(0{a5xN?q~Mh3UpE@*kfS%{g!TDUr%BQsOdm z>3;v-9y(|{%N}j%iu=pX9wU$RIotWB-4ZCTa;dGh(*#QEvz#(9AC^-eZTO<6e_6o` z##ix*<}!gj7E*FIWTEwadN;5QZj6be=9t;;XSnY>a%4^>elx%Qw26)Y-Lz}jy?O3( zwdk(&bpjXPSZOohBfY{rKr3as&bv(R(|wr6DFckYUY2%iY;WXs$AsaFhrTY~zstG( z@+_goc%-Zgmv;;S;uzeN5iI=j={$G+VzpejLQ)=mJZ3sGr;TFEp@gl38a>eSWHA-) z`U&iFNiJ>5LSfL2*bq|DKqsGM8H`w8p_Ju!zX1CA_j+0N+L}W0&2zHnQqgdh6GC{ea!?wb&SF z4n$RFC#>|2M}=kCPe3CEO(hcu&-v}1W&;e71fl*(Lk=OF|4vz3qYkC}vs_Llm1iwV zd{PYIRAN^h{DN!Q5A-$@?A4YOcZ8V|TN(ZQxBQ+TOu9aKDOZJ0+SVXka)-(St zH8*cJ!aZ7=w_7)J`JgzJ!)Ckye@iFA;FnvAvzJtVk1q9Wq~xCN*33`+(%YAAIcPY^ z?Phtr5$JQfGVNhM|Dkm2D6Q>zn}C75I-b2 zSJvJ|ydq)kF>vH>wkjW5c4=J0?EKqqc_PC|itHwS93jJKUxnJRcZlrH1KvO9*-#GR zyWMpFIG&GtO>LzO0LC~#sK&Pu|8@0aOxgc-3C;gCg{1Pb+Td@HuPv1`_iXYp+VCRe zlB1cqSI>6(S0h{=9>}&Y?KAtdU#LDlkL_MS?3ne!c}Ir9aOE2_zJNHPjIIoQ?5&!f zvw(=Q$i|B|dwHWq2lcJ}nZifeLcY|9)lQ+;Jses%>zUp>pS z7H{^5ULQdKf2ZT&c6v~ESkvdKt=R`W7qeCddX4jHN(s2U08e%Wmb(|{zJ9i zgyf1^p6h-{T>`UMjs9Asg$Z#W;>C{+!B71B!#0Uyq^c1^y~1$8%JT+hHHtc9(z%&D zgnIdqjH9QRMZMFw81gC~O6o0a#?MMfrFE0qj#(j@wUwpjh%-V&5_GlMx97pKTt2#I zmhm~s%z^FGTBAuXo)9k3Xf3#6(x69RGkUtTW8FWyV=v3y%fETLvhK=q(T8fr`JY|g z(VG)h#m-mvng7w&Cf$x2D05 zj$9E&lfXT2_^)P9i(w_Tb-{&8+R77#*g;Ru?gJn2p>##)=rd04J``Pi|S6E`E8*XIJO14=7!unUCgx0dzYnRtfL zy%_;F%*!GiOt#-_{$+lDcu;59W$>*4=T$AjAT6*68?O)@nH4RfjR(#*<~*)t0j*RC zI$x=oqxNW*;dNDX(@WK=k(id>*eujO6P1P(Q|tR5HOx`pchiJj3Mb+}p`J0CT%42Rb8 zVWF@81^s@bA>e0Ti1Z(*#BrK@nX`S=mh`f0-8oszjTB! zc)x&9xME^sEiz+5;b%wa5b|?oGkD+XkjoAc$opDaugg4`G<0eTD+CQzO|K7StSwXA9Jxl|o;i>cKGyeaSZ#=LFf_o;B_u88tl z`M%ldOT($m-iIQk(-=WSl<#r1IGbIZ2WJLdsx80zEb@fgcQtjApXD@n>OEQy>oQhyukrZ&8nyZv(5dgzGz&%_3f){Do*A(HhO zvk-5yss>l*1a(cjsOCvGB|~u8%Ee8$1!u>vqq6X&Uc-_{Hm!&tkGtuD?eYXej>0;{ zMe0!?t58l@uQZLR&U_;m4AZy;2Fd69-3?W8IR4Oz_Kl*cgn-PRO&~vVzS;0e{Y1y8 zko>EiZhr8OWR|})_Sf?eVV6(ZIAFxfUmFtZ7u`4HUfeYQJCqtO)O1ItplP?me8~Ll zd(-D8(Mt0`+shy#g${i5)ob$aB*x^>x@0WsTQiZg*$inqoSQp;61**u1vJ*&XO3kt z(G2cj4h$TrY58mH&b=3!zB<-CR)9yR2&gqJ^N&Mg!2w#WG2M=u7cNCBncdRtfk~U& zS7XOpl~L)vL} zR@$xu*dyRVk74%rGx9N+ssJ!fZSTlVu+L=$%?ul&E&4>izi#c%*rkb_(}w)2yQ%_p zV}?sDkxXdbS&3iiXC#3jlx)tgym@&m}9QLecjCKchx@dOMllQl1t11 z??cta4nnC^Zb|7k?wUp)STXh4RgGU_zb6f%_9w2+y;a}A&kG&55$}dD=BZl`oKbBt z?Dt7x*Fy(615&hT7lSyLM%`CJ)Q8qCphN5B7H8(Vo@tstGDuAkhopW{V$%QEF5CMj zADcyNMSpqz2_d&v(kOj=?G?udSbe?wy7z%|Z{OiXzr zqW($jVX=fd8vB8pzP@J98Te0D!V;{UlP|%GaFqGFCxLd>&m|Bp;yl*G>QOs$)j`By zCV(jw5}Lad>l5iwFMock_86{qLNPG9`U#2P&tGz)-d0F0rt=Bp@ZzWlV<(ig3bd`4X!TJUxwM# zn8(L%?UF|zc_SfNyUVIqYwhHG_Jvmd3wgf7%dvX>;-!HX8HK}Bk2l;O zd-|V_JAU~b7w-}9lVR5~z}9e(p+{dO6F0wb2XT$WfFKNx=Cr%Zl~<`K#(;V2(WB32MDGkSiOrt}-U} z@pVWsXn{%G@TDh&UqM%a zzVxe0r;i*Fabr#8YWbNhS+?H;!W4O~z+)vDi*m>ZX>&-YAv89{!c*k_*UJvgR=ceS zzM`)FqV8t29%)_`YIdSEE@t$&ykF>yKz;L*c0R^8vLF9#@V&-23Bs*6Wd*Ne7f&}c z<#IJ6#)8O=8u6Wy$~I=5(}W;~Gh7Wq&ZrFo;;+fc6(6{HA8zy}od0mISC|@O?mGzE z$s;robZOCoLWF>OnNr6(6suxhJUU$Eci-$K#AkO3?z!U4IhS|q*OH%^d03&y7XudIlqV( zDj2Jb?j`S-p$ed^SfZ(OIyS~@_v~E8th_8vjWK><)RE!88IN?4Q4*)4Dq9X0#R~D`rB0JB?~youl;X)oQmw{S*$^u`$(l}$?x3-RE#u4>fMKoH(!kM`!$LX z^*dpWlH$3DduJt$f4&zSJS7$@7X7c@oqTg+67#8q`8o7-^fF`hWy^7S8N{^WT&Qo= zDhvDJj9_Jf1^y!*x@QKLcX35G%rs}9&q>Vz0CA>X5aar7>?c@NWz0v|JG2rEfU)qZ zHFmxgA!M*d+**SKfw?-MnHN~JlhX;%hfNTU*Fw>k!@uJ6fud0BnJeBQXlSE7oD{hjySm|%}9UfEP6gN`^@287v4 zD3M%V1p72ywk_%0khMcwq0B_tBrm)#1O^L6Q#Wzu><&biTy@;$yM%U+j;%tXc`GiP`s zlqGhfvbm3zYcF^kcpK0@%C@cKr=Y#Y!-)v@!Y1Ai{d^liTCjxJBgiXkXIaS6v9!Bs5yUm@n&165Y4%+Aw?| zwh`bmsGLjCsroiAC~dH)WFw?SGFPlNnn&h-OzWffiP9e?m0V>`0_3p=9jQ-1;3YaE zSkc^fODgar<=OtQc7U$=`7sAU(#>Y9bJM}_8QH&LaqwbHlZ^$rYZZ)-JS6FS&V>`* zAO&Q$P@%O)E8dFTf0mR}3S1-P5zET*R7;7$4?I!`(&Q`fL5yF8(>ZwsP*~ z(L8yn4E#lj@Bon?S{7dRF;fw&K#_khKQfe3lBD!p~@BhB8&( zjnka5Vw*16H?}XAWYtb3(h-HmrJuLV-^P7h5+o6g|CYD5r+Wvd{8RdNQnYtwg6&%G z($iklTVHA#8OT528-iXMkqpy*Ww+J?pJ_F#X%P=@EpO1+N0y{;s3k--&j>vD?h67` z*RWh((=Kju^;*Z$R^`_+5rH#`6i+=~m#oRJ{^3#(_;&x9+6H+-;0JgXPQ8W1@W~l+I?bGRZ-B=VTriSJz&c0qIN zVTT-qKno(sDG~X5;9UalR5x`^Lv#>r4jVzg&KNbw^wl>%|NZlHZEiVI4D`v0o{}K8 zr%uhzdsC#&QHYDgi?#Sm$xy+wJE3q%mH-hQtC8=2MuHX`p2v>q;a-3)keE|qjmWyg z^RX<4GIB{1>iQ}B*cCu&t$8kZC>~p3Of}=l4*?JZ!^OB`-uhGkSnIRNF@8=dX#GMg zDWdSYv(>v|qM>c`pi!)(V}OfM%Jh~)Ai?}7;Yg)wtPzlu(-1MGdf#iznjP)HsF&7c zr~EX0D6l&|?~n14L!=l#5MxGo3HE}FAt!jl4vmZCMH^10#br8o1zMRH`Nh6eSsw<}IK8%+a=FCl( z4N`gCAcK)}<9=zHUiJ~QzxgK%)#Fs_>VnZ|p=YOmSJyw1sQz59I;D7(xoO*T>56~V zgqYTbh0_gUL&Uf$T@lmYtilnu8wpi5G&n2icv@NW-ZX74b~LsZH%1i;COwtWcxwLS zX<0(jKM6WjVlY$;IhxzZD{qO_L^Z*-z68f=k}>a=vu!ern3c(7V< zFs$vQdDOFx2zq$gSMBtJ?c!Jb%_!d0O~VZ&b*I(Uo9YD5#no zxr{Me^YpyoX9mIm)*j}}hiK>+pgDB~VGI}$RR}SG|KhakIg4e+jT#N@0S=kObi-x+ zNtU*4ihOz&L8o$D)@0=29e_8|rz1Mg!`5pmN}dUb{Dccb7Pp= zwr-OlU1TI^w!7aby(F_rtxDx8g_1jTA8ctplxm*cr(*qAw3zOT5!^j9k8r;MAkyi- z7iF=tWl?NYnO~w<=J<^}P7QkNRFEG?mj|pKKX@Xg7lz|V3gN}39?cQx8VNG2<86AB z*tFHL_*?b(>g_H)IjFYxJiFQG(LRl2!!N3<>8)gUFFG@NAvL{(UgX9aX<-RH1x{my z4yKCJjJ@~RrWVh7tsK9qCk#h1Fi)3YUBE+!QEJWjb%G7@Qp4x>T@)KRghJY~kl>Y; z%}@@uh+w&paEB}JkBo(G|B0v?M~}!tNazaZdJvF-VmV~yS#`G6m@R)Nq*Hs**x1oZ zVavr?IAWvqvg!JL_V1;q$!9{>WIb%gq8&V1=_6Z_*O`x=l)X&jXfkzH;3J8Sy-gN) zvv+@3e0UTI(>KT@IqLbTz5X&hbBunD=*+nB-!WiyI z|G>Wyaf?)+8nZE5R`g|QISO_C%iATpHWDG93(xJ!^HWcEB%TFlo0Lf_5?Gy&kM1mZ z)e>(dz2w!W=oeV`3cWOco?KIbJ(jv1yVnuaXkQj1K|x;+Tw_Eyw?Uj^zs7W6U(1Q- z$RSs)Pos(l%GT~&ij{)-2yF`@_`3a4^4FAKj0`8Q+v1rz5jRtlm?kPAp1hhQv0We+ z>3pjkr%qx{jXzmfk?Oh@x~TGK-YZtYHUi!&8nA24^1LMZeNay_yVNq?4lvE_R-dPi z;|x*AWF2H;$sLZ1^QM;I%WCPzdrleaZrK+G*0rAx19t+h4vz|OQ^izOvO2{vnB08y zP)ZnCJpFuJg*uw;<+!;*a}0`;oAuf|-L)d)Uac2woabaPIj<)V5ybYdq%DS?^y`vZ zV_5bpIW^-#q$!aLauChmLbkm082Tfzm6XEurtQ&n(<3V(NBtv0Kp%I)@0Wx`T2--kDK(y;r-tHQZX`4_~Ap~0P?HYL3g6&y>7=c zwOLy3xiCJFuug}!wOKalU+bpEVI40z&EK7IAlwlY|NVikzEjC{?kEO-+eo$anHh&c zE9{lyJZS?M5TUdQKPlq3cwMDNPG})8$uQG|V-feSwVn;YJUcij$Q&GHQ}{Y=An#p%^FJMq^WJf(b7u3uH_wMN|4uT5Sd5Z| z{qup_l4UGtwn{0BESjQi?=ToeL)4c$cXJ1gk3sue%mO1WE*WPV`Gx=-wT4Ck9?iEurYyH+46&P=uT+*E+Q z@$Cb7c8sJ?%F>!RNSUTS@xG1aKYk+FT$Hap=eyvgSne0QUpGKmINlyQZp3V|qJrX_ zirkEKt!Lp>@Ocwv#gs`Z0z6PDZ*}jSQj)S4tZ-e@mQq9N$L!;meoZ!2ewM^O z&nvI?*MDuZ4!=2kG`5&U$Se2`*k_YCUHe%0KwEWDMQRc?nbZDr4YXES6MgsUcl z$r3FgYK1?KMPeya!+o-9d34;>X|-DYc8H|8ekrA5DxILNxnaj5GWC?nA1`fb{4GTF zZpYE}KbznF;)~o0Y3ZY&UGm^WIDGxt(fUtX4*9ax9B@bLc<2J<$s$2IghA|@cFgGd zx2W?}c1v5<8)JM6DoEQ}Oh*2x>gW8@dzWF27hua>fGu}QmebpKD0W#sHR=X5F^Ol( z`(>9?{p%9J8(j}xzj6OKR)eUIeLvHrj-Z+rApAAOGVc5_n#4BNddbdOxn*g2JAvjv zm3!G`+}V=MwfdI-MDqoMJL^5;63cWU35U!WqnXjLqt_RxZFXtX75^AWr)S--94%=5 zhSh(}Y)_o+LEUNp@gu5JMq4_jZ%|ThL$b1b5D2m$X6=16lYom^MJZa3!O4!nEp@&u z{_EnBPb&Y0OBj+*p`x#%HQdLok?Ej4J(Mp@Y2wlJFQF$z4 zb@T1!LBrg^>fxwDy84BkvBVia!Cd|l688T-AlQa4&5P-U?&en|)r+G?Ecw-n5C;y(j(V>0kI(&QPpD2uMrTI;#d^yw7c)mC?wuL2`@GeY z9!OC7v3cjPRPxq+i{2pomm9uoPFatMw!ANcJgRQ_NulTffeS%P;Lj;pk~hO5vHKVz zb8fKK1kY%2UbRRP;Ls96(U2R}BcXp;-a2u3JTR;L*a5atNN7Jxz)U&1yL~You|`Th z?7Qfug9A3%bTe_g4b5_R8gBu3UrP5omeFE*v=Wc+F%u=@P9>X%h=z9YA;at5*#lty zEv>fX`hn`r7plB3QoS^9QvPM*jFYWHi8dSR0GRXi2}PqgwzRpNiuf{4kqKnh{%fr6 z{R%%a1Et=7Z^*0bZ)Acln!zL*yOcC?O05<%-H_VWRz~|V+lnqq=wr+q&klv{m=7g9 z>DTmhKdDP*|Bb>!tUbsj$p0PE^8HDg#m#r0L4VgzUfn`zGnAVtTg#LzklTk4Bwcv4N5wZ+y ziil>LBi7bbGuD}(=Q~qStxH^VUFrlM>}47_U+hp7k1`Y58;;HU=~<~962B4u#jHp; z)~mYmSkY&3PU9pvYV7q&hn<62ms04@B_KIEv8ulD zsBKS&x9^6<E12P+@qnot7%)s^efYFh`{|d;in}5@1~23;nRdGOv`Xo0Bt)fu z3BMq(P$X)-?Sf}=Gx0;A$bzIEpYeX3Av#7*oAYHpuKP4T4!Y)-qE0KvO?SuI>iYDd z%ETs0X(cQUt-j`WExR+d{bT{}#)3l@WwPD)(?Y8O;yJA1g{twHc+NEzFf}FI53HdCi?@v3PCf zx4hP06H<4S^4k-q>+J15J9t{=?5^btz|~>SK3--n5u)PzI9&2UVb1UCYl2x@?}(Ge zC)fS@-jmj3Q8L`9Ys4X2y7J_NJUAlo)UYVk`_rU&!%?mku(5>;Zvqz8(ej^pYzok| zhc9B2j|e|vhyBdv{nx^wWV1{oOCXz=2aMVz5;k9VcwgD^9CUh z_4bWFednz@DFU*l;Y|IR-EI>tHvTCk>m5o@CYNl?5CC(=ai430{@5tiLpUxmj(@|C zQ^)$px=(0#`!xbh5Y%Bm1)Fv{byCE!<$iqS;4bCl(6%`Xx~u>94!nBFkd!@*Wmw1M z)k<^iT6?*b|2Q9_z#y$U1|LaWdmc}(wj=udO$l&9ozz74LmI&p1J7T8C%h!bSlYSb zGn`u3;~k0k?aT6Sbh2F&+kMF!s@+U;auxpGu@X{2W5ej#5<<|~nQ&u;V&GZJiBdWN zZk@7`BZ?E!DMkCnjijfeRaw1O9!6p<3)Qsb+ZetvMCEpaZ+HD0iR!#^6BWT)hCsNo z^>|~laE4&)G-l8x@YZsD?e(Po*}^cEP=QYGt_{A9L+-o0f)L3d33AL+{Np`UNCe9h z&i>4L$_0C!k@1C42Imu>VDsXiTF_vl#XmpxbJ{2CzdpZWoVngM- zp1+>j_FEr4rja(&{5@s_5XhvE4zU3oemRX9;tlX~pvL2%w0B-PpMKcSB_R}5Itk+57y1O|~ze5oH( z81r2Adv_fd!dxQWeS)biXLmd7Cr96sPO|lr{~}_;8`PtT~x$TQb`fhEN=T0dUf}#I2 zvC`IjumF^N)|eV&2R{As&w^%K&L$0xE0+9JyAlKWV^j!DH1=TfWb_H}Fmjbi5x zBzmgmIeANz#5!9o;O7O5+#<}7eLW;Jr$3&W+Fg7I4`gpMoiS54_vk|Ib44|uTk$%J zx$nzLn$xox#nUzN>6-j?1cRm1T+wwZHzF zV^)Z5dq>GXTw?)H>Q!3VD0C+`-?SfyY2O>J$nt| z?Z1C)#ogIQWTx`$vw!hU_5s6}G)x11DIG)OSR9n4{Y2?Bi9vQ}VMK#jA}JU78`g@h z6@U6=+1YkJCS2&-tgW>A`*mM1T4(eKv?p7>-STJB~S}sz( zSX0s4P}bfc{6Fd|tBNGKgmU9SD-uSGtSmUpj0ZFnu(?l57V~UnQ{Ma4tMZDdCwM~4 zf4vVC3KFDVeg1LKI+sMXpXZy6$xpm@U)e$`sCj{_hn_FM>=Nsm3?t^F*w?Gc-qCX* zY;wQ4RRmZNo-G_973qvfDhiC1@CASW@Jan+oI6fma{Ezs9VHc=5E$)9r1zc|Cr2Ir z?gXY{mnQs8qrD@cc0~Z@={bDZ9_wQ<&*a)PphZW{hkw*v)%jp9k+Wl;o4&Eeu#EZo zP!+xcaR>^hU#m5B$C7^R%Ic|QalJj4DBhYykdg^?Tvh}cDBZTB1S?AD?{^j)T<2`t zrOndXh|c_&4#hn%5f;6jF+>WKAu(K;HyrQX1BCQF?KM-H0OIG>8kYVs^qzG-tcU@B zdL<}T{ad!Y#rEBWIy2os+1~dtj9{8iU0>pX63sh-tW&%e-Vvdv*sJiq65I6r9@9&_G8ZXwJagY*hqdCZ%PVLrbe00>vW$z zt3(#kL=Rr!QNo_;7bw_8(D%jr#KB1HRp~L0lx{85X{(0Sl+jR4nReY0bNT6WBqd6_ zUzu#V{jA;l&@{gN^}LJl4=J-&dGyQcUrb#XF+VY*E!J7do1P+DXbl~;cae^3R&rn$ zt>W%mmSKthIxKB{@k~m~=EjC9?MZmL2>HRwlrlT}5%(U!Mr13z&DOxD^ z`S$~>#ZFrGHSc@_-3hcSfD>a-gc(@Klh4b$hEK_N!@oj9d|zh)xy|!|8S@L6TYBg%2x@_=XI1} zq?PKUMBxzC90Fp0i-8ljUmgvGIf66$i9BQ=wWcXKdL~l5DxHq} zaOE#c{0JW$CX==C=x%18>Hg63<4MJ2rOa;)jHH%i&eV{cbM-%%&EMrWSADY_K%I*j ztsu#Nyb@LOWGhk!t@%0%V@glPdpx|<>^aXO9E9k(ft!2W$q2f_F-`hqB;{e!iuf?P z!|!k9Z{P$)-sb)DuG3VtbTN(p)bBgd&+@(x>%UL@;7NY`$VX<(jJv1K57Q;-wT9Qp z((m(VQLw^!jY|~G;3(Z^RQt6nScoBf+)%_PljfUm9F)WiK8NctweyecOL2leje~`+ zRMeMG!>BWu#10ZG(&(Uymf4(XzmZ~b@mw1IXck>rr6cIx-X z?&8u~Z(Euz?%*M_Q0!Q}3e?5gY;*=arcnvlM66AmM{j4Mc4jc`q{897+E zZX&F8YGIK$QHvxK6e~>i7buvQ`H~$(0{EwymYm!jJ%B$LyDdEloB~y_R zJUy>ApLFiSuk)si1|J18FxC#10R-u6p3th{k6UkPsQOHA0^~|q#EA*ttmg#7{`{4~ z&4t2UuKrKc0UV*=VxuAw z_$o~^+xvK8cgb@)e>g#_EEe&;BrbN8VDo2*xVjuhW1tem104tTJ|eG(8-JCw#VJ6) zJbIDn_~fR`HBPbVnUjwybE-(G@Ej(_R!pmG) zI!}juK6|;#G8Biil3uji&<}}+M?Xvs`Kn_>_q%XU9ZHm&Kb2b~17_(YIO%#$YzOJA z1f}4q#gbS)ct*JTRu~RTruat9`{=+`l8ZejVTDkXZ7^O=n9uQ&e81)GR?JVkKNIdP zf?<=kgPtw0okSuLn>p1e<%H+ps&}=y8}z9h4l`~K5{WG(%l-~030ES(#a^3cUQ%qq z-dRs{?SsV%3e?&B?MAFi7vb{|wn}#6Lpn{h6ZVkeh9`~oh#1$hX7?^!2&?29{9E@P z#tIT_Z(}f~18@)md!U;JQ;zjDgO5yGwZud^0N1v}P9Cy)tI)k70lH?YQ*Cx1XL|Hj z+S(#B%uts3ju#Wxo)(?NDF4u|8`EfL&u4EkVb=|Qat1K+cP%%DHtGm{E+f3Ez{`NXx zApZ8YVqOQ9hdu>+<^O*wj|{{I#xYF(PJQoYaRu7Y6zm7+j6onF5Orn6Cpjx8w_j_1 z(buz-@+7rL{oUz9h>MZdEl%JbTLiPg;(d3BthzbAb!%?r2Nkqeh(GP3mEf#b zn2Sjf1mg@)>@qaXuqcb$H{K$`$X|K1P?Kn8_1g?oE0PbmX3K7beRo%pv2@7tO9vq; zX7o2gptfAarUp?czHmv=Sf08kW$RdCjD;ZMDmN!Q8Q)Tqx@>rd>)Xa7JG?YrCzNrugfv#-BRi!0hpJUwGk(`+~1sqcAND6{h0s)OElI^!j2 z%MC$^r`M6Uvpd3$cJX~Pr~RES^QU$YG-c(2Q%-k)tI+( zv&QrE@@COT|7`nwFPU6|8Ij;vd8$7Cl1ApbW5zi1QbP}BrI8f5cOa@vq+&$WulDP2 z5jN1>d2Z(OZ9P(2|27vr`lsiO!?suuGIe4SSw(hcM^ZIXt-m7Aw)FoWUu`Fr_sSm* zTK!!NBCt38f&O5lD=InRZ|R z%b7%9r?jOkpXPK-rZm;=lfLT&?~dp@=_4Z%d6J^|q*Kq$%(zr`w&>^X>loznsU{%l zR^Ai|c(Y9jkHz3qbBBnm-F`m|5W0VAuSW`BztvE7wsc6J>~k&-i&kZUhiZ^$mx+gw zii~>a2;bd!*bo|WJ3=5An}>WupA!fN02JLd183L~O(Xl%sg$&=}I zvdZ#Eh59d2%?u0m#$U~pLl+~Jphx4LgMK%f!7RyAKJmtWue#*!d+S$H%D|r09M&Om ztWX4B*Rh)1v;NWjQF7Hd&cgIo#X0iLlvZHp;9=6~kS=b$)d~LhY4{G57^@mwWYxsk ziz#wIS^mE(MEZ=gf6%HVu`@3KrOD+tD05?1wgM(f?o_x)B*Vb_fRNF(i5eQ4OJjri z9R|d=^xTn?g<6Y^cE>dtOQoGeTlt6c zFYhM$o-3G=c)77C^ccaO0cnGG(A_zVbsB-IZ;byKGA(_JY?kIe3q$9*)&Jq@Ed!!# zyS3pVhmwv#>5d_WP`bO60hErR1(7c4j-io|Zd63NW9XC?xuv_LBn5%*azF30xA*s( zU(9)4YpwHGM=;3XSlT^BVm!cM@SAWhS#mZnwcwkkOGJAy`n6xh+KT!hIhtTu*fJ7J zczD0C%e$)Tb3}hA$+2bL=Ww2_bFr!^=M~(bX>0oDU9Jv^o3{CQcL$fvL1ef$#!>5= za)9e`)+u4tW^*`f$5k|8k=Mt`m=EY?yx1EgjrhB?Ke7FuKec>KB4VOti_KuzsGhr@ zVqkjx+w#ej3qUiw_DU$K(&0ER(fXix*<1JE&Kf3KUu-Aj_iumr3QY zIXXp#nwa1%prUL@XmFlqKgIeAL})SX87liBRk3VO-(f;kbS+3 zn<5f3^5mvd*-N#Z=Rnh5xl>ySS1J-tjeT&6iV5YZYXw3>((%Vh$ittf?_<9-VF5*i zP+d#Sc~>u-U(H>@yhz-(aqWsQq{#LhoeJ=O{ocr(4-eSVB0CPDRV>RNz-cx0tBR^j zUuZGbDo%=m)e!#Dw#*FYZ6X{a;dm))|4uH<{ACel8KcvWa-(Gmu~nRmxRCU!?Qofu zpBJN@ZRCw}6;VcYAne1eH>UbTc7gGLfx>?WCSYzdWR{*PnZ4GFr?ljGagTRnwx;a) z0wXQvE^JDBW5v%NK~5aacxuegUma7K&YV=V97+fwRaDC6WAoMc!W!Mpd}vQJ3UhUr zI@6W8SO1Vu_CA!CGlU8*kXyh2I-ywG+bF zw->--(0+K?m46d{7O8M;A)$=T6VQpd2n$g4TU1JI&LzVXI$sgkPCi-HFkuGN$Ij`@ z3e}Q=kJX^irb|@J@(5`*yB5uIyPWu|5PjjF4;_i(JNXC5nW@QRSf5wYyokM#Dz0F zQRRQBp?OtpULsQMg09V!qxa#%vbCSLzQwn%tA+=ZPi<=Hx}}!7D8w>fzx-sj_3!Gx zc*|h+$qqK8?sobI6FDnCR%0iigGotLFpW`lV%T_Rq!~K|@iB&-(C~|x*9gy|71{GC z)PdPl$;B*wkX%O7D@G{qu44*ZprDW)M2opfCl-q8Gk#BtdCh3E+0OgS9VIZbn=V69 zP?oMYX){I)G3J{?*DUrzlRgbXd& zaD{2V@@O>X4F|=FnDEYN&q<}@3o#Lz1F9#;PV)AiA3)gO@{jO_{iHsYBHD11TZw%X z9_}}@8?wkt9A_(IVI$QkTbwI5Xjh=V;vHPcVr|)v(vH~w>i6)a zDa_K8#i9@?KX$GZ9#7R0XWy_>@sLRTWBv#$rf`8y#P6evynxfJxx5R!z|bnXDJlAY zZ$CWf1N|)P=G2UN8|!8xfx6=XC`;ZHd*MG(FLwT}l&IJHyUEo-KszR-G^PF>mvZ6| z9FsblsWzof5Lh$zQzrd9gku`aVYxvy&#PJV27X}H#KF;Vmci!iV%;~Tl@vc|x+W1p_{N9E z(iin6j=~k8gHR))rWq6Vai4v-t2y-Uj^eRve0RE_k3|lzErtqJ|M$tcRzM|DjVQa# zkO+7BltL^@8Bl7!VG0rU9Ic?IH2khr!lFm~q;w0wK$7=FgLrFkOc9M_v3D|SzkYcv379dQS6yIKQ~gKE_+#y_GEX^j zJ4d%^2b=Z3GWI|G1f&CUvvV>Bezcju(!nyFxD{Y5Ir4k|btVjhZ`cjR60ndM&bJe$ zdRw$rhU=G!HUP97jvwh&$9+dZ+NTY*Oh0O?QpR^cI1BtM4^c@*R0QO}u4!~v;suVW z1~~FaAH*JN7^cGcnV!vy?6`nAL~F@X$e==cLr$Ax9xSiL_pmXxMSteY@=j1ePys`= z)!q9G%VA>{z!uw#i?|o_kb2ppa54@suLGk>WG%-zMypTEt9dE6r#N>kTXQLq517++ zz!N26E-mDT!YP|;qDH(EBm8?jjWVO2O}#C~V-y~Ih#wlc^POlY&b@fNt{e|->t@4I z*YJ35Fi7yAi;@q4^lY_o6SBB$1B;%G}6p0=mI#&kPzj%YPuz@BKCEI;k5=&Tzb zI`RxY59D(0(`2mBBn{#vY!ZU)O`O-3bA3~He?t*Gw(1dZn;zKwGC2#4VBaDj@c&1P z;r{K?{%{gu`Fs5x#V<#HEr(C{^In}FsqU`L%%)Bd?40r(WNxi86;G#wD8q9I$Uvs$ zWY~;I&aVxVmU}n{fyaw{Tw#1H0i#_&TA~t83d?u8>ZjTKyb+^xt4AxbZao~|;*$1L ztl*EBv$eXG%L-K+~5hfD$OzdW`W+_$g5xPslP!~50V z%Ah9_wvyV6IhH!n=gR5+VPYOFDq8++w-g`P4DWOyv`IKjld!3&sUcR;Qo6by9W!HiA8O?VpCrlia`WZSn$@6zv(hq zCf%Z=72bJF^reQfU$nV(S;>+cyaNpFOI66JweHw^@;vH>gw$&hoNWjn zd~+0QNFv5K*_`ZH&#?(x3%Tuca=fBWiO2^*DR6~%?eQ3*tLw&cmoff4;oHt#IFEnR zn+D{%Z_V9NlJY#K=1!Ye5eerlscpvKg$OGx+wvukiIoT_ikl4jY+f4+1Ot2f;k25h zU_-P9t)v4Pg0}q7O4S_hrnne1%b%U7O*pNz&Dj0CDkpf*(~T2!DthN>@u}eG7a-gO z+>-tx3%!b>AL9EAuhW<2)0`#EtAAnWA3t`_cR%7_6;kuE5Z>oYs-~Q(Lq)*Zn;QhQ zVSWg!0=u!~6*^fhMBQCi&05^N`wlQQ`?2x)ARw6e^d!oEF9-%xJKVXm1R*5YWhg-q z0WHkKkd!3VV;!GEUlBW1F>1^zSVvw^GfOOB3kVUiN z^RI})4FbyZDX1%aAKJXM(6*u{#@`%EQ`sOJ2pOi##YkU#6i$3u$iBG3?e)pxAvZI_j3UW#V zg!$&L_a!{UXMlS2Yt9tEME9#^coNn}k;BLH#xx>w_ktjY&x^yGasDarA)H_2EO)B4 z^CzdX{RPv_&J!VkXMp73_yV^&2uK1X24dqr?mT;@u9PG@uCA9UOjoc(kYF_sJ!`0G z(cHkjpjCl^&F)a9sT?Ny&J-IIk>*EsCC45Y${`?}_f9I-a>CYS^a3>j5m+Zh1rR{F z*i-|hlRGa|;=-t85==rOmrWeqA6{i4WQ>RRM6%CK9M^Sw%R?cJ)e;?Yc0*b_SL+H--5>hj%O> z#DSrx-t-df--LokcLPV|v%cD&HDYOa`ddcE7rE7YV^w@_PYhorE1id>y!$dpvh`(x zRz6I0SRY{gCx@aVSO!0{1xiG^A5cYX8&6GSsG`;*tI!o`5svRha2!$rOU zyWOT>=$?1l`*pTt4Zh;{Qgk55lYd@edv;CG5rWNJ^<2BG#&BzKs&!+jv%l+qkek-8 z5ya9ul=pdaxWH!%L~36v5mRRKX|Oz>(K z;Vl-FVhFXXZlmEz`-hOhL+o^cH+Q)7h^1*&4;+=&~)jgRs1S&wJ>i z^I@xr*#Utje5M5V*q}W$h`#bc%Nrq88t3SW)Bas}DB66ixeWrwtzw5dc~^0{W}J;Gc^6pYo0LG+e+5_(JkJOHd;+-dU=gHv=VY z6bcDElEWl2caz(9R^{RR8#|t~`@xyr0#4gOlkO09WbBhd-V8VawFdb(+nv=<8cvFV zj1o($kZb)IN&ffI4oq4Z>tlK=5B9@cw4Zsd(L``-Dih1UkplHAT+49yx!m$oqXQcs zVSoNgvVH}#V2o0_v(%X)T3E>;1jM_ozP*3=dhOlGD%hAEe3h(IC5J|6ocm^_cd^5i z{TsIRQG;v$B{B_zYvI-VXtT#uB>Rl%;N@Tt0EKU8q(C1lC z5!BoR)2#5QdX4(m*4glzwBrkILu3NK)#ASGab*`i;K>>cP|sQg5Kz)1G8sxeT)PF2 z_xp-Tgc%taN}0GuFUH~y(~G8L(?J~^4}rJt_Bb2B;j~`0AU8+`5?!@a))mg>aLlq# z`$P}|Vz`HWQraArV2uy0Iz%XPZ7<|LmdkN?_BZ!bab-NJr)ksP0+>Y2c*E-+L$o^N zdlE7fU`g}9iX`_AcMBp5Z8ZR3*`M8IBp2V!zz zomg}^JRSk1k2f=Pe^HEXCZzk`=T?e?=mRL<=@w=9zi33tVyR5Zj%Uz2g!Jm>v9fuG zMQV>v_k58OJ8MBjr7M%M)ICwKAD*~fY;sg?ABY=h&e!`K7a-y9dw&(Osba2%5gDtE zy|v+9A@yvU;QgRNz_egR)u}ec9hpv?f^T`1gs{pU&L8 z$5*g~IX7hh<=T2rPlI3qrg7SOKy()*8(1a*=`Jf_j>NJNWQ`C-Px7ZO-QURl*ZIkM zr!+||Ij^9*l^N$K{5a-NUdT9FO0tMRXN83Oqf{FHjhSvLB$K+}{!5f85TsP3wT<07 zr+Pc;fzr3tuuz*H*WX!d72wY$h+Q5pRL8a)rYUxDN^su5G4m6Vw;U-i+VWdTJTc*r zEs$LJ>`~c7N9i$!!A01exc_mbg9lnuZLUZoWZ*>{M^Rff3{^I!>vyq=I9REUFP6(2 z!mr-}a@*EiIT1(P9m_F*E2`rLVVgc!k4_2Me8(8o3)BxGIliK?blorPkmmFcK*GD5 zYM})OI>%chTexsmv8Ty9+-zRffmjUwn95EakcGrx%zogP^R|`8g;@Fp#Mq5Gv(?+O zR0}o_h$3(G8iBog=fD9J12U`h{k3=Izwonq%D^huI*0?0KOOrEJkw`DRx*&jA{uAJeeh{Yf$U)1SyH$t~y+Y)-gA&>KZ1YwXmjrCYv56#bh!b1~#|TNRHF4$yd|{ z6L@ycO$+H!_M(dGu>^2Tr*wHx8y7C5H&!P6K3dth9ieFB;2gS#78;x}qb+O#pNrdR zUWH>FHG8^PHH+z_#;)+V7nrb}2~7Erx*@|9&$dUBtO+ATywL-gYzN-q*Ag1**g+=ieyD5F? zw1zf55}NRP7fb901ak;bDce{kn1^jH+mHhG-p(?`Q`y^oE@kdeb9MrEYbc9T$Qwpr zm$(-e+)2`s`o_X_|D}vFGAnFcC@%k}+8py?^mOlo+9>Bz2Ew=nkM6`Vh_jRKWxBa8 zwU~01_?Dx#=YV;(!AGr3mELqQs_q>AJETl@E#XCZ%uQ|8pwvLNG4^NNY}v7sWU3_x zDIMY!IOYfxWOBD04j$Zj1JHp`yNLUSqxM_qMy}g18lY$Mlvr!%9?$n^LhYiXMOgIC z8NUMplAAkiZUb2MoeK_Ot)|q2yT-f%lgbW+QNB)8wH=7al$x=}+uk~uEjum%=-Q(;B7bQ}+N z0ha^e*1(6hE$B?>%?tUKR}C!3;rlXG4A^)5TJdDE7S}_rjgeAsH~XnwShq zZ^bR}&=Gh^ED9Of>^{jq|zw_KQ4 z+4u`jurl;zY#!{r3Dd0OYugwmq;-+=a7$RX{*#B?4H?@nVLc*Eq~z0Or`>I~ z2BliI&0=wruFf$>!v6PhJtZ`HUQAJSwOUl}rq+joD&i*bil8;WG#?Ze+9YwbkWwO9^ctAnn;CD^eeq%masKM@iujDj~B#}GZ zv+%hfsPwtcBq0RdGG66}(8*Air=1OmxNki+$_O^+Tq;bR@z$&I>#{mM@8~{gNq=o>04nC!DF4+|` zQ6qkWlp|;mJoMc1^?LrOE%6oXPx)P5*5B+2MI3_!H=Q*u@K8%YRjTr6qY<^i%qV;9gQ4hi&%35WL$@^L@_^g?B?TeHb(liZ8?yMbB z6;1vG;b`lpiXSIuZfi2)MU&#A$kB9HTt(YwPfv3?sV_m-B;c-4PZrQN-7S_wTet#fplZy3(u%fAxWNBhw8~mW1K+J&n``mob zsnmWb1wP_KwsdUQz9AH^6{-b*t{y-1QOUs{S>3jW-h@`Ne%}OaIyUJT=1EdZ@uaC% zZNW_apu^_z1#}TpH4Iv#IeP`IlT#@jIvjSG;iy>x;6Z3z*ge?E2{x;mR%GU{p9lw1 zVBPxW>2wuh6>m%z9xM7Zo*!*@gi_VKqO%0`d}p}<2&z7v?IHfwhv7gV!V7O9dga(^x?bD!;7}@ZH2#r2bGxZAtS5|nPcyJ5tu()($*vC=Yd=Z#cslzM z&xC&#O`bWC7+746YJWG9Wz={mVou!4Qb~jz@$Qrjh(mvkZbp6(W`NSl80}Zw4HpvD z%HU5%5@c625<;AHQ9j#Ij4oC}$ppsKd+x-cn_U^BU_rEua_5LEhIN9oFVA*rR9da^ zoF|CA2t3{s2d&-N$|fAaMdj(TluCJ$2hJJboMK41G7TP^W<$)&7;43dWrUa!hc~P& zQw)n%a>uFKpZC2BPtLG_V*b1Pr8Kc1wMwc@Del8F{j`GlVWkzU3B}b6{A}YhK@-=u zpq>&;GrLaw@N9t-#JzJEEnmutV zjgx<_=Bv)Ie_c(A+b)q0^#N~-`tIkZMyGXuKTLIjbl{T@n9N1k527P&g_7oTNC$NHN3#2g_P~)!wd{6 z3EVlEv<bS7?MB*DHp zHLYbEcEQ$i7UbdlJG^4FGL**sgdv+V^~Fz>7I*+$n&}6cS{(i`s9Bd*xeRbzIL*;z z`57OuwKc6fZkw5_F8KLdxQHovcY0?2a(MsG0&%3$C8OO54Ilgi=hQD?O8~$Y?jj`0 zSuc63?-EiDRIa>+^pw#wsQYRBROVj-*w2@ir|nHsDguAw&-JhXhDsqrM%s8Dp_^y?LoIA9g&7I2#E|GE-cEbH+e4v|(WU zg9=5`mCcNxjiI@$S3XOk;&9vK1LrJlIZD<6o1v3yJ{CF0Ym*Zy2dg zWV`I4V9Kt~j(6r$P*Y-4wP;kg{M_1tQM5;k%uEKbUzEO?l2yGjT5Sn&N-q*{siZ>Q zFyE>_rc4Z>0zdPB2sSL7OMB+n{aAg7#%Gn)P9C(o{rd4N_LAA^->jLKZoCv6oNY{*~HeOQxD5ivE665Z|8y@WoJ{FPPnN=g3^0uAay z^VzWgg+d~2QW7r&Y$EZ3Kq2vcW>{|$2@aV;vJY`YzbtK0wbe>g@D8jVl=1`ry);Qe z%1J|*wufJ zbeoNd%S%Xhl1uwzKOdn*-LyCrDk+B~{un|((A z52Qng=hGuzn^Dmri9uYziml3N?HO5b-)vjtx%7`<#!GEkr8Fs<+`VqWs1Dklay zE5GC=T8Ecph0!JiSS|cNbkgDA?rI(A@xFJzD^-s}0CRVJGInKouaV=bpJU3trfES> z*m51cGqjdk=pZ{O893LhrBU4dXc=Li-E-6SQw}69UFG%y76(j&gZ9aGJJZj|J4O}7 zEX>!8E$Bz(RwAmy_uj{`)w2G8HDa0`UPd+TaDRVf*{UM&F#YYWj!&hWihK?|12F7fc zePd$ROSdceu9N}JwpZ@bps&o0JN&I@ElVspCRY9K*-2b9E?E)?UW6=*(j>-(Xg!I;r_>6 zn{M_mhey%IMqkK5)*^zt-Q9(+82Yt_=S6D5Q4h$1`LDf;QM9Wl+|Tq8?ma$+(@ z@7;WwWMz?|6lFupbvhBChnJmP+Zq(vLyki4u(&CIU^brI=nLG42yu<(j!W!pGzV#I zVb(P5b3Qy$GEwrDGTssr81roO)?#ySDV{h&=kIUMCqH=blU5ffjf1w(;8hP&*0BPt zylI0eO9c~?>5bDvQIn8L7WV{A)%T?Y3o{MN;Y}tJ;WmrBb>FNhz6Iht|Gv=$C`LK; z!2I6uTNs-knkLEcGUo5rvL?f9an~B!^5Ql- z2a`D=DDdm;%WA9MgXEMZq5I~^X`T12@%yibgu|b*139pD)fR-66P*cVnNk+!NG#K8 zyC$ruUBnyz8jXLXsH6=?Me-37SmyXGGH z_e>;kUFW19;o|VmR2E&j+_DpI|IuLYTg@-Mtno?iyw%Tg58P(>o4qpo{ibV{NO}%n zt<+DlG0oC?%laFri2_~;oE}ef&qblH<(})^E)p#A6%(;-g=7-2r6tU*DkoW>`Mkwv z%U&pT%Aj`cniz8R&DAQOKO~$Yaf9;oKm-kr*nYw)W0VYNKkr0{(vf%5^R+(t00za9 zk-yw%IPJYCeD)%m%06w|Lc1Mo-FBCWTzX6iPSC_{^6`{{lX zcx1RmKf9IHN&oOM=)Z1#xqt7A&S&*#90>i0`TPjX z7?D6!_}#6U{uYS7(>iiyNnmh(*9`W^Y3j zm%DnaJi1w;b3=wUR5?%DO3A)JWIcs;x@F2xz;l&)*TW`b$Z7ZOJNcuCIQpI;_q`BK zuWqYMDQt$VjBmHAGNU2;_%3}1`WuJAXs++lXka@L!G+#aMr(?Wn=K3W$<5urB#3B* z8tB87GJMP3J5zQN^FZ zf{t9yEgFR*C0nf~Y=bRSK~(3U*?Xh?2f%~+mI(tw`O)=uU!)<|ln6b;Ml@tlvnqmh!D^~OcXY8(I^y>MTS!4@cPO817%2}S5nF*K zL1wIts}`DwvW>$_EP+q-4P}mr*kl z%PBa4HoP$najteErsyV^r0~B^@l@!Zezvp7fOeasCgW-~^^D-^l>lq(sU#up0JK?&F zkVS3!Xf2y}>;!==V)9a0*cx%u2B;UOJ_(U1#7|058mAwvUw4XdTHlK;EnV`tgx!Dq z{b_LRmT4Z}%@&Jziz1%I@_#E?#U8!st>M1>pF*~hclrR!^jQj=5UK9-kqc2y-6`?B z!=wYsvD-ihfsn!XN;5a={rQW@l=@^wNxWXZvEYxXm;p1ZuPADQS-0Io{NlwZl2h+q zsZZO_kbCsf)TUfyx@jp~%Tba(+3ifU;d1Yr==MVL7)1fJ$SZ*#!Y=ViZJ3hrMQ^Y_ zr<8JGieNUBPx#0k5p_KXhH-gI>_0E4MzBw5hz4O|+$kGN?O_IUZh0i%rJ=r}S29wa z+Pv|CK067*_dKI18l!Al@>@9A=$q(6EmT~J26sk}V6FF-9xPK<=?~1-QNh>_IK|pk z*pm;GVtUWt4`;yiw+r(zO?W>x->Lulj{UvU9?RTx^|dl(?B9KJKj8O-;eX^fHM3W? z{_-JIA6z)g3~Blf7!?U6H5s@gBsGvtm7ATMwneFIk^Su z_gG*`N@!RmB(NNki2LT5)>V=XoxW%;=`jh2TpW&}ua(U7W*>!{13eTI!{n}8E*<{5 z)*fH?!_j4ApgAnta`2)q5vK|1*+|Ur+_PD~%BFCq5HPg?%~<3Sx0u=uObGQZ8eFB9 zPHLqWb<}b@w}j;dE^}fNQV}(v#3)T(e?*|YaJZF*Zq8xP-w6g;R3AF!7}$SfzJF}~ zzt<=0TKVe^SCg^npSf1-S6iSH?y;*~QQPdFH6;miaoSEyxs>nHqSho%jfBc_z@Od*zLZd;M!Fq!(kdrW)`yOc zhU^&@@F$>oM9BidDe_kaWaAPz-cfz7z|stwJK{Ma!j6=S#1qrn@p!s{-6WEqBDRgU zTP6F1kf$R-Db)JpivNeLs1uz!Z5cxhz_n$hI@oG4^4lf}Nn4TRRE!o~vAH%PZotgL zI65Vjf$X_vroj!s$kaWKUr@RS*+u|0*4c-eDkGPP!L_ja03rVNcL3|_9EVlJzZRTat{yJt&cP5PX*)d$cfI29qaBEvx9&P})24GAGCngR z2to_fK2=@wXb$V?P6Nn6svq*Hq7V@ z=*`$>a7(i6_joL!QBcm+hivvnIo}#zTuYmNyq)J$=X`Q?*@YraQUmajiaih8msm+w zjrFsiUH?*BeJ_{Jc+PC-n3~S!kmG;xE$7hEjM2-TUB4t99PT zsuqq@VP-U@VtauxN`h#Kj49t%=x`X2-|CY%vdNV>FeLdK9z|$Xt#8}_0P6hFswnGl z4!=}}_UT)gwRMn;xVodPAIOH%F;W9>5td2V`jjI|RBOB_w0}QKm8aZ*VUde6cZDHw z;4UvWQlx6#8U>Hnsa+W|`PROL6fryw)Hb)a?2WfJ*m2h8#Tz?s8r6wPE5?}xczJHE z6Xn&Qh80_u9Y{?Mud;nHM9gHzR!H?MwHevc0+`BCxB)LB-V*agc#_BCrA;}@fgQ2V z>~phWBcK>N7Wy**1O06KXX$_4sXrxqBkx`l|34)g(B+&A{<4XMAF?vXg{Yc+#wcC8 zbHt?XPx{3Rv0fjXppakS>&0Dv>a704Mr)uhmC^&}aG_+GO-&U&H=&eDfE-cYBt&K~(5Q&eVp3$yccG79$)x z&K^qd*F!glQbc2_iaET)ZjFgj=|!rA-CF6)IL5>*A^X+>Na`YnTTAKn_x78*!rL6cEARi@aJFB{Qcd?XVOi^PHwdP z-9F&QQ?-Ajc{d#%n+_&0e8`@Nb8oNPORQ4fc@oNemL;TyYmTI9w^||az-~gga-OH2 zsgJ9O%|nWtL)6&4(-M@30xLvq_wO=3exsF2L#>!q79SFz@td7*pN` zhsUiBrye1z0W+XhHTaqsBQNz@hk=S9(XhxRo15asUTS#}3vw~QLZY&*JdxccS;B_ESTe7a+00fcPda?avfCv3w2qLzgI^e2p zcoG3Mr8?tQ529+OP9FixZGu*tt0Ih^rLaE zo9fFP)mj|W(Hr??@kg{uHvu-pRl(dJxZsXr&N|_FJfz`e<>Dj$reu2?HF>sSJVFS^ z5OU zx(vWd`a5VJBQ%}zx+b-TbMuw>KSqb?98bTeyi_(?x+vlV~wp=~iM z$F~s(lXWsv@yy7Xkr-)=_T?yGBJ?Oi~NGF=hDR}F-mE7}si562eF@VT?V*N>}IkNl0Sc8~-)M_Hq z0u33=6VsnW3vBstbqWfpl+su5o3q_}%c7Z4X|8K(#{J#spu(5ACHkZGbFtufCG7q=;0I7eh#4mrZ*;#) zg+FCe#UY@eB@^;YXzDDiHph7!%Fzk2vTul?!l!W*LU%SE{^rdMxxF(7^&l4WJpP{l zW?O!t3I6$Dp1Uu4TSr6iht!9tdJ})8v=);UZcbj{SPXh}6K2kB@OunQ@Mhq`A4>&S zW|+&(?@u$3SkFeUFcoB5c4?KfkA0EJA9V*=+3*)aH9z6A1qrRhsJ4$mUYu!V!y1ah z`cp|>fYAZcQt2rRJ|h7d1$8h@=Dz$;ja|a1-|l0|jU7BMHJldm_AI^4>xHnF4h!8auo2F)8(v2y|7=estw8MCgGl7pRxn|y4 zO)ZRnH~@QzyFn9A~9$L#P%_}M&EM( zr~Q~={4wt(+sl%Vhf54nYSH%9<0>Y${BtxeU$%PQd4 zYcT_YlvX6!os9oJqZb=D74BxDyM zD)uAaKanYgU|=v+RxtOVV=R}zl$l24cFZjlSaJ!1u{{Syle~F@sOgF`Pti1K3Rys} zl4sn?66d-7pl%&MW(vOLssA3xEBYKQ!_HR|XPOX{E;>G$>^rajlYj`VnqwTXD`;(9OcTCw90 zvoxlN!4*&4`tc~`IVgfQ6*@co4fglm%wcU{=%qsJ;-8H!w%JoiTg9^!wum2F-oMLz z9))hmW>O|up4E`1cHKagnJ0M(KIi*|G+D46ur#`_U?K9laZj0frG~{IKQP6q8=Os& zRo}RoWDGv#NvsC6-!TMQCAzgsn*a^i7A$&xjUe4bVW5%d^V0|6`R+AahNyi0^931vcMDjwnX%W<5I%NVEuC z{h~c_>a`f>0jBtu?_c-e}UpZId4)m#p&ArgH!pi0t+q4exmJVY$`wK+0tfLq>5#}6QblNba$dpOTNT7yuAqr`t142Y-9T0 zto%>s^mLH_!K&+VXVX=I|D4acYr%AE^8AA1R-{6kWNyuDi!E{FFmu&nJ_W{DLa(UQ zZ)N4Z$WQV_1EQ_80GoZVs<|;@F4sw}lg5E$lrI}4IAFkKzzE0VZ2$s&Q>i8?Wbn5i zx~woG&)zX#Mq&X`qmmsVFkm^UBDoKDRPmR2rN;_EHRfe_vdLRk__7S$vb#9~(}~ zJ3?&{I6^XovC-4idw~t$<@_CEp7@y=uty$GI7Z^v-lM=& zB9!+2X*eMy1J&5hXa!%6WQ!x zKxEi2`V5^)4JYWhFk zo0r1#YrWtW*DnLCVf z^i4dquMhPS@qngbOLjMezL2-yx_T76=7K!nqLw6a6f2+LroWqL-#m4vQwR z$i(1j7MbuOvbEMiHZ^u!sn4g}Fr_G^N>iWjU~hk(+CC=b(YG<_F&HK&wy zH-A5Ty}I=<@`mdZ_oj#Kt_Ma}aq16GpQ>fWNC>J*pU7YGU(Wz8WxGX-j}o!pn6ddB zR2%Twrgq^ZqNRrJz0d<3I}d+6E92iO(X64XJsGG6>tp*9)S)mv9p1v>qbM{EOXlI^ zVgfv3HcWpO(?Hzck_S|S`#Z}NBsNaPu!&WShahe==t-B>iW=@w=h+if2ES)qOOPM2 zwdK6~nb?YF^&yjcXVYcZ{(&gEKM(7KT(Hr;2uzqnrj^a5y;o{}Y1`Yoi9nAd-~NC= zXWe2!->Mz=^AppLE{!8SH(Cz?!V2P3w1*35c3__ScTV3v^nVX8J^;tL4ia16vQGwt zT$chmop-<_G8~Y8mGoQPgdI~zuhD)}?095?1T}H$0$EqoV)VmpvFPrU1QBz3d!_$E zDbZ`H5mHlq!n%)a;<@xHc9wz`=V_6;S8;rPQyQMquM`%(c~OHIsJkSq>dlXBulNz7{mC~WZI=|E zd9YigrcagAr}fggGwaU@IXL9}bwUoIM`xd(*Flc1YV||9Mtmm#uRvjk`g!$cQ-tz$ z`r5Ec9#(VQ2UmiX`z#(~tXV91wtu=d^PzsYh#~&uVNnZG+TEDyk^^PA7syyt%_0j9 z#PMnLnff}Xd(_(zWkB~unP?uC4pyz=3ts@sQQ#|v?1R6>c~bat6 zqt;ep;vN)LS;w3mhD}%txT-|}+g`P5glTrh@;2g)i5v%Mi_VU1ox!;Qk8(nv^hif6 zcR(I|Neaq(#9Wbc#PO%7d^h4#1~MzT2Yf?pk4?e6p#_g`4A;(T2mpPZAM*5wb^dP<< z2PUhVi9skEEa8EI*sX)Gmk8ItK-*;5~ zc@jG7|Ep_(wCxyf_y%}ux&nE{UKFfK#(ZC97a?~zv{>FoUOrH+7zK6FZ45C+HU54y zYViVTlHEvLW7s?NP-e{GXAC;7Z>}T-hI71}NT8X3*YA~U80CyQx+K6T%c$*bb^)bL z+|%Q#9pRpx9F4f0&Yj}KWZ!`E5{|IB#El8OwU>T|cCtGN-$;|;vQxy7;xE%*(>?ZI zDP%A1)#u;yba5_02gRrC3mb{lAQJmpk0w^(lMV__Zf@{lw59Hf-J=Sar(KP{gKyr? z2`+`?6VHtQiV=N5;>U1@Tdw~Z2+oxSLeo7eN>jlDz|Ex$nTTS0xII&N%KAMJ?U;?! zg3^sx;b>Y8lC5&-EA`TsN#v>J2erctltplr`BbwBfdr^g=^t848Uda9}W{bt_NbaG#LfJDDPsttbt%mhy|;wCA?yuUFnEdV(N*{H!2~}?*<||LP%am zgKLpwoJ*^S95o2v7DfZV+9-N@hWtzFyc_{2EYD4^#kBdeHK$iB5uPZ*vEAPb24LN@ zcfKuCrWuNN4+j3bh+E9dIsY0%nF3HcDu&OIT_f0&#nDV}UP)|O;rg0{1pI$geFrqw zZT$Cb6f&cXWUpl0Mn+_hY>^#qJK1E#ZI6g-Ss^5w>@sd6dxY$*?7eq*ub-a(`~Kf^ zp68r8N3QGoj?ee=9oO~4t(@xIvHVP-6yAQHSCeE@d3NB_s>4s5KrXw?hDTOuip*;v zNsOhsq(d4%up1QH{K)&1pVy>~ip)`T0JZ(OIJ4)gyI^ff2 zCx36uyesV9{yGW)%leYr^qv3JY9(}QuR~a0blr-Ks_6xZ?MT^3H5djUhi4zM_;Nij zRUs2L#^?Hy`BX|OE8rC+WK2a8Dppf z#c;lA8(Zqr<&br;(ybVVxv~uL*0wZ>zFY4lg^8sz-yAbAZEEjc(zFD;Nfkf?Z$F6L zEt#xEqJMzDcT2%;?8aQR%3E`K6;qrZvz}j*Afx7{!8-D@>Gt;ixV|V?#i*^iKw|Ah z>e9{*+thm5RztU*^2*EtQ;SK_cdx#9$^Y-EF7Q3LKUttSY9g{j(|~8l|MfE7k4qrs zwvqZ>2Cd`=B`fZ)f}>T99}DR(DSOAB7q9Ko_Wg)t#icLAgYD>3kP@}%^Q=sS9JW1M z?$Ow}BIvd?#J2EbR+Eb+tQ- zF^p%j_+gmj&gp#(m+$Vhgxg{#coTBGe|v6xB`@m%6GY7}5oN}7$7x1_(A zMBITf-K_Z5&f!PraH7=ZL=)z9xq^OcN^<8+6I+it`(W-C=X3pH_fz*GFk~{XC=+%= z%`Lv3b4#>~i0h_Zb077MVD4q5H&Y9S3HayJZBRieeSJ$J1ZM$b=YIxv3N^=?+AK=} z$P+k%pzJcbo^G~N_82_KJ_lOY!uPuGtcT*9>254ki!~YWoffY!B5~ga=*hCy?A_-s zwY!auQy0P>h<~#zH#AwpxZ;C0{q_%RLT6toS>+G*+Ws7O@|85DKfU3kTt;OaBm5;W zNmrd7CDKsxOVCd~8IwHML;v=xaQy1|CyNqZR2?VTt^G`HmpjREFohtJFTP`OwJz9`3F&Tcv|l1oIiS4=R3h-q7Wrn zeF2ha&bx#M-?}x&eh|Bs(ne(zr|w+7_;DAj_=(Qq{Rcw7BGo-2+Y0D6xU7ol{n?kC zaHgLzJ-{YnS*yu^C$w+h*KhvXtM3r*h+2EE3{a=kt!xzgVv{4QeQ@k}cH&oZBb-#Cg$j8p+(-F65_fz8 z?Qosd;|NgoY+6={yOcPxS5LEk z`4dq7`BPfY&h!>HNbzpKL1pd-qoU#Sd(H9gIjT&F)|^-)6*oNP!`;#@Q-&-Y|nS+G1K3X+WM ze?RwKOYggR@@}Y`CZGJQzDe0En-}3nUNoedaTAhgG}}LoVlvvK@%=%D+*~l4T&5-A zdo1VNfSaFg1&J#=C1FL*`5f@iqlK_afG?*%>h2!Uf9bBHDbt|IepQRX=I#wh@=VuO z3b}(CWB!)P^Db?c?E7SOKnf0G;Q!HaJq9T4OvzYqYM_h=Ur z)n&S1W5sIA{`js%ks}9-c}+p*Cu98$jBdy06~Tg4+MnMLmgM$pZa7#2NS2E6m$34TlVHwG~g2 zx4_GaHXzS*Gt%@TB;W!8mVcv0q|#rmY_eZQs8KQYf6P|P%>&%0+@O^rBefnLRlNx> zy!#5=sQjT$Z3Pg}3LUljH2Admma{6RU6Oi_PEVesr&vfp*7u z^=VpPgPprEyE_nu+yV-0EM3Hlhnn1uin8x2o#HjzP|xJl*Oo%3?Aczbvoou3;JnvQ z@1_gpEHw9~;ej@Hbk_TS?|ByDSH=q0+~Q1-2l;;K)}&C^Tu@~`*bel;tJS7hQX``z zg1c8f-X&q%hOY)>NH}bA>~5_sSgJA7=;vU1qswLK$&|gXUTeo;sHWdB9p8%SXRWB{ zZg;B$-BZjH4bE+caZTwIjze!v{h9pp#h>5e!lss+YudMEAokpb=+n*u^_J-WskacC zzpO%7dxd}*a^U>M-o~LWJLZN8g`UHVV1O^XZrY^TFlZ~vLep5-H{Mc%Vv|3^ffz;&cstC8c+JNHVtr2#TpSi#GIp75kbDm|_S|C}U5es^L zSo;(D-Dbm_4lgDKtX6P%0m0svm#U0-jK4AXZ$3ij*vrey9A}6A4Qr{EB6vVl z@u}>(`$HJp9qbBBnj(g(p%^OOyDIS(_jlqlaBoNsSBJ$5hMb#m0BcXXZFh!`^+1RQR;!P;|6BUwN-qU*~w<(+PTd!BByHBv@}&8@z5C zZ3c#HDOz5O`5x!WAR%A7+b1 zcd5;PnusV(`^lekCpI37ULBP8>BS0vfoMumgG)jauC?+CcrHtsl?CF;9AQ%o#qYAu z)RLp^Zm)gWF)1*i;wVy>!}!|&Fs-negT#xYs`^Xjf;a1@GJW6SaOb7DGD|JL9MY}N z7CkO^BH3P?edk15NLHT-)+t3Z3;^Tsp0N-dJRbGBu4NVCe{+nKBrD>(^Z~W3j;}$L ze&(Asch}K>Bv$TXKsV`R-+=EI5h5?V%tJRz7P4DB$ickw3ZMhxO2|w@j^D`+UJRPi zIzwLEPxSq7-F{Fy~+a@i*{Bi$-o4yYXUv9XxWOjX6eK!!# zZE1`ZvF(1k4~j_hI@}{tmzQyh8|VMLZhQ9baq|w^TOh+j2Z5#dr)NKA+1?)jI-W(; z96}!0)bIv(ud(X+wb-^@+*x;c8Wlq;Nf-PQM@nDReAf_@$9ve4^TL;(Qo?4%Gk~t=9oIT>G70-9E|+h+Ur0LXd~XxMkM8v~mGgfmS)$by%j6^v<~p{e93~{G zs@6WC26t$|#z)NoY`Nrt5tsx|E00rxpwz*jboXuEV82!u5Cbk}4J55+6ICPm?2@VD(MP2+>8vi9?lqOylJIxbB z;EmKzU`7Q5*0x6R_#dAsT3&OIJaC`O8ox*1GT*Kzg#WNllRcrHRQ}@4Q*V>KYLarZN1--mH zZnzq_tL{u{&9F97yN#-4fN?}R(DdYk$v1Fc-p3iFkEW?L;K(Z&N>xL9ZV6P(v@;1h6X*~kP&efhXoDEaBRj`>M!v*Bj3#=T5R|fQCm}} zq`w>Wd8yx(a-XGbf_An9D`AXg$;+73`n4^|b*Z`+$jkaJe^(^-YfK+ziTGvk$ASh;NfUHY4!k>;7pAN3&n`8Qx>kSX zpF7@=xJ)xI)oF4yh7rjA!%z=wO5;FI`an*AO@+0;=nLUf80BTQ#83)N>2?8WZYVjf z?CKgF5ELcDb`gqiawyPmvla-ORb>gTm#+9bB+>JIU`qN>el9*3kZtgOq(O!`E z0Gs|9mYtiYepf4qpeAF@6VkKQpjE@JRj`S1&2&dFrglC=^t8*H(zRrnnwmy7t6czD ztr8?&#lsREy#Ab&j!b4kUwAZ4zzwTp9^Lkaq^1vUi0iq9ufG32n9ugbTnyKbsW*h= z%XrOTw&t-?9=uIRq1m#O^k-srF=dzlTRrEQ{>5l6l$;tCa7b=UcE_skxdv5^v*?_9 zCVQr2fKc=VOeMw%g;gRih7lK<@75zDoLL;P!mIK1 z{+netDLOs;J3=fr<$hsa8bZ|Z=5UZ$rT#jKHZ1>Y;9G+?kD~Lr`T37>iA=hI zMPEejY6yWu<^pbii>|8CBQS!jc4olEfyCZN_V)vKh#&AD)%=_bYSoj`ePHSv;Mhfu zUFuw@^UMU{)I>yNI$uk>SD<^_R%n+L+;#HRCwdzXn9L+WW_b)ER1~Sy@@OG{~K%+i40fOScStP7{1i$1$)7 zY$T4uV6gI}-?&Q6s2+&uebHN{r~~C+IWR~BIM^Nf9u&;MA{v$~hIqJFu&U4f(0Mpc z9LJ>>voZV5?cm!cKX1IQWnJ6^zQ~{bQTp4SIV+*Ju0p3TIMU&^8MLo;yDEw)+;Y*G zoL_aEr=D2%4?M7EB4rZ8em9xchs)TWeIm3fGT-1;g}+#5%iqi-r=;{eX!OH)5lHl6 z#_M=Ga{ms{C;hSFUY`& zhExE@<_FU|AV#LBs~BD+rGLK|5xdAqj$jkJ7_8pCid9EVfU91MU*k-bS=(Wm_y3lC zpH;(rsPEcmA~LjX@o)3hlv!M8HJQ-Wv4XQS4cUnwvtx^|-@aVZp;W34t+m_iE0PV# z{j_85>|C*VaXfpK@E294b6o5;>vjB8_UY3}%x|62pO4KMFcLM#)CL{S)n)*B9T~0> z1NY6Goc(AVysz`UwkEd|=9cBZ5sa-l7t_Bc_3br%` zRfal0x8^Y2FR*UAOY+7k5}S_tuB44$=O2RXp@#Y6nARG%`m4+HE1vGMqx_T7_t=Oz z%VVizi2U{b{Sn>-ymEKMHW&_wqHcQoSW=fssn*vLAGwkzF*d#lOJxafaH_Dc8fOOX zqG;GhJVqF866#z6is6LjGkkkTSD ztsKUveyiK;vXTj_@2S;hAE~+IZUgo`k+?4K18{WHqCp5xMii z?0sUOPPfw(qimujT#wkNF+uxMO={m=7uw5$d89+)Q+CF|@ zgB_@YRch+t^6xDL6Wb0NEhsSc3P0|SEdw@Y*#5=HF-IVU-o&PxV)RUkT~x)y$5~IjjCiP>VRsHGTkwhIR91&C%BMWV!WdF$#kB z+5H#bFt?CN)AgRmTE>kQ?LZBaei&Fb$BH}IH6KtX+#nzeE4V$nj);ftetGE9g}{k~)vv1PUFcrKL}2eXcGVx_NEbNA{{Ie0IrvN@InL zOxCBo5J}M=BO)TI@bU3o@bK`QH^<9!+}b0kCLswIWHtQlZumI){q-_G+7_AdpNpzF zvRvkRxV5F^p(d34lNKDh{vike_W!)-7!RRS_`V?xOxn*4Lj@W3kz3WgW4NzN_VKGL zLnUd(jJmTmZu{+5LE%*rd10Go4ARK1i#>Rhm!()&3Ha=znD)|BI`lkXA)&rpyiK_QW4LigRQCWuP3W)CtZ1v#-&<>C~P{{jfu(vd$17^3=9kd$q?5K z@22=my321K=0ThZlm8%Tk0BH?);FU*5F{<>&O)@c5bJyI<8RFv63~OehB+k{(K{hX z9{Y1BtkPWmrr83Y%MNp~_W-8`_*ZxK*1@Z`gsqCmMDc_9resJ2;CgF-(NAC%wVQ3rAI2>1{{b~8 z+Z9_FgjuhP_AeBbj;>?vNUCu?=H=^@~sl4F*Ut1B|-b-Qqn63>R})VkffzIshTyhQ0Q=&!uPW z$5!r(BBPTgjPu{#*xa1n2hMoG-5|m~veuYT^rBicc*h*lVL(6t*g3!PN4O-+wq6yn zRB)Ta^|`f|93;3(3ZK7FB-Vk9yH^AUJoeUVB2u{77rYrXu!!z(PhjMOx$b#&!^NiR zUg13uHJ}(}k3%O^#%?VJxw;(Y_=RHw4EZYHBnx2c_Rmz|>chiBFFEX8K}ZQM2LO3F zZKi7ux#?O|=vu0P_a9+nJeefCFZh+|q|9Iwz8?UY6c~KAmm&uohJA{f3z#DRG3Eo1 zafcmpRZFpL1?dKq}mMbyYuAS{N!vG1ns5u1|O&Zr7px~i3Pa^Ww^ zJ!1y;aTTH9vxNpvfXiPso;DLth9-a_hfa!BN+T2&rY2$6oQ=aK>@%v`FtL;GNT{yU z(7Eoupo392K(rL1OfMs&PwNd=$5)qZPYOWZukZ7>-DisY{{0P15`{@G7gUt6rv~-_ z|D#7kXjFGz1Q7`b64iQocw8G7E`pj&5^4EA)~={^!r>n^a(ZWrY_L@g$Ss~T3)%R`4ovtGh zE1mQx6d|D~NUolF0WT;u@hmlk?Bx7tbA6`LT6^|rbJ7hEl=aos@$PF!oCYB2uBzHc zA{k#(@ttf1j6#+NZOBO`+VZNK!7w@hSHs@N^9DivVg z&5$*3kxC*HA-wb0k-^@TMj&R3FE1~>=~_mhUq}ex-KaCxq1Ecx`sIR(89Mat0=w3? zdWck%=2sD3Z^(`is`wUGezVPA3wr$RW_m$UP(NoX_U|wexl6#(PLBVnYxaER_)^ta zy`ct}n!j_y%K2U4n@7o2O-)TdA?tpIZF1vjm4-5{LYlmEk zd0x>lYP)>F2~f@BVe`?xeD$dkZVYFH2dOci4 z`C&1pnX_WdW%?HKHAI-dc~nkr!xb3~u}xOmv18zv5*^&XwGv=(VlGUHgC8dvq}v>d zUf#tF{Vv}NhOI2EtgH(4IiI32I(?rK>?HHY4o5!z(6{_NTfMW>oBNk(j4w{1Y05^z z>gwuOIt27XzWO{#Q^aAd%8}flJW1?{J!BVLR0UcE8Ft+fp`B{+CB~TC2PDzq z>Q=Qskp-)Li~J!8JVoHaK{4+rBpr6MZG>YGMLF2eD!Jy?Yq5?H^;@z|c;Y%Af;-H3 zk{}$v67v|wHrdqC)jh2qx8XN}!l5%wG-DOUAe0N)UOQVuN$iPPN^HL_(Ar znHsT?sJYFg&Q40wFRBMbcKbf~&1}OV&W}H+IhSvRbs||w!*F6xGtJxOz%srIitMDd z1mbk5DlhT7tvV8tl9C4OUZRLFxrg9K6xl`8XIwDNEc49~Y?%<(*aHWt|h*IDw ztfMd?>cf}FyJ4-;%(0!H)$^V}LciJYcM%?m(Sb&e6_u;;Fd8~$UMtmu)69F;sKD+; zr=ckL{QUfPX?itG@x35gbVh_yFPRi-POmrN+1!LbILG_Gi!7jZe{3Umx>HG_z*%zc z^?gGL^k7=-Ovj!_Yb*@vPD(=yKN({?(XW>jyE^SlSIky_Q*d8e=Vb$V1G~nvGSgsW zQd%0pdO&OWxXnZqya!1aV$?|nQ&|d&rYg{QcpFk*W-a_slNg)~^HJLM--iXcC;wdh z$-T73PP&mj#Ln@Qs3q!seA^W-dSmqc@{R->U^0@k>=KwyQLqO80DIQ>Q! zWnD2;7*XOPn-;vh2=J>HaA7%1xW&G55%`!=xbe~YD-ntbr10VhVy zeU1*sb`y~;6f<3VB8C&$`x*?BeoTO4&j?m4lvSVF2W9_=k!JF|A! z9n;dW!;Lve%fO0@6`5mSC@tao3i^h~+e`K1u}Kg~U$kDn*Ca4-_9eRL_Z}xWU%$56 z%;{Z!Ogssqz$?L3Gn8dR|JcHUnN)p(G`++>mB8-&etvvI;>038awE~g%PCaa1RVy+ zT5w;6w@{^*Y~=vl&=Uj;?7#M@%>CujB2+!SS;LWL=DCsLDG7c_h)P@d#KRdeQp?Bw zw82KmM=jlQMOu>@MOsqNpFcN+JOC-0-UTFR#-kh3KF@S!fkXdn&m6sRNuN`|5Lob{ z)8mQw9Ml50r<$ zGaUVI2kr0K@L+V<{ti?$RO8%9r!fExR-J_kE}k+Xu<2>_zdJ2T(?=)DWvizE8v*_T zlf_>xv$!g>VqbfdPe{ZXufV)-&b!7cSP1N}DF%*TX8L6*j24jy{wghCxTadXR3G?( zVdVSt2d{}`VvY*C;~+3EJ2RS}KZ@1ee@~ihV#I2P9Kb~jQFye9$d*;*y?o#l>UnoY zc@A}9FJSsl|JhQ@{BBX6#zR6{3|dxR1iB^@PwHexL=u6WISSZi2z3-3_CQ#cNV?&% z=!z24NbNMW`T0iox#XU?!zy?1b zAK#b7D_~IwF#tTl`=%KIbk_1}BcVE1{Iv^%vg4wo+hBL&C&ye_aYCP`vOnoq2VmFK z7kxk)zhJ~RA#dvl_L%y@h`AQj_`1`~2^V7?hhAsa<_R?%?b>w=A}L3aOZebDE<*rHLl9$1ChAWD`g8{S$eD;YN3U{RCY5j&(iJ#b)6-uAZgA zHhyP9)3-=%y(llkF_rb(nA~&oS=L5%tT;`L?*1!j_WVgo zq!k-FR!&Yl7U=VpJZeTvbm7Hw%u*;2ZeYNWL$;iEz9hr@^K2p9TlpR4tvl>;8M(H| zEmRRIYL)KI_wIvbhD(DOBjV;Powp2Yd`5f-Z1P3Xk35og58z+MHa0O-gOx0QngtXg z4?gO>H_i=9lFW!=Pv}+y*g^|d>MWLgiGqcI$#_iPU5$s86ympmA7olDC}up9Q&CY7 z;sfWEgkaX6d@poGExmn}ougvw`0YaX+*Eh0s8^9J?rpTH%5W>yv!OXHz4zdo))X|# zMo1}nZ0vFFegQ=yzcUME5xeL6ttS>}03or%6A27C6SB$z0v2#3oC^Yk7eM2jWw1#e z#1si4DN!_z^Ct==53;)|e$~t}#J~ey!!OEuWFdA}~= zVf0S#7e|i$ASOC&`jf^JbXdE9u!sm1e-eC6kK+rUn^WRF5?NO~q00As;9SB~`Z-le z$#7PfWAp@nR}YAk*d`q;8E=u)+0Hx1?IQH2JUYj(n{xUzU(sda+S-i-1%A(5BhEF~ z3jrsiB%#uzhZUEZ8W}y2z@#Q8+)UwPqO%$2#*{{dKS-Tnwno7|71K&MM}B- zr${>Ww`QUvB0r_)MRYAXRcJhHEh13alVLra&#oSR{!}%0`kY$hc&0jC5vve@S}1P6s|PBjVMQvpsA{2Sn_@GQm4W z5FQE4Oqw%+QWbzJNoWgZXTo8-B5q0k(=_)lY~9dWFIDS4p{^zfSA;i9?fGLn%|3aR z$15=L0F4qn*_XjFkbn&Upu{o~I_Zyvv$1Ip$3AT+x-_yNOMra<|5)5rbNyCw4_O|Y zrCIyQ8IXzu-tU(3jeTr`%%HP1!)PQ$ou@~E>Q${I?mx-QVYXplYd(w*!3n3Kp?SHa zm_8THlD3+(eK0e9KFpx{B+I+3<;9HcUaJ<9KPS#@!P89PZ?UH)X6?cCxr1F@nw6fj z0gTG%hSYEVP`ElwH=%uo#HgMp{dt2NgnWNsHqMvShi?LBRB;c7>w#_(kB8y=Ho_dz zkiGl;7(!BTC&Bex!Hw}noqcn;PO|SC1yt(xX~KP!{XvcHy8GEcDdzIn7j||0vxSa3 z_x5DApU}S(!uMJN8p&c*tf_c%T4Yb?wS>D?X39F)bo)`Q0`$8n+gD%M?P7BbDtNgx z0F^9x6`dVOjOf!tyP8X{XQ`Vm^0wr-(ffi=&$QS|GH~ghNYUurKp65?@cd|KZxBiiPg_+T&Q-Rf)B!uQ^q3hQYs; zg!DY#@;a1Y7XSLBTlGb!2d(Y@j1T+oIPMTteae@d99?4n1zc;(<3<78IU!h(vrsAr zSY;&{J~%jN49mi97AO!FkX};kkkWQo5js)*FvI-#SC;wW+Hi)FEiWK%Z;@z?$$%RY zwQ#8jR-Zo+O0dj8)x885d^}@;Je$9ptr4tsE#JnpT84fV!#o8u^m)MGUmGoT1CwPm ztYHxjuCeE5^(v>|XCj&{9BcxKiWHz|8Szw}izTy++`qCGSrqLF4X_0AY*(EpI*}4t z8#+c*AFoB+_<8?xU)Jjnq-))tvH(Q3xBXSq>(WWD*A!2$Wrn$FbVZ<%pT!Ve1Pb(} zYQw#@HAh=ZEZjGL=RB$QT=gu;hrBFJ$fNl+4cN|l%8mZ0Q+jI@=5P}lov;>wW`87w zos1ce07OK--DtzgQy7YxfOgVfX6kQHpSGa5@3cC7Jf=5k6eN#wkA=%>J3xW6 zgY21WI24+SJC{IcVop81@SFvPekpR)4X!HxTfbx9RT`_!J~=7rRR=PfiB5da+_Hl# zKPDq4!z^!$iIztZRXE#ixL`E+`TTxzhnKx)mf@ z23#@c%a@wwVq$PO>2y`W1SEBexn8dQHYK1O4eiJ02fa5yumJ9W?ztr7w_7vjc1Qfa z?{i}G#zRI#a2WE{TX*j6*x56F`{p(D`Rc?*cgu0^)s$8}Mac&Rxv^Vwb~XNh?tqoE zaX0d{d&!_I3uGKhk6wCFC;3R=9v;>+obsF;3ueXD{26@8TR{xr9OhmRRUYsztI7$G zyLNa?87<* zNsFL;C)C^16aFw8%EN$Q{X@SXM!H?~a!a>x(=+7`XS(qpiJ1?qS)3Ku`2Q2`sv(v@ znJEZl#Fkhr5cpoE8hv>2gtNOseRftV2L9u9N%i@`@PBqQ^0&~12l3gSKc)A}^Sq%Y zX?kvuf-NW(6q1!s)q0BA%cc13wh6odMT4(AhzfgtK&T3a`2no~l>xwEqu?Mlf&Re` zRUIwJZe6ih{vqTV=Dv6OQzoht%6aM-Bt`MLSI{_QTbKit%Y`K5B>_&Ujqb`Qylndq z3*CHwofZSCLde=j&wlLJv2NaIBMX0c`_U;n5GY}OfD|jANe9^rZ6cR(?ie_9%mZH} zH^{Jkf3snOU&;N6q=(6(-sZ$pZqNMNmRoO5pD3V;kR|7*mj=62K(_kmlsJeJ#~A2@ zYd^bhCl)Y{{hTWWk-Zk)HS2Ouk&84$%5(vMp1hohG@VMq3aYDz+3iBdz330Vbw-3H z!<(F)q)_5_Tw;>oKgOEmfgP_(UhGqHX6H^2aN5KK1Ei;7#;mo&hjzm650Nw_rv>?`@5%Hv^ z0JaN>CARxUdiw$((Z*X0on%J_q38eE*6_Yd%U?b|1WYhr8YFfjPd|qtg1fCgxXLFg zZlgvAyb*6Um1imlI>@pVhANZcUjnoXhNC)>ek!(5mfzR|9EcH7Gts0BRWlg=seEOj zY-2w}KQoAbsGjkZ2YFoARmgV@*thO!gnq)9SC$XCwlDLGoxiK6jq(Y@X89WhA`^xd zyN4b3rdqf-+8Cy&MK;9CyvmJc{{DWW5OHHADW>aDYo4!L;B~UO{j2Q!$eU`)Iwih9TOL(d7QhnQDG4Ht z+&Fj9CWXEl%e6ex<;``6t)<*El-O)@&*k^b1-H)uKR;po2XLjy39S|jan0;P*EHcLXM`k2ocfU{6^Un=pI2r)h0^+;3f*AG9+Wpt&@$+nh4SE-y zRMc{e&9nm;5kkW^rnH3@zLyMh6-PeVReCYHG%8Vl^P?6&?ddO}eVMKZ?)_`{os(ZI z?3<5PIe+gaCXd2f_UfykRr?R{oeylNn)u#isR8+&S#SsDORvk<0etZ33neRu;1}h0 znlR&~3VBmR$~v*Xx>?wy(QcI27U z`+vwBkzNGRyow*AtT@rV7K1Q=4LVkZBw|~@owQY{L^2HDY+@CA|Dl1`7eRkq1B_b@ zq!YXMVU4 zrrjnd76msvcVf_}3rW_hd?kmfP_f0Bb9R6Mvk2HD{(ac+q3FTu-(_l~D!AAt%6$P@ zqLFR7dLawArN6hX(0Rv0j2+*d{cUqIi-7CYB%PA$4jU zL?SIV|ISsA>Q{6FzO7-YA&G?hwQ>rml~oGAdtv z%{zNYei5D}PjnF>;4GZRhh|~7?{4DNMnghw5AYwa@}U}hKm~RIOvJwS((Q%lAWfQm zf#``he?DVAdvD13HXFhhywRQZ*eTGJ!`-kNMU zsMi)r*lsuqi0DXIR_#0A>I<84Sf4|rU&u-+q>#P&BfAoXeAN&D?of4tN@LW9RknI4 zwn0<$d;Z62?{ztxV#*qgQ^}3kU#XXF43i1^(FrCU^m=5JrvaUCZ~J_a;p#1_ATCu~ z;ys#@{hOw#Ff3O^gb{H=vrqCUc~roQxrU$gF-lhoMLu3?j32(uq!<4lJK5b)F2-%v zTE1}%HKDar@M3?V8r7TP^&ZJ8zjx!qL$&^v?v9?Go(H}+$4mc}Vql#K;J@J*e8Yuq zE8&LGMtT=m{m7feN&$dl(FQmA`&v6Qxj8gss$A&r%be!?p?p)?ezL+G?DZ@2URFXf zJ=^1Y+F(oi$LoWL2g=fWL1SaKwDW@vB@YyC5mSaab6;uSz(YM@K}qdR{Mhh=lX<;5Uoc#Rrh4P8_}#JZ(nOj>9)k${R$_-)EFu z(R-D{?sX)9QY$(U-YGre5io7p!=DVW@ZwLbYb_w;Ga~@a_|91==JTtX_rhrr!%f!B ztv;<=^-1EW$~%#0fz$lrBdzzhO$3}WC5Z^ArCzsnhai0DL(o!{ zP`?LNs|F<*5h3`q1fVVQy9Dr?(V|4%R}a!SmcOd1(m^6>(k7 zVQXnm;>3@Am|L$Y2AaP8cKUvw53(L3Y|T&)Kdnl`365GK%%fpygXfzGz#HD&94Kk6 z7S2WKg3J|G2C6{AE_*i)O?ZNlp(z9rbjr4qatn%V{QDq{bek%L*Po|(Tps*Q#)Xc- zgs9S4P{b6fR2FkM=O+HqNM~@c{NnE59!@42jn}~dp@s-$VUe7oKY63P*|5F3Dr7!4 zQR%$3?^lHX$b469j(A|PO(IR=eRU4LK%};6(h|f)>)(X!tIV^1L(kO)P#>(Wh@zCM z>J%qaWziv;Gz5w9F9o}vk_}MtsmoGeTz4$+HEaJQy%Z*I8w=q%WdgF)yv63^HoJEV zUS~q3n*&S*3vg&hR^czgZ^UGb5);r^=zrAtUL1kmK6)sb|4s{~eZz&Tia|FKig+A%Qxf1&@{Fce6$=r*QH{9JB8-qYnlZfSLHc!sSax{p1JjOQc0hTHk~0XTTA?z;=DQ zsx|$0+PA%rYp)16DY({>@7-MHL5qf0>e?la-j=9sr9p8mRVa>@p{VQ*S`q;?dt z;EXrbqW+*PO}XI=D(A3*Li4ZH3eZk8vH_Isey+V1VO$DKFwZtyoz-qk6Gp;_I150& ziubP4`%+~f(m4DT#fZ?0g#!o2Nu3C;Ihd^(MeaJ`u@w;A2a>m>Jg$ufb&L?SH1VIm zC_rhMJh>`LZAQ0WJ5HTKFLVVX$3luo7xZ-wQfB`yStDhbLaH1V!N_8RyxeC2vxYHI zOcUk6-Iap_=Wn1loBdD?^jlz{{y^P6b5}!}5+n&#+J!ic~&G`a5K{aq1MQ!wUS!J&^1&IdX zqg4trRQKthR#)O5N?NdR9))mPp&!Eq+V&&^B#g!(66sIQqW7zRK_2U8P3T>w%%>Nk z!@^<_YgR^Q_#q)d?58-7za^s z#glKmY+Xy1-wa>=?bJN|dY)aZ+z<^1c`vAzfiH85%k4Azom$FPl)F{v6$RS>r_EFW z;txc=3IZ9#ro|A%+o;kBJIN4x*%?cqe;Eowh<#qgA@*@0xZ)O=#pRf1QiK8;9Y~ z`;^H2$L7G13&}|5m`^3~EIf114$c9uDW!f*G5mmcN$w29lW=cfOMN?=ap2q~-iHNO z?{n4ImN`GRV@i=zUfE##LM?{`9d_CAQs3V!yNj|;2e%BfY3t-N#Jo;A`J&-0s(pk^ zrzQo&NsTAPae+}A4}Ej#pbjnY1Z5OR6EP54E*uzO)2gn+bO_kzPv-59&?d+h!mK!z z9`gWFqz5{D9}9_E*1ndN?KRxsvO0U5Tp9cL72Qqay>D}a_!h6|V&E;$jxIoAP#C~a zE^md`xN;8s#}O4z+>-C#c?r_=4YJ2R#$4kQZS1_Voykz?AXOndSSvnva;KnZ`qg+4 zOQiSY^X!$=L7(9P6hOPrsa6)p~tIT21A$) zs3!e33!)w2H$J5<&i=|TZB}z2;Xf8a(q5)Yrjr>2I_VhG%v;B+S3MpK9zy8@=~QpO zbh;bUQb-hvB-%jCDy=WdSC`jJJ|v0`0ZNXyVZ4a5MC2};`xVGMYZOq<>fOR2)>Moj z)r7JYm>haY2B$D75;R)>j7w_wZ_V`}aiWmAwxtXW62?WT9q?g0j(fh0h`Mo25EooQ zX~TsMQ5gD8xmoa6Aea*0eymFu!Z!yE4|@jts!_Wj1dZwVzYebFs_y5Q0&c@SW=jv% zlz4B(y<*BF+oz~*6%4ytvcLS&Sgq!-BLX4*6${LmHAe}ZS!YkCfLI1J3DFzB{VU)k zfVTFJkEft4@H~=I;w4Jw=Wy^4KMcxxlx%VRk>3xtJSl^buUv`?I3CFW2Q}cU@h=Nl zfACg$P0#V%C8@CEZG$F>)kapN=$SoInxtiI7lII-PY?JM@-BI~%Bn zj5Tg%F7e(RvZTu#eNCBk2=xdqhk=1VMN%?_RQ`_FHXKK%#WNaW`xL%%->L8+XnM^< z#05&x-J{{&?RGD9-~Sr;6a@!F;>4mT8SYmfCz$sQbaPWDfHMrg^siu89wr2l^ow&P z`-5wrG>Qdzcgb4}t?y>_57d^;0iynO^Er0A5~1nCEagWYfUim?CMVzH#SCBBfA;={ z$zw-4{^Ts{k(|{*R#kwN)WjpW{{bcQcxK?|g5B3Eu69w&ZG*Hy zaY289VUYX`Tk(Bfx0X$6RVA`+xA)#pdC3;9UKODV_q< zpACN!xrKOp3PXxXSij7flD8B}XZ~s(u9!OZM;&`TnTEKZp@snv1 zYmB)SDo!C=5R}Scj0kjv?iyhKYw@GrpA64t3Q*n0h;zU7BQZmd+Qq6P?+GTukLz1= zNI6vDcc>)_3bRe+1*uQpJtny;RR?ou3xbBzuaY8P`s)aLAC;$H%rdjEKsCk8^YL6I zYt2!5ejJl5CGyC#N2k}>Be(PB{JXxyXW5`}`qT7dAz4UGJeZ%TsjK^og~VkF5${Mi z1_7oH#NeVc?1+Q()s*`g6O=z-;T?cge)O1LLfFP!GEz4ZM=_SajDiH(Z>tIS z?(erWs8%gyAm6U8E-8qG(6cFxq%b2k$Q=Dwj&V%KbG{?g3U@&Xn!N2uZJ&aSIx8r| zj+pE0qBEM3S2RK~f%Ae8I)c&8LaeGG7OhsE20bu8vn5_mG;?Xs1zfyW>=^T4e&e1v zfjBp5*fgm5UvwmZwB#8mw}dbM(S8c$lU~&#Z#+an_MYc4*Fbao03_0D<0NqB`-NhF zTLTai?>g$s1>r&o3yVm${;N_mJ)#)YQlZ-F*Nyx*ssKe$wL+>oU_lUBI5qqya}nl+}^&5|~saOTz0msDPYfhZHf$)~b!UN%Iw^N+yO&3pLBD4iYo}j^*Q+$wT7-QYcWkSyw=v zM6+2NZ0wO((;*mf_J1`OK)cTc-Bei-P=`AFb!u2f_j)N=FG$_o?BIj9v(HHd@+09a8l<8=U^9o^3Gb-f%;lSSZRF z0D+Bsq$6kfJ8q?y1VNQgT<8_sM zQXZ_eBwyE;o!=r^!Dx_@Xu7?e?T<4v)FUtqQ%E+%`}+?}9ad&D0H&j^9Rs2U9G%(; zmwh<6rMH>im5VB;233L)x*yZB`J&)eB)yQ8o4e?#t%Qv@jBNkfke8DU7nA3Z*Eh|b zE~j7BG2bMnPb|!fU|Qz2jWqp2T|J+2ccFU;Wb=>#WT&8XQ&0co5>w2E8?--+BD*`? zB+vxLEvS(G{}q8jr!WdM7gZ{-?=j4JUc~&llQX1Z^s)@yVBtMSGA%kK7WGXdGpzZu zMIm;Vn8!Eh_&cN>n{x8{AS#L-aa+_>8X8?;MtpK6ZE!TS3LP~AETHbj!Cv6oY#9 zufI>Zm+B~y7n$jfy(b|x!B8T$KYX>M2<2S<&lIM&c~!WCBe0!B2N@q*Fb=Er{pqVe z9C_i>Q~k^zyW`cxMtL;_h0BT&kD>?O$8P7XbUxx8SZ14mu&C(I|8(3g@B+PG93?)A zY~ll`-a*Do%YWAel$4n(-$P&oVT*(AJ`@U*;OoDgHQ%?gQsFd%u`z|5!EsQ`F@kpd z6=0m)!}zY3U=}Zw6r-O}qzwmmU^%uWOSBjHfs`K9rWNwS)z{wj=|qF68Co3j6?X5U zheS7`IxNapLG9(VsCwos=bsH1*?U>vJCxj+2iRrIbDELeE|{>29buEX+b*uy_<|1s zhOn-?EvRvS+89$w~i~|^Duf@lHsofKXTdRpdTm^67&UxXw!XDH!j2~{Oc?< z_{Q9OSI^%8Y8S{czJqM|{%Pjm3(7T73B^dLZsQwUFqN5<@QMjEGcqO>dS8NBT1jbf zQGD@H&pfRHI)=wupbwM;`sE7Qdy0yRSK;;e{uG^R z({60ocx&#zCJ$g$xU_kLzuQ6gS5C}kGy?6voaIOsP_V(wU!IIZd4rP)DyO@rhIicE6f`VyWnQ$YW+Y}hk2sX>Uj4-EE zn(}76uinrI2Pebl?PMT*}g@ic5*6FiR12|^7p9PJKi7R-?{)j$gHjS)-w?%iNLP@V<@seN$)Hm~p)EKN z?Q$4`ifk(&xbkzJ-#cZ5;!xu^4AwcMS+Da8@R4hLdaenz>>8otcOvB>RdQ zZamqIl|%#lFjj4wU>ZOtsV$g~4+Ur!-NhinFi^CKQdUbI$1gIQN?jueUhKq)^E~?h zw-b7SKKmxn5^6fW)cjbT%tNcp=0g!Lkzj)qmL|l#b$fzpgsl?u*~08+P=Su1RcsIV z_g3<{%-ruB(ubBsssh`b4?4fezS6y~&Ln6ZU}Ix@SC0xwEbg2jbHmEY%AP>YG&8)zYX%h}a7x4l9 zbk(@~LlyQ@k5;&r2hX8Hok(6?Vsax9-POZZlPjA8mydmm$3olm;@pt+r7JW7L$hI4 z%i%%@o)=TyEK5!6++1OYskOhD%=#(J^ym9zg8tJ!;{exyyT(-ib*1Uh-+e)oo14B2 zO4_Zz)yYDgfwxY?^2A+@m$Hg>OOb*d5V*?|NQaT!`VGcwChK;#yn&FegVFb?_a|Z% zTUYaD`*p?x9z=CcO2;SQp8L4x#NEG@!gEQW!BFlldZT<=@QK1!5*TI!tIN?8k7Ag1 zPM?uq=q;P~6(PMpt+ZL58cpm7e7nnhel(krFi_#B3e4Sbo}OCSG=k*~+9rtv?cCF| zH_u%9P~f(ICTG(`#r) zcuHS=pa1wm9|P&V&_jC@XM&dZt(!MX`Qb&iy!;4ymiM0~UAvAJaX&^~#cGFKL>Xa@ z)pU$riGmc&>rl5}E&WG~j-qZgf~%j{N3t%@JndvRfX1(X>r@*q9N5z}$_NIYD%EXT zPf)7b*Y?R%l7WEfGKAjM|9i87*V|PJm>o0EBUR{xFJ8z1*R`-|Zf`MpTa z1L~SxIgf*J%#sPfD}}Zn144fDd8H`^d0ZN!-xV}d3$F^n>xDsrkDjNMpXg-tpm=$@ zLh)LBmj$yu=#@sUOB-f-Ow$8cL`$*}40!hJStDw2&93r6JwZZxpJqG_D9d$VZ35ZIy03MRKms$ACYSCJ_QMhAb;@9yC{+)Af}t=k1eij%_(Yi`lPAXr@w#xCV;* zA8?ELce3zDi}a~U%CBZqKVFN;oLyWDr0AAL}57RK*h@ z7+?UBpWn*lhyD5#=)=gx_PUYVoI_X3EC^IypY&pW0tU1qw&}BY(hbNiAbvhga?U^#$t@m3`XJrKo&wD%|hM;GEk(euJaD{74ot~!Ek-A%x zMdjV~_1)L0g)KXz!t-6HZ>bXP{sIrP1yZlDnBM_VazeGsab9wEz_5UjX6uV;} zJ5jrJa`p(Yv@ipxh{ws>@W3tY;jjwm)C7jE%qlbDCtY{fDm#c~!(e+AXY^;WC$moC zrKQfc5)Re8owjxyHNexf-O8gaM{+5oxfQVi~8ce%$b*+d*g}Ku$f7sBZDi{X9Qr?@Y;VloQlwhSf9WC>~fRRFce^i4B zaLDBE<$lz7YMq@^SXVzNX%+b=l_}+R+A7G@QjMSuB8JVq+L51;ECLwC=3s1Vcp5el zceNcv{k&*?)}3i}$jC?l&&K}Ge>Ie8zQxkm^_xTtz7*W^U-zg+m|i7a2EudnmM;ZY zQ?ig;xN|x0O*7Z)`Twr^&eqQ)kVl$1F`mseDzfBRU~p|_L$t*}46d8~ZVC^@L?5o3 z!yT!PX{V!jsrE4uLp~q-uEFGtyC6T^`)K|KRId%C!e={U3z`c6=|?Qnyx>iB5)2sI z6&>-BhbHqRfrc2=j}o7_vy7AlTI+mWM!DhRqdQu(>0qyP91IZRlHvSyZ3-ePAHCd@ zMjYeQsA3MEqHjy+N+yB z^iWEAm3|aN_w|#L4N-9mldgKnVG+Nfm(D&U9HOJx{&tsbBdHXY(u-|Ii;X(++eqo@ zET7~^fAlEYQS&~SZ4k8|EQB@}BPb8FLCtGxWr9}h=LeqLVXe#O`lrVfvW{4O5`%rB<{80 zcE4XubGw@cgH-cDFu&fo-M!99Mg4w?baJsb*_0i$C@_YKx#WLZ38Q`(H2idNC>AEa zPi=VlpdPT!Auj`2B6j2gUke`gA{o4Ct=oWBeQS_OpQP1X`Z7PsV0%?$usu$jnCml5 z8>Awe(ZLFGhalmjn{~}>_VfE-gFsIdM)4pqe~I`dDx&(KPALjKO;gZ`A%o9!$d~w6 zNOj47mRipwQt=`6iOo~rc7Y4Hw3y|&`YK+<44SOrl8F`Izv=Uimn~l4osOaHl6HowAb2a&vrntc&AWXk zKuyL^69kvdG8V|*p;_}fgas-!ThsgtIo~}>)QicF3zD7gm%h?6K|3N-^pXiUz}QL3 z7;$trw;Y*GHXIvwC!R#C8fH0vMzTX5bWIssrxB%itM7#>NWhoamG?x4P%*j!SaN(= zJoY@#rfo8Hq?97Z*Yu1mx6{5g#6dUL8rhLMe+1pGC=pD>Xg7cxR-1+9&+luBpNRRFkj-neTm6JN97Y(ZBKj$~H^x z5<68s$fjJK1lQUrZZD_NW9NqQ zpYPZiT!Ee;n3@hKOC?GrZM7(U@94RRa}$LZSe}JsK-acsI0dkRb}qmM@2=it35sWM zic)EfbtL8j9IqDsCG=SF$^pkcgaV>3Z4c4Uf3J1> zTZJu;$h3*UpV)TL1u5nS=OnB%?}v|i8g$iW00hcTDu^BaGF(q1@RQ*?7??U^$f)O! zmpvvtJ;GzpzpifYA-1#>p`hfh-X=RdSNvqHw?U`VAQ$< zE~QeJ%7>XJ`YJ}_22lv#+TuU9*3PUJ1pX`C^~$TDbpGzuFDE_SDzh*jqK>gM zrV*H6IFV6=Fj23)>Q3etTG;IE>tKHB)n^5s)?eD=`3@=R_I~?rr(t8ckLUW1d8|w} zXx^f{qM96*aOeP|zsCLjwU2*4xSe+5wI%ux(xZL0ic=Q=qBCo^KQDYXsHCOkoTfKO zXugppgZo{RC}b^v$KMj6*xORH-aoD#_!XoyfCEbcOg;Pf_5?{;8J*+fBC1n;Ymg$@ z;9$O@7Jwza)-vZmMo$=4`YrCd^rG+JsikAcC^lXXRADRq`pDN8$-#nG*FzMM1Z+zg zBuA~4|J1>M2w38b)V-}1=0p24>Hx%}s^y?MMRIIr>rjK`^qXhhvC69uK5nFI#q$@P zo1UQfHQSWpleB_rES%(F2R_w3QRRJ_zC`o!XJpbcH-<}WN**2of=3~fV{b^a(Q0$xLygsVsqVQot9loQJNX!vl-e;qtw}=JYztuM*4Ku2k?p+AvER_ zg6}M4O$mzI^bU1}!MFrQ{~YYd11bgOvcK{sM8_)o-51g8tp)7w%J5eHOmBUtBykQ& zeSGEhhD$IjAeb-o`}3;UJ2HH1Vp<2Lz^*^RrxreO=wq((=XQ|2*zuryMLrd_G~__p z$fEX>U^@!$ZKa4l(Jqkj9ul8~S8>{yhO zh)WOzDbe?$OG9~yLShird5`Uql5u--=ky1l|Yc`hOimo~hbh@&W z8B53Dk1f_0CTatf#gh)2jrnvJuk#<>#CMx9Fc8c@wpg=-}TEC)HEyRN^ zfE-gYAP~mQOYkU0>~{~f(=FwF5D0DU&Sl5suu>;N z^Q7x;!DyP+Is3txsD{*v>WXt@(JzfDxyAeR3T{RTn?HMi%3Iv%U~uO{ud`xmmF@PJ zXwb05r+CJaYmuLvrj@iBpe{DUB4Mf6{8wmZCQ`{(!M=9)(bWI+-GdIlwj4%kUsVs|6@KX$Cc!*#Y_OIO{5MZ3x~PmvPC zJ8)zuA^`BoWLyGBS|na8TO}tQ%#P>`63U!0dxkd6kFw<@(zwQFz!OJS*mPdwCV~w2 zT2hN3C>dWt+fnr6?^#o)aYHYdTjcM>m)D7k=4IGhEi=8gHX?%+S9ifXn&R>{IIU#X zE5nW@thAgw`IxUtjqtR0o>Gb#Q%~8Ljz16?=>z?pw|np$GazOP80vLd?TVcs%gqxN*vVC=Rq*} z?MnD+>PC~e336pPlTW2W_iM4z&I-Ozp6310ZbmfT2kSu9nI{S~$sAZg^ajDs?)ti5 zaV=3|aw7}Bm0c5n+Vwqpgj4;2FqW_RX1|%}y?hOv0iZaIbveGP%FwXCE~Ju(&DRMH zg&hyrnK-x#fJIGL?K4{Nu)dj&keK(Z*rXuGrl@f-QRB@$#KZewcJ1sj$n2NBeRx(P zBhEGl=o1z1<3Kv>zM0myU$6Mq?9qw&xTlfog+?~W!w*Z_sqaolY%s5zG4{;guUay> zuvn{<*TWHGckcZHm4c+zh+5wBHE=btz=o=}ECNL{ngVX>iMYS^nGRKIv|uKVRJ?vI zjud@K$}I9#3!7vn*+e|n|BL!?iPYqULxDHjfBY@}y`Fxz{q_jIB`T3fuKr@<=cUJ` zjyL^agP)0UWdYppW-}h$v|a2V4aYCL)^fc%j0~C71;8c)6Fzcs!}>N7g3X?SKjA@C zQI=ufJyg}7gis72I9Yny_$Dd}omqELcXB~Vy56&`hZY4D}j`7X0b zNLl4;t1(#++sWO@r)qH$$_ZHAs^AnP_XwU-p`v=VS77pU2W}pkuzi;SlT;?(!4&AP_GWv5<5r(Xvj%VK-%T2+Jbm~{|3)F2?O!N-CC}ZLW z*16Jw0M0W9LLr&kpPw}%S5YHi=K2HwocPJvt>O+Z_$U%ZV2gD+dT7Q6DPt3NFP%5e z?N$cw*Aa4&Ap)%IYhjj&A>7B+n@uk*EUp-*LCs4F792jjejjqxg}#`$H$t>`j$ ziHSWVgXjjEr2iKC1w!+YC?w;4p3nyDT!Y7*fld!S+WlgYcu^O+TRMW)kwGJ4-kIJN zP$M}H3$Hfx;gl`Dq)J(le?%R3qJ^vaqOnntzeD@)`nD6de%xLj40sDICO|n~lol#{ zGkOQFy{jL=y6jeUawW?|ED{L>w}68)!zSr5Ehk*`2RSlm$AhT>DR3eTDW)nd{!Ggl z(lZua4SVjgr(1~FaA4#FC{e5PlNMZNqE@qhH3!-LqY=nIBHpDu=lB&Z6KJxkWWHlb zr@Eo?`Ie>MjH*y=lIf1|#ycu+e%2PSfg#7Kz>^obMr9h(_>q(|@v zX*HmocXE*_6MuZ(?vJ}8N#WLy?>hv+4uJlgx6K0I67SBw_0a28BqeI+azlTC2z7Z$ z$q#LK!_8~CoHS)i$ZnjyNEj9ay!0@|)OvL&O(gxZ5L$03yMCOwo-qP6tf>aoGb%96 zgm%vb#9r$E1jIhrqXU{b<3Vjy|uTkiz!8U3AT zK`<<&Pvs{>CNiP#a>&u=e>Q71( zXd+YH)zV-F{uv{yr*+7JSnTy-+yjzpVEC?{=9#a9-YG`n)8gA<)Y;4SxRIe?%X2fiW$ z_`brcVV{4BsvN1`V=LD1W?`v_eH6o9QHuMK}y#}c$yr}g_(EWBaGp11~1nFZGig(oLVnSXl^`64d z)d?C6d;fM18#qkv6hf=GycB45J%3aqBs)UT%3asHP>m>*Mbe8pp3HX-q_5cSRisvF z(kbvU^h?0*D6%cG5!J5|Zon>$K-H6G10J@UIa{eM4uJyPO$H%;K(i}GWzSj zk2Mv9xEqBpht?g_G-2<|TOO+#5s%xw_Q1q@ZLc_Q=H)siUpfRzEPdG4`53dxDl0eX z31QxDyj|ak(1p3MmATz2L@o^}i&k*rW-SeI!ZT<2*^Hw(ZiDlk;9&0rq@|l@{CF8B zq$#u<*cbK`bW{Z&3akvQWh-?-f9u4BmOQkakLgZ5t)L<1&L$niANzSgElye)rJs2)m)=VKsCKSPrF~ybfe@2|ENAxaRnxf9 zJ&j55p8)odf2~DUkpo(>;H+Sq2Pxc%OE0{u(o{ylF3#>|QJH1=IMW#0*;&x=`da_D zZ{I53)K-;dTb}jQsFgokjD8e*te_K80mEfp!O}#q#j@W_R5A|*6IuK#Uf6bbp9wWe zw^(fuz6$Ejs#61n1VpyDS;R5b*7^=m)ylSk_fZkZ`q^Xvio(+h)Q(+zlLoO!E z>(6gLz+~ zHns2chobC=hWR#C%fwdz_uyMOn7yH^`tNNl-CzlNsKDUPsj^4@%e$|=$KUj-eB|UA zx6JRksTSk{AvRT$-2+8O23~TSAvcgEDwCgzec8+w zaE3JI$&-Y24ee-MU0u{8)CA5%i_G_Q+yX+aCIVR!7R1jWlL<3U((D29umRhPb=?xO!zZ&?)H_MgzL-|7(QFxwM3kLH0$AD zp2Zy}q2nMVfzbP0hHyCt0jAH_BjQM9Z#l(K!a}5h6C$(!BQ<&czuP=2S8=-ZeH7i> zUxbO7d8`<2999MLY|7$Eu!wlhHQ@%`Nl3B9z$hDDsu^3aow?_XS#wM$esZu)?fP5B<)9tmz7bpWMEO zhen!iTBf*WBcv!v?t>bVC3R2he2mA^37RXA$SvYVkI{(NCxU_c;CNy2f!}FO;Q6MTxKdo1AKIKE)*)Q&edPTQ#z)yHn zb@6AmjlmU77cjA6LF-GQMF1Etus`e=Si>>^cDVs#P#yp0c#*TA)=GP_(RZY zAUui8S4!^;>qyDO?hzwlI3A)mWV^ zoQ~3!;eMQMeh^@o!pek0p9QRL&=l9o`FPhb%6T?csLQ=t{w5^kaHv)E()ExetOVFb z_~ix`srTpRG=viJdF;jO+wgxxtWJo%kv?i_`gY`G{aYWO*#tZI^m+s^Q)A9y#Km^N zAEDM|`#bT1XAOu5sck4?ojD9UONlC}Mw};_?7^J#bXH$C6`O`TDt6+8#A>dpyx-dB zV>@-GA3L>zmi~rs986l++x{ubcEwx0yNcU66xkp+`IQe1g& zHPx^_AsDa7@mxcqdAb#9IXyuqz|Xq#aqLdqqAr13?&il;v-U|m&*3C8BQC8AzCiK- z{<-(ZWD?DH^@h292;tFk;i5XZR%#2`1JyY`+JrIz<0^GRnR!8OMtzqH)2XAkmLCV>v{Q1`Son@-Wi(9(xviG(G zNTuFHArNgW!JBlG8v|lz&Zdph3zCqa0BI=h!6fL)W#Ek{LW(V~aO99GN;nvJ(_#Hg z!!J33d%sJ~ey8x+w}&U-$W9_L6^QEHo zQ)7%YkR1nO?Di~5hB`?`(HQ=d}GU%P)VJxx>> zwUe2S0+e?~lKZjjYP(8)0ywDF`0%0bt7NidUpzgXzCVdbE4{jv5n5SOvy|R#v#n-s z(F!k3{+kn5OgwkkvxlV%ZcFH$qy9WAaCh4-`QXY4h*YD>tP5(5P3;h6lQyV-ITrxy zynUvKiq!Mn`{l;)!j(9lGm~dzwC2Urf0w3yR<<|R{V0bqwopEJ+21_C7Skq^WtAcT z9NS~)g!5Y&^)QgSa1HL{$A*Ec8v;6GDc-xU3D*hBn$06Y%wCk-3#IHJk@Dt+mUcj* z;x)Szgyq1E2QbZFaSH6ApilpY%jGsSfJT`oi!0Gqc|R5{F%)Prs8$2n z8gL*C9Vu}zFa+ZL=C1OLD)U=MyH$WHAIULae2|NWh3!Wn?I?b!IgrUoWz>%4wdp`7 z5)-i5OQ` zl?_fxF0=1@RCu6w`Ot~Jx6V0E$_@w`R~ z{3`{2AzpKNI;xv3tRpC-Xva=iH2V(oN2kUOp-f>DR72UbRywe~KX$nkdMZG)AAq0= z-(RoOQ(zdM!iCc9$oW-dQ>pguch~S$?ZbIAe0({5$ zoc+&mp%zaGJZ~lDb58A(H#Rov8@o$Uw7o(!+Wa_ORI^O38{MnlsTnBNcVkO;2NdjDJMLcq8fp6rD zF4BJ%R%UBHRxN6-yV2shbU!ZsB z%MC3Pg3SgMiFrEIwtO8bB6ObOTiF;10PEnibx>azoq4w1uY`ak@RNJ|Fjt(ijA7;6 zN8D>EWmV?sE9wkI6nvZ2Iesy0fi-It#cB`5@JkU!2A^Q#eSr)+D8Fp|_xwAjw*>Q- z=d*7U8#-`cHCb_X4|NLQ_Ix@4@&v6dJ2jp!ciV6!Jbw#X4E+2831T)MUZ%VOcHdqeo{wXk@ZY{~nbTxe^d<1pn>}xd(70sR!i_JfRlQkFLFfb@-)JUJP2+*xD zvAvr_b`9y$a_=fTUKb%qArJ|}#IE7r! zfZts7E>x`{eOPr)p>}@o{N|6_U+OJgw}~U2z*N0}G_N0L&K7`LZMFhpRO-`f_w9D& z2(_|)eZo8*!-SR*m07-jbECu?K$Vj?hT7Ohtw<50Zwk=-kZ`t7_yUZ~#SES8&)-Jy zItj3k;p#Rvdh$qhj}?jpBWMKN@VGOt?z|hR_PoY&&Tf$JM=0)$y!2pM?V$Kmh0l@r zw-i);EmKYX=#Ff7ZjZwsHa zFMaUVAuX_yY#0TXy?Eb@7Z(+l-O}^cbeVkw(|SJD#?nf8XO@@y6VZp_JYzP-gH!5| zgVRE_&QERQ z-t=y7=1MUPH*2+2q0yxYJV~h6y*i^4QTdCoQy^>&WP|MULsR$$;#BUy_hVS{nZ?)R z*V8cqMfEVN4i$!+<^iF4{!b}pmo3|5$c-bI^01$MH63L|*$Ak`=mvdvj9-Q+(SjynfpwZ3(Am;w)zh|t#}bqZpe zi(73jL<7g9Lb!rIFH8#<@g*>hAZ2^T-keG0P7veH`v&&q&GYP5v5A!*ftf3AcUd{- z0)F)AfJ_}t$$-ces1{5VgTAS;TyGjQ>9=zmWrZx0iABo2uB;tcW5u8HdB&CjNzwra z!Ne$Kby^ifQ+GhtUg%2aDPaY*=<+csI|AK{B@nTtlHaYR=7eWnSgcE(YZ4Zf|4W?+WkX}h2`sh%?K#IT+Eda7FrlV1+yx82$#x;b zNjChD{^4R{SLsNe(U&0<%}R6oIN$R3N(b!J;Zw02A&;k9w-T{Cl!;j94+!?(dyyQ7 ztgT_l|LHq#B6E=6NIV7L|5^!%i~5L(y949a&sCLhBhDK{_0AklyQYhHKL_B5UKMX# zH-}@UAk*x2xhmmlR06fL$fbtx!9TM69yJR?WjYf~F}px?8rV_*cea4oYhaqhO;^M2 z+PTDC4Pv+tdw*J&HpNLPtD-}fiRy`LFSQ=({eB9ie<4t>HPdnXs0;BMj3EWY;Ua00 zO_GW03(eAjit!cuXK}{l^%|fom92r?YM51(_cn^^9O?(DLvSi~ISIRbz=tSm)Tc|d zmzs5!Fc_mu@J{*Q=}DNKN*R%EPP9$L-7Q+T-sht8_Q@^xs@Nge7fi=q!jUHbscnOP zcF6tU?h!1MWB|OUlJ6?VQH{cx3|G*ih%cn`MP6m6a#ozOhCKl83-C{$H=f2i*u#C9 zl@27@W}HBs(%tqF@12$qp5-Rdq*;iD`Wj=o7X_|jqSKN0@kl~@z5a`r;%yx`kA2!7M<$GSUl#pFgPG2<;;#HLh<58-IuWOM|Y7d0IzQPBF#4m+z1Wik0stz_gF{VMO&NN>(Z!`=Y7Tw$f<(D2hS@gBX9~U8KnQhs=WS7L-I8Ail-$$ zwc$+DFEsYO?71bvoZ{wv)1;a0-2KXf)jmml=JEj*E_c51!KW=t!=F#D?exYgueF<% z+R{%)?K6LznVz z9$I#NV>a3B!i2_8=Y(T#Ts{l-Be+$zZj)Y^m}YL@N~b~v9JJ6}8(S~PzJhNZIiIK2 z&c;Rz`{!iZLk-Zsm>ebJZGGmGYE-tHFBNrNK`%octaj?&92sGDCgVAH5rYJS&I4o! zu~R*y)mbQ99*iBW5gje!M%8D!)lO{yOsk}%o$fpF8N$AMpH+?ntfqoRsbV zkd$3?a6P5emVu_g`78*!5r-Vq!scsNw>NE4o31x>`A9|#qzeWayjYOrH3;@_7bkx? z$8Qg65}kQC!KjeLjCriEqM|9-2IZNj-+m?7SS)oniR3A1(}h5=*-R?g0qSZfp=$w6%G_%6UF7 zH?>o#HI_`v9@Hg)~wZNm;d|Ii@js9 zC`y*@Yo!Tz(GTT>QTF;d&PZr@w*kxDhX4MxNykl2*$kZg<}a_*a&C&(2!23e#s~`$ zyN3_(slK*21_?D^>OfkJL0f@wSNt^XtYiUSe9Q$67BfAir7VC+oTp&A_ha4z%Qqm=gzv|NfDz&Py5)erzo;_9Wo zmZud6U!Rbxe7Oc>juWBVlDWrl8Jgx#v^r@63IWP5FQ>d zRwY#05!{7U%C=9uneH6}c>v_F%bajX_StCNB~dxgH+_S+o}uPg6lxfB9whM}r#wEz zy11kCmvO&dl#_izOuAFu(#*Htr`qTv{Z~kk8x{vWloR3l-^oZD`qql3lbEiKFZyzP z-tqQuV9=Q!HD2z(-yZ8*sgh0zJcAz*z9H40o-%z;_Ys1E5_+ntN_)Jb*M+Tal+FP) z^k`>j+gY73WN^=5B(&O) z$b}5Qkfs1~3gOAyJdtd98%a=>tR+F2n20dMt9+4ctS^?LaotCY%t6K27!P_1M(EX- z<35kxP8$IgINT9=#m8$Lhn&m0CeC1ErkMZb=J?7JZh3gX3`(gfxB3hzZ?>Hx`o7<0 ztf0GD4ZS8EulZq;fL+t%vzb=N;<*H@a$1L$-6dpU7FhkZrM%is@LjmfQqtpe#(P@p zgCf+N%I_b9g}1^WNzLjF=kY)QjkC(kVB?ZvmV+L?tOG{2KEPbLI^1B4p)h6B*mB?} zw~28x`qf}fKGk1N9dPM-x_Sn6w&v1{Wv>U|Jhn2Rqk&lbWeCb^WrX+&7`tL{q;H?7 zcn-c?Y6@e)8zy1*N)y8zx1db_0yr>zw+WnFDbc>^U->9Sqf`^d;%kH~OQ?0Nn7ZPQ z^Lfw{Is(kq9T?n>>4CE*!Rl8Fg1?g*7@9thZ*-%%sZNst;NWGjH z2JSgpV(vi?fyMF71nhHk8)8#fAA;28yiOz?0dfGSQfH4#UoT4Su7;8y2lk>1ouEM$FFieSP~@*Z`4RtE2E!O7f4w ze$)Qm8j(BO0D<{F{sBB7{a}^Pte(`bE>-$j;m%><3rpi>VyC~_S#47UY{*ogt+^nB zvK_#oI5`m7kv)BSpaxVQTk5$-SJmP83W%KHBmXgB4u=SNHqIaW7GBm{KFjD2R%6-Q zLw$iZGdgT7Y$3*$AmIezMmaR6<5-ZS(B(7?Xc6X)L>0!iML)4L0daEoceyeBOb72| zw0}=M1)2Y&D_LQb5x9g{9qM6mh0LSK_d>&d?F?kNYg{?Zz$;&=n}mf^R8VSa5`g8P zbn0wtlYuD_l-M*#`uqw~HX=Zm;;ryqsqthktu>9el+A8#Z?^&wSujD6QrLI8U-d1& zP|w<48ZJmXT<5on1pKLtP9tP#2w0&b_Dzwen|^?|(FhcX!4opiCA6Qzwqy^zH}xr* z6Z{g0u!Z9`z%#DY?YP`_yIiwB4nH@tFA)O5)Cl0y24~4y>rg6I_$2Rf8f7MnU+v!N z`@S$?{WWv)oh3EwUP7MTezjk+m0nfvwOtlL;4UnNIXyfbh3i`V4)qqS!kFZ?kT0>N zV9_WO_XZBW1bm_pJNjsaD4w}eszFO1+iO6T6ov@Zwcno-cqlH)`h4e>w4(I-vl}!F z1*59t1L1VFaX)XMcCFuc`Iv)zGcz-s5+L=J0^dGt7a7Vh4<|BJvNGwQ17haV`)~Qa z0bS$`#moy2pxG9-s$ymuy|_rGG?v0;LnVTaHZqa28hG7R!K}}UdB2t00{`)@HWdsW z0L;SKH#{U|=+U0dv;3LbNW8#Ou$!6Q;p>FXE9%E?XMC~Q^sUP*(hR^R&u^(zno zZvniABmr;_zc^TYI`O^_NA~IrM0tRzQSDz|nbM*VT{k-b^{3Ar^%dny`KPZE?bG5W zLR_O7Ib)=h+zZGqI7gtiP%bUl)4dlxY*+EH3i|3}vYS-WCo+KzD%jw#zLCC2(nz?oloi|>Cbgf_;9{o- zGJ17yo$!>eMcfhExJg$S7hwE{kfeNIWfsECP2x-Cz$BI!+K(Nytzwi|4g1Mi+^VR% zc;Dc~c~OYvu8hqz#x-^x6D#{FZU9d%wT(^To%S+F5RZWv4jJ%3-K2ai$K$$F zy4PLiyDOON|B&=-UTLIMOmwR#5aPdV>rr(QPGvgp1UG+Y26)l=E z+ME>cwxMtMJS|gKJ;({FEI>(z^N|c_LU);&M4lzMu1@yRf_DvE>>CenH|II-q7r=a zO`Tx4C$Wo_mGIyj^Y~lX4$HwQ0OXEuI4`P@}5|7ikJ!lZKzTrmL-|T`4E7Ri8y{Z z1xlbn%2vyyV1q?OG<5V~?R0P1C)5*!&TgizaGZtHY9z{ftYKIaBpKMuK-Z~5*cIKv zkPTvc&Ag4KlP*}z2hAxww#bIiBn`3jkq=UL1)Imf4cjyCG-PP;&rz2b;s)h*!SotK z^~h92ZPMfms}v{$AX?=43PLr|l4QhVO)(DK@%pSFep@Q>AILK;aUg@^2o^oO>aTIv zX0mb~N7FS}cVPW+%X-Z*V9db)gSif-tr>B^m^BGXTMp$C+N##_81#HzxeesbL`@|r zGrdrIq3@c1*P?tn9IY-T-(R17;;vgL=XISfjPzN%=-pQLv~q;Q9Tpf5^2ve*Uk+ZI zH%Dl}8&=@X*|EFm#hSW7G`mF!&j}h+U7!;GoA8){8;1-CpxE5@##6 zCDCc-S2T%>@C|I*W$CEEsR)=|tJHMVTD!{NR0RZS(?lAoc!QLL9byyTo2p}yASA80 zb7g=@?WCSD2-(1CS*Y61W^Q#^vnZOP`7Y!{zlXFlNb0|ShC!(>u(X*C+)+0RPi&uh z4vup^Y5^vJYHeB)0R_e8u_-a-$ z)H^(YnHE}c)2+wcLH^$Nzo>hzo1{O^XD4)*(8AB~2x4J|76mmIRw_yX3OvUaV5(W0y#;AsDRRYSN7I+waokf^$V55DZ4NrrdwpA*G zxw_41$PFok%8M+j6o!ZQ%Q?mKv;xizAsXCXGp%uVeox)0H&=y3u6Nr$f4|?sg4L>M zLY8gB^;1x9d1eoSPIqriGu3U7m88IC!$GyC1MmC$nt})Br=Cl^gu`sQHy)v zT#_v7X%w=8SsJ22AQFs;ED4RV1uY1;&quz5bX)eI{mO*Ur^gcLP@obM2qOWJq=2rx zfWggL&$#wfzkkq?g|*KRiud2f{=+Y9Y3%zX{ja&nZ^Me^tUt1sya8peLMbh{A%9)BW*3 zrj|J37T-nuc9lb_VL=HZPNSc3yo!br!5snE)#gRK8>9L?#~T%!UP&?3a(?rG#m zqny^Wv&;OE{ZpTP#&`Z|lbtMqy8h|L@tqzcYQ*bGo^93`YGe7M1CQ4`Vpj)R&LYeo+pl2DRra^*9PW zN^T4k*qhN`zV{}lpd)o64daSw1uLj1^?1z14)y?m(u@R;)*e46>Rm9xm`cd6Pr9?d zr&PzfhJ^_oJ_g%2c%d+YPbh4_hA^!cyfD2h)5PrmP=pXXFY$?U80idoKjIazAGSjf&Goa`ijp_G1=8`8ko#2Qs6*> zY$(JVZUbZ*CA4?9xrfOZ?7m4f(Mqft0e0|)U(V}S!+Zo8HBUys;h+l&zs5ZO8h;T8 zo})wc@-J)MfuIYpOygS27-a=SpII4zPO%oJug^+TK>JMy9ed|o37cLd;8_X`H^}0p zrh=Z6dYqY4=0?vA!35y7I{}!i%9yWmTmv-ZbV{$myR&Cfu`obE}fj>{sMK+csb{L5RlzQbe05bUpL@rTZrq98bgX zgc<#@PZlxBXc06uHa(A zr}V%BXm;--%E%`XrT8oz7=$Ck@Y7M*OY9;x+G3P*k@Y&gdR8R9S$AYhe57-ILg7<`z z*QGTD67~ggVQ%Q=8tD0-2+EJ@z4df3>7X(Jo?&|kFFIuM?rzouNXD-?5#~VSh5!0E z2n`l4g4Me8!o?YZ{adHw^I5^i5QUk>n2QRON)!XrW zfI3`i`}T^fJe;jKVDJ+O@@1@+`Nianf{YHQ{uA86d1#F187%J|uz16EP$AwmMkD8& zmY@L#7uWWA8SX#7-;t#M&Sd^H+8=rrGKH-yeovkKGj#_uvHPJYHf4eq?L#B|&XowL zY52=uy=(3v&3{&A*K@z2%s5eHC*hcVJh6gsqy_)PM-ZOLljYsb&U(tF|5}YjR@iAe zQcv=(E^`r#${;;Lt}56i(#fK^hQ&djJuAs`H7WlCSArr(U%wXe;z3p(2)Y^!9~^mw zKcK)an=$s{SA0aQf1H}Yp+j)pYjEcF?E9}jUH;CW-O1y0V^j8J!B+eH_;WV&!&h@y zV~SK0moTn9Qpv(-T;5XV#D~pC_uf*-K7O9^kzbd2!{^mInj+yoVpuV?Y0?R4KK86JQjGfwSWg1NQcf>r9^;*<*J-}KNZW9)GQ3f&r36{ zP)t_YhPU7}U>VPuHodtZlWKUkS{{ngaJ#Q2kl8cc%7($s zFAcwr@N1>cPW2szybfpb43B-Qd zNAN5bcn_ngIHP~Yjzjiw^x*1t3CTw#)+lW5Y(#(}E7R>M`D1UcBcBv>gFj28cYSsn z4%c)42wP??4#5bE;tT#4yJk1j3O5^ltDxzq=cGiC5BV#ys4HZdI}qFzd+TK=!8?+@ zQ>w9#0E}4C*?^6uzs&=hOwO98IBUh>{()#FN28}a$mHvUhEVBpRpAx_*rEy<64iMmDz}c5a$ORS)egeG)4cB)I!;*xb?9j(WH@T(|P0tINiG^QE%y zmW~9)KmR~$jlJcLv~j6n8ZNmLEO0rpd0h)ScX zWv7*4;UL@_OuW#06s+ce@NnJPS#Ys<61Fw&=y;7Vr-frVZzg-^&+ci})F4ecZ~c7X z_QiRn1Z-OKeQ=XPqrAcm4y&N_z?CSg-%{p$%7wglR;BN(o+Ud%aYVTwK`S$wE|UI~ zwwj#pBRrrn%;L|hFs0~(+aTD)bT^_-2>jX0^Y!mR-MX=L%{yzFl5RCCjeoV7!`Z#A z5OAazl>W>Zrt+kiyi)SOzR&v^BHkS@wN#WoeR^YFOOWV+k%set;Xv?Ds;zW>GUfq) z5^c8r!VEHp@-^C^RU3{ku9-q1ZU#UMV?IKp>_=gnGY2_doEt2>@ERgH27VK0w>b*V zUVC6LYN)W7|7=0Zr_z0xx|RR4!yD(xpxC1FxP!EcQ|=E~7Z%08^p)I7mhXB&FE0iD~+^O753Z8oT(nA&|+9w*pe2Yf+ z@Loss$7odikAq8-Y?|ymbfH|Bu{{%)N;h=i9pK9 z9*IynbhO}n_Kgp+32FBfvv{vFbre}v#szAGuF%WS_79|*a^L+g-8=(i{s0|_F?-t( z{_u>1VhZx`i!C7^$e{@fFXtGExLk_fxhU9P=>iKnoVc|=a z^u^nZEmfaT`|b8QmH^=BQ1=0|D3PNZMeyY3i;9YF?p{3;Vp#j@&!rHu&faJ~vsSlt z8e#i!IYUOezE9+ytwL3FOvK7}9+ z*R`4iPN1D2*`U3Mhab#>3#w}ReMkI^F3vXSH~rDZe^b=4iDa9(oSSRc8Ter!bo^Yz zl`qde*fu_Yak#i>`&oo5=QZ)vx`nU#CAz`js42EI@HM-LG^e=e@CJ>-+_8|eo znBZQhg&2xp5Q7x<0P+0GXvhPFy%If9i9Uyvo^U`}4;uI6e=dX2lLWVzWPR zk{iAR#z4Jxgv;i(EsNczK=xlRB}xF=1|=6r9SrFdVu^au8MuxQ_soJ zVCB6%L85xlX=}!dYE`-aeG!>KI~>X!h5)^mn3TiDn!wNm&@IxqrR)zo8S=^e?vK&| zEy`$a*LVk#&GCTEyH*mh2P3E5PK_>v+%0*)$XO1_$=KH~9XXfMhP$rP6k4i^O^_mR zNWJUnNeh-7m|eZ~8%esLh8i$20bFh1<{_6|8c(12?Nx?T3apSwj|Hy4r_P2S9`%3_ zPqp%~&kq#X4{g&AWQf za@sam5vI2E0ZB*#JzGTei1VaY=5G#;S&;o54f~gIg{plO=A2Ox<>caOOH1EF^4Wq$*au)NfdU$|UY_^(&My8>4}hbryeWj}g+8U8SFzHq5-Khvxwb&r_* z`^M1mB697%Dxq1ny#U730P6DVs7lw|pFWrW6Xm@FVct7gukof2%VORmA-FhD=UlRP zf$OFCVtw?%;=Mh7M~mw!tgDUY?ZmAO-shvv5vG1ee9#g#{?&9X{>g1?r8m!bHLop4 zvBUl*UZQKN7Im$6r}=ERV2!*aaPN0bW-hkw;*;pwto z)V{Mt@G$&WmkNV72`CJMi$sVfoXB59n9UnR8HLkxPdjK|x@@jPV}D2&aFEmgR<{MpM8&c9HE5E?(qe0&dXS zdH0A2%2YCHDXn_0a(rc3%CB6DIwn%+UKD2?IIOHz+SfR=TlB77hP@%TIQMGIv z7)&S+7V+}H1$T=L65Zw4TP6=58^t>l+?_q07OJfMiv}LRsyVSX=iXzy(FY1Vi zi5U`_sWNiz>O-bQ@K&UG7+pYTgsq~43 z1vl<>#Fn;JOC$d{iy0=`kB)!d+H6+@p-e~GiKFy4*7E7CSipMh4ZM9rFU-DJlG1Gi zdIG1KmWpF&$=J(`t}!8Wq|)6(2pUtfIZQ7r9}F9`zv!dFzz3Y%ovM9D zPYuRv=&8Q7Pk*i;wFV_;!c8R4{n|N7E0$%(np@F4;1lo&)`M}9EGiBg^Jll`m6WSJ zzP*FptkJTlZ%OU-e$OA7jHr}q!iDc*tr>Z#S3PxuTD!C*6aJ7GR!@e+IZQ51!lBav zdv*uMsbZJQ8~iBK-lt8R=Ng0tQUV1-**+tBIS{ElXYjH$+HNFB40D8gr3lgPkf{0?UT3lYe4JW9H~W2V#q} zVVt|S+_oJ5vA%Cx7VC0<7fzr|nP3~;`-gw1!I_hTqtY5)Vw~*QL?X&69V=Xc(HzJs z(%APg(uJK-z2of>>tWEuB_X#-kzX)Ry@pA)F4g~s&r}LB*eS{y{bFF}KN;KH3x+6& z1>Ty=|Hd0~<-(G1tQ6y~fA#5G@2e<$%!`-rEENs!x<0z)Z2)-u{GYcCjEt<1-HNfW zm3^%2Zc-h8C)=6eJRM?|?)xe|o(dk8$h)XPc_}F`q?VyUSwYvrtB3yabv1mDYd1`@ zh41_w2vJ^LhHH+*qXjSHS>;C239bv5&vkn(ai8v(TD;Ib@41PkpaO*8;&wbzMUi2o zdw?kr{yr&v(Xc0UmKjNN3`t+?@P+Q_HyUTAaPcw{hux;U%Tc1_pFG=y6`qxBQf2KL z#m^BJGx)B*-O+)FT~C_&C(a>o>hku4Ykr`laiP)8jE~idA#&rcS%Kre%}(`_!{(WV zfrpnBJ`pQxWy8b9n(BWxw6WoxCIZ1L@2%J(qX<#Mh8vCxA2wmqP^vN`sVq^WG+q%u>#*uxX&Q2&b&&)K`Ogirt!E41sTkS0>J?rkBqG`KYG@S~Gfq9`LV@%wcTC@$8g_Q^(H z^q46S`kD?=#Yp?@)ycR<7}in!@)cGip%A-}zGqQCoyq+p*HKLO4GP+R-!glt1lg&4 zC?JWF+_7w0y+unMOrQ*7=I$gaGuP;6){Ga&ktbwIePfv$@$E}mChNzirtq0z4g^wZ zht_NdZD8E@RGK zCAwskN&jG2sGN2Src2%1Eui{dEc9Vi2p!^eJb4R#usgUsn?J=^a#HI&Q-65JZm<+V5}?GRx2OZC z|JahX|Ht%0fVaTHL&KbhRT2}xX(q639~v!n>clzY6@DZzgJB%y$RNt}`ThEv&3w~C z@X~J~W!M=*fh$I}JC$T`@75T(#G=y{6>#o!Opp-Vqfy7TnN89?Sj4sr#5&F}{k2w= zWHUV@W{3FC-|D)50%IIRY3Qhc=eV zu77GLc?Pa4GNlcFsHwb1AQ^hdZBd~s^}OAK&KGhZ1fJz@2&L@HdOr`!I4KG?5VFFB zKA7t+9c1cF3@M6*8%0DE5Mi()%9^nXpKRm6(DGh68#o+5XSU#4h7OG7lb}8vYGLH& zCulKSX%ywUx2)W=wyr^vJfvNp@I);C>4aRHn|pUN?Fw5mow3wb`+<96)O<0va}z)V zHBS?C*h^XPFd^m+t(C5!x5>dc)i1wq?}BPVo#~xhLLu?D_%yPi6im4FBzJw+2wT3C zhd-R)WBZg=zZ}Egu;`orvhVE1U(=1KVAn#uOKTR;(g$C00#PJ{X!g?B6!d5z ztGRg-d-_kXWgCY+KO-kE{f@08gZ99W3g#xnf+Pu*n=I6Gsd6sLi0`R!yag2S@1c}y zS46}?n#i93tt%*+)V%UQjtG&?0uLYGbmx~c05j_cLsqotqeEA*-jTeAu9U<FZNOKQ-lEO&1%9y01#}jl>L*{$HgZ26;ZHCE zX?f1Z?vO~piC=>jmBzZh(#OY7YjXJ)37ccV8*$q=cY09$nvv#p!F2TYM4hqs&{9oz z_61^e#lHy&XV&k+e|9W{SH@6B*v7yyh!8bSebc zfP>EfHdS)GpE;NGxV-bIm_ZViH9>tm(IA7`je4nigbNxrB?1nLwBny_=WvrY4s!Gw zk{fRD9eHI^&R2dtdGw_b0k<5CTS}|Kr&aeZtq!jMjng~UywKUSGEO+FdZ@R&-$`NJ zFE6kxy`xQJWxShfC2N|?cXQ4Yy;)$&ZobABa&y;e7N+JbiB^6lsvh$_MI;*p#SCUq zLGw+hlhW;X6Xz`=*!v@$ral;4?-#QMa1L}0r}H5+fHCEs#r{NuFd?jf_gQw_{pB1M z@ziizPC}$EXfp>JL6nz5?*f^`XU~CC@eCuHM3>Xze-ee1-n`O2SWbL9pc2i>_m@1p zwS3mqgC`=^)!Nn76&J0GAHE%)5^;OWv%J8?W45N19gR%Lk(pY^j^G)D!>GRc4>Fnh zPfgYKeok9Bg8xV3(rICb>7tH@p0CNbu$|kQJ4N*Un)RVMMHT2OvF&t~1TzB`GQ*%z za%lcdi^gEiV~X?MTVl^F)pl_y?@;osx2}4%eSqY)-q`G*8G$yG3}dwK`{oI^vDC;R zNA;5^R#}b_S!32oOO#>`&H~dTX`xu)U=6HZk8YjRTzc8Y!I2!KO98*2N5!yOX2kxv zvCVrDXM!mvv98n0$YO1_{xvqklhO_~%u_R-+783*o)J#zfz5&6Hk$(b@Xw-3QjCVF z*E6u>QBP+0gWd%9qm6%tOfsUX6tPP&)tKVBn!#{Ktt49N?E9*H3w?V2RkyXK1GN){ zrGE=yDVL^oFJ(nFdtEWM5i*x8%$pz8ovLq_*OzW;J`fTia6cW!Pym7py1c)+NNJF0 z48A|Rb5=JLr-aFCy-yDoJc%TYsu5R@p)sKA8);(S{ES95oh;TOC(q zqziqDWR(|gax?WDtZPX{*ZmV|C6UxGnRh9rN)qC!jzZmsDK@=@ru?agYI_21sn_9g zB7ML;BeMv@ZQH}nZn~1sBI3$_eNmEW@aAtCVR-8ZD&QU~nhZ@e&Ri0mD17E{Fqh|^ zf}J)PRQp*xu?Xk7$N$u0kz?kQsUKQW(6{uym7hXIKh~2AZ$PPdlwhi0hhd0DxPVPh zE`Yugl4y`tZ+k@f4RoT-mFkB*O9Mqt+fHk3P7IIjPhf9c*L5+H=S!av!uOw@n>XUU!%7| zY~WVVis>wwXo4M+e)+L#r7LW0n{vt3hco0^Z|_TWDAFLsTlHN4<_1LjAnhRia~ddD zlo+q1jQ}o5=v#yfcRnRck8VY@!u6L#6mZj7M6B5r7m=p#9nsUIH|lQJ*rHGWq+-`KlAQO4^GDx3f`U+(c(h?h zrV>Iff`R?6&&@kGYo?lg`*IIAig^kvdne{q)6N}qE;jwgU}?g=!o+nq!|suf0~Z3l z;fYT@+Q+qe9m4k%Kb4iO(hjnV1gtdH zTZcyrT(@s92Mg~KkO!z~F;bimej zNF$)iXXg3h=8qRI_I|C^@zZ9J4)ZB;1x(JBuQ2ep3QR#>{Wh?48=w31lpGjZvOH(XE6Cwoo|7R`~PrJAwkK)cJgCeiK)CTd7%U*Si zRfhd&l7s5&n==OW1ekrGUx_H$>`!(YxUhK-<-NPiP%dwd!E8&9;qJB9x9)B0N!CPp zwywA-`1?-ULxn##tBfx&{0_JQba z=he2pj$)_BF?ox2tB&coDv=HfZtgYoZPD!H{zmL@ixhWZ3q~o!%az`axu}CbA?0`( zFTgq{l@-#`nv^y?1v!D_@(<{HBf5^OsBBxIq()rD97xl}x=U zvg~a$jFL*L-x_)u9;od~)LADKb~q`N7YqkwaZ(qH3iW4OoNB&G$<&OM(Yo7$dY2}$ zgg0ly$%@s~39q*NfrQe0%GNmEuQJcBFw9X*Uj!~P*`7LMIrxJiVw*0G@u#l6H8H!B zq<>cBc;(E$06Ovi#FPY^!5=>P!K_AV^b4>97c?fMTm<(eDA36R`#>5G(QUW7Skm={ zqke%Tlh=1dP!Hr-jY7gb8#n&RyuU0#L3Uoo>nB1Va416HU$C=4hWNT_c`nbP=?8Zg z#Y|(~dTxW&x)mJGv%5&;v;&VZkfg>Hq^gBQ7-BpE)XevF`4JrjMh z;os39LKYK>V81WLrmS9*7Y;W|=!B?f>m(yxe5+_4tCF{N^4Jzhp-H*15jM9Q?Xt_x z7U>>RX`U~~mc5pVL%j)&-PFK=pAEFCdgpc-D7i8xWCkeB6c@5yMmVXw%*i2k%yS6S zC`JBv(WAF?nOt%c8+d7mW8QlZpHDVj-)Qr~Na3~#9Xed(?DR`USRQ6iIh~0``g6OQ zFc${1{n!YevpUbiqfZ}aB_#x%GaeKWYDmc5m-z%A39(q?7j0bPQ!Zk9hYh5KlH@StMUaJW7243b!L_xBTPLUa0uaFG1Uz zE4G(E9IZ=r-BWR^_yps3!dxLGesk1LF8gj6?{bCGMeX%7lHiPF~Y4{`$Jor zZVhW&7@m0#f=8AiMS3$wsU@(f#!aEz*_R8`?%gq0S5#xaiCL!KbG^abSh8}VW4Ktg zJ1B4Qg_k6$+sqb+rpPt2VWuq44j2EpFPt_kJX=UoSpDD`s550bELdI;D2xfVUjOrH z>AvwmT6Jb+R#mcHhk*^XCDjF)w-RQeu^rJ!tAQ0Q{acr1$4Q{}MZMSWisiER+>CqW zG0|_TvD(+wWGcuIHg^(b85Y5C^auJ%5wa%gw81v#VCyw_R1$GVDy3~NRWL2GD-Svu z)3^!S4Nu3a%;j*grPVTOwR9iO7AFPw0uz$O-1VS2FU&$!{hTX-lH^rdgC$KZr3IQ` zNWa~xb)>|lTq^Z&H|+#{iOY1K{;OQW9U}R&kB8XWwh^mr!VrttlNQ9VWEHFtDRzN0 zqDuTre*9Ay6c+}zdjWK1*2;PLyK(@am>eD$SmR4ROmrOvK)_45S=-2$0SI@uZ?{2BlfPN4$kc;Eg`hN4 zuH4;s`&j%p?JX3}G&*yyvOKb%G9>O3HJ_s@Xx(=t&Li zTi^OXa;+MOAd49d^rv^(4BQ}JO7IEiG4=?=qGzHD|0kf4fGgb{l;YtLOX!?csGPg~ z+|81!)WqmSl;1aE1<++U8ZJ98MXj9=xxKkC))V$9_t@L*I8?-Mb^q|J3(&}>R7TK$ z<~jE!1h>g{X<(MkmjwyZ!!4VrhX$ko+swr3^o=&z!m!~s;lJY)lga z6}tr7&MteBSH5gumYvUD<08|f$wAA`c;mr=km|B@Ty;LlcVM``F=1u+sPOk^7SCM7 zo)!&>8ylV5(^+`zF<#;z$lHMz`R5aV7bP;zG)(d9~?ac6i$a)Ks-2#6az~Z4b zQf#?f;l06v-DwDp|Jm89*HG$P7>$gm*E`PK11XOW>=E8yyQ;(ZYedHZ8cXo5pkEkznj8M0~bRy;x>IQ2@A$CsCv z^zj3eUUT( z9D=7;m_D);TYm3)lxxdZG^vFZ5Cy-Q)N~OX7gGd4Xc(NJ!x6tB;0i5*YeZzs^*<_w zzyf4R>{fc6nmva3!LWKL(UmhOGYr zV8y}rpp9ZKlG^_0yN}H>?T)QsOP%fQj#-IQqGzK(scbj3_|B896?$hTq%UPUw)+D* zpV}M7ad9Q(6M0@*Frj6qjke zcvCj=&sjHNli?u@`I`Z0X~Sr_-%NUauukSQM?k;9b_-YIRT!2%z5kmCC<_uhLp4pQ_U3-+q zuJ1(lM3vE1v)-X2Z&MvUtDkGIahM1xYE}^%sEcksBieay68AoxXB)e8X&mq7ud$l! zIbmzm(sZhnbH>JWQhwY*?q{2KFI_rbXL%{b(c_4g5wuXt(deQ~r*| z!F}}fz%%spqR8r-zDB-74M9@#lWvD~YWk-fhoZuy#=VG`DEXW;ouT$G#ItWCWZnF^ zoH#$kQ7V02JqmzlFb2JaMqutZb4vhXqEZ9_bzV>qh-9tt6Al`00c&5_rm3+$550@m zsLE&lcD{^(C=yihsCVG3*FI9njV!i+)OsP|@y%$KxtBM>axUa->||u`tdJKkUW7+4 z9wPlMly>^OGJuI4lRV3+*zn6Qkn_c}R|IRMU+l?|5RfqUYhiP^{ogr(-l)I*`%l`C z0VqT2)j{GU6aJVBY|k``x774-e>`vhw*j`0t7OHC;${WAT^K*Lit9Ew;a1Ec%X#$(<-C z=fs*8hxq}uAm_rdr5aCRf^Ytq+a?B2Y-@|PP$HC{A&0<* zNRdnurj|6X1=kVT=!O^9o11i9W7Ec~|KL8RaZ=YUWNypPy&ewQr2h6il8<#GIec=; z8^q}u(aZ+$)1NpdRIeR<*Y*C#-NijfTUJHA8t$4?et7v5qZI&cFxLREcerE|_$*TVxcv7{;0*q&)IJKp0_WRvte+@w8 zve&=~l)(dECQK;Vq&ksYY|Cf*kORF?e_1lW!dwM4S6AGQzvUE(iU22^6%tBhX?!l> z{I2s1Wn7;dQG@$G^O%>sU6VkhgsR3#JCbBV@}8wbDey2PCKqkQhCwvak>)wE?Pgrg zJfnMyc^%IVTdt|UF=4aGvu$ot&Q+&iYRc=z=t@Ur{};!1g>#Fei^7=3;uXQ*ec!F_ zV8;S~QltqE*hU=fm6K0YwKJVGHsEXrfa1V)={W!IX8Xt)eoC3$Ew;#n&Vz%fPx7Q^ zaT_C>K_X(i1#4jER%YpHPEhZMx2SO_X_dv z{qQLF{+KGC&&!*4a^m)B(Mt2DO4s{-IB~p^S#NqV&a&#aGHPjwcK|j{2(>-fv~hZ2~z?3 zztvtq=hd#&S_7kQ2`5cJP=C3Fm=b@gYZCAodq?YI0?NU+4$ZEqj5`RC>&J@|rAW(o z{^|n%19&Y1`a}Ib_^^Z|(nnbWuQ+Z*b_1dos!RxDdI=`=ftzZV+*QC)@n>s&Zd+dp z5+mX90gC&ZtkuJwuUyMT(}s#Rd9Hs=YRV5ixzzE$SYS{tuqktKfJaNfkosAu<}@#Y zy@An#CtIO1x;%Dp7=Df==y>XUF+jLEQjTLUWP&}dRgu*!3sVa4n7_BY9$U6Is{Gh# z%oNUNIS-^J5pzVUC1O%1dp;Q7GE8@tc3cFnKF2d8h?g~W5EML+yHpzBtP=jDIQ+=R zE1~zRSOviksR;u(hU15rSV0U+(zTKXqeNVq<4Ib{*7Nq0Wxj}9>Pl;Xnp~Ol-an^M znVphWt+rGC{$lGzM4ZyFW3cTOq5ZYp{DY}UFT8vIvW{UVj*-P4 zR!;q3y4E{edVVEU_V-_4ip*tQ@$G-j>W}xox!GRCNiM|&a;&s1p80`dW6)0i(g8vD+k2Kq$8SjGB-l?%yf+W{u$DZ3 zK)56*R&)A!&r=UzHp55!)x>`4iM99$`2|-Z*{@9o*1}S27GWZ?noZhb3V|)`@o9FS z=QSU9y~(9i0PKNFt(jEc$P^A_Nl?UAW(TKFQN28^>sO4b@uvAs2&xNK$ST)Ihx$kwsV+YR!-ho+H)O)0Bo3%IzeOxfHXg_4J5oe1s zK8p-qPVJJOnHckX|0;uLOlWlbCTjJ`);23jk!L$qb=y=dcxk2GlwR47?F&|=e*9H7 zX;c?9f!CRGGJY(7y>R>D=;_l1Wb4dWVS{vB;3kGL;Dx6SxU`w9e8y)Fd-WDoZxQCA zoc!12d`)y#zTwO7uNTj~zwD(-=tO3$F~}?rWo_I&;diY96`!T0M)KA`Tx~oi+WF46 zvEOctk0h%?)ZJNX&ZHJ%YL;O~$=vc15?yT$DV4ZTZ2fIxe=;)sQU2+}*&*OV)rjGH z2wlj>-amb(;@`u-bW-bEJX+w}gCg|_?id_SvF(vQ+DZQH-^B}z##m&e2W@#o2`%#m z?2D^l8>|fP{!e&>q&-F+KE5EvXI2oSb5JoSuj^`cuwGN@8?QF(e{@MC^`)lh_S)O@ z3Cw;JeRgieglIAzC0{T+s(Pk*tR1=BO8L?nokhmHH@(7YhL^=r7)d;`CoLFAoo3(_ zH#M?u{FLyvY28d4H~f^D>9NDV{JNp7N84R#jT$M+20CAlvqY+hA$JR?Xd#FQ01uRs zu{FOQ+};9GJCy^f4rZ7LN!TdbDDGZO7I0Q%i&0Y&AW00T(b{BT)EdVDMb>cZoXy#_ zm9Wib8g*&HYaw8KsYw-MTWC2~n`aq&v13RvHjR17yWI<)Pjz4U)YrHHg+&(=i~ZoO-y4+9L-C_CfGuWxlwwGx1+v7k3?~oyI=G?Z%I?6@o%wC zVoaNNDxbUDl-|l`n!Fa*N^cv+(3om-cDMz@j~&+JTKT|N*r~XWUd?4Gz}Jb`9{CcR zuYxw7;+5V4S$WqglD!}BJbtK-y?yNvd9H-dWPj-I{%giF)4SDCi0Sk9Im9e^a7UvJ zL1)mbiM_R3tYNLW8%nbL%$oz#RiiUEV4ytq4yjk$diA@)_=uMc~;Q)F% zj@AjY)(KZO;2)fd4NwJa(sC9tueXGdb4lk>qir9rC%do9+SDP z8a*1GCep87w=o+_eWy+2G1LC#a`B*m9>|#o3KMsUA}m|DMZdLfE*={jD+g(>zK}2O z;jJ68M)zl1ewuLAZeU*0mTe2ilIxAau%)2zzmF6Nnw9m4rbVp%r;21Fdnnn^pyx{ z1D($z%#1K_mcD!ZM;z4!t71?3^e%3UZDsNnkGdAY;MdNUdXtL^)aLwzCWv4Cy^9X+ zw7&jdzuOEj@b71fx{Ldj+d0MUP@SzCCcB&#gP0kWFOi{DmEPwruX``Z+torq36ewC}c&qq$z5v1jE=_8)Fl#H&w*9b6fXhdx1Fgqzv*8&QxQZ9d*0^v*Q~ttuVTaw&S&ib zYB1aomtF9xZHnz552gLZE}g6{=JV4bsxuXUK*Gl)Eq#wg;yK_sCcWrt_q~{%%GW%= zQw=-Im7P)(Ku3vr$$=uYXJ|L2Ainr2FX^lAZ#H8iVcGV}OIYQLwp&+Rt0;>J{3LG9 zJ@aM1bz*J;KdY5C(m;p1>(|RT(06qONNrP;zhoI*-zu$7(*CoJE+sbAtBUBC%u- zMYrwVZ86GA;}r+vaCfd=U)2e($<&Fh@zMdxzfPyy@6R?GRjw+{;M1*f$yJBfjm9N4 zu71U*w*)#=pxIvk!t1mW2_oeFW#=^^a){Fr-_5R{&mMNtybgU%<~hkfZ4tq?NHS3F zddeNKRF{Jsgeq#sqw4oAK${gwT7CoXp~kyzeVTUcH1^kCUv`UYH{+86Qm>@2*)y@W zNq<;ZW!&kOBNf1PannYmMCLbjyq?-F%TX^G1ALrADztdk;3@N$keZ#P%oGB#y>%{YHVX zI+i3#)7nf`z@@W~VxYljQ75(~E0VaEpjGLVa$ z!!cgTi*j$#e!vG2o-YvyUK|pP@sB;xiVyMo(=(nIa{x!!kVI_~XAA6d`RtjnlCu}5I#v|u7Ph4& zm3>{7u(w+EHoO!@ZI(SCi5&?zWHyKPWciMlZ&~w}tG?ziN&65^QPD*Zp7cN-mvoFYCnkatM zMe*_H)o0fSC(6GqIr%4Ziz{(d7`f-Zpw-)w7DX2Y$zGV{dHa2sDS=7r0t()MO8KBrM)9?9zZHh_ZWcZ*pce$ z`Q;rGxMQ7e{&QVhp%~m<2%X3Hq1bgq*&~RcpK$M?pl7CA?ArY~Pn%HmVbZb}DCrsI zDm6-y&9Se!sKq>1jnq*plx;>>=ov4(;G#&<&qW@pNkt>HzfUztBbj}no+u(cjepg| zZkOg|-{E+?3GBitXx)YI+B>Ap%Q`n~CldL28?=1mUTUAZK(>;pNT*qGUXB`feGyQ+ z;;_s^I?-tV;CjYmc~f5@3gMV5kynH?nkQI8C^R*}4fYa)KITgcEqmb=T0NT?5E|HoKlTO}HU42bh;Zx<{1XmkNP?F(S8PU*z|EMM5z6v*blzfmBM{45gwcuzUCa zurFB1Rx^>yOOzGm&eOPMV_gw<@6<&4rrSziHul6*Pb8y+YWiF=4Jz?0tziy(61MgaSpUdlE)83*&avNK3 zP-^=fma`&Wk3EB2aB-f&G^qn|i*Ejco^pHP@)0akhI#_|cHr!j1uGR30uK|I{^b+=*Ko+yede=4X_(hDE4;3S;NJg?h%l;J-pxUknit zh@%b%5)ky{W;6eaImNKeDAL~;?G!(VUAyRtRik6G82U|ZRBk+D6zvaTWkSGtQq7`| zk{&QCLvk--N`RY#)GbQ65!$Z9eC#+r%I@n%1ic*{s>za|z_l`->o1zhp)GP%o zPb5h)6mHV1Ied-7vCMbl4t(+NSM|JgHap$@o}&JFr@lk!CFaDpER03RxLVTyT>)%N zgg+ry222;I2oVVt3-pc>4;MgaIUyAt;M?ajW>;QMLw9R(Dx|566_dp0Y%Z^aAtK?F&SblhgZmEyPAdg{6+hPTIm z2Z|(V`dL{#lp9zj5n-cubPkK4#!zl3f!`zNB1W2lPBX0u33pj1_q~si&S_s8T!U)j z`pMgxG93b_W0$ln#6?2@fC3OVP})GMn_Y&E?$xmNOC9sUUmC0K8RS+jp4u#W6u`jU zjLqK^k((6co!m>NqX|e8;CZ<@8a}`cP>|o$VyTVFE0VBBdOy^{A4SGYxm*coXj&RE zvB_P_{!#8xE5(s55)H1*@fIJL=5s9UL8ug9qh{<>CSE=)a%V9c$};4^wZw{5By)#) zyBG^{8h!7q4-5FEVqF{iUdw~H=U<(Qn@<>2pqp}L=`#wk6|(QzUBYK`MN3lbA`Umd z9dve4|Q?agWl}HsCH#7Yjr2_$ARyRA2lV^^a^$8Y5O8L5P+R z)9+c>hL@cn%t_MU+*OckYEwk+z5rTu9tbCwd)RZx$u-6J^gm2NBVL66TB(r0_MBfy zb>}mP^^9vFc}Ns(NN|7Biy6v$8#N8j0KzYTb;i5+v^_sYCIholPJ5oNpDod5MTBtc z@PK0}=m4o>Dt|h1!cx&lMTNSW!*%MqV29X61R`KLdMLj~4odAjde_Q$%fy8B^<{vZ z_9p>C+iadZi!28af_uW98zS!Rz^eM8e6EQ`PLPoU2y@1IaojrOV&G;0AE^h6kRTrx zsDObCyso7Daap7*;hTXE9N#iXkIHn~n5ifOfUP>aG{k~dfcF7pIZxo!M?T(@BMjH} zv0wSy@a3uBWJ?l2*u(%O^YXU^iztViknx{#8q}SQ%TK!WQ@%f9FkrdU%R7dPgoTxk z+@)?|H{6TG>5dDW1g=FfSJ-B@jMVlOU22MTVuZs_>h%90k96qs?H$oue7gJifB$Mq z-pPIJsCT(y=V_3I?CO#NQFz55IcCR7abN{NTPj^Cc#DHN z4QDsJ;85|3rl1E3mA_^nuhssq+7JF}b)F9i1O2beGc@Hf6w$ZgCH7z|ED5q0`!!l0 z6?b|;b!O(8EfHll+0wvq9d_P@wCwH?+9{^7cxg0FZ$DH!v$l=U01o(q9;li9-D)5#kC!Y^Y>+zL{_T ziX5)k86>~^nNt99xS=#JtYBQkQG4GK7{z(jd@Ms zum$2_&0Cpb%K)35<$fii`CnI;dw@sm{6QMXPxDl1skmGQePJhV?k$&fFXVI;Ciufv zh3tjMuZ~?&nm-P5r7pp?2WbwEc;+TRI{O!DKaVB&$ZDFE%1yDZ#l_|go%)&NP4<%S zGnqFvdw5w9i~&aC)w8~_2X-m{@hBdupr9Zp5`KYT zUJ##`a&lT4d6FLpy?576UDgCT;ZepfOb0({$d;k%R427utHn(ZD{c9((%qm~??pd` zo)~34;U6S2vNK47_!pg34gHj*rEyFRs{(Hq#c6Bb!DWG!NK_^AYs|=*U%?B0QhXB@dUfaM;@psf zw;Fa1N=L*=hdQN!fMH(dIVR6{-!uVhyMY|TZnL}BZx5uPDgQ)j9Nn!+G!n##Gt@yU z`Mr*?=(zVg#6jIp?vU`xaJ1X$@?+W8WiOEH5|ZH?vLh{$Xgkv{rieyfgivlq`_fcE z)N$KeHR`_au|ZMJ(a!NVPICFF!}jA~=+_!A1(6lgpWp!(xx#cp8ZhZu#&8}-KRy`E zMm~Qy^Q|JmFNw_{(FuYvJe%L{_fQb;;TO9A>kQ~_uy^RPu<9v@4@pP%TK0v;mCwC< z3U{y(-Niji)cpU!|NfK3ngPYkaW8`Yiv&uVWU*yy65q}bSr&FIy9cRj^efivP%^@D zz3z4}-du(i$!zt653wd-TzO!=e^zoo;S_}O+*t8ZPZ){$dQ{OiO?n%v~WcIDtzMlO2rZ5SdpK|2`pZ5GLK%I@KRxdEuqcY zz|N^dKm!hniClg;y&VfF$4PE}u^|9Bd8@_VpegWfz~%83@)bST1pqNaH0+Q&+-U^r z2OA)?U?9)i;PzwpQ#;RZzk<2gG0V4L>$?j<1E^6{*VX$qQ-?)Tsu%vNF5GsXGS`ah zw|WZVxkd*W-whvtTFR~rB_Y!qlMbYVjYO;r20>B6%=aiz0l9bFYH4sbBm{yC?hX`! z3Wc>Vx8bDfSd@o&kqnYIe7T%*Zdo*nVVg_^?QOdM1&tFmltexw0;iGM06)SThQvc~ z+4l7LHcd&L&3@WDt1bcci8(3(G)^5hxc|1S-(5H+Wj(E?y3UvMueNvAE&D^`nm?A@ za_KlU@9kl4|KSmmDAt~-v;#Ez3f@5uNPFP(h!KTKd1u=cxlxb+=&;*&Ua=_eqMS4q z;%|h2&}q%4zQYTcooeOuOxr4E?8*&fF5BV+Ne{oYh)oXOhJ(eA#ud3BjBr8`;$w|k zJgbTWHuf|7G6!Yp%T4*fLL#XU`_>5*rlqTR?Vf-OsE5#aX=A_fU@eGhv``KY^L(;x zV$v^nd*Zm;&XxhhWR!sD=LF8j@$tWK@87yogCJe&`_(T>=#p-4M>jX|Bv6SVIKx)} zz1tyEg#s-I41KmZeL^c%C)ZY#b?0*(Qcy!h|HfvyAR{I_cH^Ehc%+4EoD2SGj-m_q z+&FR7!g4l>TJI3rv(LBKlmSuQEu@r$IurjTr3ME70&ZY<2^mbEgXxWNKN(s{W_`cNnW~T#6@=zWF46CroEc^E9=Bj zIn=Uydn2>*Tdam|M**h?!nDpvbLWo;4eR|;)Bbw~=FnL^KcsIk%9gDSbV_K28{GV) zfre}}z>Xn#zayDC*-&T4cL?v2 zWgr-SOZ~Jj^VH7w;bQS1m`6Opf@gZi|1!na(RKBU zeA@H&Z%+;*r$0)n5X&cU<&Zf29KRUd@qnX{e`_OE(%^jA)Kh`|*G!5nk-``NSqRz- zDa1-c!>$qco9^CO!;FGLM=RF87hB3MX1_aJfQZoBCgW8?IEuQM7wV5d#T^hwicjeL zA~pyCfuADd^!g<*lJ}|FJkm{vPAM(a3D{IQ(gX`9y<)x6K5685HM7^WVu5-GX_6zS zjkWjPAC610&Lmf=9Ne?w7}_3?0vAe%NPAfH;CV-m1vs!D%^nHeuPBOA-kZcHVBM&v zkaRDwHRvhZ0jjTg6Bg=N)R~0|NT)!q^&yC3P>!0421+)6<9~+;D*Je=RlN%PZqdU9 zmwI_B`feI%U-)s$<0oz9zp$O16~}T9{}=oI1WwO3ST0ND=y+86y{?&zK1$QNxfY>W zwf9Lv|CK97ww2>8$Q>&ct+vocD;tUyQ8V_+i-)ZgPfhl^ETNkh+}H+Z&PtHM`OCJc zT`A}aQ=lM1z&o^%FCZ;6@*_bgVyV?y%3VdLn8@>{tAZYEGE zsjRa~or3dwr!2hjpg>1&2h+!(!cYfuSU-h-PisxBBE43a@-atwj8_}=%-p_<#=>h>6v*3HlzgSC@9UpzRv zoMe~%`HY?DBL?_!VyO?{;^7d9#DA%HH?s?hD*s+tEH9}LbE=G+))#c7xn!KmwOx>I zj%C;xPl*FYZ2vE4g+_K9+WDa))C5Tpt}ed};Nt2v9r?H=2|Lr1Ff6=KFK`y3|BzUl z_i*Cb%i3xXOFrzowvfFt9@RgL%=|Qx;i~VI9+IFM!*IE?9@T)lHIQA?*rK0l=>MGU zKu_)96pzj3|KgTAZ99C z*B52D1LfP9P@%M;B5zw85<&)?(8}<#YCI0khn*k3V0s2}og+nN;o5DiOiZu$D%qz) z>>K|KF+HipPE}3pY030$+{)Y)45x1pdNu!_D%U@Jw_YjV3g!E)?{_V~*RX7P@OS01 z&;5_Yi21#i{V&N}vg$GgX>B8pgF{`6Hckm9lEOMg;XHKROW{f7&-?vV_l3X2pS)vH zXdQV*ri2BXA1~8=&hR4_vzAqO7MrFnp+auuh!vLr%hZ`OcNy;GewY&+Rk`=emCN^5 z<_@Ng!nT7sXyZD^ps~`1alZ^MPP0l7V96Z&&wIDCp!G;mn z$$xn_1!m-8No_VDDG>)4vJg{hoBsR&hjSEZ*e5Nfh_Q}cT}vBvO5q|amVNK7fE54C z-aikwl*|vdaoiJh)DE)YMdbnYa<4L3?CM&pM2Ecl*^nP@+P$|IRfYM+y!Y}vIePWv zq0_{*Zv(1}KTKoxEc_q|*Dq`9L`F$S#HQ(^OxUlj2@^d&ijDCTTcy&RI+o7e5IQlh z%HI0YaU{$*z_qerr6ZlAZ_4N+T|8C~o<+}t18D8;v+cyL7O6>j!`y9FyEh7p{JKJH zmc_Xj0S)-Fd+z0l4w@Vv7x~k@fVV^q?CCm7u0@5_PqB2tY-MnRo_E2* z=Msls+xL`~J*=ofPu1cL%9M@wbVbioyH5KJX<5}XT*KO);mDqTd%c>gbNW%j`R&s6 z^nl3%n~N*PMv0N5bEW%@2w#Rtgek8tWxQbu2=ZMC<+J_vIxFU9@}t&z=X{@``-dH# zZxAH^NOB}6C)0x5-OCCOHL7NcnlVHLS(MyKHz5Im{VajSsqneB-q{= zb?hW$=A1*7hZ~UBaG*d*@U!{w3GO zOIG4Z7oDc$s=g`=44Hl^fOGyeN)39di^>~OjW)oY$&56VgYM9fvZIcu&|4`8;w2WVq%dGx3RpBV3ycim{vxtaN z;V=X7#adP7AiG%*j%J>vL-wotqLRT|6fM<5Dh;LRy-qR{)~>@JTc3y*Hl=?=<@qo4 zSGFvAyg|L7m!0^z({eSYB3xE|ERu)9YNj#TvA5*OM^+EfTtce0U)wn6c7WVajC`g4 zTYur|_RxUQy(uflujEfe8k#t4PS5m+GUz{eoq2J=MWw~z`8(CzPh^U>+$md5Hz=BK z41$c8aLM;S;EhOSN75F0)w!zV9L)2Z@daEg1 zkLGrJW@o@!_szfQHpCZ~57xUgVRNgT7tS}GyP0A5wVUu+kLLiCS-S}zx~@9?uE`)j z5;>!xY+q^Ea9Zea!+i_O$BBh~ZlnWk(k+&9{o;rfWV~IIJZeR;{`6OJ_fJ-P4Qu@tdmk|;hAc0nucqIy7Z_L0)ogY6&3ie`gZb zkX8kWp4h<+0V5l;zEQOsaKML?6mqq2f)vI^=Ik*fu6!%in@AWhD_pGbp6V0Il9mw? z#kyZdyjVdX)|H#&_g{}zcZtf7?OB-w#S6<)*FjH)f~!643kmXzHAnVWeH|jQ+j?~+ zLR|oP|Ghb!1#?!1Ya5Q~r7 zSb~ov;q!LySTPJoA3l8exo^E(t|dN>uyC|K;zXu3{r-6wI#fk?g&E*infHZypNhZ! z{MJ$qDrih4Q+oT0L5KHrt&e4G-Q8a=&%=NAns5Ew+Iweme%p=Z4ia`|hw4X{*j{*5 zv_dS2G-N|HuweQw@85bmZVn?Lu%ak#@iP>@QG${IieUZJXM$oU@32H5ZtG8r7hwGo zB$oK#{h&to!myC$XmsOCW7Wnl;ShCr8G!WbSR|^e6kUD;TYLA%d;GWtGNvx=o3f}j zJ&eKIU${-1oayp-!!0@2%%;Mj1{RJQRHyEeQZ<+PgbLZ_tTll?EapoKw}TOJN;;gk z>1KMmo@d);Tm0C1czgxl4AmRH+|Qr)m>J!H`xUq6iR#`%F&suVUlvtSBJaMoVrwZI zZh^*%v)p)~4DS-L^QOG)0T`doL?p9p@E@7rQU+v;Jv{qPYQW}3>DJ>x)fnb)JpMAdl*pj_D_`E&-@LYEPPjaQCh{}31Fn9!riq>6 ziOzePR{p!xXD}Y|d`fPfJ7>*U<=&h$J@4*rT4jOeyzu$x0MDW&XBl@26DDfmRDk@- zq|Ah7?4ZkikuEo3$#3uyuzeInt->iwpxu4}hy!;@^~IUOjk_Jr-dx0I#BD7025$F~ zL8`};&aATHv|g3@BfClt9fK|AnO&dwwaU<;_0`ML)bE>!I`HVH?mHo^v9Um;4W!bb z(a@uK`*hV|V`T_;nW}Q@0@zHc z+uDwFcL?v&Va%~|iCf4OD^agf(OXW_BrjD%+Qrf)k=o$GE~AUtkbyjh)ZyoS$rXnL{!c#qj&O!v%+xQldf3SulF?;;Gk^wsY2 zF@@hk?d!0q*z-LV5ju9}bYhb5+SzI+V>6gA&yGj|Z@vQt{BuvQ#!B?0tdnJm$eG{Z z6*6^XB`U>GK_;W_4s_i*o(fpx$6;V`ja^5{1zo9 z->)*?o}c94u72^3Hhc)5ot1+A#@@~!7n}>oEKyTRpB-3f2kYvs0 zFIdPlnptY{yPowPx-sxh_^Sm+&?Dda5y(G_%8b`aUrYuVcMnLYNnUt{$4IEI2u6~Cd!v7wB%RgcLF^r#b+h9Vk;y1@YID%v>i z*tGlZpfESHtEhW~Or%RWx+{sB6^_oQDEMq4@d}!N+G9ZEuCNn3w3}F6H_|lc!vO}Y zz_>#T)Ufq{oG>E~-ss%<*^@84cBYGWrpfz2t^`sJaGRI%6$vm&`xIDQZ=?JY-#tVZ;}=)IK3#&Xtc7KNwoZ906A}Ov6DsSoCk!kL zJBkex>55;MFf8tEvZ&JC1K#E|cpE)b4U@unPLio%JK0;&cd`YI-x_S<_M5fW!Wsxo z(v`V7uq0KQ2csI-{VyRN^nR5uT2M`1OkDhsAWNh;3UTU{!uA(Aq6fxWo?i|-d-vD% zYYXo{2nyKP9y6|5dyh9x-w`u2s0b*XbrEVW4`taR*{Z5~($kR

    (`#I8a? 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

    +Xp1;JZ(I4ktFYU(dNV;H^0xAUA;jV$ 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 literal 415584 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®%7VEW>NnX2k8xO)_+=RCGc$JRx+ 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

    {<==YRdXXm6VbaS7AM9t{I|X#D!W3jh0DMg6F?; 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 Wf30KUo$+75M_onhajBB!oBsiyO47yv diff --git a/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg b/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg index a19eaabc9c..4556168c5f 100644 --- a/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg +++ b/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/brand/classic-logo/logo192.png b/frontend/editor/src/core/assets/brand/classic-logo/logo192.png index 08101ad33ccd9412c402d3191457cc98abcdb0c0..6c8d372c56ab886719e45f75d4af8fea837f504a 100644 GIT binary patch literal 21703 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 literal 24013 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

      6T_87YxF4_5}&h=9E)TAq*C# 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 Ef=e;v!~g&Q diff --git a/frontend/editor/src/core/assets/brand/classic-logo/logo512.png b/frontend/editor/src/core/assets/brand/classic-logo/logo512.png index 1f7fe384fd98ca7ada6d9d8ddb4cff46ae26ebe7..b71e8b11aafd573bada3bcef6df358f68278a16f 100644 GIT binary patch literal 80195 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�w|iqoNuPLvqNcH#Kgt@J@3sk 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

      (i=VwrMIPqlRmr& zcfePoO?HVx9l(G95BxbQ=Ag$&ZyaH?e-j(Kw!eBx(W?s`GgjVDokD)2Q0t(qX@aT( zep8Ee5GO21=z&cV+4z$6PhNtyZgXEbS7x^_&!JtcbC%2mzpztVc*Mej4}(NKD;Fye z@{LcP2i^OFFjjyT`1D8+EHLWq^}k(IWDt=68(1VZGjbC2m8F7!F{^Xi0UQ`xYAQBu zy4fd<(MtDA66nJQMTJ`gF4+f(l<<2+LyQ9=OdEPN7nr6TQ~q;5;*9q<3}mS0L@Ma4 z8>^tFkp!I40^#Idw$b!-*s)@}ig@y`X=_l;X@c{=p5kkk3-b36G9F0D$W0#WqGNrJ`X4!D^pN#pCCvbA>%O*}ok?8aoiy zqvUkMI*8?!x*};099m)83=VyE8@zgd^1q;DVuUyksG+?6pgCKTz`R!$csgTR8{8wq z_vA(LMdyFUZ0Psu6Dp?FUwga8(AH*c)US}>J{#l93hgnUA9M@2dA4HP_J=ukBmtZ=i|G(=;R%s3oK=MBB* z=DxJa*kBJ|B^O*R@z68ppCPM0pL{4`a*)NVUb3wW_GSNN?h%qDi?P&2YQn9@=XdT; zqbES16o;4VRIrX88VBw-o~T`I`rTRDDTh&Os)j%ydfm(1Ad$f}Ou?0k4BbC55AUD9V%yac?mvzxhlsB|eA*1&0ecz2lClkKv<3Y;qEgg1s~bk% z9)C^p=W}HJ19!aEqM}spvylJL1G+{CvSR3k0|2rmKE|5^v>_{fALBkITpizd`Rh!C z$jsOMs$W{BrqkEHSO-BSGJq%q5rFEw$Ub%@A9oJ;yYwfdY!&LXeND?;(WZ5>5T#+N zt89`J{dw8Itg%=y>cg98umcJ`&;Pf$XDR5btR>HQ@-t?DUe}#+I2CcXZsYr)Mk#rh zRh&Hna1wN+l?a(lP3Uh1v?ABn?Py8TY~MnnQu&SL{dH2SYfqX1>B_!FUT1`S2m)#! zw~|&hL;#Js2c#k{)L88G4aO|@qZu~|-cEMs$TSmtU`3KM!7Da3s!(eyAAn1G4d z*(c=ba25d)QR@u0Q8U1r%>K6hK(^` zPupskx+0s>2}so$lNp_p=R^5_T!wgn4f!}oP?*ZPTh+!9F<2eGepDk?3EQU(Z6?aB zRK(9fnSz@M>0pkrkBZ%9z9LWL2LlZeUa&#M!N#9T-EaY2k;obX3>#BCIneAqe)vto z#D{`UIq!?l14&tGW()D>&-MjhC3w@7um9S-)(58K>465SK8T=2a>0qI=!uWdB_V8o zogInXoimcBvWT6!DFbX3U=;u^GHY?P&EwQ{IEOeWZ#r6o#O`p#rG-KV)NOW}nR^;Q!{Ip*qfUya8b;YGd4yb8$D$e(Vo6|KX9;E@-9nP~UYAeE> zvy`a>rhN~9V0Mnr>HWm!c`?Hc^JWEer#7Zu&s?yH-OP$PUtp80qLR{)cPc-5mO4xb z1q-?Cc96J%gdCDo6Yr@ZV{&ibN-KNz&NGAay16I~3HKOgodHFO%j0>4_Yp=;TY_s@ zhv$`GywVGBpGky|@AD%tV;tQjD`nK)>wCFpEDvCVmQWRW$_lLwEfp2njcOb!MnO}; zb#M~@PWW!)sCw_ID@Uroi^LJW3>BlZeFb+gmGJZt*>`C7ElO@<5>eC9sn_lGh$fAl zXT{KfZUi)x;(;XcI)h~>g7!Fe=?e%?R=u6pX@L3rcfx;9HL=T|R7e;H_8n}h*VXG- zEuPkE<(18WeW~$h)*g`S>F(|}VU**k`TJ*A-G<|8;u}=lf)F;qvUIv^=Q&^jF6+w5 z%35tW)D3XkR}$cE_rWIK zrlJs0{@}mnsiMtyz38Mt$q(q<8>yhara% zu%8qlFBz~FJR*_{bi{vH^GxJw7cnVsl(jWW5HcQ^X_zbzTHV0Efc*d#vtK=2S&8fT zV%rzp@FKL(CtXFFqueXf*4eq#7w|>#iHK0qgTm=GgA-^pS7^7oQM(Fno$Fa+V`G{G z4w!7@HN)pHtD+=(Z`Vl;ll^5?JP4_PH0&?9qu5`2fS}HUbqjbvl)_qcKO@+7vASn8*Oy4ld>> z8&Frz2Rsq=psiCM^Y(38+K<<8joqs{t0G64UM6b;1tx=XBRm8kO#0Syp#W0DfwE=g zA{qVPSjhhwfO_vUJr_A;qh_{LuU%8HrGR580At@a-EEaA4)K`lSwFxpN36*~XvKm(pC6EIHm_xwePh9K$16`fr zLCT{tN3*c&u$BB^5w&}#hrb(aKPltB0uZxR{EY*qBo&a112P2Ft1dgIs41xKO!75DLg4~ z5o$@}%Oh#O(6Z>CfrJ{E2G|TqK>NYr%g@>jb&GXA_9W>G*U`&e-RgfAN9*AnY`i{Z z0Tc!2;BH@UXeP9|Q-Y%epx*6Z-bBJG0gC)7NlCwd5p!6xw0@#T3onN zqzlL+WbEF&c@zWg(7<-V$cRlMXka-f%n+so19Qf1^QDJ)VUd((oq0(QKXX)e0u4!X zZ&M(jvVVAB|LYoW8~k~XrFyc+L9Z|qOVL8Zc^rCohLC`O0Cs=en6aED${qKqFliI? zfOgBzn3UEkV4^)Vl4C7OEaW=4CJo#l2$P_jkg;v$vgeYWu}oQ_a2{)I0Wq;J0970p zn(^8*bA-;y41Hi$jPqn8I0GgF)4Rt8@9(TDXGWR&UL0`SIS=sob$(d@w^Zdo+aJs8 zCx=6CPBbT(YE6OA&5=|gc$KpL3KpaaVprT|oML=wo~|_&0(m=thI_qu!G7z>-;YAm z#}o=#z+Y@UmpoH|U58pDL}oiI9k{zKr*UzN>JF|b*}y!-ELu$9+>HR+Ff@pYv2*hm zP0`M7O=uDPO&xG4znn~lsBVa<0H?J@gc<@AV{QLGO$CCkZ0fbsTnyDGj4VGsZ6XCE z>Au-y8-L}ZGTOjg9GkHAT0q_o6rR$73YYOc*T~xHUZb1=**F#0GwX~t!Ji{c zS@Xru*|Sf}5?)(^YELA28C0-Xl+ zp_`OqC@_Ixg5v6oz#UoZj`z@T2T2vwq;h~tcK;uhRD0VAf(A>-K(^2OTDd#=vF>GcL(?d_0HK*3@CGsp z5S5i>`?b$nALGpYipal8bG)aDGD92L=19}5OK?Vi-KIX-B7?|N};7EF>J z4pBX2E#mP&Qwahz!L)0zC)uwpM>xLKOZ0m<>=tyaeX}jIC%Z+VuM9=TE5AdY;UY-;ukilW?ASA+l z>jP8Oyld&6oZwGa+q+^-h?C$)MEQ(^2W-7dlEmyI9BaAhySP_5GUX!_?n%hir>eSH`Vo?95LFU z0%l%Dnua4}R1`OR55>cc?=@55AvW!PyQn^A4u}Xx(d*+PJkEQ8ZawrXNS{Z82uWsn zwgqa+f+~b{X|oc*z)zGN&>bwci(Y$RKjHDoAYmIkB5mMv?kH>mDrQ#A})?;VITAlQjRw+uvt)y6tUjtpiUlQ@q#8 zM>VkOO{hgK^(RnJ1TmwdNdw+%{k#=Cfqr`LT_djX;r?-bZp|T<@1OUStXAaMF9><= z`-5_xjd#U%T7;Be!L{EH*Y{R{$N_L5`)Iy&r$I>*iy37ECNJWeCROCM9*GyJ_a@&e zYaITz(=cEq^XKpE+6d|Sj<*Jm-X!1P<#U2pMUB``bRQ;h7i2&K)nycmdJha+H{C+G zWx?l9yl$AE^m@w;u*f2E)W(ja+R|?}N2T=jT;2vM)T`lnbnPAVI$3iB9h$cxBm);bDC!ofAl< zN6s9Zk7wQhGNaDanVyDXQ_l;zX4lpnd-8e5rwrSPDIQDiZZoo<@_y%5V3|(xVaCy+ zm#Ks=kMh}J_pM}V2HvgL2hzEOh#RYj4z0fKJzQAO=P0GdM#=^fcmtNIB7bDYeb8u@ z0Bk&Oj4~H+RRA0zSf^{XkHNf!ja0XtFH~14q$FE>SH6Lt)bBJ;7 zGDXX=Ls!b$$)Tw0N<>~pe3UL(oY_}nTqi?`lqAzXAp`ZZvQ4_1**d9r(A0!HiCvYZv(7m(J;?_n z?%>76%}n~ApVJ$KH!d#DQu(QTnKfny#@vP_kdQt_08Her#pStDK$9O~TFSImz;&$k zz2Z4_y;f>RD}!r&Dn9o5yqB!`5FhdC)ksd!5f-bzN;o?1t|NyzpV>W%I=*xff7=5< zi=plZtZOIvBp5ZV9D4&yFPCpxBn>8#ne5#&Y~}tZs_%Y#v^e#|j`4xBa^-}!XYHon zIuP`uE>ykVm4zLLFU1O8R+3Mqr>DCLaiPdi)n_u_I)O0J;L8UA0pU!YU#G1cGz1cZ zByh0RCmR*6039O&7nfYE=#+LbFngNIF*&8cG`mC}^kN_TmOnjjun=B82?6mD6yPP& zAokM$t1xmaXcjam0Y{H1f)fX*Qi`1n>bnLplzegec{9KPXa37wRX z)u~RaQwev8dj`SIUhx?y)#-Tg>??6R8;T8adb8R~uh-jMtD{dY*YKb&VQZ|cD3=-K z8W97WW=i`xNb$>lt1W>cud%?twUt7SLeT*q%af*ZpurM4w9kh;07C6JY|KAHwr zCV18&WNxmjH&y@Bw&w(gjV@_UN|L*EKbh=rgv1j}eM79#(iZd*)@ydu)!HinaWrUT z)^t311mrB_`SCsNT6RY__oNyvJNhS`00wly20WpH`eV*Fbk0(R4JyO?*F8P}bFx4) zk8aV?#FBzU!1$5Z@n1_2Y*}F;uRLXTAY|Yjf8EZ0!K2|jepVI#3LH*; zq*+~b#v>5j8XX>+6jFJ0o6T>UE-f0Q?UZ zrj>{8?B{u3?&=R^8k3*ZU+M>0q+ubOm7Py-$n#!qlqc#MaX5}!Oz7uwCXMTb6vR+% zHskUHkpZUSGz$e<$Ifa7VjmN$qxuz85vw5ABxeGVqp z+476$r@pUMCB$(umD(ujBktW_{-faA=R%D5CWeZ&=aU4b-4UCF=X^(}>%|T?{2R*+ znlUAtzUrBGQJ|(B7XX3A9zJCo2nW7adl50AHpt*E%v*gz3`LQwva<87A(adpW!|+ z;;=TUVmh-TcB?pkgPk+3anb0d?doMnpNy!CN{A-m_KEa3dh0yOGv8;~^wX>iP8R!` zy3TkOZ*@j^)}z+fw7)F$QmNOYzheGSr?hUo`1;EgOk5NnI}}J_tIvqlQa)@BQO>*A z@Fd1j0A;zdzvFB@^b`g8_U9UYoewCEGK%V}ifsCnxFhv_nDn_VhF7b;TT%Vc&%H!# ztrV-%qLXlWm>s!o&v|*?#I<`dFrn6hWAj-Zr%J4Uf99pB@~yDg*j-uE_3PBR;<&Tb zU+#E79#c~$%*gXh&Q;B) zEbGH|eZi_ITFjri<6BSNiv9@tGMuXBMl6^{p~d8n#+vDj(Tk-n-8}Rb$H@?zBU;-OtZcw_A36xyk};e8vt+qf2VQ0^``WH#{VHqB4Ty-5ssP z2$3&^5^Qt9=)%`=TFMu%F1>Rjz23h#?6y~{QWPg_k{4uovZ0TE`ITHo z|1EC~xj;hkFLD+r&=7Q`25P!)`)GSEjsU%7<1;CHsF9dOe(G z%ldJ^*Dhjjz6Ql1J`{3bA`697=@e#MJp0Q?dA@?^j*~C8WK}^kb-_!!WGH{fn=20} zANzGuR6(TTCZLce5TXHKTHWMeX$PKy5D3g$+ws+>NU$YC$~cr;YIDDrJnQ+{V_)~c zGoQ8JUReHsmeaB5<=C;=jVNJeN5)y!y>dJRQsAct7LOrz)50_l3r(p$Gs-fE$tXg! zc#?IOtWxp2h!O9~U&pvsYS05vt+46zMVagg@J546bQxcqGm z=Y8w?@JvD;JOq#k9cVzzYWK3&=?pZnOZNE_e6Xd$tEX9p&BK!4CwTUO7n>*f?2Nqz zD@%#CNRt0#F~3%Z49;UQlXRX@8Dd8ZRPhbXGn!?OVD3TBAF;joDdgZSNY6}@tjx?( zLNQ2Vx{oDo_)z{^O^YGiHGX2j$3p$$14#{D3nlFr$CxB3{OXbIfFi+Hv>?YV7& z6y2NtEhl3`WE!1O1p<(1aRPYjiH|x*jMe&nPGk1j6E5ZK0X%LgqJeuobq6EHT*KIz z2c?(3kCEaJE|0Xx-tjnAwG4IBXwz}zGHS6pS^T7-hK^1OE2*?8lX z8KpNYBNMkZk)Hiz#=s9W_MFs=@rjd5YdzyJ1Ng&>KYrf413D1AE_to5m?m;hdc{}w z4jM-U-Y=Mgv=ZAQ$+s!FS! zGr9By1V1OGi>Bw}w)J}>9;Lc_JI)DR2i1)%9Rbg1b;%N`nzCI(;)yG#ebE=irT9`t zO*7}+GdHiTP_Bmfty)I1D15(4=!@y`!Cbe8-gCL5N2jyz2@H~#1!-8UF}(i^ z3fcG5dChDgMoLKsol+!rEql=?a!!dd8D~_Er-i zcarokGR1Thqf0~muDNE%$i9CK7aE*eFviSKH1`_Z&Ozfy{Q@f6%$jkp@ja&gGB6As z;Xc~pr_yZZOei~G_f0~T^CXxZArSxDV^)FtnEFL*y;|5vHRJ8P>P_H&4NAH~c^fFe zNM<=l(4!}yJFBHPT}M%QiXjJ$(3xygkhCTTxOKZHD4DQkpgN|Rv=;F885}>&N1G>7 zU?bCL2*Clr*pDR{&hHLwuAK2X>)arYpg64au{t$12ud*{X7KBzMpOH#d_r?Ly2Uph z{60BKK2$)i6unf{5WtLkkO0Da6}|)^JY@Y>S(pV(3|okub??LAHn;*=f1dBL^*-*) zATPH@A^4wzWPSC$Z%i(5wq8twWy8>RkW(M7`TU^(UXXG5Y zJe_~^yEbsrcpEV!wu$q#T#HlWSMsTqnEAcBf}Z=(rz zaXT^Gc#rCIs?hhI?I*l`swH@dr}1M z8-TW5P%TcF{tVPO4kv8UCQvCyQA+=y={*E{Hc`X2oW8JC0SQSv4Nc28CEP@a@Q9R>=(@uB;6w!khzw20Ju=`VRH(itK$hd^8|XOFi-B@$>USYfZq588A%fA!W%nLCG$@7lFLMjJC4+sud)^lh zQOE3<5dDWGmz~pm|3%!5Y#d5#8ohpU2WyR9@M zR9m_J&H3faEeS~A$LlF+JvC$8(+*1;Sz6Jo{-GQp!`R4W(>LwHK(4bbs?O*< zx`ekuZmK!!jEi`7UR?fl5gUcVLcZ*2%5EH{<_LjxbOdB5T^a*mSv5Gf)xL*oTS6FKXltwWLtjecoOLFJ(=b7- zw@|SzODpfs>uLApnNnyA8_-ASOMIAKVq7S2J}rR-%f&zcIg`=`$`*7H3CgGh5y1>> z6&2s5P(KEg+pJ?f#{%04hW5Z}IYXAP99%;(s)@+I%itk$qTJ?w-2jkzcJ1+8sF_;s zpe*bH6W5Ba4gs^F@DTP88bHl=S$X}$l&-d?$qIUYtjqD=KT0rnQjq9R{33&IbI$=I z>IzC97!3ba2<%0U0s;Aid}6W%WAZ!Z%-Nf;=QO#k1Q0AZm(ZQ+f?$SMsE^XfwsjHT zJ64BqdNBFJS`ajp%-&jB1ej)-BtnDon0OvtdJ~#a0D%5SkmKGzmK=@PX>jiY4m$b< zqTRAIS9NI{-Y{Hv`= z_otxTayU*p8?&{^;v_GH@K;io)Rgt{5m1UL%Sp5UV+h$wBn+I&J zNgY)+3OXVJKm70&cXF5~uI+j610o;+=K7k5kz+I7C&~vOyZ z(BKcTMf2l$GKFL(yz?@iCFu_ZaJaqj5k_I@v}rs^6^R)CG$!K)RlQ}ArW%tb zZ{Rlfr`P64X{tjk_-C@%xl~Pchzz?y(F0jY2@H6Fs9`@Vg#kmdpuuZ*JkY>{2TE$g z;kxZkQ(fPzL?oTD-jboTpn3?n&T`?9*di}+=Q(VF9wQqS9o3) z8LI0yeu%N(?Y|(`BO%iR74)^%j^Z`;_kRc|Q5(2C$-<={K;4iQlhW0XJ}QI#)KnZ$ zR&s^Hk0ilZI==$a7PKZTeNpE#uePI=V3etF(=x#DKyv(hP+REKUYB}7Hu{ErOA^_4 zAj-fqdP+~SOu27eW5@4>lb$atj@}%=hw?bf{&~T9)GKAXgdR#%T6YnM-oqN3L4nRW zt6dCM9Kz&pPWb1eUs$_X0sBjrXYzH2r@=X@JTre8@DLL7=tV%a|ZXSO{KrB0P?(?#iFE5*C7y zIO-&*G_|GHmX-|Zzq5Q#8H{J~Jg`O~;E$lK4G-DKf^QE(pkm$yqPH)A54R!Y$+3>C zAlOasraPz_4%70G9%{}aPZh=a!s~493 z?YC@>V5U@=<}4MM*_!ao6a9*+G=!-YjqmCo_?lmvsmA=~U1n4)C?hOd$%qqzF=R@a z_5-n`2tCaJ zk=FGt6gtKiQ77i4z4MU%?$>gpmMy!NN}<%v(t{!bcwo<%(aIVoLOhu00}$ zV7<_x*IlI@w9UGf>#jMCR!pb>@m~xusFb@gS+yq*E65TI0p&1j1EpyQAkGu5ulfjx zBU`0Lj(y$pests_crjDnsZ6jrCg6O;C4Xs@7^~IMDVf^{7nz-`R9jkY8m@U)>n4#a z14V10L*A2zqR4EVF}~zG!gExw?~>fm(;VvRP5ofdWXG+?WSYS#jQ*RJV@80=R86gS=yZ&U@Fk z;~AAJ+iueK{zwef$QhD#4N!F}?W9?EFw88Z8e>v6n`93GI5B@edAoAT&S`MObI4Xw zk~rRFCIn#4!%6RI{d=vyg+u#u?_{j|5i$=XF8p9HvmaL*>t4eb@$9WJ9b@GoP>6K? z)<@&VWt-G>bnNz`1%1)Sy7z<}KEPV0)@j3kDfGSq5<)!|VQ_#`@4QwXgUaw9nIH>U zQ3DxrB>Oj_{iHLn62aFMMuG>`ZQ!`Wnk>E#x+p0GvnzABMMI&`z4fuF=JkA0Xd+!1 zAed&dJ2jP`#k0T~1?N9(Z*u|QZs8{{hyedmLdIC9H}B%tyI}TBnf9!IOmk@=w0H3b~B5_i%xCsu|YCgh?DhP(;IM>OYRZ}e{;Kz^?1$NRsNK|Y?+ zP6bCIu^=XUaut(-g#g2@65-AJl#l)wE^@I})$*?XJlNaO*{K7@~u#dj)Hmv0pw;L|uvMk?OL)Q9&U@a=%y>q;Id&3tr$ zO}lY0*`?AG^3p8%bY3b+ODF_F5?C*sOYA1oJ;Xew!~7tXRKucFTT|BE*ODzBtkNgs_A5h^OJHW^M!bjB{PCk5sgK5iz4c9mGvfTlC^(cH}vPoOxvM zu!Y$AT-HL01!dS*_5W4MpyB%*A%H^qDU$*i-_;IfA_MBGAsHps{qyKP&~ULU%@3ON zt~40ysEaP$<&Q3@7^>{WEV;p3bxl>)D3ICN+t$qd1O?jb%Cmfbs$?bsW=PJA^2nB5 zea>U{1mq)3YUGhBz(;`O9ZPu@>In#u=yBoaJ?Vi04X)biir6&=9_^z3#WV6>HXm8x zn)L=kk<8LH1khRHBZxp*R`7E48r##u7YdFK;E`oycbYNH`9=$sox{~BpbW13JyaF$ z4ruQrsS2B3%OhB%YfaC63pzXn4sTP?j*bggsJ8Y&>A|UxTLeJrhLg zl*~Y7y8FYS^n+3%Vnkn_W*;4*&a`Qwb|r4!SNyQURp!KD!I35cafI}B1~p-9^L zH^WR!PBs;|&fjdp%_IlO~6DH$85FQ^pi7lf> z03sp6=8W}&?uU-%&CiWiXAZ+8AFql=r||OK$u5ocLviope)|1$r?D!R<&UKy77=2o zg=0zR@^oYKj->U;u_Pe;*8Obu3M;NNFbI4y4k#b<>@h;+REkh|=Iq1MC4v<)_`GBw zEWWGZc(-7!iqdp9XVk~XA5%XN7x8M}f=I*=gF^iZ_~ZdM0u7uI4#3{gaDt&uh0s8< zZNmIUT@`^?Zbxso{0ih6r0q0SzcL?|34(6y& zg|a?fYhiCuMcatAsMWM2)JOY*5Xo=@p&{<_t@P90-kLW}jf0-b&?vB2FB^&+UIMb9 zy;|V)3=cZTwd;qeY{!dDiBS8U%>ltFgfcUo4xpA3T1GYBWQBvew=A+j%JznA;zbhZ zJ~P3L1Dr(eqzw(vXENqu zr&0^_iuxTyRUUlb1|c$NSOM_R?U_`%ZC}vzqo;Y-o*Y#uV+?@A4QUA;ozI>8{-Lg) z{eOprQ@F<0#$=#Qulk<=7J!%(T$YH>7_)sR68GZAP-Ty(x&mdDDu?sIhi_l3*30x4 zK>q}J6FGa7yS{H%7U$*uqCPLKIjHI>J{O2eRV>$WDwyy&x#2JszBB8uu4$KZu2N?_&vU=;`+lt^8;Zcwfy{xiUNK+J zzALkQE#l^Up>s2r=ZW*k?krUxvJ?0&yWNAM20$n9X~*`Cc>%$#ouz)zu+1j-c~|Uj z)onH`OPfnwxbOhWQM+{u6KYIe2#>RP7b{YMfFP85Db)B*bw(E;;5oceH^phugZsMl zu=GE{$?{b6^zo)0GfdUQaZR97-45|x=b^Z91>g^*Vs*m8^c=Dy3eDjps=(!|KpP+$*%5Y(Of%Bc_`JrSq(xh^q!{t6Zy-H`s}(ZGZmLuE$8BBfil1+{ zbdQ85j2%aLzAaiCvn&XmGW~~K8Z*`qHw!~Rf6&;g z$-DU0`1vHu>ELMjaZmejxi+z`Ase-gcoxs6o8g-qO-(llI`l+!f>xuC-v7>IY7wt{ z!VQKX+0xl_Dj+WC#&{((Jwqw>hTDmfb0z`eRntxf2cF))1gP>U(bw=+$guD)wIz2u z;VU%!{D|Ioo33vWnjan(TOTN9BcI=7NXU%3m;L(tKVwDBo@^D)n_vHoH{iy~-9AcP zkgoUBCKnFC#*Fv0jj1{^6FrIj3029c$pm25|k&j+*W(iSp z`SYTQzSVJArXSNFp?Lau#)`LElTfks=#`@QzIA1SLX-N8NzT)PLjz%%&F<#sp~mPB z_Bs25N@Q4P*i22WdpV7LAbXwuC``5;B5C2obe{Y1q3{SFwX-3t8Qg7>;_2Psg3M@C zI9sXkC-$z472R}zk2hZL<<=^;pXOVV+vGm`{FE(xQig#o2OQ)03ejgf-eK`iPjWpU zde;xLj<5bNB&lKBRNQZ+{@>3j43<*T2nm?VuC{Ye$b>89mrI9bF~m(-y{&s?n%M+H zriO$kE(BJ1<2WzqtGj)M#n}A1?+ktw4vUfHHBYtdDud9{NaIp|x?bzVg z5%k{neI4PxNvQ>=i!kZYYJ}YQI|ip%WRcIs@Eey{Em`h|t0`Y)H0qdfn{(Qq6t!)A&|Wk5>zafT3c2U;$u+6@dJfPI*;9dgOa|A zk82Q9iN+L5jcd zxGk9WA`=r(-F})__t7T&@{=}ouDD596H`f&`7`%v@y9Dt`P(KKo%Mq9egTvDJ{|<@ z0TPMb`e&^gbGZ1fO|IAT; z*}M+@lvd2{w<1yp@8H>5&;3zyUDg$#_UZK=ek3UdslDwMu{$Vy{F3WwhfH7rQx((% ztg!RQVjP_wgcxL2-B%OH(3z$)^mH7)J3zsG@7yw#=q{U7pZNq8tOv!MCNkfit-Gx) zv^Ew+afwuCNQND9TDc2M$)Sx0e}N^5ai4%y!ps;YpdIstjaCmoj#(p*vk8OG zVhW)Vw`cxTBsN@)GgDVB%_xI%w=%b(c%@wnoM@&v@pZRtHbRs`Po(z;(A?0PO_R7q z5X;W=ccL-L&hoS$)!P)dynB!S$P+ru5q{lqyA);YtWCe%nm)-&F*!CsBjOip27I`56cPsO|kgxv;TVTGm`;@ zVHOfpnAzFcJjP@vqN4rb}8spe9IB6x2#Mp|4a(+!MF5(v{_1Nvy6GbGpnzF~eJyxV!8p=5Jb_ zEnQ(uDDiulLdhvjhbUqB0Ud1JO~$JK5x6zukM|C;MWt>9QMIFz zX4a4oJAXABQ~!jki5wsVa}TFX_=!^QyKel~_QlrhcP3*so8s(#svQE~`-7UR^K__?AWLTq&bqq+`Sc3%L0wL^&zmkK zcpf*Y_4xC->a|st;=259y_6wa#XWlMKevAVtz_~5Z2Ej4thUI3z$ge<BXthyM=k+TIB4AB0|8{ZX)8x5&=$E8|y8MgT=^+a%w#IUx z!dlqrYrfg{)HRcDy)PR^%jjG79is^Sej?Jrc?lw|KwN>DAD{qT-YC-a6Avh7UEi9% zb>*_rVRZMm6>dG!KVc04%Xu1Mzu~gZ+&s$pS${^U__1q}Uipu&(h~M}YqLKJK21|r za@KId7i_JJ8;k1R((}M*`sbT0_c7#L67eq;<}Rx4o|V4e+;buVJxuKO>&-A=@;{0gB=@eZkH+hyC9-_1;#Xc)&~9alHb@P|nzww7eBHp;fI4AHkv zwncgkz)MIy%QvDxvk)(Gr<{XI`trncxKlQCXsk2in>AIgF6$Pqnn=U)=Z>q`{im9Bm_x6G-bR8Obm1D#+ljhZM%a#yuyuo9%sl? zeD$Q7nTi%5%D$b`o@+aJn(Kz0Z%0y>TA)gUr@l|lwu)JF&arzQ>T`tA? zaL{%Ccx4`39_zOo2nf~1P7?f2>|xw-lkpB4qwvIT5uh#dzL4pN2hwg@+BW(%+#;U@ zz6^$arnkwfQvP&>&UjoWrTLPxc*{~BW0kr$`Y{E_;RY$tIL|z~d(S-eZ{+cfI?(}w zE}qiL-}YZtwakSSuX;H`mFae=^(eRV5oYqD8Z1Qt45R7kW~DZZvTA248LxZ{aGbgH zvB-KmL$h&lYj)IVl#V%wx=8@V(iK9T`{Evb-Yb%{5pXJPELF-3HWj(AjzdENAqj;{ z9ha^&F17PZUs(!OVb_NHxvN6BZ36U6nd)~O{`21VC|sDdAj3kp@?<^f_Mez{`i$5s zeI{a$KBkg+E{74`kYP*m_`a+W-q2xgo~`m&9-H4MO`pJ2a85I|ZY~8KW#FyEZ&?je zNPq3uytfoTX66^1Y|GRCsP-3s*q;8%Oe!>z=H<5P+vUXgva;N#MH>?Y;_h5c zT$z~5=SQx%SAD;IgY1(;ZUhU&h2&u=(E1!pT```y{b}+`uim;U`3@R+^;Nt!=zck2 zEvcQ~?_OBsHv63wIQm7Q;3)c%3jJO>yqiDkowhC5jY;b&d9%~PTup}yUEYr^Mr<4h1^a;hbRmh_EUaw3c zLeIu;+|*C&QL68%-dY5xj9QUA;d45(Vo+Dzl*A;-k@;xi9N)u*2`-*>IOYVu^}E`e zIdre^T8SGIpf#UOX1BRYhrMoQbWxnk=kp*P28np*NOxQ%7=;19r}}HY@^Q=^VP@>^ zbbII7?Sq3l_@L2Bv*owwiClFx7i9aWFmN=|Xxl%gR5RS2J&oY>6@$czqCWCIkN|mW zXV0d$MQHk1HC42^sBRZogZg@LXfQ~oE#5|h9{csH;*-aZuhKO6R5OzYspYiFsuQzY z`&klj0VmQ&>I9YgHtM~J3Xi><-EqwAamVKFbX+;MQ$BmpxJbnE zlvAMBd9n;ceD`M@zxyE~)=`%~N87{QvWUvsGcI1_tQpOh2-=yO)4f`>GZ65S1+M%tH)3M~#a3xHB zsZXY&#iDXG8LOs9Cg;9dG)s@4Y0hdRNnSbjMIm;jU$*MK-^=df>`$-eYyN_JsQzlJ z5qiQ%K*{6E3^|jWV&4NQOs<$xG#&CD(ayJN(Ou@z+!^1OIpfd2a05>wszS5h(x0|I zYd;6N7$9};$DOZ9vwkMpDb3aRYydfTxdPvvLz27O5JE8YH`) z|BgKTCTClxE!e;n<+t8Q_~v_M=Me?{d1<`J+1fulGr_!Aoqbsk`9F~wxZE(SZP^V2 zrL@IUzrsfs$q?^vlnz1RQzHSR>@h26e;=vUb^?PP5;=# zq5Y}bN{%n(GkS$nQ<7#H$Of+qY9irjbb+Vwe306%BEpd!y!k18SOCNS8i?7GpNDq- zrgGd*nh0<+<{a&K+kLbhZXwe`^W`gty&iizc;*(Tkt%JNNh-b9r*4b1+)nzI{D>C} zY^grN@fxS(BYqF+Zam$H6;FIx)1}7VL*BIeP-i-MN@=PcjH#sM<;{Mn40VX?E<=F8 zIZ_wpDMj(KXa6Vc%%qJbzPI_-Gu~$I;{Jo8AnV-wYi;P-wRFbLv}SquJV2(NzOm31m4&yi(+yR_PnWhN9cc{nr$PV7XvU|xAE7r zyqe@vN~^Gd$^(ktJhRIpf-EG+vI`WdO^s48Duh8g&!}W3oTBw^vb|CT)4&4NV7cEC znM&&&W>NYCgc52CZhis14NYayn%d_pzOee2y^B2$jaBKTZ)l_J$oJmFo;z+6=;|oMOFPJ8w!T zjW*`eKv@!9Yx?KQUMxTy`KwDG(T-56cN@m-2FuDH2lR#;E04udFg_B)ucoxfMz&PF z5WWS~q!+5mulGicakNMOeq;NGi5Gmk=cL$RmR6(>k{$1{UrNp&+A?ORcK~ho!0nLW zzLxS|GU%Zg?yg%t<1=-5t<>Nhzd2SzY5oAn$i8d>bm@E=GX{VH*u4g-F;&bi<Y` zvX`KeD6;aM9IKSALOt~!r%Z4AM>kfeSxfq!`>agSbU>KdpsSk>Wdp*Q1g3zkh4jI5 zPHCg*Tk>v~ogHB9#f+jwI~~ZwW*73VJAj0qMJZuJDHVQ6Qx0ZX##a@H zR#d7zL9!~hSzBoD8(P}X+DQbEX^*=4%M6NOfmwG)4h4E0)3(cRz_s%7$BhV-sVgY7RntUaqEwtX8A+oUNy;es!3aw# z8ia<3XsUUF?K-d@N)(=Hpk)~Q%iu-hU3dQer$s0^wtGqoSl9(%RR42UjvMxuUrpAx z6yn=;nL2Z0zO!WZNYVTPI`Qj*h5~5ClRTd5DhNd>C~ zsAv)T72Hx8l`>5x1Lm8{VGDZZ!9IN$aeKi&AYDLYx!0MO5K6&J4|K15{;{DVQO6ybMrueOsGdg z?1rB>i(8nFUus`05(*BV4Cv>986>@`Zng2MPpo9s1~68xH*2S6GuRAqn$07Mk3{-b zQ~7N~YFeSEw2_RwIDrB^M@FqY=AcZWUGmxId0MqEVA~+Oo`f5}xoOlOMh(Z z!Rp%QLDFErj*%VTE^#orL5uE}1P%nlsF;(ERT5+1O}Gkds+44;AU{=xFQ7gO_ zR8&c1`jjUy0Kh$+eZGbyX^rs`z6awT%H{%gF=E1(FVgV{0vuyz*IaZB0{-ttPt7AIqYD2Mnm&{u&bZtoub zW-B$@1@jkt1&17@Hqz?3Ymzn$jU?iV7R21BZG`YpbM^@ZsWsGJ{%`F|h@Ch<;V|G~ zdrbPQ8txslR$+VL09drDmg!1?ZO$xxM%6hU=fsADP(-l;cZ;x!w{v25H0MrQ=W3&G z$Nyk(P*+S-R`KPjf0AiFg4x^0lMd;@@FN)Fu(##E!OL>Ks?cC~b}6}A_)PO9X~WaM zs_Uv_fO9!BgT`~Ys{V`)F+rqysv>6Fv*)n~CiaQb+l`!c0a^ceHb5?G61b-%`iVd6 zY9jCQ)Q=Ch%#PmG1~EF%x|kxY>RK&|)nKw*RvB5!WH>MDy`cpz#I4GNU>XKS8zaA- zx@zj6Z~(;|oJ8REA#Md{q;GkUZ(pYw0TZ+fvVP+O_6?F7BZ=HVY5ELzF+7P>1)Pe# z`m7}2RxL9hy@@Mp2RI&T<7p+-ZI}%GjHW9`LQKo1a zAiwC&97P;?kjtW==&Va`aQD@3+lRcsU2RA7lJoO+@_A$07^RBuYi8)Mx!tR>fKE{q z+!qkH=c+=)`ds3F zq^G>lTy@Cdj3Ln$S;zA_YXEOGIUyWjy#nlQ2}BDteEH7c#*ySg8JPWq@EOM>4fOX-VRQ1jq{)5c#V*GeyGF@aFySg6uc6aR0Nuf+`Mfa?fAiz>uKESdcS8 z{yenGnQU(b5Gem_Swi^U{xf@y-_Tt+pHwsoTP3{V4})Xl_GEzD5yb2Yp`xQM0&ju` ze2?2ESNk9~SP5LsXOO^Vw`=k?$FyWtze+$r`5^eRpFlBXLD~(AfY6!~tD_h#TPnh0 zW@Smu!*eNz-0k>Col*G!w7KL9a+fEoXk&-P)B>uhmWh^Lh*5>G%16EeWV9Ad)W5TN zoWnvSI2;Oao0|WXW9MK;C!RSRVp{dj4G8fw0v@X>LVs00f}smlETDPOvN++rm{0n8 z>&Ceb5H=sKfFb`w?&x-16dD5ixPjgK2neB z5@-*b@gyR?@k`5%bIpjkRR9Og3ncCx{P$1XIMS4au%9aQMSrvhQlD$c9R z6gFMBa73}qD#W9X>{T3CpYHyXEW>wedZTpr*mnJ^CJN5%w(|%F@)rnJgu)5^BkJGI zWhJbdt{r`O4isws-rjo;Qdu(N?1zhffy@*RtxHj)xND=&8PF#=Db2+L6|SDE7dsP* zVTE@E-{vOx0_~92?!KP>JCJs`5#)T`_ZDPy(=z9fef<$E8$`yOb)aKrhj}wR|639GTRaZ)D(nmXMqvhR)&nUs^E3My$#Ya z!WqNc6Xewh+2G1iKa>1|?iXqw`595ZNaj5?!R?px&U45~?n2JJjtrHkLnrzgRpBo~ zQ2_^&KDR#nV1i#g@I!lW0aD~X7QAY;NZ>gWDvr`4W4=5eirUj4SA(K(=_F%oj~^oP zZ+NB>QFg{qiH@2bLx=g4t{RkA&L*GKWj2Sxg&b_w_}zI&COMwf88+qnd9JWXOEP)3t7`c zcvN(u@PvVNzb*Uabaf|#)ZyIYHevq9!hz0+u(>+_sa@51WMjO#=3;N957Afwo#c@K zUx?PFFy}`XETddi%IL&0D6~e>mTniTnNeRu4(#}!+hSX%QT-^yv`4_%aD+SPB~l#s zT5~%lYA&9OC?NhjGkQ<=t0S7010ndWh_*>4oXBoDy4;EuZK&ahSP3TDBBl0dlcI~s z3Bnyn1-YQRN{wuN(DOPhWj#qwrnNHOaH9p!0q%YQG~$xn+h;EjFteG)}N ztp!H!9|-M_KTPr1m&Dy>Hzw{o?7jQ@RTw3K+NmpeHwPAhbNPtk1#&NTVA*7lHmq^B z%8Hz+tT~<09d@!PSnTD%0FL|`tb3J6g0;s{tf`bzad(IyrxRW6f2XB!c_9YIv8NT3 zw5z%GsI=vM{={6Vyf3^Fx=u_}xjg`vuxcr~k8nuahaSviY2(GsuqU<$+u*#aD|P1 zcd#(244oDrzg}!V;u>%8&}sYHZpzfdt;3EL-t9Igvnh|(gRR4w2CRb|u1e#CAY4G=~Fuddgl*A+2N_Pk$NrL0hT8Ob0a)w6|WnKDuO}#5&La%eTpTQ(t z`jJVxa;si9oK7-}o-)x&QbGS_oXU}O{q!38o*C9PSGv&1 z9S!GA&$fz#wnp>gfM7AfKEMS&h|IYN|2z7+n2!Stg%W)jl;cqFH({=l#qb$V?%?@e zM~1ZO8PVWGx;qVghYf?N_|4j#{qO=(s6)bX%&`B6zhuR#680j=$)9QeKc|&&)Y2~e z6+dl%v+8$5q80A#bnAA^qLA@~>5IJad`q6jLNn>H^qwiFpR?_=WjP0Mju7v|+vv{Z zS-9!v4JW6g{$*_@0l#Z?G1lL=5lN(K*C}6y)+=sqslyxal@0<{1C7`Xf*60;j4;<+ z^Wehw)U@cwA6`gTPOO{pyC>=_@YR}FRdZpHBC~kk>pAK^tzx=BR!JBI-?{#60Ch|^ z5Xvcx&5F(x-B#>kMM`^4c0r3_P_H&Q4w^Z}_K+ikt(P8LDi8j)H15A3{pIi%g<5@z z0F}pI8^j{O@Md8fVKw!Lmb`T5Ly~hV3^9nq=Y6v!7L*`ztg%%*kqp#2!W^Z@m3I6^ zOWM)DUMMrF7cCN~D!L{OsgkajrV>Z1j#Jy%O3eTKR)XJr9Mii}kYi8$T^i;d=H)1c zqu)2U*0>qqLzLKGTG7zm!f!T~c(^Rr+K4Q!Co_&P&3cws&aaPX=#H-N*b!Z&ADB6v z2_8B%s(MHbhXtG}PfRYJ*;R-3O$v&Y_;1F*1F)?j{P=PA+zka4oj6@h`=O)Cp=Fq} z#V?<&W$q$7CnJKC?_cx8rgHk`P%`}#2mhVFnYuSGo|Zdpa=Ko8AOv5kK1}vw8yXBu z_Sl5wQ=w;WcXAdtBzcxE*T}t5<1fdr`f(80%C`2SRu2(pv}7vaxmW*BZIeJ_a|zbN z)Q2CB-y;a1$P#w_!3`40{}Xo3|2d?XaHQ%)(?e2g;|`PAE@*@&W=0gw!Atr6-OAQT zZb)AalRHHyL9&F*r{PnK8ZabsxhZEDt=%qAq)(u$MtwzHEa$-9LFJWSc3}xDhbJ~y z^`ck-x7*M>UrMiK^TT;+?;7@|&C$w5@7-#;n<$JE57Dz#X2wl63e(HU-`=YBUd!x5F>3 z7!IL-lhUwl0c1_?@YS<1c2BiIH~JCc%nXDW1;0WwZ6%h7?F%}JNH+0P&!$4Mz(qi^ zH(zHkNK1RH!6A8XZ*LRX)emB^h5E>94i>og2)0}vhi}(N%(euf-*VAFftP+lx!tfrGyel;)PRb<{MN8f2*+HBEHwU|^4Nwf zVGGE0N~1i^EGubHqo$LMJ$uWkYzEV>7+PIw6-7;=3XVDxIf#86#03=@yLO!& z#2qsu*{NZdR1jh@bfjvu;60z&-(>#~qsaQ8OFU25%<6+w|H;#KGZ`-)J92Wd(=(&k zU`dCq^EzXMa^X*QleA;bagl#(j7a|Fsde}xm{U!M2Lh5&c4xXwF2xt_;0_!~pRN72 z9u75(Hkw@`t?X#(+Z`#g7`Hgg%PrF|iLo4?kn1G6`tk1X8}Bb{%W6pbooo7--Z%FV z^%)Kk{kg|q)gDjb7amK=A{!E9qD3NA{ty(qJ3gbIIEiom;*vI;`KB4OOFbWIe9iIx z9g~+cYiCnvIArER&0WPT;?xKT_Uh(en)?0(#^VwP`PaF!TT(u-BC*a*I2P9_7E4+5 z8yu#q-i@RC)e)P5o^%1%R<%KNefA$$-@%N!n4yVnveVD6CH6w$0A}KIIQ|CjuMMwN z;k~PYU7OX{tb$FywY72c$hwRW$#o6;4VB%z;utoY1oJaa>VXUDFL*gAWMrFM4KE*d z=MPjs4oJpu6Pl*|*3wt$amf426p>*!wwA5A1^~9+ z$>tw@u%RWh_7c8xr^G62YK+d3xmin3>(cW(mS@IP zexEI(_s-$AlbzH@G`C><-4<264;Dk!l@s1}S?c@RkpihE9z408cKYf~K@@^GG#bm%i`Z{t|`2Yf-q*B7*R`3k-(jE4)N+&)_3l7WEC^j*QWVo}d*%T0On z!1{ZGm~eI5vbPI(@LGOPT>X(#J zW3)uS@uouy|6W+LkmEY@&I_w$Hk|#QWEq7>%uM2)(h7P1US{C%vvEm{h1_03-%04tAg3Dndx>#GQYAz6FW@D!sC zGi7;Eu^3o1A7K`g?^$tuL&B!z|76dqbUv*PcJ9hA_by+-J7Q!a7_m9!30wQUcd$kO5;>`GM zAq@x&*>{R;CzyX*z05z10NqL+9>|AhKxI^AoT2{`RSGu!#+JI5&sA z$N5zqaLJd}m7*uM(}8MDuE@~ZbTgW%ubre z(V#{9jQ`Uw^Sq_ytu`(kKGXmEskVffw$7eJiKWr8BG(*?A}5`#lghWBl_(N)EtW z`~Olg76fi{DSNzN6Ex_&W{0n@4)AjW{+0!qV@%_tz=iXVUSw?e^y= z+HE(Z{%2_UpZtmNT*Nx&^b3hh2BZ;KA^v?K-<>Q`s`vF;&&<(_j{GLoJsb7vpb3D+n5kNh7;tvPGo0~;6FJNpX#?7U`Vh7#8k6)E zu{D7Im|a+IG}66I-a6C0K);<>WU+p1SmwR@L&Ft=&yZ7Cc7kbuPTB?KLiDjfMzM6S z?v*t+y7@_>Lx5wvyT%sDWbyKTdgU@kbEWbC&a~fKo^S9Y6HKM1*tG{@>u=p_M!Ou< zs%5dHDUWXa%znr6D6V*`!?@D$`$QuWxhZ$phs<>TcC2W|@Cz}Kk0uBLzsd&}UWE3` zo$j|XRdLRzu?6vx&_wF1%Rm$_mLVOio2l`{cnO#AAbWF*mvk77M%r#EnzsA29u_@X zg;HyXn>ssOCEa&Y?hD7*5{;sBhg-4weXF{?tKW@?{uhB%A{%ffR8fTr)1UimaS@wG z)RG(dyU8K%q0T1n@%an3%vkb@#7Cr01o=YA;?_7YnW@mbA?%^cC{!Tow&KnEuA+Qc zZuI@Q?WLE9udUQe3+5(O=NU>c> zcqjTU@AYaPyM2jH>m{NKQFzF2k_%VcS6ooyZqzmYi91PP=%L)UFF}-L1A`Sx>C``>JzHWLCT}`R}zhkFJ@% zm`d3Xruqk31-H%z`uoT)6EE*C*wSJTJN-87_O~j=PhdS0mW33%!YsK- zvr_&%Qfluq?@sH|rTo^DmNrDCXWthmOG@J)1-Dls z9T9%~!A-iVSKzD{WKOPsZ{_Hf$Xms*u`1fz9g_!lU1Yi^4Vn=LuU}&7@8^rU^6>B> z%Hv3c+#t~=>{1&xYs5Fs=S>Ug@nyj60cfmTaoYB>-9vjiO^nfBxO3uBJ(qK0#lp7+ z-1ih|r>ln_XAU#lBqDMqhCqk+(DJ=fK^1=!BQK8d%9*Y|=<9uUwV% zVT~lNZ4HiQuZXQ(=|h)jU>u}>trG=CZ&W<<>z=AO-?k=&!ajrRX8UXXoUpy$34rN^ zJXiw5&q7=*3`G}Ph0l)Ump|L0f4_?RR70Sf4EsLsCbTG#wu0|G@zB$E=pi-#@0qFE z0^3IY#JeqPe@C34`>6J5atFVt(3%Mgw**i=Xn7M`G3%6nG7X}t?w%Nu*p5STkkrhsw>)U z+P1sv)q88uv|yBEkLbOUx8Z5`ZTx8S5qP6{6`^AlUD@d-;DV)hQ@=?0h75r0-d-Z{ zO4GklIxxM(z;OiI(m@o=aK{6?$|SIH27hO301SDxIDbyq*CbU zrAP9MFere>ooH05DcW29AY~6|5~H{h?Cjg674xl|1zi)fb|(8gjirljJ{)Yz`o&R^ z*IX(>7WZHF7Dl&y89fvqDk-p$fel`MZ-~~>DzS4Bk$?e_1*!q&t%QtT@Rmk@$FI(8 zNHR&+xS42Yk6n1ZS?ynVX8hj0DJ|K>Hx?;re3Pd)I)@f3>y?J4_f0bSW-5Et=wD4; z+s7>5RoT~-^-uA=Cn4v`gO`C}28I*@SCy4FqK@0~cyHgEzrMb1?IbyUQGR>zWXC+T zA~k!$gcAG$yBy|$7;PXOrlZ_gD;AenBlf>>&^cfrB~1 zJ}%f+?1+DxzJH*n5s3=zchJREH44I?h9AgPv2Bja=DT-ldu4nPXC?-KK`oEZ_*z!l z_L|eI=10Cu`%Y`tnakWft>v~1`3T#+G_AsV@|#$i@a~rq3o|A8Kftnb!)@)2GBiUp z)pKzBLBw-p2I?MBbk*t?s*znx%DmV+bsl}dhr>~r^Wm-N2mquIObwvf2<0}|Cm6|^ z95>Hrl$o0H%MK`rBT*I&B{lvIcpzDvTE6AIv^=tJ;oy^BehOPNwBVj!j=c~cy_mO` zh*4fKYnAIDGVV7H6`QTq7PyyvsP7SM6|LzX?G?8Kg=vms}2;c~}7z*n01QCcqZE zQRe+STBTDO9VL_gXQIyVA8#DCaNBzQp9e0*!;k7A{5@f}2acM^-4DOvvFAo{A(;Ax z99-bFU0$&9AKx|iW2C`C-HyB^{*0v+%wd`CgBGCpkiclma~WNe?raN}B9uaDAI zt0apA{JnP%*6-v9tyCS7CA>^sp$h6U5NE!r7N+EZ|71v(KtIymrNHlj$k8m!mYBk` zovgzx3rHi3XIVzs? zsQ&tI)%(EQ-25`Y`9}@n{+Gs1hhhR+OCMlpCkVe`pX~zC(-)zwPv>qxiw4xnRR-*J zd#-Y=Cy~q+D%r94twUX0>h746Nw^>0cK_FQWu7pmYiGH|Vt+=WQ0BRA1GaVVZ4^*e z!Z)gmq`;`*4&d|#FMsr)ROk6R-ahFVER+(gh0G3)gG5cnJia%Y|BFLmt4St5t>oo$ ztiS3z#=Lv_!@$`dKO|5HZn_zmh*Ai;Dn=co-2m{5hG$gro~R8l$HxT85*j8RoHyG? zPq+x~o1*IIl^$^~YC`B%XCwiRG={6Bq<*f0Gc zMe_;nI~yhQdtaVkYD9zqo|HTo4-9x@I@~)dUgHNh74CUQb`a%YV)w%;>*r#Eo2EgU z11#2RkwJdfxcc)UTZTycdM~qE(d`ttg=r{)GPKEDD#bJr^5|d9W#Jy{ih+Sm`(j$& zMywW3%uJ~-7;VP^8ztv{r%*zI{4}Y@b;fNy5fg=Jtbb)!YryyS;&zUGva9KOK=D{^ z>z0b|On%FzpAu_ak6*eNXh#Da7wG($(Y|yaqgO7U=iDAEQ??%|F$+h1hJ%?#RksFvHJU z?2VlpLoC+32BY;{XSx&RwczR@!wc%SE9VF|7H1M!s8Q;b){tPLBpR*y+2$j}r@V8` z`ZZz`)^oawG4JX!)23B?BHV{1yLdPawgcAoYx?O03(Pn!*a3f3F`&9%CgPC?2n`%+ zMSyf*f6aKNnc-_fcxRi4A-xbaV!xts^?INp;=pr+`;<8{HNw_~C$En`!jSvP&k#eJ z8zZv{GOPZTvn|2nDkF6>qyoRi@v8SJjvtt=-Xv(0>O1E>4KVefp8HU4{69etFx9Si~WVx);FE@iukOjO*^{8K*R7 zZbG3%bQGwI>YeMYH|xJPz`@vVAf2L$s=HY}3U37uHJAT{ON)pa=s zhbz37bU|oP1t7`nmB1A7E_IyoOw)6M}wJNW!%9s6C z>^I~d0RxYCUe34bb6EOSsc8n>54(VxH9yTpuTz#E&h_7b-QJ!j=?{LqDKE0@ z%|AmuB$*1@1@0A>`m3++-;r(ND^S8w=@1rTsnEFDPIaB(=x9y21#^kLj5sTXFUr&y zSpMJrHE_wV%9a5X28_w0=bNpUE}ZEnU2UJ_p8oXcV`qJ}-VJ@@cM$Hk=s|7SY|70= zK|?hB>6=Zg>y*2qEw?pWFujCNSWC2H_u1cC863JWE1qiYk8|}4XzVClUh*s0s+OS% zUrspuyl|N8-nC-mk5$lPzR|8|$D-XOv)JmT^k4XEp85|4VA3>4-(-u(u9ci`CSRs3 zf1?#sxErjs&g5O4apL{|XEy!FY5+46U?lPAkjb9QS~zsRO1uy#B}o@9IEy$nuD0IrmUY7ll}>m`n=}q zGjOyVyYXeC{wGC?yJ_Wb_T`s;l*27j=l%=j6g*F7X)W&%;(At>XJ{qA zjd7}b&DAiCuUyVY&PUqSw9&7$Qh0WA-@iomJWgbv#+BU%ugLoO?|y2*HP;?XKasbV zbEg%uURsA|`RTof_|Lu+AwL%{GA71cz8OOxrzas1WELOp+-5}Gqfd4l@0CjO&&6_I zdewyN8rvb93pkAj2>$sMo&_V|))?vO{Ny?eGd-uir|6P0$+q()P=$YxHHN8LVLQ*7HJ=gkyUOC7)Sz;)aG z3f_Wn0(FVgYpyEXbk|{dt0tAM|&(p7H%u4N`No!+uXeB%;>QmIdw|q-1M#@=;JU ziv^S-!Z$!x5YgLRT%|+aC#n%((gx0+dPI)is)dZ&$J0$(*?cGdQDcS461>8xQqo_d zSW~Y3cUOG!(N=9R=Tx%7Ic2zLpwP%1tb@c!(hwC!%Ay8jb#F3`Ke`rhq;O@>F-R?& zw?=@eM)5p%yK3>B6L*n0EYz&v#4{acHwR;7*$%{B;=7-HW|R8^#{%`&VgpF;;&9i& z1byL$Q%ZLIl4^y=zxXa`xNFQeAf)1T0sFxSFlkQnq=BQTjd*`Z*W2#JIR%fB_<(%M zPK;@~>{$gQNl7jTBoV>kWx~N~tYIIKp5fcO3d$8p!?XtmZTrOtdYlABi=}kkXf}rK zzEi494k)+&*FocJv1c2*570M3EnNp>f%k~32LialSN7>;CxUe3W4kAr=B>86;?@kh zNK1aPIDhE0>#Tb`U61Pk;pWw(nWSr`eJWJ13UP0>9B|Uy>`EgKq+(S6#v-c)(I~Z2 z2DvgoK0+KqFCi}vyE(nhQ@n;1P33n^V1CeHmaK&Lz44Qe)?{y1_1XkoCFkq}iv)hM zgjGWS>`WMY{UJs>gSKcz*RWH!8kB~fp z@Wi4w)Bnj-p{$-YF`Qx0Z0|pI-@nhemD4xC5-^*`MNbRp5oVwXL5vPlb$2=k`$tx@ z)GO=$wK+c#lrsD9V$ZUZif2HS z?7l;|`S4}s$+J$@S9J@iG4KiJS-tTB|AejZuhCb<`{mbmPe&b_ss*7LVon9plbI~O zY#n}YI4e$I=2q#ITu!mOUu`v^uj1S{{pYuffTi#R#WaZnS6B?5I|$aLx?m#c3d>BC zFc`oJ_U(AdF<-EUJ4+WkDJGKDRDM-`niD5e9!HjNJO-Yx$hr?r<#mg`Lw#fSj+|N3 zSN&&FiRf2O{0kwP1EE8G8-Lax*Q>23_f45h(a;QekzpZw)=$2W9%0pN)Pq?C^183O ztQr6;7rG(~)*!mA^!IMQAk$y`n@98WPLLoZGHSe)L9vWCX%6)t(^+KGkY&vb%9Qg2 z0-lrtJU-tt`O@pcEDBFx49j1~Ke~5vt=R>=t^O|7OUGNniCEqT@h!j1OCtF1F_^0o5k?@p0nQgE(GSmS)brlJbD;KKy^NwbI&Kd` zz-Il5--Whx1yyCY+ZS$oKOTvp(>pkHdYTL??fK*8TV>$4krHj~v5;x! z&+QC8E~QWh|Acuhx1;%g16#tXRSw2VZCLKCrw?{EEPhYXZ0=qPsb|N9cXw|u1?*{k zU*W;7j5cf&-Jib0SNuC}!LTNq3bXfaNhL~;*{*qW8Qj~b`fuoX{@6bftMt$G@)nX> zKo_WRF6_bj_l5+o>PNYJkaq~ziHy4ni7Z#(rLK7s_2&`ql|*%#8%TQJDX{r?Z_Sm2 z;depA#GucX6`g~%NN0N7zk$pZdH34^+_8v}Oe&!)V8ygNU}Zwg|;rm5C$Q;(uR;^k12(0Bl~WsBwMnM zB}>XW7#hYh#{BNb+xxq|f9Sf}p5;8}+~+>`{aG5jJ_Mv)*1j<7-9+7kC}8GoA@hJE zh1vsf8!+SPNs*u}uwS^Sa9UhLSJN)BxB?>MLSDx0&(f+u%m4V5x9iSskZ5^T)(R0_ zX{9A{zHp`KspfI#&SN%Bo-OU9;p%>~5oAAlbJ@j6N?7>qyE>ap>m<)6lEg}n&fOy6 ze4BT#B22JD!+{1TkSE^W^jkHQvv7Lt=fO{0dyYN}vBX=&GWQ2ck(vo`O zmn}A+t^xSO+Wp-nSW`_OrwfTZ$WH;w@D5%HAcy^b6Z zz>V=l_~;(p>6B~P(CiPwdf-97~)E1>&_8ZbXyf< zKn3FB-^!L*|IwYppcVAmmf4adR%#K=G*9$Q^G__E8h}UDb3^j@m#@Val>)xqxyUY) zF_xA)-xtal?+|5L{V`4qw4rvoCq~Ec zhB_QbjzxV!b|q)W-ZH1jYovXzKvARA4eBF1r)_G$9XYiQG2N-05ePUuQ-#|`a{Hz5 z+=hwJnSfQz2Wnw)<*($yZtzaZO_=waI)#WQ^j(+J#6ap7!dstC(l3gfs1QFtGV)Fg z=I1(M?K~iaap1y%&h_1;bGNxJz!W(MO`t8aTBK+R-9rnW2Ec!wKKcOi()C3Ix*(Lm zeHZ=l<(23dwFGX9J5kcmujRNQGvk!?c&UxIeAdf7Ky|-u2}siPa6+S?=KYfa>u6W) z-Q^#wSPt!c!Qio%HB$cq@1KbF*MZ_G-IILE<&IB#5w#Lg$BGOkd1rluR;X(($AGCIu9P)YhacXEbM>1EkbQ5Cy3A3^_9B$5Kq_j;z%r%PvD}X3VJX6dimDG ze%tWzjF4gIok{6)KM}=LXyhTLqAu70y`m4}Nu_aK??LI?Iz$RSmYXp5Y3zj(EBT;r z9qJ8-f5aSS-!K*p%h?a~grk_9{}|~4JLUd~K;%=FDVm;i-3^`vt>7SVV5VNq)iV&bW!5$(FmsW#P~zs)r5X%r7m3@i}QR*2y1OyVRltT)>LRduUNe#>uD!A_^( zO$nHo%As@+gf*LSgG_q&w#efHd!LhB^fEsk*Kh-xtO3Z4uVO*R%K^=S{+FzKG6&oP z*`}L0c|V(}qjXbbP9dKVoTcf@s=sbwi(k0<|2jxGZ?wg9eOfR0={Fxf)j^6RjoBZw zb|TmYAR)n`VW~I$^Q?OixlCkWz~^)&4i7RPj=T-GuULZhFzDfOH@qGyVgzeKDfL+2 z-Dtg0$Cq@`BPEQZ)_awu#f`-gmrS;4&1VdB+K@`;wMCx>~m5UziA(pM}9NrM>eu@X?BkqbA!AeRC1uZVQJ zkd_=qG;FLEj(Qn-yB>Pu*>Ln<^i|c(?S4$}4E$GrKFL1_q9bT-(Xd%>WV5RLW}C>x z1UmQQ2rozwpmzCdt0H;(@3jj?r$`86whOX(6>sFKluP-`BBX@J5=K8 zK`z|RgYxK>Lktc)_U)-OF2LAXqj(tksOHF_1Q&AbV-z%v{)ejMzY$}IS(@rlWx#~y z{uXAEdfY@%z4Xeq1%0VOem7cJSU424DG(F8v<_2PwWcY7Wv4_RB2Jhw`9{xMr@;MY zX_OtqWMtJgkxtJlc!K(%2*}BGAj(YG&+-7m2_cY0<;H_O0CI?48P&-k^)V8-f>uA# z??(l7zLq?^KO4>tj6wv&|57JUV9a;Lf$~S-km%EvaS)=}SzVW|@vM-$A(#W& zuM^FbKPxbvP}ma<17vS!zW=zm-hKDloU+TDsUyD&)i=ih6hM#d1D%S*Y0ll7C8-tq26{V!u zi9c~qrLRu3DFY9k@Qa%2Qj-9pxSE(_dz^|Bq8WGfiG}rgD*n2S(~}0?Fw~|X(-2*F zYbj;1R)nR^S{6MypR~VbITjvL6J?Z8%NNYhwZd4VvmL&lhe!+5D=Qi5fPR8M1Km`H z@`)|d$+vlw1IxvzLo@(fRG4C&-U?})Vu;JOm|b7qk~C|ccMDJ)5@I}@2q#oXI};dT z`-W13!+mjcFkga&$VbQ$-?NO)*K|vyz8G3T0H0Tu{NZX8K!i}~1@Y&T>H&0MIka=s z5fzX0AUmg*zC(8u6}&}l!f)7Z)k5ZpHliuD+YZY>bp-Xi!_^(S%?Oosy~T3#Pad8& z=c@BT8>piPVd(jQFHwZB1)$sS0{{(%lc&#|Idc-Bj_%@gpSiY^Kst1>=-UTdF_H(= zQ@6BLcZ0=g&j7KLJUXV#dEKRGt;)B8WFW!&&vDNK=jEZ_Mhx@XV&z29SEK0v&b>af z!x6tBtDt+s&1B)H3ub`~3gN0A3of4a+uxD0avb^Uy-kdF?|%9#3-m9WbWh#Wzy)(2 zAUak#bY{<+JfmDkNIDSa&wAG!Er!61KG8%1a(~AEK5<21fFM)xg?r^P zAk0t|2)2|t^PUd!+%<5BL&-A68(r4*yMWFW3BQ?}VLuj#$kgn8YIdvywI#iq7>&ur){WW!BQPbY%| z(x@YBv?*JTwJ$<0Yvdn1k$wnsdT^Wax^+S8?cp1GFAZ{mYH<&klL()Jelck2Vi|sI zBDwv8$c4DhmGyS|n(D~5(c>sA>q0-=m?v2Lb&XEJ^_l^SiY*s%l=Jo~zVdKe@D!M{ z2*L3Q*2!~$Y+W&2Np?1rG`y2UA95Sjb_B1+-daNsMhEf|v$RpXc%kR~+`D^$Q2o z-SW0AJOU`J{ucHwFKG2i&}zxA#wdEQfRwd&?iSlrM0Ksn+qGgmj?Pk-2aGqy=}Bas zFrWnhyMSmQK(AT)?SVUVNw30Y+@Nyg@sUq)ZXAGSK@LI)L;kSP4g5+CY3LGSq^tx6 zdtfJ>)o1fFDaGxyd6to-RGikay%z-c^6t)IKp%kV{7hYlaQ@==mRfLFT>peT&nN-E zxh&oH>#)}dF@JL=-)q>tMUsB?1oyg7uo>!Jfb`z8QIDqb48T|h(JcVaJ(lRyum6^V zc*>t~eP)09qUU&+HvV$;74Kr4oIoB7=Ph{aWANDy04{hN#tbgjDd7EMQKyy)R7)pl zw0IjN&-xtloqAJ<2Z}^})z)9MwA#~=XhQOZUut~D-6{6&$|bY$P6BrHR-Ln0<%+l! zDt08$fQsM1(uQoSzLvrjp41xXDD@4>wp7)|_fP!8rM#3^1(kp7kXxew{U{)hMd3j* z+M~=ofA>bL|2VbDHL){|uO?mGM^k-?SIKCcaOZx9ndeQ#DVm}d)bO0S%LA3@wTOSR zBUoQBiehSz(z$%%m1eQ9RKj7_7sJZ)QbxNl((Gu{VA7MPCwmMmo-m-f0e;e@VT|jE zRvFFj_SbE0D#0)mEHfH0;d}hV^O$rF(COHq#zBmS) zHd5*fP^z$~z*7nXX{Y=~vX@sj26{?%8Y%e$OPSVN>O5HchH6n3x=G+eUdHr?DIZWP zf!8$v;ECR|U-T`ec9q?IEF@EmY@63bUIwUE=G9Y2*{13r~mVFboiew48L zki;XYQvuuS;)_>pf%iN6Jn`~IJJnp;BYCyvP9fpQWX>Oxj^W2nG>S@PocHV}XNqF3 z=a5~Vw#zShf=c+fIo0D}aA9c)<+7Ci-4dfb=kl+oUJw<`;Sf+9c1{iV>BGqY#~#+wnjgO zSGioWne247pEXxz$dKqwrXKPV{N@@+SXpr}!v~sNC3+XPIEOE3w-w;8ZqPJ@U8X(8 z$zu&;nbrlyM*w`nWMv#acX?_il)DTm7lwUhMnC<{e)COsl&Dz^C@JKILiAzW*CmkW zRI)hzkn-rcE4S-zE`nLfSGSz`hl$hSW8VA6Z*6)f%h8?P1t>butn*o~NVe~4>TT{OZ%1F;OmBHf4Q`A+BMmJj8_E5f*9O80C z;k8UNf7634mH>7lBiTQKiZFhE)PTYgZFIX~S&_JJ^=g5w{~!ly9fZ~ej?}Z^GAA67%R62n02}8idgIYb^}Vl4|35Z~Rhxgtx@cQkaZ~BarOd z`C?1tm|Dfp4K0+@-AiO1MVrkeKoxV=J-ntQ>&oNWsj^#v_++-t_$yr@+M+Ch9y*n8 zR(#T{XcfQGRgk+=V5g>3LyJp4kL_?qTBMer~5vw zK@?{`c}E=w`DF#=ccp2j1g=Zy%R}DNIKtSM<@J?=#TBbNCtS%&DA^$^_1y*4iH)s6 zA+#!VnMpkQ$r}Dvwq|QYNj${ zmmlS-4DK`xBC#~q8ZNoclE(VH@hZbRdrQT7Z!Xu&F30B?QO?e7;)dcq{f(-WT`~O9 zRjPN&LSEIr*2vp_&2y$BUBk6bBh4yh1L}d4vs9%SFZYrj&-H}-E{CVsj;=GoIZMf(#1YQlz{(L< zp)!CFx+7{a0H%(%Ybo?-h~#Y>j}N(26B+sJ+0h4L7+SygA*sFPh@t%fQjVOrA?W$3 zcv8FL!W={GKnD8XstqXQZvrWk`yiQZ*ceKsGIIJw{{>A%^snK%@cHC1FnD=Sn9{~=hUOr(2_w9G3E zP(4yCgDz3(b2k%)ylJm==-vHaB5QwnMwV88TX*3Z%udIO_Lgzh*?Z!N0|?prEldiF z%y@)Fscve`61-x)+i+ZcH~?7@&%Pgv&|!G#S*&i`5Q{QQR0ym5IL3p*bz2iM8PMq( z`EIfd7+PS&a=#qvOWV=-GD1OLwKdIl@B`)70dg6wOOB!Z%dk-+rFnC*sMZOs+U6^O zHIQ&B(}-QJ80cR)vzk|>Wo9XT82D;Qy(3vtxT_8c%qS6iVG0Zsfe&tgLCO1^mWw)Qd* zZh;e|vIr(IGVahmP1GfF7ebuuOv#f&0N0lhE;>MU@3Q~m@jdYA9_sz6I$Dq{h(fkc_P9UA{5!iztU^7D%{$&)a1I;<{ zt7JS0^b1J?5=*`Y1$H9@)IE^va_r8A4ym64%Vo3=*g>)1pI)kJiAn>@H_PZvercdC0t;F$H70YYv$(UwWX$wQekS? zx2Ako`+&w0FMP+aJz?cTZxC~Eizl6`x3_@V$`g1cz0w)7g(PO(?~QxP`$)*iQGeTo z^Z1HW8RX-@XX;mHIEs1)SVm0_b8*jAe-%h%E(6H79&3rK*fpAUfq%a}Wxav^d-Fzaqv>QrulI9uR5PTkCgg~j$S?0}%pnww=*;>x z=u}DOkzC4Rh<;50)yA{&HLqyq(q8^g!b&?-r4eGWqSg^Et4rRvLL_@ZXhL&yybWr% z{qe_KVm;S=Y&+3`75m;;TvR{$!D^VloDBy>V1uXHP}?h>U3Niy9vUQNoh9eka#{J* zeRt*ehWS~R5*h-Cw$}uuZWPZGXgGwm4xZshG4*>rchxl`>E`)iHVe(AJ?#^ADs=OJ z^6Ys5gn{wnP6F3YOE#Q?Fmm;jL+{Oz)#+p(m>^xDrB-wJ4sw9G=4 zoF1vSy{BRtzh2>+4qQ1fS5N}7**lZt!wN}q5HLF?W5wOIBbgzrJRhF2sZc~07Qvs- zdzM~NN5pY$z?AeaDmN?h2lbM3o()DU%R_S8I%(Z}6Im`YHl%WaHI9(|a=h-qo z>seV!YI|E&)I1zH+$%=#&BNx*dQ#g+1C~Z+Uug=1aw`9l_<15ui?R!h;LR%L+r zSwO3i;|1}|%pJVV*F`*8m09XmotL*6A9>5SuVTDf-d`c!-9Mg~-euBj8-0!we#1BSmbL<^9(?BgEC831{j3Xkg24XV30{QoWuig=Dv7cPk zK4P*~K?|aN>7)t<=Wd#u$~VDooP!(8Ik#ZlY3>IprE>wY#AUNgWxPq|+K|;m!AjSL z&gRI5bKJ6%PSK{LvowX!n(SHZXJQtfb;qKspEo7@<@{3-#8n!SXFC=T#W@3ZvjBgm zDL>`PXL0s^4Ywk?bV(-GT1!=Il@x|iy}gs&vKd|WGNt6r>Po=@9Q z2Prtf;4vBvt<-tccySe5N`yVd@qF}G*>Tv>LMG|GR2N;Q{U*NlhA@_1h#w;mvU<7p z(&BT6R$c_0F|urTHS3jcUdMQ0vj#Q?15{=6r$P*tPMJGPQSaeA&#vQaTPil~1v$D# zdR4n%wPit33HZ#5>lViyTVTAq<7n>5-uy^}mUi#orV%r*4<*Ad4~L(c!~127a5s21 zw%o1TgW99H@Eo7aS}>J+40B{a5?3^!($wDSHNL)48}kFFLfMW=eT^)j-A5Mx zwB1bn(7=#ND!JsbW(z32Wx2gy#lo~imj~K)PPb0gB$0Z-P0~>AEtg|xUG>YNYirycE3a1Z3G@GsenL0>G{%orlvU0$)lewzu3TaKl{xSA!Z#BU6-nWED4 zx@A;?B5U~?Bw@7bxhwd8wglLHf3IYwIm_;JM0m$x>(4u7_t)P76ms@kQK8Bh-sqs@ zk*kwe@BS7V^MI|(ry{Vzyy(>$|M~7#=`!1i*p|@ow>?k;u%J-Jz4rzg`Ab(+4{84K ztE_!HqSEg?I$+d~>lx^7B@QT5ygJLOUK}@9)HCT3SrB3nxRclaOA~YHwYim3%I9-; zm1ksTx^gDfEf0cCq0syJzT&+3#)4MKH@;ZC4C_``QM5?a_%I+)Fj^#DYduHEuIr8Ea|z zO%7Y*T;*)5symM#`l2!-?YN#nQJkeM66uM2p&6uOmM5g-7p4ATJ96~8P<&1oGjZqh zp~a66@Or9C2QQLe^Sq%9Pfp0Ll_~c_nRwjnYK1wuYQZ%N4 zVeSuUk$#YE`Z+c{Gxe;o!Mp6z+Hr|Nr{iQW(6;!nNpNdVZj@g)of(qkb~@lr?ch#LP=3;H970g zSI)=Ny?xN-=cM`7u4u#x;{$SSN6ZBbt7P?@%*sD&5>`AOWl;0$O!vzjzJ+>sjsR9eW@;{L2 zi7-n^URww>rYQy<{`vqv9>GpMru#&NPIa8dmakvH(O!&23`rN3+0)zSrKL)x1Lrb+ z@H7(FaNmB74+K&D42d4=d0mqmhoP={Y3H`D>_@I_>YT%fKit{4t?Wv5mVExm2<}OF ziKVgn?W`H?(Mj_UUpH?=m}Va-%xa(XUQ3iMsW0Iw;nyL0bRV#_D%+`K;AyF^FXiD= z+WO+rS{Qf3Rz{T_JQg;V-AK<)qQ`M(Uu!qNr0@!|s+;blB(0!L46xOT>ol@0TZsNi za^P3hHyLy_K;7&^POtZeO|Rv**T?;Ohgfj6x8>fv|0X|^W54G6_FO-b^1$*;kx5W$ zp33_IO`(W}8@jEKn!WbL7{ItSVrqHs6A?EHL{mL4T_AgDlFKMBbszUzA5jd$LiPzp zt1xS4f(Oi$y>KOD45W9C^>(0Z-Jrw51ML>tuV24*n`eoAFyKBWy*X_ z`n!b6L4_R-cyQF4wV%1(M)2t9$erwy*?K5Q#u7u$#8+$_DsAlqAAlQ{pw9V3*5`FHsfZ|WN0YPp~CfTw* z4tYo<@A!70n#cdk7h<2H_TKU<;O~RTLPU+qWQBn*i`h`m4s5bdlSs`%;Br^IrFr*< zP3Q*ua%rv#~{?9Jh3qD3dgDp?OZACt#^l^RK4es*dl1#B2l6I<|T57UAh7` zrm-IDLq)Yh@mLYykZ3xH;itSNze!ak-Qx4ZjHB*}hP`g`S0>!;&GZSsBk=DyvQ59L zACzbtKRA#Y05W?V=l7iFJr98Vfem-c%lz#d0?DOkABh_!jX}e``{;GV$qeFHn~6_B ze6(m7NOGq=8#V3ptQqO32BF)(kM~ZN#Thr1XHwc)I8e;R(Q)d?6t{*%LMs;u;M=>= zabL&zn#RL&|KxnW*O=jqxRtNEOv0~c8uGvt`YKp7-)VV{sXVSCNOi!HZW_zlic zh7JQfw?+Y@Ud9DvwB&vV`tX@O{(H>RBeOX9?W^f| z={2|QyO9L%95<-vNPh(9S_5?tNR~0*s_4@p+Ivb7a7T&hEw1F&WHtcXNzn$r%@nH} zzB=}I3UMBI-&QDQtUth;uIKQG2RbND`_s*B-V#?!_Y$rz!>=^ox>Lnw2dbdN(1yp4*>nwrl?;cANo0nsB($X2(tE551dR0D~h#jNbK2~()-_4q?Kj#$?UAx-tr^` zK@E0v-p$R8wJ`Q)7sm6-h6i9!%ni7GN&slFH5${`vR_t#UDbik+5lAZ79_O#2|RmIa7sp8$#jfME}005Ito#uf-{l+iE-HqrF;Erj2{7d2c=AC={l zg3D??0)_ww;GQ`bMiDF9Yrr)sscXoI{rh<@t-N_T@N?g`_7AoP2EGIs@2XH!8*P3E z?4d1@oOi#~OeHZ4#>yv8o;(97>mj&=+C|JX8MG|_zJTK5qlrIluD3Fq&P@TpMq(>2 z=^=Qqe}_2lW6R518%iy44(tKoBlhmCEqEjW+B4-v8XLe?{{AzyO0M+o9dFA_JHJ^V zcnD}+!@qF`pS2x~mzt0n@;AV-P0M#2)Am@4E0NPm?_|FP%E|T2fW+i{3S|XJpcYD~ zBLd0)-QDw}_65=9_X|GLgDQe(?{42&EHD_s*n7lTHymef1= zz!6RU1;*gl`1i68cLhDGv-R6T`-UJz5Z2%x*!&cLV}^hwtqU&2`1~EuBM(=9$s{Tu zCODUJe75kXmvuis=ySaCKl9~`%Aq5MVdI% z>)b6Oi;_M!m^}Z_vyO%9u_KH3+31#a0lagI4>7OI3(=4QI4f!kg#Yg?S8o9&9IDsI zw1^sQo+MvJut?$m&BG(L`+iy>829k)SHJ<74|Ge)QR+wl+G>jY`_IP0c{$fXb!H1Q z^&K#BgHUJKs}#{N&bUMM5L)&IWC4>qz3NN4XZuawhp1FB8x^OFAoVt3=F9kzZCx_v!h@$xRI z`wfsNU<>UA5zUudkp6$4Co3!~?dckz`ML9G3b8%i3D$}kQh z%qP&9@KH-%+V4lHW6-%CgC)dR_g_9vhUF!P^8KG_Vz-XkT80GCFmqQKR}N*{-QWUh z$Y!p0TV^ay`;JUY(;8p+OUH+Gwj>e^lf8O;N?LzJRv0LN){R05K?@sm1SNhi>%VQ} z$mh$_VE+|u2sq0W(I=f`M_@XTJZ|;6WPrVC?k>Cy`)}(n^Wdn7W{gYuc$-wzuSzH! z`d$BsIcCimQacCing1s7!(Ac)>3U7!qcZ^6ivJFLoBG0HHItjV?1nwZg~OV}B9`B2 zOKBilg`_mmgh2f=4q#e>{QTcFV4HAaSNaV@QG-$LK2ay?@9zqk11SH^lW1b!K$saS|2 zt7Cfo8*aYuJjVfV(R7M$=Gc@(lM7{DSHd&@V} zk0pLF4nI|O+2M{xSeo|e3pz;?o0qPMnf8b+9<}8`t#yXA-?R3(AzE@#)Sx?{UWMIa z>qflK5{@zBJWW3zwmD@NtEWGc2u6(TAs*QXRa0lNPt~O zQgj~}G_9l&gMj#vP1cT{MdUqpPN!ctjH>et3>=nyF;eJu%g9hWwM z3tKbiex{kvbn8K0UCY;*RLFVh8%#S}`1u;9-tC&(=)k5^4KPq2 zqwX1ScccT9(_aR7(8`$e2$9CbcMRrmOPp-2sa+y`e#8R`@tW%f6XND<@l>EVlF0*z zvob8hf7Kl(uWcFm`lOQ(OTP{Jpx?@9dG^xmNqusd9iCE%{5_-zLGXFx&U=y$?Ba-4CaC6tqiYe4s&}ud*!s)iYv3jb0+lerWhVV{DTB+DDA#IHqUI-oxibv z<#$YNWBl>;Ne?E%=;+~`6Q!5BOZ~YlXHUu`pZ=CmbaDpUv9+sAzc07-bwkg74+=T$ zjgknm(Vd!EQ0A%=#-xyrli@}w%6s>whCNv!_5I{eNczJQIMHl736`p<&R;HGOaY>w zY1yf$0(g6Spk+ptAJZsvPL{8rb`xg@s|fwFr+)TplDykLKQyc8;1bw*T%UJEdF^-F zu+`)(CO&YETDmMTG?MdDowE4OA)xxzVOv#T3Bt&5`7S<+XJoB?cPtuxazpXV>RfAp zt1o7nyMf#j68;Jg$j$XP#uBBPnwSd)y1%{u(f*vr10wT*s?>X(cR+Wj>{Gt$WX%=i zVqQCZk9=l9P3=E;ka*U6{zn&ob~NvT_Bf&AR`U3`dB42jvxtc0XV0E3sTt^YA14oi z(Ue30P)>E3M%mFIk@V?f+#;PFr3^ru*C?v7DWX6IwwfZiq&Dq6!DSQplZ8X-f zU&1U5CQ?V9jn1%eNx27d0OLn2rez!*ivzFRjzvBx+RNR7yj-kDS{{p@QVG;T%_#)}Sre6-iXupfyQ zcM2sh%yHR?6qM7n@%i&-mO#bJB1o~;&Bo?kyl7a2j@8-7BDL-PHR*by3;p(3f!`vL z#UYj(V|@6$M~?nCFV}@iAo0K0Wi+SZkzN+}8UNJ6M7pVn7{fbPH^PH(6C&A>z#f5gt`F_K3GB=$5Q{UVT8faZb%pdY~H(Cqn$8SK7wb( zhv2!SW-cx+qROVunu9Q4tA@Ajirt;a8L@SM?R+3u8vJyaahDV~dj}$Ns0*W{q^oKt z4DWN{)6+QF>O5O!Ah+3E23ASnu9&3HlIPbBO|#8GzGqy49&Xb)=pf=pnHBwdLh9eN z)1fKK%>2StT3rO{DWWG!J;N_N$8_G&%Ia`NTKVtMct)e}Af|A!up=m3okomLrwc-( zdUErIE;l2eYu~`}4?j+SKvo7aF<=ajV%r;4!MNEiaNv^CS@@800KhqgIwFc$tUZoQ z^DdUUUB^B#JcL8KHGt$yq~bG?TOI}Q2zkxF8b+Og$u>!d8P^Nd21Xb~i5!GDdROQH zhbE84r-Y<#k-N*BFeQLD!F~P@f-TlY?&ULIF^MD8`u<6^`}?6e(Cdwo04zNXBsCXE zKGzwJ?i4o7I8i$7cRuq3 zCS<^XH7Sh55GMF z=V!&H(7hsF&`<9{7HyjyI3zh%M?DD}hW6$&P^VS5w6vsyX+~`Wa+CIR{80UCZh>wR2r~jY` z#z&7E90c+$+H{UPh~!uQX~wGgK<;BT%m;ow%Rdcun0CRLuLDpmfV~&Dx3?Gkof%Oa z12QthJ9wkE>X_opQVvpiOt>3>!>yrjQt1(&PIX?pFH^a;AayOXx zChYr-7>uc~q-z>%}=*1mafX5KqjTZxQka@%oePr-R#@MFmc0mE9=>)5Q+{*{hlxK|xrD)9q$k^@p}qU5>;gro*XP=`xG=&e0*1%}rvS6~`D$$Unbw zya=_9Qb%IPE`zvxjivK`=eG~lRE7z?1bSwPtgLL+>h#gM(IM{<8VMx@+@2{oY6XaUG-?{6dzZ>{xDV{xyB3_?b1V5X}E%i%Z1 zrSYF&;(9O{UEm62C=aoOSxYvDt;w8hd_3mzu|3<3M%QU0blNg(F>9N6S%MH{CJJ-h{& zi6iEsi7WER*(9l+y|q1z^^|JW*3x6hg@;x=jLCz`ojtqS!^p>qpIeiO@tbw1dntr-^eK2`|#-Dx(bTJmt5^JoBz1uF zEP&`{PY!tdP1(##(egn5gw)EN{^>iluHF4|ln><3`qY$3&YCHHKN^#?h3);8n6wj2 zQk;QiRCAOhav;a3V55kRPADlRjL!+~1MDmz3M?V@o{f|KwnSX}ftt^AuPfug}uz!n>9i3dgYbS7ajlcgl zjr9ZjvIR>9g5|ddA403ZBRxc(kB`lyWb2{Ntxk1)a$Q7{0aN-z6s%JcbLQv0Ou(hoTK*UyJx-C!i3dHu%z4kDY!{OHuRCTreh_4 z2AuL~28VZ*OYYk-yk`fC>y7Eqcm`t*+mqP#$zQM~f-ov$Ha6|M`AEQA;%7aiUN^&C zc9*Ubom0RjC5Zt6z`xb2(*~n^c;bvTI$onwJyMmH(ux8I+jfSU2DVJhz5OOj#d;4X zDr?^tKOhNJFaBr!=HIbz$49o!j2!Z;34zA8n6M&n8LWWIfX)s)+hjZ-o&E-g*E0Xyp0B7=kyRIy9t8 zUu%!LJ}Z=jWb>b7?Y~u1K+T>$SO(^abbdCb`R=i}XTu-;mp^lWrI=%@pL)~M=V6dJ zJ`~8>{iHVY(asCNc>$xy3vS^;Mz=s>PUb{d00(_5Qlb^M^k$ z)Cs_oUqb;jyX5Al$0LK)K4if=!_{kCn^RGnQ(ViI_y&J-mEl6$7D1Q(!iMz?YwBjR zQSBdAUuBw+i!i;Jfd3PlFVUCj@&v7;Dou!=>qjmzMOY%221t7;w^EE8Luny+@Hkon#CTYc=&3L>xlEbx{c@{)lkxufroIt#&4cpm{6Y1#t)&O@UAx;uJr$DX zxCc$6BugdtN44E@DSm}tPBO(cebI_?9^6NC&b@2?yJVMhwf=UjHxuLior>-pbys1| znWBss%YkjkJGOXFC05I}u>X$U4V_s5ID_g6@vSOfw-n?8*6bDVK3WDsw2+a=J!G2{ zkMYq7OZx|Vb!$I%S`fGMe%pV?tKSzB+u~uM;XG7in@MinoFw8NuNEncxWkgQHn1kZ zVr>z#)n!M+l5A$zo};ULSAD9Tu4e@ZQR5TbX{Z;KX=&FCZU_UO*Gbelt&M7kOE`VqAO*1MAVM6A{fQ}O z8&xwuTY${+Bpu>c&PKNrE^^-?kp_Lw%11UQFuK2IAYl2>G((l#j0ac$6B`TJ7#lST{Gg7@!BU#a6@p$BKDu^nbP{8=n)jw*FM zNV!4eu7*ZRHV{wAs9sH&slWspPtY-{K2#T>Hf74k#^j>k!ct>3oKN&x#IFrJcC_fC zc33Cf8>aktKue_VZXa>kkc$dR-W;Kz(Mz!6vd4pU-}{TQ#ot5KGexP6>Yq=8(;2f$=G(>|8xb^h<78~uP+bnAiK zOF-PE+pwd#D->hXPJ~}NsS%A760Qnt%&?(38BC^19BjIXx#?(Xi&!3NM@0wsiBD7B?N z;jKeo;CDt$7vEj6PIoO`^pzi5A>mt<{h+*6;6@fn!YNui{X`{8@%2sqS3}O!l?MkK zKvJzUhZ!U44w)`u9rysYlik7EH%=2`-(0_af}7p^;0~62qn4epiL#&+tW=tbnlvM6 zQ|=;VQd#X>P>agAhe994%z@f4U_IDNI_p7g_|}BZKASq{Kkm8Q-+s09R{wyjg#wl` z^g50hJL)4D{010kz{Z1YGaYQ%u4_2qt{smt8DEV1?f@=|qOZksE*KcyV%Mjw6y>J9 z7byT1a&%72ZuCrQ)U?_jD;`8W>EUUo1*eT*b3;ag9{;OD_W~=elWY_YwzqI=X!B@r z1`bMKZmN_tE&qBTlfwF)^>7TlSAp@J2pKG%4J>ZMP{nHe8n zltVr4b}!kq%oB;v87Hp?;qhZhcyJ+sn~ol; zW9!YHxr8fH6U%u^hrR|JB>Z;t!ObihsC8FL051_FWWlwTx9&WWt*$4%2ngS&8*X%OadOKMjm1P-JK@|3$@s}vNczU{%!egft?JSCJWm;QN)o~12AK9!$6bw!Rt zL&`NQ4SHCUWH)D-_7l!q^J1=10!0XMs$fGyXwxNIbxWtX6?E78mMKy8(&Z0Pn?iB^ zmMHF}QKJlTH?J}pWBeW-<*Zi4fg&i17-)G^Lv-aLZ(gRM3Q>j(Ux5xFhhhP?2D7#3 zS7x$`1v7aKzNjHCpKywxkZLJzVJ2=_G&|yrEiGNEaiKbyS)Qw{>y2ZdL+6yqT38ax z1dC{allZU8(zkWBYHCY610`CRVJaTx4bKa0{FMXR>0+pU$d>ngI(HO#FMJ2Rj7d29&j`aZY&0N7cYf0JUIyT%jwvmZjM!)q^MUS8m(nlL4&`_c1wl)%r zZGJD73-d9C)XeaZ}Zbj}vvEMG_XT~9|XIr=jAP%4$ zayj&Ao-sfxvd5S&m}~4LM?VlxNbO5fIYbuya@iMCqSI&1MED4TY&m*aH#DhtIv)ok zL0$6ThFy_K*~5Ef?C8L(P%f4AyXLzseSLnll_iy?d~Uy2JFxVXd{#r(7qj@pg{mPH zeTe0VOr;P3I%(H6MjkS#8_Zari*GqrJetmj3V_IYj1QFsWN3M87d>b02YuX<>{*lN ze`hd%L~&-V*0EG!&|zL*MV?-k@^*Y*KrAjaFCfmt1QT%A^mRpQzhO4rb9SXEH#?>MFy&-M7(a8~PoZk3I?PmS@pR=u*rS~@D z!z(O3=<7h=7F4h#?ACNQ^W;non0EH`TlR{L6j{k>Z@iOVk-COISL!R71l!4&sUOZL zooMZZ^>GQ$%V}@$KX>xvE`3qA-N%9--)%k>Z1J9vMeU3y-!SPaUlw2AE(vcgoVQ)O zp@ht>gTk{1{JuJ!q)Q%XB^<0zM-;TS%1&$BtV}ct2I2f1@i<%ay&`2jG{f0wF0itV z@Xtleg&Vp|;u4avf=%l|T?d=4nD3avi^-gR-yn@=CQnCB{Q3}>t1i1D=V3PZON$8S z(gl$2zNG%zXTZuK+|#dU&fR>_t27-JaF1ulLB=8_AsWV#E3N zem&tLd7wU)WtTXl2RHwzOO8WkWCT~R?KkbjV4By3>qJ|qb2Y4$YD^_cy z27Z|k;9$}FF=Z`jIBKq>VZiR`{nT51rOQ=PCACt67SsCzCk>`w-633DcibPld)#{O zbsucm`zY4Y^*d5V_qSf6K&RHe<=P}{5Jt1{G@*JYp0e!3A2XUz_S=niKrQtjMKT2< zGtMPq^iRZtZ1?BW_N$xe(azuQ|I>AKagU)$~+=nz}Vc6$FKyTL~5&FhiB&bx{azD*6$dHBM0 z>BU^N*z8;Tf@NodIyR==@0XA3IK^7o~&CP#zFR;O+vB>C1lG!_EAZ8h?DJ3_TJgg=Q{kJ*X#M?@yC6?D(AYc z^S!>`&wRh%pW9xQxyk7iXx^{ff%DCYIhSpMkvv6{q6IQ-pXz@0D$Yxn2vGI{lFt3k zeOr)&2TivLalh#^L!SMoZ)#-SE!n76Q5N~iR=qS5g&k+suJI_D?xe?ZE-%$zvn8Z% zT`2#u$HSdd^IK|VU0tM}ae8A{yP&9{bXJt^2m4BFh6qolq;ir&QQ(arOcDj5HZP~7 zUW)K}YP~9sEp2RKJj4 z$>Nz4r7WKcT(pY+bVEex89Z6hqU<2(7Zmvx0T^{SzFwgq_%h-fp=3XXWDP@n0o;Ar z8L^2-uC&y=WLenbT%vO9y-ToUc-DofJxs=VZH zRhDvDGq5sKMC2!@eV4eL>-az+YlZ)4RA#!;81|WoYbnt>a0Lt@%?+E-;erj@V5nKO zo%?O;TymC9)p*GBi~9_}?MB@4^O#2^%%iqo2Sox5Cu=FrajVgbp*WwuiPJf+MjsvD zP{}t{M(5zdW#3g1YJ4OAPK1k^VYN277VI3dmtZsm_G^|`)Q0^ngpxpi$vGZxx;*qI zK&__8&-ql@mWE4FDtdc3DO5-Hvkg5C8kkT~{tunq4KJkW*)B&93!v zFgoVb?Hub z`l8MHpSP@-4Ns|7qR7|ND_*GA8Mq@5S_YdRgdx_N|f(pb0I1i~zJ2vPNlFLqC=frIkxcoX z4PQWqW2s+U9kRUB>udPzlAic8taQH;=Q6Yf_)r8B)+_Hhp1zTLasj7)J~mfbWhKZt zYG+sVj=Lqr5}uBW#yf+mq&BxjK{0OB z^mpux|HiaGAUmjT+Dj4whMdlX2h7KSoQBW0fW1)zS z$FBxxg=mPJ+l@^(`pxaVHm29z@n6SQQ=33({GqQX#+1uI6&3LEqrEj{>~~lT3gm=4 zc0HT{QL65L>`ftys#+O-3mrBMV(7?ilS3@j1I{XP0#u3QIhW0Jzn)Z{b%E1lFwn{FAkH3g3%j#GwGR1{>+JU9436 z088zt*0r%N_jMuS0OJHvhoWvW$m*qj zY3aKY?OD+@DrU9PqbkyjrN;^#KSzNVi9HP6U^vvb|8~$JwGnwgD~q+|`AU!OLb&0; zziw9v++$>$J$;jQ+_THqsCKxtj~y4X^Y@$ExZ!H+jE>=xtiH-p&=2MF4+=1ZOHm8U z_Z6R?(DXwpc~W_tMb(Qf_0hrSt3!r6BHvCDVX<%0s4y|gTT3y`R%E?4CvZ}{K7acQ zO~>6-{5~Uf)}Fn};;QkFKH9rK~vt~J}Du()DzoK;v%Nu z8Ns=nMnfWtDC9l8BC*gFV3~o48vb>`Zt@;)x_rayj{Wwl3>xSk zpc<9j1mk_6Q@+qvpB*r0d_%0g!_QeHS)*8?v~>!o2gSsuCdc8L;TbeAHU{d?NX5t6 ztPQ(EVB4Biw7bkvUfBa}>wfOQhfnug9FqSiXrIZe%s{-zuapmmL)3UnF3KjMy*Tx< znGiF<#Nfzhq_LUODrKOh65-_uAv45~mRly8Rh7aX-Gki(Wk*vsTT0B6O>3h0H6@Ln zY-`7oMvq)Wiq*DF`1Kcg+c=9a-D~>C9cLewzD0V{;6_eel~p-{Int~pKi5l{yUfMi z$X`$?K}gV`XLI`-)~AkCV~A004jw8e{q%K^fNUq@L~YryQ@-}gYwgdU<>(t97eh`@ z;k%cR0itX`K+bJL>}oIEz_Zfv%m6X@rTz({l5Bm zSc>2E#2Tmwl!}MHY|q$eQDGp!X5E_}dzYJ3i%m5}`W<6iatTO2?Kbx_s1+$4>x7^# zqqH9ifl#y@@w?n&gMqT;y#ZNoW$~&(uM3o(gX3AuogKLZ0Y~4a>(71QY0jx_XAkn| zkUzbiNs?bOnYoUZ_^lFm)#-k;h@V1yGZWRb%biTH-h$SmT3~UMPjq_JKD|H|V-- zcQf^JW-b3U#Ko0Qa7D`d8RegfihNrkhJ3_|Vg$Tm@7q^@mI{R?H23(p>@uXJaimhvLXBS2DDxD2kNbkqhpgP_W_P6uUzF{^1q;$6~jZ4Nn#k5 zHF$I3jBWL3g>oP!hzeu%NHEqskR6WMo#75I&#Rv6p(P4kR6A!{#$Hq z^9bVLbX%~cUOOb2+CiT#k9i)dsnmlnA#Xw+jXhx{b_5eLat%qn>#8dmzAr<(u0PyR z_D&Y9#~_QnjY{JpS`(EgO&iHyRDWiFF2Yct&w6PnUD3)>$T2Wzx+4380Xq8;Hx8S- zz-6Tz13}&9==3e2CO+8GjCt?2k=Hrmvy+fUsXw~(4k^*+oYZg9`3uOMnUVeIl;yu# z4QR}D1W-5qF!9bW@;5u8A3uLapOLd5m*U?pScN8BpP!@6@^fyFN|NXrxH9tQQ& z{?j?Dexf95%XK*H4r{)gou4Q<(OyOU0F!PCH z_iLTU%J_pD9SCLRF3q2)mk9dm2&|vZ93AGZB~=r+OlZSgx>QacDzWqf zivn)}Q27TL>Qum3AjZ<7s5P$h_x%u1l2y)KL%Rz&qH=ylMJPz!kqp#-Qq4FD5)(X* zv!^}MANg4ke&23|6O;0JG(0&S=rs~Jb+)!M#AKZYa1deDz8jRek?&zl_ZvI~es$Pi z;Qc64Kr8+ziIZVdKAtOkxvfR)hU;j3E`?vnx22Of(V_Kc%#yM>jigNK0smq3qGMPbg6a{SNw8j~5uCSRsN!$h8Ash1g1+LAcedf53`FA349?4~Q6AmrsGMKch+}vsP*%8fvK5U?`1DDwyu5D~=EO}tg zU;E8d)&FU`35!Y9>|vJ!vAeGU>L4!jl5h-6PW%Q&oLN)lVggV_?7aY9Wq}ByKi7a4 zydV3Sqj(|lf=#9aEqz79bKqUKTu~EUT!Db>Awf81L5`}!{!7E8SW042&-(=YErJe< zf`-5Pra$`pAeY}Xz?2KkjlLk@T$ZHR#Y$&DEGKw*tcwHOS4`EBW&FoVkw;FH(BVX{ zZf3T#PWKZe%3|;u4>afpYWWfBLq<(fH0Lsa^BTAVQ#v8A>F6+5`^2>rf{@?tehn~0 zRrMxah;I?4uXQqW?iLf0oMnkkbT*K5FS2PL8?2ajo~zojL2k3PopmR)ye%{|B=IU) zd6|8W7|b2vk(uqQaPhO#;`iX>?6)e2=qYo`D#UiCffOp0y))Bt-En}X!cqTE&cZ6( zOn+`($`9fOu{>Pntk{3Hu9{* zk`-u$vN{2Td0Ko7m>>xNT6W8QML&5uSmrt#d9KU z3bdzSMNOSNdN~I&ZF|8q4K^m3)|V% zm->-UX|R`l_Cn6K{+md5fV&jJfC2XGA$N@Bo+a_sw18Xo34=22p9Uy%IicZpJbCgWki7BTI|w@(xRW(UbJIq(;O z)s12ecN?f*{=fSbAPI#}T_r&j|CgA6$Y^t9Q&1WgtCZ`{DJ2fctdvs@u#3G6G-*~m zyz>do^tZ|?-B;4%<0i(FG?Xst<#f@6Dk^WeJHxgu<@B3wp2@!RSh$lz=|81A3D~}( z1~K95(-tr8r)E1US4uKA^^2BTZ{{8E&AuC96 z4*jvHXFp}&A|?Fz@v=T33p&GWpa_3Y<<6bu*P$Vsfj3oMJGg?&JKlR%Ymi1?O0b_d z81MRcCjr^sYl@23;DtNE3s}!*bT7g7{?)vd{WpO__Ps9my~dKM$N9*dF$CMsW500( zlZGn>yMP>59mAwghPtVu%R~D{;t6tz)h(A9J2=F@g2y_NV}^>;*TiE;qc>IL5YEz+ zaUICI<-Pj`NibjsQ{N>2xJN{71Hz~vk~U8Sy!53;^()LDOyj;-8K!y6SNAEs(rXUL ziY1M1%7{BHnRJoe;4+EDb{Ku2r{y_t~ zepu`uY2#8XO9JgJ$O4Xe?+2_;SSBvdLaxy|YX55AU1TXk5rYeEb1UUdK}<|1 zOx<}M*nur#{#<&&UqS9pPY2sh>sN>mn|TyZkMlbZ>~!$2iY)4B1=)+vf2JbUIuDx) z(1*GjtG|$b3pl~EUX`yqqV{=`s`MaDd#?GQ-{`PFEmCoYoHpl@tV&v?hOT3rE=PMt z0%^2Vl*Ko1IChB<<+>EP@{@iFO2cpaG%na}=nKbNRPADT87ECo;=t7r7vA{^wXcDH zZvaU6nIho(^H54w6*^dC#^KgEqv|iJEiiogP;Z&jw0|AF$F}NU!1r#v677i3)bvvb z)QHUF+mx;CVNX<4+lD#fXT`<|W;^AIY}uN_Z?SLdueND?y4OvLGCYnZ8e+Y@xogM3 z7Cwq=E(C8#MF)chg7)i}r|+a)W~6%d7~%=ibO<}Yyynxj$ij`!D{`yfx1m1-6~HTHLv$IXgW z57iv}r3<)oW-c$F4NYw2LpTRb?FUlv*~xdsAy{ZIQH8h?O@aQu(Nn6q%LjHGNY74L zlA~i2C&sUG)G_i++~vPyvE&WD=;S?(V7zJIZNvz|2@5?OQx+^0LhsYs+*&@ZU@OqG z>n>4@_)7D(zbOt=fA5WhZE=7P4vZ%@+{>!z0uM4^#X&Qs6sAOlQRNM+V` zV>NLxFksBNdrB?sLa|BTN_D1E`xRAs$w_L|wSQ0n72o;?m1_fd?xxWbh{Uh>X{byC zkWTi0p=S#nuBgFNG(M-nWRTNFedL~q7Mg$INk@iJn<%RDT8LY80Ws6&J}Q_ zlJ$|dqO5BM%|#+fQXl>;y$gf-qTTq4T*qCorC@vLvK4Q;mhlVI6oTxzTo_nJi&~NJ zt+*DURyJ~Vh#8CdztoF#qkDB>EBy3}= zA1^azIfit}*v-bqe4cBQVB+KLmIu*z9;LBZL}i}l2uAZby6A_=V0T{JNtv;~@>Pgs zUJEX^hd1WplOr8s$gSiWTc=wNMsUlT+wWkj#=THJvY4qf7BBc&LQ1%B0-t;OW=^ly zJ--Qv(BLhbwcjkb&;N_-O>yg>rU#3|j%BN9zH^uTjHUVHPDqAVk4c<44IPGJvh71B z69n{Eq06GqZ4B}UwmYrvvS^!uh(l=5 zFUkh8A9ORM{)t=wUN%Oal&~1fjCd`{3~w`TRvqz)^^8PyYKZ9%)e!4}ep0N`(kqL6 z%fp!$;mGceO*;S=4hdT!a55k7&LM^y|K@jF3kQ)1Atuf~Yki7-7r1M!iLZ{dl_Pry ziEt5D=WZTn;rqX^gFR!V+B)nHF!=`7de*dT$;)5g4bN+fY-&ZK!dHt=uCDq(yl8Z# zq~|;QsZA_) zU{xfL$po?=1>*%PteS*RmBaxYPzcPWIEP)spG>`EK@&QigdIG0~ABV{b`gus-G3 zAclajTj4Fro&AOv7^%mm5w;=yi?ASUa$8<-BB?Mt=^(FMXY-YgI#qgL#=yjmn9xC^ zeg53@MHxGb@Qu`quxZ~p&JE>%g>df}L}9-QGDM45NFLxl1C6Mpvmw0@s{nzL)%C>( zk#9A~qhVe~=Mk{T+U5LLgHn-&3{h0}SWB(Su%^R6IR1`IO$}mHuJssQTrf zd90FCJ!V5d1*b$W|8ox;q1`CYWy~nmYkub~=%?VJ)mSolr5WB7!W(P_$Lh{L+}t1) zYnBnbooaV&m&xZpBt59R#Wfck8X}Hd4)T&Gd7Ti&#Yqjuy?cjAx_eQRce=d!LqyV0B1=(p^l^t#PdL89&7 z`@fIXf&tT`5Isu=Cbs|^xn13y&bgt>A(;M_m>FF7+_0RVO4Vq^Ye*P>7>M1}jjcA{ zj2SDA;fWrY{xx$s7sMr30a|nSwvrK$gNRj~z}h@sVyX`$tz8xbe%YfB33Vlcdj~RZ z)&<;l6vRynFK?=1gk;&dO75OFgW1W9=S>5xyQnZimYJ%Sk2q`}6vC0~6jGyA@@wm_ zN7g90;jiFMazhH`C)pd!f@VCFABXDQXAdzdr~v55g7P@}phKl(MB&Sd6r&Rd5ZVlv zonK)E^g`3|6nf-Z*U8cGFO5&yZ6{}5=|F*Ck3#&XtqpAr9y_Jiq5Rh_m8qWyv?h(l z_LNwJi|ERqncvz7Z!n&+vQ1;Oys2m~1W{x$l?{Z|+Eb=EQ0%x+>Zj66v=XDkOa?w7 z7eRdirVS`VjmPF0$6yX@Z?(N#S+Xpen-vl`6Y?-60UgERf>+8xFe^*K3nHf0>PtzE z$e!}V#c|k8rb0D-vF0E3Ri|YzdU-c@GQuwnSV6MKNeEJqi?~OOrpI-WSbX~ISoj~E z$#=R8wG^X;To4<#?bnN;2T%($xV1oRJOz|$P^$12#3BAthxV8zWUFUG@kAd7G#*c< zEJ<97-35w^YHvE)@1@;QPbENjsj3+FyQ=R@%f();96U#92Enlk#>;z;V>K-z#%<(+ zk-$E4;ximHy=Szz4wMlJ2om#fZKxFU+T}b52tvA<$o-c|3EXFEImpqib4ct3gR*+a z-;n=2{?O@q78D-Fch6q;l+0CxxuAmH-&fM4F9c62g*!6`YvjDtXdthF5FRr`|7p8b z|Hh4ck5VM>5R(!fc_;4H!QFVzw$SX;dCl{7qY&Y_!?>P<2=Wzv)ZOl8BrQ_G;IJbB z^RI%HZt~-YBSEO|QyfD$4bF0QvA3e=u_-Zyz}3}?5Xz!%+-WGP2b-o>$bG6aRT}Tp21Uo1}+p zi21Z3ccet*Tas*>Qas1U=ZY5`nVn4antyPW@t1)K&{^mO6Cg3DF6yU1WoF-wjquj) zh#BC#B?1HZ(v%t<7Ea<)KAWGeAA&qlSWs$i@kl&RwB(`P&CP#RcUtH{lA{j6>mS5p zjd%jfw&n5MR93_gc&O4ao;g|?j(8AHN5`9%GM|v*gRqqN)QrbcLn<#4v_~%VlBMy3 z!O`E5@J)oKC2CmCq7>XCw&0!|P3+D}*yJQd7wJ40SVH9v1n|_|gs?}51#?r{deAWC z7ujp3@I(UgKe~jJC)I`=^-zrzE-a*OeFXNBm89);fe=Mj9XEd~cpNwxMF#6&V8PG5 zogTmDbKWal)gm}%tW7kkXFng8)&yPIoj5c}nm2djc;z)Ee zfI6eEKXkR2IKFuhliD5l3lvH@@Q*43?yj-%@f+tAl~J2VFtgH{DnPhw*4jqkJd_r9 zhy6i)LA!ts({afdJh>CL26E8gf4#zDlh^g!pk{z%Vfopfo4)sNr!K&8PS}^g86i3Cg$WpXU=@ z46xZ<-%L0=u{Y*W9nWR)jfkzrBZZc!leped2#5Kh-Tu#nT#Ki9B#YQ^S5zTCD73)r zUXD(tsAN0`MT+i)l)oiiuFeev>*_OoqF8&`i<5be^c?k4kIcmO=qSslGNxMB+shU< zG*Ha0EcgEgQ;UT1p_dx{cQhs`I_BtQgKiwQRR>E&N0)lmm-`~wxlV52hiqKNN`|HG zNRM9o7`~g5hNpOR8+Fk4WF|9ge(`i2MG2|SW%WQK30A|m8uY0#YSi{?_7LzQJJ|td z(S}AxbH&6O{e=1nQhq56=Dk@B%PWk32fjP&dgEmQF6qfN( zYGtPoC*R%p5-w$b?>A>UeI1K>AUmWNNRQnt2m=g}mN~QzH08{+_=L(E`|r!C#KKUN zmv6~#TU^YOy%;}iaz?R{u+yC%+tBz+kuNj$OTw$N<6H}T8YY28U}j-yh-qiY?pX#^ z5V#4u?`9WEhdJo799?<%g&wDg?xM_f-!4VwjkbFjfi!`Y&a%A!n~-#&K9wN62G)HT z9F13L__$_+@{A|Q1N}wurYHaCgZFQF68FpH6Ax5&Es#Ps{2P>!s$;(WQ9_j5-aJ2y zKgG;E*+mLsv(C>O_~fGwIS1&-^qOszQk_ zbQlo2Myi4gRL>>=fcSbh9REzF&*;@8CakZ-L#WUwIhp(f9RmdGX>%a1G5 zOTym`Es@)@n;-Mmi1h&fDzb(Irm(@ZEINPQk8tWJ_fmz+lk9|u$sI1D1j-DKw| z&z1k8Knwq|L=pl>>Bqqyw<1T7Je>ZGwTcovNullhV7cn%HaVIr4I<-go#D7sY=-9s z;R)uJ%{-8377{sRN_N-uaiYKf{TI-JQ$ zgj6dmQomze9eFO;ed=D*5{=S&iSC{2&exDsMarB?r^E}H!AO4rr%AGqa#=it?3gcP zUSm?M3z{m9N+5|Bc8m&>;pazk)j#OqO*69k97{j(NBavM#@ky>6;-fdm=K$=dJ1Pn z_ZZxc=GRSksIkE;IbnqI=fM*v zaa8bDqM>sJ!dJx3gKk|81m3607HTfX$kH1vnGZSLhp@`)3(p4%(Dhhqsd^UXC za-HuQ@+OcR?_aZ`MurPg-bJhn9Ko#IZ`#L;i!xNtWIck^e$#c+y?ArmYP7%1VV>-A z3@aj%K`diocPchZ599sm>#h#Ej+}|W+^zUngcNx91-m+a)#ja6Ux|=U^<~642L74O ztEmnLVmexC<8L?tDsrcPLOk|y;vO%w3$;|X+e5uqYVya=&(4e#)A-Gd+&o}h=?%Z- z_~x|v1*R+-4%VJ$wqQbtqCO!JX*-Gvv1b)Mt|?gXTBHJA(G zkjD*v0Hv`dA=r8#RSttV#6sJQ*WK%`v&Dq!TnfOnUfy&a%ph8bqr(2wzJODv$OX2y zPyAoFXq@5Bl$I@wtYY^q1{$xriU|?W_%Vq96M9PnlTYB#Mr_Kxbs)sW1;jKI&P#$g z!kM-svo;Sz&QRKCf!{D+HlC<=jrT&1nX~g+8E{c85RRsFE`sns?yaN$3OwW;E%`&V z&w*_Zq>BNEHpx-})%c=}Q2tvR)}(jAT%>bf>>T;Ik9mbK4CVljnY#$1G|6z6fm!lo z>Q{#OH3`s230Icd*g%9*hD0gQI=5I53AZ=po38awG$Z6WpeqRAd(@9RAKR%B`qtiN z-rnXZ5B3gu88*Ya7f&SKSK-Nk2J6l7^M zwpTvg@M6Jt!6>;7;Qph|^$iUTeY$`P}6TN$U$O|deACI;q7KM{7- z^($BC!PPI_EI-F`j0yPB8Ymmr$<*lYzlFohkhz;l)i4-pHyTJ$`al3C#Xsa_)C-41 z4BW%hg1Uzh`>3z06B+@54D!QZ5uRNh`)FRk9kG1^rImR(K0cIHa#P?1HbrH3D0A>HYxSFZeX^1>C}IgF^3R4+{6=0+%yLQ*^<72T_<*U|4ZOvaks6Aqz3d&u z!_9DL;K-BV81W1U>ut*xV8ws&cxlBA+kE-`^LHH-3ZmEkJALZZ%MAMG)}T%BKn0`q zP%3cxJ3OluLRF`w?Dw9pTHhVN{o+_~4I&)E_QK`c-$GztFZ^u?AM!$O#|ytjGQ0-2 zl!9CGjPzuE{m)JiR24Kds)Udx_c_vYz8ghx41ZpnhFDB|6WVz37J=|sKVSYS7onxJ zrUZKLc0Q=~chjHKlx*mPFQ{`Q=gty2q@=hjU_?^QTwjc$uEA3tc{uW%=8U(kz5P;K z%^ncPx4N_py)qL=@7&mIHzZW#l!eM~WsY7eE@>$77H-Rm-LkIRT2S6wQ4VDhcYF*P z=ZZ24AtRqD#!*Ng_q08V5c%$Oo%0nzX|AoWuamq)lqV3RMe5bV2okpA0Zaunm3_~{ znf2<~Etqcz!`Sqhsh;JM*`4%7c=uS;918i0zi|jjd~2<=X2(apv!2lT;`81OpUu5_ z@AV3ZZa(@XZ?40zGb&)?PfD@kMZmBv5y_Jzs2*tOA}zwF(n;cIE1mepWmJQ@f$#557(bfIG>s#U-n0zaK8TY9cROKcudnlp*l)WueO5KT> zR(Bt~7UG$k()Lw?f5Ro;SUW_cfTd|&{C8rhzExj!voXJO3vXUD@3b|2(zEN4l;tB$)ZEuM6uc)BS!wa-Udpi5XBp}WZY9Gt@fFbE}8CG`M>3A zl&8<|c|FO9+l}1u(OTJ4{avo+b-$=|EEtum(G@lzg)p&ExUQ4TRm6NGipFzMmf{R^ z9fKCbtUU@%YjN~8%JCwh9NB>Iy-;=)fn()rdm2HE*F{eU7%%6uNDo=$&KC|OY>I7E z{@c5sZ~d#??7P(bm35wP%x$8X!I33ZodqG-u-Ub3JKE6sBK=R6xi6l~B@&oC_`hvk z%9~CID!yGkuKQax=~W*pTWuGs+h2YHp9<*ooWK(^^YEjlA#oc;y5!J6B0wzROwT_^ zNYe)7m{1_KG~+UzXaKyi0f@72q9*LQbuP@xr z3-x+oxMe-AHQ8S`?V>K8n?L8j$MddYuypW+_14)E>^BFO-yx>e%HK=QtXSuskhWDZ znxFy3JWML{e75u#Cjh1x=63RI!4E*sFbrba0t=)10Cx&rU+%RwSX~Umpv`ATNH8?R z;-d{tg)z+?J6ng6q`q!SZ0+Sv>|I6ueh=Lv8@4?5T^p1g7EGWJgdL~BHLBJb7XX|! zXX_sVKxZU>{gViK&U(XowkkQm3)`7p3d&f+bF^&7}VQirrl0Y z=bfzAw!n&96V7b*ga?%#q~v$=6^wtXW7RtVDnVCg04cF z^$%aU57SN{#)Pd1qE@Zdz4GAF%<5e;k+}*%uohYDd+5Vb|zY%9@Y)?9-^}z=UPR)d=e11Sj9#3k=kfWfs~Kc z&+F{NO(olw#~h3dH}~R$$NLE@n^ChiLVcDqof2!GXGM~nY*h0GOy<;VFO*d1%-zNs zV?#X$9@i_G>G2Y5YRjgvSVE6yQnS~YzUk#kI{fO?vK>s!Jgq8RE{kF!1N1YE>9wr7 z96;HiS{^|od{sshWM0Vbl7dw~jbh4dc-d5E((R->b7H5`*05o>h6f(# z^~hj4d-2M*NXC-fqc6V7YB*KEd@(JnC!iP4p>An(Dp_2JIEvzFk@3QGASUyR60NlM znGVt)s2_D8&c&VZA3G`Tdw!J}hip-j_ora757|8e2MPgh*4u`^jbM>G$PD>xCNp0Vdtq)~6;RkLDw!KW z)aL+%m9hCR`^04`6k63H2;vt~-%p{3-$z?+-e21rciHm^)_o3U;`Zc!}wKFkF)g-QU%7@ANLSK5Hkwoc3pG--u zm@UHWm5?W8v;BXic3NA^Z$zKLYTF&npJnjwi^t|ynN=KP$lWtF;v$9UrVc#00K~5d zYZ&q#l*}&M4}Jkc0}q95^fj=#Whf`@PQit zKCmC1DeFDIwOTW?SMh0YePz+I*+m69wr7Is4mq;r$#hgh9Qdjg^ujxPNLC2@yu!mZ zB{x4amc8#_SVf(Z76Q4s$g5gtpl^rN8Ihq5MZC|~bQ8d&AChLuERt*6i%B|LeU~PY z|5smnJyXN#it}ck_dtJ=Cohv5uYA65I$WTl)iIPF^^(NbeL<9T~P77$5oG^p@f0DvV(b zx>UGtf^e)1)URU4jcjaL2$Gjx&1x@Utj8}AGzbF@UatuqQhBD|UVD8nIo`fo)Gt>g zNplVXh%~lwe0chE0)!A{+9q@>udUn(No0Z8?u*nQZN-X1A~d?wp($JZJN08ZYQ53^1iee+T zqE+-&+E&(}Bq9yaWa*fnK=+tH!uI3ONxrQbN>X<0WnHBN*O|(y$`TDLtoxeQP7$vE=hD*bJYDB` z<8M9`@7$+mSGIC*kXe2{!v%C>jbPzxkVIMu#!}FjL&a*=tCGtz2le@umUaZ>xi15& zg9GRP;Q%7(^?K&g@4Y0#PR;eiua#_8*$*zea^^k`wVi($ee8<#|`PP(A$ld-LVHLo*`-ZT^h6SN}%Rm!`Nj z?n}ZdST0zl2}`SgY0se1{qfVM827>aDG50Me5I+`HuHCRV67)>!##P|N7l=+H%bVO zdZ4&uWth6tupGH1lKbo(M=7gXE`P4A#_W}(Xfl;$+!)Zz-1Rn$>z%7h{Bj?in-`24 zwFc&uBmIWH^^Q&V(0}aD+B<8srqp`&lJ<6LUN+h@^`%7e-BJiSi>%+R)-$zDqSQz6 zW-NooJPdpkw2Qi-p4L5sh5Q>@f!vIKvaHHdHShIHNUGfuz3aQSUX)%ei67%C6)|hp z`cV?kIkVLJPhZc?d!hB+T1inkFP`@>CaBGQM64sUG~)8Boi*wC7dT1Lc}&D;BU!GJ(7%Hf7L zot#v_)wZLl|5=d+{RrT0ZE)txrc?GFx46#A?O}vpGnT8Y3aaAg;r2Ro*%I!y(L?B~ zZZaLh@77Fj`TXkGTb@I{DpH2r|IgI$-r=vV%yJz=+tK8aqp7&Dy(#<; zB_tpu$|rD*Pf%E0P)J-*K>UgbkAQ%LBJj}iyA literal 215942 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 z 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 diff --git a/frontend/editor/src/core/assets/brand/modern-logo/logo512.png b/frontend/editor/src/core/assets/brand/modern-logo/logo512.png index b481550734d0cc9f0edcc223808a620a6cb08bc0..90934019e88a1ca460351a7e7501431fead127c0 100644 GIT binary patch literal 4596 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 V12%BJ*o^|fb(8zXn;UpP{2RbX@h$)W literal 8151 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 diff --git a/frontend/editor/src/core/assets/login/authentik.svg b/frontend/editor/src/core/assets/login/authentik.svg index 26dc0189ef..4ed18d49b4 100644 --- a/frontend/editor/src/core/assets/login/authentik.svg +++ b/frontend/editor/src/core/assets/login/authentik.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/login/github.svg b/frontend/editor/src/core/assets/login/github.svg index 1174b67928..41c82d53fe 100644 --- a/frontend/editor/src/core/assets/login/github.svg +++ b/frontend/editor/src/core/assets/login/github.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/login/microsoft.svg b/frontend/editor/src/core/assets/login/microsoft.svg index fc1130cbb2..691300857f 100644 --- a/frontend/editor/src/core/assets/login/microsoft.svg +++ b/frontend/editor/src/core/assets/login/microsoft.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/login/oidc.svg b/frontend/editor/src/core/assets/login/oidc.svg index 440b54487c..6c697d709a 100644 --- a/frontend/editor/src/core/assets/login/oidc.svg +++ b/frontend/editor/src/core/assets/login/oidc.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file From 9ef20dcab80b85041912f045e17a6aea1d08f969 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:25:01 +0000 Subject: [PATCH 182/262] Fix WebKit PDF-engine and storage failures, and catch them in cross-browser CI (#7366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Follow-up to #7314, which fixed the IndexedDB blob rejection itself. This one fixes the remaining WebKit engine gaps, fixes the ways that class of failure surfaced to the user, and adds the cross-browser signal that would have caught them on the PR instead of six weeks later. ## Why this exists Two total WebKit outages sat on `main` for weeks: 1. pdf.js reads its text stream with `for await (… of readableStream)`, and WebKit has no `ReadableStream[Symbol.asyncIterator]`. **All** pdf.js text extraction threw `TypeError: undefined is not a function` — Compare, read-aloud and the PDF text editor were dead on Safari. 2. IndexedDB in WebKit rejects Blob/File values with `UnknownError: Error preparing Blob/File data to be stored in object store`, so nothing persisted and every reload came back empty. Neither was caught, because the existing specs never did the work. The Compare specs filled both slots and asserted the button was enabled; none of them clicked it. The persistence specs asserted a *filename* reappeared after a reload, which only needs the metadata record, not the bytes. Every failure here **looked like success** — empty panes, blank thumbnails, a `src` that was set but empty. That shapes the tests more than the fixes. ## WebKit engine gaps - **`ReadableStream[Symbol.asyncIterator]`**, installed at the entry point before any PDF work starts. The lock discipline is the subtle part: releasing is idempotent, is *not* done after a successful read, and *is* done in the read's error steps — `for await` never calls `return()` when `next()` rejects, so nothing else would ever unlock an errored stream. - **`requestIdleCallback`**, installed once instead of guarded at each call site. This one wasn't broken, it was mistimed: the local fallbacks fired at 200ms and 1000ms, landing the pdfium WASM compile on top of the app's first renders. The shim honours the caller's full timeout, so `{timeout: 2000}` means 2000ms. - **`convertToBlob()` does not fail on a format it can't encode.** Per spec it silently serialises to PNG, so asking for WebP and getting PNG back looks like success. Canvas output now probes what the engine really produced (once per realm) and uses the best lossy format it honours. PNG of a rendered page is several times the size of the equivalent WebP or JPEG, held as object URLs for every page on screen, on the engine with the tightest renderer memory budget. ## WebKit storage failures These read as generic transaction hygiene. They aren't — a refused blob write **aborts its transaction**, which is the mechanism that turned a WebKit rejection into a hang. - **Blob refusal is remembered from any write**, not just the initial `add`. WebKit reports it when it can't write the blob's *backing file*, which is per-operation — an engine that accepted the add can still refuse the rewrite, and every read-modify-write rewrites the record with its body attached. - **Aborted transactions no longer hang.** Read-modify-write moves to a single `updateRecord` helper that owns its transaction, guards it once, and resolves on **commit** rather than on the put's `onsuccess`. The previous shape — two promises over one shared transaction, with an `await` between the get and the put — put the abort guard on the read, leaving the write with no handler at all. `persistVersionedOutputs` awaits that, and `.catch` can't rescue a promise that never settles, so tool outputs could silently stop persisting. - **Stored blobs are no longer re-wrapped on read.** Since #7175 the record holds the `File` itself; wrapping it in `new Blob([record.data])` can cost WebKit the backing handle, giving you an object that looks valid and reads as empty. - **The file sidebar reaches a resting state** when the library can't be read, instead of spinning forever on a rejection nobody observes. It carries on with the in-memory workbench files: an unreadable library should cost the user their history, not the file they're working on. - **Thumbnail failures are logged.** Three `catch {}` blocks returned `""`, and an empty thumbnail is indistinguishable from "this file has no preview" — which is how outage #1 hid as a cosmetic nicety. ## CI `main` now runs the whole stubbed suite once per engine (#7304), so the new `@engine-capability` specs get chromium, firefox and webkit for free. They assert the primitives actually work — a **counted** comparison, a raster thumbnail data URL with real payload, and a page rendered from a file restored by a reload — rather than that the UI rendered. Deliberately small: anything added there is paid for three times per PR, so add depth, not breadth. Run them alone with `task e2e:cross-browser -- --grep @engine-capability`. The cross-browser projects now share the stubbed project's viewport. At the device presets' default 1280x720 a layout difference would fail these specs on Firefox/WebKit only, which reads as an engine outage. `vite.config.ts` gains a `worker.plugins` entry so `@app/*` resolves inside worker bundles. Worker bundles are a separate Rollup pass and don't inherit `plugins`, so the alias worked in the app and failed in a worker — previously worked around with a relative import plus a lint exemption, which silently bypasses the layer cascade. ## Verification - `task frontend:check` green: typecheck, oxlint, theme lint, stylelint, prettier, 215 test files / 1841 tests. - The `@engine-capability` suite passes on Chromium and WebKit locally. - **Negative control:** with the `ReadableStream` shim removed, the WebKit comparison spec fails at the Deletions/Additions assertion — the exact reported Safari symptom. Restored, and it passes. Both the fix and the test that guards it are load-bearing. - The worker alias change verified both ways: the build inlines the encoding probe into the worker chunk, and removing `worker.plugins` fails with `Rollup failed to resolve import "@app/utils/canvasImageEncoding"`. - The abort regression test aborts the transaction mid-write and asserts `markFileAsProcessed` settles. Before the fix it never settles and the test times out. ## Split out of this PR Two things in earlier revisions of this branch were engine-agnostic — found via the same symptom, not the same cause — and now have their own PRs: - **#7416** — blocked IndexedDB upgrades hanging the file library (multi-tab lifecycle, the concurrent-open race, `onversionchange`). - **#7417** — the thumbnail TTL rewriting the whole library on every listing. `FileSidebar`'s try/catch appears in both this PR and #7416, identically: a WebKit rejection and a blocked-open rejection both have to stop stranding the spinner. Whichever merges second is a no-op for that file. ## Known gaps - The blob-refused **rewrite** recovery in `updateRecord` isn't unit-tested. `fake-indexeddb` never returns Blob values from a read, so the branch that converts to a copy can't be reached there. Noted in the test file. - For the same reason, `fileFromRecord`'s "hand the stored File back untouched" path is only covered on a real engine, by the reload spec. - Nothing asserts that `src/index.tsx` imports the shims. The unit suite installs the same module via `setupTests.ts` (jsdom has the same gaps WebKit does), so a future regression where the entry point drops the import would still be green under vitest. - `FileSidebar`'s resting-state fix loses its E2E coverage until #7416 lands — forcing WebKit's blob refusal from a spec isn't practical, which is why that spec blocks the database instead. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] My changes generate no new warnings ### Documentation - [x] 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) --- frontend/.storybook/main.ts | 32 +- frontend/editor/playwright.config.ts | 15 +- .../public/locales/en-US/translation.toml | 4 + .../fileEditor/FileEditorThumbnail.tsx | 7 +- .../src/core/components/layout/Workbench.tsx | 18 +- .../core/components/shared/FileSidebar.tsx | 105 ++- .../components/shared/FileSidebarFileItem.css | 10 + .../components/shared/FileSidebarFileItem.tsx | 20 + .../shared/PolicyEnforcingOverlay.tsx | 3 + .../editor/src/core/contexts/FileContext.tsx | 16 + .../src/core/contexts/FilesPageContext.tsx | 5 +- .../core/contexts/file/FileReducer.test.ts | 37 + .../src/core/contexts/file/FileReducer.ts | 33 +- .../src/core/contexts/file/fileActions.ts | 118 +-- .../contexts/file/hydrationPublish.test.ts | 77 ++ .../editor/src/core/hooks/useFileManager.ts | 12 + .../services/fileStorage.blobFallback.test.ts | 421 ++++++++++- .../editor/src/core/services/fileStorage.ts | 692 +++++++++++++----- .../services/indexedDBManager.blocked.test.ts | 79 ++ .../src/core/services/indexedDBManager.ts | 129 +++- .../src/core/services/pdfiumInit.test.ts | 75 ++ .../editor/src/core/services/pdfiumService.ts | 83 +-- frontend/editor/src/core/setupTests.ts | 4 + .../tests/convert/ConvertIntegration.test.tsx | 2 + .../ConvertSmartDetectionIntegration.test.tsx | 2 + .../src/core/tests/stubbed/compare.spec.ts | 3 + .../tests/stubbed/engine-capabilities.spec.ts | 138 ++++ .../src/core/tools/formFill/FormFill.tsx | 8 +- frontend/editor/src/core/types/fileContext.ts | 6 + .../core/utils/canvasImageEncoding.test.ts | 91 +++ .../src/core/utils/canvasImageEncoding.ts | 45 ++ frontend/editor/src/core/utils/engineShims.ts | 4 + .../patchReadableStreamAsyncIterator.test.ts | 93 +++ .../utils/patchReadableStreamAsyncIterator.ts | 58 ++ .../utils/patchRequestIdleCallback.test.ts | 92 +++ .../core/utils/patchRequestIdleCallback.ts | 37 + .../editor/src/core/utils/thumbnailUtils.ts | 17 +- .../src/core/workers/pixelCompareWorker.ts | 7 +- frontend/editor/src/index.tsx | 12 +- frontend/editor/src/portal/setupTests.ts | 3 + .../policies/policyRunSettles.test.ts | 51 ++ .../components/policies/usePolicyAutoRun.ts | 24 +- .../src/proprietary/utils/scheduleIdle.ts | 13 +- frontend/editor/src/saas/setupTests.ts | 3 + frontend/editor/vite.config.ts | 5 + 45 files changed, 2305 insertions(+), 404 deletions(-) create mode 100644 frontend/editor/src/core/contexts/file/hydrationPublish.test.ts create mode 100644 frontend/editor/src/core/services/indexedDBManager.blocked.test.ts create mode 100644 frontend/editor/src/core/services/pdfiumInit.test.ts create mode 100644 frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts create mode 100644 frontend/editor/src/core/utils/canvasImageEncoding.test.ts create mode 100644 frontend/editor/src/core/utils/canvasImageEncoding.ts create mode 100644 frontend/editor/src/core/utils/engineShims.ts create mode 100644 frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.test.ts create mode 100644 frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.ts create mode 100644 frontend/editor/src/core/utils/patchRequestIdleCallback.test.ts create mode 100644 frontend/editor/src/core/utils/patchRequestIdleCallback.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index c0436a3a82..e86a8636e3 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -10,6 +10,18 @@ import tsconfigPaths from "vite-tsconfig-paths"; * the portal layer at editor/src/portal/). MDX docs pages live in * editor/src/portal/docs/. */ +/** + * Editor stories import via `@app/*` (proprietary→core fallback), `@core/*` and + * `@proprietary/*`. Resolve them exactly the way the editor's own build does - + * through vite-tsconfig-paths against the proprietary vite tsconfig - so the + * shared Storybook can host editor components without duplicating the alias map + * here. Built per pass: the main bundle and the worker bundle each need their own. + */ +const editorPathAliases = () => + tsconfigPaths({ + projects: [resolve(__dirname, "../editor/tsconfig.proprietary.vite.json")], + }); + const config: StorybookConfig = { stories: [ "../editor/src/portal/**/*.mdx", @@ -47,19 +59,15 @@ const config: StorybookConfig = { // than a relative path. "@public": resolve(__dirname, "../editor/public"), }; - // Editor stories import via @app/* (proprietary→core fallback), @core/* and - // @proprietary/*. Resolve them exactly the way the editor's own build does — - // through vite-tsconfig-paths against the proprietary vite tsconfig — so the - // shared Storybook can host editor components without duplicating the alias - // map here. config.plugins = config.plugins ?? []; - config.plugins.push( - tsconfigPaths({ - projects: [ - resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"), - ], - }), - ); + config.plugins.push(editorPathAliases()); + // Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, so + // without this a worker importing @app/* fails to resolve while the same + // import works everywhere else. Mirrors editor/vite.config.ts. + config.worker = { + ...(config.worker ?? {}), + plugins: () => [editorPathAliases()], + }; // Point apiClient.saas at a mock origin so the SaaS-backed billing stories // (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and // their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts index 93e6572392..c4a3885b15 100644 --- a/frontend/editor/playwright.config.ts +++ b/frontend/editor/playwright.config.ts @@ -17,9 +17,12 @@ import { defineConfig, devices } from "@playwright/test"; * * @see https://playwright.dev/docs/test-configuration */ +/** Shared by every stubbed project so a spec sees one layout on all engines. */ +const STUBBED_VIEWPORT = { width: 1920, height: 1080 }; + const chromiumViewport = { ...devices["Desktop Chrome"], - viewport: { width: 1920, height: 1080 }, + viewport: STUBBED_VIEWPORT, }; export default defineConfig({ @@ -55,7 +58,8 @@ export default defineConfig({ }, projects: [ - // Stubbed - no backend required, chromium-only for CI speed + // Stubbed - no backend required. The chromium arm of the cross-browser + // set below; CI fans all three out, one job per engine. { name: "stubbed", testDir: "./src/core/tests/stubbed", @@ -93,16 +97,17 @@ export default defineConfig({ }, }, - // Cross-browser coverage for the stubbed suite (opt-in locally) + // Cross-browser coverage for the stubbed suite. Same viewport as `stubbed`, + // or a layout difference here reads as an engine outage. { name: "stubbed-firefox", testDir: "./src/core/tests/stubbed", - use: { ...devices["Desktop Firefox"] }, + use: { ...devices["Desktop Firefox"], viewport: STUBBED_VIEWPORT }, }, { name: "stubbed-webkit", testDir: "./src/core/tests/stubbed", - use: { ...devices["Desktop Safari"] }, + use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT }, }, ], diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d6b38bc2e8..bebe1b392d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3890,6 +3890,8 @@ addFiles = "Add files" addingFiles = "Adding files…" collapse = "Collapse sidebar" customizeGroups = "Customize groups" +dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it." +dataLostTitle = "File data is unavailable" dropHint = "Open files to get started" dropToAdd = "Drop files to add" expand = "Expand sidebar" @@ -3908,6 +3910,8 @@ viewAll = "View all {{count}} files" [fileSidebar.fileItem] closeViewer = "Close viewer" +dataLost = "Data lost" +dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it." delete = "Delete" moreActions = "More actions" openInViewer = "Open in viewer" diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 1a55c53a90..22c969e704 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -300,6 +300,10 @@ const FileEditorThumbnail = ({ const [showVersionHistory, setShowVersionHistory] = useState(false); const policyEnforcing = policies.some((p) => p.enforcing); + // The overlay swallows clicks, so a run that never settles would leave the card + // unusable with no way out. Dismissible, like the viewer's; resets per run. + const [enforcingDismissed, setEnforcingDismissed] = useState(false); + if (!policyEnforcing && enforcingDismissed) setEnforcingDismissed(false); // The policy currently enforcing, so the overlay's icon/spinner match that // policy's badge instead of a fixed blue. const enforcingPolicy = policies.find((p) => p.enforcing); @@ -548,8 +552,9 @@ const FileEditorThumbnail = ({ {/* Policy enforcement overlay — shown while any policy is in-flight */} setEnforcingDismissed(true)} accentVar={enforcingPolicy?.accentColor} categoryId={enforcingPolicy?.id} /> diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 9f54e224e8..7dbc6f75be 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -1,7 +1,7 @@ import { useState, Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import { Box, Loader, Center } from "@mantine/core"; +import { Box, Loader, Center, Stack, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; @@ -44,7 +44,7 @@ export default function Workbench() { useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true }); // Use context-based hooks to eliminate all prop drilling - const { files: activeFiles } = useAllFiles(); + const { files: activeFiles, fileIds } = useAllFiles(); const { workbench: currentView } = useNavigationState(); const { actions: navActions } = useNavigationActions(); const setCurrentView = navActions.setWorkbench; @@ -134,6 +134,20 @@ export default function Workbench() { } if (activeFiles.length === 0) { + // Files are open but their bytes are still loading (a cold PDF engine can + // take seconds). Showing the drop zone here reads as "the click did nothing". + if (fileIds.length > 0) { + return ( +

      + + + + {t("fileManager.loadingFiles", "Loading files...")} + + +
      + ); + } return ; } diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index f74e96220f..2514e7f556 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -59,7 +59,8 @@ import { deleteServerFile, type DeleteScope, } from "@app/services/serverStorageDelete"; -import { fileStorage } from "@app/services/fileStorage"; +import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage"; +import { alert } from "@app/components/toast"; import { useBulkAddProgress } from "@app/services/bulkAddProgress"; import { useFolderMembership } from "@app/hooks/useFolderMembership"; import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders"; @@ -280,6 +281,19 @@ const FileSidebar = forwardRef( // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); + // Files whose stored bytes this session PROVED unreadable. Rows render a + // "data lost" state instead of pretending the file can open; storage keeps + // the record so a reload re-tests it. + const [lostFileIds, setLostFileIds] = useState>( + () => new Set(), + ); + useEffect( + () => + onRecordUnreadable((fileId) => + setLostFileIds((prev) => new Set(prev).add(fileId as string)), + ), + [], + ); const [stubsLoaded, setStubsLoaded] = useState(false); // Kebab "Save to cloud" target; drives BulkUploadToServerModal. const [saveToServerTarget, setSaveToServerTarget] = useState< @@ -298,32 +312,45 @@ const FileSidebar = forwardRef( const storageEnabled = config?.storageEnabled === true && !isAnonymous; const refreshStubs = useCallback(async () => { - // Leaf files from IDB - same source as the file selection modal. - const stubs = await indexedDB.loadLeafMetadata(); - const idbIds = new Set(stubs.map((s) => s.id as string)); + // `stubsLoaded` gates the spinner, so the `finally` below must set it on + // every path - callers never await this, so a rejection goes nowhere. + let stubs: StirlingFileStub[] = []; + try { + // Leaf files from IDB - same source as the file selection modal. + stubs = await indexedDB.loadLeafMetadata(); + } catch (error) { + // Carry on with the in-memory workbench files: an unreadable library + // should cost the user their history, not the file they're working on. + console.error("Failed to read the file library from storage:", error); + } - // Also include workbench files not yet flushed to IDB. - const pendingStubs = state.files.ids - .map((id) => state.files.byId[id]) - .filter( - (stub): stub is NonNullable => - !!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string), + try { + const idbIds = new Set(stubs.map((s) => s.id as string)); + + // Also include workbench files not yet flushed to IDB. + const pendingStubs = state.files.ids + .map((id) => state.files.byId[id]) + .filter( + (stub): stub is NonNullable => + !!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string), + ); + + const allStubs = [...stubs, ...pendingStubs]; + // A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent. + const superseded = new Set( + allStubs.map((s) => s.parentFileId as string | undefined), ); - - const allStubs = [...stubs, ...pendingStubs]; - // A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent. - const superseded = new Set( - allStubs.map((s) => s.parentFileId as string | undefined), - ); - const currentStubs = allStubs.filter( - (s) => !superseded.has(s.id as string), - ); - setAllFileStubs( - currentStubs.sort( - (a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0), - ), - ); - setStubsLoaded(true); + const currentStubs = allStubs.filter( + (s) => !superseded.has(s.id as string), + ); + setAllFileStubs( + currentStubs.sort( + (a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0), + ), + ); + } finally { + setStubsLoaded(true); + } }, [indexedDB, state.files.ids, state.files.byId]); // Refresh on mount, workbench changes, or external IndexedDB writes — @@ -362,7 +389,9 @@ const FileSidebar = forwardRef( setDeleteTarget(stub); return; } - await fileActions.removeFiles([fileId], true); + // Its superseded versions go too - see orphanedAncestorIds. + const orphans = await fileStorage.orphanedAncestorIds([fileId]); + await fileActions.removeFiles([fileId, ...orphans], true); await refreshStubs(); }, [allFileStubs, fileActions, refreshStubs], @@ -380,7 +409,8 @@ const FileSidebar = forwardRef( await deleteServerFile(stub.remoteStorageId); } if (scope === "device" || scope === "everywhere") { - await fileActions.removeFiles([stub.id], true); + const orphans = await fileStorage.orphanedAncestorIds([stub.id]); + await fileActions.removeFiles([stub.id, ...orphans], true); } else if (scope === "cloud") { // Local copy kept - drop the dead remote pointer so the cloud badge // clears (the sidebar doesn't reconcile with the server itself). @@ -484,6 +514,22 @@ const FileSidebar = forwardRef( const stub = allFileStubs.find((s) => s.id === fileId); if (!stub) return; + // Its bytes are gone; opening it can only fail. Say so instead of a + // click that goes nowhere. + if (stub.dataUnavailable || lostFileIds.has(fileId as string)) { + alert({ + alertType: "warning", + title: t("fileSidebar.dataLostTitle", "File data is unavailable"), + body: t( + "fileSidebar.dataLostBody", + "This browser lost this file's contents. Upload it again to keep working with it.", + ), + expandable: false, + durationMs: 6000, + }); + return; + } + // In the Watched Folders view a click sends the file into the open folder // (mirrors how a click toggles a file into the active workbench elsewhere). // On the folder list (no folder open) it's a no-op so browsing isn't disrupted. @@ -538,6 +584,8 @@ const FileSidebar = forwardRef( }, [ allFileStubs, + lostFileIds, + t, state.files.ids, state.ui.selectedFileIds, fileActions, @@ -725,6 +773,8 @@ const FileSidebar = forwardRef( ? state.files.byId[workbenchFileId]?.thumbnailUrl : undefined) || stub.thumbnailUrl; const fileOrigin = getFileOrigin(stub); + const dataUnavailable = + stub.dataUnavailable === true || lostFileIds.has(stub.id as string); // Key by lineage (originalFileId) so a version swap updates the row in place instead of // remounting. But a 1-input→many-output op (split) yields sibling leaves that share one // originalFileId; those would collide on the key, so fall back to the unique leaf id when a @@ -747,6 +797,7 @@ const FileSidebar = forwardRef( thumbnailUrl={thumbnailUrl} onClick={handleFileClick} onEyeClick={handleEyeClick} + dataUnavailable={dataUnavailable} draggable={isWatchedFoldersActive} onDragStart={handleWatchedFolderDragStart} folders={memberFolders} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 794ed4ca97..874bf47e28 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -447,3 +447,13 @@ transform: translateY(-50%) scale(1); } } + +/* The stored bytes are gone - the row says so instead of pretending to open. */ +.file-sidebar-datalost-badge { + display: inline-flex; + align-items: center; + gap: 0.15rem; + color: var(--c-danger); + font-size: 0.7rem; + white-space: nowrap; +} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index c1a93594c5..2b06c27e56 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -9,6 +9,7 @@ import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined"; import CloudDoneIcon from "@mui/icons-material/CloudDone"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import HistoryIcon from "@mui/icons-material/History"; import type { FileId } from "@app/types/file"; @@ -163,6 +164,9 @@ export interface FileItemProps { onVersionHistory?: (fileId: FileId) => void; /** Whether this file has more than one version (drives the menu item). */ hasVersionHistory?: boolean; + /** The stored bytes are gone (WebKit lost the blob's backing store). The row + * says so instead of pretending the file can open. */ + dataUnavailable?: boolean; } const MAX_VISIBLE_FOLDER_TAGS = 2; @@ -177,6 +181,7 @@ export const FileItem = React.memo(function FileItem({ isSelected, isActive, isViewedInViewer, + dataUnavailable, thumbnailUrl, onClick, onEyeClick, @@ -294,6 +299,21 @@ export const FileItem = React.memo(function FileItem({ )} + {dataUnavailable && ( + + + + {t("fileSidebar.fileItem.dataLost", "Data lost")} + + + )} {isUploadedToCloud && ( void; }) { return null; } diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index a982c347ca..c627d61f35 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -58,6 +58,7 @@ import { IndexedDBProvider, useIndexedDB, } from "@app/contexts/IndexedDBContext"; +import { onRecordUnreadable } from "@app/services/fileStorage"; import { useZipConfirmation } from "@app/hooks/useZipConfirmation"; import ZipWarningModal from "@app/components/shared/ZipWarningModal"; import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; @@ -186,6 +187,21 @@ function FileContextInner({ setUnlockError(null); }, [activeEncryptedFileId]); + // Storage proved a file's bytes unreadable (WebKit losing a blob's backing + // store). Drop it: the viewer would otherwise spin on a document that can + // never load. The record stays, so a reload re-tests it. + useEffect( + () => + onRecordUnreadable((fileId) => { + if (!stateRef.current.files.byId[fileId]) return; + console.error( + `[FileContext] dropping ${fileId} from the workbench: its stored bytes are unreadable`, + ); + lifecycleManager.removeFiles([fileId], stateRef); + }), + [lifecycleManager], + ); + const handleUnlockSkip = useCallback(() => { if (activeEncryptedFileId) { dismissedEncryptedFilesRef.current.add(activeEncryptedFileId); diff --git a/frontend/editor/src/core/contexts/FilesPageContext.tsx b/frontend/editor/src/core/contexts/FilesPageContext.tsx index 6774798aae..b8cf4ccf29 100644 --- a/frontend/editor/src/core/contexts/FilesPageContext.tsx +++ b/frontend/editor/src/core/contexts/FilesPageContext.tsx @@ -455,7 +455,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) { }) .map((s) => s.id); if (localIds.length > 0) { - await fileActions.removeFiles(localIds, true); + // Take the superseded versions with it, or their bytes sit in storage + // forever - invisible, because listings only show leaves. + const orphans = await fileStorage.orphanedAncestorIds(localIds); + await fileActions.removeFiles([...localIds, ...orphans], true); } } diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts index 523ef2e358..bde6a17a02 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.test.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -222,3 +222,40 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", expect(next.ui.selectedFileIds).toEqual(["b2"]); }); }); + +describe("fileContextReducer — REMOVE_FILES", () => { + /** Deleting from the library dispatches this for files that were never in the + * workbench; reallocating then re-renders every consumer for nothing. */ + it("is a true no-op when none of the ids are in the workbench", () => { + const state = stateWith([stub("a")]); + const next = fileContextReducer(state, { + type: "REMOVE_FILES", + payload: { fileIds: ["gone" as FileId] }, + }); + expect(next).toBe(state); + }); + + it("still removes the ids it does hold", () => { + const state = stateWith([stub("a"), stub("b")]); + const next = fileContextReducer(state, { + type: "REMOVE_FILES", + payload: { fileIds: ["a" as FileId, "gone" as FileId] }, + }); + expect(next.files.ids).toEqual(["b"]); + expect(next.files.byId["a" as FileId]).toBeUndefined(); + }); + + it("keeps the files slice when only a selection is cleared", () => { + const base = stateWith([stub("a")]); + const state: FileContextState = { + ...base, + ui: { ...base.ui, selectedFileIds: ["gone" as FileId] }, + }; + const next = fileContextReducer(state, { + type: "REMOVE_FILES", + payload: { fileIds: ["gone" as FileId] }, + }); + expect(next.files).toBe(state.files); + expect(next.ui.selectedFileIds).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 2697974823..a9dcae7b38 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -183,6 +183,20 @@ export function fileContextReducer( const remainingIds = state.files.ids.filter( (id) => !fileIds.includes(id), ); + // Clear selections that reference removed files + const validSelectedFileIds = state.ui.selectedFileIds.filter( + (id) => !fileIds.includes(id), + ); + + // Deleting a library file that was never in the workbench removes nothing + // here, and must not re-render every file and UI consumer. + const removedFromWorkbench = + remainingIds.length !== state.files.ids.length || + fileIds.some((id) => id in state.files.byId); + const deselected = + validSelectedFileIds.length !== state.ui.selectedFileIds.length; + if (!removedFromWorkbench && !deselected) return state; + const newById = { ...state.files.byId }; // Remove files from state (resource cleanup handled by lifecycle manager) @@ -190,21 +204,14 @@ export function fileContextReducer( delete newById[id]; }); - // Clear selections that reference removed files - const validSelectedFileIds = state.ui.selectedFileIds.filter( - (id) => !fileIds.includes(id), - ); - return { ...state, - files: { - ids: remainingIds, - byId: newById, - }, - ui: { - ...state.ui, - selectedFileIds: validSelectedFileIds, - }, + files: removedFromWorkbench + ? { ids: remainingIds, byId: newById } + : state.files, + ui: deselected + ? { ...state.ui, selectedFileIds: validSelectedFileIds } + : state.ui, }; } diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index a95f42939d..4067a99069 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -26,6 +26,9 @@ import { clearBulkAddProgress, } from "@app/services/bulkAddProgress"; const DEBUG = process.env.NODE_ENV === "development"; +/** How long a file may sit unhydrated before the console says so. Reporting only: + * the read is never abandoned, because large files legitimately take time. */ +const STALLED_LOAD_MS = 8000; const HYDRATION_CONCURRENCY = 2; let activeHydrations = 0; const hydrationQueue: Array<() => Promise> = []; @@ -854,61 +857,78 @@ export async function addStirlingFileStubs( // Load File object and hydrate metadata in background (non-blocking) const fileId = stub.id; - // Load File object from IndexedDB asynchronously - scheduleMetadataHydration(async () => { - const stirlingFile = await fileStorage.getStirlingFile(fileId); + // Regenerate page metadata + thumbnails. Queued, because parsing several + // PDFs at once is what the concurrency limit exists to bound. + const scheduleMetadataFor = (stirlingFile: StirlingFile): void => { + scheduleMetadataHydration(async () => { + const processedFileMetadata = + await generateProcessedFileMetadata(stirlingFile); + if (!processedFileMetadata) return; + + const updates: Partial = { + processedFile: processedFileMetadata, + }; + + // Update thumbnail only if current stub doesn't have one + const currentStub = stateRef.current.files.byId[fileId]; + if ( + !currentStub?.thumbnailUrl && + processedFileMetadata.thumbnailUrl + ) { + updates.thumbnailUrl = processedFileMetadata.thumbnailUrl; + if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) { + lifecycleManager.trackBlobUrl(processedFileMetadata.thumbnailUrl); + } + } + + lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef); + }); + }; + + // Load and publish the File, ahead of any parsing. NOT queued: whether a + // file opens at all must not wait on other files' parses. + void (async () => { + // A storage read that never settles renders as a file that silently won't + // open. Name it in the console rather than leaving the user guessing. + const stall = setTimeout( + () => + console.error( + `[Hydration] ${stub.name} (${fileId}) has been loading for ${STALLED_LOAD_MS / 1000}s - the IndexedDB read has not settled`, + ), + STALLED_LOAD_MS, + ); + const stirlingFile = await fileStorage + .getStirlingFile(fileId) + .finally(() => clearTimeout(stall)); if (!stirlingFile) { + // A row with no bytes renders empty and its clicks look dead, so take it + // back out. Storage keeps the record; fileStorage has said why. + console.error( + `[Hydration] No readable data for ${stub.name} (${fileId}); removing it from the workbench`, + ); + lifecycleManager.removeFiles([fileId], stateRef); return; } - // Store the loaded file in filesRef filesRef.current.set(fileId, stirlingFile); - - // Check if processedFile data needs regeneration - if (stirlingFile.type.startsWith("application/pdf")) { - const needsProcessing = - !stub.processedFile || - !stub.processedFile.pages || - stub.processedFile.pages.length === 0 || - stub.processedFile.totalPages !== stub.processedFile.pages.length; - - if (needsProcessing) { - // Regenerate metadata - const processedFileMetadata = - await generateProcessedFileMetadata(stirlingFile); - - if (processedFileMetadata) { - const updates: Partial = { - processedFile: processedFileMetadata, - }; - - // Update thumbnail only if current stub doesn't have one - const currentStub = stateRef.current.files.byId[fileId]; - if ( - !currentStub?.thumbnailUrl && - processedFileMetadata.thumbnailUrl - ) { - updates.thumbnailUrl = processedFileMetadata.thumbnailUrl; - if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) { - lifecycleManager.trackBlobUrl( - processedFileMetadata.thumbnailUrl, - ); - } - } - - lifecycleManager.updateStirlingFileStub( - fileId, - updates, - stateRef, - ); - return; - } - } - } - - // Stub dispatch triggers re-render so the viewer appears (ADD_FILES alone doesn't update selectors). + // filesRef is a ref, so the selectors gating the workbench only see the + // file once something dispatches. Parsing it can't be a precondition. lifecycleManager.updateStirlingFileStub(fileId, {}, stateRef); - }); + + const needsProcessing = + !stub.processedFile || + !stub.processedFile.pages || + stub.processedFile.pages.length === 0 || + stub.processedFile.totalPages !== stub.processedFile.pages.length; + if ( + stirlingFile.type.startsWith("application/pdf") && + needsProcessing + ) { + scheduleMetadataFor(stirlingFile); + } + })().catch((error) => + console.error(`[Hydration] Failed to load ${fileId}:`, error), + ); } return loadedFiles; diff --git a/frontend/editor/src/core/contexts/file/hydrationPublish.test.ts b/frontend/editor/src/core/contexts/file/hydrationPublish.test.ts new file mode 100644 index 0000000000..c4bdda01c1 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/hydrationPublish.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test, vi } from "vitest"; +import type { + FileContextState, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * A clicked file is only visible once hydration DISPATCHES: the workbench reads + * files out of a ref, so `activeFiles` stays empty until then. Parsing must not + * gate that - a PDF engine that stalls used to leave the workbench on its empty + * state with the row showing as open, and clicks doing nothing. + */ + +const getStirlingFile = vi.hoisted(() => vi.fn()); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getStirlingFile }, +})); +/** The stall under test: the page parse never settles. */ +vi.mock("@app/utils/thumbnailUtils", () => ({ + generateThumbnailPairWithMetadata: () => new Promise(() => {}), +})); + +const stub = (id: string): StirlingFileStub => + ({ + id: id as FileId, + name: `${id}.pdf`, + type: "application/pdf", + size: 10, + lastModified: 0, + }) as StirlingFileStub; + +async function harness(ids: string[]) { + vi.resetModules(); + getStirlingFile.mockImplementation( + async (id: FileId) => + new File(["%PDF-1.7"], `${id}.pdf`, { type: "application/pdf" }), + ); + const { addStirlingFileStubs } = + await import("@app/contexts/file/fileActions"); + + const stubs = ids.map(stub); + const state = { + files: { ids: [], byId: {} }, + pinnedFiles: new Set(), + ui: { selectedFileIds: [], selectedPageNumbers: [] }, + } as unknown as FileContextState; + const stateRef = { current: state }; + const filesRef = { current: new Map() }; + const published: FileId[] = []; + const lifecycleManager = { + updateStirlingFileStub: (fileId: FileId) => published.push(fileId), + removeFiles: () => {}, + trackBlobUrl: () => {}, + }; + + await addStirlingFileStubs( + stubs, + {}, + stateRef, + filesRef, + () => {}, + lifecycleManager as never, + ); + return { filesRef, published }; +} + +describe("workbench hydration — a stalled parse can't hide the file", () => { + test("publishes every file's bytes while their parses hang", async () => { + // Three, because the parse queue only runs two at a time: the third proves + // loading isn't queued behind parses that never finish. + const { filesRef, published } = await harness(["a", "b", "c"]); + + await vi.waitFor(() => expect(published).toHaveLength(3)); + expect([...filesRef.current.keys()]).toEqual(["a", "b", "c"]); + }); +}); diff --git a/frontend/editor/src/core/hooks/useFileManager.ts b/frontend/editor/src/core/hooks/useFileManager.ts index 442027364e..4393a22d61 100644 --- a/frontend/editor/src/core/hooks/useFileManager.ts +++ b/frontend/editor/src/core/hooks/useFileManager.ts @@ -389,6 +389,18 @@ export const useFileManager = () => { // Optimistic update — remove from UI immediately, delete IDB in background setFiles(files.filter((_, i) => i !== index)); onRemovedFromWorkbench?.(file.id); + // Superseded versions go with it (see orphanedAncestorIds); best-effort, + // because failing to tidy history must not fail the delete itself. + void fileStorage + .orphanedAncestorIds([file.id]) + .then((orphans) => + orphans.length > 0 + ? fileStorage.deleteMultipleStirlingFiles(orphans) + : undefined, + ) + .catch((error) => + console.warn("Failed to remove superseded versions:", error), + ); indexedDB.deleteFile(file.id).catch((error) => { console.error("Failed to remove file from IndexedDB:", error); // Restore consistency — file is still in IDB so refresh brings it back diff --git a/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts index d8530af026..d6f1c4b89d 100644 --- a/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts +++ b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts @@ -3,23 +3,26 @@ import "fake-indexeddb/auto"; import { expectConsole } from "@app/tests/failOnConsole"; /** - * Regression test for the WebKit nightly breakage introduced with the - * large-file OOM fix (#7175): `storeStirlingFile` began putting the `File` - * itself into IndexedDB (persisted by reference, so multi-GB uploads never - * materialize in JS memory). WebKit refuses blob values whenever it can't write - * the blob's backing file and rejects the request with `UnknownError: Error - * preparing Blob/File data to be stored in object store`, so on WebKit every - * upload silently failed to persist: files vanished on navigation, Compare - * slots never filled, and the classification backfill had no bytes to read. + * WebKit refuses blob values when it can't write the blob's backing file, so + * every upload silently failed to persist after #7175. Retried as a copy now. * - * The service now retries such a rejection with an ArrayBuffer copy and stops - * offering blobs for the rest of the session. + * It can also accept one and then lose the backing store. fake-indexeddb returns no + * Blob, so that loss is injected at the read; real round-trips: the e2e spec. */ -const nativeAdd = IDBObjectStore.prototype.add; +const alertMock = vi.hoisted(() => vi.fn()); +vi.mock("@app/components/toast", () => ({ + alert: (options: unknown) => alertMock(options), +})); -/** What each `add` attempt carried in `data` — the blob path or the copy path. */ +const nativeAdd = IDBObjectStore.prototype.add; +const nativePut = IDBObjectStore.prototype.put; +const nativeGet = IDBObjectStore.prototype.get; + +/** What each `add` attempt carried in `data`: blob path or copy path. */ let attempts: Array<"blob" | "copy"> = []; +/** The same, for `put` - the rewrite path a lost backing store recovers through. */ +let putAttempts: Array<"blob" | "copy"> = []; /** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */ class FailingRequest extends EventTarget { @@ -32,10 +35,7 @@ class FailingRequest extends EventTarget { } } -/** - * Record every add attempt, optionally failing the blob-valued ones the way an - * engine without blob storage does. - */ +/** Record every add, optionally failing the blob-valued ones. */ function instrumentAdd(options: { rejectBlobs: boolean }) { IDBObjectStore.prototype.add = function ( this: IDBObjectStore, @@ -58,11 +58,72 @@ function instrumentAdd(options: { rejectBlobs: boolean }) { } as typeof IDBObjectStore.prototype.add; } -/** - * A fresh service per test: whether the engine accepts blobs is remembered for - * the process lifetime by design, so tests must not inherit that decision from - * each other. - */ +/** Record every put, so the copy-rewrite recovery can be observed. */ +function instrumentPut() { + IDBObjectStore.prototype.put = function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey, + ) { + putAttempts.push( + (value as { data?: unknown } | null)?.data instanceof Blob + ? "blob" + : "copy", + ); + return key === undefined + ? nativePut.call(this, value) + : nativePut.call(this, value, key); + } as typeof IDBObjectStore.prototype.put; +} + +/** A stored blob whose backing store the engine has lost: it still reports a name, + * type and size, and every read of its bytes fails the way WebKit's does. */ +function blobWithLostBackingStore(): Blob { + const lost = () => { + throw new DOMException( + "The object can not be found here.", + "NotFoundError", + ); + }; + return Object.assign( + new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }), + { slice: lost, arrayBuffer: lost, text: lost, stream: lost }, + ); +} + +/** The next `deadReads` reads come back with a lost backing store, later ones + * untouched - so a repaired record can still be read normally. */ +function loseBackingStoreOnRead(deadReads: number) { + let remaining = deadReads; + IDBObjectStore.prototype.get = function ( + this: IDBObjectStore, + key: IDBValidKey | IDBKeyRange, + ) { + const request = nativeGet.call(this, key as IDBValidKey); + // One substitution per request, however often `result` is read. + let injected = false; + return new Proxy(request, { + get(target, prop) { + // Receiver must be the real request: IDBRequest's accessors are branded. + const value = Reflect.get(target, prop, target); + if (prop !== "result") { + return typeof value === "function" ? value.bind(target) : value; + } + if (!value || injected || remaining === 0) return value; + injected = true; + remaining--; + return { ...(value as object), data: blobWithLostBackingStore() }; + }, + set(target, prop, value) { + Reflect.set(target, prop, value, target); + return true; + }, + }); + } as typeof IDBObjectStore.prototype.get; +} + +/** A fresh service per test: the blob decision is remembered by design, so + * tests must not inherit it from each other. */ async function freshFileStorage() { vi.resetModules(); const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] = @@ -86,10 +147,58 @@ async function freshFileStorage() { beforeEach(() => { attempts = []; + putAttempts = []; + alertMock.mockClear(); + // The blob verdict is deliberately durable, so each test must start undecided. + localStorage.clear(); }); afterEach(() => { IDBObjectStore.prototype.add = nativeAdd; + IDBObjectStore.prototype.put = nativePut; + IDBObjectStore.prototype.get = nativeGet; +}); + +/** Abort the transaction the moment a write is issued over it. */ +function abortOnPut() { + IDBObjectStore.prototype.put = function (this: IDBObjectStore) { + const request = new FailingRequest( + new DOMException("transaction aborted", "AbortError"), + ) as unknown as IDBRequest; + this.transaction.abort(); + return request; + } as typeof IDBObjectStore.prototype.put; +} + +describe("read-modify-write — a refused rewrite must not hang or vanish", () => { + /** The abort guard used to sit on the read promise, leaving the write with a + * dead reject - and `.catch` can't rescue a promise that never settles. */ + test("settles instead of hanging when the write transaction aborts", async () => { + expectConsole.error(/Failed to mark file as processed/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("aborts.pdf"); + + abortOnPut(); + + // Before the fix this never settled and the test timed out. + await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(false); + }); + + /** The copy-and-retry recovery can't be exercised here: it needs a record that + * reads back as a Blob, which fake-indexeddb never returns. */ + test("a metadata rewrite still commits, and reports commit not put", async () => { + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("rewrite.pdf"); + + await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(true); + // Missing record: `false`, not a throw and not a claim of success. + await expect( + fileStorage.markFileAsProcessed("nope" as never), + ).resolves.toBe(false); + expect((await fileStorage.getStirlingFile(id))?.name).toBe("rewrite.pdf"); + }); }); describe("storeStirlingFile — blob-value fallback", () => { @@ -113,8 +222,7 @@ describe("storeStirlingFile — blob-value fallback", () => { const id = await store("webkit.pdf"); expect(attempts).toEqual(["blob", "copy"]); - // Readable back is what every downstream consumer depends on: rehydration - // after navigation, thumbnails, the classification backfill. + // Readable back is what rehydration, thumbnails and backfill depend on. expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf"); }); @@ -127,12 +235,32 @@ describe("storeStirlingFile — blob-value fallback", () => { attempts = []; const id = await store("second.pdf"); - // Straight to the copy path — no repeated blob probe, and only the single - // warning expected above. + // Straight to the copy path, and only the one warning expected above. expect(attempts).toEqual(["copy"]); expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf"); }); + /** Committing is not evidence the bytes survived, and by the next reload the + * source File is gone: without this the upload looks fine and the file is dead. */ + test("repairs a record whose stored blob loses its backing store", async () => { + expectConsole.warn(/could not read its bytes back/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + instrumentPut(); + loseBackingStoreOnRead(1); // only the store's own read-back is dead + + const id = await store("dead-on-arrival.pdf"); + + // Accepted as a blob, then rewritten from the file still in hand. + expect(attempts).toEqual(["blob"]); + expect(putAttempts).toEqual(["copy"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe( + "dead-on-arrival.pdf", + ); + // Self-healed, so nothing to tell the user about. + expect(alertMock).not.toHaveBeenCalled(); + }); + test("does not retry a failure a copy can't fix (quota)", async () => { const { store } = await freshFileStorage(); IDBObjectStore.prototype.add = function (this: IDBObjectStore) { @@ -144,3 +272,246 @@ describe("storeStirlingFile — blob-value fallback", () => { expect(attempts).toEqual(["blob"]); }); }); + +/** An earlier session's record can't be repaired, and the user has to be told - but + * the telling must never gate the open. Awaiting the probe stalled every file in + * Safari, where the probe read of a lost backing store never settles. */ +describe("reads — a stored blob whose bytes are gone", () => { + test("hands the file over and reports the loss out of band", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("lost.pdf"); + + loseBackingStoreOnRead(5); // every read from here on + + // Not null, and not awaited on the probe: the caller is never blocked. + expect((await fileStorage.getStirlingFile(id))?.name).toBe("lost.pdf"); + await new Promise((resolve) => setTimeout(resolve)); + + // Told once, not once per reader: every consumer of the file hits this record. + expect(alertMock).toHaveBeenCalledTimes(1); + expect(alertMock.mock.calls[0][0]).toMatchObject({ + alertType: "warning", + body: expect.stringContaining("lost.pdf"), + }); + }); + + /** The loop this closes: a reload re-decides optimistically, writes blobs the + * engine loses again, and the browser never settles on a shape that works. */ + test("remembers across reloads that this browser loses blob values", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const first = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await first.store("lost.pdf"); + loseBackingStoreOnRead(1); + expect(await first.fileStorage.getStirlingFile(id)).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve)); + + // A new page load: a fresh service, same browser profile. + const next = await freshFileStorage(); + attempts = []; + const later = await next.store("after-reload.pdf"); + + expect(attempts).toEqual(["copy"]); + expect((await next.fileStorage.getStirlingFile(later))?.name).toBe( + "after-reload.pdf", + ); + }); + + test("stops offering blob values for the rest of the session", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const first = await store("lost.pdf"); + + loseBackingStoreOnRead(1); + expect(await fileStorage.getStirlingFile(first)).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve)); + + // An engine that loses a blob it accepted can't be trusted with the next one, + // so the read failure degrades writes too. + attempts = []; + const second = await store("later.pdf"); + expect(attempts).toEqual(["copy"]); + expect((await fileStorage.getStirlingFile(second))?.name).toBe("later.pdf"); + }); +}); + +/** Deleting a file used to leave its superseded versions in storage forever, + * invisible (listings filter on isLeaf) and still holding their full bytes. */ +describe("orphanedAncestorIds", () => { + const store = async ( + fileStorage: { storeStirlingFile: (f: never, s: never) => Promise }, + id: string, + parentFileId: string | undefined, + isLeaf: boolean, + ) => { + const { createStirlingFile, createNewStirlingFileStub } = + await import("@app/types/fileContext"); + const file = new File(["%PDF-1.7"], `${id}.pdf`, { + type: "application/pdf", + }); + const base = createNewStirlingFileStub(file); + await fileStorage.storeStirlingFile( + createStirlingFile(file, id as never) as never, + { ...base, id, isLeaf, parentFileId, originalFileId: "v1" } as never, + ); + }; + + test("takes the superseded versions with the leaf", async () => { + const { fileStorage } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + await store(fileStorage as never, "v1", undefined, false); + await store(fileStorage as never, "v2", "v1", true); + + expect(await fileStorage.orphanedAncestorIds(["v2" as never])).toEqual([ + "v1", + ]); + }); + + test("leaves a split sibling's history alone", async () => { + const { fileStorage } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + // Distinct ids: the fake database outlives the module reset between tests. + await store(fileStorage as never, "split-root", undefined, false); + await store(fileStorage as never, "split-a", "split-root", true); + await store(fileStorage as never, "split-b", "split-root", true); + + // `split-b` still descends from the root, so deleting `split-a` can't strip it. + expect(await fileStorage.orphanedAncestorIds(["split-a" as never])).toEqual( + [], + ); + // Once both leaves go, the shared ancestor is genuinely unreachable. + expect( + await fileStorage.orphanedAncestorIds([ + "split-a" as never, + "split-b" as never, + ]), + ).toEqual(["split-root"]); + }); +}); + +/** Handing dead bytes over is only safe if whoever holds them is told to let go - + * otherwise the viewer renders a document that never loads (an endless spinner). */ +describe("confirmed-unreadable records", () => { + test("notifies listeners and refuses to hand the same file out twice", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + const { onRecordUnreadable } = await import("@app/services/fileStorage"); + instrumentAdd({ rejectBlobs: false }); + const id = await store("doomed.pdf"); + + const dropped: string[] = []; + const unsubscribe = onRecordUnreadable((fileId) => dropped.push(fileId)); + + loseBackingStoreOnRead(5); + // First read still hands the file over: the probe is out of band. + expect(await fileStorage.getStirlingFile(id)).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve)); + + // The holder is told, so the workbench can drop it instead of spinning. + expect(dropped).toEqual([id]); + // And a second consumer never gets the same dead bytes. + expect(await fileStorage.getStirlingFile(id)).toBeNull(); + + unsubscribe(); + }); +}); + +/** A readable-blob substitute, for the rescue path: fake-indexeddb never returns + * Blob values, so a healthy legacy blob record is injected the same way a dead + * one is. */ +function substituteHealthyBlobOnRead(reads: number) { + let remaining = reads; + IDBObjectStore.prototype.get = function ( + this: IDBObjectStore, + key: IDBValidKey | IDBKeyRange, + ) { + const request = nativeGet.call(this, key as IDBValidKey); + let injected = false; + return new Proxy(request, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (prop !== "result") { + return typeof value === "function" ? value.bind(target) : value; + } + if (!value || injected || remaining === 0) return value; + injected = true; + remaining--; + return { + ...(value as object), + data: new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }), + }; + }, + set(target, prop, value) { + Reflect.set(target, prop, value, target); + return true; + }, + }); + } as typeof IDBObjectStore.prototype.get; +} + +/** The library must tell the truth per row: a record whose bytes are gone lists + * as data-lost instead of a file that pretends to open. */ +describe("stub listings — data-lost auditing", () => { + test("flags a dead record on the stub once the audit lands", async () => { + expectConsole.warn(/could not read its bytes back/); + expectConsole.error(/cannot be read/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + const id = await store("husk.pdf"); + + loseBackingStoreOnRead(1); + // First read schedules the out-of-band audit; unknown is not yet flagged. + expect( + (await fileStorage.getStirlingFileStub(id))?.dataUnavailable, + ).toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve)); + + expect((await fileStorage.getStirlingFileStub(id))?.dataUnavailable).toBe( + true, + ); + }); + + test("rescues a still-readable legacy blob to a copy on a no-blob browser", async () => { + // The durable verdict says this browser loses blob values... + localStorage.setItem("stirling.indexeddb.blobValuesUnsupported", "true"); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + instrumentPut(); + const id = await store("legacy.pdf"); + + // ...and a legacy record still holds a READABLE blob: save it while we can. + substituteHealthyBlobOnRead(5); + await fileStorage.getStirlingFileStub(id); + await vi.waitFor(() => expect(putAttempts).toContain("copy")); + // Rescued, not condemned: the stub stays openable. + expect( + (await fileStorage.getStirlingFileStub(id))?.dataUnavailable, + ).toBeUndefined(); + }); +}); + +/** One hung request inside the TTL bump's readwrite transaction wedged the whole + * store: every later read and write queued behind it forever - the infinite + * "Loading files..." after a Safari reload. Maintenance must not touch + * blob-bodied records on a browser that can't rewrite them anyway. */ +describe("maintenanceMayRewrite", () => { + test("keeps maintenance away from blob records on a no-blob browser", async () => { + const { maintenanceMayRewrite } = await import("@app/services/fileStorage"); + const blobRecord = { data: new Blob(["x"]) }; + const copyRecord = { data: new ArrayBuffer(1) }; + + expect(maintenanceMayRewrite(blobRecord, false)).toBe(false); + // Copies never hang and their rewrite is accepted - always safe. + expect(maintenanceMayRewrite(copyRecord, false)).toBe(true); + // On engines that genuinely support blobs (Chrome), nothing changes. + expect(maintenanceMayRewrite(blobRecord, true)).toBe(true); + expect(maintenanceMayRewrite(copyRecord, true)).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 40f62642f0..27b8746604 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -15,6 +15,7 @@ import { indexedDBManager, DATABASE_CONFIGS, } from "@app/services/indexedDBManager"; +import { alert } from "@app/components/toast"; /** * Storage record - single source of truth @@ -75,15 +76,135 @@ function isBlobValueRejection(error: unknown): boolean { return name === "UnknownError" || name === "DataCloneError"; } +/** This engine loses Blob values, remembered per browser: session-scoped, each + * reload re-decides optimistically and writes more files it will lose. */ +const BLOB_VALUES_UNSUPPORTED_KEY = "stirling.indexeddb.blobValuesUnsupported"; + +function readBlobValuesSupported(): boolean { + try { + return localStorage.getItem(BLOB_VALUES_UNSUPPORTED_KEY) !== "true"; + } catch { + // Storage unavailable (private mode): decide fresh each session. + return true; + } +} + +function persistBlobValuesUnsupported(): void { + try { + localStorage.setItem(BLOB_VALUES_UNSUPPORTED_KEY, "true"); + } catch { + // Storage unavailable: the session-scoped flag still degrades this session. + } +} + +/** + * Whether maintenance writes (the thumbnail TTL bump) may re-read/re-write this + * record. In WebKit, a `get` touching a blob-bodied record whose backing store is + * damaged can HANG rather than error - and one pending request wedges the whole + * object store: every later transaction, read or write, queues behind it forever. + * That was the infinite "Loading files..." after a reload: the TTL bump's + * transaction never completed, so nothing else on the store ever ran. On a + * browser whose verdict is "blobs unsupported" the rewrite would be refused + * anyway, so blob-bodied records are not worth the risk of touching at all. + */ +export function maintenanceMayRewrite( + record: { data: ArrayBuffer | Blob }, + blobValuesSupported: boolean, +): boolean { + return !(record.data instanceof Blob) || blobValuesSupported; +} + +/** WebKit loses backing stores for blobs it accepted, and only a real read shows + * it. One byte is enough: what fails is opening the store, not the length. */ +async function blobReadFailure(data: Blob): Promise { + try { + await data.slice(0, 1).arrayBuffer(); + return null; + } catch (error) { + return error ?? new Error("Reading a stored blob's bytes failed"); + } +} + +/** Notified when a record's bytes are proven unreadable, so whoever is holding the + * file can drop it instead of rendering a document that never arrives. */ +const unreadableListeners = new Set<(fileId: FileId) => void>(); + +export function onRecordUnreadable( + listener: (fileId: FileId) => void, +): () => void { + unreadableListeners.add(listener); + return () => unreadableListeners.delete(listener); +} + +/** The probe read itself can hang in WebKit, so anything that awaits it needs a + * deadline. Distinct from a failure: nothing was proven either way. */ +const PROBE_UNANSWERED = { unanswered: true } as const; +const PROBE_DEADLINE_MS = 3000; + +function withProbeDeadline( + probe: Promise, +): Promise { + return Promise.race([ + probe, + new Promise((resolve) => + setTimeout(() => resolve(PROBE_UNANSWERED), PROBE_DEADLINE_MS), + ), + ]); +} + +/** + * The File for a stored record. Re-wrapping a stored blob can cost WebKit the + * backing handle, so hand it back untouched when its identity fields match. + */ +function fileFromRecord(record: StoredStirlingFileRecord): File { + const { data } = record; + if ( + data instanceof File && + data.name === record.name && + data.type === record.type && + data.lastModified === record.lastModified + ) { + return data; + } + return new File([data], record.name, { + type: record.type, + lastModified: record.lastModified, + }); +} + +/** + * Settle on abort, for promises whose settle paths (a cursor tick, a request not + * yet issued) never arrive. Call ONCE per transaction - there is one slot. + */ +function settleOnAbort( + transaction: IDBTransaction, + settle: (reason: Error) => void, +): void { + transaction.onabort = () => + settle( + transaction.error ?? + new Error("IndexedDB transaction aborted before it completed"), + ); +} + class FileStorageService { private readonly dbConfig = DATABASE_CONFIGS.FILES; private readonly storeName = "files"; - /** - * Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the - * blob path avoids copying multi-GB files into JS memory, so we try it and - * remember the answer, rather than pre-emptively degrading everywhere. - */ - private blobValuesSupported = true; + /** Whether this engine takes Blob/File values, which avoid copying multi-GB + * files into JS memory. Optimistic; a No outlives the session (see the key). */ + private blobValuesSupported = readBlobValuesSupported(); + /** Whether a stored blob's bytes have come back yet. Until they have, each + * store proves it: accepting the write is no evidence the bytes survived. */ + private blobReadbackVerified = false; + /** Ids whose TTL write failed. Without this the swallowed failure repeats a + * whole-file rewrite on every listing. Session-scoped on purpose. */ + private readonly unwritableRecords = new Set(); + /** Ids already reported as unreadable, so one dead record is surfaced once + * rather than on every read of it. Session-scoped on purpose. */ + private readonly unreadableRecords = new Set(); + /** Ids whose blob bytes this session has already audited (either way), so + * listings don't re-probe every record on every refresh. */ + private readonly auditedRecords = new Set(); /** * Get database connection using centralized manager @@ -101,7 +222,8 @@ class FileStorageService { /** Fire-and-forget: bump thumbnailStoredAt (or clear expired thumbnail) for a set of ids. */ private async bumpThumbnailTTL(ids: FileId[], clear = false): Promise { - if (ids.length === 0) return; + const targets = ids.filter((id) => !this.unwritableRecords.has(id)); + if (targets.length === 0) return; const db = await this.getDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); @@ -112,7 +234,7 @@ class FileStorageService { // Issue all gets up front - each onsuccess creates a put before the // transaction can auto-commit, keeping it alive until all puts settle. - ids.forEach((id) => { + targets.forEach((id) => { const req = store.get(id); req.onsuccess = () => { const record = req.result as StoredStirlingFileRecord | undefined; @@ -123,7 +245,30 @@ class FileStorageService { } else { record.thumbnailStoredAt = Date.now(); } - store.put(record); + // One unwritable record must not take the batch with it: a rejected + // put aborts the transaction the other queued gets are still using. + try { + const put = store.put(record); + put.onerror = (event) => { + // The write we just swallowed is the one that would have taken + // this record out of the expiring set, so stop retrying it. + this.unwritableRecords.add(id); + this.noteBlobRefusal(put.error); + console.warn( + `[fileStorage] thumbnail TTL bump skipped for ${id}:`, + put.error, + ); + // Swallow it here so the failure doesn't abort the transaction. + event.preventDefault(); + event.stopPropagation(); + }; + } catch (error) { + this.unwritableRecords.add(id); + console.warn( + `[fileStorage] thumbnail TTL bump could not be issued for ${id}:`, + error, + ); + } }; req.onerror = () => reject(req.error); }); @@ -186,18 +331,255 @@ class FileStorageService { } catch (error) { // Recoverable: re-add as a copy, and stop offering blobs this session. // Anything else is the caller's to report. - if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) { + if (!(record.data instanceof Blob) || !this.noteBlobRefusal(error)) { throw error; } - this.blobValuesSupported = false; - console.warn( - "IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " + - "Very large files may now exhaust renderer memory.", - error, - ); record.data = await record.data.arrayBuffer(); await this.addFileRecord(db, record); + return; } + + // Committed is not retrievable. Prove the round-trip while the source File is + // still in hand; after a reload there is nothing left to repair from. + if (record.data instanceof Blob && !this.blobReadbackVerified) { + await this.verifyStoredBlobReadable(db, record, stirlingFile); + } + } + + /** Read one stored blob back, rewriting the record from {@code source} if its + * bytes don't come with it. Runs until one round-trip succeeds. */ + private async verifyStoredBlobReadable( + db: IDBDatabase, + record: StoredStirlingFileRecord, + source: File, + ): Promise { + // A record we can't read back at all is the caller's problem, not the probe's. + const stored = await this.readRecord(db, record.id).catch(() => undefined); + if (!(stored?.data instanceof Blob)) return; + + const failure = await withProbeDeadline(blobReadFailure(stored.data)); + if (!failure) { + this.blobReadbackVerified = true; + return; + } + if (failure === PROBE_UNANSWERED) { + // Nothing proven, and an upload must never wait on a probe. Leave the record + // as written; the read path reports it if the bytes really are gone. + console.warn( + `[fileStorage] readability probe for ${record.id} did not answer in ${PROBE_DEADLINE_MS}ms`, + ); + return; + } + + this.noteBlobUnreadable(failure); + try { + record.data = await source.arrayBuffer(); + await this.putRecord(db, record); + } catch (error) { + // The record is unusable either way, and the read path reports that to the + // user. Don't turn a write that already committed into a failure. + console.warn( + `[fileStorage] could not rewrite ${record.id} as an in-memory copy:`, + error, + ); + } + } + + /** Refused a Blob value? Stop offering blobs on this browser. Any write can flip + * this: WebKit refuses per-operation, not per-engine. */ + private noteBlobRefusal(error: unknown): boolean { + if (!isBlobValueRejection(error)) return false; + this.disableBlobValues( + "IndexedDB rejected a Blob value; falling back to in-memory copies on this browser. " + + "Very large files may now exhaust renderer memory.", + error, + ); + return true; + } + + /** A stored blob whose bytes won't read back means blob values can't be trusted + * on this engine either, even though it accepted the write. */ + private noteBlobUnreadable(error: unknown): void { + this.disableBlobValues( + "IndexedDB accepted a Blob value but could not read its bytes back; " + + "falling back to in-memory copies on this browser. " + + "Very large files may now exhaust renderer memory.", + error, + ); + } + + private disableBlobValues(message: string, error: unknown): void { + if (!this.blobValuesSupported) return; + this.blobValuesSupported = false; + persistBlobValuesUnsupported(); + console.warn(message, error); + } + + /** + * Audit a blob-backed record's bytes WITHOUT gating anything on the answer. + * Awaiting this was a mistake: in Safari the probe read of a lost backing store + * can stay pending forever, so it stalled every file open instead of the one + * consumer that would have failed anyway. + * + * Two outcomes, both out of band: + * - Bytes gone: mark + report, so the library shows "data lost" instead of a + * file that pretends to open. + * - Bytes readable on a browser whose verdict is "blobs unsupported": RESCUE the + * record to an ArrayBuffer copy now, while the bytes still exist. Legacy blob + * records on WebKit are one engine hiccup away from being lost for good. + */ + private reportIfUnreadable(record: StoredStirlingFileRecord): void { + if (!(record.data instanceof Blob)) return; + if (this.auditedRecords.has(record.id)) return; + this.auditedRecords.add(record.id); + void blobReadFailure(record.data).then((failure) => { + if (!failure) { + this.blobReadbackVerified = true; + if (!this.blobValuesSupported) void this.rescueBlobRecord(record.id); + return; + } + this.noteBlobUnreadable(failure); + this.reportUnreadableRecord(record, failure); + }); + } + + /** + * Rewrite one still-readable legacy blob record as an ArrayBuffer copy. Reads + * the FULL bytes (the audit only proved the first one) and goes through + * {@link updateRecord}'s read-modify-write so a concurrent metadata update + * isn't clobbered by a stale snapshot. + */ + private async rescueBlobRecord(fileId: FileId): Promise { + try { + const db = await this.getDatabase(); + const record = await this.readRecord(db, fileId); + if (!(record?.data instanceof Blob)) return; + const bytes = await withProbeDeadline(record.data.arrayBuffer()); + if (bytes === PROBE_UNANSWERED || !(bytes instanceof ArrayBuffer)) return; + record.data = bytes; + await this.putRecord(db, record); + console.info( + `[fileStorage] rescued "${record.name}" (${fileId}) to an in-memory copy before this browser could lose its blob`, + ); + } catch (error) { + // Best-effort: a failed rescue leaves the record exactly as it was. + console.warn(`[fileStorage] could not rescue ${fileId}:`, error); + } + } + + /** One console error and one toast per dead record: every consumer of the file + * hits the same record, and the user needs the reason once, not per reader. */ + private reportUnreadableRecord( + record: StoredStirlingFileRecord, + failure: unknown, + ): void { + if (this.unreadableRecords.has(record.id)) return; + this.unreadableRecords.add(record.id); + // Whoever is holding it needs to let go, or the viewer renders a document + // whose bytes never arrive - a spinner with no terminal state. + for (const listener of unreadableListeners) listener(record.id); + console.error( + `[fileStorage] stored data for "${record.name}" (${record.id}) cannot be read; ` + + "the browser no longer has the blob's backing store", + failure, + ); + alert({ + alertType: "warning", + title: "File data is unavailable", + body: + `"${record.name}" is saved in this browser but its contents can no longer be read. ` + + "Upload the file again to keep working on it.", + expandable: false, + durationMs: 8000, + }); + } + + /** Read-modify-write one record in one transaction, resolving on COMMIT. Split + * across two promises, the abort guard covers one and the other hangs. */ + private async updateRecord( + fileId: FileId, + mutate: (record: StoredStirlingFileRecord) => boolean | void, + ): Promise { + const db = await this.getDatabase(); + try { + return await this.readModifyWrite(db, fileId, mutate); + } catch (error) { + // The record we read back still carries its Blob body; retry as a copy. + if (!this.noteBlobRefusal(error)) throw error; + return await this.rewriteRecordAsCopy(db, fileId, mutate); + } + } + + /** {@link updateRecord}'s happy path: one transaction, resolve on commit. */ + private readModifyWrite( + db: IDBDatabase, + fileId: FileId, + mutate: (record: StoredStirlingFileRecord) => boolean | void, + ): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + let written = false; + settleOnAbort(transaction, reject); + transaction.onerror = () => reject(transaction.error); + transaction.oncomplete = () => resolve(written); + + const store = transaction.objectStore(this.storeName); + const getRequest = store.get(fileId); + getRequest.onerror = () => reject(getRequest.error); + getRequest.onsuccess = () => { + const record = getRequest.result as + | StoredStirlingFileRecord + | undefined; + // Nothing to write: let the empty transaction commit and report false. + if (!record || mutate(record) === false) return; + written = true; + store.put(record); + }; + }); + } + + /** Recovery path: two transactions, because materializing the copy is async + * and a transaction cannot survive an await. Last-write-wins either way. */ + private async rewriteRecordAsCopy( + db: IDBDatabase, + fileId: FileId, + mutate: (record: StoredStirlingFileRecord) => boolean | void, + ): Promise { + const record = await this.readRecord(db, fileId); + if (!record || mutate(record) === false) return false; + if (record.data instanceof Blob) { + record.data = await record.data.arrayBuffer(); + } + await this.putRecord(db, record); + return true; + } + + /** One record by id, in its own transaction. */ + private readRecord( + db: IDBDatabase, + fileId: FileId, + ): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); + const request = transaction.objectStore(this.storeName).get(fileId); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + } + + /** One `put`, resolving on commit. */ + private putRecord( + db: IDBDatabase, + record: StoredStirlingFileRecord, + ): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + settleOnAbort(transaction, reject); + transaction.onerror = () => reject(transaction.error); + transaction.oncomplete = () => resolve(); + transaction.objectStore(this.storeName).put(record); + }); } /** Single `add` of a file record. Rejects with the underlying IDB error. */ @@ -215,6 +597,7 @@ class FileStorageService { } const transaction = db.transaction([this.storeName], "readwrite"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.add(record); @@ -227,37 +610,21 @@ class FileStorageService { }); } - /** - * Get StirlingFile with full data - for loading into workbench - */ + /** Get StirlingFile with full data - for loading into workbench. Null covers + * both no such record and bytes gone; neither is a file callers can use. */ async getStirlingFile(id: FileId): Promise { + // Already proven unreadable this session: don't hand the same dead bytes to + // another consumer that will spin on them. Session-scoped, so a reload retries. + if (this.unreadableRecords.has(id)) return null; const db = await this.getDatabase(); + const record = await this.readRecord(db, id); + if (!record) return null; + // Reporting only, and NEVER awaited: WebKit can leave a read of a lost backing + // store pending forever, and this is the path every file open goes through. + this.reportIfUnreadable(record); - return new Promise((resolve, reject) => { - const transaction = db.transaction([this.storeName], "readonly"); - const store = transaction.objectStore(this.storeName); - const request = store.get(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const record = request.result as StoredStirlingFileRecord | undefined; - if (!record) { - resolve(null); - return; - } - - // Create File from stored data - const blob = new Blob([record.data], { type: record.type }); - const file = new File([blob], record.name, { - type: record.type, - lastModified: record.lastModified, - }); - - // Convert to StirlingFile with preserved IDs - const stirlingFile = createStirlingFile(file, record.fileId); - resolve(stirlingFile); - }; - }); + // Convert to StirlingFile with preserved IDs + return createStirlingFile(fileFromRecord(record), record.fileId); } /** @@ -278,6 +645,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.get(id); @@ -295,9 +663,13 @@ class FileStorageService { // We still gate thumbnailUrl on freshness so stale thumbnails // don't leak through this read path. const fresh = this.isThumbnailFresh(record); + // Out-of-band byte audit, so the library reflects lost data (and rescues + // still-readable legacy blobs) instead of listing files that can't open. + this.reportIfUnreadable(record); const stub: StirlingFileStub = { id: record.id, + dataUnavailable: this.unreadableRecords.has(record.id) || undefined, name: record.name, type: record.type, size: record.size, @@ -338,6 +710,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.openCursor(); const stubs: StirlingFileStub[] = []; @@ -352,12 +725,18 @@ class FileStorageService { const record = cursor.value as StoredStirlingFileRecord; if (record && record.name && typeof record.size === "number") { const fresh = this.isThumbnailFresh(record); - if (record.thumbnail) { + if ( + record.thumbnail && + maintenanceMayRewrite(record, this.blobValuesSupported) + ) { if (fresh) tobump.push(record.id); else toexpire.push(record.id); } + this.reportIfUnreadable(record); stubs.push({ id: record.id, + dataUnavailable: + this.unreadableRecords.has(record.id) || undefined, name: record.name, type: record.type, size: record.size, @@ -425,6 +804,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readonly"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.openCursor(); const leafStubs: StirlingFileStub[] = []; @@ -444,12 +824,18 @@ class FileStorageService { record.isLeaf !== false ) { const fresh = this.isThumbnailFresh(record); - if (record.thumbnail) { + if ( + record.thumbnail && + maintenanceMayRewrite(record, this.blobValuesSupported) + ) { if (fresh) tobump.push(record.id); else toexpire.push(record.id); } + this.reportIfUnreadable(record); leafStubs.push({ id: record.id, + dataUnavailable: + this.unreadableRecords.has(record.id) || undefined, name: record.name, type: record.type, size: record.size, @@ -579,6 +965,46 @@ class FileStorageService { return cleared; } + /** + * Superseded versions that nothing else needs once {@code deleting} goes. + * + * Deleting a file removes one record; its older versions keep their full bytes + * and are invisible (listings filter on isLeaf), so they accumulate forever. + * Only for user-facing "delete this file" - deleting ONE version from the + * history journey must leave the rest of the chain alone. + */ + async orphanedAncestorIds(deleting: FileId[]): Promise { + if (deleting.length === 0) return []; + const stubs = await this.getAllStirlingFileStubs(); + const byId = new Map(stubs.map((s) => [s.id as string, s])); + const doomed = new Set(deleting.map(String)); + + // Anything a surviving record descends from has to stay: split siblings + // share a lineage, so one leaf's delete must not strip another's history. + const keep = new Set(); + for (const stub of stubs) { + if (doomed.has(stub.id as string)) continue; + let cursor = stub.parentFileId as string | undefined; + while (cursor && !keep.has(cursor)) { + keep.add(cursor); + cursor = byId.get(cursor)?.parentFileId as string | undefined; + } + } + + const orphans: FileId[] = []; + for (const id of deleting) { + let cursor = byId.get(String(id))?.parentFileId as string | undefined; + while (cursor) { + if (!keep.has(cursor) && !doomed.has(cursor) && byId.has(cursor)) { + doomed.add(cursor); + orphans.push(cursor as FileId); + } + cursor = byId.get(cursor)?.parentFileId as string | undefined; + } + } + return orphans; + } + /** * Delete StirlingFile - single operation, no sync issues */ @@ -587,11 +1013,12 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - const request = store.delete(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); + // On commit, not on the request: callers refresh their list from storage as + // soon as this resolves, and an aborted delete would put the row back. + settleOnAbort(transaction, reject); + transaction.onerror = () => reject(transaction.error); + transaction.oncomplete = () => resolve(); + transaction.objectStore(this.storeName).delete(id); }); } @@ -617,45 +1044,16 @@ class FileStorageService { * Update thumbnail for existing file */ async updateThumbnail(id: FileId, thumbnail: string): Promise { - const db = await this.getDatabase(); - - return new Promise((resolve, _reject) => { - try { - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - const getRequest = store.get(id); - - getRequest.onsuccess = () => { - const record = getRequest.result as StoredStirlingFileRecord; - if (record) { - record.thumbnail = thumbnail; - record.thumbnailStoredAt = Date.now(); - const updateRequest = store.put(record); - - updateRequest.onsuccess = () => { - resolve(true); - }; - updateRequest.onerror = () => { - console.error("Failed to update thumbnail:", updateRequest.error); - resolve(false); - }; - } else { - resolve(false); - } - }; - - getRequest.onerror = () => { - console.error( - "Failed to get file for thumbnail update:", - getRequest.error, - ); - resolve(false); - }; - } catch (error) { - console.error("Transaction error during thumbnail update:", error); - resolve(false); - } - }); + // Reports failure as `false` rather than rejecting; callers just need an answer. + try { + return await this.updateRecord(id, (record) => { + record.thumbnail = thumbnail; + record.thumbnailStoredAt = Date.now(); + }); + } catch (error) { + console.error("Failed to update thumbnail:", error); + return false; + } } /** @@ -666,6 +1064,7 @@ class FileStorageService { return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], "readwrite"); + settleOnAbort(transaction, reject); const store = transaction.objectStore(this.storeName); const request = store.clear(); @@ -720,24 +1119,16 @@ class FileStorageService { async createBlobUrl(id: FileId): Promise { try { const db = await this.getDatabase(); + const record = await this.readRecord(db, id); + if (!record) return null; - return new Promise((resolve, reject) => { - const transaction = db.transaction([this.storeName], "readonly"); - const store = transaction.objectStore(this.storeName); - const request = store.get(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const record = request.result as StoredStirlingFileRecord | undefined; - if (record) { - const blob = new Blob([record.data], { type: record.type }); - const url = URL.createObjectURL(blob); - resolve(url); - } else { - resolve(null); - } - }; - }); + // Stored blobs are handed straight to createObjectURL — re-wrapping + // one can cost WebKit the backing handle. See fileFromRecord. + const blob = + record.data instanceof Blob + ? record.data + : new Blob([record.data], { type: record.type }); + return URL.createObjectURL(blob); } catch (error) { console.warn(`Failed to create blob URL for ${id}:`, error); return null; @@ -750,32 +1141,9 @@ class FileStorageService { */ async markFileAsProcessed(fileId: FileId): Promise { try { - const db = await this.getDatabase(); - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - - const record = await new Promise( - (resolve, reject) => { - const request = store.get(fileId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }, - ); - - if (!record) { - return false; // File not found - } - - // Update the isLeaf flag to false - record.isLeaf = false; - - await new Promise((resolve, reject) => { - const request = store.put(record); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return await this.updateRecord(fileId, (record) => { + record.isLeaf = false; }); - - return true; } catch (error) { console.error("Failed to mark file as processed:", error); return false; @@ -835,32 +1203,9 @@ class FileStorageService { */ async markFileAsLeaf(fileId: FileId): Promise { try { - const db = await this.getDatabase(); - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - - const record = await new Promise( - (resolve, reject) => { - const request = store.get(fileId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }, - ); - - if (!record) { - return false; // File not found - } - - // Update the isLeaf flag to true - record.isLeaf = true; - - await new Promise((resolve, reject) => { - const request = store.put(record); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return await this.updateRecord(fileId, (record) => { + record.isLeaf = true; }); - - return true; } catch (error) { console.error("Failed to mark file as leaf:", error); return false; @@ -870,41 +1215,16 @@ class FileStorageService { /** * Update metadata fields for a stored file record. * - * Resolves on transaction.oncomplete, NOT on the individual put's onsuccess, - * so callers only receive `true` once the write actually commits. If the - * transaction aborts after put() succeeded but before commit, we return false - * - the previous behavior incorrectly claimed success in that window. + * Returns `true` only once the write commits, never on the put's `onsuccess`. + * {@link updateRecord} owns that guarantee for every write in this class. */ async updateFileMetadata( fileId: FileId, updates: Partial, ): Promise { try { - const db = await this.getDatabase(); - return await new Promise((resolve, reject) => { - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - let recordFound = false; - - const getRequest = store.get(fileId); - getRequest.onsuccess = () => { - const record = getRequest.result as - | StoredStirlingFileRecord - | undefined; - if (!record) { - // Don't commit anything; caller wants false. - return; - } - recordFound = true; - const updatedRecord = { ...record, ...updates }; - store.put(updatedRecord); - }; - getRequest.onerror = () => reject(getRequest.error); - - transaction.oncomplete = () => resolve(recordFound); - transaction.onerror = () => reject(transaction.error); - transaction.onabort = () => - reject(transaction.error ?? new Error("updateFileMetadata aborted")); + return await this.updateRecord(fileId, (record) => { + Object.assign(record, updates); }); } catch (error) { console.error("Failed to update file metadata:", error); diff --git a/frontend/editor/src/core/services/indexedDBManager.blocked.test.ts b/frontend/editor/src/core/services/indexedDBManager.blocked.test.ts new file mode 100644 index 0000000000..9031b079bb --- /dev/null +++ b/frontend/editor/src/core/services/indexedDBManager.blocked.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { expectConsole } from "@app/tests/failOnConsole"; +import type { DatabaseConfig } from "@app/services/indexedDBManager"; + +/** + * A blocked open fires `blocked` and then nothing at all - no success, no error - + * until the other connection goes away. Unguarded, the open promise never settles + * and every caller hangs SILENTLY: the file library spun forever with an empty + * console, which is why this kept being reported as unreproducible. + */ + +const config = (name: string, version: number): DatabaseConfig => ({ + name, + version, + stores: [{ name: "things", keyPath: "id" }], +}); + +/** A raw connection on an older version that never yields, i.e. the other tab. */ +function holdOlderVersion(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains("things")) { + request.result.createObjectStore("things", { keyPath: "id" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +afterEach(() => { + vi.useRealTimers(); + vi.resetModules(); +}); + +describe("openDatabase — blocked by another connection", () => { + test("rejects with something actionable instead of hanging", async () => { + expectConsole.warn(/blocked by another connection/); + const { indexedDBManager } = await import("@app/services/indexedDBManager"); + const held = await holdOlderVersion("blocked-db"); + + vi.useFakeTimers(); + const open = indexedDBManager.openDatabase(config("blocked-db", 2)); + const settled = vi.fn(); + void open.then(settled, settled); + + // Still pending before the grace period is up: a tab that yields quickly + // must not be failed prematurely. + await vi.advanceTimersByTimeAsync(4_000); + expect(settled).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2_000); + await expect(open).rejects.toThrow(/blocked by another connection/); + + held.close(); + }); + + test("dedupes concurrent callers onto one connection", async () => { + const { indexedDBManager } = await import("@app/services/indexedDBManager"); + const spy = vi.spyOn(indexedDB, "open"); + + // Racing in the same tick is the case registration-after-await could not + // dedupe, and only the first request would ever receive `blocked`. + const [a, b, c] = await Promise.all([ + indexedDBManager.openDatabase(config("shared-db", 1)), + indexedDBManager.openDatabase(config("shared-db", 1)), + indexedDBManager.openDatabase(config("shared-db", 1)), + ]); + + expect(a).toBe(b); + expect(b).toBe(c); + expect( + spy.mock.calls.filter(([name]) => name === "shared-db"), + ).toHaveLength(1); + spy.mockRestore(); + }); +}); diff --git a/frontend/editor/src/core/services/indexedDBManager.ts b/frontend/editor/src/core/services/indexedDBManager.ts index 043b2e4386..dd823a2bd9 100644 --- a/frontend/editor/src/core/services/indexedDBManager.ts +++ b/frontend/editor/src/core/services/indexedDBManager.ts @@ -19,6 +19,11 @@ export interface DatabaseConfig { }[]; } +/** How long to wait out another connection before failing an open with something + * the user can act on. Rejecting does NOT cancel the request, so a connection + * that arrives later is closed rather than held. */ +const BLOCKED_GRACE_MS = 5000; + class IndexedDBManager { private static instance: IndexedDBManager; private databases = new Map(); @@ -47,6 +52,26 @@ class IndexedDBManager { return existingPromise; } + // Registered BEFORE anything async. A map written after a yield point can't + // dedupe callers racing into it in the same tick, so every context that opened + // this database during boot got its own connection - and per spec only the + // FIRST request ever receives `blocked`, leaving the rest waiting on an event + // that never comes. + const initPromise = this.openWithRecovery(config); + this.initPromises.set(config.name, initPromise); + + try { + const db = await initPromise; + this.databases.set(config.name, db); + return db; + } catch (error) { + this.initPromises.delete(config.name); + throw error; + } + } + + /** The v6/v7 wipe, kept off {@link openDatabase}'s synchronous registration path. */ + private async openWithRecovery(config: DatabaseConfig): Promise { // SaaS lineage shipped a v6 and a v7 of stirling-pdf-files whose // upgrade paths corrupted records (separate cursor walks racing in // one versionchange transaction). The SaaS build wipes those @@ -64,18 +89,7 @@ class IndexedDBManager { await this.deleteDatabase(config.name); } } - - const initPromise = this.performDatabaseInit(config); - this.initPromises.set(config.name, initPromise); - - try { - const db = await initPromise; - this.databases.set(config.name, db); - return db; - } catch (error) { - this.initPromises.delete(config.name); - throw error; - } + return this.performDatabaseInit(config); } private performDatabaseInit(config: DatabaseConfig): Promise { @@ -83,15 +97,60 @@ class IndexedDBManager { console.log(`Opening IndexedDB: ${config.name} v${config.version}`); const request = indexedDB.open(config.name, config.version); + // A blocked upgrade fires `blocked` and then NOTHING - no success, no error - + // until the other connection goes away. Unguarded, the promise never settles + // and every awaiting caller hangs with nothing in the console. + let settled = false; + let blockedTimer: ReturnType | undefined; + + request.onblocked = () => { + console.warn( + `Opening ${config.name} is blocked by another connection (another tab on an older version?). ` + + `Giving up in ${BLOCKED_GRACE_MS}ms if it doesn't yield.`, + ); + blockedTimer = setTimeout(() => { + if (settled) return; + settled = true; + reject( + new Error( + `Opening ${config.name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms. ` + + "Close other tabs of this app and reload.", + ), + ); + }, BLOCKED_GRACE_MS); + }; + request.onerror = () => { + clearTimeout(blockedTimer); + if (settled) return; + settled = true; console.error(`Failed to open ${config.name}:`, request.error); reject(request.error); }; request.onsuccess = () => { + clearTimeout(blockedTimer); const db = request.result; + // We already gave up waiting: close it rather than hold a handle nobody + // awaits, or we become the next tab's blocker. + if (settled) { + db.close(); + return; + } + settled = true; console.log(`Successfully opened ${config.name}`); + // Another tab wants a newer schema. Forget BEFORE closing: a cached but + // closed handle is worse than none, because every transaction on it throws. + db.onversionchange = () => { + console.warn( + `${config.name}: another tab requested a version change; closing this connection`, + ); + this.databases.delete(config.name); + this.initPromises.delete(config.name); + db.close(); + }; + // Set up close handler to clean up our references db.onclose = () => { console.log(`Database ${config.name} closed`); @@ -329,9 +388,36 @@ class IndexedDBManager { return new Promise((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase(name); + // A delete blocks exactly like an upgrade, and this one is awaited on the + // files open path - so an unguarded block hangs the whole storage layer. + let settled = false; + let blockedTimer: ReturnType | undefined; - deleteRequest.onerror = () => reject(deleteRequest.error); + deleteRequest.onblocked = () => { + console.warn( + `Deleting ${name} is blocked by another connection; giving up in ${BLOCKED_GRACE_MS}ms.`, + ); + blockedTimer = setTimeout(() => { + if (settled) return; + settled = true; + reject( + new Error( + `Deleting ${name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms.`, + ), + ); + }, BLOCKED_GRACE_MS); + }; + + deleteRequest.onerror = () => { + clearTimeout(blockedTimer); + if (settled) return; + settled = true; + reject(deleteRequest.error); + }; deleteRequest.onsuccess = () => { + clearTimeout(blockedTimer); + if (settled) return; + settled = true; console.log(`Deleted database: ${name}`); resolve(); }; @@ -343,17 +429,32 @@ class IndexedDBManager { */ async getDatabaseVersion(name: string): Promise { return new Promise((resolve) => { + // This probe runs BEFORE the guarded open, and a versionless open can be + // delayed indefinitely by another tab mid-versionchange. Unknown after the + // grace period beats hanging every storage consumer: the real open that + // follows has its own blocked guard and a message the user can act on. + const giveUp = setTimeout(() => { + console.warn( + `Version probe for ${name} did not answer in ${BLOCKED_GRACE_MS}ms; proceeding without it.`, + ); + resolve(null); + }, BLOCKED_GRACE_MS); const request = indexedDB.open(name); request.onsuccess = () => { + clearTimeout(giveUp); const db = request.result; const version = db.version; db.close(); resolve(version); }; - request.onerror = () => resolve(null); + request.onerror = () => { + clearTimeout(giveUp); + resolve(null); + }; request.onupgradeneeded = () => { // Cancel the upgrade request.transaction?.abort(); + clearTimeout(giveUp); resolve(null); }; }); diff --git a/frontend/editor/src/core/services/pdfiumInit.test.ts b/frontend/editor/src/core/services/pdfiumInit.test.ts new file mode 100644 index 0000000000..315c39a662 --- /dev/null +++ b/frontend/editor/src/core/services/pdfiumInit.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi } from "vitest"; + +/** + * A WASM instantiate that fails must reject, not hang. `instantiateWasm` reports + * success by callback, so a swallowed rejection leaves `init()` pending and takes + * every thumbnail, page parse and form read with it - silently. + */ + +const init = vi.hoisted(() => vi.fn()); +vi.mock("@embedpdf/pdfium", () => ({ init })); + +const wasmModule = vi.hoisted(() => ({}) as WebAssembly.Module); +vi.mock("@app/services/wasmPrecompiler", () => ({ + pdfiumWasmModulePromise: Promise.resolve(wasmModule), + startEagerWasmCompilation: () => {}, + pdfiumWasmUrl: "http://localhost/pdfium.wasm", +})); + +/** emscripten's contract: it calls instantiateWasm and waits to be called back. */ +function emscriptenInit( + instantiate: (imports: object, ok: () => void) => void, +) { + return new Promise(() => { + instantiate({}, () => {}); + }); +} + +async function loadService() { + vi.resetModules(); + return await import("@app/services/pdfiumService"); +} + +describe("pdfium bootstrap", () => { + test("rejects when instantiating the pre-compiled module fails", async () => { + const failure = new Error("LinkError: import mismatch"); + vi.spyOn(WebAssembly, "instantiate").mockRejectedValue(failure as never); + init.mockImplementation((overrides: Record) => + emscriptenInit( + overrides.instantiateWasm as unknown as ( + imports: object, + ok: () => void, + ) => void, + ), + ); + + const { getPdfiumModule } = await loadService(); + + // Before the fix this never settled, so the test timed out. + await expect(getPdfiumModule()).rejects.toThrow(/LinkError/); + }); + + test("a failed load isn't cached, so the next call retries", async () => { + const instantiate = vi + .spyOn(WebAssembly, "instantiate") + .mockRejectedValueOnce(new Error("transient") as never) + .mockResolvedValue({} as never); + const ready = { PDFiumExt_Init: () => {} }; + init.mockImplementation( + (overrides: Record) => + new Promise((resolve) => { + const instantiateWasm = overrides.instantiateWasm as unknown as ( + imports: object, + ok: () => void, + ) => void; + instantiateWasm({}, () => resolve(ready)); + }), + ); + + const { getPdfiumModule } = await loadService(); + + await expect(getPdfiumModule()).rejects.toThrow(/transient/); + await expect(getPdfiumModule()).resolves.toBe(ready); + expect(instantiate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/editor/src/core/services/pdfiumService.ts b/frontend/editor/src/core/services/pdfiumService.ts index a9a44c693b..d6cf12a311 100644 --- a/frontend/editor/src/core/services/pdfiumService.ts +++ b/frontend/editor/src/core/services/pdfiumService.ts @@ -80,17 +80,24 @@ function wasmUrl(): string { * This is the low-level PDFium WASM interface with all C functions wrapped. * Prefer `withDocument()` for document-scoped work. */ -export async function getPdfiumModule(): Promise { - if (_module) return _module; - if (!_initPromise) { - // Ensure eager compilation has started if PDF service is requested before idle timeout - startEagerWasmCompilation(); +/** Reuses the WASM pre-compiled at boot. Every failure must reach this promise: + * `instantiateWasm` reports success by callback, so a rejection inside it leaves + * `init()` pending forever - and with it every thumbnail, parse and form read. */ +async function initPdfiumModule(): Promise { + // Ensure eager compilation has started if PDF service is requested before idle timeout + startEagerWasmCompilation(); - const overrides: PdfiumModuleOverrides = { - locateFile: () => wasmUrl(), - }; + const overrides: PdfiumModuleOverrides = { locateFile: () => wasmUrl() }; + const precompiled = await pdfiumWasmModulePromise; - // Eagerly reuse pre-compiled WASM module from app boot if available + let reportFailure: (error: unknown) => void = () => {}; + const instantiateFailed = new Promise((_, reject) => { + reportFailure = reject; + }); + + // No pre-compiled module: leave instantiateWasm alone so emscripten fetches the + // WASM itself and rejects init() on failure, instead of a fallback that can't. + if (precompiled) { overrides.instantiateWasm = ( imports: WebAssembly.Imports, successCallback: ( @@ -98,40 +105,34 @@ export async function getPdfiumModule(): Promise { module: WebAssembly.Module, ) => void, ) => { - pdfiumWasmModulePromise - .then((wasmModule) => { - if (wasmModule) { - return WebAssembly.instantiate(wasmModule, imports).then( - (instance) => { - successCallback(instance, wasmModule); - }, - ); - } else { - throw new Error("No pre-compiled WASM module found"); - } - }) - .catch((err: unknown) => { - console.warn( - "Eager WebAssembly instantiation failed, falling back to streaming compilation:", - err, - ); - WebAssembly.instantiateStreaming(fetch(wasmUrl()), imports).then( - (result) => { - successCallback(result.instance, result.module); - }, - ); - }); + WebAssembly.instantiate(precompiled, imports) + .then((instance) => successCallback(instance, precompiled)) + .catch(reportFailure); }; + } - _initPromise = init(overrides as Partial).then((m) => { - // Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up - try { - m.PDFiumExt_Init(); - } catch { - /* already initialized */ - } - _module = m; - return m; + const m = await Promise.race([ + init(overrides as Partial), + instantiateFailed, + ]); + // Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up + try { + m.PDFiumExt_Init(); + } catch { + /* already initialized */ + } + _module = m; + return m; +} + +export async function getPdfiumModule(): Promise { + if (_module) return _module; + if (!_initPromise) { + _initPromise = initPdfiumModule().catch((error: unknown) => { + // Don't cache the failure: every PDF feature in the app goes through here, + // so a transient WASM fetch would take them all down for the session. + _initPromise = null; + throw error; }); } return _initPromise; diff --git a/frontend/editor/src/core/setupTests.ts b/frontend/editor/src/core/setupTests.ts index 57bcaa76a5..d01c3cb589 100644 --- a/frontend/editor/src/core/setupTests.ts +++ b/frontend/editor/src/core/setupTests.ts @@ -2,6 +2,10 @@ import "@testing-library/jest-dom"; import { vi } from "vitest"; import { installFailOnConsole } from "@app/tests/failOnConsole"; +// jsdom is missing the same APIs WebKit is, so tests must agree with the +// browser. Same module `src/index.tsx` installs. +import "@app/utils/engineShims"; + installFailOnConsole(); // Mock localStorage for tests diff --git a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx index 06f9198f05..42c61cf5dc 100644 --- a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx @@ -68,6 +68,8 @@ const mockedApiClient = vi.mocked(apiClient); // Mock only essential services that are actually called by the tests vi.mock("../../services/fileStorage", () => ({ + // FileContext subscribes to this to drop files whose bytes are unreadable. + onRecordUnreadable: () => () => {}, fileStorage: { init: vi.fn().mockResolvedValue(undefined), storeFile: vi.fn().mockImplementation((file, thumbnail) => { diff --git a/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx index 1e1e273de4..aec52300ca 100644 --- a/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/editor/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -66,6 +66,8 @@ const mockedApiClient = vi.mocked(apiClient); // Mock only essential services that are actually called by the tests vi.mock("../../services/fileStorage", () => ({ + // FileContext subscribes to this to drop files whose bytes are unreadable. + onRecordUnreadable: () => () => {}, fileStorage: { init: vi.fn().mockResolvedValue(undefined), storeFile: vi.fn().mockImplementation((file, thumbnail) => { diff --git a/frontend/editor/src/core/tests/stubbed/compare.spec.ts b/frontend/editor/src/core/tests/stubbed/compare.spec.ts index 6893721465..c3c6a990a1 100644 --- a/frontend/editor/src/core/tests/stubbed/compare.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/compare.spec.ts @@ -230,4 +230,7 @@ test.describe("Compare tool slot selection", () => { page.locator('[data-testid="compare-slot-comparison"]'), ).toHaveAttribute("data-slot-state", "empty"); }); + + // These specs stop at slot state. Actually running a comparison lives in + // `engine-capabilities.spec.ts`, which is cross-browser in PR CI. }); diff --git a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts new file mode 100644 index 0000000000..6107fb5276 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts @@ -0,0 +1,138 @@ +/** Runs on all three engines in PR CI, asserting on evidence that can only exist + * if the engine did the work. Keep small - it is paid for three times per PR. */ + +import path from "path"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { dismissTourTooltip, uploadFiles } from "@app/tests/helpers/ui-helpers"; + +const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures"); +const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf"); +const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf"); +const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf"); + +/** A missing global or prototype method always surfaces as one of these. + * Matching the shape keeps benign engine noise out (console-clean.spec.ts). */ +const MISSING_API_ERROR = + /is not a function|is not a constructor|undefined is not an object|has no method/i; + +/** Collect the "this engine lacks an API we used" errors seen on the page. */ +function recordMissingApiErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (error: Error) => { + const text = String(error); + if (MISSING_API_ERROR.test(text)) errors.push(text); + }); + return errors; +} + +async function fillCompareSlot( + page: Page, + role: "base" | "comparison", + filePath: string, +) { + await page + .getByTestId(`compare-slot-${role}-add-input`) + .setInputFiles(filePath); + await expect( + page.locator(`[data-testid="compare-slot-${role}"]`), + ).toHaveAttribute("data-slot-state", "filled", { timeout: 20_000 }); + // The upload modal's overlay outlives its close transition and eats clicks. + await page + .locator(".mantine-Modal-overlay") + .waitFor({ state: "detached", timeout: 5_000 }) + .catch(() => { + /* already gone */ + }); +} + +test.describe("engine capabilities", { tag: "@engine-capability" }, () => { + test("extracts PDF text and completes a comparison", async ({ page }) => { + test.setTimeout(120_000); + const missingApis = recordMissingApiErrors(page); + + await page.locator('[data-tour="tool-button-compare"]').first().click(); + await page.waitForSelector('[data-testid="compare-slot-base"]', { + timeout: 20_000, + }); + + await fillCompareSlot(page, "base", PDF_A); + await fillCompareSlot(page, "comparison", PDF_B); + + // By test id: `name` matches as a substring, so "Compare" also hits the + // tool button that opened this panel. + await page.getByTestId("compare-execute").click(); + + // Counted results, not headings: an extraction returning nothing still + // renders empty panes, which is how the WebKit failure looked like success. + const deletions = page.getByText(/Deletions \((\d+)\)/); + const additions = page.getByText(/Additions \((\d+)\)/); + await expect(deletions).toBeVisible({ timeout: 60_000 }); + await expect(additions).toBeVisible(); + expect(await deletions.innerText()).not.toMatch(/\(0\)/); + expect(await additions.innerText()).not.toMatch(/\(0\)/); + + expect(missingApis, "no missing-API errors during comparison").toEqual([]); + }); + + test("rasterises page thumbnails via the PDF engine", async ({ page }) => { + test.setTimeout(120_000); + const missingApis = recordMissingApiErrors(page); + + await uploadFiles(page, SAMPLE_PDF); + await dismissTourTooltip(page); + + // A page thumbnail only exists if the WASM engine loaded, rendered and + // encoded. When it fails the grid still renders, just with no . + await page.getByText("PDF Multi Tool", { exact: true }).first().click(); + + const thumbnail = page + .locator("[data-page-id] img[data-original-rotation]") + .first(); + await expect(thumbnail).toBeVisible({ timeout: 60_000 }); + + // An empty encode still yields a src; require enough payload to be real. + const src = await thumbnail.getAttribute("src"); + expect(src ?? "").toMatch(/^data:image\//); + expect(src?.length ?? 0).toBeGreaterThan(1_000); + + expect(missingApis, "no missing-API errors during thumbnailing").toEqual( + [], + ); + }); + + test("reads a stored file's bytes back after a reload", async ({ page }) => { + test.setTimeout(120_000); + const missingApis = recordMissingApiErrors(page); + + await uploadFiles(page, SAMPLE_PDF); + + // Full reload: FileContext rehydrates from IndexedDB, not from memory. + await page.reload({ waitUntil: "domcontentloaded" }); + + const restored = page.locator(".file-sidebar-file-item").first(); + await expect(restored).toBeVisible({ timeout: 30_000 }); + + // Rendering it is the assertion that matters: the metadata record survives + // even when the bytes were never stored, so a filename proves nothing. + await restored.hover(); + await restored + .locator(".file-sidebar-eye-btn") + .click({ timeout: 15_000, force: true }); + + const firstPage = page.locator('[data-page-index="0"]').first(); + await expect(firstPage).toBeVisible({ timeout: 60_000 }); + + // A tile that decoded has non-zero naturalWidth. A blob stored but not + // readable back resolves to nothing, and renders as an empty page. + const tile = firstPage.locator('img[src^="blob:"]').first(); + await expect(tile).toBeAttached({ timeout: 30_000 }); + await expect + .poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), { + timeout: 30_000, + }) + .toBeGreaterThan(0); + + expect(missingApis, "no missing-API errors after rehydration").toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index 599d586319..5ed9dcd47b 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -35,7 +35,7 @@ import { } from "@app/tools/formFill/FormFillContext"; import { useNavigation } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles, useFileState } from "@app/contexts/FileContext"; import { Skeleton } from "@mantine/core"; import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; import type { BaseToolProps } from "@app/types/tool"; @@ -124,7 +124,7 @@ const _MODE_TABS: ModeTabDef[] = [ const FormFill = (_props: BaseToolProps) => { const { t } = useTranslation(); const { selectedTool } = useNavigation(); - const { selectors, state: fileState } = useFileState(); + const { state: fileState } = useFileState(); const { state: formState, @@ -178,7 +178,9 @@ const FormFill = (_props: BaseToolProps) => { const isDirtyRef = useRef(formState.isDirty); isDirtyRef.current = formState.isDirty; - const activeFiles = selectors.getFiles(); + // Subscribing read: getFiles() during render doesn't re-run when the workbench + // changes, so the panel kept showing the pre-hydration (or pre-version) file. + const { files: activeFiles } = useAllFiles(); const selectedFileIds = fileState.ui.selectedFileIds; const currentFile = useMemo(() => { if (activeFiles.length === 0) return null; diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index 0d99b3589d..3dc00c306d 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -61,6 +61,12 @@ export interface StirlingFileStub extends BaseFileMetadata { * unclassified files / non-SaaS builds. */ classificationLabels?: string[]; + /** + * This session proved the stored bytes unreadable (WebKit losing a blob's + * backing store). The row renders as "data lost" instead of pretending the + * file can open; re-uploading is the only recovery. + */ + dataUnavailable?: boolean; // Note: File object stored in provider ref, not in state } diff --git a/frontend/editor/src/core/utils/canvasImageEncoding.test.ts b/frontend/editor/src/core/utils/canvasImageEncoding.test.ts new file mode 100644 index 0000000000..4e94909d53 --- /dev/null +++ b/frontend/editor/src/core/utils/canvasImageEncoding.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + lossyEncodeOptions, + resetCanvasEncodingProbe, +} from "@app/utils/canvasImageEncoding"; + +/** An engine that can't encode a format returns PNG instead of throwing, so + * only the returned Blob's `type` reveals what happened. */ + +/** Stand in for OffscreenCanvas, honouring only the given MIME types. */ +function stubOffscreenCanvas(honoured: string[]): void { + class FakeOffscreenCanvas { + constructor( + public width: number, + public height: number, + ) {} + + getContext() { + return { fillStyle: "", fillRect: () => {} }; + } + + convertToBlob({ type }: { type: string }) { + // Per spec: an unsupported type silently serialises as PNG. + const actual = honoured.includes(type) ? type : "image/png"; + return Promise.resolve(new Blob([new Uint8Array([0])], { type: actual })); + } + } + vi.stubGlobal("OffscreenCanvas", FakeOffscreenCanvas); +} + +afterEach(() => { + vi.unstubAllGlobals(); + resetCanvasEncodingProbe(); +}); + +describe("lossyEncodeOptions", () => { + it("uses WebP when the engine really encodes it", async () => { + stubOffscreenCanvas(["image/webp", "image/jpeg", "image/png"]); + await expect(lossyEncodeOptions()).resolves.toEqual({ + type: "image/webp", + quality: 0.85, + }); + }); + + it("falls back to JPEG when a WebP request silently yields PNG", async () => { + // WebKit's actual behaviour: no WebP encoder, no error either. + stubOffscreenCanvas(["image/jpeg", "image/png"]); + await expect(lossyEncodeOptions()).resolves.toEqual({ + type: "image/jpeg", + quality: 0.85, + }); + }); + + it("falls back to PNG when no lossy format is honoured", async () => { + stubOffscreenCanvas(["image/png"]); + await expect(lossyEncodeOptions()).resolves.toEqual({ + type: "image/png", + quality: 0.85, + }); + }); + + it("passes the caller's quality through", async () => { + stubOffscreenCanvas(["image/webp", "image/png"]); + await expect(lossyEncodeOptions(0.5)).resolves.toEqual({ + type: "image/webp", + quality: 0.5, + }); + }); + + it("probes once and reuses the answer", async () => { + stubOffscreenCanvas(["image/webp", "image/png"]); + const spy = vi.spyOn( + (globalThis as unknown as { OffscreenCanvas: { prototype: object } }) + .OffscreenCanvas.prototype as { convertToBlob: () => unknown }, + "convertToBlob", + ); + await lossyEncodeOptions(); + const afterFirst = spy.mock.calls.length; + await lossyEncodeOptions(); + expect(spy.mock.calls.length).toBe(afterFirst); + }); + + it("returns PNG where there is no OffscreenCanvas at all", async () => { + vi.stubGlobal("OffscreenCanvas", undefined); + await expect(lossyEncodeOptions()).resolves.toEqual({ + type: "image/png", + quality: 0.85, + }); + }); +}); diff --git a/frontend/editor/src/core/utils/canvasImageEncoding.ts b/frontend/editor/src/core/utils/canvasImageEncoding.ts new file mode 100644 index 0000000000..45de65bebf --- /dev/null +++ b/frontend/editor/src/core/utils/canvasImageEncoding.ts @@ -0,0 +1,45 @@ +/** `convertToBlob` silently serialises to PNG for a format it can't encode + * (WebKit, WebP), so only the returned `type` reveals it. Probe once per realm. */ + +/** Candidates in preference order, best compression first. PNG always works. */ +const LOSSY_CANDIDATES = ["image/webp", "image/jpeg"] as const; +const PNG_TYPE = "image/png"; + +let probe: Promise | null = null; + +async function honoursType(type: string, quality: number): Promise { + try { + const canvas = new OffscreenCanvas(2, 2); + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + // JPEG has no alpha, and the pixels are thrown away either way. + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, 2, 2); + const blob = await canvas.convertToBlob({ type, quality }); + return blob.type === type; + } catch { + return false; + } +} + +async function detectLossyType(quality: number): Promise { + if (typeof OffscreenCanvas === "undefined") return PNG_TYPE; + for (const candidate of LOSSY_CANDIDATES) { + if (await honoursType(candidate, quality)) return candidate; + } + return PNG_TYPE; +} + +/** Encode options for a page raster: the best lossy format this engine really + * supports, PNG only if it supports none. `quality` is ignored by PNG. */ +export function lossyEncodeOptions( + quality = 0.85, +): Promise { + probe ??= detectLossyType(quality); + return probe.then((type) => ({ type, quality })); +} + +/** Reset the cached probe. Tests only. */ +export function resetCanvasEncodingProbe(): void { + probe = null; +} diff --git a/frontend/editor/src/core/utils/engineShims.ts b/frontend/editor/src/core/utils/engineShims.ts new file mode 100644 index 0000000000..603c88f969 --- /dev/null +++ b/frontend/editor/src/core/utils/engineShims.ts @@ -0,0 +1,4 @@ +// Browser APIs the app is entitled to assume exist, stood in where an engine +// omits them. Import once, first, before anything that might use them. +import "@app/utils/patchReadableStreamAsyncIterator"; +import "@app/utils/patchRequestIdleCallback"; diff --git a/frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.test.ts b/frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.test.ts new file mode 100644 index 0000000000..d9db1fe62d --- /dev/null +++ b/frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { patchReadableStreamAsyncIterator } from "@app/utils/patchReadableStreamAsyncIterator"; + +/** Node implements the API, so remove it to exercise the shim, then restore. */ +const KEY: PropertyKey = Symbol.asyncIterator; +const proto: object = ReadableStream.prototype; +let native: PropertyDescriptor | undefined; + +beforeEach(() => { + native = Object.getOwnPropertyDescriptor(proto, KEY); + Reflect.deleteProperty(proto, KEY); + patchReadableStreamAsyncIterator(); +}); + +afterEach(() => { + if (native) Object.defineProperty(proto, KEY, native); +}); + +function streamOf(...chunks: number[]): ReadableStream { + return new ReadableStream({ + start(controller) { + chunks.forEach((c) => controller.enqueue(c)); + controller.close(); + }, + }); +} + +function failingStream(error: Error): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(1); + controller.error(error); + }, + }); +} + +describe("patchReadableStreamAsyncIterator", () => { + it("drains a stream with for await, then unlocks it", async () => { + const stream = streamOf(1, 2, 3); + const seen: number[] = []; + for await (const chunk of stream) seen.push(chunk); + + expect(seen).toEqual([1, 2, 3]); + expect(stream.locked).toBe(false); + }); + + // The reader must be released in the read's error steps. `for await` does not + // call `return()` when `next()` rejects, so nothing else would ever unlock it. + it("releases the lock when the stream errors mid-read", async () => { + const boom = new Error("boom"); + const stream = failingStream(boom); + + await expect( + (async () => { + for await (const _ of stream) { + /* drain until it throws */ + } + })(), + ).rejects.toThrow("boom"); + + expect(stream.locked).toBe(false); + }); + + it("cancels and unlocks when the consumer breaks early", async () => { + const stream = streamOf(1, 2, 3); + for await (const chunk of stream) { + expect(chunk).toBe(1); + break; + } + expect(stream.locked).toBe(false); + }); + + // Native short-circuits once finished; throwing "reader owned by no readable + // stream" would break Array.fromAsync and defensive `return()` in a finally. + it("keeps reporting done after the stream is drained", async () => { + const iterator = streamOf(1)[Symbol.asyncIterator](); + await iterator.next(); + + await expect(iterator.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); + }); + + it("leaves a real implementation alone", () => { + if (native) Object.defineProperty(proto, KEY, native); + const before = Object.getOwnPropertyDescriptor(proto, KEY); + patchReadableStreamAsyncIterator(); + expect(Object.getOwnPropertyDescriptor(proto, KEY)).toEqual(before); + }); +}); diff --git a/frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.ts b/frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.ts new file mode 100644 index 0000000000..2aef7c6ce7 --- /dev/null +++ b/frontend/editor/src/core/utils/patchReadableStreamAsyncIterator.ts @@ -0,0 +1,58 @@ +// WebKit ships no `ReadableStream[Symbol.asyncIterator]`, and pdf.js reads its +// text stream with `for await`, so all text extraction threw on Safari. + +export function patchReadableStreamAsyncIterator(): void { + if (typeof ReadableStream === "undefined") return; + if (Symbol.asyncIterator in ReadableStream.prototype) return; + + Object.defineProperty(ReadableStream.prototype, Symbol.asyncIterator, { + writable: true, + configurable: true, + value: function (this: ReadableStream): AsyncIterableIterator { + const reader = this.getReader(); + // Releasing must be idempotent and must NOT happen after a successful + // read - the next `read()` on a released reader throws. + let finished = false; + const release = () => { + if (finished) return; + finished = true; + reader.releaseLock(); + }; + return { + async next(): Promise> { + // Spec short-circuits once finished; throwing here would break any + // consumer that calls `next()` or `return()` defensively. + if (finished) return { done: true, value: undefined }; + try { + const { done, value } = await reader.read(); + if (done) { + release(); + return { done: true, value: undefined }; + } + return { done: false, value }; + } catch (error) { + // The spec releases the reader in the read request's error steps. + // Without this an errored stream stays locked for good: `for await` + // does not call `return()` when `next()` rejects. + release(); + throw error; + } + }, + async return(value?: unknown): Promise> { + if (finished) return { done: true, value: value as T }; + try { + await reader.cancel(); + } finally { + release(); + } + return { done: true, value: value as T }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }, + }); +} + +patchReadableStreamAsyncIterator(); diff --git a/frontend/editor/src/core/utils/patchRequestIdleCallback.test.ts b/frontend/editor/src/core/utils/patchRequestIdleCallback.test.ts new file mode 100644 index 0000000000..dcd0dc722e --- /dev/null +++ b/frontend/editor/src/core/utils/patchRequestIdleCallback.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { patchRequestIdleCallback } from "@app/utils/patchRequestIdleCallback"; + +/** Two things must hold: it stands in where the API is absent, and it never + * displaces a real implementation. */ + +type IdleGlobals = { + requestIdleCallback?: unknown; + cancelIdleCallback?: unknown; +}; + +const globals = globalThis as IdleGlobals; + +let saved: IdleGlobals; + +beforeEach(() => { + vi.useFakeTimers(); + saved = { + requestIdleCallback: globals.requestIdleCallback, + cancelIdleCallback: globals.cancelIdleCallback, + }; +}); + +afterEach(() => { + vi.useRealTimers(); + globals.requestIdleCallback = saved.requestIdleCallback; + globals.cancelIdleCallback = saved.cancelIdleCallback; +}); + +describe("patchRequestIdleCallback", () => { + it("stands in where the engine has no requestIdleCallback", () => { + delete globals.requestIdleCallback; + delete globals.cancelIdleCallback; + patchRequestIdleCallback(); + + const task = vi.fn(); + requestIdleCallback(task); + + // Asynchronous: never runs on the caller's own turn. + expect(task).not.toHaveBeenCalled(); + // With no deadline given, it yields briefly and then runs. + vi.advanceTimersByTime(200); + expect(task).toHaveBeenCalledTimes(1); + + const deadline = task.mock.calls[0][0] as IdleDeadline; + expect(deadline.didTimeout).toBe(false); + expect(deadline.timeRemaining()).toBeGreaterThan(0); + }); + + it("never runs later than the deadline the caller asked for", () => { + delete globals.requestIdleCallback; + patchRequestIdleCallback(); + + const task = vi.fn(); + requestIdleCallback(task, { timeout: 50 }); + vi.advanceTimersByTime(50); + expect(task).toHaveBeenCalledTimes(1); + }); + + // `src/index.tsx` asks for 2000ms so the pdfium WASM compile doesn't land on + // top of the app's first renders. + it("does not run background work earlier than the caller allowed for", () => { + delete globals.requestIdleCallback; + patchRequestIdleCallback(); + + const task = vi.fn(); + requestIdleCallback(task, { timeout: 2000 }); + vi.advanceTimersByTime(1999); + expect(task).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(task).toHaveBeenCalledTimes(1); + }); + + it("cancels a scheduled task by handle", () => { + delete globals.requestIdleCallback; + delete globals.cancelIdleCallback; + patchRequestIdleCallback(); + + const task = vi.fn(); + cancelIdleCallback(requestIdleCallback(task)); + vi.advanceTimersByTime(1000); + expect(task).not.toHaveBeenCalled(); + }); + + it("leaves a real implementation alone", () => { + const native = vi.fn(); + globals.requestIdleCallback = native; + patchRequestIdleCallback(); + expect(globals.requestIdleCallback).toBe(native); + }); +}); diff --git a/frontend/editor/src/core/utils/patchRequestIdleCallback.ts b/frontend/editor/src/core/utils/patchRequestIdleCallback.ts new file mode 100644 index 0000000000..899af7c14c --- /dev/null +++ b/frontend/editor/src/core/utils/patchRequestIdleCallback.ts @@ -0,0 +1,37 @@ +// WebKit ships no `requestIdleCallback`. The shim honours the async/deadline/ +// cancel contract, not the *idle* part, and is window-only like the real API. + +/** Short delay before running, so the current turn's work drains first. */ +const YIELD_MS = 200; + +/** Idle-deadline shape for the timer stand-in. A small positive budget keeps a + * `while (timeRemaining() > 0)` loop doing one chunk rather than spinning. */ +function makeDeadline(): IdleDeadline { + return { didTimeout: false, timeRemaining: () => 1 }; +} + +export function patchRequestIdleCallback(): void { + if (typeof globalThis === "undefined") return; + const target = globalThis as typeof globalThis & { + requestIdleCallback?: typeof requestIdleCallback; + cancelIdleCallback?: typeof cancelIdleCallback; + }; + if (typeof target.requestIdleCallback === "function") return; + + target.requestIdleCallback = ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ): number => + setTimeout( + () => callback(makeDeadline()), + // Honour the caller's deadline: `timeout` is them saying how long this may + // wait, so firing earlier lands background work on top of startup. + options?.timeout ?? YIELD_MS, + ) as unknown as number; + + target.cancelIdleCallback = (handle: number): void => { + clearTimeout(handle); + }; +} + +patchRequestIdleCallback(); diff --git a/frontend/editor/src/core/utils/thumbnailUtils.ts b/frontend/editor/src/core/utils/thumbnailUtils.ts index b356b26db9..00f047bd6d 100644 --- a/frontend/editor/src/core/utils/thumbnailUtils.ts +++ b/frontend/editor/src/core/utils/thumbnailUtils.ts @@ -31,6 +31,12 @@ export function calculateScaleFromFileSize(fileSize: number): number { /** PDFium error code 4 = password required (encrypted PDF). */ const PDFIUM_ERR_PASSWORD = 4; +/** Callers still get a placeholder, but log the cause: an empty thumbnail is + * indistinguishable from "no raster preview", so an outage hides as a nicety. */ +function reportThumbnailFailure(file: File, error: unknown): void { + console.warn(`Thumbnail generation failed for ${file.name}:`, error); +} + /** PDFs at or above this size never get a full-buffer client-side parse * (renderer OOM) - only the linearized-prefix attempt below. */ export const LARGE_PDF_PARSE_LIMIT = 100 * 1024 * 1024; @@ -262,7 +268,7 @@ export async function generateThumbnailForFile(file: File): Promise { const fullArrayBuffer = await file.arrayBuffer(); return await generatePDFThumbnail(fullArrayBuffer, scale); } catch (error) { - console.warn(`PDF processing failed for ${file.name}:`, error); + reportThumbnailFailure(file, error); return ""; } } @@ -314,7 +320,8 @@ export async function generateThumbnailWithMetadata( pageRotations: result.pageRotations, pageDimensions: result.pageDimensions, }; - } catch { + } catch (error) { + reportThumbnailFailure(file, error); return { thumbnail: "", pageCount: 0 }; } } @@ -344,7 +351,8 @@ export async function generateThumbnailWithMetadata( pageRotations: result.pageRotations, pageDimensions: result.pageDimensions, }; - } catch { + } catch (error) { + reportThumbnailFailure(file, error); return { thumbnail: "", pageCount: 1 }; } } @@ -389,7 +397,8 @@ export async function generateThumbnailPairWithMetadata(file: File): Promise<{ unrotated: toPublic(pair.unrotated), rotated: toPublic(pair.rotated), }; - } catch { + } catch (error) { + reportThumbnailFailure(file, error); return { unrotated: { thumbnail: "", pageCount: 0 }, rotated: { thumbnail: "", pageCount: 0 }, diff --git a/frontend/editor/src/core/workers/pixelCompareWorker.ts b/frontend/editor/src/core/workers/pixelCompareWorker.ts index c1390391ba..cc0e85a164 100644 --- a/frontend/editor/src/core/workers/pixelCompareWorker.ts +++ b/frontend/editor/src/core/workers/pixelCompareWorker.ts @@ -14,6 +14,7 @@ import type { PixelCompareWorkerResponse, PixelCompareWorkerWarnings, } from "@app/types/compare"; +import { lossyEncodeOptions } from "@app/utils/canvasImageEncoding"; declare const self: DedicatedWorkerGlobalScope; @@ -155,7 +156,7 @@ const renderPageToBitmap = async ( return { imageData, bitmap }; }; -const ENCODE_OPTS: ImageEncodeOptions = { type: "image/webp", quality: 0.85 }; +const ENCODE_QUALITY = 0.85; const bitmapToBlob = async ( bitmap: ImageBitmap, @@ -168,7 +169,7 @@ const bitmapToBlob = async ( if (!ctx) throw new Error(errorStrings.canvasContextUnavailable); ctx.drawImage(bitmap, 0, 0); bitmap.close(); - return await canvas.convertToBlob(ENCODE_OPTS); + return await canvas.convertToBlob(await lossyEncodeOptions(ENCODE_QUALITY)); }; const diffDataToBlob = async ( @@ -183,7 +184,7 @@ const diffDataToBlob = async ( ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, width, height); ctx.putImageData(diff, 0, 0); - return await canvas.convertToBlob(ENCODE_OPTS); + return await canvas.convertToBlob(await lossyEncodeOptions(ENCODE_QUALITY)); }; interface PageTotals { diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index d66f092bab..1d05928f60 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -3,6 +3,9 @@ // (Edge / Google Translate / extensions) from crashing the app via // parent-mismatch DOMExceptions. See the module for details. import "@app/utils/patchDomForTranslators"; +// WebKit is missing several APIs the app assumes (ReadableStream async +// iteration, which pdf.js needs for all text extraction; requestIdleCallback). +import "@app/utils/engineShims"; import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "../vite-env.d.ts"; // oxlint-disable-line no-restricted-imports -- Outside app paths @@ -21,13 +24,8 @@ import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; applyDevWorktreeLabel(); if (typeof window !== "undefined") { - const scheduleCompilation = () => { - if (typeof requestIdleCallback === "function") { - requestIdleCallback(() => startEagerWasmCompilation(), { timeout: 2000 }); - } else { - setTimeout(startEagerWasmCompilation, 1000); - } - }; + const scheduleCompilation = () => + requestIdleCallback(() => startEagerWasmCompilation(), { timeout: 2000 }); if (document.readyState === "complete") { scheduleCompilation(); diff --git a/frontend/editor/src/portal/setupTests.ts b/frontend/editor/src/portal/setupTests.ts index 9064c734ba..7de4f9f309 100644 --- a/frontend/editor/src/portal/setupTests.ts +++ b/frontend/editor/src/portal/setupTests.ts @@ -1,6 +1,9 @@ import "@testing-library/jest-dom"; import { vi } from "vitest"; +// The shims `src/index.tsx` installs - see core/setupTests.ts. +import "@app/utils/engineShims"; + // Mirrors the editor's setup: jsdom lacks a handful of browser APIs that shared // components (Mantine FocusTrap, responsive helpers) touch on render. diff --git a/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts new file mode 100644 index 0000000000..fdce331821 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { finishedWithNothingToDeliver } from "@app/components/policies/usePolicyAutoRun"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +/** + * A redaction that matches nothing completes with no output file. The import + * effect used to skip those runs entirely, so `imported` never flipped and the + * file's badge + blocking overlay spun forever - on every engine. + */ +const run = (overrides: Partial = {}): PolicyRunRecord => + ({ + runId: "r", + categoryId: "security", + fileId: "f", + fileName: "f.pdf", + fileSize: 1, + target: "saas", + status: "COMPLETED", + outputs: [], + error: null, + startedAt: 0, + ...overrides, + }) as PolicyRunRecord; + +describe("finishedWithNothingToDeliver", () => { + it("settles a completed run that produced no output", () => { + expect(finishedWithNothingToDeliver(run())).toBe(true); + }); + + it("leaves a run with outputs to the import path", () => { + expect( + finishedWithNothingToDeliver( + run({ outputs: [{ fileId: "o", fileName: "o.pdf" }] as never }), + ), + ).toBe(false); + }); + + it("ignores runs that aren't finished, or are already settled", () => { + expect(finishedWithNothingToDeliver(run({ status: "PENDING" }))).toBe( + false, + ); + expect(finishedWithNothingToDeliver(run({ status: "FAILED" }))).toBe(false); + expect(finishedWithNothingToDeliver(run({ imported: true }))).toBe(false); + }); + + it("leaves classification alone - it settles via its own label path", () => { + expect( + finishedWithNothingToDeliver(run({ categoryId: "classification" })), + ).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 597136a32f..7a2c4bda2e 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -124,6 +124,21 @@ function failRun(runId: string, message: string): void { const FILE_WAIT_TRIES = 20; const FILE_WAIT_MS = 250; +/** + * A policy that changed nothing (redaction matched no text, say) completes with no + * output: nothing to deliver, but finished. Left unimported, the file's badge and + * its blocking overlay spin forever. + */ +export function finishedWithNothingToDeliver(run: PolicyRunRecord): boolean { + return ( + run.status === "COMPLETED" && + !run.imported && + (run.outputs?.length ?? 0) === 0 && + // Classification has its own settle path: labels, no output file. + !isClassificationCategory(run.categoryId) + ); +} + function isTerminal(status: PolicyRunStatus): boolean { return ( status === "COMPLETED" || status === "FAILED" || status === "CANCELLED" @@ -351,13 +366,14 @@ export function usePolicyAutoRun(): void { if ( run.status !== "COMPLETED" || run.imported || - importing.current.has(run.runId) || - // Classification settles even with no outputs (nothing to tag); other - // policies need an output to import. - (!run.outputs?.length && !classification) + importing.current.has(run.runId) ) { continue; } + if (finishedWithNothingToDeliver(run)) { + updateRun(run.runId, { imported: true }); + continue; + } importing.current.add(run.runId); // Classification is metadata-only: stamp labels onto the current leaf of // the file it ran on (no version fork). See importClassificationLabels. diff --git a/frontend/editor/src/proprietary/utils/scheduleIdle.ts b/frontend/editor/src/proprietary/utils/scheduleIdle.ts index 8d3e5bec96..55d395b504 100644 --- a/frontend/editor/src/proprietary/utils/scheduleIdle.ts +++ b/frontend/editor/src/proprietary/utils/scheduleIdle.ts @@ -1,11 +1,10 @@ // Idle-time scheduling shared by the classification/backfill passes. -/** Schedule work for the browser's idle time (or soon after, as a fallback). */ +/** + * Schedule work for the browser's idle time. Main thread only: the entry-point + * shim guarantees `requestIdleCallback`, but not in worker scope. + */ export function scheduleIdle(task: () => void): () => void { - if (typeof requestIdleCallback === "function") { - const handle = requestIdleCallback(task, { timeout: 2000 }); - return () => cancelIdleCallback(handle); - } - const timer = window.setTimeout(task, 200); - return () => window.clearTimeout(timer); + const handle = requestIdleCallback(task, { timeout: 2000 }); + return () => cancelIdleCallback(handle); } diff --git a/frontend/editor/src/saas/setupTests.ts b/frontend/editor/src/saas/setupTests.ts index 2c5f53e271..3e8864e49e 100644 --- a/frontend/editor/src/saas/setupTests.ts +++ b/frontend/editor/src/saas/setupTests.ts @@ -2,6 +2,9 @@ import "@testing-library/jest-dom"; import { vi } from "vitest"; import { installFailOnConsole } from "@app/tests/failOnConsole"; +// The shims `src/index.tsx` installs - see core/setupTests.ts. +import "@app/utils/engineShims"; + installFailOnConsole(); // Mock localStorage for tests diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 3dc3272d59..96ce29c480 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -335,6 +335,11 @@ export default defineConfig(async ({ mode, command }) => { compressStaticCopyPlugin(), prerenderOgPlugin(effectiveMode === "saas"), ], + // Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, + // so without this `@app/*` resolves in the app and fails in a worker. + worker: { + plugins: () => [tsconfigPaths({ projects: [tsconfigProject] })], + }, server: { host: true, allowedHosts: allowedHosts.length > 0 ? allowedHosts : undefined, From 4a2329ab6d38c7b6d1903a9ced27229e2215bcda Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:24:45 +0200 Subject: [PATCH 183/262] refactor(hibernate): implement manual Hibernate-compliant equals/hashCode for entity classes (#6433) # Description of Changes This PR refactors our JPA entity classes to replace Lombok's `@Data` and auto-generated `@EqualsAndHashCode` annotations with explicit Lombok annotations and custom, JPA-compliant `equals()` and `hashCode()` implementations. ### Rationale Lombok's default `@Data` and `@EqualsAndHashCode` annotations are not recommended for JPA entities. They often lead to: - Severe performance issues (e.g., loading lazy collections when evaluating `hashCode` or `toString`). - Identity mismatches or collection bugs (e.g., when database-generated IDs transition from `null` to assigned, breaking the entity's lookup in a `Set` or `Map`). This change ensures all JPA entities use safe Hibernate proxy checking and use only the entity's database identifier for equality and hash code calculations. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../software/proprietary/model/Team.java | 36 +++++++++++++++- .../model/security/PersistentAuditEvent.java | 42 ++++++++++++++++-- .../security/model/PersistentLogin.java | 39 ++++++++++++++++- .../security/model/SessionEntity.java | 43 +++++++++++++++++-- .../proprietary/security/model/User.java | 43 +++++++++++++------ .../model/UserServerCertificateEntity.java | 31 ++++++++++++- 6 files changed, 208 insertions(+), 26 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java index a54959b0ff..661e42c771 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -2,13 +2,19 @@ package stirling.software.proprietary.model; import java.io.Serializable; import java.util.HashSet; +import java.util.Objects; import java.util.Set; +import org.hibernate.proxy.HibernateProxy; + import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; -import lombok.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import stirling.software.proprietary.security.model.User; @@ -18,7 +24,6 @@ import stirling.software.proprietary.security.model.User; @NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) public class Team implements Serializable { @@ -47,4 +52,31 @@ public class Team implements Serializable { users.remove(user); user.setTeam(null); } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + Team team = (Team) o; + return getId() != null && Objects.equals(getId(), team.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java index ccaf337c0b..aeb66b47a8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java @@ -1,6 +1,9 @@ package stirling.software.proprietary.model.security; import java.time.Instant; +import java.util.Objects; + +import org.hibernate.proxy.HibernateProxy; import jakarta.persistence.*; @@ -28,7 +31,9 @@ import lombok.*; name = "idx_audit_source_timestamp_principal", columnList = "source,timestamp,principal") }) -@Data +@Getter +@Setter +@ToString(onlyExplicitlyIncluded = true) @Builder @NoArgsConstructor @AllArgsConstructor @@ -36,14 +41,43 @@ public class PersistentAuditEvent { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @ToString.Include private Long id; - private String principal; - private String type; + @ToString.Include private String principal; + + @ToString.Include private String type; private String source; @Column(columnDefinition = "text") private String data; // JSON blob - private Instant timestamp; + @ToString.Include private Instant timestamp; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + PersistentAuditEvent that = (PersistentAuditEvent) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java index fe9c9f4209..312cddb662 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/PersistentLogin.java @@ -1,17 +1,23 @@ package stirling.software.proprietary.security.model; import java.time.Instant; +import java.util.Objects; + +import org.hibernate.proxy.HibernateProxy; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; -import lombok.Data; +import lombok.*; @Entity @Table(name = "persistent_logins") -@Data +@Getter +@Setter +@ToString(onlyExplicitlyIncluded = true) +@NoArgsConstructor public class PersistentLogin { @Id @@ -19,11 +25,40 @@ public class PersistentLogin { private String series; @Column(name = "username", length = 64, nullable = false) + @ToString.Include private String username; @Column(name = "token", length = 64, nullable = false) private String token; @Column(name = "last_used", nullable = false) + @ToString.Include private Instant lastUsed; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + PersistentLogin that = (PersistentLogin) o; + return getSeries() != null && Objects.equals(getSeries(), that.getSeries()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java index 552d97d022..44b2153500 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java @@ -2,16 +2,22 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; import java.time.Instant; +import java.util.Objects; + +import org.hibernate.proxy.HibernateProxy; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; -import lombok.Data; +import lombok.*; @Entity -@Data +@Getter +@Setter +@ToString +@NoArgsConstructor @Table( name = "sessions", indexes = { @@ -23,11 +29,42 @@ import lombok.Data; @Index(name = "idx_sessions_expired", columnList = "expired") }) public class SessionEntity implements Serializable { - @Id private String sessionId; + @Id + @Setter(AccessLevel.NONE) + private String sessionId; private String principalName; private Instant lastRequest; private boolean expired; + + public void setSessionId(String sessionId) { + if (this.sessionId != null && !this.sessionId.equals(sessionId)) { + throw new IllegalStateException("sessionId is immutable once set"); + } + this.sessionId = sessionId; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + SessionEntity that = (SessionEntity) o; + return getSessionId() != null && Objects.equals(getSessionId(), that.getSessionId()); + } + + @Override + public final int hashCode() { + return getSessionId() != null ? getSessionId().hashCode() : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 32733f5fc5..9455ed6e4b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -2,27 +2,19 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.proxy.HibernateProxy; import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; +import lombok.*; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.model.Team; @@ -35,7 +27,6 @@ import stirling.software.proprietary.model.Team; @NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) public class User implements UserDetails, Serializable { @@ -44,7 +35,6 @@ public class User implements UserDetails, Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") - @EqualsAndHashCode.Include private Long id; @Column(name = "username", unique = true) @@ -181,4 +171,31 @@ public class User implements UserDetails, Serializable { public void setOauthGrandfathered(boolean oauthGrandfathered) { this.oauthGrandfathered = oauthGrandfathered; } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + User user = (User) o; + return getId() != null && Objects.equals(getId(), user.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java index 0ad30a3bf7..aef781dd9b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java @@ -2,9 +2,11 @@ package stirling.software.proprietary.workflow.model; import java.io.Serializable; import java.time.LocalDateTime; +import java.util.Objects; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.proxy.HibernateProxy; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -19,7 +21,6 @@ import stirling.software.proprietary.security.model.User; @NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) public class UserServerCertificateEntity implements Serializable { @@ -28,7 +29,6 @@ public class UserServerCertificateEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") - @EqualsAndHashCode.Include @ToString.Include private Long id; @@ -70,4 +70,31 @@ public class UserServerCertificateEntity implements Serializable { @UpdateTimestamp @Column(name = "updated_at") private LocalDateTime updatedAt; + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = + o instanceof HibernateProxy + ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() + : o.getClass(); + Class thisEffectiveClass = + this instanceof HibernateProxy + ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() + : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + UserServerCertificateEntity that = (UserServerCertificateEntity) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy + ? ((HibernateProxy) this) + .getHibernateLazyInitializer() + .getPersistentClass() + .hashCode() + : getClass().hashCode(); + } } From 51705096956f8672869ab63bfb55a70d86fe98b9 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 13 Aug 2026 23:25:36 +0200 Subject: [PATCH 184/262] deps: upgrade mwiede JSch to 2.28.6 and adapt SFTP password handling (#7496) # Description of Changes This PR replaces #7490 and upgrades `com.github.mwiede:jsch` from `0.2.23` to `2.28.6`. In addition to the dependency bump from the original Dependabot PR, this PR includes the required compatibility adjustment for SFTP password authentication: - Updated `jschVersion` in `build.gradle` from `0.2.23` to `2.28.6`. - Updated `SftpFileClient` to pass the configured password to JSch as UTF-8 encoded bytes instead of using the `String` overload. - Preserved the existing SFTP connection and host-key verification behavior. - Addresses the API compatibility changes introduced by the newer JSch version that prevented the dependency upgrade from being used unchanged. This supersedes #7490 --- ## 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. --- .../software/proprietary/policy/network/SftpFileClient.java | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java index 5ecc29392d..d222e3be40 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/network/SftpFileClient.java @@ -65,7 +65,7 @@ final class SftpFileClient implements RemoteFileClient { } Session session = jsch.getSession(config.username(), config.host(), config.port()); if (config.password() != null) { - session.setPassword(config.password()); + session.setPassword(config.password().getBytes(StandardCharsets.UTF_8)); } if (config.hostKeyFingerprint() != null) { // Pinned key: only the configured fingerprint is ever accepted. diff --git a/build.gradle b/build.gradle index a4a62df0a9..2b2d0c625f 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ ext { jpdfiumVersion = "1.0.4" jwtVersion = "0.13.0" awsSdkVersion = "2.44.12" - jschVersion = "0.2.23" + jschVersion = "2.28.6" commonsNetVersion = "3.11.1" smbjVersion = "0.14.0" tinkVersion = "1.23.0" From 929ded41a873f9b024aca38487a1e52208f86444 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:03:47 +0100 Subject: [PATCH 185/262] Harden actions secret handling (#7435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes ## Harden GitHub Actions secret handling Moves secrets behind deployment environments, removes the GitHub App token from workflows that only comment and label, and moves PR preview images to GHCR so the preview path needs no registry credential. Builds on #6005 by @dagecko — that commit is preserved with original authorship, rebased onto current main. ### Extract secrets from `run:` blocks (@dagecko, #6005 rebased) - Secrets referenced in shell bodies moved to step-level `env:` so values never reach a rendered command line - Two `workflow_dispatch` inputs moved out of shell interpolation (`multiOSReleases`, `push-docker-base`) - Dropped the hunks main has since solved — `setup-uv`, `reviewdog`, `build-push-action` and `github-script` are all pinned newer on main now - Fixed a bug in the original: `PR-Demo-cleanup.yml` uses a **quoted** `<< 'ENDSSH'` heredoc, so rewriting `${{ secrets.DOCKER_HUB_USERNAME }}` to `${DOCKER_HUB_USERNAME}` would have sent the literal string to the VPS and expanded to empty, silently orphaning preview images behind `|| true` ### Gate secret-bearing jobs behind environments - `environment:` added to 15 jobs across 10 workflows, mapping to `release-signing`, `docker-publish`, `package-publish`, `pr-preview` and `bot-identity` - Environment branch/tag policies are enforced by GitHub before the job starts, so editing the workflow file cannot bypass them - Four jobs deliberately **not** gated — `tauri-build`, `frontend-backend-licenses-update`, `swagger` and `push-docker-base` would fail their own triggers under the current policies and need restructuring first - Removed the `testMain` trigger from `push-docker` — the branch doesn't exist and isn't in the environment's policy ### Publish PR previews to GHCR instead of Docker Hub - Preview images now go to `ghcr.io/stirling-tools/stirling-pdf-test`, authenticated with `GITHUB_TOKEN` rather than `DOCKER_HUB_API` - Docker Hub personal access tokens cannot be scoped to a single repository, so the preview path was holding the same credential that publishes `s-pdf` and `stirling-pdf` - `DOCKER_HUB_API` no longer appears in any PR-reachable workflow - Login now precedes every `docker manifest inspect` — `deploy-on-v2-commit` had them reversed, which only worked because the Docker Hub repo was public ### Use `GITHUB_TOKEN` for comment and label workflows - Seven workflows no longer mint a GitHub App token; only `sync_files_v2`, `sync-portal-docs` and `frontend-backend-licenses-update` still do, so unattended auto-merge is unaffected - `permissions:` blocks derived per job from the API calls each actually makes — these were previously inert, since an App installation token ignores them, and one job had no block at all - Comment-threading matchers updated to `github-actions[bot]` so workflows still edit their own previous comment instead of posting duplicates - Removed the App token from the `refs/pull/N/merge` checkout in `PR-Demo-Comment-with-react` and set `persist-credentials: false` — it was written into `.git/config` of an untrusted tree that the same job then builds - Fixed a script injection in `check_toml.yml`: a fork-controlled branch name was interpolated into `actions/github-script` JS source, with validation running after the injected code had already executed. Values now come from `process.env` and are validated before use. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: dagecko --- .github/workflows/PR-Auto-Deploy-V2.yml | 84 +++++++++------- .../workflows/PR-Demo-Comment-with-react.yml | 95 +++++++++---------- .github/workflows/PR-Demo-cleanup.yml | 34 ++++--- .github/workflows/ai_pr_title_review.yml | 21 ++-- .github/workflows/aur-publish.yml | 1 + .github/workflows/auto-labelerV2.yml | 13 +-- .github/workflows/check_toml.yml | 61 ++++++------ .github/workflows/deploy-on-v2-commit.yml | 54 +++++++---- .github/workflows/multiOSReleases.yml | 21 ++-- .github/workflows/package-managers.yml | 1 + .github/workflows/pr-conflict-labeler.yml | 15 +-- .github/workflows/push-docker-base.yml | 4 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/rollback-latest.yml | 1 + .github/workflows/sync-portal-docs.yml | 1 + .github/workflows/sync_files_v2.yml | 1 + .github/workflows/tauri-build.yml | 16 +++- .github/workflows/testdriver.yml | 42 ++++++-- 18 files changed, 264 insertions(+), 203 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 50be9fe4cf..1ae657fa76 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -26,6 +26,10 @@ jobs: check-pr: if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + # Only reads the PR via pulls.get with the default GITHUB_TOKEN. + permissions: + contents: read + pull-requests: read outputs: should_deploy: ${{ steps.decide.outputs.should_deploy }} is_fork: ${{ steps.resolve.outputs.is_fork }} @@ -97,6 +101,7 @@ jobs: echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT deploy-v2-pr: + environment: pr-preview needs: check-pr runs-on: ubuntu-latest if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true') @@ -107,6 +112,7 @@ jobs: permissions: contents: read issues: write + packages: write pull-requests: write env: # Single source of truth for whether this preview embeds the admin portal: @@ -125,20 +131,11 @@ jobs: repository: ${{ github.repository }} ref: main - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Add deployment started comment id: deployment-started uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ needs.check-pr.outputs.pr_number }}; @@ -180,7 +177,8 @@ jobs: with: repository: ${{ needs.check-pr.outputs.pr_repository }} ref: ${{ needs.check-pr.outputs.pr_ref }} - token: ${{ secrets.GITHUB_TOKEN }} + # untrusted tree is built below - never leave credentials in .git/config + persist-credentials: false fetch-depth: 0 # Fetch full history for commit hash detection - name: Set up Docker Buildx @@ -192,11 +190,16 @@ jobs: VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - - name: Login to Docker Hub + - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - name: Get commit hash for app id: commit-hash @@ -220,7 +223,7 @@ jobs: - name: Check if image exists id: check-image run: | - if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then + if docker manifest inspect ${IMAGE_BASE}:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT echo "Image already exists, skipping build" else @@ -228,6 +231,8 @@ jobs: echo "Image needs to be built" fi + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - name: Build and push V2 image if: steps.check-image.outputs.exists == 'false' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 @@ -237,7 +242,7 @@ jobs: push: true cache-from: type=gha,scope=stirling-pdf-latest cache-to: type=gha,mode=max,scope=stirling-pdf-latest - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} + tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-${{ steps.commit-hash.outputs.app_short }} build-args: | VERSION_TAG=v2-alpha BUILD_PORTAL=${{ env.BUILD_PORTAL }} @@ -246,9 +251,11 @@ jobs: - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Deploy V2 to VPS id: deploy run: | @@ -261,7 +268,7 @@ jobs: services: stirling-pdf-v2: container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }} - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} + image: ${IMAGE_BASE}:v2-${{ steps.commit-hash.outputs.app_short }} ports: - "${V2_PORT}:8080" volumes: @@ -273,8 +280,8 @@ jobs: DISABLE_ADDITIONAL_FEATURES: "false" STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" SECURITY_ENABLELOGIN: "true" - SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" - SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" + SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}" + SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}" SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}" UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture" @@ -288,9 +295,9 @@ jobs: EOF # Deploy to VPS - scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml + scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose-v2.yml - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH # Create V2 PR-specific directories mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage} @@ -315,6 +322,13 @@ jobs: # Set port for output echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + TEST_LOGIN_USERNAME: ${{ secrets.TEST_LOGIN_USERNAME }} + TEST_LOGIN_PASSWORD: ${{ secrets.TEST_LOGIN_PASSWORD }} + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + # ---- Storybook preview (only when this PR touches stories/.storybook) ---- # Runs inside the same approved-contributor-gated deploy job, so it deploys # under the exact same access rules as the app preview. @@ -379,8 +393,9 @@ jobs: env: SB_URL: ${{ steps.storybook.outputs.url }} SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ needs.check-pr.outputs.pr_number }}; @@ -401,7 +416,7 @@ jobs: } } - const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`; + const deploymentUrl = `http://${process.env.NEW_VPS_HOST}:${v2Port}`; // Only mention the portal when this image actually embeds it. // Use the direct IP URL - the SSL hostname isn't supported yet. @@ -447,6 +462,7 @@ jobs: }); cleanup-v2-deployment: + environment: pr-preview if: github.event.action == 'closed' runs-on: ubuntu-latest permissions: @@ -463,19 +479,10 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Clean up V2 deployment comments uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { owner, repo } = context.repo; const prNumber = ${{ github.event.pull_request.number }}; @@ -504,12 +511,14 @@ jobs: - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Cleanup V2 deployment run: | - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH' + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << 'ENDSSH' if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then echo "Found V2 PR directory, proceeding with cleanup..." @@ -542,6 +551,9 @@ jobs: # Only remove PR-specific containers and directories ENDSSH + env: + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - name: Cleanup temporary files if: always() run: | diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index c804489836..8e0e66032e 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -37,7 +37,8 @@ jobs: check-comment: runs-on: ubuntu-latest permissions: - issues: write + contents: read # actions/checkout + issues: write # add reaction to the triggering issue comment if: | vars.CI_PROFILE != 'lite' && ( github.event_name == 'workflow_dispatch' || @@ -76,15 +77,6 @@ jobs: - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Get PR data id: get-pr uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -155,7 +147,7 @@ jobs: id: add-eyes-reaction uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`); try { @@ -174,11 +166,14 @@ jobs: } deploy-pr: + environment: pr-preview needs: check-comment runs-on: ubuntu-latest permissions: - issues: write + contents: read # actions/checkout, incl. the PR merge ref + issues: write # reactions, 'pr-deployed' label, deployment URL comment pull-requests: write + packages: write # push PR image to ghcr.io steps: - name: Harden Runner @@ -189,20 +184,12 @@ jobs: - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge - token: ${{ steps.setup-bot.outputs.token }} + # 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 @@ -240,11 +227,16 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Login to Docker Hub + - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - name: Build and push PR-specific image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 @@ -254,7 +246,7 @@ jobs: push: true cache-from: type=gha,scope=stirling-pdf-latest cache-to: type=gha,mode=max,scope=stirling-pdf-latest - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }} + tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:pr-${{ needs.check-comment.outputs.pr_number }} build-args: | VERSION_TAG=alpha PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }} @@ -269,15 +261,17 @@ jobs: push: true cache-from: type=gha,scope=stirling-pdf-engine cache-to: type=gha,mode=max,scope=stirling-pdf-engine - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }} + tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:engine-pr-${{ needs.check-comment.outputs.pr_number }} platforms: linux/amd64 - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Deploy to VPS id: deploy run: | @@ -295,11 +289,11 @@ jobs: # Set pro/enterprise settings (enterprise implies pro) if [ "${{ needs.check-comment.outputs.enable_enterprise }}" == "true" ]; then PREMIUM_ENABLED="true" - PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}" + PREMIUM_KEY="${ENTERPRISE_KEY}" PREMIUM_PROFEATURES_AUDIT_ENABLED="true" elif [ "${{ needs.check-comment.outputs.enable_pro }}" == "true" ]; then PREMIUM_ENABLED="true" - PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}" + PREMIUM_KEY="${PRO_KEY}" PREMIUM_PROFEATURES_AUDIT_ENABLED="true" else PREMIUM_ENABLED="false" @@ -309,7 +303,6 @@ jobs: ENABLE_PROTOTYPES="${{ needs.check-comment.outputs.enable_prototypes }}" PR_NUMBER="${{ needs.check-comment.outputs.pr_number }}" - DOCKER_USER="${{ secrets.DOCKER_HUB_USERNAME }}" # Build engine env vars for backend (only set when prototypes enabled) if [ "$ENABLE_PROTOTYPES" == "true" ]; then @@ -319,9 +312,9 @@ jobs: ENGINE_SERVICE=" stirling-pdf-engine: container_name: stirling-pdf-engine-pr-${PR_NUMBER} - image: ${DOCKER_USER}/test:engine-pr-${PR_NUMBER} + image: ${IMAGE_BASE}:engine-pr-${PR_NUMBER} environment: - ANTHROPIC_API_KEY: \"${{ secrets.ANTHROPIC_API_KEY }}\" + ANTHROPIC_API_KEY: \"${ANTHROPIC_API_KEY}\" networks: - pr-network restart: on-failure:5" @@ -344,7 +337,7 @@ jobs: services: stirling-pdf: container_name: stirling-pdf-pr-${PR_NUMBER} - image: ${DOCKER_USER}/test:pr-${PR_NUMBER} + image: ${IMAGE_BASE}:pr-${PR_NUMBER} ports: - "${PR_NUMBER}:8080" volumes: @@ -368,9 +361,9 @@ jobs: EOF # Then copy the file and execute commands - scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml + scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH # Create PR-specific directories mkdir -p /stirling/PR-${PR_NUMBER}/{data,config,logs} @@ -386,11 +379,19 @@ jobs: # Set output for use in PR comment echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV + env: + ENTERPRISE_KEY: ${{ secrets.ENTERPRISE_KEY }} + # named PRO_KEY, not PREMIUM_KEY, so the shell var it feeds is not self-referential + PRO_KEY: ${{ secrets.PREMIUM_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - name: Add success reaction to comment if: success() && github.event_name == 'issue_comment' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`); try { @@ -425,7 +426,7 @@ jobs: if: failure() && github.event_name == 'issue_comment' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`); try { @@ -444,15 +445,17 @@ jobs: - name: Post deployment URL to PR if: success() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { GITHUB_REPOSITORY } = process.env; const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/'); const prNumber = ${{ needs.check-comment.outputs.pr_number }}; const securityStatus = process.env.security_status || "Security Disabled"; - const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`; + const deploymentUrl = `http://${process.env.NEW_VPS_HOST}:${prNumber}`; const commentBody = `## 🚀 PR Test Deployment\n\n` + `Your PR has been deployed for testing!\n\n` + `🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` + @@ -477,6 +480,9 @@ jobs: handle-label-commands: if: ${{ github.event.issue.pull_request != null }} runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout, reads repo_devs.json and labels.yml + issues: write # add/remove labels, delete the command comment steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -486,17 +492,10 @@ jobs: - name: Check out the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Apply label commands uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const fs = require('fs'); const path = require('path'); diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 146f5c7f78..7b4ee8b3a3 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -13,11 +13,13 @@ env: jobs: cleanup: + environment: pr-preview if: github.event.action == 'closed' runs-on: ubuntu-latest permissions: + contents: read # actions/checkout pull-requests: write - issues: write + issues: write # list/remove labels, list/delete comments steps: - name: Harden Runner @@ -28,20 +30,11 @@ jobs: - name: Checkout PR uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Remove 'pr-deployed' label if present id: remove-label-comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const prNumber = ${{ github.event.pull_request.number }}; const owner = context.repo.owner; @@ -100,14 +93,22 @@ jobs: if: steps.remove-label-comment.outputs.present == 'true' run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - name: Cleanup PR deployment if: steps.remove-label-comment.outputs.present == 'true' id: cleanup + # ENDSSH heredoc is quoted, so its body is sent literally: secrets inside it + # must stay as GitHub expressions, a shell var would be empty on the remote host. run: | - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH' + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << 'ENDSSH' if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then echo "Found PR directory, proceeding with cleanup..." @@ -122,8 +123,8 @@ jobs: rm -rf /stirling/PR-${{ github.event.pull_request.number }} # Remove the Docker images - docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true - docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ github.event.pull_request.number }} || true + docker rmi --no-prune ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:pr-${{ github.event.pull_request.number }} || true + docker rmi --no-prune ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:engine-pr-${{ github.event.pull_request.number }} || true echo "PERFORMED_CLEANUP" else @@ -131,6 +132,9 @@ jobs: echo "NO_CLEANUP_NEEDED" fi ENDSSH + env: + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - name: Cleanup temporary files if: always() diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 563e94c9b3..9922177b4b 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -10,10 +10,12 @@ permissions: # required for secure-repo hardening jobs: ai-title-review: + # GITHUB_TOKEN obeys this block, so it must cover every API call made below. permissions: - contents: read - pull-requests: write - models: read + contents: read # actions/checkout, git fetch/diff + issues: write # issues.listComments / createComment / updateComment on the PR + pull-requests: write # same endpoints when the target is a pull request + models: read # actions/ai-inference runs-on: ubuntu-latest @@ -30,15 +32,6 @@ jobs: - name: Configure Git to suppress detached HEAD warning run: git config --global advice.detachedHead false - - name: Setup GitHub App Bot - if: github.actor != 'dependabot[bot]' - id: setup-bot - uses: ./.github/actions/setup-bot - continue-on-error: true - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Check if actor is repo developer id: actor run: | @@ -161,7 +154,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 continue-on-error: true with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const fs = require('fs'); const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8'); @@ -172,7 +165,7 @@ jobs: const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/); const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null; - const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]"; + const expectedActor = "github-actions[bot]"; const comments = await github.rest.issues.listComments({ owner, repo, issue_number }); const existing = comments.data.find(c => diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index f5af23da07..f1ca2be8ca 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -66,6 +66,7 @@ jobs: echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT" publish-aur: + environment: package-publish needs: get-release-info runs-on: ubuntu-latest steps: diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index 6039c0e7df..bcbd0fcba5 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -13,7 +13,9 @@ jobs: labeler: runs-on: ubuntu-latest permissions: - pull-requests: write + contents: read # checkout + labeler fetching its config from the repo + pull-requests: write # read changed files, apply labels to the PR + issues: write # labels are applied through the issues API steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -22,17 +24,10 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0 with: config_path: .github/labeler-config-srvaroa.yml use_local_config: false fail_on_error: true env: - GITHUB_TOKEN: "${{ steps.setup-bot.outputs.token }}" + GITHUB_TOKEN: "${{ github.token }}" diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index d134c80a9b..eff416c379 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -23,6 +23,7 @@ jobs: if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest permissions: + contents: read # Checkout, and read translation files via the contents API issues: write # Allow posting comments on issues/PRs pull-requests: write # Allow writing to pull requests steps: @@ -34,18 +35,11 @@ jobs: - name: Checkout main branch first uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Get PR data id: get-pr-data uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const prNumber = context.payload.pull_request.number; const repoOwner = context.payload.repository.owner.login; @@ -66,17 +60,18 @@ jobs: - name: Fetch PR changed files id: fetch-pr-changes env: - GH_TOKEN: ${{ steps.setup-bot.outputs.token }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.get-pr-data.outputs.pr_number }} run: | echo "Fetching PR changed files..." echo "Getting list of changed files from PR..." # Check if PR number exists - if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then + if [ -z "${PR_NUMBER}" ]; then echo "Error: PR number is empty" exit 1 fi # Get changed files and filter for TOML translation files - gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR" + gh pr view "${PR_NUMBER}" --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR" # Check if any files were found if [ ! -s changed_files.txt ]; then echo "No TOML translation files changed in this PR" @@ -88,32 +83,36 @@ jobs: - name: Determine reference file id: determine-file uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + # Untrusted, fork-controlled values are passed via env, never interpolated into the script + PR_NUMBER: ${{ steps.get-pr-data.outputs.pr_number }} + REPO_OWNER: ${{ steps.get-pr-data.outputs.repo_owner }} + REPO_NAME: ${{ steps.get-pr-data.outputs.repo_name }} + PR_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + PR_REPO_NAME: ${{ github.event.pull_request.head.repo.name }} + PR_BRANCH: ${{ steps.get-pr-data.outputs.branch }} with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const fs = require("fs"); const path = require("path"); - const prNumber = ${{ steps.get-pr-data.outputs.pr_number }}; - const repoOwner = "${{ steps.get-pr-data.outputs.repo_owner }}"; - const repoName = "${{ steps.get-pr-data.outputs.repo_name }}"; - - const prRepoOwner = "${{ github.event.pull_request.head.repo.owner.login }}"; - const prRepoName = "${{ github.event.pull_request.head.repo.name }}"; - const branch = "${{ steps.get-pr-data.outputs.branch }}"; - - console.log(`Determining reference file for PR #${prNumber}`); - - // Validate inputs + // Validate inputs before any use const validateInput = (input, regex, name) => { - if (!regex.test(input)) { + if (typeof input !== "string" || !regex.test(input)) { throw new Error(`Invalid ${name}: ${input}`); } + return input; }; - validateInput(repoOwner, /^[a-zA-Z0-9_-]+$/, "repository owner"); - validateInput(repoName, /^[a-zA-Z0-9._-]+$/, "repository name"); - validateInput(branch, /^[a-zA-Z0-9._/-]+$/, "branch name"); + const repoOwner = validateInput(process.env.REPO_OWNER, /^[a-zA-Z0-9_-]+$/, "repository owner"); + const repoName = validateInput(process.env.REPO_NAME, /^[a-zA-Z0-9._-]+$/, "repository name"); + const prRepoOwner = validateInput(process.env.PR_REPO_OWNER, /^[a-zA-Z0-9_-]+$/, "PR repository owner"); + const prRepoName = validateInput(process.env.PR_REPO_NAME, /^[a-zA-Z0-9._-]+$/, "PR repository name"); + const branch = validateInput(process.env.PR_BRANCH, /^[a-zA-Z0-9._/-]+$/, "branch name"); + const prNumber = Number(validateInput(process.env.PR_NUMBER, /^[0-9]+$/, "PR number")); + + console.log(`Determining reference file for PR #${prNumber}`); // Get the list of changed files in the PR const { data: files } = await github.rest.pulls.listFiles({ @@ -209,10 +208,12 @@ jobs: - name: Run Python script to check files id: run-check + env: + PR_ACTOR: ${{ github.event.pull_request.user.login }} run: | echo "Running Python script to check TOML files..." uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py \ - --actor ${{ github.event.pull_request.user.login }} \ + --actor "${PR_ACTOR}" \ --reference-file "${REFERENCE_FILE}" \ --branch "pr-branch" \ --files "${FILES_LIST[@]}" > result.txt @@ -245,7 +246,7 @@ jobs: if: env.SCRIPT_OUTPUT != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env; const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/'); @@ -261,7 +262,7 @@ jobs: const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary")); // Only update or create comments by the action user - const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]"; + const expectedActor = "github-actions[bot]"; if (comment && comment.user.login === expectedActor) { // Update existing comment diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index c98ec8641c..21d12044f5 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -11,7 +11,11 @@ permissions: jobs: deploy-v2-on-push: + environment: pr-preview runs-on: ubuntu-latest + permissions: + contents: read + packages: write concurrency: group: deploy-v2-push-V2 cancel-in-progress: true @@ -62,10 +66,21 @@ jobs: echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT fi + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Check if frontend image exists id: check-frontend run: | - if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then + if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT echo "Frontend image already exists, skipping build" else @@ -73,10 +88,12 @@ jobs: echo "Frontend image needs to be built" fi + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - name: Check if backend image exists id: check-backend run: | - if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then + if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT echo "Backend image already exists, skipping build" else @@ -84,11 +101,8 @@ jobs: echo "Backend image needs to be built" fi - - name: Login to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - name: Build and push frontend image if: steps.check-frontend.outputs.exists == 'false' @@ -100,8 +114,8 @@ jobs: cache-from: type=gha,scope=stirling-v2-frontend cache-to: type=gha,mode=max,scope=stirling-v2-frontend tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 @@ -115,17 +129,19 @@ jobs: cache-from: type=gha,scope=stirling-v2-backend cache-to: type=gha,mode=max,scope=stirling-v2-backend tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Deploy to VPS on port 3000 run: | export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml @@ -135,7 +151,7 @@ jobs: services: backend: container_name: stirling-v2-backend - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} ports: - "13000:8080" volumes: @@ -158,21 +174,21 @@ jobs: frontend: container_name: stirling-v2-frontend - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} ports: - "3000:80" environment: - VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000" + VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000" depends_on: - backend restart: on-failure:5 EOF # Copy to remote with unique name - scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME + scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME # SSH and rename/move atomically to avoid interference - ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH + ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH mkdir -p /stirling/V2/{data,config,logs} mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml cd /stirling/V2 @@ -183,6 +199,10 @@ jobs: docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true ENDSSH + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} - name: Cleanup temporary files if: always() run: | diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f02e6c612c..c712467e7b 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -93,7 +93,7 @@ jobs: ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX" if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - case "${{ github.event.inputs.platform }}" in + case "${INPUT_PLATFORM}" in "windows") echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT ;; @@ -115,6 +115,8 @@ jobs: echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT fi + env: + INPUT_PLATFORM: ${{ github.event.inputs.platform }} build-jars: needs: determine-matrix runs-on: ubuntu-latest @@ -194,6 +196,7 @@ jobs: retention-days: 1 build: + environment: release-signing needs: determine-matrix strategy: fail-fast: false @@ -308,16 +311,16 @@ jobs: Write-Host "Setting up DigiCert KeyLocker environment..." # Decode client certificate - $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") + $certBytes = [Convert]::FromBase64String("$env:SM_CLIENT_CERT_FILE_B64") $certPath = "D:\Certificate_pkcs12.p12" [IO.File]::WriteAllBytes($certPath, $certBytes) # Set environment variables echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV - echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV - echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV + echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV + echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV + echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV + echo "SM_KEYPAIR_ALIAS=$env:SM_KEYPAIR_ALIAS" >> $env:GITHUB_ENV # Get PKCS11 config path from DigiCert action $pkcs11Config = $env:PKCS11_CONFIG @@ -335,6 +338,12 @@ jobs: } } + env: + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + SM_HOST: ${{ secrets.SM_HOST }} + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - name: Import Windows Code Signing Certificate if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index e88c5e400e..751b3c1877 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -73,6 +73,7 @@ jobs: echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT" update-homebrew-and-scoop: + environment: package-publish needs: get-release-info runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index a44d4f7b28..362421d0f8 100644 --- a/.github/workflows/pr-conflict-labeler.yml +++ b/.github/workflows/pr-conflict-labeler.yml @@ -27,9 +27,9 @@ jobs: name: Label conflicted PRs runs-on: ubuntu-latest permissions: - contents: read - issues: write - pull-requests: read + contents: read # actions/checkout + issues: write # get/create the repo-level conflict label + pull-requests: write # pulls.get/list plus add/remove the label on PRs steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -39,17 +39,10 @@ jobs: - name: Check out the repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up stirling-bot token - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Apply conflict label uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - github-token: ${{ steps.setup-bot.outputs.token }} + github-token: ${{ github.token }} script: | const conflictLabel = process.env.CONFLICT_LABEL; const owner = context.repo.owner; diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 167b71531e..f7dd1bcab4 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -32,9 +32,11 @@ jobs: - name: Set version id: version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} run: | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" + VERSION="${INPUT_VERSION}" elif [ "${{ github.ref_name }}" == "accessIssueFix" ]; then VERSION="1.0.3" else diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 906884aaab..c9caf1b2f2 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -23,7 +23,6 @@ on: - master - main - V2-master - - testMain # cancel in-progress jobs if a new job is triggered # This is useful to avoid running multiple builds for the same branch if a new commit is pushed @@ -42,6 +41,7 @@ permissions: jobs: push: + environment: docker-publish if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-24.04-8core permissions: diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 27141eff31..442a6f499e 100644 --- a/.github/workflows/rollback-latest.yml +++ b/.github/workflows/rollback-latest.yml @@ -13,6 +13,7 @@ permissions: jobs: rollback: + environment: docker-publish runs-on: ubuntu-latest permissions: packages: write diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml index 0b27858d5f..5ff4f465c7 100644 --- a/.github/workflows/sync-portal-docs.yml +++ b/.github/workflows/sync-portal-docs.yml @@ -24,6 +24,7 @@ permissions: jobs: sync: + environment: bot-identity name: Sync docs manifest runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 1888c08ca7..7e6f999618 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -33,6 +33,7 @@ permissions: jobs: sync-files: + environment: bot-identity runs-on: ubuntu-latest steps: - name: Harden Runner diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index b0888d83cf..5c04f6009f 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -227,20 +227,26 @@ jobs: - name: Setup DigiCert KeyLocker Certificate if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: pwsh + env: + SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} + SM_HOST: ${{ secrets.SM_HOST }} + SM_API_KEY: ${{ secrets.SM_API_KEY }} + SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} run: | Write-Host "Setting up DigiCert KeyLocker environment..." # Decode client certificate - $certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") + $certBytes = [Convert]::FromBase64String("$env:SM_CLIENT_CERT_FILE_B64") $certPath = "D:\Certificate_pkcs12.p12" [IO.File]::WriteAllBytes($certPath, $certBytes) # Set environment variables echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV - echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV - echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV + echo "SM_HOST=$env:SM_HOST" >> $env:GITHUB_ENV + echo "SM_API_KEY=$env:SM_API_KEY" >> $env:GITHUB_ENV + echo "SM_CLIENT_CERT_PASSWORD=$env:SM_CLIENT_CERT_PASSWORD" >> $env:GITHUB_ENV + echo "SM_KEYPAIR_ALIAS=$env:SM_KEYPAIR_ALIAS" >> $env:GITHUB_ENV # Get PKCS11 config path from DigiCert action $pkcs11Config = $env:PKCS11_CONFIG diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 7c03e57967..f98d874dfa 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -21,8 +21,12 @@ permissions: jobs: deploy: + environment: pr-preview if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -66,11 +70,16 @@ jobs: VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - - name: Login to Docker Hub + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT + + - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_API }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} - name: Build and push test image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 @@ -80,16 +89,18 @@ jobs: push: true cache-from: type=gha,scope=stirling-pdf-latest cache-to: type=gha,mode=max,scope=stirling-pdf-latest - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }} + tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }} build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }} platforms: linux/amd64 - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Deploy to VPS run: | cat > docker-compose.yml << EOF @@ -97,7 +108,7 @@ jobs: services: stirling-pdf: container_name: stirling-pdf-test-${{ github.sha }} - image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }} + image: ${IMAGE_BASE}:test-${{ github.sha }} ports: - "1337:8080" volumes: @@ -118,9 +129,9 @@ jobs: restart: on-failure:5 EOF - scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml + scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs} mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml cd /stirling/test-${{ github.sha }} @@ -128,6 +139,10 @@ jobs: docker-compose up -d EOF + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} files-changed: if: always() name: detect what files changed @@ -150,6 +165,7 @@ jobs: filters: ".github/config/.files.yaml" test: + environment: pr-preview if: needs.files-changed.outputs.frontend == 'true' needs: [deploy, files-changed] runs-on: ubuntu-latest @@ -185,6 +201,7 @@ jobs: FORCE_COLOR: "3" cleanup: + environment: pr-preview needs: [deploy, test] runs-on: ubuntu-latest if: always() @@ -198,16 +215,21 @@ jobs: - name: Set up SSH run: | mkdir -p ~/.ssh/ - echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key + echo "${NEW_VPS_SSH_KEY}" > ../private.key sudo chmod 600 ../private.key + env: + NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - name: Cleanup deployment if: always() run: | - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF cd /stirling/test-${{ github.sha }} docker-compose down cd /stirling rm -rf test-${{ github.sha }} EOF + env: + NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} continue-on-error: true # Ensure cleanup runs even if previous steps fail From 4b26797ad8da0b5696b3324f0287ae27a8150b66 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 14 Aug 2026 07:44:41 +0100 Subject: [PATCH 186/262] Redesign New/Edit Pipeline top bars (#7438) # Description of Changes Replace the dev-UI top-bar in the New Pipeline and Edit Pipeline pages with a redesigned layout appropriate for users. I've got a big list of extra tweaks I'd like to do to the rest of the page including graph tweaks etc. but this is the only thing on the New/Edit Pipelines pages that is blocking for the release. ## Before ### New Pipeline image ### Edit Pipeline image ## After ### New Pipeline image ### Edit Pipeline image --- .../public/locales/en-US/translation.toml | 26 +- .../pipelines/PipelineBlockerTooltip.css | 22 ++ .../pipelines/PipelineBlockerTooltip.tsx | 48 +++ .../pipelines/PipelineCreateHeader.css | 47 +++ .../PipelineCreateHeader.stories.tsx | 52 +++ .../pipelines/PipelineCreateHeader.test.tsx | 86 +++++ .../pipelines/PipelineCreateHeader.tsx | 90 +++++ .../pipelines/PipelineEditHeader.css | 67 ++++ .../pipelines/PipelineEditHeader.stories.tsx | 71 ++++ .../pipelines/PipelineEditHeader.test.tsx | 156 ++++++++ .../pipelines/PipelineEditHeader.tsx | 235 ++++++++++++ .../pipelines/PipelineGraphToolbar.css | 48 +++ .../PipelineGraphToolbar.stories.tsx | 54 +++ .../pipelines/PipelineGraphToolbar.test.tsx | 103 ++++++ .../pipelines/PipelineGraphToolbar.tsx | 147 ++++++++ .../components/pipelines/PipelineHeader.css | 133 ------- .../pipelines/PipelineHeader.stories.tsx | 118 ------- .../pipelines/PipelineHeader.test.tsx | 200 ----------- .../components/pipelines/PipelineHeader.tsx | 301 ---------------- .../src/portal/views/PipelineBuilder.css | 40 +++ .../src/portal/views/PipelineBuilder.test.tsx | 59 +++- .../src/portal/views/PipelineBuilder.tsx | 333 +++++++++++------- 22 files changed, 1548 insertions(+), 888 deletions(-) create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx delete mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.css delete mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx delete mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx delete mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index bebe1b392d..1948b71bf4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7878,6 +7878,7 @@ title = "Pipelines" newPipeline = "New pipeline" [portal.pipelines.builder] +activate = "Activate" back = "Back to pipelines" cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" @@ -7885,7 +7886,6 @@ chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" chooseSource = "Choose a source" discard = "Discard changes" -enabled = "Enabled" inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" @@ -7896,6 +7896,8 @@ needsDestination = "No destination chosen" needsSource = "No source chosen" needsUpload = "Needs an uploaded file" noToolMatches = "No tools match your search." +pause = "Pause" +rename = "Rename pipeline" searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." @@ -7908,6 +7910,17 @@ uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these usesDefaults = "Runs with default settings" viewDefinition = "View definition" +[portal.pipelines.builder.blocker] +destination = "Choose a destination" +heading = "To create this pipeline:" +incompatible = "Fix steps that can't run in order: {{tools}}" +name = "Give the pipeline a name" +saveHeading = "To save your changes:" +schedule = "Set how often it runs" +setup = "Finish setting up: {{tools}}" +source = "Choose an input source" +upload = "Remove steps that need an uploaded file: {{tools}}" + [portal.pipelines.builder.diagnostic] fan-in = "Combines every incoming file" fan-out = "Runs once per incoming file" @@ -7918,12 +7931,12 @@ undeclared-operation = "Can't check what this step accepts" [portal.pipelines.composer] addTool = "Add a tool" -cancel = "Cancel" create = "Create pipeline" +createPaused = "Create paused" editingUnsupported = "Displaying these tool params for editing is not supported yet." editSource = "Edit source" name = "Name" -namePlaceholder = "e.g. Redaction sweep" +namePlaceholder = "Pipeline name" noToolSettings = "This tool has no configurable settings." output = "Destination" save = "Save changes" @@ -7955,7 +7968,7 @@ confirm = "Delete" title = "Delete pipeline?" [portal.pipelines.detail] -clearHistory = "Clear history" +clearHistory = "Process ignored files in source" delete = "Delete pipeline" run = "Run now" @@ -8008,10 +8021,9 @@ completed_one = "Run completed." completed_other = "All {{count}} runs completed." empty = "Nothing to run: the sources had no documents to process." failed = "Run failed: {{error}}" -historyCleared = "History cleared. The next run reprocesses everything currently in the sources." inFlight = "Nothing new to run: documents are still being processed from an earlier run." -parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then clear history to retry it." -parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then clear history to retry them." +parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then reprocess the source to retry it." +parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then reprocess the source to retry them." running = "Run started; still in progress." timeout = "Run is taking longer than expected; it may still finish in the background." diff --git a/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css new file mode 100644 index 0000000000..c0779b9aeb --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.css @@ -0,0 +1,22 @@ +/** + * The "why is this disabled" list, inside a save/create button's tooltip. Left-aligned (a bulleted + * list reads oddly centred) and inheriting the tooltip's own colours. + */ + +.portal-pipeline-blockers { + text-align: left; +} + +.portal-pipeline-blockers__heading { + margin: 0 0 0.25rem; + font-weight: 600; +} + +.portal-pipeline-blockers ul { + margin: 0; + padding-left: 1.1rem; +} + +.portal-pipeline-blockers li { + margin: 0.125rem 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx new file mode 100644 index 0000000000..adba6ecd26 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineBlockerTooltip.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from "react"; +import { Tooltip } from "@mantine/core"; +import "@portal/components/pipelines/PipelineBlockerTooltip.css"; + +export interface PipelineBlockerTooltipProps { + /** Short line above the list, e.g. "To create this pipeline:" / "To save your changes:". */ + heading: string; + /** Everything still owed before the action is possible; empty means the action is allowed. */ + blockers: string[]; + /** The disabled control (wrapped so its hover still reaches the tooltip - see below). */ + children: ReactElement; +} + +/** + * Explains why a disabled save/create control can't be used yet, by listing what is still owed. + * + * A disabled button swallows its own pointer events, so the caller must pass a NON-disabled wrapper + * (a span/div around the button) as the child - that wrapper is what the pointer lands on. The + * tooltip hides itself when there is nothing to list (the action is allowed, or it is only + * mid-save), so callers can wire it unconditionally. + */ +export function PipelineBlockerTooltip({ + heading, + blockers, + children, +}: PipelineBlockerTooltipProps) { + return ( + +

      {heading}

      +
        + {blockers.map((blocker) => ( +
      • {blocker}
      • + ))} +
      + + } + disabled={blockers.length === 0} + position="bottom-end" + withinPortal + multiline + w={280} + > + {children} +
      + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css new file mode 100644 index 0000000000..d3e5e4827c --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.css @@ -0,0 +1,47 @@ +/** + * Create mode's toolbar: name on the left, commit actions on the right - the same shape as the edit + * header, so the two modes feel like one page. + */ + +.portal-pipeline-create-header { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +/* Sized to a name, not the width of the page: a title-length field reads as a title, where the old + full-bleed input read as a search bar. It gives a little on narrow viewports but never grows to + fill the row - the actions on the right anchor the far end instead. */ +.portal-pipeline-create-header__name { + flex: 0 1 22rem; + min-width: 12rem; +} + +.portal-pipeline-create-header__name input { + font-size: 1rem; + font-weight: 500; +} + +/* Pinned to the right, mirroring the edit header. Buttons hold their width and the row wraps rather + than clipping. */ +.portal-pipeline-create-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +/* The create buttons hold their width, so their labels never squash. */ +.portal-pipeline-create-header .sui-btn { + flex: none; + white-space: nowrap; +} + +/* The two create buttons share one tooltip target, so they sit in their own inline group. */ +.portal-pipeline-create-header__create { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx new file mode 100644 index 0000000000..b9717c0848 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineCreateHeader } from "@portal/components/pipelines/PipelineCreateHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineCreateHeader", + component: PipelineCreateHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** + * The name is live, so the toolbar can be seen as it is filled in. Until it is named (a stand-in for + * the app's full validity check) the create buttons are disabled and carry a tooltip of what's owed. + */ +function Playground({ initialName }: { initialName: string }) { + const [name, setName] = useState(initialName); + const blockers = + name.trim() === "" + ? [ + "Give the pipeline a name", + "Choose an input source", + "Choose a destination", + ] + : []; + return ( + + ); +} + +/** A new pipeline: create is disabled, and hovering it lists what's still needed. */ +export const New: Story = { + render: () => , +}; + +/** Named: the create actions become available. */ +export const Named: Story = { + render: () => , +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx new file mode 100644 index 0000000000..c2f1826136 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineCreateHeader, + type PipelineCreateHeaderProps, +} from "@portal/components/pipelines/PipelineCreateHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onCreate: vi.fn(), + onCreatePaused: vi.fn(), + onBack: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineCreateHeader", () => { + it("edits the pipeline's name", () => { + const handlers = renderHeader(); + fireEvent.change( + screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), + { target: { value: "Renamed" } }, + ); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("creates the pipeline, live or paused, and backs out", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.composer.create")); + expect(handlers.onCreate).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.composer.createPaused")); + expect(handlers.onCreatePaused).toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back")); + expect(handlers.onBack).toHaveBeenCalled(); + }); + + it("blocks both create actions until the pipeline is valid", () => { + renderHeader({ canSave: false, blockers: ["Choose a destination"] }); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + expect( + screen + .getByText("portal.pipelines.composer.createPaused") + .closest("button"), + ).toBeDisabled(); + }); + + it("explains, on hover, why the create buttons are disabled", async () => { + renderHeader({ + canSave: false, + blockers: ["Give the pipeline a name", "Choose a destination"], + }); + const group = document.querySelector( + ".portal-pipeline-create-header__create", + ) as HTMLElement; + fireEvent.pointerEnter(group); + fireEvent.mouseEnter(group); + expect(await screen.findByText("Choose a destination")).toBeInTheDocument(); + expect(screen.getByText("Give the pipeline a name")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx new file mode 100644 index 0000000000..b4e3fa7951 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineCreateHeader.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import { ActionIcon, Button, Input } from "@app/ui"; +import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip"; +import "@portal/components/pipelines/PipelineCreateHeader.css"; + +export interface PipelineCreateHeaderProps { + name: string; + onNameChange: (name: string) => void; + + canSave: boolean; + /** Everything still owed before the pipeline can be created, shown on the disabled create button. */ + blockers: string[]; + saving: boolean; + /** Which create action is mid-save, so only the button that was clicked shows its spinner. */ + pendingCreateEnabled: boolean | null; + onCreate: () => void; + onCreatePaused: () => void; + onBack: () => void; +} + +/** + * The create-mode toolbar. Mirrors the edit header's shape - a back arrow and the name on the left, + * actions on the right - so the two modes read as the same page in two states rather than two + * different screens. The right commits the pipeline live or paused; while it can't yet, the disabled + * create buttons carry a tooltip listing exactly what is still owed, so "disabled" is never a dead end. + */ +export function PipelineCreateHeader({ + name, + onNameChange, + canSave, + blockers, + saving, + pendingCreateEnabled, + onCreate, + onCreatePaused, + onBack, +}: PipelineCreateHeaderProps) { + const { t } = useTranslation(); + + return ( +
      + + + + + onNameChange(e.target.value)} + /> + +
      + {/* The pair share one tooltip target because a disabled button swallows its own hover - the + wrapper is what the pointer lands on. */} + +
      + + +
      +
      +
      +
      + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css new file mode 100644 index 0000000000..a83d3ff6ae --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.css @@ -0,0 +1,67 @@ +/** + * Edit mode's toolbar: identity on the left, operational actions on the right. + */ + +.portal-pipeline-edit-header { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.portal-pipeline-edit-header__identity { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + flex: 1 1 16rem; +} + +/* The name is the page's title. It takes the room the identity row leaves and truncates rather than + wrapping, so a long name never pushes the pencil out of reach. */ +.portal-pipeline-edit-header__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.portal-pipeline-edit-header__name-input { + flex: 1 1 16rem; + min-width: 12rem; +} + +.portal-pipeline-edit-header__name-input input { + font-size: 1.125rem; + font-weight: 600; +} + +/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ +.portal-pipeline-edit-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex: none; +} + +.portal-pipeline-edit-header__actions .sui-btn { + flex: none; + white-space: nowrap; +} + +/* Save is wrapped so its disabled hover reaches the blocker tooltip; the wrapper must not shrink. */ +.portal-pipeline-edit-header__save { + display: inline-flex; + flex: none; +} + +/* Destructive item in the overflow tray: red label and icon, so it reads as the exception among + the neutral entries above it. */ +.portal-pipeline-edit-header__delete-item .sui-dd__item-label, +.portal-pipeline-edit-header__delete-item .sui-dd__item-leading { + color: var(--c-danger); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx new file mode 100644 index 0000000000..538024e933 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineEditHeader", + component: PipelineEditHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** The name and the pause/activate state are live, so both can be exercised. */ +function Playground({ + initialName, + initialEnabled = true, + canSave = true, + blockers = [], +}: { + initialName: string; + initialEnabled?: boolean; + canSave?: boolean; + blockers?: string[]; +}) { + const [name, setName] = useState(initialName); + const [enabled, setEnabled] = useState(initialEnabled); + return ( + setEnabled((e) => !e)} + togglingEnabled={false} + onBack={noop} + canSave={canSave} + blockers={blockers} + saving={false} + onSave={noop} + onRun={noop} + running={false} + onReprocess={noop} + reprocessing={false} + onDelete={noop} + /> + ); +} + +/** A live pipeline: the toggle offers to pause it. */ +export const Active: Story = { + render: () => , +}; + +/** A paused pipeline: the toggle offers to activate it. */ +export const Paused: Story = { + render: () => ( + + ), +}; + +/** Edits that cannot yet be saved: Save is disabled and hovering it lists what's still needed. */ +export const CannotSave: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx new file mode 100644 index 0000000000..e216dbb292 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.test.tsx @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineEditHeader, + type PipelineEditHeaderProps, +} from "@portal/components/pipelines/PipelineEditHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onTogglePause: vi.fn(), + onBack: vi.fn(), + onSave: vi.fn(), + onRun: vi.fn(), + onReprocess: vi.fn(), + onDelete: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineEditHeader", () => { + it("shows the name as the title and renames it in place", () => { + const handlers = renderHeader(); + expect(screen.getByText("Claims redaction")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("abandons a rename on Escape, keeping the old name", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Discarded" } }); + fireEvent.keyDown(input, { key: "Escape" }); + // Escape must not commit, even via the blur that unmounting the field fires in a real browser. + fireEvent.blur(input); + expect(handlers.onNameChange).not.toHaveBeenCalled(); + expect(screen.getByText("Claims redaction")).toBeInTheDocument(); + }); + + it("commits a rename when focus leaves the field", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.rename")); + const input = screen.getByRole("textbox", { + name: "portal.pipelines.composer.name", + }); + fireEvent.change(input, { target: { value: "Renamed" } }); + fireEvent.blur(input); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + }); + + it("offers to pause a live pipeline and to activate a paused one", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.builder.pause")); + expect(handlers.onTogglePause).toHaveBeenCalled(); + + renderHeader({ enabled: false }); + expect( + screen.getByText("portal.pipelines.builder.activate"), + ).toBeInTheDocument(); + }); + + it("runs the saved pipeline from the row", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.detail.run")); + expect(handlers.onRun).toHaveBeenCalled(); + }); + + it("keeps clear-history and delete behind the overflow tray", () => { + const handlers = renderHeader(); + // Not in the row itself... + expect( + screen.queryByText("portal.pipelines.detail.clearHistory"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.delete"), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + expect(handlers.onReprocess).toHaveBeenCalled(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); + expect(handlers.onDelete).toHaveBeenCalled(); + }); + + it("blocks saving until the edits are valid", () => { + renderHeader({ canSave: false }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("cannot pause while a save is committing", () => { + renderHeader({ saving: true }); + expect( + screen.getByText("portal.pipelines.builder.pause").closest("button"), + ).toBeDisabled(); + }); + + it("cannot save while a pause is committing", () => { + renderHeader({ togglingEnabled: true }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("explains, on hover, why Save is disabled", async () => { + renderHeader({ canSave: false, blockers: ["Choose a destination"] }); + const save = document.querySelector( + ".portal-pipeline-edit-header__save", + ) as HTMLElement; + fireEvent.pointerEnter(save); + fireEvent.mouseEnter(save); + expect(await screen.findByText("Choose a destination")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx new file mode 100644 index 0000000000..7ee2fbd173 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineEditHeader.tsx @@ -0,0 +1,235 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import PauseRoundedIcon from "@mui/icons-material/PauseRounded"; +import PowerSettingsNewRoundedIcon from "@mui/icons-material/PowerSettingsNewRounded"; +import ReplayRoundedIcon from "@mui/icons-material/ReplayRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; +import { ActionIcon, Button, Dropdown, Input } from "@app/ui"; +import { PipelineBlockerTooltip } from "@portal/components/pipelines/PipelineBlockerTooltip"; +import "@portal/components/pipelines/PipelineEditHeader.css"; + +export interface PipelineEditHeaderProps { + name: string; + onNameChange: (name: string) => void; + + /** The pipeline's live state. Toggling it takes effect immediately, not on save. */ + enabled: boolean; + onTogglePause: () => void; + togglingEnabled: boolean; + + onBack: () => void; + + canSave: boolean; + /** Everything still owed before the edits can be saved, shown on the disabled Save button. */ + blockers: string[]; + saving: boolean; + onSave: () => void; + + /** Run the saved pipeline against its real input, delivering to its real destination. */ + onRun: () => void; + running: boolean; + /** Reprocess everything in the sources: clears the processed record, then runs at once. */ + onReprocess: () => void; + reprocessing: boolean; + onDelete: () => void; +} + +/** + * Edit mode's toolbar over an existing, live pipeline. The left is what it *is* - a back arrow, its + * name as the page title, a pencil to rename in place. The right is what you can *do to it*: pause + * or activate it (an operational toggle that acts at once, matching the Policies vocabulary), run it + * now, and - behind an overflow, since they are rare or destructive - reprocess its sources or delete + * it. Saving the chain edits is the primary action, on the far right. (Reading the definition is an + * inspect action, so it lives in the graph toolbar beside Test, not here.) + */ +export function PipelineEditHeader({ + name, + onNameChange, + enabled, + onTogglePause, + togglingEnabled, + onBack, + canSave, + blockers, + saving, + onSave, + onRun, + running, + onReprocess, + reprocessing, + onDelete, +}: PipelineEditHeaderProps) { + const { t } = useTranslation(); + const [renaming, setRenaming] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef(null); + // Enter and Escape both end the rename, which unmounts the input - and unmounting a focused input + // fires blur in a real browser (jsdom does not). Without this guard that blur would re-run the + // commit, so Escape would save the very draft it was meant to discard. The key handler sets this so + // the trailing blur is ignored; a plain click-away leaves it false and blur commits as normal. + const keyHandledRef = useRef(false); + + useEffect(() => { + if (renaming) inputRef.current?.select(); + }, [renaming]); + + function startRename() { + keyHandledRef.current = false; + setDraft(name); + setRenaming(true); + } + + // End the rename, committing the draft only when asked and only if non-empty (an all-whitespace + // rename would leave the pipeline titleless). + function finishRename(commit: boolean) { + keyHandledRef.current = true; + if (commit) { + const next = draft.trim(); + if (next) onNameChange(next); + } + setRenaming(false); + } + + // Clicking away commits; the unmount-triggered blur that follows a key press does not (the key + // already decided the outcome). + function handleBlur() { + if (keyHandledRef.current) { + keyHandledRef.current = false; + return; + } + finishRename(true); + } + + return ( +
      +
      + + + + + {renaming ? ( + setDraft(e.target.value)} + onBlur={handleBlur} + onKeyDown={(e) => { + if (e.key === "Enter") finishRename(true); + if (e.key === "Escape") finishRename(false); + }} + /> + ) : ( + <> +

      {name}

      + + + + + )} +
      + +
      + {/* Pause and Save both write the whole policy, so they are mutually exclusive: neither can + start while the other is committing, or the two writes race and the loser's version wins. */} + + + {/* Run and Reprocess both start a run, so only one at a time: each is disabled while the + other is in flight, matching the handler guards (a click otherwise silently no-ops). */} + + + {/* Rare and destructive actions kept off the row so they do not compete with running. */} + + + + + + + + } + > + {t("portal.pipelines.detail.clearHistory")} + + + + } + > + {t("portal.pipelines.detail.delete")} + + + + + {/* Wrapped in a span so the disabled button's hover still reaches the tooltip. */} + + + + + +
      +
      + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css new file mode 100644 index 0000000000..9dc2d53bec --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.css @@ -0,0 +1,48 @@ +/** + * The test control and the last run's outcome, directly above the graph. + */ + +.portal-pipeline-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Reading the definition sits at the far end of the bar, opposite Test. */ +.portal-pipeline-toolbar__definition { + margin-left: auto; +} + +/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the + backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ +.portal-pipeline-toolbar__result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-pipeline-toolbar__result-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--c-text); +} + +.portal-pipeline-toolbar__result-icon.is-ok { + color: var(--c-success); +} + +.portal-pipeline-toolbar__result-icon.is-bad { + color: var(--c-danger); +} + +/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); + it may wrap to keep a long backend message readable rather than clipping it. */ +.portal-pipeline-toolbar__result-error { + font-size: 0.8125rem; + color: var(--c-text-muted); + min-width: 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx new file mode 100644 index 0000000000..359aa9bfa1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineGraphToolbar", + component: PipelineGraphToolbar, + parameters: { layout: "padded" }, + args: { + stepCount: 2, + testing: false, + runResult: null, + onTest: () => {}, + onDownloadOutput: () => {}, + onViewDefinition: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Idle: just the test control. */ +export const Idle: Story = {}; + +/** A chain with no steps cannot be tested. */ +export const NoSteps: Story = { args: { stepCount: 0 } }; + +/** Mid test-run. */ +export const Testing: Story = { args: { testing: true } }; + +/** After a completed run: the outcome and its files sit beside the button. */ +export const Completed: Story = { + args: { + runResult: { + status: "completed", + completedSteps: 3, + stepCount: 3, + outputs: [ + { fileId: "f1", fileName: "claim-redacted.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }, +}; + +/** A failed run: the summary and the failure reason. */ +export const Failed: Story = { + args: { + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx new file mode 100644 index 0000000000..4aafb27766 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineGraphToolbar, + type PipelineGraphToolbarProps, +} from "@portal/components/pipelines/PipelineGraphToolbar"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderToolbar(overrides: Partial = {}) { + const handlers = { + onTest: vi.fn(), + onDownloadOutput: vi.fn(), + onViewDefinition: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineGraphToolbar", () => { + it("hands the chosen file to the test run", () => { + const handlers = renderToolbar(); + const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); + const input = + document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + expect(handlers.onTest).toHaveBeenCalledWith(file); + }); + + it("will not offer a test run on a chain with no steps", () => { + renderToolbar({ stepCount: 0 }); + expect( + screen.getByText("portal.pipelines.builder.testRun").closest("button"), + ).toBeDisabled(); + }); + + it("opens the definition from its icon", () => { + const handlers = renderToolbar(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.viewDefinition"), + ); + expect(handlers.onViewDefinition).toHaveBeenCalled(); + }); + + it("shows no result strip until a test has been run", () => { + renderToolbar(); + expect( + screen.queryByText(/portal.pipelines.inspector.status/), + ).not.toBeInTheDocument(); + }); + + it("shows why a test run failed, not only that it did", () => { + renderToolbar({ + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }); + expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); + }); + + it("reports a finished run and downloads the file clicked", () => { + const handlers = renderToolbar({ + runResult: { + status: "completed", + completedSteps: 2, + stepCount: 2, + outputs: [ + { fileId: "f1", fileName: "claim.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }); + fireEvent.click(screen.getByText("claim.pdf")); + expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ + fileId: "f1", + fileName: "claim.pdf", + }); + // A file the backend did not name still has to be reachable. + expect(screen.getByText("f2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx new file mode 100644 index 0000000000..30513f6a83 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineGraphToolbar.tsx @@ -0,0 +1,147 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; +import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; +import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; +import { ActionIcon, Button, FilePicker, Spinner } from "@app/ui"; +import { type RunOutputFile } from "@portal/api/pipelines"; +import "@portal/components/pipelines/PipelineGraphToolbar.css"; + +/** + * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files + * plus the step it stopped at, so there is no per-node output to attach to a node. + */ +export interface RunResultSummary { + status: "running" | "completed" | "failed"; + completedSteps: number; + stepCount: number; + error?: string | null; + outputs?: RunOutputFile[]; +} + +export interface PipelineGraphToolbarProps { + /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ + stepCount: number; + /** Run the steps as they stand against one uploaded file, without saving or delivering. */ + onTest: (file: File) => void; + testing: boolean; + /** The last test run in this session, or null if there has not been one. */ + runResult: RunResultSummary | null; + onDownloadOutput: (output: RunOutputFile) => void; + /** Opens the definition (JSON + cURL) - an inspect action, sibling to Test, hence its home here. */ + onViewDefinition: () => void; +} + +/** + * The graph's own toolbar, above the canvas in both create and edit. It gathers the two ways to + * *inspect* what you are building - testing the chain against one file, and reading its definition - + * as opposed to committing (Save/Create) or operating on the live pipeline (Run now). A test run's + * progress shows on the graph's nodes, so the strip that summarises it belongs next to the graph too. + */ +export function PipelineGraphToolbar({ + stepCount, + onTest, + testing, + runResult, + onDownloadOutput, + onViewDefinition, +}: PipelineGraphToolbarProps) { + const { t } = useTranslation(); + + return ( +
      + file && onTest(file)} + leftSection={} + > + {t("portal.pipelines.builder.testRun")} + + + {runResult && ( + + )} + + {/* The graph is the visual definition; reading it as JSON/cURL sits at the far end of its bar. */} + + + + + +
      + ); +} + +interface RunResultStripProps { + result: RunResultSummary; + onDownload: (output: RunOutputFile) => void; +} + +/** What the last test run did, beside the button that started it. */ +function RunResultStrip({ result, onDownload }: RunResultStripProps) { + const { t } = useTranslation(); + const outputs = result.outputs ?? []; + + return ( +
      +
      + {result.status === "running" && } + {result.status === "completed" && ( + + )} + {result.status === "failed" && ( + + )} + + {t(`portal.pipelines.inspector.status.${result.status}`, { + done: result.completedSteps, + count: result.stepCount, + })} + +
      + + {/* The reason it failed, where the failure is announced - not only on the node, which the user + has to know to click. */} + {result.status === "failed" && result.error && ( + + {result.error} + + )} + + {outputs.map((output) => ( + + ))} +
      + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css deleted file mode 100644 index f559e42795..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css +++ /dev/null @@ -1,133 +0,0 @@ -/** - * The builder's opening section: identity above the rule, actions below it. - */ - -.portal-pipeline-header { - display: flex; - flex-direction: column; - gap: 0.875rem; - padding: 1.125rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); -} - -/* Leaving the page and saving it are the same kind of decision, so they share a row - and the back - link is short, so the save pair always has room beside it. */ -.portal-pipeline-header__top { - display: flex; - align-items: center; - gap: 1rem; - flex-wrap: wrap; -} - -/* The back link is the shared Button restyled to a plain link, so re-assert that over the - design-system base (which imposes a fixed height, its own padding and an accent colour). */ -.portal-pipeline-header__back.sui-btn { - height: auto; - min-height: 0; - padding: 0; - font-size: 0.8125rem; - font-weight: 400; - color: var(--c-text-muted); -} - -.portal-pipeline-header__back.sui-btn:hover { - background: none; - color: var(--c-text); -} - -.portal-pipeline-header__identity { - display: flex; - align-items: center; - gap: 1.25rem; - flex-wrap: wrap; -} - -/* The shared Checkbox aligns its box to the top of the first text line, with a nudge tuned for its - own font size - that is for the label-plus-description case. This one is a single line, so centre - the box on it and leave the component's sizing alone (overriding the font size shifts the line - box and leaves the tick floating high). */ -.portal-pipeline-header__enabled.sui-check { - flex: none; - align-items: center; -} - -.portal-pipeline-header__enabled.sui-check .sui-check__box { - margin-top: 0; -} - -/* The name is the page's title, so it takes the room and reads at title size. */ -.portal-pipeline-header__name { - flex: 1 1 16rem; - min-width: 12rem; -} - -.portal-pipeline-header__name input { - font-size: 1rem; - font-weight: 500; -} - -/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ -.portal-pipeline-header__save { - display: flex; - align-items: center; - gap: 0.5rem; - margin-left: auto; - flex: none; -} - -.portal-pipeline-header__save .sui-btn { - flex: none; - white-space: nowrap; -} - -/* Operational actions: what you can do to this pipeline, kept off the identity row. */ -.portal-pipeline-header__actions { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.875rem; - border-top: 1px solid var(--c-border-subtle); -} - -/* Destructive, so it sits away from the rest rather than next in line. */ -.portal-pipeline-header__delete.sui-btn { - margin-left: auto; -} - -/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the - backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ -.portal-pipeline-header__result { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.875rem; - border-top: 1px solid var(--c-border-subtle); -} - -.portal-pipeline-header__result-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; - color: var(--c-text); -} - -.portal-pipeline-header__result-icon.is-ok { - color: var(--c-success); -} - -.portal-pipeline-header__result-icon.is-bad { - color: var(--c-danger); -} - -/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); - it may wrap to keep a long backend message readable rather than clipping it. */ -.portal-pipeline-header__result-error { - font-size: 0.8125rem; - color: var(--c-text-muted); - min-width: 0; -} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx deleted file mode 100644 index 41c73a5ade..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useState } from "react"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { - PipelineHeader, - type RunResultSummary, -} from "@portal/components/pipelines/PipelineHeader"; - -const meta: Meta = { - title: "Portal/Pipelines/PipelineHeader", - component: PipelineHeader, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -const noop = () => {}; - -/** The name and the enabled switch are live, so the section can be seen in both states. */ -function Playground({ - initialName, - isEdit, - initialEnabled = true, - runResult = null, - ...rest -}: { - initialName: string; - isEdit: boolean; - initialEnabled?: boolean; - runResult?: RunResultSummary | null; - saving?: boolean; - testing?: boolean; - running?: boolean; - canSave?: boolean; - stepCount?: number; -}) { - const [name, setName] = useState(initialName); - const [enabled, setEnabled] = useState(initialEnabled); - return ( - - ); -} - -/** An existing pipeline: everything is available. */ -export const Editing: Story = { - render: () => , -}; - -/** - * A pipeline that has never been saved. It can still be tested against a file, but there is - * nothing yet to run on a schedule, clear history for, or delete. - */ -export const New: Story = { - render: () => , -}; - -/** Paused: the pipeline exists but its trigger will not fire. */ -export const Paused: Story = { - render: () => ( - - ), -}; - -/** Mid test-run: the picker shows its own progress while the graph shows the steps. */ -export const Testing: Story = { - render: () => , -}; - -/** After a test run: the outcome and its files sit beside the button that started them. */ -export const WithRunResult: Story = { - render: () => ( - - ), -}; - -/** A failed run: the summary is here, the failing step's own message is on its node. */ -export const WithFailedRun: Story = { - render: () => ( - - ), -}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx deleted file mode 100644 index 2c5552be07..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - fireEvent, - render as baseRender, - screen, -} from "@testing-library/react"; -import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -import { - PipelineHeader, - type PipelineHeaderProps, -} from "@portal/components/pipelines/PipelineHeader"; - -const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: PortalTestProviders }); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -function renderHeader(overrides: Partial = {}) { - const handlers = { - onNameChange: vi.fn(), - onEnabledChange: vi.fn(), - onSave: vi.fn(), - onCancel: vi.fn(), - onBack: vi.fn(), - onTest: vi.fn(), - onRun: vi.fn(), - onClearHistory: vi.fn(), - onDelete: vi.fn(), - onViewDefinition: vi.fn(), - onDownloadOutput: vi.fn(), - }; - render( - , - ); - return handlers; -} - -describe("PipelineHeader", () => { - it("edits the pipeline's name and enabled state", () => { - const handlers = renderHeader(); - fireEvent.change( - screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), - { target: { value: "Renamed" } }, - ); - expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); - - fireEvent.click(screen.getByRole("checkbox")); - expect(handlers.onEnabledChange).toHaveBeenCalledWith(false); - }); - - it("offers run, clear history and delete only once the pipeline exists", () => { - renderHeader({ isEdit: false }); - expect( - screen.queryByText("portal.pipelines.detail.run"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.detail.delete"), - ).not.toBeInTheDocument(); - // A test run needs no saved record, so it stays: it is how you check the steps as you build. - expect( - screen.getByText("portal.pipelines.builder.testRun"), - ).toBeInTheDocument(); - }); - - it("labels the save action for what it will do", () => { - renderHeader({ isEdit: false }); - expect( - screen.getByText("portal.pipelines.composer.create"), - ).toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.composer.save"), - ).not.toBeInTheDocument(); - }); - - it("blocks saving until the pipeline is valid", () => { - renderHeader({ canSave: false }); - expect( - screen.getByText("portal.pipelines.composer.save").closest("button"), - ).toBeDisabled(); - }); - - it("hands the chosen file to the test run", () => { - const handlers = renderHeader(); - const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); - const input = - document.querySelector('input[type="file"]'); - expect(input).not.toBeNull(); - fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); - expect(handlers.onTest).toHaveBeenCalledWith(file); - }); - - it("will not offer a test run on a chain with no steps", () => { - renderHeader({ stepCount: 0 }); - expect( - screen.getByText("portal.pipelines.builder.testRun").closest("button"), - ).toBeDisabled(); - }); - - it("shows why a test run failed, not only that it did", () => { - renderHeader({ - runResult: { - status: "failed", - completedSteps: 1, - stepCount: 3, - error: "OCR failed: unreadable page", - }, - }); - expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); - }); - - it("runs and deletes from the row, clears history from the tray", () => { - const handlers = renderHeader(); - fireEvent.click(screen.getByText("portal.pipelines.detail.run")); - expect(handlers.onRun).toHaveBeenCalled(); - fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); - expect(handlers.onDelete).toHaveBeenCalled(); - - fireEvent.click( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ); - fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); - expect(handlers.onClearHistory).toHaveBeenCalled(); - }); - - it("leaves the page through cancel and back", () => { - const handlers = renderHeader(); - fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); - expect(handlers.onCancel).toHaveBeenCalled(); - fireEvent.click(screen.getByText("portal.pipelines.builder.back")); - expect(handlers.onBack).toHaveBeenCalled(); - }); - - it("keeps the occasional actions out of the row, behind a tray", () => { - renderHeader(); - // Running and testing earn a button each; reading the definition and wiping history do not. - expect( - screen.queryByText("portal.pipelines.builder.viewDefinition"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("portal.pipelines.detail.clearHistory"), - ).not.toBeInTheDocument(); - expect( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ).toBeInTheDocument(); - }); - - it("opens the definition from the tray", () => { - const handlers = renderHeader(); - fireEvent.click( - screen.getByLabelText("portal.pipelines.builder.moreActions"), - ); - fireEvent.click( - screen.getByText("portal.pipelines.builder.viewDefinition"), - ); - expect(handlers.onViewDefinition).toHaveBeenCalled(); - }); - - it("shows no run strip until a test has been run", () => { - renderHeader(); - expect( - screen.queryByText(/portal.pipelines.inspector.status/), - ).not.toBeInTheDocument(); - }); - - it("reports a finished run and downloads the file clicked", () => { - const handlers = renderHeader({ - runResult: { - status: "completed", - completedSteps: 2, - stepCount: 2, - outputs: [ - { fileId: "f1", fileName: "claim.pdf" }, - { fileId: "f2", fileName: null }, - ], - }, - }); - fireEvent.click(screen.getByText("claim.pdf")); - expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ - fileId: "f1", - fileName: "claim.pdf", - }); - // A file the backend did not name still has to be reachable. - expect(screen.getByText("f2")).toBeInTheDocument(); - }); -}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx deleted file mode 100644 index 25bfbd044f..0000000000 --- a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; -import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; -import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; -import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; -import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; -import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; -import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; -import { - ActionIcon, - Button, - Checkbox, - Dropdown, - FilePicker, - Input, - Spinner, -} from "@app/ui"; -import "@portal/components/pipelines/PipelineHeader.css"; - -/** One file a test run produced, downloadable from the result strip. */ -export interface RunOutputFile { - fileId: string; - fileName: string | null; -} - -/** - * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files - * plus the step it stopped at, so there is no per-node output to attach to a node. - */ -export interface RunResultSummary { - status: "running" | "completed" | "failed"; - completedSteps: number; - stepCount: number; - error?: string | null; - outputs?: RunOutputFile[]; -} - -export interface PipelineHeaderProps { - name: string; - onNameChange: (name: string) => void; - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - /** False for a pipeline that has never been saved: it cannot yet be run, cleared or deleted. */ - isEdit: boolean; - /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ - stepCount: number; - - canSave: boolean; - saving: boolean; - onSave: () => void; - onCancel: () => void; - onBack: () => void; - - /** Run the steps as they stand against one uploaded file, without saving or delivering. */ - onTest: (file: File) => void; - testing: boolean; - /** Run the saved pipeline against its real input, delivering to its real destination. */ - onRun: () => void; - running: boolean; - onClearHistory: () => void; - clearingHistory: boolean; - onDelete: () => void; - - /** Opens the definition (JSON + cURL), which is pipeline-scoped like the rest of this row. */ - onViewDefinition: () => void; - /** The last test run in this session, or null if there has not been one. */ - runResult: RunResultSummary | null; - onDownloadOutput: (output: RunOutputFile) => void; -} - -/** - * The pipeline's identity and its whole-pipeline actions, at the top of the builder. - * - * Split in two so neither half gets lost in a single crowded row: what the pipeline *is* (name, - * whether it is live) sits with the actions that leave the page, and what you can *do to it* sits - * below the rule. A test run is part of building, so it lives here rather than off in a corner - - * its progress shows on the graph's nodes and its results in the inspector. - */ -export function PipelineHeader({ - name, - onNameChange, - enabled, - onEnabledChange, - isEdit, - stepCount, - canSave, - saving, - onSave, - onCancel, - onBack, - onTest, - testing, - onRun, - running, - onClearHistory, - clearingHistory, - onDelete, - onViewDefinition, - runResult, - onDownloadOutput, -}: PipelineHeaderProps) { - const { t } = useTranslation(); - - return ( -
      -
      - -
      - - -
      -
      - -
      - onNameChange(e.target.value)} - /> - {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch - would imply it applies the moment it is flipped. No description - a second line beside - the single-line name field leaves the row ragged. */} - onEnabledChange(e.target.checked)} - label={t("portal.pipelines.builder.enabled")} - /> -
      - -
      - file && onTest(file)} - leftSection={} - > - {t("portal.pipelines.builder.testRun")} - - - {isEdit && ( - - )} - - {/* Occasional things - reading the definition, wiping the processed history - kept behind a - tray so they do not compete with running and testing, which is what this row is for. */} - - - - - - - - } - > - {t("portal.pipelines.builder.viewDefinition")} - - {isEdit && ( - - } - > - {t("portal.pipelines.detail.clearHistory")} - - )} - - - - {isEdit && ( - - )} -
      - - {runResult && ( - - )} -
      - ); -} - -interface RunResultStripProps { - result: RunResultSummary; - onDownload: (output: RunOutputFile) => void; -} - -/** What the last test run did, beside the button that started it. */ -function RunResultStrip({ result, onDownload }: RunResultStripProps) { - const { t } = useTranslation(); - const outputs = result.outputs ?? []; - - return ( -
      -
      - {result.status === "running" && } - {result.status === "completed" && ( - - )} - {result.status === "failed" && ( - - )} - - {t(`portal.pipelines.inspector.status.${result.status}`, { - done: result.completedSteps, - count: result.stepCount, - })} - -
      - - {/* The reason it failed, where the failure is announced - not only on the node, which the user - has to know to click. */} - {result.status === "failed" && result.error && ( - - {result.error} - - )} - - {outputs.map((output) => ( - - ))} -
      - ); -} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index 464d3fad55..343aea95f6 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -225,3 +225,43 @@ gap: 0.5rem; width: 100%; } + +/* The graph column IS the card: the test control is its header and the graph its body, so the test + control reads as the graph's toolbar and - crucially - the card's top lines up with the inspector + beside it (a toolbar sitting *above* the card pushed the graph down out of alignment). Caps at the + row height and clips its rounded corners; the body scrolls inside while the header stays put. */ +.portal-builder__canvas { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; + overflow: hidden; + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); + background: var(--c-surface-sunken); +} + +/* The test control as the card's header, ruled off from the graph below it. */ +.portal-builder__canvas > .portal-pipeline-toolbar { + flex: none; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--c-border-subtle); +} + +/* The graph as the card's body: it drops its own frame (the canvas provides it now) and scrolls + inside while the header stays put. */ +.portal-builder__canvas > .portal-graph { + flex: 1 1 auto; + min-height: 0; + max-height: none; + border: none; + border-radius: 0; + background: transparent; +} + +/* Stacked (short viewport): the page scrolls, so the column must not cap or nest its own scroll. */ +@media (max-width: 60rem) { + .portal-builder__canvas { + max-height: none; + } +} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index b1ff80682d..52fb5d516c 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -456,7 +456,7 @@ describe("PipelineBuilder", () => { expect( await screen.findByText("portal.pipelines.builder.needsSource"), ).toBeInTheDocument(); - // Still nothing chosen, so the pipeline cannot be saved. + // Still nothing chosen, so the pipeline cannot be created. expect( screen.getByText("portal.pipelines.composer.create").closest("button"), ).toBeDisabled(); @@ -601,15 +601,15 @@ describe("PipelineBuilder", () => { target: { value: "Needs both" }, }, ); - const saveButton = () => + const createButton = () => screen.getByText("portal.pipelines.composer.create").closest("button"); // Name only: blocked (no source, no destination). - expect(saveButton()).toBeDisabled(); + expect(createButton()).toBeDisabled(); // An input with a source but still no destination: blocked. await pickInputSource("Claims intake"); - expect(saveButton()).toBeDisabled(); + expect(createButton()).toBeDisabled(); // Both chosen: allowed, and both are sent. await pickDestination(); @@ -782,17 +782,19 @@ describe("PipelineBuilder", () => { ).toBeInTheDocument(); }); - it("clears processed history from the header and confirms", async () => { + it("reprocesses the source: clears the processed record, runs, and reports", async () => { renderBuilder("/processor/pipelines/plc-1"); await openTray(); fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + // It forgets what was processed, then triggers a run so those files go through now. await waitFor(() => expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"), ); + await waitFor(() => expect(triggerPipeline).toHaveBeenCalledWith("plc-1")); expect( - await screen.findByText("portal.pipelines.run.historyCleared"), + await screen.findByText("portal.pipelines.run.completed"), ).toBeInTheDocument(); }); @@ -846,6 +848,8 @@ describe("PipelineBuilder", () => { it("deletes an existing pipeline after confirmation", async () => { renderBuilder("/processor/pipelines/plc-1"); + // Delete is a rare, destructive action, so it lives behind the overflow tray. + await openTray(); fireEvent.click(await screen.findByText("portal.pipelines.detail.delete")); fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm")); @@ -853,6 +857,45 @@ describe("PipelineBuilder", () => { expect(await screen.findByText("pipelines list")).toBeInTheDocument(); }); + it("pauses a live pipeline at once, in place, and re-saves the persisted policy", async () => { + renderBuilder("/processor/pipelines/plc-1"); + + // POLICY.enabled is true, so the toggle offers to pause it. + fireEvent.click(await screen.findByText("portal.pipelines.builder.pause")); + + await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ id: "plc-1", enabled: false }), + ); + // It acts in place: the builder stays open and the control now offers to activate again. + expect( + await screen.findByText("portal.pipelines.builder.activate"), + ).toBeInTheDocument(); + expect(screen.queryByText("pipelines list")).not.toBeInTheDocument(); + }); + + it("creates a paused pipeline when Create paused is chosen", async () => { + renderBuilder("/processor/pipelines/new"); + + fireEvent.change( + await screen.findByRole("textbox", { + name: "portal.pipelines.composer.name", + }), + { target: { value: "Paused draft" } }, + ); + await addTool("Compress"); + await pickInputSource("Claims intake"); + await pickDestination(); + + fireEvent.click(screen.getByText("portal.pipelines.composer.createPaused")); + + await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ name: "Paused draft", enabled: false }), + ); + expect(await screen.findByText("pipelines list")).toBeInTheDocument(); + }); + it("prompts to save or discard when leaving with unsaved edits", async () => { renderBuilder("/processor/pipelines/new"); @@ -864,7 +907,7 @@ describe("PipelineBuilder", () => { target: { value: "Draft" }, }, ); - fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back")); expect( await screen.findByText("portal.pipelines.builder.unsavedTitle"), @@ -928,7 +971,7 @@ describe("PipelineBuilder", () => { await screen.findByRole("textbox", { name: "portal.pipelines.composer.name", }); - fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + fireEvent.click(screen.getByLabelText("portal.pipelines.builder.back")); expect(await screen.findByText("pipelines list")).toBeInTheDocument(); }); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index 746fd61ab0..d5092f5c88 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -67,7 +67,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; -import { PipelineHeader } from "@portal/components/pipelines/PipelineHeader"; +import { PipelineCreateHeader } from "@portal/components/pipelines/PipelineCreateHeader"; +import { PipelineEditHeader } from "@portal/components/pipelines/PipelineEditHeader"; +import { PipelineGraphToolbar } from "@portal/components/pipelines/PipelineGraphToolbar"; import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; import { @@ -258,10 +260,19 @@ export function PipelineBuilder() { const [inputAsked, setInputAsked] = useState(false); const [outputAsked, setOutputAsked] = useState(false); const [submitting, setSubmitting] = useState(false); + // Which create action is in flight, so only the button that was clicked (Create / Create paused) + // shows its spinner. Null in edit and while idle. + const [pendingCreateEnabled, setPendingCreateEnabled] = useState< + boolean | null + >(null); const [error, setError] = useState(null); const [seeded, setSeeded] = useState(false); const [running, setRunning] = useState(false); - const [clearingHistory, setClearingHistory] = useState(false); + // Pausing/activating an existing pipeline acts immediately (a separate save), not on the next + // "Save changes"; this tracks that in-flight toggle. + const [togglingEnabled, setTogglingEnabled] = useState(false); + // Clearing the processed record then running, so already-handled files go through again. + const [reprocessing, setReprocessing] = useState(false); const [runResult, setRunResult] = useState(null); const [pendingDelete, setPendingDelete] = useState(false); const [deleting, setDeleting] = useState(false); @@ -587,10 +598,11 @@ export function PipelineBuilder() { } // Track unsaved edits: snapshot the form and compare against the state captured just after - // seeding, so leaving the builder can prompt to save or discard. + // seeding, so leaving the builder can prompt to save or discard. `enabled` is deliberately left + // out: in edit it is toggled and persisted at once (never an unsaved edit), and in create it is + // chosen at submit - so it can never be the thing that makes the form dirty. const snapshot = JSON.stringify({ name: name.trim(), - enabled, input, steps: steps.map((step) => serializeToolStep(step, allTools)), uploads: steps.map(stepRequiresUpload), @@ -602,20 +614,46 @@ export function PipelineBuilder() { }, [seeded, snapshot]); const dirty = baseline.current !== null && baseline.current !== snapshot; - // The input needs a source, and a scheduled input needs a positive interval; the pipeline - // needs exactly one output destination. - const inputValid = - input.sourceId !== "" && - (input.triggerType !== "schedule" || Number(input.scheduleCount) > 0); + // Each validity condition is defined exactly once here, then consumed both by the graph (which + // flags each end) and by the blocker list below. + const sourceChosen = input.sourceId !== ""; + const scheduleValid = + input.triggerType !== "schedule" || Number(input.scheduleCount) > 0; + const inputValid = sourceChosen && scheduleValid; const outputValid = outputIds.length === 1; - const canSave = - name.trim() !== "" && - inputValid && - outputValid && - !hasUploadSteps && - !hasUnconfiguredSteps && - !hasIncompatibleSteps && - !submitting; + + // The single source of truth for "can this be committed": every reason it can't be, in the order + // they appear down the form, so a disabled Create / Save button can say exactly what is still owed. + const blockers: string[] = []; + if (name.trim() === "") + blockers.push(t("portal.pipelines.builder.blocker.name")); + if (!sourceChosen) + blockers.push(t("portal.pipelines.builder.blocker.source")); + else if (!scheduleValid) + blockers.push(t("portal.pipelines.builder.blocker.schedule")); + if (!outputValid) + blockers.push(t("portal.pipelines.builder.blocker.destination")); + if (hasUnconfiguredSteps) + blockers.push( + t("portal.pipelines.builder.blocker.setup", { + tools: unconfiguredStepLabels.join(", "), + }), + ); + if (hasUploadSteps) + blockers.push( + t("portal.pipelines.builder.blocker.upload", { + tools: uploadStepLabels.join(", "), + }), + ); + if (hasIncompatibleSteps) + blockers.push( + t("portal.pipelines.builder.blocker.incompatible", { + tools: blockingSteps.join(", "), + }), + ); + + // Nothing left to fix, and not already committing. + const canSave = blockers.length === 0 && !submitting; const listPath = toPortalPath(VIEW_PATHS.pipelines); @@ -629,14 +667,14 @@ export function PipelineBuilder() { else navigate(destination); } - async function save(destination: string) { + async function save(destination: string, enabledOverride?: boolean) { if (!canSave) return; setSubmitting(true); setError(null); const policy: Policy = { id: policyState.data?.id ?? undefined, name: name.trim(), - enabled, + enabled: enabledOverride ?? enabled, // The wire shape stays a list; canSave guarantees the one input has a source. inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], steps: steps.map((step) => serializeToolStep(step, allTools)), @@ -652,6 +690,39 @@ export function PipelineBuilder() { } catch (e) { setError(errorMessage(e)); setSubmitting(false); + setPendingCreateEnabled(null); + } + } + + // Create live or paused. The buttons disable until the pipeline is valid, so this only fires on a + // saveable pipeline; the flag records which button spins and whether it starts live or paused. + function submitCreate(enabledValue: boolean) { + setPendingCreateEnabled(enabledValue); + void save(listPath, enabledValue); + } + + /** + * Pause or activate the saved pipeline now, without leaving the builder. It re-saves the + * persisted policy with the flag flipped - deliberately NOT the working form - so a pending chain + * edit is not silently committed by a pause. The dirty tracker ignores `enabled`, so this never + * looks like an unsaved change. + */ + async function handleTogglePause() { + // Never run alongside a Save: both write the whole policy, and a concurrent pair would race + // (the pause carries the persisted steps, so it could clobber the edits Save is committing). + if (togglingEnabled || submitting || !policyState.data) return; + const next = !enabled; + setTogglingEnabled(true); + setError(null); + try { + await savePipeline({ ...policyState.data, enabled: next }); + if (!mounted.current) return; + setEnabled(next); + await invalidatePipelines(); + } catch (e) { + if (mounted.current) setError(errorMessage(e)); + } finally { + if (mounted.current) setTogglingEnabled(false); } } @@ -741,39 +812,45 @@ export function PipelineBuilder() { return { tone: "info", text: t("portal.pipelines.run.empty") }; } + // Trigger the saved pipeline and report the outcome: what the sweep started (or why it started + // nothing), then each run's terminal state. Shared by Run now and the reprocess action. + async function reportRun(policyId: string) { + const outcome = await triggerPipeline(policyId); + const runIds = outcome.runIds; + if (runIds.length === 0) { + if (mounted.current) setRunResult(emptySweepResult(outcome)); + return; + } + const finals = await Promise.all(runIds.map((runId) => awaitRun(runId))); + if (!mounted.current) return; + const failed = finals.find((r) => r?.status === "FAILED"); + if (failed) { + setRunResult({ + tone: "danger", + text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }), + }); + } else if (finals.some((r) => r === null)) { + // Gave up polling before a terminal status; the run may still finish server-side. + setRunResult({ + tone: "warning", + text: t("portal.pipelines.run.timeout"), + }); + } else if (finals.every((r) => r?.status === "COMPLETED")) { + setRunResult({ + tone: "success", + text: t("portal.pipelines.run.completed", { count: finals.length }), + }); + } else { + setRunResult({ tone: "info", text: t("portal.pipelines.run.running") }); + } + } + async function handleRun() { - if (running || !id) return; + if (running || reprocessing || !id) return; setRunning(true); setRunResult(null); try { - const outcome = await triggerPipeline(id); - const runIds = outcome.runIds; - if (runIds.length === 0) { - if (mounted.current) setRunResult(emptySweepResult(outcome)); - return; - } - const finals = await Promise.all(runIds.map((runId) => awaitRun(runId))); - if (!mounted.current) return; - const failed = finals.find((r) => r?.status === "FAILED"); - if (failed) { - setRunResult({ - tone: "danger", - text: t("portal.pipelines.run.failed", { error: failed.error ?? "" }), - }); - } else if (finals.some((r) => r === null)) { - // Gave up polling before a terminal status; the run may still finish server-side. - setRunResult({ - tone: "warning", - text: t("portal.pipelines.run.timeout"), - }); - } else if (finals.every((r) => r?.status === "COMPLETED")) { - setRunResult({ - tone: "success", - text: t("portal.pipelines.run.completed", { count: finals.length }), - }); - } else { - setRunResult({ tone: "info", text: t("portal.pipelines.run.running") }); - } + await reportRun(id); } catch (e) { if (mounted.current) setRunResult({ tone: "danger", text: errorMessage(e) }); @@ -783,26 +860,22 @@ export function PipelineBuilder() { } /** - * Forget which source files this pipeline has processed, so the next sweep - * reprocesses everything currently in its sources (the standard retry for a - * parked-by-failure file). Does not touch the files themselves. + * Reprocess everything currently in the sources: forget which files the pipeline already handled, + * then run at once so those files - which a normal run skips - go through now. Reports the run's + * outcome exactly like Run now; does not touch the files themselves. */ - async function handleClearHistory() { - if (clearingHistory || !id) return; - setClearingHistory(true); + async function handleReprocessAll() { + if (running || reprocessing || !id) return; + setReprocessing(true); setRunResult(null); try { await clearProcessedHistory(id); - if (mounted.current) - setRunResult({ - tone: "success", - text: t("portal.pipelines.run.historyCleared"), - }); + await reportRun(id); } catch (e) { if (mounted.current) setRunResult({ tone: "danger", text: errorMessage(e) }); } finally { - if (mounted.current) setClearingHistory(false); + if (mounted.current) setReprocessing(false); } } @@ -1056,29 +1129,37 @@ export function PipelineBuilder() { return (
      - save(listPath)} - onCancel={() => attemptLeave(listPath)} - onBack={() => attemptLeave(listPath)} - onTest={handleTest} - testing={testing} - onRun={handleRun} - running={running} - onClearHistory={handleClearHistory} - clearingHistory={clearingHistory} - onDelete={() => setPendingDelete(true)} - onViewDefinition={() => setDefinitionOpen(true)} - runResult={testSummary} - onDownloadOutput={downloadOutput} - /> + {isEdit ? ( + attemptLeave(listPath)} + canSave={canSave} + blockers={blockers} + saving={submitting} + onSave={() => save(listPath)} + onRun={handleRun} + running={running} + onReprocess={handleReprocessAll} + reprocessing={reprocessing} + onDelete={() => setPendingDelete(true)} + /> + ) : ( + submitCreate(true)} + onCreatePaused={() => submitCreate(false)} + onBack={() => attemptLeave(listPath)} + /> + )} {error && } {runResult && ( @@ -1110,44 +1191,54 @@ export function PipelineBuilder() { )}
      - setSelected({ steps: [index] })} - /> +
      + setDefinitionOpen(true)} + /> + setSelected({ steps: [index] })} + /> +
      Date: Fri, 14 Aug 2026 10:55:15 +0000 Subject: [PATCH 187/262] Processor UI snags: fat CTAs, real Infrastructure tabs, one surface style (#7497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five unrelated snags in the processor (portal) UI, plus fixes they turned up. No backend changes. `84 files changed, +892 / −3364` ## Fat CTA buttons - New `fat` prop on the SUI `Button`: 2.75rem tall, 1.25rem side padding, 0.75rem corners, semibold. Composes with all four variants/accents. - Applied to the page-header CTA on Sources, Documents, Pipelines, Users (both), Usage, Integrations, Infrastructure — 8 buttons, all in line with a page title. Nothing else. - `LandingActions` migrated onto the prop; `.landing-btn-primary` / `.landing-btn-secondary` and their four `!important`s deleted. The editor landing CTAs come down 4px with everything else. - Infrastructure's header CTA is now primary; its "Create key" dropped to secondary so they stop competing. ## Documents empty state - "Connect a source" opened the Sources *page*; it now opens the `SourceModal` connect flow in place, no route change. - No extra cache wiring: `SourceModal` already invalidates the sources query. ## Infrastructure tabs - Only API Keys and Audit Logs hit real endpoints. Deployments, Security, Models and Storage read mock-only `/v1/infrastructure/*` that no backend serves. - Those four are now disabled: native `disabled`, out of the keyboard tab order, `aria-disabled`, with the view refusing non-enabled keys as a second guard. - Real tabs moved leftmost; API Keys is the default; `?tab=` deep links validated against the enabled set (the home flow's audit link still works). - Deleted: 4 tab components, their fetch fns and ~25 dead types, MSW handlers, fixtures (908 → 253 lines), dead CSS, unused formatters, 240 lines of `en-US` strings. Most of the −3364. - Page subtitle no longer advertises the disabled tabs. ## Surface consolidation - New `Surface` primitive (`sui-surface`): fill, hairline, radius, no shadow. Kept separate from `sui-nav-surface` so nav chrome can diverge later. - `Card` composes it and no longer draws its own shadow — this changes editor Card usages too, by design. - SUI primitives that are surfaces adopt it: `MetricCard`, `MetricStrip`, `NodeCard`, `Table`, `Collapsible`, `CodeBlock`. - The portal gets its own `.portal-surface` with the same three declarations, applied to 19 elements. A `sui-` class belongs to the component that emits it, so feature markup doesn't wear one. - `raised` variant = one subtle shadow for a surface in front of another surface (the flow diagram's tiles). Same fill as its parent, so nesting never shifts a region's colour. Dark has its own value. - Floating chrome (modals, drawers, dropdowns, assistant, sidebar) keeps its elevation; sunken wells stay sunken. ## Sources list - Centred "No sources connected yet" empty state removed — it duplicated the header CTA and pushed the table down the page. The header's "Connect source" is the single way in. ## Drive-by fixes - The connect flow rendered unstyled outside the Sources view: `.portal-conn-picker__*` / `.portal-sources__connection-*` lived in `views/Sources.css`, which none of the five components rendering them imported. Moved to `components/sources/connections.css`. - Three inert custom properties (`--surface-input`, `--color-border-2`, `--text-default`) are defined nowhere in the codebase — `.portal-conn-picker__card` had no fill at all as a result. - Dead CSS removed from `Sources.css` (grep-verified unused): old expanded-row panel + its keyframes, type-card block. ## Testing - `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes + stylelint), format, 238 files / 2063 tests. - `frontend:typecheck:all` across all 9 tsconfigs. - `frontend:storybook:a11y:changed` — 119 stories, light and dark, zero violations, no regressions vs baseline. - New tests: `Infrastructure.test.tsx` (tab order, default, disabled behaviour, deep-link filtering) and a Documents test that the empty-state CTA opens the modal without navigating. - Merged `origin/main` (#7438 replaced `PipelineHeader` with the new Create/Edit headers); full suite green at 240 files / 2072 tests after the merge. --- .../public/locales/en-US/translation.toml | 246 +------ .../core/components/shared/LandingActions.tsx | 8 +- .../core/components/shared/LandingPage.css | 10 - frontend/editor/src/core/ui/Button.css | 5 + .../editor/src/core/ui/Button.stories.tsx | 30 + frontend/editor/src/core/ui/Button.tsx | 16 +- frontend/editor/src/core/ui/Card.css | 8 +- frontend/editor/src/core/ui/Card.tsx | 4 +- frontend/editor/src/core/ui/CodeBlock.css | 3 +- frontend/editor/src/core/ui/Collapsible.css | 2 - frontend/editor/src/core/ui/Collapsible.tsx | 5 +- frontend/editor/src/core/ui/MetricCard.css | 6 - frontend/editor/src/core/ui/MetricCard.tsx | 2 + frontend/editor/src/core/ui/MetricStrip.css | 4 - frontend/editor/src/core/ui/MetricStrip.tsx | 2 + frontend/editor/src/core/ui/NodeCard.css | 4 - frontend/editor/src/core/ui/NodeCard.tsx | 2 + frontend/editor/src/core/ui/Surface.css | 20 + .../editor/src/core/ui/Surface.stories.tsx | 46 ++ frontend/editor/src/core/ui/Surface.tsx | 33 + frontend/editor/src/core/ui/Table.css | 3 - frontend/editor/src/core/ui/Table.tsx | 5 +- frontend/editor/src/core/ui/Tabs.tsx | 8 +- frontend/editor/src/core/ui/index.ts | 1 + frontend/editor/src/portal/MOCKS.md | 9 +- .../editor/src/portal/api/infrastructure.ts | 229 ------- .../portal/components/EditorStatusCard.css | 4 - .../portal/components/EditorStatusCard.tsx | 3 +- .../editor/src/portal/components/HomeHero.tsx | 2 +- .../src/portal/components/ProcessorFlow.css | 11 +- .../src/portal/components/ProcessorFlow.tsx | 1 + .../billing/BundleCheckoutModal.tsx | 3 +- .../components/billing/CardPlaceholder.tsx | 3 +- .../src/portal/components/billing/billing.css | 5 - .../docs/EndpointReferenceSection.tsx | 6 +- .../components/documents/ReviewQueue.test.tsx | 46 +- .../components/documents/ReviewQueue.tsx | 12 +- .../components/failures/FileRunEventList.tsx | 3 +- .../portal/components/failures/failures.css | 3 - .../components/infrastructure/ApiKeysTab.tsx | 1 + .../infrastructure/DeploymentsTab.stories.tsx | 46 -- .../infrastructure/DeploymentsTab.tsx | 233 ------- .../infrastructure/ModelsTab.stories.tsx | 30 - .../components/infrastructure/ModelsTab.tsx | 272 -------- .../infrastructure/SecurityTab.stories.tsx | 58 -- .../components/infrastructure/SecurityTab.tsx | 342 ---------- .../infrastructure/StorageTab.stories.tsx | 80 --- .../components/infrastructure/StorageTab.tsx | 244 ------- .../components/infrastructure/infraFormat.ts | 135 +--- .../pipelines/PipelineInspector.css | 3 - .../pipelines/PipelineInspector.tsx | 7 +- .../pipelines/graph/PipelineGraph.css | 2 +- .../policies/PolicyExternalApiConfig.tsx | 4 +- .../components/procurement/DealStatusHero.tsx | 3 +- .../procurement/ProcurementAgreement.tsx | 3 +- .../components/procurement/QuoteBuilder.tsx | 3 +- .../components/sources/ConnectionForm.tsx | 1 + .../components/sources/ConnectionModal.tsx | 1 + .../components/sources/ConnectionPicker.tsx | 1 + .../sources/ConnectionTypePicker.tsx | 4 +- .../sources/SourcesTable.stories.tsx | 19 + .../portal/components/sources/connections.css | 241 +++++++ .../components/users/PendingInvitations.tsx | 3 +- .../components/users/UsersDirectory.tsx | 7 +- .../portal/mocks/handlers/infrastructure.ts | 34 +- .../editor/src/portal/mocks/infrastructure.ts | 617 +----------------- frontend/editor/src/portal/theme/surface.css | 7 + .../editor/src/portal/views/DeveloperDocs.css | 2 - .../editor/src/portal/views/Documents.tsx | 2 +- .../src/portal/views/Infrastructure.css | 255 -------- .../src/portal/views/Infrastructure.test.tsx | 94 +++ .../src/portal/views/Infrastructure.tsx | 70 +- .../editor/src/portal/views/Integrations.css | 3 - .../editor/src/portal/views/Integrations.tsx | 4 +- .../editor/src/portal/views/Pipelines.tsx | 1 + .../editor/src/portal/views/Procurement.css | 15 +- frontend/editor/src/portal/views/Sources.css | 356 ---------- .../editor/src/portal/views/Sources.test.tsx | 4 +- frontend/editor/src/portal/views/Sources.tsx | 30 +- frontend/editor/src/portal/views/Usage.tsx | 2 +- frontend/editor/src/portal/views/Users.css | 3 - frontend/editor/src/portal/views/Users.tsx | 8 +- 82 files changed, 702 insertions(+), 3361 deletions(-) create mode 100644 frontend/editor/src/core/ui/Surface.css create mode 100644 frontend/editor/src/core/ui/Surface.stories.tsx create mode 100644 frontend/editor/src/core/ui/Surface.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/ModelsTab.stories.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/SecurityTab.stories.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/StorageTab.stories.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/StorageTab.tsx create mode 100644 frontend/editor/src/portal/components/sources/connections.css create mode 100644 frontend/editor/src/portal/theme/surface.css create mode 100644 frontend/editor/src/portal/views/Infrastructure.test.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 1948b71bf4..4ddc2497ee 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7444,7 +7444,7 @@ morning = "Good morning" [portal.infrastructure] manageEditorDeployment = "Manage Editor deployment" sectionsAriaLabel = "Infrastructure sections" -subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace." +subtitle = "API credentials and the audit trail for your Stirling workspace." title = "Infrastructure" [portal.infrastructure.apiKeys] @@ -7472,11 +7472,6 @@ cancel = "Cancel" confirm = "Revoke key" title = "Revoke API key" -[portal.infrastructure.attestationLabel] -attested = "Attested" -inScope = "In scope" -notApplicable = "N/A" - [portal.infrastructure.audit] filterAriaLabel = "Filter audit events by category" heading = "Audit logs" @@ -7548,11 +7543,6 @@ info = "Info" success = "Success" warning = "Warning" -[portal.infrastructure.certLabel] -certified = "Certified" -inProgress = "In progress" -notStarted = "Not started" - [portal.infrastructure.createKey] cancel = "Cancel" createKey = "Create key" @@ -7566,240 +7556,10 @@ subtitleCreated = "Copy this secret now — it won't be shown again." title = "Create API key" titleCreated = "Key created" -[portal.infrastructure.deployLabel] -live = "Live" -queued = "Queued" -rolledBack = "Rolled back" -rolling = "Rolling out" - -[portal.infrastructure.deployments] -loadAria = "Load for {{name}}" -msValue = "{{value}} ms" -throughputValue = "{{value}}/min" - -[portal.infrastructure.deployments.deployColumns] -deployedBy = "Deployed by" -environment = "Environment" -product = "Product" -status = "Status" -version = "Version" -when = "When" - -[portal.infrastructure.deployments.recent] -heading = "Recent deployments" -subheading = "The latest rollouts across products and environments." - -[portal.infrastructure.deployments.regionColumns] -instances = "Instances" -latency = "Latency" -load = "Load" -p99 = "P99" -region = "Region" -status = "Status" -throughput = "Throughput" -uptime = "Uptime" -version = "Version" - -[portal.infrastructure.deployments.regions] -heading = "Regions" -subheading = "Live health for every deployed Stirling region — latency, load, and rollout version." - -[portal.infrastructure.deployments.regions.empty] -description = "Deployed regions appear here once your workspace is provisioned." -title = "No regions deployed" - [portal.infrastructure.keyLabel] active = "Active" revoked = "Revoked" -[portal.infrastructure.modelLabel] -active = "Active" -degraded = "Degraded" -disabled = "Disabled" - -[portal.infrastructure.models] -heading = "Models" -loadAria = "Load for {{name}}" -msValue = "{{value}} ms" -subheading = "The model catalogue and routing that powers document processing across your workspace." - -[portal.infrastructure.models.byom] -description = "Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing." -title = "Bring your own model" - -[portal.infrastructure.models.catalogue] -heading = "Catalogue" -sub = "Managed models available to your workspace, with live latency and cost." -subEnterprise = "Managed, bring-your-own, and on-prem models — with per-region pinning available." - -[portal.infrastructure.models.catalogue.empty] -description = "Models in your workspace's catalogue appear here." -title = "No models available" - -[portal.infrastructure.models.columns] -cost = "Cost" -latency = "Latency" -load = "Load" -model = "Model" -status = "Status" -type = "Type" -version = "Version" - -[portal.infrastructure.models.cost] -perCall = "{{price}}/call" -perThousand = "{{price}}/1k" - -[portal.infrastructure.models.metrics] -activeModels = "Active models" -avgLatency = "Avg latency" -included = "Included" -monthlySpend = "Monthly model spend" - -[portal.infrastructure.models.routing] -empty = "No routing rules configured." -heading = "Routing rules" -sub = "Which model handles each operation. The default applies when no narrower rule matches." -subLocked = "Route operations to specific models — available on paid plans." - -[portal.infrastructure.models.routing.lockedBanner] -description = "Upgrade to Pro to control which model handles each operation and document type." -title = "Model routing is a paid feature" - -[portal.infrastructure.models.routingColumns] -default = "Default" -docType = "Document type" -modelForAria = "Model for {{operation}}" -operation = "Operation" -routedTo = "Routed to" - -[portal.infrastructure.modelTypeLabel] -classification = "Classification" -extraction = "Extraction" -llm = "LLM" -ocr = "OCR" - -[portal.infrastructure.regionLabel] -degraded = "Degraded" -down = "Down" -healthy = "Healthy" - -[portal.infrastructure.security.access.byok] -description = "Supply a key from your own KMS. Stirling encrypts with it but can still read." -label = "Bring your own key (BYOK)" - -[portal.infrastructure.security.access.hyok] -description = "Keys never leave your KMS. Stirling holds only ciphertext." -label = "Hold your own key (HYOK)" - -[portal.infrastructure.security.access.stirling] -description = "Stirling manages encryption keys. Simplest — zero key ops on your side." -label = "Stirling-held keys" - -[portal.infrastructure.security.accessPolicy] -heading = "Document access policy" -subheading = "Controls who can decrypt processed documents at rest." - -[portal.infrastructure.security.attestations] -heading = "Compliance attestations" -noReport = "No report available" -subheading = "Framework-by-framework audit posture, with reports available on attested controls." -viewReport = "View report →" - -[portal.infrastructure.security.compliance] -heading = "Compliance" -subheading = "Attestations and certifications covering the Stirling platform." - -[portal.infrastructure.security.empty] -description = "Your workspace's security configuration will appear here." -title = "Security posture unavailable" - -[portal.infrastructure.security.hyokBanner] -description = "With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time." -title = "Stirling cannot decrypt your documents" - -[portal.infrastructure.security.ipAllowlist] -empty = "No IP ranges configured — all IPs allowed." -heading = "IP allowlist" -sub = "API access is restricted to these CIDR ranges." -subLocked = "Restrict API access to known IP ranges — available on paid plans." - -[portal.infrastructure.security.ipAllowlist.lockedBanner] -description = "Upgrade to Pro to restrict API access to specific networks." -title = "IP allowlisting is a paid feature" - -[portal.infrastructure.security.ipColumns] -added = "Added" -addedBy = "Added by" -cidr = "CIDR" -label = "Label" - -[portal.infrastructure.security.keyManagement] -algorithm = "Algorithm" -heading = "Encryption key management" -keyId = "Key identifier" -lastRotated = "Last rotated" -rotateKey = "Rotate key" -rotationPolicy = "Rotation policy" -subheading = "Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate." - -[portal.infrastructure.security.managedBanner] -description = "Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS." -title = "Keys are managed by Stirling on your plan" - -[portal.infrastructure.security.residency.apac] -description = "ap-southeast-1" -label = "Asia Pacific" - -[portal.infrastructure.security.residency.eu] -description = "eu-west-1 · GDPR data boundary" -label = "European Union" - -[portal.infrastructure.security.residency.us] -description = "us-east-1 · us-west-2" -label = "United States" - -[portal.infrastructure.security.residencyHeader] -heading = "Data residency" -subheading = "Where documents are stored and processed." - -[portal.infrastructure.storage] -gbValue = "{{value}} GB" -percentUsed = "{{value}} used" - -[portal.infrastructure.storage.empty] -description = "Connected storage and usage appear here." -title = "No storage configured" - -[portal.infrastructure.storage.lifecycle] -active = "Active" -activeRange = "0–{{value}}d" -archived = "Archived" -coldStorage = "cold storage" -deleted = "Deleted" -never = "never" -purged = "purged" - -[portal.infrastructure.storage.providers] -connect = "Connect" -connected = "Connected" -heading = "Connected providers" -subheading = "Where processed artifacts are written." - -[portal.infrastructure.storage.retention] -heading = "Retention" -subheading = "How long artifacts are kept before lifecycle deletion." -windowLabel = "Default retention window" - -[portal.infrastructure.storage.retentionOption] -days_one = "{{count}} day" -days_other = "{{count}} days" -never = "Never delete" - -[portal.infrastructure.storage.totalUsage] -heading = "Total usage" -progressLabel = "Storage used" -subheading = "Storage consumed across all connected providers." - [portal.infrastructure.tabs] apiKeys = "API Keys" audit = "Audit Logs" @@ -8875,10 +8635,6 @@ cancel = "Cancel" confirm = "Delete" title = "Delete source?" -[portal.sources.empty] -description = "Connect a storage location so your policies have somewhere to pull data from." -title = "No sources connected yet" - [portal.sources.kpi] inUse = "In use" total = "Connections" diff --git a/frontend/editor/src/core/components/shared/LandingActions.tsx b/frontend/editor/src/core/components/shared/LandingActions.tsx index d69f90a1ef..750848f3c7 100644 --- a/frontend/editor/src/core/components/shared/LandingActions.tsx +++ b/frontend/editor/src/core/components/shared/LandingActions.tsx @@ -32,8 +32,7 @@ export function LandingActions({ <>
      + ), +}; + /** Icons are optional and positional: `leftSection`, `rightSection`, or both. */ export const WithIcons: Story = { render: (args) => ( diff --git a/frontend/editor/src/core/ui/Button.tsx b/frontend/editor/src/core/ui/Button.tsx index 2f46d5ab75..4b6bf2db85 100644 --- a/frontend/editor/src/core/ui/Button.tsx +++ b/frontend/editor/src/core/ui/Button.tsx @@ -42,6 +42,7 @@ type ButtonOwnProps = { variant?: ButtonVariant; accent?: ButtonAccent; size?: ButtonSize; + fat?: boolean; /** Label size relative to `size`. Defaults to the `size`-derived value. */ fontSize?: ButtonFontSize; /** Padding override for both axes */ @@ -103,6 +104,9 @@ function ButtonGroup({ ); } +const FAT_HEIGHT = "2.75rem"; +const FAT_PADDING_X = "lg" satisfies ControlPadding; + const MANTINE_VARIANT: Record = { primary: "filled", secondary: "outline", @@ -123,6 +127,7 @@ const ButtonRoot = forwardRef( variant = "primary", accent = "default", size = "sm", + fat = false, fontSize, p, px, @@ -161,7 +166,7 @@ const ButtonRoot = forwardRef( : undefined; // px/py override p for their axis; each stays undefined (= size default) if unset. - const padX = px ?? p; + const padX = px ?? p ?? (fat ? FAT_PADDING_X : undefined); const padY = py ?? p; // Sections flank a label → spread them without requiring justify="between". @@ -175,6 +180,7 @@ const ButtonRoot = forwardRef( `sui-acc-${accent}`, `sui-btn--${variant}`, iconOnly ? "sui-btn--icon" : "", + fat ? "sui-btn--fat" : "", shape !== "default" ? `sui-btn--${shape}` : "", overflow === "wrap" ? "sui-btn--wrap" : "", !hover ? "sui-btn--no-hover" : "", @@ -238,7 +244,9 @@ const ButtonRoot = forwardRef( className={classes} style={{ ...(accentVars as CSSProperties), - ...({ "--button-height": CONTROL_HEIGHT[size] } as CSSProperties), + ...({ + "--button-height": fat ? FAT_HEIGHT : CONTROL_HEIGHT[size], + } as CSSProperties), // Relative label size, scaled off the `size` base (unset → Mantine default). ...(fontSize ? ({ @@ -253,6 +261,10 @@ const ButtonRoot = forwardRef( ...(padY ? ({ "--sui-btn-py": CONTROL_PADDING[padY] } as CSSProperties) : {}), + // mantineTheme writes font-weight inline on every button root, so this must be inline too. + ...(fat + ? ({ fontWeight: "var(--font-weight-semibold)" } as CSSProperties) + : {}), // Icon-only: zero the size padding inline so the lone icon centres. ...(iconOnly ? ({ "--button-padding-x": "0" } as CSSProperties) : {}), ...style, diff --git a/frontend/editor/src/core/ui/Card.css b/frontend/editor/src/core/ui/Card.css index fcbefdab5b..62deaa0cf0 100644 --- a/frontend/editor/src/core/ui/Card.css +++ b/frontend/editor/src/core/ui/Card.css @@ -1,11 +1,6 @@ .sui-card { position: relative; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-md); transition: - box-shadow var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); } @@ -28,7 +23,6 @@ } .sui-card--interactive:hover { border-color: var(--c-border-strong); - box-shadow: var(--shadow-lg); transform: translateY(-0.0625rem); } @@ -43,7 +37,7 @@ left: 0; bottom: 0; width: 0.25rem; - border-radius: var(--radius-lg) 0 0 var(--radius-lg); + border-radius: var(--radius-nav) 0 0 var(--radius-nav); } .sui-card--accent-default::before { background: var(--c-primary); diff --git a/frontend/editor/src/core/ui/Card.tsx b/frontend/editor/src/core/ui/Card.tsx index dbdb1a393a..42d4c2d93a 100644 --- a/frontend/editor/src/core/ui/Card.tsx +++ b/frontend/editor/src/core/ui/Card.tsx @@ -1,4 +1,5 @@ import type { HTMLAttributes, ReactNode } from "react"; +import "@app/ui/Surface.css"; import "@app/ui/Card.css"; /** Subset of the shared accent dial that has a styled strip (see Card.css). */ @@ -18,7 +19,7 @@ export interface CardProps extends HTMLAttributes { * (e.g. a list with row dividers). */ padding?: "none" | "tight" | "default" | "loose"; - /** Use the lifted surface treatment (taller shadow, hover affordance). */ + /** Adds the clickable affordance (pointer cursor, hover lift). */ interactive?: boolean; children?: ReactNode; } @@ -40,6 +41,7 @@ export function Card({
      +
      + ); +} + +function cellClass( + align: "left" | "right", + nowrap: boolean, + fit: boolean, +): string { + return [ + "sui-datatable__td", + `sui-datatable__td--${align}`, + nowrap ? "sui-datatable__td--nowrap" : "", + fit ? "sui-datatable__td--fit" : "", + ] + .filter(Boolean) + .join(" "); +} + +function headerClass(align: "left" | "right", fit: boolean): string { + return [ + "sui-datatable__th", + `sui-datatable__th--${align}`, + fit ? "sui-datatable__th--fit" : "", + ] + .filter(Boolean) + .join(" "); +} diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 8b84af29f1..d71026d881 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -4,28 +4,20 @@ } .sui-dd__menu { - position: absolute; - top: calc(100% + var(--space-1)); + /* Positioned (fixed, portaled to ) entirely by the Menu component. */ min-width: 12rem; padding: var(--space-1); background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); - z-index: var(--z-dropdown); + z-index: var(--z-popover); animation: fadeInUp var(--motion-enter) both; display: flex; flex-direction: column; gap: 0.0625rem; } -.sui-dd__menu--start { - left: 0; -} -.sui-dd__menu--end { - right: 0; -} - .sui-dd__item { display: flex; align-items: center; diff --git a/frontend/editor/src/core/ui/Dropdown.tsx b/frontend/editor/src/core/ui/Dropdown.tsx index bf481c9ea5..25019d7c3c 100644 --- a/frontend/editor/src/core/ui/Dropdown.tsx +++ b/frontend/editor/src/core/ui/Dropdown.tsx @@ -6,12 +6,14 @@ import { useContext, useEffect, useId, + useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import "@app/ui/Dropdown.css"; type Alignment = "start" | "end"; @@ -20,6 +22,8 @@ interface DropdownContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; + /** The portaled menu element, so click-outside can exclude it. */ + menuRef: React.RefObject; menuId: string; align: Alignment; } @@ -68,15 +72,20 @@ function Root({ const triggerRef = useRef(null); const containerRef = useRef(null); + const menuRef = useRef(null); const menuId = useId(); - // Click-outside + Escape close. + // Click-outside + Escape close. The menu is portaled to , so it is not + // inside containerRef - check it separately or a click on it would close the + // menu before the item's handler runs. useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { + const target = e.target as Node; if ( containerRef.current && - !containerRef.current.contains(e.target as Node) + !containerRef.current.contains(target) && + !(menuRef.current && menuRef.current.contains(target)) ) { setOpen(false); } @@ -96,7 +105,7 @@ function Root({ }, [open, setOpen]); const value = useMemo( - () => ({ open, setOpen, triggerRef, menuId, align }), + () => ({ open, setOpen, triggerRef, menuRef, menuId, align }), [open, setOpen, menuId, align], ); @@ -150,23 +159,79 @@ export interface DropdownMenuProps { } function Menu({ children, className, width }: DropdownMenuProps) { - const { open, menuId, align } = useDropdownCtx(); - if (!open) return null; - const style = - width !== undefined + const { open, menuId, align, triggerRef, menuRef } = useDropdownCtx(); + // Fixed position tracked to the trigger. Portaling to keeps the menu + // out of any `overflow` ancestor (e.g. a table's horizontal scroll area), + // which would otherwise clip it and add a scrollbar. + const [pos, setPos] = useState<{ + top?: number; + bottom?: number; + left?: number; + right?: number; + maxHeight: number; + } | null>(null); + + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const el = triggerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const gap = 4; + const margin = 8; + const spaceBelow = window.innerHeight - r.bottom - margin; + const spaceAbove = r.top - margin; + // Flip above when there's more room there, so a trigger near the viewport + // bottom doesn't open a fixed menu that runs off-screen and can't scroll. + const below = spaceBelow >= spaceAbove; + const horizontal = + align === "end" + ? { right: window.innerWidth - r.right } + : { left: r.left }; + setPos({ + ...horizontal, + ...(below + ? { top: r.bottom + gap } + : { bottom: window.innerHeight - r.top + gap }), + maxHeight: Math.max(0, (below ? spaceBelow : spaceAbove) - gap), + }); + }; + place(); + // Track the trigger while scrolling/resizing (capture catches inner scrollers). + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open, align, triggerRef]); + + if (!open || !pos) return null; + const style: React.CSSProperties = { + position: "fixed", + // Explicit auto (not undefined) so the CSS fallback `top`/`left` can't leak + // in on the axis this placement isn't pinning. + top: pos.top ?? "auto", + bottom: pos.bottom ?? "auto", + left: pos.left ?? "auto", + right: pos.right ?? "auto", + maxHeight: pos.maxHeight, + overflowY: "auto", + ...(width !== undefined ? { minWidth: typeof width === "number" ? `${width}px` : width } - : undefined; - return ( + : {}), + }; + return createPortal( + , + document.body, ); } diff --git a/frontend/editor/src/core/ui/Table.css b/frontend/editor/src/core/ui/Table.css deleted file mode 100644 index fa89fd04d6..0000000000 --- a/frontend/editor/src/core/ui/Table.css +++ /dev/null @@ -1,73 +0,0 @@ -.sui-table-wrap { - width: 100%; - overflow-x: auto; -} - -.sui-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8125rem; -} - -/* Header text kept for assistive tech only, so a column of controls can be named - without putting a heading above it. Defined here rather than borrowing a global - utility, since the portal loads its own stylesheet. */ -.sui-table__th-sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - -.sui-table__th { - text-align: left; - font-weight: 600; - color: var(--c-text-subtle); - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0.625rem 0.875rem; - border-bottom: 1px solid var(--c-border); - white-space: nowrap; -} -.sui-table__th--right, -.sui-table__td--right { - text-align: right; -} -.sui-table__th--center, -.sui-table__td--center { - text-align: center; -} - -.sui-table__td { - padding: 0.625rem 0.875rem; - color: var(--c-text-muted); - border-bottom: 1px solid var(--c-border-subtle); - vertical-align: middle; -} -.sui-table tbody tr:last-child .sui-table__td { - border-bottom: none; -} - -.sui-table__row--interactive { - cursor: pointer; - transition: background var(--motion-fast); -} -.sui-table__row--interactive:hover { - background: var(--c-hover); -} -.sui-table__row--interactive:focus-visible { - outline: 0.125rem solid var(--c-primary); - outline-offset: -0.125rem; -} - -.sui-table__empty { - padding: 2rem; - text-align: center; - color: var(--c-text-subtle); -} diff --git a/frontend/editor/src/core/ui/Table.stories.tsx b/frontend/editor/src/core/ui/Table.stories.tsx deleted file mode 100644 index 2e7e0f64f1..0000000000 --- a/frontend/editor/src/core/ui/Table.stories.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Table, type TableColumn } from "@app/ui/Table"; -import { StatusBadge } from "@app/ui/StatusBadge"; - -interface Region { - id: string; - name: string; - code: string; - status: "healthy" | "degraded"; - docs: number; - latency: string; -} - -const REGIONS: Region[] = [ - { - id: "1", - name: "US East", - code: "us-east-1", - status: "healthy", - docs: 12481, - latency: "41 ms", - }, - { - id: "2", - name: "US West", - code: "us-west-2", - status: "healthy", - docs: 8210, - latency: "63 ms", - }, - { - id: "3", - name: "EU West", - code: "eu-west-1", - status: "degraded", - docs: 3044, - latency: "190 ms", - }, -]; - -const COLUMNS: TableColumn[] = [ - { key: "name", header: "Region", render: (r) => r.name }, - { - key: "code", - header: "Code", - render: (r) => ( - {r.code} - ), - }, - { - key: "status", - header: "Status", - render: (r) => ( - - {r.status} - - ), - }, - { - key: "docs", - header: "Docs 24h", - align: "right", - render: (r) => r.docs.toLocaleString(), - }, - { key: "latency", header: "P95", align: "right", render: (r) => r.latency }, -]; - -const meta: Meta = { - title: "Compound/Table", - component: Table, - tags: ["autodocs"], - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */ -export const Basic: Story = { - render: () => ( - columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> - ), -}; - -/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */ -export const Interactive: Story = { - render: () => ( - - columns={COLUMNS} - rows={REGIONS} - rowKey={(r) => r.id} - onRowClick={() => {}} - /> - ), -}; - -/** Empty body slot. */ -export const Empty: Story = { - render: () => ( - - columns={COLUMNS} - rows={[]} - rowKey={(r) => r.id} - empty="No regions deployed yet." - /> - ), -}; diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx deleted file mode 100644 index 417df3d6c1..0000000000 --- a/frontend/editor/src/core/ui/Table.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { ReactNode } from "react"; -import "@app/ui/Surface.css"; -import "@app/ui/Table.css"; - -export interface TableColumn { - /** Stable column id. */ - key: string; - header: ReactNode; - /** - * Hides the header visually but keeps it for assistive tech. For a trailing column of controls - * or chevrons, where a visible heading would be noise but a blank one leaves the cells below it - * unlabelled. - */ - headerHidden?: boolean; - /** Cell renderer for a row. */ - render: (row: T) => ReactNode; - align?: "left" | "right" | "center"; - /** Optional fixed/min width (any CSS length). */ - width?: string; -} - -export interface TableProps { - columns: TableColumn[]; - rows: T[]; - /** Stable key per row. */ - rowKey: (row: T) => string; - /** Makes rows interactive (hover + click + keyboard). */ - onRowClick?: (row: T) => void; - /** - * Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which - * this returns false is inert: no click/keyboard, and not announced as a button. Defaults to - * all rows interactive. - */ - isRowInteractive?: (row: T) => boolean; - /** - * Set when rows render controls of their own. The row keeps its click as a mouse shortcut but - * stops announcing itself as a button, because a button may not contain other controls and a - * {@code

      aK^R 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 literal 102114 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 diff --git a/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png index f12133f4f70b11f534d26eab1b50a886acea02b4..dab3d43aa399e597bfb2442da32d432020643875 100644 GIT binary patch literal 173332 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℘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 z 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≫L4_Rg_lvI#-cY>-;_SVw%ia+@hJHcnmTYd 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%0A+uPX*?MrAM#WIQ@iftH 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

    - - - {columns.map((c) => ( - - ))} - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.map((row) => { - const rowInteractive = - interactive && (isRowInteractive?.(row) ?? true); - // Only a row that owns the whole interaction takes the button role and the keyboard - // handling that goes with it; see rowsContainControls. - const rowIsControl = rowInteractive && !rowsContainControls; - return ( - onRowClick?.(row) : undefined} - tabIndex={rowIsControl ? 0 : undefined} - role={rowIsControl ? "button" : undefined} - onKeyDown={ - rowIsControl - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick?.(row); - } - } - : undefined - } - > - {columns.map((c) => ( - - ))} - - ); - }) - )} - -
    - {c.headerHidden ? ( - {c.header} - ) : ( - c.header - )} -
    - {empty ?? "No data"} -
    - {c.render(row)} -
    -
    - ); -} diff --git a/frontend/editor/src/core/ui/dataTableColumns.tsx b/frontend/editor/src/core/ui/dataTableColumns.tsx new file mode 100644 index 0000000000..df31dc384a --- /dev/null +++ b/frontend/editor/src/core/ui/dataTableColumns.tsx @@ -0,0 +1,569 @@ +import { Fragment, type ReactNode } from "react"; +import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge"; +import { Chip, type ChipAccent } from "@app/ui/Chip"; +import { Button } from "@app/ui/Button"; +import { Dropdown } from "@app/ui/Dropdown"; +import { ProgressBar } from "@app/ui/ProgressBar"; +import { Select, type SelectOption } from "@app/ui/Select"; + +/** + * The column vocabulary for {@link DataTable}. Call-sites pick a cell KIND and + * supply the data + semantics; the component owns 100% of the appearance. There + * is no raw-JSX / className escape hatch by design; a cell can only look the way + * the design system draws its kind, so every table looks and behaves the same. + */ + +type Align = "left" | "right"; +type SortValue = string | number | boolean | null | undefined; + +/** + * Which built-in comparator sorts a column. Set by the builder from the cell's + * data type - `alphanumeric` (case-insensitive, natural: `v2` before `v10`) for + * text, `basic` (raw numeric) for numbers. Call-sites never choose this. + */ +export type DataTableSortFn = "alphanumeric" | "basic"; + +/** Opaque, fully-resolved column. Produced only by the {@link column} builders. */ +export interface DataTableColumn { + key: string; + header: ReactNode; + align: Align; + /** Prevent wrapping (mono/number values). */ + nowrap: boolean; + /** Shrink the column to its content (actions / affordances). */ + fit: boolean; + sortable: boolean; + sortValue?: (row: T) => SortValue; + /** Comparator kind, derived from the cell type. Only set when sortable. */ + sortFn?: DataTableSortFn; + /** Cell renders its own interactive control (button/link/select/chip). Rows + * containing one drop their `role="button"` so a button never nests inside a + * button - the control is the keyboard path instead. */ + interactive?: boolean; + /** Internal, design-system-owned renderer. Call-sites never supply this. */ + renderCell: (row: T) => ReactNode; +} + +/** The only design-system glyph a cell may use (icon-only actions). */ +export type CellGlyph = "kebab"; + +function KebabGlyph() { + return ( + + + + + + ); +} + +/** An item in a kebab action menu. */ +export interface CellMenuItem { + label: string; + tone?: "default" | "danger"; + disabled?: boolean; + onClick: () => void; + /** Draw a divider above this item. */ + dividerBefore?: boolean; +} + +/** A row/group action. A locked button, or a kebab menu when `menu` is set. */ +export interface CellAction { + label: string; + glyph?: CellGlyph; + /** Icon-only (uses `label` as the accessible name). */ + iconOnly?: boolean; + tone?: "default" | "danger"; + onClick?: () => void; + loading?: boolean; + disabled?: boolean; + /** When set, the button opens this menu instead of firing `onClick`. */ + menu?: CellMenuItem[]; +} + +/** Renders a row of locked action buttons / kebab menus. Shared by the + * `actions` cell kind and grouped-table headers. */ +export function renderCellActions(actions: CellAction[]): ReactNode { + return ( +
    e.stopPropagation()}> + {actions.map((a) => + a.menu ? ( + + + + + + {a.menu.map((m) => ( + + {m.dividerBefore && } + + {m.label} + + + ))} + + + ) : ( + + ), + )} +
    + ); +} + +/** An external link inside a cell. */ +export interface CellLink { + label: string; + href: string; + ariaLabel?: string; +} + +interface Common { + key: string; + header: ReactNode; + sortable?: boolean; +} + +function base( + o: Common, + extra: Pick, "align" | "nowrap" | "fit" | "renderCell"> & { + sortValue?: (row: T) => SortValue; + sortFn?: DataTableSortFn; + interactive?: boolean; + }, +): DataTableColumn { + return { + key: o.key, + header: o.header, + align: extra.align, + nowrap: extra.nowrap, + fit: extra.fit, + sortable: !!o.sortable, + sortValue: o.sortable ? extra.sortValue : undefined, + sortFn: o.sortable ? extra.sortFn : undefined, + interactive: extra.interactive, + renderCell: extra.renderCell, + }; +} + +function text( + o: Common & { + get: (row: T) => string; + /** Optional bold label rendered before the value as "Label: value". */ + label?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const label = o.label?.(r); + return label ? ( + + {label}: {o.get(r)} + + ) : ( + {o.get(r)} + ); + }, + }); +} + +function mono( + o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => {o.get(r)}, + }); +} + +function muted( + o: Common & { + get: (row: T) => string | null | undefined; + placeholder?: string; + /** Override the sort key (e.g. an ISO date behind a "3 days ago" label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "alphanumeric", + renderCell: (r) => ( + + {o.get(r) || (o.placeholder ?? "-")} + + ), + }); +} + +function number( + o: Common & { + get: (row: T) => number | null | undefined; + format?: (n: number, row: T) => string; + placeholder?: string; + /** Override the sort key (e.g. a raw count behind a formatted label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "right", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "basic", + renderCell: (r) => { + const n = o.get(r); + if (n == null) { + return ( + + {o.placeholder ?? "-"} + + ); + } + return ( + + {o.format ? o.format(n, r) : String(n)} + + ); + }, + }); +} + +function badge( + o: Common & { + get: (row: T) => { tone: StatusTone; label: string }; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r).label), + sortFn: "alphanumeric", + renderCell: (r) => { + const b = o.get(r); + return ( + + {b.label} + + ); + }, + }); +} + +/** + * A user-defined label, rendered as a dot-less pill. Use this ONLY for labels + * that come from data / the user (e.g. a document's classification). Values from + * a fixed set we define (types, environments, providers) are `text`, not pills. + */ +export interface CellLabel { + label: string; + accent?: ChipAccent; +} + +function labels( + o: Common & { get: (row: T) => CellLabel[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: (r) => o.get(r)[0]?.label ?? undefined, + sortFn: "alphanumeric", + renderCell: (r) => ( +
    + {o.get(r).map((l) => ( + + {l.label} + + ))} +
    + ), + }); +} + +/** + * An interactive capability chip: click to grant, remove to revoke, dashed to + * offer adding. A functional cell (it toggles state), distinct from static + * `labels`. + */ +export interface CellCap { + label: string; + accent?: ChipAccent; + onClick?: () => void; + onRemove?: () => void; + dashed?: boolean; +} + +function caps( + o: Common & { get: (row: T) => CellCap[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + interactive: true, + renderCell: (r) => ( +
    + {o.get(r).map((c) => ( + + {c.label} + + ))} +
    + ), + }); +} + +function entity( + o: Common & { + /** Semantic leading icon (component owns its size + colour container). */ + icon?: (row: T) => ReactNode; + primary: (row: T) => string; + /** Muted inline suffix after the name, its own node (e.g. "(you)"). */ + suffix?: (row: T) => string | null | undefined; + /** Secondary muted line under the name. */ + note?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.primary(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const icon = o.icon?.(r); + const suffix = o.suffix?.(r); + const note = o.note?.(r); + return ( +
    + {icon != null && ( + + {icon} + + )} +
    + + {o.primary(r)} + {suffix && ( + {suffix} + )} + + {note && {note}} +
    +
    + ); + }, + }); +} + +function actions(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellAction[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => renderCellActions(o.get(r)), + }; +} + +function progress( + o: Common & { + get: (row: T) => { value: number; label?: string }; + /** Accessible name for the bar (it has no visible text). Defaults to the + * shown percent; pass a description like "Load for us-east-1" when useful. */ + ariaLabel?: (row: T) => string; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: (r) => o.get(r).value, + sortFn: "basic", + renderCell: (r) => { + const p = o.get(r); + const shown = p.label ?? `${Math.round(p.value * 100)}%`; + return ( +
    + + + + {shown} +
    + ); + }, + }); +} + +function links(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellLink[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => ( +
    + ), + }; +} + +function select(o: { + key: string; + header: ReactNode; + get: (row: T) => { + value?: string | null; + defaultValue?: string; + options: SelectOption[]; + ariaLabel?: string; + disabled?: boolean; + }; + /** Omit for an uncontrolled select (local UI state only). */ + onChange?: (row: T, value: string | null) => void; +}): DataTableColumn { + return { + key: o.key, + header: o.header, + align: "left", + nowrap: true, + fit: false, + sortable: false, + interactive: true, + renderCell: (r) => { + const s = o.get(r); + const change = o.onChange; + return ( +
    + onChangeRole(m, (value ?? m.role) as RoleId)} - /> -
    - )} + const groups = useMemo[]>(() => { + function ownerNames(owners: string[]): string { + return owners.map((u) => nameByUsername.get(u) ?? u).join(", "); + } + // A team whose name/membership is system-managed - no rename/delete. + function isManagedTeam(team: TeamGroup): boolean { + return SYSTEM_TEAMS.has(team.name) || team.isPersonal === true; + } + function teamKebabHasItems(team: TeamGroup): boolean { + return ( + capabilities.manageGrants || + (!isManagedTeam(team) && + (capabilities.renameTeam || capabilities.deleteTeam)) + ); + } + function teamActions(team: TeamGroup): CellAction[] { + const acts: CellAction[] = [ + { + label: t("users.group.addToTeam", "Add to team"), + onClick: () => onAddToTeam(team), + }, + ]; + if (teamKebabHasItems(team)) { + const items: CellMenuItem[] = []; + if (capabilities.manageGrants) { + items.push( + processorTeamIds.has(team.id) + ? { + label: t( + "users.team.revokeProcessor", + "Revoke Processor from team", + ), + onClick: () => onRevokeTeamProcessor(team), + } + : { + label: t( + "users.team.grantProcessor", + "Grant Processor to team", + ), + onClick: () => onGrantTeamProcessor(team), + }, + ); + } + if (!isManagedTeam(team)) { + const divider = capabilities.manageGrants; + if (capabilities.renameTeam) { + items.push({ + label: t("users.action.rename", "Rename team"), + onClick: () => onRenameTeam(team), + dividerBefore: divider, + }); + } + if (capabilities.deleteTeam) { + items.push({ + label: t("users.action.deleteTeam", "Delete team"), + tone: "danger", + onClick: () => onDeleteTeam(team), + dividerBefore: divider && !capabilities.renameTeam, + }); + } + } + acts.push({ + label: t("users.teamActions", "Team actions"), + glyph: "kebab", + iconOnly: true, + menu: items, + }); + } + return acts; + } - {rowKebab(m)} -
    - ); - } - - /** Rows for a group, collapsing past COLLAPSED_LIMIT behind a toggle. */ - function renderMembers(list: Member[], key: string) { - const isOpen = expanded.has(key); - const overflow = list.length > COLLAPSED_LIMIT; - const shown = overflow && !isOpen ? list.slice(0, COLLAPSED_LIMIT) : list; - return ( - <> - {shown.map(renderRow)} - {overflow && ( - - )} - - ); - } + const gs: DataTableGroup[] = []; + if (capabilities.orgGroup && dir.organization.length > 0) { + gs.push({ + key: "org", + title: t("users.group.org", "Organization"), + meta: t("users.group.owners", "{{count}} owner", { + count: dir.organization.length, + }), + rows: dir.organization, + collapseAfter: COLLAPSED_LIMIT, + }); + } + for (const team of dir.teams) { + const led = + team.owners.length > 0 + ? ` · ${t("users.group.ledBy", "led by {{owner}}", { + owner: ownerNames(team.owners), + })}` + : ""; + gs.push({ + key: `team-${team.id}`, + title: t("users.group.team", "{{name}} team", { name: team.name }), + meta: + t("users.group.teamMeta", "{{count}} people", { + count: team.members.length, + }) + led, + actions: teamActions(team), + rows: team.members, + collapseAfter: COLLAPSED_LIMIT, + }); + } + if (showGuests && dir.guests.length > 0) { + gs.push({ + key: "guests", + title: t("users.group.guests", "Guests"), + meta: t("users.group.guestCount", "{{count}} guest", { + count: dir.guests.length, + }), + rows: dir.guests, + collapseAfter: COLLAPSED_LIMIT, + }); + } + return gs; + }, [ + t, + dir, + nameByUsername, + capabilities, + showGuests, + processorTeamIds, + onAddToTeam, + onGrantTeamProcessor, + onRevokeTeamProcessor, + onRenameTeam, + onDeleteTeam, + ]); return ( -
    - {/* Organization (a single-org deployment only; SaaS has no org). */} - {capabilities.orgGroup && dir.organization.length > 0 && ( -
    -
    -
    - {t("users.group.org", "Organization")} - - {t( - "users.group.orgDesc", - "Owners with org-wide authority and policy approval", - )} - -
    - - {t("users.group.owners", "{{count}} owner", { - count: dir.organization.length, - })} - -
    - {renderMembers(dir.organization, "org")} -
    - )} - - {/* Teams */} - {dir.teams.map((team) => ( -
    -
    -
    - - {t("users.group.team", "{{name}} team", { name: team.name })} - - - {t("users.group.teamMeta", "{{count}} people", { - count: team.members.length, - })} - {team.owners.length > 0 && - ` · ${t("users.group.ledBy", "led by {{owner}}", { - owner: ownerNames(team.owners), - })}`} - -
    -
    - - {teamKebabHasItems(team) && ( - - - - - - {capabilities.manageGrants && - (processorTeamIds.has(team.id) ? ( - onRevokeTeamProcessor(team)}> - {t( - "users.team.revokeProcessor", - "Revoke Processor from team", - )} - - ) : ( - onGrantTeamProcessor(team)}> - {t( - "users.team.grantProcessor", - "Grant Processor to team", - )} - - ))} - {!isManagedTeam(team) && - (capabilities.renameTeam || capabilities.deleteTeam) && ( - <> - {capabilities.manageGrants && } - {capabilities.renameTeam && ( - onRenameTeam(team)}> - {t("users.action.rename", "Rename team")} - - )} - {capabilities.deleteTeam && ( - onDeleteTeam(team)} - > - {t("users.action.deleteTeam", "Delete team")} - - )} - - )} - - - )} -
    -
    - {renderMembers(team.members, `team-${team.id}`)} -
    - ))} - - {/* Guests (parked in the live app; shown when showGuests is set). */} - {showGuests && dir.guests.length > 0 && ( -
    -
    -
    - {t("users.group.guests", "Guests")} - - {t( - "users.group.guestsDesc", - "External collaborators, scoped to what you shared. Editor only.", - )} - -
    - - {t("users.group.guestCount", "{{count}} guest", { - count: dir.guests.length, - })} - -
    - {renderMembers(dir.guests, "guests")} -
    - )} -
    + + columns={columns} + groups={groups} + rowKey={(m) => String(m.id)} + collapseLabels={{ + showAll: (count) => t("users.showAll", "Show all {{count}}", { count }), + showLess: t("users.showLess", "Show less"), + }} + /> ); } diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index c5e96d8666..8c4e684bfa 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -68,39 +68,35 @@ describe("Integrations view", () => { ).toBeInTheDocument(); }); - it("groups connections of the same type and expands to the instances", async () => { + it("groups connections of the same type, instances shown as rows (no expand)", async () => { fetchIntegrations.mockResolvedValue([ bucket(1, "Claims"), bucket(2, "Archive"), ]); render(); - // One connected group row for S3 with the instance count, not two rows. - const group = await screen.findByText( - "portal.integrations.connectionCount", - ); - expect(group).toBeInTheDocument(); - - fireEvent.click(screen.getByText("portal.connections.types.s3.label")); + // Instances are rows directly under the S3 vendor group - no expand click. expect(await screen.findByText("Claims")).toBeInTheDocument(); expect(screen.getByText("Archive")).toBeInTheDocument(); + // Vendor group header shows the instance count and the "add another" action. expect( - screen.getByText("portal.integrations.addAnother"), + screen.getByText("portal.integrations.connectionCount"), ).toBeInTheDocument(); + // Each connected vendor group offers a Connect action (to add another). + expect( + screen.getAllByText("portal.integrations.connect").length, + ).toBeGreaterThan(0); // The available band remains for the other, unconnected vendors. expect( screen.getByText(/portal\.integrations\.availableHeading/), ).toBeInTheDocument(); }); - it("deletes an instance from the expanded group", async () => { + it("deletes an instance directly from its row", async () => { fetchIntegrations.mockResolvedValueOnce([bucket(5, "Claims")]); fetchIntegrations.mockResolvedValueOnce([]); render(); - fireEvent.click( - await screen.findByText("portal.connections.types.s3.label"), - ); fireEvent.click(await screen.findByText("portal.connections.delete")); await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5)); @@ -115,9 +111,6 @@ describe("Integrations view", () => { ); render(); - fireEvent.click( - await screen.findByText("portal.connections.types.s3.label"), - ); fireEvent.click(await screen.findByText("portal.connections.delete")); expect( diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 993583a364..793c21f880 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -2,8 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; -import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded"; -import { Banner, Button, Skeleton } from "@app/ui"; +import { + Banner, + Button, + column, + DataTable, + type DataTableColumn, + type DataTableGroup, + EmptyState, +} from "@app/ui"; import { errorMessage } from "@portal/api/http"; import { deleteIntegration, @@ -30,12 +37,11 @@ import "@portal/views/Integrations.css"; /** * The integrations catalogue: everything Stirling can talk to, in one place. * - * Three bands in one list. Connected first — stored connections grouped by - * vendor, expandable when a vendor has several (two S3 buckets is normal, not - * an error), each instance editable and one click from "add another". Then - * Available — the supported vendors, each saying what it works with (sources, - * policies, pipelines) so it's obvious whether a vendor feeds documents in or - * receives them. Coming-soon source connectors close the list greyed out, so + * Three bands, one grouped table. Connected first - stored connections grouped + * by vendor (two S3 buckets is normal, not an error), every instance a row you + * can edit or remove, with "add another" on the vendor's group header. Then + * Available - the supported vendors, each saying what it works with (sources, + * policies, pipelines). Coming-soon source connectors close the list so * "do you support X?" is answered honestly instead of hidden. * * Setup itself stays in the shared {@link ConnectionModal}; every entry point @@ -72,6 +78,20 @@ interface TypeGroup { connections: IntegrationConfig[]; } +/** One normalized row across the three bands, so a single grouped table renders + * connected instances, available vendors, and coming-soon vendors alike. */ +type IntegrationRow = { + key: string; + brandId: string; + title: string; + subtitle: string; + worksWith: WorksWith[]; +} & ( + | { kind: "instance"; connection: IntegrationConfig; canManage: boolean } + | { kind: "available"; typeId: string } + | { kind: "soon" } +); + export function Integrations() { const { t } = useTranslation(); const [connections, setConnections] = useState( @@ -82,13 +102,13 @@ export function Integrations() { >(undefined); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); - const [expanded, setExpanded] = useState>(new Set()); const [modal, setModal] = useState<{ open: boolean; editing: IntegrationConfig | null; fixedTypeId?: string; }>({ open: false, editing: null }); const [busy, setBusy] = useState(false); + const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -192,45 +212,152 @@ export function Integrations() { return counts; }, [catalogue]); - function toggleExpand(typeId: string) { - setExpanded((current) => { - const next = new Set(current); - if (next.has(typeId)) next.delete(typeId); - else next.add(typeId); - return next; - }); - } - - function openCreate(typeId: string) { + const openCreate = useCallback((typeId: string) => { setModal({ open: true, editing: null, fixedTypeId: typeId }); - } + }, []); - function openEdit(connection: IntegrationConfig) { + const openEdit = useCallback((connection: IntegrationConfig) => { setModal({ open: true, editing: connection }); - } + }, []); - async function remove(connection: IntegrationConfig) { - if (busy) return; - setBusy(true); - setError(null); - try { - await deleteIntegration(connection.id); - await refresh(); - } catch (e) { - setError(errorMessage(e)); - } finally { - setBusy(false); - } - } + const remove = useCallback( + async (connection: IntegrationConfig) => { + if (busy) return; + setBusy(true); + setDeletingId(connection.id); + setError(null); + try { + await deleteIntegration(connection.id); + await refresh(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + setDeletingId(null); + } + }, + [busy, refresh], + ); const isLoading = connections === null; - const chip = (kind: WorksWith) => ( - - {t(`portal.integrations.worksWith.${kind}`)} - + const worksWithText = useCallback( + (list: WorksWith[]) => + list.map((w) => t(`portal.integrations.worksWith.${w}`)).join(", "), + [t], ); + const columns = useMemo[]>( + () => [ + column.entity({ + key: "integration", + header: t("portal.integrations.table.integration"), + icon: (r) => , + primary: (r) => r.title, + note: (r) => r.subtitle || undefined, + }), + column.text({ + key: "worksWith", + header: t("portal.integrations.table.worksWith"), + get: (r) => worksWithText(r.worksWith), + }), + column.actions({ + key: "actions", + get: (r) => { + if (r.kind === "instance") { + return r.canManage + ? [ + { + label: t("portal.connections.edit"), + disabled: busy, + onClick: () => openEdit(r.connection), + }, + { + label: t("portal.connections.delete"), + tone: "danger", + loading: busy && deletingId === r.connection.id, + disabled: busy, + onClick: () => void remove(r.connection), + }, + ] + : []; + } + if (r.kind === "available") { + return [ + { + label: t("portal.integrations.connect"), + onClick: () => openCreate(r.typeId), + }, + ]; + } + return []; + }, + }), + ], + [t, busy, deletingId, remove, openEdit, openCreate, worksWithText], + ); + + const tableGroups = useMemo[]>(() => { + const gs: DataTableGroup[] = []; + for (const { type, connections: list } of connectedGroups) { + gs.push({ + key: `connected-${type.id}`, + title: t(type.labelKey), + meta: + list.length > 1 + ? t("portal.integrations.connectionCount", { count: list.length }) + : t("portal.integrations.status.connected"), + actions: [ + { + label: t("portal.integrations.connect"), + onClick: () => openCreate(type.id), + }, + ], + rows: list.map((c) => ({ + kind: "instance" as const, + key: `i-${c.id}`, + brandId: type.id, + title: c.name, + subtitle: connectionDetail(c), + worksWith: worksWith(type), + connection: c, + canManage: !!c.canManage, + })), + }); + } + if (availableTypes.length > 0) { + gs.push({ + key: "available", + title: t("portal.integrations.availableHeading"), + rows: availableTypes.map((type) => ({ + kind: "available" as const, + key: `a-${type.id}`, + brandId: type.id, + title: t(type.labelKey), + subtitle: t(type.descriptionKey), + worksWith: worksWith(type), + typeId: type.id, + })), + }); + } + if (comingSoon.length > 0) { + gs.push({ + key: "soon", + title: t("portal.integrations.comingSoonHeading"), + muted: true, + rows: comingSoon.map((entry) => ({ + kind: "soon" as const, + key: `s-${entry.type}`, + brandId: entry.type, + title: t(entry.labelKey), + subtitle: t(entry.descriptionKey), + worksWith: ["sources"], + })), + }); + } + return gs; + }, [connectedGroups, availableTypes, comingSoon, t, openCreate]); + return (
    @@ -301,187 +428,23 @@ export function Integrations() { {error && } - {isLoading ? ( -
    - {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
    + {!isLoading && tableGroups.length === 0 ? ( + ) : ( -
    -
    - {t("portal.integrations.table.integration")} - {t("portal.integrations.table.worksWith")} - -
    - - {connectedGroups.length > 0 && ( -
    - {t("portal.integrations.connectedHeading")} ·{" "} - {connectedGroups.length} -
    - )} - {connectedGroups.map(({ type, connections: list }) => { - const open = expanded.has(type.id); - return ( -
    - - {open && ( -
    - {list.map((connection) => ( -
    - - {connection.name} - - - {connectionDetail(connection)} - - {connection.canManage && ( - - - - - )} -
    - ))} -
    - -
    -
    - )} -
    - ); - })} - - {availableTypes.length > 0 && ( -
    - {t("portal.integrations.availableHeading")} ·{" "} - {availableTypes.length} -
    - )} - {availableTypes.map((type) => ( -
    - - - - - {t(type.labelKey)} - - - {t(type.descriptionKey)} - - - - - {worksWith(type).map(chip)} - - - - -
    - ))} - - {comingSoon.length > 0 && ( -
    - {t("portal.integrations.comingSoonHeading")} · {comingSoon.length} -
    - )} - {comingSoon.map((entry) => ( -
    - - - - - {t(entry.labelKey)} - - - {t(entry.descriptionKey)} - - - - - {chip("sources")} - - - - {t("portal.sources.builder.comingSoon")} - - -
    - ))} -
    + + columns={columns} + groups={tableGroups} + rowKey={(r) => r.key} + loading={isLoading} + skeletonRows={5} + /> )} DELETE /invitations/{id} -> refetch drops the invite. fireEvent.click( diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 535bf3e627..79c6c11611 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton } from "@app/ui"; @@ -71,12 +71,14 @@ export function Users() { }, [searchParams, setSearchParams]); // Scroll to and flash the row for ?member= (deep link from the super - // search), once the roster has rendered; then strip the param. + // search), once the roster has rendered; then strip the param. Scoped to the + // roster so a pending-invitation row sharing the id can't match first. + const rosterRef = useRef(null); useEffect(() => { const memberId = searchParams.get("member"); if (memberId === null || usersState.loading) return; - const row = document.querySelector( - `[data-member-id="${CSS.escape(memberId)}"]`, + const row = rosterRef.current?.querySelector( + `[data-row-key="${CSS.escape(memberId)}"]`, ); if (row) { row.scrollIntoView({ block: "center" }); @@ -343,28 +345,30 @@ export function Users() { )} {!loading && members.length > 0 && ( - openInvite(team.id)} - onResetPassword={setResetPwMember} - onMoveToTeam={setMoveMember} - onToggleEnabled={toggleEnabled} - onUnlock={unlock} - onDisableMfa={disableMfa} - onRemove={removeUser} - onRenameTeam={(team) => - setRenameTarget({ id: team.id, name: team.name }) - } - onDeleteTeam={deleteTeamAction} - /> +
    + openInvite(team.id)} + onResetPassword={setResetPwMember} + onMoveToTeam={setMoveMember} + onToggleEnabled={toggleEnabled} + onUnlock={unlock} + onDisableMfa={disableMfa} + onRemove={removeUser} + onRenameTeam={(team) => + setRenameTarget({ id: team.id, name: team.name }) + } + onDeleteTeam={deleteTeamAction} + /> +
    )} =20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", @@ -4953,6 +4992,32 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3fd1f8e106..90a0e10b05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-query": "^5.101.4", + "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0", From 526bb85e17e5224f264af8d891399675291268f1 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:00:35 +0000 Subject: [PATCH 211/262] Translate the failures debug panel strings (#7500) Follow-up to #7296, addressing a missing translation. --- .../public/locales/en-US/translation.toml | 8 ++++++++ .../components/failures/FileRunEventList.tsx | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 99d68c35de..70d3d03bf4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7372,6 +7372,14 @@ confirm = "Are you sure?" dismiss = "Dismiss" dismissSkipFile = "Skip this file" +[portal.failures.debug] +copyJson = "Copy JSON" +dismissAll = "Dismiss all ({{total}})" +dismissing = "Dismissing..." +hideJson = "Hide raw JSON ({{total}})" +refresh = "Refresh failures" +showJson = "Show raw JSON ({{total}})" + [portal.failures.disabled] closed = "This failure is already closed." unavailable = "Not available for this failure." diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx index 477af32e50..2330546655 100644 --- a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx @@ -69,7 +69,7 @@ export function FileRunEventList() { const debugPanel = !import.meta.env.DEV ? null : (
    {showJson && (
    
    From 89d8ffec5d1266251b7feeb05a3d998a6d5c747f Mon Sep 17 00:00:00 2001
    From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
    Date: Mon, 17 Aug 2026 17:49:13 +0000
    Subject: [PATCH 212/262] Ci/environments cleanups, new envs and master to
     release naming (#7511)
    
    # Description of Changes
    
    Ci/environments cleanups, new envs and master to release naming
    
    ---
    
    ## Checklist
    
    ### General
    
    - [ ] I have read the [Contribution
    Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
    - [ ] I have read the [Stirling-PDF Developer
    Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
    (if applicable)
    - [ ] I have read the [How to add new languages to
    Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
    (if applicable)
    - [ ] I have performed a self-review of my own code
    - [ ] My changes generate no new warnings
    
    ### Documentation
    
    - [ ] I have updated relevant docs on [Stirling-PDF's doc
    repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
    (if functionality has heavily changed)
    - [ ] I have read the section [Add New Translation
    Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
    (for new translation tags only)
    
    ### Translations (if applicable)
    
    - [ ] I ran
    [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
    
    ### UI Changes (if applicable)
    
    - [ ] Screenshots or videos demonstrating the UI changes are attached
    (e.g., as comments or direct attachments in the PR)
    
    ### Testing (if applicable)
    
    - [ ] I have run `task check` to verify linters, typechecks, and tests
    pass
    - [ ] I have tested my changes locally. Refer to the [Testing
    Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
    for more details.
    ---
     .github/config/.files.yaml                    |   1 -
     .github/workflows/PR-Demo-cleanup.yml         |   4 -
     .github/workflows/ai_pr_title_review.yml      | 221 ----------------
     .github/workflows/backend-build.yml           |   1 +
     .github/workflows/build-enterprise.yml        |   2 +
     .github/workflows/build.yml                   |   1 +
     .github/workflows/check-licence.yml           |   1 +
     .github/workflows/check-openapi.yml           |   1 +
     .github/workflows/db-migration-test.yml       |   1 +
     .github/workflows/deploy-on-v2-commit.yml     | 209 ----------------
     .github/workflows/docker-compose-tests.yml    |   1 +
     .github/workflows/e2e-live.yml                |   1 +
     .../frontend-backend-licenses-update.yml      |   4 +
     .github/workflows/multiOSReleases.yml         |  63 ++---
     .github/workflows/nightly.yml                 |   1 +
     .github/workflows/push-docker-base.yml        |   3 +
     .github/workflows/push-docker.yml             |  29 ++-
     .github/workflows/swagger.yml                 |   5 +-
     .github/workflows/tauri-build.yml             |  42 +---
     .github/workflows/test-build-docker.yml       |   1 +
     .github/workflows/testdriver.yml              | 235 ------------------
     WINDOWS_SIGNING.md                            |  71 +++---
     .../editor/src/core/services/updateService.ts |   2 +-
     23 files changed, 95 insertions(+), 805 deletions(-)
     delete mode 100644 .github/workflows/ai_pr_title_review.yml
     delete mode 100644 .github/workflows/deploy-on-v2-commit.yml
     delete mode 100644 .github/workflows/testdriver.yml
    
    diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml
    index 70a964b020..b5cc0527b0 100644
    --- a/.github/config/.files.yaml
    +++ b/.github/config/.files.yaml
    @@ -68,7 +68,6 @@ project: &project
     frontend: &frontend
       - *ci
       - frontend/**
    -  - .github/workflows/testdriver.yml
       - testing/**
       - docker/**
       - scripts/translations/*.py
    diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml
    index e0032955e3..1407939994 100644
    --- a/.github/workflows/PR-Demo-cleanup.yml
    +++ b/.github/workflows/PR-Demo-cleanup.yml
    @@ -7,10 +7,6 @@ on:
     permissions:
       contents: read
     
    -env:
    -  SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
    -  CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
    -
     jobs:
       cleanup:
         environment: pr-preview
    diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml
    deleted file mode 100644
    index b9b391af0e..0000000000
    --- a/.github/workflows/ai_pr_title_review.yml
    +++ /dev/null
    @@ -1,221 +0,0 @@
    -name: AI - PR Title Review
    -
    -on:
    -  pull_request:
    -    types: [opened, edited]
    -    branches: [main]
    -
    -permissions: # required for secure-repo hardening
    -  contents: read
    -
    -jobs:
    -  ai-title-review:
    -    # GITHUB_TOKEN obeys this block, so it must cover every API call made below.
    -    permissions:
    -      contents: read # actions/checkout, git fetch/diff
    -      issues: write # issues.listComments / createComment / updateComment on the PR
    -      pull-requests: write # same endpoints when the target is a pull request
    -      models: read # actions/ai-inference
    -
    -    runs-on: ubuntu-latest
    -
    -    steps:
    -      - name: Harden Runner
    -        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
    -        with:
    -          egress-policy: audit
    -
    -      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
    -        with:
    -          fetch-depth: 0
    -
    -      - name: Configure Git to suppress detached HEAD warning
    -        run: git config --global advice.detachedHead false
    -
    -      - name: Check if actor is repo developer
    -        id: actor
    -        run: |
    -          if [[ "${{ github.actor }}" == *"[bot]" ]]; then
    -            echo "PR opened by a bot – skipping AI title review."
    -            echo "is_repo_dev=false" >> $GITHUB_OUTPUT
    -            exit 0
    -          fi
    -          if [ ! -f .github/config/repo_devs.json ]; then
    -            echo "Error: .github/config/repo_devs.json not found" >&2
    -            exit 1
    -          fi
    -          # Validate JSON and extract repo_devs
    -          REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
    -          # Convert developer list into Bash array
    -          mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
    -          if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
    -            echo "is_repo_dev=true" >> $GITHUB_OUTPUT
    -          else
    -            echo "is_repo_dev=false" >> $GITHUB_OUTPUT
    -          fi
    -
    -      - name: Get PR diff
    -        if: steps.actor.outputs.is_repo_dev == 'true'
    -        id: get_diff
    -        run: |
    -          git fetch origin ${{ github.base_ref }}
    -          git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
    -          echo "diff<> $GITHUB_OUTPUT
    -          cat pr.diff >> $GITHUB_OUTPUT
    -          echo "EOF" >> $GITHUB_OUTPUT
    -
    -      - name: Check and sanitize PR title
    -        if: steps.actor.outputs.is_repo_dev == 'true'
    -        id: sanitize_pr_title
    -        env:
    -          PR_TITLE_RAW: ${{ github.event.pull_request.title }}
    -        run: |
    -          # Sanitize PR title: max 72 characters, only printable characters
    -          PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
    -          if [[ ${#PR_TITLE} -lt 5 ]]; then
    -            echo "PR title is too short. Must be at least 5 characters." >&2
    -          fi
    -          echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
    -
    -      - name: AI PR Title Analysis
    -        if: steps.actor.outputs.is_repo_dev == 'true'
    -        id: ai-title-analysis
    -        uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
    -        with:
    -          model: openai/gpt-4o
    -          system-prompt-file: ".github/config/system-prompt.txt"
    -          prompt: |
    -            Based on the following input data:
    -
    -            {
    -              "diff": "${{ steps.get_diff.outputs.diff }}",
    -              "pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
    -            }
    -
    -            Respond ONLY with valid JSON in the format:
    -            {
    -              "improved_rating": <0-10>,
    -              "improved_ai_title_rating": <0-10>,
    -              "improved_title": ""
    -            }
    -
    -      - name: Validate and set SCRIPT_OUTPUT
    -        if: steps.actor.outputs.is_repo_dev == 'true'
    -        run: |
    -          cat < ai_response.json
    -          ${{ steps.ai-title-analysis.outputs.response }}
    -          EOF
    -
    -          # Validate JSON structure
    -          jq -e '
    -            (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
    -            (.improved_rating | type == "number" and . >= 0 and . <= 10) and
    -            (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
    -            (.improved_title | type == "string")
    -          ' ai_response.json
    -          if [ $? -ne 0 ]; then
    -            echo "Invalid AI response format" >&2
    -            cat ai_response.json >&2
    -            exit 1
    -          fi
    -          # Parse JSON fields
    -          IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
    -          IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
    -          # Limit comment length to 1000 characters
    -          COMMENT=$(cat < /tmp/ai-title-comment.md
    -          # Log input and output to the GitHub Step Summary
    -          echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
    -          echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
    -          echo '```bash' >> $GITHUB_STEP_SUMMARY
    -          echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
    -          echo '```' >> $GITHUB_STEP_SUMMARY
    -          echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
    -          echo '```json' >> $GITHUB_STEP_SUMMARY
    -          cat ai_response.json >> $GITHUB_STEP_SUMMARY
    -          echo '```' >> $GITHUB_STEP_SUMMARY
    -
    -      - name: Post comment on PR if needed
    -        if: steps.actor.outputs.is_repo_dev == 'true'
    -        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
    -        continue-on-error: true
    -        with:
    -          github-token: ${{ github.token }}
    -          script: |
    -            const fs = require('fs');
    -            const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
    -            const { GITHUB_REPOSITORY } = process.env;
    -            const [owner, repo] = GITHUB_REPOSITORY.split('/');
    -            const issue_number = context.issue.number;
    -
    -            const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
    -            const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
    -
    -            const expectedActor = "github-actions[bot]";
    -            const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
    -
    -            const existing = comments.data.find(c =>
    -              c.user?.login === expectedActor &&
    -              c.body.includes("## 🤖 AI PR Title Suggestion")
    -            );
    -
    -            if (rating === null) {
    -              console.log("No rating found in AI response – skipping.");
    -              return;
    -            }
    -
    -            if (rating <= 5) {
    -              if (existing) {
    -                await github.rest.issues.updateComment({
    -                  owner, repo,
    -                  comment_id: existing.id,
    -                  body
    -                });
    -                console.log("Updated existing suggestion comment.");
    -              } else {
    -                await github.rest.issues.createComment({
    -                  owner, repo, issue_number,
    -                  body
    -                });
    -                console.log("Created new suggestion comment.");
    -              }
    -            } else {
    -              const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
    -
    -              if (existing) {
    -                await github.rest.issues.updateComment({
    -                  owner, repo,
    -                  comment_id: existing.id,
    -                  body: praise
    -                });
    -                console.log("Replaced suggestion with praise.");
    -              } else {
    -                console.log("Rating > 5 and no existing comment – skipping comment.");
    -              }
    -            }
    -
    -      - name: is not repo dev
    -        if: steps.actor.outputs.is_repo_dev != 'true'
    -        run: |
    -          exit 0 # Skip the AI title review for non-repo developers
    -
    -      - name: Clean up
    -        if: always()
    -        run: |
    -          rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
    -          echo "Cleaned up temporary files."
    -        continue-on-error: true # Ensure cleanup runs even if previous steps fail
    diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml
    index 9561833f07..54bd4cb907 100644
    --- a/.github/workflows/backend-build.yml
    +++ b/.github/workflows/backend-build.yml
    @@ -20,6 +20,7 @@ permissions:
     
     jobs:
       build:
    +    environment: ci-unsigned
         runs-on: ubuntu-latest
         strategy:
           fail-fast: false
    diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml
    index 08849c683b..0604f7176f 100644
    --- a/.github/workflows/build-enterprise.yml
    +++ b/.github/workflows/build-enterprise.yml
    @@ -37,6 +37,7 @@ jobs:
         uses: ./.github/workflows/_runner-pick.yml
     
       playwright-e2e-enterprise:
    +    environment: ci-unsigned
         needs: pick
         # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
         # so the suite can't boot premium and would fail. See the header comment.
    @@ -309,6 +310,7 @@ jobs:
       # Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
       # and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
       multinode-e2e:
    +    environment: ci-unsigned
         needs: [pick, playwright-e2e-enterprise]
         # Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
         if: >-
    diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
    index eee07d599a..2f50249099 100644
    --- a/.github/workflows/build.yml
    +++ b/.github/workflows/build.yml
    @@ -61,6 +61,7 @@ jobs:
               filters: .github/config/.files.yaml
     
       gradle-cache-prime:
    +    environment: ci-unsigned
         name: Prime shared Gradle cache
         needs: [files-changed]
         runs-on: ubuntu-latest
    diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml
    index d45a68e860..2eec970b8f 100644
    --- a/.github/workflows/check-licence.yml
    +++ b/.github/workflows/check-licence.yml
    @@ -10,6 +10,7 @@ permissions:
     
     jobs:
       check-licence:
    +    environment: ci-unsigned
         runs-on: ubuntu-latest
         steps:
           - name: Harden Runner
    diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml
    index 751a34a33e..f224ce18cf 100644
    --- a/.github/workflows/check-openapi.yml
    +++ b/.github/workflows/check-openapi.yml
    @@ -11,6 +11,7 @@ permissions:
     
     jobs:
       check-generate-openapi-docs:
    +    environment: ci-unsigned
         runs-on: ubuntu-latest
         steps:
           - name: Harden Runner
    diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml
    index 4181390285..d6a61b45c4 100644
    --- a/.github/workflows/db-migration-test.yml
    +++ b/.github/workflows/db-migration-test.yml
    @@ -13,6 +13,7 @@ permissions:
     
     jobs:
       migration-test:
    +    environment: ci-unsigned
         runs-on: ubuntu-latest
         timeout-minutes: 30
         steps:
    diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml
    deleted file mode 100644
    index 01114c64b2..0000000000
    --- a/.github/workflows/deploy-on-v2-commit.yml
    +++ /dev/null
    @@ -1,209 +0,0 @@
    -name: Auto V2 Deploy on Push
    -
    -on:
    -  push:
    -    branches:
    -      - V2
    -      - deploy-on-v2-commit
    -
    -permissions:
    -  contents: read
    -
    -jobs:
    -  deploy-v2-on-push:
    -    environment: pr-preview
    -    runs-on: ubuntu-latest
    -    permissions:
    -      contents: read
    -      packages: write
    -    concurrency:
    -      group: deploy-v2-push-V2
    -      cancel-in-progress: true
    -
    -    steps:
    -      - name: Harden Runner
    -        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
    -        with:
    -          egress-policy: audit
    -
    -      - name: Checkout code
    -        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
    -
    -      - name: Get commit hashes for frontend and backend
    -        id: commit-hashes
    -        run: |
    -          # Get last commit that touched the frontend folder, docker/frontend, or docker/compose
    -          FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
    -          if [ -z "$FRONTEND_HASH" ]; then
    -            FRONTEND_HASH="no-frontend-changes"
    -          fi
    -
    -          # Get last commit that touched backend code, docker/backend, or docker/compose
    -          BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
    -          if [ -z "$BACKEND_HASH" ]; then
    -            BACKEND_HASH="no-backend-changes"
    -          fi
    -
    -          echo "Frontend hash: $FRONTEND_HASH"
    -          echo "Backend hash: $BACKEND_HASH"
    -
    -          echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
    -          echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
    -
    -          # Short hashes for tags
    -          if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
    -            echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
    -          else
    -            echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
    -          fi
    -
    -          if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
    -            echo "backend_short=no-backend" >> $GITHUB_OUTPUT
    -          else
    -            echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
    -          fi
    -
    -      - name: Convert repository owner to lowercase
    -        id: repoowner
    -        run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
    -
    -      - name: Login to GitHub Container Registry
    -        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
    -        with:
    -          registry: ghcr.io
    -          username: ${{ github.actor }}
    -          password: ${{ github.token }}
    -
    -      - name: Check if frontend image exists
    -        id: check-frontend
    -        run: |
    -          if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
    -            echo "exists=true" >> $GITHUB_OUTPUT
    -            echo "Frontend image already exists, skipping build"
    -          else
    -            echo "exists=false" >> $GITHUB_OUTPUT
    -            echo "Frontend image needs to be built"
    -          fi
    -
    -        env:
    -          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
    -      - name: Check if backend image exists
    -        id: check-backend
    -        run: |
    -          if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
    -            echo "exists=true" >> $GITHUB_OUTPUT
    -            echo "Backend image already exists, skipping build"
    -          else
    -            echo "exists=false" >> $GITHUB_OUTPUT
    -            echo "Backend image needs to be built"
    -          fi
    -
    -        env:
    -          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
    -
    -      - name: Build and push frontend image
    -        if: steps.check-frontend.outputs.exists == 'false'
    -        uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
    -        with:
    -          context: .
    -          file: ./docker/frontend/Dockerfile
    -          push: true
    -          cache-from: type=gha,scope=stirling-v2-frontend
    -          cache-to: type=gha,mode=max,scope=stirling-v2-frontend
    -          tags: |
    -            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
    -            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest
    -          build-args: VERSION_TAG=v2-alpha
    -          platforms: linux/amd64
    -
    -      - name: Build and push backend image
    -        if: steps.check-backend.outputs.exists == 'false'
    -        uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
    -        with:
    -          context: .
    -          file: ./docker/backend/Dockerfile
    -          push: true
    -          cache-from: type=gha,scope=stirling-v2-backend
    -          cache-to: type=gha,mode=max,scope=stirling-v2-backend
    -          tags: |
    -            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
    -            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest
    -          build-args: VERSION_TAG=v2-alpha
    -          platforms: linux/amd64
    -
    -      - name: Set up SSH
    -        run: |
    -          mkdir -p ~/.ssh/
    -          echo "${NEW_VPS_SSH_KEY}" > ../private.key
    -          chmod 600 ../private.key
    -
    -        env:
    -          NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
    -      - name: Deploy to VPS on port 3000
    -        run: |
    -          export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
    -
    -          cat > $UNIQUE_NAME << EOF
    -          version: '3.3'
    -          services:
    -            backend:
    -              container_name: stirling-v2-backend
    -              image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
    -              ports:
    -                - "13000:8080"
    -              volumes:
    -                - /stirling/V2/data:/usr/share/tessdata:rw
    -                - /stirling/V2/config:/configs:rw
    -                - /stirling/V2/logs:/logs:rw
    -              environment:
    -                DISABLE_ADDITIONAL_FEATURES: "true"
    -                SECURITY_ENABLELOGIN: "false"
    -                SYSTEM_DEFAULTLOCALE: en-US
    -                UI_APPNAME: "Stirling-PDF V2"
    -                UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
    -                UI_APPNAMENAVBAR: "V2 Deployment"
    -                SYSTEM_MAXFILESIZE: "100"
    -                METRICS_ENABLED: "true"
    -                SYSTEM_GOOGLEVISIBILITY: "false"
    -                SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
    -                baseUrl: "https://demo.stirlingpdf.cloud"
    -              restart: on-failure:5
    -
    -            frontend:
    -              container_name: stirling-v2-frontend
    -              image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
    -              ports:
    -                - "3000:80"
    -              environment:
    -                VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000"
    -              depends_on:
    -                - backend
    -              restart: on-failure:5
    -          EOF
    -
    -          # Copy to remote with unique name
    -          scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME
    -
    -          # SSH and rename/move atomically to avoid interference
    -          ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
    -            mkdir -p /stirling/V2/{data,config,logs}
    -            mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
    -            cd /stirling/V2
    -            docker-compose down || true
    -            docker-compose pull
    -            docker-compose up -d
    -            docker system prune -af --volumes || true
    -            docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
    -          ENDSSH
    -
    -        env:
    -          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
    -          NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
    -          NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
    -      - name: Cleanup temporary files
    -        if: always()
    -        run: |
    -          rm -f ../private.key
    diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml
    index ddb35b4e1a..9d5911404f 100644
    --- a/.github/workflows/docker-compose-tests.yml
    +++ b/.github/workflows/docker-compose-tests.yml
    @@ -17,6 +17,7 @@ permissions:
     
     jobs:
       docker-compose-tests:
    +    environment: ci-unsigned
         runs-on: ubuntu-latest
         permissions:
           actions: write
    diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml
    index 4aa2a78c89..7bc95df05e 100644
    --- a/.github/workflows/e2e-live.yml
    +++ b/.github/workflows/e2e-live.yml
    @@ -11,6 +11,7 @@ permissions:
     
     jobs:
       playwright-e2e-live:
    +    environment: ci-unsigned
         runs-on: ubuntu-latest
         timeout-minutes: 30
         steps:
    diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml
    index 1155f01e39..458766660f 100644
    --- a/.github/workflows/frontend-backend-licenses-update.yml
    +++ b/.github/workflows/frontend-backend-licenses-update.yml
    @@ -42,6 +42,8 @@ jobs:
               filters: .github/config/.files.yaml
     
       generate-frontend-license-report:
    +    # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
    +    environment: ci-bot
         if: needs.files-changed.outputs.licenses-frontend == 'true'
         name: Generate Frontend License Report
         needs: files-changed
    @@ -316,6 +318,8 @@ jobs:
               GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
     
       generate-backend-license-report:
    +    # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
    +    environment: ci-bot
         if: needs.files-changed.outputs.licenses-backend == 'true'
         needs: files-changed
         name: Generate Backend License Report
    diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml
    index 3bb014a82d..d9477f722f 100644
    --- a/.github/workflows/multiOSReleases.yml
    +++ b/.github/workflows/multiOSReleases.yml
    @@ -38,6 +38,7 @@ permissions:
     
     jobs:
       determine-matrix:
    +    environment: ci-unsigned
         if: ${{ vars.CI_PROFILE != 'lite' }}
         runs-on: ubuntu-latest
         outputs:
    @@ -118,6 +119,7 @@ jobs:
             env:
               INPUT_PLATFORM: ${{ github.event.inputs.platform }}
       build-jars:
    +    environment: ci-unsigned
         needs: determine-matrix
         runs-on: ubuntu-latest
         strategy:
    @@ -204,7 +206,6 @@ jobs:
         runs-on: ${{ matrix.platform }}
         env:
           SM_API_KEY: ${{ secrets.SM_API_KEY }}
    -      WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
           RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
         steps:
           - name: Harden Runner
    @@ -295,7 +296,7 @@ jobs:
           # DigiCert KeyLocker Setup (Cloud HSM)
           - name: Setup DigiCert KeyLocker
             id: digicert-setup
    -        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
    +        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
             uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
             env:
               SM_API_KEY: ${{ secrets.SM_API_KEY }}
    @@ -305,7 +306,7 @@ jobs:
               SM_HOST: ${{ secrets.SM_HOST }}
     
           - name: Setup DigiCert KeyLocker Certificate
    -        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
    +        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
             shell: pwsh
             run: |
               Write-Host "Setting up DigiCert KeyLocker environment..."
    @@ -344,40 +345,8 @@ jobs:
               SM_API_KEY: ${{ secrets.SM_API_KEY }}
               SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
               SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
    -      # Traditional PFX Certificate Import (fallback if KeyLocker not configured)
    -      - name: Import Windows Code Signing Certificate
    -        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
    -        env:
    -          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
    -          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
    -        shell: powershell
    -        run: |
    -          if ($env:WINDOWS_CERTIFICATE) {
    -            Write-Host "Importing Windows Code Signing Certificate..."
    -
    -            # Decode base64 certificate and save to file
    -            $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
    -            $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
    -            [IO.File]::WriteAllBytes($certPath, $certBytes)
    -
    -            # Import certificate to CurrentUser\My store
    -            $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
    -
    -            # Extract and set thumbprint as environment variable
    -            $thumbprint = $cert.Thumbprint
    -            Write-Host "Certificate imported with thumbprint: $thumbprint"
    -            echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
    -
    -            # Clean up certificate file
    -            Remove-Item $certPath
    -
    -            Write-Host "Windows certificate import completed."
    -          } else {
    -            Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
    -          }
    -
           - name: Import Apple Developer Certificate
    -        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
    +        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
             env:
               APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
               APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
    @@ -398,7 +367,7 @@ jobs:
               rm certificate.p12
     
           - name: Verify Certificate
    -        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
    +        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
             run: |
               echo "Verifying Apple Developer Certificate..."
               KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
    @@ -414,7 +383,7 @@ jobs:
           # Without this, signCommand failures are opaque (Tauri captures but drops
           # smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
           - name: Preflight smctl
    -        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
    +        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
             shell: pwsh
             env:
               KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
    @@ -445,7 +414,7 @@ jobs:
           # smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
           # from env (set by prior DigiCert setup step). No --config-file needed.
           - name: Configure Windows code signing
    -        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
    +        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
             shell: bash
             env:
               KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
    @@ -466,7 +435,7 @@ jobs:
               sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
     
           - name: Import release GPG signing key (Linux)
    -        if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
    +        if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
             run: |
               echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
               gpg --list-secret-keys --keyid-format=long
    @@ -498,8 +467,8 @@ jobs:
               #   APPIMAGETOOL_SIGN_PASSPHRASE  appimagetool uses this to unlock the GPG key non-interactively
               #   SIGN_KEY                      appimagetool picks the key matching this fingerprint
               # Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
    -          # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present.
    -          SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
    +          # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present.
    +          SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
               APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
               SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
               TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
    @@ -525,7 +494,7 @@ jobs:
             env:
               TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
               TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
    -          GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
    +          GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
               SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
               APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
             run: |
    @@ -564,7 +533,7 @@ jobs:
               echo "Stripped bundled libwayland from $(basename "$AI")"
     
           - name: Clear release GPG key from runner keyring (Linux)
    -        if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
    +        if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
             env:
               RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
             run: |
    @@ -579,7 +548,7 @@ jobs:
           # artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
           # cargo output unsigned, so checking it produces false negatives.
           - name: Verify Windows Code Signature
    -        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
    +        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
             timeout-minutes: 15
             shell: pwsh
             run: |
    @@ -911,11 +880,11 @@ jobs:
           # workflow_dispatch path requires platform=='all' so a single-platform
           # dispatch can't overwrite an existing release's full latest.json with a
           # partial one (action-gh-release defaults overwrite_files:true).
    -      # release / V2-master always build the full matrix so no extra guard needed.
    +      # release event / release branch always build the full matrix so no extra guard needed.
           # fail_on_unmatched_files makes a missing latest.json or installer fail loudly
           # instead of silently shipping a broken auto-update.
           - name: Upload binaries to Release
    -        if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
    +        if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
             uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
             with:
               tag_name: v${{ needs.determine-matrix.outputs.version }}
    diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
    index 70e99d6074..c92d17f027 100644
    --- a/.github/workflows/nightly.yml
    +++ b/.github/workflows/nightly.yml
    @@ -127,6 +127,7 @@ jobs:
       # Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
       # of every other feature.
       cucumber-nightly:
    +    environment: ci-unsigned
         name: Cucumber (nightly scenarios + full concurrency)
         runs-on: ubuntu-latest
         # Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
    diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml
    index 658583ea08..97c227f23c 100644
    --- a/.github/workflows/push-docker-base.yml
    +++ b/.github/workflows/push-docker-base.yml
    @@ -17,6 +17,9 @@ permissions:
     
     jobs:
       push-base:
    +    # Own environment: docker-publish is branch-locked to release/main,
    +    # which excludes the baseDockerImage/accessIssueFix branches this runs on.
    +    environment: docker-base-publish
         if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
         runs-on: ubuntu-24.04-8core
         permissions:
    diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml
    index b88d69c3c2..ec9d14822c 100644
    --- a/.github/workflows/push-docker.yml
    +++ b/.github/workflows/push-docker.yml
    @@ -20,9 +20,8 @@ on:
             default: false
       push:
         branches:
    -      - master
    +      - release
           - main
    -      - V2-master
     
     # cancel in-progress jobs if a new job is triggered
     # This is useful to avoid running multiple builds for the same branch if a new commit is pushed
    @@ -91,13 +90,13 @@ jobs:
               MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
     
           - name: Install cosign
    -        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
    +        if: github.ref == 'refs/heads/release'
             uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
             with:
               cosign-release: "v2.4.1"
     
           - name: Install cosign
    -        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
    +        if: github.ref == 'refs/heads/release'
             uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
             with:
               cosign-release: "v2.4.1"
    @@ -133,8 +132,8 @@ jobs:
                 ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
                 ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
               tags: |
    -            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
    -            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
    +            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }}
    +            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }}
     
           - name: Build and push Unified Dockerfile (latest variant)
             id: build-push-latest
    @@ -158,7 +157,7 @@ jobs:
               sbom: true
     
           - name: Sign regular images
    -        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
    +        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != ''
             env:
               DIGEST: ${{ steps.build-push-latest.outputs.digest }}
               TAGS: ${{ steps.meta.outputs.tags }}
    @@ -182,8 +181,8 @@ jobs:
                 ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
                 ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
               tags: |
    -            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
    -            type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
    +            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }}
    +            type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }}
     
           - name: Build and push Unified Dockerfile (fat variant)
             id: build-push-fat
    @@ -204,7 +203,7 @@ jobs:
               sbom: true
     
           - name: Sign fat images
    -        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
    +        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != ''
             env:
               DIGEST: ${{ steps.build-push-fat.outputs.digest }}
               TAGS: ${{ steps.meta-fat.outputs.tags }}
    @@ -226,8 +225,8 @@ jobs:
                 ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
                 ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
               tags: |
    -            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
    -            type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
    +            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
    +            type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
     
           - name: Build and push Unified Dockerfile (ultra-lite variant)
             id: build-push-lite
    @@ -248,7 +247,7 @@ jobs:
               sbom: true
     
           - name: Sign ultra-lite images
    -        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
    +        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != ''
             env:
               DIGEST: ${{ steps.build-push-lite.outputs.digest }}
               TAGS: ${{ steps.meta-lite.outputs.tags }}
    @@ -260,7 +259,7 @@ jobs:
               done
     
           # Standalone unoserver image — versioned independently via
    -      # docker/unoserver/VERSION. master/V2-master: publish +latest
    +      # docker/unoserver/VERSION. release: publish +latest
           # only when the version is new. main/testMain: republish :alpha only
           # when the source hash differs from the published image's annotation.
           - name: Read unoserver image version
    @@ -319,7 +318,7 @@ jobs:
               fi
     
               case "$EFFECTIVE_REF" in
    -            refs/heads/master|refs/heads/V2-master)
    +            refs/heads/release)
                   if [ "${FORCE_REBUILD}" = "true" ]; then
                     echo "force_unoserver_rebuild=true — building stable regardless"
                     mode="stable"
    diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml
    index 38c1985a83..115de87d4e 100644
    --- a/.github/workflows/swagger.yml
    +++ b/.github/workflows/swagger.yml
    @@ -4,7 +4,7 @@ on:
       workflow_dispatch:
       push:
         branches:
    -      - master
    +      - release
     
     # cancel in-progress jobs if a new job is triggered
     # This is useful to avoid running multiple builds for the same branch if a new commit is pushed
    @@ -23,6 +23,9 @@ permissions:
     
     jobs:
       push:
    +    # package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and
    +    # is limited to main / release / v* tags, so every push to release waits on one.
    +    environment: package-publish
         if: ${{ vars.CI_PROFILE != 'lite' }}
         runs-on: ubuntu-latest
         steps:
    diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml
    index ab6d8aca71..ddf1104bac 100644
    --- a/.github/workflows/tauri-build.yml
    +++ b/.github/workflows/tauri-build.yml
    @@ -57,6 +57,9 @@ permissions:
     
     jobs:
       determine-matrix:
    +    # Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
    +    # signing environment - release-signing would block every PR run.
    +    environment: ci-signing
         if: ${{ vars.CI_PROFILE != 'lite' }}
         runs-on: ubuntu-latest
         outputs:
    @@ -103,6 +106,12 @@ jobs:
               echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
     
       build:
    +    # Windows/GPG signing only runs on main (see the per-step gates below), so only
    +    # that path needs the reviewer-gated release-signing environment. Everything else
    +    # (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no
    +    # approval or branch restriction.
    +    environment:
    +      name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }}
         needs: determine-matrix
         strategy:
           fail-fast: false
    @@ -110,7 +119,6 @@ jobs:
         runs-on: ${{ matrix.platform }}
         env:
           SM_API_KEY: ${{ secrets.SM_API_KEY }}
    -      WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
           APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
           RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
           # Per-platform sign gate. macOS signs on any run with the cert available,
    @@ -264,38 +272,6 @@ jobs:
                 }
               }
     
    -      # Traditional PFX Certificate Import (fallback if KeyLocker not configured)
    -      - name: Import Windows Code Signing Certificate
    -        if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
    -        env:
    -          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
    -          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
    -        shell: powershell
    -        run: |
    -          if ($env:WINDOWS_CERTIFICATE) {
    -            Write-Host "Importing Windows Code Signing Certificate..."
    -
    -            # Decode base64 certificate and save to file
    -            $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
    -            $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
    -            [IO.File]::WriteAllBytes($certPath, $certBytes)
    -
    -            # Import certificate to CurrentUser\My store
    -            $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
    -
    -            # Extract and set thumbprint as environment variable
    -            $thumbprint = $cert.Thumbprint
    -            Write-Host "Certificate imported with thumbprint: $thumbprint"
    -            echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
    -
    -            # Clean up certificate file
    -            Remove-Item $certPath
    -
    -            Write-Host "Windows certificate import completed."
    -          } else {
    -            Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
    -          }
    -
           - name: Import Apple Developer Certificate
             if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
             env:
    diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml
    index 660cd2458b..4a79cb3733 100644
    --- a/.github/workflows/test-build-docker.yml
    +++ b/.github/workflows/test-build-docker.yml
    @@ -37,6 +37,7 @@ jobs:
       # spring-security=true matrix entry if `task backend:build` and
       # `task backend:build:ci` produce equivalent JARs (verify before wiring).
       test-build-docker-images:
    +    environment: ci-unsigned
         runs-on: ubuntu-latest
         strategy:
           fail-fast: false
    diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml
    deleted file mode 100644
    index 751eaf44f7..0000000000
    --- a/.github/workflows/testdriver.yml
    +++ /dev/null
    @@ -1,235 +0,0 @@
    -name: UI test with TestDriverAI
    -
    -on:
    -  push:
    -    branches: ["master", "UITest", "testdriver"]
    -
    -# cancel in-progress jobs if a new job is triggered
    -# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
    -# or a pull request is updated.
    -# It helps to save resources and time by ensuring that only the latest commit is built and tested
    -# This is particularly useful for long-running jobs that may take a while to complete.
    -# The `group` is set to a combination of the workflow name, event name, and branch name.
    -# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
    -# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
    -concurrency:
    -  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
    -  cancel-in-progress: true
    -
    -permissions:
    -  contents: read
    -
    -jobs:
    -  deploy:
    -    environment: pr-preview
    -    if: ${{ vars.CI_PROFILE != 'lite' }}
    -    runs-on: ubuntu-latest
    -    permissions:
    -      contents: read
    -      packages: write
    -    steps:
    -      - name: Harden Runner
    -        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
    -        with:
    -          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
    -        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: Build with Gradle
    -        run: ./gradlew build
    -        env:
    -          MAVEN_USER: ${{ secrets.MAVEN_USER }}
    -          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
    -          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
    -          DISABLE_ADDITIONAL_FEATURES: true
    -
    -      - name: Set up Docker Buildx
    -        uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
    -
    -      - name: Get version number
    -        id: versionNumber
    -        run: |
    -          VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
    -          echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
    -
    -      - name: Convert repository owner to lowercase
    -        id: repoowner
    -        run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
    -
    -      - name: Login to GitHub Container Registry
    -        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
    -        with:
    -          registry: ghcr.io
    -          username: ${{ github.actor }}
    -          password: ${{ github.token }}
    -
    -      - name: Build and push test image
    -        uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
    -        with:
    -          context: .
    -          file: ./docker/embedded/Dockerfile
    -          push: true
    -          cache-from: type=gha,scope=stirling-pdf-latest
    -          cache-to: type=gha,mode=max,scope=stirling-pdf-latest
    -          tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }}
    -          build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
    -          platforms: linux/amd64
    -
    -      - name: Set up SSH
    -        run: |
    -          mkdir -p ~/.ssh/
    -          echo "${NEW_VPS_SSH_KEY}" > ../private.key
    -          sudo chmod 600 ../private.key
    -
    -        env:
    -          NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
    -      - name: Deploy to VPS
    -        run: |
    -          cat > docker-compose.yml << EOF
    -          version: '3.3'
    -          services:
    -            stirling-pdf:
    -              container_name: stirling-pdf-test-${{ github.sha }}
    -              image: ${IMAGE_BASE}:test-${{ github.sha }}
    -              ports:
    -                - "1337:8080"
    -              volumes:
    -                - /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
    -                - /stirling/test-${{ github.sha }}/config:/configs:rw
    -                - /stirling/test-${{ github.sha }}/logs:/logs:rw
    -              environment:
    -                DISABLE_ADDITIONAL_FEATURES: "true"
    -                SECURITY_ENABLELOGIN: "false"
    -                SYSTEM_DEFAULTLOCALE: en-US
    -                UI_APPNAME: "Stirling-PDF Test"
    -                UI_HOMEDESCRIPTION: "Test Deployment"
    -                UI_APPNAMENAVBAR: "Test"
    -                SYSTEM_MAXFILESIZE: "100"
    -                METRICS_ENABLED: "true"
    -                SYSTEM_GOOGLEVISIBILITY: "false"
    -                SYSTEM_ENABLEANALYTICS: "false"
    -              restart: on-failure:5
    -          EOF
    -
    -          scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml
    -
    -          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
    -            mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
    -            mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
    -            cd /stirling/test-${{ github.sha }}
    -            docker-compose pull
    -            docker-compose up -d
    -          EOF
    -
    -        env:
    -          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
    -          NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
    -          NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
    -  files-changed:
    -    if: always()
    -    name: detect what files changed
    -    runs-on: ubuntu-latest
    -    timeout-minutes: 3
    -    outputs:
    -      frontend: ${{ steps.changes.outputs.frontend }}
    -    steps:
    -      - name: Harden the runner (Audit all outbound calls)
    -        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
    -        with:
    -          egress-policy: audit
    -
    -      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
    -
    -      - name: Check for file changes
    -        uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
    -        id: changes
    -        with:
    -          filters: ".github/config/.files.yaml"
    -
    -  test:
    -    environment: pr-preview
    -    if: needs.files-changed.outputs.frontend == 'true'
    -    needs: [deploy, files-changed]
    -    runs-on: ubuntu-latest
    -    steps:
    -      - name: Harden Runner
    -        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
    -        with:
    -          egress-policy: audit
    -
    -      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
    -
    -      - name: Set up Node
    -        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
    -        with:
    -          cache: "npm"
    -          cache-dependency-path: frontend/package-lock.json
    -
    -      - name: Run TestDriver.ai
    -        uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
    -        with:
    -          key: ${{secrets.TESTDRIVER_API_KEY}}
    -          prerun: |
    -            choco install go-task -y
    -            task frontend:build
    -            cd frontend
    -            npm install dashcam-chrome --save
    -            Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
    -            Start-Sleep -Seconds 20
    -          prompt: |
    -            1. /run testing/testdriver/test.yml
    -        env:
    -          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    -          FORCE_COLOR: "3"
    -
    -  cleanup:
    -    environment: pr-preview
    -    needs: [deploy, test]
    -    runs-on: ubuntu-latest
    -    if: always()
    -
    -    steps:
    -      - name: Harden Runner
    -        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
    -        with:
    -          egress-policy: audit
    -
    -      - name: Set up SSH
    -        run: |
    -          mkdir -p ~/.ssh/
    -          echo "${NEW_VPS_SSH_KEY}" > ../private.key
    -          sudo chmod 600 ../private.key
    -
    -        env:
    -          NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
    -      - name: Cleanup deployment
    -        if: always()
    -        run: |
    -          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
    -            cd /stirling/test-${{ github.sha }}
    -            docker-compose down
    -            cd /stirling
    -            rm -rf test-${{ github.sha }}
    -          EOF
    -        env:
    -          NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
    -          NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
    -        continue-on-error: true # Ensure cleanup runs even if previous steps fail
    diff --git a/WINDOWS_SIGNING.md b/WINDOWS_SIGNING.md
    index 58ffd6e657..95cbbd24e2 100644
    --- a/WINDOWS_SIGNING.md
    +++ b/WINDOWS_SIGNING.md
    @@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop
     
     ## Overview
     
    +Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never
    +leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach
    +of uploading a base64 `.pfx` to a repository secret has been removed from the
    +workflows - the sections below describe KeyLocker, which is what actually runs.
    +
     Windows code signing is essential for:
     - Preventing Windows SmartScreen warnings
     - Building trust with users
    @@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica
     
     ### Required Secrets
     
    -Navigate to your GitHub repository → Settings → Secrets and variables → Actions
    +Navigate to your GitHub repository → Settings → Environments → `release-signing`.
     
    -Add the following secrets:
    +These live in the `release-signing` environment, not at repository scope. That
    +environment requires reviewer approval and is limited to `main`, `release`,
    +`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console.
     
    -#### 1. `WINDOWS_CERTIFICATE`
    -- **Description**: Base64-encoded .pfx certificate file
    -- **How to create**:
    -
    -**On macOS/Linux:**
    -```bash
    -base64 -i certificate.pfx | pbcopy  # Copies to clipboard
    -```
    -
    -**On Windows (PowerShell):**
    -```powershell
    -[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
    -```
    -
    -Paste the entire base64 string into the GitHub secret.
    -
    -#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
    -- **Description**: Password for the .pfx certificate
    -- **Value**: The password you set when creating/exporting the .pfx file
    +| Secret | Description |
    +| --- | --- |
    +| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. |
    +| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. |
    +| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. |
    +| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. |
    +| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. |
     
     ### Optional Secrets for Tauri Updater
     
    @@ -110,23 +105,23 @@ The Windows signing configuration is already set up:
     
     ### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
     
    -The workflow includes three Windows signing steps:
    +The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being
    +set and the ref being the release branch:
     
    -1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
    -2. **Build Tauri App**: Builds and signs the application using the imported certificate
    -3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
    +1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing`
    +2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config
    +3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider
    +4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed
     
     ## Testing the Setup
     
     ### 1. Local Testing (Windows Only)
     
    -Before pushing to GitHub, test locally:
    +KeyLocker is CI-only. To check signing locally, install your own certificate into
    +the Windows store and point Tauri at it; the build no longer reads any certificate
    +from an environment variable.
     
     ```powershell
    -# Set environment variables
    -$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
    -$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
    -
     # Build the application
     cd frontend
     npm run tauri build
    @@ -191,9 +186,10 @@ Look for:
     - Consider EV certificate for immediate reputation
     
     ### Certificate Not Found During Build
    -- Verify `WINDOWS_CERTIFICATE` secret is set
    -- Check base64 encoding is correct (no extra whitespace)
    -- Ensure password is correct
    +- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty
    +  the signing steps skip silently and the build succeeds unsigned.
    +- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace)
    +- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair
     
     ## Security Best Practices
     
    @@ -220,11 +216,10 @@ Look for:
     ## Certificate Lifecycle
     
     ### Before Expiration
    -1. Obtain new certificate from CA (typically annual renewal)
    -2. Convert to .pfx format if needed
    -3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
    -4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
    -5. Test build to verify new certificate works
    +1. Renew the certificate in the DigiCert ONE console (typically annual)
    +2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment
    +3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD`
    +4. Test build to verify the new certificate works
     
     ### Expired Certificates
     - Signed binaries remain valid (timestamp proves signing time)
    diff --git a/frontend/editor/src/core/services/updateService.ts b/frontend/editor/src/core/services/updateService.ts
    index 043c53bc36..8b23d26deb 100644
    --- a/frontend/editor/src/core/services/updateService.ts
    +++ b/frontend/editor/src/core/services/updateService.ts
    @@ -185,7 +185,7 @@ export class UpdateService {
        */
       async getCurrentVersionFromGitHub(): Promise {
         const url =
    -      "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/V2-master/build.gradle";
    +      "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/release/build.gradle";
     
         try {
           const response = await fetch(url);
    
    From a14eec94ec13677d71541bcfd26c1397a776ade4 Mon Sep 17 00:00:00 2001
    From: James Brunton 
    Date: Tue, 18 Aug 2026 09:02:17 +0000
    Subject: [PATCH 213/262] Fix corner radius on Mantine checkboxes in Processor
     (#7537)
    
    # Description of Changes
    
    ## Before
    image
    
    ## After
    image
    ---
     frontend/editor/src/portal/theme/mantineTheme.ts | 4 ++++
     1 file changed, 4 insertions(+)
    
    diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts
    index 7108a90073..e371b4aeb8 100644
    --- a/frontend/editor/src/portal/theme/mantineTheme.ts
    +++ b/frontend/editor/src/portal/theme/mantineTheme.ts
    @@ -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)",
    
    From fb70fc13da03e25ee535b74116de00a83648764e Mon Sep 17 00:00:00 2001
    From: James Brunton 
    Date: Tue, 18 Aug 2026 13:08:23 +0000
    Subject: [PATCH 214/262] Fix tools which crash in the Pipelines page (#7538)
    
    # Description of Changes
    Overlay PDFs and Change Metadata both crashed in the Processor because
    they required `FilesModalContext` and `ViewerContext` respectively.
    Neither of those contexts make sense to provide in the Processor because
    there are no files in context and there is no Viewer, so redesign both
    tool settings to only optionally require these contexts. Their behaviour
    is unchanged in the Editor but they now work in the Processor (just
    without the extra info about the active files, since there are none).
    
    Also hooks up the Reorganise Pages settings so that it can be used from
    Automate. The component already existed but just wasn't being used,
    which just looks like an oversight.
    ---
     .../ChangeMetadataSingleStep.tsx              | 153 ++++++++++------
     .../tools/overlayPdfs/OverlayPdfsSettings.tsx |  61 +++++--
     .../src/core/contexts/FilesModalContext.tsx   |   4 +-
     ...tomatableToolsHaveOperationConfig.test.tsx |  11 ++
     .../core/data/useTranslatedToolRegistry.tsx   |   5 +-
     .../pipelines/PipelineStepSettings.test.tsx   | 168 +++++++++++++++++-
     6 files changed, 323 insertions(+), 79 deletions(-)
    
    diff --git a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx
    index 2eff20b23b..07ba4e7e05 100644
    --- a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx
    +++ b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx
    @@ -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 (
    -    
    -      {/* Delete All */}
    -      
    -        
    -          {t("changeMetadata.deleteAll.label", "Delete All Metadata")}
    -        
    -        
    +      {hasViewerContext && (
    +        
    -      
    -
    -      
    -
    -      {/* Standard Metadata Fields */}
    +      )}
           
    -        
    -          {t("changeMetadata.standardFields.title", "Standard Metadata")}
    -        
    -        
    +        {/* Delete All */}
    +        
    +          
    +            {t("changeMetadata.deleteAll.label", "Delete All Metadata")}
    +          
    +          
    +        
    +
    +        
    +
    +        {/* Standard Metadata Fields */}
    +        
    +          
    +            {t("changeMetadata.standardFields.title", "Standard Metadata")}
    +          
    +          
    +        
    +
    +        
    +
    +        {/* Document Dates */}
    +        
    +          
    +            {t("changeMetadata.dates.title", "Document Dates")}
    +          
    +          
    +        
    +
    +        
    +
    +        {/* Advanced Options */}
    +        
    +          
    +            {t("changeMetadata.advanced.title", "Advanced Options")}
    +          
    +          
    +        
           
    -
    -      
    -
    -      {/* Document Dates */}
    -      
    -        
    -          {t("changeMetadata.dates.title", "Document Dates")}
    -        
    -        
    -      
    -
    -      
    -
    -      {/* Advanced Options */}
    -      
    -        
    -          {t("changeMetadata.advanced.title", "Advanced Options")}
    -        
    -        
    -      
    -    
    +    
       );
     };
     
    diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
    index 2648990248..e7246bb4e3 100644
    --- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
    +++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
    @@ -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 (
         
           
    @@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
             
               {t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
             
    -        
    +        {filesModal ? (
    +          
    +        ) : (
    +          }
    +            fullWidth
    +          >
    +            {overlayFilesButtonLabel}
    +          
    +        )}
     
             {parameters.overlayFiles?.length > 0 &&
               (() => {
    diff --git a/frontend/editor/src/core/contexts/FilesModalContext.tsx b/frontend/editor/src/core/contexts/FilesModalContext.tsx
    index 73d1b0477f..17585ae7e8 100644
    --- a/frontend/editor/src/core/contexts/FilesModalContext.tsx
    +++ b/frontend/editor/src/core/contexts/FilesModalContext.tsx
    @@ -41,7 +41,9 @@ interface FilesModalContextType {
       setOnModalClose: (callback: () => void) => void;
     }
     
    -const FilesModalContext = createContext(null);
    +export const FilesModalContext = createContext(
    +  null,
    +);
     
     export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
       children,
    diff --git a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx
    index 461c07ebdf..c8942942df 100644
    --- a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx
    +++ b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx
    @@ -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();
    +  });
     });
    diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
    index 0a39da2fb9..5ae8075d0f 100644
    --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
    +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
    @@ -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: (
    diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
    index 946e24b810..55a52b496e 100644
    --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
    +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
    @@ -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()),
       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;
     
    +// 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;
    +
    +// 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;
    +
     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(
    +        
    +           {}}
    +          />
    +        ,
    +      ),
    +    ).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(
    +        
    +           {}}
    +          />
    +        ,
    +      ),
    +    ).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
    +      >;
    +      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(
    +        
    +          
    +            
    +               {
    +                  caught.error = error;
    +                }}
    +              >
    +                
    +                   {}}
    +                    disabled={false}
    +                  />
    +                  
    +                
    +              
    +            
    +          
    +        ,
    +      );
    +
    +      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);
    +});
    
    From cf49742d9774802c603b4d068c2f8ac9d3ffbfd1 Mon Sep 17 00:00:00 2001
    From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
    Date: Tue, 18 Aug 2026 13:56:47 +0000
    Subject: [PATCH 215/262] Fix the top bar styling (#7544)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Every top bar styled itself, so none of them matched the new UI. Also,
    colors on the premium banner (and possibly others) clashed since the
    theme changes.
    
    ## Before Example Issue
    
    Screenshot 2026-08-17 at 11 47
    20 PM
    
    
    ## After (all)
    
    danger__dark
    danger__light
    default-app__dark
    default-app__light
    free-tier-limit__dark
    free-tier-limit__light
    server-attention__dark
    server-attention__light
    team-invitation__dark
    team-invitation__light
    upgrade-prompt__dark
    upgrade-prompt__light
    
    
    ## What changed
    
    - `InfoBanner` exposed 8 colour-override props (`background`,
    `borderColor`, `textColor`, `iconColor`, `buttonColor`,
    `buttonTextColor`, `closeIconColor`, `buttonVariant`), so every caller
    invented its own look. Replaced with a closed tone set: `info` · `promo`
    · `warning` · `danger`.
    - Tone drives the whole bar — fill, border, icon and the button — so a
    CTA can't drift from the bar it sits on. Text is neutral in every tone;
    only the icon carries the tone colour.
    - All colour comes from `--c-*` tokens mixed over `--c-surface`, so the
    bars follow light and dark instead of ignoring them. The old bars were
    hardcoded: in dark mode the two licence warnings stayed cream-on-white.
    - `promo` keeps the gradient it was always meant to have, built from the
    existing `--c-hue-indigo`/`--c-hue-purple` stops (documented in
    `colors.css` as gradient hues, deliberately not accent-following), with
    the existing `premium` button accent on it.
    - Deleted the hardcoded colours from all four callers: the purple
    gradient (`#667eea`→`#764ba2`), the orange soup (`#FFF4E6` / `#9A3412` /
    `#EA580C`) duplicated across the urgent banner and the admin plan
    section, and the fixed dark bar (`--mantine-color-dark-7`) on the team
    invitation.
    - `UpgradeBanner|AdminPlanSection` sat on the theme linter's exemption
    list, which is how those colours survived the theme migration. Exemption
    removed, so `code-colors` now guards them.
    - The banner's class was colliding with `core/ui/Banner.css`'s
    `.sui-banner` (16 live rules), which restyled it in the app but not in
    Storybook — that's why the two disagreed on radius, border and tone.
    Renamed to `.app-banner`; the two surfaces now render identically.
    - Bar is square and full-bleed with a single hairline rule underneath;
    button labels are optically centred.
    - Added `--c-warning-subtle`, matching the existing `--c-danger-subtle`
    / `--c-success-subtle`.
    - New `Shared → Top bars` story renders all six bars at once, so a
    change to the shared component is visible against the whole set.
    - Unrelated one-liner: `frontend/.prettierignore` now ignores the
    gitignored `editor/screenshots/` capture artifacts, which were failing
    `format:check` locally. Happy to drop it if you'd rather keep this PR to
    the bars.
    
    ## Testing
    
    - `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes
    + stylelint), format, 244 files / 2119 tests.
    - `frontend:storybook:a11y:changed` — clean in light and dark.
    - The a11y gate caught a real defect mid-change: giving each banner
    `role="region"` with the same label produced duplicate landmarks, which
    the app hits for real whenever two banners show at once. Landmark
    removed.
    - All six bars captured in the running editor, light and dark, and
    diffed against `origin/main`'s component rendered with each caller's
    original props.
    ---
     .../public/locales/en-US/translation.toml     |   6 +-
     frontend/editor/scripts/lint/theme-lint.mjs   |   1 -
     .../shared/TeamInvitationBanner.tsx           |   8 +-
     .../src/core/components/AppLayout.stories.tsx |   4 +-
     .../src/core/components/shared/AppBanner.css  | 115 ++++++++
     .../components/shared/AppBanner.stories.tsx   | 194 +++++++++++++
     .../src/core/components/shared/AppBanner.tsx  | 124 +++++++++
     .../components/shared/InfoBanner.stories.tsx  |  38 ---
     .../src/core/components/shared/InfoBanner.tsx | 263 ------------------
     frontend/editor/src/core/theme/colors.css     |   5 +
     .../components/shared/DefaultAppBanner.tsx    |   4 +-
     .../components/shared/UpgradeBanner.tsx       |  22 +-
     .../configSections/AdminPlanSection.tsx       |  11 +-
     13 files changed, 453 insertions(+), 342 deletions(-)
     create mode 100644 frontend/editor/src/core/components/shared/AppBanner.css
     create mode 100644 frontend/editor/src/core/components/shared/AppBanner.stories.tsx
     create mode 100644 frontend/editor/src/core/components/shared/AppBanner.tsx
     delete mode 100644 frontend/editor/src/core/components/shared/InfoBanner.stories.tsx
     delete mode 100644 frontend/editor/src/core/components/shared/InfoBanner.tsx
    
    diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
    index 70d3d03bf4..850f69ca02 100644
    --- a/frontend/editor/public/locales/en-US/translation.toml
    +++ b/frontend/editor/public/locales/en-US/translation.toml
    @@ -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"
    diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs
    index 494fc03cca..97b32ef208 100644
    --- a/frontend/editor/scripts/lint/theme-lint.mjs
    +++ b/frontend/editor/scripts/lint/theme-lint.mjs
    @@ -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\//,
    diff --git a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx
    index 3b373e9c3b..638c752f9e 100644
    --- a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx
    +++ b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx
    @@ -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 (
    -    
       );
     }
    diff --git a/frontend/editor/src/core/components/AppLayout.stories.tsx b/frontend/editor/src/core/components/AppLayout.stories.tsx
    index 4d7e6780cf..69aceef9d7 100644
    --- a/frontend/editor/src/core/components/AppLayout.stories.tsx
    +++ b/frontend/editor/src/core/components/AppLayout.stories.tsx
    @@ -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(
    -      ;
    +export default meta;
    +type Story = StoryObj;
    +
    +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 (
    +    
    + + {caption} + + {children} +
    + ); +} + +/** + * 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: () => ( +
    + + {}} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Decline" + onSecondaryButtonClick={() => {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Don't remind me again" + onSecondaryButtonClick={() => {}} + /> + + + + {}} + dismissible={false} + /> + +
    + ), +}; diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx new file mode 100644 index 0000000000..ee0ba03741 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -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 = ({ + 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 ( +
    + {icon != null && ( + + {typeof icon === "string" ? ( + + ) : ( + icon + )} + + )} + +
    + {title && {title}} + {message} +
    + +
    + {buttonText && onButtonClick && ( + + )} + {secondaryButtonText && onSecondaryButtonClick && ( + + )} + {dismissible && ( + onDismiss?.()} + aria-label={t("appBanner.dismiss", "Dismiss")} + > + + + )} +
    +
    + ); +}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx deleted file mode 100644 index 5fad071d05..0000000000 --- a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx +++ /dev/null @@ -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; -export default meta; -type Story = StoryObj; - -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, - }, -}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.tsx b/frontend/editor/src/core/components/shared/InfoBanner.tsx deleted file mode 100644 index 2056b6a92f..0000000000 --- a/frontend/editor/src/core/components/shared/InfoBanner.tsx +++ /dev/null @@ -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 = ({ - 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 ( - - - - {icon != null && - (typeof icon === "string" ? ( - - ) : ( -
    - {icon} -
    - ))} - - {title && ( - - {title} - - )} - - {message} - - -
    - - {buttonText && onButtonClick && ( - - )} - {secondaryButtonText && onSecondaryButtonClick && ( - - )} - {dismissible && ( - - - - )} - -
    -
    - ); -}; diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index dffd6beaba..dd3c2aec7c 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -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 diff --git a/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx b/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx index 1b24cd4675..5ba92c2782 100644 --- a/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx +++ b/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx @@ -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 ( - { ); return ( - { 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 && ( - { 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 /> )} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index f683861e8d..c69040f0e7 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -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 = () => { {shouldShowLicenseWarning && ( - { 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" /> )} From 913601ff0372d3fa1cadd11e48b1ebd2c921cdaa Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:00:43 +0000 Subject: [PATCH 216/262] Consolidate the editor + processor sidebar footers into one component (#7539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Both sidebars ended in a different bottom section. The editor showed an account row (avatar, name, settings); the processor showed a "Link Stirling account" CTA plus a `Settings` nav item and no identity at all. They are now **one shared ``** rendering the same rows in both apps, in this order: 1. the link-account CTA (self-hosted, when unlinked) 2. free credits remaining 3. **Open \** 4. the account row — avatar, name, settings It's a **single surface** with hairline dividers between rows, not stacked cards. Rows are assembled as a list, so a row this build doesn't show (no wallet, no processor access, nothing to link) takes its divider with it rather than leaving a stray line. This also fixes the profile-picture/initials desync between the sidebar and the account settings page. ## Screenshots Captured with the stubbed Playwright harness at 1600x900, scoped to the sidebar and auto-cropped to the region that actually changed. Base is `origin/main`; every state is driven by dummy backend stubs so all the nav-bar permutations are covered. montage_cloud-dark montage_cloud-light montage_editor-dark montage_editor-light montage_processor-dark montage_processor-light The free-credits meter is a cloud-build surface, so the self-hosted capture can't reach it. Those states come from the new Storybook stories with dummy wallet data (`Shared/NavFooter`), which is also where the credit tone bands and the collapsed rail are easiest to review. ## How it's wired `NavFooter` is purely presentational. Each app resolves its own data through three `@app/*` seams, so core carries no build-specific gating and any box whose data is absent is dropped rather than rendered empty. | Seam | core | cloud / proprietary / saas | |---|---|---| | `useFreeCreditsSummary` | `null` — self-hosted editor installs aren't metered | cloud reads `freeRemaining` / `freeAllowance` off the same `useWallet()` the Plan page's free meter uses, so the sidebar and Plan can't disagree | | `useOtherAppSwitch` | `null` — core ships no processor | gated on `portalAccess` (`/api/v1/auth/me` in SaaS, the Spring session flag self-hosted) | | Link-account CTA | n/a | unchanged conditions — passed in as `accountExtras`, still only when `linkState === "unlinked"`, still a no-op in SaaS | - The processor reads the meter through its own `@portal/hooks/useFreeCreditsSummary` rather than the editor's `@app` one. Self-hosted resolves `@app/*` as proprietary → core, where the cloud wallet hook isn't in the cascade, and the implementation can't live in `proprietary/` because core/desktop builds ship no portal and must never resolve `@portal`. Keeping it in `portal/` gets the figure to the linked self-hosted processor without weakening that rule; it reads the same `GET /api/v1/payg/wallet` the Usage page's trial meter already renders, gated on link state and behind the portal's query cache. `portal-saas/` just re-exports the cloud hook, so both footers share one fetch. The processor-access gate previously lived in two near-identical `AppSwitcher` copies. It moves into `useOtherAppSwitch`, `AppSwitcher` now reads it too, and the duplicate `saas/components/shared/AppSwitcher.tsx` is deleted — the logo switcher and the footer row can no longer disagree about access. ## Profile picture sync One `useAccountIdentity` hook now backs the editor footer, the processor footer and the account settings page. Previously settings derived its initial from `email[0]` while the sidebar used `displayName[0]`, and the two drew different blue discs. Alongside that, the shared `Avatar`: - falls back to initials when a picture URL fails to load, instead of leaving an empty disc - renders one letter for single-word names (`admin` → "A", not "AD") - gains an `xl` size so the settings hero disc is the same component ## Notes - Labelled **"Free credits"** rather than "free monthly credits": `freeAllowance` is documented as a one-time lifetime grant, not a monthly reset, so "monthly" would misdescribe the data. Happy to change if the backend semantics differ from the type comments. ## Testing - `task frontend:check` and `task frontend:typecheck:all` pass (all 9 build variants). - 9 new `Shared/NavFooter` stories pass the Chromium + axe story scan; `frontend:storybook:a11y:changed` reports no regressions. - Stubbed E2E suite passes, including the `config-button` tour/settings specs that target the account row. Two failures (`console-clean › landing`, `viewer-text-selection › Ctrl+C`) also fail on `origin/main` locally — they need a backend on :8080 and clipboard permissions. --- .../public/locales/en-US/translation.toml | 23 +- .../config/configSections/usageMeters.tsx | 22 +- .../src/cloud/hooks/useFreeCreditsSummary.ts | 50 ++++ .../editor/src/cloud/hooks/useOpenPlan.ts | 13 ++ frontend/editor/src/cloud/hooks/useWallet.ts | 90 +++++++- .../src/core/components/shared/BrandMark.css | 46 ++++ .../core/components/shared/FileSidebar.css | 92 +------- .../core/components/shared/FileSidebar.tsx | 130 ++--------- .../components/shared/navFooter/NavFooter.css | 156 +++++++++++++ .../shared/navFooter/NavFooter.stories.tsx | 119 ++++++++++ .../shared/navFooter/NavFooter.test.tsx | 58 +++++ .../components/shared/navFooter/NavFooter.tsx | 213 ++++++++++++++++++ .../shared/navFooter/NavFooterCreditsRow.css | 83 +++++++ .../shared/navFooter/NavFooterCreditsRow.tsx | 158 +++++++++++++ .../src/core/hooks/useAccountIdentity.ts | 64 ++++++ .../src/core/hooks/useFreeCreditsSummary.ts | 12 + frontend/editor/src/core/hooks/useOpenPlan.ts | 10 + .../src/core/hooks/useOtherAppSwitch.ts | 12 + frontend/editor/src/core/query/keys.ts | 3 + .../src/core/services/navFooterCache.ts | 73 ++++++ frontend/editor/src/core/ui/Avatar.css | 6 + frontend/editor/src/core/ui/Avatar.tsx | 27 ++- .../hooks/useFreeCreditsSummary.ts | 7 + .../src/portal-saas/hooks/useOpenPlan.ts | 11 + .../editor/src/portal/components/Sidebar.css | 8 +- .../editor/src/portal/components/Sidebar.tsx | 29 ++- .../billing/PrepaidCapacityCard.tsx | 9 +- .../portal/components/billing/WalletMeter.tsx | 26 ++- .../hooks/useFreeCreditsSummary.test.tsx | 110 +++++++++ .../src/portal/hooks/useFreeCreditsSummary.ts | 65 ++++++ .../editor/src/portal/hooks/useOpenPlan.ts | 13 ++ frontend/editor/src/portal/queries/keys.ts | 2 + .../editor/src/proprietary/billing/format.ts | 23 ++ .../editor/src/proprietary/billing/index.ts | 1 + .../components/shared/AppSwitcher.tsx | 20 +- .../proprietary/hooks/useOtherAppSwitch.ts | 15 ++ .../saas/components/shared/AppSwitcher.tsx | 41 ---- .../shared/config/configSections/Overview.tsx | 24 +- .../src/saas/hooks/useOtherAppSwitch.ts | 16 ++ .../src/saas/hooks/usePortalAccess.test.tsx | 43 +++- .../editor/src/saas/hooks/usePortalAccess.ts | 76 ++++--- .../src/saas/hooks/useWallet.poll.test.tsx | 158 +++++++++++++ 42 files changed, 1804 insertions(+), 353 deletions(-) create mode 100644 frontend/editor/src/cloud/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/cloud/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.css create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx create mode 100644 frontend/editor/src/core/hooks/useAccountIdentity.ts create mode 100644 frontend/editor/src/core/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/core/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/core/hooks/useOtherAppSwitch.ts create mode 100644 frontend/editor/src/core/services/navFooterCache.ts create mode 100644 frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/portal-saas/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/portal/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts delete mode 100644 frontend/editor/src/saas/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/saas/hooks/useOtherAppSwitch.ts create mode 100644 frontend/editor/src/saas/hooks/useWallet.poll.test.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 850f69ca02..12ad26ceec 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index 8811e537b2..c4407cae7f 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -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 }) { { + if (live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet]); + + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/cloud/hooks/useOpenPlan.ts b/frontend/editor/src/cloud/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..4d532319f6 --- /dev/null +++ b/frontend/editor/src/cloud/hooks/useOpenPlan.ts @@ -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]); +} diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0a3f78b3ce..ed3cb2ce6b 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -32,6 +32,14 @@ * promise see the UI flip exactly once the new state is visible — no * intermediate flash of the old value. * + *

    Freshness

    + * + * 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. + * *

    Dev preview fallback

    * * 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 | 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("/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 | 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 diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css index 7ddff9b4c7..df05ff1307 100644 --- a/frontend/editor/src/core/components/shared/BrandMark.css +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -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; } } diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 590f59fa7d..2347d9a2b2 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -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 : 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; -} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 2514e7f556..1c06236027 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -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( 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(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([]); @@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef( )} data-testid="open-files-page" > - + ( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — account footer (avatar + name + settings). */} - - {/* Bottom bar: user name + settings */} - -
    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} - > -
    - {showProfilePicture ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() - )} -
    - {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
    - -
    - )} -
    -
    -
    + {/* Box 3 — the shared footer: credits, app switch, account row. */} +
    ); }, diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.css b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css new file mode 100644 index 0000000000..aa2688cd35 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css @@ -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; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx new file mode 100644 index 0000000000..31a04be596 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx @@ -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 = { + title: "Shared/NavFooter", + component: NavFooter, + parameters: { layout: "padded" }, + args: { + displayName: "admin", + onOpenSettings: () => {}, + credits: { remaining: 247, total: 500 }, + onOpenPlan: () => {}, + otherApp: { app: "processor", onOpen: () => {} }, + }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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: ( + } + /> + ), + }, +}; + +/** 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: }, +}; + +/** A real profile picture replaces the initials disc. */ +export const WithProfilePicture: Story = { + args: { + displayName: "Ada Lovelace", + profilePictureUrl: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +/** 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) => ( +
    + +
    + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx new file mode 100644 index 0000000000..a32063e56f --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx @@ -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 {ui}; +} + +function renderFooter() { + const { container } = render( + withProviders( + {}} + 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( + {}} + 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); + }); +}); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx new file mode 100644 index 0000000000..373c91bccf --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -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 " + * 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: ( + + ), + }); + } + + if (otherApp) { + rows.push({ + key: "switch", + node: ( + + + + ), + }); + } + + rows.push({ + key: "account", + node: ( + + + + ), + }); + + return ( + + {rows.map((row) => ( +
    + {row.node} +
    + ))} +
    + ); +} + +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"); +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css new file mode 100644 index 0000000000..b37def6b2d --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css @@ -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; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx new file mode 100644 index 0000000000..94e941d71c --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx @@ -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 ( + + + {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. + 0 ? remaining / total : 0} + tone={tone} + label={`${label}: ${count}`} + /> + ) : ( + <> +
    + + {label} + {count} +
    + 0 ? remaining / total : 0} + height={6} + color={`var(--c-${tone})`} + label={`${label}: ${count}`} + /> + + )} +
    +
    + ); +} + +/** 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 ( + + + + + ); +} + +/** + * 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
    {children}
    ; + return ( + + ); +} diff --git a/frontend/editor/src/core/hooks/useAccountIdentity.ts b/frontend/editor/src/core/hooks/useAccountIdentity.ts new file mode 100644 index 0000000000..026ac08dab --- /dev/null +++ b/frontend/editor/src/core/hooks/useAccountIdentity.ts @@ -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(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, + }; +} diff --git a/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..52702a3f74 --- /dev/null +++ b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts @@ -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; +} diff --git a/frontend/editor/src/core/hooks/useOpenPlan.ts b/frontend/editor/src/core/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..da6fe2207e --- /dev/null +++ b/frontend/editor/src/core/hooks/useOpenPlan.ts @@ -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; +} diff --git a/frontend/editor/src/core/hooks/useOtherAppSwitch.ts b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..612589899b --- /dev/null +++ b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts @@ -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; +} diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index a7a68ea256..5354b56b63 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -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; diff --git a/frontend/editor/src/core/services/navFooterCache.ts b/frontend/editor/src/core/services/navFooterCache.ts new file mode 100644 index 0000000000..cbde941c81 --- /dev/null +++ b/frontend/editor/src/core/services/navFooterCache.ts @@ -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)); +} diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index e2e8dc63bd..360c15c566 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -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%; diff --git a/frontend/editor/src/core/ui/Avatar.tsx b/frontend/editor/src/core/ui/Avatar.tsx index c7cfac501b..42e7aca11f 100644 --- a/frontend/editor/src/core/ui/Avatar.tsx +++ b/frontend/editor/src/core/ui/Avatar.tsx @@ -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 ? ( - {ariaLabel + const content = showImage ? ( + {ariaLabel setSrcFailed(true)} + /> ) : ( - {initialsOf(name)} + {avatarInitials(name)} ); diff --git a/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..8f7a16ca90 --- /dev/null +++ b/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts @@ -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"; diff --git a/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts b/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..ce12e843b3 --- /dev/null +++ b/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts @@ -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]); +} diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index dfb533f8ab..48b54b177d 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -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 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; } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index ebf91c636e..8ce7008d67 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -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() { ))} - - - } - onClick={() => openSettings()} - /> - + } + collapsed={collapsed} + /> ); } diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx index c7ab0e8704..6ea4d8d336 100644 --- a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx @@ -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") diff --git a/frontend/editor/src/portal/components/billing/WalletMeter.tsx b/frontend/editor/src/portal/components/billing/WalletMeter.tsx index c8be188390..9558960e89 100644 --- a/frontend/editor/src/portal/components/billing/WalletMeter.tsx +++ b/frontend/editor/src/portal/components/billing/WalletMeter.tsx @@ -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) {
    diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx new file mode 100644 index 0000000000..fb48250fed --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx @@ -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 ( + + {credits ? `${credits.remaining}/${credits.total}` : "none"} + + ); +} + +function renderFor(initialState: LinkState) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ).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")); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..3fd814ebb1 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts @@ -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; +} diff --git a/frontend/editor/src/portal/hooks/useOpenPlan.ts b/frontend/editor/src/portal/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..57b30c91d0 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useOpenPlan.ts @@ -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]); +} diff --git a/frontend/editor/src/portal/queries/keys.ts b/frontend/editor/src/portal/queries/keys.ts index 6c29430cb4..e1c46a59c0 100644 --- a/frontend/editor/src/portal/queries/keys.ts +++ b/frontend/editor/src/portal/queries/keys.ts @@ -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, diff --git a/frontend/editor/src/proprietary/billing/format.ts b/frontend/editor/src/proprietary/billing/format.ts index f91f98af44..ff44e69882 100644 --- a/frontend/editor/src/proprietary/billing/format.ts +++ b/frontend/editor/src/proprietary/billing/format.ts @@ -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, diff --git a/frontend/editor/src/proprietary/billing/index.ts b/frontend/editor/src/proprietary/billing/index.ts index 687adec541..2fe9dffd93 100644 --- a/frontend/editor/src/proprietary/billing/index.ts +++ b/frontend/editor/src/proprietary/billing/index.ts @@ -14,6 +14,7 @@ export { docCapForMoney, formatPeriodDate, meterState, + remainingMeter, PREPAID_MONTHS_GRANTED, PREPAID_MONTHS_PAID, PDFS_PER_USER_MONTH, diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx index 2e02db3068..9ba0b6438d 100644 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx @@ -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 ( navigate(PORTAL_BASENAME)} + onSwitch={otherApp.onOpen} collapsed={collapsed} /> ); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..8bf07b5c2f --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -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) }; +} diff --git a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx deleted file mode 100644 index 364f094478..0000000000 --- a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx +++ /dev/null @@ -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 ( - - ); - } - - return ( - navigate(PORTAL_BASENAME)} - collapsed={collapsed} - /> - ); -} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx index fac87148fe..3a28152425 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx @@ -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 = ({ 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 = ({ 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 = ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
    = ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
    navigate(PORTAL_BASENAME) }; +} diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx index a0e8ba618d..809138850a 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx +++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx @@ -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(cb: () => T) { + return baseRenderHook(cb, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + 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 = () => {}; diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts index 442061cbe1..6e91f0864c 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.ts +++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts @@ -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 { + 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; } diff --git a/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx new file mode 100644 index 0000000000..b72eac67f8 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx @@ -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(); + }); +}); From ec3de16c0862c01190bf45896bae87e9f0e10ca7 Mon Sep 17 00:00:00 2001 From: Ludy Date: Tue, 18 Aug 2026 20:25:55 +0200 Subject: [PATCH 217/262] ci: centralize Gradle caching across GitHub Actions workflows (#7546) ## Summary This pull request restructures Gradle dependency caching across the GitHub Actions workflows. The central `gradle-cache-prime` job is responsible for preparing the shared backend Gradle cache. Reusable workflows restore that shared cache without writing to the same key, while independently triggered workflows use isolated cache namespaces. ## What changed ### Shared Gradle cache - Added a stable `gradle-v1-` cache namespace for the shared backend cache. - The cache key includes the runner OS, runner architecture, JDK version, and the relevant Gradle configuration files. - The cache key is calculated before Gradle runs and reused for the later save step. - The prime job performs a lookup first and resolves backend dependencies only when the exact cache is missing. - This prevents Gradle or Spotless changes during the prime step from producing a different save key from the key used by downstream jobs. ### Reusable workflows - Backend, OpenAPI, license, Docker, E2E, and migration workflows restore the shared cache instead of writing to the shared key. - The backend build matrix includes `matrix.jdk-version` in its cache key. - Enterprise, Tauri, and generated-model workflows support the `use_shared_cache` boolean input. - When `use_shared_cache` is enabled, those workflows restore the shared cache. - When it is disabled, they use workflow-specific cache namespaces. ### Independent workflows Independent workflows now use separate cache prefixes, including: - `gradle-license-report-v1-` - `gradle-swagger-v1-` - `gradle-push-docker-v1-` - `gradle-tauri-releases-v1-` - `gradle-deploy-pr-v1-` - `gradle-playwright-e2e-v1-` - `gradle-generated-models-v1-` This prevents them from creating or affecting the shared backend cache before the prime job. ### Build and E2E flow - Removed the `-PnoSpotless` option from the central Gradle dependency-resolution command. - Removed the separate Gradle dependency prime/retry logic from the live E2E workflow. - Connected the Tauri build and generated-models check to the central cache-prime job. ## Motivation Previously, multiple workflows could use and save the same Gradle cache key independently. The first workflow to save the cache could therefore determine its contents, even if it had resolved a different or incomplete set of dependencies. The cache key was also evaluated after some Gradle tasks had run. If Gradle or Spotless modified a file covered by `hashFiles(...)`, the save key could differ from the restore key used by downstream jobs. This change gives the shared cache a single owner, isolates workflow-specific caches, and makes cache usage deterministic across the CI pipeline. ## Expected result - `gradle-cache-prime` is the single writer for the shared backend Gradle cache. - Downstream jobs restore the same cache without competing cache writes. - Independently triggered workflows remain isolated through their own cache namespaces. - Changes to the monitored Gradle configuration files produce a new cache key. - The normal Gradle/Spotless path is included when the shared cache is populated. ## Validation - Compared the cache key expressions and `hashFiles(...)` inputs across the affected workflows. - Verified that the central restore and save steps use the same precomputed key. - CI should confirm that the prime job populates the shared cache and downstream workflows only restore it. ## Checklist - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have performed a self-review of my changes - [ ] I have run the relevant CI checks - [ ] I have tested the workflow changes --- .../workflows/PR-Demo-Comment-with-react.yml | 19 +++--- .github/workflows/backend-build.yml | 21 +++---- .github/workflows/build-enterprise.yml | 33 ++++++++--- .github/workflows/build.yml | 59 ++++++++++++++----- .github/workflows/check-generated-models.yml | 30 +++++++--- .github/workflows/check-licence.yml | 19 +++--- .github/workflows/check-openapi.yml | 19 +++--- .github/workflows/coverage-aggregate.yml | 19 +++--- .github/workflows/db-migration-test.yml | 19 +++--- .github/workflows/docker-compose-tests.yml | 19 +++--- .github/workflows/e2e-live.yml | 38 ++++-------- .../frontend-backend-licenses-update.yml | 19 +++--- .github/workflows/multiOSReleases.yml | 57 ++++++++---------- .github/workflows/push-docker.yml | 19 +++--- .github/workflows/swagger.yml | 19 +++--- .github/workflows/tauri-build.yml | 33 +++++++---- .github/workflows/test-build-docker.yml | 19 +++--- 17 files changed, 235 insertions(+), 226 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index fe3f28a637..410aa82dc9 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -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 diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 54bd4cb907..6623940bce 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -35,23 +35,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) @@ -156,7 +153,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() diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 0604f7176f..b4a8373ccc 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -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: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2f50249099..566262d24f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,29 +73,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: @@ -170,6 +189,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' @@ -193,7 +214,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 @@ -205,7 +233,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 @@ -219,6 +247,7 @@ jobs: with: platform: windows-macos sign: true + use_shared_cache: true ai-engine: if: needs.files-changed.outputs.engine == 'true' @@ -242,6 +271,8 @@ jobs: pull-requests: write uses: ./.github/workflows/check-generated-models.yml secrets: inherit + with: + use_shared_cache: true pre-commit: needs: [files-changed] diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 39c6467889..fafffcc241 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -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] @@ -39,22 +44,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 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 2eec970b8f..17c64d5c64 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -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 diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index f224ce18cf..ed83447335 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -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 diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index a97b579f15..61ef8793c4 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -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: diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index d6a61b45c4..ccb46d3988 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -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 diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 9d5911404f..039c73e5db 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -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 diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 7bc95df05e..43d66dd1cf 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -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: diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 458766660f..7fd0136718 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -348,22 +348,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 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index d9477f722f..0f0b2d3585 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -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 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index ec9d14822c..ea379cf7c6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -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 diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 115de87d4e..1bfc94be5b 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -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 diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index ddf1104bac..e3f3122773 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -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 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4a79cb3733..12d5a35a1f 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -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 From 6f7f28946c6643aecd96da043e9a1d1549193437 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:19:26 +0000 Subject: [PATCH 218/262] Set deployment: false on environment jobs that do not deploy (#7562) # Description of Changes thanks ludy for the tip :P --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/workflows/PR-Auto-Deploy-V2.yml | 5 ++++- .github/workflows/PR-Demo-cleanup.yml | 5 ++++- .github/workflows/backend-build.yml | 4 +++- .github/workflows/build-enterprise.yml | 8 ++++++-- .github/workflows/build.yml | 4 +++- .github/workflows/check-licence.yml | 4 +++- .github/workflows/check-openapi.yml | 4 +++- .github/workflows/db-migration-test.yml | 4 +++- .github/workflows/docker-compose-tests.yml | 4 +++- .github/workflows/e2e-live.yml | 4 +++- .github/workflows/frontend-backend-licenses-update.yml | 8 ++++++-- .github/workflows/multiOSReleases.yml | 8 ++++++-- .github/workflows/nightly.yml | 4 +++- .github/workflows/tauri-build.yml | 4 +++- .github/workflows/test-build-docker.yml | 4 +++- 15 files changed, 56 insertions(+), 18 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 0f07aabbe5..4375b1b8b0 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -462,7 +462,10 @@ jobs: }); cleanup-v2-deployment: - environment: pr-preview + # Tearing a preview down is not a deployment - no deployment object. + environment: + name: pr-preview + deployment: false if: github.event.action == 'closed' runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 1407939994..098f8d7803 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -9,7 +9,10 @@ permissions: jobs: cleanup: - environment: pr-preview + # Tearing a preview down is not a deployment - no deployment object. + environment: + name: pr-preview + deployment: false if: github.event.action == 'closed' runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 6623940bce..596be96e8c 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -20,7 +20,9 @@ permissions: jobs: build: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index b4a8373ccc..194d86d9da 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -42,7 +42,9 @@ jobs: uses: ./.github/workflows/_runner-pick.yml playwright-e2e-enterprise: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false needs: pick # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE, # so the suite can't boot premium and would fail. See the header comment. @@ -325,7 +327,9 @@ jobs: # Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml) # and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel). multinode-e2e: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false needs: [pick, playwright-e2e-enterprise] # Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret. if: >- diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 566262d24f..1feaff2560 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,7 +61,9 @@ jobs: filters: .github/config/.files.yaml gradle-cache-prime: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false name: Prime shared Gradle cache needs: [files-changed] runs-on: ubuntu-latest diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 17c64d5c64..4e04a83656 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -10,7 +10,9 @@ permissions: jobs: check-licence: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest steps: - name: Harden Runner diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index ed83447335..bc9b302857 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -11,7 +11,9 @@ permissions: jobs: check-generate-openapi-docs: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest steps: - name: Harden Runner diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index ccb46d3988..785073944e 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -13,7 +13,9 @@ permissions: jobs: migration-test: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 039c73e5db..439d4240b2 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -17,7 +17,9 @@ permissions: jobs: docker-compose-tests: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest permissions: actions: write diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 43d66dd1cf..844d26a3d1 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -11,7 +11,9 @@ permissions: jobs: playwright-e2e-live: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 7fd0136718..9aef2316d2 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -43,7 +43,9 @@ jobs: generate-frontend-license-report: # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only. - environment: ci-bot + environment: + name: ci-bot + deployment: false if: needs.files-changed.outputs.licenses-frontend == 'true' name: Generate Frontend License Report needs: files-changed @@ -319,7 +321,9 @@ jobs: generate-backend-license-report: # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only. - environment: ci-bot + environment: + name: ci-bot + deployment: false if: needs.files-changed.outputs.licenses-backend == 'true' needs: files-changed name: Generate Backend License Report diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 0f0b2d3585..0ac94ffe68 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -38,7 +38,9 @@ permissions: jobs: determine-matrix: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest outputs: @@ -116,7 +118,9 @@ jobs: env: INPUT_PLATFORM: ${{ github.event.inputs.platform }} build-jars: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false needs: determine-matrix runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c92d17f027..65c25b7b66 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -127,7 +127,9 @@ jobs: # Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run # of every other feature. cucumber-nightly: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false name: Cucumber (nightly scenarios + full concurrency) runs-on: ubuntu-latest # Fork pull requests get no MAVEN_* secrets, so the image build cannot work. diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index e3f3122773..0a82647690 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -63,7 +63,9 @@ jobs: determine-matrix: # Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted # signing environment - release-signing would block every PR run. - environment: ci-signing + environment: + name: ci-signing + deployment: false if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest outputs: diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 12d5a35a1f..37cb7cb546 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -37,7 +37,9 @@ jobs: # spring-security=true matrix entry if `task backend:build` and # `task backend:build:ci` produce equivalent JARs (verify before wiring). test-build-docker-images: - environment: ci-unsigned + environment: + name: ci-unsigned + deployment: false runs-on: ubuntu-latest strategy: fail-fast: false From 0f8803f35f14cf8e9c191cfc5b6101a33586674c Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:27:39 +0000 Subject: [PATCH 219/262] Require the policy-management role to run a policy against its sources (#7565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Running a stored policy against its **configured sources** (`POST /api/v1/policies/{id}/trigger`, the manual "run now") now requires the policy-management role — global admin self-hosted, team leader on SaaS — alongside the existing team scoping. ## Why A source sweep operates on the team's configured sources using the server's stored connection credentials, so it belongs with the other policy-management capabilities rather than with ordinary use. Team scoping on its own didn't express that distinction. ## Not changed - `POST /{id}/run` — running a policy over documents the **caller supplied** stays open to every team member. That's ordinary editor enforcement on upload and export, and gating it would break it. - Ad-hoc pipelines (`/run`, `/run/stream`). - The scheduled, folder-watch and webhook triggers. - Single-user deployments (login disabled), which have no roles. ## Implementation `PolicyManagementAuthority` gains `canTriggerPolicies()`, kept separate from `canEditPolicies()` so the two capabilities can diverge later. Both current implementations grant it to the same principals that may edit policies. ## Tests - role absent → 403, rejected before any run starts - role present → 202 - login disabled → check skipped entirely - `/{id}/run` asserted to consult neither authority method, so the gate can't quietly extend to the editor path later --- .../AdminPolicyManagementAuthority.java | 5 ++ .../config/PolicyManagementAuthority.java | 11 +++ .../policy/controller/PolicyController.java | 30 +++++++- .../AdminPolicyManagementAuthorityTest.java | 12 +++ .../controller/PolicyControllerTest.java | 74 +++++++++++++++++++ .../TeamLeaderPolicyManagementAuthority.java | 5 ++ ...amLeaderPolicyManagementAuthorityTest.java | 12 +++ 7 files changed, 145 insertions(+), 4 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java index a49c8e5aa9..6d8229aa26 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java @@ -26,6 +26,11 @@ public class AdminPolicyManagementAuthority implements PolicyManagementAuthority return userService.isCurrentUserAdmin(); } + @Override + public boolean canTriggerPolicies() { + return userService.isCurrentUserAdmin(); + } + @Override public Long currentUserTeamId() { String username = userService.getCurrentUsername(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java index 0ea3c298ad..d7e4f50ad1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java @@ -12,6 +12,17 @@ public interface PolicyManagementAuthority { /** Whether the current user may create, edit, or delete policies (for their own team). */ boolean canEditPolicies(); + /** + * Whether the current user may run a policy against its configured sources (the manual + * "run now" sweep). Kept separate from {@link #canEditPolicies()} because the two are distinct + * capabilities, even where a deployment grants both to the same people: a sweep operates on the + * team's configured sources using the server's stored connection credentials, which makes it a + * policy-management capability rather than ordinary use. Running a policy over the caller's + * own uploaded files is not covered by this and stays open to every team member — that + * is ordinary editor enforcement. + */ + boolean canTriggerPolicies(); + /** * The team that scopes the current user's policies — the team a new policy is stamped with and * the only team whose policies the user may see/run/edit. {@code null} when it can't be diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 506bb75578..778a04e169 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -432,9 +432,10 @@ public class PolicyController { * admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by * {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume * re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two - * covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login - * disabled) have no such role, so they trust the local operator. The path allowlist for folder - * sources/outputs is enforced separately by {@link PolicyValidator} at validation time. + * covers them all; runs over the caller's own files ({@code /{id}/run}) stay open to the team, + * while source sweeps are gated by {@link #requirePolicySweepAllowed}. Single-user deployments + * (login disabled) have no such role, so they trust the local operator. The path allowlist for + * folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time. */ private void requirePolicyEditingAllowed() { if (!applicationProperties.getSecurity().isEnableLogin()) { @@ -447,6 +448,25 @@ public class PolicyController { } } + /** + * Sweeping a policy's configured sources requires the same role as managing policies: the sweep + * operates on the team's configured sources using the server's stored connection credentials, + * which makes it a policy-management capability rather than ordinary use, and team scoping on + * its own does not express that. Deliberately narrower than it looks: it gates only the sweep, + * not {@link #runStoredPolicy}, because running a policy over documents the caller supplied is + * ordinary editor enforcement that every member performs on upload and export. + */ + private void requirePolicySweepAllowed() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + return; + } + if (!policyManagementAuthority.canTriggerPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Not permitted to run this policy against its configured sources"); + } + } + @GetMapping @Operation( summary = "List policies", @@ -571,8 +591,10 @@ public class PolicyController { + " the enabled flag (which only gates automatic triggering). Returns" + " the ids of the runs started (poll the run-status endpoint for each)" + " plus what the sweep skipped - already-processed, parked-by-failure," - + " and in-flight counts - so an empty result explains itself.") + + " and in-flight counts - so an empty result explains itself. Requires" + + " the policy-management role.") public ResponseEntity trigger(@PathVariable String policyId) { + requirePolicySweepAllowed(); Policy policy = policyStore .get(policyId) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java index 811aae6b4f..0f97fcaf62 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java @@ -39,6 +39,18 @@ class AdminPolicyManagementAuthorityTest { assertFalse(authority().canEditPolicies()); } + @Test + void adminMayTriggerPolicies() { + when(userService.isCurrentUserAdmin()).thenReturn(true); + assertTrue(authority().canTriggerPolicies()); + } + + @Test + void nonAdminMayNotTriggerPolicies() { + when(userService.isCurrentUserAdmin()).thenReturn(false); + assertFalse(authority().canTriggerPolicies()); + } + @Test void currentUserTeamIdResolvesFromTheCurrentUsersTeam() { Team team = new Team(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 84e9998b90..2fa675597c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -738,5 +739,78 @@ class PolicyControllerTest { assertThat(((ResponseStatusException) e).getStatusCode()) .isEqualTo(HttpStatus.NOT_FOUND)); } + + @Test + @DisplayName("trigger is forbidden for a team member who cannot manage policies") + void triggerForbiddenForMember() { + // Sweeping a policy's configured sources is a policy-management capability, so being + // in the policy's team is not on its own enough to perform it. + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canTriggerPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.trigger("a")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + // Rejected before the policy is looked up, so no run starts. + verify(policyRunner, never()).run(any()); + verify(policyStore, never()).get(any()); + } + + @Test + @DisplayName("trigger runs for a caller who may manage policies") + void triggerAllowedForLeader() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canTriggerPolicies()).thenReturn(true); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0); + when(policyRunner.run(p)).thenReturn(outcome); + + ResponseEntity response = controller.trigger("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody()).isEqualTo(outcome); + } + + @Test + @DisplayName("trigger skips the role check when login is disabled") + void triggerTrustsTheLocalOperator() { + // Single-user deployments have no roles at all; the gate must not lock them out of + // their + // own sweeps. + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", null); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0); + when(policyRunner.run(p)).thenReturn(outcome); + + assertThat(controller.trigger("a").getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + verify(policyManagementAuthority, never()).canTriggerPolicies(); + } + + @Test + @DisplayName("running a policy over the caller's own files stays open to any member") + void storedRunIsNotGatedByRole() { + // Editor enforcement: every member's upload/export runs the team's stored policies on + // their own documents. Gating this the way the sweep is gated would break the editor. + applicationProperties.getSecurity().setEnableLogin(true); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-9")); + + ResponseEntity> response = + assertDoesNotThrow(() -> controller.runStoredPolicy("a", new PolicyRunFiles())); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + verify(policyManagementAuthority, never()).canTriggerPolicies(); + verify(policyManagementAuthority, never()).canEditPolicies(); + } } } diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java index e2f5b65b47..0ce5e8e308 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java @@ -25,6 +25,11 @@ public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuth return teamSecurity.isCurrentUserTeamLeader(); } + @Override + public boolean canTriggerPolicies() { + return teamSecurity.isCurrentUserTeamLeader(); + } + @Override public Long currentUserTeamId() { return teamSecurity.currentUserTeamId(); diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java index 70cd360d7c..2c37980a5c 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java @@ -32,6 +32,18 @@ class TeamLeaderPolicyManagementAuthorityTest { assertFalse(authority().canEditPolicies()); } + @Test + void teamLeaderMayTriggerPolicies() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true); + assertTrue(authority().canTriggerPolicies()); + } + + @Test + void nonLeaderMayNotTriggerPolicies() { + when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false); + assertFalse(authority().canTriggerPolicies()); + } + @Test void currentUserTeamIdDelegatesToTeamSecurity() { when(teamSecurity.currentUserTeamId()).thenReturn(9L); From 6bae9d516dc029869fee80ec5c2e92da89a5d541 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:46:00 +0000 Subject: [PATCH 220/262] chore(saas): one task per environment, and make the frontend follow it (#7483) ## The problem The `dev` profile hardcoded one project ref (`qacaivhsjtftfwtgjvva`) in five places: the ref, the Supabase URL, the publishable key, the datasource host and the meter endpoint. That made it both the shared environment everyone relies on *and* the only thing you could point the backend at. Testing an open SaaS PR meant hand-overriding all five via env just to reach that PR's Supabase preview branch, which is the only place the PR's migrations have actually been applied. Get it wrong and you see `relation "stirling_pdf." does not exist` for a table the PR added, which is what happened on [#7414](https://github.com/Stirling-Tools/Stirling-PDF/pull/7414). ## One task per environment ```bash task dev:saas # backend + frontend + engine, against this PR's preview branch task staging:saas # backend + frontend + engine, against the shared v3 project task backend:dev:saas # backend only, preview branch task backend:staging:saas # backend only, v3 ``` | | how | vars | project | |---|---|---|---| | prod | `PROFILES=none` | `SAAS_DB_*` | the live one | | staging | `PROFILES=staging` | `SAAS_STAGING_*` | pinned to v3, always there | | dev | `PROFILES=dev` | `SAAS_DEV_*` | follows a SaaS PR's preview branch | `PROFILES` is still the underlying switch, so the old spelling keeps working. Production deliberately has no named task: reaching it should take a conscious `PROFILES=none`, not a tab-complete. **staging** is the old `dev` configuration, moved and kept pinned. The value of a shared environment is that it is still there tomorrow: reproduce a bug, paste a link to a colleague, share data. **dev** is parameterised by `SAAS_DEV_PROJECT_REF` and derives the Supabase URL, JWT issuer, JWKS, meter endpoint and (unless overridden) the database host from it. Switching which PR you are testing is one variable instead of five. With no ref set, `task backend:dev:saas` stops and says what to set rather than falling back. ## The frontend was the real gap `frontend/editor/.env` is committed and pins the **production** Supabase project, and nothing in the frontend knew about dev or staging. So `task dev:saas` gave you a backend on a preview branch and a login against prod, unless you happened to have hand-written `frontend/editor/.env.saas.local`. The dev tasks now read the backend's env files and derive `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` from the same project ref the backend resolved, so the two halves cannot point at different projects. Nothing to keep in sync by hand, no new vite mode, and `SAAS_ENV=prod` opts back out to the committed values. ## Where to put your local values Two files, both gitignored, neither ever committed: **`app/.env.saas.local`** is the only one you normally need. The tasks load it for the backend *and* the frontend. ```bash # staging: everything else is already defaulted, so this is all it takes SAAS_STAGING_DB_PASSWORD=... # dev: the preview branch of the PR you are testing, from its "Supabase Preview" check. # A branch has its OWN password and API keys; the parent project's will not authenticate. SAAS_DEV_PROJECT_REF=... SAAS_DEV_DB_PASSWORD=... SAAS_DEV_PUBLISHABLE_KEY=... # prod, if you ever need it SAAS_DB_PROJECT_REF=... SAAS_DB_URL=... SAAS_DB_PASSWORD=... SUPABASE_EDGE_FUNCTION_SECRET=... ``` **`frontend/editor/.env.saas.local`** is no longer needed for choosing a Supabase project, and is best left empty or deleted. If you have one from before this PR, note that the task-supplied values now win, which is the point: the frontend follows the backend. **A blank is not the same as absent.** A dotenv line with an empty value still *sets* the variable, and Spring's `${VAR:default}` only falls back when a variable is absent. So `.env.saas` lists what you must set as blanks, and leaves out the two `*_DB_URL` overrides, which have real defaults to fall back to. This is not theoretical, see below. Committed `app/.env.saas` holds non-secret defaults only. Real secrets are passwords, the edge-function secret and service-role keys. Project refs and publishable keys are neither: a ref is the public `.supabase.co` subdomain and a publishable key ships in the browser bundle by design, which is why `frontend/editor/.env` has always carried prod's. ## Three bugs found while building the tasks All three were in this PR's own earlier commits, and all three were caught by actually booting things rather than by reading the config. **staging could not boot at all.** A blank `SAAS_STAGING_DB_URL=` in `.env.saas` set the variable to empty, so `${SAAS_STAGING_DB_URL:jdbc:...}` resolved to `""` and startup failed with `spring.datasource.url is required when the saas profile is active`. The file already carried a comment warning about exactly this; it had only been applied to the dev block. The original verification for this PR was "placeholders resolve" and "the task parses", neither of which boots anything. **The dev to staging fallback ran `ddl-auto=update` against shared v3.** The dev profile sets `update`, which is right for a disposable preview branch, and separately fell back to staging's project ref. Together that meant Hibernate was free to reconcile tables that RLS policies depend on. `application-staging.properties` pins `none`, but that only applies when the staging profile is the active one, which it was not on the fallback path. There is no fallback now: with no ref the task stops before gradle, and the frontend fails the same way, both naming the variable. **`PROFILES=` never selected production.** Go template `default` treats `""` as absent, so it silently resolved back to `dev`. It is `PROFILES=none` now. ## Two choices worth reviewing **Staging keeps its committed project ref**, now as a `${SAAS_STAGING_PROJECT_REF:...}` default in one place, with the URL, database host and meter endpoint all derived from it. So staging still works with zero setup, and repointing it is one variable. Nothing in CI referenced the ref or the profile. Its publishable key default carries no inline `gitleaks:allow`: a trailing comment in a `.properties` file is part of the value, so the pragma ended up inside the key. It is in `.gitleaksignore` instead. **`SAAS_DEV_DB_URL` still overrides the whole URL**, so a branch needing the pooler host rather than the direct one is reachable without touching committed config. ## Verification - `task backend:staging:saas` boots against v3 and serves `200`. It could not boot before this commit. - `task backend:dev:saas` with no ref stops before gradle naming the variable, and `PROFILES=none` still reaches production. `task frontend:dev:saas` fails the same way; `SAAS_ENV=staging` still resolves with no local config. - Frontend routing picks the SaaS runner for dev/staging and the plain runner for prod; the derivation returns the right URL and key for each. - Vite's `process.env` precedence and Task's dotenv/env semantics were measured, not assumed. That is how one trap surfaced: Task sets an `env:` key even when its value resolves to empty, and Vite treats an empty `process.env` `VITE_*` as authoritative over a committed `.env`. Putting the Supabase vars on the shared `dev:_run` would have blanked Supabase config for the core, proprietary and desktop dev servers, so the SaaS path has its own runner. - `:saas:spotlessApply` and `:saas:compileJava` green. `DevProfileProjectNotice` becomes `SaasProjectNotice` and covers both profiles, stating the project ref and `ddl-auto` at startup so which environment you are on is never a guess. No behaviour change for prod: the `saas` profile is untouched. --- .gitleaksignore | 5 ++ .taskfiles/backend.yml | 55 ++++++++++++-- .taskfiles/frontend.yml | 74 ++++++++++++++++--- Taskfile.yml | 21 +++++- app/.env.saas | 52 ++++++++----- .../saas/config/SaasProjectNotice.java | 53 +++++++++++++ .../main/resources/application-dev.properties | 38 ++++++---- .../resources/application-staging.properties | 39 ++++++++++ 8 files changed, 286 insertions(+), 51 deletions(-) create mode 100644 app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java create mode 100644 app/saas/src/main/resources/application-staging.properties diff --git a/.gitleaksignore b/.gitleaksignore index 12d98aebeb..c3917e985f 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase. # Supabase publishable key (public by design, RLS-protected) used as a CI fallback # default in the tauri-build workflow when the GitHub secret is unset - not a real secret. .github/workflows/tauri-build.yml:generic-api-key:402 + +# Staging Supabase publishable key (public by design). Ignored here rather than with an +# inline gitleaks:allow because a trailing comment in a .properties file is part of the +# value, so the pragma would end up inside the key. +app/saas/src/main/resources/application-staging.properties:generic-api-key:16 diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 63773f61fc..08a12b9535 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -57,16 +57,57 @@ tasks: - cmd: ./gradlew clean bootRun -PbuildWithFrontend=true platforms: [linux, darwin] + # SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3, + # PROFILES=none -> production against your own SAAS_DB_*. Production has no named + # task on purpose. Use `none`, not an empty value: Go template `default` treats "" + # as absent and would resolve back to dev. + dev:saas: - desc: "Start backend in SaaS flavor against Supabase" - # `dotenv:` reads from the root Taskfile's directory (".") because this - # subtaskfile is included with `dir: .`. + desc: "Start SaaS backend against the current PR's Supabase preview branch" + dotenv: ['app/.env.saas.local', 'app/.env.saas'] + vars: + PROFILES: '{{.PROFILES | default "dev"}}' + cmds: + # Don't move this check into a `sh:` var: dotenv is visible in cmds but not + # during var evaluation, so the test would always see an empty value. + - cmd: | + if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then + echo ">> SAAS_DEV_PROJECT_REF is not set." + echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local." + echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead." + exit 1 + fi + - task: _run:saas + vars: + PORT: '{{.PORT}}' + PROFILES: '{{.PROFILES}}' + AIENGINE_URL: '{{.AIENGINE_URL}}' + AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + + staging:saas: + desc: "Start SaaS backend against the shared v3 staging project" + cmds: + - task: _run:saas + vars: + PORT: '{{.PORT}}' + PROFILES: staging + AIENGINE_URL: '{{.AIENGINE_URL}}' + AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' + AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + + _run:saas: + internal: true dotenv: ['app/.env.saas.local', 'app/.env.saas'] ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' - # Override to "" to run the pure `saas` profile against your own SAAS_DB_*. PROFILES: '{{.PROFILES | default "dev"}}' + # Built here rather than inline in the cmds below: the Windows line is an + # unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X + # "none"}} needs escaped quotes that reach the Go template as literal + # backslashes and fail with `unexpected "\" in operand`. + PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}' AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' @@ -77,9 +118,11 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' cmds: - - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}" + # PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile + # against SAAS_DB_* (production). + - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}" platforms: [windows] - - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}} + - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}} platforms: [linux, darwin] build: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 481a225ce1..f5325c9ed7 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -5,6 +5,14 @@ version: '3' # mode flag) or use `--project editor/...` for tsc — so the editor lives # under frontend/editor/ without each task needing a cd. +vars: + # Dev-only browser-tab label so concurrent worktrees are distinguishable. Only + # the worktree folder basename (e.g. "wt1") is exposed — never the full path, + # hostname, or user. Dropped from production builds. + DEV_LABEL: + sh: >- + {{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}} + tasks: install: desc: "Install dependencies" @@ -80,16 +88,52 @@ tasks: OPEN: '{{.OPEN | default ""}}' env: BACKEND_URL: '{{.BACKEND_URL}}' - # Dev-only browser-tab label so concurrent worktrees are distinguishable. - # Only the worktree folder basename (e.g. "wt1") is exposed — never the - # full path, hostname, or user. Consumed at dev-serve time by vite.config - # and dropped from production builds. - STIRLING_DEV_LABEL: - sh: >- - {{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}} + STIRLING_DEV_LABEL: '{{.DEV_LABEL}}' cmds: - npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}} + # Separate from dev:_run rather than a flag on it: Task sets an `env:` key even + # when its value resolves to empty, and Vite treats an empty process.env VITE_* as + # authoritative over the committed editor/.env, so folding these in blanks Supabase + # config for the core, proprietary and desktop dev servers. + dev:_run:saas: + internal: true + ignore_error: true + # The backend's own env files, so both halves target one project. Paths are + # relative to this taskfile's dir, `frontend`. + dotenv: ['../app/.env.saas.local', '../app/.env.saas'] + vars: + PORT: '{{.PORT | default "5173"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + OPEN: '{{.OPEN | default ""}}' + SAAS_ENV: '{{.SAAS_ENV | default "dev"}}' + env: + BACKEND_URL: '{{.BACKEND_URL}}' + STIRLING_DEV_LABEL: '{{.DEV_LABEL}}' + SAAS_ENV: '{{.SAAS_ENV}}' + # A real process.env VITE_* beats a committed .env in Vite (loadEnv applies + # process.env last), which is what lets this override editor/.env. + # + # These must stay `sh:`, not Go templates: dotenv values are visible to Task's + # embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is + # always empty. + VITE_SUPABASE_URL: + sh: | + case "${SAAS_ENV:-dev}" in + staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;; + *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;; + esac + echo "https://${ref}.supabase.co" + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: + sh: | + case "${SAAS_ENV:-dev}" in + staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; + *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; + esac + cmds: + - 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"' + - npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}} + dev: desc: "Start frontend dev server" cmds: @@ -111,13 +155,23 @@ tasks: vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } dev:saas: - desc: "Start frontend dev server in SaaS mode" + desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)" deps: - task: prepare vars: { MODE: saas } + vars: + SAAS_ENV: '{{.SAAS_ENV | default "dev"}}' + # prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves + # the committed editor/.env alone. + RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}' cmds: - - task: dev:_run - vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } + - task: '{{.RUNNER}}' + vars: + MODE: saas + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + OPEN: '{{.OPEN}}' + SAAS_ENV: '{{.SAAS_ENV}}' dev:desktop: desc: "Start frontend dev server in desktop mode" diff --git a/Taskfile.yml b/Taskfile.yml index fc7a564032..92dcdcc742 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -99,11 +99,22 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + # Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR. dev:saas: - desc: "Start SaaS backend + frontend concurrently on free ports" + desc: "Start SaaS backend + frontend + engine against the current PR's preview branch" cmds: - task: dev:_all - vars: { FRONTEND: saas, BACKEND: saas } + vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev } + + staging:saas: + desc: "Start SaaS backend + frontend + engine against the shared v3 staging project" + cmds: + - task: dev:_all + vars: + FRONTEND: saas + BACKEND: saas + BACKEND_TASK: backend:staging:saas + SAAS_ENV: staging dev:all: desc: "Start backend + frontend + engine concurrently on free ports" @@ -115,6 +126,9 @@ tasks: vars: FRONTEND: '{{.FRONTEND | default "proprietary"}}' BACKEND: '{{.BACKEND | default "proprietary"}}' + BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}' + # Only meaningful to the saas frontend; every other flavor ignores it. + SAAS_ENV: '{{.SAAS_ENV | default ""}}' PORTS: sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}' BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' @@ -124,7 +138,7 @@ tasks: - task: engine:dev vars: PORT: '{{.ENGINE_PORT}}' - - task: 'backend:dev:{{.BACKEND}}' + - task: '{{.BACKEND_TASK}}' vars: PORT: '{{.BACKEND_PORT}}' AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}' @@ -134,6 +148,7 @@ tasks: PORT: '{{.FRONTEND_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + SAAS_ENV: '{{.SAAS_ENV}}' # ============================================================ # Build diff --git a/app/.env.saas b/app/.env.saas index fb5feec559..25eefb84c5 100644 --- a/app/.env.saas +++ b/app/.env.saas @@ -1,15 +1,16 @@ -############################################################################### -# Stirling-PDF SaaS environment defaults. +# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in +# .env.saas.local, which is loaded first and wins. Do not commit that file. # -# This file is committed and provides non-secret defaults loaded by -# `task backend:dev:saas`. Put real values for secrets (passwords, project -# refs, edge function secrets) in `.env.saas.local` - any variable set there -# takes precedence over what's defined here. +# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref: # -# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in. -############################################################################### +# prod PROFILES=none SAAS_DB_* the live project +# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there +# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch +# +# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when +# testing an open SaaS PR, since its preview branch is the only place those migrations are applied. -# ---------- Supabase project ---------- +# ---------- Supabase project (prod / no-profile) ---------- # Project reference (the subdomain part of .supabase.co). Required. # Set in .env.saas.local. SAAS_DB_PROJECT_REF= @@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF= # Edge function secret used by billing/license rollup calls. Set in .env.saas.local. SUPABASE_EDGE_FUNCTION_SECRET= -# ---------- Database (saas profile) ---------- -# Direct JDBC URL to the Supabase Postgres. Required when running the plain -# `saas` profile (i.e. without `--spring.profiles.include=dev`). +# ---------- Database (no profile) ---------- +# Direct JDBC URL to the Supabase Postgres. Required when running without +# `--spring.profiles.include=...`. # Example: jdbc:postgresql://db..supabase.co:5432/postgres SAAS_DB_URL= SAAS_DB_USERNAME=postgres SAAS_DB_PASSWORD= -# ---------- Database (dev profile overrides) ---------- -# Used when `--spring.profiles.include=dev` is active. The dev profile -# defaults the URL/username to the shared dev Supabase project, but the -# password must still be provided in .env.saas.local. -SAAS_DEV_DB_URL= +# ---------- staging profile ---------- +# The shared long-lived v3 project. application-staging.properties defaults the ref, +# URL, database host and meter endpoint, so staging needs only the password, in +# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives. +# +# The ref and publishable key are duplicated here because the task derives the +# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default. +# Neither is secret: the ref is a public subdomain, the key ships in the bundle. +SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva +SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow +SAAS_STAGING_DB_USERNAME=postgres +SAAS_STAGING_DB_PASSWORD= + +# ---------- dev profile ---------- +# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase +# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and +# database host from it, so this one value follows a different PR. +# +# A preview branch has its own password and keys; the parent project's will not +# authenticate. Both go in .env.saas.local, along with the ref. +SAAS_DEV_PROJECT_REF= +SAAS_DEV_PUBLISHABLE_KEY= SAAS_DEV_DB_USERNAME=postgres SAAS_DEV_DB_PASSWORD= diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java b/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java new file mode 100644 index 0000000000..39305fcbcd --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java @@ -0,0 +1,53 @@ +package stirling.software.saas.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** Logs which Supabase project this backend is talking to, and its schema policy. */ +@Slf4j +@Component +@Profile({"dev", "staging"}) +public class SaasProjectNotice { + + private final Environment environment; + private final String projectRef; + private final String ddlAuto; + + public SaasProjectNotice( + Environment environment, + @Value("${app.supabase.project-ref:unknown}") String projectRef, + @Value("${spring.jpa.hibernate.ddl-auto:none}") String ddlAuto) { + this.environment = environment; + this.projectRef = projectRef; + this.ddlAuto = ddlAuto; + } + + @EventListener(ApplicationReadyEvent.class) + public void announceProject() { + boolean staging = environment.matchesProfiles("staging"); + if (staging) { + log.info( + """ + SaaS staging profile: Supabase project {}, ddl-auto={}. This is the SHARED \ + long-lived environment, so its data and schema are not yours alone. Testing an \ + open SaaS PR? Use that PR's preview branch instead \ + (SAAS_DEV_PROJECT_REF in app/.env.saas.local); staging will not have its \ + migrations.\ + """, + projectRef, + ddlAuto); + return; + } + log.info( + "SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so Hibernate" + + " is allowed to add the inherited tables the migrations do not create.", + projectRef, + ddlAuto); + } +} diff --git a/app/saas/src/main/resources/application-dev.properties b/app/saas/src/main/resources/application-dev.properties index ee8bf80ff2..b289fc95cc 100644 --- a/app/saas/src/main/resources/application-dev.properties +++ b/app/saas/src/main/resources/application-dev.properties @@ -1,32 +1,40 @@ -# SaaS dev profile. Points at the dev Supabase project. -# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev +# SaaS dev profile: follows the Supabase preview branch of the SaaS PR under test. +# One variable switches PR, SAAS_DEV_PROJECT_REF; everything else derives from it. +# Want a stable shared environment instead? Use the staging profile. + spring.config.import=optional:classpath:application-dev-local.properties -app.supabase.project-ref=qacaivhsjtftfwtgjvva +# Let Hibernate reconcile the entity tables so a fresh preview branch heals itself. A branch is built +# from the Supabase migrations, which cover the SaaS-owned tables but not the ~28 inherited from the +# self-hosted app -- those have only ever been created by ddl-auto. Safe here because a preview branch +# is disposable and `update` only ever adds; staging pins `none`, so keep this profile-scoped. +spring.jpa.hibernate.ddl-auto=update -stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co -stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow +# From the PR's "Supabase Preview" check. Required with no fallback: ddl-auto=update above must never +# be aimed at the shared project. +app.supabase.project-ref=${SAAS_DEV_PROJECT_REF} -spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}} +stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co +# Per-branch, not derivable. Dashboard > Settings > API. +stirling.supabase.publishable-key=${SAAS_DEV_PUBLISHABLE_KEY} + +# Override the whole URL if the branch needs the pooler host rather than the direct one. +spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-dev-${user.name}} spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres} -# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=... +# A preview branch has its own password; the parent project's will not authenticate. spring.datasource.password=${SAAS_DEV_DB_PASSWORD:} -# Conservative dev pool sizing. spring.datasource.hikari.maximum-pool-size=2 spring.datasource.hikari.minimum-idle=1 spring.datasource.hikari.idle-timeout=60000 spring.datasource.hikari.max-lifetime=1800000 spring.datasource.hikari.keepalive-time=300000 -spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name} +spring.datasource.hikari.data-source-properties.ApplicationName=stirling-dev-${user.name} logging.level.stirling.software.saas=DEBUG logging.level.org.springframework.security.oauth2.jwt=WARN logging.level.org.springframework.security.oauth2.server.resource=WARN -# Supabase meter edge fn the Java backend calls (server-to-server, on job close). -# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same -# shared secret the team-invitation flow uses — no service-role key in the Java env). -# Blank secret → the meter service no-ops with a WARN, so the app still boots. -# The billing portal is NOT here — the FE calls create-customer-portal-session directly. -payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units +# Server-to-server meter call. Auth rides SUPABASE_EDGE_FUNCTION_SECRET; blank secret means the meter +# service no-ops with a WARN rather than failing the boot. +payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units diff --git a/app/saas/src/main/resources/application-staging.properties b/app/saas/src/main/resources/application-staging.properties new file mode 100644 index 0000000000..4874ed3fb5 --- /dev/null +++ b/app/saas/src/main/resources/application-staging.properties @@ -0,0 +1,39 @@ +# SaaS staging profile: the long-lived shared v3 project, pinned so it is still there tomorrow. +# For work on an open SaaS PR use the dev profile, which follows that PR's preview branch. + +spring.config.import=optional:classpath:application-staging-local.properties + +# Stated rather than inherited: application-saas.properties defaults to `update`, and staging's +# schema is shared and RLS-dependent, so it must not be reconciled by Hibernate. +spring.jpa.hibernate.ddl-auto=none + +# Committed as a default rather than a literal, so staging needs no setup but stays repointable. +# Neither the ref nor the publishable key is secret: the ref is a public subdomain, the key ships in +# the browser bundle. Everything below derives from the ref, so an override follows through. +app.supabase.project-ref=${SAAS_STAGING_PROJECT_REF:qacaivhsjtftfwtgjvva} + +stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co +stirling.supabase.publishable-key=${SAAS_STAGING_PUBLISHABLE_KEY:sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY} + +spring.datasource.url=${SAAS_STAGING_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-staging-${user.name}} +spring.datasource.username=${SAAS_STAGING_DB_USERNAME:postgres} +# Password not committed; export SAAS_STAGING_DB_PASSWORD or pass --spring.datasource.password=... +spring.datasource.password=${SAAS_STAGING_DB_PASSWORD:} + +# Conservative pool sizing: this is a shared project, so don't hold connections others need. +spring.datasource.hikari.maximum-pool-size=2 +spring.datasource.hikari.minimum-idle=1 +spring.datasource.hikari.idle-timeout=60000 +spring.datasource.hikari.max-lifetime=1800000 +spring.datasource.hikari.keepalive-time=300000 +spring.datasource.hikari.data-source-properties.ApplicationName=stirling-staging-${user.name} + +logging.level.stirling.software.saas=DEBUG +logging.level.org.springframework.security.oauth2.jwt=WARN +logging.level.org.springframework.security.oauth2.server.resource=WARN + +# Supabase meter edge fn the Java backend calls (server-to-server, on job close). +# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same +# shared secret the team-invitation flow uses — no service-role key in the Java env). +# Blank secret → the meter service no-ops with a WARN, so the app still boots. +payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units From 088e0ef4e25e06ae5f911f8be74912fef8673240 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 19 Aug 2026 18:29:02 +0000 Subject: [PATCH 221/262] deps(frontend): upgrade Cantoo PDF library to 2.8.2 (#7493) # Description of Changes This pull request upgrades the frontend PDF dependency from `@cantoo/pdf-lib` 2.6.5 to 2.8.2. - Updated `frontend/package.json` to require `@cantoo/pdf-lib` `^2.8.2`. - Regenerated `frontend/package-lock.json` with `@cantoo/pdf-lib@2.8.2`, `pako@2.2.0`, and `node-html-better-parser@1.5.9`. - Added the root npm `pako` override recommended by the upstream release. - The upgrade brings upstream parser, object-stream, encryption, form, PNG, and PDF serialization fixes into the frontend dependency. - No application API migration was required because the project does not use the newly added PDF/A, XFA, Factur-X, incremental-update, fontkit, or page-content-extraction APIs. The main challenge was validating the broad upstream change set against the project's actual usage. The frontend typecheck and a direct PDF create/save/load smoke test passed. The complete `frontend:check` and `frontend:test` tasks exceeded the available execution timeout without reporting a test failure. No related issue. --- ## 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/HowToAddNewLanguage.md) (if applicable) - [x] 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/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### 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) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. --- frontend/package-lock.json | 38 ++++++++++++++++++++++++++------------ frontend/package.json | 3 ++- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d50868bd60..37bca97b95 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@cantoo/pdf-lib": "^2.5.3", + "@cantoo/pdf-lib": "^2.8.2", "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^2.14.4", "@embedpdf/engines": "^2.14.4", @@ -606,18 +606,22 @@ } }, "node_modules/@cantoo/pdf-lib": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.5.tgz", - "integrity": "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.8.2.tgz", + "integrity": "sha512-f0BJM3uPOjbPR3YriSEUIaTM0qnqthjFmTZX9NGI0NDM2Tj4a8xv7Z5Hb6jzUrhfb3/9Y77+xxoOln0IIiYq+w==", "license": "MIT", "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "color": "^4.2.3", "crypto-js": "^4.2.0", - "node-html-better-parser": ">=1.4.0", - "pako": "^1.0.11", + "html-entities": "^2.3.2", + "node-html-better-parser": ">=1.5.9", + "pako": "^2.2.0", "tslib": ">=2" + }, + "peerDependencies": { + "html-entities": "^2.3.2" } }, "node_modules/@csstools/color-helpers": { @@ -12459,9 +12463,9 @@ } }, "node_modules/node-html-better-parser": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz", - "integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==", + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.9.tgz", + "integrity": "sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==", "license": "MIT", "dependencies": { "html-entities": "^2.3.2" @@ -12751,9 +12755,19 @@ "license": "MIT" }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { diff --git a/frontend/package.json b/frontend/package.json index 90a0e10b05..f874dedfa3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@cantoo/pdf-lib": "^2.5.3", + "@cantoo/pdf-lib": "^2.8.2", "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^2.14.4", "@embedpdf/engines": "^2.14.4", @@ -171,6 +171,7 @@ }, "overrides": { "devalue": "^5.8.1", + "pako": "^2.2.0", "tsconfck": { "typescript": "$typescript" } From 1690cc25ccbf100f3be1699ac098bca419f1ca6a Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:15:37 +0000 Subject: [PATCH 222/262] Update Frontend 3rd Party Licenses (#7573) Auto-generated by stirlingbot[bot] This PR updates the frontend license report based on changes to package.json dependencies. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- frontend/editor/src/assets/3rdPartyLicenses.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index 110a6c88a5..e17cef8f8b 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -10,7 +10,7 @@ { "moduleName": "@cantoo/pdf-lib", "moduleUrl": "https://github.com/cantoo-scribe/pdf-lib", - "moduleVersion": "2.6.5", + "moduleVersion": "2.8.2", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -255,14 +255,14 @@ { "moduleName": "@stripe/react-stripe-js", "moduleUrl": "https://github.com/stripe/react-stripe-js", - "moduleVersion": "4.0.2", + "moduleVersion": "6.8.0", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "@stripe/stripe-js", "moduleUrl": "https://github.com/stripe/stripe-js", - "moduleVersion": "7.9.0", + "moduleVersion": "9.10.0", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -287,6 +287,13 @@ "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, + { + "moduleName": "@tanstack/react-table", + "moduleUrl": "https://github.com/TanStack/table", + "moduleVersion": "9.1.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, { "moduleName": "@tanstack/react-virtual", "moduleUrl": "https://github.com/TanStack/virtual", From a744102cb68441a5dcf9b436a77919cf73c7eaab Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 20 Aug 2026 08:23:20 +0000 Subject: [PATCH 223/262] Support Supporting Files in Pipelines (#7547) # Description of Changes Currently in the Processor's Pipelines page, none of the tools which require supporting files are usable because it's never been hooked up to the new API to upload supporting files. This PR hooks it up to that so all tools using supporting files work in the processor. I had to tweak the type generation a little for this so we have a static map of which params are for supporting files so we know to handle them differently. The `Test with a file` button has to work a little differently than the main run since it's running an ad-hoc pipeline so the files haven't necessarily been saved to the server yet. In this case, it'll use whatever local changes the user has made for those pipeline steps, and for all other steps, it'll just use what's saved in the server. --- .../policy/controller/PolicyController.java | 42 +++- .../controller/PolicyControllerTest.java | 48 +++- .../public/locales/en-US/translation.toml | 4 +- .../scripts/generate-tool-api-types.mts | 64 ++++- .../hooks/tools/shared/toolApiMapping.test.ts | 19 +- .../core/hooks/tools/shared/toolApiMapping.ts | 22 +- .../hooks/tools/shared/toolAutomation.test.ts | 178 +++++++++++++- .../core/hooks/tools/shared/toolAutomation.ts | 223 +++++++++++++++++- .../hooks/tools/shared/toolOperationTypes.ts | 47 +++- .../editor/src/core/types/toolApiTypes.ts | 46 ++-- .../editor/src/portal/api/pipelineAssets.ts | 41 ++++ frontend/editor/src/portal/api/pipelines.ts | 28 ++- .../pipelines/PipelineStepSettings.css | 17 ++ .../PipelineStepSettings.stories.tsx | 2 + .../pipelines/PipelineStepSettings.test.tsx | 10 + .../pipelines/PipelineStepSettings.tsx | 146 +++++++++--- .../src/portal/mocks/handlers/pipelines.ts | 42 ++++ .../src/portal/views/PipelineBuilder.test.tsx | 122 +++++++++- .../src/portal/views/PipelineBuilder.tsx | 201 +++++++++++++--- 19 files changed, 1148 insertions(+), 154 deletions(-) create mode 100644 frontend/editor/src/portal/api/pipelineAssets.ts create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 778a04e169..9b9fca4133 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.RequestContextHolder; @@ -51,6 +52,7 @@ import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.audit.AuditContext; import stirling.software.proprietary.policy.asset.PolicyAssetCleaner; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.engine.PolicyRunHandle; @@ -106,6 +108,7 @@ public class PolicyController { private final PolicyTriggerManager policyTriggerManager; private final PolicyOverviewService policyOverviewService; private final PolicyAssetCleaner assetCleaner; + private final PolicyAssetResolver assetResolver; private final ProcessedLedger processedLedger; private final List policyTriggers; private final ApplicationProperties applicationProperties; @@ -125,12 +128,13 @@ public class PolicyController { + " endpoint and download outputs via /api/v1/general/files/{id}.") public ResponseEntity> run( @RequestPart("json") PipelineDefinition definition, + @RequestParam(value = "policyId", required = false) String policyId, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { stampPolicyAudit(definition); requireRunnable(definition); validateAdHocRun(definition); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = resolveStoredAssets(policyId, toInputs(files)); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); recordEditorDocs(inputs); @@ -146,12 +150,13 @@ public class PolicyController { + " 'cancelled', or 'waiting' event carrying the final run view.") public SseEmitter runStream( @RequestPart("json") PipelineDefinition definition, + @RequestParam(value = "policyId", required = false) String policyId, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { stampPolicyAudit(definition); requireRunnable(definition); validateAdHocRun(definition); - PolicyInputs inputs = toInputs(files); + PolicyInputs inputs = resolveStoredAssets(policyId, toInputs(files)); SseEmitter emitter = new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs()); @@ -438,10 +443,7 @@ public class PolicyController { * folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time. */ private void requirePolicyEditingAllowed() { - if (!applicationProperties.getSecurity().isEnableLogin()) { - return; - } - if (!policyManagementAuthority.canEditPolicies()) { + if (!policyEditingAllowed()) { throw new ResponseStatusException( HttpStatus.FORBIDDEN, "Policies may only be created or modified by a team leader"); @@ -467,6 +469,15 @@ public class PolicyController { } } + /** + * Whether the caller may create/modify policies (a team leader, or any operator when login is + * off). + */ + private boolean policyEditingAllowed() { + return !applicationProperties.getSecurity().isEnableLogin() + || policyManagementAuthority.canEditPolicies(); + } + @GetMapping @Operation( summary = "List policies", @@ -672,6 +683,25 @@ public class PolicyController { inputs.primary().size()); } + /** + * Resolve a test run's stored {@code asset:} bindings from the saved policy the builder is + * editing, so their bytes need not be re-uploaded. Scoped to that policy (the resolver loads + * only the assets it references, in its own team) and gated to policy editors - the same + * authority that can read asset bytes - so a member can't rebind a policy's stored asset into + * an ad-hoc step to read it back. A blank id (an unsaved pipeline has no stored bindings) or an + * inaccessible policy leaves the run-supplied inputs untouched. + */ + private PolicyInputs resolveStoredAssets(String policyId, PolicyInputs inputs) { + if (policyId == null || policyId.isBlank() || !policyEditingAllowed()) { + return inputs; + } + return policyStore + .get(policyId) + .filter(policyAccessGuard::canAccess) + .map(policy -> assetResolver.resolve(policy, inputs)) + .orElse(inputs); + } + /** * Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the * named supporting-file store, where each asset's {@code key} is the name a step references diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 2fa675597c..36f5cc221b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -83,6 +83,8 @@ class PolicyControllerTest { @Mock private stirling.software.proprietary.policy.asset.PolicyAssetCleaner assetCleaner; + @Mock private stirling.software.proprietary.policy.asset.PolicyAssetResolver assetResolver; + @Mock private ProcessedLedger processedLedger; @Mock private TempFileManager tempFileManager; @@ -115,6 +117,7 @@ class PolicyControllerTest { policyTriggerManager, policyOverviewService, assetCleaner, + assetResolver, processedLedger, policyTriggers, applicationProperties, @@ -232,7 +235,7 @@ class PolicyControllerTest { .thenReturn(handle("run-1")); ResponseEntity> response = - controller.run(definitionWithStep(), new PolicyRunFiles()); + controller.run(definitionWithStep(), null, new PolicyRunFiles()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getBody().getJobId()).isEqualTo("run-1"); @@ -245,7 +248,7 @@ class PolicyControllerTest { .thenReturn(handle("run-1")); when(sourceAccessGuard.currentTeamId()).thenReturn(3L); - controller.run(definitionWithStep(), new PolicyRunFiles()); + controller.run(definitionWithStep(), null, new PolicyRunFiles()); verify(docCounter).record(EditorSource.counterKey(3L), 0L); } @@ -255,7 +258,7 @@ class PolicyControllerTest { void rejectsEmptyPipeline() { PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); - assertThatThrownBy(() -> controller.run(empty, new PolicyRunFiles())) + assertThatThrownBy(() -> controller.run(empty, null, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class) .satisfies( e -> @@ -277,7 +280,7 @@ class PolicyControllerTest { .when(policyValidator) .validateOutput(any()); - assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles())) + assertThatThrownBy(() -> controller.run(definition, null, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class) .satisfies( e -> @@ -285,6 +288,38 @@ class PolicyControllerTest { .isEqualTo(HttpStatus.BAD_REQUEST)); verify(policyRunner, never()).runAdHoc(any(), any(), any()); } + + @Test + @DisplayName("resolves stored assets from the supplied policy when the caller may edit it") + void resolvesStoredAssetsForEditor() throws Exception { + applicationProperties.getSecurity().setEnableLogin(false); // editing allowed + Policy p = policy("pol-1", 1L); + when(policyStore.get("pol-1")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(assetResolver.resolve(eq(p), any())).thenAnswer(inv -> inv.getArgument(1)); + when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-1")); + + controller.run(definitionWithStep(), "pol-1", new PolicyRunFiles()); + + verify(assetResolver).resolve(eq(p), any()); + } + + @Test + @DisplayName("does not resolve a policy's stored assets for a caller who cannot edit it") + void skipsStoredAssetsForNonEditor() throws Exception { + // Gating asset resolution to editors keeps a member from rebinding a policy's stored + // asset into an ad-hoc step to read it back. + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-1")); + + controller.run(definitionWithStep(), "pol-1", new PolicyRunFiles()); + + verify(assetResolver, never()).resolve(any(), any()); + verify(policyStore, never()).get(any()); + } } @Nested @@ -296,7 +331,8 @@ class PolicyControllerTest { void returnsEmitter() throws Exception { when(policyRunner.runAdHoc(any(), any(), any())).thenReturn(handle("run-2")); - SseEmitter emitter = controller.runStream(definitionWithStep(), new PolicyRunFiles()); + SseEmitter emitter = + controller.runStream(definitionWithStep(), null, new PolicyRunFiles()); assertThat(emitter).isNotNull(); } @@ -306,7 +342,7 @@ class PolicyControllerTest { void rejectsEmpty() { PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); - assertThatThrownBy(() -> controller.runStream(empty, new PolicyRunFiles())) + assertThatThrownBy(() -> controller.runStream(empty, null, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class); } } diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 12ad26ceec..01db314031 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7705,7 +7705,6 @@ moreActions = "More actions" needsConfiguring = "Needs setting up" needsDestination = "No destination chosen" needsSource = "No source chosen" -needsUpload = "Needs an uploaded file" noToolMatches = "No tools match your search." pause = "Pause" rename = "Rename pipeline" @@ -7713,11 +7712,11 @@ searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." stepsNeedSetup = "These steps still need setting up before saving: {{tools}}." +supportingFiles = "Supporting files" testRun = "Test with a file" unknownStep = "Unrecognized operation, kept as-is." unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?" unsavedTitle = "Unsaved changes" -uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}." usesDefaults = "Runs with default settings" viewDefinition = "View definition" @@ -7730,7 +7729,6 @@ saveHeading = "To save your changes:" schedule = "Set how often it runs" setup = "Finish setting up: {{tools}}" source = "Choose an input source" -upload = "Remove steps that need an uploaded file: {{tools}}" [portal.pipelines.builder.diagnostic] fan-in = "Combines every incoming file" diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index d5990096af..38eb7fe7b6 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -28,10 +28,11 @@ const ALLOWED_PATH_PREFIXES = [ "/api/v1/integration/", ]; -// File plumbing, not user parameters: `fileInput` is the uploaded document and -// `fileId` a server-side handle. Stripped from every generated request model. -// Named file fields (stampImage, attachments, ...) are real parameters and kept. -const BASE_FILE_FIELDS = new Set(["fileInput", "fileId"]); +// File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document +// (endpoints use one name or the other - `file` is never a second, supporting upload) and `fileId` +// a server-side handle. Stripped from every generated request model. Named supporting-file fields +// (stampImage, attachments, ...) are real parameters and kept. +const BASE_FILE_FIELDS = new Set(["fileInput", "file", "fileId"]); // The shared "upload a file or provide a file ID" wrapper schema and its two // branches. An endpoint whose body is exactly this has no parameters, so it must @@ -73,6 +74,19 @@ function isObject(value: unknown): value is Json { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** A single file upload: `type: string, format: binary` (a Java MultipartFile param). */ +function isBinaryField(schema: unknown): schema is Json { + return ( + isObject(schema) && schema.type === "string" && schema.format === "binary" + ); +} + +/** A multi file upload: an array of binary items (some specs also flag the array itself binary). */ +function isBinaryArrayField(schema: unknown): schema is Json { + if (!isObject(schema) || schema.type !== "array") return false; + return schema.format === "binary" || isBinaryField(schema.items); +} + /** * Recursively sort object keys so the output is byte-stable regardless of the * key ordering springdoc happens to emit. @@ -358,6 +372,9 @@ async function main(): Promise { const usedClassNames = new Set(); const pendingComponents = new Set(); const skipped: string[] = []; + // Named file fields (as File uploads) per model, so a caller can tell a file param from a scalar + // string param - which `format: binary` -> `string` would otherwise erase. + const fileFieldsByClass: Record = {}; for (const path of Object.keys(paths).sort()) { if ( @@ -408,7 +425,32 @@ async function main(): Promise { const query = queryParameters(pathItem); // Body wins over query on a name collision. const properties: Json = { ...query.props, ...bodyProps }; + // `file` is stripped as a primary-document alias (see BASE_FILE_FIELDS). That only holds while + // no endpoint uses `file` as a *supporting* upload beside a primary `fileInput`; if one ever + // does, blanket-stripping would silently drop it. Fail generation so the assumption is fixed + // here rather than shipping a lost file. + if ("file" in properties && "fileInput" in properties) { + throw new Error( + `${path} has both 'fileInput' and 'file' uploads. 'file' is stripped as a primary-document` + + " alias, which would drop it as a supporting file. Rename the supporting param or revise" + + " BASE_FILE_FIELDS handling in this generator.", + ); + } for (const field of BASE_FILE_FIELDS) delete properties[field]; + // Type each named file upload as File/File[] (not the `string` a binary format yields) via + // json-schema-to-typescript's `tsType` override, and record it. Base file fields are already + // stripped, so what remains is the real supporting-file params. + const fileFields: string[] = []; + for (const [name, prop] of Object.entries(properties)) { + if (isBinaryField(prop)) { + prop.tsType = "File"; + fileFields.push(name); + } else if (isBinaryArrayField(prop)) { + prop.tsType = "File[]"; + fileFields.push(name); + } + } + fileFieldsByClass[className] = fileFields; modelSchema.properties = properties; const required = new Set(computeRequired(modelSchema, properties)); for (const name of query.required) { @@ -464,6 +506,7 @@ async function main(): Promise { await compileAndWrite( tools, definitions, + fileFieldsByClass, outputPath, values.check ?? false, skipped, @@ -473,6 +516,7 @@ async function main(): Promise { async function compileAndWrite( tools: DiscoveredTool[], definitions: Record, + fileFieldsByClass: Record, outputPath: string, check: boolean, skipped: string[], @@ -525,6 +569,15 @@ async function compileAndWrite( const endpointList = tools .map((t) => ` ${JSON.stringify(t.path)},`) .join("\n"); + // Endpoints that take supporting files, mapped to those file params' names. Only endpoints with at + // least one are listed, so membership answers "does this tool take extra files". + const fileFieldEntries = tools + .filter((t) => (fileFieldsByClass[t.className] ?? []).length > 0) + .map( + (t) => + ` ${JSON.stringify(t.path)}: ${JSON.stringify(fileFieldsByClass[t.className])},`, + ) + .join("\n"); const footer = [ "/** Endpoint path for a generated tool operation (the operation identity across languages). */", @@ -536,6 +589,9 @@ async function compileAndWrite( "/** Every generated tool endpoint, for iteration. */", `export const TOOL_ENDPOINTS = [\n${endpointList}\n] as const satisfies readonly ToolEndpoint[];`, "", + "/** The supporting-file parameters each endpoint accepts beyond its primary fileInput, by name. */", + `export const TOOL_FILE_FIELDS = {\n${fileFieldEntries}\n} as const satisfies Partial<\n Record\n>;`, + "", "/** Union of every generated tool request model. */", `export type ToolApiRequest = ToolApiParams[ToolEndpoint];`, ].join("\n"); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts index ba8c0bc2be..f3443dfb51 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts @@ -41,12 +41,13 @@ describe("objectToFormData", () => { }); test("expands arrays into repeated fields", () => { - const request: ToolApiParams["/api/v1/misc/add-attachments"] = { - attachments: ["a.png", "b.png", "c.png"], + const request: ToolApiParams["/api/v1/misc/ocr-pdf"] = { + ocrType: "Normal", + languages: ["eng", "fra", "deu"], }; const formData = objectToFormData(request); - expect(formData.getAll("attachments")).toEqual(["a.png", "b.png", "c.png"]); + expect(formData.getAll("languages")).toEqual(["eng", "fra", "deu"]); }); test("throws on a non-primitive field value rather than dropping it", () => { @@ -70,6 +71,18 @@ describe("objectToFormData", () => { expect(formData.get("optimizeLevel")).toBe("5"); }); + test("sends a File-valued model field as a file part, not stringified", () => { + const stamp = new File(["s"], "stamp.png", { type: "image/png" }); + const request: ToolApiParams["/api/v1/misc/add-stamp"] = { + stampType: "image", + stampImage: stamp, + }; + const formData = objectToFormData(request); + + expect(formData.get("stampImage")).toBe(stamp); + expect(formData.get("stampType")).toBe("image"); + }); + test("appends multiple files under the same field name", () => { const files = [ new File(["1"], "a.pdf", { type: "application/pdf" }), diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts index 9c6f5d9e4b..81663fbf69 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts @@ -47,26 +47,28 @@ function appendPrimitive( formData.append(key, value); } else if (typeof value === "number" || typeof value === "boolean") { formData.append(key, `${value}`); + } else if (typeof Blob !== "undefined" && value instanceof Blob) { + // A File upload (models type binary params as File): send it as the file part, not stringified. + formData.append(key, value); } else { - // A non-primitive here means a mapper produced a value the backend cannot - // receive as a form field. Fail loudly rather than silently drop it: - // structured fields must be JSON-encoded in the mapper, and Files passed via - // the `files` argument. + // Any other non-primitive means a mapper produced a value the backend cannot receive as a form + // field. Fail loudly rather than silently drop it: structured fields must be JSON-encoded first. throw new Error( `objectToFormData: field "${key}" has an unsupported value of type ` + - `"${typeof value}"; expected a string, number, or boolean.`, + `"${typeof value}"; expected a string, number, boolean, or File.`, ); } } /** * Serialize a backend request model (the output of a `toApiParams` function) - * into multipart FormData: primitives become string fields, arrays become - * repeated fields, and `undefined`/`null` are omitted. Files are appended - * separately via `files`, keeping file plumbing out of the parameter mapper. + * into multipart FormData: primitives become string fields, `File` values become + * file parts, arrays become repeated fields, and `undefined`/`null` are omitted. + * Extra files may still be passed via `files` (the primary `fileInput`, or a + * field the mapper doesn't carry). * - * Throws if a field holds a non-primitive value, since that cannot be sent as a - * form field: structured fields must be JSON-encoded by the mapper. + * Throws if a field holds any other non-primitive value, since that cannot be + * sent as a form field: structured fields must be JSON-encoded by the mapper. */ export function objectToFormData( params: ToolApiRequest, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 5be4831907..0f7184f848 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -10,11 +10,14 @@ import { asRegistryConfig, ToolType, } from "@app/hooks/tools/shared/toolOperationTypes"; +import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping"; import { + activeFileFields, deserializeToolStep, + extractStepFiles, getExecutableTools, serializeToolStep, - stepRequiresUpload, + stepNeedsConfiguring, type WorkingToolStep, } from "@app/hooks/tools/shared/toolAutomation"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; @@ -28,6 +31,10 @@ import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddP import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation"; import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation"; import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters"; +import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsOperation"; +import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters"; +import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation"; +import { defaultParameters as certSignDefaults } from "@app/hooks/tools/certSign/useCertSignParameters"; function entry(over: Partial): ToolRegistryEntry { return { @@ -419,18 +426,167 @@ describe("convert (format-routed custom tool)", () => { }); }); -describe("stepRequiresUpload", () => { - const step = (params: Record): WorkingToolStep => ({ - toolId: "compress" as ToolId, - operation: "/api/v1/misc/compress-pdf", - params, +describe("supporting files", () => { + const fileRegistry: Partial = { + overlayPdfs: entry({ + name: "Overlay", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(overlayPdfsOperationConfig), + }), + certSign: entry({ + name: "Cert sign", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(certSignOperationConfig), + }), + }; + + const overlayStep = ( + params: Record, + fileParameters?: Record, + ): WorkingToolStep => ({ + toolId: "overlayPdfs" as ToolId, + operation: "/api/v1/general/overlay-pdfs", + params: { ...overlayDefaults, ...params }, support: "editable", + fileParameters, }); - test("detects a File (or list of Files) among the parameters", () => { - const image = new File(["x"], "logo.png", { type: "image/png" }); - expect(stepRequiresUpload(step({ level: 5 }))).toBe(false); - expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true); - expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true); + const certStep = ( + params: Record, + fileParameters?: Record, + ): WorkingToolStep => ({ + toolId: "certSign" as ToolId, + operation: "/api/v1/security/cert-sign", + params: { ...certSignDefaults, signMode: "MANUAL", ...params }, + support: "editable", + fileParameters, + }); + + test("extractStepFiles groups fresh picks by their backend file field", () => { + const a = new File(["1"], "a.pdf", { type: "application/pdf" }); + const b = new File(["2"], "b.pdf", { type: "application/pdf" }); + expect( + extractStepFiles(overlayStep({ overlayFiles: [a, b] }), fileRegistry), + ).toEqual({ overlayFiles: [a, b] }); + }); + + test("extractStepFiles respects a tool's file selection (certSign by certType)", () => { + const p12 = new File(["k"], "key.p12"); + expect( + extractStepFiles( + certStep({ certType: "PKCS12", p12File: p12 }), + fileRegistry, + ), + ).toEqual({ p12File: [p12] }); + }); + + test("serialize/deserialize round-trips fileParameters", () => { + const step = certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }); + const api = serializeToolStep(step, fileRegistry); + expect(api.fileParameters).toEqual({ p12File: "asset:abc" }); + expect(deserializeToolStep(api, fileRegistry).fileParameters).toEqual({ + p12File: "asset:abc", + }); + }); + + test("stepNeedsConfiguring: a stored binding satisfies the file requirement", () => { + expect( + stepNeedsConfiguring( + certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }), + fileRegistry, + ), + ).toBe(false); + // Without the binding the keystore is still owed. + expect( + stepNeedsConfiguring(certStep({ certType: "PKCS12" }), fileRegistry), + ).toBe(true); + }); + + test("activeFileFields drops a stored binding the tool no longer emits", () => { + // Still PKCS12: the p12File binding is what the tool sends. + expect( + activeFileFields( + certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }), + fileRegistry, + ), + ).toEqual(["p12File"]); + // Switched to PEM: certSign wants privateKeyFile/certFile, so the p12File binding is stale. + expect( + activeFileFields( + certStep({ certType: "PEM" }, { p12File: "asset:abc" }), + fileRegistry, + ), + ).toEqual([]); + }); + + test("activeFileFields is null (not empty) when the tool can't be probed", () => { + // A buildFormData that throws can't be probed; returning null (vs []) tells callers to keep the + // step's stored bindings rather than drop them and let the server GC the assets. + const config = asRegistryConfig<{ signingCert?: File }>({ + toolType: ToolType.singleFile, + operationType: "certSign", + endpoint: "/api/v1/security/cert-sign", + defaultParameters: {}, + buildFormData: () => { + throw new Error("cannot build"); + }, + }); + const registry: Partial = { + certSign: entry({ name: "Boom", operationConfig: config }), + }; + const step: WorkingToolStep = { + toolId: "certSign" as ToolId, + operation: "/api/v1/security/cert-sign", + params: {}, + support: "editable", + fileParameters: { certFile: "asset:x" }, + }; + expect(activeFileFields(step, registry)).toBeNull(); + }); + + test("the overlay sentinel is sized to the binding's asset count", () => { + // Two ids -> two files, matching two counts, so FixedRepeat validation passes. + const step = overlayStep( + { overlayMode: "FixedRepeatOverlay", counts: [1, 2] }, + { overlayFiles: "asset:one,two" }, + ); + expect(activeFileFields(step, fileRegistry)).toEqual(["overlayFiles"]); + expect(stepNeedsConfiguring(step, fileRegistry)).toBe(false); + }); + + test("a rename override binds a backend field to a differently-named param", () => { + // The cert-sign endpoint's `certFile` is held by a frontend param named `signingCert`. + const config = asRegistryConfig<{ signingCert?: File }>({ + toolType: ToolType.singleFile, + operationType: "certSign", + endpoint: "/api/v1/security/cert-sign", + defaultParameters: {}, + validateParams: (p) => p.signingCert !== undefined, + // Sends the File under the backend field `certFile`, like real tools do via objectToFormData + // (which sends a param's File or File[] under a named field, iterating arrays). + buildFormData: (p, file) => + objectToFormData({}, { fileInput: file, certFile: p.signingCert }), + fileParamOverrides: [{ field: "certFile", param: "signingCert" }], + }); + const registry: Partial = { + certSign: entry({ name: "Sign", operationConfig: config }), + }; + const step = ( + fileParameters?: Record, + ): WorkingToolStep => ({ + toolId: "certSign" as ToolId, + operation: "/api/v1/security/cert-sign", + params: {}, + support: "editable", + fileParameters, + }); + // The stored binding is keyed by the backend field, but satisfies the frontend param on reload. + expect(stepNeedsConfiguring(step({ certFile: "asset:x" }), registry)).toBe( + false, + ); + expect(stepNeedsConfiguring(step(), registry)).toBe(true); + expect(activeFileFields(step({ certFile: "asset:x" }), registry)).toEqual([ + "certFile", + ]); }); }); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index 0485791f15..a9368ccdb7 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -18,11 +18,13 @@ import { type ToolRegistryEntry, } from "@app/data/toolsTaxonomy"; import { type ToolId } from "@app/types/toolId"; +import { TOOL_FILE_FIELDS } from "@app/types/toolApiTypes"; import { isToolEndpoint, type ToolEndpoint, } from "@app/hooks/tools/shared/toolApiMapping"; import { + ToolType, type ErasedToolParams, type RegistryToolOperationConfig, } from "@app/hooks/tools/shared/toolOperationTypes"; @@ -62,6 +64,12 @@ export interface ExecutableTool { export interface ToolApiStep { operation: string; parameters: Record; + /** + * Supporting-file bindings: a backend file field (e.g. `stampImage`, `overlayFiles`) mapped to + * `asset:[,]` (stored supporting files) or a run-supplied key. Absent when the step needs + * no supporting file. Mirrors the wire {@code PipelineStep.fileParameters}. + */ + fileParameters?: SupportingFileBindings; } /** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */ @@ -70,6 +78,12 @@ export interface KnownToolStep { operation: ToolEndpoint; params: ErasedToolParams; support: ToolStepSupport; + /** + * Stored supporting-file bindings carried from a saved step (field -> `asset:`), so an edit + * round-trips them without the user re-picking. A field the user re-picks lands in `params` as a + * File and takes precedence on save. + */ + fileParameters?: SupportingFileBindings; } /** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */ @@ -78,6 +92,8 @@ export interface UnknownToolStep { operation: string; params: ErasedToolParams; support: "unknown"; + /** Supporting-file bindings preserved verbatim, so an unknown step's files round-trip untouched. */ + fileParameters?: SupportingFileBindings; } /** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */ @@ -135,12 +151,176 @@ function isFileValue(value: unknown): boolean { } /** - * True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be - * saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later - * (e.g. scheduled) run would have nothing to send for that named file field. + * A stored supporting-file id, as returned by the asset store. */ -export function stepRequiresUpload(step: WorkingToolStep): boolean { - return Object.values(step.params).some(isFileValue); +declare const ASSET_ID_BRAND: unique symbol; +export type AssetId = string & { readonly [ASSET_ID_BRAND]: never }; + +/** + * A step's supporting-file bindings: each backend file field (e.g. `stampImage`) mapped to its file. + * A value of `asset:[,]` names stored assets loaded at run time; any other value is a key for + * a file supplied with the run itself. + */ +export type SupportingFileBindings = Record; + +/** + * The `fileParameters` binding format shared with the backend (see PolicyAssetRefs). This module owns + * the frontend side of the step contract, so the format lives here and the builder/settings reuse it. + */ +export const ASSET_REF_PREFIX = "asset:"; + +/** A `fileParameters` value binding one tool file field to the given stored asset ids. */ +export function assetRef(ids: readonly AssetId[]): string { + return ASSET_REF_PREFIX + ids.join(","); +} + +/** The stored asset ids inside a binding value, or none when it isn't an `asset:` ref. */ +export function assetRefIds(binding: string): AssetId[] { + if (!binding.startsWith(ASSET_REF_PREFIX)) return []; + return binding + .slice(ASSET_REF_PREFIX.length) + .split(",") + .map((id) => id.trim()) + .filter(Boolean) as AssetId[]; +} + +/** A throwaway primary document for probing a tool's buildFormData; never sent anywhere. */ +function dummyPrimaryFile(): File { + return new File([], "input.pdf", { type: "application/pdf" }); +} + +/** + * Run a tool's buildFormData so we can read the request it would produce. + * Returns null when File is unavailable or buildFormData throws. + */ +function probeFormData( + config: RegistryToolOperationConfig, + params: ErasedToolParams, +): FormData | null { + if (typeof File === "undefined") return null; + const dummy = dummyPrimaryFile(); + try { + switch (config.toolType) { + case ToolType.singleFile: + return config.buildFormData(params, dummy); + case ToolType.multiFile: + return config.buildFormData(params, [dummy]); + default: + return null; + } + } catch { + return null; + } +} + +/** Defaults merged under the step's params - the shape a tool's mappers and buildFormData expect. */ +function mergedStepParams( + step: WorkingToolStep, + config: RegistryToolOperationConfig, +): ErasedToolParams { + return { ...(config.defaultParameters ?? {}), ...step.params }; +} + +/** The backend file fields an endpoint accepts, from the generated spec-sourced table. */ +function backendFileFields(operation: string): readonly string[] { + return ( + (TOOL_FILE_FIELDS as Partial>)[ + operation + ] ?? [] + ); +} + +/** + * Each backend file field the step's endpoint accepts (from {@link TOOL_FILE_FIELDS}), mapped to the + * tool param that holds it - the same name unless the tool declared a rename override. + */ +function fileFieldMappings( + operation: string, + config: RegistryToolOperationConfig, +): { field: string; param: string }[] { + // The override's erased type collapses `param` to `never`; restore the real runtime shape. + const overrides = (config.fileParamOverrides ?? []) as readonly { + field: string; + param: string; + }[]; + const paramByField = new Map(overrides.map((o) => [o.field, o.param])); + return backendFileFields(operation).map((field) => ({ + field, + param: paramByField.get(field) ?? field, + })); +} + +/** + * The step's params with a stand-in File array injected for each stored binding whose param has no + * fresh pick, so a tool's buildFormData/validateParams sees the supporting file as present. Stored + * bindings are keyed by the backend field (from {@link TOOL_FILE_FIELDS}), so each field finds its + * binding and the sentinel lands on its param - the two coincide unless the tool declared a rename + * override. The array is sized to the binding's asset count (overlay validates count == file count). + * Sentinels are empty and live only in this local object - never written back to step.params, so they + * can never be uploaded. + */ +function withStoredFileSentinels( + step: WorkingToolStep, + config: RegistryToolOperationConfig, +): ErasedToolParams { + const merged = mergedStepParams(step, config); + const bindings = step.fileParameters; + if (!bindings || typeof File === "undefined") return merged; + for (const { param, field } of fileFieldMappings(step.operation, config)) { + const binding = bindings[field]; + if (binding == null || isFileValue(merged[param])) continue; // unbound, or a fresh pick stands in + const count = Math.max(1, assetRefIds(binding).length); + merged[param] = Array.from({ length: count }, () => new File([], "stored")); + } + return merged; +} + +/** + * The fresh File picks on a step, grouped by the backend file field its buildFormData sends them + * under (excluding the primary `fileInput`). buildFormData is the source of truth for the field name + * and for tool-specific selection (certSign picks files by certType), so probing it - rather than + * scanning params - keeps the field mapping correct. These are the files to upload on save. + */ +export function extractStepFiles( + step: WorkingToolStep, + registry: Partial, +): Record { + if (step.toolId === null) return {}; + const config = registry[step.toolId]?.operationConfig; + if (!config) return {}; + const formData = probeFormData(config, mergedStepParams(step, config)); + if (!formData) return {}; + const files: Record = {}; + formData.forEach((value, key) => { + if (key !== "fileInput" && value instanceof File) { + (files[key] ??= []).push(value); + } + }); + return files; +} + +/** + * The backend file fields this step actually uses right now, per its own buildFormData: fresh picks + * plus any stored binding the tool still emits (a stale one - e.g. a PKCS12 keystore after switching + * to PEM - is dropped, because buildFormData no longer sends it). Drives the stored-file chips, the + * save-time binding set, and the test run. + */ +export function activeFileFields( + step: WorkingToolStep, + registry: Partial, +): string[] | null { + if (step.toolId === null) { + return step.fileParameters ? Object.keys(step.fileParameters) : []; + } + const config = registry[step.toolId]?.operationConfig; + if (!config) return null; + const formData = probeFormData(config, withStoredFileSentinels(step, config)); + if (!formData) return null; + const fields = new Set(); + formData.forEach((value, key) => { + if (key !== "fileInput" && value instanceof File) fields.add(key); + }); + return [...fields]; } /** @@ -158,9 +338,10 @@ export function stepNeedsConfiguring( ): boolean { if (step.toolId === null) return false; const config = registry[step.toolId]?.operationConfig; - if (!config?.validateParams) return false; - const merged = { ...(config.defaultParameters ?? {}), ...step.params }; - return !config.validateParams(merged); + if (!config || !config.validateParams) return false; + // Stored supporting files satisfy their field just as a fresh pick would, so validate against the + // sentinel-injected params rather than the bare ones (which drop the file on reload). + return !config.validateParams(withStoredFileSentinels(step, config)); } /** @@ -240,14 +421,27 @@ export function serializeToolStep( step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined; if (!config) { // Unmapped step (unknown endpoint on edit): round-trip it unchanged. - return { operation: step.operation, parameters: step.params }; + return withFileParameters( + { operation: step.operation, parameters: step.params }, + step, + ); } const merged = { ...(config.defaultParameters ?? {}), ...step.params }; const operation = resolveEndpoint(config, merged) ?? step.operation; const parameters = config.toApiParams ? (config.toApiParams(merged) as Record) : {}; - return { operation, parameters }; + return withFileParameters({ operation, parameters }, step); +} + +/** Attach the step's supporting-file bindings to a serialized step, omitting the field when empty. */ +function withFileParameters( + serialized: ToolApiStep, + step: WorkingToolStep, +): ToolApiStep { + const bindings = step.fileParameters; + if (!bindings || Object.keys(bindings).length === 0) return serialized; + return { ...serialized, fileParameters: bindings }; } /** @@ -308,6 +502,7 @@ function unmappedStep(step: ToolApiStep): UnknownToolStep { operation: step.operation, params: { ...step.parameters }, support: "unknown", + fileParameters: step.fileParameters, }; } @@ -345,5 +540,11 @@ export function deserializeToolStep( resolveEndpoint(config, params) ?? (isToolEndpoint(step.operation) ? step.operation : undefined); if (operation === undefined) return unmappedStep(step); - return { toolId, operation, params, support: classifyToolStepSupport(entry) }; + return { + toolId, + operation, + params, + support: classifyToolStepSupport(entry), + fileParameters: step.fileParameters, + }; } diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts index d6dbf3b355..d01d897002 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -3,7 +3,11 @@ import { StirlingFile } from "@app/types/fileContext"; import type { ResponseHandler } from "@app/utils/toolResponseProcessor"; import { ToolId } from "@app/types/toolId"; import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState"; -import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes"; +import { + TOOL_FILE_FIELDS, + type ToolApiParams, + type ToolEndpoint, +} from "@app/types/toolApiTypes"; export type { ProcessingProgress, ResponseHandler }; @@ -45,6 +49,39 @@ export interface CustomProcessorResult { consumedAllInputs?: boolean; } +/** + * The parameter keys that carry a supporting file - a `File` or `File[]` value the tool sends + * beyond its primary document. Derived from the tool's own parameter type, so a file field can only + * ever be declared against a param that genuinely holds a file. + */ +export type FileParamKey = { + [K in keyof TParams]-?: NonNullable extends File | File[] + ? K + : never; +}[keyof TParams] & + string; + +/** + * The backend multipart file fields an endpoint accepts, from the generated {@link TOOL_FILE_FIELDS} + * (which the spec derives from the Java MultipartFile params). `never` for an endpoint that takes no + * supporting files. This is what makes a rename override's `field` a checked name, not a free string. + */ +export type BackendFileField = + TEndpoint extends keyof typeof TOOL_FILE_FIELDS + ? (typeof TOOL_FILE_FIELDS)[TEndpoint][number] + : never; + +/** + * A remap for the rare case where a tool's frontend file param has a different name from the backend + * field it is sent under. Both sides are checked: `field` must be one of the endpoint's generated + * backend file fields, and `param` a real file param of the tool. Same-name fields need no entry - + * they are derived from {@link TOOL_FILE_FIELDS} directly. + */ +export interface FileParamOverride { + field: BackendFileField; + param: FileParamKey; +} + /** * Configuration for tool operations defining processing behavior and API integration. * @@ -79,6 +116,14 @@ interface BaseToolOperationConfig { /** Default parameter values for automation */ defaultParameters?: TParams; + /** + * Rename overrides for supporting-file params. The set of a tool's file fields is derived from the + * generated {@link TOOL_FILE_FIELDS} (spec-sourced), keyed by the backend field name; declare an + * override only when a backend field maps to a differently-named frontend param, so a step composer + * can bind the stored file to the right param. Omitted by the common case where field == param. + */ + fileParamOverrides?: readonly FileParamOverride[]; + /** * Whether these parameters are complete enough to run. The same predicate a tool gives * `useBaseParameters` as its `validateFn`, so the Run button in the editor and anything composing diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index bf498eb450..b2bdf7fb4b 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -7,7 +7,7 @@ export interface AddAttachmentRequest { /** * The image file to be overlaid onto the PDF. */ - attachments: string[]; + attachments: File[]; /** * Convert the resulting PDF to PDF/A-3b format after adding attachments */ @@ -148,7 +148,7 @@ export interface AddStampRequest { * The rotation of the stamp in degrees */ rotation?: number; - stampImage?: string; + stampImage?: File; /** * The stamp text */ @@ -187,7 +187,7 @@ export interface AddWatermarkRequest { * The rotation of the watermark in degrees */ rotation?: number; - watermarkImage?: string; + watermarkImage?: File; /** * The watermark text */ @@ -525,9 +525,7 @@ export interface FlattenRequest { */ renderDpi?: number; } -export interface GeneralExtractBookmarksRequest { - file: string; -} +export type GeneralExtractBookmarksRequest = Record; export type GeneralFile = Record; export type GeneralPdfToSinglePageRequest = Record; export type GeneralRemoveImagePdfRequest = Record; @@ -788,7 +786,7 @@ export interface OverlayImageRequest { * Whether to overlay the image onto every page of the PDF. */ everyPage?: boolean; - imageFile: string; + imageFile: File; /** * The x-coordinate at which to place the top-left corner of the image. */ @@ -806,7 +804,7 @@ export interface OverlayPdfsRequest { /** * An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode. */ - overlayFiles: string[]; + overlayFiles: File[]; /** * The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts */ @@ -1276,7 +1274,6 @@ export interface ScannerEffectRequest { yellowish?: boolean; } export interface SecurityCertSignSessionsRequest { - file: string; request?: WorkflowCreationRequest; } export interface WorkflowCreationRequest { @@ -1291,8 +1288,8 @@ export interface WorkflowCreationRequest { } export interface SecurityCertSignValidateCertificateRequest { certType: string; - jksFile?: string; - p12File?: string; + jksFile?: File; + p12File?: File; password?: string; } export type SecurityGetInfoOnPdfRequest = Record; @@ -1302,7 +1299,7 @@ export interface SignPDFWithCertRequest { * The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates. */ alias?: string; - certFile?: string; + certFile?: File; /** * The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app. */ @@ -1314,7 +1311,7 @@ export interface SignPDFWithCertRequest { | "SERVER" | "WINDOWS_STORE" | "PKCS11"; - jksFile?: string; + jksFile?: File; /** * The location where the PDF is signed */ @@ -1323,7 +1320,7 @@ export interface SignPDFWithCertRequest { * The name of the signer */ name?: string; - p12File?: string; + p12File?: File; /** * The page number where the signature should be visible. This is required if showSignature is set to true */ @@ -1340,7 +1337,7 @@ export interface SignPDFWithCertRequest { * Optional PKCS#11 slot index. When omitted the first slot with a token is used. */ pkcs11Slot?: number; - privateKeyFile?: string; + privateKeyFile?: File; /** * The reason for signing the PDF */ @@ -1355,7 +1352,7 @@ export interface SignPDFWithCertRequest { showSignature?: boolean; } export interface SignatureValidationRequest { - certFile?: string; + certFile?: File; } export interface SplitPagesRequest { /** @@ -1741,5 +1738,22 @@ export const TOOL_ENDPOINTS = [ "/api/v1/security/verify-pdf", ] as const satisfies readonly ToolEndpoint[]; +/** The supporting-file parameters each endpoint accepts beyond its primary fileInput, by name. */ +export const TOOL_FILE_FIELDS = { + "/api/v1/general/overlay-pdfs": ["overlayFiles"], + "/api/v1/misc/add-attachments": ["attachments"], + "/api/v1/misc/add-image": ["imageFile"], + "/api/v1/misc/add-stamp": ["stampImage"], + "/api/v1/security/add-watermark": ["watermarkImage"], + "/api/v1/security/cert-sign": [ + "privateKeyFile", + "certFile", + "p12File", + "jksFile", + ], + "/api/v1/security/cert-sign/validate-certificate": ["p12File", "jksFile"], + "/api/v1/security/validate-signature": ["certFile"], +} as const satisfies Partial>; + /** Union of every generated tool request model. */ export type ToolApiRequest = ToolApiParams[ToolEndpoint]; diff --git a/frontend/editor/src/portal/api/pipelineAssets.ts b/frontend/editor/src/portal/api/pipelineAssets.ts new file mode 100644 index 0000000000..b68c95fda5 --- /dev/null +++ b/frontend/editor/src/portal/api/pipelineAssets.ts @@ -0,0 +1,41 @@ +import { apiClient } from "@portal/api/http"; +import { type AssetId } from "@app/hooks/tools/shared/toolAutomation"; + +export { type AssetId }; + +/** + * Stored supporting files for pipeline steps (backend PolicyAssetController). + * + * A pipeline step that needs more than the document stream - a signing + * certificate, a watermark/stamp image, overlay PDFs, attachments - references + * its file by id from the step's `fileParameters` as `asset:`. The bytes are + * uploaded here first (the save-time validator rejects a policy that binds an + * asset id that doesn't yet exist), then a triggered or scheduled run loads the + * file server-side without anyone re-supplying it. Assets are team-scoped exactly + * like the policies that reference them, and unreferenced uploads are cleaned up + * server-side, so the builder never has to delete what a cancelled edit left. + */ + +/** Metadata for one stored supporting file. Mirrors the Java `PolicyAsset` record. */ +export interface PolicyAsset { + id: AssetId; + fileName: string; + contentType: string | null; + size: number; + createdAt: number; +} + +/** POST /api/v1/policies/assets: store a supporting file, returning its metadata (with the id). */ +export async function uploadPipelineAsset(file: File): Promise { + const form = new FormData(); + form.append("file", file); + return apiClient.local.multipart( + "/api/v1/policies/assets", + form, + ); +} + +/** GET /api/v1/policies/assets: the team's stored supporting files (metadata only). */ +export async function listPipelineAssets(): Promise { + return apiClient.local.json("/api/v1/policies/assets"); +} diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index 50bdc173c4..e441fcdac8 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -1,5 +1,8 @@ import { apiClient } from "@portal/api/http"; -import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation"; +import { + type SupportingFileBindings, + type ToolApiStep, +} from "@app/hooks/tools/shared/toolAutomation"; /** * Pipelines service layer: the backend contract. @@ -15,7 +18,7 @@ import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation"; export interface PipelineStep { operation: string; parameters: Record; - fileParameters?: Record; + fileParameters?: SupportingFileBindings; } /** When a policy input fires automatically. `type` keys a trigger bean (e.g. "schedule"). */ @@ -217,14 +220,28 @@ export interface TestRunDefinition { output: OutputSpec; } +/** + * A fresh, in-memory supporting file sent inline with a test run, bound to the run key a test step's + * `fileParameters` references. Only unsaved picks ride along here; a stored file keeps its + * `asset:` binding, which the backend resolves from the saved policy (see `runPipelineTest`). + */ +export interface TestRunAsset { + key: string; + file: File; +} + /** * POST /api/v1/policies/run: run a definition against one uploaded file now. The builder's test * path - callers force an inline output so nothing reaches the pipeline's real destination, and - * the pipeline need not be saved first. + * the pipeline need not be saved first. Fresh supporting files travel as keyed `assets[i]` parts; + * a stored file keeps its `asset:` binding, and `policyId` lets the backend resolve it from that + * saved policy (so its bytes need not be re-sent). */ export async function runPipelineTest( definition: TestRunDefinition, file: File, + assets: TestRunAsset[] = [], + policyId?: string, ): Promise<{ runId: string }> { const form = new FormData(); form.append( @@ -232,6 +249,11 @@ export async function runPipelineTest( new Blob([JSON.stringify(definition)], { type: "application/json" }), ); form.append("fileInput", file); + if (policyId) form.append("policyId", policyId); + assets.forEach((asset, i) => { + form.append(`assets[${i}].key`, asset.key); + form.append(`assets[${i}].file`, asset.file); + }); // The POST returns the identifier as `jobId`, but it is the same run id every other endpoint // (fetchRun, fetchRunOutput) calls `runId`; normalise to that here so callers see one name. const res = await apiClient.local.multipart<{ jobId: string }>( diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css new file mode 100644 index 0000000000..46bb72a736 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css @@ -0,0 +1,17 @@ +.portal-step-settings__files { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-bottom: 0.75rem; +} + +.portal-step-settings__files-label { + font-size: 0.75rem; + color: var(--c-text-muted); +} + +.portal-step-settings__files-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx index d2bd6bc6f2..823d0ae4fe 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx @@ -52,6 +52,8 @@ const meta = { step: editableStep, registry, onChange: () => {}, + assetNames: {}, + onClearBinding: () => {}, }, } satisfies Meta; export default meta; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx index 55a52b496e..0b15b7233a 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx @@ -116,6 +116,8 @@ describe("PipelineStepSettings", () => { step={step} registry={registry} onChange={() => {}} + assetNames={{}} + onClearBinding={() => {}} /> , ), @@ -131,6 +133,8 @@ describe("PipelineStepSettings", () => { step={convertStep} registry={convertRegistry} onChange={() => {}} + assetNames={{}} + onClearBinding={() => {}} /> , ), @@ -146,6 +150,8 @@ describe("PipelineStepSettings", () => { step={changeMetadataStep} registry={changeMetadataRegistry} onChange={() => {}} + assetNames={{}} + onClearBinding={() => {}} /> , ), @@ -161,6 +167,8 @@ describe("PipelineStepSettings", () => { step={overlayStep} registry={overlayRegistry} onChange={() => {}} + assetNames={{}} + onClearBinding={() => {}} /> , ), @@ -205,6 +213,8 @@ describe("PipelineStepSettings", () => { typeof update === "function" ? update(prev) : update, ) } + assetNames={{}} + onClearBinding={() => {}} /> {JSON.stringify(params)} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx index 58fb2a96fa..3d17dbf652 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx @@ -1,15 +1,22 @@ import { Suspense } from "react"; import { useTranslation } from "react-i18next"; -import { Banner } from "@app/ui"; +import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; +import { Banner, Chip } from "@app/ui"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import { SidebarProvider } from "@app/contexts/SidebarContext"; import { type ToolRegistry } from "@app/data/toolsTaxonomy"; import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; -import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { + activeFileFields, + assetRefIds, + extractStepFiles, + type WorkingToolStep, +} from "@app/hooks/tools/shared/toolAutomation"; import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig"; import { isIntegrationStep } from "@portal/components/pipelines/integrationStep"; import type { ExternalApiStepParams } from "@portal/components/policies/stepOperations"; +import "@portal/components/pipelines/PipelineStepSettings.css"; /** * A params update: the next params outright, or a merge from the latest params. Settings UIs fire @@ -25,17 +32,61 @@ interface PipelineStepSettingsProps { step: WorkingToolStep; registry: Partial; onChange: (update: ParamsUpdate) => void; + /** Stored asset id -> file name, for labelling the supporting-file chips on a reopened pipeline. */ + assetNames: Record; + /** Drop a field's stored supporting-file binding (the user re-picks a file if the step still needs one). */ + onClearBinding: (field: string) => void; +} + +/** One reopened supporting file shown as a chip: the field it binds and the stored file name(s). */ +interface StoredFileChip { + field: string; + label: string; +} + +/** + * The supporting files this step is reusing from a previous save: an active binding whose field has + * no fresh pick (a fresh pick shows in the tool's own file picker instead). Labelled by the resolved + * asset name so the user sees "using cert.pfx" rather than an empty picker. + */ +function storedFileChips( + step: WorkingToolStep, + registry: Partial, + assetNames: Record, +): StoredFileChip[] { + const bindings = step.fileParameters; + if (!bindings) return []; + // A null active set means the tool couldn't be probed; show every stored binding rather than hide + // the user's files (mirrors the save path, which keeps them too). + const active = activeFileFields(step, registry); + const activeSet = active === null ? null : new Set(active); + const fresh = extractStepFiles(step, registry); + return Object.entries(bindings) + .filter( + ([field]) => + (activeSet === null || activeSet.has(field)) && !fresh[field], + ) + .map(([field, binding]) => ({ + field, + label: + assetRefIds(binding) + .map((id) => assetNames[id] ?? id) + .join(", ") || binding, + })); } /** * Renders the parameter editor for one pipeline step, chosen by the tool's capability: * the tool's own settings UI when editable, an explanatory note when it has no parameters, - * or a "not supported yet" fallback for tools not yet migrated to the mapper seam. + * or a "not supported yet" fallback for tools not yet migrated to the mapper seam. Reopened + * supporting files appear as removable chips above the tool's own settings. */ export function PipelineStepSettings({ step, registry, onChange, + assetNames, + onClearBinding, }: PipelineStepSettingsProps) { // Hooks first: selecting a different step re-renders this same instance, so an early return // above useTranslation would change the hook count between renders and crash. @@ -52,41 +103,70 @@ export function PipelineStepSettings({ ); } - if (step.support === "noSettings") { - return ( - - ); - } + const chips = storedFileChips(step, registry, assetNames); - const entry = step.toolId ? registry[step.toolId] : undefined; - const Settings = - step.support === "editable" ? entry?.automationSettings : null; - - if (!Settings) { + function toolBody() { + if (step.support === "noSettings") { + return ( + + ); + } + const entry = step.toolId ? registry[step.toolId] : undefined; + const Settings = + step.support === "editable" ? entry?.automationSettings : null; + if (!Settings) { + return ( + + ); + } return ( - + + + + + onChange((prev) => ({ ...prev, [key]: value })) + } + disabled={false} + /> + + + ); } return ( - - - - - onChange((prev) => ({ ...prev, [key]: value })) - } - disabled={false} - /> - - - + <> + {chips.length > 0 && ( +
    + + {t("portal.pipelines.builder.supportingFiles")} + +
    + {chips.map((chip) => ( + + } + onRemove={() => onClearBinding(chip.field)} + > + {chip.label} + + ))} +
    +
    + )} + {toolBody()} + ); } diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index ed7cec1626..c5505f38ff 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -116,6 +116,21 @@ function nextId(): string { return `plc_${Date.now().toString(36)}_${idCounter}`; } +/** Stored supporting files a step binds as `asset:` (PolicyAssetController), for mock mode. */ +interface StoredAsset { + id: string; + fileName: string; + contentType: string | null; + size: number; + createdAt: number; +} +let assetStore: StoredAsset[] = []; +let assetCounter = 0; +function nextAssetId(): string { + assetCounter += 1; + return `ast_${Date.now().toString(36)}_${assetCounter}`; +} + function deriveStatus(policy: StoredPolicy): PipelineStatus { return policy.enabled ? "active" : "paused"; } @@ -197,6 +212,33 @@ export const pipelinesHandlers = [ ]); }), + // Supporting files. Registered before the `/policies/:id` matcher so "assets" isn't read as an id. + http.get("/api/v1/policies/assets", async () => { + await delay(80); + return HttpResponse.json(assetStore); + }), + + http.post("/api/v1/policies/assets", async ({ request }) => { + const form = await request.formData(); + const file = form.get("file"); + if (!(file instanceof File)) { + return HttpResponse.json( + { detail: "Uploaded file is empty" }, + { status: 400 }, + ); + } + await delay(120); + const asset: StoredAsset = { + id: nextAssetId(), + fileName: file.name || "asset", + contentType: file.type || null, + size: file.size, + createdAt: Date.now(), + }; + assetStore = [...assetStore, asset]; + return HttpResponse.json(asset); + }), + // Run status: the mock completes runs immediately, so polling resolves at once. http.get("/api/v1/policies/run/:runId", async ({ params }) => { await delay(120); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index 52fb5d516c..2737411c74 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -43,6 +43,13 @@ vi.mock("@portal/api/pipelines", () => ({ fetchRun: (runId: string) => fetchRun(runId), })); +const uploadPipelineAsset = vi.fn(); +const listPipelineAssets = vi.fn(); +vi.mock("@portal/api/pipelineAssets", () => ({ + uploadPipelineAsset: (file: File) => uploadPipelineAsset(file), + listPipelineAssets: () => listPipelineAssets(), +})); + const fetchSources = vi.fn(); vi.mock("@portal/api/sources", () => ({ fetchSources: () => fetchSources(), @@ -149,8 +156,21 @@ vi.mock("@app/contexts/ToolRegistryContext", () => { toolType: 0, endpoint: "/api/v1/misc/compress-pdf", defaultParameters: {}, - buildFormData: () => new FormData(), - toApiParams: (params: Record) => ({ ...params }), + // Sends the supporting file under a named field, like a real file tool, so the upload path + // has a field to bind. The scalar mapper drops the File (files never ride in parameters). + buildFormData: (params: Record, file: File | File[]) => { + const fd = new FormData(); + fd.append("fileInput", Array.isArray(file) ? file[0] : file); + if (params.watermarkImage instanceof File) { + fd.append("watermarkImage", params.watermarkImage); + } + return fd; + }, + toApiParams: (params: Record) => { + const scalars = { ...params }; + delete scalars.watermarkImage; + return scalars; + }, fromApiParams: (params: Record) => ({ ...params }), }, } as unknown as ToolRegistryEntry; @@ -203,10 +223,32 @@ vi.mock("@app/contexts/ToolRegistryContext", () => { fromApiParams: (params: Record) => ({ ...params }), }, } as unknown as ToolRegistryEntry; + // A tool whose buildFormData throws, so it can't be probed: exercises the "activeFileFields is + // null" path where a reopened step's stored binding must be kept, not dropped. + const sign = { + name: "Sign", + icon: null, + component: null, + description: "", + categoryId: "recommendedTools", + subcategoryId: "general", + operationConfig: { + operationType: "certSign", + toolType: 0, + endpoint: "/api/v1/security/cert-sign", + defaultParameters: {}, + buildFormData: () => { + throw new Error("cannot build"); + }, + toApiParams: (params: Record) => ({ ...params }), + fromApiParams: (params: Record) => ({ ...params }), + }, + } as unknown as ToolRegistryEntry; const allTools = { compress, extractImages, ocr, + sign, } as unknown as ToolRegistryCatalog["allTools"]; const catalog: ToolRegistryCatalog = { regularTools: allTools, @@ -288,6 +330,16 @@ describe("PipelineBuilder", () => { fetchS3Connections.mockReset(); fetchS3Connections.mockResolvedValue([]); createIntegration.mockReset(); + uploadPipelineAsset.mockReset(); + uploadPipelineAsset.mockResolvedValue({ + id: "ast-1", + fileName: "logo.png", + contentType: "image/png", + size: 1, + createdAt: 0, + }); + listPipelineAssets.mockReset(); + listPipelineAssets.mockResolvedValue([]); }); // The settings of a node are reached by selecting it in the graph, so every helper below opens @@ -798,7 +850,7 @@ describe("PipelineBuilder", () => { ).toBeInTheDocument(); }); - it("blocks saving a step that needs an uploaded file", async () => { + it("uploads a step's supporting file and saves it as an asset binding", async () => { renderBuilder("/processor/pipelines/new"); fireEvent.change( @@ -810,15 +862,65 @@ describe("PipelineBuilder", () => { }, ); await addTool("Compress"); - // The tool's settings upload a file, which a stored pipeline can't persist yet. + // The tool's settings attach a supporting file. fireEvent.click(await screen.findByText("upload logo")); - expect( - await screen.findByText("portal.pipelines.builder.uploadUnsupported"), - ).toBeInTheDocument(); - expect( - screen.getByText("portal.pipelines.composer.create").closest("button"), - ).toBeDisabled(); + await pickInputSource("Claims intake"); + await pickDestination(); + + fireEvent.click(screen.getByText("portal.pipelines.composer.create")); + + // The file is uploaded to the asset store first, then the policy is saved binding that asset. + await waitFor(() => expect(uploadPipelineAsset).toHaveBeenCalledTimes(1)); + expect(uploadPipelineAsset.mock.calls[0][0]).toBeInstanceOf(File); + await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ + steps: [ + expect.objectContaining({ + operation: "/api/v1/misc/compress-pdf", + fileParameters: { watermarkImage: "asset:ast-1" }, + }), + ], + }), + ); + }); + + it("keeps a step's stored file binding on save when the tool can't be probed", async () => { + // buildFormData throws for `sign`, so activeFileFields is null. The stored binding must survive + // the save unchanged - dropping it would let the server GC the user's uploaded file - and no + // re-upload should happen. + fetchPipeline.mockResolvedValue({ + id: "plc-sign", + name: "Signed", + enabled: true, + inputs: [{ sourceId: "src-in", trigger: null }], + steps: [ + { + operation: "/api/v1/security/cert-sign", + parameters: {}, + fileParameters: { certFile: "asset:x" }, + }, + ], + output: { type: "inline", options: {} }, + outputIds: ["src-1"], + }); + renderBuilder("/processor/pipelines/plc-sign"); + + fireEvent.click(await screen.findByText("portal.pipelines.composer.save")); + + await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); + expect(savePipeline).toHaveBeenCalledWith( + expect.objectContaining({ + steps: [ + expect.objectContaining({ + operation: "/api/v1/security/cert-sign", + fileParameters: { certFile: "asset:x" }, + }), + ], + }), + ); + expect(uploadPipelineAsset).not.toHaveBeenCalled(); }); it("blocks saving an integration step with no account chosen", async () => { diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index d5092f5c88..975a8e5cfe 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -17,14 +17,17 @@ import { } from "@app/ui"; import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { + activeFileFields, + assetRef, deserializeToolStep, + extractStepFiles, getExecutableTools, newWorkingToolStep, serializeToolStep, stepNeedsConfiguring, - stepRequiresUpload, updateWorkingStepParams, type ExecutableTool, + type SupportingFileBindings, type WorkingToolStep, } from "@app/hooks/tools/shared/toolAutomation"; import { @@ -48,13 +51,20 @@ import { runPipelineTest, savePipeline, triggerPipeline, + type PipelineStep, type Policy, type PolicyRunView, type RunOutputFile, + type TestRunAsset, type TriggerConfig, type TriggerInfo, type TriggerOutcome, } from "@portal/api/pipelines"; +import { + listPipelineAssets, + uploadPipelineAsset, + type PolicyAsset, +} from "@portal/api/pipelineAssets"; import { clearProcessedHistory } from "@portal/api/policies"; import { DestinationPicker } from "@portal/components/pipelines/DestinationPicker"; import { availableOutputModes } from "@portal/components/pipelines/outputModes"; @@ -207,6 +217,17 @@ export function PipelineBuilder() { [allTools], ); + // Stored supporting files from earlier saves, so a reopened step can label its bindings by name. + const assetsState = useAsync( + async () => await listPipelineAssets(), + [], + ); + const assetNames = useMemo(() => { + const map: Record = {}; + for (const asset of assetsState.data ?? []) map[asset.id] = asset.fileName; + return map; + }, [assetsState.data]); + const policyState = useAsync( async () => (id ? await fetchPipeline(id) : null), [id], @@ -486,6 +507,21 @@ export function PipelineBuilder() { ); } + /** Drop a step's stored supporting-file binding for one field (the chip's remove action). */ + function clearStepBinding(index: number, field: string) { + setSteps((current) => + current.map((step, i) => { + if (i !== index || !step.fileParameters) return step; + const next = { ...step.fileParameters }; + delete next[field]; + return { + ...step, + fileParameters: Object.keys(next).length > 0 ? next : undefined, + }; + }), + ); + } + function stepLabel(step: WorkingToolStep): string { // An integration step's endpoint is the same for every vendor, so the raw path would read // "External api call" for all of them. Name it by the operation instead. @@ -512,11 +548,6 @@ export function PipelineBuilder() { return step.toolId ? allTools[step.toolId]?.icon : undefined; } - // Steps whose params carry an uploaded file can't be saved: the bytes aren't persisted with the - // policy, so a later run would send null for that field (see stepRequiresUpload). - const uploadStepLabels = steps.filter(stepRequiresUpload).map(stepLabel); - const hasUploadSteps = uploadStepLabels.length > 0; - // A step still missing a choice - an integration with no operation or account, a tool whose // mandatory parameters are unset - would fail at run time with a raw backend rejection, so block // saving on it here where the fix is one click away. @@ -601,11 +632,30 @@ export function PipelineBuilder() { // seeding, so leaving the builder can prompt to save or discard. `enabled` is deliberately left // out: in edit it is toggled and persisted at once (never an unsaved edit), and in create it is // chosen at submit - so it can never be the thing that makes the form dirty. + // Per-step dirty signature: the serialized step plus a stable identity (name/size/mtime) of its + // fresh file picks - a raw File JSON-stringifies to `{}`, so serializeToolStep (which excludes + // Files) can't see a file added or swapped. Memoized on the steps because it probes each tool's + // buildFormData; without this it would re-run for every step on any render (e.g. each keystroke in + // the name field). Stored bindings are covered by the serialized step. + const stepSnapshot = useMemo( + () => + steps.map((step) => { + const files: Record = {}; + for (const [field, picks] of Object.entries( + extractStepFiles(step, allTools), + )) { + files[field] = picks.map( + (file) => `${file.name}:${file.size}:${file.lastModified}`, + ); + } + return { step: serializeToolStep(step, allTools), files }; + }), + [steps, allTools], + ); const snapshot = JSON.stringify({ name: name.trim(), input, - steps: steps.map((step) => serializeToolStep(step, allTools)), - uploads: steps.map(stepRequiresUpload), + steps: stepSnapshot, outputIds: [...outputIds].sort(), }); const baseline = useRef(null); @@ -639,12 +689,6 @@ export function PipelineBuilder() { tools: unconfiguredStepLabels.join(", "), }), ); - if (hasUploadSteps) - blockers.push( - t("portal.pipelines.builder.blocker.upload", { - tools: uploadStepLabels.join(", "), - }), - ); if (hasIncompatibleSteps) blockers.push( t("portal.pipelines.builder.blocker.incompatible", { @@ -667,23 +711,84 @@ export function PipelineBuilder() { else navigate(destination); } + /** + * The active supporting-file fields of a step, each paired with its fresh in-memory pick(s) and its + * stored `asset:` binding (either may be absent). The single source both saving and test-running + * read, so the two agree on which fields are active and how a binding is chosen; they differ only in + * how a fresh pick is emitted - uploaded as an asset vs. sent inline. + */ + function stepFileFields( + step: WorkingToolStep, + ): { field: string; fresh: File[] | null; stored: string | null }[] { + const fresh = extractStepFiles(step, allTools); + const stored = step.fileParameters ?? {}; + const fields = activeFileFields(step, allTools) ?? Object.keys(stored); + return fields.map((field) => ({ + field, + fresh: fresh[field] ?? null, + stored: stored[field] ?? null, + })); + } + + /** A wire step, attaching fileParameters only when it has any. */ + function toWireStep( + operation: string, + parameters: Record, + bindings: SupportingFileBindings, + ): PipelineStep { + return Object.keys(bindings).length > 0 + ? { operation, parameters, fileParameters: bindings } + : { operation, parameters }; + } + + /** + * The wire steps for saving: scalar params from serialization, plus supporting-file bindings. A + * fresh pick is uploaded to the asset store (the save-time validator rejects a policy that binds an + * asset id that doesn't yet exist); a stored binding the tool still uses is kept when the user + * didn't replace it. Uploads run in parallel; any abandoned by a later failure are GC'd server-side. + */ + async function serializeStepsForSave(): Promise { + return Promise.all( + steps.map(async (step) => { + const { operation, parameters } = serializeToolStep(step, allTools); + const entries = await Promise.all( + stepFileFields(step).map(async ({ field, fresh, stored }) => { + if (fresh?.length) { + const ids = await Promise.all( + fresh.map((file) => + uploadPipelineAsset(file).then((a) => a.id), + ), + ); + return [field, assetRef(ids)] as const; + } + return stored ? ([field, stored] as const) : null; + }), + ); + const bindings: SupportingFileBindings = Object.fromEntries( + entries.filter((e): e is readonly [string, string] => e !== null), + ); + return toWireStep(operation, parameters, bindings); + }), + ); + } + async function save(destination: string, enabledOverride?: boolean) { if (!canSave) return; setSubmitting(true); setError(null); - const policy: Policy = { - id: policyState.data?.id ?? undefined, - name: name.trim(), - enabled: enabledOverride ?? enabled, - // The wire shape stays a list; canSave guarantees the one input has a source. - inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], - steps: steps.map((step) => serializeToolStep(step, allTools)), - // Destinations are the referenced saved sources; the inline output field is - // preserved as-is (e.g. an editor policy's membership metadata) or defaults to inline. - output: policyState.data?.output ?? { type: "inline", options: {} }, - outputIds, - }; try { + const policy: Policy = { + id: policyState.data?.id ?? undefined, + name: name.trim(), + enabled: enabledOverride ?? enabled, + // The wire shape stays a list; canSave guarantees the one input has a source. + inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], + steps: await serializeStepsForSave(), + // Destinations are the referenced saved sources; the inline output field is + // preserved as-is (e.g. an editor policy's membership metadata) or defaults to inline. + output: policyState.data?.output ?? { type: "inline", options: {} }, + outputIds, + }; await savePipeline(policy); await invalidatePipelines(); navigate(destination); @@ -741,6 +846,32 @@ export function PipelineBuilder() { return null; } + /** + * The steps + inline supporting files for a test run. A fresh (in-memory) pick rides along as a + * keyed `assets[i]` under a per-step run key; a stored file keeps its `asset:` binding, which + * the backend resolves from the pipeline's saved policy (passed as policyId) - no re-fetch needed. + */ + function buildTestSteps(): { steps: PipelineStep[]; assets: TestRunAsset[] } { + const assets: TestRunAsset[] = []; + const outSteps = steps.map((step, i) => { + const { operation, parameters } = serializeToolStep(step, allTools); + const bindings: SupportingFileBindings = {}; + for (const { field, fresh, stored } of stepFileFields(step)) { + if (fresh?.length) { + // In-memory pick: inline the bytes under a run key. + const key = `s${i}_${field}`; + bindings[field] = key; + for (const file of fresh) assets.push({ key, file }); + } else if (stored) { + // Already an asset: keep its ref for the backend to resolve from the saved policy. + bindings[field] = stored; + } + } + return toWireStep(operation, parameters, bindings); + }); + return { steps: outSteps, assets }; + } + /** * Run the steps as they stand against one uploaded file. Output is forced inline so nothing * reaches the pipeline's real destination, and the pipeline need not be saved first - this is @@ -752,13 +883,17 @@ export function PipelineBuilder() { setTestRun(null); setRunResult(null); try { + const { steps: testSteps, assets } = buildTestSteps(); const { runId } = await runPipelineTest( { name: name.trim() || t("portal.pipelines.builder.testRun"), - steps: steps.map((step) => serializeToolStep(step, allTools)), + steps: testSteps, output: { type: "inline", options: {} }, }, file, + assets, + // Lets the backend resolve any stored `asset:` refs from this saved policy. + policyState.data?.id, ); const final = await awaitRun(runId, (view) => { if (mounted.current) setTestRun(view); @@ -934,8 +1069,6 @@ export function PipelineBuilder() { return t("portal.pipelines.builder.chooseAccount"); return undefined; } - if (stepRequiresUpload(step)) - return t("portal.pipelines.builder.needsUpload"); if (stepNeedsConfiguring(step, allTools)) return t("portal.pipelines.builder.needsConfiguring"); return undefined; @@ -1120,6 +1253,8 @@ export function PipelineBuilder() { step={selectedStep} registry={allTools} onChange={(params) => updateStepParams(chosenSteps[0], params)} + assetNames={assetNames} + onClearBinding={(field) => clearStepBinding(chosenSteps[0], field)} /> ); } @@ -1165,14 +1300,6 @@ export function PipelineBuilder() { {runResult && ( )} - {hasUploadSteps && ( - - )} {hasUnconfiguredSteps && ( Date: Thu, 20 Aug 2026 09:33:26 +0000 Subject: [PATCH 224/262] Float the editor search when no file is open (#7575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The super-search work pinned the editor's `WorkbenchBar` visible on every view except My Files, even with no file open. That left an empty workbench showing a fully painted bar whose only live control was the search — download / close / print / save were all disabled, because those actions only make sense with a file open. This stops forcing the bar. When nothing is open, only the global search floats (unpainted, centered), mirroring how the Processor already works. When a file **is** open, the `WorkbenchBar` renders exactly as before. Also fixes a smaller Processor issue: its floating search strip was shorter than the sidebar logo row, so the search sat higher than the brand. Its height now matches the logo row (51px) so they line up. ## Changes - **`Workbench.tsx`** — render the `WorkbenchBar` only when a file is open (or a custom view supplies content); otherwise render the new floating search. My Files and `hideTopControls` custom views are unchanged. - **`WorkbenchFloatingSearch.tsx` / `.css`** (new) — the editor's `SuperSearch` floated in an unpainted strip, mirroring `PortalSearchBar`. Its vertical band matches the bar's so opening a file swaps in the bar without a shift. - **`PortalSearchBar.css`** — strip height matched to `.portal-sidebar__logo` (51px) so the Processor search aligns with the logo. The notification bell is intentionally out of scope — it ships in a separate PR. ## Before / after (ignore the bell icon in the after that’s not live yet) Screenshot 2026-08-20 at 2 28
17 AM Screenshot 2026-08-20 at 2 28
31 AM - **Editor, no file:** painted bar with disabled buttons → just a floating search. - **Editor, file open:** unchanged. - **Processor:** search now vertically aligned with the logo. ## Testing - `task frontend:check` — lint (incl. colour linters) + typecheck + tests (247 files / 2137 tests) all pass. - Processor alignment verified in Storybook (`Portal/Shell/AppShell`): logo row, search strip, and search pill share the same vertical center. - Editor float not verified in-browser (local backend is behind a login gate); covered by types/tests and reuses the verified Processor pattern. --- .../src/core/components/layout/Workbench.tsx | 77 +++++++++++-------- .../shared/WorkbenchFloatingSearch.css | 31 ++++++++ .../shared/WorkbenchFloatingSearch.tsx | 15 ++++ .../src/portal/components/PortalSearchBar.css | 7 +- 4 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.css create mode 100644 frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.tsx diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 7dbc6f75be..8bc0794140 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -18,6 +18,7 @@ import { useCookieConsent } from "@app/hooks/useCookieConsent"; import styles from "@app/components/layout/Workbench.module.css"; import WorkbenchBar from "@app/components/shared/WorkbenchBar"; +import WorkbenchFloatingSearch from "@app/components/shared/WorkbenchFloatingSearch"; import LandingPage from "@app/components/shared/LandingPage"; import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton"; import { ChatFAB } from "@app/components/chat/ChatFAB"; @@ -77,6 +78,22 @@ export default function Workbench() { const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false); const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed; + // The WorkbenchBar carries file-scoped actions, so it only shows once a file + // is open or a custom view supplies content; otherwise the search floats. + const activeCustomView = customWorkbenchViews.find( + (v) => v.workbenchId === currentView, + ); + const topControlsAvailable = + currentView !== "myFiles" && !activeCustomView?.hideTopControls; + const hasWorkbenchContent = + hasFiles || + fileIds.length > 0 || + !isBaseWorkbench(currentView) || + // Shared signing drives the viewer from the sidebar with no file in context. + (currentView === "viewer" && !!signingOverlay?.file); + const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent; + const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent; + const handlePreviewClose = () => { setPreviewFile(null); const previousMode = sessionStorage.getItem("previousMode"); @@ -231,41 +248,35 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* Workbench Bar — always visible outside My Files (it hosts the - global search), even with no files loaded. */} - {currentView !== "myFiles" && - !customWorkbenchViews.find((v) => v.workbenchId === currentView) - ?.hideTopControls && ( -
    -
    -
    - -
    -
    - {/* Reopen tab: a little handle hanging off the bar's bottom-right - while the viewer tool row is retracted. */} - {showReopenTab && ( -
    + )} + {showFloatingSearch && } {/* Dismiss All Errors Button */} diff --git a/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.css b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.css new file mode 100644 index 0000000000..47c7e9eed4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.css @@ -0,0 +1,31 @@ +/* Unpainted floating search for the empty workbench. Height + top margin match + the WorkbenchBar's band so opening a file swaps it in without a shift. */ +.workbench-floating-search { + display: flex; + align-items: center; + justify-content: center; + min-height: 38px; + margin-top: var(--nav-gutter); + padding: 0 1rem; + flex-shrink: 0; +} + +.workbench-floating-search .super-search { + flex: 0 1 24rem; + width: min(100%, 24rem); + max-width: 24rem; +} + +.workbench-floating-search .super-search input { + background-color: transparent; + padding-top: 4px; + padding-bottom: 4px; + font-size: 12.5px; +} + +[data-mantine-color-scheme="dark"] + .workbench-floating-search + .super-search + input { + background-color: transparent; +} diff --git a/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.tsx b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.tsx new file mode 100644 index 0000000000..37e858adff --- /dev/null +++ b/frontend/editor/src/core/components/shared/WorkbenchFloatingSearch.tsx @@ -0,0 +1,15 @@ +import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; +import { useEditorSearchScopes } from "@app/hooks/useSuperSearch"; +import "@app/components/shared/WorkbenchFloatingSearch.css"; + +// The editor's global search, floated while no file is open (mirrors the +// processor's PortalSearchBar). Renders only when the WorkbenchBar doesn't, so +// reusing the default input id is safe. +export default function WorkbenchFloatingSearch() { + const scopes = useEditorSearchScopes(); + return ( +
    + +
    + ); +} diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css index cf1d9971c7..8e253b6136 100644 --- a/frontend/editor/src/portal/components/PortalSearchBar.css +++ b/frontend/editor/src/portal/components/PortalSearchBar.css @@ -1,10 +1,11 @@ -/* Slim strip at the top of the main column hosting the shared search bar. - Deliberately unpainted: only the input itself shows, on the page ground. */ +/* Unpainted strip at the top of the main column. Height matches the sidebar's + logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */ .portal-searchbar { display: flex; align-items: center; justify-content: center; - padding: 0.22rem 1rem; + min-height: 3.1875rem; + padding: 0 1rem; flex-shrink: 0; } From 91fc26f10c21690af6d7c78c7f247ea1d5acfcaa Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 20 Aug 2026 09:53:28 +0000 Subject: [PATCH 225/262] chore: Bump version to 2.14.3 (#7554) # Description of Changes This PR bumps the Stirling PDF application version from `2.14.2` to `2.14.3` across the project. Changes include: - Updated the Gradle project version in `build.gradle` to `2.14.3`. - Updated the Tauri desktop application version in `frontend/editor/src-tauri/tauri.conf.json`. - Updated the AUR package version for `stirling-pdf-desktop`. - Updated the AUR package version for `stirling-pdf-server-bin`. - Updated the mocked `appVersion` used by the core frontend server experience simulations. - Updated the mocked `appVersion` used by the proprietary frontend server experience simulations. - Kept all application, desktop, packaging, and test/simulation version references synchronized for the `2.14.3` release. The change prepares the project metadata and packaging configuration for the `2.14.3` release and prevents different components from reporting or packaging the previous `2.14.2` version. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/aur/stirling-pdf-desktop/PKGBUILD | 2 +- .github/aur/stirling-pdf-server-bin/PKGBUILD | 2 +- build.gradle | 2 +- frontend/editor/src-tauri/tauri.conf.json | 2 +- frontend/editor/src/core/testing/serverExperienceSimulations.ts | 2 +- .../src/proprietary/testing/serverExperienceSimulations.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index fb6a99cfca..c47c8e3ee7 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.2 +pkgver=2.14.3 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index 70bcee0423..d6159ddce3 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.2 +pkgver=2.14.3 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/build.gradle b/build.gradle index cadf4bafa6..30a9123ebb 100644 --- a/build.gradle +++ b/build.gradle @@ -108,7 +108,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.14.2' + version = '2.14.3' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index dee2cc7023..c308e3ad4e 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling PDF", "mainBinaryName": "Stirling-PDF", - "version": "2.14.2", + "version": "2.14.3", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index 97da95c730..55ab79c111 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.2", + appVersion: "2.14.3", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts index d92cf8fdf9..0519b9afb8 100644 --- a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.2", + appVersion: "2.14.3", serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true, From 4791d558c515c08bfbb954bb2af5315a68953b93 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:16:18 +0000 Subject: [PATCH 226/262] Update Backend 3rd Party Licenses (#7579) Auto-generated by stirlingbot[bot] This PR updates the backend license report based on dependency changes. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- .../resources/static/3rdPartyLicenses.json | 226 ++++++++++++++---- 1 file changed, 185 insertions(+), 41 deletions(-) diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index fa852846b3..fbdce0a158 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -14,6 +14,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, + { + "moduleName": "ch.qos.logback:logback-classic", + "moduleUrl": "http://www.qos.ch", + "moduleVersion": "1.6.1", + "moduleLicense": "LGPL-2.1-only", + "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + }, { "moduleName": "ch.qos.logback:logback-core", "moduleUrl": "http://www.qos.ch", @@ -21,6 +28,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, + { + "moduleName": "ch.qos.logback:logback-core", + "moduleUrl": "http://www.qos.ch", + "moduleVersion": "1.6.1", + "moduleLicense": "LGPL-2.1-only", + "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + }, { "moduleName": "com.adobe.xmp:xmpcore", "moduleUrl": "https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html", @@ -182,7 +196,7 @@ { "moduleName": "com.github.mwiede:jsch", "moduleUrl": "https://github.com/mwiede/jsch", - "moduleVersion": "0.2.23", + "moduleVersion": "2.28.6", "moduleLicense": "Revised BSD", "moduleLicenseUrl": "https://github.com/mwiede/jsch/blob/master/LICENSE.txt" }, @@ -758,7 +772,7 @@ { "moduleName": "commons-net:commons-net", "moduleUrl": "https://commons.apache.org/proper/commons-net/", - "moduleVersion": "3.11.1", + "moduleVersion": "3.13.0", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -1213,24 +1227,48 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-networking", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-security", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-security", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-support", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-support", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "net.shibboleth:shib-velocity", "moduleVersion": "9.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "net.shibboleth:shib-velocity", + "moduleVersion": "9.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.antlr:antlr4-runtime", "moduleUrl": "https://www.antlr.org/", @@ -1325,13 +1363,6 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "org.apache.httpcomponents:httpclient", - "moduleUrl": "http://hc.apache.org/httpcomponents-client", - "moduleVersion": "4.5.13", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "org.apache.httpcomponents:httpclient", "moduleUrl": "http://hc.apache.org/httpcomponents-client-ga", @@ -1456,6 +1487,13 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.apache.santuario:xmlsec", + "moduleUrl": "https://www.apache.org/", + "moduleVersion": "3.0.6", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.apache.tomcat.embed:tomcat-embed-el", "moduleUrl": "https://tomcat.apache.org/", @@ -1470,6 +1508,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.apache.velocity:velocity-engine-core", + "moduleUrl": "https://www.apache.org/", + "moduleVersion": "2.4.1", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.apache.xmlbeans:xmlbeans", "moduleUrl": "https://xmlbeans.apache.org/", @@ -1647,6 +1692,13 @@ "moduleLicense": "GNU Lesser General Public License", "moduleLicenseUrl": "http://www.gnu.org/licenses/lgpl-3.0.txt" }, + { + "moduleName": "org.cryptacular:cryptacular", + "moduleUrl": "https://www.cryptacular.org", + "moduleVersion": "1.3.0", + "moduleLicense": "GNU Lesser General Public License", + "moduleLicenseUrl": "https://www.gnu.org/licenses/lgpl-3.0.txt" + }, { "moduleName": "org.eclipse.angus:angus-activation", "moduleUrl": "https://www.eclipse.org", @@ -2016,78 +2068,156 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-core-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-core-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-core-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-messaging-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-messaging-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-profile-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-profile-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-saml-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-saml-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-saml-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-saml-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-security-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-security-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-security-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-security-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-soap-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-soap-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-soap-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-soap-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-storage-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-storage-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-xmlsec-api", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-xmlsec-api", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.opensaml:opensaml-xmlsec-impl", "moduleVersion": "5.1.6", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "org.opensaml:opensaml-xmlsec-impl", + "moduleVersion": "5.2.2", + "moduleLicense": "The Apache Software License, Version 2.0", + "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "org.ow2.asm:asm", "moduleUrl": "http://asm.ow2.org", @@ -2564,6 +2694,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.security:spring-security-core", + "moduleUrl": "https://spring.io/projects/spring-security", + "moduleVersion": "7.1.0", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.security:spring-security-crypto", "moduleUrl": "https://spring.io/projects/spring-security", @@ -2606,6 +2743,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.security:spring-security-saml2-service-provider", + "moduleUrl": "https://spring.io/projects/spring-security", + "moduleVersion": "7.1.0", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.security:spring-security-web", "moduleUrl": "https://spring.io/projects/spring-security", @@ -2805,207 +2949,207 @@ }, { "moduleName": "software.amazon.awssdk:annotations", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { - "moduleName": "software.amazon.awssdk:apache-client", - "moduleVersion": "2.44.12", + "moduleName": "software.amazon.awssdk:apache5-client", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:arns", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:auth", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-query-protocol", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:aws-xml-protocol", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:checksums", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:checksums-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:crt-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:endpoints-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-aws", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-aws-eventstream", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-auth-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:http-client-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:identity-spi", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:json-utils", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:metrics-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:netty-nio-client", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:profiles", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:protocol-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:regions", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:retries", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:retries-spi", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:s3", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:sdk-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:third-party-jackson-core", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:url-connection-client", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:utils", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, { "moduleName": "software.amazon.awssdk:utils-lite", "moduleUrl": "https://aws.amazon.com/sdkforjava", - "moduleVersion": "2.44.12", + "moduleVersion": "2.51.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://aws.amazon.com/apache2.0" }, From 50d34fcca5e778ec9dd66ec5c6d2048a23a35a2e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:47:05 +0000 Subject: [PATCH 227/262] Add download, rename and duplicate to the file actions menu (#7536) # Description of Changes Adds expanded dropdown menu for download, rename and duplicate image image --- ## 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. --- .../public/locales/en-US/translation.toml | 15 + .../core/components/filesPage/FileGrid.tsx | 552 +++++++++--------- .../components/filesPage/FileManagerView.tsx | 101 ++++ .../core/components/shared/FileSidebar.tsx | 137 +++++ .../components/shared/FileSidebarFileItem.css | 22 + .../components/shared/FileSidebarFileItem.tsx | 351 +++++++---- .../shared/RenameFileDialog.stories.tsx | 39 ++ .../components/shared/RenameFileDialog.tsx | 140 +++++ .../core/components/shared/WorkbenchBar.tsx | 6 +- .../editor/src/core/hooks/useFileHandler.ts | 4 + .../tests/stubbed/file-actions-menu.spec.ts | 167 ++++++ .../src/core/utils/duplicateFile.test.ts | 130 +++++ .../editor/src/core/utils/duplicateFile.ts | 85 +++ frontend/editor/src/core/utils/fileUtils.ts | 11 + 14 files changed, 1373 insertions(+), 387 deletions(-) create mode 100644 frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/RenameFileDialog.tsx create mode 100644 frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts create mode 100644 frontend/editor/src/core/utils/duplicateFile.test.ts create mode 100644 frontend/editor/src/core/utils/duplicateFile.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 01db314031..c2287ace9c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3897,8 +3897,10 @@ collapse = "Collapse sidebar" customizeGroups = "Customize groups" dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it." dataLostTitle = "File data is unavailable" +downloadFailed = "Download failed" dropHint = "Open files to get started" dropToAdd = "Drop files to add" +duplicateFailed = "Could not duplicate file" expand = "Expand sidebar" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" @@ -3918,8 +3920,10 @@ closeViewer = "Close viewer" dataLost = "Data lost" dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it." delete = "Delete" +duplicate = "Duplicate" moreActions = "More actions" openInViewer = "Open in viewer" +rename = "Rename" savedToServer = "Saved to server" updateOnServer = "Update on server" uploadToServer = "Upload to server" @@ -3931,6 +3935,14 @@ reset = "Show all" subtitle = "Show or hide categories in the files sidebar." title = "Sidebar categories" +[fileSidebar.rename] +cancel = "Cancel" +error = "Could not rename the file." +illegalCharacters = "A file name can't contain \\ / : * ? \" < > |" +label = "File name" +save = "Rename" +title = "Rename file" + [filesPage] addToWorkspace = "Add to workspace" addToWorkspaceCount = "Add {{count}} to workspace" @@ -3972,6 +3984,7 @@ downloadAll = "Download all" downloadVersion = "Download this version" dropOverlay = "Drop files to upload" dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder." +duplicate = "Duplicate" file = "File" fileInfo = "File info" fileMenu = "File actions" @@ -4087,6 +4100,8 @@ cloudDeleteFailed_one = "Couldn't delete 1 file from the cloud." cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud." deleteFolderFailed = "Could not delete folder." deleteFolderFailedDetail = "Could not delete folder: {{message}}" +downloadFailed = "Could not download the file." +duplicateFailed = "Could not duplicate the file." folderAppearanceFailed = "Could not update folder appearance." folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}" moveFilesFailed = "Could not move files." diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index e54964c592..7fd2eda133 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -13,6 +13,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import HistoryIcon from "@mui/icons-material/History"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; @@ -36,6 +37,8 @@ import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; import { findFolderIcon } from "@app/components/filesPage/folderIcons"; import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; @@ -83,6 +86,12 @@ interface FileGridProps { onSaveToServer?: (file: StirlingFileStub) => void; /** Open the version-history modal for a file (only when it has >1 version). */ onVersionHistory?: (file: StirlingFileStub) => void; + /** Download a copy (desktop: save a copy). */ + onDownloadFile?: (file: StirlingFileStub) => void; + /** Open the rename dialog for a file. */ + onRenameFile?: (file: StirlingFileStub) => void; + /** Save a second copy of the file into the library. */ + onDuplicateFile?: (file: StirlingFileStub) => void; /** When set, the Save to server item renders disabled with this tooltip. */ saveToServerDisabledReason?: string | null; /** When supplied the list-view column headers become sortable. */ @@ -366,24 +375,21 @@ function EmptyState({ ); } -function GridView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, -}: FileGridProps) { +function GridView(props: FileGridProps) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); return (
    {entries.map((entry) => { @@ -424,22 +430,7 @@ function GridView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onDoubleClick={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -626,7 +617,222 @@ function PolicyBadges({ fileId }: { fileId: string }) { return ; } -interface FileCardProps { +/** Per-file actions. Shared verbatim by the grid card and the list row, and + * kept in step with the file sidebar's kebab so both surfaces offer the same. */ +interface FileActionsMenuProps { + file: StirlingFileStub; + triggerRef: React.RefObject; + onOpen: () => void; + onMove: () => void; + onRemove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + onSaveToServer?: () => void; + onVersionHistory?: () => void; + saveToServerDisabledReason?: string | null; +} + +function FileActionsMenu({ + file, + triggerRef, + onOpen, + onMove, + onRemove, + onDownload, + onRename, + onDuplicate, + onSaveToServer, + onVersionHistory, + saveToServerDisabledReason, +}: FileActionsMenuProps) { + const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const DownloadIcon = useFileActionIcons().download; + const showSaveToServer = + Boolean(onSaveToServer) && file.remoteStorageId == null; + const showVersionHistory = + Boolean(onVersionHistory) && (file.versionNumber ?? 1) > 1; + return ( + + + e.stopPropagation()} + aria-label={t("filesPage.fileMenu", "File actions")} + data-testid="file-card-actions" + > + + + + + } + onClick={(e) => { + e.stopPropagation(); + onOpen(); + }} + > + {t("filesPage.addToWorkspace", "Add to workspace")} + + + } + onClick={(e) => { + e.stopPropagation(); + onMove(); + }} + data-testid="file-menu-move-to" + > + {t("filesPage.moveTo", "Move to…")} + + + {(onDownload || onRename || onDuplicate) && } + {onDownload && ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + data-testid="file-menu-download" + > + {terminology.download} + + )} + {onRename && ( + } + onClick={(e) => { + e.stopPropagation(); + onRename(); + }} + data-testid="file-menu-rename" + > + {t("filesPage.rename", "Rename")} + + )} + {onDuplicate && ( + } + onClick={(e) => { + e.stopPropagation(); + onDuplicate(); + }} + data-testid="file-menu-duplicate" + > + {t("filesPage.duplicate", "Duplicate")} + + )} + + {(showSaveToServer || showVersionHistory) && } + {/* Per-file Save to server; shown for local-only files. When + storage is off it stays visible but disabled with a tooltip. */} + {showSaveToServer && onSaveToServer && ( + + } + disabled={Boolean(saveToServerDisabledReason)} + onClick={(e) => { + e.stopPropagation(); + onSaveToServer(); + }} + style={ + saveToServerDisabledReason + ? { pointerEvents: "auto" } + : undefined + } + > + {t("filesPage.saveToServer", "Save to server")} + + + )} + {showVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(); + }} + > + {t("filesPage.versionHistory", "Version history")} + + )} + + + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t("filesPage.remove", "Delete")} + + + + ); +} + +/** Binds one file's kebab handlers, so grid and list wire them identically. */ +function useFileMenuHandlers( + props: FileGridProps, +): (file: StirlingFileStub) => FileMenuHandlers { + const { + selectedFileIds, + onRemoveFiles, + onPromptMoveFiles, + onSaveToServer, + onVersionHistory, + onDownloadFile, + onRenameFile, + onDuplicateFile, + saveToServerDisabledReason, + } = props; + return (file: StirlingFileStub) => ({ + onRemove: () => onRemoveFiles([file.id]), + // A move acts on the whole selection when this file is part of it. + onMove: () => + onPromptMoveFiles( + selectedFileIds.has(file.id) ? Array.from(selectedFileIds) : [file.id], + ), + onDownload: onDownloadFile ? () => onDownloadFile(file) : undefined, + onRename: onRenameFile ? () => onRenameFile(file) : undefined, + onDuplicate: onDuplicateFile ? () => onDuplicateFile(file) : undefined, + onSaveToServer: onSaveToServer ? () => onSaveToServer(file) : undefined, + onVersionHistory: onVersionHistory + ? () => onVersionHistory(file) + : undefined, + saveToServerDisabledReason, + }); +} + +/** Per-file kebab handlers, shared by the card and row wrappers. */ +interface FileMenuHandlers { + onRemove: () => void; + onMove: () => void; + onDownload?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; + /** Open the version-history modal; shown only when file has >1 version. */ + onVersionHistory?: () => void; + /** When set, the kebab Save to server is disabled with this tooltip. */ + saveToServerDisabledReason?: string | null; +} + +interface FileCardProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -637,14 +843,6 @@ interface FileCardProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onDoubleClick: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileCard({ @@ -656,11 +854,7 @@ function FileCard({ multiSelectActive, onClick, onDoubleClick, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileCardProps) { const { t } = useTranslation(); const cardRef = useRef(null); @@ -790,120 +984,40 @@ function FileCard({
    - - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onDoubleClick(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - data-testid="file-menu-move-to" - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - +
    ); } -function ListView({ - entries, - selectedFileIds, - activeWorkspaceFileIds, - onSelectFile, - onSetSelection, - onOpenFolder, - onOpenFile, - onMoveFiles, - onMoveFolder, - onRenameFolder, - onDeleteFolder, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, - onChangeFolderAppearance, - onRemoveFiles, - onPromptMoveFiles, - sortMode, - onChangeSortMode, -}: FileGridProps & { - sortMode?: FilesPageSortMode; - onChangeSortMode?: (next: FilesPageSortMode) => void; -}) { +function ListView( + props: FileGridProps & { + sortMode?: FilesPageSortMode; + onChangeSortMode?: (next: FilesPageSortMode) => void; + }, +) { + const { + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onSetSelection, + onOpenFolder, + onOpenFile, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + sortMode, + onChangeSortMode, + } = props; + const menuHandlersFor = useFileMenuHandlers(props); const { t } = useTranslation(); // Tri-state header checkbox state - computed from current entries. @@ -1029,22 +1143,7 @@ function ListView({ onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) } onOpen={() => onOpenFile(entry.file!)} - onRemove={() => onRemoveFiles([entry.file!.id])} - onMove={() => { - const target = selectedFileIds.has(entry.file!.id) - ? Array.from(selectedFileIds) - : [entry.file!.id]; - onPromptMoveFiles(target); - }} - onSaveToServer={ - onSaveToServer ? () => onSaveToServer(entry.file!) : undefined - } - onVersionHistory={ - onVersionHistory - ? () => onVersionHistory(entry.file!) - : undefined - } - saveToServerDisabledReason={saveToServerDisabledReason} + {...menuHandlersFor(entry.file)} /> ); } @@ -1241,7 +1340,7 @@ function FolderRow({ ); } -interface FileRowProps { +interface FileRowProps extends FileMenuHandlers { file: StirlingFileStub; isSelected: boolean; isInWorkspace: boolean; @@ -1251,14 +1350,6 @@ interface FileRowProps { multiSelectActive: boolean; onClick: (e: React.MouseEvent) => void; onOpen: () => void; - onRemove: () => void; - onMove: () => void; - /** Kebab Save to server; only fires when file is local-only. */ - onSaveToServer?: () => void; - /** Open the version-history modal; shown only when file has >1 version. */ - onVersionHistory?: () => void; - /** When set, the kebab Save to server is disabled with this tooltip. */ - saveToServerDisabledReason?: string | null; } function FileRow({ @@ -1270,11 +1361,7 @@ function FileRow({ multiSelectActive, onClick, onOpen, - onRemove, - onMove, - onSaveToServer, - onVersionHistory, - saveToServerDisabledReason, + ...menuHandlers }: FileRowProps) { const { t } = useTranslation(); const kebabRef = useRef(null); @@ -1414,91 +1501,12 @@ function FileRow({ {fileSize} {fileDate} - - - e.stopPropagation()} - aria-label={t("filesPage.fileMenu", "File actions")} - data-testid="file-card-actions" - > - - - - - } - onClick={(e) => { - e.stopPropagation(); - onOpen(); - }} - > - {t("filesPage.addToWorkspace", "Add to workspace")} - - - } - onClick={(e) => { - e.stopPropagation(); - onMove(); - }} - > - {t("filesPage.moveTo", "Move to…")} - - {/* Per-file Save to server; shown for local-only files. When - storage is off it stays visible but disabled with a tooltip. */} - {onSaveToServer && file.remoteStorageId == null && ( - - } - disabled={Boolean(saveToServerDisabledReason)} - onClick={(e) => { - e.stopPropagation(); - onSaveToServer(); - }} - style={ - saveToServerDisabledReason - ? { pointerEvents: "auto" } - : undefined - } - > - {t("filesPage.saveToServer", "Save to server")} - - - )} - {onVersionHistory && (file.versionNumber ?? 1) > 1 && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(); - }} - > - {t("filesPage.versionHistory", "Version history")} - - )} - - } - onClick={(e) => { - e.stopPropagation(); - onRemove(); - }} - > - {t("filesPage.remove", "Delete")} - - - +
    ); diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index a0b0c6cfa1..e87b348780 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -71,6 +71,10 @@ import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog"; import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal"; +import { RenameFileDialog } from "@app/components/shared/RenameFileDialog"; +import { duplicateStoredFile } from "@app/utils/duplicateFile"; +import { downloadFileFromStorage } from "@app/utils/downloadUtils"; +import { fileStorage } from "@app/services/fileStorage"; import { materializeServerStubs } from "@app/services/fileSyncService"; import { FILES_PAGE_DRAG_TYPE, @@ -794,6 +798,92 @@ export default function FileManagerView() { [removeFiles], ); + // ─── per-file kebab: download / rename / duplicate ─────────────────────── + // Same actions the file sidebar's kebab offers, so both surfaces match. + + /** Cloud-only rows hold no bytes; pull them local before acting on them. */ + const localCopyOf = useCallback( + async (file: StirlingFileStub): Promise => { + const [materialized] = await materializeServerStubs([file], { + addFiles: fileActions.addFilesWithOptions, + updateStub: fileActions.updateStirlingFileStub, + }); + return materialized ?? null; + }, + [fileActions], + ); + + const handleDownloadFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + await downloadFileFromStorage(local); + } catch (err) { + console.error("[FilesPage] Download failed", err); + folders.setError( + t("filesPage.error.downloadFailed", "Could not download the file."), + ); + } + }, + [localCopyOf, folders, t], + ); + + const handleDuplicateFile = useCallback( + async (file: StirlingFileStub) => { + try { + const local = await localCopyOf(file); + if (!local) return; + const copyId = await duplicateStoredFile( + local, + allFiles.map((f) => f.name), + addFiles, + ); + if (!copyId) { + throw new Error(`File "${local.name}" not found in storage`); + } + await refresh(); + } catch (err) { + console.error("[FilesPage] Duplicate failed", err); + folders.setError( + t("filesPage.error.duplicateFailed", "Could not duplicate the file."), + ); + } + }, + [localCopyOf, allFiles, addFiles, refresh, folders, t], + ); + + const [renameTarget, setRenameTarget] = useState( + null, + ); + + // The stub name is what the UI and exports read, so a rename is a metadata + // write; the workbench copy (if any) is updated in the same breath. + const handleConfirmRename = useCallback( + async (name: string) => { + const file = renameTarget; + if (!file) return; + const local = await localCopyOf(file); + if (!local) return; + // quickKey is name|size|lastModified; a stale one would make a re-upload + // of the original look like a duplicate of the renamed file. + const quickKey = `${name}|${local.size}|${local.lastModified}`; + const saved = await fileStorage.updateFileMetadata(local.id, { + name, + quickKey, + }); + if (!saved) { + throw new Error( + t("fileSidebar.rename.error", "Could not rename the file."), + ); + } + fileActions.updateStirlingFileStub(local.id, { name, quickKey }); + setRenameTarget(null); + await refresh(); + }, + [renameTarget, localCopyOf, fileActions, refresh, t], + ); + // ─── derived UI bits ──────────────────────────────────────────────────── const currentFolderRecord = currentFolderId ? (foldersById.get(currentFolderId) ?? null) @@ -1503,6 +1593,9 @@ export default function FileManagerView() { onPromptMoveFiles={promptMoveFiles} onSaveToServer={(file) => setSaveToServerTarget([file])} onVersionHistory={(file) => setVersionHistoryFile(file)} + onDownloadFile={handleDownloadFile} + onRenameFile={setRenameTarget} + onDuplicateFile={handleDuplicateFile} saveToServerDisabledReason={saveToServerDisabledReason} // Center-of-grid CTAs when the empty state shows - same // handlers the corner header buttons use so behaviour @@ -1662,6 +1755,14 @@ export default function FileManagerView() { onConfirm={confirmRemoveFiles} /> + {/* Rename (opened from the card kebab). */} + setRenameTarget(null)} + onSubmit={handleConfirmRename} + /> + {/* Version journey in a modal (opened from the card kebab). */} ( const [deleteTarget, setDeleteTarget] = useState( null, ); + // Kebab "Rename" target; drives RenameFileDialog. + const [renameTarget, setRenameTarget] = useState( + null, + ); // Storage gate: only offer Save-to-cloud when the server allows it and // the user is signed in (guests have no cloud library). const storageEnabled = config?.storageEnabled === true && !isAnonymous; @@ -417,6 +425,121 @@ const FileSidebar = forwardRef( [allFileStubs], ); + const warnDataUnavailable = useCallback(() => { + alert({ + alertType: "warning", + title: t("fileSidebar.dataLostTitle", "File data is unavailable"), + body: t( + "fileSidebar.dataLostBody", + "This browser lost this file's contents. Upload it again to keep working with it.", + ), + expandable: false, + durationMs: 6000, + }); + }, [t]); + + // Kebab: download a copy (desktop saves via the native dialog). Routed + // through the policy wrapper so export policies enforce here too. + const handleDownload = useCallback( + async (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + const file = await fileStorage.getStirlingFile(fileId); + if (!file) { + warnDataUnavailable(); + return; + } + try { + await downloadFileWithPolicy({ + data: file, + filename: stub?.name ?? file.name, + fileId: fileId as string, + }); + } catch (error) { + console.error("[FileSidebar] Download failed:", error); + alert({ + alertType: "error", + title: t("fileSidebar.downloadFailed", "Download failed"), + body: error instanceof Error ? error.message : String(error), + expandable: false, + }); + } + }, + [allFileStubs, warnDataUnavailable, t], + ); + + // Kebab: copy the file into the library under a free "(copy)" name. + const handleDuplicate = useCallback( + async (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (!stub) return; + try { + const copyId = await duplicateStoredFile( + stub, + allFileStubs.map((s) => s.name), + addFiles, + ); + if (!copyId) { + warnDataUnavailable(); + return; + } + await refreshStubs(); + } catch (error) { + console.error("[FileSidebar] Duplicate failed:", error); + alert({ + alertType: "error", + title: t("fileSidebar.duplicateFailed", "Could not duplicate file"), + body: error instanceof Error ? error.message : String(error), + expandable: false, + }); + } + }, + [allFileStubs, addFiles, refreshStubs, warnDataUnavailable, t], + ); + + // Kebab: open the rename dialog for this one file. + const handleRename = useCallback( + (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (stub) setRenameTarget(stub); + }, + [allFileStubs], + ); + + // The stub name is what the UI and exports read, so a rename is a metadata + // write - storage first, then the workbench copy if the file is open. + const handleConfirmRename = useCallback( + async (name: string) => { + const stub = renameTarget; + if (!stub) return; + // quickKey is name|size|lastModified; a stale one would make a re-upload + // of the original look like a duplicate of the renamed file. + const quickKey = `${name}|${stub.size}|${stub.lastModified}`; + const saved = await fileStorage.updateFileMetadata(stub.id, { + name, + quickKey, + }); + if (!saved) { + throw new Error( + t("fileSidebar.rename.error", "Could not rename the file."), + ); + } + fileActions.updateStirlingFileStub(stub.id, { name, quickKey }); + setRenameTarget(null); + await refreshStubs(); + }, + [renameTarget, fileActions, refreshStubs, t], + ); + + // Desktop-only; a no-op stub on web, where this stays hidden. + const { canOpenInNewWindow, openInNewWindow } = useOpenInNewWindow(); + const handleOpenInNewWindow = useCallback( + (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (stub) openInNewWindow(stub); + }, + [allFileStubs, openInNewWindow], + ); + // Once a pending file lands in state, open it in the viewer. useEffect(() => { if (!pendingViewFileId) return; @@ -773,6 +896,12 @@ const FileSidebar = forwardRef( onFolderClick={openWatchedFolder} policies={policyFileBadges.get(stub.id as string) ?? NO_POLICIES} onDelete={isWatchedFoldersActive ? undefined : handleSidebarDelete} + onDownload={handleDownload} + onRename={isWatchedFoldersActive ? undefined : handleRename} + onDuplicate={isWatchedFoldersActive ? undefined : handleDuplicate} + onOpenInNewWindow={ + canOpenInNewWindow(stub) ? handleOpenInNewWindow : undefined + } onSaveToCloud={isWatchedFoldersActive ? undefined : handleSaveToCloud} canSaveToCloud={storageEnabled && fileOrigin !== "shared-with-me"} isUploadedToCloud={fileOrigin === "cloud"} @@ -1222,6 +1351,14 @@ const FileSidebar = forwardRef( onChanged={refreshStubs} /> + {/* Kebab "Rename" dialog. */} + setRenameTarget(null)} + onSubmit={handleConfirmRename} + /> + {/* Cloud-aware delete choice (only opened for cloud-uploaded files). */} void; + /** Download a copy (desktop: save a copy) from the kebab menu. */ + onDownload?: (fileId: FileId) => void; + /** Rename the file from the kebab menu. */ + onRename?: (fileId: FileId) => void; + /** Save a second copy of the file into the library. */ + onDuplicate?: (fileId: FileId) => void; + /** Desktop only: open the file in its own window. Omit where unsupported. */ + onOpenInNewWindow?: (fileId: FileId) => void; /** Save to cloud from the kebab menu. */ onSaveToCloud?: (fileId: FileId) => void; /** Whether the upload-to-server menu item is offered (storage on, signed in). */ @@ -171,6 +184,46 @@ export interface FileItemProps { const MAX_VISIBLE_FOLDER_TAGS = 2; +/** One kebab row. `disabledReason`, when set, greys the row out and says why. */ +function FileMenuItem({ + disabledReason, + icon, + color, + onClick, + children, +}: { + disabledReason?: React.ReactNode; + icon: React.ReactNode; + color?: string; + onClick: (e: React.MouseEvent) => void; + children: React.ReactNode; +}) { + return ( + + {/* Disabled items swallow pointer events, so the tooltip needs a live wrapper. */} +
    + { + e.stopPropagation(); + onClick(e); + }} + > + {children} + +
    +
    + ); +} + // Memoized: sidebar rows bail out unless THEIR props change, so one file's // update (e.g. a new version landing) re-renders one row, not the whole list. export const FileItem = React.memo(function FileItem({ @@ -192,6 +245,10 @@ export const FileItem = React.memo(function FileItem({ policies = [], primaryLabel, onDelete, + onDownload, + onRename, + onDuplicate, + onOpenInNewWindow, onSaveToCloud, canSaveToCloud = false, isUploadedToCloud = false, @@ -199,9 +256,14 @@ export const FileItem = React.memo(function FileItem({ hasVersionHistory = false, }: FileItemProps) { const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const DownloadIcon = useFileActionIcons().download; const ext = getFileExtension(name); const dateLabel = lastModified ? formatFileDate(lastModified) : ""; const typeLabel = ext ? ext.toUpperCase() : "File"; + const metaLine = [typeLabel, size ? formatFileSize(size) : null, dateLabel] + .filter(Boolean) + .join(" · "); const policyEnforcing = policies.some((p) => p.enforcing); const enforcingTooltip = (action: string): React.ReactNode => ( @@ -220,6 +282,25 @@ export const FileItem = React.memo(function FileItem({ ); + // Why an action can't run right now: a policy is rewriting the file, or its + // bytes are gone. `needsBytes` actions are the ones that read the file. + const blockedReason = ( + action: string, + needsBytes = true, + ): React.ReactNode | null => { + if (policyEnforcing) return enforcingTooltip(action); + if (needsBytes && dataUnavailable) + return t( + "fileSidebar.fileItem.dataLostTooltip", + "This browser lost this file's contents. Upload it again to keep working with it.", + ); + return null; + }; + + const viewerLabel = isViewedInViewer + ? t("fileSidebar.fileItem.closeViewer", "Close viewer") + : t("fileSidebar.fileItem.openInViewer", "Open in viewer"); + const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS); const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS); @@ -233,6 +314,7 @@ export const FileItem = React.memo(function FileItem({ const itemRef = useRef(null); const [hoverRect, setHoverRect] = useState(null); + const [menuOpened, setMenuOpened] = useState(false); const handleMouseEnter = useCallback(() => { setHoverRect(itemRef.current?.getBoundingClientRect() ?? null); @@ -240,9 +322,10 @@ export const FileItem = React.memo(function FileItem({ const handleMouseLeave = useCallback(() => setHoverRect(null), []); - // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready + // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready. + // The kebab suppresses it - two cards floating off one row read as a glitch. const thumbPos = - hoverRect && resolvedThumbnail + hoverRect && resolvedThumbnail && !menuOpened ? { top: hoverRect.top + hoverRect.height / 2, left: hoverRect.right + 10, @@ -389,11 +472,7 @@ export const FileItem = React.memo(function FileItem({ onEyeClick(fileId, e); }} tabIndex={-1} - aria-label={ - isViewedInViewer - ? t("fileSidebar.fileItem.closeViewer", "Close viewer") - : t("fileSidebar.fileItem.openInViewer", "Open in viewer") - } + aria-label={viewerLabel} > - {(onDelete || - (canSaveToCloud && onSaveToCloud) || - (hasVersionHistory && onVersionHistory)) && ( - - - e.stopPropagation()} - tabIndex={-1} - aria-label={t( - "fileSidebar.fileItem.moreActions", - "More actions", - )} - > - - - - e.stopPropagation()}> - {hasVersionHistory && onVersionHistory && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(fileId); - }} - > - {t( - "fileSidebar.fileItem.versionHistory", - "Version history", - )} - + + + e.stopPropagation()} + tabIndex={-1} + aria-label={t( + "fileSidebar.fileItem.moreActions", + "More actions", )} - {canSaveToCloud && - onSaveToCloud && - (() => { - const uploadLabel = isUploadedToCloud - ? t( - "fileSidebar.fileItem.updateOnServer", - "Update on server", - ) - : t( - "fileSidebar.fileItem.uploadToServer", - "Upload to server", - ); - return ( - -
    - - } - onClick={(e) => { - e.stopPropagation(); - onSaveToCloud(fileId); - }} - > - {uploadLabel} - -
    -
    - ); - })()} - {onDelete && - (() => { - const deleteLabel = t( - "fileSidebar.fileItem.delete", - "Delete", - ); - return ( - -
    - - } - onClick={(e) => { - e.stopPropagation(); - onDelete(fileId); - }} - > - {deleteLabel} - -
    -
    - ); - })()} - -
    - )} + > + + + + e.stopPropagation()}> + {/* Rows truncate long names; the menu header is where the whole + name (and the size the row has no space for) is readable. */} + + {name} + + {metaLine} + + + + + ) : ( + + ) + } + onClick={(e) => onEyeClick(fileId, e)} + > + {viewerLabel} + + + {onOpenInNewWindow && ( + } + onClick={() => onOpenInNewWindow(fileId)} + > + {t("openInNewWindow", "Open in new window")} + + )} + + {(onDownload || onRename || onDuplicate) && } + + {onDownload && ( + } + onClick={() => onDownload(fileId)} + > + {terminology.download} + + )} + + {onRename && ( + } + onClick={() => onRename(fileId)} + > + {t("fileSidebar.fileItem.rename", "Rename")} + + )} + + {onDuplicate && ( + } + onClick={() => onDuplicate(fileId)} + > + {t("fileSidebar.fileItem.duplicate", "Duplicate")} + + )} + + {((canSaveToCloud && onSaveToCloud) || + (hasVersionHistory && onVersionHistory)) && } + + {canSaveToCloud && + onSaveToCloud && + (() => { + const uploadLabel = isUploadedToCloud + ? t( + "fileSidebar.fileItem.updateOnServer", + "Update on server", + ) + : t( + "fileSidebar.fileItem.uploadToServer", + "Upload to server", + ); + return ( + } + onClick={() => onSaveToCloud(fileId)} + > + {uploadLabel} + + ); + })()} + + {hasVersionHistory && onVersionHistory && ( + } + onClick={() => onVersionHistory(fileId)} + > + {t("fileSidebar.fileItem.versionHistory", "Version history")} + + )} + + {onDelete && ( + <> + + } + onClick={() => onDelete(fileId)} + > + {t("fileSidebar.fileItem.delete", "Delete")} + + + )} + +
    diff --git a/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx b/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx new file mode 100644 index 0000000000..9413db41b0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RenameFileDialog } from "@app/components/shared/RenameFileDialog"; + +const meta = { + title: "Shared/RenameFileDialog", + component: RenameFileDialog, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + fileName: "Q3 invoice bundle.pdf", + onClose: () => {}, + onSubmit: () => {}, + }, +}; + +export const NoExtension: Story = { + args: { + opened: true, + fileName: "scanned-document", + onClose: () => {}, + onSubmit: () => {}, + }, +}; + +export const SaveFails: Story = { + args: { + opened: true, + fileName: "contract.pdf", + onClose: () => {}, + onSubmit: () => { + throw new Error("Could not rename the file."); + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/RenameFileDialog.tsx b/frontend/editor/src/core/components/shared/RenameFileDialog.tsx new file mode 100644 index 0000000000..83e00f6ba3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/RenameFileDialog.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Group, Modal, Stack, TextInput } from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +import { Button } from "@app/ui/Button"; +import { splitFileName } from "@app/utils/fileUtils"; + +/** Characters Windows/macOS reject in a filename, which is also what a download saves as. */ +const ILLEGAL_NAME_CHARS = /[\\/:*?"<>|]/; + +interface RenameFileDialogProps { + opened: boolean; + /** Current name, including its extension. */ + fileName: string; + onClose: () => void; + /** Gets the new full name. Throwing keeps the dialog open with the message. */ + onSubmit: (name: string) => void | Promise; +} + +/** + * Renames one library file. Only the base name is editable - the extension is + * shown but fixed, so a rename can't leave the file claiming the wrong type. + */ +export function RenameFileDialog({ + opened, + fileName, + onClose, + onSubmit, +}: RenameFileDialogProps) { + const { t } = useTranslation(); + const [base, extension] = splitFileName(fileName); + const [value, setValue] = useState(base); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setValue(base); + setSubmitting(false); + setError(null); + } + }, [opened, base]); + + const submit = async () => { + const nextBase = value.trim(); + if (!nextBase) return; + if (ILLEGAL_NAME_CHARS.test(nextBase)) { + setError( + t( + "fileSidebar.rename.illegalCharacters", + "A file name can't contain \\ / : * ? \" < > |", + ), + ); + return; + } + const nextName = `${nextBase}${extension}`; + if (nextName === fileName) { + onClose(); + return; + } + setSubmitting(true); + setError(null); + try { + await onSubmit(nextName); + onClose(); + } catch (err) { + // Stay open on failure: closing would look like the rename worked. + setError( + err instanceof Error + ? err.message + : t("fileSidebar.rename.error", "Could not rename the file."), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + + setValue(e.currentTarget.value)} + onFocus={(e) => e.currentTarget.select()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + maxLength={200} + aria-label={t("fileSidebar.rename.label", "File name")} + rightSection={ + extension ? ( + + {extension} + + ) : undefined + } + rightSectionWidth={extension ? extension.length * 8 + 12 : undefined} + rightSectionPointerEvents="none" + /> + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 241d658bad..ad38fb1974 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -229,7 +229,8 @@ export default function WorkbenchBar({ try { const result = await downloadFile({ data: new Blob([buffer], { type: "application/pdf" }), - filename: fileToExport.name, + // Stub name, not File.name: a rename only writes the stub. + filename: stub?.name ?? fileToExport.name, localPath: forceNewFile ? undefined : stub?.localFilePath, fileId: stub?.id, }); @@ -281,7 +282,8 @@ export default function WorkbenchBar({ try { const result = await downloadRaw({ data: enforced[idx], - filename: file.name, + // Stub name, not File.name: a rename only writes the stub. + filename: stub?.name ?? file.name, localPath: forceNewFile ? undefined : stub?.localFilePath, fileId: stub?.id, }); diff --git a/frontend/editor/src/core/hooks/useFileHandler.ts b/frontend/editor/src/core/hooks/useFileHandler.ts index 2f26648fd1..6fcbcb2350 100644 --- a/frontend/editor/src/core/hooks/useFileHandler.ts +++ b/frontend/editor/src/core/hooks/useFileHandler.ts @@ -13,6 +13,10 @@ export const useFileHandler = () => { selectFiles?: boolean; /** Persist to IDB without dispatching to workspace state. */ skipWorkspaceDispatch?: boolean; + /** Defaults to true; false keeps an archive intact (e.g. duplicating one). */ + autoUnzip?: boolean; + /** Skip the upload metric - the file isn't new to the system (e.g. a copy). */ + skipUploadTracking?: boolean; } = {}, ): Promise => { // Merge default options with passed options - passed options take precedence diff --git a/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts b/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts new file mode 100644 index 0000000000..cafc188462 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts @@ -0,0 +1,167 @@ +import path from "path"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; + +// Per-file actions live behind a kebab on two surfaces - the file sidebar and +// the My Files grid. They must offer the same file actions on both. + +const SAMPLE = path.join(import.meta.dirname, "../test-fixtures/sample.pdf"); + +const rows = (page: Page) => page.locator(".file-sidebar-file-item"); + +/** Hover the first sidebar row (the kebab only shows on hover) and open it. */ +async function openKebab(page: Page): Promise { + const row = rows(page).first(); + await row.hover(); + await row.locator(".file-sidebar-kebab-btn").click(); + await expect(page.getByRole("menu")).toBeVisible(); +} + +test("the kebab lists the file's actions under its full name", async ({ + page, +}) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + + const menu = page.getByRole("menu"); + await expect(menu.locator(".file-sidebar-kebab-header-name")).toHaveText( + "sample.pdf", + ); + // Type · size · date - the row itself has no space for the size. + await expect(menu.locator(".file-sidebar-kebab-header-meta")).toContainText( + "PDF", + ); + // A lone upload lands in the viewer, so the toggle offers the way out. + await expect( + menu.getByRole("menuitem", { name: "Close viewer" }), + ).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Download" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Rename" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Duplicate" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible(); +}); + +test("Download saves the file under its current name", async ({ page }) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + + const download = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "Download" }).click(); + expect((await download).suggestedFilename()).toBe("sample.pdf"); +}); + +test("Rename updates the row and survives a reload", async ({ page }) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + // Only the base name is editable; the extension is re-applied on submit. + const input = page.getByLabel("File name"); + await expect(input).toHaveValue("sample"); + await input.fill("quarterly report"); + await page.getByRole("button", { name: "Rename" }).click(); + + await expect(rows(page).first()).toContainText("quarterly report.pdf"); + + // The name is metadata in IndexedDB, so it must outlive the page. + await page.reload(); + await expect(rows(page).first()).toContainText("quarterly report.pdf"); +}); + +test("Duplicate adds a copy to the library", async ({ page }) => { + await uploadFiles(page, SAMPLE); + await openKebab(page); + await page.getByRole("menuitem", { name: "Duplicate" }).click(); + + await expect(rows(page)).toHaveCount(2); + await expect(rows(page).filter({ hasText: "sample (copy).pdf" })).toHaveCount( + 1, + ); +}); + +test("a duplicate inherits the original's classification", async ({ page }) => { + // The copy is byte-identical, so it must land in the same category group - + // it inherits the label rather than waiting on the idle backfill to re-parse. + await uploadFiles( + page, + path.join( + import.meta.dirname, + "../test-fixtures/classification/classified_invoice.pdf", + ), + ); + const financial = page + .locator(".file-sidebar-group") + .filter({ hasText: "Financial" }); + await expect(financial).toBeVisible({ timeout: 15_000 }); + + await openKebab(page); + await page.getByRole("menuitem", { name: "Duplicate" }).click(); + + // The copy carries the label straight away - its row shows the label chip... + const copy = rows(page) + .filter({ hasText: "classified_invoice (copy).pdf" }) + .first(); + await expect(copy).toContainText("Invoice", { timeout: 5_000 }); + // ...and it counts towards the same category group. + await expect(financial.locator(".file-sidebar-group-count")).toHaveText("2"); +}); + +// ─── My Files grid: the same actions, same behaviour ──────────────────────── + +const cards = (page: Page) => page.locator(".files-page-card:not(.is-folder)"); + +/** Upload a file, cross to My Files, and open the card's kebab. */ +async function openCardKebab(page: Page): Promise { + await uploadFiles(page, SAMPLE); + await page.getByTestId("my-files-button").click(); + const card = cards(page).filter({ hasText: "sample.pdf" }).first(); + await expect(card).toBeVisible(); + await card.getByRole("button", { name: /File actions/i }).click(); + await expect(page.getByRole("menu")).toBeVisible(); +} + +test("My Files offers the same file actions as the sidebar", async ({ + page, +}) => { + await openCardKebab(page); + + const menu = page.getByRole("menu"); + await expect( + menu.getByRole("menuitem", { name: "Add to workspace" }), + ).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Move to…" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Download" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Rename" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Duplicate" })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible(); +}); + +test("My Files Download saves the file under its current name", async ({ + page, +}) => { + await openCardKebab(page); + + const download = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "Download" }).click(); + expect((await download).suggestedFilename()).toBe("sample.pdf"); +}); + +test("My Files Rename updates the card", async ({ page }) => { + await openCardKebab(page); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + await page.getByLabel("File name").fill("statement"); + await page.getByRole("button", { name: "Rename" }).click(); + + await expect(cards(page).filter({ hasText: "statement.pdf" })).toHaveCount(1); +}); + +test("My Files Duplicate adds a copy", async ({ page }) => { + await openCardKebab(page); + await page.getByRole("menuitem", { name: "Duplicate" }).click(); + + await expect( + cards(page).filter({ hasText: "sample (copy).pdf" }), + ).toHaveCount(1); +}); diff --git a/frontend/editor/src/core/utils/duplicateFile.test.ts b/frontend/editor/src/core/utils/duplicateFile.test.ts new file mode 100644 index 0000000000..88c7cce686 --- /dev/null +++ b/frontend/editor/src/core/utils/duplicateFile.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import type { FolderId } from "@app/types/folder"; + +const getStirlingFile = vi.fn(); +const updateFileMetadata = vi.fn(); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: (...args: unknown[]) => getStirlingFile(...args), + updateFileMetadata: (...args: unknown[]) => updateFileMetadata(...args), + }, +})); + +const { copyNameFor, duplicateStoredFile } = + await import("@app/utils/duplicateFile"); + +/** + * A duplicate is byte-identical to its source, so everything derived from those + * bytes (classification labels, thumbnail) and its place in the library (folder) + * is inherited rather than re-derived. These lock that contract. + */ + +const stub = (extra: Partial = {}): StirlingFileStub => + ({ + id: "src-id" as FileId, + name: "report.pdf", + size: 10, + type: "application/pdf", + lastModified: 1, + ...extra, + }) as StirlingFileStub; + +const asStirlingFile = (name: string): StirlingFile => + Object.assign(new File(["%PDF-1.7"], name, { type: "application/pdf" }), { + fileId: "new-id" as FileId, + quickKey: "k", + }) as StirlingFile; + +type AddFilesOptions = { + selectFiles?: boolean; + skipWorkspaceDispatch?: boolean; + autoUnzip?: boolean; + skipUploadTracking?: boolean; +}; +const addFiles = vi.fn< + (files: File[], options: AddFilesOptions) => Promise +>(async () => [asStirlingFile("report (copy).pdf")]); + +beforeEach(() => { + vi.clearAllMocks(); + getStirlingFile.mockResolvedValue(asStirlingFile("report.pdf")); + updateFileMetadata.mockResolvedValue(true); + addFiles.mockResolvedValue([asStirlingFile("report (copy).pdf")]); +}); + +describe("copyNameFor", () => { + it("keeps the extension and counts up until the name is free", () => { + expect(copyNameFor("report.pdf", [])).toBe("report (copy).pdf"); + expect(copyNameFor("report.pdf", ["report (copy).pdf"])).toBe( + "report (copy 2).pdf", + ); + expect( + copyNameFor("report.pdf", ["report (copy).pdf", "report (copy 2).pdf"]), + ).toBe("report (copy 3).pdf"); + }); + + it("handles a name with no extension", () => { + expect(copyNameFor("scan", [])).toBe("scan (copy)"); + }); +}); + +describe("duplicateStoredFile", () => { + it("inherits labels, folder and a persisted thumbnail", async () => { + const id = await duplicateStoredFile( + stub({ + classificationLabels: ["invoice"], + folderId: "folder-1" as FolderId, + thumbnailUrl: "data:image/png;base64,AAA", + }), + [], + addFiles, + ); + + expect(id).toBe("new-id"); + const [, updates] = updateFileMetadata.mock.calls[0]; + expect(updates.classificationLabels).toEqual(["invoice"]); + expect(updates.folderId).toBe("folder-1"); + expect(updates.thumbnail).toBe("data:image/png;base64,AAA"); + expect(updates.thumbnailStoredAt).toEqual(expect.any(Number)); + }); + + it("drops a blob: thumbnail - it would be dead on the next load", async () => { + await duplicateStoredFile( + stub({ classificationLabels: ["invoice"], thumbnailUrl: "blob:abc" }), + [], + addFiles, + ); + + const [, updates] = updateFileMetadata.mock.calls[0]; + expect(updates).not.toHaveProperty("thumbnail"); + }); + + it("writes nothing when the source has no derived metadata", async () => { + await duplicateStoredFile(stub(), [], addFiles); + + expect(updateFileMetadata).not.toHaveBeenCalled(); + }); + + it("copies the library entry without touching the workbench or metrics", async () => { + await duplicateStoredFile(stub(), ["report (copy).pdf"], addFiles); + + const [files, options] = addFiles.mock.calls[0]; + expect(files[0].name).toBe("report (copy 2).pdf"); + expect(options).toMatchObject({ + selectFiles: false, + skipWorkspaceDispatch: true, + autoUnzip: false, + skipUploadTracking: true, + }); + }); + + it("returns null when the source bytes are gone", async () => { + getStirlingFile.mockResolvedValue(null); + + expect(await duplicateStoredFile(stub(), [], addFiles)).toBeNull(); + expect(addFiles).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/utils/duplicateFile.ts b/frontend/editor/src/core/utils/duplicateFile.ts new file mode 100644 index 0000000000..5bad75b513 --- /dev/null +++ b/frontend/editor/src/core/utils/duplicateFile.ts @@ -0,0 +1,85 @@ +import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import { fileStorage } from "@app/services/fileStorage"; +import { splitFileName } from "@app/utils/fileUtils"; + +/** The subset of `useFileHandler().addFiles` a duplicate needs. */ +type AddFilesFn = ( + files: File[], + options: { + selectFiles?: boolean; + skipWorkspaceDispatch?: boolean; + autoUnzip?: boolean; + skipUploadTracking?: boolean; + }, +) => Promise; + +/** "report.pdf" → "report (copy).pdf", counting up until the name is free. */ +export function copyNameFor(name: string, taken: Iterable): string { + const [base, extension] = splitFileName(name); + const used = new Set(taken); + let candidate = `${base} (copy)${extension}`; + for (let n = 2; used.has(candidate); n++) { + candidate = `${base} (copy ${n})${extension}`; + } + return candidate; +} + +/** + * Copies a stored file into the library under a free "(copy)" name. + * + * The copy stays out of the workbench - it's an archive of the current bytes, + * not a file the user asked to work on. That skips the ingest side-effects that + * hang off the workspace dispatch, so the derived metadata is inherited from + * the source instead: the bytes are identical, so its classification labels and + * thumbnail are too, and re-deriving them would only re-parse the same PDF. The + * copy also lands in the source's folder rather than back at the root. + * + * @returns the new file's id, or null if the source has no readable bytes. + */ +export async function duplicateStoredFile( + stub: StirlingFileStub, + existingNames: Iterable, + addFiles: AddFilesFn, +): Promise { + const source = await fileStorage.getStirlingFile(stub.id); + if (!source) return null; + + const [copy] = await addFiles( + [ + new File([source], copyNameFor(stub.name, existingNames), { + type: source.type, + }), + ], + { + selectFiles: false, + skipWorkspaceDispatch: true, + // Duplicating an archive must yield one copy, not its contents scattered + // across the library. + autoUnzip: false, + // A local copy is not a new document entering the system. + skipUploadTracking: true, + }, + ); + if (!copy) return null; + + const inherited: Parameters[1] = {}; + if (stub.classificationLabels) { + inherited.classificationLabels = stub.classificationLabels; + } + // A copy belongs beside its original. Safe to set on a local-only file: the + // server is authoritative for folderId only on files it actually holds. + if (stub.folderId) { + inherited.folderId = stub.folderId; + } + // Blob URLs die with the session, so only a persisted thumbnail is worth + // carrying over; without one the row falls back to lazy regeneration. + if (stub.thumbnailUrl && !stub.thumbnailUrl.startsWith("blob:")) { + inherited.thumbnail = stub.thumbnailUrl; + inherited.thumbnailStoredAt = Date.now(); + } + if (Object.keys(inherited).length > 0) { + await fileStorage.updateFileMetadata(copy.fileId, inherited); + } + return copy.fileId; +} diff --git a/frontend/editor/src/core/utils/fileUtils.ts b/frontend/editor/src/core/utils/fileUtils.ts index 6a256a9073..13002db52e 100644 --- a/frontend/editor/src/core/utils/fileUtils.ts +++ b/frontend/editor/src/core/utils/fileUtils.ts @@ -73,6 +73,17 @@ export function getFilenameWithoutExtension( return preserveCase ? withoutExtension : withoutExtension.toLowerCase(); } +/** + * Splits a filename into its base and extension, keeping the dot on the + * extension so `base + extension` round-trips. A name with no extension (or a + * leading-dot name like ".env") gets an empty extension. + * @example splitFileName('report.pdf') // ['report', '.pdf'] + */ +export function splitFileName(name: string): [string, string] { + const dot = name.lastIndexOf("."); + return dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""]; +} + /** * Checks if a file is a PDF based on extension and MIME type * @param file - File or file-like object with name and type properties From 96a00cebd18ba703f7c5719fa348d31885cd6543 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:00:03 +0000 Subject: [PATCH 228/262] Pdf ua converter testing (#7301) # Description of Changes Adds a PDF/UA converter, an accessibility report, and PDF/A conformance level A. **New: `POST /api/v1/convert/pdf/ua`** (Convert tool, "PDF/UA" target). Tags an untagged PDF, marks decorative content as artifacts, embeds missing fonts and applies the document-level PDF/UA requirements (title, language, tab order, form-field descriptions), then validates with veraPDF. The `pdfuaid` declaration is written only if validation passes, so a returned file never claims more than it delivers; response headers report whether it was declared, how many checks still fail and how many images still need a description. **New: `POST /api/v1/security/accessibility-report`.** Reports what fails, what the converter can fix on its own, what needs a person, and lists the figures needing a description with the keys the conversion accepts back. Read-only; does not modify the file. Capped at 100 MB / 2000 pages and weighted `LARGE_WEIGHT`, since it runs a full veraPDF pass plus the converter's layout analysis over every page. **PDF/A level A.** `pdfa-1a`, `pdfa-2a` and `pdfa-3a` output formats on the existing `/api/v1/convert/pdf/pdfa` endpoint. Level A is level B plus tagging, so the document is tagged after Ghostscript (which discards any structure tree it is given) and the level A claim is written only if veraPDF agrees. Optional `pdfUa=true` additionally declares PDF/UA alongside PDF/A, again only if it validates. Honesty rules the implementation holds to: - **Never claim a level that was not reached.** If tagging fails, the file is returned at level B and is named `_PDFA-2b.pdf`, not `_PDFA-2a.pdf`. With `strict=true` the request fails outright rather than returning a level B file against a level A request, and a level B pass no longer satisfies a strict level A request. - **Never relabel a document's language.** The requested language (default `en-GB`) is applied only when the document declares none; a French PDF stays French unless the caller sets `overrideLanguage`, and ignoring a requested language is reported as a warning. - **Never invent alternative text.** Descriptions come from the caller. The Convert panel can list the images needing one (via the report endpoint) and send them back per figure; any image left undescribed blocks the conformance claim rather than being papered over. - **Never certify hidden content.** Marking images decorative, or suppressing text that could not be tagged reliably, withdraws the claim instead of passing the checker by hiding content. PDF/UA-1 and PDF/UA-2 are both offered; UA-2 raises the file to PDF 2.0 and namespaces the structure tree, and its test asserts conformance rather than merely reporting it. Convert steps saved in Automations/Pipelines round-trip their PDF/UA settings (profile, language, override, title, font embedding, descriptions). --- ## 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. --- .../SPDF/config/EndpointConfiguration.java | 19 +- .../service/PdfaLevelAServiceInterface.java | 22 + .../config/EndpointConfigurationGapTest.java | 32 +- .../api/converters/ConvertPDFToPDFA.java | 171 +++- .../api/converters/PdfToPdfARequest.java | 12 +- .../software/SPDF/service/VeraPDFService.java | 2 + .../converters/ConvertPDFToPDFAGapTest.java | 136 ++- .../converters/ConvertPDFToPDFAMoreTest.java | 5 +- .../VeraPDFServicePdfaFixtureTest.java | 17 +- app/proprietary/build.gradle | 9 + .../api/converters/ConvertPdfToPdfUa.java | 176 ++++ .../AccessibilityReportController.java | 67 ++ .../api/converters/PdfToPdfUaRequest.java | 73 ++ .../model/api/ua/AccessibilityIssue.java | 38 + .../model/api/ua/AccessibilityReport.java | 63 ++ .../api/ua/AccessibilityReportRequest.java | 19 + .../model/api/ua/FigureDescriptor.java | 18 + .../model/api/ua/PdfUaConversionOutcome.java | 26 + .../model/api/ua/UaValidationResult.java | 18 + .../proprietary/pdf/ua/ArtifactType.java | 23 + .../software/proprietary/pdf/ua/BBox.java | 48 + .../proprietary/pdf/ua/DocumentStructure.java | 84 ++ .../proprietary/pdf/ua/LayoutAnalyzer.java | 831 ++++++++++++++++++ .../proprietary/pdf/ua/MarkableOp.java | 44 + .../pdf/ua/MarkedContentInjector.java | 284 ++++++ .../proprietary/pdf/ua/PageContent.java | 33 + .../pdf/ua/PdfUaIdentificationSchema.java | 47 + .../pdf/ua/PdfUaMetadataWriter.java | 224 +++++ .../proprietary/pdf/ua/PdfUaProfile.java | 47 + .../proprietary/pdf/ua/PdfUaTagger.java | 303 +++++++ .../proprietary/pdf/ua/SourceFacts.java | 59 ++ .../proprietary/pdf/ua/StructBlock.java | 134 +++ .../proprietary/pdf/ua/StructTreeWriter.java | 295 +++++++ .../proprietary/pdf/ua/StructType.java | 62 ++ .../pdf/ua/TaggedContentExtractor.java | 630 +++++++++++++ .../proprietary/pdf/ua/TaggingOptions.java | 63 ++ .../proprietary/pdf/ua/TaggingResult.java | 42 + .../proprietary/pdf/ua/TextLineInfo.java | 42 + .../software/proprietary/pdf/ua/WordInfo.java | 18 + .../service/ua/AccessibilityAuditService.java | 187 ++++ .../service/ua/FontEmbeddingService.java | 254 ++++++ .../service/ua/PdfUaConversionService.java | 251 ++++++ .../service/ua/PdfUaValidationService.java | 245 ++++++ .../service/ua/PdfaAccessibilityService.java | 297 +++++++ .../pdf/ua/LayoutAnalyzerTest.java | 286 ++++++ .../pdf/ua/MarkedContentInjectorTest.java | 164 ++++ .../pdf/ua/MarkedContentSafetyTest.java | 193 ++++ .../pdf/ua/PdfUaFormAndDeclarationTest.java | 171 ++++ .../proprietary/pdf/ua/PdfUaLanguageTest.java | 79 ++ .../pdf/ua/PdfUaMetadataWriterTest.java | 137 +++ .../proprietary/pdf/ua/PdfUaModelTest.java | 160 ++++ .../pdf/ua/VectorAndHeadingTest.java | 155 ++++ .../service/ua/AltTextRoundTripTest.java | 115 +++ .../service/ua/PdfUa2ProfileTest.java | 92 ++ .../service/ua/PdfUaBenchmarkTest.java | 341 +++++++ .../ua/PdfUaConversionIntegrationTest.java | 231 +++++ .../service/ua/PdfUaHardeningTest.java | 322 +++++++ .../service/ua/PdfUaHttpEndpointTest.java | 183 ++++ .../service/ua/PdfUaRealCorpusTest.java | 249 ++++++ .../service/ua/PdfUaSampleDumpTest.java | 103 +++ .../service/ua/PdfUaServicesTest.java | 319 +++++++ .../service/ua/PdfUaTestDocuments.java | 390 ++++++++ .../service/ua/PdfaLevelATest.java | 202 +++++ .../TaggedContentExtractorRealFilesTest.java | 99 +++ engine/src/stirling/models/tool_io.py | 4 + engine/src/stirling/models/tool_models.py | 89 ++ .../public/locales/en-US/translation.toml | 23 + .../tools/convert/ConvertSettings.tsx | 15 + .../ConvertToPdfUaSettings.selection.test.tsx | 149 ++++ .../convert/ConvertToPdfUaSettings.test.ts | 34 + .../tools/convert/ConvertToPdfUaSettings.tsx | 280 ++++++ .../tools/convert/ConvertToPdfaSettings.tsx | 11 + .../src/core/constants/convertConstants.ts | 6 + .../tools/convert/convertPdfUaAltText.test.ts | 92 ++ .../tools/convert/useConvertOperation.ts | 55 +- .../tools/convert/useConvertParameters.ts | 17 + .../hooks/tools/shared/toolAutomation.test.ts | 46 + .../tests/convert/ConvertIntegration.test.tsx | 96 ++ .../editor/src/core/types/toolApiTypes.ts | 53 ++ frontend/editor/src/core/types/toolIO.ts | 10 + 80 files changed, 10373 insertions(+), 68 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PageContent.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaIdentificationSchema.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaProfile.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaTagger.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/SourceFacts.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructBlock.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructTreeWriter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructType.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggedContentExtractor.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingOptions.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingResult.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TextLineInfo.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/WordInfo.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/AccessibilityAuditService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/FontEmbeddingService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaConversionService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaValidationService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfaAccessibilityService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentInjectorTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentSafetyTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaFormAndDeclarationTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaLanguageTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriterTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaModelTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/VectorAndHeadingTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/AltTextRoundTripTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUa2ProfileTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaBenchmarkTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaConversionIntegrationTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHardeningTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHttpEndpointTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaRealCorpusTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaSampleDumpTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaServicesTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaTestDocuments.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfaLevelATest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/TaggedContentExtractorRealFilesTest.java create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.selection.test.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.test.ts create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.tsx create mode 100644 frontend/editor/src/core/hooks/tools/convert/convertPdfUaAltText.test.ts diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index ff1a880010..5e8f7fe336 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; @@ -13,6 +14,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfaLevelAServiceInterface; @Service @Slf4j @@ -51,12 +53,16 @@ public class EndpointConfiguration { private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); private final boolean runningProOrHigher; + private final boolean pdfUaAvailable; public EndpointConfiguration( ApplicationProperties applicationProperties, - @Qualifier("runningProOrHigher") boolean runningProOrHigher) { + @Qualifier("runningProOrHigher") boolean runningProOrHigher, + @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService) { this.applicationProperties = applicationProperties; this.runningProOrHigher = runningProOrHigher; + // The PDF/UA tagger ships in the proprietary module, and so do its endpoints. + this.pdfUaAvailable = pdfaLevelAService != null; init(); processEnvironmentConfigs(); } @@ -356,6 +362,7 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "pdf-to-img"); addEndpointToGroup("Convert", "img-to-pdf"); addEndpointToGroup("Convert", "pdf-to-pdfa"); + addEndpointToGroup("Convert", "pdf-to-ua"); addEndpointToGroup("Convert", "file-to-pdf"); addEndpointToGroup("Convert", "pdf-to-word"); addEndpointToGroup("Convert", "pdf-to-presentation"); @@ -395,6 +402,7 @@ public class EndpointConfiguration { // Backend-only endpoints (not in frontend tool registry endpoints) addEndpointToGroup("Security", "redact"); addEndpointToGroup("Security", "verify-pdf"); + addEndpointToGroup("Security", "accessibility-report"); addEndpointToGroup("Security", "sign"); // Adding endpoints to "Other" group @@ -529,6 +537,8 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "json-to-pdf"); addEndpointToGroup("Java", "pdf-to-video"); addEndpointToGroup("Java", "verify-pdf"); + addEndpointToGroup("Java", "pdf-to-ua"); + addEndpointToGroup("Java", "accessibility-report"); addEndpointToGroup("Java", "flatten"); addEndpointToGroup("Java", "unlock-pdf-forms"); addEndpointToGroup("Java", "validate-signature"); @@ -600,6 +610,8 @@ public class EndpointConfiguration { // veraPDF dependent endpoints addEndpointToGroup("veraPDF", "verify-pdf"); + addEndpointToGroup("veraPDF", "pdf-to-ua"); + addEndpointToGroup("veraPDF", "accessibility-report"); // Pdftohtml dependent endpoints addEndpointToGroup("Pdftohtml", "pdf-to-html"); @@ -630,6 +642,11 @@ public class EndpointConfiguration { disableGroup("enterprise"); } + if (!pdfUaAvailable) { + disableEndpoint("pdf-to-ua"); + disableEndpoint("accessibility-report"); + } + if (!applicationProperties.getSystem().isEnableUrlToPDF()) { disableEndpoint("url-to-pdf"); } diff --git a/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java new file mode 100644 index 0000000000..2ee55719b2 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java @@ -0,0 +1,22 @@ +package stirling.software.common.service; + +import java.util.List; + +/** + * Raises a converted PDF/A file from conformance level B to level A, which needs the tagging the + * PDF/UA tagger does. Implemented only in the proprietary module; core builds convert at level B. + */ +public interface PdfaLevelAServiceInterface { + + /** + * @param levelA true only when the file was tagged and validated, so the claim is never a guess + */ + record Result(byte[] pdfBytes, boolean levelA, List warnings) {} + + /** + * @param part PDF/A part, 1 to 3; part 1 keeps its PDF 1.4 version + * @param alsoDeclareUa additionally claim PDF/UA, but only if it validates + */ + Result upgradeToLevelA( + byte[] pdfBytes, int part, String language, String title, boolean alsoDeclareUa); +} diff --git a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java index 6275b49343..afc6fa7020 100644 --- a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java +++ b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test; import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfaLevelAServiceInterface; /** * Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in @@ -32,7 +33,14 @@ class EndpointConfigurationGapTest { * Construct an EndpointConfiguration with the given pro flag and current applicationProperties. */ private EndpointConfiguration build(boolean runningProOrHigher) { - return new EndpointConfiguration(applicationProperties, runningProOrHigher); + return build(runningProOrHigher, null); + } + + /** The PDF/UA service is only present in proprietary builds, so it is injected separately. */ + private EndpointConfiguration build( + boolean runningProOrHigher, PdfaLevelAServiceInterface pdfaLevelAService) { + return new EndpointConfiguration( + applicationProperties, runningProOrHigher, pdfaLevelAService); } /** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */ @@ -177,6 +185,28 @@ class EndpointConfigurationGapTest { } } + @Nested + @DisplayName("PDF/UA availability") + class PdfUaTests { + + @Test + @DisplayName("the PDF/UA endpoints are off when the proprietary tagger is absent") + void disabledWithoutTagger() { + EndpointConfiguration config = build(false, null); + assertFalse(config.isEndpointEnabled("pdf-to-ua")); + assertFalse(config.isEndpointEnabled("accessibility-report")); + } + + @Test + @DisplayName("they are on once the tagger is on the classpath") + void enabledWithTagger() { + EndpointConfiguration config = + build(false, (pdfBytes, part, language, title, alsoDeclareUa) -> null); + assertTrue(config.isEndpointEnabled("pdf-to-ua")); + assertTrue(config.isEndpointEnabled("accessibility-report")); + } + } + @Nested @DisplayName("group enable / disable") class GroupTests { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java index 49bf4e4895..0354817315 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.*; +import java.util.Locale; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -71,6 +72,7 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema; import org.apache.xmpbox.schema.XMPBasicSchema; import org.apache.xmpbox.xml.DomXmpParser; import org.apache.xmpbox.xml.XmpSerializer; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -83,7 +85,6 @@ import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; @@ -93,6 +94,7 @@ import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; @@ -102,14 +104,26 @@ import stirling.software.common.util.WebResponseUtils; @ConvertApi @Slf4j -@RequiredArgsConstructor public class ConvertPDFToPDFA { private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]"); private final RuntimePathConfig runtimePathConfig; private final stirling.software.SPDF.service.VeraPDFService veraPDFService; + // Level A needs the proprietary tagger; core builds convert at level B instead. + private final PdfaLevelAServiceInterface pdfaLevelAService; private final TempFileManager tempFileManager; + public ConvertPDFToPDFA( + RuntimePathConfig runtimePathConfig, + stirling.software.SPDF.service.VeraPDFService veraPDFService, + @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService, + TempFileManager tempFileManager) { + this.runtimePathConfig = runtimePathConfig; + this.veraPDFService = veraPDFService; + this.pdfaLevelAService = pdfaLevelAService; + this.tempFileManager = tempFileManager; + } + private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc"; private static final int PDFA_COMPATIBILITY_POLICY = 1; @@ -604,7 +618,10 @@ public class ConvertPDFToPDFA { return handlePdfXConversion(inputFile, outputFormat); } else { return handlePdfAConversion( - inputFile, outputFormat, request.getStrict() != null && request.getStrict()); + inputFile, + outputFormat, + request.getStrict() != null && request.getStrict(), + request.getPdfUa() != null && request.getPdfUa()); } } @@ -1815,8 +1832,64 @@ public class ConvertPDFToPDFA { return Files.readAllBytes(outputPdf); } + /** Tags a converted PDF/A for level A; must run after Ghostscript, which discards tags. */ + private PdfaLevelAServiceInterface.Result applyLevelA( + byte[] converted, + Path original, + PdfaProfile profile, + String baseFileName, + boolean declarePdfUa) { + if (!profile.requiresTagging()) { + return new PdfaLevelAServiceInterface.Result(converted, true, List.of()); + } + if (pdfaLevelAService == null) { + return new PdfaLevelAServiceInterface.Result( + converted, + false, + List.of( + "Level A tagging is not available in this build, so the file was left" + + " at conformance level B.")); + } + // Prefer the document's own title/language; hardcoding "en" mislabelled German reports. + // Read the original, not the converted bytes: Ghostscript discards /Lang, so probing its + // output always yields null and every document would be relabelled with the default. + String language = null; + String title = null; + try (PDDocument probe = Loader.loadPDF(original.toFile())) { + language = probe.getDocumentCatalog().getLanguage(); + title = probe.getDocumentInformation().getTitle(); + } catch (IOException e) { + log.debug("Could not read original title/language: {}", e.getMessage()); + } + if (language == null || language.isBlank()) { + try (PDDocument probe = Loader.loadPDF(converted)) { + language = probe.getDocumentCatalog().getLanguage(); + if (title == null || title.isBlank()) { + title = probe.getDocumentInformation().getTitle(); + } + } catch (IOException e) { + log.debug("Could not read converted title/language: {}", e.getMessage()); + } + } + PdfaLevelAServiceInterface.Result result = + pdfaLevelAService.upgradeToLevelA( + converted, + profile.getPart(), + language, + title != null && !title.isBlank() ? title : baseFileName, + declarePdfUa); + result.warnings().forEach(warning -> log.info("PDF/A level A: {}", warning)); + if (!result.levelA()) { + log.warn( + "{} requested but the document could not be tagged; returning level B", + profile.getDisplayName()); + } + return result; + } + private ResponseEntity handlePdfAConversion( - MultipartFile inputFile, String outputFormat, boolean strict) throws Exception { + MultipartFile inputFile, String outputFormat, boolean strict, boolean declarePdfUa) + throws Exception { PdfaProfile profile = PdfaProfile.fromRequest(outputFormat); // Get the original filename without extension @@ -1841,12 +1914,15 @@ public class ConvertPDFToPDFA { log.info("Using Ghostscript for PDF/A conversion to {}", profile.getDisplayName()); try { converted = convertWithGhostscript(inputPath, workingDir, profile); - String outputFilename = baseFileName + profile.outputSuffix(); + var levelA = + applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa); + converted = levelA.pdfBytes(); + String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA()); validateAndWarnPdfA(converted, profile, "Ghostscript"); if (strict) { - verifyStrictCompliance(converted); + verifyStrictCompliance(converted, profile, levelA.levelA()); } TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); @@ -1867,13 +1943,15 @@ public class ConvertPDFToPDFA { } converted = convertWithPdfBoxMethod(inputPath, profile); - String outputFilename = baseFileName + profile.outputSuffix(); + var levelA = applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa); + converted = levelA.pdfBytes(); + String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA()); // Validate with PDFBox preflight and warn if issues found validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice"); if (strict) { - verifyStrictCompliance(converted); + verifyStrictCompliance(converted, profile, levelA.levelA()); } TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); @@ -1889,11 +1967,56 @@ public class ConvertPDFToPDFA { } } - private void verifyStrictCompliance(byte[] pdfBytes) throws IOException { + /** True for a PDF/UA or WCAG result, which says nothing about archival conformance. */ + private static boolean isAccessibilityProfile( + stirling.software.SPDF.model.api.security.PDFVerificationResult result) { + String profile = result.getValidationProfile(); + if (profile == null) { + return false; + } + String normalised = profile.toLowerCase(Locale.ROOT); + return normalised.contains("ua") || normalised.contains("wcag"); + } + + /** + * True when a result speaks for the requested profile. Only archival results count, and a level + * B pass must never satisfy a level A request. + */ + private static boolean answersRequest( + PdfaProfile profile, + stirling.software.SPDF.model.api.security.PDFVerificationResult result) { + if (isAccessibilityProfile(result)) { + return false; + } + String standard = result.getStandard(); + if (standard == null || standard.length() < 2) { + return false; + } + if (standard.charAt(0) != Character.forDigit(profile.getPart(), 10)) { + return false; + } + return !profile.requiresTagging() || Character.toLowerCase(standard.charAt(1)) == 'a'; + } + + private void verifyStrictCompliance(byte[] pdfBytes, PdfaProfile profile, boolean levelAReached) + throws IOException { + // Tagging is the only route to level A, so an untagged file cannot answer a strict request. + if (!levelAReached) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Strict PDF/A mode enabled: the document could not be tagged, so " + + profile.getDisplayName() + + " was not reached. It is valid at level B."); + } try (InputStream is = new ByteArrayInputStream(pdfBytes)) { List results = veraPDFService.validatePDF(is); - boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant()); + boolean isCompliant = + results.stream() + .filter(result -> answersRequest(profile, result)) + .anyMatch( + stirling.software.SPDF.model.api.security.PDFVerificationResult + ::isCompliant); if (!isCompliant) { String details = results.stream() @@ -1901,7 +2024,9 @@ public class ConvertPDFToPDFA { .collect(Collectors.joining("; ")); throw new ResponseStatusException( HttpStatus.BAD_REQUEST, - "Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: " + "Strict PDF/A mode enabled: the output is not perfectly compliant with " + + profile.getDisplayName() + + ". Details: " + details); } } catch (Exception e) { @@ -2466,11 +2591,16 @@ public class ConvertPDFToPDFA { @Getter private enum PdfaProfile { - PDF_A_1B(1, "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"), - PDF_A_2B(2, "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"), - PDF_A_3B(3, "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"); + PDF_A_1B(1, "B", "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"), + PDF_A_2B(2, "B", "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"), + PDF_A_3B(3, "B", "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"), + // Level A = level B plus tagging, declared language and Unicode text; tagged post-convert. + PDF_A_1A(1, "A", "PDF/A-1a", "_PDFA-1a.pdf", "1.4", Format.PDF_A1B, "pdfa-1a"), + PDF_A_2A(2, "A", "PDF/A-2a", "_PDFA-2a.pdf", "1.7", null, "pdfa-2a"), + PDF_A_3A(3, "A", "PDF/A-3a", "_PDFA-3a.pdf", "1.7", null, "pdfa-3a"); private final int part; + private final String conformanceLevel; private final String displayName; private final String suffix; private final String compatibilityLevel; @@ -2479,12 +2609,14 @@ public class ConvertPDFToPDFA { PdfaProfile( int part, + String conformanceLevel, String displayName, String suffix, String compatibilityLevel, Format preflightFormat, String... requestTokens) { this.part = part; + this.conformanceLevel = conformanceLevel; this.displayName = displayName; this.suffix = suffix; this.compatibilityLevel = compatibilityLevel; @@ -2495,6 +2627,10 @@ public class ConvertPDFToPDFA { .toList(); } + boolean requiresTagging() { + return "A".equals(conformanceLevel); + } + static PdfaProfile fromRequest(String requestToken) { if (requestToken == null) { return PDF_A_2B; @@ -2508,8 +2644,11 @@ public class ConvertPDFToPDFA { return match.orElse(PDF_A_2B); } - String outputSuffix() { - return suffix; + /** + * Names the file at the level actually reached; a level A name over level B content lies. + */ + String outputSuffix(boolean levelAReached) { + return levelAReached ? suffix : "_PDFA-" + part + "b.pdf"; } Optional preflightFormat() { diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java index bb0520a4ba..921663912b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java @@ -14,9 +14,19 @@ public class PdfToPdfARequest extends PDFFile { @Schema( description = "The output format type (PDF/A or PDF/X)", requiredMode = Schema.RequiredMode.REQUIRED, - allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"}) + allowableValues = { + "pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfa-1a", "pdfa-2a", + "pdfa-3a", "pdfx" + }) private String outputFormat; + @Schema( + description = + "Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A" + + " formats, and the claim is written only if it validates.", + defaultValue = "false") + private Boolean pdfUa; + @Schema( description = "If true, the conversion will fail if the output is not perfectly compliant") diff --git a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java index bb3c84534c..6361157b21 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java @@ -285,6 +285,8 @@ public class VeraPDFService { } } + // Never force PDF/UA here - it flags every ordinary document as non-compliant and doubles + // verify cost; /accessibility-report checks PDF/UA on demand. if (!hasPdfaDeclaration) { results.add(createNoPdfaDeclarationResult()); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java index b64776665b..ea693ddd27 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java @@ -46,6 +46,7 @@ import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; import stirling.software.SPDF.model.api.security.PDFVerificationResult; import stirling.software.SPDF.service.VeraPDFService; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.TempFileManager; /** @@ -62,10 +63,12 @@ class ConvertPDFToPDFAGapTest { @Mock private RuntimePathConfig runtimePathConfig; @Mock private VeraPDFService veraPDFService; + @Mock private PdfaLevelAServiceInterface pdfaLevelAService; @Mock private TempFileManager tempFileManager; private ConvertPDFToPDFA newController() { - return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager); + return new ConvertPDFToPDFA( + runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager); } // ---- reflection helpers ---------------------------------------------------------------- @@ -161,9 +164,21 @@ class ConvertPDFToPDFAGapTest { } private String suffixOf(Object profile) throws Exception { - Method m = profile.getClass().getDeclaredMethod("outputSuffix"); + return suffixOf(profile, true); + } + + private String suffixOf(Object profile, boolean levelAReached) throws Exception { + Method m = profile.getClass().getDeclaredMethod("outputSuffix", boolean.class); m.setAccessible(true); - return (String) m.invoke(profile); + return (String) m.invoke(profile, levelAReached); + } + + @Test + @DisplayName("a level A profile falls back to the level B name when tagging failed") + void levelANotReachedIsNamedLevelB() throws Exception { + assertThat(suffixOf(resolveProfile("pdfa-1a"), false)).isEqualTo("_PDFA-1b.pdf"); + assertThat(suffixOf(resolveProfile("pdfa-2a"), false)).isEqualTo("_PDFA-2b.pdf"); + assertThat(suffixOf(resolveProfile("pdfa-3a"), true)).isEqualTo("_PDFA-3a.pdf"); } @Test @@ -717,6 +732,30 @@ class ConvertPDFToPDFAGapTest { @DisplayName("verifyStrictCompliance (VeraPDFService mocked)") class StrictCompliance { + private Object profile(String token) throws Exception { + Class enumClass = null; + for (Class inner : ConvertPDFToPDFA.class.getDeclaredClasses()) { + if (inner.getSimpleName().equals("PdfaProfile")) { + enumClass = inner; + } + } + Method m = enumClass.getDeclaredMethod("fromRequest", String.class); + m.setAccessible(true); + return m.invoke(null, token); + } + + private Throwable verify(String token, boolean levelAReached) throws Exception { + ConvertPDFToPDFA controller = newController(); + return catchThrowable( + () -> + invokeInstance( + controller, + "verifyStrictCompliance", + (Object) "dummy".getBytes(), + profile(token), + levelAReached)); + } + @Test @DisplayName("compliant result passes without throwing") void compliantPasses() throws Exception { @@ -726,14 +765,7 @@ class ConvertPDFToPDFAGapTest { ok.setComplianceSummary("PDF/A-1b compliant"); when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); - ConvertPDFToPDFA controller = newController(); - assertThatCode( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())) - .doesNotThrowAnyException(); + assertThat(verify("pdfa-1", true)).isNull(); } @Test @@ -745,34 +777,70 @@ class ConvertPDFToPDFAGapTest { bad.setComplianceSummary("PDF/A-1b with errors"); when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad)); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(ex.getReason()).contains("PDF/A-1b with errors"); } + @Test + @DisplayName("a level B pass does not satisfy a level A request") + void levelBDoesNotSatisfyLevelA() throws Exception { + PDFVerificationResult ok = new PDFVerificationResult(); + ok.setCompliant(true); + ok.setStandard("1b"); + ok.setComplianceSummary("PDF/A-1b compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); + + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1a", true); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getReason()).contains("PDF/A-1a"); + } + + @Test + @DisplayName("a level A result satisfies a level A request") + void levelASatisfiesLevelA() throws Exception { + PDFVerificationResult ok = new PDFVerificationResult(); + ok.setCompliant(true); + ok.setStandard("2a"); + ok.setComplianceSummary("PDF/A-2a compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); + + assertThat(verify("pdfa-2a", true)).isNull(); + } + + @Test + @DisplayName("untagged output fails a level A request before validation runs") + void untaggedLevelARequestFails() throws Exception { + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2a", false); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getReason()).contains("could not be tagged"); + verifyNoInteractions(veraPDFService); + } + + @Test + @DisplayName("a compliant PDF/UA result never satisfies a strict PDF/A request") + void accessibilityResultIsIgnored() throws Exception { + PDFVerificationResult ua = new PDFVerificationResult(); + ua.setCompliant(true); + ua.setStandard("ua1"); + ua.setValidationProfile("ua1"); + ua.setComplianceSummary("PDF/UA-1 compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ua)); + + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2b", true); + assertThat(ex).isNotNull(); + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + @Test @DisplayName("empty result list is treated as non-compliant -> 400") void emptyResultsTreatedNonCompliant() throws Exception { when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList()); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } @@ -782,15 +850,7 @@ class ConvertPDFToPDFAGapTest { void serviceErrorWrappedAs500() throws Exception { when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom")); - ConvertPDFToPDFA controller = newController(); - ResponseStatusException ex = - (ResponseStatusException) - catchThrowable( - () -> - invokeInstance( - controller, - "verifyStrictCompliance", - (Object) "dummy".getBytes())); + ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true); assertThat(ex).isNotNull(); assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java index e9d9b9ce1d..d56a283464 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java @@ -42,6 +42,7 @@ import org.springframework.mock.web.MockMultipartFile; import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; import stirling.software.SPDF.service.VeraPDFService; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.PdfaLevelAServiceInterface; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.TempFile; @@ -63,10 +64,12 @@ class ConvertPDFToPDFAMoreTest { @Mock private RuntimePathConfig runtimePathConfig; @Mock private VeraPDFService veraPDFService; + @Mock private PdfaLevelAServiceInterface pdfaLevelAService; @Mock private TempFileManager tempFileManager; private ConvertPDFToPDFA newController() { - return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager); + return new ConvertPDFToPDFA( + runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager); } private static ResponseEntity streamingOk(byte[] bytes) { diff --git a/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java b/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java index 38043780d9..e071ece3fb 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java @@ -90,7 +90,9 @@ class VeraPDFServicePdfaFixtureTest { () -> service.validatePDF(new ByteArrayInputStream(pdfBytes)), "Empty veraPDF flavour list must not surface as IndexOutOfBoundsException"); - assertEquals(1, results.size()); + // One result: PDF/UA is checked by the dedicated accessibility-report endpoint, not here. + assertEquals(1, results.size(), () -> "Expected a single PDF/A result, got: " + results); + PDFVerificationResult result = results.get(0); assertEquals("not-pdfa", result.getStandard()); assertFalse(result.isDeclaredPdfa()); @@ -161,13 +163,22 @@ class VeraPDFServicePdfaFixtureTest { } } + /** The PDF/A result; every document is also checked against PDF/UA, so filter that one out. */ private PDFVerificationResult onlyResult(byte[] pdfBytes) throws Exception { List results = service.validatePDF(new ByteArrayInputStream(pdfBytes)); assertNotNull(results); - assertEquals(1, results.size(), () -> "Expected a single result, got: " + results); - return results.get(0); + List pdfaResults = + results.stream().filter(r -> !isUaResult(r)).toList(); + assertEquals( + 1, pdfaResults.size(), () -> "Expected a single PDF/A result, got: " + results); + return pdfaResults.get(0); + } + + private static boolean isUaResult(PDFVerificationResult result) { + String profile = result.getValidationProfile(); + return profile != null && profile.toLowerCase().contains("ua"); } private static String messages(PDFVerificationResult result) { diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index c20241dbb5..b884cb18be 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -37,6 +37,15 @@ dependencies { // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" + // PDF/UA tagging and its validation oracle. + implementation 'org.verapdf:validation-model:1.30.2' + // CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13 + implementation "org.mozilla:rhino:${rhinoVersion}" + // veraPDF still uses javax.xml.bind, not the new jakarta namespace + implementation 'javax.xml.bind:jaxb-api:2.3.1' + runtimeOnly 'com.sun.xml.bind:jaxb-impl:2.3.9' + runtimeOnly 'com.sun.xml.bind:jaxb-core:4.0.9' + implementation "com.google.code.gson:gson:${gsonVersion}" // jinjava/jjwt transitively request older Jackson 2 versions; declare the current diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java new file mode 100644 index 0000000000..7f5241388f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java @@ -0,0 +1,176 @@ +package stirling.software.proprietary.controller.api.converters; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.ConvertApi; +import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.model.api.converters.PdfToPdfUaRequest; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.TaggingOptions; +import stirling.software.proprietary.service.ua.PdfUaConversionService; + +/** Converts a PDF to PDF/UA; response headers say whether the result actually conforms. */ +@ConvertApi +@Slf4j +@RequiredArgsConstructor +public class ConvertPdfToPdfUa { + + private static final String HEADER_DECLARED = "X-Stirling-UA-Declared"; + private static final String HEADER_FAILURES = "X-Stirling-UA-Failures"; + private static final String HEADER_ALT_NEEDED = "X-Stirling-UA-Figures-Needing-Alt"; + private static final String HEADER_WARNINGS = "X-Stirling-UA-Warnings"; + + /** Any line ending, so descriptions pasted from any platform parse the same. */ + private static final Pattern NEWLINE = Pattern.compile("\\R"); + + private final PdfUaConversionService conversionService; + private final TempFileManager tempFileManager; + + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/pdf/ua", + resourceWeight = ResourceWeight.LARGE_WEIGHT) + @ToolIO(produces = ToolFormat.PDF) + @Operation( + summary = "Convert a PDF to PDF/UA-1 or PDF/UA-2", + description = + "Tags the document, marks decorative content as artifacts, embeds fonts and" + + " applies the document-level requirements of PDF/UA, then validates" + + " the result. A conformance declaration is written only if validation" + + " passes, so the returned file never claims more than it delivers.") + public ResponseEntity pdfToPdfUa(@ModelAttribute PdfToPdfUaRequest request) + throws IOException { + + MultipartFile input = request.getFileInput(); + if (input == null || input.isEmpty()) { + throw ExceptionUtils.createPdfFileRequiredException(); + } + + String originalName = Filenames.toSimpleFileName(input.getOriginalFilename()); + String stem = stripExtension(originalName == null ? "document" : originalName); + PdfUaProfile profile = PdfUaProfile.fromRequest(request.getProfile()); + + TaggingOptions options = + TaggingOptions.builder() + .profile(profile) + .title(request.getTitle()) + .fallbackTitle(stem) + // Only used when the document declares no language of its own. + .language( + request.getLanguage() == null || request.getLanguage().isBlank() + ? "en-GB" + : request.getLanguage()) + .overrideLanguage( + request.getOverrideLanguage() != null + && request.getOverrideLanguage()) + .existingTags(existingTags(request.getExistingTags())) + .figurePolicy(figurePolicy(request.getFigurePolicy())) + .embedFonts(request.getEmbedFonts() == null || request.getEmbedFonts()) + .altTextByFigure(parseAltText(request.getAltText())) + .build(); + + PdfUaConversionOutcome outcome = conversionService.convert(input.getBytes(), options); + + log.info( + "Converted '{}' to {}: declared={}, {} remaining failure(s)", + originalName, + profile.displayName(), + outcome.declared(), + outcome.validation().totalFailures()); + + outcome.warnings().forEach(warning -> log.info("PDF/UA warning: {}", warning)); + + // Streamed from a temp file so a large conversion does not hold a second heap copy. + String suffix = outcome.declared() ? "_pdfua" + profile.part() : "_tagged"; + TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); + try { + Files.write(tempOut.getPath(), outcome.pdfBytes()); + } catch (IOException e) { + tempOut.close(); + throw e; + } + ResponseEntity response = + WebResponseUtils.pdfFileToWebResponse(tempOut, stem + suffix + ".pdf"); + + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .header(HEADER_DECLARED, String.valueOf(outcome.declared())) + .header(HEADER_FAILURES, String.valueOf(outcome.validation().totalFailures())) + .header( + HEADER_ALT_NEEDED, + String.valueOf(outcome.tagging().figuresNeedingAltText())) + // Count only: warning text is multi-line prose, which HTTP headers mangle. + .header(HEADER_WARNINGS, String.valueOf(outcome.warnings().size())) + .body(response.getBody()); + } + + /** + * Parses newline-separated {@code key=description} pairs, keyed as the report hands them out. + * Only the first "=" splits, since a description may contain one. + */ + public static Map parseAltText(String raw) { + if (raw == null || raw.isBlank()) { + return Map.of(); + } + Map parsed = new LinkedHashMap<>(); + for (String line : NEWLINE.split(raw)) { + int split = line.indexOf('='); + if (split <= 0) { + continue; + } + String key = line.substring(0, split).strip(); + String description = line.substring(split + 1).strip(); + if (!key.isEmpty() && !description.isEmpty()) { + parsed.put(key, description); + } + } + return parsed; + } + + private static TaggingOptions.ExistingTags existingTags(String value) { + if (value == null) { + return TaggingOptions.ExistingTags.AUTO; + } + return switch (value.trim().toLowerCase()) { + case "keep" -> TaggingOptions.ExistingTags.KEEP; + case "rebuild" -> TaggingOptions.ExistingTags.REBUILD; + default -> TaggingOptions.ExistingTags.AUTO; + }; + } + + private static TaggingOptions.FigurePolicy figurePolicy(String value) { + if (value != null && value.trim().equalsIgnoreCase("mark-decorative")) { + return TaggingOptions.FigurePolicy.MARK_DECORATIVE; + } + return TaggingOptions.FigurePolicy.REQUIRE_ALT; + } + + private static String stripExtension(String filename) { + int dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.substring(0, dot) : filename; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java new file mode 100644 index 0000000000..043734dad8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java @@ -0,0 +1,67 @@ +package stirling.software.proprietary.controller.api.security; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.SecurityApi; +import stirling.software.common.enumeration.ResourceWeight; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.proprietary.model.api.ua.AccessibilityReport; +import stirling.software.proprietary.model.api.ua.AccessibilityReportRequest; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.service.ua.AccessibilityAuditService; + +/** Reports how accessible a document is, without modifying it. */ +@SecurityApi +@RequiredArgsConstructor +@Slf4j +public class AccessibilityReportController { + + private final AccessibilityAuditService auditService; + + @ToolIO(produces = ToolFormat.JSON) + @Operation( + summary = "Report a document's accessibility standing", + description = + "Validates the document against PDF/UA and reports what fails, which failures" + + " can be fixed automatically, and which checks still need a person." + + " Does not modify the file.") + // Costs a full veraPDF pass plus the converter's own layout analysis over every page. + @AutoJobPostMapping( + value = "/accessibility-report", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + resourceWeight = ResourceWeight.LARGE_WEIGHT) + public ResponseEntity report( + @ModelAttribute AccessibilityReportRequest request) { + + MultipartFile file = request.getFileInput(); + if (file == null || file.isEmpty()) { + throw ExceptionUtils.createPdfFileRequiredException(); + } + PdfUaProfile profile = PdfUaProfile.fromRequest(request.getProfile()); + try { + AccessibilityReport report = auditService.audit(file.getBytes(), profile); + log.info( + "Accessibility report for '{}': tagged={}, {} issue(s)", + file.getOriginalFilename(), + report.isTagged(), + report.getIssues().size()); + return ResponseEntity.ok(report); + } catch (IOException e) { + throw ExceptionUtils.createRuntimeException( + "error.ioException", "Could not read the PDF: {0}", e, e.getMessage()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java new file mode 100644 index 0000000000..8981398001 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.model.api.converters; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.common.model.api.PDFFile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class PdfToPdfUaRequest extends PDFFile { + + @Schema( + description = "PDF/UA conformance level to target", + defaultValue = "ua1", + allowableValues = {"ua1", "ua2"}) + private String profile; + + @Schema( + description = + "Document title, required by PDF/UA. Falls back to the first heading, then the" + + " filename.") + private String title; + + @Schema( + description = + "Document language as a BCP-47 tag, for example en-GB. Applied only when the" + + " document does not already declare one, unless overrideLanguage is" + + " set.", + defaultValue = "en-GB") + private String language; + + @Schema( + description = + "Replace the language the document already declares. Off by default, so a" + + " document is never relabelled into a language it is not written in.", + defaultValue = "false") + private Boolean overrideLanguage; + + @Schema( + description = + "What to do with an existing structure tree: keep it, rebuild it, or decide" + + " automatically", + defaultValue = "auto", + allowableValues = {"auto", "keep", "rebuild"}) + private String existingTags; + + @Schema( + description = + "How to treat images with no description. require-alt leaves them undescribed so" + + " the report asks for input; mark-decorative treats every image as" + + " decoration.", + defaultValue = "require-alt", + allowableValues = {"require-alt", "mark-decorative"}) + private String figurePolicy; + + @Schema( + description = + "Embed fonts the document references but does not carry. Required for" + + " conformance and needs Ghostscript.", + defaultValue = "true") + private Boolean embedFonts; + + @Schema( + description = + "Alternative descriptions for figures, as key=text pairs separated by newlines." + + " Keys come from the accessibility-report endpoint's" + + " figuresNeedingDescription list, for example \"0:12=Bar chart of" + + " quarterly revenue\". Descriptions are never invented, so without" + + " these an illustrated document cannot claim conformance.") + private String altText; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java new file mode 100644 index 0000000000..0c2a86ad99 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.model.api.ua; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; + +/** One accessibility problem, grouped across all of its occurrences. */ +@Data +@Schema(description = "A single accessibility issue found in a document") +public class AccessibilityIssue { + + @Schema(description = "ISO 14289 clause, e.g. 7.3") + private String clause; + + @Schema(description = "Test number within the clause") + private String testNumber; + + @Schema(description = "Plain-English description of the problem") + private String message; + + @Schema(description = "The validator's own wording, for support and debugging") + private String technicalMessage; + + @Schema(description = "error or warning") + private String severity = "error"; + + @Schema(description = "Standard the check came from, e.g. PDF/UA-1") + private String specification; + + @Schema(description = "Where the problem was found, when the validator reports it") + private String location; + + @Schema(description = "How many times this issue occurs") + private int occurrences; + + @Schema(description = "True when the converter can fix this without human input") + private boolean autoFixable; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java new file mode 100644 index 0000000000..bc810635d1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java @@ -0,0 +1,63 @@ +package stirling.software.proprietary.model.api.ua; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; + +/** + * A document's accessibility standing. The machine/human split is load-bearing: veraPDF covers only + * about half of the Matterhorn Protocol, so a clean automated pass is not "accessible". + */ +@Data +@Schema(description = "Accessibility standing of a document") +public class AccessibilityReport { + + @Schema(description = "Profile the document was checked against, e.g. PDF/UA-1") + private String profile; + + @Schema(description = "Whether the document has a structure tree at all") + private boolean tagged; + + @Schema(description = "Whether the document declares PDF/UA conformance in its metadata") + private boolean declaresConformance; + + @Schema(description = "Whether every automated check passed") + private boolean passesAutomatedChecks; + + @Schema(description = "Automated checks that failed, grouped by rule") + private List issues = List.of(); + + @Schema(description = "Things a person still has to verify; automation cannot decide these") + private List humanChecks = List.of(); + + @Schema(description = "How many of the failing checks the converter can fix on its own") + private int automaticallyFixable; + + @Schema(description = "How many need information from the user, such as alternative text") + private int needsInput; + + @Schema( + description = + "Figures that need an alternative description. Each carries the key to pass" + + " back in the conversion request's altTextByFigure map, so a caller" + + " can enumerate what is missing and then supply it.") + private List figuresNeedingDescription = List.of(); + + @Schema(description = "Document-level facts that drive most failures") + private Summary summary = new Summary(); + + @Data + @Schema(description = "Quick document-level facts") + public static class Summary { + private int pages; + private boolean hasTitle; + private boolean displaysDocTitle; + private boolean hasLanguage; + private boolean allFontsEmbedded; + private int unembeddedFonts; + private int figures; + private boolean encrypted; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java new file mode 100644 index 0000000000..178d2637b2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.model.api.ua; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.common.model.api.PDFFile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class AccessibilityReportRequest extends PDFFile { + + @Schema( + description = "Profile to check against", + defaultValue = "ua1", + allowableValues = {"ua1", "ua2"}) + private String profile; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java new file mode 100644 index 0000000000..1c960d87e7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.model.api.ua; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * One figure needing an alternative description, which is never invented. key is the + * altTextByFigure key "pageIndex:ordinal"; page is 1-based; kind is "figure" or "formula". + */ +@Schema(description = "A figure that needs an alternative description") +public record FigureDescriptor( + String key, + int page, + String kind, + float x, + float y, + float width, + float height, + String existingAlt) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java new file mode 100644 index 0000000000..dcef97c916 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java @@ -0,0 +1,26 @@ +package stirling.software.proprietary.model.api.ua; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Result of a PDF/UA conversion. + * + * @param declared whether a {@code pdfuaid} conformance claim was written into {@code pdfBytes} + */ +@Schema(description = "Result of converting a document to PDF/UA") +public record PdfUaConversionOutcome( + byte[] pdfBytes, + boolean declared, + UaValidationResult validation, + TaggingSummary tagging, + List warnings) { + + @Schema(description = "What the tagging pass produced") + public record TaggingSummary( + boolean rebuiltStructure, + int taggedElements, + int artifacts, + int figuresNeedingAltText) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java new file mode 100644 index 0000000000..5e494c35be --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.model.api.ua; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Outcome of validating against one PDF/UA profile. compliant means every automated check passed, + * which is not the same as usable by assistive technology; totalFailures is ungrouped. + */ +@Schema(description = "Result of validating a document against a PDF/UA profile") +public record UaValidationResult( + String profile, boolean compliant, List issues, int totalFailures) { + + public boolean hasIssues() { + return !issues.isEmpty(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java new file mode 100644 index 0000000000..78df1fbc27 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.pdf.ua; + +/** Artifact subtypes (ISO 32000-1 14.8.2.2). Artifacts are excluded from the structure tree. */ +public enum ArtifactType { + /** Running heads, folios, page numbers. Required by PDF/UA-1 clause 7.8. */ + PAGINATION("Pagination"), + /** Rules, boxes, and other layout ornamentation. */ + LAYOUT("Layout"), + /** Cut marks and colour bars. */ + PAGE("Page"), + /** Background graphics with no informational content. */ + BACKGROUND("Background"); + + private final String subtype; + + ArtifactType(String subtype) { + this.subtype = subtype; + } + + public String subtype() { + return subtype; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java new file mode 100644 index 0000000000..f2fbf97438 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java @@ -0,0 +1,48 @@ +package stirling.software.proprietary.pdf.ua; + +/** An axis-aligned rectangle in PDF user space, with y increasing upwards. */ +public record BBox(float x0, float y0, float x1, float y1) { + + public static final BBox EMPTY = new BBox(0, 0, 0, 0); + + public static BBox of(float x, float y, float width, float height) { + return new BBox(x, y, x + width, y + height); + } + + public float width() { + return x1 - x0; + } + + public float height() { + return y1 - y0; + } + + public float centreX() { + return (x0 + x1) / 2f; + } + + public BBox union(BBox other) { + if (other == null || other.isEmpty()) { + return this; + } + if (isEmpty()) { + return other; + } + return new BBox( + Math.min(x0, other.x0), + Math.min(y0, other.y0), + Math.max(x1, other.x1), + Math.max(y1, other.y1)); + } + + public boolean isEmpty() { + return x1 <= x0 || y1 <= y0; + } + + /** Horizontal overlap with another box as a fraction of the narrower box's width. */ + public float horizontalOverlap(BBox other) { + float overlap = Math.min(x1, other.x1) - Math.max(x0, other.x0); + float narrower = Math.min(width(), other.width()); + return narrower <= 0 ? 0 : Math.max(0, overlap) / narrower; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java new file mode 100644 index 0000000000..5922895453 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java @@ -0,0 +1,84 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import lombok.Getter; +import lombok.Setter; + +/** The derived logical structure of a document, ready for serialisation into a structure tree. */ +@Getter +@Setter +public class DocumentStructure { + + /** Top-level blocks in document reading order. */ + private final List blocks = new ArrayList<>(); + + /** Warnings raised during analysis, surfaced in the conversion report. */ + private final List warnings = new ArrayList<>(); + + private String title; + private String language; + + /** True when real text was wrapped as artifacts, which blocks any conformance claim. */ + private boolean textSuppressed; + + /** Body text size used as the baseline for heading detection, in points. */ + private float bodyFontSize; + + public void add(StructBlock block) { + blocks.add(block); + } + + public void warn(String message) { + if (!warnings.contains(message)) { + warnings.add(message); + } + } + + public void visit(Consumer visitor) { + blocks.forEach(block -> block.visit(visitor)); + } + + public int count(StructType type) { + int[] total = {0}; + visit( + block -> { + if (block.getType() == type) { + total[0]++; + } + }); + return total[0]; + } + + public int artifactCount() { + int[] total = {0}; + visit( + block -> { + if (block.isArtifact()) { + total[0]++; + } + }); + return total[0]; + } + + /** Figures with no alternative description, the most common PDF/UA failure. */ + public List figuresWithoutAlt() { + List missing = new ArrayList<>(); + visit( + block -> { + if ((block.getType() == StructType.FIGURE + || block.getType() == StructType.FORMULA) + && (block.getAlt() == null || block.getAlt().isBlank()) + && (block.getActualText() == null || block.getActualText().isBlank())) { + missing.add(block); + } + }); + return missing; + } + + public boolean isEmpty() { + return blocks.isEmpty(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java new file mode 100644 index 0000000000..11c510d6ea --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java @@ -0,0 +1,831 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +/** + * Derives a logical structure from extracted lines and graphics, reusing {@code HeadingDetector}'s + * heuristics. Degrades to paragraphs rather than guessing, since a wrong tag misleads readers. + */ +@Slf4j +public class LayoutAnalyzer { + + private static final Pattern BULLET = Pattern.compile("^[•‣◦⁃∙·▪●■o\\-\\*\\+]\\s+.*"); + private static final Pattern ORDERED = + Pattern.compile("^(\\d{1,3}|[a-zA-Z]|[ivxlcIVXLC]{1,5})[\\.\\)]\\s+.*"); + private static final Pattern PAGE_NUMBER = + Pattern.compile( + "^(page\\s+)?\\d{1,4}(\\s*(of|/)\\s*\\d{1,4})?$", Pattern.CASE_INSENSITIVE); + private static final Pattern DIGITS = Pattern.compile("\\d+"); + + /** Fraction of page height treated as the running head / foot band. */ + private static final float MARGIN_BAND = 0.10f; + + /** A line must exceed the body size by this ratio before it can be a heading. */ + private static final float HEADING_RATIO = 1.10f; + + /** Sizes within this many points are treated as the same heading tier. */ + private static final float TIER_TOLERANCE = 0.4f; + + private static final int MAX_HEADING_WORDS = 12; + + /** Word gap beyond this multiple of the font size separates table cells. */ + private static final float CELL_GAP_RATIO = 1.2f; + + /** Images smaller than this in either dimension are decoration, not content. */ + private static final float MIN_FIGURE_SIZE = 12f; + + /** A size used by more than this share of lines is body text, however large the median says. */ + private static final float MAX_HEADING_LINE_SHARE = 0.2f; + + /** Consecutive lines sharing a size are a text block; headings appear alone. */ + private static final int MAX_HEADING_RUN = 3; + + /** A vector thinner than this in either dimension is a rule or border, not a drawing. */ + private static final float MIN_VECTOR_THICKNESS = 3f; + + /** Vector clusters smaller than this are ornament; larger ones are probably a chart. */ + private static final float MIN_VECTOR_FIGURE_SIZE = 40f; + + /** A drawing is built from several strokes; one big rectangle is a panel, not a chart. */ + private static final int MIN_VECTOR_FIGURE_OPS = 4; + + /** More text than this inside the region means shading behind content, not a drawing. */ + private static final int MAX_LINES_INSIDE_FIGURE = 2; + + public DocumentStructure analyse(List pages) { + DocumentStructure structure = new DocumentStructure(); + float bodySize = bodyFontSize(pages); + structure.setBodyFontSize(bodySize); + Map tiers = headingTiers(pages, bodySize); + Map> artifactLines = repeatedMarginLines(pages, bodySize); + + for (PageContent page : pages) { + analysePage( + page, + structure, + bodySize, + tiers, + artifactLines.getOrDefault(page.pageIndex(), List.of())); + } + + List suppressedPages = + pages.stream() + .filter(PageContent::linesDropped) + .map(PageContent::pageIndex) + .toList(); + if (!suppressedPages.isEmpty()) { + structure.setTextSuppressed(true); + structure.warn( + "Text on page(s) " + + suppressedPages.stream() + .map(i -> String.valueOf(i + 1)) + .collect(Collectors.joining(", ")) + + " could not be tagged reliably and was marked as artifacts. The" + + " converter will not claim conformance while real text is hidden" + + " from assistive technology."); + } + + normaliseHeadingLevels(structure); + structure.setTitle(deriveTitle(structure)); + return structure; + } + + // --- Document-wide statistics ----------------------------------------- + + /** Character-weighted median line size, which is far more stable than a plain median. */ + static float bodyFontSize(List pages) { + Map weights = new HashMap<>(); + for (PageContent page : pages) { + for (TextLineInfo line : page.lines()) { + if (line.dominantFontSize() > 0 && !line.isBlank()) { + weights.merge(line.dominantFontSize(), line.charCount(), Integer::sum); + } + } + } + if (weights.isEmpty()) { + return 0f; + } + int total = weights.values().stream().mapToInt(Integer::intValue).sum(); + List> sorted = + weights.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList(); + int seen = 0; + for (Map.Entry entry : sorted) { + seen += entry.getValue(); + if (seen >= total / 2) { + return entry.getKey(); + } + } + return sorted.get(sorted.size() - 1).getKey(); + } + + /** Maps each distinct heading size to a 1-based level, largest size first. */ + static Map headingTiers(List pages, float bodySize) { + if (bodySize <= 0) { + return Map.of(); + } + // A size used by a large share of the lines is body text, whatever the median says. + Map lineCounts = new HashMap<>(); + int totalLines = 0; + for (PageContent page : pages) { + for (TextLineInfo line : page.lines()) { + if (!line.isBlank()) { + lineCounts.merge(line.dominantFontSize(), 1, Integer::sum); + totalLines++; + } + } + } + int headingLineCeiling = Math.max(1, (int) (totalLines * MAX_HEADING_LINE_SHARE)); + + // Headings do not cluster; a run of same-size lines is a text block, not headings. + Map longestRun = new HashMap<>(); + for (PageContent page : pages) { + Float runSize = null; + int runLength = 0; + for (TextLineInfo line : page.lines()) { + if (line.isBlank()) { + continue; + } + float size = line.dominantFontSize(); + if (runSize != null && Float.compare(size, runSize) == 0) { + runLength++; + } else { + runSize = size; + runLength = 1; + } + int seen = longestRun.getOrDefault(size, 0); + if (runLength > seen) { + longestRun.put(size, runLength); + } + } + } + + List sizes = new ArrayList<>(); + for (PageContent page : pages) { + for (TextLineInfo line : page.lines()) { + if (isHeadingCandidate(line) + && line.dominantFontSize() > bodySize * HEADING_RATIO + && lineCounts.getOrDefault(line.dominantFontSize(), 0) <= headingLineCeiling + && longestRun.getOrDefault(line.dominantFontSize(), 0) < MAX_HEADING_RUN) { + sizes.add(line.dominantFontSize()); + } + } + } + List distinct = sizes.stream().distinct().sorted(Comparator.reverseOrder()).toList(); + + Map tiers = new LinkedHashMap<>(); + int level = 0; + Float previous = null; + for (Float size : distinct) { + if (previous == null || previous - size > TIER_TOLERANCE) { + level = Math.min(level + 1, 6); + previous = size; + } + tiers.put(size, level); + } + return tiers; + } + + /** + * Claims a line's operators word run by word run; claiming the whole ordinal interval would + * swallow anything drawn between them, an image included. + */ + private static void claimLine(StructBlock block, TextLineInfo line) { + // Sort by ordinal, not position: merging out-of-order runs silently drops them to + // /Artifact, hiding them from assistive technology while the file still validates. + List words = + line.words().stream() + .filter(w -> !w.isBlank()) + .sorted(Comparator.comparingInt(WordInfo::startOrdinal)) + .toList(); + if (words.isEmpty()) { + block.addRange(line.startOrdinal(), line.endOrdinal()); + return; + } + int start = words.get(0).startOrdinal(); + int end = words.get(0).endOrdinal(); + for (int i = 1; i < words.size(); i++) { + WordInfo word = words.get(i); + if (word.startOrdinal() <= end + 1) { + end = Math.max(end, word.endOrdinal()); + } else { + block.addRange(start, end); + start = word.startOrdinal(); + end = word.endOrdinal(); + } + } + block.addRange(start, end); + } + + static boolean isHeadingCandidate(TextLineInfo line) { + String text = line.text().strip(); + if (text.isEmpty() || line.wordCount() > MAX_HEADING_WORDS) { + return false; + } + char last = text.charAt(text.length() - 1); + return last != '.' && last != '!' && last != '?'; + } + + /** + * Finds lines in the head/foot bands whose text repeats across pages. Digits are masked first + * so that "Page 4" and "Page 5" count as the same running foot. + */ + static Map> repeatedMarginLines(List pages) { + return repeatedMarginLines(pages, bodyFontSize(pages)); + } + + static Map> repeatedMarginLines( + List pages, float bodySize) { + Map> result = new HashMap<>(); + if (pages.isEmpty()) { + return result; + } + Map counts = new HashMap<>(); + Map> candidates = new HashMap<>(); + + for (PageContent page : pages) { + float height = page.mediaBox().height(); + if (height <= 0) { + continue; + } + float topEdge = page.mediaBox().y1() - height * MARGIN_BAND; + float bottomEdge = page.mediaBox().y0() + height * MARGIN_BAND; + List inBand = new ArrayList<>(); + for (TextLineInfo line : page.lines()) { + if (line.bbox().y0() >= topEdge || line.bbox().y1() <= bottomEdge) { + inBand.add(line); + counts.merge(mask(line.text()), 1, Integer::sum); + } + } + candidates.put(page.pageIndex(), inBand); + } + + int threshold = Math.max(2, pages.size() / 2); + for (Map.Entry> entry : candidates.entrySet()) { + List artifacts = new ArrayList<>(); + for (TextLineInfo line : entry.getValue()) { + boolean repeats = + pages.size() >= 3 && counts.getOrDefault(mask(line.text()), 0) >= threshold; + boolean pageNumber = PAGE_NUMBER.matcher(line.text().strip()).matches(); + // Masked digits merge "Section 1" and "Section 2"; size is the tie-break that stops + // a real heading being demoted, as running heads are never larger than body text. + boolean looksLikeChrome = + bodySize <= 0 || line.dominantFontSize() <= bodySize * 1.05f; + if (pageNumber || (repeats && looksLikeChrome)) { + artifacts.add(line); + } + } + result.put(entry.getKey(), artifacts); + } + return result; + } + + private static String mask(String text) { + return DIGITS.matcher(text.strip().toLowerCase()).replaceAll("#").replaceAll("\\s+", " "); + } + + // --- Per-page analysis ------------------------------------------------- + + private void analysePage( + PageContent page, + DocumentStructure structure, + float bodySize, + Map tiers, + List marginArtifacts) { + + for (TextLineInfo line : marginArtifacts) { + StructBlock artifact = StructBlock.artifact(ArtifactType.PAGINATION, page.pageIndex()); + claimLine(artifact, line); + artifact.setBbox(line.bbox()); + artifact.setText(line.text()); + structure.add(artifact); + } + + // Identity set, not List.contains: TextLineInfo is a record whose equals walks its word + // list, so a linear scan per line is quadratic with a deep comparison inside it. + java.util.Set marginSet = Collections.newSetFromMap(new IdentityHashMap<>()); + marginSet.addAll(marginArtifacts); + List body = + page.lines().stream() + .filter(line -> !line.isBlank() && !marginSet.contains(line)) + .sorted(readingOrder(page)) + .toList(); + + List blocks = new ArrayList<>(); + int index = 0; + while (index < body.size()) { + TextLineInfo line = body.get(index); + + int tableEnd = tableRunEnd(body, index); + if (tableEnd > index) { + StructBlock table = buildTable(body.subList(index, tableEnd + 1), page.pageIndex()); + if (table != null) { + blocks.add(table); + index = tableEnd + 1; + continue; + } + } + + int listEnd = listRunEnd(body, index); + if (listEnd > index) { + blocks.add(buildList(body.subList(index, listEnd + 1), page.pageIndex())); + index = listEnd + 1; + continue; + } + + Integer level = headingLevel(line, tiers); + if (level != null) { + StructBlock heading = new StructBlock(StructType.heading(level), page.pageIndex()); + claimLine(heading, line); + heading.setBbox(line.bbox()); + heading.setText(line.text()); + blocks.add(heading); + index++; + continue; + } + + int paragraphEnd = paragraphRunEnd(body, index, tiers, bodySize); + blocks.add(buildParagraph(body.subList(index, paragraphEnd + 1), page.pageIndex())); + index = paragraphEnd + 1; + } + + // Form XObject text is attributed to its Do, so a Figure too would double-claim it. + Set claimed = new HashSet<>(); + for (StructBlock block : blocks) { + block.visit( + node -> + node.getRanges() + .forEach( + range -> { + for (int i = range.start(); i <= range.end(); i++) { + claimed.add(i); + } + })); + } + blocks.addAll(buildGraphics(page, structure, claimed)); + blocks.forEach(structure::add); + } + + /** + * Orders lines top-to-bottom, splitting into columns first when the page is clearly + * multi-column. Without this, a two-column page reads as interleaved half-sentences. + */ + private Comparator readingOrder(PageContent page) { + Float gutter = detectGutter(page); + if (gutter == null) { + return Comparator.comparingDouble((TextLineInfo l) -> -l.bbox().y1()) + .thenComparingDouble(l -> l.bbox().x0()); + } + return Comparator.comparingInt((TextLineInfo l) -> l.bbox().centreX() < gutter ? 0 : 1) + .thenComparingDouble(l -> -l.bbox().y1()) + .thenComparingDouble(l -> l.bbox().x0()); + } + + /** + * Returns the x of a vertical gutter when the page is two-column, else null. A gutter must sit + * near the middle, be crossed by almost no line, and have substantial text on both sides. + */ + static Float detectGutter(PageContent page) { + List lines = page.lines().stream().filter(line -> !line.isBlank()).toList(); + if (lines.size() < 8) { + return null; + } + float pageWidth = page.mediaBox().width(); + if (pageWidth <= 0) { + return null; + } + float centre = page.mediaBox().x0() + pageWidth / 2f; + long crossing = + lines.stream() + .filter( + line -> + line.bbox().x0() < centre - 5 + && line.bbox().x1() > centre + 5) + .count(); + if (crossing > lines.size() * 0.1) { + return null; + } + long left = lines.stream().filter(line -> line.bbox().centreX() < centre).count(); + long right = lines.size() - left; + boolean balanced = left > lines.size() * 0.25 && right > lines.size() * 0.25; + return balanced ? centre : null; + } + + private static Integer headingLevel(TextLineInfo line, Map tiers) { + if (!isHeadingCandidate(line)) { + return null; + } + return tiers.get(line.dominantFontSize()); + } + + // --- Paragraphs -------------------------------------------------------- + + private static int paragraphRunEnd( + List lines, int start, Map tiers, float bodySize) { + int end = start; + for (int i = start + 1; i < lines.size(); i++) { + TextLineInfo previous = lines.get(i - 1); + TextLineInfo current = lines.get(i); + if (headingLevel(current, tiers) != null || startsListItem(current)) { + break; + } + float gap = previous.bbox().y0() - current.bbox().y1(); + float leading = Math.max(bodySize, current.bbox().height()); + boolean sameBlock = gap < leading * 0.8f && gap > -leading; + boolean sentenceEnded = endsSentence(previous.text()); + if (!sameBlock || (sentenceEnded && gap > leading * 0.4f)) { + break; + } + end = i; + } + return end; + } + + private static boolean endsSentence(String text) { + String stripped = text.strip(); + if (stripped.isEmpty()) { + return false; + } + char last = stripped.charAt(stripped.length() - 1); + return last == '.' || last == '!' || last == '?'; + } + + private static StructBlock buildParagraph(List lines, int pageIndex) { + StructBlock paragraph = new StructBlock(StructType.P, pageIndex); + BBox box = BBox.EMPTY; + StringBuilder text = new StringBuilder(); + for (TextLineInfo line : lines) { + claimLine(paragraph, line); + box = box.union(line.bbox()); + if (text.length() > 0) { + text.append(' '); + } + text.append(line.text().strip()); + } + paragraph.setBbox(box); + paragraph.setText(text.toString()); + return paragraph; + } + + // --- Lists ------------------------------------------------------------- + + static boolean startsListItem(TextLineInfo line) { + String text = line.text().strip(); + return BULLET.matcher(text).matches() || ORDERED.matcher(text).matches(); + } + + private static int listRunEnd(List lines, int start) { + if (!startsListItem(lines.get(start))) { + return start; + } + float indent = lines.get(start).bbox().x0(); + int end = start; + for (int i = start + 1; i < lines.size(); i++) { + TextLineInfo line = lines.get(i); + boolean isItem = startsListItem(line) && Math.abs(line.bbox().x0() - indent) < 6f; + boolean isContinuation = !startsListItem(line) && line.bbox().x0() > indent + 2f; + if (!isItem && !isContinuation) { + break; + } + end = i; + } + // A single marker is a stray character, not a list. + long items = + lines.subList(start, end + 1).stream() + .filter(LayoutAnalyzer::startsListItem) + .count(); + return items >= 2 ? end : start; + } + + private static StructBlock buildList(List lines, int pageIndex) { + StructBlock list = new StructBlock(StructType.L, pageIndex); + list.setListNumbering(listNumbering(lines.get(0))); + BBox box = BBox.EMPTY; + StructBlock currentBody = null; + + for (TextLineInfo line : lines) { + box = box.union(line.bbox()); + if (startsListItem(line) || currentBody == null) { + StructBlock item = new StructBlock(StructType.LI, pageIndex); + StructBlock body = new StructBlock(StructType.LBODY, pageIndex); + claimLine(body, line); + body.setBbox(line.bbox()); + body.setText(line.text()); + item.addChild(body); + item.setBbox(line.bbox()); + list.addChild(item); + currentBody = body; + } else { + claimLine(currentBody, line); + currentBody.setBbox(currentBody.getBbox().union(line.bbox())); + currentBody.setText(currentBody.getText() + " " + line.text().strip()); + } + } + list.setBbox(box); + return list; + } + + private static String listNumbering(TextLineInfo first) { + String text = first.text().strip(); + if (BULLET.matcher(text).matches()) { + return "Disc"; + } + char c = text.charAt(0); + if (Character.isDigit(c)) { + return "Decimal"; + } + if ("ivxlc".indexOf(Character.toLowerCase(c)) >= 0 && text.length() > 1) { + return Character.isUpperCase(c) ? "UpperRoman" : "LowerRoman"; + } + return Character.isUpperCase(c) ? "UpperAlpha" : "LowerAlpha"; + } + + // --- Tables ------------------------------------------------------------ + + /** Splits a line into cells wherever the gap between words exceeds the cell threshold. */ + static List> splitCells(TextLineInfo line) { + List words = line.words().stream().filter(w -> !w.isBlank()).toList(); + List> cells = new ArrayList<>(); + if (words.isEmpty()) { + return cells; + } + float threshold = Math.max(line.dominantFontSize(), 1f) * CELL_GAP_RATIO; + List current = new ArrayList<>(); + current.add(words.get(0)); + for (int i = 1; i < words.size(); i++) { + float gap = words.get(i).bbox().x0() - words.get(i - 1).bbox().x1(); + if (gap > threshold) { + cells.add(List.copyOf(current)); + current = new ArrayList<>(); + } + current.add(words.get(i)); + } + cells.add(List.copyOf(current)); + return cells; + } + + /** + * Index of the last line of a table run starting at {@code start}, or {@code start} if none. + */ + private static int tableRunEnd(List lines, int start) { + int end = start; + for (int i = start; i < lines.size(); i++) { + if (splitCells(lines.get(i)).size() < 2) { + break; + } + end = i; + } + return end > start ? end : start; + } + + /** + * Builds a Table when the run really looks tabular and each cell owns its own operators. + * Returns null when it does not, so the caller falls back to paragraphs. + */ + private static StructBlock buildTable(List rows, int pageIndex) { + if (rows.size() < 2) { + return null; + } + List>> grid = new ArrayList<>(); + for (TextLineInfo row : rows) { + if (!row.wordsAreSeparable()) { + log.debug("Table row shares operators between cells; falling back to paragraphs"); + return null; + } + grid.add(splitCells(row)); + } + int columns = grid.get(0).size(); + long consistent = grid.stream().filter(row -> row.size() == columns).count(); + if (columns < 2 || consistent < Math.max(2, grid.size() * 0.6)) { + return null; + } + + boolean headerRow = looksLikeHeader(rows, grid); + StructBlock table = new StructBlock(StructType.TABLE, pageIndex); + BBox box = BBox.EMPTY; + + for (int r = 0; r < grid.size(); r++) { + List> cells = grid.get(r); + if (cells.size() != columns) { + continue; + } + StructBlock tr = new StructBlock(StructType.TR, pageIndex); + boolean isHeader = headerRow && r == 0; + for (List cell : cells) { + StructBlock td = + new StructBlock(isHeader ? StructType.TH : StructType.TD, pageIndex); + if (isHeader) { + td.setScope("Column"); + } + BBox cellBox = BBox.EMPTY; + StringBuilder text = new StringBuilder(); + int from = cell.get(0).startOrdinal(); + int to = cell.get(cell.size() - 1).endOrdinal(); + for (WordInfo word : cell) { + cellBox = cellBox.union(word.bbox()); + if (text.length() > 0) { + text.append(' '); + } + text.append(word.text()); + } + td.addRange(from, to); + td.setBbox(cellBox); + td.setText(text.toString()); + tr.addChild(td); + box = box.union(cellBox); + } + tr.setBbox(box); + table.addChild(tr); + } + table.setBbox(box); + if (table.getChildren().size() < 2) { + return null; + } + // Clause 7.5 needs equal cell counts per row; a ragged table fails validation outright. + long distinctWidths = + table.getChildren().stream() + .map(row -> row.getChildren().size()) + .distinct() + .count(); + if (distinctWidths != 1) { + log.debug("Discarding a table whose rows have different cell counts"); + return null; + } + return table; + } + + /** The first row is a header when it is bold, or when only later rows carry numbers. */ + private static boolean looksLikeHeader( + List rows, List>> grid) { + if (rows.get(0).bold()) { + return true; + } + boolean firstHasDigits = DIGITS.matcher(rows.get(0).text()).find(); + boolean laterHasDigits = + rows.subList(1, rows.size()).stream() + .anyMatch(row -> DIGITS.matcher(row.text()).find()); + return !firstHasDigits && laterHasDigits; + } + + // --- Graphics ---------------------------------------------------------- + + private List buildGraphics( + PageContent page, DocumentStructure structure, java.util.Set claimed) { + List blocks = new ArrayList<>(); + boolean warnedForms = false; + + // Vectors cluster: a chart is many strokes in one region, a rule is a single thin one. + java.util.Set vectorFigureOrdinals = vectorFigureOrdinals(page, claimed); + + for (MarkableOp op : page.ops()) { + if (op.kind() == MarkableOp.Kind.TEXT || claimed.contains(op.ordinal())) { + continue; + } + BBox box = op.bbox(); + + if (op.kind() == MarkableOp.Kind.VECTOR) { + StructBlock block; + if (vectorFigureOrdinals.contains(op.ordinal())) { + block = new StructBlock(StructType.FIGURE, page.pageIndex()); + } else { + block = StructBlock.artifact(ArtifactType.LAYOUT, page.pageIndex()); + } + block.addRange(op.ordinal(), op.ordinal()); + block.setBbox(box); + blocks.add(block); + continue; + } + + boolean decorative = box.width() < MIN_FIGURE_SIZE || box.height() < MIN_FIGURE_SIZE; + if (decorative) { + StructBlock artifact = StructBlock.artifact(ArtifactType.LAYOUT, page.pageIndex()); + artifact.addRange(op.ordinal(), op.ordinal()); + artifact.setBbox(box); + blocks.add(artifact); + continue; + } + + if (op.kind() == MarkableOp.Kind.FORM && !warnedForms) { + structure.warn( + "Content inside form XObjects was tagged as a single region because its" + + " text is not separately addressable; review those areas."); + warnedForms = true; + } + + StructBlock figure = new StructBlock(StructType.FIGURE, page.pageIndex()); + figure.addRange(op.ordinal(), op.ordinal()); + figure.setBbox(box); + blocks.add(figure); + } + return blocks; + } + + /** + * Finds vector operators belonging to a substantial drawing rather than page furniture; thin + * paths are rules and table borders, and a short run is ornament. + */ + private static Set vectorFigureOrdinals( + PageContent page, java.util.Set claimed) { + // A chart's plot area is mostly empty, while shading sits behind the text it decorates. + Set result = new HashSet<>(); + List run = new ArrayList<>(); + BBox extent = BBox.EMPTY; + + for (MarkableOp op : page.ops()) { + boolean substantial = + op.kind() == MarkableOp.Kind.VECTOR + && !claimed.contains(op.ordinal()) + && !op.bbox().isEmpty() + && op.bbox().width() >= MIN_VECTOR_THICKNESS + && op.bbox().height() >= MIN_VECTOR_THICKNESS; + if (substantial) { + run.add(op); + extent = extent.isEmpty() ? op.bbox() : extent.union(op.bbox()); + continue; + } + flushVectorRun(run, extent, page.lines(), result); + run = new ArrayList<>(); + extent = BBox.EMPTY; + } + flushVectorRun(run, extent, page.lines(), result); + return result; + } + + private static void flushVectorRun( + List run, + BBox extent, + List lines, + java.util.Set result) { + if (run.size() < MIN_VECTOR_FIGURE_OPS + || extent.width() < MIN_VECTOR_FIGURE_SIZE + || extent.height() < MIN_VECTOR_FIGURE_SIZE) { + return; + } + if (overlappingLines(extent, lines) > MAX_LINES_INSIDE_FIGURE) { + return; + } + run.forEach(op -> result.add(op.ordinal())); + } + + /** How many text lines sit within the region a vector cluster covers. */ + private static int overlappingLines(BBox extent, List lines) { + int count = 0; + for (TextLineInfo line : lines) { + BBox box = line.bbox(); + boolean inside = + box.x0() >= extent.x0() - 2 + && box.x1() <= extent.x1() + 2 + && box.y0() >= extent.y0() - 2 + && box.y1() <= extent.y1() + 2; + if (inside) { + count++; + } + } + return count; + } + + // --- Post-processing --------------------------------------------------- + + /** + * Rewrites heading levels so no level is skipped, which PDF/UA-1 clause 7.4 requires. A + * document that jumps H1 to H3 is remapped to H1, H2 while preserving relative depth. + */ + static void normaliseHeadingLevels(DocumentStructure structure) { + List headings = new ArrayList<>(); + structure.visit( + block -> { + if (block.getType().isHeading()) { + headings.add(block); + } + }); + int previous = 0; + for (StructBlock heading : headings) { + int level = heading.getType().headingLevel(); + int adjusted = level > previous + 1 ? previous + 1 : level; + heading.setType(StructType.heading(adjusted)); + previous = adjusted; + } + } + + /** Uses the first top-level heading as the title when the document has no metadata title. */ + private static String deriveTitle(DocumentStructure structure) { + for (StructBlock block : structure.getBlocks()) { + if (block.getType().isHeading() && !block.getText().isBlank()) { + return block.getText().strip(); + } + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java new file mode 100644 index 0000000000..c48319260f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java @@ -0,0 +1,44 @@ +package stirling.software.proprietary.pdf.ua; + +/** + * One operator in a page content stream that may be wrapped in a marked-content sequence. The + * ordinal counts only markable operators, joining text extraction to token rewriting. + */ +public record MarkableOp(int ordinal, Kind kind, BBox bbox, String resourceName) { + + public enum Kind { + /** Tj, TJ, ' or " */ + TEXT, + /** Do referencing an image XObject */ + IMAGE, + /** Do referencing a form XObject */ + FORM, + /** BI ... ID ... EI */ + INLINE_IMAGE, + /** A path-painting or shading operator: rules, borders, fills, logos */ + VECTOR; + + public boolean isGraphic() { + return this == IMAGE || this == INLINE_IMAGE; + } + } + + /** + * Operator names counted as markable; both passes must agree on this set. Path painting is + * included because clause 7.1 needs visible rules and borders tagged or artifacted. + */ + public static boolean isMarkableOperator(String name) { + return switch (name) { + case "Tj", "TJ", "'", "\"", "Do", "BI" -> true; + default -> isPathPainting(name); + }; + } + + /** Painting operators only: {@code n} ends a path without marking the page. */ + public static boolean isPathPainting(String name) { + return switch (name) { + case "S", "s", "f", "F", "f*", "B", "B*", "b", "b*", "sh" -> true; + default -> false; + }; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java new file mode 100644 index 0000000000..c9d6330a60 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java @@ -0,0 +1,284 @@ +package stirling.software.proprietary.pdf.ua; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSInteger; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdfwriter.ContentStreamWriter; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDStream; + +import lombok.extern.slf4j.Slf4j; + +/** + * Rewrites a page stream so every markable operator sits inside a marked-content sequence: claimed + * content gets an MCID, everything else /Artifact, satisfying PDF/UA-1 clause 7.1 by construction. + */ +@Slf4j +public class MarkedContentInjector { + + private static final COSName ARTIFACT = COSName.getPDFName("Artifact"); + private static final COSName MCID = COSName.getPDFName("MCID"); + private static final COSName ACTUAL_TEXT = COSName.getPDFName("ActualText"); + private static final COSName ALT = COSName.getPDFName("Alt"); + + /** Operators that force an open sequence to close so nesting stays legal. */ + private static boolean isBoundary(String name) { + return "BT".equals(name) || "ET".equals(name) || "q".equals(name) || "Q".equals(name); + } + + private static boolean isMarkedContentOperator(String name) { + return "BDC".equals(name) || "BMC".equals(name) || "EMC".equals(name); + } + + /** + * Path-construction operators; ISO 32000-1 forbids marked content inside a path object, so a + * sequence wrapping a fill or stroke must open before the path starts. + */ + private static boolean isPathConstruction(String name) { + return switch (name) { + case "m", "l", "c", "v", "y", "h", "re" -> true; + default -> false; + }; + } + + private static boolean opensMarkedContent(String name) { + return "BDC".equals(name) || "BMC".equals(name); + } + + /** + * True for an optional-content sequence; stripping an {@code /OC} wrapper would make hidden + * layers such as watermarks or redaction overlays visible. + */ + private static boolean isOptionalContent(String name, List operands) { + return opensMarkedContent(name) + && !operands.isEmpty() + && operands.get(0) instanceof COSName tag + && "OC".equals(tag.getName()); + } + + /** + * True when a sequence supplies replacement text for its glyphs; dropping it leaves a screen + * reader with the font's own mapping, which for a ligature says nothing useful. + */ + private static boolean carriesReplacementText(String name, List operands) { + if (!opensMarkedContent(name)) { + return false; + } + for (COSBase operand : operands) { + if (operand instanceof COSDictionary properties + && (properties.containsKey(ACTUAL_TEXT) + || properties.containsKey(ALT) + || properties.containsKey(COSName.E))) { + return true; + } + } + return false; + } + + /** The source's own ids mean nothing once the tree is rebuilt, so they are dropped. */ + private static void stripStaleMcid(List operands) { + for (COSBase operand : operands) { + if (operand instanceof COSDictionary properties) { + properties.removeItem(MCID); + } + } + } + + /** Wraps every markable operator on the page; returns the next unused marked content id. */ + public int inject( + PDDocument document, + PDPage page, + List blocks, + int nextMcid, + boolean stripExisting) + throws IOException { + + Map owners = ownersByOrdinal(blocks); + List tokens = parse(page); + List output = new ArrayList<>(tokens.size() + owners.size() * 4); + + List operands = new ArrayList<>(); + // Tracks, for each surviving source sequence, whether its closer should be kept. + Deque keptSequences = new ArrayDeque<>(); + StructBlock openBlock = null; + boolean open = false; + int ordinal = -1; + int mcid = nextMcid; + int pathStart = -1; + + for (Object token : tokens) { + if (!(token instanceof Operator operator)) { + operands.add((COSBase) token); + continue; + } + String name = operator.getName(); + + if (stripExisting && isMarkedContentOperator(name)) { + boolean keep; + if (opensMarkedContent(name)) { + keep = + isOptionalContent(name, operands) + || carriesReplacementText(name, operands); + if (keep) { + stripStaleMcid(operands); + } + keptSequences.push(keep); + } else { + // A closer is kept exactly when its matching opener was. + keep = !keptSequences.isEmpty() && keptSequences.pop(); + } + if (!keep) { + operands.clear(); + continue; + } + // Close our own sequence first so the two never interleave illegally. + if (open) { + output.add(Operator.getOperator("EMC")); + open = false; + openBlock = null; + } + output.addAll(operands); + output.add(operator); + operands.clear(); + continue; + } + + if (isBoundary(name) && open) { + output.add(Operator.getOperator("EMC")); + open = false; + openBlock = null; + } + + // Remember where the current path object began so a sequence wrapping its painting + // operator can be opened before it rather than inside it. + if (isPathConstruction(name)) { + if (pathStart < 0) { + pathStart = output.size(); + } + } else if (!MarkableOp.isPathPainting(name) && !"n".equals(name)) { + pathStart = -1; + } + + if (MarkableOp.isMarkableOperator(name)) { + ordinal++; + StructBlock owner = owners.get(ordinal); + if (!open || owner != openBlock) { + boolean insidePath = MarkableOp.isPathPainting(name) && pathStart >= 0; + if (open) { + // Close before the path began, so the EMC also stays outside the path. + output.add( + insidePath ? pathStart : output.size(), + Operator.getOperator("EMC")); + if (insidePath) { + pathStart++; + } + } + int at = insidePath ? pathStart : output.size(); + mcid = openSequenceAt(output, at, owner, mcid); + open = true; + openBlock = owner; + } + } + + output.addAll(operands); + output.add(operator); + operands.clear(); + + if (MarkableOp.isPathPainting(name) || "n".equals(name)) { + pathStart = -1; + } + } + + if (open) { + output.add(Operator.getOperator("EMC")); + } + + write(document, page, output); + return mcid; + } + + /** Emits the opening BDC/BMC at a given position and records the id on the owning block. */ + private int openSequenceAt(List output, int at, StructBlock owner, int mcid) { + List opening = new ArrayList<>(3); + if (owner == null) { + opening.add(ARTIFACT); + opening.add(Operator.getOperator("BMC")); + } else if (owner.isArtifact()) { + COSDictionary properties = new COSDictionary(); + if (owner.getArtifactType() != null) { + properties.setName(COSName.TYPE, owner.getArtifactType().subtype()); + } + opening.add(ARTIFACT); + opening.add(properties); + opening.add(Operator.getOperator("BDC")); + } else { + COSDictionary properties = new COSDictionary(); + properties.setItem(MCID, COSInteger.get(mcid)); + opening.add(COSName.getPDFName(owner.getType().tag())); + opening.add(properties); + opening.add(Operator.getOperator("BDC")); + owner.getMcids().add(mcid); + mcid++; + } + output.addAll(at, opening); + return mcid; + } + + /** + * Maps each claimed ordinal to its block. Overlapping claims are dropped rather than merged: + * two structure elements sharing content would make the reading order ambiguous. + */ + static Map ownersByOrdinal(List blocks) { + Map owners = new HashMap<>(); + for (StructBlock block : blocks) { + block.visit( + node -> { + for (StructBlock.OrdinalRange range : node.getRanges()) { + for (int i = range.start(); i <= range.end(); i++) { + StructBlock existing = owners.putIfAbsent(i, node); + if (existing != null && existing != node) { + log.debug( + "Ordinal {} claimed by both {} and {}; keeping the first", + i, + existing, + node); + } + } + } + }); + } + return owners; + } + + private static List parse(PDPage page) throws IOException { + PDFStreamParser parser = new PDFStreamParser(page); + List tokens = new ArrayList<>(); + Object token; + while ((token = parser.parseNextToken()) != null) { + tokens.add(token); + } + return tokens; + } + + private static void write(PDDocument document, PDPage page, List tokens) + throws IOException { + PDStream stream = new PDStream(document); + try (OutputStream out = stream.createOutputStream(COSName.FLATE_DECODE)) { + new ContentStreamWriter(out).writeTokens(tokens); + } + page.setContents(stream); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PageContent.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PageContent.java new file mode 100644 index 0000000000..6c74212621 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PageContent.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.List; + +/** + * Everything the layout analyser needs about one page. carriesTextSemantics: existing marked + * content has ActualText/Alt/expansion a rebuild would discard. linesDropped: text became + * artifacts. + */ +public record PageContent( + int pageIndex, + List lines, + List ops, + int markableCount, + boolean preExistingMarkedContent, + boolean carriesTextSemantics, + boolean linesDropped, + BBox mediaBox) { + + public boolean hasText() { + return lines.stream().anyMatch(line -> !line.isBlank()); + } + + /** Markable operators that draw graphics rather than text. */ + public List graphics() { + return ops.stream().filter(op -> op.kind().isGraphic()).toList(); + } + + /** Form XObject invocations, which are tagged as a unit because their text is opaque here. */ + public List forms() { + return ops.stream().filter(op -> op.kind() == MarkableOp.Kind.FORM).toList(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaIdentificationSchema.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaIdentificationSchema.java new file mode 100644 index 0000000000..d37c2569c9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaIdentificationSchema.java @@ -0,0 +1,47 @@ +package stirling.software.proprietary.pdf.ua; + +import org.apache.xmpbox.XMPMetadata; +import org.apache.xmpbox.schema.XMPSchema; +import org.apache.xmpbox.type.IntegerType; +import org.apache.xmpbox.type.StructuredType; + +/** + * The {@code pdfuaid} XMP conformance schema, which XMPBox does not ship. Only write it once + * validation has passed - it is a compliance claim. + */ +@StructuredType( + preferedPrefix = PdfUaIdentificationSchema.PREFERRED_PREFIX, + namespace = PdfUaIdentificationSchema.NAMESPACE) +public class PdfUaIdentificationSchema extends XMPSchema { + + public static final String PREFERRED_PREFIX = "pdfuaid"; + public static final String NAMESPACE = "http://www.aiim.org/pdfua/ns/id/"; + + public static final String PART = "part"; + public static final String REV = "rev"; + + public PdfUaIdentificationSchema(XMPMetadata metadata) { + super(metadata); + } + + public PdfUaIdentificationSchema(XMPMetadata metadata, String prefix) { + super(metadata, prefix); + } + + /** Sets {@code pdfuaid:part}, the conformance level (1 or 2). */ + public void setPart(int part) { + addProperty(new IntegerType(getMetadata(), getNamespace(), getPrefix(), PART, part)); + } + + /** Sets {@code pdfuaid:rev}, the four-digit revision year used by PDF/UA-2. */ + public void setRevision(int year) { + addProperty(new IntegerType(getMetadata(), getNamespace(), getPrefix(), REV, year)); + } + + public Integer getPart() { + if (getProperty(PART) instanceof IntegerType part) { + return part.getValue(); + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriter.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriter.java new file mode 100644 index 0000000000..7f58654c87 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriter.java @@ -0,0 +1,224 @@ +package stirling.software.proprietary.pdf.ua; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences; +import org.apache.xmpbox.XMPMetadata; +import org.apache.xmpbox.schema.DublinCoreSchema; +import org.apache.xmpbox.schema.XMPSchema; +import org.apache.xmpbox.xml.DomXmpParser; +import org.apache.xmpbox.xml.XmpSerializer; + +import lombok.extern.slf4j.Slf4j; + +/** Applies the document-level PDF/UA requirements: title, language, tab order, declaration. */ +@Slf4j +public class PdfUaMetadataWriter { + + private static final COSName TABS = COSName.getPDFName("Tabs"); + private static final COSName SUSPECTS = COSName.getPDFName("Suspects"); + + /** + * Applies everything except the conformance declaration. Clause 7.1 requires a title, so a + * blank one falls back to the existing metadata title. + */ + public List applyDocumentRequirements( + PDDocument document, String title, String language, PdfUaProfile profile) + throws IOException { + return applyDocumentRequirements(document, title, language, profile, false); + } + + public List applyDocumentRequirements( + PDDocument document, + String title, + String language, + PdfUaProfile profile, + boolean preserveVersion) + throws IOException { + + List warnings = new ArrayList<>(); + PDDocumentCatalog catalog = document.getDocumentCatalog(); + + if (language != null && !language.isBlank()) { + catalog.setLanguage(language); + } + + String effectiveTitle = resolveTitle(document, title); + if (effectiveTitle != null) { + PDDocumentInformation info = document.getDocumentInformation(); + info.setTitle(effectiveTitle); + document.setDocumentInformation(info); + } + + // Without this a viewer shows the filename instead of the title, which defeats the point. + PDViewerPreferences preferences = catalog.getViewerPreferences(); + if (preferences == null) { + preferences = new PDViewerPreferences(catalog.getCOSObject()); + } + preferences.setDisplayDocTitle(true); + catalog.setViewerPreferences(preferences); + + // Clause 7.18.1: every page needs an explicit tab order. + for (PDPage page : document.getPages()) { + page.getCOSObject().setName(TABS, "S"); + } + + // A structure tree flagged as suspect is not conforming. + if (catalog.getMarkInfo() != null) { + catalog.getMarkInfo().getCOSObject().removeItem(SUSPECTS); + } + + if (!preserveVersion && document.getVersion() < profile.pdfVersion()) { + document.setVersion(profile.pdfVersion()); + } + + warnings.addAll(describeFormFields(document)); + writeXmp(document, effectiveTitle, language, null); + return warnings; + } + + /** + * Gives every form field the {@code /TU} description clause 7.18.1 requires, reusing its + * authored partial name. Unnamed fields are reported, never given a useless placeholder. + */ + private static List describeFormFields(PDDocument document) { + List warnings = new ArrayList<>(); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(); + if (form == null) { + return warnings; + } + int unnamed = 0; + for (PDField field : form.getFieldTree()) { + String existing = field.getAlternateFieldName(); + if (existing != null && !existing.isBlank()) { + continue; + } + String partialName = field.getPartialName(); + if (partialName == null || partialName.isBlank()) { + unnamed++; + continue; + } + field.setAlternateFieldName(partialName); + } + if (unnamed > 0) { + warnings.add( + unnamed + + " form field(s) have neither a description nor a name, so no tooltip" + + " could be derived. Add one for each before claiming conformance."); + } + return warnings; + } + + /** + * Strips the {@code pdfuaid} declaration when validation fails after it was written, so the + * returned file does not assert conformance it lacks. + */ + public void removeConformanceDeclaration(PDDocument document) throws IOException { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + XMPMetadata metadata = loadOrCreate(catalog); + XMPSchema identification = metadata.getSchema(PdfUaIdentificationSchema.NAMESPACE); + if (identification == null) { + return; + } + metadata.removeSchema(identification); + serialiseInto(document, metadata); + } + + /** Writes the {@code pdfuaid:part} declaration. Only call this after validation has passed. */ + public void declareConformance(PDDocument document, PdfUaProfile profile) throws IOException { + writeXmp(document, resolveTitle(document, null), documentLanguage(document), profile); + } + + private String resolveTitle(PDDocument document, String preferred) { + if (preferred != null && !preferred.isBlank()) { + return preferred.strip(); + } + String existing = document.getDocumentInformation().getTitle(); + return existing != null && !existing.isBlank() ? existing.strip() : null; + } + + private static String documentLanguage(PDDocument document) { + return document.getDocumentCatalog().getLanguage(); + } + + /** + * Rewrites the XMP packet, preserving what was there. A malformed packet is replaced, since an + * unparseable one fails validation on its own. + */ + private void writeXmp(PDDocument document, String title, String language, PdfUaProfile profile) + throws IOException { + + PDDocumentCatalog catalog = document.getDocumentCatalog(); + XMPMetadata metadata = loadOrCreate(catalog); + + if (title != null) { + DublinCoreSchema dublinCore = metadata.getDublinCoreSchema(); + if (dublinCore == null) { + dublinCore = metadata.createAndAddDublinCoreSchema(); + } + dublinCore.setTitle(title); + if (language != null + && !language.isBlank() + && (dublinCore.getLanguages() == null + || !dublinCore.getLanguages().contains(language))) { + dublinCore.addLanguage(language); + } + } + + if (profile != null) { + // Re-converting an already-declared file must not leave two pdfuaid schemas. + XMPSchema stale = metadata.getSchema(PdfUaIdentificationSchema.NAMESPACE); + if (stale != null) { + metadata.removeSchema(stale); + } + PdfUaIdentificationSchema identification = new PdfUaIdentificationSchema(metadata); + identification.setPart(profile.part()); + if (profile.revision() > 0) { + identification.setRevision(profile.revision()); + } + metadata.addSchema(identification); + } + + serialiseInto(document, metadata); + } + + private static void serialiseInto(PDDocument document, XMPMetadata metadata) + throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + new XmpSerializer().serialize(metadata, out, true); + } catch (javax.xml.transform.TransformerException e) { + throw new IOException("Could not serialise XMP metadata", e); + } + PDMetadata pdMetadata = new PDMetadata(document); + pdMetadata.importXMPMetadata(out.toByteArray()); + document.getDocumentCatalog().setMetadata(pdMetadata); + } + + private XMPMetadata loadOrCreate(PDDocumentCatalog catalog) { + PDMetadata existing = catalog.getMetadata(); + if (existing != null) { + try { + DomXmpParser parser = new DomXmpParser(); + // Strict parsing rejects pdfuaid, silently discarding a packet we just wrote. + parser.setStrictParsing(false); + return parser.parse(new ByteArrayInputStream(existing.toByteArray())); + } catch (Exception e) { + log.debug("Replacing unparseable XMP packet: {}", e.getMessage()); + } + } + return XMPMetadata.createXMPMetadata(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaProfile.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaProfile.java new file mode 100644 index 0000000000..9523f5c790 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaProfile.java @@ -0,0 +1,47 @@ +package stirling.software.proprietary.pdf.ua; + +/** The PDF/UA conformance level a conversion targets. */ +public enum PdfUaProfile { + /** ISO 14289-1, layered on PDF 1.7. */ + UA1(1, 1.7f, 0), + /** ISO 14289-2: needs PDF 2.0, namespaced structure types and a revision year. */ + UA2(2, 2.0f, 2024); + + private final int part; + private final float pdfVersion; + private final int revision; + + PdfUaProfile(int part, float pdfVersion, int revision) { + this.part = part; + this.pdfVersion = pdfVersion; + this.revision = revision; + } + + public int part() { + return part; + } + + public float pdfVersion() { + return pdfVersion; + } + + /** The {@code pdfuaid:rev} year, or 0 when the profile does not use one. */ + public int revision() { + return revision; + } + + public String displayName() { + return "PDF/UA-" + part; + } + + public static PdfUaProfile fromRequest(String value) { + if (value == null || value.isBlank()) { + return UA1; + } + String normalised = value.trim().toLowerCase().replace("/", "").replace("-", ""); + return switch (normalised) { + case "ua2", "pdfua2", "2" -> UA2; + default -> UA1; + }; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaTagger.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaTagger.java new file mode 100644 index 0000000000..7f5dcc3739 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaTagger.java @@ -0,0 +1,303 @@ +package stirling.software.proprietary.pdf.ua; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot; + +import lombok.extern.slf4j.Slf4j; + +/** + * Tags an untagged PDF and applies the document-level PDF/UA requirements. Content must be marked + * before the tree can reference it, and conformance is declared elsewhere, only after validation. + */ +@Slf4j +public class PdfUaTagger { + + private final TaggedContentExtractor extractor = new TaggedContentExtractor(); + private final LayoutAnalyzer analyzer = new LayoutAnalyzer(); + private final MarkedContentInjector injector = new MarkedContentInjector(); + private final PdfUaMetadataWriter metadataWriter = new PdfUaMetadataWriter(); + + public TaggingResult tag(PDDocument document, TaggingOptions options) throws IOException { + boolean alreadyTagged = hasUsableStructureTree(document); + boolean rebuild = + switch (options.getExistingTags()) { + case KEEP -> false; + case REBUILD -> true; + case AUTO -> !alreadyTagged; + }; + + List languageWarnings = new ArrayList<>(); + String language = resolveLanguage(document, options, languageWarnings); + + if (!rebuild) { + log.info("Keeping existing structure tree; applying document requirements only"); + DocumentStructure kept = new DocumentStructure(); + languageWarnings.forEach(kept::warn); + metadataWriter + .applyDocumentRequirements( + document, + options.getTitle(), + language, + options.getProfile(), + options.isPreservePdfVersion()) + .forEach(kept::warn); + return new TaggingResult(kept, false); + } + + // Types the old tree carried, so a rebuild that cannot reproduce them can say so. Font + // embedding may already have deleted the tree, so fall back to what the source had. + Set discardedTypes = + alreadyTagged + ? structureTypes(document) + : options.getSourceFacts().structureTypes(); + + if (alreadyTagged) { + stripStructure(document); + } + + List pages = extractor.extract(document); + DocumentStructure structure = analyzer.analyse(pages); + structure.setLanguage(language); + languageWarnings.forEach(structure::warn); + applyFigurePolicy(structure, options); + + if (structure.isEmpty()) { + structure.warn( + "No taggable content was found; the document may be a scan with no text layer."); + } + + injectMarkedContent(document, structure, pages); + new StructTreeWriter().write(document, structure, options.getProfile()); + // Losing the tree to the embedder is a different problem from a requested rebuild, and + // the advice that helps differs too, so tell them apart. + boolean lostToEmbedder = !alreadyTagged && options.getSourceFacts().hasUsableTree(); + warnAboutFlattenedStructure( + discardedTypes, structureTypes(document), structure, lostToEmbedder); + + String title = resolveTitle(options, structure); + if (title == null) { + structure.warn( + "No document title could be derived. PDF/UA requires one, so supply a title."); + } + metadataWriter + .applyDocumentRequirements( + document, + title, + language, + options.getProfile(), + options.isPreservePdfVersion()) + .forEach(structure::warn); + + return new TaggingResult(structure, true); + } + + /** + * Keeps the language the document already declares. Overwriting it relabels, say, a French file + * as English, and no validator can catch that. + */ + private static String resolveLanguage( + PDDocument document, TaggingOptions options, List warnings) { + String existing = document.getDocumentCatalog().getLanguage(); + if (existing == null || existing.isBlank()) { + // Font embedding discards /Lang, so without this a rewritten French document would + // silently take the caller's default language. + existing = options.getSourceFacts().language(); + } + String requested = options.getLanguage(); + if (existing == null || existing.isBlank() || options.isOverrideLanguage()) { + return requested; + } + if (requested != null && !requested.isBlank() && !requested.equalsIgnoreCase(existing)) { + warnings.add( + "The document already declares its language as '" + + existing + + "', so the requested '" + + requested + + "' was ignored. Ask to override the language to change it."); + } + return existing; + } + + /** Explicit title first, then the first heading, then the caller's fallback. */ + private static String resolveTitle(TaggingOptions options, DocumentStructure structure) { + for (String candidate : + new String[] { + options.getTitle(), structure.getTitle(), options.getFallbackTitle() + }) { + if (candidate != null && !candidate.isBlank()) { + return candidate.strip(); + } + } + return null; + } + + /** Writes the conformance declaration. Separate from tagging so validation can gate it. */ + public void declareConformance(PDDocument document, PdfUaProfile profile) throws IOException { + metadataWriter.declareConformance(document, profile); + } + + /** Withdraws the conformance claim, for a document that turned out not to validate. */ + public void withdrawConformance(PDDocument document) throws IOException { + metadataWriter.removeConformanceDeclaration(document); + } + + /** Wraps content page by page; marked content ids restart on each page. */ + private void injectMarkedContent( + PDDocument document, DocumentStructure structure, List pages) + throws IOException { + Map markableCounts = new LinkedHashMap<>(); + pages.forEach(page -> markableCounts.put(page.pageIndex(), page.markableCount())); + Map> byPage = new LinkedHashMap<>(); + for (StructBlock block : structure.getBlocks()) { + byPage.computeIfAbsent(block.getPageIndex(), k -> new ArrayList<>()).add(block); + } + for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) { + List blocks = byPage.getOrDefault(pageIndex, List.of()); + // Nothing to wrap, and rewriting costs a parse and recompress for an identical stream. + if (blocks.isEmpty() && markableCounts.getOrDefault(pageIndex, 0) == 0) { + continue; + } + injector.inject(document, document.getPage(pageIndex), blocks, 0, true); + } + } + + /** Applies alt text supplied by the caller, or demotes images to artifacts on request. */ + private static void applyFigurePolicy(DocumentStructure structure, TaggingOptions options) { + int[] suppressed = {0}; + structure.visit( + block -> { + if (block.getType() != StructType.FIGURE) { + return; + } + if (options.getFigurePolicy() == TaggingOptions.FigurePolicy.MARK_DECORATIVE) { + block.setType(StructType.ARTIFACT); + block.setArtifactType(ArtifactType.LAYOUT); + suppressed[0]++; + return; + } + int ordinal = + block.getRanges().isEmpty() ? -1 : block.getRanges().get(0).start(); + String alt = options.altTextFor(block.getPageIndex(), ordinal); + if (alt != null && !alt.isBlank()) { + block.setAlt(alt); + } + }); + // Marking images decorative validates by hiding content, so never report it as clean. + if (suppressed[0] > 0) { + structure.warn( + suppressed[0] + + " image(s) were marked as decoration and are now hidden from" + + " assistive technology. Confirm none of them carried meaning."); + } + int missing = structure.figuresWithoutAlt().size(); + if (missing > 0) { + structure.warn( + missing + + " figure(s) have no alternative description. PDF/UA requires one for" + + " every image that carries meaning."); + } + } + + /** + * A tree is only worth keeping when wired up: kids, a parent tree, and a marked catalog. + * Keeping one that fails any of those leaves the document permanently unfixable. + */ + public static boolean hasUsableStructureTree(PDDocument document) { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + PDStructureTreeRoot root = catalog.getStructureTreeRoot(); + if (root == null) { + return false; + } + try { + boolean hasKids = root.getKids() != null && !root.getKids().isEmpty(); + boolean hasParentTree = root.getParentTree() != null; + boolean marked = catalog.getMarkInfo() != null && catalog.getMarkInfo().isMarked(); + return hasKids && hasParentTree && marked; + } catch (RuntimeException e) { + log.debug("Unreadable structure tree, treating as absent: {}", e.getMessage()); + return false; + } + } + + /** + * A rebuild derives structure from layout, so semantics the old tree carried can vanish - a + * table becomes loose paragraphs. Validators cannot see that loss, so it has to be reported. + */ + private static void warnAboutFlattenedStructure( + Set before, + Set after, + DocumentStructure structure, + boolean lostToEmbedder) { + List lost = + MEANINGFUL_TYPES.stream() + .filter(type -> before.contains(type) && !after.contains(type)) + .toList(); + if (lost.isEmpty()) { + return; + } + // Keeping the tags cannot help once the embedder has deleted them, so do not suggest it. + String remedy = + lostToEmbedder + ? " Embedding the missing fonts rewrote the document and deleted its" + + " original tags. Turn off font embedding to keep them." + : " Keep the existing tags instead to preserve it."; + structure.warn( + "Rebuilding the tags could not reproduce " + + String.join(", ", lost) + + " structure, so that content is now plain paragraphs." + + remedy); + } + + /** Structure whose loss changes what a screen reader conveys, not just how it is nested. */ + private static final List MEANINGFUL_TYPES = + List.of("Table", "TH", "Formula", "L", "LI", "TOC", "Note"); + + private static Set structureTypes(PDDocument document) { + Set types = new HashSet<>(); + try { + PDStructureTreeRoot root = document.getDocumentCatalog().getStructureTreeRoot(); + if (root != null) { + collectTypes(root.getKids(), types, 0); + } + } catch (RuntimeException e) { + log.debug("Could not read structure types: {}", e.getMessage()); + } + return types; + } + + private static void collectTypes(Object node, Set types, int depth) { + // Structure trees can be deep or, in damaged files, cyclic; cap rather than overflow. + if (node == null || depth > 64) { + return; + } + if (node instanceof List list) { + list.forEach(child -> collectTypes(child, types, depth + 1)); + } else if (node instanceof PDStructureElement element) { + types.add(element.getStructureType()); + collectTypes(element.getKids(), types, depth + 1); + } + } + + private static void stripStructure(PDDocument document) { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + catalog.getCOSObject().removeItem(COSName.getPDFName("StructTreeRoot")); + catalog.getCOSObject().removeItem(COSName.getPDFName("MarkInfo")); + document.getPages() + .forEach( + page -> + page.getCOSObject() + .removeItem(COSName.getPDFName("StructParents"))); + log.info("Removed existing structure tree before rebuilding"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/SourceFacts.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/SourceFacts.java new file mode 100644 index 0000000000..4f058d6c48 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/SourceFacts.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.HashSet; +import java.util.Set; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot; + +import lombok.extern.slf4j.Slf4j; + +/** + * What the document said about itself before anything rewrote it. Font embedding shells out to + * Ghostscript, which returns a file with no structure tree, no {@code /Lang} and no XMP, so a + * tagger reading the rewritten document sees an untagged, language-less file and cannot tell that + * anything was lost. These facts are captured from the original and carried past that stage. + * + * @param language the catalog {@code /Lang} the author declared, or null + * @param structureTypes every structure element type the original tree contained + * @param hasUsableTree whether the original had a structure tree worth preserving + */ +@Slf4j +public record SourceFacts(String language, Set structureTypes, boolean hasUsableTree) { + + private static final int MAX_DEPTH = 64; + + /** Facts for a document nothing has rewritten, used when font embedding did not run. */ + public static final SourceFacts NONE = new SourceFacts(null, Set.of(), false); + + public static SourceFacts of(PDDocument document) { + String language = null; + Set types = new HashSet<>(); + boolean usable = false; + try { + language = document.getDocumentCatalog().getLanguage(); + usable = PdfUaTagger.hasUsableStructureTree(document); + PDStructureTreeRoot root = document.getDocumentCatalog().getStructureTreeRoot(); + if (root != null) { + collect(root.getKids(), types, 0); + } + } catch (RuntimeException e) { + log.debug("Could not read source facts: {}", e.getMessage()); + } + return new SourceFacts(language, Set.copyOf(types), usable); + } + + private static void collect(Object node, Set types, int depth) { + // Damaged files can present a cyclic tree; cap rather than overflow the stack. + if (node == null || depth > MAX_DEPTH) { + return; + } + if (node instanceof java.util.List list) { + list.forEach(child -> collect(child, types, depth + 1)); + } else if (node instanceof PDStructureElement element) { + types.add(element.getStructureType()); + collect(element.getKids(), types, depth + 1); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructBlock.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructBlock.java new file mode 100644 index 0000000000..d2f3e452b1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructBlock.java @@ -0,0 +1,134 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import lombok.Getter; +import lombok.Setter; + +/** + * One node of the derived logical structure: either page content (ranges of markable operator + * ordinals) or child blocks. Containers with no content are pruned before serialisation. + */ +@Getter +@Setter +public class StructBlock { + + /** A contiguous, inclusive run of markable operator ordinals within one page stream. */ + public record OrdinalRange(int start, int end) { + public boolean contains(int ordinal) { + return ordinal >= start && ordinal <= end; + } + + public int size() { + return end - start + 1; + } + } + + private StructType type; + private ArtifactType artifactType; + private int pageIndex; + private BBox bbox = BBox.EMPTY; + private String text = ""; + + private final List ranges = new ArrayList<>(); + private final List children = new ArrayList<>(); + + /** {@code /Alt} - required on Figure and Formula for PDF/UA. */ + private String alt; + + /** {@code /ActualText} - replacement text for content whose glyphs do not spell the word. */ + private String actualText; + + /** {@code /Lang} - set only where it differs from the document default. */ + private String lang; + + /** {@code /Scope} on a TH: Row, Column or Both. */ + private String scope; + + /** {@code /ListNumbering} on an L. */ + private String listNumbering; + + /** Unique {@code /ID}, required on Note and FENote elements. */ + private String id; + + /** + * Marked content ids assigned during injection; one block yields several when split, since a + * sequence must nest inside BT/ET and q/Q rather than straddle them. + */ + private final List mcids = new ArrayList<>(); + + /** True when the source content was already inside a marked-content sequence. */ + private boolean preMarked; + + public StructBlock(StructType type, int pageIndex) { + this.type = type; + this.pageIndex = pageIndex; + } + + public static StructBlock artifact(ArtifactType artifactType, int pageIndex) { + StructBlock block = new StructBlock(StructType.ARTIFACT, pageIndex); + block.artifactType = artifactType; + return block; + } + + public StructBlock addChild(StructBlock child) { + children.add(child); + return this; + } + + public StructBlock addRange(int start, int end) { + ranges.add(new OrdinalRange(start, end)); + return this; + } + + public boolean isArtifact() { + return type == StructType.ARTIFACT; + } + + /** Depth-first walk over this block and all descendants. */ + public void visit(Consumer visitor) { + visitor.accept(this); + for (StructBlock child : children) { + child.visit(visitor); + } + } + + /** Total number of ordinals owned by this block and its descendants. */ + public int contentCount() { + int total = ranges.stream().mapToInt(OrdinalRange::size).sum(); + for (StructBlock child : children) { + total += child.contentCount(); + } + return total; + } + + /** Concatenated text of this block and its descendants, in tree order. */ + public String collectText() { + StringBuilder sb = new StringBuilder(); + visit( + block -> { + if (!block.text.isBlank()) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(block.text.strip()); + } + }); + return sb.toString(); + } + + @Override + public String toString() { + return type.tag() + + (artifactType != null ? "[" + artifactType.subtype() + "]" : "") + + "(p" + + pageIndex + + ", " + + ranges.size() + + " ranges, " + + children.size() + + " kids)"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructTreeWriter.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructTreeWriter.java new file mode 100644 index 0000000000..f6b5d89b4c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructTreeWriter.java @@ -0,0 +1,295 @@ +package stirling.software.proprietary.pdf.ua; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSInteger; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDNumberTreeNode; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkInfo; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDObjectReference; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot; +import org.apache.pdfbox.pdmodel.documentinterchange.taggedpdf.PDListAttributeObject; +import org.apache.pdfbox.pdmodel.documentinterchange.taggedpdf.PDTableAttributeObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; + +import lombok.extern.slf4j.Slf4j; + +/** + * Serialises a {@link DocumentStructure} into a PDF structure tree. Must run after {@link + * MarkedContentInjector}, which assigns the marked content ids this writer references. + */ +@Slf4j +public class StructTreeWriter { + + private static final COSName STRUCT_PARENT = COSName.getPDFName("StructParent"); + private static final COSName NUMS = COSName.getPDFName("Nums"); + private static final String PDF2_STANDARD_NAMESPACE = "http://iso.org/pdf2/ssn"; + + /** Per-page marked content id to owning element, built while walking the tree. */ + private final Map> mcidOwners = new LinkedHashMap<>(); + + private COSDictionary standardNamespace; + private int nextParentKey; + + public void write(PDDocument document, DocumentStructure structure, PdfUaProfile profile) + throws IOException { + PDStructureTreeRoot root = new PDStructureTreeRoot(); + PDStructureElement documentElement = + new PDStructureElement(StructType.DOCUMENT.tag(), root); + if (structure.getLanguage() != null) { + documentElement.setLanguage(structure.getLanguage()); + } + if (profile == PdfUaProfile.UA2) { + applyNamespace(documentElement, document); + } + + for (StructBlock block : structure.getBlocks()) { + if (block.isArtifact()) { + continue; + } + PDStructureElement child = buildElement(document, block, documentElement, profile); + if (child != null) { + documentElement.appendKid(child); + } + } + + root.appendKid(documentElement); + buildParentTree(document, root); + registerNamespaces(root); + + PDMarkInfo markInfo = new PDMarkInfo(); + markInfo.setMarked(true); + document.getDocumentCatalog().setMarkInfo(markInfo); + document.getDocumentCatalog().setStructureTreeRoot(root); + } + + /** Recursively builds an element, returning null when the block carries no content at all. */ + private PDStructureElement buildElement( + PDDocument document, + StructBlock block, + PDStructureElement parent, + PdfUaProfile profile) { + + // Prune on assigned MCIDs, not claimed ranges: form-XObject lines all resolve to one Do, + // and emitting the losers would announce empty paragraphs to a screen reader. + if (!carriesContent(block)) { + return null; + } + StructType type = effectiveType(block, profile); + PDStructureElement element = new PDStructureElement(type.tag(), parent); + PDPage page = document.getPage(block.getPageIndex()); + element.setPage(page); + + if (profile == PdfUaProfile.UA2) { + applyNamespace(element, document); + } + applyAttributes(block, element); + + for (int mcid : block.getMcids()) { + element.appendKid(mcid); + mcidOwners + .computeIfAbsent(block.getPageIndex(), k -> new LinkedHashMap<>()) + .put(mcid, element); + } + + for (StructBlock child : block.getChildren()) { + PDStructureElement childElement = buildElement(document, child, element, profile); + if (childElement != null) { + element.appendKid(childElement); + } + } + return element; + } + + /** True when this block, or something beneath it, was actually given marked content. */ + private static boolean carriesContent(StructBlock block) { + if (!block.getMcids().isEmpty()) { + return true; + } + return block.getChildren().stream().anyMatch(StructTreeWriter::carriesContent); + } + + /** PDF/UA-2 replaces Note with FENote for footnotes. */ + private static StructType effectiveType(StructBlock block, PdfUaProfile profile) { + if (profile == PdfUaProfile.UA2 && block.getType() == StructType.NOTE) { + return StructType.FENOTE; + } + return block.getType(); + } + + private static void applyAttributes(StructBlock block, PDStructureElement element) { + if (block.getAlt() != null && !block.getAlt().isBlank()) { + element.setAlternateDescription(block.getAlt()); + } + if (block.getActualText() != null && !block.getActualText().isBlank()) { + element.setActualText(block.getActualText()); + } + if (block.getLang() != null && !block.getLang().isBlank()) { + element.setLanguage(block.getLang()); + } + if (block.getId() != null && !block.getId().isBlank()) { + element.setElementIdentifier(block.getId()); + } + if (block.getScope() != null) { + PDTableAttributeObject table = new PDTableAttributeObject(); + table.setScope(block.getScope()); + element.addAttribute(table); + } + if (block.getListNumbering() != null) { + PDListAttributeObject list = new PDListAttributeObject(); + list.setListNumbering(block.getListNumbering()); + element.addAttribute(list); + } + } + + /** PDF/UA-2 requires every element to declare the standard structure namespace. */ + private void applyNamespace(PDStructureElement element, PDDocument document) { + element.getCOSObject().setItem(COSName.getPDFName("NS"), standardNamespace()); + } + + /** The PDF 2.0 standard structure namespace, created once per document. */ + private COSDictionary standardNamespace() { + if (standardNamespace == null) { + standardNamespace = new COSDictionary(); + standardNamespace.setName(COSName.TYPE, "Namespace"); + standardNamespace.setString(COSName.getPDFName("NS"), PDF2_STANDARD_NAMESPACE); + } + return standardNamespace; + } + + private void registerNamespaces(PDStructureTreeRoot root) { + if (standardNamespace == null) { + return; + } + COSArray namespaces = new COSArray(); + namespaces.add(standardNamespace); + root.getCOSObject().setItem(COSName.getPDFName("Namespaces"), namespaces); + } + + /** + * Builds {@code /ParentTree}: per page, an array indexed by marked content id keyed on {@code + * /StructParents}, plus one entry per annotation keyed on {@code /StructParent}. + */ + private void buildParentTree(PDDocument document, PDStructureTreeRoot root) { + COSArray nums = new COSArray(); + nextParentKey = 0; + + for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) { + Map owners = mcidOwners.get(pageIndex); + if (owners == null || owners.isEmpty()) { + continue; + } + PDPage page = document.getPage(pageIndex); + int key = nextParentKey++; + page.setStructParents(key); + + int maxMcid = owners.keySet().stream().mapToInt(Integer::intValue).max().orElse(-1); + COSArray entries = new COSArray(); + for (int mcid = 0; mcid <= maxMcid; mcid++) { + PDStructureElement owner = owners.get(mcid); + entries.add( + owner != null ? owner.getCOSObject() : org.apache.pdfbox.cos.COSNull.NULL); + } + nums.add(COSInteger.get(key)); + nums.add(entries); + } + + List annotationEntries = tagAnnotations(document, root); + for (int i = 0; i + 1 < annotationEntries.size(); i += 2) { + nums.add(annotationEntries.get(i)); + nums.add(annotationEntries.get(i + 1)); + } + + COSDictionary parentTreeDict = new COSDictionary(); + parentTreeDict.setItem(NUMS, nums); + root.setParentTree(new PDNumberTreeNode(parentTreeDict, PDStructureElement.class)); + root.setParentTreeNextKey(nextParentKey); + } + + /** + * Clause 7.18: every visible annotation needs a structure element so it is reachable from the + * tree. Links become Link elements, anything else an Annot. + */ + private List tagAnnotations(PDDocument document, PDStructureTreeRoot root) { + List entries = new ArrayList<>(); + PDStructureElement documentElement = firstDocumentElement(root); + if (documentElement == null) { + return entries; + } + for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) { + PDPage page = document.getPage(pageIndex); + List annotations; + try { + annotations = page.getAnnotations(); + } catch (IOException e) { + log.debug("Could not read annotations on page {}: {}", pageIndex, e.getMessage()); + continue; + } + for (PDAnnotation annotation : annotations) { + if (annotation == null + || annotation.isHidden() + || annotation.isNoView() + || "Popup".equals(annotation.getSubtype())) { + continue; + } + PDStructureElement element = + new PDStructureElement(annotationType(annotation), documentElement); + element.setPage(page); + + PDObjectReference reference = new PDObjectReference(); + reference.setReferencedObject(annotation); + element.appendKid(reference); + documentElement.appendKid(element); + + int key = nextParentKey++; + annotation.getCOSObject().setInt(STRUCT_PARENT, key); + entries.add(COSInteger.get(key)); + entries.add(element.getCOSObject()); + + if (annotation.getContents() == null || annotation.getContents().isBlank()) { + annotation.setContents(defaultContents(annotation)); + } + } + } + return entries; + } + + /** Clause 7.18.4: widgets need a Form element, links a Link element, everything else Annot. */ + private static String annotationType(PDAnnotation annotation) { + if (annotation instanceof PDAnnotationWidget) { + return StructType.FORM.tag(); + } + if (annotation instanceof PDAnnotationLink) { + return StructType.LINK.tag(); + } + return "Annot"; + } + + private static String defaultContents(PDAnnotation annotation) { + if (annotation instanceof PDAnnotationLink link && link.getAction() != null) { + return "Link"; + } + return annotation.getSubtype() == null ? "Annotation" : annotation.getSubtype(); + } + + private static PDStructureElement firstDocumentElement(PDStructureTreeRoot root) { + for (Object kid : root.getKids()) { + if (kid instanceof PDStructureElement element) { + return element; + } + } + return null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructType.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructType.java new file mode 100644 index 0000000000..b4634a58c6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructType.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.pdf.ua; + +/** + * PDF standard structure types emitted by the tagger (ISO 32000-1 14.8.4), limited to the PDF/UA + * subset. {@link #ARTIFACT} is not one: it marks content in the stream and stays out of the tree. + */ +public enum StructType { + DOCUMENT("Document"), + PART("Part"), + SECT("Sect"), + H1("H1"), + H2("H2"), + H3("H3"), + H4("H4"), + H5("H5"), + H6("H6"), + P("P"), + L("L"), + LI("LI"), + LBL("Lbl"), + LBODY("LBody"), + TABLE("Table"), + TR("TR"), + TH("TH"), + TD("TD"), + FIGURE("Figure"), + CAPTION("Caption"), + FORMULA("Formula"), + NOTE("Note"), + FENOTE("FENote"), + LINK("Link"), + /** Wraps a widget annotation; PDF/UA-1 clause 7.18.4 requires widgets to sit inside one. */ + FORM("Form"), + SPAN("Span"), + ARTIFACT("Artifact"); + + private final String tag; + + StructType(String tag) { + this.tag = tag; + } + + /** The name written into the PDF {@code /S} entry. */ + public String tag() { + return tag; + } + + public boolean isHeading() { + return this == H1 || this == H2 || this == H3 || this == H4 || this == H5 || this == H6; + } + + /** Heading level 1-6, or 0 when this is not a heading. */ + public int headingLevel() { + return isHeading() ? ordinal() - H1.ordinal() + 1 : 0; + } + + /** The heading type for a 1-based level, clamped to the H1-H6 range. */ + public static StructType heading(int level) { + int clamped = Math.max(1, Math.min(6, level)); + return values()[H1.ordinal() + clamped - 1]; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggedContentExtractor.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggedContentExtractor.java new file mode 100644 index 0000000000..01c86215d4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggedContentExtractor.java @@ -0,0 +1,630 @@ +package stirling.software.proprietary.pdf.ua; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFontDescriptor; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.apache.pdfbox.pdmodel.graphics.PDXObject; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.apache.pdfbox.pdmodel.graphics.form.PDTransparencyGroup; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.apache.pdfbox.util.Matrix; +import org.apache.pdfbox.util.Vector; + +import lombok.extern.slf4j.Slf4j; + +/** + * Extracts text lines and graphic ops from page streams, tagging each with its operator ordinal. + * Both passes count the same operators in the same order, so ordinals cross-reference. + */ +@Slf4j +public class TaggedContentExtractor { + + /** Glyph size below which a run is treated as noise rather than a line. */ + private static final float MIN_FONT_SIZE = 0.5f; + + public List extract(PDDocument document) throws IOException { + LineCollector collector = new LineCollector(); + collector.setSortByPosition(true); + collector.setStartPage(1); + collector.setEndPage(document.getNumberOfPages()); + collector.writeText(document, Writer.nullWriter()); + + List pages = new ArrayList<>(document.getNumberOfPages()); + for (int i = 0; i < document.getNumberOfPages(); i++) { + PDPage page = document.getPage(i); + List ops = collector.opsFor(i); + List lines = collector.linesFor(i); + boolean dropped = false; + if (ops.size() < maxOrdinal(lines) + 1) { + // Untrusted ordinals: drop the lines so the page is untaggable rather than + // mis-tagged, and flag it so the caller refuses to declare conformance. + log.warn( + "Ordinal mismatch on page {} (ops={}, text={}); skipping page", + i, + ops.size(), + maxOrdinal(lines) + 1); + dropped = !lines.isEmpty(); + lines = List.of(); + } + pages.add( + new PageContent( + i, + lines, + ops, + ops.size(), + collector.preMarkedOn(i), + collector.textSemanticsOn(i), + dropped, + normalisedBox(page))); + } + return pages; + } + + /** + * The page box in the space of extracted line coordinates: origin-zero, width and height + * swapped for 90/270 rotations, because the text engine reports in the rotated frame. + */ + static BBox normalisedBox(PDPage page) { + PDRectangle mediaBox = page.getMediaBox(); + boolean sideways = page.getRotation() % 180 != 0; + float width = sideways ? mediaBox.getHeight() : mediaBox.getWidth(); + float height = sideways ? mediaBox.getWidth() : mediaBox.getHeight(); + return new BBox(0, 0, width, height); + } + + /** Counts images with the token scan alone, skipping the expensive text pass. */ + public int countGraphics(PDDocument document) { + int total = 0; + for (int i = 0; i < document.getNumberOfPages(); i++) { + try { + PDResources resources = document.getPage(i).getResources(); + PDFStreamParser parser = new PDFStreamParser(document.getPage(i)); + List operands = new ArrayList<>(); + Object token; + while ((token = parser.parseNextToken()) != null) { + if (!(token instanceof Operator operator)) { + operands.add((COSBase) token); + continue; + } + if (isGraphicOperator(operator.getName(), operands, resources)) { + total++; + } + operands.clear(); + } + } catch (IOException e) { + log.debug("Could not scan page {} for graphics: {}", i, e.getMessage()); + } + } + return total; + } + + /** True for an inline image, or a Do that resolves to an image XObject. */ + private static boolean isGraphicOperator( + String name, List operands, PDResources resources) { + if ("BI".equals(name)) { + return true; + } + if (!"Do".equals(name) || resources == null || operands.size() != 1) { + return false; + } + if (!(operands.get(0) instanceof COSName resourceName)) { + return false; + } + try { + return resources.getXObject(resourceName) instanceof PDImageXObject; + } catch (IOException e) { + return false; + } + } + + private static int maxOrdinal(List lines) { + return lines.stream().mapToInt(TextLineInfo::endOrdinal).max().orElse(-1); + } + + static BBox toBBox(PDRectangle rect) { + return new BBox( + rect.getLowerLeftX(), + rect.getLowerLeftY(), + rect.getUpperRightX(), + rect.getUpperRightY()); + } + + // --- Operator classification ------------------------------------------- + + private static boolean isPathConstruction(String name) { + return switch (name) { + case "m", "l", "c", "v", "y", "re" -> true; + default -> false; + }; + } + + /** True when a sequence carries replacement or alternative text, which a rebuild would drop. */ + private static boolean carriesTextSemantics(List operands) { + for (COSBase operand : operands) { + if (operand instanceof COSDictionary dictionary + && (dictionary.containsKey(COSName.getPDFName("ActualText")) + || dictionary.containsKey(COSName.getPDFName("Alt")) + || dictionary.containsKey(COSName.E))) { + return true; + } + } + return false; + } + + /** + * Describes one markable operator, placed with the engine's own matrix rather than a + * hand-rolled q/Q/cm stack that would get nesting and form matrices wrong. + */ + private static MarkableOp classify( + String name, + List operands, + PDResources resources, + Matrix ctm, + BBox pathBox, + int ordinal) { + + if ("BI".equals(name)) { + return new MarkableOp(ordinal, MarkableOp.Kind.INLINE_IMAGE, unitSquare(ctm), null); + } + if (MarkableOp.isPathPainting(name)) { + return new MarkableOp(ordinal, MarkableOp.Kind.VECTOR, pathBox, null); + } + if (!"Do".equals(name)) { + return new MarkableOp(ordinal, MarkableOp.Kind.TEXT, BBox.EMPTY, null); + } + COSName resourceName = + operands.size() == 1 && operands.get(0) instanceof COSName n ? n : null; + if (resourceName == null || resources == null) { + return new MarkableOp(ordinal, MarkableOp.Kind.FORM, unitSquare(ctm), null); + } + try { + PDXObject xobject = resources.getXObject(resourceName); + if (xobject instanceof PDImageXObject) { + return new MarkableOp( + ordinal, MarkableOp.Kind.IMAGE, unitSquare(ctm), resourceName.getName()); + } + if (xobject instanceof PDFormXObject form) { + return new MarkableOp( + ordinal, MarkableOp.Kind.FORM, formBox(form, ctm), resourceName.getName()); + } + } catch (IOException e) { + log.debug("Could not resolve XObject {}: {}", resourceName.getName(), e.getMessage()); + } + return new MarkableOp( + ordinal, MarkableOp.Kind.FORM, unitSquare(ctm), resourceName.getName()); + } + + /** + * Extends the running path box with one path-construction operator's points; without it every + * vector had an empty box and charts and vector logos vanished from the structure tree. + */ + private static BBox extendPath(BBox current, String name, List operands, Matrix ctm) { + int pairs = + switch (name) { + case "m", "l" -> 1; + case "re" -> 2; + case "v", "y" -> 2; + case "c" -> 3; + default -> 0; + }; + if (pairs == 0 || operands.size() < pairs * 2) { + return current; + } + + // Deliberately allocation-free; the obvious version cost a third of the extraction budget. + float minX = current.isEmpty() ? Float.MAX_VALUE : current.x0(); + float minY = current.isEmpty() ? Float.MAX_VALUE : current.y0(); + float maxX = current.isEmpty() ? -Float.MAX_VALUE : current.x1(); + float maxY = current.isEmpty() ? -Float.MAX_VALUE : current.y1(); + + for (int pair = 0; pair < pairs; pair++) { + Float x = numberAt(operands, pair * 2); + Float y = numberAt(operands, pair * 2 + 1); + if (x == null || y == null) { + continue; + } + float px = x; + float py = y; + // "re" gives origin plus size, so the second pair is a corner offset from the first. + if ("re".equals(name) && pair == 1) { + Float ox = numberAt(operands, 0); + Float oy = numberAt(operands, 1); + if (ox == null || oy == null) { + continue; + } + px = ox + x; + py = oy + y; + } + float tx = ctm.getScaleX() * px + ctm.getShearX() * py + ctm.getTranslateX(); + float ty = ctm.getShearY() * px + ctm.getScaleY() * py + ctm.getTranslateY(); + minX = Math.min(minX, tx); + minY = Math.min(minY, ty); + maxX = Math.max(maxX, tx); + maxY = Math.max(maxY, ty); + } + return maxX < minX ? current : new BBox(minX, minY, maxX, maxY); + } + + private static Float numberAt(List operands, int index) { + return index < operands.size() + && operands.get(index) instanceof org.apache.pdfbox.cos.COSNumber number + ? number.floatValue() + : null; + } + + /** The unit square mapped through the CTM, which is how images are placed. */ + private static BBox unitSquare(Matrix ctm) { + return transformBox(new BBox(0, 0, 1, 1), ctm); + } + + private static BBox formBox(PDFormXObject form, Matrix ctm) { + PDRectangle box = form.getBBox(); + if (box == null) { + return unitSquare(ctm); + } + Matrix combined = form.getMatrix() != null ? form.getMatrix().multiply(ctm) : ctm; + return transformBox(toBBox(box), combined); + } + + private static BBox transformBox(BBox box, Matrix m) { + float[] xs = new float[4]; + float[] ys = new float[4]; + float[][] corners = { + {box.x0(), box.y0()}, {box.x1(), box.y0()}, + {box.x0(), box.y1()}, {box.x1(), box.y1()} + }; + for (int i = 0; i < 4; i++) { + Vector v = m.transform(new Vector(corners[i][0], corners[i][1])); + xs[i] = v.getX(); + ys[i] = v.getY(); + } + float minX = Math.min(Math.min(xs[0], xs[1]), Math.min(xs[2], xs[3])); + float maxX = Math.max(Math.max(xs[0], xs[1]), Math.max(xs[2], xs[3])); + float minY = Math.min(Math.min(ys[0], ys[1]), Math.min(ys[2], ys[3])); + float maxY = Math.max(Math.max(ys[0], ys[1]), Math.max(ys[2], ys[3])); + return new BBox(minX, minY, maxX, maxY); + } + + // --- Text pass --------------------------------------------------------- + + /** Marker recorded for each glyph so a finished line knows where it came from. */ + private record GlyphOrigin(int ordinal, boolean marked) {} + + private static final class LineCollector extends PDFTextStripper { + + private final Map> byPage = new HashMap<>(); + private final Map> opsByPage = new HashMap<>(); + private final Map preMarkedByPage = new HashMap<>(); + private final Map textSemanticsByPage = new HashMap<>(); + private final Map origins = new IdentityHashMap<>(); + private final List lineBuffer = new ArrayList<>(); + private final List lineWords = new ArrayList<>(); + private final StringBuilder lineText = new StringBuilder(); + + private int ordinal = -1; + private int markedDepth; + private int nestedDepth; + private BBox pathBox = BBox.EMPTY; + private int syntheticDepth; + private float pageHeight; + private int pageIndex; + + LineCollector() throws IOException { + super(); + } + + List linesFor(int index) { + return byPage.getOrDefault(index, List.of()); + } + + List opsFor(int index) { + return opsByPage.getOrDefault(index, List.of()); + } + + boolean preMarkedOn(int index) { + return preMarkedByPage.getOrDefault(index, false); + } + + boolean textSemanticsOn(int index) { + return textSemanticsByPage.getOrDefault(index, false); + } + + @Override + protected void startPage(PDPage page) throws IOException { + ordinal = -1; + markedDepth = 0; + nestedDepth = 0; + syntheticDepth = 0; + pathBox = BBox.EMPTY; + origins.clear(); + lineBuffer.clear(); + lineWords.clear(); + lineText.setLength(0); + // Dir-adjusted glyph coordinates live in the rotated frame, so the flip must too. + pageHeight = normalisedBox(page).height(); + pageIndex = getCurrentPageNo() - 1; + super.startPage(page); + } + + @Override + protected void endPage(PDPage page) throws IOException { + flushLine(); + super.endPage(page); + } + + /** + * Counts only operators physically present in the page's own stream: PDFBox re-enters here + * with synthetic calls for {@code '} and {@code "}, and descends into form XObjects. + */ + @Override + protected void processOperator(Operator operator, List operands) + throws IOException { + String name = operator.getName(); + if (nestedDepth == 0 && syntheticDepth == 0) { + if (isPathConstruction(name)) { + pathBox = + extendPath( + pathBox, + name, + operands, + getGraphicsState().getCurrentTransformationMatrix()); + } + if (MarkableOp.isMarkableOperator(name)) { + ordinal++; + // Classified here rather than in a second parse of the same stream: the engine + // already has the operands and the live transformation matrix. + opsByPage + .computeIfAbsent(pageIndex, k -> new ArrayList<>()) + .add( + classify( + name, + operands, + getResources(), + getGraphicsState().getCurrentTransformationMatrix(), + pathBox, + ordinal)); + if (MarkableOp.isPathPainting(name)) { + pathBox = BBox.EMPTY; + } + } else if ("n".equals(name)) { + pathBox = BBox.EMPTY; + } else if ("BDC".equals(name) || "BMC".equals(name)) { + markedDepth++; + preMarkedByPage.put(pageIndex, true); + if (carriesTextSemantics(operands)) { + textSemanticsByPage.put(pageIndex, true); + } + } else if ("EMC".equals(name) && markedDepth > 0) { + markedDepth--; + } + } + boolean synthesises = "'".equals(name) || "\"".equals(name); + if (synthesises) { + syntheticDepth++; + } + try { + super.processOperator(operator, operands); + } finally { + if (synthesises) { + syntheticDepth--; + } + } + } + + @Override + public void showForm(PDFormXObject form) throws IOException { + nestedDepth++; + try { + super.showForm(form); + } finally { + nestedDepth--; + } + } + + @Override + public void showTransparencyGroup(PDTransparencyGroup group) throws IOException { + nestedDepth++; + try { + super.showTransparencyGroup(group); + } finally { + nestedDepth--; + } + } + + @Override + protected void showType3Glyph( + Matrix textRenderingMatrix, + PDType3Font font, + int code, + org.apache.pdfbox.util.Vector displacement) + throws IOException { + nestedDepth++; + try { + super.showType3Glyph(textRenderingMatrix, font, code, displacement); + } finally { + nestedDepth--; + } + } + + @Override + protected void processChildStream( + org.apache.pdfbox.contentstream.PDContentStream contentStream, PDPage page) + throws IOException { + nestedDepth++; + try { + super.processChildStream(contentStream, page); + } finally { + nestedDepth--; + } + } + + @Override + protected void processTextPosition(TextPosition text) { + origins.put(text, new GlyphOrigin(ordinal, markedDepth > 0)); + super.processTextPosition(text); + } + + @Override + protected void writeString(String text, List positions) { + lineText.append(text); + lineBuffer.addAll(positions); + WordInfo word = buildWord(text, positions); + if (word != null) { + lineWords.add(word); + } + } + + private WordInfo buildWord(String text, List positions) { + if (text == null || text.isBlank() || positions.isEmpty()) { + return null; + } + Bounds bounds = new Bounds(); + for (TextPosition tp : positions) { + bounds.accept(tp, pageHeight, origins.get(tp)); + } + if (bounds.end < 0) { + return null; + } + return new WordInfo( + text, + bounds.box(), + bounds.start, + bounds.end, + bounds.dominantSize(), + bounds.bold); + } + + @Override + protected void writeWordSeparator() { + lineText.append(' '); + } + + @Override + protected void writeLineSeparator() { + flushLine(); + } + + @Override + protected void writeParagraphSeparator() { + flushLine(); + } + + private void flushLine() { + if (lineBuffer.isEmpty()) { + lineText.setLength(0); + lineWords.clear(); + return; + } + TextLineInfo line = buildLine(); + lineBuffer.clear(); + lineWords.clear(); + lineText.setLength(0); + if (line != null) { + byPage.computeIfAbsent(pageIndex, k -> new ArrayList<>()).add(line); + } + } + + private TextLineInfo buildLine() { + String text = lineText.toString(); + if (text.isBlank()) { + return null; + } + Bounds bounds = new Bounds(); + for (TextPosition tp : lineBuffer) { + bounds.accept(tp, pageHeight, origins.get(tp)); + } + if (bounds.end < 0) { + return null; + } + return new TextLineInfo( + pageIndex, + text, + bounds.box(), + bounds.dominantSize(), + bounds.bold, + bounds.start, + bounds.end, + bounds.marked, + List.copyOf(lineWords)); + } + } + + /** Accumulates glyph geometry, ordinals and font signals for a word or a line. */ + private static final class Bounds { + private float minX = Float.MAX_VALUE; + private float maxX = -Float.MAX_VALUE; + private float minY = Float.MAX_VALUE; + private float maxY = -Float.MAX_VALUE; + private int start = Integer.MAX_VALUE; + private int end = -1; + private boolean marked; + private boolean bold; + private final Map sizeCounts = new HashMap<>(); + + void accept(TextPosition tp, float pageHeight, GlyphOrigin origin) { + float top = pageHeight - tp.getYDirAdj(); + float bottom = top - Math.max(tp.getHeightDir(), 0); + minX = Math.min(minX, tp.getXDirAdj()); + maxX = Math.max(maxX, tp.getXDirAdj() + tp.getWidthDirAdj()); + minY = Math.min(minY, bottom); + maxY = Math.max(maxY, top); + + if (origin != null) { + start = Math.min(start, origin.ordinal()); + end = Math.max(end, origin.ordinal()); + marked |= origin.marked(); + } + float size = tp.getFontSizeInPt(); + if (size > MIN_FONT_SIZE) { + sizeCounts.merge(round(size), 1, Integer::sum); + } + bold |= isBold(tp); + } + + BBox box() { + return new BBox(minX, minY, maxX, maxY); + } + + float dominantSize() { + return sizeCounts.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(0f); + } + + private static float round(float value) { + return Math.round(value * 10f) / 10f; + } + + private static boolean isBold(TextPosition tp) { + if (tp.getFont() == null) { + return false; + } + String name = tp.getFont().getName(); + if (name != null && name.toLowerCase().contains("bold")) { + return true; + } + PDFontDescriptor descriptor = tp.getFont().getFontDescriptor(); + return descriptor != null + && (descriptor.getFontWeight() >= 600 || descriptor.isForceBold()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingOptions.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingOptions.java new file mode 100644 index 0000000000..bba15ed09f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingOptions.java @@ -0,0 +1,63 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.Map; + +import lombok.Builder; +import lombok.Getter; + +/** Inputs that change how a document is tagged. */ +@Getter +@Builder(toBuilder = true) +public class TaggingOptions { + + /** What to do when the source already has a structure tree. */ + public enum ExistingTags { + /** Leave the tree alone and fix only document-level requirements. */ + KEEP, + /** Discard the tree and derive a new one. */ + REBUILD, + /** Keep a usable tree, rebuild an empty or trivially broken one. */ + AUTO + } + + /** How images with no alternative description are handled. */ + public enum FigurePolicy { + /** Leave undescribed so validation fails honestly; a faked {@code /Alt} helps nobody. */ + REQUIRE_ALT, + /** Treat every image as decoration and mark it as an artifact. */ + MARK_DECORATIVE + } + + @Builder.Default private PdfUaProfile profile = PdfUaProfile.UA1; + + /** BCP-47 language tag for the document, for example {@code en-GB}. */ + private String language; + + /** Replace a language the document already declares. Off, so a French file stays French. */ + @Builder.Default private boolean overrideLanguage = false; + + private String title; + + /** Last resort when no title is given and none can be derived; pass the uploaded filename. */ + private String fallbackTitle; + + /** Embed any font the document references but does not carry, which clause 7.21 requires. */ + @Builder.Default private boolean embedFonts = true; + + /** Leave the PDF version alone; raising it would break PDF/A-1, defined on PDF 1.4. */ + @Builder.Default private boolean preservePdfVersion = false; + + @Builder.Default private ExistingTags existingTags = ExistingTags.AUTO; + + @Builder.Default private FigurePolicy figurePolicy = FigurePolicy.REQUIRE_ALT; + + /** Alternative descriptions supplied by the caller, keyed by "pageIndex:ordinal". */ + @Builder.Default private Map altTextByFigure = Map.of(); + + /** What the document said before font embedding rewrote it; see {@link SourceFacts}. */ + @Builder.Default private SourceFacts sourceFacts = SourceFacts.NONE; + + public String altTextFor(int pageIndex, int ordinal) { + return altTextByFigure.get(pageIndex + ":" + ordinal); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingResult.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingResult.java new file mode 100644 index 0000000000..d54fad5de8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingResult.java @@ -0,0 +1,42 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; + +/** What a tagging run produced, for the conversion report. */ +@Getter +public class TaggingResult { + + private final List warnings = new ArrayList<>(); + private final DocumentStructure structure; + private final boolean rebuilt; + private final int taggedElements; + private final int artifacts; + private final int figuresNeedingAlt; + + /** True when text was hidden as artifacts; the caller must not declare conformance. */ + private final boolean contentSuppressed; + + public TaggingResult(DocumentStructure structure, boolean rebuilt) { + this.structure = structure; + this.rebuilt = rebuilt; + this.warnings.addAll(structure.getWarnings()); + int[] elements = {0}; + structure.visit( + block -> { + if (!block.isArtifact()) { + elements[0]++; + } + }); + this.taggedElements = elements[0]; + this.artifacts = structure.artifactCount(); + this.figuresNeedingAlt = structure.figuresWithoutAlt().size(); + this.contentSuppressed = structure.isTextSuppressed(); + } + + public boolean needsHumanReview() { + return figuresNeedingAlt > 0 || !warnings.isEmpty(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TextLineInfo.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TextLineInfo.java new file mode 100644 index 0000000000..67f37d1c8d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TextLineInfo.java @@ -0,0 +1,42 @@ +package stirling.software.proprietary.pdf.ua; + +import java.util.List; + +/** + * A run of text on one baseline, with the operator ordinals that produced it. {@code preMarked} + * means the source stream already wrapped this text in BDC/EMC. + */ +public record TextLineInfo( + int pageIndex, + String text, + BBox bbox, + float dominantFontSize, + boolean bold, + int startOrdinal, + int endOrdinal, + boolean preMarked, + List words) { + + public boolean isBlank() { + return text == null || text.isBlank(); + } + + public int charCount() { + return text == null ? 0 : text.strip().length(); + } + + public int wordCount() { + return (int) words.stream().filter(w -> !w.isBlank()).count(); + } + + /** True when every word occupies its own operator run, so cells can be tagged separately. */ + public boolean wordsAreSeparable() { + List real = words.stream().filter(w -> !w.isBlank()).toList(); + for (int i = 1; i < real.size(); i++) { + if (!real.get(i - 1).isSeparableFrom(real.get(i))) { + return false; + } + } + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/WordInfo.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/WordInfo.java new file mode 100644 index 0000000000..515775b6f8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/WordInfo.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.pdf.ua; + +/** + * A whitespace-delimited run of glyphs, with the operator ordinals that produced it. Cell detection + * needs both: geometry to find cells, ordinals to tell whether they can be tagged separately. + */ +public record WordInfo( + String text, BBox bbox, int startOrdinal, int endOrdinal, float fontSize, boolean bold) { + + public boolean isBlank() { + return text == null || text.isBlank(); + } + + /** True when this word shares no operator with the other, so both can carry their own MCID. */ + public boolean isSeparableFrom(WordInfo other) { + return endOrdinal < other.startOrdinal || other.endOrdinal < startOrdinal; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/AccessibilityAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/AccessibilityAuditService.java new file mode 100644 index 0000000000..869d489a04 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/AccessibilityAuditService.java @@ -0,0 +1,187 @@ +package stirling.software.proprietary.service.ua; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.ExceptionUtils; +import stirling.software.proprietary.model.api.ua.AccessibilityIssue; +import stirling.software.proprietary.model.api.ua.AccessibilityReport; +import stirling.software.proprietary.model.api.ua.FigureDescriptor; +import stirling.software.proprietary.model.api.ua.UaValidationResult; +import stirling.software.proprietary.pdf.ua.BBox; +import stirling.software.proprietary.pdf.ua.DocumentStructure; +import stirling.software.proprietary.pdf.ua.LayoutAnalyzer; +import stirling.software.proprietary.pdf.ua.PageContent; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.StructBlock; +import stirling.software.proprietary.pdf.ua.StructType; +import stirling.software.proprietary.pdf.ua.TaggedContentExtractor; + +/** Produces an accessibility report without changing the document. */ +@Service +@Slf4j +@RequiredArgsConstructor +public class AccessibilityAuditService { + + /** Checks no validator can make; omitting them implies the work does not exist. */ + private static final List HUMAN_CHECKS = + List.of( + "Is the reading order correct for someone who cannot see the layout?", + "Does each alternative description convey what the image is for, not just what" + + " it looks like?", + "Are headings used for structure rather than for visual emphasis?", + "Is any information conveyed by colour alone also available another way?", + "Do tables have headers that identify the right rows and columns?", + "Is the document language correct, including for quoted passages?", + "Do links describe their destination rather than saying 'click here'?"); + + /** The report walks every page and validates, so it carries the conversion's own caps. */ + private static final long MAX_INPUT_BYTES = 100L * 1024 * 1024; + + private static final int MAX_PAGES = 2000; + + private final PdfUaValidationService validationService; + + public AccessibilityReport audit(byte[] pdfBytes, PdfUaProfile profile) throws IOException { + enforceLimits(pdfBytes); + AccessibilityReport report = new AccessibilityReport(); + report.setProfile(profile.displayName()); + + UaValidationResult validation = validationService.validate(pdfBytes, profile); + report.setIssues(validation.issues()); + report.setPassesAutomatedChecks(validation.compliant()); + report.setHumanChecks(HUMAN_CHECKS); + + int fixable = 0; + int needsInput = 0; + for (AccessibilityIssue issue : validation.issues()) { + if (issue.isAutoFixable()) { + fixable++; + } else { + needsInput++; + } + } + report.setAutomaticallyFixable(fixable); + report.setNeedsInput(needsInput); + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + populateSummary(document, report); + report.setFiguresNeedingDescription(figuresNeedingDescription(document)); + } catch (IOException e) { + log.debug("Could not inspect document for the summary: {}", e.getMessage()); + } + return report; + } + + /** + * Rejects before the expensive pass. An unreadable file is left to the report itself to say. + */ + private static void enforceLimits(byte[] pdfBytes) { + if (pdfBytes.length > MAX_INPUT_BYTES) { + throw ExceptionUtils.createIllegalArgumentException( + "error.fileTooLarge", + "This PDF is {0} MB. The accessibility report is limited to {1} MB.", + pdfBytes.length / (1024 * 1024), + MAX_INPUT_BYTES / (1024 * 1024)); + } + int pages; + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + pages = document.getNumberOfPages(); + } catch (IOException e) { + return; + } + if (pages > MAX_PAGES) { + throw ExceptionUtils.createIllegalArgumentException( + "error.tooManyPages", + "This PDF has {0} pages. The accessibility report is limited to {1} pages;" + + " split it first.", + pages, + MAX_PAGES); + } + } + + private void populateSummary(PDDocument document, AccessibilityReport report) + throws IOException { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + AccessibilityReport.Summary summary = report.getSummary(); + + report.setTagged(catalog.getStructureTreeRoot() != null); + report.setDeclaresConformance(declaresUa(document)); + + summary.setPages(document.getNumberOfPages()); + summary.setEncrypted(document.isEncrypted()); + summary.setHasLanguage(catalog.getLanguage() != null && !catalog.getLanguage().isBlank()); + + String title = document.getDocumentInformation().getTitle(); + summary.setHasTitle(title != null && !title.isBlank()); + + PDViewerPreferences preferences = catalog.getViewerPreferences(); + summary.setDisplaysDocTitle(preferences != null && preferences.displayDocTitle()); + + Set unembedded = FontEmbeddingService.findUnembeddedFonts(document); + summary.setUnembeddedFonts(unembedded.size()); + summary.setAllFontsEmbedded(unembedded.isEmpty()); + + try { + summary.setFigures(new TaggedContentExtractor().countGraphics(document)); + } catch (Exception e) { + log.debug("Could not count figures: {}", e.getMessage()); + } + } + + /** + * Lists the figures a conversion would leave undescribed, running the converter's own analysis + * because counting raster images would miss vector charts and existing descriptions. + */ + private List figuresNeedingDescription(PDDocument document) { + try { + List pages = new TaggedContentExtractor().extract(document); + DocumentStructure structure = new LayoutAnalyzer().analyse(pages); + List figures = new ArrayList<>(); + for (StructBlock block : structure.figuresWithoutAlt()) { + int ordinal = block.getRanges().isEmpty() ? -1 : block.getRanges().get(0).start(); + BBox box = block.getBbox(); + figures.add( + new FigureDescriptor( + block.getPageIndex() + ":" + ordinal, + block.getPageIndex() + 1, + block.getType() == StructType.FORMULA ? "formula" : "figure", + box.x0(), + box.y0(), + box.width(), + box.height(), + block.getAlt())); + } + return figures; + } catch (Exception e) { + log.debug("Could not enumerate figures: {}", e.getMessage()); + return List.of(); + } + } + + /** True when the XMP packet carries a pdfuaid identifier. */ + private static boolean declaresUa(PDDocument document) { + try { + var metadata = document.getDocumentCatalog().getMetadata(); + if (metadata == null) { + return false; + } + String xmp = + new String(metadata.toByteArray(), java.nio.charset.StandardCharsets.UTF_8); + return xmp.contains("pdfuaid"); + } catch (IOException e) { + return false; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/FontEmbeddingService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/FontEmbeddingService.java new file mode 100644 index 0000000000..9c38b54f6b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/FontEmbeddingService.java @@ -0,0 +1,254 @@ +package stirling.software.proprietary.service.ua; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; + +/** + * Embeds any font the document references but does not carry, as PDF/UA-1 clause 7.21 requires. + * Ghostscript does the embedding and discards the structure tree, so this must run before tagging. + */ +@Service +@Slf4j +public class FontEmbeddingService { + + public boolean hasUnembeddedFonts(byte[] pdfBytes) { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + return !findUnembeddedFonts(document).isEmpty(); + } catch (IOException e) { + log.debug("Could not inspect fonts: {}", e.getMessage()); + return false; + } + } + + public static Set findUnembeddedFonts(PDDocument document) { + Set missing = new HashSet<>(); + for (PDPage page : document.getPages()) { + PDResources resources = page.getResources(); + if (resources == null) { + continue; + } + for (COSName name : resources.getFontNames()) { + try { + PDFont font = resources.getFont(name); + if (font != null && !font.isEmbedded()) { + missing.add(font.getName()); + } + } catch (IOException e) { + log.debug("Could not read font {}: {}", name.getName(), e.getMessage()); + } + } + } + return missing; + } + + /** + * Returns the document with all fonts embedded, or the input unchanged. Never throws: failing + * to embed is a reportable shortfall, not a reason to abandon the conversion. + */ + public Result embedFonts(byte[] pdfBytes) { + Set missing; + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + missing = findUnembeddedFonts(document); + } catch (IOException e) { + return new Result( + pdfBytes, false, Set.of(), "Could not inspect fonts: " + e.getMessage()); + } + if (missing.isEmpty()) { + return new Result(pdfBytes, false, Set.of(), null); + } + if (!isGhostscriptAvailable()) { + return new Result( + pdfBytes, + false, + missing, + "Ghostscript is not installed, so " + + missing.size() + + " unembedded font(s) could not be embedded. PDF/UA requires every font" + + " to be embedded."); + } + + Path workingDir = null; + try { + workingDir = Files.createTempDirectory("pdfua_fonts_"); + Path input = workingDir.resolve("input.pdf"); + Path output = workingDir.resolve("output.pdf"); + Files.write(input, pdfBytes); + + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) + .runCommandWithOutputHandling(command(input, output, workingDir)); + + if (result.getRc() != 0 || !Files.exists(output)) { + return new Result( + pdfBytes, + false, + missing, + "Font embedding failed with code " + result.getRc()); + } + byte[] embedded = Files.readAllBytes(output); + + // Ghostscript can exit 0 having written a blank page, so keep the original rather than + // return an empty document. + if (!survived(pdfBytes, embedded)) { + log.warn("Ghostscript produced a degenerate document; keeping the original"); + return new Result( + pdfBytes, + false, + missing, + "Font embedding was skipped because the embedder returned a document that" + + " had lost content. " + + missing.size() + + " font(s) remain unembedded."); + } + + // It can also exit 0 while simply leaving fonts unembedded. + Set remaining; + try (PDDocument check = Loader.loadPDF(embedded)) { + remaining = findUnembeddedFonts(check); + } + if (!remaining.isEmpty()) { + return new Result( + embedded, + true, + remaining, + remaining.size() + + " font(s) could not be embedded (" + + String.join(", ", remaining) + + "). PDF/UA requires every font to be embedded."); + } + log.info("Embedded {} previously unembedded font(s)", missing.size()); + return new Result(embedded, true, missing, null); + + } catch (Exception e) { + log.warn("Font embedding failed: {}", e.getMessage()); + return new Result(pdfBytes, false, missing, "Font embedding failed: " + e.getMessage()); + } finally { + deleteQuietly(workingDir); + } + } + + /** + * True when the rewritten document still holds the original's content. A collapse in page count + * or content-stream size is the only signature of a failed rewrite the exit code hides. + */ + private static boolean survived(byte[] original, byte[] rewritten) { + try (PDDocument before = Loader.loadPDF(original); + PDDocument after = Loader.loadPDF(rewritten)) { + if (after.getNumberOfPages() != before.getNumberOfPages()) { + return false; + } + long beforeBytes = contentBytes(before); + long afterBytes = contentBytes(after); + if (beforeBytes == 0) { + return true; + } + return afterBytes * 20L >= beforeBytes; + } catch (IOException e) { + log.debug("Could not compare documents after embedding: {}", e.getMessage()); + return false; + } + } + + private static long contentBytes(PDDocument document) { + long total = 0; + for (PDPage page : document.getPages()) { + try (InputStream in = page.getContents()) { + if (in != null) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + total += read; + } + } + } catch (IOException e) { + log.debug("Could not measure page content: {}", e.getMessage()); + } + } + return total; + } + + private static List command(Path input, Path output, Path workingDir) { + List command = new ArrayList<>(); + command.add("gs"); + command.add("--permit-file-read=" + workingDir.toAbsolutePath()); + command.add("--permit-file-write=" + workingDir.toAbsolutePath()); + command.add("-sDEVICE=pdfwrite"); + command.add("-dEmbedAllFonts=true"); + command.add("-dSubsetFonts=true"); + command.add("-dCompressFonts=true"); + command.add("-dNOSUBSTFONTS=false"); + command.add("-dPDFSETTINGS=/prepress"); + command.add("-dNOPAUSE"); + command.add("-dBATCH"); + command.add("-sOutputFile=" + output.toAbsolutePath()); + command.add(input.toAbsolutePath().toString()); + return command; + } + + /** Cached after the first probe: availability does not change mid-process. */ + private volatile Boolean ghostscriptAvailable; + + private boolean isGhostscriptAvailable() { + Boolean cached = ghostscriptAvailable; + if (cached != null) { + return cached; + } + boolean available; + try { + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) + .runCommandWithOutputHandling(List.of("gs", "--version")); + available = result.getRc() == 0; + } catch (Exception e) { + log.debug("Ghostscript availability check failed: {}", e.getMessage()); + available = false; + } + ghostscriptAvailable = available; + return available; + } + + private static void deleteQuietly(Path directory) { + if (directory == null) { + return; + } + try (Stream stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()) + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.debug("Could not delete {}", path); + } + }); + } catch (IOException e) { + log.debug("Could not clean {}", directory); + } + } + + /** + * @param warning non-null when fonts remain unembedded, for the conversion report + */ + public record Result(byte[] pdfBytes, boolean changed, Set fonts, String warning) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaConversionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaConversionService.java new file mode 100644 index 0000000000..1d0920c109 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaConversionService.java @@ -0,0 +1,251 @@ +package stirling.software.proprietary.service.ua; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.model.api.ua.UaValidationResult; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.PdfUaTagger; +import stirling.software.proprietary.pdf.ua.SourceFacts; +import stirling.software.proprietary.pdf.ua.TaggingOptions; +import stirling.software.proprietary.pdf.ua.TaggingResult; + +/** + * Converts a PDF to PDF/UA. The declaration is written first and withdrawn unless validation + * passes, so a returned file either conforms or does not claim to. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class PdfUaConversionService { + + private final PdfUaValidationService validationService; + private final FontEmbeddingService fontEmbeddingService; + private final stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory; + + /** Matches the cap GetInfoOnPDF already applies to comparable whole-document work. */ + private static final long MAX_INPUT_BYTES = 100L * 1024 * 1024; + + /** Beyond this the structure model alone runs to hundreds of megabytes. */ + private static final int MAX_PAGES = 2000; + + public PdfUaConversionOutcome convert(byte[] input, TaggingOptions options) throws IOException { + if (input.length > MAX_INPUT_BYTES) { + throw new IOException( + "This PDF is " + + (input.length / (1024 * 1024)) + + " MB. PDF/UA conversion is limited to " + + (MAX_INPUT_BYTES / (1024 * 1024)) + + " MB."); + } + PdfUaProfile profile = options.getProfile(); + List warnings = new ArrayList<>(); + + // Read the document's own facts before anything rewrites it. Font embedding runs + // Ghostscript over the whole file, which discards the structure tree, /Lang and XFA, so + // every guard and every "what did the source say" question must be answered from here. + SourceFacts facts; + try (PDDocument original = load(input)) { + rejectUnsupportedSource(original); + warnSignatures(original, warnings); + facts = SourceFacts.of(original); + } + + byte[] source = input; + if (options.isEmbedFonts()) { + // Must precede tagging: the embedder rewrites the file and drops any structure tree. + FontEmbeddingService.Result fonts = fontEmbeddingService.embedFonts(input); + source = fonts.pdfBytes(); + if (fonts.warning() != null) { + warnings.add(fonts.warning()); + } + source = keepTagsOverFonts(input, source, facts, options, warnings); + } + + TaggingOptions effective = options.toBuilder().sourceFacts(facts).build(); + + // Tag and declare in one pass; the claim is withdrawn below if validation disagrees. + byte[] declared; + TaggingResult taggingResult; + PdfUaTagger tagger = new PdfUaTagger(); + try (PDDocument document = load(source)) { + rejectEncrypted(document); + taggingResult = tagger.tag(document, effective); + warnings.addAll(taggingResult.getWarnings()); + tagger.declareConformance(document, profile); + declared = save(document); + } + + UaValidationResult validation = validationService.validate(declared, profile); + + // A validator cannot see text hidden behind artifact markers, so a clean verdict over + // suppressed content would be a false claim. + boolean honest = !taggingResult.isContentSuppressed(); + + if (validation.compliant() && honest) { + log.info("{} conversion passed validation", profile.displayName()); + return new PdfUaConversionOutcome( + declared, true, validation, summary(taggingResult), warnings); + } + + byte[] undeclared; + try (PDDocument document = load(declared)) { + tagger.withdrawConformance(document); + undeclared = save(document); + } + + if (!validation.compliant()) { + warnings.add( + "The document could not be made " + + profile.displayName() + + " conformant, so no conformance claim was written. " + + validation.totalFailures() + + " automated check(s) still fail."); + } + log.info( + "{} conversion left undeclared: {} failures, suppressedText={}", + profile.displayName(), + validation.totalFailures(), + !honest); + return new PdfUaConversionOutcome( + undeclared, false, validation, summary(taggingResult), warnings); + } + + private static PdfUaConversionOutcome.TaggingSummary summary(TaggingResult result) { + return new PdfUaConversionOutcome.TaggingSummary( + result.isRebuilt(), + result.getTaggedElements(), + result.getArtifacts(), + result.getFiguresNeedingAlt()); + } + + /** + * Tagging rewrites the content streams a signature covers, so the conversion still runs but the + * caller has to know the signature will no longer verify. + */ + private static void warnSignatures(PDDocument document, List warnings) { + int signatures = document.getSignatureDictionaries().size(); + if (signatures > 0) { + warnings.add( + signatures + + " digital signature(s) will stop verifying: tagging rewrites the" + + " content streams they cover. Convert first, then re-sign."); + } + } + + /** Replaces PDFBox's "incorrect password" wording, baffling when the caller supplied none. */ + private PDDocument load(byte[] bytes) throws IOException { + try { + // The factory spills large documents to a temp-file cache instead of the heap. + return pdfDocumentFactory.load(bytes); + } catch (IOException | RuntimeException e) { + // The factory wraps the parse failure, so check the cause chain rather than the type. + if (mentionsPassword(e)) { + throw new IOException( + "This PDF is encrypted. Remove the password before converting it to" + + " PDF/UA.", + e); + } + throw e; + } + } + + private static boolean mentionsPassword(Throwable error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (cause instanceof InvalidPasswordException) { + return true; + } + String message = cause.getMessage(); + if (message != null) { + String lower = message.toLowerCase(Locale.ROOT); + if (lower.contains("password") || lower.contains("decrypt")) { + return true; + } + } + } + return false; + } + + /** XFA is forbidden by PDF/UA-1 clause 7.15; encrypted or huge files cannot be restructured. */ + /** + * Under KEEP nothing rebuilds a tree, so if the embedder deleted one we would hand back an + * untagged document. Fonts are not worth the whole structure; give the tags back instead. + */ + private byte[] keepTagsOverFonts( + byte[] input, + byte[] embedded, + SourceFacts facts, + TaggingOptions options, + List warnings) + throws IOException { + if (options.getExistingTags() != TaggingOptions.ExistingTags.KEEP + || !facts.hasUsableTree() + || embedded == input) { + return embedded; + } + boolean survived; + try (PDDocument rewritten = load(embedded)) { + survived = PdfUaTagger.hasUsableStructureTree(rewritten); + } + if (survived) { + return embedded; + } + warnings.add( + "Embedding the missing fonts would have deleted the document's existing tags, so" + + " the tags were kept and the fonts left unembedded. Turn off font" + + " embedding to silence this, or rebuild the tags to embed them."); + return input; + } + + /** + * Checks that must see the document as the author wrote it. Font embedding strips XFA, so + * running this afterwards would let a dynamic form through unnoticed, and it would push a + * document we are about to reject through the whole embedder first. + */ + private static void rejectUnsupportedSource(PDDocument document) throws IOException { + if (document.getNumberOfPages() > MAX_PAGES) { + throw new IOException( + "This PDF has " + + document.getNumberOfPages() + + " pages. PDF/UA conversion is limited to " + + MAX_PAGES + + " pages; split it first."); + } + PDAcroForm form = document.getDocumentCatalog().getAcroForm(); + if (form != null && form.xfaIsDynamic()) { + throw new IOException( + "Dynamic XFA forms are not permitted by PDF/UA. Flatten the form first."); + } + } + + /** + * Deliberately checked on the working document rather than the source. Permissions-only + * encryption with an empty user password is common in published documents, the embedder + * resolves it, and those files convert usefully; rejecting them up front would fail a document + * for a password its author never set. + */ + private static void rejectEncrypted(PDDocument document) throws IOException { + if (document.isEncrypted()) { + throw new IOException( + "Encrypted PDFs cannot be converted to PDF/UA. Remove the password first."); + } + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaValidationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaValidationService.java new file mode 100644 index 0000000000..18d9fcaee7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaValidationService.java @@ -0,0 +1,245 @@ +package stirling.software.proprietary.service.ua; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.verapdf.gf.foundry.VeraGreenfieldFoundryProvider; +import org.verapdf.pdfa.Foundries; +import org.verapdf.pdfa.PDFAParser; +import org.verapdf.pdfa.PDFAValidator; +import org.verapdf.pdfa.flavours.PDFAFlavour; +import org.verapdf.pdfa.results.TestAssertion; +import org.verapdf.pdfa.results.ValidationResult; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.api.ua.AccessibilityIssue; +import stirling.software.proprietary.model.api.ua.UaValidationResult; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; + +/** + * Validates a document against a PDF/UA profile using veraPDF, the oracle a conversion is declared + * against. It checks only the machine-verifiable subset, so a clean result is not "accessible". + */ +@Service +@Slf4j +public class PdfUaValidationService { + + /** Plain-English text and remediability for the clauses users actually hit. */ + private static final Map CLAUSES = buildClauseTable(); + + record ClauseInfo(String message, boolean autoFixable) {} + + @PostConstruct + public void initialise() { + try { + VeraGreenfieldFoundryProvider.initialise(); + } catch (Exception e) { + log.error("Failed to initialise veraPDF for PDF/UA validation", e); + } + } + + public UaValidationResult validate(byte[] pdfBytes, PdfUaProfile profile) { + PDFAFlavour flavour = flavourFor(profile); + try (PDFAParser parser = + Foundries.defaultInstance() + .createParser(new ByteArrayInputStream(pdfBytes), flavour)) { + + PDFAValidator validator = Foundries.defaultInstance().createValidator(flavour, false); + ValidationResult result = validator.validate(parser); + return toResult(profile, result); + + } catch (Exception e) { + log.warn("PDF/UA validation failed for {}: {}", profile.displayName(), e.getMessage()); + AccessibilityIssue issue = new AccessibilityIssue(); + issue.setMessage("Validation could not run: " + e.getMessage()); + issue.setSeverity("error"); + issue.setClause("n/a"); + return new UaValidationResult(profile.displayName(), false, List.of(issue), 0); + } + } + + public static PDFAFlavour flavourFor(PdfUaProfile profile) { + return profile == PdfUaProfile.UA2 ? PDFAFlavour.PDFUA_2 : PDFAFlavour.PDFUA_1; + } + + /** + * Whether the bytes really validate as PDF/A level A for the given part. Tagging is necessary + * for level A but not sufficient, so the claim is only written once veraPDF agrees. + */ + public boolean validatesAsPdfaLevelA(byte[] pdfBytes, int part) { + PDFAFlavour flavour = + switch (part) { + case 1 -> PDFAFlavour.PDFA_1_A; + case 2 -> PDFAFlavour.PDFA_2_A; + case 3 -> PDFAFlavour.PDFA_3_A; + default -> null; + }; + if (flavour == null) { + log.warn("No PDF/A level A flavour for part {}", part); + return false; + } + try (PDFAParser parser = + Foundries.defaultInstance() + .createParser(new ByteArrayInputStream(pdfBytes), flavour)) { + PDFAValidator validator = Foundries.defaultInstance().createValidator(flavour, false); + return validator.validate(parser).isCompliant(); + } catch (Exception e) { + log.warn("Level A validation could not run: {}", e.getMessage()); + return false; + } + } + + /** + * Groups repeated failures of the same rule so a report lists issues, not thousands of lines. + */ + private static UaValidationResult toResult(PdfUaProfile profile, ValidationResult result) { + Map grouped = new LinkedHashMap<>(); + int total = 0; + + for (TestAssertion assertion : result.getTestAssertions()) { + if (assertion.getStatus() != TestAssertion.Status.FAILED) { + continue; + } + total++; + String clause = + assertion.getRuleId() != null ? assertion.getRuleId().getClause() : "unknown"; + int test = assertion.getRuleId() != null ? assertion.getRuleId().getTestNumber() : 0; + String key = clause + "-" + test; + + AccessibilityIssue issue = + grouped.computeIfAbsent( + key, + k -> { + AccessibilityIssue created = new AccessibilityIssue(); + created.setClause(clause); + created.setTestNumber(String.valueOf(test)); + created.setSeverity("error"); + ClauseInfo info = lookupClause(clause); + created.setMessage( + info != null ? info.message() : assertion.getMessage()); + created.setTechnicalMessage(assertion.getMessage()); + created.setAutoFixable(info != null && info.autoFixable()); + created.setSpecification(profile.displayName()); + return created; + }); + issue.setOccurrences(issue.getOccurrences() + 1); + if (issue.getLocation() == null && assertion.getLocation() != null) { + issue.setLocation(assertion.getLocation().toString()); + } + } + + List issues = new ArrayList<>(grouped.values()); + return new UaValidationResult( + profile.displayName(), result.isCompliant() && total == 0, issues, total); + } + + /** + * Finds the most specific entry covering a clause by walking up the dotted hierarchy. String + * prefixes would be wrong: {@code 7.1} prefixes {@code 7.18.1} without being its ancestor. + */ + static ClauseInfo lookupClause(String clause) { + if (clause == null) { + return null; + } + String current = clause; + while (!current.isEmpty()) { + ClauseInfo info = CLAUSES.get(current); + if (info != null) { + return info; + } + int dot = current.lastIndexOf('.'); + if (dot < 0) { + return null; + } + current = current.substring(0, dot); + } + return null; + } + + private static Map buildClauseTable() { + Map table = new LinkedHashMap<>(); + table.put( + "7.1", + new ClauseInfo( + "Document is not tagged, or some content is neither tagged nor marked as an artifact.", + true)); + table.put( + "7.2", + new ClauseInfo( + "Text cannot be mapped to Unicode, or the document language is not declared.", + true)); + table.put( + "7.3", + new ClauseInfo("An image or graphic has no alternative description.", false)); + table.put( + "7.4", + new ClauseInfo( + "Heading levels skip a level, or headings are nested incorrectly.", true)); + table.put( + "7.5", + new ClauseInfo("A table is missing header cells or header associations.", false)); + table.put( + "7.6", new ClauseInfo("A list is not structured as list items with bodies.", true)); + table.put( + "7.7", + new ClauseInfo("A mathematical expression has no alternative description.", false)); + table.put( + "7.8", + new ClauseInfo("Running heads or page numbers are not marked as artifacts.", true)); + table.put("7.9", new ClauseInfo("A note is missing a unique identifier.", true)); + // Tagging does not touch optional content groups, so this needs the authoring tool. + table.put("7.10", new ClauseInfo("An optional content group has no name.", false)); + // The attachment's own /AFRelationship and /Desc are not something tagging can supply. + table.put( + "7.11", + new ClauseInfo( + "An embedded file is missing its relationship or description.", false)); + table.put( + "7.15", + new ClauseInfo( + "The document uses a dynamic XFA form, which PDF/UA does not allow.", + false)); + table.put( + "7.16", + new ClauseInfo( + "Security settings prevent assistive technology from reading the content.", + true)); + table.put("7.17", new ClauseInfo("Navigation aids such as page labels are missing.", true)); + table.put( + "7.18", + new ClauseInfo( + "An annotation is missing a description, tab order, or structure entry.", + true)); + table.put( + "7.20", + new ClauseInfo( + "A form or group XObject is not marked as content or as an artifact.", + false)); + // Most font defects (CIDFont, CMap, metrics, encoding) need the font itself repaired. + table.put( + "7.21", + new ClauseInfo("A font in the document does not meet PDF/UA rules.", false)); + // The one font defect embedding does fix. + table.put("7.21.4.1", new ClauseInfo("A font used in the document is not embedded.", true)); + // ToUnicode gaps need the font itself repaired, which embedding does not do. + table.put( + "7.21.7", + new ClauseInfo( + "A font does not map every character it uses to Unicode, so extracted text" + + " may be wrong.", + false)); + table.put( + "5", + new ClauseInfo( + "The document does not declare PDF/UA conformance in its XMP metadata.", + true)); + return table; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfaAccessibilityService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfaAccessibilityService.java new file mode 100644 index 0000000000..5a13b4ab65 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfaAccessibilityService.java @@ -0,0 +1,297 @@ +package stirling.software.proprietary.service.ua; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdfwriter.compress.CompressParameters; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.apache.xmpbox.XMPMetadata; +import org.apache.xmpbox.schema.PDFAExtensionSchema; +import org.apache.xmpbox.schema.PDFAIdentificationSchema; +import org.apache.xmpbox.type.AbstractStructuredType; +import org.apache.xmpbox.type.ArrayProperty; +import org.apache.xmpbox.type.Cardinality; +import org.apache.xmpbox.type.PDFAPropertyType; +import org.apache.xmpbox.type.PDFASchemaType; +import org.apache.xmpbox.xml.DomXmpParser; +import org.apache.xmpbox.xml.XmpSerializer; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.PdfaLevelAServiceInterface; +import stirling.software.proprietary.pdf.ua.PdfUaIdentificationSchema; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.PdfUaTagger; +import stirling.software.proprietary.pdf.ua.TaggingOptions; +import stirling.software.proprietary.pdf.ua.TaggingResult; + +/** + * Raises a PDF/A file from conformance level B to level A, which adds the tagging the PDF/UA tagger + * already does. Must run after Ghostscript, which discards any structure tree it is given. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class PdfaAccessibilityService implements PdfaLevelAServiceInterface { + + /** + * Matches the PDF/UA converter's own cap; beyond this the structure model exhausts the heap. + */ + private static final int MAX_TAGGABLE_PAGES = 2000; + + private final PdfUaValidationService validationService; + + /** + * Tags a converted PDF/A and marks it conformance A, or returns it unchanged rather than + * claiming level A over untagged content. part is 1 to 3; part 1 keeps its PDF 1.4 version. + */ + public Result upgradeToLevelA(byte[] pdfBytes, int part, String language, String title) { + return upgradeToLevelA(pdfBytes, part, language, title, false); + } + + /** + * @param alsoDeclareUa additionally claim PDF/UA, but only if it validates + */ + @Override + public Result upgradeToLevelA( + byte[] pdfBytes, int part, String language, String title, boolean alsoDeclareUa) { + List warnings = new ArrayList<>(); + try { + byte[] tagged; + TaggingResult taggingResult; + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + // Tagging holds a model of the whole document; without a cap a large file exhausts + // the heap, and OutOfMemoryError is an Error, so the catch below never sees it. + if (document.getNumberOfPages() > MAX_TAGGABLE_PAGES) { + warnings.add( + "This document has " + + document.getNumberOfPages() + + " pages, more than the " + + MAX_TAGGABLE_PAGES + + " that can be tagged, so it was left at conformance level B."); + return new Result(pdfBytes, false, warnings); + } + TaggingOptions options = + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language(language) + .title(title) + .fallbackTitle(title) + // Fonts were embedded on the PDF/A pass; a rewrite would undo it. + .embedFonts(false) + // PDF/A-1 is defined on PDF 1.4; raising it breaks conformance. + .preservePdfVersion(part == 1) + .existingTags(TaggingOptions.ExistingTags.AUTO) + .build(); + + taggingResult = new PdfUaTagger().tag(document, options); + warnings.addAll(taggingResult.getWarnings()); + tagged = save(document, part); + } + + if (taggingResult.getTaggedElements() == 0 && taggingResult.isRebuilt()) { + warnings.add( + "No taggable content was found, so the file cannot claim PDF/A level A." + + " It remains valid at level B."); + return new Result(pdfBytes, false, warnings); + } + if (taggingResult.isContentSuppressed()) { + warnings.add( + "Some text could not be tagged reliably and was marked as an artifact, so" + + " no level A claim was written. The file remains valid at level B."); + return new Result(tagged, false, warnings); + } + + byte[] declared = setConformance(tagged, part, "A"); + + // Tagging is necessary for level A but not sufficient: Unicode mappings are too. + if (!validationService.validatesAsPdfaLevelA(declared, part)) { + warnings.add( + "The document was tagged but does not validate as PDF/A-" + + part + + "a, so it was left at conformance level B."); + return new Result(setConformance(tagged, part, "B"), false, warnings); + } + + if (alsoDeclareUa) { + byte[] withUa = declarePdfUaAlongsidePdfa(declared, part); + var uaResult = validationService.validate(withUa, PdfUaProfile.UA1); + if (uaResult.compliant()) { + log.info("Upgraded PDF/A-{} to level A and declared PDF/UA", part); + return new Result(withUa, true, warnings); + } + // The archival upgrade stands on its own; only the accessibility claim is dropped. + warnings.add( + "PDF/UA was requested alongside PDF/A but " + + uaResult.totalFailures() + + " accessibility check(s) still fail, so no PDF/UA claim was" + + " written. The file is valid PDF/A-" + + part + + "a."); + } + + log.info("Upgraded PDF/A-{} to conformance level A", part); + return new Result(declared, true, warnings); + + } catch (Exception e) { + log.warn("Could not upgrade to PDF/A level A: {}", e.getMessage()); + warnings.add( + "Level A upgrade failed (" + + e.getMessage() + + "), so the file was left at conformance level B."); + return new Result(pdfBytes, false, warnings); + } + } + + /** + * Declares PDF/UA alongside PDF/A in one file. The extension schema is required: PDF/A forbids + * XMP properties no schema describes, and XMPBox has none for {@code pdfuaid}. + */ + static byte[] declarePdfUaAlongsidePdfa(byte[] pdfBytes, int part) throws Exception { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + XMPMetadata xmp = parseOrCreate(document); + + PdfUaIdentificationSchema identification = new PdfUaIdentificationSchema(xmp); + identification.setPart(1); + xmp.addSchema(identification); + + addPdfUaExtensionSchema(xmp); + writeMetadata(document, xmp); + return save(document, part); + } + } + + /** + * Describes the pdfuaid namespace so a PDF/A validator accepts it. Fields are set individually, + * not by subclassing: XMPBox reads the namespace from an annotation, which is not inherited. + */ + private static void addPdfUaExtensionSchema(XMPMetadata xmp) { + PDFAExtensionSchema extension = + (PDFAExtensionSchema) xmp.getSchema(PDFAExtensionSchema.class); + if (extension == null) { + extension = xmp.createAndAddPDFAExtensionSchemaWithDefaultNS(); + } + + PDFAPropertyType partProperty = new PDFAPropertyType(xmp); + addField(xmp, partProperty, PDFAPropertyType.NAME, "part"); + addField(xmp, partProperty, PDFAPropertyType.VALUETYPE, "Integer"); + addField(xmp, partProperty, PDFAPropertyType.CATEGORY, "internal"); + addField( + xmp, + partProperty, + PDFAPropertyType.DESCRIPTION, + "Indicates which part of ISO 14289 the document conforms to"); + + PDFASchemaType schema = new PDFASchemaType(xmp); + addField(xmp, schema, PDFASchemaType.SCHEMA, "PDF/UA Universal Accessibility Schema"); + addField(xmp, schema, PDFASchemaType.NAMESPACE_URI, PdfUaIdentificationSchema.NAMESPACE); + addField(xmp, schema, PDFASchemaType.PREFIX, PdfUaIdentificationSchema.PREFERRED_PREFIX); + + ArrayProperty properties = + xmp.getTypeMapping() + .createArrayProperty( + schema.getNamespace(), + schema.getPrefix(), + PDFASchemaType.PROPERTY, + Cardinality.Seq); + properties.getContainer().addProperty(partProperty); + schema.getContainer().addProperty(properties); + + // A freshly created extension schema has no schemas bag yet, so make one. + ArrayProperty schemas = extension.getSchemasProperty(); + if (schemas == null) { + schemas = + xmp.getTypeMapping() + .createArrayProperty( + extension.getNamespace(), + extension.getPrefix(), + PDFAExtensionSchema.SCHEMAS, + Cardinality.Bag); + extension.addProperty(schemas); + } + schemas.getContainer().addProperty(schema); + } + + /** Adds one text field to a structured type, in that type's own namespace. */ + private static void addField( + XMPMetadata xmp, AbstractStructuredType target, String name, String value) { + target.getContainer() + .addProperty( + xmp.getTypeMapping() + .createText( + target.getNamespace(), target.getPrefix(), name, value)); + } + + private static XMPMetadata parseOrCreate(PDDocument document) throws Exception { + PDMetadata existing = document.getDocumentCatalog().getMetadata(); + if (existing == null) { + return XMPMetadata.createXMPMetadata(); + } + try (InputStream in = new ByteArrayInputStream(existing.toByteArray())) { + DomXmpParser parser = new DomXmpParser(); + parser.setStrictParsing(false); + return parser.parse(in); + } + } + + private static void writeMetadata(PDDocument document, XMPMetadata xmp) throws Exception { + ByteArrayOutputStream serialised = new ByteArrayOutputStream(); + new XmpSerializer().serialize(xmp, serialised, true); + PDMetadata metadata = new PDMetadata(document); + metadata.importXMPMetadata(serialised.toByteArray()); + document.getDocumentCatalog().setMetadata(metadata); + } + + /** Rewrites {@code pdfaid:conformance} without disturbing the rest of the packet. */ + static byte[] setConformance(byte[] pdfBytes, int part, String conformance) throws Exception { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + PDMetadata existing = document.getDocumentCatalog().getMetadata(); + XMPMetadata xmp; + if (existing != null) { + try (InputStream in = new ByteArrayInputStream(existing.toByteArray())) { + DomXmpParser parser = new DomXmpParser(); + parser.setStrictParsing(false); + xmp = parser.parse(in); + } + } else { + xmp = XMPMetadata.createXMPMetadata(); + } + + PDFAIdentificationSchema identification = + (PDFAIdentificationSchema) xmp.getSchema(PDFAIdentificationSchema.class); + if (identification == null) { + identification = xmp.createAndAddPDFAIdentificationSchema(); + } + identification.setPart(part); + identification.setConformance(conformance); + + ByteArrayOutputStream serialised = new ByteArrayOutputStream(); + new XmpSerializer().serialize(xmp, serialised, true); + PDMetadata metadata = new PDMetadata(document); + metadata.importXMPMetadata(serialised.toByteArray()); + document.getDocumentCatalog().setMetadata(metadata); + + return save(document, part); + } + } + + /** + * Part 1 is saved uncompressed: PDFBox's default object streams need PDF 1.5, which would push + * a PDF/A-1 file off its required 1.4 version. + */ + private static byte[] save(PDDocument document, int part) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save( + out, part == 1 ? CompressParameters.NO_COMPRESSION : new CompressParameters()); + return out.toByteArray(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzerTest.java new file mode 100644 index 0000000000..e9aaab0a1a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzerTest.java @@ -0,0 +1,286 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Unit tests for the heuristics that decide what a run of text means. */ +class LayoutAnalyzerTest { + + private static final BBox A4 = new BBox(0, 0, 595, 842); + + private static TextLineInfo line(String text, float size, float x, float y) { + return line(text, size, x, y, false, 0, 0); + } + + private static TextLineInfo line( + String text, float size, float x, float y, boolean bold, int start, int end) { + List words = new ArrayList<>(); + float cursor = x; + for (String token : text.strip().split("\\s+")) { + float width = token.length() * size * 0.5f; + words.add( + new WordInfo( + token, + new BBox(cursor, y, cursor + width, y + size), + start, + end, + size, + bold)); + cursor += width + size * 0.3f; + } + return new TextLineInfo( + 0, text, new BBox(x, y, cursor, y + size), size, bold, start, end, false, words); + } + + private static PageContent page(List lines) { + return new PageContent(0, lines, List.of(), lines.size(), false, false, false, A4); + } + + @Nested + @DisplayName("body font size") + class BodyFontSize { + + @Test + @DisplayName("weights by characters so one huge title does not skew the baseline") + void weightsByCharacterCount() { + List lines = + List.of( + line("A Very Large Title", 32, 50, 700), + line("Body text line one which is long", 11, 50, 650), + line("Body text line two which is long", 11, 50, 630), + line("Body text line three also long", 11, 50, 610)); + assertEquals(11f, LayoutAnalyzer.bodyFontSize(List.of(page(lines)))); + } + + @Test + @DisplayName("returns zero when there is no text") + void handlesEmptyDocument() { + assertEquals(0f, LayoutAnalyzer.bodyFontSize(List.of(page(List.of())))); + } + } + + @Nested + @DisplayName("heading detection") + class Headings { + + @Test + @DisplayName("assigns distinct sizes to descending levels") + void assignsTiers() { + List lines = + List.of( + line("Title", 24, 50, 800), + line("Chapter", 18, 50, 750), + line("Section", 14, 50, 700), + line("Body text that is long enough to set a baseline", 11, 50, 650)); + Map tiers = LayoutAnalyzer.headingTiers(List.of(page(lines)), 11f); + assertEquals(1, tiers.get(24f)); + assertEquals(2, tiers.get(18f)); + assertEquals(3, tiers.get(14f)); + assertNull(tiers.get(11f), "body size must not be a heading tier"); + } + + @Test + @DisplayName("rejects long lines and full sentences whatever their size") + void rejectsProse() { + assertFalse( + LayoutAnalyzer.isHeadingCandidate( + line("This line ends like a sentence does.", 20, 50, 700)), + "a line ending in a full stop reads as prose"); + assertFalse( + LayoutAnalyzer.isHeadingCandidate( + line( + "one two three four five six seven eight nine ten eleven twelve" + + " thirteen", + 20, + 50, + 700)), + "a long line is body text however large"); + assertTrue(LayoutAnalyzer.isHeadingCandidate(line("Financial Results", 20, 50, 700))); + } + + @Test + @DisplayName("boldness alone never promotes a line to a heading") + void boldIsNotAHeadingSignal() { + List lines = + List.of( + line("Bold Label", 11, 50, 700, true, 0, 0), + line("Body text long enough to set the baseline here", 11, 50, 650)); + assertTrue( + LayoutAnalyzer.headingTiers(List.of(page(lines)), 11f).isEmpty(), + "a bold line at body size is emphasis, not a heading"); + } + + @Test + @DisplayName("rewrites skipped levels so H1 is never followed by H3") + void normalisesSkippedLevels() { + DocumentStructure structure = new DocumentStructure(); + structure.add(new StructBlock(StructType.H1, 0)); + structure.add(new StructBlock(StructType.H3, 0)); + structure.add(new StructBlock(StructType.H4, 0)); + LayoutAnalyzer.normaliseHeadingLevels(structure); + + assertEquals(StructType.H1, structure.getBlocks().get(0).getType()); + assertEquals(StructType.H2, structure.getBlocks().get(1).getType()); + assertEquals(StructType.H3, structure.getBlocks().get(2).getType()); + } + } + + @Nested + @DisplayName("lists") + class Lists { + + @Test + @DisplayName("recognises bullet and ordered markers") + void recognisesMarkers() { + assertTrue(LayoutAnalyzer.startsListItem(line("• First item", 11, 50, 700))); + assertTrue(LayoutAnalyzer.startsListItem(line("- First item", 11, 50, 700))); + assertTrue(LayoutAnalyzer.startsListItem(line("1. First item", 11, 50, 700))); + assertTrue(LayoutAnalyzer.startsListItem(line("a) First item", 11, 50, 700))); + assertFalse(LayoutAnalyzer.startsListItem(line("Ordinary prose here", 11, 50, 700))); + } + } + + @Nested + @DisplayName("table cells") + class Tables { + + @Test + @DisplayName("splits a row at wide gaps but not at ordinary word spacing") + void splitsOnWideGaps() { + List words = + List.of( + new WordInfo("Region", new BBox(50, 700, 90, 711), 0, 0, 11, false), + new WordInfo("name", new BBox(93, 700, 125, 711), 0, 0, 11, false), + new WordInfo("Units", new BBox(250, 700, 285, 711), 1, 1, 11, false)); + TextLineInfo row = + new TextLineInfo( + 0, + "Region name Units", + new BBox(50, 700, 285, 711), + 11, + false, + 0, + 1, + false, + words); + List> cells = LayoutAnalyzer.splitCells(row); + assertEquals(2, cells.size(), "the small gap is a word space, the large one is a cell"); + assertEquals(2, cells.get(0).size()); + assertEquals("Units", cells.get(1).get(0).text()); + } + + @Test + @DisplayName("words sharing an operator cannot become separate cells") + void detectsInseparableWords() { + List shared = + List.of( + new WordInfo("A", new BBox(50, 700, 60, 711), 3, 3, 11, false), + new WordInfo("B", new BBox(250, 700, 260, 711), 3, 3, 11, false)); + TextLineInfo row = + new TextLineInfo( + 0, "A B", new BBox(50, 700, 260, 711), 11, false, 3, 3, false, shared); + assertFalse( + row.wordsAreSeparable(), + "cells drawn by one operator cannot carry separate marked content ids"); + } + } + + @Nested + @DisplayName("running heads") + class RunningHeads { + + @Test + @DisplayName("treats text repeating in the margin band across pages as an artifact") + void findsRepeatedMarginText() { + List pages = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + List lines = + List.of( + line("Confidential Report", 9, 50, 800), + line("Body content for the page", 11, 50, 400), + line("Page " + (i + 1), 9, 300, 20)); + pages.add(new PageContent(i, lines, List.of(), 3, false, false, false, A4)); + } + Map> artifacts = LayoutAnalyzer.repeatedMarginLines(pages); + assertEquals( + 2, artifacts.get(0).size(), "the running head and the folio are artifacts"); + assertTrue( + artifacts.get(0).stream().noneMatch(l -> l.text().contains("Body content")), + "body text must never be demoted to an artifact"); + } + + @Test + @DisplayName("does not treat a one-off margin line as a running head") + void ignoresUniqueMarginText() { + List titles = List.of("Alpha", "Beta", "Gamma", "Delta"); + List pages = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + List lines = + List.of( + line(titles.get(i) + " overview", 9, 50, 800), + line("Body content", 11, 50, 400)); + pages.add(new PageContent(i, lines, List.of(), 2, false, false, false, A4)); + } + assertTrue(LayoutAnalyzer.repeatedMarginLines(pages).get(0).isEmpty()); + } + + @Test + @DisplayName("a large heading high on the page stays a heading, not chrome") + void doesNotDemoteHeadingsNearTheTop() { + List pages = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + List lines = + List.of( + // Masking digits makes these look identical across pages. + line("Section " + (i + 1), 20, 50, 800), + line("Body text long enough to set the baseline", 11, 50, 400)); + pages.add(new PageContent(i, lines, List.of(), 2, false, false, false, A4)); + } + assertTrue( + LayoutAnalyzer.repeatedMarginLines(pages, 11f).get(0).isEmpty(), + "a heading larger than body text is content, wherever it sits"); + } + } + + @Nested + @DisplayName("columns") + class Columns { + + @Test + @DisplayName("detects a gutter when text sits in two balanced blocks") + void detectsTwoColumns() { + List lines = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + lines.add(line("Left column text", 10, 50, 700 - i * 14)); + lines.add(line("Right column text", 10, 320, 700 - i * 14)); + } + assertNotNull(LayoutAnalyzer.detectGutter(page(lines))); + } + + @Test + @DisplayName("does not split a page whose lines span the full width") + void ignoresSingleColumn() { + List lines = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + lines.add( + line( + "A full width line of prose that crosses the centre of the page", + 10, + 50, + 700 - i * 14)); + } + assertNull(LayoutAnalyzer.detectGutter(page(lines))); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentInjectorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentInjectorTest.java new file mode 100644 index 0000000000..73d4c64817 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentInjectorTest.java @@ -0,0 +1,164 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Tests the content-stream rewriting that makes tagging possible. */ +class MarkedContentInjectorTest { + + private static byte[] threeLinePdf() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + for (int i = 0; i < 3; i++) { + cs.beginText(); + cs.setFont(font, 12); + cs.newLineAtOffset(50, 700 - i * 20); + cs.showText("Line " + i); + cs.endText(); + } + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private static String contentOf(PDDocument document) throws IOException { + try (InputStream in = document.getPage(0).getContents()) { + return new String(in.readAllBytes(), StandardCharsets.ISO_8859_1); + } + } + + @Test + @DisplayName("wraps claimed content in BDC/EMC with a marked content id") + void wrapsClaimedContent() throws Exception { + try (PDDocument document = Loader.loadPDF(threeLinePdf())) { + StructBlock paragraph = new StructBlock(StructType.P, 0); + paragraph.addRange(0, 1); + + int next = + new MarkedContentInjector() + .inject(document, document.getPage(0), List.of(paragraph), 0, true); + + String content = contentOf(document); + assertTrue(content.contains("/P"), "the structure type was not written"); + assertTrue(content.contains("/MCID"), "no marked content id was written"); + assertTrue(content.contains("BDC"), "no marked content sequence was opened"); + assertTrue(content.contains("EMC"), "no marked content sequence was closed"); + assertFalse(paragraph.getMcids().isEmpty(), "the block was given no marked content id"); + assertTrue(next > 0, "the id counter did not advance"); + } + } + + @Test + @DisplayName("marks unclaimed content as an artifact so nothing is left untagged") + void unclaimedContentBecomesArtifact() throws Exception { + try (PDDocument document = Loader.loadPDF(threeLinePdf())) { + StructBlock paragraph = new StructBlock(StructType.P, 0); + paragraph.addRange(0, 0); + + new MarkedContentInjector() + .inject(document, document.getPage(0), List.of(paragraph), 0, true); + + String content = contentOf(document); + assertTrue( + content.contains("/Artifact"), + "content nobody claimed must be marked as an artifact, or PDF/UA clause 7.1" + + " fails"); + } + } + + @Test + @DisplayName("opens and closes sequences in balanced pairs") + void sequencesAreBalanced() throws Exception { + try (PDDocument document = Loader.loadPDF(threeLinePdf())) { + StructBlock first = new StructBlock(StructType.P, 0); + first.addRange(0, 0); + StructBlock second = new StructBlock(StructType.H1, 0); + second.addRange(2, 2); + + new MarkedContentInjector() + .inject(document, document.getPage(0), List.of(first, second), 0, true); + + String content = contentOf(document); + int opens = count(content, "BDC") + count(content, "BMC"); + int closes = count(content, "EMC"); + assertEquals(opens, closes, "every opened sequence must be closed"); + } + } + + @Test + @DisplayName("rewriting does not change what a reader extracts") + void textIsUnchanged() throws Exception { + byte[] original = threeLinePdf(); + String before = extract(original); + + byte[] rewritten; + try (PDDocument document = Loader.loadPDF(original)) { + StructBlock paragraph = new StructBlock(StructType.P, 0); + paragraph.addRange(0, 2); + new MarkedContentInjector() + .inject(document, document.getPage(0), List.of(paragraph), 0, true); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + rewritten = out.toByteArray(); + } + assertEquals(before, extract(rewritten), "marked content operators must not render"); + } + + @Test + @DisplayName("two blocks claiming the same content keep the first, not both") + void overlappingClaimsAreResolved() { + StructBlock first = new StructBlock(StructType.P, 0); + first.addRange(0, 2); + StructBlock second = new StructBlock(StructType.H1, 0); + second.addRange(1, 1); + + Map owners = + MarkedContentInjector.ownersByOrdinal(List.of(first, second)); + assertSame(first, owners.get(1), "the first claim wins so reading order stays unambiguous"); + assertEquals(3, owners.size()); + } + + private static int count(String haystack, String needle) { + int total = 0; + int index = 0; + while ((index = haystack.indexOf(needle, index)) >= 0) { + total++; + index += needle.length(); + } + return total; + } + + private static String extract(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new org.apache.pdfbox.text.PDFTextStripper() + .getText(document) + .replaceAll("\\s+", " ") + .strip(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentSafetyTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentSafetyTest.java new file mode 100644 index 0000000000..29cc18c525 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentSafetyTest.java @@ -0,0 +1,193 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Regression tests for rewriter damage a validator cannot see, so it still passes validation. */ +class MarkedContentSafetyTest { + + private static String contentOf(PDDocument document) throws IOException { + try (InputStream in = document.getPage(0).getContents()) { + return new String(in.readAllBytes(), StandardCharsets.ISO_8859_1); + } + } + + private static void setContent(PDDocument document, String content) throws IOException { + PDStream stream = new PDStream(document); + try (var out = stream.createOutputStream()) { + out.write(content.getBytes(StandardCharsets.ISO_8859_1)); + } + document.getPage(0).setContents(stream); + } + + private static PDDocument onePage() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 700); + cs.showText("visible"); + cs.endText(); + } + return document; + } + + @Test + @DisplayName("an optional-content layer survives the rebuild, so hidden content stays hidden") + void optionalContentIsPreserved() throws Exception { + try (PDDocument document = onePage()) { + String original = contentOf(document); + setContent(document, "/OC /MC0 BDC\n" + original + "\nEMC\n"); + + StructBlock paragraph = new StructBlock(StructType.P, 0); + paragraph.addRange(0, 0); + new MarkedContentInjector() + .inject(document, document.getPage(0), List.of(paragraph), 0, true); + + String rewritten = contentOf(document); + assertTrue( + rewritten.contains("/OC"), + "the optional-content wrapper was stripped, which would make a hidden" + + " DRAFT/CONFIDENTIAL or redaction layer permanently visible:\n" + + rewritten); + assertEquals( + countOf(rewritten, "BDC") + countOf(rewritten, "BMC"), + countOf(rewritten, "EMC"), + "marked content is unbalanced after preserving the layer"); + } + } + + @Test + @DisplayName("replacement text survives the rebuild so ligatures still read correctly") + void actualTextIsPreserved() throws Exception { + try (PDDocument document = onePage()) { + String original = contentOf(document); + // A generator marks an ffi ligature with what it really spells. + setContent( + document, "/Span <> BDC\n" + original + "\nEMC\n"); + + StructBlock paragraph = new StructBlock(StructType.P, 0); + paragraph.addRange(0, 0); + new MarkedContentInjector() + .inject(document, document.getPage(0), List.of(paragraph), 0, true); + + String rewritten = contentOf(document); + assertTrue( + rewritten.contains("ActualText"), + "dropping ActualText leaves a screen reader announcing the raw glyph:\n" + + rewritten); + assertFalse( + rewritten.contains("/MCID 7"), + "the source's own marked content id is meaningless after a rebuild"); + assertEquals( + countOf(rewritten, "BDC") + countOf(rewritten, "BMC"), + countOf(rewritten, "EMC"), + "marked content is unbalanced after preserving replacement text"); + } + } + + @Test + @DisplayName("a sequence wrapping a fill opens before the path, not inside it") + void markedContentNeverOpensInsideAPathObject() throws Exception { + try (PDDocument document = onePage()) { + setContent(document, "0 0 0 rg\n10 10 50 5 re\nf\n"); + + new MarkedContentInjector().inject(document, document.getPage(0), List.of(), 0, true); + + String rewritten = contentOf(document); + int reAt = rewritten.indexOf(" re"); + int openAt = Math.max(rewritten.indexOf("BMC"), rewritten.indexOf("BDC")); + assertTrue(openAt >= 0, "no sequence was opened at all: " + rewritten); + assertTrue( + openAt < reAt, + "ISO 32000-1 does not permit a marked-content operator inside a path object;" + + " the sequence must open before the path construction:\n" + + rewritten); + } + } + + @Test + @DisplayName("words drawn out of stream order are still claimed, not silently artifacted") + void outOfOrderWordsAreClaimed() { + // A line whose second word on the page was painted first: ordinals 1 then 0. + WordInfo right = new WordInfo("label", new BBox(50, 700, 90, 712), 1, 1, 11, false); + WordInfo left = new WordInfo("value", new BBox(200, 700, 240, 712), 0, 0, 11, false); + TextLineInfo line = + new TextLineInfo( + 0, + "label value", + new BBox(50, 700, 240, 712), + 11, + false, + 0, + 1, + false, + List.of(right, left)); + + PageContent page = + new PageContent( + 0, + List.of(line), + List.of(), + 2, + false, + false, + false, + new BBox(0, 0, 595, 842)); + DocumentStructure structure = new LayoutAnalyzer().analyse(List.of(page)); + + boolean[] claimed = new boolean[2]; + structure.visit( + block -> { + if (block.isArtifact()) { + return; + } + block.getRanges() + .forEach( + r -> { + for (int i = r.start(); i <= r.end() && i < 2; i++) { + claimed[i] = true; + } + }); + }); + assertTrue( + claimed[0] && claimed[1], + "an out-of-order word was left unclaimed and would be hidden from assistive" + + " technology while the file still validated"); + } + + private static int countOf(String haystack, String needle) { + int total = 0; + int index = 0; + while ((index = haystack.indexOf(needle, index)) >= 0) { + total++; + index += needle.length(); + } + return total; + } + + private static byte[] bytes(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaFormAndDeclarationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaFormAndDeclarationTest.java new file mode 100644 index 0000000000..bae4ebed9a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaFormAndDeclarationTest.java @@ -0,0 +1,171 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureNode; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Covers form-field descriptions, widget nesting and withdrawing a conformance claim. */ +class PdfUaFormAndDeclarationTest { + + /** A document with one named text field and one unnamed one. */ + private static PDDocument formDocument(boolean nameTheSecondField) throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + + PDTextField named = new PDTextField(form); + named.setPartialName("EmailAddress"); + addWidget(named, page, 700); + form.getFields().add(named); + + PDTextField second = new PDTextField(form); + if (nameTheSecondField) { + second.setPartialName("PostCode"); + } + addWidget(second, page, 650); + form.getFields().add(second); + + return document; + } + + private static void addWidget(PDTextField field, PDPage page, float y) throws IOException { + PDAnnotationWidget widget = field.getWidgets().get(0); + PDRectangle rectangle = new PDRectangle(); + rectangle.setLowerLeftX(50); + rectangle.setLowerLeftY(y); + rectangle.setUpperRightX(250); + rectangle.setUpperRightY(y + 18); + widget.setRectangle(rectangle); + widget.setPage(page); + page.getAnnotations().add(widget); + } + + private static String xmpOf(PDDocument document) throws IOException { + var metadata = document.getDocumentCatalog().getMetadata(); + assertNotNull(metadata, "no XMP packet"); + return new String(metadata.toByteArray(), StandardCharsets.UTF_8); + } + + @Test + @DisplayName("a form field gets its tooltip from its own name, not an invented one") + void derivesTooltipFromFieldName() throws Exception { + try (PDDocument document = formDocument(true)) { + List warnings = + new PdfUaMetadataWriter() + .applyDocumentRequirements(document, "Form", "en", PdfUaProfile.UA1); + + PDAcroForm form = document.getDocumentCatalog().getAcroForm(); + assertEquals("EmailAddress", form.getField("EmailAddress").getAlternateFieldName()); + assertEquals("PostCode", form.getField("PostCode").getAlternateFieldName()); + assertTrue(warnings.isEmpty(), "nothing needed reporting: " + warnings); + } + } + + @Test + @DisplayName("a field with no name is reported rather than given a placeholder tooltip") + void reportsUnnameableField() throws Exception { + try (PDDocument document = formDocument(false)) { + List warnings = + new PdfUaMetadataWriter() + .applyDocumentRequirements(document, "Form", "en", PdfUaProfile.UA1); + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0).contains("form field"), warnings.get(0)); + } + } + + @Test + @DisplayName("an existing description is never overwritten") + void keepsExistingDescription() throws Exception { + try (PDDocument document = formDocument(true)) { + PDAcroForm form = document.getDocumentCatalog().getAcroForm(); + form.getField("EmailAddress").setAlternateFieldName("Your email address"); + + new PdfUaMetadataWriter() + .applyDocumentRequirements(document, "Form", "en", PdfUaProfile.UA1); + assertEquals( + "Your email address", form.getField("EmailAddress").getAlternateFieldName()); + } + } + + @Test + @DisplayName("widget annotations are nested inside a Form structure element") + void widgetsAreNestedInFormElements() throws Exception { + try (PDDocument document = formDocument(true)) { + DocumentStructure structure = new DocumentStructure(); + StructBlock paragraph = new StructBlock(StructType.P, 0); + paragraph.getMcids().add(0); + structure.add(paragraph); + + new StructTreeWriter().write(document, structure, PdfUaProfile.UA1); + + var root = document.getDocumentCatalog().getStructureTreeRoot(); + assertTrue( + typesUnder(root).contains("Form"), + "clause 7.18.4 requires a widget to sit inside a Form element, found: " + + typesUnder(root)); + } + } + + private static List typesUnder(PDStructureNode node) { + List types = new java.util.ArrayList<>(); + for (Object kid : node.getKids()) { + if (kid instanceof PDStructureElement element) { + types.add(element.getStructureType()); + types.addAll(typesUnder(element)); + } + } + return types; + } + + @Test + @DisplayName("withdrawing conformance removes the claim but keeps the other metadata") + void withdrawingConformanceRemovesOnlyTheClaim() throws Exception { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + PdfUaTagger tagger = new PdfUaTagger(); + PdfUaMetadataWriter writer = new PdfUaMetadataWriter(); + + writer.applyDocumentRequirements(document, "Kept Title", "en-GB", PdfUaProfile.UA1); + writer.declareConformance(document, PdfUaProfile.UA1); + assertTrue(xmpOf(document).contains("pdfuaid")); + + tagger.withdrawConformance(document); + + String xmp = xmpOf(document); + assertFalse(xmp.contains("pdfuaid"), "the conformance claim should be gone"); + assertTrue(xmp.contains("Kept Title"), "the title should survive"); + assertEquals("en-GB", document.getDocumentCatalog().getLanguage()); + } + } + + @Test + @DisplayName("withdrawing conformance on a document that never claimed it is harmless") + void withdrawingIsIdempotent() throws Exception { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + new PdfUaMetadataWriter() + .applyDocumentRequirements(document, "Title", "en", PdfUaProfile.UA1); + new PdfUaTagger().withdrawConformance(document); + assertFalse(xmpOf(document).contains("pdfuaid")); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaLanguageTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaLanguageTest.java new file mode 100644 index 0000000000..ed05e8de0b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaLanguageTest.java @@ -0,0 +1,79 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A relabelled language is invisible to every validator, so the tagger must not guess over one the + * document already declares. + */ +class PdfUaLanguageTest { + + private static PDDocument documentWithLanguage(String language) { + PDDocument document = new PDDocument(); + document.addPage(new PDPage()); + if (language != null) { + document.getDocumentCatalog().setLanguage(language); + } + return document; + } + + private static TaggingOptions.TaggingOptionsBuilder options() { + return TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Rapport") + .embedFonts(false); + } + + @Test + @DisplayName("keeps the language the document already declares") + void keepsExistingLanguage() throws Exception { + try (PDDocument document = documentWithLanguage("fr-FR")) { + TaggingResult result = new PdfUaTagger().tag(document, options().build()); + + assertEquals("fr-FR", document.getDocumentCatalog().getLanguage()); + assertTrue( + result.getWarnings().stream().anyMatch(w -> w.contains("fr-FR")), + "ignoring the requested language must be reported: " + result.getWarnings()); + } + } + + @Test + @DisplayName("applies the requested language when the document declares none") + void fillsInMissingLanguage() throws Exception { + try (PDDocument document = documentWithLanguage(null)) { + new PdfUaTagger().tag(document, options().build()); + + assertEquals("en-GB", document.getDocumentCatalog().getLanguage()); + } + } + + @Test + @DisplayName("replaces the declared language only when the caller asks") + void overridesOnRequest() throws Exception { + try (PDDocument document = documentWithLanguage("fr-FR")) { + new PdfUaTagger().tag(document, options().overrideLanguage(true).build()); + + assertEquals("en-GB", document.getDocumentCatalog().getLanguage()); + } + } + + @Test + @DisplayName("keeps the existing language when an existing structure tree is left alone") + void keepsExistingLanguageWithoutRebuilding() throws Exception { + try (PDDocument document = documentWithLanguage("de-DE")) { + new PdfUaTagger() + .tag( + document, + options().existingTags(TaggingOptions.ExistingTags.KEEP).build()); + + assertEquals("de-DE", document.getDocumentCatalog().getLanguage()); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriterTest.java new file mode 100644 index 0000000000..f953488b03 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriterTest.java @@ -0,0 +1,137 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Tests the document-level requirements that have nothing to do with tagging. */ +class PdfUaMetadataWriterTest { + + private static PDDocument twoPageDocument() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage()); + document.addPage(new PDPage()); + return document; + } + + private static String xmpOf(PDDocument document) throws IOException { + var metadata = document.getDocumentCatalog().getMetadata(); + assertNotNull(metadata, "no XMP packet was written"); + return new String(metadata.toByteArray(), StandardCharsets.UTF_8); + } + + @Test + @DisplayName("sets title, language, tab order and the display-title flag") + void appliesDocumentRequirements() throws Exception { + try (PDDocument document = twoPageDocument()) { + new PdfUaMetadataWriter() + .applyDocumentRequirements( + document, "Annual Report", "en-GB", PdfUaProfile.UA1); + + assertEquals("en-GB", document.getDocumentCatalog().getLanguage()); + assertEquals("Annual Report", document.getDocumentInformation().getTitle()); + assertTrue( + document.getDocumentCatalog().getViewerPreferences().displayDocTitle(), + "without DisplayDocTitle a viewer shows the filename instead of the title"); + + for (PDPage page : document.getPages()) { + assertEquals( + "S", + page.getCOSObject().getNameAsString(COSName.getPDFName("Tabs")), + "clause 7.18.1 requires an explicit tab order on every page"); + } + } + } + + @Test + @DisplayName("writes dc:title into the XMP packet, not just the info dictionary") + void writesDublinCoreTitle() throws Exception { + try (PDDocument document = twoPageDocument()) { + new PdfUaMetadataWriter() + .applyDocumentRequirements( + document, "Annual Report", "en-GB", PdfUaProfile.UA1); + assertTrue(xmpOf(document).contains("Annual Report")); + } + } + + @Test + @DisplayName("does not declare conformance as part of applying requirements") + void doesNotDeclareEarly() throws Exception { + try (PDDocument document = twoPageDocument()) { + new PdfUaMetadataWriter() + .applyDocumentRequirements(document, "Report", "en", PdfUaProfile.UA1); + assertFalse( + xmpOf(document).contains("pdfuaid"), + "the conformance claim must wait until validation has passed"); + } + } + + @Test + @DisplayName("declaring conformance writes pdfuaid with the right part") + void declaresConformance() throws Exception { + try (PDDocument document = twoPageDocument()) { + PdfUaMetadataWriter writer = new PdfUaMetadataWriter(); + writer.applyDocumentRequirements(document, "Report", "en", PdfUaProfile.UA1); + writer.declareConformance(document, PdfUaProfile.UA1); + + String xmp = xmpOf(document); + assertTrue(xmp.contains("pdfuaid"), "no PDF/UA identifier was written"); + assertTrue(xmp.contains("part"), "no conformance part was written"); + } + } + + @Test + @DisplayName("UA-2 raises the PDF version to 2.0") + void ua2RaisesVersion() throws Exception { + try (PDDocument document = twoPageDocument()) { + new PdfUaMetadataWriter() + .applyDocumentRequirements(document, "Report", "en", PdfUaProfile.UA2); + assertEquals(2.0f, document.getVersion()); + } + } + + @Test + @DisplayName("keeps an existing title when none is supplied") + void keepsExistingTitle() throws Exception { + try (PDDocument document = twoPageDocument()) { + var info = document.getDocumentInformation(); + info.setTitle("Original Title"); + document.setDocumentInformation(info); + + new PdfUaMetadataWriter() + .applyDocumentRequirements(document, null, "en", PdfUaProfile.UA1); + assertEquals("Original Title", document.getDocumentInformation().getTitle()); + } + } + + @Test + @DisplayName("survives a round trip through save and reload") + void survivesRoundTrip() throws Exception { + byte[] saved; + try (PDDocument document = twoPageDocument()) { + PdfUaMetadataWriter writer = new PdfUaMetadataWriter(); + writer.applyDocumentRequirements(document, "Round Trip", "fr-FR", PdfUaProfile.UA1); + writer.declareConformance(document, PdfUaProfile.UA1); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + try (PDDocument reloaded = Loader.loadPDF(saved)) { + assertEquals("fr-FR", reloaded.getDocumentCatalog().getLanguage()); + assertEquals("Round Trip", reloaded.getDocumentInformation().getTitle()); + assertTrue(xmpOf(reloaded).contains("pdfuaid")); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaModelTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaModelTest.java new file mode 100644 index 0000000000..de189f2f74 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaModelTest.java @@ -0,0 +1,160 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Tests for the small types the tagger is built from. */ +class PdfUaModelTest { + + @Nested + @DisplayName("structure types") + class Types { + + @Test + @DisplayName("maps levels to heading tags and back") + void headingLevelsRoundTrip() { + for (int level = 1; level <= 6; level++) { + assertEquals(level, StructType.heading(level).headingLevel()); + assertEquals("H" + level, StructType.heading(level).tag()); + } + } + + @Test + @DisplayName("clamps out-of-range levels rather than throwing") + void clampsLevels() { + assertEquals(StructType.H1, StructType.heading(0)); + assertEquals(StructType.H1, StructType.heading(-3)); + assertEquals(StructType.H6, StructType.heading(9)); + } + + @Test + @DisplayName("reports zero for types that are not headings") + void nonHeadingsHaveNoLevel() { + assertEquals(0, StructType.P.headingLevel()); + assertFalse(StructType.TABLE.isHeading()); + } + } + + @Nested + @DisplayName("markable operators") + class Markable { + + @ParameterizedTest + @ValueSource(strings = {"Tj", "TJ", "'", "\"", "Do", "BI", "S", "f", "f*", "B", "sh"}) + @DisplayName("counts text, XObjects and path painting") + void counted(String operator) { + assertTrue(MarkableOp.isMarkableOperator(operator), operator + " should be markable"); + } + + @ParameterizedTest + @ValueSource(strings = {"q", "Q", "cm", "BT", "ET", "Tf", "Td", "n", "W", "gs", "re"}) + @DisplayName("ignores operators that paint nothing") + void notCounted(String operator) { + assertFalse( + MarkableOp.isMarkableOperator(operator), operator + " should not be markable"); + } + + @Test + @DisplayName("n ends a path without painting, so it is not content") + void pathEndIsNotPainting() { + assertFalse(MarkableOp.isPathPainting("n")); + assertTrue(MarkableOp.isPathPainting("f")); + } + } + + @Nested + @DisplayName("profiles") + class Profiles { + + @Test + @DisplayName("parses the shapes a caller might send") + void parsesRequestValues() { + assertEquals(PdfUaProfile.UA1, PdfUaProfile.fromRequest("ua1")); + assertEquals(PdfUaProfile.UA1, PdfUaProfile.fromRequest(null)); + assertEquals(PdfUaProfile.UA1, PdfUaProfile.fromRequest("")); + assertEquals(PdfUaProfile.UA1, PdfUaProfile.fromRequest("nonsense")); + assertEquals(PdfUaProfile.UA2, PdfUaProfile.fromRequest("ua2")); + assertEquals(PdfUaProfile.UA2, PdfUaProfile.fromRequest("PDF/UA-2")); + } + + @Test + @DisplayName("UA-2 requires PDF 2.0") + void ua2NeedsPdf2() { + assertEquals(2.0f, PdfUaProfile.UA2.pdfVersion()); + assertEquals(1.7f, PdfUaProfile.UA1.pdfVersion()); + } + } + + @Nested + @DisplayName("bounding boxes") + class Boxes { + + @Test + @DisplayName("union of an empty box is the other box") + void unionWithEmpty() { + BBox box = new BBox(10, 10, 20, 20); + assertEquals(box, box.union(BBox.EMPTY)); + assertEquals(box, BBox.EMPTY.union(box)); + } + + @Test + @DisplayName("union covers both boxes") + void unionCoversBoth() { + BBox union = new BBox(0, 0, 10, 10).union(new BBox(20, 5, 30, 25)); + assertEquals(new BBox(0, 0, 30, 25), union); + } + + @Test + @DisplayName("reports horizontal overlap as a fraction of the narrower box") + void overlapIsRelative() { + BBox wide = new BBox(0, 0, 100, 10); + BBox narrow = new BBox(40, 0, 60, 10); + assertEquals(1.0f, wide.horizontalOverlap(narrow)); + assertEquals(0f, wide.horizontalOverlap(new BBox(200, 0, 220, 10))); + } + } + + @Nested + @DisplayName("structure blocks") + class Blocks { + + @Test + @DisplayName("counts content across the whole subtree") + void countsDescendantContent() { + StructBlock table = new StructBlock(StructType.TABLE, 0); + StructBlock row = new StructBlock(StructType.TR, 0); + StructBlock cell = new StructBlock(StructType.TD, 0); + cell.addRange(3, 5); + row.addChild(cell); + table.addChild(row); + assertEquals(3, table.contentCount()); + } + + @Test + @DisplayName("collects text in tree order") + void collectsText() { + StructBlock list = new StructBlock(StructType.L, 0); + StructBlock first = new StructBlock(StructType.LI, 0); + first.setText("one"); + StructBlock second = new StructBlock(StructType.LI, 0); + second.setText("two"); + list.addChild(first).addChild(second); + assertEquals("one two", list.collectText()); + } + + @Test + @DisplayName("an artifact is not a structure element") + void artifactsAreDistinct() { + StructBlock artifact = StructBlock.artifact(ArtifactType.PAGINATION, 0); + assertTrue(artifact.isArtifact()); + assertEquals("Pagination", artifact.getArtifactType().subtype()); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/VectorAndHeadingTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/VectorAndHeadingTest.java new file mode 100644 index 0000000000..aa1b13e3b9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/VectorAndHeadingTest.java @@ -0,0 +1,155 @@ +package stirling.software.proprietary.pdf.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Tests the heuristics that decide what counts as a drawing and what counts as a heading. */ +class VectorAndHeadingTest { + + private static final BBox A4 = new BBox(0, 0, 595, 842); + + private static TextLineInfo line(String text, float size, float x, float y) { + List words = new ArrayList<>(); + float cursor = x; + for (String token : text.strip().split("\\s+")) { + float width = token.length() * size * 0.5f; + words.add( + new WordInfo( + token, + new BBox(cursor, y, cursor + width, y + size), + 0, + 0, + size, + false)); + cursor += width + size * 0.3f; + } + return new TextLineInfo( + 0, text, new BBox(x, y, cursor, y + size), size, false, 0, 0, false, words); + } + + private static MarkableOp vector(int ordinal, BBox box) { + return new MarkableOp(ordinal, MarkableOp.Kind.VECTOR, box, null); + } + + private static DocumentStructure analyse(List lines, List ops) { + PageContent page = new PageContent(0, lines, ops, ops.size(), false, false, false, A4); + return new LayoutAnalyzer().analyse(List.of(page)); + } + + private static long countOf(DocumentStructure structure, StructType type) { + long[] total = {0}; + structure.visit( + block -> { + if (block.getType() == type) { + total[0]++; + } + }); + return total[0]; + } + + @Test + @DisplayName("a cluster of substantial strokes becomes a figure, not silent decoration") + void chartBecomesAFigure() { + List bars = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + bars.add(vector(i, new BBox(100 + i * 20, 400, 115 + i * 20, 400 + 30 + i * 10))); + } + DocumentStructure structure = analyse(List.of(), bars); + + assertTrue( + countOf(structure, StructType.FIGURE) > 0, + "a bar chart drawn with path operators must not vanish as decoration"); + assertTrue( + structure.figuresWithoutAlt().size() > 0, + "the report must say the chart needs a description"); + } + + @Test + @DisplayName("thin rules and table borders stay artifacts") + void tableRulesStayDecoration() { + List rules = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + rules.add(vector(i, new BBox(60, 700 - i * 20, 540, 701 - i * 20))); + } + DocumentStructure structure = analyse(List.of(), rules); + + assertEquals( + 0, + countOf(structure, StructType.FIGURE), + "horizontal rules are page furniture and must not demand alt text"); + assertEquals(0, structure.figuresWithoutAlt().size()); + } + + @Test + @DisplayName("a lone box is ornament, not a chart") + void singleBoxIsNotAFigure() { + DocumentStructure structure = + analyse(List.of(), List.of(vector(0, new BBox(60, 400, 500, 700)))); + assertEquals(0, countOf(structure, StructType.FIGURE)); + } + + @Test + @DisplayName("shaded table rows behind text are not mistaken for a chart") + void shadedTableRowsAreNotFigures() { + List shading = new ArrayList<>(); + List rows = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + float y = 600 - i * 20; + // A filled row background, tall enough to pass the thinness test. + shading.add(vector(i, new BBox(60, y, 540, y + 16))); + rows.add(line("Expense line item " + i + " amount", 10, 64, y + 3)); + } + DocumentStructure structure = analyse(rows, shading); + + assertEquals( + 0, + countOf(structure, StructType.FIGURE), + "row shading sits behind the text it decorates and is not a drawing"); + } + + @Test + @DisplayName("small print dominating an invoice does not promote addresses to headings") + void smallPrintDoesNotCreateHeadings() { + List lines = new ArrayList<>(); + // Address block at ordinary 11pt. + lines.add(line("Acme Industries Limited", 11, 60, 780)); + lines.add(line("14 Example Street", 11, 60, 765)); + lines.add(line("Manchester M1 2AB", 11, 60, 750)); + // 40 lines of 9pt line-item small print, which dominates the character count. + for (int i = 0; i < 40; i++) { + lines.add(line("Item " + i + " widget assembly part number " + i, 9, 60, 700 - i * 12)); + } + + Map tiers = + LayoutAnalyzer.headingTiers( + List.of(new PageContent(0, lines, List.of(), 0, false, false, false, A4)), + 9f); + assertNull( + tiers.get(11f), + "11pt address lines are body text on an invoice, not headings: " + tiers); + } + + @Test + @DisplayName("a genuinely rare large size is still a heading") + void realHeadingsSurvive() { + List lines = new ArrayList<>(); + lines.add(line("Annual Report", 24, 60, 780)); + for (int i = 0; i < 40; i++) { + lines.add( + line("Body prose line number " + i + " continues here", 11, 60, 700 - i * 12)); + } + Map tiers = + LayoutAnalyzer.headingTiers( + List.of(new PageContent(0, lines, List.of(), 0, false, false, false, A4)), + 11f); + assertEquals(1, tiers.get(24f), "a rare large size is exactly what a heading looks like"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/AltTextRoundTripTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/AltTextRoundTripTest.java new file mode 100644 index 0000000000..0f8e52d9ec --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/AltTextRoundTripTest.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.controller.api.converters.ConvertPdfToPdfUa; +import stirling.software.proprietary.model.api.ua.AccessibilityReport; +import stirling.software.proprietary.model.api.ua.FigureDescriptor; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** + * The alt-text loop end to end: the report hands out keys the conversion accepts. The converter + * never invents descriptions, so a caller must be able to supply them. + */ +class AltTextRoundTripTest { + + private static PdfUaConversionService conversion; + private static AccessibilityAuditService audit; + + @BeforeAll + static void setUp() { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + conversion = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + audit = new AccessibilityAuditService(validation); + } + + private static TaggingOptions.TaggingOptionsBuilder options() { + return TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Illustrated") + .embedFonts(false); + } + + @Test + @DisplayName("the report names the figures that need describing, with usable keys") + void reportEnumeratesFigures() throws Exception { + byte[] input = PdfUaTestDocuments.imageDocument(); + AccessibilityReport report = audit.audit(input, PdfUaProfile.UA1); + + assertFalse( + report.getFiguresNeedingDescription().isEmpty(), + "a document with an undescribed image must say which figure needs text"); + + FigureDescriptor figure = report.getFiguresNeedingDescription().get(0); + assertTrue(figure.key().matches("\\d+:\\d+"), "key should be pageIndex:ordinal: " + figure); + assertEquals(1, figure.page(), "pages are reported 1-based for humans"); + assertTrue(figure.width() > 0 && figure.height() > 0, "figure should carry its box"); + } + + @Test + @DisplayName("feeding the report's key back makes the document conform") + void suppliedDescriptionClosesTheLoop() throws Exception { + byte[] input = PdfUaTestDocuments.imageDocument(); + + PdfUaConversionOutcome before = conversion.convert(input, options().build()); + assertFalse(before.declared(), "an undescribed image must block the claim"); + + String key = + audit.audit(input, PdfUaProfile.UA1).getFiguresNeedingDescription().get(0).key(); + PdfUaConversionOutcome after = + conversion.convert( + input, options().altTextByFigure(Map.of(key, "A blue rectangle")).build()); + + assertEquals( + 0, + after.tagging().figuresNeedingAltText(), + "the description supplied against the report's own key was not applied"); + assertTrue(after.declared(), "with every figure described the document should conform"); + } + + @Test + @DisplayName("the request's key=text form parses the way the report emits keys") + void parsesTheWireFormat() { + Map parsed = + ConvertPdfToPdfUa.parseAltText( + "0:12=Bar chart of quarterly revenue\r\n" + + "1:3=Company logo\n" + + " \n" + + "malformed-line\n" + + "2:7=Diagram showing the approval flow = end to end"); + + assertEquals(3, parsed.size(), "blank and malformed lines are skipped: " + parsed); + assertEquals("Bar chart of quarterly revenue", parsed.get("0:12")); + assertEquals("Company logo", parsed.get("1:3")); + assertEquals( + "Diagram showing the approval flow = end to end", + parsed.get("2:7"), + "only the first equals splits, so descriptions may contain one"); + } + + @Test + @DisplayName("no descriptions supplied means none invented") + void emptyInputInventsNothing() { + assertTrue(ConvertPdfToPdfUa.parseAltText(null).isEmpty()); + assertTrue(ConvertPdfToPdfUa.parseAltText(" ").isEmpty()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUa2ProfileTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUa2ProfileTest.java new file mode 100644 index 0000000000..9cf342824f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUa2ProfileTest.java @@ -0,0 +1,92 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** PDF/UA-2 is not just a metadata number: it needs PDF 2.0 and namespaced structure types. */ +class PdfUa2ProfileTest { + + private static PdfUaConversionService conversion; + + @BeforeAll + static void setUp() { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + conversion = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + } + + private static PdfUaConversionOutcome convertUa2(byte[] input) throws Exception { + return conversion.convert( + input, + TaggingOptions.builder() + .profile(PdfUaProfile.UA2) + .language("en-GB") + .title("UA-2 Document") + .embedFonts(false) + .build()); + } + + @Test + @DisplayName("raises the file to PDF 2.0 and namespaces the structure tree") + void producesPdf2WithNamespaces() throws Exception { + PdfUaConversionOutcome outcome = convertUa2(PdfUaTestDocuments.headingHierarchy()); + + try (PDDocument document = Loader.loadPDF(outcome.pdfBytes())) { + assertEquals(2.0f, document.getVersion(), "UA-2 is defined on PDF 2.0"); + + var root = document.getDocumentCatalog().getStructureTreeRoot(); + assertNotNull(root, "no structure tree was written"); + assertNotNull( + root.getCOSObject().getDictionaryObject(COSName.getPDFName("Namespaces")), + "UA-2 requires the standard structure namespace to be declared"); + } + } + + @Test + @DisplayName("validates against the PDF/UA-2 profile, not the UA-1 one") + void validatesAgainstUa2() throws Exception { + PdfUaConversionOutcome outcome = convertUa2(PdfUaTestDocuments.simpleDocument()); + assertEquals("PDF/UA-2", outcome.validation().profile()); + } + + @Test + @DisplayName("reaches UA-2 conformance and declares it") + void reachesUa2Conformance() throws Exception { + PdfUaConversionOutcome outcome = convertUa2(PdfUaTestDocuments.simpleDocument()); + + String failures = + outcome.validation().issues().stream() + .map(issue -> issue.getClause() + ": " + issue.getTechnicalMessage()) + .collect(java.util.stream.Collectors.joining("; ")); + assertEquals(0, outcome.validation().totalFailures(), "UA-2 checks failed: " + failures); + assertTrue(outcome.declared(), "a conforming UA-2 file must carry the declaration"); + assertTrue(outcome.pdfBytes().length > 0); + } + + @Test + @DisplayName("an illustrated document still cannot claim UA-2 without descriptions") + void undescribedImageBlocksTheUa2Claim() throws Exception { + PdfUaConversionOutcome outcome = convertUa2(PdfUaTestDocuments.imageDocument()); + assertFalse(outcome.declared(), "an undescribed image must block the claim"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaBenchmarkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaBenchmarkTest.java new file mode 100644 index 0000000000..2c4ca6db2a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaBenchmarkTest.java @@ -0,0 +1,341 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.pdf.ua.DocumentStructure; +import stirling.software.proprietary.pdf.ua.LayoutAnalyzer; +import stirling.software.proprietary.pdf.ua.PageContent; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.PdfUaTagger; +import stirling.software.proprietary.pdf.ua.TaggedContentExtractor; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** + * Measures where conversion time and memory go; meant to be read, not to gate CI. Assertions catch + * only order-of-magnitude regressions - wall-clock numbers are no contract. + */ +class PdfUaBenchmarkTest { + + private static PdfUaConversionService service; + private static PdfUaValidationService validation; + + @BeforeAll + static void setUp() { + validation = new PdfUaValidationService(); + validation.initialise(); + service = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + } + + /** A realistic page: heading, prose, a small table, a bullet list. */ + private static byte[] document(int pages) throws IOException { + try (PDDocument document = new PDDocument()) { + PDFont font = null; + for (int p = 0; p < pages; p++) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + if (font == null) { + font = PdfUaTestDocuments.font(document); + } + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 790; + write(cs, font, 9, 60, 810, "Benchmark Corpus Running Head"); + write(cs, font, 18, 60, y, "Section " + (p + 1)); + y -= 30; + for (int line = 0; line < 22; line++) { + write( + cs, + font, + 11, + 60, + y, + "Body line " + line + " of section " + (p + 1) + " with prose."); + y -= 15; + } + for (int row = 0; row < 4; row++) { + cs.beginText(); + cs.setFont(font, 11); + cs.newLineAtOffset(60, y); + cs.showText("Row " + row); + cs.newLineAtOffset(160, 0); + cs.showText(String.valueOf(row * 120)); + cs.newLineAtOffset(140, 0); + cs.showText(String.valueOf(row * 480)); + cs.endText(); + y -= 16; + } + write(cs, font, 11, 60, y - 10, "• First bullet point"); + write(cs, font, 11, 60, y - 25, "• Second bullet point"); + write(cs, font, 9, 300, 30, "Page " + (p + 1)); + } + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private static void write( + PDPageContentStream cs, PDFont font, float size, float x, float y, String text) + throws IOException { + cs.beginText(); + cs.setFont(font, size); + cs.newLineAtOffset(x, y); + cs.showText(text); + cs.endText(); + } + + private static TaggingOptions options() { + return TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Benchmark") + .embedFonts(false) + .existingTags(TaggingOptions.ExistingTags.REBUILD) + .build(); + } + + private static long usedHeap() { + Runtime runtime = Runtime.getRuntime(); + System.gc(); + return runtime.totalMemory() - runtime.freeMemory(); + } + + @Test + @DisplayName("reports throughput and memory across document sizes") + void throughputAcrossSizes() throws Exception { + int[] sizes = {1, 10, 50, 150}; + StringBuilder report = + new StringBuilder("\nPDF/UA conversion throughput\n") + .append( + String.format( + " %-7s %-10s %-12s %-12s %-10s %s%n", + "pages", + "input", + "convert ms", + "ms/page", + "pages/s", + "heap MB")); + + // Warm up so the first timed run is not measuring class loading and JIT. + service.convert(document(5), options()); + + for (int pages : sizes) { + byte[] input = document(pages); + long heapBefore = usedHeap(); + long start = System.nanoTime(); + var outcome = service.convert(input, options()); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + long heapDelta = (usedHeap() - heapBefore) / (1024 * 1024); + + assertTrue(outcome.pdfBytes().length > 0); + report.append( + String.format( + Locale.ROOT, + " %-7d %-10s %-12d %-12.2f %-10.1f %d%n", + pages, + humanBytes(input.length), + elapsedMs, + elapsedMs / (double) pages, + pages * 1000.0 / Math.max(elapsedMs, 1), + Math.max(heapDelta, 0))); + } + System.out.println(report); + } + + @Test + @DisplayName("breaks conversion down by phase so optimisation has a target") + void phaseBreakdown() throws Exception { + byte[] input = document(60); + + // Warm up. + try (PDDocument warm = Loader.loadPDF(input)) { + new TaggedContentExtractor().extract(warm); + } + + long parseMs; + long extractMs; + long analyseMs; + long tagMs; + List pages; + DocumentStructure structure; + + long t0 = System.nanoTime(); + try (PDDocument document = Loader.loadPDF(input)) { + parseMs = ms(t0); + + long t1 = System.nanoTime(); + pages = new TaggedContentExtractor().extract(document); + extractMs = ms(t1); + + long t2 = System.nanoTime(); + structure = new LayoutAnalyzer().analyse(pages); + analyseMs = ms(t2); + } + + long t3 = System.nanoTime(); + try (PDDocument document = Loader.loadPDF(input)) { + new PdfUaTagger().tag(document, options()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + } + tagMs = ms(t3); + + long t4 = System.nanoTime(); + var outcome = service.convert(input, options()); + long totalMs = ms(t4); + + long t5 = System.nanoTime(); + validation.validate(outcome.pdfBytes(), PdfUaProfile.UA1); + long validateMs = ms(t5); + + System.out.printf( + Locale.ROOT, + "%nPhase breakdown over %d pages (%d blocks)%n" + + " parse %5d ms%n" + + " extract %5d ms (text pass + token scan)%n" + + " analyse %5d ms%n" + + " tag end-to-end %5d ms (includes parse, extract, analyse, inject, write)%n" + + " validate %5d ms (veraPDF)%n" + + " full convert %5d ms (tag + declare + validate)%n", + 60, + structure.getBlocks().size(), + parseMs, + extractMs, + analyseMs, + tagMs, + validateMs, + totalMs); + + assertTrue(pages.size() == 60, "extractor lost pages"); + } + + @Test + @DisplayName("splits the tagging pass into its own sub-phases") + void taggingSubPhases() throws Exception { + byte[] input = document(60); + try (PDDocument warm = Loader.loadPDF(input)) { + new TaggedContentExtractor().extract(warm); + } + + long extractMs; + long analyseMs; + long injectMs; + long treeMs; + long saveMs; + + try (PDDocument document = Loader.loadPDF(input)) { + long t = System.nanoTime(); + List pages = new TaggedContentExtractor().extract(document); + extractMs = ms(t); + + t = System.nanoTime(); + DocumentStructure structure = new LayoutAnalyzer().analyse(pages); + analyseMs = ms(t); + + t = System.nanoTime(); + var injector = new stirling.software.proprietary.pdf.ua.MarkedContentInjector(); + var byPage = + new java.util.LinkedHashMap< + Integer, List>(); + structure + .getBlocks() + .forEach( + b -> + byPage.computeIfAbsent(b.getPageIndex(), k -> new ArrayList<>()) + .add(b)); + for (int p = 0; p < document.getNumberOfPages(); p++) { + injector.inject( + document, document.getPage(p), byPage.getOrDefault(p, List.of()), 0, true); + } + injectMs = ms(t); + + t = System.nanoTime(); + new stirling.software.proprietary.pdf.ua.StructTreeWriter() + .write(document, structure, PdfUaProfile.UA1); + treeMs = ms(t); + + t = System.nanoTime(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saveMs = ms(t); + } + + System.out.printf( + Locale.ROOT, + "%nTagging sub-phases over 60 pages%n" + + " extract %5d ms%n" + + " analyse %5d ms%n" + + " inject %5d ms%n" + + " struct tree %5d ms%n" + + " save %5d ms%n", + extractMs, + analyseMs, + injectMs, + treeMs, + saveMs); + } + + @Test + @DisplayName("memory stays proportional to document size, not quadratic") + void memoryScales() throws Exception { + List rows = new ArrayList<>(); + long previousPerPage = 0; + boolean blewUp = false; + + for (int pages : new int[] {20, 80, 200}) { + byte[] input = document(pages); + long before = usedHeap(); + var outcome = service.convert(input, options()); + long after = usedHeap(); + long perPageKb = Math.max(after - before, 0) / 1024 / pages; + rows.add( + String.format( + Locale.ROOT, + " %-6d pages in %-9s out %-9s ~%d KB/page retained", + pages, + humanBytes(input.length), + humanBytes(outcome.pdfBytes().length), + perPageKb)); + // Per-page cost should stay roughly flat; a big jump means something accumulates. + if (previousPerPage > 0 && perPageKb > previousPerPage * 4 && perPageKb > 200) { + blewUp = true; + } + previousPerPage = Math.max(perPageKb, 1); + } + System.out.println("\nMemory scaling\n" + String.join("\n", rows)); + assertTrue(!blewUp, "per-page memory grew superlinearly: " + rows); + } + + private static long ms(long startNanos) { + return (System.nanoTime() - startNanos) / 1_000_000; + } + + private static String humanBytes(int bytes) { + return bytes < 1024 * 1024 + ? (bytes / 1024) + " KB" + : String.format(Locale.ROOT, "%.1f MB", bytes / 1024.0 / 1024.0); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaConversionIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaConversionIntegrationTest.java new file mode 100644 index 0000000000..e785957b7b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaConversionIntegrationTest.java @@ -0,0 +1,231 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.model.api.ua.AccessibilityIssue; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.model.api.ua.UaValidationResult; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** + * End-to-end conversion over the fixture corpus, validated with veraPDF. The corpus is deliberately + * varied: what breaks a tagger is rarely the simple case. + */ +class PdfUaConversionIntegrationTest { + + private static PdfUaConversionService service; + + @BeforeAll + static void setUp() { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + service = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + } + + /** Fixtures already embed fonts, so the Ghostscript pass is off to keep tests hermetic. */ + private static TaggingOptions options() { + return TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Test Document") + .embedFonts(false) + .build(); + } + + private static PdfUaConversionOutcome convert(byte[] input) throws IOException { + return service.convert(input, options()); + } + + private static String extractText(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setSortByPosition(true); + return PdfUaRealCorpusTest.normalise(stripper.getText(document)); + } + } + + @Test + @DisplayName("every fixture converts without error and gains a structure tree") + void corpusConverts() throws Exception { + Map> corpus = corpus(); + StringBuilder report = new StringBuilder("\nPDF/UA conversion over the fixture corpus\n"); + + for (Map.Entry> entry : corpus.entrySet()) { + byte[] input = + entry.getKey().equals("empty") + ? entry.getValue().call() + : entry.getValue().call(); + PdfUaConversionOutcome outcome = convert(input); + + assertNotNull(outcome.pdfBytes(), entry.getKey() + " produced no output"); + report.append( + String.format( + " %-18s declared=%-5s failures=%-3d elements=%-3d artifacts=%-3d altNeeded=%d%n", + entry.getKey(), + outcome.declared(), + outcome.validation().totalFailures(), + outcome.tagging().taggedElements(), + outcome.tagging().artifacts(), + outcome.tagging().figuresNeedingAltText())); + for (AccessibilityIssue issue : outcome.validation().issues()) { + report.append( + String.format( + " clause %-6s x%-4d %s%n", + issue.getClause(), + issue.getOccurrences(), + issue.getTechnicalMessage())); + } + } + System.out.println(report); + } + + @Test + @DisplayName("tagging never changes the text content of a page") + void textIsPreserved() throws Exception { + for (Map.Entry> entry : corpus().entrySet()) { + byte[] input = entry.getValue().call(); + String before = extractText(input); + String after = extractText(convert(input).pdfBytes()); + assertEquals(before, after, "text changed for fixture " + entry.getKey()); + } + } + + @Test + @DisplayName("a simple document gains a structure tree with headings and paragraphs") + void simpleDocumentIsTagged() throws Exception { + PdfUaConversionOutcome outcome = convert(PdfUaTestDocuments.simpleDocument()); + try (PDDocument document = Loader.loadPDF(outcome.pdfBytes())) { + assertNotNull( + document.getDocumentCatalog().getStructureTreeRoot(), + "no structure tree was written"); + assertTrue( + document.getDocumentCatalog().getMarkInfo() != null + && document.getDocumentCatalog().getMarkInfo().isMarked(), + "MarkInfo/Marked was not set"); + assertEquals("en-GB", document.getDocumentCatalog().getLanguage()); + assertEquals("Test Document", document.getDocumentInformation().getTitle()); + } + assertTrue(outcome.tagging().taggedElements() > 0, "nothing was tagged"); + } + + @Test + @DisplayName("running heads and page numbers become artifacts, not content") + void runningHeadersBecomeArtifacts() throws Exception { + PdfUaConversionOutcome outcome = convert(PdfUaTestDocuments.runningHeadersDocument()); + assertTrue( + outcome.tagging().artifacts() >= 4, + "expected the repeated header on each page to become an artifact, got " + + outcome.tagging().artifacts()); + } + + @Test + @DisplayName("an image is tagged as a figure and reported as needing alt text") + void imagesNeedAltText() throws Exception { + PdfUaConversionOutcome outcome = convert(PdfUaTestDocuments.imageDocument()); + assertEquals(1, outcome.tagging().figuresNeedingAltText()); + assertFalse( + outcome.declared(), + "a document with an undescribed image must not claim conformance"); + } + + @Test + @DisplayName("supplying alt text lets an illustrated document conform") + void suppliedAltTextIsApplied() throws Exception { + byte[] input = PdfUaTestDocuments.imageDocument(); + PdfUaConversionOutcome probe = convert(input); + assertEquals(1, probe.tagging().figuresNeedingAltText()); + + TaggingOptions withAlt = + options().toBuilder() + .altTextByFigure(Map.of(figureKey(input), "A blue rectangle")) + .build(); + PdfUaConversionOutcome outcome = service.convert(input, withAlt); + assertEquals( + 0, + outcome.tagging().figuresNeedingAltText(), + "alt text supplied by the caller was not applied"); + } + + @Test + @DisplayName("an empty document does not crash the converter") + void emptyDocumentIsHandled() throws Exception { + PdfUaConversionOutcome outcome = convert(PdfUaTestDocuments.emptyDocument()); + assertNotNull(outcome.pdfBytes()); + assertFalse(outcome.warnings().isEmpty(), "an empty document should warn"); + } + + @Test + @DisplayName("an un-OCRed scan is reported rather than silently declared conformant") + void scannedDocumentIsNotDeclared() throws Exception { + PdfUaConversionOutcome outcome = convert(PdfUaTestDocuments.scannedDocument()); + assertFalse(outcome.declared(), "a scan with no text layer must not claim conformance"); + } + + @Test + @DisplayName("validation of an untagged document reports the missing structure") + void untaggedDocumentFailsValidation() throws Exception { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + UaValidationResult result = + validation.validate(PdfUaTestDocuments.simpleDocument(), PdfUaProfile.UA1); + assertFalse(result.compliant(), "an untagged document cannot be PDF/UA compliant"); + assertTrue(result.hasIssues()); + } + + /** The key the tagger uses for figure alt text is "pageIndex:firstOrdinal". */ + private static String figureKey(byte[] input) throws IOException { + try (PDDocument document = Loader.loadPDF(input)) { + var pages = + new stirling.software.proprietary.pdf.ua.TaggedContentExtractor() + .extract(document); + for (var page : pages) { + for (var op : page.graphics()) { + return page.pageIndex() + ":" + op.ordinal(); + } + } + } + return "0:0"; + } + + private static Map> corpus() { + Map> corpus = new LinkedHashMap<>(); + corpus.put("simple", PdfUaTestDocuments::simpleDocument); + corpus.put("headings", PdfUaTestDocuments::headingHierarchy); + corpus.put("lists", PdfUaTestDocuments::listDocument); + corpus.put("table", PdfUaTestDocuments::tableDocument); + corpus.put("image", PdfUaTestDocuments::imageDocument); + corpus.put("runningHeads", PdfUaTestDocuments::runningHeadersDocument); + corpus.put("twoColumn", PdfUaTestDocuments::twoColumnDocument); + corpus.put("link", PdfUaTestDocuments::linkDocument); + corpus.put("empty", PdfUaTestDocuments::emptyDocument); + corpus.put("scanned", PdfUaTestDocuments::scannedDocument); + corpus.put("formXObject", PdfUaTestDocuments::formXObjectDocument); + corpus.put("multiStream", PdfUaTestDocuments::multiStreamDocument); + corpus.put("rotated", PdfUaTestDocuments::rotatedDocument); + corpus.put("offsetMediaBox", PdfUaTestDocuments::offsetMediaBoxDocument); + return corpus; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHardeningTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHardeningTest.java new file mode 100644 index 0000000000..cf72013a13 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHardeningTest.java @@ -0,0 +1,322 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.pdf.ua.PageContent; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.StructBlock; +import stirling.software.proprietary.pdf.ua.StructType; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** Covers the document shapes and failure modes the first round of tests missed. */ +class PdfUaHardeningTest { + + private static PdfUaConversionService service; + + @BeforeAll + static void setUp() { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + service = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + } + + private static PdfUaConversionOutcome convert(byte[] input) throws IOException { + return service.convert( + input, + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Hardening") + .embedFonts(false) + .build()); + } + + private static String extract(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setSortByPosition(true); + return PdfUaRealCorpusTest.normalise(stripper.getText(document)); + } + } + + @Nested + @DisplayName("document shapes") + class Shapes { + + @Test + @DisplayName("text inside a form XObject is attributed to the Do and tagged as prose") + void formXObjectTextIsTagged() throws Exception { + byte[] input = PdfUaTestDocuments.formXObjectDocument(); + assertTrue( + extract(input).contains("inside the form XObject"), + "fixture must actually draw text inside a form"); + + PdfUaConversionOutcome outcome = convert(input); + assertTrue(outcome.declared(), warnings(outcome)); + assertEquals(extract(input), extract(outcome.pdfBytes())); + assertEquals( + 0, + outcome.tagging().figuresNeedingAltText(), + "a form whose text is reachable must not degrade to an undescribed figure"); + } + + @Test + @DisplayName("a page built from multiple content streams converts as one sequence") + void multiStreamPageConverts() throws Exception { + byte[] input = PdfUaTestDocuments.multiStreamDocument(); + PdfUaConversionOutcome outcome = convert(input); + assertTrue(outcome.declared(), warnings(outcome)); + assertEquals(extract(input), extract(outcome.pdfBytes())); + } + + @Test + @DisplayName("a rotated page keeps its text and converts") + void rotatedPageConverts() throws Exception { + byte[] input = PdfUaTestDocuments.rotatedDocument(); + PdfUaConversionOutcome outcome = convert(input); + assertEquals(extract(input), extract(outcome.pdfBytes())); + assertTrue(outcome.tagging().taggedElements() > 0, "rotated text was not tagged"); + } + + @Test + @DisplayName("a MediaBox that does not start at the origin does not break analysis") + void offsetMediaBoxConverts() throws Exception { + byte[] input = PdfUaTestDocuments.offsetMediaBoxDocument(); + PdfUaConversionOutcome outcome = convert(input); + assertTrue(outcome.declared(), warnings(outcome)); + assertEquals(extract(input), extract(outcome.pdfBytes())); + } + + private static String warnings(PdfUaConversionOutcome outcome) { + return "warnings: " + + String.join(" | ", outcome.warnings()) + + " issues: " + + outcome.validation().issues(); + } + } + + @Nested + @DisplayName("pre-marked content") + class PreMarked { + + @Test + @DisplayName("existing BDC/EMC operators are stripped before new ones are written") + void stripsExistingMarkedContent() throws Exception { + byte[] premarked = premarkedDocument(); + PdfUaConversionOutcome outcome = convert(premarked); + + try (PDDocument document = Loader.loadPDF(outcome.pdfBytes())) { + Counts counts = countMarkedContent(document.getPage(0)); + assertEquals( + counts.opens(), + counts.closes(), + "unbalanced marked content after stripping and re-injection"); + assertFalse( + contentOf(document).contains("/OldTag"), + "the source's own marked content survived the rebuild"); + } + assertEquals(extract(premarked), extract(outcome.pdfBytes())); + } + + /** A document whose stream already contains a BDC sequence under a custom tag. */ + private static byte[] premarkedDocument() throws Exception { + byte[] plain = PdfUaTestDocuments.simpleDocument(); + try (PDDocument document = Loader.loadPDF(plain)) { + PDPage page = document.getPage(0); + String content; + try (InputStream in = page.getContents()) { + content = new String(in.readAllBytes(), StandardCharsets.ISO_8859_1); + } + String wrapped = "/OldTag <> BDC\n" + content + "\nEMC\n"; + var stream = new org.apache.pdfbox.pdmodel.common.PDStream(document); + try (var out = stream.createOutputStream()) { + out.write(wrapped.getBytes(StandardCharsets.ISO_8859_1)); + } + page.setContents(stream); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private record Counts(int opens, int closes) {} + + private static Counts countMarkedContent(PDPage page) throws IOException { + int opens = 0; + int closes = 0; + PDFStreamParser parser = new PDFStreamParser(page); + Object token; + while ((token = parser.parseNextToken()) != null) { + if (token instanceof Operator operator) { + switch (operator.getName()) { + case "BDC", "BMC" -> opens++; + case "EMC" -> closes++; + default -> {} + } + } + } + return new Counts(opens, closes); + } + + private static String contentOf(PDDocument document) throws IOException { + try (InputStream in = document.getPage(0).getContents()) { + return new String(in.readAllBytes(), StandardCharsets.ISO_8859_1); + } + } + } + + @Nested + @DisplayName("honesty rules") + class Honesty { + + @Test + @DisplayName("suppressed text blocks the conformance claim even when validation passes") + void suppressedTextBlocksDeclaration() { + var structure = new stirling.software.proprietary.pdf.ua.DocumentStructure(); + structure.setTextSuppressed(true); + var result = new stirling.software.proprietary.pdf.ua.TaggingResult(structure, true); + assertTrue( + result.isContentSuppressed(), + "the suppression flag must survive into the tagging result"); + } + + @Test + @DisplayName("dropped lines are reported per page by the analyser") + void analyserWarnsOnDroppedLines() { + PageContent dropped = + new PageContent( + 0, + List.of(), + List.of(), + 5, + false, + false, + true, + new stirling.software.proprietary.pdf.ua.BBox(0, 0, 595, 842)); + var structure = + new stirling.software.proprietary.pdf.ua.LayoutAnalyzer() + .analyse(List.of(dropped)); + assertTrue(structure.isTextSuppressed()); + assertTrue( + structure.getWarnings().stream().anyMatch(w -> w.contains("page(s) 1")), + "warning should name the affected page: " + structure.getWarnings()); + } + } + + @Nested + @DisplayName("clause table") + class Clauses { + + @Test + @DisplayName("subclauses resolve to their parent entry, not to a string prefix") + void subclauseLookupWalksSegments() { + assertNotNull(PdfUaValidationService.lookupClause("7.21.4.1"), "7.21.4.1 -> 7.21"); + assertNotNull(PdfUaValidationService.lookupClause("7.18.1"), "7.18.1 -> 7.18"); + var toUnicode = PdfUaValidationService.lookupClause("7.21.7"); + assertNotNull(toUnicode); + assertFalse( + toUnicode.autoFixable(), + "a missing ToUnicode map is not fixable by embedding fonts"); + assertNull(PdfUaValidationService.lookupClause("9.9.9")); + assertNull(PdfUaValidationService.lookupClause(null)); + } + } + + @Nested + @DisplayName("range claiming") + class Claiming { + + @Test + @DisplayName("a figure drawn between two text runs on one line stays a figure") + void interleavedFigureIsNotSwallowed() throws Exception { + // Words at ordinals 0 and 2 with an image at ordinal 1 between them. + var line = + new stirling.software.proprietary.pdf.ua.TextLineInfo( + 0, + "left right", + new stirling.software.proprietary.pdf.ua.BBox(50, 700, 400, 712), + 11, + false, + 0, + 2, + false, + List.of( + new stirling.software.proprietary.pdf.ua.WordInfo( + "left", + new stirling.software.proprietary.pdf.ua.BBox( + 50, 700, 90, 712), + 0, + 0, + 11, + false), + new stirling.software.proprietary.pdf.ua.WordInfo( + "right", + new stirling.software.proprietary.pdf.ua.BBox( + 360, 700, 400, 712), + 2, + 2, + 11, + false))); + var image = + new stirling.software.proprietary.pdf.ua.MarkableOp( + 1, + stirling.software.proprietary.pdf.ua.MarkableOp.Kind.IMAGE, + new stirling.software.proprietary.pdf.ua.BBox(150, 650, 350, 760), + "Im0"); + PageContent page = + new PageContent( + 0, + List.of(line), + List.of(image), + 3, + false, + false, + false, + new stirling.software.proprietary.pdf.ua.BBox(0, 0, 595, 842)); + + var structure = + new stirling.software.proprietary.pdf.ua.LayoutAnalyzer() + .analyse(List.of(page)); + List figures = new java.util.ArrayList<>(); + structure.visit( + block -> { + if (block.getType() == StructType.FIGURE) { + figures.add(block); + } + }); + assertEquals( + 1, + figures.size(), + "the image between the words must survive as its own figure"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHttpEndpointTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHttpEndpointTest.java new file mode 100644 index 0000000000..90c40af82b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHttpEndpointTest.java @@ -0,0 +1,183 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.controller.api.converters.ConvertPdfToPdfUa; +import stirling.software.proprietary.controller.api.security.AccessibilityReportController; + +/** + * Exercises the endpoints over HTTP, not through the service layer. Covers route mapping, multipart + * binding, and the headers and JSON a client depends on. + */ +class PdfUaHttpEndpointTest { + + private static MockMvc convertMvc; + private static MockMvc reportMvc; + private static final ObjectMapper JSON = new ObjectMapper(); + + @BeforeAll + static void setUp() throws Exception { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + PdfUaConversionService conversion = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + + // A real TempFileManager, so the streamed response path is exercised rather than mocked. + TempFileManager tempFiles = + new TempFileManager( + new stirling.software.common.util.TempFileRegistry(), + new stirling.software.common.model.ApplicationProperties()); + + // Stand in for the app's advice, which lives in core; without one every rejection is a 500. + var advice = new BadRequestAdvice(); + convertMvc = + MockMvcBuilders.standaloneSetup(new ConvertPdfToPdfUa(conversion, tempFiles)) + .setControllerAdvice(advice) + .build(); + reportMvc = + MockMvcBuilders.standaloneSetup( + new AccessibilityReportController( + new AccessibilityAuditService(validation))) + .setControllerAdvice(advice) + .build(); + } + + private static MockMultipartFile upload(byte[] pdf, String name) { + return new MockMultipartFile("fileInput", name, "application/pdf", pdf); + } + + /** Mirrors the one rule these endpoints rely on: a rejected input is a 400, not a 500. */ + @org.springframework.web.bind.annotation.RestControllerAdvice + static class BadRequestAdvice { + @org.springframework.web.bind.annotation.ExceptionHandler(IllegalArgumentException.class) + org.springframework.http.ResponseEntity badRequest(IllegalArgumentException ex) { + return org.springframework.http.ResponseEntity.badRequest().body(ex.getMessage()); + } + } + + @Test + @DisplayName("POST /api/v1/convert/pdf/ua returns a PDF and reports what it did in headers") + void conversionEndpointResponds() throws Exception { + MvcResult result = + convertMvc + .perform( + multipart("/api/v1/convert/pdf/ua") + .file(upload(PdfUaTestDocuments.simpleDocument(), "in.pdf")) + .param("language", "en-GB") + .param("title", "Over The Wire") + .param("embedFonts", "false")) + .andExpect(status().isOk()) + .andExpect(header().exists("X-Stirling-UA-Declared")) + .andExpect(header().exists("X-Stirling-UA-Failures")) + .andReturn(); + + byte[] body = result.getResponse().getContentAsByteArray(); + assertTrue(body.length > 0, "no document came back"); + assertEquals( + "%PDF", + new String(body, 0, 4, java.nio.charset.StandardCharsets.ISO_8859_1), + "the response body is not a PDF"); + assertEquals( + "true", + result.getResponse().getHeader("X-Stirling-UA-Declared"), + "a simple embedded-font document should convert and be declared conformant"); + } + + @Test + @DisplayName("POST /api/v1/security/accessibility-report returns the figure inventory as JSON") + void reportEndpointResponds() throws Exception { + MvcResult result = + reportMvc + .perform( + multipart("/api/v1/security/accessibility-report") + .file(upload(PdfUaTestDocuments.imageDocument(), "in.pdf")) + .param("profile", "ua1")) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode json = JSON.readTree(result.getResponse().getContentAsString()); + assertEquals("PDF/UA-1", json.get("profile").asText()); + assertNotNull(json.get("summary"), "the report should carry a summary"); + + JsonNode figures = json.get("figuresNeedingDescription"); + assertNotNull(figures, "the field a caller needs to supply alt text is missing"); + assertTrue(figures.isArray() && figures.size() > 0, "the image should be listed: " + json); + assertTrue( + figures.get(0).get("key").asText().matches("\\d+:\\d+"), + "the key must be usable in a follow-up conversion request"); + } + + @Test + @DisplayName("alt text supplied as form data reaches the converter over HTTP") + void altTextBindsFromFormData() throws Exception { + byte[] input = PdfUaTestDocuments.imageDocument(); + + // Discover the key the way a client would, through the report endpoint. + MvcResult reported = + reportMvc + .perform( + multipart("/api/v1/security/accessibility-report") + .file(upload(input, "in.pdf"))) + .andExpect(status().isOk()) + .andReturn(); + String key = + JSON.readTree(reported.getResponse().getContentAsString()) + .get("figuresNeedingDescription") + .get(0) + .get("key") + .asText(); + + MvcResult converted = + convertMvc + .perform( + multipart("/api/v1/convert/pdf/ua") + .file(upload(input, "in.pdf")) + .param("embedFonts", "false") + .param("altText", key + "=A blue rectangle")) + .andExpect(status().isOk()) + .andReturn(); + + assertEquals( + "0", + converted.getResponse().getHeader("X-Stirling-UA-Figures-Needing-Alt"), + "the description posted as form data was not applied"); + } + + @Test + @DisplayName("a request with no file is rejected rather than processed") + void missingFileIsRejected() throws Exception { + convertMvc + .perform( + multipart("/api/v1/convert/pdf/ua") + .file( + new MockMultipartFile( + "fileInput", + "e.pdf", + "application/pdf", + new byte[0]))) + .andExpect(status().is4xxClientError()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaRealCorpusTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaRealCorpusTest.java new file mode 100644 index 0000000000..9ebda08736 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaRealCorpusTest.java @@ -0,0 +1,249 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** + * Runs the converter over every PDF in the repository, where real-tool output breaks assumptions. A + * clean refusal counts as a pass; nothing may crash or make a false conformance claim. + */ +class PdfUaRealCorpusTest { + + private static PdfUaConversionService service; + private static Path repoRoot; + + /** Files the converter is expected to refuse rather than process. */ + private static final List EXPECTED_REJECTS = List.of("encrypted.pdf", "corrupted.pdf"); + + @BeforeAll + static void setUp() { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + service = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + repoRoot = findRepoRoot(); + } + + private static Path findRepoRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } + + private record Outcome(String name, String status, int failures, int elements, int artifacts) {} + + private static TaggingOptions.TaggingOptionsBuilder options(String fallbackTitle) { + return TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .fallbackTitle(fallbackTitle) + .existingTags(TaggingOptions.ExistingTags.REBUILD); + } + + @Test + @DisplayName("converts every PDF in the repository without crashing or lying about conformance") + void realCorpusConverts() throws Exception { + assertNotNull(repoRoot, "could not locate the repository root"); + List pdfs = findPdfs(); + assertTrue(pdfs.size() >= 20, "expected a substantial corpus, found " + pdfs.size()); + + List outcomes = new ArrayList<>(); + List crashes = new ArrayList<>(); + java.util.Map clauseFiles = new java.util.TreeMap<>(); + java.util.Map clauseText = new java.util.HashMap<>(); + java.util.Map clauseExamples = new java.util.HashMap<>(); + + for (Path pdf : pdfs) { + String name = repoRoot.relativize(pdf).toString().replace('\\', '/'); + byte[] input; + try { + input = Files.readAllBytes(pdf); + } catch (IOException e) { + continue; + } + try { + String stem = pdf.getFileName().toString().replaceFirst("\\.pdf$", ""); + + // Fidelity is a tagging property, so measure it with font embedding off. + PdfUaConversionOutcome taggedOnly = + service.convert(input, options(stem).embedFonts(false).build()); + assertTextPreserved(name, input, taggedOnly.pdfBytes()); + + PdfUaConversionOutcome outcome = service.convert(input, options(stem).build()); + // Full pipeline too: Ghostscript can exit 0 having blanked the document. + assertTextPreserved(name + " (with font embedding)", input, outcome.pdfBytes()); + outcomes.add( + new Outcome( + name, + outcome.declared() ? "CONFORMS" : "improved", + outcome.validation().totalFailures(), + outcome.tagging().taggedElements(), + outcome.tagging().artifacts())); + outcome.validation() + .issues() + .forEach( + issue -> { + clauseFiles.merge(issue.getClause(), 1, Integer::sum); + clauseText.putIfAbsent( + issue.getClause(), issue.getTechnicalMessage()); + clauseExamples.putIfAbsent(issue.getClause(), name); + }); + + } catch (IOException e) { + // A refusal with an explanation is an acceptable outcome. + outcomes.add(new Outcome(name, "refused: " + e.getMessage(), 0, 0, 0)); + } catch (RuntimeException e) { + crashes.add(name + " -> " + e); + outcomes.add(new Outcome(name, "CRASH: " + e, 0, 0, 0)); + } + } + + System.out.println(render(outcomes)); + + StringBuilder clauses = + new StringBuilder("\nBlocking clauses, by number of files affected\n"); + clauseFiles.entrySet().stream() + .sorted(java.util.Map.Entry.comparingByValue().reversed()) + .forEach( + e -> + clauses.append( + String.format( + " clause %-9s %-3d files e.g. %s%n %s%n", + e.getKey(), + e.getValue(), + clauseExamples.get(e.getKey()), + abbreviate(clauseText.get(e.getKey()))))); + System.out.println(clauses); + + List unexpectedCrashes = + crashes.stream() + .filter(c -> EXPECTED_REJECTS.stream().noneMatch(c::contains)) + .toList(); + assertTrue( + unexpectedCrashes.isEmpty(), + "converter crashed on: " + String.join("; ", unexpectedCrashes)); + } + + /** Tagging must not change extracted text; a diff means the rewrite corrupted the page. */ + private static void assertTextPreserved(String name, byte[] before, byte[] after) { + String textBefore = safeExtract(before); + if (textBefore == null) { + // The source itself is unreadable, so there is nothing to compare against. + return; + } + String textAfter = safeExtract(after); + assertTrue( + textAfter != null, "the converted file could not be read back at all for " + name); + assertTrue( + textBefore.equals(textAfter), + "tagging changed extracted text for " + + name + + "\n before: " + + preview(textBefore) + + "\n after: " + + preview(textAfter)); + } + + private static String abbreviate(String text) { + if (text == null) { + return ""; + } + return text.length() <= 110 ? text : text.substring(0, 110) + "..."; + } + + private static String preview(String text) { + return text.length() <= 160 ? text : text.substring(0, 160) + "..."; + } + + private static String safeExtract(byte[] pdf) { + try (PDDocument document = Loader.loadPDF(pdf)) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setSortByPosition(true); + return normalise(stripper.getText(document)); + } catch (Exception e) { + return null; + } + } + + /** Drops invisible formatting characters: a rebuild legitimately loses soft hyphens. */ + static String normalise(String text) { + StringBuilder sb = new StringBuilder(text.length()); + text.codePoints() + .forEach( + cp -> { + if (Character.getType(cp) != Character.FORMAT && cp != 0x00AD) { + sb.appendCodePoint(cp); + } + }); + return sb.toString().replaceAll("\\s+", " ").strip(); + } + + private List findPdfs() throws IOException { + try (Stream stream = Files.walk(repoRoot)) { + return stream.filter(Files::isRegularFile) + .filter(p -> p.toString().toLowerCase().endsWith(".pdf")) + .filter(p -> !p.toString().contains("node_modules")) + .filter(p -> !p.toString().contains(File_BUILD)) + .filter(p -> !p.toString().contains(".git")) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + } + } + + private static final String File_BUILD = "build" + java.io.File.separator; + + private static String render(List outcomes) { + StringBuilder sb = new StringBuilder("\nPDF/UA conversion over the repository corpus\n"); + long conforming = outcomes.stream().filter(o -> "CONFORMS".equals(o.status())).count(); + long refused = outcomes.stream().filter(o -> o.status().startsWith("refused")).count(); + sb.append( + String.format( + " %d files: %d conform, %d improved but not conformant, %d refused%n%n", + outcomes.size(), + conforming, + outcomes.size() - conforming - refused, + refused)); + for (Outcome outcome : outcomes) { + sb.append( + String.format( + " %-62s %-10s fail=%-4d el=%-5d art=%d%n", + outcome.name().length() > 60 + ? "..." + outcome.name().substring(outcome.name().length() - 57) + : outcome.name(), + outcome.status().length() > 10 + ? outcome.status().substring(0, 10) + : outcome.status(), + outcome.failures(), + outcome.elements(), + outcome.artifacts())); + } + return sb.toString(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaSampleDumpTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaSampleDumpTest.java new file mode 100644 index 0000000000..46cdb37c76 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaSampleDumpTest.java @@ -0,0 +1,103 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** + * Dumps converted output for independent checkers; validating PDFBox with PDFBox is circular. Off + * by default as it writes outside the build directory: run with {@code DUMP_UA_SAMPLES=}. + */ +@EnabledIfEnvironmentVariable(named = "DUMP_UA_SAMPLES", matches = ".+") +class PdfUaSampleDumpTest { + + private static PdfUaConversionService service; + private static Path repoRoot; + + @BeforeAll + static void setUp() { + PdfUaValidationService validation = new PdfUaValidationService(); + validation.initialise(); + service = + new PdfUaConversionService( + validation, + new FontEmbeddingService(), + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + repoRoot = Path.of("").toAbsolutePath(); + while (repoRoot != null && !Files.exists(repoRoot.resolve("settings.gradle"))) { + repoRoot = repoRoot.getParent(); + } + } + + @Test + @DisplayName("writes original and converted pairs for external validation") + void dumpSamples() throws Exception { + Path out = Path.of(System.getenv("DUMP_UA_SAMPLES")); + Files.createDirectories(out); + + List pdfs; + try (Stream stream = Files.walk(repoRoot)) { + pdfs = + stream.filter(Files::isRegularFile) + .filter(p -> p.toString().toLowerCase().endsWith(".pdf")) + .filter(p -> !p.toString().contains("node_modules")) + .filter(p -> !p.toString().contains(java.io.File.separator + "build")) + .filter(p -> !p.toString().contains(".git")) + .sorted() + .toList(); + } + + List manifest = new ArrayList<>(); + int written = 0; + for (Path pdf : pdfs) { + String stem = pdf.getFileName().toString().replaceFirst("\\.pdf$", ""); + byte[] input; + try { + input = Files.readAllBytes(pdf); + } catch (Exception e) { + continue; + } + try { + PdfUaConversionOutcome outcome = + service.convert( + input, + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .fallbackTitle(stem) + .existingTags(TaggingOptions.ExistingTags.REBUILD) + .build()); + Files.write(out.resolve(stem + "__before.pdf"), input); + Files.write(out.resolve(stem + "__after.pdf"), outcome.pdfBytes()); + manifest.add( + stem + + "\tdeclared=" + + outcome.declared() + + "\tfailures=" + + outcome.validation().totalFailures()); + written++; + } catch (Exception e) { + manifest.add(stem + "\tREFUSED\t" + e.getMessage()); + } + } + Files.write(out.resolve("manifest.tsv"), manifest); + System.out.println("Wrote " + written + " before/after pairs to " + out); + assertTrue(written > 10, "expected a usable sample set, wrote " + written); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaServicesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaServicesTest.java new file mode 100644 index 0000000000..9d2e14ee0d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaServicesTest.java @@ -0,0 +1,319 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.model.api.ua.AccessibilityReport; +import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome; +import stirling.software.proprietary.model.api.ua.UaValidationResult; +import stirling.software.proprietary.pdf.ua.PdfUaProfile; +import stirling.software.proprietary.pdf.ua.TaggingOptions; + +/** Tests for the services that validate, audit and convert. */ +class PdfUaServicesTest { + + private static PdfUaValidationService validation; + private static PdfUaConversionService conversion; + private static AccessibilityAuditService audit; + private static FontEmbeddingService fonts; + + @BeforeAll + static void setUp() { + validation = new PdfUaValidationService(); + validation.initialise(); + fonts = new FontEmbeddingService(); + conversion = + new PdfUaConversionService( + validation, + fonts, + new CustomPDFDocumentFactory( + org.mockito.Mockito.mock(PdfMetadataService.class))); + audit = new AccessibilityAuditService(validation); + } + + /** Uses a standard 14 font deliberately: never embedded, which clause 7.21 forbids. */ + private static byte[] unembeddedFontPdf() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 700); + cs.showText("Hello accessibility"); + cs.endText(); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private static byte[] manyPages(int pages) throws IOException { + try (PDDocument document = new PDDocument()) { + for (int i = 0; i < pages; i++) { + document.addPage(new PDPage()); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private static byte[] encryptedPdf() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + AccessPermission permissions = new AccessPermission(); + document.protect(new StandardProtectionPolicy("owner", "user", permissions)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + @Nested + @DisplayName("validation") + class Validation { + + @Test + @DisplayName("an untagged document fails and the failures are grouped by rule") + void untaggedFails() throws Exception { + UaValidationResult result = validation.validate(unembeddedFontPdf(), PdfUaProfile.UA1); + assertFalse(result.compliant()); + assertTrue(result.hasIssues()); + assertTrue( + result.totalFailures() >= result.issues().size(), + "grouping must not invent failures"); + assertTrue( + result.issues().stream().allMatch(i -> i.getOccurrences() > 0), + "every grouped issue should count its occurrences"); + } + + @Test + @DisplayName("malformed input reports a failure instead of throwing") + void malformedInputIsReported() { + UaValidationResult result = + validation.validate("not a pdf".getBytes(), PdfUaProfile.UA1); + assertFalse(result.compliant()); + assertFalse(result.issues().isEmpty()); + } + + @Test + @DisplayName("issues carry plain-English text as well as the validator's own wording") + void issuesAreReadable() throws Exception { + UaValidationResult result = validation.validate(unembeddedFontPdf(), PdfUaProfile.UA1); + assertTrue( + result.issues().stream() + .allMatch(i -> i.getMessage() != null && !i.getMessage().isBlank())); + } + } + + @Nested + @DisplayName("auditing") + class Auditing { + + @Test + @DisplayName("reports the document facts that drive most failures") + void reportsSummary() throws Exception { + AccessibilityReport report = audit.audit(unembeddedFontPdf(), PdfUaProfile.UA1); + + assertFalse(report.isTagged(), "the fixture has no structure tree"); + assertFalse(report.isDeclaresConformance()); + assertFalse(report.isPassesAutomatedChecks()); + assertEquals(1, report.getSummary().getPages()); + assertFalse(report.getSummary().isAllFontsEmbedded()); + assertTrue(report.getSummary().getUnembeddedFonts() > 0); + assertFalse(report.getSummary().isHasLanguage()); + assertFalse(report.getSummary().isHasTitle()); + } + + @Test + @DisplayName("always lists the checks a person still has to make") + void listsHumanChecks() throws Exception { + AccessibilityReport report = audit.audit(unembeddedFontPdf(), PdfUaProfile.UA1); + assertFalse( + report.getHumanChecks().isEmpty(), + "a report showing only automated results implies the rest does not exist"); + } + + @Test + @DisplayName("splits failures into automatically fixable and needs-input") + void splitsRemediability() throws Exception { + AccessibilityReport report = audit.audit(unembeddedFontPdf(), PdfUaProfile.UA1); + assertEquals( + report.getIssues().size(), + report.getAutomaticallyFixable() + report.getNeedsInput()); + } + + @Test + @DisplayName("refuses a document past the page cap the conversion also applies") + void refusesTooManyPages() throws Exception { + byte[] oversized = manyPages(2001); + assertThrows( + IllegalArgumentException.class, + () -> audit.audit(oversized, PdfUaProfile.UA1), + "an uncapped report walks every page of any document a caller uploads"); + } + + @Test + @DisplayName("a converted document reports as tagged and conformant") + void reportsAfterConversion() throws Exception { + PdfUaConversionOutcome outcome = + conversion.convert( + unembeddedFontPdf(), + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Converted") + .build()); + AccessibilityReport report = audit.audit(outcome.pdfBytes(), PdfUaProfile.UA1); + assertTrue(report.isTagged()); + assertTrue(report.getSummary().isHasTitle()); + assertTrue(report.getSummary().isHasLanguage()); + assertTrue(report.getSummary().isDisplaysDocTitle()); + } + } + + @Nested + @DisplayName("font embedding") + class Fonts { + + @Test + @DisplayName("detects a standard 14 font as unembedded") + void detectsUnembedded() throws Exception { + assertTrue(fonts.hasUnembeddedFonts(unembeddedFontPdf())); + } + + @Test + @DisplayName("embeds fonts, or explains why it could not") + void embedsOrExplains() throws Exception { + FontEmbeddingService.Result result = fonts.embedFonts(unembeddedFontPdf()); + assertNotNull(result.pdfBytes()); + if (result.changed()) { + assertFalse( + fonts.hasUnembeddedFonts(result.pdfBytes()), + "embedding reported success but fonts are still missing"); + } else { + assertNotNull( + result.warning(), "failing to embed must be explained, not passed over"); + } + } + + @Test + @DisplayName("leaves a document alone when every font is already embedded") + void skipsWhenNothingToDo() throws Exception { + byte[] embedded = PdfUaTestDocuments.simpleDocument(); + FontEmbeddingService.Result result = fonts.embedFonts(embedded); + assertFalse(result.changed()); + assertEquals(embedded.length, result.pdfBytes().length); + } + } + + @Nested + @DisplayName("conversion") + class Conversion { + + @Test + @DisplayName("refuses an encrypted document with an explanation") + void refusesEncrypted() throws Exception { + byte[] encrypted = encryptedPdf(); + IOException error = + assertThrows( + IOException.class, + () -> + conversion.convert( + encrypted, + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .build())); + assertTrue(error.getMessage().toLowerCase().contains("encrypted")); + } + + @Test + @DisplayName("keeping existing tags does not rebuild the tree") + void keepRespectsExistingTags() throws Exception { + byte[] tagged = + conversion + .convert( + PdfUaTestDocuments.simpleDocument(), + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("First pass") + .embedFonts(false) + .build()) + .pdfBytes(); + + PdfUaConversionOutcome second = + conversion.convert( + tagged, + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Second pass") + .embedFonts(false) + .existingTags(TaggingOptions.ExistingTags.KEEP) + .build()); + + assertFalse(second.tagging().rebuiltStructure(), "KEEP must not rebuild"); + try (PDDocument document = Loader.loadPDF(second.pdfBytes())) { + assertEquals("Second pass", document.getDocumentInformation().getTitle()); + } + } + + @Test + @DisplayName("marking images decorative removes the alt-text blocker") + void decorativePolicyClearsFigures() throws Exception { + PdfUaConversionOutcome outcome = + conversion.convert( + PdfUaTestDocuments.imageDocument(), + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Decorative") + .embedFonts(false) + .figurePolicy(TaggingOptions.FigurePolicy.MARK_DECORATIVE) + .build()); + assertEquals(0, outcome.tagging().figuresNeedingAltText()); + assertTrue(outcome.declared(), "with no undescribed figures the file should conform"); + } + + @Test + @DisplayName("converting twice produces the same conformance verdict") + void conversionIsStable() throws Exception { + byte[] input = PdfUaTestDocuments.headingHierarchy(); + TaggingOptions options = + TaggingOptions.builder() + .profile(PdfUaProfile.UA1) + .language("en-GB") + .title("Stable") + .embedFonts(false) + .build(); + PdfUaConversionOutcome first = conversion.convert(input, options); + PdfUaConversionOutcome second = conversion.convert(first.pdfBytes(), options); + assertEquals(first.declared(), second.declared()); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaTestDocuments.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaTestDocuments.java new file mode 100644 index 0000000000..589edbc1a2 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaTestDocuments.java @@ -0,0 +1,390 @@ +package stirling.software.proprietary.service.ua; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDFormContentStream; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.util.Matrix; + +/** + * Builds the fixture corpus used by the PDF/UA tests. Fonts are embedded deliberately: the standard + * 14 fail clause 7.21 and would mask every result. + */ +final class PdfUaTestDocuments { + + private static final String FONT_RESOURCE = "/static/fonts/DejaVuSans.ttf"; + // The font ships with core's resources, which are not on this module's classpath. + private static final String FONT_REPO_PATH = + "app/core/src/main/resources/static/fonts/DejaVuSans.ttf"; + private static final float MARGIN = 60f; + + private PdfUaTestDocuments() {} + + static PDFont font(PDDocument document) throws IOException { + try (InputStream in = PdfUaTestDocuments.class.getResourceAsStream(FONT_RESOURCE)) { + if (in != null) { + return PDType0Font.load(document, in, true); + } + } + Path repoRoot = Path.of("").toAbsolutePath(); + while (repoRoot != null && !Files.exists(repoRoot.resolve("settings.gradle"))) { + repoRoot = repoRoot.getParent(); + } + Path font = repoRoot == null ? null : repoRoot.resolve(FONT_REPO_PATH); + if (font == null || !Files.exists(font)) { + throw new IOException( + "Test font not found: " + FONT_RESOURCE + " or " + FONT_REPO_PATH); + } + try (InputStream in = Files.newInputStream(font)) { + return PDType0Font.load(document, in, true); + } + } + + /** A heading followed by two paragraphs: the simplest thing that should convert cleanly. */ + static byte[] simpleDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 760; + y = text(cs, font, 20, MARGIN, y, "Quarterly Report"); + y -= 14; + y = text(cs, font, 11, MARGIN, y, "This document summarises the results for the"); + y = text(cs, font, 11, MARGIN, y, "period and outlines the outlook for next year."); + y -= 14; + text(cs, font, 11, MARGIN, y, "A second paragraph follows the first one here."); + } + return bytes(document); + } + } + + /** Three heading tiers, to exercise level assignment and the no-skipped-levels rule. */ + static byte[] headingHierarchy() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 760; + y = text(cs, font, 24, MARGIN, y, "Annual Review"); + y -= 12; + y = text(cs, font, 11, MARGIN, y, "Introductory prose sits under the title here."); + y -= 16; + y = text(cs, font, 17, MARGIN, y, "Financial Results"); + y -= 10; + y = text(cs, font, 11, MARGIN, y, "Revenue grew steadily across every region."); + y -= 16; + y = text(cs, font, 13, MARGIN, y, "Europe"); + y -= 10; + text(cs, font, 11, MARGIN, y, "European revenue rose by eleven per cent."); + } + return bytes(document); + } + } + + /** A bulleted and a numbered list. */ + static byte[] listDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 760; + y = text(cs, font, 18, MARGIN, y, "Checklist"); + y -= 14; + y = text(cs, font, 11, MARGIN, y, "• Review the source document"); + y = text(cs, font, 11, MARGIN, y, "• Check every heading level"); + y = text(cs, font, 11, MARGIN, y, "• Describe each image"); + y -= 16; + y = text(cs, font, 11, MARGIN, y, "1. Open the file"); + y = text(cs, font, 11, MARGIN, y, "2. Run the converter"); + text(cs, font, 11, MARGIN, y, "3. Validate the result"); + } + return bytes(document); + } + } + + /** A three-column table whose cells each occupy their own text-showing operator. */ + static byte[] tableDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 760; + y = text(cs, font, 18, MARGIN, y, "Regional Totals"); + y -= 20; + String[][] rows = { + {"Region", "Units", "Revenue"}, + {"North", "1200", "48000"}, + {"South", "980", "39200"}, + {"East", "1430", "57200"} + }; + float[] columns = {MARGIN, MARGIN + 160, MARGIN + 300}; + for (String[] row : rows) { + tableRow(cs, font, 11, columns, y, row); + y -= 20; + } + } + return bytes(document); + } + } + + /** A page with a real image, which must end up as a Figure needing alternative text. */ + static byte[] imageDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + BufferedImage bitmap = new BufferedImage(120, 90, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D graphics = bitmap.createGraphics(); + graphics.setColor(Color.BLUE); + graphics.fillRect(0, 0, 120, 90); + graphics.dispose(); + PDImageXObject image = LosslessFactory.createFromImage(document, bitmap); + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 760; + y = text(cs, font, 18, MARGIN, y, "Illustrated Page"); + y -= 20; + y = text(cs, font, 11, MARGIN, y, "The chart below shows the trend."); + cs.drawImage(image, MARGIN, y - 120, 180, 100); + } + return bytes(document); + } + } + + /** Four pages sharing a running head and a page number, which must become artifacts. */ + static byte[] runningHeadersDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDFont font = null; + for (int i = 1; i <= 4; i++) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + if (font == null) { + font = font(document); + } + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + text(cs, font, 9, MARGIN, 810, "Confidential Internal Report"); + float y = 750; + y = text(cs, font, 16, MARGIN, y, "Section " + i); + y -= 12; + text(cs, font, 11, MARGIN, y, "Body text for section number " + i + " here."); + text(cs, font, 9, 300, 30, "Page " + i); + } + } + return bytes(document); + } + } + + /** Two columns of prose, to exercise reading order. */ + static byte[] twoColumnDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float left = MARGIN; + float right = 320; + float y = 740; + for (int i = 1; i <= 8; i++) { + text(cs, font, 10, left, y - i * 16, "Left column line number " + i); + text(cs, font, 10, right, y - i * 16, "Right column line number " + i); + } + } + return bytes(document); + } + } + + /** A page carrying a link annotation, which must be reachable from the structure tree. */ + static byte[] linkDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 760; + y = text(cs, font, 18, MARGIN, y, "Useful Links"); + text(cs, font, 11, MARGIN, y - 20, "Visit the project home page for details."); + } + PDAnnotationLink link = new PDAnnotationLink(); + PDRectangle rectangle = new PDRectangle(); + rectangle.setLowerLeftX(MARGIN); + rectangle.setLowerLeftY(725); + rectangle.setUpperRightX(MARGIN + 200); + rectangle.setUpperRightY(740); + link.setRectangle(rectangle); + PDActionURI action = new PDActionURI(); + action.setURI("https://example.org"); + link.setAction(action); + page.getAnnotations().add(link); + return bytes(document); + } + } + + /** A page with no content at all. */ + static byte[] emptyDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.A4)); + return bytes(document); + } + } + + /** A page whose only content is a full-page image, standing in for an un-OCRed scan. */ + static byte[] scannedDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + BufferedImage bitmap = new BufferedImage(600, 850, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D graphics = bitmap.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, 600, 850); + graphics.setColor(Color.BLACK); + graphics.drawString("scanned page", 40, 60); + graphics.dispose(); + PDImageXObject image = LosslessFactory.createFromImage(document, bitmap); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(image, 0, 0, PDRectangle.A4.getWidth(), PDRectangle.A4.getHeight()); + } + return bytes(document); + } + } + + /** Text drawn inside a form XObject, attributed to the Do operator that invoked it. */ + static byte[] formXObjectDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + + PDFormXObject form = new PDFormXObject(document); + form.setBBox(new PDRectangle(220, 40)); + form.setResources(new PDResources()); + try (PDFormContentStream fcs = new PDFormContentStream(form)) { + fcs.beginText(); + fcs.setFont(font, 11); + fcs.newLineAtOffset(4, 14); + fcs.showText("Text living inside the form XObject"); + fcs.endText(); + } + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + text(cs, font, 18, MARGIN, 760, "Page With Embedded Form"); + text(cs, font, 11, MARGIN, 730, "Ordinary page text sits above the form."); + cs.saveGraphicsState(); + cs.transform(Matrix.getTranslateInstance(MARGIN, 650)); + cs.drawForm(form); + cs.restoreGraphicsState(); + } + return bytes(document); + } + } + + /** Content split across two streams (a PDF array) - the parser must see one sequence. */ + static byte[] multiStreamDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + text(cs, font, 18, MARGIN, 760, "First Stream Heading"); + } + try (PDPageContentStream cs = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true)) { + text(cs, font, 11, MARGIN, 720, "Second stream paragraph appended later."); + } + return bytes(document); + } + } + + /** A landscape page via /Rotate 90, which flips the frame the text engine reports in. */ + static byte[] rotatedDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + page.setRotation(90); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + // Drawn rotated so the text reads upright on the rotated page. + cs.transform(Matrix.getRotateInstance(Math.toRadians(90), 595, 0)); + text(cs, font, 18, MARGIN, 500, "Rotated Page Title"); + text(cs, font, 11, MARGIN, 470, "Body text on a landscape page."); + } + return bytes(document); + } + } + + /** A MediaBox whose origin is not (0,0), which some scanners produce. */ + static byte[] offsetMediaBoxDocument() throws IOException { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(new PDRectangle(100, 200, 595, 842)); + document.addPage(page); + PDFont font = font(document); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + text(cs, font, 18, 160, 960, "Offset Origin Title"); + text(cs, font, 11, 160, 930, "Text on a page whose MediaBox starts at 100,200."); + } + return bytes(document); + } + } + + // --- helpers ----------------------------------------------------------- + + private static float text( + PDPageContentStream cs, PDFont font, float size, float x, float y, String value) + throws IOException { + cs.beginText(); + cs.setFont(font, size); + cs.newLineAtOffset(x, y); + cs.showText(value); + cs.endText(); + return y - size * 1.35f; + } + + /** + * Emits one row with a separate show-text operator per cell, so each cell gets its own MCID. + */ + private static void tableRow( + PDPageContentStream cs, + PDFont font, + float size, + float[] columns, + float y, + String[] values) + throws IOException { + cs.beginText(); + cs.setFont(font, size); + cs.newLineAtOffset(columns[0], y); + cs.showText(values[0]); + for (int i = 1; i < values.length; i++) { + cs.newLineAtOffset(columns[i] - columns[i - 1], 0); + cs.showText(values[i]); + } + cs.endText(); + } + + private static byte[] bytes(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfaLevelATest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfaLevelATest.java new file mode 100644 index 0000000000..367b9d1171 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfaLevelATest.java @@ -0,0 +1,202 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.verapdf.pdfa.Foundries; +import org.verapdf.pdfa.PDFAParser; +import org.verapdf.pdfa.flavours.PDFAFlavour; +import org.verapdf.pdfa.results.TestAssertion; +import org.verapdf.pdfa.results.ValidationResult; + +/** + * Proves tagging raises a PDF/A file from level B to the accessible level A. veraPDF is the + * arbiter: the claim only counts if the validator agrees. + */ +class PdfaLevelATest { + + private static PdfaAccessibilityService service; + private static Path repoRoot; + + @BeforeAll + static void setUp() { + PdfUaValidationService uaValidation = new PdfUaValidationService(); + uaValidation.initialise(); + service = new PdfaAccessibilityService(uaValidation); + repoRoot = Path.of("").toAbsolutePath(); + while (repoRoot != null && !Files.exists(repoRoot.resolve("settings.gradle"))) { + repoRoot = repoRoot.getParent(); + } + } + + private static byte[] fixture(String name) throws Exception { + return Files.readAllBytes( + repoRoot.resolve("app/core/src/test/resources/pdfa").resolve(name)); + } + + private static String xmpOf(byte[] pdf) throws Exception { + try (PDDocument document = Loader.loadPDF(pdf)) { + var metadata = document.getDocumentCatalog().getMetadata(); + assertNotNull(metadata, "no XMP packet"); + return new String(metadata.toByteArray(), StandardCharsets.UTF_8); + } + } + + /** The flavour the file declares in its XMP, which is what a validator picks up by itself. */ + private static String declaredStandard(byte[] pdf) throws Exception { + try (PDFAParser parser = + Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdf))) { + List flavours = parser.getFlavours(); + return flavours == null || flavours.isEmpty() ? null : flavours.get(0).getId(); + } + } + + private static ValidationResult validate(byte[] pdf, PDFAFlavour flavour) throws Exception { + try (PDFAParser parser = + Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdf), flavour)) { + return Foundries.defaultInstance().createValidator(flavour, false).validate(parser); + } + } + + private static List failures(ValidationResult result) { + return result.getTestAssertions().stream() + .filter(assertion -> assertion.getStatus() == TestAssertion.Status.FAILED) + .map(TestAssertion::getMessage) + .toList(); + } + + @Test + @DisplayName("a level B file gains a structure tree and a conformance A claim") + void upgradesLevelBToLevelA() throws Exception { + byte[] levelB = fixture("valid-pdfa-2b.pdf"); + + try (PDDocument before = Loader.loadPDF(levelB)) { + assertEquals( + null, + before.getDocumentCatalog().getStructureTreeRoot(), + "the fixture should start untagged, or the test proves nothing"); + } + + PdfaAccessibilityService.Result result = + service.upgradeToLevelA(levelB, 2, "en-GB", "Archived Report"); + assertTrue(result.levelA(), "upgrade failed: " + result.warnings()); + + try (PDDocument after = Loader.loadPDF(result.pdfBytes())) { + assertNotNull( + after.getDocumentCatalog().getStructureTreeRoot(), "no structure tree written"); + assertTrue(after.getDocumentCatalog().getMarkInfo().isMarked()); + assertEquals("en-GB", after.getDocumentCatalog().getLanguage()); + } + + String xmp = xmpOf(result.pdfBytes()); + assertTrue(xmp.contains("part"), "pdfaid:part missing"); + assertTrue( + xmp.contains(">A<") || xmp.contains("conformance=\"A\""), + "conformance was not raised to A: " + xmp); + } + + @Test + @DisplayName("the upgraded file still validates as PDF/A, now at level A") + void upgradedFileStillValidates() throws Exception { + byte[] levelB = fixture("valid-pdfa-2b.pdf"); + PdfaAccessibilityService.Result result = + service.upgradeToLevelA(levelB, 2, "en-GB", "Archived Report"); + assertTrue(result.levelA(), "upgrade failed: " + result.warnings()); + + assertEquals( + "2a", declaredStandard(result.pdfBytes()), "the file should now declare PDF/A-2a"); + + ValidationResult pdfa = validate(result.pdfBytes(), PDFAFlavour.PDFA_2_A); + assertTrue(pdfa.isCompliant(), () -> "PDF/A-2a validation failed: " + failures(pdfa)); + } + + @Test + @DisplayName("PDF/A-1 keeps its 1.4 version, since level A must not change the part") + void partOneKeepsItsVersion() throws Exception { + byte[] levelB = fixture("valid-pdfa-1b.pdf"); + float versionBefore; + try (PDDocument document = Loader.loadPDF(levelB)) { + versionBefore = document.getVersion(); + } + + PdfaAccessibilityService.Result result = + service.upgradeToLevelA(levelB, 1, "en", "Archived"); + try (PDDocument document = Loader.loadPDF(result.pdfBytes())) { + assertEquals( + versionBefore, + document.getVersion(), + "raising the PDF version would break PDF/A-1 conformance"); + } + } + + @Test + @DisplayName("a document with nothing to tag is left at level B rather than mislabelled") + void refusesToClaimLevelAWithoutTags() throws Exception { + byte[] blank; + try (PDDocument document = new PDDocument()) { + document.addPage(new org.apache.pdfbox.pdmodel.PDPage()); + var out = new java.io.ByteArrayOutputStream(); + document.save(out); + blank = out.toByteArray(); + } + + PdfaAccessibilityService.Result result = service.upgradeToLevelA(blank, 2, "en", "Empty"); + assertFalse(result.levelA(), "an untaggable document must not claim level A"); + assertFalse(result.warnings().isEmpty(), "the refusal should be explained"); + } + + @Test + @DisplayName("setting conformance leaves the rest of the XMP packet intact") + void conformanceRewritePreservesPacket() throws Exception { + byte[] levelB = fixture("valid-pdfa-2b.pdf"); + byte[] rewritten = PdfaAccessibilityService.setConformance(levelB, 2, "A"); + + assertEquals("2a", declaredStandard(rewritten), "the rewritten packet should declare 2a"); + } + + @Test + @DisplayName("a file can declare PDF/A and PDF/UA at once without breaking either") + void combinedPdfaAndPdfUa() throws Exception { + byte[] levelB = fixture("valid-pdfa-2b.pdf"); + PdfaAccessibilityService.Result upgraded = + service.upgradeToLevelA(levelB, 2, "en-GB", "Archived and Accessible"); + assertTrue(upgraded.levelA(), "upgrade failed: " + upgraded.warnings()); + + byte[] both = PdfaAccessibilityService.declarePdfUaAlongsidePdfa(upgraded.pdfBytes(), 2); + + String xmp = xmpOf(both); + assertTrue(xmp.contains("pdfuaid"), "no PDF/UA identifier"); + assertTrue( + xmp.contains("pdfaSchema") || xmp.contains("schemas"), + "PDF/A requires an extension schema describing pdfuaid, none found: " + xmp); + + assertEquals( + "2a", declaredStandard(both), "the combined file should still declare PDF/A-2a"); + + ValidationResult pdfa = validate(both, PDFAFlavour.PDFA_2_A); + assertTrue( + pdfa.isCompliant(), + () -> "adding the PDF/UA identifier broke PDF/A: " + failures(pdfa)); + } + + @Test + @DisplayName("PDFAFlavour exposes the level A profiles the converter now targets") + void flavoursExistForLevelA() { + assertNotNull(PDFAFlavour.PDFA_1_A); + assertNotNull(PDFAFlavour.PDFA_2_A); + assertNotNull(PDFAFlavour.PDFA_3_A); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/TaggedContentExtractorRealFilesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/TaggedContentExtractorRealFilesTest.java new file mode 100644 index 0000000000..b42a429007 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ua/TaggedContentExtractorRealFilesTest.java @@ -0,0 +1,99 @@ +package stirling.software.proprietary.service.ua; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.pdf.ua.PageContent; +import stirling.software.proprietary.pdf.ua.TaggedContentExtractor; + +/** + * Guards the invariant the tagger rests on: the token pass and text pass must agree on ordinals. + * They can silently disagree, and the extractor then drops the page rather than mis-tag it. + */ +class TaggedContentExtractorRealFilesTest { + + private static Path repoRoot; + + @BeforeAll + static void setUp() { + repoRoot = Path.of("").toAbsolutePath(); + while (repoRoot != null && !Files.exists(repoRoot.resolve("settings.gradle"))) { + repoRoot = repoRoot.getParent(); + } + } + + @Test + @DisplayName("pages with extractable text always yield lines across the repository corpus") + void ordinalsAgreeOnRealFiles() throws Exception { + assertNotNull(repoRoot, "could not locate the repository root"); + List dropped = new ArrayList<>(); + int inspected = 0; + + for (Path pdf : findPdfs()) { + byte[] bytes; + try { + bytes = Files.readAllBytes(pdf); + } catch (Exception e) { + continue; + } + try (PDDocument document = Loader.loadPDF(bytes)) { + if (document.getNumberOfPages() > 30) { + continue; + } + inspected++; + List pages = new TaggedContentExtractor().extract(document); + + for (PageContent page : pages) { + if (page.markableCount() == 0 || !hasText(document, page.pageIndex())) { + continue; + } + if (page.lines().isEmpty() && page.forms().isEmpty()) { + dropped.add(repoRoot.relativize(pdf) + " page " + page.pageIndex()); + } + } + } catch (Exception e) { + // Unreadable files are covered by the conversion tests. + } + } + + assertTrue(inspected > 15, "expected to inspect a real corpus, saw " + inspected); + assertTrue( + dropped.isEmpty(), + "the two extraction passes disagreed, so these pages were skipped: " + dropped); + } + + private static boolean hasText(PDDocument document, int pageIndex) { + try { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(pageIndex + 1); + stripper.setEndPage(pageIndex + 1); + return !stripper.getText(document).isBlank(); + } catch (Exception e) { + return false; + } + } + + private List findPdfs() throws Exception { + try (Stream stream = Files.walk(repoRoot)) { + return stream.filter(Files::isRegularFile) + .filter(p -> p.toString().toLowerCase().endsWith(".pdf")) + .filter(p -> !p.toString().contains("node_modules")) + .filter(p -> !p.toString().contains(java.io.File.separator + "build")) + .filter(p -> !p.toString().contains(".git")) + .toList(); + } + } +} diff --git a/engine/src/stirling/models/tool_io.py b/engine/src/stirling/models/tool_io.py index da0cd5b9ae..c9da9cce90 100644 --- a/engine/src/stirling/models/tool_io.py +++ b/engine/src/stirling/models/tool_io.py @@ -126,6 +126,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { ) ], ), + ToolEndpoint.PDF_TO_UA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), ToolEndpoint.PDF_TO_VECTOR: ToolIOSpec( accepts=[ToolFormat.PDF], produces=ToolFormat.IMAGE, @@ -246,6 +247,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { ToolEndpoint.SCANNER_EFFECT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), ToolEndpoint.UNLOCK_PDF_FORMS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), ToolEndpoint.UPDATE_METADATA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO), + ToolEndpoint.ACCESSIBILITY_REPORT: ToolIOSpec( + accepts=[ToolFormat.PDF], produces=ToolFormat.JSON, arity=ToolArity.SISO + ), ToolEndpoint.ADD_PASSWORD: ToolIOSpec( accepts=[ToolFormat.PDF], produces=ToolFormat.PDF_ENCRYPTED, diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 712e3575af..6650942b6f 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -11,6 +11,19 @@ from pydantic import Field, RootModel, SecretStr from stirling.models.base import ApiModel +class Profile(StrEnum): + """ + Profile to check against + """ + + ua1 = "ua1" + ua2 = "ua2" + + +class AccessibilityReportParams(ApiModel): + profile: Profile = Field(Profile.ua1, description="Profile to check against") + + class AddCommentsParams(ApiModel): comments: str = Field( ..., @@ -843,11 +856,18 @@ class OutputFormat1(StrEnum): pdfa_2b = "pdfa-2b" pdfa_3 = "pdfa-3" pdfa_3b = "pdfa-3b" + pdfa_1a = "pdfa-1a" + pdfa_2a = "pdfa-2a" + pdfa_3a = "pdfa-3a" pdfx = "pdfx" class PdfToPdfaParams(ApiModel): output_format: OutputFormat1 = Field(..., description="The output format type (PDF/A or PDF/X)") + pdf_ua: bool = Field( + False, + description="Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A formats, and the claim is written only if it validates.", + ) strict: bool | None = Field( None, description="If true, the conversion will fail if the output is not perfectly compliant" ) @@ -886,6 +906,65 @@ class PdfToTextParams(ApiModel): output_format: OutputFormat3 = Field(..., description="The output Text or RTF format") +class ExistingTags(StrEnum): + """ + What to do with an existing structure tree: keep it, rebuild it, or decide automatically + """ + + auto = "auto" + keep = "keep" + rebuild = "rebuild" + + +class FigurePolicy(StrEnum): + """ + How to treat images with no description. require-alt leaves them undescribed so the report asks for input; mark-decorative treats every image as decoration. + """ + + require_alt = "require-alt" + mark_decorative = "mark-decorative" + + +class Profile1(StrEnum): + """ + PDF/UA conformance level to target + """ + + ua1 = "ua1" + ua2 = "ua2" + + +class PdfToUaParams(ApiModel): + alt_text: str | None = Field( + None, + description='Alternative descriptions for figures, as key=text pairs separated by newlines. Keys come from the accessibility-report endpoint\'s figuresNeedingDescription list, for example "0:12=Bar chart of quarterly revenue". Descriptions are never invented, so without these an illustrated document cannot claim conformance.', + ) + embed_fonts: bool = Field( + True, + description="Embed fonts the document references but does not carry. Required for conformance and needs Ghostscript.", + ) + existing_tags: ExistingTags = Field( + ExistingTags.auto, + description="What to do with an existing structure tree: keep it, rebuild it, or decide automatically", + ) + figure_policy: FigurePolicy = Field( + FigurePolicy.require_alt, + description="How to treat images with no description. require-alt leaves them undescribed so the report asks for input; mark-decorative treats every image as decoration.", + ) + language: str = Field( + "en-GB", + description="Document language as a BCP-47 tag, for example en-GB. Applied only when the document does not already declare one, unless overrideLanguage is set.", + ) + override_language: bool = Field( + False, + description="Replace the language the document already declares. Off by default, so a document is never relabelled into a language it is not written in.", + ) + profile: Profile1 = Field(Profile1.ua1, description="PDF/UA conformance level to target") + title: str | None = Field( + None, description="Document title, required by PDF/UA. Falls back to the first heading, then the filename." + ) + + class OutputFormat4(StrEnum): """ Target vector format extension @@ -1451,6 +1530,7 @@ class Model( | PdfToPdfaParams | PdfToPresentationParams | PdfToTextParams + | PdfToUaParams | PdfToVectorParams | PdfToWordParams | PdfToXlsxParams @@ -1495,6 +1575,7 @@ class Model( | ScannerEffectParams | UnlockPdfFormsParams | UpdateMetadataParams + | AccessibilityReportParams | AddPasswordParams | AddWatermarkParams | AutoRedactParams @@ -1525,6 +1606,7 @@ class Model( | PdfToPdfaParams | PdfToPresentationParams | PdfToTextParams + | PdfToUaParams | PdfToVectorParams | PdfToWordParams | PdfToXlsxParams @@ -1569,6 +1651,7 @@ class Model( | ScannerEffectParams | UnlockPdfFormsParams | UpdateMetadataParams + | AccessibilityReportParams | AddPasswordParams | AddWatermarkParams | AutoRedactParams @@ -1600,6 +1683,7 @@ type ParamToolModel = ( | PdfToPdfaParams | PdfToPresentationParams | PdfToTextParams + | PdfToUaParams | PdfToVectorParams | PdfToWordParams | PdfToXlsxParams @@ -1644,6 +1728,7 @@ type ParamToolModel = ( | ScannerEffectParams | UnlockPdfFormsParams | UpdateMetadataParams + | AccessibilityReportParams | AddPasswordParams | AddWatermarkParams | AutoRedactParams @@ -1676,6 +1761,7 @@ class ToolEndpoint(StrEnum): PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa" PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation" PDF_TO_TEXT = "/api/v1/convert/pdf/text" + PDF_TO_UA = "/api/v1/convert/pdf/ua" PDF_TO_VECTOR = "/api/v1/convert/pdf/vector" PDF_TO_WORD = "/api/v1/convert/pdf/word" PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx" @@ -1720,6 +1806,7 @@ class ToolEndpoint(StrEnum): SCANNER_EFFECT = "/api/v1/misc/scanner-effect" UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms" UPDATE_METADATA = "/api/v1/misc/update-metadata" + ACCESSIBILITY_REPORT = "/api/v1/security/accessibility-report" ADD_PASSWORD = "/api/v1/security/add-password" ADD_WATERMARK = "/api/v1/security/add-watermark" AUTO_REDACT = "/api/v1/security/auto-redact" @@ -1750,6 +1837,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = { ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams, ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams, ToolEndpoint.PDF_TO_TEXT: PdfToTextParams, + ToolEndpoint.PDF_TO_UA: PdfToUaParams, ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams, ToolEndpoint.PDF_TO_WORD: PdfToWordParams, ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams, @@ -1794,6 +1882,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = { ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams, ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams, ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams, + ToolEndpoint.ACCESSIBILITY_REPORT: AccessibilityReportParams, ToolEndpoint.ADD_PASSWORD: AddPasswordParams, ToolEndpoint.ADD_WATERMARK: AddWatermarkParams, ToolEndpoint.AUTO_REDACT: AutoRedactParams, diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index c2287ace9c..2424b8e2c5 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3402,6 +3402,24 @@ pdfOptions = "PDF Options" pdfToCbr = "PDF → CBR" pdfToCbz = "PDF → CBZ" pdfToEpub = "PDF → EPUB" +pdfUaAltTextNotice = "Images need a written description before a document can be certified. Descriptions are never generated automatically, because an invented one passes the checker while telling a screen-reader user nothing. Any image left without one is reported, and the file comes back tagged but not certified." +pdfUaAltTextScanFailed = "The images could not be listed. Convert anyway and the response reports what is missing." +pdfUaAltTextSingleFileOnly = "Descriptions belong to one document: an image is identified by its position, which is a different image in every file. Convert these {{fileCount}} files to tag them, then convert one at a time to describe its images." +pdfUaEmbedFonts = "Embed missing fonts" +pdfUaEmbedFontsHelp = "PDF/UA requires every font to be embedded. Turning this off is faster but usually prevents conformance." +pdfUaFigureLabel = "Page {{page}} {{kind}}" +pdfUaFigurePlaceholder = "What this image tells the reader" +pdfUaFindImages = "Find images needing a description" +pdfUaLanguage = "Document language" +pdfUaLanguageHelp = "A BCP-47 tag such as en-GB. Used only when the document does not already declare its own language." +pdfUaNoImagesNeedingText = "No image is missing a description." +pdfUaOptions = "PDF/UA Options" +pdfUaOverrideLanguage = "Replace the document's own language" +pdfUaOverrideLanguageHelp = "Only tick this if the language above is right and the document's own is wrong. Relabelling a document into a language it is not written in makes a screen reader unintelligible." +pdfUaProfile = "Conformance level" +pdfUaSignatureWarning = "This PDF is digitally signed. Tagging rewrites the page content the signature covers, so the signature will stop verifying. Convert first, then re-sign." +pdfUaTitle = "Document title" +pdfUaTitleHelp = "Shown by a reader instead of the filename. Left blank, the first heading is used." selectSourceFormatFirst = "Choose a source format first" settings = "Settings" single = "Single" @@ -6053,6 +6071,11 @@ header = "PDF To PDF/A" tags = "archive,long-term,standard,conversion,storage,preservation" title = "PDF To PDF/A" +[pdfToPDFUA] +header = "PDF To PDF/UA" +tags = "accessibility,accessible,tagged,screen reader,wcag,eaa,section 508,conversion" +title = "PDF To PDF/UA" + [pdfToPDFX] tags = "print,standard,conversion,production,prepress,archive" title = "PDF To PDF/X" diff --git a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx index c3ee0758f6..c550832c78 100644 --- a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx @@ -20,6 +20,7 @@ import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromE import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings"; import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings"; import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings"; +import ConvertToPdfUaSettings from "@app/components/tools/convert/ConvertToPdfUaSettings"; import ConvertToPdfxSettings from "@app/components/tools/convert/ConvertToPdfxSettings"; import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings"; import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings"; @@ -456,6 +457,20 @@ const ConvertSettings = ({ )} + {/* PDF to PDF/UA options */} + {parameters.fromExtension === "pdf" && + parameters.toExtension === "pdfua" && ( + <> + + + + )} + {/* PDF to PDF/X options */} {parameters.fromExtension === "pdf" && parameters.toExtension === "pdfx" && ( diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.selection.test.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.selection.test.tsx new file mode 100644 index 0000000000..5fbeace1b5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.selection.test.tsx @@ -0,0 +1,149 @@ +/** + * Which document the PDF/UA descriptions belong to. + * + * A description is keyed by an image's position inside one file, so it is only meaningful for the + * file it was written against. The panel therefore offers the description fields for a single + * selection only, and forgets what was typed as soon as the selection changes. + */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MantineProvider } from "@mantine/core"; +import ConvertToPdfUaSettings from "@app/components/tools/convert/ConvertToPdfUaSettings"; +import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters"; +import type { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters"; +import type { StirlingFile } from "@app/types/fileContext"; + +// Render the English fallbacks (the test i18n instance has no loaded locale). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown, options?: Record) => { + const text = typeof fallback === "string" ? fallback : key; + return options + ? text.replace(/\{\{(\w+)\}\}/g, (_, name) => String(options[name])) + : text; + }, + }), +})); + +const api = vi.hoisted(() => ({ post: vi.fn() })); +vi.mock("@app/services/apiClient", () => ({ default: { post: api.post } })); + +// The real hook parses the PDF in a worker, which is not what this file is about. +vi.mock("@app/hooks/usePdfSignatureDetection", () => ({ + usePdfSignatureDetection: () => ({ + hasDigitalSignatures: false, + isChecking: false, + }), +})); + +const file = (name: string, content = "%PDF-1.7") => + new File([content], name, { type: "application/pdf" }) as StirlingFile; + +const parametersWith = (altText: string): ConvertParameters => ({ + ...defaultParameters, + fromExtension: "pdf", + toExtension: "pdfua", + pdfUaOptions: { ...defaultParameters.pdfUaOptions, altText }, +}); + +function renderPanel(selectedFiles: StirlingFile[], altText = "") { + const onParameterChange = vi.fn(); + const view = render( + + + , + ); + const rerenderWith = (files: StirlingFile[], text = altText) => + view.rerender( + + + , + ); + return { onParameterChange, rerenderWith }; +} + +beforeEach(() => { + vi.clearAllMocks(); + api.post.mockResolvedValue({ + data: { + figuresNeedingDescription: [{ key: "0:1", page: 1, kind: "image" }], + }, + }); +}); + +describe("PDF/UA descriptions are scoped to one document", () => { + test("one file: the images can be listed and described", async () => { + const { onParameterChange } = renderPanel([file("report.pdf")]); + + await userEvent.click(screen.getByTestId("pdfua-find-figures")); + const field = await screen.findByTestId("pdfua-alt-text-0:1"); + await userEvent.type(field, "B"); + + expect(onParameterChange).toHaveBeenCalledWith( + "pdfUaOptions", + expect.objectContaining({ altText: "0:1=B" }), + ); + }); + + test("several files: no description fields, and a reason why", () => { + renderPanel([file("report.pdf"), file("appendix.pdf")]); + + expect( + screen.getByTestId("pdfua-alt-text-single-file-only"), + ).toHaveTextContent( + /Convert these 2 files to tag them, then convert one at a time/, + ); + expect(screen.queryByTestId("pdfua-find-figures")).toBeNull(); + }); + + test("several files: descriptions already typed are dropped, not carried over", async () => { + const { onParameterChange, rerenderWith } = renderPanel( + [file("report.pdf")], + "0:1=Bar chart of revenue", + ); + + rerenderWith([file("report.pdf"), file("appendix.pdf")]); + + await waitFor(() => + expect(onParameterChange).toHaveBeenCalledWith( + "pdfUaOptions", + expect.objectContaining({ altText: "" }), + ), + ); + }); + + test("swapping the single file clears the descriptions written for the old one", async () => { + const { onParameterChange, rerenderWith } = renderPanel( + [file("report.pdf")], + "0:1=Bar chart of revenue", + ); + + rerenderWith([file("other.pdf")]); + + await waitFor(() => + expect(onParameterChange).toHaveBeenCalledWith( + "pdfUaOptions", + expect.objectContaining({ altText: "" }), + ), + ); + }); + + test("mounting with stored descriptions keeps them, so an automation step survives editing", () => { + const { onParameterChange } = renderPanel( + [file("report.pdf")], + "0:1=Bar chart of revenue", + ); + + expect(onParameterChange).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.test.ts b/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.test.ts new file mode 100644 index 0000000000..ccef5b5142 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { + formatAltText, + parseAltText, +} from "@app/components/tools/convert/ConvertToPdfUaSettings"; + +describe("PDF/UA alt-text wire format", () => { + test("reads the key=description lines the report's keys produce", () => { + expect(parseAltText("0:12=Bar chart\n1:3=Company logo")).toEqual({ + "0:12": "Bar chart", + "1:3": "Company logo", + }); + }); + + test("keeps a description containing an equals sign whole", () => { + expect(parseAltText("2:7=Flow: approval = sign-off")).toEqual({ + "2:7": "Flow: approval = sign-off", + }); + }); + + test("skips blank and malformed lines rather than inventing keys", () => { + expect(parseAltText("\nnot-a-pair\n3:1= \n")).toEqual({}); + }); + + test("round-trips a half-typed description, spaces and all", () => { + // Trimming here would eat the space the moment it is typed, blocking the next word. + const typed = { "0:1": "Bar chart " }; + expect(parseAltText(formatAltText(typed))).toEqual(typed); + }); + + test("drops a description the user cleared", () => { + expect(formatAltText({ "0:1": "Kept", "0:2": " " })).toBe("0:1=Kept"); + }); +}); diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.tsx new file mode 100644 index 0000000000..b1e5e4e5fe --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.tsx @@ -0,0 +1,280 @@ +import { useEffect, useRef, useState } from "react"; +import { Stack, Text, Select, Alert, Checkbox, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import apiClient from "@app/services/apiClient"; +import { Button } from "@app/ui/Button"; +import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters"; +import { usePdfSignatureDetection } from "@app/hooks/usePdfSignatureDetection"; +import { StirlingFile } from "@app/types/fileContext"; +import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; + +interface ConvertToPdfUaSettingsProps { + parameters: ConvertParameters; + onParameterChange: ( + key: K, + value: ConvertParameters[K], + ) => void; + selectedFiles: StirlingFile[]; + disabled?: boolean; +} + +/** One image the backend says has no description yet, keyed as the conversion expects it back. */ +interface FigureNeedingDescription { + key: string; + page: number; + kind: string; +} + +/** + * The wire form the endpoint parses: one `pageIndex:ordinal=description` per line. Descriptions are + * kept verbatim so that typing a space does not fight the field; the backend trims them. + */ +export const parseAltText = (raw: string): Record => { + const parsed: Record = {}; + raw.split(/\r?\n/).forEach((line) => { + const split = line.indexOf("="); + if (split <= 0) return; + const key = line.slice(0, split).trim(); + const description = line.slice(split + 1); + if (key && description.trim()) parsed[key] = description; + }); + return parsed; +}; + +export const formatAltText = (descriptions: Record): string => + Object.entries(descriptions) + .filter(([, description]) => description.trim()) + .map(([key, description]) => `${key}=${description}`) + .join("\n"); + +/** PDF/UA conversion options; copy is deliberate - conformance is not guaranteed by one click. */ +const ConvertToPdfUaSettings = ({ + parameters, + onParameterChange, + selectedFiles, + disabled = false, +}: ConvertToPdfUaSettingsProps) => { + const { t } = useTranslation(); + const { hasDigitalSignatures } = usePdfSignatureDetection(selectedFiles); + const [figures, setFigures] = useState( + null, + ); + const [isScanning, setIsScanning] = useState(false); + const [scanError, setScanError] = useState(null); + + const profileOptions = [ + { value: "ua1", label: "PDF/UA-1" }, + { value: "ua2", label: "PDF/UA-2 (PDF 2.0)" }, + ]; + + const update = (patch: Partial) => + onParameterChange("pdfUaOptions", { ...parameters.pdfUaOptions, ...patch }); + + const descriptions = parseAltText(parameters.pdfUaOptions.altText); + // A key is a position inside one document, so descriptions only mean anything for one file. + const scannableFile = selectedFiles.length === 1 ? selectedFiles[0] : null; + const tooManyFiles = selectedFiles.length > 1; + const fileKey = selectedFiles + .map((file) => `${file.name}:${file.size}`) + .join("|"); + const describedFileKey = useRef(fileKey); + + // The same key names a different image in the next document, so descriptions must not outlive the + // selection. Mount is skipped so a stored automation step keeps the text it was saved with. + useEffect(() => { + if (describedFileKey.current === fileKey) return; + describedFileKey.current = fileKey; + setFigures(null); + if (parameters.pdfUaOptions.altText) update({ altText: "" }); + }, [fileKey]); + + // The keys are opaque, so they have to come from the backend's own analysis of this file. + const findFigures = async () => { + const file = scannableFile; + if (!file) return; + setIsScanning(true); + setScanError(null); + try { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("profile", parameters.pdfUaOptions.profile); + const { data } = await apiClient.post<{ + figuresNeedingDescription?: FigureNeedingDescription[]; + }>("/api/v1/security/accessibility-report", formData); + setFigures(data.figuresNeedingDescription ?? []); + } catch { + setScanError( + t( + "convert.pdfUaAltTextScanFailed", + "The images could not be listed. Convert anyway and the response reports what is missing.", + ), + ); + } finally { + setIsScanning(false); + } + }; + + return ( + + + {t("convert.pdfUaOptions", "PDF/UA Options")}: + + + {hasDigitalSignatures && ( + + + {t( + "convert.pdfUaSignatureWarning", + "This PDF is digitally signed. Tagging rewrites the page content the signature covers, so the signature will stop verifying. Convert first, then re-sign.", + )} + + + )} + + + + {t("convert.pdfUaProfile", "Conformance level")}: + + + value && + setOriginFilter(value as FilesPageOriginFilter) + } + data={[ + { + value: "all", + label: t("filesPage.origin.all", "All sources"), + }, + { + value: "local", + label: t("filesPage.origin.local", "Local"), + }, + { + value: "cloud", + label: t("filesPage.origin.cloud", "Cloud"), + }, + { + value: "shared-with-me", + label: t("filesPage.origin.shared", "Shared"), + }, + ]} + style={{ width: 140 }} + aria-label={t( + "filesPage.originFilter", + "Filter by source", )} - - - - - - - - clearSelection()} + /> + {availableTypes.length > 1 && ( + ({ + value: ext, + label: ext, + }))} + placeholder={ + typeFilter.length === 0 + ? t("filesPage.typeFilter.allTypes", "All types") + : undefined + } + clearable + hidePickedOptions + searchable={false} + style={{ width: 160 }} aria-label={t( - "filesPage.clearSelection", - "Clear selection", + "filesPage.typeFilter.label", + "Filter by type", )} - > - × - - - - ); - })()} - {selectedFiles.length > 0 && ( - + + + ); +} + +export default FilesToolbarFilterMenu; diff --git a/frontend/editor/src/core/components/filesPage/FilesToolbarSortMenu.tsx b/frontend/editor/src/core/components/filesPage/FilesToolbarSortMenu.tsx new file mode 100644 index 0000000000..d8a5ee1018 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesToolbarSortMenu.tsx @@ -0,0 +1,86 @@ +import { Menu } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import CheckIcon from "@mui/icons-material/Check"; +import SwapVertIcon from "@mui/icons-material/SwapVert"; + +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; + +interface FilesToolbarSortMenuProps { + value: FilesPageSortMode; + onChange: (mode: FilesPageSortMode) => void; +} + +/** + * Sort control collapsed to a single icon. The desktop Select needs 160px and + * still truncated its longest label ("Recent first" → "Recent fi") once the + * toolbar got tight, so on narrow viewports the options move into a menu where + * they have room to read in full. + */ +export function FilesToolbarSortMenu({ + value, + onChange, +}: FilesToolbarSortMenuProps) { + const { t } = useTranslation(); + + const options: { value: FilesPageSortMode; label: string }[] = [ + { + value: "modified-desc", + label: t("filesPage.sort.modifiedDesc", "Recent first"), + }, + { + value: "modified-asc", + label: t("filesPage.sort.modifiedAsc", "Oldest first"), + }, + { value: "name-asc", label: t("filesPage.sort.nameAsc", "Name A→Z") }, + { value: "name-desc", label: t("filesPage.sort.nameDesc", "Name Z→A") }, + { + value: "size-desc", + label: t("filesPage.sort.sizeDesc", "Largest first"), + }, + { value: "size-asc", label: t("filesPage.sort.sizeAsc", "Smallest first") }, + ]; + + const label = t("filesPage.sort.label", "Sort files"); + const current = options.find((o) => o.value === value)?.label ?? ""; + + return ( + + +
    + + + + + +
    +
    + + {label} + {options.map((option) => ( + onChange(option.value)} + leftSection={ + option.value === value ? ( + + ) : ( + + ) + } + > + {option.label} + + ))} + +
    + ); +} + +export default FilesToolbarSortMenu; diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index fb73be8655..dd2b4a12bd 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -38,6 +38,13 @@ background: var(--c-hover); } +@media (max-width: 64rem) { + .workbenchBarReopenTab { + width: 3rem; + height: 1.375rem; + } +} + .workbenchBarWrapper { display: grid; grid-template-rows: 1fr; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.css b/frontend/editor/src/core/components/shared/AppConfigModal.css index 2953632d98..1f2246c8f5 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.css +++ b/frontend/editor/src/core/components/shared/AppConfigModal.css @@ -39,41 +39,77 @@ flex-direction: column; } -/* Mobile: compact icon-only navigation */ +/* Mobile: two-level settings navigation */ @media (max-width: 1024px) { .modal-container { - height: 100vh !important; + flex-direction: column; + height: 100dvh !important; max-height: none !important; } .modal-nav { - width: 5rem; /* 80px - wider for larger icons */ - height: 100vh !important; + width: 100%; + flex: 1; + min-height: 0; + height: auto !important; max-height: none !important; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-radius: 0; } .modal-nav-scroll { - padding: 1rem 0.5rem; + padding: 0.75rem 0.75rem 2rem; } .modal-nav-section { - margin-bottom: 1.5rem; + margin-bottom: 1.25rem; + } + + .modal-nav-section > .mantine-Text-root { + padding: 0 0.5rem; } .modal-nav-item.mobile { - padding: 1rem; - justify-content: center; - border-radius: 0.75rem; - margin-bottom: 0.75rem; + padding: 0.75rem 0.625rem; + min-height: 3rem; + border-radius: 0.625rem; + margin-bottom: 0.125rem; + gap: 0.75rem; + } + + .modal-nav-item .modal-nav-item-badge { + display: inline-flex; + } + + .modal-nav-chevron { + flex-shrink: 0; + color: var(--c-text-subtle); } .modal-content { - height: 100vh !important; + height: auto !important; + flex: 1; + min-height: 0; max-height: none !important; border-radius: 0; } + + .modal-body { + padding: 1rem; + padding-top: 0.75rem; + } +} + +@media (max-width: 48rem) { + .modal-body [id^="setting-"] { + flex-direction: column; + align-items: flex-start !important; + gap: 0.625rem; + } + + .modal-body [id^="setting-"]:has(.mantine-Switch-root) { + flex-direction: row; + align-items: center !important; + } } .modal-nav-scroll { @@ -241,6 +277,7 @@ @media (max-width: 1024px) { .settings-sticky-footer { padding: 0.75rem 1rem; + padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px)); margin: 0 -1rem; margin-bottom: -1rem; } diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 12faf6eea5..8c6bf423ed 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -7,6 +7,9 @@ import React, { } from "react"; import { Badge, Modal, Text, Tooltip, Group } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; +import { SettingsMobileBackButton } from "@app/components/shared/config/SettingsMobileBackButton"; +import { SettingsMobileNavHeader } from "@app/components/shared/config/SettingsMobileNavHeader"; +import { SettingsNavChevron } from "@app/components/shared/config/SettingsNavChevron"; import { useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -82,6 +85,7 @@ const AppConfigModalInner: React.FC = ({ "general", ); const isMobile = useIsMobile(); + const [mobilePane, setMobilePane] = useState<"nav" | "content">("nav"); const navigate = useNavigate(); const location = useLocation(); const { config } = useAppConfig(); @@ -122,6 +126,14 @@ const AppConfigModalInner: React.FC = ({ } }, [opened]); + useEffect(() => { + if (!opened) return; + const target = urlSync + ? getSectionFromPath(window.location.pathname) + : initialSection; + setMobilePane(target ? "content" : "nav"); + }, [opened, urlSync, initialSection]); + // Switch tab without forcing every `useLocation()` subscriber (HomePage and // its FileSidebar/Workbench/RightSidebar/FileManager tree) to re-render. // @@ -306,10 +318,17 @@ const AppConfigModalInner: React.FC = ({ const canProceed = await confirmIfDirty(); if (!canProceed) return; switchSection(key); + setMobilePane("content"); }, [confirmIfDirty, switchSection], ); + const handleMobileBack = useCallback(async () => { + const canProceed = await confirmIfDirty(); + if (!canProceed) return; + setMobilePane("nav"); + }, [confirmIfDirty]); + return ( = ({ className={`modal-nav ${isMobile ? "mobile" : ""}`} style={{ background: colors.navBg, - borderRight: `1px solid ${colors.headerBorder}`, + ...(isMobile + ? { display: mobilePane === "nav" ? undefined : "none" } + : { borderRight: `1px solid ${colors.headerBorder}` }), }} > +
    {configNavSections.map((section) => (
    - {!isMobile && ( - - {section.title} - - )} + + {section.title} +
    {section.items.map((item) => { const isActive = active === item.key; @@ -355,7 +380,7 @@ const AppConfigModalInner: React.FC = ({ const color = isActive ? colors.navItemActive : colors.navItem; - const iconSize = isMobile ? 28 : 18; + const iconSize = 18; const showPlanWarning = item.key === "adminPlan" && licenseAlert.active && @@ -383,47 +408,46 @@ const AppConfigModalInner: React.FC = ({ icon={item.icon} width={iconSize} height={iconSize} - style={{ color }} + style={{ color, flexShrink: 0 }} /> - {!isMobile && ( - + - + {item.badge && ( + - {item.label} - - {item.badge && ( - - {item.badge} - - )} - {showPlanWarning && ( - - )} - - )} + {item.badge} + + )} + {showPlanWarning && ( + + )} + +
    ); @@ -450,7 +474,15 @@ const AppConfigModalInner: React.FC = ({
    {/* Right content */} -
    +
    {/* Sticky header with section title and small close button */}
    = ({ borderBottom: `1px solid ${colors.headerBorder}`, }} > - - {activeLabel} - + + void handleMobileBack()} + /> + + {activeLabel} + + * { +.workbench-bar[data-wrapped="true"] .workbench-bar-center-scroll > * { flex-shrink: 0; } @@ -152,16 +152,25 @@ .workbench-bar-center { order: 4; flex: 0 0 100%; + min-width: 0; + max-width: 100%; position: relative; display: flex; align-items: center; + /* Symmetric side padding leaves room for the retract handle pinned right + without knocking the centred tool icons off-centre. */ + padding: 4px 36px; + border-top: 1px solid var(--c-border-subtle); +} + +.workbench-bar-center-scroll { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; justify-content: center; flex-wrap: wrap; gap: 2px; - /* Symmetric side padding leaves room for the retract handle pinned right - without knocking the centred tool icons off-centre. */ - padding: 4px 36px; - border-top: 1px solid var(--c-border-subtle); } /* Retract / reopen handle for the viewer tool row. */ @@ -297,3 +306,79 @@ text-align: right; white-space: nowrap; } + +/* ---- Mobile layout (matches useIsMobile's 1024px) ---- */ +@media (max-width: 64rem) { + .workbench-bar { + margin: var(--nav-gutter) var(--nav-gutter) 0; + } + + .workbench-bar-action-icon { + width: 40px !important; + height: 40px !important; + min-width: 40px !important; + min-height: 40px !important; + } + + .workbench-bar-views, + .workbench-bar-globals { + height: auto; + min-height: 44px; + } + + .workbench-bar-center { + padding: 2px 4px 2px 8px; + } + + .workbench-bar-center-scroll { + gap: 4px; + } + + .workbench-bar[data-wrapped="true"] .workbench-bar-search { + order: 2; + flex: 1 1 0; + min-width: 0; + padding: 4px 0; + } + + .workbench-bar[data-wrapped="true"] .workbench-bar-globals { + order: 3; + } + + .workbench-bar[data-wrapped="true"] .workbench-bar-center-scroll { + scrollbar-width: none; + /* Wider than one 40px icon plus its gap: a 2rem fade always landed + mid-glyph, which read as a clipping bug rather than "scroll me". */ + -webkit-mask-image: linear-gradient( + to right, + #000 calc(100% - 3.5rem), + transparent + ); + mask-image: linear-gradient( + to right, + #000 calc(100% - 3.5rem), + transparent + ); + } + + .workbench-bar[data-wrapped="true"] + .workbench-bar-center--expanded + .workbench-bar-center-scroll { + flex-wrap: wrap; + justify-content: center; + overflow-x: visible; + -webkit-mask-image: none; + mask-image: none; + } + + .workbench-bar[data-wrapped="true"] + .workbench-bar-center--expanded + .workbench-bar-divider { + display: none; + } + + .workbench-bar-toolbar-handle-expand { + flex-shrink: 0; + align-self: flex-start; + } +} diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index ad38fb1974..19026c9df0 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -3,9 +3,9 @@ import React, { useLayoutEffect, useMemo, useRef, + useState, useSyncExternalStore, } from "react"; -import { Group, Loader, Progress, Stack, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import { SegmentedControl } from "@app/ui/SegmentedControl"; @@ -31,7 +31,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useNavigationState } from "@app/contexts/NavigationContext"; import { ViewerContext, useViewer } from "@app/contexts/ViewerContext"; import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; -import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; import { useEditorSearchScopes } from "@app/hooks/useSuperSearch"; @@ -53,10 +52,12 @@ import { } from "@app/types/workbenchBar"; import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; -import CloseIcon from "@mui/icons-material/Close"; -import PrintIcon from "@mui/icons-material/Print"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import WorkbenchBarDesktopActions from "@app/components/shared/workbenchBar/WorkbenchBarDesktopActions"; +import WorkbenchBarMobileActions from "@app/components/shared/workbenchBar/WorkbenchBarMobileActions"; +import WorkbenchBarToolbarHandle from "@app/components/shared/workbenchBar/WorkbenchBarToolbarHandle"; +import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbenchBarTooltip"; +import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import "@app/components/shared/WorkbenchBar.css"; const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"]; @@ -77,24 +78,6 @@ interface WorkbenchBarProps { onCollapseViewerToolbar?: (collapsed: boolean) => void; } -function renderWithTooltip( - node: React.ReactNode, - tooltip: React.ReactNode | undefined, -) { - if (!tooltip) return node; - return ( - -
    {node}
    -
    - ); -} - export default function WorkbenchBar({ currentView, setCurrentView, @@ -132,6 +115,8 @@ export default function WorkbenchBar({ const icons = useFileActionIcons(); const { sharingEnabled } = useSharingEnabled(); const viewerContext = React.useContext(ViewerContext); + const isMobile = useIsMobile(); + const [mobileToolsExpanded, setMobileToolsExpanded] = useState(false); const selectors = useFileSelectors(); const { selectedFiles, selectedFileIds } = useFileSelection(); @@ -166,32 +151,6 @@ export default function WorkbenchBar({ enforcingRun?.currentStep != null && enforcingRun.stepCount ? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100) : undefined; - const makeEnforcingTooltip = (action: string): React.ReactNode => ( - - - - - {t( - "policy.blockingAction", - "{{action}} blocked while enforcing policy, please wait", - { action }, - )} - - - {enforcingProgress != null ? ( - - ) : ( - - )} - - ); const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; @@ -365,6 +324,33 @@ export default function WorkbenchBar({ return terminology.downloadAll; }, [currentView, selectedCount, t, terminology]); + const actionsDisabled = + totalItems === 0 || allButtonsDisabled || disableForFullscreen; + + // Shared by the mobile overflow menu and the desktop icon cluster so the two + // stay in step; each renders the same actions in its own shape. + const globalActionProps: WorkbenchBarActionsProps = { + currentView, + isCustomView, + actionsDisabled, + policyEnforcing, + downloadLabel: downloadTooltip, + downloadIconName: icons.downloadIconName, + saveAsIconName: icons.saveAsIconName, + onPrint: handlePrint, + onExport: handleExportAll, + onClose: handleClose, + }; + + const toggleMobileTools = useCallback( + () => setMobileToolsExpanded((v) => !v), + [], + ); + const handleRetractToolbar = useCallback( + () => onCollapseViewerToolbar?.(true), + [onCollapseViewerToolbar], + ); + const renderButton = useCallback( (btn: WorkbenchBarButtonConfig) => { const action = actions[btn.id]; @@ -560,38 +546,44 @@ export default function WorkbenchBar({ whole row; Workbench then shows a tab below the bar to bring it back. */} {sectionsWithButtons.length > 0 && !(isViewer && viewerToolbarCollapsed) && ( -
    - {sectionsWithButtons.map( - ({ section, buttons: sectionButtons }, idx) => ( - - {idx > 0 &&
    } - {sectionButtons.map((btn) => { - const content = renderButton(btn); - if (!content) return null; - return ( -
    - {content} -
    - ); - })} - - ), - )} - {isViewer && onCollapseViewerToolbar && ( -
    - )} + ) : null} (null); + // Phones render a desktop-width document into ~400px, which reads as a blank + // column, so the iframe is opt-in there. Derived rather than seeded into + // state because useIsMobile resolves after first paint. + const [optedIn, setOptedIn] = useState(false); + const showPreview = !isMobile || optedIn; useEffect(() => { + if (!showPreview) return; const url = URL.createObjectURL(file); setObjectUrl(url); return () => URL.revokeObjectURL(url); - }, [file]); + }, [file, showPreview]); return ( - - {t("viewer.nonPdf.htmlPreviewWarning", { - size: formatFileSize(file.size), - })} - + + + {t("viewer.nonPdf.htmlPreviewWarning", { + size: formatFileSize(file.size), + })} + + {/* Opting in used to be one-way: the only way back was closing and + reopening the file. */} + {isMobile && optedIn && ( + + )} + - {objectUrl && ( -