@retiregolden/planner-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +181 -0
  3. package/package.json +77 -0
  4. package/src/App.tsx +246 -0
  5. package/src/RouteErrorBoundary.tsx +45 -0
  6. package/src/assets/hero.png +0 -0
  7. package/src/assets/react.svg +1 -0
  8. package/src/assets/vite.svg +1 -0
  9. package/src/data/fedInvestClient.ts +113 -0
  10. package/src/data/localStore.ts +42 -0
  11. package/src/data/planOrigin.ts +24 -0
  12. package/src/data/planStore.ts +165 -0
  13. package/src/data/v2Backup.ts +101 -0
  14. package/src/import/ImportPage.tsx +347 -0
  15. package/src/import/ReviewChecklistView.tsx +38 -0
  16. package/src/import/brokerCsv.ts +395 -0
  17. package/src/import/csv.ts +133 -0
  18. package/src/import/genericCsv.ts +224 -0
  19. package/src/import/projectionLab.ts +350 -0
  20. package/src/import/reviewChecklist.ts +33 -0
  21. package/src/import/tenForty.ts +275 -0
  22. package/src/index.css +630 -0
  23. package/src/index.ts +16 -0
  24. package/src/learn/ArticleBody.tsx +78 -0
  25. package/src/learn/ArticlePage.tsx +57 -0
  26. package/src/learn/GlossaryPage.tsx +33 -0
  27. package/src/learn/LearnAboutScreen.tsx +41 -0
  28. package/src/learn/LearnCards.tsx +41 -0
  29. package/src/learn/LearnLink.tsx +91 -0
  30. package/src/learn/LearningCenterPage.tsx +114 -0
  31. package/src/learn/SourcesPage.tsx +98 -0
  32. package/src/learn/components/ArticleFigure.tsx +34 -0
  33. package/src/learn/components/ArticleShell.tsx +86 -0
  34. package/src/learn/components/ComparisonTable.tsx +42 -0
  35. package/src/learn/components/FormulaBlock.tsx +34 -0
  36. package/src/learn/components/PurchasingPowerChart.tsx +41 -0
  37. package/src/learn/components/RelatedArticles.tsx +27 -0
  38. package/src/learn/components/ScenarioCard.tsx +24 -0
  39. package/src/learn/components/SourceList.tsx +23 -0
  40. package/src/learn/components/charts.tsx +21 -0
  41. package/src/learn/content/about-retiregolden.ts +100 -0
  42. package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
  43. package/src/learn/content/account-types-overview.ts +106 -0
  44. package/src/learn/content/after-tax-estate.ts +111 -0
  45. package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
  46. package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
  47. package/src/learn/content/assumption-general-inflation.ts +82 -0
  48. package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
  49. package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
  50. package/src/learn/content/assumption-investment-returns.ts +90 -0
  51. package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
  52. package/src/learn/content/assumption-recent-magi.ts +83 -0
  53. package/src/learn/content/assumption-social-security-cola.ts +89 -0
  54. package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
  55. package/src/learn/content/assumption-state-tax-override.ts +79 -0
  56. package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
  57. package/src/learn/content/break-even-useful-lens.ts +94 -0
  58. package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
  59. package/src/learn/content/cola-and-inflation-protection.ts +102 -0
  60. package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
  61. package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
  62. package/src/learn/content/earnings-test-before-fra.ts +100 -0
  63. package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
  64. package/src/learn/content/examplePlanArticles.ts +525 -0
  65. package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
  66. package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
  67. package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
  68. package/src/learn/content/funded-ratio.ts +70 -0
  69. package/src/learn/content/healthcare-after-65.ts +103 -0
  70. package/src/learn/content/healthcare-before-65.ts +104 -0
  71. package/src/learn/content/historical-vs-random-return-models.ts +101 -0
  72. package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
  73. package/src/learn/content/how-much-can-i-spend.ts +105 -0
  74. package/src/learn/content/how-social-security-is-taxed.ts +95 -0
  75. package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
  76. package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
  77. package/src/learn/content/how-to-model-accumulation.ts +67 -0
  78. package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
  79. package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
  80. package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
  81. package/src/learn/content/inflation-risk.ts +98 -0
  82. package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
  83. package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
  84. package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
  85. package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
  86. package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
  87. package/src/learn/content/longevity-risk.ts +99 -0
  88. package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
  89. package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
  90. package/src/learn/content/mortality-weighted-social-security.ts +113 -0
  91. package/src/learn/content/moving-to-retiregolden.ts +86 -0
  92. package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
  93. package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
  94. package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
  95. package/src/learn/content/pensions-and-annuities.ts +101 -0
  96. package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
  97. package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
  98. package/src/learn/content/planner-overview.ts +106 -0
  99. package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
  100. package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
  101. package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
  102. package/src/learn/content/reading-the-results-page.ts +96 -0
  103. package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
  104. package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
  105. package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
  106. package/src/learn/content/risk-based-guardrails.ts +100 -0
  107. package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
  108. package/src/learn/content/roth-conversion-basics.ts +104 -0
  109. package/src/learn/content/rsus-and-espp.ts +101 -0
  110. package/src/learn/content/rule-of-55-and-72t.ts +107 -0
  111. package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
  112. package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
  113. package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
  114. package/src/learn/content/sequence-of-returns-risk.ts +98 -0
  115. package/src/learn/content/social-security-bridge.ts +67 -0
  116. package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
  117. package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
  118. package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
  119. package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
  120. package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
  121. package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
  122. package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
  123. package/src/learn/content/step-up-in-basis.ts +102 -0
  124. package/src/learn/content/survivor-planning-for-couples.ts +110 -0
  125. package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
  126. package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
  127. package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
  128. package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
  129. package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
  130. package/src/learn/content/tips-ladders.ts +92 -0
  131. package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
  132. package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
  133. package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
  134. package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
  135. package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
  136. package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
  137. package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
  138. package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
  139. package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
  140. package/src/learn/content/what-is-fire.ts +65 -0
  141. package/src/learn/content/what-monte-carlo-proves.ts +98 -0
  142. package/src/learn/content/what-retiregolden-models.ts +103 -0
  143. package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
  144. package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
  145. package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
  146. package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
  147. package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
  148. package/src/learn/content/withdrawal-order-basics.ts +105 -0
  149. package/src/learn/glossary.ts +191 -0
  150. package/src/learn/inlineMarkdown.tsx +54 -0
  151. package/src/learn/learn.css +537 -0
  152. package/src/learn/learningRegistry.ts +502 -0
  153. package/src/longevity/LongevityResults.tsx +85 -0
  154. package/src/longevity/LongevityWizard.tsx +305 -0
  155. package/src/longevity/constants.ts +15 -0
  156. package/src/longevity/factors.ts +125 -0
  157. package/src/longevity/model.ts +31 -0
  158. package/src/longevity/persistedGuard.ts +129 -0
  159. package/src/longevity/storage.ts +40 -0
  160. package/src/mc/messages.ts +118 -0
  161. package/src/mc/monteCarlo.worker.ts +44 -0
  162. package/src/mc/pool.ts +267 -0
  163. package/src/mc/runRequest.ts +125 -0
  164. package/src/optimize/messages.ts +84 -0
  165. package/src/optimize/optimize.worker.ts +29 -0
  166. package/src/optimize/runOptimize.ts +92 -0
  167. package/src/optimize/runSpendingSolve.ts +47 -0
  168. package/src/optimize/runner.ts +21 -0
  169. package/src/optimize/spendingMessages.ts +44 -0
  170. package/src/optimize/spendingRunner.ts +21 -0
  171. package/src/optimize/spendingSolve.worker.ts +18 -0
  172. package/src/planner/AssumptionsCardPage.tsx +136 -0
  173. package/src/planner/BucketLensCard.tsx +114 -0
  174. package/src/planner/ComparePlansPage.tsx +219 -0
  175. package/src/planner/DisclaimerPage.tsx +88 -0
  176. package/src/planner/HowTestedPage.tsx +159 -0
  177. package/src/planner/LiveStatus.tsx +15 -0
  178. package/src/planner/LongevityModal.tsx +55 -0
  179. package/src/planner/Modal.tsx +97 -0
  180. package/src/planner/MonteCarloPage.tsx +907 -0
  181. package/src/planner/OptimizePage.tsx +611 -0
  182. package/src/planner/PlanContext.tsx +198 -0
  183. package/src/planner/PlanPickerPage.tsx +124 -0
  184. package/src/planner/PlanWorkspace.tsx +290 -0
  185. package/src/planner/ProvenancePanel.tsx +45 -0
  186. package/src/planner/RelocationComparePage.tsx +485 -0
  187. package/src/planner/ReportPage.tsx +375 -0
  188. package/src/planner/ResultsPage.tsx +817 -0
  189. package/src/planner/ScenariosPage.tsx +285 -0
  190. package/src/planner/SocialSecuritySection.tsx +556 -0
  191. package/src/planner/SpendingSolverPage.tsx +512 -0
  192. package/src/planner/SsAnalysisPage.tsx +1134 -0
  193. package/src/planner/SurvivalPercentileModal.tsx +161 -0
  194. package/src/planner/SurvivorTransitionPage.tsx +286 -0
  195. package/src/planner/assumptionsExport.ts +371 -0
  196. package/src/planner/bucketLens.ts +89 -0
  197. package/src/planner/chartFrame.ts +8 -0
  198. package/src/planner/chartStyle.ts +11 -0
  199. package/src/planner/dialogViews.tsx +184 -0
  200. package/src/planner/dialogs.tsx +133 -0
  201. package/src/planner/examples/ExampleLibrary.tsx +189 -0
  202. package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
  203. package/src/planner/examples/ExamplesPage.tsx +25 -0
  204. package/src/planner/examples/OpenExampleButton.tsx +61 -0
  205. package/src/planner/examples/buildAggressiveSaver.ts +102 -0
  206. package/src/planner/examples/buildAnnuityEstate.ts +137 -0
  207. package/src/planner/examples/buildBaristaFire.ts +115 -0
  208. package/src/planner/examples/buildBracketFillRoth.ts +65 -0
  209. package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
  210. package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
  211. package/src/planner/examples/buildCoastFire.ts +88 -0
  212. package/src/planner/examples/buildContext.ts +20 -0
  213. package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
  214. package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
  215. package/src/planner/examples/buildExampleCouple.ts +103 -0
  216. package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
  217. package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
  218. package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
  219. package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
  220. package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
  221. package/src/planner/examples/buildLeanFatFire.ts +109 -0
  222. package/src/planner/examples/buildLtcShock.ts +62 -0
  223. package/src/planner/examples/buildMovingStateTax.ts +53 -0
  224. package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
  225. package/src/planner/examples/buildRmdIrmaa.ts +55 -0
  226. package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
  227. package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
  228. package/src/planner/examples/buildSurvivorYears.ts +62 -0
  229. package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
  230. package/src/planner/examples/exampleCopy.ts +23 -0
  231. package/src/planner/examples/loadExample.ts +90 -0
  232. package/src/planner/examples/registry.ts +313 -0
  233. package/src/planner/explainPanels.tsx +233 -0
  234. package/src/planner/fields.tsx +381 -0
  235. package/src/planner/format.ts +33 -0
  236. package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
  237. package/src/planner/home/GettingStartedPaths.tsx +46 -0
  238. package/src/planner/home/GettingStartedReopener.tsx +32 -0
  239. package/src/planner/home/StartHereLinks.tsx +22 -0
  240. package/src/planner/home/WelcomeHero.tsx +39 -0
  241. package/src/planner/home/YourPlans.tsx +72 -0
  242. package/src/planner/home/importErrorMessage.ts +22 -0
  243. package/src/planner/home/startHereSlugs.ts +7 -0
  244. package/src/planner/home/useHomeData.ts +190 -0
  245. package/src/planner/home/useHomeMode.ts +47 -0
  246. package/src/planner/householdActions.ts +22 -0
  247. package/src/planner/insights/InsightCardView.tsx +340 -0
  248. package/src/planner/insights/InsightsPage.tsx +204 -0
  249. package/src/planner/insights/categoryLabels.ts +11 -0
  250. package/src/planner/learnLinks.ts +85 -0
  251. package/src/planner/marketModelPicker.ts +172 -0
  252. package/src/planner/optimizePageChart.ts +40 -0
  253. package/src/planner/optimizePageClaim.ts +64 -0
  254. package/src/planner/planCompleteness.ts +27 -0
  255. package/src/planner/planContextCore.ts +26 -0
  256. package/src/planner/planner.css +2304 -0
  257. package/src/planner/provenanceLinks.ts +25 -0
  258. package/src/planner/sections/AccountFields.tsx +872 -0
  259. package/src/planner/sections/AccountsSection.tsx +89 -0
  260. package/src/planner/sections/AllocationPanel.tsx +261 -0
  261. package/src/planner/sections/AssumptionsSection.tsx +256 -0
  262. package/src/planner/sections/HouseholdSection.tsx +243 -0
  263. package/src/planner/sections/IncomeFloorSection.tsx +418 -0
  264. package/src/planner/sections/IncomeSection.tsx +170 -0
  265. package/src/planner/sections/InsuranceSection.tsx +362 -0
  266. package/src/planner/sections/SpendingSection.tsx +904 -0
  267. package/src/planner/sections/StrategySection.tsx +349 -0
  268. package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
  269. package/src/planner/sections/sectionHelpers.ts +48 -0
  270. package/src/planner/sections/shared.tsx +15 -0
  271. package/src/planner/sections.tsx +15 -0
  272. package/src/planner/ssAnalysis.ts +325 -0
  273. package/src/planner/successBand.ts +20 -0
  274. package/src/planner/survivorAnalysis.ts +277 -0
  275. package/src/planner/usStates.ts +19 -0
  276. package/src/planner/useMcSuccessRate.ts +77 -0
  277. package/src/planner/useProjection.ts +63 -0
  278. package/src/relocation/messages.ts +21 -0
  279. package/src/relocation/relocation.worker.ts +18 -0
  280. package/src/relocation/runRelocation.ts +17 -0
  281. package/src/relocation/runner.ts +22 -0
  282. package/src/report/brandingContext.ts +15 -0
  283. package/src/report/downloadReport.ts +34 -0
  284. package/src/report/reportHtml.ts +547 -0
  285. package/src/routes/LearnRoutes.tsx +46 -0
  286. package/src/routes/PlanRoutes.tsx +55 -0
  287. package/src/routes/RouteFallback.tsx +9 -0
  288. package/src/socialSecurity/breakEven.ts +107 -0
  289. package/src/socialSecurity/expectedPv.ts +164 -0
  290. package/src/socialSecurity/explain.ts +92 -0
  291. package/src/socialSecurity/ficaReturn.ts +81 -0
  292. package/src/socialSecurity/persistedSsGuard.ts +138 -0
  293. package/src/socialSecurity/ssFormUtils.ts +48 -0
  294. package/src/socialSecurity/ssaStatementXml.ts +156 -0
  295. package/src/socialSecurity/storage.ts +69 -0
  296. package/src/socialSecurity/survivorSwitching.ts +153 -0
  297. package/src/testSupport/samplePlan.ts +2 -0
  298. package/src/workers/run.ts +45 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Assumptions snapshot: every live assumption behind a plan's projections —
