From 9c8b0e3bd1f22abc3423f9de90f79bcac696b536 Mon Sep 17 00:00:00 2001 From: peacebas Date: Mon, 19 Oct 2026 00:47:38 +0100 Subject: [PATCH] feat(core): fix #153, #155, #159, and #180 reduced motion, storage isolation, bundle budget, and administrative approvals --- __tests__/bundle-initial-load.test.ts | 161 ++++++++ __tests__/storage-isolation.test.tsx | 126 +++++++ app/globals.css | 22 ++ bundle-baseline.json | 5 + .../signed-administrative-approvals.test.ts | 347 ++++++++++++++++++ core/auth/maintainer-approval.ts | 208 ++++++++++- core/network/NetworkProvider.tsx | 24 +- core/testing/index.ts | 2 +- core/testing/render.tsx | 44 ++- core/ui/Button.tsx | 2 +- core/ui/Skeleton.tsx | 2 +- core/ui/StatusMessage.tsx | 2 +- core/ui/__tests__/reduced-motion.test.tsx | 117 ++++++ core/ui/accessibility.ts | 52 +++ core/ui/index.ts | 2 + docs/BUNDLE_BUDGET.md | 25 ++ docs/MAINTAINER_APPROVAL_FLOW.md | 46 +++ docs/REDUCED_MOTION.md | 97 +++++ scripts/bundle-budget-exceptions.json | 25 ++ scripts/bundle-budget.mjs | 217 ++++++++--- vitest.config.ts | 3 +- 21 files changed, 1465 insertions(+), 64 deletions(-) create mode 100644 __tests__/bundle-initial-load.test.ts create mode 100644 __tests__/storage-isolation.test.tsx create mode 100644 core/auth/__tests__/signed-administrative-approvals.test.ts create mode 100644 core/ui/__tests__/reduced-motion.test.tsx create mode 100644 docs/REDUCED_MOTION.md create mode 100644 scripts/bundle-budget-exceptions.json diff --git a/__tests__/bundle-initial-load.test.ts b/__tests__/bundle-initial-load.test.ts new file mode 100644 index 0000000..346163c --- /dev/null +++ b/__tests__/bundle-initial-load.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + checkBudgets, + loadExceptions, + loadBaseline, + analyzeRouteInitialLoad, + formatReport, + DEFAULT_BUDGET_KB, + ROUTE_INITIAL_LOAD_BUDGET_KB +} from "@/scripts/bundle-budget.mjs"; + +describe("Initial-Load Performance Budgets & SDK Exception Path (#159)", () => { + const sampleExceptions = { + "xdr-inspector": { + budgetKB: 350, + justification: "Deep recursive XDR parser and Soroban contract type schemas", + approved: true + }, + "soroban-decoder": { + budgetKB: 300, + justification: "Full Soroban ScVal decoding and contract auth types", + approved: true + } + }; + + const sampleBaseline = { + "address-validator": { totalSize: 102400, sizeKB: 100 }, + "_route_initial_load": { totalSize: 184320, sizeKB: 180 } + }; + + it("approves standard feature within normal budget", () => { + const featureSizes = { + "address-validator": { totalSize: 110 * 1024, chunks: [] } + }; + const routeInitialLoad = { totalSize: 150 * 1024, sizeKB: 150, chunks: [] }; + + const { results, routeResult, hasViolations } = checkBudgets( + featureSizes, + routeInitialLoad, + sampleBaseline, + sampleExceptions + ); + + expect(hasViolations).toBe(false); + expect(results[0].status).toBe("OK"); + expect(results[0].withinBudget).toBe(true); + expect(routeResult.status).toBe("OK"); + }); + + it("flags violation when tool exceeds budget without approved exception", () => { + const featureSizes = { + "random-heavy-tool": { totalSize: 180 * 1024, chunks: [] } // Exceeds default 150 KB + }; + const routeInitialLoad = { totalSize: 150 * 1024, sizeKB: 150, chunks: [] }; + + const { results, hasViolations } = checkBudgets( + featureSizes, + routeInitialLoad, + sampleBaseline, + sampleExceptions + ); + + expect(hasViolations).toBe(true); + expect(results[0].status).toBe("EXCEEDED"); + expect(results[0].withinBudget).toBe(false); + }); + + it("approves SDK-heavy feature that exceeds standard budget via documented exception path", () => { + const featureSizes = { + "xdr-inspector": { totalSize: 280 * 1024, chunks: [] } // 280 KB > 150 KB standard, but < 350 KB exception ceiling + }; + const routeInitialLoad = { totalSize: 150 * 1024, sizeKB: 150, chunks: [] }; + + const { results, hasViolations } = checkBudgets( + featureSizes, + routeInitialLoad, + sampleBaseline, + sampleExceptions + ); + + expect(hasViolations).toBe(false); + expect(results[0].status).toBe("EXCEPTION_APPROVED"); + expect(results[0].withinBudget).toBe(true); + expect(results[0].isException).toBe(true); + expect(results[0].justification).toContain("Deep recursive XDR parser"); + }); + + it("rejects feature that exceeds its approved exception ceiling", () => { + const featureSizes = { + "xdr-inspector": { totalSize: 380 * 1024, chunks: [] } // 380 KB > 350 KB exception ceiling! + }; + const routeInitialLoad = { totalSize: 150 * 1024, sizeKB: 150, chunks: [] }; + + const { results, hasViolations } = checkBudgets( + featureSizes, + routeInitialLoad, + sampleBaseline, + sampleExceptions + ); + + expect(hasViolations).toBe(true); + expect(results[0].status).toContain("EXCEEDED"); + expect(results[0].withinBudget).toBe(false); + }); + + it("enforces route-level initial-load budget limit", () => { + const featureSizes = { + "address-validator": { totalSize: 100 * 1024, chunks: [] } + }; + // Exceeds 220 KB route initial-load budget + const heavyRouteInitialLoad = { totalSize: 260 * 1024, sizeKB: 260, chunks: [] }; + + const { routeResult, hasViolations } = checkBudgets( + featureSizes, + heavyRouteInitialLoad, + sampleBaseline, + sampleExceptions + ); + + expect(hasViolations).toBe(true); + expect(routeResult.status).toBe("EXCEEDED"); + expect(routeResult.withinBudget).toBe(false); + }); + + it("loads documented exceptions catalog from repository file", () => { + const exceptions = loadExceptions(); + expect(exceptions["xdr-inspector"]).toBeDefined(); + expect(exceptions["xdr-inspector"].approved).toBe(true); + expect(exceptions["xdr-inspector"].budgetKB).toBeGreaterThan(DEFAULT_BUDGET_KB); + }); + + it("formats comprehensive report containing route and exception information", () => { + const results = [ + { + feature: "xdr-inspector", + sizeKB: 280, + budgetKB: 350, + isException: true, + justification: "Soroban bindings", + status: "EXCEPTION_APPROVED", + delta: 0, + baselineSizeKB: 280, + withinBudget: true + } + ]; + const routeResult = { + name: "Route Initial-Load (/tools/[slug])", + sizeKB: 180, + budgetKB: ROUTE_INITIAL_LOAD_BUDGET_KB, + baselineSizeKB: 180, + delta: 0, + status: "OK", + withinBudget: true + }; + + const report = formatReport(results, routeResult, false); + expect(report).toContain("UTIX INITIAL-LOAD & FEATURE BUNDLE BUDGET REPORT"); + expect(report).toContain("ROUTE INITIAL-LOAD COSTS"); + expect(report).toContain("Exception Approved: Soroban bindings"); + }); +}); diff --git a/__tests__/storage-isolation.test.tsx b/__tests__/storage-isolation.test.tsx new file mode 100644 index 0000000..1528129 --- /dev/null +++ b/__tests__/storage-isolation.test.tsx @@ -0,0 +1,126 @@ +import { cleanup, screen, act } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderFeature, createIsolatedStorage } from "@/core/testing"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { useNotifications } from "@/core/notifications/NotificationProvider"; +import { NETWORK_STORAGE_KEY } from "@/core/network/config"; + +function TestConsumer() { + const { network, setNetwork } = useNetwork(); + const { notifications, unreadCount, publish } = useNotifications(); + + return ( +
+ {network} + {unreadCount} + + + +
+ ); +} + +describe("Deterministic Browser Storage Isolation (#155)", () => { + afterEach(() => { + cleanup(); + }); + + describe("Failing-order regression test demonstration", () => { + it("demonstrates state contamination when sharing unisolated storage across tests", () => { + // Unisolated shared storage simulation + const leakySharedStorage = createIsolatedStorage(); + + // Test 1: Mutates shared storage to 'mainnet' + leakySharedStorage.setItem(NETWORK_STORAGE_KEY, "mainnet"); + + // Test 2 (running after Test 1): Expects default clean state, but gets contaminated by Test 1! + const leakedValue = leakySharedStorage.getItem(NETWORK_STORAGE_KEY); + expect(leakedValue).toBe("mainnet"); // Demonstrates leakage hazard + }); + }); + + describe("Deterministic isolation with renderFeature and injected storage", () => { + it("Step A: Render 1 modifies network and publishes notifications in its isolated fixture", async () => { + const { user, storage } = renderFeature(); + + expect(screen.getByTestId("network-status").textContent).toBe("testnet"); + expect(screen.getByTestId("unread-count").textContent).toBe("0"); + + // Mutate network to mainnet + await user.click(screen.getByRole("button", { name: "Switch to Mainnet" })); + expect(screen.getByTestId("network-status").textContent).toBe("mainnet"); + expect(storage.getItem(NETWORK_STORAGE_KEY)).toBe("mainnet"); + + // Send notification + await user.click(screen.getByRole("button", { name: "Send Notification" })); + expect(screen.getByTestId("unread-count").textContent).toBe("1"); + }); + + it("Step B: Render 2 starts with pristine testnet and zero notifications (independent of Step A)", () => { + // Render 2 runs after Step A. Without isolation, Step A's mainnet and notifications would leak. + // With deterministic isolation, Render 2 owns a brand new storage fixture. + const { storage } = renderFeature(); + + expect(screen.getByTestId("network-status").textContent).toBe("testnet"); + expect(screen.getByTestId("unread-count").textContent).toBe("0"); + expect(storage.getItem(NETWORK_STORAGE_KEY)).toBeNull(); + }); + + it("supports explicitly sharing an injected storage fixture when persistence is under test", async () => { + const sharedFixture = createIsolatedStorage(); + + // First session: write to the explicit fixture + const session1 = renderFeature(, { storage: sharedFixture }); + await session1.user.click(session1.getByRole("button", { name: "Switch to Mainnet" })); + expect(session1.getByTestId("network-status").textContent).toBe("mainnet"); + cleanup(); + + // Second session: re-mount with the SAME fixture to test persistence + const session2 = renderFeature(, { storage: sharedFixture }); + expect(session2.getByTestId("network-status").textContent).toBe("mainnet"); + }); + + it("createIsolatedStorage provides complete Storage interface fidelity", () => { + const storage = createIsolatedStorage({ initialKey: "initialValue" }); + + expect(storage.length).toBe(1); + expect(storage.getItem("initialKey")).toBe("initialValue"); + expect(storage.key(0)).toBe("initialKey"); + + storage.setItem("key2", "value2"); + expect(storage.length).toBe(2); + expect(storage.getItem("key2")).toBe("value2"); + + storage.removeItem("initialKey"); + expect(storage.length).toBe(1); + expect(storage.getItem("initialKey")).toBeNull(); + + storage.clear(); + expect(storage.length).toBe(0); + expect(storage.getItem("key2")).toBeNull(); + }); + }); +}); diff --git a/app/globals.css b/app/globals.css index e489c6f..67ca483 100644 --- a/app/globals.css +++ b/app/globals.css @@ -94,3 +94,25 @@ textarea { html { scroll-behavior: smooth; } + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .animate-fade-in-up, + .animate-float, + .animate-pulse { + animation: none !important; + } +} + diff --git a/bundle-baseline.json b/bundle-baseline.json index de4820f..5c35dd2 100644 --- a/bundle-baseline.json +++ b/bundle-baseline.json @@ -33,5 +33,10 @@ "totalSize": 81920, "sizeKB": 80, "timestamp": "2026-09-25T00:00:00.000Z" + }, + "_route_initial_load": { + "totalSize": 184320, + "sizeKB": 180, + "timestamp": "2026-09-25T00:00:00.000Z" } } diff --git a/core/auth/__tests__/signed-administrative-approvals.test.ts b/core/auth/__tests__/signed-administrative-approvals.test.ts new file mode 100644 index 0000000..aacbc25 --- /dev/null +++ b/core/auth/__tests__/signed-administrative-approvals.test.ts @@ -0,0 +1,347 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + ACTION_THRESHOLDS, + executeProtectedAction, + MaintainerApprovalManager, + verifyProtectedActionApproval, + type HighImpactAction, + type ProtectedActionRequest +} from "../maintainer-approval"; + +const MAINTAINER_ALICE = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)); +const MAINTAINER_BOB = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)); +const MAINTAINER_CAROL = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)); +const ROGUE_ACTOR = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 99)); + +describe("Issue #180: Signed Administrative Action Approvals for High-Impact Changes", () => { + let manager: MaintainerApprovalManager; + let mockCurrentTime: number; + + beforeEach(() => { + mockCurrentTime = 1700000000000; + manager = new MaintainerApprovalManager({ + now: () => new Date(mockCurrentTime), + defaultExpiryMs: 15 * 60 * 1000 // 15 minutes + }); + }); + + function signProposal(intentId: string, signer: Keypair) { + const intent = manager.getIntent(intentId); + if (!intent) throw new Error("Intent not found for test helper"); + const sigHex = signer.sign(Buffer.from(intent.intentHash, "hex")).toString("hex"); + return manager.signIntent(intentId, signer.publicKey(), sigHex); + } + + describe("Thresholds & Action Types", () => { + it("defines proper multi-maintainer thresholds for ticketing and event access actions", () => { + const expectedThresholds: Record = { + emergency_killswitch: 2, + protocol_rollback: 2, + reconciliation_repair: 2, + admin_config_override: 2, + quota_override: 1, + dead_letter_purge: 1, + event_capacity_override: 2, + event_cancellation: 2, + fraud_lock_quarantine: 1, + batch_ticket_burn: 2, + organizer_fee_override: 2 + }; + + for (const [action, threshold] of Object.entries(expectedThresholds)) { + expect(ACTION_THRESHOLDS[action as HighImpactAction]).toBe(threshold); + } + }); + }); + + describe("Protected Action Verification & Guardrails", () => { + it("fails when approval ID is missing or not found (approval_not_found)", async () => { + const request: ProtectedActionRequest = { + action: "event_cancellation", + target: "event-stadium-2026", + approvalId: "intent-does-not-exist", + executorPublicKey: MAINTAINER_ALICE.publicKey() + }; + + const verification = verifyProtectedActionApproval(request, manager); + expect(verification.valid).toBe(false); + if (!verification.valid) { + expect(verification.error).toBe("approval_not_found"); + expect(verification.message).toContain("was not found"); + } + + await expect( + executeProtectedAction(request, () => ({ success: true }), manager) + ).rejects.toThrow("approval_not_found"); + }); + + it("rejects pending actions when approval threshold is not yet met (approval_not_ready)", async () => { + const intent = manager.proposeIntent({ + action: "event_cancellation", + target: "event-stadium-2026", + reason: "Severe hurricane alert issued by authorities", + requestedBy: MAINTAINER_ALICE.publicKey() + }); + + // Only 1 of 2 required maintainers signs + signProposal(intent.id, MAINTAINER_ALICE); + + const request: ProtectedActionRequest = { + action: "event_cancellation", + target: "event-stadium-2026", + approvalId: intent.id, + executorPublicKey: MAINTAINER_ALICE.publicKey() + }; + + const verification = verifyProtectedActionApproval(request, manager); + expect(verification.valid).toBe(false); + if (!verification.valid) { + expect(verification.error).toBe("approval_not_ready"); + } + + await expect( + executeProtectedAction(request, () => ({ success: true }), manager) + ).rejects.toThrow("approval_not_ready"); + }); + + it("rejects expired approvals (approval_expired)", async () => { + const intent = manager.proposeIntent({ + action: "event_capacity_override", + target: "event-arena-101", + parameters: { maxTickets: 25000 }, + reason: "Fire marshal permit granted for floor expansion", + requestedBy: MAINTAINER_ALICE.publicKey(), + expiresInMs: 5000 + }); + + // Alice and Bob sign to approve + signProposal(intent.id, MAINTAINER_ALICE); + signProposal(intent.id, MAINTAINER_BOB); + expect(manager.getIntent(intent.id)?.status).toBe("approved"); + + // Advance clock past expiration + mockCurrentTime += 10000; + + const request: ProtectedActionRequest = { + action: "event_capacity_override", + target: "event-arena-101", + approvalId: intent.id, + executorPublicKey: MAINTAINER_ALICE.publicKey() + }; + + const verification = verifyProtectedActionApproval(request, manager); + expect(verification.valid).toBe(false); + if (!verification.valid) { + expect(verification.error).toBe("approval_expired"); + } + + await expect( + executeProtectedAction(request, () => ({ expanded: true }), manager) + ).rejects.toThrow("approval_expired"); + }); + + it("rejects mismatched actions and targets (action_mismatch, target_mismatch)", async () => { + const intent = manager.proposeIntent({ + action: "event_cancellation", + target: "event-main-stage", + reason: "Force majeure event", + requestedBy: MAINTAINER_ALICE.publicKey() + }); + + signProposal(intent.id, MAINTAINER_ALICE); + signProposal(intent.id, MAINTAINER_BOB); + + // 1. Action Mismatch + const actionMismatchReq: ProtectedActionRequest = { + action: "event_capacity_override", // Mismatched action! + target: "event-main-stage", + approvalId: intent.id, + executorPublicKey: MAINTAINER_ALICE.publicKey() + }; + const vAction = verifyProtectedActionApproval(actionMismatchReq, manager); + expect(vAction.valid).toBe(false); + if (!vAction.valid) { + expect(vAction.error).toBe("action_mismatch"); + } + + await expect( + executeProtectedAction(actionMismatchReq, () => {}, manager) + ).rejects.toThrow("action_mismatch"); + + // 2. Target Mismatch + const targetMismatchReq: ProtectedActionRequest = { + action: "event_cancellation", + target: "event-different-stage", // Mismatched target! + approvalId: intent.id, + executorPublicKey: MAINTAINER_ALICE.publicKey() + }; + const vTarget = verifyProtectedActionApproval(targetMismatchReq, manager); + expect(vTarget.valid).toBe(false); + if (!vTarget.valid) { + expect(vTarget.error).toBe("target_mismatch"); + } + + await expect( + executeProtectedAction(targetMismatchReq, () => {}, manager) + ).rejects.toThrow("target_mismatch"); + }); + + it("rejects mismatched parameters (parameter_mismatch)", async () => { + const intent = manager.proposeIntent({ + action: "organizer_fee_override", + target: "organizer-fest-co", + parameters: { feeBasisPoints: 150 }, + reason: "Approved partnership promotional discount", + requestedBy: MAINTAINER_ALICE.publicKey() + }); + + signProposal(intent.id, MAINTAINER_ALICE); + signProposal(intent.id, MAINTAINER_BOB); + + const alteredParamsReq: ProtectedActionRequest = { + action: "organizer_fee_override", + target: "organizer-fest-co", + parameters: { feeBasisPoints: 0 }, // Altered from 150 to 0! + approvalId: intent.id, + executorPublicKey: MAINTAINER_ALICE.publicKey() + }; + + const vParam = verifyProtectedActionApproval(alteredParamsReq, manager); + expect(vParam.valid).toBe(false); + if (!vParam.valid) { + expect(vParam.error).toBe("parameter_mismatch"); + } + + await expect( + executeProtectedAction(alteredParamsReq, () => {}, manager) + ).rejects.toThrow("parameter_mismatch"); + }); + + it("rejects unauthorized actors and invalid Ed25519 public keys (unauthorized_actor)", async () => { + const intent = manager.proposeIntent({ + action: "fraud_lock_quarantine", + target: "ticket-batch-90210", + reason: "Suspected credit card chargeback cluster", + requestedBy: MAINTAINER_ALICE.publicKey() + }); + + signProposal(intent.id, MAINTAINER_ALICE); + + // 1. Invalid key format + const invalidKeyReq: ProtectedActionRequest = { + action: "fraud_lock_quarantine", + target: "ticket-batch-90210", + approvalId: intent.id, + executorPublicKey: "MALFORMED_STELLAR_KEY" + }; + + const vInvalid = verifyProtectedActionApproval(invalidKeyReq, manager); + expect(vInvalid.valid).toBe(false); + if (!vInvalid.valid) { + expect(vInvalid.error).toBe("unauthorized_actor"); + } + + await expect( + executeProtectedAction(invalidKeyReq, () => {}, manager) + ).rejects.toThrow("unauthorized_actor"); + + // 2. Key not in authorized maintainers list + const unauthorizedReq: ProtectedActionRequest = { + action: "fraud_lock_quarantine", + target: "ticket-batch-90210", + approvalId: intent.id, + executorPublicKey: ROGUE_ACTOR.publicKey() + }; + + const vRogue = verifyProtectedActionApproval(unauthorizedReq, manager, { + authorizedMaintainers: [MAINTAINER_ALICE.publicKey(), MAINTAINER_BOB.publicKey()] + }); + expect(vRogue.valid).toBe(false); + if (!vRogue.valid) { + expect(vRogue.error).toBe("unauthorized_actor"); + } + + await expect( + executeProtectedAction(unauthorizedReq, () => {}, manager, { + authorizedMaintainers: [MAINTAINER_ALICE.publicKey(), MAINTAINER_BOB.publicKey()] + }) + ).rejects.toThrow("unauthorized_actor"); + + // 3. Executor required to be a signer + const notSignerReq: ProtectedActionRequest = { + action: "fraud_lock_quarantine", + target: "ticket-batch-90210", + approvalId: intent.id, + executorPublicKey: MAINTAINER_CAROL.publicKey() + }; + + const vNotSigner = verifyProtectedActionApproval(notSignerReq, manager, { + requireSignerAsExecutor: true + }); + expect(vNotSigner.valid).toBe(false); + if (!vNotSigner.valid) { + expect(vNotSigner.error).toBe("unauthorized_actor"); + } + }); + + it("executes valid multi-maintainer signed approval and prevents replay (already_executed)", async () => { + const intent = manager.proposeIntent({ + action: "batch_ticket_burn", + target: "event-2026-fraudulent-run", + parameters: { ticketIds: ["t-1", "t-2", "t-3"], burnReason: "Counterfeit barcodes" }, + reason: "Court ordered ticket invalidation", + requestedBy: MAINTAINER_ALICE.publicKey() + }); + + // Both Alice and Bob sign + signProposal(intent.id, MAINTAINER_ALICE); + signProposal(intent.id, MAINTAINER_BOB); + + const request: ProtectedActionRequest = { + action: "batch_ticket_burn", + target: "event-2026-fraudulent-run", + parameters: { ticketIds: ["t-1", "t-2", "t-3"], burnReason: "Counterfeit barcodes" }, + approvalId: intent.id, + executorPublicKey: MAINTAINER_BOB.publicKey() + }; + + // 1. Verification succeeds + const vOk = verifyProtectedActionApproval(request, manager); + expect(vOk.valid).toBe(true); + if (vOk.valid) { + expect(vOk.intent.id).toBe(intent.id); + } + + // 2. Execution succeeds + let executedBurn = false; + const executionResult = await executeProtectedAction( + request, + async (approvedIntent) => { + executedBurn = true; + return { burnedCount: (approvedIntent.parameters.ticketIds as string[]).length }; + }, + manager + ); + + expect(executedBurn).toBe(true); + expect(executionResult.result.burnedCount).toBe(3); + expect(executionResult.receipt.receiptDigest).toBeDefined(); + expect(executionResult.receipt.receiptDigest).toHaveLength(64); + expect(executionResult.receipt.signers).toContain(MAINTAINER_ALICE.publicKey()); + expect(executionResult.receipt.signers).toContain(MAINTAINER_BOB.publicKey()); + expect(executionResult.receipt.status).toBe("executed"); + + // 3. Replay protection: Attempting to verify or execute again is blocked + const vReplay = verifyProtectedActionApproval(request, manager); + expect(vReplay.valid).toBe(false); + if (!vReplay.valid) { + expect(vReplay.error).toBe("already_executed"); + } + + await expect( + executeProtectedAction(request, () => ({ burnedCount: 0 }), manager) + ).rejects.toThrow("already_executed"); + }); + }); +}); diff --git a/core/auth/maintainer-approval.ts b/core/auth/maintainer-approval.ts index b124f90..8e48e47 100644 --- a/core/auth/maintainer-approval.ts +++ b/core/auth/maintainer-approval.ts @@ -23,7 +23,12 @@ export type HighImpactAction = | "protocol_rollback" | "quota_override" | "dead_letter_purge" - | "admin_config_override"; + | "admin_config_override" + | "event_capacity_override" + | "event_cancellation" + | "fraud_lock_quarantine" + | "batch_ticket_burn" + | "organizer_fee_override"; export type IntentStatus = "pending" | "approved" | "executing" | "executed" | "rejected" | "expired" | "failed"; @@ -84,14 +89,50 @@ export interface ApprovalFlowOptions { defaultExpiryMs?: number; } +export interface ProtectedActionRequest { + action: HighImpactAction; + target: string; + parameters?: Record; + approvalId: string; + executorPublicKey: string; +} + +export type ProtectedActionErrorCode = + | "approval_not_found" + | "approval_not_ready" + | "approval_expired" + | "already_executed" + | "action_mismatch" + | "target_mismatch" + | "parameter_mismatch" + | "unauthorized_actor"; + +export type ProtectedActionVerificationResult = + | { valid: true; intent: IntentRequest } + | { + valid: false; + error: ProtectedActionErrorCode; + message: string; + }; + +export interface ProtectedActionExecutionOptions { + authorizedMaintainers?: string[]; + requireSignerAsExecutor?: boolean; +} + const DEFAULT_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes -const ACTION_THRESHOLDS: Record = { +export const ACTION_THRESHOLDS: Record = { emergency_killswitch: 2, protocol_rollback: 2, reconciliation_repair: 2, admin_config_override: 2, quota_override: 1, - dead_letter_purge: 1 + dead_letter_purge: 1, + event_capacity_override: 2, + event_cancellation: 2, + fraud_lock_quarantine: 1, + batch_ticket_burn: 2, + organizer_fee_override: 2 }; /** @@ -403,6 +444,143 @@ export class MaintainerApprovalManager { return Array.from(this.intents.values()).filter(i => i.status === "pending" && i.expiresAt > now); } + /** + * Returns the current timestamp according to the manager's clock configuration. + */ + currentTime(): number { + return this.now(); + } + + /** + * Determines whether an intent is expired. + */ + isExpired(intent: IntentRequest): boolean { + return intent.status === "expired" || this.now() > intent.expiresAt; + } + + /** + * Verifies that a protected high-impact administrative action has a valid, + * unexpired, signed approval matching the target, action, parameters, and authorized actor. + * Time complexity: O(1). + * Space complexity: O(1). + */ + verifyProtectedAction( + request: ProtectedActionRequest, + options?: ProtectedActionExecutionOptions + ): ProtectedActionVerificationResult { + if (!StrKey.isValidEd25519PublicKey(request.executorPublicKey)) { + return { + valid: false, + error: "unauthorized_actor", + message: `Invalid Ed25519 public key format for executor: ${request.executorPublicKey}` + }; + } + + if (options?.authorizedMaintainers && !options.authorizedMaintainers.includes(request.executorPublicKey)) { + return { + valid: false, + error: "unauthorized_actor", + message: `Executor ${request.executorPublicKey} is not in authorized maintainer allowlist` + }; + } + + const intent = this.intents.get(request.approvalId); + if (!intent) { + return { + valid: false, + error: "approval_not_found", + message: `Approval intent with ID '${request.approvalId}' was not found` + }; + } + + if (options?.requireSignerAsExecutor) { + const isSigner = intent.signatures.some(s => s.signerPublicKey === request.executorPublicKey) || intent.requestedBy === request.executorPublicKey; + if (!isSigner) { + return { + valid: false, + error: "unauthorized_actor", + message: `Executor ${request.executorPublicKey} is not a valid signer or requester for intent ${request.approvalId}` + }; + } + } + + if (intent.status === "executed" || intent.status === "executing" || this.receiptsByIntent.has(request.approvalId)) { + return { + valid: false, + error: "already_executed", + message: `Approval intent '${request.approvalId}' has already been executed (replay prevented)` + }; + } + + if (this.isExpired(intent)) { + intent.status = "expired"; + return { + valid: false, + error: "approval_expired", + message: `Approval intent '${request.approvalId}' has expired` + }; + } + + if (intent.status !== "approved") { + return { + valid: false, + error: "approval_not_ready", + message: `Approval intent '${request.approvalId}' is not approved (current status: ${intent.status}, required: ${intent.requiredSignatures}, signed: ${intent.signatures.length})` + }; + } + + if (intent.action !== request.action) { + return { + valid: false, + error: "action_mismatch", + message: `Action mismatch: approval authorizes '${intent.action}', but request attempted '${request.action}'` + }; + } + + if (intent.target !== request.target) { + return { + valid: false, + error: "target_mismatch", + message: `Target mismatch: approval authorizes target '${intent.target}', but request attempted '${request.target}'` + }; + } + + if (request.parameters) { + for (const [key, val] of Object.entries(request.parameters)) { + if (JSON.stringify(intent.parameters[key]) !== JSON.stringify(val)) { + return { + valid: false, + error: "parameter_mismatch", + message: `Parameter '${key}' mismatch between approval intent and requested action` + }; + } + } + } + + return { + valid: true, + intent + }; + } + + /** + * Executes a protected administrative action after strictly validating cryptographic approval. + * Time complexity: O(1) + actionHandler execution time. + * Space complexity: O(1). + */ + async executeProtectedAction( + request: ProtectedActionRequest, + actionHandler: (intent: IntentRequest) => Promise | T, + options?: ProtectedActionExecutionOptions + ): Promise<{ result: T; receipt: ApprovalReceipt }> { + const verification = this.verifyProtectedAction(request, options); + if (!verification.valid) { + throw new Error(`Protected action rejected [${verification.error}]: ${verification.message}`); + } + + return this.executeIntent(request.approvalId, request.executorPublicKey, actionHandler); + } + /** * Clears in-memory data (useful for test isolation). */ @@ -414,3 +592,27 @@ export class MaintainerApprovalManager { } export const maintainerApprovalManager = new MaintainerApprovalManager(); + +/** + * Top-level convenience wrapper for verifying protected action approvals against the default or provided manager. + */ +export function verifyProtectedActionApproval( + request: ProtectedActionRequest, + manager: MaintainerApprovalManager = maintainerApprovalManager, + options?: ProtectedActionExecutionOptions +): ProtectedActionVerificationResult { + return manager.verifyProtectedAction(request, options); +} + +/** + * Top-level convenience wrapper for executing protected actions after approval verification. + */ +export async function executeProtectedAction( + request: ProtectedActionRequest, + actionHandler: (intent: IntentRequest) => Promise | T, + manager: MaintainerApprovalManager = maintainerApprovalManager, + options?: ProtectedActionExecutionOptions +): Promise<{ result: T; receipt: ApprovalReceipt }> { + return manager.executeProtectedAction(request, actionHandler, options); +} + diff --git a/core/network/NetworkProvider.tsx b/core/network/NetworkProvider.tsx index 6c68c18..97620a9 100644 --- a/core/network/NetworkProvider.tsx +++ b/core/network/NetworkProvider.tsx @@ -49,9 +49,14 @@ function subscribe(onStoreChange: () => void): () => void { }; } -function getSnapshot(): StellarNetwork { +export function resetNetworkStorageListeners(): void { + listeners.clear(); +} + +function getSnapshotForStorage(storage?: Storage): StellarNetwork { try { - return resolveNetwork(window.localStorage.getItem(NETWORK_STORAGE_KEY), DEFAULT_NETWORK); + const s = storage ?? (typeof window !== "undefined" ? window.localStorage : undefined); + return resolveNetwork(s?.getItem(NETWORK_STORAGE_KEY), DEFAULT_NETWORK); } catch { // Private windows and blocked site data both throw here. return DEFAULT_NETWORK; @@ -62,9 +67,10 @@ function getServerSnapshot(): StellarNetwork { return DEFAULT_NETWORK; } -function writeNetwork(network: StellarNetwork): void { +function writeNetworkForStorage(network: StellarNetwork, storage?: Storage): void { try { - window.localStorage.setItem(NETWORK_STORAGE_KEY, network); + const s = storage ?? (typeof window !== "undefined" ? window.localStorage : undefined); + s?.setItem(NETWORK_STORAGE_KEY, network); } catch { // The choice still applies for this session even if it cannot be persisted. } @@ -73,12 +79,16 @@ function writeNetwork(network: StellarNetwork): void { export function NetworkProvider({ children, - initialNetwork + initialNetwork, + storage: injectedStorage }: { children: React.ReactNode; /** Forces a network regardless of stored preference. Used by tests. */ initialNetwork?: StellarNetwork; + /** Custom storage engine for test isolation and deterministic persistence (#155). */ + storage?: Storage; }) { + const getSnapshot = useCallback(() => getSnapshotForStorage(injectedStorage), [injectedStorage]); const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const [override, setOverride] = useState(initialNetwork); const [epoch, setEpoch] = useState(0); @@ -89,7 +99,7 @@ export function NetworkProvider({ setEpoch((current) => current + 1); // Keep the in-memory choice authoritative even when storage is blocked. setOverride(next); - writeNetwork(next); + writeNetworkForStorage(next, injectedStorage); // The Horizon client is memoised per network; a pruning job is enqueued as // delayed maintenance work so rapid switches settle before the cache is torn down. getMaintenanceFramework().enqueue({ @@ -97,7 +107,7 @@ export function NetworkProvider({ params: { network: next }, delayMs: PRUNE_HORIZON_CLIENTS_DELAY_MS }); - }, [network]); + }, [network, injectedStorage]); const value = useMemo( () => ({ diff --git a/core/testing/index.ts b/core/testing/index.ts index e711e05..e1024cc 100644 --- a/core/testing/index.ts +++ b/core/testing/index.ts @@ -1,4 +1,4 @@ -export { renderFeature, screen, waitFor, within, userEvent } from "@/core/testing/render"; +export { renderFeature, createIsolatedStorage, screen, waitFor, within, userEvent } from "@/core/testing/render"; export { renderFeatureSlice, FEATURE_CONTRACT_STATES, diff --git a/core/testing/render.tsx b/core/testing/render.tsx index 674e536..6ce8cf1 100644 --- a/core/testing/render.tsx +++ b/core/testing/render.tsx @@ -1,36 +1,64 @@ import { render, type RenderOptions, type RenderResult } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement, ReactNode } from "react"; -import { NetworkProvider } from "@/core/network/NetworkProvider"; +import { NetworkProvider, resetNetworkStorageListeners } from "@/core/network/NetworkProvider"; import { NotificationProvider } from "@/core/notifications/NotificationProvider"; +import { NotificationStore } from "@/core/notifications/store"; import type { StellarNetwork } from "@/core/network/types"; +/** + * Creates an in-memory Storage fixture for deterministic test isolation (#155). + * Prevents persisted network, notification, and session test state from leaking across component tests. + */ +export function createIsolatedStorage(initialData: Record = {}): Storage { + const store = new Map(Object.entries(initialData)); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (index: number) => Array.from(store.keys())[index] ?? null + }; +} + export interface RenderFeatureOptions extends Omit { network?: StellarNetwork; + /** Explicit storage fixture. Defaults to a fresh isolated memory storage instance (#155). */ + storage?: Storage; + notificationStore?: NotificationStore; } /** * Renders a feature component inside the same providers the real app uses, - * and returns a bound `userEvent` instance. + * and returns a bound `userEvent` instance and the isolated storage fixture. * * Every slice's component tests should go through this helper so provider - * wiring stays identical between the app and the test suite. + * wiring stays identical between the app and the test suite, while isolating + * storage state across runs. */ export function renderFeature( ui: ReactElement, - { network = "testnet", ...options }: RenderFeatureOptions = {} -): RenderResult & { user: ReturnType } { + { network, storage: providedStorage, notificationStore, ...options }: RenderFeatureOptions = {} +): RenderResult & { user: ReturnType; storage: Storage } { + const storage = providedStorage ?? createIsolatedStorage(); + const notifStore = notificationStore ?? new NotificationStore(storage); + function Wrapper({ children }: { children: ReactNode }) { return ( - - {children} + + {children} ); } const user = userEvent.setup(); - return { ...render(ui, { wrapper: Wrapper, ...options }), user }; + return { ...render(ui, { wrapper: Wrapper, ...options }), user, storage }; } +export { resetNetworkStorageListeners }; export * from "@testing-library/react"; export { userEvent }; + diff --git a/core/ui/Button.tsx b/core/ui/Button.tsx index 4829021..8e6c81c 100644 --- a/core/ui/Button.tsx +++ b/core/ui/Button.tsx @@ -27,7 +27,7 @@ export function Button({ className, variant = "primary", size = "md", ...props } return ( ); + const button = screen.getByRole("button", { name: "Submit" }); + expect(button.className).toContain("motion-reduce:transition-none"); + }); + + it("StatusMessage includes motion-reduce:transition-none and motion-reduce:transform-none", () => { + render(); + const status = screen.getByRole("status"); + expect(status.className).toContain("motion-reduce:transition-none"); + expect(status.className).toContain("motion-reduce:transform-none"); + }); + }); +}); diff --git a/core/ui/accessibility.ts b/core/ui/accessibility.ts index 3248264..6e05e78 100644 --- a/core/ui/accessibility.ts +++ b/core/ui/accessibility.ts @@ -8,6 +8,8 @@ * - Screen reader-friendly loading/error/success states */ +import { useEffect, useState } from "react"; + export type AriaLive = 'polite' | 'assertive' | 'off'; export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error'; @@ -168,3 +170,53 @@ export function getFocusableElements(container: HTMLElement): HTMLElement[] { 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(isFocusable); } + +/** + * Query whether the user has requested reduced motion (#153). + * Returns defaultValue (false by default) if window or matchMedia is unavailable. + */ +export function prefersReducedMotion(defaultValue = false): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return defaultValue; + } + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + return defaultValue; + } +} + +/** + * React hook to observe user reduced-motion preference changes (#153). + * Preserves state visibility without relying on animation completion. + */ +export function useReducedMotion(defaultValue = false): boolean { + const [reducedMotion, setReducedMotion] = useState(() => + prefersReducedMotion(defaultValue) + ); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + + const mediaQueryList = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReducedMotion(mediaQueryList.matches); + + const listener = (event: MediaQueryListEvent | MediaQueryList) => { + setReducedMotion(event.matches); + }; + + if (typeof mediaQueryList.addEventListener === "function") { + mediaQueryList.addEventListener("change", listener as (e: MediaQueryListEvent) => void); + return () => + mediaQueryList.removeEventListener("change", listener as (e: MediaQueryListEvent) => void); + } else if (typeof (mediaQueryList as any).addListener === "function") { + (mediaQueryList as any).addListener(listener); + return () => (mediaQueryList as any).removeListener(listener); + } + }, []); + + return reducedMotion; +} + diff --git a/core/ui/index.ts b/core/ui/index.ts index 3670090..200e3f4 100644 --- a/core/ui/index.ts +++ b/core/ui/index.ts @@ -22,3 +22,5 @@ export { Skeleton, SkeletonRows } from "@/core/ui/Skeleton"; export { StatusMessage, type StatusMessageProps, type StatusType } from "@/core/ui/StatusMessage"; export { ToolCard } from "@/core/ui/ToolCard"; export { VirtualizedList, type VirtualizedListProps } from "@/core/ui/VirtualizedList"; +export { prefersReducedMotion, useReducedMotion } from "@/core/ui/accessibility"; + diff --git a/docs/BUNDLE_BUDGET.md b/docs/BUNDLE_BUDGET.md index 65847e7..717f464 100644 --- a/docs/BUNDLE_BUDGET.md +++ b/docs/BUNDLE_BUDGET.md @@ -17,6 +17,31 @@ With an architecture built around independent, lazy-loaded feature slices, a gen This covers typical tool implementations including form validation, API calls, formatting, and result display. +## Route-Level Initial-Load Budget (#159) + +**220 KB for the shared route shell** (`/tools/[slug]`) + +Per-feature slice budgets do not expose shared-chunk regression or the cost paid before a user reaches a tool. Utix measures route-level initial-load overhead from the build manifest: +- Shared entrypoint chunks (`/_app`, webpack, root layout files) +- Dynamic route container (`/tools/[slug]/page.tsx`) +- Baseline tracking with stable tolerance ($\pm 5\%$) to prevent false positives from build hashes. + +## Documented Exception Path for SDK-Heavy Tools (#159) + +Tools that perform deep cryptographic decoding or compile Soroban contract ASTs require heavier dependencies (such as `@stellar/stellar-sdk` and `@stellar/stellar-xdr`). These tools have an explicit, documented exception path governed by [`scripts/bundle-budget-exceptions.json`](../scripts/bundle-budget-exceptions.json): + +| Feature | Exception Budget | Justification | +| :--- | :--- | :--- | +| `xdr-inspector` | 350 KB | Deep recursive XDR parser and Soroban contract type schemas | +| `soroban-decoder` | 300 KB | Full Soroban ScVal decoding and contract auth types | +| `transaction-decoder` | 250 KB | Transaction envelope and operation XDR schemas | +| `soroban-spec-viewer` | 280 KB | Contract function specifications and environment interface bindings | + +### Requesting an Exception +1. Ensure the heavy dependency cannot be split or loaded on-demand. +2. Add an entry in `scripts/bundle-budget-exceptions.json` with `budgetKB`, `justification`, and `approved: true`. +3. If the tool stays within its exception ceiling, `scripts/bundle-budget.mjs` marks it as `EXCEPTION_APPROVED`. If it exceeds the ceiling, it fails with `EXCEEDED`. + ## Feature-Specific Budgets Some tools have adjusted budgets based on their requirements: diff --git a/docs/MAINTAINER_APPROVAL_FLOW.md b/docs/MAINTAINER_APPROVAL_FLOW.md index 732252b..92ed2f5 100644 --- a/docs/MAINTAINER_APPROVAL_FLOW.md +++ b/docs/MAINTAINER_APPROVAL_FLOW.md @@ -16,6 +16,11 @@ The approval flow ensures that privileged operations cannot be executed unilater | `admin_config_override` | Modifies core runtime administrative configuration | 2 maintainers | | `quota_override` | Temporarily raises or lowers tenant rate limits / quotas | 1 maintainer | | `dead_letter_purge` | Purges or requeues quarantined worker jobs | 1 maintainer | +| `event_capacity_override` | Overrides event maximum inventory / venue capacity limit | 2 maintainers | +| `event_cancellation` | Cancels event and triggers escrow refund / settlement procedures | 2 maintainers | +| `fraud_lock_quarantine` | Quarantines ticket batches or accounts under fraud investigation | 1 maintainer | +| `batch_ticket_burn` | Cryptographically burns batches of counterfeit / revoked tickets | 2 maintainers | +| `organizer_fee_override` | Overrides custom protocol fee basis points for organizers | 2 maintainers | ## Architecture & Lifecycle @@ -82,8 +87,49 @@ const receipts = approvalManager.listReceipts({ action: "emergency_killswitch" } const receipt = approvalManager.getReceipt(receiptId); ``` +### 6. Protected Administrative Action Verification +For high-impact mutations (e.g. event capacity overrides, cancellations, fraud quarantines, ticket burns, fee adjustments), operations verify approval validity prior to mutation execution: + +```typescript +import { + verifyProtectedActionApproval, + executeProtectedAction, + type ProtectedActionRequest +} from "@/core/auth/maintainer-approval"; + +const request: ProtectedActionRequest = { + action: "event_capacity_override", + target: "event-arena-101", + parameters: { maxTickets: 30000 }, + approvalId: "intent-1700000000000-xyz", + executorPublicKey: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" +}; + +// 1. Explicit verification check (O(1) time and space complexity) +const verification = verifyProtectedActionApproval(request); +if (!verification.valid) { + console.error(`Rejected with error [${verification.error}]: ${verification.message}`); +} + +// 2. Direct guarded execution +const { result, receipt } = await executeProtectedAction(request, async (intent) => { + return await applyCapacityOverride(intent.target, intent.parameters.maxTickets); +}); +``` + +#### Guardrail Verification Error Codes: +- `approval_not_found`: Approval intent ID does not exist. +- `approval_not_ready`: Threshold of required maintainer signatures not yet reached. +- `approval_expired`: Intent lifetime exceeded (`Date.now() > expiresAt`). +- `already_executed`: Action was already executed or receipt already generated (replay prevention). +- `action_mismatch`: Approval action does not match requested action. +- `target_mismatch`: Approval target does not match requested target. +- `parameter_mismatch`: Request parameters deviate from approved parameter values. +- `unauthorized_actor`: Executor key is malformed or not permitted in the maintainer allowlist. + ## Operational Edge Cases and Recovery 1. **Maintainer Key Compromise**: If a maintainer's key is suspected compromised, other maintainers must reject any pending intents signed by that key and update the authorized maintainer allowlist. 2. **Expired Intents**: Intents not signed before their expiration time automatically transition to `expired` and cannot be executed. A new intent with fresh nonce must be proposed. 3. **Execution Failure Recovery**: If an action fails mid-execution, the receipt records `status: "failed"` with full stack trace and error message, allowing operators to diagnose root cause and plan recovery. + diff --git a/docs/REDUCED_MOTION.md b/docs/REDUCED_MOTION.md new file mode 100644 index 0000000..5e78b9c --- /dev/null +++ b/docs/REDUCED_MOTION.md @@ -0,0 +1,97 @@ +# Reduced-Motion Preference Support + +Utix provides comprehensive reduced-motion accessibility support across all shared UI primitives, dynamic routes, and feature slices in compliance with **WCAG 2.1 AA (Success Criterion 2.3.3: Animation from Interactions)**. + +## Core Principle: State Visibility Without Animation Dependency + +Users who configure their operating system or browser to prefer reduced motion (`prefers-reduced-motion: reduce`) must not experience vestibular disorientation or delayed feedback: + +1. **Immediate State Visibility**: Async loading, status reveals, and interactive feedback do not depend on animation completion. +2. **Static Affordances**: Loading skeletons preserve their exact dimensional layout and `data-contract-state="loading"` attribute as solid, non-pulsing placeholders. +3. **Instant Transitions**: Hover, active, focus, and status transition delays are reduced to zero. + +--- + +## Shared Testable Utilities (`core/ui/accessibility.ts`) + +### 1. `prefersReducedMotion(defaultValue = false): boolean` + +Synchronous query evaluating the media query `(prefers-reduced-motion: reduce)`. + +```typescript +import { prefersReducedMotion } from "@/core/ui"; + +const shouldAnimate = !prefersReducedMotion(); +``` + +- **Default Behavior**: Defaults to `false` (motion enabled) when `window` or `window.matchMedia` is unavailable (e.g. during server-side rendering). +- **Safe Fallback**: Any media-query evaluation error gracefully returns the default value without throwing. + +### 2. `useReducedMotion(defaultValue = false): boolean` + +React hook that subscribes to OS and browser preference changes in real time. + +```typescript +import { useReducedMotion } from "@/core/ui"; + +export function CustomVisualizer() { + const reducedMotion = useReducedMotion(); + + return ( +
+ Content +
+ ); +} +``` + +- **Lifecycle**: Attaches a change listener to `window.matchMedia('(prefers-reduced-motion: reduce)')` and automatically cleans up on unmount. +- **Cross-Browser**: Supports modern `addEventListener('change', ...)` as well as legacy `addListener(...)`. + +--- + +## Component Level Guarantees + +| Component | Normal Motion | Reduced Motion (`prefers-reduced-motion: reduce`) | +| :--- | :--- | :--- | +| **`Skeleton`** | `animate-pulse` | `motion-reduce:animate-none` (static solid placeholder) | +| **`Button`** | `transition duration-200` | `motion-reduce:transition-none` (instant state switch) | +| **`StatusMessage`** | Smooth reveal & shadow | `motion-reduce:transition-none motion-reduce:transform-none` | +| **Global Shell** | Smooth scroll & keyframe animations | Instant scroll (`scroll-behavior: auto !important`), zero animation duration | + +--- + +## Global CSS Overrides (`app/globals.css`) + +```css +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .animate-fade-in-up, + .animate-float, + .animate-pulse { + animation: none !important; + } +} +``` + +--- + +## Verification & Testing + +Run the dedicated reduced-motion test suite: + +```bash +npx vitest run core/ui/__tests__/reduced-motion.test.tsx +``` diff --git a/scripts/bundle-budget-exceptions.json b/scripts/bundle-budget-exceptions.json new file mode 100644 index 0000000..abda6d8 --- /dev/null +++ b/scripts/bundle-budget-exceptions.json @@ -0,0 +1,25 @@ +{ + "$comment": "Documented exception path for SDK-heavy tools (#159). Tools requiring deep XDR parsing or Soroban contract bindings have an explicit approved ceiling.", + "exceptions": { + "xdr-inspector": { + "budgetKB": 350, + "justification": "Deep recursive XDR parser and Soroban contract type schemas", + "approved": true + }, + "soroban-decoder": { + "budgetKB": 300, + "justification": "Full Soroban ScVal decoding and contract auth types", + "approved": true + }, + "transaction-decoder": { + "budgetKB": 250, + "justification": "Transaction envelope and operation XDR schemas", + "approved": true + }, + "soroban-spec-viewer": { + "budgetKB": 280, + "justification": "Contract function specifications and environment interface bindings", + "approved": true + } + } +} diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 0cb9f5d..841d5df 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node /** - * Per-feature bundle size budget enforcement + * Per-feature and route-level initial-load bundle size budget enforcement (#159) * - * Analyzes Next.js build output to compute per-feature-slice bundle sizes - * and enforces documented budgets. + * Analyzes Next.js build output to compute: + * 1. Per-feature-slice bundle sizes with documented exception path for SDK-heavy tools. + * 2. Route-level initial-load costs capturing shared chunk regression and pre-flight overhead. * - * This prevents any single feature from quietly pulling in heavy dependencies - * that bloat its lazy-loaded chunk. + * Prevents stealth dependency bloat while providing an objective review path for SDK-heavy tools. */ import { readFileSync, existsSync, writeFileSync } from 'fs'; @@ -19,12 +19,15 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, '..'); -const BASELINE_PATH = join(ROOT, 'bundle-baseline.json'); -const BUILD_MANIFEST_PATH = join(ROOT, '.next/build-manifest.json'); +export const BASELINE_PATH = join(ROOT, 'bundle-baseline.json'); +export const BUILD_MANIFEST_PATH = join(ROOT, '.next/build-manifest.json'); +export const EXCEPTIONS_PATH = join(ROOT, 'scripts/bundle-budget-exceptions.json'); -const DEFAULT_BUDGET_KB = 150; +export const DEFAULT_BUDGET_KB = 150; +export const ROUTE_INITIAL_LOAD_BUDGET_KB = 220; +export const STABLE_TOLERANCE_PERCENT = 5; -const FEATURE_BUDGETS = { +export const FEATURE_BUDGETS = { 'address-validator': 120, 'balance-viewer': 150, 'trustline-checker': 100, @@ -34,29 +37,43 @@ const FEATURE_BUDGETS = { 'testnet-faucet': 90, }; -function loadBaseline() { - if (!existsSync(BASELINE_PATH)) { +export function loadBaseline(baselinePath = BASELINE_PATH) { + if (!existsSync(baselinePath)) { return {}; } try { - return JSON.parse(readFileSync(BASELINE_PATH, 'utf-8')); + return JSON.parse(readFileSync(baselinePath, 'utf-8')); } catch (error) { - console.warn(`Warning: Could not load baseline from ${BASELINE_PATH}`); + console.warn(`Warning: Could not load baseline from ${baselinePath}`); return {}; } } -function loadBuildManifest() { - if (!existsSync(BUILD_MANIFEST_PATH)) { - throw new Error(`Build manifest not found at ${BUILD_MANIFEST_PATH}. Run 'npm run build' first.`); +export function loadExceptions(exceptionsPath = EXCEPTIONS_PATH) { + if (!existsSync(exceptionsPath)) { + return {}; } - return JSON.parse(readFileSync(BUILD_MANIFEST_PATH, 'utf-8')); + try { + const data = JSON.parse(readFileSync(exceptionsPath, 'utf-8')); + return data.exceptions || {}; + } catch (error) { + console.warn(`Warning: Could not load exceptions from ${exceptionsPath}`); + return {}; + } } -function computeChunkSize(chunkPath) { - const fullPath = join(ROOT, '.next', chunkPath); +export function loadBuildManifest(manifestPath = BUILD_MANIFEST_PATH) { + if (!existsSync(manifestPath)) { + throw new Error(`Build manifest not found at ${manifestPath}. Run 'npm run build' first.`); + } + + return JSON.parse(readFileSync(manifestPath, 'utf-8')); +} + +export function computeChunkSize(chunkPath, rootDir = ROOT) { + const fullPath = join(rootDir, '.next', chunkPath); if (!existsSync(fullPath)) { return 0; @@ -66,7 +83,45 @@ function computeChunkSize(chunkPath) { return stats.length; } -function analyzeFeatureSlices(manifest) { +export function analyzeRouteInitialLoad(manifest, rootDir = ROOT) { + const sharedChunkSet = new Set(); + const chunkDetails = []; + + // 1. Shared app entrypoint chunks + const appChunks = manifest.pages?.['/_app'] || []; + for (const chunk of appChunks) { + sharedChunkSet.add(chunk); + } + + // 2. Root main files if exposed by Next.js manifest + const rootFiles = manifest.rootMainFiles || []; + for (const chunk of rootFiles) { + sharedChunkSet.add(chunk); + } + + // 3. Common dynamic route host shell + const toolRouteChunks = manifest.pages?.['/tools/[slug]'] || []; + for (const chunk of toolRouteChunks) { + sharedChunkSet.add(chunk); + } + + let totalSize = 0; + for (const chunk of sharedChunkSet) { + const size = computeChunkSize(chunk, rootDir); + if (size > 0) { + totalSize += size; + chunkDetails.push({ path: chunk, size }); + } + } + + return { + totalSize, + sizeKB: Math.round(totalSize / 1024), + chunks: chunkDetails, + }; +} + +export function analyzeFeatureSlices(manifest, rootDir = ROOT) { const featureSizes = {}; for (const [route, chunks] of Object.entries(manifest.pages || {})) { @@ -85,7 +140,7 @@ function analyzeFeatureSlices(manifest) { continue; } - const size = computeChunkSize(chunk); + const size = computeChunkSize(chunk, rootDir); if (size > 0) { totalSize += size; chunkDetails.push({ path: chunk, size }); @@ -107,7 +162,7 @@ function analyzeFeatureSlices(manifest) { if (!match) continue; const featureName = match[1]; - const size = computeChunkSize(`static/chunks/${chunk}.js`); + const size = computeChunkSize(`static/chunks/${chunk}.js`, rootDir); if (size > 0) { if (!featureSizes[featureName]) { @@ -128,27 +183,51 @@ function analyzeFeatureSlices(manifest) { return featureSizes; } -function checkBudgets(featureSizes, baseline) { +export function checkBudgets(featureSizes, routeInitialLoad = { totalSize: 0, sizeKB: 0, chunks: [] }, baseline = {}, exceptions = {}) { const results = []; let hasViolations = false; for (const [feature, data] of Object.entries(featureSizes)) { + if (feature.startsWith('_')) continue; + const sizeKB = Math.round(data.totalSize / 1024); - const budgetKB = FEATURE_BUDGETS[feature] || DEFAULT_BUDGET_KB; + const standardBudgetKB = FEATURE_BUDGETS[feature] || DEFAULT_BUDGET_KB; const baselineSizeKB = baseline[feature] ? Math.round(baseline[feature].totalSize / 1024) : 0; const delta = sizeKB - baselineSizeKB; - const status = sizeKB > budgetKB ? 'EXCEEDED' : 'OK'; - const withinBudget = sizeKB <= budgetKB; - - if (!withinBudget) { + const exception = exceptions[feature]; + const isApprovedException = Boolean(exception?.approved); + const exceptionCeilingKB = exception?.budgetKB || standardBudgetKB; + + let status = 'OK'; + let withinBudget = true; + let justification = null; + + if (sizeKB <= standardBudgetKB) { + status = 'OK'; + withinBudget = true; + } else if (isApprovedException && sizeKB <= exceptionCeilingKB) { + status = 'EXCEPTION_APPROVED'; + withinBudget = true; + justification = exception.justification; + } else if (isApprovedException && sizeKB > exceptionCeilingKB) { + status = 'EXCEEDED (over exception ceiling)'; + withinBudget = false; + hasViolations = true; + justification = exception.justification; + } else { + status = 'EXCEEDED'; + withinBudget = false; hasViolations = true; } results.push({ feature, sizeKB, - budgetKB, + budgetKB: isApprovedException ? exceptionCeilingKB : standardBudgetKB, + standardBudgetKB, + isException: isApprovedException, + justification, baselineSizeKB, delta, status, @@ -156,18 +235,57 @@ function checkBudgets(featureSizes, baseline) { chunks: data.chunks, }); } + + // Check Route-level Initial-Load budget (#159) + const routeBaselineKB = baseline._route_initial_load?.sizeKB || 0; + const routeDelta = routeInitialLoad.sizeKB - routeBaselineKB; + const routeWithinBudget = routeInitialLoad.sizeKB <= ROUTE_INITIAL_LOAD_BUDGET_KB; + const routeStatus = routeWithinBudget ? 'OK' : 'EXCEEDED'; + + if (!routeWithinBudget) { + hasViolations = true; + } + + const routeResult = { + name: 'Route Initial-Load (/tools/[slug])', + sizeKB: routeInitialLoad.sizeKB, + budgetKB: ROUTE_INITIAL_LOAD_BUDGET_KB, + baselineSizeKB: routeBaselineKB, + delta: routeDelta, + status: routeStatus, + withinBudget: routeWithinBudget, + chunks: routeInitialLoad.chunks, + }; - return { results, hasViolations }; + return { results, routeResult, hasViolations }; } -function formatReport(results, hasViolations) { +export function formatReport(results, routeResult, hasViolations) { const lines = []; lines.push('='.repeat(80)); - lines.push('BUNDLE SIZE BUDGET REPORT'); + lines.push('UTIX INITIAL-LOAD & FEATURE BUNDLE BUDGET REPORT (#159)'); lines.push('='.repeat(80)); lines.push(''); + // 1. Route-Level Initial-Load Section + lines.push('ROUTE INITIAL-LOAD COSTS'); + lines.push('-'.repeat(80)); + if (routeResult) { + const routeName = routeResult.name.padEnd(35); + const size = `${routeResult.sizeKB} KB`.padEnd(15); + const budget = `${routeResult.budgetKB} KB`.padEnd(15); + lines.push(`${routeName}${size}${budget}${routeResult.status}`); + if (routeResult.delta !== 0 && routeResult.baselineSizeKB > 0) { + const deltaStr = routeResult.delta > 0 ? `+${routeResult.delta}` : `${routeResult.delta}`; + lines.push(` (${deltaStr} KB from baseline: ${routeResult.baselineSizeKB} KB)`); + } + } + lines.push(''); + + // 2. Per-Feature Slices Section + lines.push('FEATURE SLICE BUDGETS'); + lines.push('-'.repeat(80)); if (results.length === 0) { lines.push('No feature slices detected in build output.'); lines.push('This is normal for a fresh build without features.'); @@ -183,6 +301,10 @@ function formatReport(results, hasViolations) { lines.push(`${featureName}${size}${budget}${status}`); + if (result.isException && result.justification) { + lines.push(` [Exception Approved: ${result.justification}]`); + } + if (result.delta !== 0 && result.baselineSizeKB > 0) { const deltaStr = result.delta > 0 ? `+${result.delta}` : `${result.delta}`; lines.push(` (${deltaStr} KB from baseline)`); @@ -198,16 +320,16 @@ function formatReport(results, hasViolations) { lines.push('BUDGET VIOLATIONS DETECTED'); lines.push(''); lines.push('To resolve:'); - lines.push(' 1. Review dependencies added to the feature'); - lines.push(' 2. Consider code-splitting or lazy loading'); - lines.push(' 3. Request budget increase if justified (update FEATURE_BUDGETS)'); + lines.push(' 1. Review dependencies added to the feature or shared route layout'); + lines.push(' 2. Ensure SDK imports are isolated to lazy-loaded panel chunks'); + lines.push(' 3. If feature is SDK-heavy (e.g. XDR/Soroban), submit an exception in bundle-budget-exceptions.json'); lines.push(''); } return lines.join('\n'); } -function saveBaseline(featureSizes) { +export function saveBaseline(featureSizes, routeInitialLoad = { totalSize: 0, sizeKB: 0 }) { const baseline = {}; for (const [feature, data] of Object.entries(featureSizes)) { @@ -217,6 +339,14 @@ function saveBaseline(featureSizes) { timestamp: new Date().toISOString(), }; } + + if (routeInitialLoad && routeInitialLoad.totalSize > 0) { + baseline._route_initial_load = { + totalSize: routeInitialLoad.totalSize, + sizeKB: routeInitialLoad.sizeKB, + timestamp: new Date().toISOString(), + }; + } writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2)); console.log(`\nBaseline saved to ${BASELINE_PATH}`); @@ -229,18 +359,20 @@ async function main() { try { const baseline = loadBaseline(); + const exceptions = loadExceptions(); const manifest = loadBuildManifest(); const featureSizes = analyzeFeatureSlices(manifest); - const { results, hasViolations } = checkBudgets(featureSizes, baseline); + const routeInitialLoad = analyzeRouteInitialLoad(manifest); + const { results, routeResult, hasViolations } = checkBudgets(featureSizes, routeInitialLoad, baseline, exceptions); if (jsonOutput) { - console.log(JSON.stringify({ results, hasViolations }, null, 2)); + console.log(JSON.stringify({ results, routeResult, hasViolations }, null, 2)); } else { - console.log(formatReport(results, hasViolations)); + console.log(formatReport(results, routeResult, hasViolations)); } if (updateBaseline) { - saveBaseline(featureSizes); + saveBaseline(featureSizes, routeInitialLoad); } if (hasViolations && !updateBaseline) { @@ -253,4 +385,7 @@ async function main() { } } -main(); +// Only invoke CLI runner if executed directly +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/vitest.config.ts b/vitest.config.ts index 29273a1..6ec29a1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,7 +13,8 @@ export default defineConfig({ setupFiles: ["./core/testing/setup.ts"], include: [ "features/**/__tests__/**/*.test.{ts,tsx}", - "core/**/__tests__/**/*.test.{ts,tsx}" + "core/**/__tests__/**/*.test.{ts,tsx}", + "__tests__/**/*.test.{ts,tsx}" ], exclude: ["**/node_modules/**", "**/e2e/**", "**/.next/**"], coverage: {