From b21afd99f7d6942f24ba27d5a2a8ca00b20e0bcf Mon Sep 17 00:00:00 2001 From: HouseofTyrell Date: Sat, 11 Jul 2026 17:37:07 -0600 Subject: [PATCH] Improve advisor workspace and meeting mode --- .github/workflows/ci.yml | 6 + .gitignore | 2 + docs/DECISIONS.md | 65 ++ eslint.config.js | 3 +- package-lock.json | 88 +++ package.json | 5 +- playwright.config.ts | 24 + src/Calculator.tsx | 8 +- src/ResultsTable.tsx | 48 +- src/calculations/core.ts | 53 +- src/calculations/exitTax.ts | 25 +- src/calculations/sensitivity.ts | 36 +- src/components/DisclaimerFooter.tsx | 6 +- src/components/FloatingPinnedPanel.tsx | 13 +- .../MeetingMode/MeetingMode.test.tsx | 100 +++ src/components/MeetingMode/MeetingMode.tsx | 403 ++++++++++- .../QualifiedPurchaserModal.test.tsx | 34 + src/components/QualifiedPurchaserModal.tsx | 55 +- src/exitTax.test.ts | 21 + src/index.css | 10 +- src/lossBreakdown.test.ts | 2 + src/main.tsx | 5 +- src/popupContent.ts | 23 +- src/strategyData.ts | 6 +- src/taxData.ts | 127 +++- src/taxParameters.test.ts | 26 + src/taxParameters.ts | 47 ++ src/types.ts | 18 +- src/utils/excelExport.ts | 4 + src/utils/stateTaxWarnings.ts | 10 +- src/washingtonIncomeTax.test.ts | 69 ++ src/workspace/MeetingSession.tsx | 5 +- src/workspace/WorkspaceTab.test.tsx | 54 ++ src/workspace/WorkspaceTab.tsx | 678 +++++++++++++++++- src/workspace/workspace.css | 475 ++++++++++++ tests/e2e/workspace.spec.ts | 95 +++ 36 files changed, 2546 insertions(+), 103 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/components/QualifiedPurchaserModal.test.tsx create mode 100644 src/taxParameters.test.ts create mode 100644 src/taxParameters.ts create mode 100644 src/washingtonIncomeTax.test.ts create mode 100644 tests/e2e/workspace.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91a686e..72c4eee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,11 @@ jobs: - name: Tests run: npm run test:run + - name: Install browser + run: npx playwright install --with-deps chromium + + - name: Browser and accessibility tests + run: npm run test:e2e + - name: Build run: npm run build diff --git a/.gitignore b/.gitignore index d97aca2..11dfce0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist/ # Test coverage coverage/ +test-results/ +playwright-report/ # IDE .vscode/ diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4e4c927..e29274d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -530,6 +530,41 @@ keep the literal `qfaf`/`quantinno` spelling, so no CalculatorInputs field was a CSV/Excel round-trips are unaffected. **Standing requirement:** new user-visible manager/fund text must be wrapped in `brandText()` (or come from `getPopupContent`). +### D-030 — Washington 2028 income-tax treatment +**Date:** 2026-07-11 +**Context:** Washington enacted a 9.9% tax on income above the statutory deduction, +effective for income earned beginning in 2028. The existing Washington profile models +no general income tax and only the capital-gains excise, while projections can run as +long as 40 years. The new income tax overlaps with the capital-gains tax through a +credit, so a simple additional 9.9% rate would double-count some income. +**Decision (owner):** **Implement the enacted 2028 rules now.** Model the year-aware +Washington income tax and its capital-gains-tax credit rather than merely warning or +stopping Washington projections at 2027. +**Implications:** Tax parameters must be versioned by effective year and carry source, +verification-date, and provisional-status metadata. Washington projections use the +existing rules through 2027 and the enacted income-tax regime beginning in 2028, with +the capital-gains credit preventing double taxation. The UI and disclosures must clearly +mark implementation details that remain provisional pending agency regulations/forms. +Tests must cover the 2027/2028 boundary, the statutory deduction, overlapping capital +gains and credit, and multi-year projections. Apply the audit-complete directive to any +new per-year engine outputs and use the same year-aware tax path in liquidation analysis. + +### D-031 — Results-first mobile Workspace +**Date:** 2026-07-11 +**Context:** At phone width the Workspace avoids horizontal overflow but becomes an +extremely long, dense page. Responsive CSS alone does not preserve the results-first +information hierarchy already chosen for the Workspace. +**Decision (owner):** Build a **results-first mobile Workspace with an accessible input +drawer and selected-year detail**. +**Implications:** At mobile breakpoints, headline metrics and flags appear before the +input editor. Inputs move into an accessible drawer with focus containment, Escape/close +behavior, focus restoration, and touch-sized controls. The default year-by-year mobile +experience becomes a selected-year detail view rather than requiring the full dense +table; the audit-complete desktop table remains available and no engine outputs may be +silently omitted. Desktop Workspace behavior and the one-engine rule remain unchanged. +Browser coverage must verify representative phone widths, keyboard accessibility, dark +theme, input edits updating results, and selected-year navigation. + --- ## Pending decision queue (next batches) @@ -905,6 +940,36 @@ Clean exits skip the prompt. The Classic-tab Meeting Mode host remains un-sandbo (live edits, as before) and is labeled accordingly in code. Workspace-level test proves Discard leaves inputs bit-identical and Keep applies them. +### D-032 — Fast-learning client education layer +**Date:** 2026-07-11 +**Context:** Owner directed implementation of all findings from a mock presentation to a +high-income California software engineer with moderate financial knowledge. +**Decision:** Keep the default results-first surface, then add progressive, engine-backed +explanations rather than a separate beginner mode or parallel projection. +**Status: IMPLEMENTED (2026-07-11)** — Workspace Overview now includes: a six-step +calculation trace; persistent capital/ordinary/NOL character map; federal-versus-CA +timing timeline; realized-benefit/tax-asset/deferral/liquidation grouping; selected-year +deterministic savings reconciliation; clearer income-utilization terminology; software- +compensation presets with character and AMT guidance; marginal-rate labeling; statutory +source provenance; and component attribution for pinned-scenario savings changes. All +displayed dollar explanations aggregate existing `CalculationResult` fields. Mixed IPO/ +acquisition presets compose ordinary income and a capital-gain event through the existing +`YearOverride` engine path. + +### D-033 — Meeting Mode education essentials +**Date:** 2026-07-11 +**Context:** Workspace gained the full fast-learning client education layer in D-032; +owner asked for Meeting Mode to be complete enough for an advisor-led conversation +without requiring 100% Workspace parity. +**Decision:** Port five presentation-critical explanations, keeping construction, +provenance, presets, and the full audit workflow in Workspace. +**Status: IMPLEMENTED (2026-07-11)** — High Level adds client-outcome classification +(realized savings, unused tax assets, signed liquidation tax, net after liquidation) and +component attribution beneath the existing Was→Now comparison. Detail adds a selected- +year deterministic benefit/cost reconciliation. Mechanics adds the capital/ordinary/NOL +character map and a California SB 167 federal-versus-state timing callout. Every dollar +comes from existing `CalculationResult` / exit-analysis fields; no parallel projection. + --- ## Bugs — no decision required diff --git a/eslint.config.js b/eslint.config.js index c2a1e34..5499b77 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -36,7 +36,7 @@ export default tseslint.config( '@typescript-eslint/no-non-null-assertion': 'warn', // General code quality - 'no-console': 'warn', + 'no-console': ['warn', { allow: ['warn', 'error'] }], 'no-debugger': 'error', 'prefer-const': 'error', 'no-var': 'error', @@ -54,6 +54,7 @@ export default tseslint.config( files: ['**/*.test.ts', '**/*.test.tsx'], rules: { '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', 'no-console': 'off', }, }, diff --git a/package-lock.json b/package-lock.json index 1789b82..752ba1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -108,6 +110,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -1279,6 +1294,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -2511,6 +2542,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4150,6 +4191,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index e0801ca..e7db954 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "preview": "vite preview", "test": "vitest --config vitest.config.ts", "test:run": "vitest run --config vitest.config.ts", + "test:e2e": "playwright test", "test:coverage": "vitest run --config vitest.config.ts --coverage", - "lint": "eslint src/", + "lint": "eslint src/ --max-warnings 0", "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/" @@ -24,7 +25,9 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..62bbeae --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, + { name: 'mobile', use: { ...devices['iPhone 13'], browserName: 'chromium' } }, + ], + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 4173', + env: { ...process.env, GITHUB_ACTIONS: '' }, + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/Calculator.tsx b/src/Calculator.tsx index ca0fbbc..90ac95f 100644 --- a/src/Calculator.tsx +++ b/src/Calculator.tsx @@ -131,6 +131,9 @@ export function Calculator({ isActive = true }: CalculatorProps) { ); const results = useMemo(() => { + // Custom strategy rates live outside React state; rateVersion is the + // explicit invalidation token that forces this memo to read them again. + void rateVersion; // Priority: Year overrides > Sensitivity > Base calculation // Note: Year overrides and sensitivity don't combine (would need a combined function) if (hasActiveOverrides) { @@ -207,7 +210,8 @@ export function Calculator({ isActive = true }: CalculatorProps) { combinedLtRate, advancedSettings.growthEnabled ? advancedSettings.defaultAnnualReturn : 0, taxRates.stateProfile.ltcgExcise, - inputs.collateralCostBasis + inputs.collateralCostBasis, + { stateCode: inputs.stateCode, ordinaryIncome: inputs.annualIncome } ), [ results, @@ -216,6 +220,8 @@ export function Calculator({ isActive = true }: CalculatorProps) { advancedSettings.growthEnabled, advancedSettings.defaultAnnualReturn, inputs.collateralCostBasis, + inputs.stateCode, + inputs.annualIncome, ] ); diff --git a/src/ResultsTable.tsx b/src/ResultsTable.tsx index 4199ddc..81a9ecf 100644 --- a/src/ResultsTable.tsx +++ b/src/ResultsTable.tsx @@ -124,6 +124,7 @@ export function ResultsTable({ // Per-leg unwind attribution columns (D-028): only in split mode, where the // engine populates core/overlay unwind gains. const hasPerLegDeleverage = data.some(y => y.overlayDeleverageGain !== undefined); + const hasWaIncomeTax = data.some(y => y.waIncomeTax > 0.01 || y.waCapitalGainsTaxCredit > 0.01); // Toggle all details const handleToggleAll = () => { @@ -183,12 +184,12 @@ export function ResultsTable({ cols += 1; // Tax Savings column cols += 1; // Cumulative column - if (expandSavings) cols += 6; // Ord Ded., NOL Ben., $3K Ben., LT Cost, ST Cost, Fin. Cost + if (expandSavings) cols += 6 + (hasWaIncomeTax ? 2 : 0); return cols; }; // Savings breakdown columns count for starting row - const savingsDetailCols = expandSavings ? 6 : 0; + const savingsDetailCols = expandSavings ? 6 + (hasWaIncomeTax ? 2 : 0) : 0; // Filter out quiet wind-down rows where only the routine $3K capital loss deduction is running. // Keep wind-down rows that have meaningful activity: NOL usage, ordinary loss benefits, or @@ -521,6 +522,16 @@ export function ResultsTable({ Fin. Cost + {hasWaIncomeTax && ( + <> + + WA Income Tax + + + WA CGT Credit + + + )} )} @@ -934,6 +945,20 @@ export function ResultsTable({ ? `(${formatCurrency(year.financingCostPaid)})` : '—'} + {hasWaIncomeTax && ( + <> + + {year.waIncomeTax > 0 + ? `(${formatCurrency(year.waIncomeTax)})` + : '—'} + + + {year.waCapitalGainsTaxCredit > 0 + ? formatCurrency(year.waCapitalGainsTaxCredit) + : '—'} + + + )} )} @@ -1276,6 +1301,25 @@ function TransposedTable({ }); } + if (data.some(y => y.waIncomeTax > 0.01 || y.waCapitalGainsTaxCredit > 0.01)) { + sections.push({ + title: 'Washington Income Tax (2028+)', + rows: [ + { + label: 'WA Income Tax', + contentKey: 'col-wa-income-tax', + cell: y => negMoney(y.waIncomeTax), + className: () => 'negative', + }, + { + label: 'WA CGT Credit', + contentKey: 'col-wa-cgt-credit', + cell: y => moneyOrDash(y.waCapitalGainsTaxCredit), + }, + ], + }); + } + if (data.some(y => y.extensionFraction < 1)) { sections.push({ title: 'Deleverage', diff --git a/src/calculations/core.ts b/src/calculations/core.ts index 6fc5c57..d422981 100644 --- a/src/calculations/core.ts +++ b/src/calculations/core.ts @@ -14,6 +14,7 @@ import { getStateRate, getStateTaxProfile, computeLtcgExcise, + computeWashingtonYearTaxImpact, } from '../taxData'; import { getStrategy, @@ -392,7 +393,7 @@ export function calculateWithOverrides( splitDlvSt += wgt * legSt; splitDlvLt += wgt * leg.strategy.ltGainRate; splitDlvFin += wgt * getEffectiveFinancingCost(leg.strategy, settings); - splitDlvExtension += wgt * 1; + splitDlvExtension += Number(wgt); } } } @@ -890,10 +891,10 @@ export function calculateYear( // Benefits: // 1. Ordinary loss reduces W2 income tax - const ordinaryLossBenefit = safeNumber(usableOrdinaryLoss * combinedOrdinaryRate); + let ordinaryLossBenefit = safeNumber(usableOrdinaryLoss * combinedOrdinaryRate); // 2. Capital loss carryforward used against ordinary income ($3k/yr limit) - const capitalLossBenefit = safeNumber(capitalLossUsedAgainstIncome * combinedOrdinaryRate); + let capitalLossBenefit = safeNumber(capitalLossUsedAgainstIncome * combinedOrdinaryRate); // 3. NOL used against taxable income. The STATE component is suppressed // when the state suspends NOL deductions (CA SB 167: MAGI ≥ $1M through @@ -923,13 +924,41 @@ export function calculateYear( overrides?.stateNolAvailable !== undefined ? Math.min(nolUsed, overrides.stateNolAvailable) : nolUsed; - const nolUsageBenefit = + let nolUsageBenefit = stateEligibleNol >= nolUsed ? safeNumber(nolAtOrdinary * (ordinaryRate + nolStateRate) + nolAtLt * ltNolRate) : safeNumber( nolAtOrdinary * ordinaryRate + nolAtLt * fedLtNolRate + stateEligibleNol * nolStateRate ); + // D-030: projection year 1 is tax year 2026. Washington's enacted income + // tax begins in 2028 (projection year 3) and credits capital-gains excise + // already paid. Compare each deduction step so the benefit attribution + // still reconciles to the headline without double-counting the credit. + const strategyLtcgExciseTax = computeLtcgExcise(taxableLt, state.ltcgExcise); + const totalLtcgExciseTax = computeLtcgExcise(taxableLt + eventTaxableLt, state.ltcgExcise); + const eventLtcgExciseTax = totalLtcgExciseTax - strategyLtcgExciseTax; + const waImpact = + inputs.stateCode === 'WA' + ? computeWashingtonYearTaxImpact({ + taxYear: 2025 + year, + ordinaryIncome: effectiveIncome, + strategyStGain: taxableSt, + strategyLtGain: taxableLt, + eventStGain: eventTaxableSt, + eventLtGain: eventTaxableLt, + ordinaryDeduction: usableOrdinaryLoss, + capitalLossDeduction: capitalLossUsedAgainstIncome, + nolDeduction: nolUsed, + strategyCapitalGainsExcise: strategyLtcgExciseTax, + eventCapitalGainsExcise: eventLtcgExciseTax, + }) + : null; + + ordinaryLossBenefit = safeNumber(ordinaryLossBenefit + (waImpact?.ordinaryLossBenefit ?? 0)); + capitalLossBenefit = safeNumber(capitalLossBenefit + (waImpact?.capitalLossBenefit ?? 0)); + nolUsageBenefit = safeNumber(nolUsageBenefit + (waImpact?.nolUsageBenefit ?? 0)); + // Costs: // 1. LT gains are taxed at LT rates (+ WA-style excise above the annual exemption). // Charged on TAXABLE LT gains (after carryforward and current-year ST loss @@ -937,24 +966,28 @@ export function calculateYear( // losses are consumed by QFAF gains), but in collateral-only mode harvested // ST losses offset the LT gains and the cost would otherwise be overstated, // inflating the incremental benefit of adding the QFAF. (CPA review finding E.) - const ltcgExciseTax = computeLtcgExcise(taxableLt, state.ltcgExcise); - const ltGainCost = safeNumber(taxableLt * combinedLtRate + ltcgExciseTax); + const ltcgExciseTax = strategyLtcgExciseTax; + const ltGainCost = safeNumber( + taxableLt * combinedLtRate + ltcgExciseTax + (waImpact?.strategyGainCost ?? 0) + ); // Planned gain event (D-012): taxed separately — it is exogenous, so it is // NOT charged against the strategy's taxSavings. The program's help shows // up as carryforward shelter (event-last), §461(l) absorption, and NOL // usage. Marginal excise above the strategy's own gains is event-borne. const gainEventAmount = eventStGain + eventLtGain; - const totalExcise = computeLtcgExcise(taxableLt + eventTaxableLt, state.ltcgExcise); + const totalExcise = totalLtcgExciseTax; const gainEventTax = safeNumber( eventTaxableSt * combinedStRate + eventTaxableLt * combinedLtRate + - (totalExcise - ltcgExciseTax) + (totalExcise - ltcgExciseTax) + + (waImpact?.gainEventTax ?? 0) ); const gainEventTaxWithoutStrategy = safeNumber( eventStGain * combinedStRate + eventLtGain * combinedLtRate + - computeLtcgExcise(eventLtGain, state.ltcgExcise) + computeLtcgExcise(eventLtGain, state.ltcgExcise) + + (waImpact?.gainEventTaxWithoutStrategy ?? 0) ); const gainEventCfShelter = safeNumber( eventStGain - eventTaxableSt + (eventLtGain - eventTaxableLt) @@ -1117,6 +1150,8 @@ export function calculateYear( nolUsedThisYear: nolUsed, capitalLossUsedAgainstIncome, stateNolExpired: 0, // Set by the calling loop's vintage ledger (D-020) + waIncomeTax: safeNumber(waImpact?.incomeTax ?? 0), + waCapitalGainsTaxCredit: safeNumber(waImpact?.capitalGainsTaxCredit ?? 0), effectiveStLossRate, incomeOffsetAmount, maxIncomeOffsetCapacity, diff --git a/src/calculations/exitTax.ts b/src/calculations/exitTax.ts index 96e4f54..dabfb41 100644 --- a/src/calculations/exitTax.ts +++ b/src/calculations/exitTax.ts @@ -1,5 +1,5 @@ import { CalculationResult } from '../types'; -import { StateTaxProfile, computeLtcgExcise } from '../taxData'; +import { StateTaxProfile, computeLtcgExcise, computeWashingtonIncomeTax } from '../taxData'; import { safeNumber } from '../utils/formatters'; /** @@ -76,7 +76,9 @@ export function computeExitTaxAnalysis( /** WA-style LTCG excise applied to a single-year full liquidation (D-005) */ ltcgExcise?: StateTaxProfile['ltcgExcise'], /** Cost basis of the collateral at inception (default: equals initial value) */ - collateralCostBasis?: number + collateralCostBasis?: number, + /** D-030: context for the enacted WA income tax at the liquidation horizon. */ + washingtonContext?: { stateCode: string; ordinaryIncome: number } ): ExitTaxAnalysis { const years = result.years; const initialCollateral = result.sizing.collateralValue; @@ -118,8 +120,21 @@ export function computeExitTaxAnalysis( const cfShelterUsed = Math.min(remainingCapitalLossCf, embeddedGain); const taxableGainAfterShelter = Math.max(0, safeNumber(embeddedGain - cfShelterUsed)); const exciseOn = (gain: number) => computeLtcgExcise(gain, ltcgExcise); + const horizonTaxYear = 2025 + years.length; + const waIncomeTaxOnGain = (gain: number) => { + if (washingtonContext?.stateCode !== 'WA') return 0; + const baseTax = computeWashingtonIncomeTax(horizonTaxYear, washingtonContext.ordinaryIncome, 0); + const withGain = computeWashingtonIncomeTax( + horizonTaxYear, + washingtonContext.ordinaryIncome + gain, + exciseOn(gain) + ); + return Math.max(0, withGain.netTax - baseTax.netTax); + }; const exitTax = safeNumber( - taxableGainAfterShelter * combinedLtRate + exciseOn(taxableGainAfterShelter) + taxableGainAfterShelter * combinedLtRate + + exciseOn(taxableGainAfterShelter) + + waIncomeTaxOnGain(taxableGainAfterShelter) ); // Passive baseline: buy-and-hold the same initial collateral at the same @@ -129,7 +144,9 @@ export function computeExitTaxAnalysis( // The passive holder carries the same pre-existing gain, so it cancels out // of the incremental deferred tax (which stays strategy-attributable). const passiveGain = Math.max(0, safeNumber(passiveValue - costBasis)); - const passiveExitTax = safeNumber(passiveGain * combinedLtRate + exciseOn(passiveGain)); + const passiveExitTax = safeNumber( + passiveGain * combinedLtRate + exciseOn(passiveGain) + waIncomeTaxOnGain(passiveGain) + ); // Deliberately unclamped: with QFAF off, carryforward shelter can push the // strategy's exit tax BELOW the passive baseline's. Clamping at 0 would diff --git a/src/calculations/sensitivity.ts b/src/calculations/sensitivity.ts index 75007f7..8ad01e9 100644 --- a/src/calculations/sensitivity.ts +++ b/src/calculations/sensitivity.ts @@ -14,6 +14,7 @@ import { getStateRate, getStateTaxProfile, computeLtcgExcise, + computeWashingtonYearTaxImpact, } from '../taxData'; import { getStrategy, @@ -526,8 +527,8 @@ function calculateYearWithSensitivity( const combinedOrdinaryRate = ordinaryRate + stateDeductionRate; // Benefits - const ordinaryLossBenefit = safeNumber(usableOrdinaryLoss * combinedOrdinaryRate); - const capitalLossBenefit = safeNumber(capitalLossUsedAgainstIncome * combinedOrdinaryRate); + let ordinaryLossBenefit = safeNumber(usableOrdinaryLoss * combinedOrdinaryRate); + let capitalLossBenefit = safeNumber(capitalLossUsedAgainstIncome * combinedOrdinaryRate); // State NOL component suppressed under CA-style suspension (see core.ts) const stateNolSuspended = state.nolStateSuspension !== undefined && @@ -550,16 +551,39 @@ function calculateYearWithSensitivity( overrides?.stateNolAvailable !== undefined ? Math.min(nolUsed, overrides.stateNolAvailable) : nolUsed; - const nolUsageBenefit = + let nolUsageBenefit = stateEligibleNol >= nolUsed ? safeNumber(nolAtOrdinary * (ordinaryRate + nolStateRate) + nolAtLt * ltNolRate) : safeNumber( nolAtOrdinary * ordinaryRate + nolAtLt * fedLtNolRate + stateEligibleNol * nolStateRate ); + const strategyLtcgExciseTax = computeLtcgExcise(taxableLt, state.ltcgExcise); + const waImpact = + inputs.stateCode === 'WA' + ? computeWashingtonYearTaxImpact({ + taxYear: 2025 + year, + ordinaryIncome: inputs.annualIncome, + strategyStGain: taxableSt, + strategyLtGain: taxableLt, + eventStGain: 0, + eventLtGain: 0, + ordinaryDeduction: usableOrdinaryLoss, + capitalLossDeduction: capitalLossUsedAgainstIncome, + nolDeduction: nolUsed, + strategyCapitalGainsExcise: strategyLtcgExciseTax, + eventCapitalGainsExcise: 0, + }) + : null; + ordinaryLossBenefit = safeNumber(ordinaryLossBenefit + (waImpact?.ordinaryLossBenefit ?? 0)); + capitalLossBenefit = safeNumber(capitalLossBenefit + (waImpact?.capitalLossBenefit ?? 0)); + nolUsageBenefit = safeNumber(nolUsageBenefit + (waImpact?.nolUsageBenefit ?? 0)); + // Costs: charged on taxable (post-offset) LT gains — see core.ts (CPA finding E) - const ltcgExciseTax = computeLtcgExcise(taxableLt, state.ltcgExcise); - const ltGainCost = safeNumber(taxableLt * combinedLtRate + ltcgExciseTax); + const ltcgExciseTax = strategyLtcgExciseTax; + const ltGainCost = safeNumber( + taxableLt * combinedLtRate + ltcgExciseTax + (waImpact?.strategyGainCost ?? 0) + ); const remainingStGainCost = safeNumber(Math.max(0, netStGainLoss) * combinedStRate); // Net tax savings: ordinary deductions minus capital gains costs @@ -675,6 +699,8 @@ function calculateYearWithSensitivity( nolUsedThisYear: nolUsed, capitalLossUsedAgainstIncome, stateNolExpired: 0, // Set by the calling loop's vintage ledger (D-020) + waIncomeTax: safeNumber(waImpact?.incomeTax ?? 0), + waCapitalGainsTaxCredit: safeNumber(waImpact?.capitalGainsTaxCredit ?? 0), effectiveStLossRate: adjustedStLossRate, incomeOffsetAmount, maxIncomeOffsetCapacity, diff --git a/src/components/DisclaimerFooter.tsx b/src/components/DisclaimerFooter.tsx index f7edef2..e892079 100644 --- a/src/components/DisclaimerFooter.tsx +++ b/src/components/DisclaimerFooter.tsx @@ -77,8 +77,10 @@ export function DisclaimerFooter() { This calculator provides estimates for illustrative purposes only and does not constitute investment, tax, or legal advice. Past performance does not guarantee future results. Federal tax parameters (brackets, LTCG thresholds, §461(l) limits) verified against Rev. - Proc. 2025-32 in June 2026. WA's 2026 capital-gains exemption was unpublished at review time - (2025 figure used); verify state figures before relying on them. + Proc. 2025-32 and IRS 2026 guidance, reverified July 2026. WA's 2026 capital-gains exemption + remains unpublished (2025 figure used). The enacted WA income tax is modeled from 2028 with + its capital-gains credit; implementation details remain provisional pending final DOR + regulations and forms. Verify state figures before relying on them.