3
+ * economy, per-account returns/allocations, longevity, law toggles, and the
4
+ * parameter-pack provenance — each tagged with where it comes from (user-set,
5
+ * app default, or a published source). Feeds the "Your assumptions" card and
6
+ * its copy-export, whose purpose is answering "why do tools disagree?": hand
7
+ * the exported assumptions to another tool (or a human) to see exactly where
8
+ * the inputs differ.
9
+ *
10
+ * Pure module (no React) so the snapshot and both export renderings are unit
11
+ * testable, including the JSON round-trip guarantee. Scope: the export carries
12
+ * the *assumptions* (what this card shows) — incomes, expenses, goals, and the
13
+ * rest of the plan's inputs travel in the full plan backup, not here.
14
+ */
15
+
16
+ import {
17
+ accountAllocation,
18
+ expectedAccountReturnPct,
19
+ resolveAssetClassParams,
20
+ DEFAULT_ASSET_CLASS_PARAMS,
21
+ } from '@retiregolden/engine/allocation/assetClasses'
22
+ import { createEmptyPlan, ASSET_CLASS_IDS, type Account, type Plan } from '@retiregolden/engine/model/plan'
23
+ import {
24
+ LATEST_PACK_YEAR,
25
+ PARAMETER_DATA_AS_OF,
26
+ PARAMETER_PROVENANCE,
27
+ } from '@retiregolden/engine/params'
28
+ import { fmtMoney } from './format'
29
+
30
+ /** Where a shown value comes from. */
31
+ export type AssumptionProvenance = 'user-set' | 'app-default' | 'published-source'
32
+
33
+ export interface AssumptionRow {
34
+ /** Stable id, unique within its group. */
35
+ id: string
36
+ label: string
37
+ /** Human-readable value as shown on the card. */
38
+ value: string
39
+ provenance: AssumptionProvenance
40
+ /** `PARAMETER_PROVENANCE` entry id when a citable source backs the row. */
41
+ sourceId?: string
42
+ }
43
+
44
+ export interface AssumptionGroup {
45
+ id: string
46
+ label: string
47
+ rows: AssumptionRow[]
48
+ }
49
+
50
+ export interface AssumptionsSnapshot {
51
+ planName: string
52
+ /** Tax parameter-pack vintage the engine applies. */
53
+ packYear: number
54
+ dataAsOf: string
55
+ groups: AssumptionGroup[]
56
+ /**
57
+ * Machine-readable section: the raw assumptions object plus the per-account
58
+ * return model. Round-trips: `JSON.parse` of the JSON export returns these
59
+ * values exactly as the plan holds them. This is the card's contents, not a
60
+ * full plan export — replicating Results/Monte Carlo/optimizer numbers also
61
+ * needs the incomes, expenses, and goals from a plan backup.
62
+ */
63
+ machine: {
64
+ assumptions: Plan['assumptions']
65
+ household: {
66
+ filingStatus: Plan['household']['filingStatus']
67
+ state: string
68
+ stateMoves: Plan['household']['stateMoves']
69
+ people: { name: string; dob: string; retirementAge: number | null; planningAge: number; longevitySource: string }[]
70
+ }
71
+ accounts: {
72
+ name: string
73
+ type: Account['type']
74
+ balance: number
75
+ annualReturnPct: number | null
76
+ allocation: unknown
77
+ }[]
78
+ strategies: Plan['strategies']
79
+ }
80
+ }
81
+
82
+ const PROVENANCE_LABEL: Record<AssumptionProvenance, string> = {
83
+ 'user-set': 'you set this',
84
+ 'app-default': 'app default',
85
+ 'published-source': 'published source',
86
+ }
87
+
88
+ /** Canonical app defaults, taken from the same factory that seeds new plans. */
89
+ function defaultAssumptions(): Plan['assumptions'] {
90
+ return createEmptyPlan({ newId: () => 'defaults' }).assumptions
91
+ }
92
+
93
+ const pct = (v: number) => `${v}%`
94
+
95
+ function userOrDefault(current: unknown, fallback: unknown): AssumptionProvenance {
96
+ return JSON.stringify(current) === JSON.stringify(fallback) ? 'app-default' : 'user-set'
97
+ }
98
+
99
+ function economyGroup(plan: Plan, defaults: Plan['assumptions']): AssumptionGroup {
100
+ const a = plan.assumptions
101
+ return {
102
+ id: 'economy',
103
+ label: 'Economy & rates',
104
+ rows: [
105
+ { id: 'inflation', label: 'General inflation', value: `${pct(a.inflationPct)}/yr`, provenance: userOrDefault(a.inflationPct, defaults.inflationPct) },
106
+ { id: 'healthcare-inflation', label: 'Healthcare extra inflation', value: `+${pct(a.healthcareExtraInflationPct)}/yr on top of general`, provenance: userOrDefault(a.healthcareExtraInflationPct, defaults.healthcareExtraInflationPct) },
107
+ { id: 'default-return', label: 'Default nominal return', value: `${pct(a.defaultReturnPct)}/yr (accounts without their own)`, provenance: userOrDefault(a.defaultReturnPct, defaults.defaultReturnPct) },
108
+ { id: 'swr', label: 'Safe withdrawal rate (FI lens)', value: `${pct(a.safeWithdrawalRatePct ?? 4)}/yr`, provenance: userOrDefault(a.safeWithdrawalRatePct ?? 4, defaults.safeWithdrawalRatePct ?? 4) },
109
+ ],
110
+ }
111
+ }
112
+
113
+ /** Return-bearing account types the card enumerates (income streams, property, and debt live elsewhere). */
114
+ const INVESTABLE_TYPE_LABEL = {
115
+ cash: 'Cash',
116
+ taxable: 'Taxable brokerage',
117
+ equityComp: 'Equity comp',
118
+ traditional: 'Traditional',
119
+ roth: 'Roth',
120
+ hsa: 'HSA',
121
+ } as const
122
+
123
+ type InvestableAccount = Extract<Account, { type: keyof typeof INVESTABLE_TYPE_LABEL }>
124
+
125
+ function investableAccounts(plan: Plan): InvestableAccount[] {
126
+ return plan.accounts.filter((acct): acct is InvestableAccount => acct.type in INVESTABLE_TYPE_LABEL)
127
+ }
128
+
129
+ function allocationSummary(account: Account): string | null {
130
+ const policy = accountAllocation(account)
131
+ if (!policy) return null
132
+ switch (policy.mode) {
133
+ case 'static': {
134
+ const parts = ASSET_CLASS_IDS.filter((id) => policy.weights[id] > 0).map(
135
+ (id) => `${policy.weights[id]}% ${DEFAULT_ASSET_CLASS_PARAMS[id].label.toLowerCase()}`,
136
+ )
137
+ return `static ${parts.join(' / ')}${policy.rebalancing === 'annual' ? ', rebalanced annually' : ''}`
138
+ }
139
+ case 'linear':
140
+ return `linear glidepath ${policy.startYear}–${policy.endYear}`
141
+ case 'staged':
142
+ return `staged glidepath (${policy.stages.length} stages)`
143
+ case 'custom':
144
+ return `custom glidepath (${policy.targets.length} targets)`
145
+ }
146
+ }
147
+
148
+ function accountsGroup(plan: Plan, startYear: number): AssumptionGroup {
149
+ const rows: AssumptionRow[] = investableAccounts(plan).map((acct, i) => {
150
+ const allocation = allocationSummary(acct)
151
+ const own = acct.annualReturnPct
152
+ const returnPct = expectedAccountReturnPct(acct, plan.assumptions, startYear)
153
+ const value = allocation
154
+ ? `${pct(Math.round(returnPct * 100) / 100)}/yr blended — ${allocation}`
155
+ : `${pct(returnPct)}/yr${own === null ? ' (plan default)' : ''}`
156
+ return {
157
+ id: `account-${i}`,
158
+ label: `${acct.name} (${INVESTABLE_TYPE_LABEL[acct.type]}, ${fmtMoney(acct.balance)})`,
159
+ value,
160
+ provenance: (allocation || own !== null ? 'user-set' : 'app-default') as AssumptionProvenance,
161
+ }
162
+ })
163
+ return { id: 'accounts', label: 'Account returns', rows }
164
+ }
165
+
166
+ function assetClassGroup(plan: Plan): AssumptionGroup | null {
167
+ const usesAllocation = plan.accounts.some((acct) => accountAllocation(acct) !== undefined)
168
+ if (!usesAllocation && plan.assumptions.assetClassParams === undefined) return null
169
+ const resolved = resolveAssetClassParams(plan.assumptions.assetClassParams)
170
+ return {
171
+ id: 'asset-classes',
172
+ label: 'Asset-class parameters',
173
+ rows: ASSET_CLASS_IDS.map((id) => {
174
+ const p = resolved[id]
175
+ const overridden = plan.assumptions.assetClassParams?.[id] !== undefined
176
+ return {
177
+ id,
178
+ label: p.label,
179
+ value: `${pct(p.returnPct)}/yr return · ${pct(p.volatilityPct)} volatility · yields ${pct(p.interestYieldPct)} interest / ${pct(p.dividendYieldPct)} dividends (${p.qualifiedRatioPct}% qualified)`,
180
+ provenance: (overridden ? 'user-set' : 'published-source') as AssumptionProvenance,
181
+ }
182
+ }),
183
+ }
184
+ }
185
+
186
+ function longevityGroup(plan: Plan): AssumptionGroup {
187
+ return {
188
+ id: 'longevity',
189
+ label: 'Household & longevity',
190
+ rows: [
191
+ {
192
+ id: 'filing',
193
+ label: 'Filing status & state',
194
+ value: `${plan.household.filingStatus === 'marriedFilingJointly' ? 'Married filing jointly' : 'Single'} · ${plan.household.state}${plan.household.stateMoves.length > 0 ? ` (moves: ${plan.household.stateMoves.map((m) => `${m.state} in ${m.fromYear}`).join(', ')})` : ''}`,
195
+ provenance: 'user-set',
196
+ },
197
+ ...plan.household.people.map((p, i): AssumptionRow => ({
198
+ id: `person-${i}`,
199
+ label: `${p.name} — retirement & planning age`,
200
+ value: `${p.retirementAge !== null ? `retires at ${p.retirementAge}, ` : ''}plan runs to age ${p.longevity.planningAge}${p.longevity.source === 'percentile' && p.longevity.percentile ? ` (${p.longevity.percentile.pct}% survival percentile${p.longevity.percentile.joint ? ', joint' : ''})` : ''}`,
201
+ // 'model' and 'percentile' both derive from the SSA period table.
202
+ provenance: p.longevity.source === 'manual' ? 'user-set' : 'published-source',
203
+ sourceId: undefined,
204
+ })),
205
+ ],
206
+ }
207
+ }
208
+
209
+ function lawTogglesGroup(plan: Plan, defaults: Plan['assumptions']): AssumptionGroup {
210
+ const a = plan.assumptions
211
+ const heirByClass = a.heirTaxByClass
212
+ return {
213
+ id: 'law-toggles',
214
+ label: 'Law & policy toggles',
215
+ rows: [
216
+ {
217
+ id: 'ss-cola',
218
+ label: 'Social Security COLA',
219
+ value: a.ssCola.mode === 'matchInflation' ? 'matches general inflation' : `fixed ${pct(a.ssCola.annualPct)}/yr`,
220
+ provenance: userOrDefault(a.ssCola, defaults.ssCola),
221
+ sourceId: 'social-security',
222
+ },
223
+ {
224
+ id: 'ss-haircut',
225
+ label: 'Social Security trust-fund cut',
226
+ value: a.ssHaircut ? `${pct(a.ssHaircut.cutPct)} cut from ${a.ssHaircut.fromYear}` : 'not modeled (scheduled benefits)',
227
+ provenance: userOrDefault(a.ssHaircut, defaults.ssHaircut),
228
+ sourceId: 'social-security',
229
+ },
230
+ {
231
+ id: 'state-tax',
232
+ label: 'State income tax',
233
+ value: a.stateEffectiveTaxPct > 0 ? `flat ${pct(a.stateEffectiveTaxPct)} override (modeled brackets bypassed)` : `modeled ${plan.household.state} brackets`,
234
+ provenance: a.stateEffectiveTaxPct > 0 ? 'user-set' : 'published-source',
235
+ sourceId: 'state-income-tax',
236
+ },
237
+ {
238
+ id: 'local-tax',
239
+ label: 'Local income tax',
240
+ value: a.localIncomeTaxPct > 0 ? `flat ${pct(a.localIncomeTaxPct)}` : 'none',
241
+ provenance: userOrDefault(a.localIncomeTaxPct, defaults.localIncomeTaxPct),
242
+ },
243
+ {
244
+ id: 'recent-magi',
245
+ label: 'Recent annual MAGI (IRMAA lookback seed)',
246
+ value: a.recentAnnualMagi > 0 ? fmtMoney(a.recentAnnualMagi) : 'not set',
247
+ provenance: userOrDefault(a.recentAnnualMagi, defaults.recentAnnualMagi),
248
+ sourceId: 'medicare-irmaa',
249
+ },
250
+ {
251
+ id: 'heir-tax',
252
+ label: 'Heir tax on inherited pre-tax balances',
253
+ value: heirByClass
254
+ ? `traditional ${pct(heirByClass.traditional ?? a.heirTaxRatePct)} / HSA ${pct(heirByClass.hsa ?? a.heirTaxRatePct)}`
255
+ : pct(a.heirTaxRatePct),
256
+ provenance: heirByClass ? 'user-set' : userOrDefault(a.heirTaxRatePct, defaults.heirTaxRatePct),
257
+ },
258
+ ],
259
+ }
260
+ }
261
+
262
+ function strategyGroup(plan: Plan): AssumptionGroup {
263
+ const rc = plan.strategies.rothConversion
264
+ const conversionValue =
265
+ rc.mode === 'none'
266
+ ? 'none'
267
+ : rc.mode === 'manual' || rc.mode === 'optimized'
268
+ ? `${rc.mode} schedule (${rc.conversions.length} year${rc.conversions.length === 1 ? '' : 's'}, ${fmtMoney(rc.conversions.reduce((s, c) => s + c.amount, 0))} total)`
269
+ : `fill to ${rc.target} through ${rc.endYear}`
270
+ return {
271
+ id: 'strategy',
272
+ label: 'Strategy settings',
273
+ rows: [
274
+ {
275
+ id: 'withdrawal-order',
276
+ label: 'Withdrawal order',
277
+ value: plan.strategies.withdrawalOrder.mode,
278
+ provenance: plan.strategies.withdrawalOrder.mode === 'sequential' ? 'app-default' : 'user-set',
279
+ },
280
+ { id: 'roth-conversion', label: 'Roth conversions', value: conversionValue, provenance: rc.mode === 'none' ? 'app-default' : 'user-set' },
281
+ { id: 'qcd', label: 'Qualified charitable distributions', value: plan.strategies.qcdAnnual > 0 ? `${fmtMoney(plan.strategies.qcdAnnual)}/yr` : 'none', provenance: plan.strategies.qcdAnnual > 0 ? 'user-set' : 'app-default' },
282
+ ],
283
+ }
284
+ }
285
+
286
+ function taxParametersGroup(): AssumptionGroup {
287
+ return {
288
+ id: 'tax-parameters',
289
+ label: `Tax & benefit parameters (${LATEST_PACK_YEAR} pack)`,
290
+ rows: PARAMETER_PROVENANCE.map((s) => ({
291
+ id: s.id,
292
+ label: s.label,
293
+ value: s.figures,
294
+ provenance: 'published-source' as AssumptionProvenance,
295
+ sourceId: s.id,
296
+ })),
297
+ }
298
+ }
299
+
300
+ export function buildAssumptionsSnapshot(plan: Plan, startYear: number): AssumptionsSnapshot {
301
+ const defaults = defaultAssumptions()
302
+ const groups = [
303
+ economyGroup(plan, defaults),
304
+ accountsGroup(plan, startYear),
305
+ assetClassGroup(plan),
306
+ longevityGroup(plan),
307
+ lawTogglesGroup(plan, defaults),
308
+ strategyGroup(plan),
309
+ taxParametersGroup(),
310
+ ].filter((g): g is AssumptionGroup => g !== null && g.rows.length > 0)
311
+
312
+ return {
313
+ planName: plan.name,
314
+ packYear: LATEST_PACK_YEAR,
315
+ dataAsOf: PARAMETER_DATA_AS_OF,
316
+ groups,
317
+ machine: {
318
+ assumptions: plan.assumptions,
319
+ household: {
320
+ filingStatus: plan.household.filingStatus,
321
+ state: plan.household.state,
322
+ stateMoves: plan.household.stateMoves,
323
+ people: plan.household.people.map((p) => ({
324
+ name: p.name,
325
+ dob: p.dob,
326
+ retirementAge: p.retirementAge,
327
+ planningAge: p.longevity.planningAge,
328
+ longevitySource: p.longevity.source,
329
+ })),
330
+ },
331
+ accounts: investableAccounts(plan).map((acct) => ({
332
+ name: acct.name,
333
+ type: acct.type,
334
+ balance: acct.balance,
335
+ annualReturnPct: acct.annualReturnPct,
336
+ allocation: accountAllocation(acct) ?? null,
337
+ })),
338
+ strategies: plan.strategies,
339
+ },
340
+ }
341
+ }
342
+
343
+ /** Human-readable export: every card row, grouped, with provenance tags and sources. */
344
+ export function assumptionsExportText(snapshot: AssumptionsSnapshot): string {
345
+ const sourceById = new Map(PARAMETER_PROVENANCE.map((s) => [s.id, s]))
346
+ const lines: string[] = [
347
+ `RetireGolden assumptions — ${snapshot.planName}`,
348
+ `Tax parameters: ${snapshot.packYear} pack, compiled ${snapshot.dataAsOf}.`,
349
+ '',
350
+ ]
351
+ for (const group of snapshot.groups) {
352
+ lines.push(`## ${group.label}`)
353
+ for (const row of group.rows) {
354
+ const source = row.sourceId ? sourceById.get(row.sourceId) : undefined
355
+ const sourceNote = source ? ` [${source.publisher}: ${source.url}]` : ''
356
+ lines.push(`- ${row.label}: ${row.value} (${PROVENANCE_LABEL[row.provenance]})${sourceNote}`)
357
+ }
358
+ lines.push('')
359
+ }
360
+ lines.push(
361
+ 'Replication notes: deterministic results apply these returns and inflation every year;',
362
+ 'Monte Carlo varies markets around them (model, path count, and seed are chosen on the',
363
+ 'Monte Carlo page). Success = investable assets never deplete before the end of the plan.',
364
+ )
365
+ return lines.join('\n')
366
+ }
367
+
368
+ /** Machine-readable export; parses back to the exact values the plan holds. */
369
+ export function assumptionsExportJson(snapshot: AssumptionsSnapshot): string {
370
+ return JSON.stringify(snapshot, null, 2)
371
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Bucket reporting lens (spending-paths & SWR-lenses plan, Goal 5).
3
+ *
4
+ * Buckets are hugely popular and academically shaky: Estrada's bucket studies
5
+ * and Kitces' reviews find no systematic benefit over a total-return portfolio
6
+ * with rebalancing — the comfort is real, the mechanism mostly isn't. The
7
+ * honest version this lens implements: keep investing (and simulating) total
8
+ * return, and *report* the ledger's balances as buckets — "years 1–2 of net
9
+ * spending", "years 3–10", "the rest" — purely as a reading of the same
10
+ * numbers. Nothing here feeds back into the engine; it is presentation only.
11
+ *
12
+ * Mapping: for each projection year, the net portfolio need of a future year
13
+ * is its total spending (including taxes and penalties) minus all income that
14
+ * year, floored at 0 — the dollars that must come from the portfolio. Bucket k
15
+ * claims the (projected, nominal) need of the next `spans[k]` years,
16
+ * cumulatively, capped by what is actually left; the final bucket is the
17
+ * remainder. Buckets therefore reconcile to the ledger's investable total by
18
+ * construction, every year — the acceptance criterion.
19
+ */
20
+
21
+ import type { ProjectionResult, YearResult } from '@retiregolden/engine/projection/types'
22
+
23
+ export interface BucketYearRow {
24
+ year: number
25
+ /** This year's net portfolio need (spending + tax + penalties − income, ≥ 0). */
26
+ need: number
27
+ /** One balance per bucket; sums exactly to `investableTotal`. */
28
+ buckets: number[]
29
+ investableTotal: number
30
+ }
31
+
32
+ /** Net dollars year `y` must draw from the portfolio (nominal, floored at 0). */
33
+ export function netPortfolioNeed(y: YearResult): number {
34
+ return Math.max(0, y.expenses.total + y.tax + y.penalties - y.incomes.total)
35
+ }
36
+
37
+ /**
38
+ * Partition every year's investable total into `spans.length + 1` buckets:
39
+ * bucket k holds the projected need of the next `spans[k]` years (starting
40
+ * with the current year, cumulatively after earlier buckets), the last bucket
41
+ * holds the remainder. Needs beyond the projection horizon are unknown and
42
+ * count as 0 — near the end of the plan the leading buckets naturally drain.
43
+ */
44
+ export function bucketLens(result: ProjectionResult, spans: number[]): BucketYearRow[] {
45
+ const years = result.years
46
+ const needs = years.map(netPortfolioNeed)
47
+ return years.map((y, i) => {
48
+ let remaining = y.investableTotal
49
+ const buckets: number[] = []
50
+ let cursor = i
51
+ for (const span of spans) {
52
+ let bucketNeed = 0
53
+ for (let k = 0; k < span; k++) {
54
+ if (cursor + k >= needs.length) break
55
+ bucketNeed += needs[cursor + k]!
56
+ }
57
+ cursor += span
58
+ const claimed = Math.min(remaining, bucketNeed)
59
+ buckets.push(claimed)
60
+ remaining -= claimed
61
+ }
62
+ buckets.push(remaining)
63
+ return { year: y.year, need: needs[i]!, buckets, investableTotal: y.investableTotal }
64
+ })
65
+ }
66
+
67
+ export interface BucketPreset {
68
+ id: 'three' | 'two'
69
+ label: string
70
+ /** Year spans of the leading buckets; the growth bucket is the remainder. */
71
+ spans: number[]
72
+ bucketLabels: string[]
73
+ }
74
+
75
+ /** The two classic constructions the community actually uses. */
76
+ export const BUCKET_PRESETS: readonly BucketPreset[] = [
77
+ {
78
+ id: 'three',
79
+ label: '3 buckets (2 yrs / 8 yrs / growth)',
80
+ spans: [2, 8],
81
+ bucketLabels: ['Bucket 1 — next 2 years of net spending', 'Bucket 2 — years 3–10', 'Bucket 3 — growth (the rest)'],
82
+ },
83
+ {
84
+ id: 'two',
85
+ label: '2 buckets (3 yrs / growth)',
86
+ spans: [3],
87
+ bucketLabels: ['Bucket 1 — next 3 years of net spending', 'Bucket 2 — growth (the rest)'],
88
+ },
89
+ ]
@@ -0,0 +1,8 @@
1
+ import type { CSSProperties } from 'react'
2
+
3
+ /**
4
+ * Height for a shared `.chart-frame` wrapper, passed via the custom property the
5
+ * class reads (UI/UX round 2, Step 5). Keeps the reusable width:100% frame in
6
+ * CSS while the per-chart height stays a value token, not an inline style block.
7
+ */
8
+ export const frameH = (px: number): CSSProperties => ({ ['--chart-frame-h']: `${px}px` }) as CSSProperties
@@ -0,0 +1,11 @@
1
+ /** Shared recharts tooltip surface, matching the app's card styling. */
2
+
3
+ import type { CSSProperties } from 'react'
4
+
5
+ export const chartTooltipStyle: CSSProperties = {
6
+ backgroundColor: 'var(--surface-1)',
7
+ border: '1px solid var(--border)',
8
+ borderRadius: 8,
9
+ color: 'var(--fg)',
10
+ boxShadow: 'var(--shadow-card)',
11
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The dialog bodies used by useDialogs (./dialogs.tsx): confirm (with an
3
+ * optional typed-confirmation gate for unrecoverable actions), prompt, alert,
4
+ * and a multi-option choice — all rendered on the shared focus-managed Modal.
5
+ */
6
+
7
+ import { useId, useRef, useState, type ReactNode } from 'react'
8
+
9
+ import { Modal } from './Modal'
10
+
11
+ export interface ConfirmOptions {
12
+ title: string
13
+ body: ReactNode
14
+ confirmLabel?: string
15
+ cancelLabel?: string
16
+ /** Style the confirm button as destructive. */
17
+ danger?: boolean
18
+ /**
19
+ * Require typing this word (case-insensitive) before the confirm button
20
+ * enables — for unrecoverable actions like clearing all local data.
21
+ */
22
+ typedConfirmation?: string
23
+ /** Optional side action shown with the buttons (e.g. "Download backup"). Does not close the dialog. */
24
+ extraAction?: { label: string; onClick: () => void }
25
+ }
26
+
27
+ export interface PromptOptions {
28
+ title: string
29
+ label: string
30
+ defaultValue?: string
31
+ confirmLabel?: string
32
+ cancelLabel?: string
33
+ }
34
+
35
+ export interface AlertOptions {
36
+ title: string
37
+ body: ReactNode
38
+ closeLabel?: string
39
+ }
40
+
41
+ export interface ChoiceOption<T extends string> {
42
+ value: T
43
+ label: string
44
+ /** Explanatory line under the button label. */
45
+ description?: string
46
+ }
47
+
48
+ export interface ChoiceOptions<T extends string> {
49
+ title: string
50
+ body: ReactNode
51
+ choices: ReadonlyArray<ChoiceOption<T>>
52
+ }
53
+
54
+ export function ConfirmDialog({ opts, onResult }: { opts: ConfirmOptions; onResult: (ok: boolean) => void }) {
55
+ const [typed, setTyped] = useState('')
56
+ const inputId = useId()
57
+ const inputRef = useRef<HTMLInputElement>(null)
58
+ const gate = opts.typedConfirmation
59
+ const ready = !gate || typed.trim().toLowerCase() === gate.toLowerCase()
60
+ return (
61
+ <Modal title={opts.title} onClose={() => onResult(false)} initialFocus={gate ? inputRef : undefined}>
62
+ <div className="dialog-body">{opts.body}</div>
63
+ {gate ? (
64
+ <div className="field dialog-typed-field">
65
+ <label className="field-label" htmlFor={inputId}>
66
+ Type <strong>{gate}</strong> to confirm
67
+ </label>
68
+ <input
69
+ id={inputId}
70
+ ref={inputRef}
71
+ type="text"
72
+ value={typed}
73
+ autoComplete="off"
74
+ spellCheck={false}
75
+ onChange={(e) => setTyped(e.target.value)}
76
+ />
77
+ </div>
78
+ ) : null}
79
+ <div className="dialog-actions">
80
+ {opts.extraAction ? (
81
+ <button type="button" className="btn btn-secondary dialog-extra-action" onClick={opts.extraAction.onClick}>
82
+ {opts.extraAction.label}
83
+ </button>
84
+ ) : null}
85
+ <button type="button" className="btn btn-secondary" onClick={() => onResult(false)}>
86
+ {opts.cancelLabel ?? 'Cancel'}
87
+ </button>
88
+ <button
89
+ type="button"
90
+ className={opts.danger ? 'btn btn-secondary btn-danger' : 'btn btn-primary'}
91
+ disabled={!ready}
92
+ onClick={() => onResult(true)}
93
+ >
94
+ {opts.confirmLabel ?? 'OK'}
95
+ </button>
96
+ </div>
97
+ </Modal>
98
+ )
99
+ }
100
+
101
+ export function PromptDialog({ opts, onResult }: { opts: PromptOptions; onResult: (value: string | null) => void }) {
102
+ const [value, setValue] = useState(opts.defaultValue ?? '')
103
+ const inputId = useId()
104
+ const inputRef = useRef<HTMLInputElement>(null)
105
+ return (
106
+ <Modal title={opts.title} onClose={() => onResult(null)} initialFocus={inputRef}>
107
+ <form
108
+ onSubmit={(e) => {
109
+ e.preventDefault()
110
+ onResult(value)
111
+ }}
112
+ >
113
+ <div className="field">
114
+ <label className="field-label" htmlFor={inputId}>
115
+ {opts.label}
116
+ </label>
117
+ <input
118
+ id={inputId}
119
+ ref={inputRef}
120
+ type="text"
121
+ value={value}
122
+ autoComplete="off"
123
+ onChange={(e) => setValue(e.target.value)}
124
+ onFocus={(e) => e.target.select()}
125
+ />
126
+ </div>
127
+ <div className="dialog-actions">
128
+ <button type="button" className="btn btn-secondary" onClick={() => onResult(null)}>
129
+ {opts.cancelLabel ?? 'Cancel'}
130
+ </button>
131
+ <button type="submit" className="btn btn-primary">
132
+ {opts.confirmLabel ?? 'OK'}
133
+ </button>
134
+ </div>
135
+ </form>
136
+ </Modal>
137
+ )
138
+ }
139
+
140
+ export function AlertDialog({ opts, onResult }: { opts: AlertOptions; onResult: () => void }) {
141
+ return (
142
+ <Modal title={opts.title} onClose={onResult}>
143
+ <div className="dialog-body">{opts.body}</div>
144
+ <div className="dialog-actions">
145
+ <button type="button" className="btn btn-primary" onClick={onResult}>
146
+ {opts.closeLabel ?? 'Close'}
147
+ </button>
148
+ </div>
149
+ </Modal>
150
+ )
151
+ }
152
+
153
+ export function ChoiceDialog({
154
+ opts,
155
+ onResult,
156
+ }: {
157
+ opts: ChoiceOptions<string>
158
+ onResult: (value: string | null) => void
159
+ }) {
160
+ return (
161
+ <Modal title={opts.title} onClose={() => onResult(null)}>
162
+ <div className="dialog-body">{opts.body}</div>
163
+ <div className="dialog-choices">
164
+ {opts.choices.map((choice) => (
165
+ <button
166
+ key={choice.value}
167
+ type="button"
168
+ className="dialog-choice"
169
+ aria-label={choice.description ? `${choice.label} — ${choice.description}` : choice.label}
170
+ onClick={() => onResult(choice.value)}
171
+ >
172
+ <span className="dialog-choice-label">{choice.label}</span>
173
+ {choice.description ? <span className="dialog-choice-desc">{choice.description}</span> : null}
174
+ </button>
175
+ ))}
176
+ </div>
177
+ <div className="dialog-actions">
178
+ <button type="button" className="btn btn-secondary" onClick={() => onResult(null)}>
179
+ Cancel
180
+ </button>
181
+ </div>
182
+ </Modal>
183
+ )
184
+ }