v{version}
diff --git a/src/components/FloatingPinnedPanel.tsx b/src/components/FloatingPinnedPanel.tsx index 1f64f0d..2b74a6b 100644 --- a/src/components/FloatingPinnedPanel.tsx +++ b/src/components/FloatingPinnedPanel.tsx @@ -230,7 +230,9 @@ export function FloatingPinnedPanel({ // Derive ordered elements from itemOrder const orderedElements = itemOrder.length > 0 - ? itemOrder.map(id => elements.find(e => e.id === id)).filter((e): e is PinnedElement => !!e) + ? itemOrder + .map(id => elements.find(e => e.id === id)) + .filter((e): e is PinnedElement => Boolean(e)) : elements; // --- Panel drag --- @@ -356,10 +358,11 @@ export function FloatingPinnedPanel({ const handleItemResizeMove = useCallback( (e: React.PointerEvent) => { - if (!itemResizing || !itemResizeStartRef.current) return; - const dy = e.clientY - itemResizeStartRef.current.py; - const newH = Math.max(MIN_ITEM_HEIGHT, itemResizeStartRef.current.h + dy); - setItemHeights(prev => ({ ...prev, [itemResizeStartRef.current!.id]: newH })); + const resizeStart = itemResizeStartRef.current; + if (!itemResizing || !resizeStart) return; + const dy = e.clientY - resizeStart.py; + const newH = Math.max(MIN_ITEM_HEIGHT, resizeStart.h + dy); + setItemHeights(prev => ({ ...prev, [resizeStart.id]: newH })); }, [itemResizing] ); diff --git a/src/components/MeetingMode/MeetingMode.test.tsx b/src/components/MeetingMode/MeetingMode.test.tsx index 54aaa2d..c65aa4f 100644 --- a/src/components/MeetingMode/MeetingMode.test.tsx +++ b/src/components/MeetingMode/MeetingMode.test.tsx @@ -136,6 +136,62 @@ describe('Meeting Mode handout — QFAF mode (D-021)', () => { // Protection ratio is an EDI-economics metric — not shown with QFAF on. expect(text).not.toContain('Protection ratio:'); }); + + it('classifies client outcomes without adding unused tax assets to realized savings', () => { + const { getByTestId } = renderMeetingMode( + createInputs({ qfafEnabled: true }), + DEFAULT_SETTINGS + ); + const text = getByTestId('mm-client-outcomes').textContent ?? ''; + expect(text).toContain('Realized modeled savings'); + expect(text).toContain('Unused tax assets'); + expect(text).toContain('not added to savings'); + expect(text).toContain('Incremental tax at liquidation'); + expect(text).toContain('Net after liquidation'); + }); + + it('shows cumulative financing cost and the modeled rate schedule when fees are enabled', () => { + const { container } = renderMeetingMode(createInputs({ qfafEnabled: true }), { + ...DEFAULT_SETTINGS, + financingFeesEnabled: true, + }); + const text = container.textContent ?? ''; + expect(text).toContain('Financing cost:'); + expect(text).toContain('cumulative, already reflected in projected wealth'); + expect(text).toContain('Modeled using the simple financing schedule'); + expect(text).toContain('not subtracted from the tax-savings headline'); + expect(text).toContain('later losses, and NOL utilization'); + }); +}); + +describe('Meeting Mode gain-event bridge', () => { + it('surfaces event amount, shelter, with-strategy tax, and counterfactual tax', () => { + const inputs = createInputs({ qfafEnabled: true }); + const props = buildProps(inputs, DEFAULT_SETTINGS); + const years = props.results.years.map((year, index) => + index === 2 + ? { + ...year, + gainEventAmount: 10_000_000, + gainEventCfShelter: 4_000_000, + gainEventTax: 1_500_000, + gainEventTaxWithoutStrategy: 2_400_000, + } + : year + ); + const { getByTestId } = render( + + ); + const text = getByTestId('meeting-gain-event-bridge').textContent ?? ''; + expect(text).toContain('$10.00M event in Year 3'); + expect(text).toContain('$4.00M sheltered'); + expect(text).toContain('$1.50M modeled event tax'); + expect(text).toContain('$2.40M tax without the strategy'); + expect(text).toContain('$900K'); + expect(text).toContain('not added to strategy tax savings'); + expect(text).toContain("adding the event can change when the strategy's NOL is used"); + expect(text).toContain('event tax above remains a separate comparison'); + }); }); // ─── D-022: the "How we get to {headline}" strip must SUM EXACTLY ─── @@ -215,6 +271,23 @@ describe('Meeting Mode decomposition sums to the headline (D-022)', () => { }); }); +describe('Meeting Mode selected-year explanation', () => { + it('reconciles the selected engine year into benefits, costs, and net savings', () => { + const { getByText, getByTestId } = renderMeetingMode( + createInputs({ qfafEnabled: true }), + DEFAULT_SETTINGS + ); + fireEvent.click(getByText('Detail')); + const text = getByTestId('mm-selected-year-reconciliation').textContent ?? ''; + expect(text).toContain('Explain Year 1'); + expect(text).toContain('ordinary-loss benefit'); + expect(text).toContain('NOL-use benefit'); + expect(text).toContain('capital-loss benefit'); + expect(text).toContain('gain costs'); + expect(text).toContain('future tax asset, not a current-year benefit'); + }); +}); + describe('Meeting Mode mechanics view (mock-meeting review)', () => { it('EDI mode: no QFAF steps — harvest → reserve → shelter with real numbers', () => { const { container, getByText } = renderMeetingMode(createInputs(), DEFAULT_SETTINGS); @@ -238,9 +311,35 @@ describe('Meeting Mode mechanics view (mock-meeting review)', () => { expect(text).toContain('§475(f)'); // $3M income, MFJ: year-1 ordinary losses far exceed the $512K cap. expect(text).toContain('Year 1 ordinary deduction capped at $512,000 (MFJ)'); + expect(text).toContain("Fund's modeled §475(f) ordinary losses offset ordinary income"); + expect(text).toContain('Capital losses from the collateral overlay remain capital'); expect(text).toContain('Wash-sale assumption'); // CA scenario → one-line state caveat from the shared warning util. expect(text).toContain('Modeled per California law'); + expect(getByText('Mechanics').ownerDocument.body.textContent).toContain( + 'Federal vs. California timing' + ); + expect(container.querySelector('[data-testid="mm-loss-character-map"]')?.textContent).toContain( + 'Capital loss' + ); + expect(container.querySelector('[data-testid="mm-loss-character-map"]')?.textContent).toContain( + 'Fund ordinary loss' + ); + expect(container.querySelector('[data-testid="mm-loss-character-map"]')?.textContent).toContain( + 'NOL carryforward' + ); + }); + + it('labels Washington as a time-varying rate assumption', () => { + const { container, getByText } = renderMeetingMode( + createInputs({ qfafEnabled: true, stateCode: 'WA', stateRate: 0 }), + DEFAULT_SETTINGS + ); + fireEvent.click(getByText('Mechanics')); + const text = container.textContent ?? ''; + expect(text).toContain('WA current rate'); + expect(text).toContain('0% individual income tax is shown for 2026–2027'); + expect(text).toContain('enacted 9.9% income tax is modeled beginning in 2028'); }); it('explains the dead tail when the QFAF stops before the projection ends', () => { @@ -280,6 +379,7 @@ describe('Meeting Mode comparison memory (D-024)', () => { expect(chip.textContent).toContain( `Was ${fmtCompact(oldHeadline)} → Now ${fmtCompact(newHeadline)}` ); + expect(getByTestId('mm-change-attribution').textContent).toContain('Why savings changed'); // Dismiss → chip disappears (baseline re-captured at "now"). fireEvent.click(getByLabelText('Dismiss comparison')); diff --git a/src/components/MeetingMode/MeetingMode.tsx b/src/components/MeetingMode/MeetingMode.tsx index 2e30722..0b3097a 100644 --- a/src/components/MeetingMode/MeetingMode.tsx +++ b/src/components/MeetingMode/MeetingMode.tsx @@ -1904,7 +1904,7 @@ function PrintPageHeader({ ); } -function PrintPageFooter() { +function PrintPageFooter({ financingIncluded }: { financingIncluded: boolean }) { useBrandRevealed(); return (
Important disclosures. This is a hypothetical illustration for discussion purposes only — not investment, tax, or legal advice, and not an offer of any security. - Estimates do not reflect advisory fees, financing costs, tracking error, transaction costs, or - behavioral effects; actual results will vary. Tax-loss harvesting reduces cost basis: a - portion of projected savings is tax deferral that becomes due if the portfolio is liquidated - (it may become permanent via basis step-up at death or charitable transfer). Ordinary loss - deductions are limited by IRC §461(l) ($512K MFJ / $256K others, 2026); excess becomes an NOL - usable against up to 80% of future taxable income. Projections assume 0–15% of harvested - losses are disallowed as wash sales and that the{' '} + Estimates do not reflect advisory fees, tracking error, transaction costs, or behavioral + effects;{' '} + {financingIncluded + ? 'the selected financing-cost schedule is included' + : 'financing costs are excluded'} + ; actual results will vary. Tax-loss harvesting reduces cost basis: a portion of projected + savings is tax deferral that becomes due if the portfolio is liquidated (it may become + permanent via basis step-up at death or charitable transfer). Ordinary loss deductions are + limited by IRC §461(l) ($512K MFJ / $256K others, 2026); excess becomes an NOL usable against + up to 80% of future taxable income. Projections assume 0–15% of harvested losses are + disallowed as wash sales and that the{' '} {brandText('QFAF (Quantinno Fundamental Arbitrage Fund)')} qualifies for the modeled tax treatment under current IRS guidance, which may change. State treatment varies — CA, NY, PA, NJ, MA, and WA differ materially from the federal rules modeled here. Consult qualified tax, @@ -2030,9 +2034,11 @@ function MechanicsView({ }, { n: 3, - title: 'Loss harvesting generates NOL', + title: brandText('QFAF ordinary losses generate NOL'), tone: '#f59e0b', - body: `Realized short-term losses offset high-tax ordinary income first (combined ${fmtPercent1(taxRates.combinedOrdinaryRate)} ordinary rate), with the balance carried forward as NOL.`, + body: brandText( + `The QFAF's modeled §475(f) ordinary losses offset ordinary income, subject to §461(l), at the ${fmtPercent1(taxRates.combinedOrdinaryRate)} combined ordinary rate; excess becomes an NOL. Capital losses from the collateral overlay remain capital and follow §1211 netting.` + ), metric: fmtCurrency(totalNol), metricLabel: 'NOL generated', }, @@ -2284,7 +2290,11 @@ function MechanicsView({ [ ['Fed ST', fmtPercent(taxRates.federalStRate), false], ['Fed LT', fmtPercent(taxRates.federalLtRate), false], - ['State', fmtPercent(taxRates.stateRate), false], + [ + inputs.stateCode === 'WA' ? 'WA current rate' : 'State', + fmtPercent(taxRates.stateRate), + false, + ], ['Combined ST', fmtPercent(taxRates.combinedStRate), true], ['Ordinary (deductions)', fmtPercent(taxRates.combinedOrdinaryRate), true], ['Combined LT', fmtPercent(taxRates.combinedLtRate), true], @@ -2315,8 +2325,82 @@ function MechanicsView({
))} + {inputs.stateCode === 'WA' && ( +
+ Washington changes during this projection: 0% + individual income tax is shown for 2026–2027; the enacted 9.9% income tax is modeled + beginning in 2028 after its statutory deduction and Washington capital-gains tax credit. + Actual liability depends on final guidance and the client's full return. +
+ )} + + +
+ {[ + [ + 'Capital loss', + 'Offsets capital gains', + 'Then up to $3,000/yr of ordinary income under §1211.', + ], + [ + 'Fund ordinary loss', + 'Offsets ordinary income', + 'Modeled §475(f) treatment; current use is subject to §461(l).', + ], + [ + 'NOL carryforward', + 'Offsets future taxable income', + 'Generally limited to 80% of taxable income in a later year.', + ], + ].map(([title, use, limit]) => ( +
+
{title}
+
{use}
+
+ {limit} +
+
+ ))}
+ {inputs.stateCode === 'CA' && ( +
+ Federal vs. California timing: eligible federal + deductions and NOL use follow the federal limits shown above. For modeled MAGI of $1M or + more, California SB 167 suspends the state NOL deduction in tax year 2026, so the state + benefit may arrive later. A federal NOL does not automatically create an immediate + California benefit. +
+ )} + {/* ─── For your CPA (4b) ─── Collapsible on screen; the print handout renders the expanded variant instead (page 3, verified to still paginate at exactly 3 sheets). Visibility is swapped via the @@ -2462,6 +2546,27 @@ export function MeetingMode({ [results, exitAnalysis] ); const insights = useMemo(() => computeEdiInsights(results), [results]); + const gainEventYears = useMemo( + () => visibleYears.filter(y => y.gainEventAmount > 0), + [visibleYears] + ); + const gainEventTotals = useMemo( + () => + gainEventYears.reduce( + (totals, y) => ({ + amount: totals.amount + y.gainEventAmount, + tax: totals.tax + y.gainEventTax, + taxWithoutStrategy: totals.taxWithoutStrategy + y.gainEventTaxWithoutStrategy, + shelter: totals.shelter + y.gainEventCfShelter, + }), + { amount: 0, tax: 0, taxWithoutStrategy: 0, shelter: 0 } + ), + [gainEventYears] + ); + const currentBenefitParts = useMemo( + () => computeBenefitDecomposition(visibleYears), + [visibleYears] + ); // Year focus defaults to Year 1 — the start of the story — not the final // (often $0 wind-down) year (mock-meeting review, 7b). @@ -2479,12 +2584,37 @@ export function MeetingMode({ const [chipBaseline, setChipBaseline] = useState(() => ({ savings: totalTaxSavings, reserve: lossReserve, + benefitParts: currentBenefitParts, })); const chipSavingsMoved = Math.abs(totalTaxSavings - chipBaseline.savings) > 0.5; const chipReserveMoved = ediMode && Math.abs(lossReserve - chipBaseline.reserve) > 0.5; const showChangeChip = chipSavingsMoved || chipReserveMoved; const dismissChangeChip = () => - setChipBaseline({ savings: totalTaxSavings, reserve: lossReserve }); + setChipBaseline({ + savings: totalTaxSavings, + reserve: lossReserve, + benefitParts: currentBenefitParts, + }); + const changeAttribution = [ + { + label: 'ordinary deductions', + value: + currentBenefitParts.ordinaryLossBenefit - chipBaseline.benefitParts.ordinaryLossBenefit, + }, + { + label: 'NOL usage', + value: currentBenefitParts.nolUsageBenefit - chipBaseline.benefitParts.nolUsageBenefit, + }, + { + label: 'capital-loss use', + value: currentBenefitParts.capitalLossBenefit - chipBaseline.benefitParts.capitalLossBenefit, + }, + { + label: 'gain costs', + value: -(currentBenefitParts.gainAndOtherCosts - chipBaseline.benefitParts.gainAndOtherCosts), + }, + ].filter(item => Math.abs(item.value) > 0.5); + const selectedYearResult = yearIdx > 0 ? visibleYears[yearIdx - 1] : undefined; // Real pin (single slot, replace-on-repin). Values are FROZEN at pin time — // a small struct, not live results — so later edits can't mutate the row. @@ -3038,6 +3168,38 @@ export function MeetingMode({ )} + {showChangeChip && changeAttribution.length > 0 && !printMode && ( +
+ Why savings changed:{' '} + {changeAttribution.map((item, index) => ( + + {index > 0 && ' · '} + = 0 ? M.good : '#dc2626' }}> + {item.value >= 0 ? '+' : '−'} + {fmtCurrency(Math.abs(item.value))} + {' '} + {item.label} + + ))} + {whatIfs?.gainEventEnabled && gainEventYears.length > 0 && ( +
+ Gain-event context: event tax stays separate from strategy tax savings. The + event can change when strategy NOLs are used, so the savings headline may + move even when the event-tax bridge shows no carryforward shelter. +
+ )} + {advancedSettings.financingFeesEnabled && ( +
+ Financing context: the financing cost is not subtracted from tax savings. It + reduces projected portfolio values, which can change later modeled losses + and NOL utilization; those tax-component changes explain the headline move. +
+ )} +
+ )} {/* ─── D-024 pinned ghost row ─── frozen comparison numbers captured by "Pin current scenario" in the rail. */} @@ -3417,6 +3579,104 @@ export function MeetingMode({ ))} +
+ {[ + ['Realized modeled savings', totalTaxSavings, 'recognized during the projection'], + [ + 'Unused tax assets', + cfReserveBalance + + (visibleYears[visibleYears.length - 1]?.nolCarryforward ?? 0), + 'ending loss balances · not added to savings', + ], + [ + 'Incremental tax at liquidation', + exitAnalysis.incrementalDeferredTax, + 'signed reversal versus passive', + ], + [ + 'Net after liquidation', + exitAnalysis.netBenefitAfterLiquidation, + 'conservative everything-sold comparison', + ], + ].map(([label, value, note]) => ( +
+
+ {label} +
+
+ {fmtCurrency(value as number)} +
+
{note}
+
+ ))} +
+ + {gainEventYears.length > 0 && ( +
+
+ Gain-event tax bridge +
+
+ + {fmtCurrency(gainEventTotals.amount)} event in{' '} + {gainEventYears.map(y => `Year ${y.year}`).join(', ')} + + + {fmtCurrency(gainEventTotals.shelter)} sheltered by current + losses and carryforwards + + + {fmtCurrency(gainEventTotals.tax)} modeled event tax + + + {fmtCurrency(gainEventTotals.taxWithoutStrategy)} tax without + the strategy + +
+
+ Estimated event-tax reduction:{' '} + + {fmtCurrency(gainEventTotals.taxWithoutStrategy - gainEventTotals.tax)} + + . Event tax is reported separately and is not added to strategy tax savings. +
+
+ Why the savings headline can still move: adding the event can change when the + strategy's NOL is used across the projection. That timing effect belongs to + strategy tax savings; the event tax above remains a separate comparison. +
+
+ )} + {/* Step-up co-metric (D-018) + EDI protection ratio — included in the printed handout per D-021 (export parity). Compact strip so the page-1 print pagination (exactly 3 sheets) holds. */} @@ -3465,7 +3725,30 @@ export function MeetingMode({ cumulative financing cost )} + {advancedSettings.financingFeesEnabled && ( + + Financing cost:{' '} + + {fmtCurrency(insights.cumulativeFinancingCost)} + {' '} + cumulative, already reflected in projected wealth + + )} + {advancedSettings.financingFeesEnabled && ( +
+ Modeled using the {advancedSettings.financingMode} financing schedule:{' '} + {advancedSettings.financingMode === 'simple' + ? `${fmtPercent1(advancedSettings.simpleWealthMgmtFee)} wealth-management fee plus ${fmtPercent1(advancedSettings.simpleManagerFeeBase)} × leverage and ${fmtPercent1(advancedSettings.simpleManagerFeeFixed)} fixed manager fee` + : `${fmtPercent1(advancedSettings.brokerMarginRate)} margin, ${fmtPercent1(advancedSettings.shortBorrowRate)} borrow, ${fmtPercent1(advancedSettings.shortDividendRate)} short dividends, and ${fmtPercent1(advancedSettings.wealthManagementFeeRate)} wealth-management fee`} + . +
+ The financing cost reduces projected wealth; it is not subtracted from the + tax-savings headline. Any headline change comes indirectly from changes to + modeled portfolio values, later losses, and NOL utilization. +
+
+ )}
“Net if held to step-up” is mortality-contingent and assumes basis step-up under current law (IRC §1014). Unused loss carryforwards are lost at death @@ -3588,7 +3871,9 @@ export function MeetingMode({
- {printMode && } + {printMode && ( + + )} )} @@ -3691,7 +3976,85 @@ export function MeetingMode({ finalPortfolio={finalPortfolio} /> - {printMode && } + {selectedYearResult && ( +
+
+ Explain Year {selectedYearResult.year} +
+
+ {[ + [ + '+', + selectedYearResult.ordinaryLossBenefit, + 'ordinary-loss benefit', + `${fmtCurrency(selectedYearResult.usableOrdinaryLoss)} deduction`, + ], + [ + '+', + selectedYearResult.nolUsageBenefit, + 'NOL-use benefit', + `${fmtCurrency(selectedYearResult.nolUsedThisYear)} NOL used`, + ], + [ + '+', + selectedYearResult.capitalLossBenefit, + 'capital-loss benefit', + `${fmtCurrency(selectedYearResult.capitalLossUsedAgainstIncome)} used`, + ], + [ + '−', + selectedYearResult.ltGainCost + selectedYearResult.remainingStGainCost, + 'gain costs', + 'LT plus unoffset ST gains', + ], + [ + '=', + selectedYearResult.taxSavings, + 'year tax savings', + `${fmtCurrency(selectedYearResult.excessToNol)} new NOL deferred`, + ], + ].map(([sign, value, label, note]) => ( +
- Estimates do not reflect advisory fees, financing costs, tracking error, transaction - costs, or behavioral effects. Actual results will vary. For discussion purposes only. + Estimates do not reflect advisory fees, tracking error, transaction costs, or behavioral + effects.{' '} + {advancedSettings.financingFeesEnabled + ? 'The selected financing-cost schedule is included.' + : 'Financing costs are excluded unless enabled.'}{' '} + Actual results will vary. For discussion purposes only.
diff --git a/src/components/QualifiedPurchaserModal.test.tsx b/src/components/QualifiedPurchaserModal.test.tsx new file mode 100644 index 0000000..ce6ee73 --- /dev/null +++ b/src/components/QualifiedPurchaserModal.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { QualifiedPurchaserModal } from './QualifiedPurchaserModal'; + +describe('QualifiedPurchaserModal', () => { + it('exposes dialog semantics and focuses the first acknowledgment', () => { + render(); + + const dialog = screen.getByRole('dialog', { name: 'Important Acknowledgments' }); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(screen.getAllByRole('checkbox')[0]).toHaveFocus(); + }); + + it('contains keyboard focus and submits only after every acknowledgment', async () => { + const user = userEvent.setup(); + const onAcknowledge = vi.fn(); + render(); + + const checkboxes = screen.getAllByRole('checkbox'); + const submit = screen.getByRole('button', { name: 'I Acknowledge and Wish to Proceed' }); + expect(submit).toBeDisabled(); + + for (const checkbox of checkboxes) await user.click(checkbox); + expect(submit).toBeEnabled(); + + submit.focus(); + await user.tab(); + expect(checkboxes[0]).toHaveFocus(); + + await user.click(submit); + expect(onAcknowledge).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/QualifiedPurchaserModal.tsx b/src/components/QualifiedPurchaserModal.tsx index 9f53066..6090d29 100644 --- a/src/components/QualifiedPurchaserModal.tsx +++ b/src/components/QualifiedPurchaserModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; interface QualifiedPurchaserModalProps { onAcknowledge: () => void; @@ -39,6 +39,8 @@ const ACKNOWLEDGMENTS: Acknowledgment[] = [ export function QualifiedPurchaserModal({ onAcknowledge }: QualifiedPurchaserModalProps) { const [checked, setChecked] = useState>({}); + const dialogRef = useRef(null); + const firstCheckboxRef = useRef(null); const allChecked = ACKNOWLEDGMENTS.every(ack => checked[ack.id]); @@ -52,12 +54,54 @@ export function QualifiedPurchaserModal({ onAcknowledge }: QualifiedPurchaserMod } }; + useEffect(() => { + const previouslyFocused = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + firstCheckboxRef.current?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !dialogRef.current) return; + + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus(); + }; + }, []); + return (
-
+
-

Important Acknowledgments

-

+

Important Acknowledgments

+

This calculator models sophisticated tax optimization strategies involving leveraged investments. Please confirm the following before proceeding.

@@ -67,6 +111,7 @@ export function QualifiedPurchaserModal({ onAcknowledge }: QualifiedPurchaserMod {ACKNOWLEDGMENTS.map(ack => (
+ {attribution.length > 0 && ( +
+ Why total savings changed:{' '} + {attribution.map((item, index) => ( + + {index > 0 && ' · '} + = 0 ? 'pos' : 'neg'}> + {item.value >= 0 ? '+' : '−'} + {formatCurrencyAbbreviated(Math.abs(item.value))} + {' '} + {item.label} + + ))} +
+ )}
); } @@ -601,6 +640,12 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { // keyed by year — income changes, cash infusions, and planned gain events. const [yearEvents, setYearEvents] = useState>(new Map()); const [showEventsEditor, setShowEventsEditor] = useState(false); + const [mobileInputsOpen, setMobileInputsOpen] = useState(false); + const [isMobileWorkspace, setIsMobileWorkspace] = useState(false); + const [mobileSelectedYear, setMobileSelectedYear] = useState(1); + const [explainYear, setExplainYear] = useState(1); + const mobileDrawerRef = useRef(null); + const mobileInputsTriggerRef = useRef(null); // Income schedule builder: start amount + annual growth → per-year w2Income // rows (which stay hand-editable afterward). const [schedStart, setSchedStart] = useState(DEFAULTS.annualIncome); @@ -625,6 +670,55 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { }); const groupRefs = useRef>>({}); + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const query = window.matchMedia('(max-width: 640px)'); + const update = () => setIsMobileWorkspace(query.matches); + update(); + query.addEventListener('change', update); + return () => query.removeEventListener('change', update); + }, []); + + useEffect(() => { + if (!isMobileWorkspace || !mobileInputsOpen) return; + const drawer = mobileDrawerRef.current; + if (!drawer) return; + const trigger = mobileInputsTriggerRef.current; + const priorOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + drawer.querySelector('input, select, button')?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setMobileInputsOpen(false); + return; + } + if (event.key !== 'Tab') return; + const focusable = Array.from( + drawer.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter(element => element.offsetParent !== null); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.body.style.overflow = priorOverflow; + document.removeEventListener('keydown', onKeyDown); + trigger?.focus(); + }; + }, [isMobileWorkspace, mobileInputsOpen]); + const set = (key: K, value: CalculatorInputs[K]) => setInputs(prev => ({ ...prev, [key]: value })); const setSetting = (key: K, value: AdvancedSettings[K]) => @@ -704,7 +798,8 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { rates.combinedLt, settings.growthEnabled ? settings.defaultAnnualReturn : 0, rates.profile.ltcgExcise, - effectiveInputs.collateralCostBasis + effectiveInputs.collateralCostBasis, + { stateCode: effectiveInputs.stateCode, ordinaryIncome: effectiveInputs.annualIncome } ), [ results, @@ -712,6 +807,8 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { settings.growthEnabled, settings.defaultAnnualReturn, effectiveInputs.collateralCostBasis, + effectiveInputs.stateCode, + effectiveInputs.annualIncome, ] ); @@ -819,7 +916,16 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { // Scenario presets (D-014 folded default): one-click starting points that // fill the per-year events rows via the same setEvent machinery — every // value stays hand-editable afterward. - const applyPreset = (preset: 'business-sale' | 'rsu-vesting' | 'concentrated-stock') => { + const applyPreset = ( + preset: + | 'business-sale' + | 'rsu-vesting' + | 'concentrated-stock' + | 'bonus' + | 'nso-exercise' + | 'ipo-liquidity' + | 'sabbatical' + ) => { const collateral = results.sizing.collateralValue; if (preset === 'business-sale') { // Business exit: a large LT gain (2× collateral) once the reserve has @@ -833,10 +939,21 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { for (let yr = 1; yr <= Math.min(4, projYears); yr++) { setEvent(yr, { w2Income: bumped }); } - } else { + } else if (preset === 'concentrated-stock') { // Diversify a concentrated position: LT gain of 50% of collateral early. const yr = Math.min(2, projYears); setEvent(yr, { gainEvent: { amount: Math.round(collateral * 0.5), character: 'lt' } }); + } else if (preset === 'bonus') { + setEvent(1, { w2Income: Math.round(effectiveInputs.annualIncome * 1.25) }); + } else if (preset === 'nso-exercise') { + setEvent(2, { w2Income: Math.round(effectiveInputs.annualIncome * 1.5) }); + } else if (preset === 'ipo-liquidity') { + setEvent(3, { + w2Income: Math.round(effectiveInputs.annualIncome * 1.25), + gainEvent: { amount: Math.round(collateral * 0.5), character: 'lt' }, + }); + } else { + setEvent(2, { w2Income: Math.round(effectiveInputs.annualIncome * 0.25) }); } setShowEventsEditor(true); }; @@ -844,6 +961,18 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { const eventYears = results.years.filter(y => y.gainEventAmount > 0); const { summary } = results; + const explainedYear = results.years.find(year => year.year === explainYear) ?? results.years[0]; + // Client-facing calculation flow: every value is a direct aggregation of + // audit-complete engine outputs. This is explanation only — no parallel math. + const flowTotals = results.years.reduce( + (totals, year) => ({ + capitalLosses: totals.capitalLosses + year.stLossesHarvested, + ordinaryLosses: totals.ordinaryLosses + year.ordinaryLossesGenerated, + currentOrdinaryDeductions: totals.currentOrdinaryDeductions + year.usableOrdinaryLoss, + nolGenerated: totals.nolGenerated + year.excessToNol, + }), + { capitalLosses: 0, ordinaryLosses: 0, currentOrdinaryDeductions: 0, nolGenerated: 0 } + ); // §461(l)/NOL surfaces are QFAF-shaped: hide them whenever the projection // generated no NOL (covers EDI-only mode without hardcoding on qfafEnabled). const hasNol = summary.totalNolGenerated >= 1; @@ -887,6 +1016,8 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { : undefined; const year1 = results.years[0]?.taxSavings ?? 0; const year2 = results.years[1]?.taxSavings ?? 0; + const selectedMobileYear = + results.years.find(y => y.year === mobileSelectedYear) ?? results.years[0]; const projectionYears = settings.projectionYears ?? 10; const currentStrategy = getStrategy(inputs.strategyId); @@ -920,6 +1051,7 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { year1, netIfLiquidated: exit.netBenefitAfterLiquidation, finalWealth: summary.finalTotalWealth, + result: results, }; // Deleveraging plan (D-016/D-017) @@ -1488,7 +1620,7 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { names on/off for internal use. No affordance — invisible to the public. */}
Q - EDI Calculator +

EDI Calculator

{secretReveal.flash !== null && ( {secretReveal.flash ? 'Internal names on' : 'Anonymized'} @@ -1554,6 +1686,16 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) {
+ +
)} @@ -2428,11 +2593,55 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { > Diversify concentrated stock + + + + starting points — each fills the rows below; edit amounts and years to fit the client
+
+ + RSU vesting, bonus, NSO spread → ordinary/W-2 income + + + Share sale after vesting/exercise → separate ST or LT capital gain + + + IPO/acquisition preset → both buckets in the same year + + + ISO exercise → AMT is not modeled; review separately + +
Income schedule
+
+ + + How the calculation flows + Trace the current scenario from activity to net benefit + + + Show calculation path + Hide calculation path + + +
    +
  1. + 1 +
    + Strategy activity +

    + The projection models of harvested + capital losses + {!ediMode && ( + <> + {' '} + and of Fund ordinary losses + + )} + . +

    +
    +
  2. +
  3. + 2 +
    + Losses keep their tax character +

    + Capital losses net against capital gains under §1211. Fund losses are + modeled as ordinary under §475(f); the two buckets are not interchangeable. +

    +
    +
  4. +
  5. + 3 +
    + Annual limits are applied +

    + is modeled as usable + current ordinary loss after §461(l); excess ordinary loss moves to the NOL + bucket. +

    +
    +
  6. +
  7. + 4 +
    + Unused amounts carry forward +

    + The program generates of NOL and ends + with {' '} + of capital-loss carryforwards after modeled use. +

    +
    +
  8. +
  9. + 5 +
    + Annual benefits become the headline +

    + Character-specific deductions and gain costs reconcile to{' '} + of cumulative modeled tax savings. +

    +
    +
  10. +
  11. + 6 +
    + Liquidation tests the deferral +

    + After the modeled incremental liquidation tax, the strategy retains{' '} + of net benefit versus the + passive baseline. +

    +
    +
  12. +
+

+ Every amount above comes from the same year-by-year engine shown in the audit + table. Carryforward shelter value is contingent and is not added to cumulative tax + savings. +

+
+ +
+
Loss-character map
+
+
+ Capital loss + Offsets capital gains + Then up to $3,000/yr of ordinary income under §1211 +
+
+ Fund ordinary loss + Offsets ordinary income + Current use subject to §461(l); modeled §475(f) treatment +
+
+ NOL carryforward + Offsets future taxable income + Generally limited to 80% of taxable income per year +
+
+
+ + {inputs.stateCode === 'CA' && ( +
+
Federal vs. California timing
+
+
+ Federal + + Eligible current deductions and later NOL use follow the federal limits + modeled year by year. + +
+
+ California · Year 1 + + For modeled MAGI of $1M or more, SB 167 suspends the California NOL + deduction in tax year 2026. + +
+
+ California · Later years + + The state benefit may arrive later than the federal benefit; the state + carryover remains separately tracked and may expire. + +
+
+

+ Federal and California benefits can occur in different years. A federal NOL does + not automatically create an immediate California benefit. +

+
+ )} + +
+
+ Realized modeled benefit + {formatCurrency(summary.totalTaxSavings)} + Annual tax savings already recognized in the projection +
+
+ Unused tax assets + + {formatCurrency( + summary.finalStCarryforward + + summary.finalLtCarryforward + + (results.years[results.years.length - 1]?.nolCarryforward ?? 0) + )} + + Ending NOL and capital-loss balances; not added to savings +
+
+ Incremental tax at liquidation + {formatCurrency(exit.incrementalDeferredTax)} + Signed reversal versus passive; negative means a cheaper exit +
+
+ Net after liquidation + {formatCurrency(exit.netBenefitAfterLiquidation)} + Conservative comparison after the modeled exit +
+
+ + {explainedYear && ( +
+ + + Explain this result + Deterministic reconciliation from the selected engine year + + + +
+ + + {formatCurrency(explainedYear.ordinaryLossBenefit)} ordinary-loss + benefit + + {formatCurrency(explainedYear.usableOrdinaryLoss)} current ordinary + deduction + + + + + {formatCurrency(explainedYear.nolUsageBenefit)} NOL-use benefit + {formatCurrency(explainedYear.nolUsedThisYear)} NOL used + + + + {formatCurrency(explainedYear.capitalLossBenefit)} capital-loss + benefit + + {formatCurrency(explainedYear.capitalLossUsedAgainstIncome)} used against + income + + + + + −{' '} + {formatCurrency( + explainedYear.ltGainCost + explainedYear.remainingStGainCost + )} + {' '} + gain costsLT gains plus unoffset ST gains + + + = {formatCurrency(explainedYear.taxSavings)} Year {explainedYear.year}{' '} + tax savings + + {formatCurrency(explainedYear.excessToNol)} new NOL is deferred, not a + current benefit + + +
+
+ )} + {/* D-027: rates compressed to a single inline strip (for the CPA) */}
@@ -2817,8 +3266,16 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { Fed LT {formatPercent(rates.full.federalLtRate)} - State ({inputs.stateCode}) {formatPercent(rates.profile.ordinaryRate)} + {inputs.stateCode === 'WA' + ? 'WA current (2026–27)' + : `State (${inputs.stateCode})`}{' '} + {formatPercent(rates.profile.ordinaryRate)} + {inputs.stateCode === 'WA' && ( + + WA income tax (2028+) 9.90% before deduction & CGT credit + + )} {rates.profile.stRate !== rates.profile.ordinaryRate && ( State ST {formatPercent(rates.profile.stRate)} @@ -2836,8 +3293,86 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { )} Fed ST & LT incl. NIIT + + Marginal statutory rates used to value eligible deductions and gains — not the + client's effective tax rate. +
+
+ + Tax assumption provenance + Sources, effective years, and verification status + +
+
+ Federal §461(l) limits + + Tax year {TAX_PARAMETER_MANIFEST.federal.section461l.effectiveTaxYear} + + Verified {TAX_PARAMETER_MANIFEST.federal.section461l.verifiedAt} + + IRS source + +
+
+ California NOL timing + SB 167 · modeled for 2026 high-income cases + Enacted; state carryover character approximated + + California FTB source + +
+
+ Washington income tax + + Effective {TAX_PARAMETER_MANIFEST.washington.incomeTax.effectiveTaxYear} + + + Verified {TAX_PARAMETER_MANIFEST.washington.incomeTax.verifiedAt} · + provisional implementation guidance + + + Enacted statute + +
+
+ Washington capital-gains excise + + Effective{' '} + {TAX_PARAMETER_MANIFEST.washington.capitalGainsExcise.effectiveTaxYear} + + + Verified {TAX_PARAMETER_MANIFEST.washington.capitalGainsExcise.verifiedAt} · + 2026 exemption provisional + + + Washington DOR source + +
+
+

+ Other federal and state rates are the 2026 model parameters shown above. + User-entered assumptions and opt-in model settings remain scenario-specific. +

+
+ {!ediMode && (
@@ -2852,16 +3387,119 @@ export function WorkspaceTab({ isActive = true }: WorkspaceTabProps) { {resultsView === 'table' && (
- +
+
+ + + +
+ {selectedMobileYear && ( +
+
+
Portfolio value
+
{formatCurrency(selectedMobileYear.totalValue)}
+
+
+
Tax savings
+
{formatCurrency(selectedMobileYear.taxSavings)}
+
+
+
ST losses harvested
+
{formatCurrency(selectedMobileYear.stLossesHarvested)}
+
+
+
LT gains realized
+
{formatCurrency(selectedMobileYear.ltGainsRealized)}
+
+
+
Ordinary loss used
+
{formatCurrency(selectedMobileYear.usableOrdinaryLoss)}
+
+
+
NOL balance
+
{formatCurrency(selectedMobileYear.nolCarryforward)}
+
+
+
Capital loss reserve
+
+ {formatCurrency( + selectedMobileYear.stLossCarryforward + + selectedMobileYear.ltLossCarryforward + )} +
+
+
+
Income for full utilization
+
{formatCurrency(selectedMobileYear.incomeRequiredForFullUtilization)}
+
+ {(selectedMobileYear.waIncomeTax > 0 || + selectedMobileYear.waCapitalGainsTaxCredit > 0) && ( + <> +
+
WA income tax
+
{formatCurrency(selectedMobileYear.waIncomeTax)}
+
+
+
WA capital-gains credit
+
{formatCurrency(selectedMobileYear.waCapitalGainsTaxCredit)}
+
+ + )} +
+ )} +
+ Open full audit table + +
+
+
+ +
)} diff --git a/src/workspace/workspace.css b/src/workspace/workspace.css index f0fe095..2e85ca3 100644 --- a/src/workspace/workspace.css +++ b/src/workspace/workspace.css @@ -178,9 +178,11 @@ letter-spacing: -0.03em; } .wx-brand-name { + margin: 0; font-weight: 650; font-size: 14px; letter-spacing: -0.01em; + color: inherit; } /* Transient confirmation for the hidden brand-reveal toggle. No affordance on the brand itself (no pointer cursor) so the toggle stays undiscoverable. */ @@ -322,6 +324,12 @@ max-height: calc(100vh - 53px); overflow-y: auto; } +.wx-mobile-inputs-btn, +.wx-mobile-drawer-head, +.wx-mobile-drawer-backdrop, +.wx-mobile-year-detail { + display: none; +} /* rail search */ .wx-railsearch { @@ -1190,6 +1198,244 @@ font-weight: 600; } +/* client-facing, engine-backed calculation trace */ +.wx-calc-flow { + background: var(--wx-panel); + border: 1px solid var(--wx-line); + border-radius: var(--wx-r-md); + overflow: hidden; +} +.wx-calc-flow > summary { + min-height: 52px; + padding: 11px 15px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + cursor: pointer; + list-style: none; +} +.wx-calc-flow > summary::-webkit-details-marker { + display: none; +} +.wx-calc-flow > summary > span:first-child { + display: grid; + gap: 2px; +} +.wx-calc-flow > summary strong { + color: var(--wx-ink); + font-size: 13px; +} +.wx-calc-flow > summary small { + color: var(--wx-ink-3); + font-size: 11px; +} +.wx-calc-flow-action { + color: var(--wx-accent); + font-size: 11.5px; + font-weight: 650; + white-space: nowrap; +} +.wx-calc-flow-open { + display: none; +} +.wx-calc-flow[open] .wx-calc-flow-open { + display: inline; +} +.wx-calc-flow[open] .wx-calc-flow-closed { + display: none; +} +.wx-calc-flow-steps { + list-style: none; + margin: 0; + padding: 4px 15px 14px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.wx-calc-flow-steps li { + min-width: 0; + padding: 11px; + display: flex; + gap: 9px; + border: 1px solid var(--wx-line); + border-radius: var(--wx-r-sm); + background: var(--wx-panel-2); +} +.wx-calc-flow-num { + width: 22px; + height: 22px; + flex: 0 0 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--wx-accent-soft); + color: var(--wx-accent); + font: 700 11px var(--wx-num-font); +} +.wx-calc-flow-steps strong { + color: var(--wx-ink); + font-size: 11.5px; +} +.wx-calc-flow-steps p, +.wx-calc-flow-foot { + margin: 4px 0 0; + color: var(--wx-ink-2); + font-size: 11px; + line-height: 1.5; +} +.wx-calc-flow-steps .num { + color: var(--wx-ink); + font-family: var(--wx-num-font); + font-weight: 650; + font-variant-numeric: tabular-nums; +} +.wx-calc-flow-foot { + margin: 0; + padding: 10px 15px 13px; + border-top: 1px solid var(--wx-line); + color: var(--wx-ink-3); +} + +/* client education and deterministic explanations */ +.wx-reqchips-note { + color: var(--wx-ink-3); + font-size: 11px; + line-height: 1.45; +} +.wx-loss-legend, +.wx-ca-timeline, +.wx-year-explain, +.wx-provenance { + padding: 13px 15px; + border: 1px solid var(--wx-line); + border-radius: var(--wx-r-md); + background: var(--wx-panel); +} +.wx-loss-legend-grid, +.wx-ca-timeline-grid, +.wx-benefit-map, +.wx-year-explain-equation, +.wx-provenance-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; + margin-top: 9px; +} +.wx-loss-legend-grid > div, +.wx-ca-timeline-grid > div, +.wx-benefit-map > div, +.wx-provenance-grid > div { + display: grid; + gap: 4px; + padding: 10px 11px; + border: 1px solid var(--wx-line); + border-radius: var(--wx-r-sm); + background: var(--wx-panel-2); + min-width: 0; +} +.wx-loss-legend strong, +.wx-ca-timeline strong, +.wx-benefit-map strong, +.wx-provenance b { + color: var(--wx-ink); + font-size: 12px; +} +.wx-loss-legend span, +.wx-ca-timeline span, +.wx-benefit-map span, +.wx-provenance span { + color: var(--wx-ink-2); + font-size: 11.5px; +} +.wx-loss-legend small, +.wx-benefit-map small { + color: var(--wx-ink-3); + font-size: 10.5px; + line-height: 1.4; +} +.wx-ca-timeline p, +.wx-provenance p { + margin: 9px 0 0; + color: var(--wx-ink-2); + font-size: 11px; + line-height: 1.5; +} +.wx-benefit-map { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 0; +} +.wx-benefit-map strong { + font: 700 16px var(--wx-num-font); +} +.wx-year-explain, +.wx-provenance { + padding: 0; + overflow: hidden; +} +.wx-year-explain > summary, +.wx-provenance > summary { + padding: 12px 15px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; +} +.wx-year-explain > summary small, +.wx-provenance > summary span { + display: block; + margin-top: 2px; + color: var(--wx-ink-3); + font-size: 10.5px; +} +.wx-year-explain select { + border: 1px solid var(--wx-line); + border-radius: 5px; + background: var(--wx-panel-2); + color: var(--wx-ink); +} +.wx-year-explain-equation { + grid-template-columns: repeat(5, minmax(0, 1fr)); + padding: 0 15px 15px; +} +.wx-year-explain-equation > span { + display: grid; + gap: 4px; + padding: 9px; + background: var(--wx-panel-2); + border-radius: var(--wx-r-sm); + color: var(--wx-ink-2); + font-size: 11px; +} +.wx-year-explain-equation b { + color: var(--wx-ink); + font-family: var(--wx-num-font); +} +.wx-year-explain-equation small { + color: var(--wx-ink-3); + line-height: 1.35; +} +.wx-year-explain-equation .total { + background: var(--wx-accent-soft); +} +.wx-provenance-grid { + grid-template-columns: 1fr 1fr; + padding: 0 15px 15px; +} +.wx-provenance a { + color: var(--wx-accent); + font-size: 11px; +} +.wx-provenance p { + padding: 0 15px 13px; +} +.wx-rate-note { + flex-basis: 100%; + font-style: italic; +} + /* QFAF treatment details (draft) — same panel language */ .wx-details { background: var(--wx-panel); @@ -1369,6 +1615,20 @@ .ws-pinstrip-delta--zero { color: var(--wx-ink-3); } +.ws-pinstrip-attribution { + margin-top: 9px; + padding-top: 9px; + border-top: 1px dashed var(--wx-line); + color: var(--wx-ink-2); + font-size: 11.5px; + line-height: 1.55; +} +.ws-pinstrip-attribution .pos { + color: var(--wx-pos); +} +.ws-pinstrip-attribution .neg { + color: var(--wx-neg); +} /* per-year events editor (wider panel in the results pane) */ .ws-events-editor { @@ -1414,6 +1674,18 @@ font-size: 11px; color: var(--wx-ink-3); } +.ws-event-character-guide { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px 14px; + margin: -3px 0 12px; + padding: 9px 11px; + border-radius: var(--wx-r-sm); + background: var(--wx-panel-3); + color: var(--wx-ink-2); + font-size: 11px; + line-height: 1.45; +} .ws-sched { display: flex; flex-wrap: wrap; @@ -1505,10 +1777,213 @@ .wx-charts { grid-template-columns: 1fr; } + .wx-calc-flow-steps { + grid-template-columns: 1fr 1fr; + } + .wx-benefit-map, + .wx-year-explain-equation { + grid-template-columns: 1fr 1fr; + } } @media (max-width: 640px) { + .wx-top { + padding: 10px 12px; + gap: 10px; + } + .wx-chips { + order: 3; + width: 100%; + overflow-x: auto; + flex-wrap: nowrap; + padding-bottom: 2px; + } + .wx-top-right { + margin-left: auto; + } + .wx-search { + display: none; + } + .wx-mobile-inputs-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 16px; + border: 1px solid var(--wx-accent); + border-radius: var(--wx-r-md); + background: var(--wx-accent); + color: var(--wx-accent-ink); + font: 650 14px var(--wx-ui-font); + } + .wx-body { + display: block; + } + .wx-main { + padding: 14px 12px 32px; + } + .wx-rail { + position: fixed; + inset: 0 auto 0 0; + z-index: 3001; + width: min(90vw, 370px); + max-height: 100dvh; + border-right: 1px solid var(--wx-line-strong); + border-bottom: none; + box-shadow: 18px 0 50px rgba(15, 23, 42, 0.3); + transform: translateX(-105%); + transition: transform 180ms ease; + overscroll-behavior: contain; + } + .wx-rail.is-open { + transform: translateX(0); + } + .wx-mobile-drawer-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 3000; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: rgba(15, 23, 42, 0.58); + } + .wx-mobile-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 48px; + margin: -4px 0 2px; + } + .wx-mobile-drawer-head strong { + font-size: 16px; + } + .wx-mobile-drawer-head button { + min-width: 64px; + min-height: 44px; + border: 1px solid var(--wx-line-strong); + border-radius: var(--wx-r-sm); + background: var(--wx-panel); + color: var(--wx-ink); + font: 600 13px var(--wx-ui-font); + } + .wx-rail button, + .wx-rail input, + .wx-rail select, + .wx-subnav button { + min-height: 44px; + } .wx-metrics, .wx-cards { grid-template-columns: 1fr; } + .wx-calc-flow > summary { + align-items: flex-start; + } + .wx-calc-flow-action { + white-space: normal; + text-align: right; + } + .wx-calc-flow-steps { + grid-template-columns: 1fr; + } + .wx-loss-legend-grid, + .wx-ca-timeline-grid, + .wx-benefit-map, + .wx-year-explain-equation, + .wx-provenance-grid, + .ws-event-character-guide { + grid-template-columns: 1fr; + } + .wx-metrics { + gap: 8px; + } + .wx-metric { + min-height: 112px; + padding: 16px; + } + .wx-metric-value { + font-size: clamp(25px, 8vw, 34px); + } + .wx-flags { + margin-top: 10px; + } + .wx-flags-head { + min-height: 48px; + } + .wx-desktop-year-table { + display: none; + } + .wx-mobile-year-detail { + display: block; + } + .wx-mobile-year-nav { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: end; + margin-bottom: 12px; + } + .wx-mobile-year-nav button, + .wx-mobile-year-nav select { + min-height: 44px; + border: 1px solid var(--wx-line-strong); + border-radius: var(--wx-r-sm); + background: var(--wx-panel); + color: var(--wx-ink); + } + .wx-mobile-year-nav button { + padding: 0 10px; + font-weight: 650; + } + .wx-mobile-year-nav label { + display: grid; + gap: 4px; + font-size: 11px; + color: var(--wx-ink-2); + } + .wx-mobile-year-nav select { + width: 100%; + padding: 0 8px; + font-size: 14px; + } + .wx-mobile-year-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 0; + } + .wx-mobile-year-grid > div { + min-width: 0; + padding: 12px; + border: 1px solid var(--wx-line); + border-radius: var(--wx-r-md); + background: var(--wx-panel); + } + .wx-mobile-year-grid dt { + margin-bottom: 5px; + color: var(--wx-ink-2); + font-size: 11px; + } + .wx-mobile-year-grid dd { + margin: 0; + color: var(--wx-ink); + font: 650 15px var(--wx-num-font); + overflow-wrap: anywhere; + } + .wx-mobile-full-table { + margin-top: 12px; + border: 1px solid var(--wx-line); + border-radius: var(--wx-r-md); + background: var(--wx-panel); + } + .wx-mobile-full-table > summary { + min-height: 48px; + padding: 14px; + cursor: pointer; + font-weight: 650; + } + .wx-mobile-full-table .table-container { + padding: 0 10px 10px; + } } diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts new file mode 100644 index 0000000..05b630e --- /dev/null +++ b/tests/e2e/workspace.spec.ts @@ -0,0 +1,95 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +const ACK_KEY = 'taxCalc:qp-acknowledged'; + +async function openAcknowledgedWorkspace(page: Page) { + await page.addInitScript(key => { + localStorage.setItem( + key, + JSON.stringify({ acknowledged: true, acknowledgedAt: '2026-07-11T00:00:00.000Z' }), + ); + }, ACK_KEY); + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1, name: 'EDI Calculator' })).toBeVisible(); +} + +test('acknowledgment gate is keyboard-contained and accessible', async ({ page }) => { + await page.goto('/'); + + const dialog = page.getByRole('dialog', { name: 'Important Acknowledgments' }); + await expect(dialog).toBeVisible(); + const checkboxes = dialog.getByRole('checkbox'); + await expect(checkboxes.first()).toBeFocused(); + + for (const checkbox of await checkboxes.all()) await checkbox.check(); + const submit = dialog.getByRole('button', { name: 'I Acknowledge and Wish to Proceed' }); + await submit.focus(); + await page.keyboard.press('Tab'); + await expect(checkboxes.first()).toBeFocused(); + + const scan = await new AxeBuilder({ page }).include('.qp-modal').analyze(); + expect(scan.violations).toEqual([]); + + await submit.click(); + await expect(page.getByRole('heading', { level: 1, name: 'EDI Calculator' })).toBeVisible(); +}); + +test('workspace renders without horizontal page overflow or browser errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error') errors.push(message.text()); + }); + + await openAcknowledgedWorkspace(page); + await expect(page.getByText('Est. Tax Savings').first()).toBeVisible(); + const dimensions = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1); + expect(errors).toEqual([]); +}); + +test('dark theme and core Workspace navigation remain operable', async ({ page }) => { + await openAcknowledgedWorkspace(page); + + await page.getByRole('button', { name: /switch to dark mode/i }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page.getByRole('button', { name: 'Year-by-Year' }).click(); + await expect(page.locator('.year-breakdown-table').first()).toBeVisible(); + await page.getByRole('button', { name: 'Charts' }).click(); + await expect(page.locator('.wx-charts')).toBeVisible(); +}); + +test('mobile inputs drawer and selected-year detail are keyboard operable', async ( + { page }, + testInfo, +) => { + test.skip(testInfo.project.name !== 'mobile', 'mobile-specific product shape'); + await openAcknowledgedWorkspace(page); + + const savings = page.getByTestId('ws-metric-total-savings'); + const before = await savings.textContent(); + const trigger = page.getByRole('button', { name: 'Edit inputs' }); + await trigger.click(); + const drawer = page.getByRole('dialog', { name: 'Scenario inputs' }); + await expect(drawer).toBeVisible(); + await expect(drawer.locator(':focus')).toBeVisible(); + + const income = drawer.getByText('Annual income').locator('..').locator('input'); + await income.fill('4,000,000'); + await page.keyboard.press('Escape'); + await expect(drawer).not.toBeVisible(); + await expect(trigger).toBeFocused(); + await expect(savings).not.toHaveText(before ?? ''); + + await page.getByRole('button', { name: 'Year-by-Year' }).click(); + const detail = page.getByTestId('wx-mobile-year-detail'); + await expect(detail).toBeVisible(); + await detail.getByRole('button', { name: 'Next' }).click(); + await expect(detail.getByLabel('Selected year')).toHaveValue('2'); + await expect(detail.getByText('Open full audit table')).toBeVisible(); +});