@umituz/web-dashboard 5.0.1 → 6.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 (120) hide show
  1. package/package.json +19 -17
  2. package/src/domains/onboarding/calc/calc.test.ts +181 -0
  3. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +33 -0
  4. package/src/domains/onboarding/calc/count-with-locale.ts +16 -0
  5. package/src/domains/onboarding/calc/duration-equal-split.ts +24 -0
  6. package/src/domains/onboarding/calc/elapsed-millis.ts +12 -0
  7. package/src/domains/onboarding/calc/index.ts +36 -0
  8. package/src/domains/onboarding/calc/progress-percentage.ts +37 -0
  9. package/src/domains/onboarding/calc/slider-value-bounds.ts +44 -0
  10. package/src/domains/onboarding/calc/step-index-clamp.ts +28 -0
  11. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +67 -0
  12. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +481 -0
  13. package/src/domains/onboarding/components/OnboardingFlow.tsx +361 -0
  14. package/src/domains/onboarding/components/OnboardingIntl.tsx +108 -0
  15. package/src/domains/onboarding/components/hooks/index.ts +7 -0
  16. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.test.tsx +74 -0
  17. package/src/domains/onboarding/components/hooks/useOnboardingAnalytics.ts +62 -0
  18. package/src/domains/onboarding/components/hooks/useOnboardingProgress.test.tsx +150 -0
  19. package/src/domains/onboarding/components/hooks/useOnboardingProgress.ts +260 -0
  20. package/src/domains/onboarding/components/hooks/usePermissionPriming.ts +52 -0
  21. package/src/domains/onboarding/components/hooks/useReducedMotion.ts +24 -0
  22. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +89 -0
  23. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +39 -0
  24. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +59 -0
  25. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +56 -0
  26. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  27. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +138 -0
  28. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +113 -0
  29. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +53 -0
  30. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +194 -0
  31. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +79 -0
  32. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +116 -0
  33. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +108 -0
  34. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +85 -0
  35. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +52 -0
  36. package/src/domains/onboarding/components/screens/index.ts +28 -0
  37. package/src/domains/onboarding/components/shared/AnimationWrapper.tsx +73 -0
  38. package/src/domains/onboarding/components/shared/OnboardingErrorBoundary.tsx +81 -0
  39. package/src/domains/onboarding/components/shared/OptionCard.tsx +73 -0
  40. package/src/domains/onboarding/components/shared/PrimaryButton.tsx +58 -0
  41. package/src/domains/onboarding/components/shared/ProgressBar.tsx +51 -0
  42. package/src/domains/onboarding/components/shared/QuestionWrapper.tsx +36 -0
  43. package/src/domains/onboarding/components/shared/ScreenContainer.tsx +29 -0
  44. package/src/domains/onboarding/components/shared/SwipeCard.test.tsx +51 -0
  45. package/src/domains/onboarding/components/shared/SwipeCard.tsx +106 -0
  46. package/src/domains/onboarding/components/shared/index.ts +16 -0
  47. package/src/domains/onboarding/components/shared/renderBundledIcon.tsx +20 -0
  48. package/src/domains/onboarding/index.ts +182 -0
  49. package/src/domains/onboarding/onboarding.css +1 -0
  50. package/src/domains/onboarding/predicates/has-handler.ts +25 -0
  51. package/src/domains/onboarding/predicates/icon-name-resolves.ts +113 -0
  52. package/src/domains/onboarding/predicates/index.ts +26 -0
  53. package/src/domains/onboarding/predicates/permission-granted.ts +26 -0
  54. package/src/domains/onboarding/predicates/permission-supports.ts +66 -0
  55. package/src/domains/onboarding/predicates/predicates.test.ts +202 -0
  56. package/src/domains/onboarding/predicates/screen-emits-data.ts +17 -0
  57. package/src/domains/onboarding/predicates/storage-available.ts +22 -0
  58. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +112 -0
  59. package/src/domains/onboarding/templates/art-onboarding.ts +81 -0
  60. package/src/domains/onboarding/templates/health-onboarding.ts +97 -0
  61. package/src/domains/onboarding/templates/index.ts +6 -0
  62. package/src/domains/onboarding/templates/platform-onboarding.ts +210 -0
  63. package/src/domains/onboarding/templates/video-onboarding.ts +100 -0
  64. package/src/domains/onboarding/test/setup.ts +30 -0
  65. package/src/domains/onboarding/theme/cn.ts +10 -0
  66. package/src/domains/onboarding/theme/index.css +131 -0
  67. package/src/domains/onboarding/theme/index.ts +12 -0
  68. package/src/domains/onboarding/theme/tokens.ts +122 -0
  69. package/src/domains/onboarding/types/config.ts +68 -0
  70. package/src/domains/onboarding/types/index.ts +10 -0
  71. package/src/domains/onboarding/types/labels.ts +83 -0
  72. package/src/domains/onboarding/types/screens.ts +193 -0
  73. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  74. package/src/domains/onboarding/utils/analytics-dispatch.ts +92 -0
  75. package/src/domains/onboarding/utils/default-storage-key.ts +2 -0
  76. package/src/domains/onboarding/utils/index.ts +13 -0
  77. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  78. package/src/domains/onboarding/utils/local-storage-bridge.ts +104 -0
  79. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  80. package/src/domains/onboarding/utils/permission-bridge.ts +95 -0
  81. package/src/domains/onboarding/validation/flow-structure-check.ts +28 -0
  82. package/src/domains/onboarding/validation/index.ts +9 -0
  83. package/src/domains/onboarding/validation/permission-status-check.ts +23 -0
  84. package/src/domains/onboarding/validation/screen-config-check.ts +105 -0
  85. package/src/domains/onboarding/validation/stored-progress-check.ts +68 -0
  86. package/src/domains/onboarding/validation/validation.test.ts +175 -0
  87. package/src/index.ts +0 -10
  88. package/src/styles/dashboard.contract.test.ts +1 -0
  89. package/src/styles/dashboard.css +0 -460
  90. package/src/domains/auth/components/AuthLayout.tsx +0 -99
  91. package/src/domains/auth/components/ForgotPasswordForm.tsx +0 -167
  92. package/src/domains/auth/components/LoginForm.tsx +0 -222
  93. package/src/domains/auth/components/RegisterForm.tsx +0 -281
  94. package/src/domains/auth/components/ResetPasswordForm.tsx +0 -173
  95. package/src/domains/auth/components/index.ts +0 -11
  96. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +0 -25
  97. package/src/domains/auth/components/shared/EmailInput.tsx +0 -73
  98. package/src/domains/auth/components/shared/PasswordInput.tsx +0 -100
  99. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +0 -63
  100. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +0 -44
  101. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +0 -48
  102. package/src/domains/auth/components/shared/index.ts +0 -20
  103. package/src/domains/auth/hooks/index.ts +0 -8
  104. package/src/domains/auth/hooks/useAuth.ts +0 -230
  105. package/src/domains/auth/index.ts +0 -62
  106. package/src/domains/auth/types/auth.ts +0 -302
  107. package/src/domains/auth/types/index.ts +0 -25
  108. package/src/domains/auth/utils/auth.test.ts +0 -188
  109. package/src/domains/auth/utils/auth.ts +0 -221
  110. package/src/domains/auth/utils/i18nKeys.ts +0 -109
  111. package/src/domains/auth/utils/index.ts +0 -29
  112. package/src/domains/auth/utils/secureToken.ts +0 -33
  113. package/src/domains/auth/utils/validationKeys.ts +0 -23
  114. package/src/domains/session/DemoLogin.tsx +0 -42
  115. package/src/domains/session/OrgSwitcher.tsx +0 -102
  116. package/src/domains/session/SessionContext.tsx +0 -150
  117. package/src/domains/session/inMemoryAuthAdapter.ts +0 -53
  118. package/src/domains/session/index.ts +0 -18
  119. package/src/domains/session/types.ts +0 -47
  120. package/src/domains/session/utils/i18nKeys.ts +0 -12
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@umituz/web-dashboard",
3
- "version": "5.0.1",
4
- "description": "Installable app skeleton for the fleet — layouts, auth, analytics (dependency-free SVG charts), calendar, i18n via @umituz/web-localization, permissions/session/module registry; udb-* stylesheet on design-system tokens",
3
+ "version": "6.1.0",
4
+ "description": "Installable app skeleton for the fleet — layouts, analytics (dependency-free SVG charts), calendar, onboarding flow, i18n via @umituz/web-localization, permissions/module registry; udb-* stylesheet on design-system tokens (auth lives in @umituz/web-auth)",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
@@ -12,7 +12,6 @@
12
12
  ".": "./src/index.ts",
13
13
  "./styles.css": "./src/styles/dashboard.css",
14
14
  "./permissions": "./src/domains/permissions/index.ts",
15
- "./session": "./src/domains/session/index.ts",
16
15
  "./modules": "./src/domains/modules/index.ts",
17
16
  "./layouts": "./src/domains/layouts/index.ts",
18
17
  "./layouts/components": "./src/domains/layouts/components/index.ts",
@@ -25,11 +24,6 @@
25
24
  "./settings/hooks": "./src/domains/settings/hooks/index.ts",
26
25
  "./settings/utils": "./src/domains/settings/utils/index.ts",
27
26
  "./settings/types": "./src/domains/settings/types/index.ts",
28
- "./auth": "./src/domains/auth/index.ts",
29
- "./auth/components": "./src/domains/auth/components/index.ts",
30
- "./auth/hooks": "./src/domains/auth/hooks/index.ts",
31
- "./auth/utils": "./src/domains/auth/utils/index.ts",
32
- "./auth/types": "./src/domains/auth/types/index.ts",
33
27
  "./analytics": "./src/domains/analytics/index.ts",
34
28
  "./analytics/components": "./src/domains/analytics/components/index.ts",
35
29
  "./analytics/hooks": "./src/domains/analytics/hooks/index.ts",
@@ -43,7 +37,9 @@
43
37
  "./calendar/utils": "./src/domains/calendar/utils/index.ts",
44
38
  "./config": "./src/config/index.ts",
45
39
  "./package.json": "./package.json",
46
- "./charts": "./src/domains/charts/index.ts"
40
+ "./charts": "./src/domains/charts/index.ts",
41
+ "./onboarding": "./src/domains/onboarding/index.ts",
42
+ "./onboarding.css": "./src/domains/onboarding/onboarding.css"
47
43
  },
48
44
  "files": [
49
45
  "src",
@@ -61,12 +57,16 @@
61
57
  "peerDependencies": {
62
58
  "react": "^18.0.0 || ^19.0.0",
63
59
  "react-dom": "^18.0.0 || ^19.0.0",
64
- "react-router-dom": "^6.0.0 || ^7.0.0"
60
+ "react-router-dom": "^6.0.0 || ^7.0.0",
61
+ "framer-motion": "^12.0.0"
65
62
  },
66
63
  "dependencies": {
67
64
  "@umituz/web-design-system": "^4.8.1",
68
65
  "lucide-react": "^0.577.0",
69
- "@umituz/web-localization": "^1.2.1"
66
+ "@umituz/web-localization": "^1.2.1",
67
+ "@radix-ui/react-progress": "^1.1.7",
68
+ "clsx": "^2.1.1",
69
+ "tailwind-merge": "^3.5.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^22.20.5",
@@ -77,16 +77,15 @@
77
77
  "eslint": "^10.0.2",
78
78
  "react-router-dom": "^7.13.1",
79
79
  "typescript": "^5.9.3",
80
- "vitest": "^5.0.1"
80
+ "vitest": "^5.0.1",
81
+ "@testing-library/dom": "^10.4.2",
82
+ "@testing-library/react": "^16.3.3",
83
+ "jsdom": "^29.1.1"
81
84
  },
82
85
  "keywords": [
83
86
  "dashboard",
84
87
  "layout",
85
88
  "settings",
86
- "auth",
87
- "authentication",
88
- "login",
89
- "register",
90
89
  "analytics",
91
90
  "analytics-services",
92
91
  "cohort-analysis",
@@ -112,7 +111,10 @@
112
111
  "sidebar",
113
112
  "header",
114
113
  "config-driven",
115
- "package-driven"
114
+ "package-driven",
115
+ "onboarding",
116
+ "wizard",
117
+ "product-tour"
116
118
  ],
117
119
  "author": "umituz",
118
120
  "license": "MIT",
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ computeProgressFraction,
4
+ computeProgressPercentage,
5
+ computeRemainingSteps,
6
+ } from './progress-percentage';
7
+ import {
8
+ advanceStepIndex,
9
+ clampStepIndex,
10
+ retreatStepIndex,
11
+ } from './step-index-clamp';
12
+ import {
13
+ didCrossLeftSwipeThreshold,
14
+ didCrossRightSwipeThreshold,
15
+ SWIPE_DECISION_THRESHOLD_PX,
16
+ } from './swipe-gesture-curve';
17
+ import {
18
+ clampValueToBounds,
19
+ resolveSliderBounds,
20
+ areSliderBoundsValid,
21
+ } from './slider-value-bounds';
22
+ import {
23
+ getDefaultStepDurationMs,
24
+ splitTotalDuration,
25
+ } from './duration-equal-split';
26
+ import { elapsedMillis } from './elapsed-millis';
27
+ import { formatCountWithLocale } from './count-with-locale';
28
+ import {
29
+ getCardEnterAnimate,
30
+ getCardEnterExit,
31
+ getCardEnterInitial,
32
+ getCardTransitionDurationMs,
33
+ } from './card-stack-motion-curve';
34
+
35
+ // TEST_SCENARIOS.md §22
36
+ describe('progress-percentage (§22.1)', () => {
37
+ it('computes percentages without NaN or Infinity', () => {
38
+ expect(computeProgressPercentage(0, 5)).toBe(0);
39
+ expect(computeProgressPercentage(5, 5)).toBe(100);
40
+ expect(computeProgressPercentage(0, 0)).toBe(0);
41
+ expect(computeProgressPercentage(-1, 5)).toBe(0);
42
+ expect(computeProgressPercentage(10, 5)).toBe(100);
43
+ expect(Number.isFinite(computeProgressPercentage(3, 7))).toBe(true);
44
+ });
45
+
46
+ it('computes the 0..1 fraction', () => {
47
+ expect(computeProgressFraction(1, 4)).toBe(0.25);
48
+ expect(computeProgressFraction(0, 0)).toBe(0);
49
+ });
50
+
51
+ it('counts remaining steps including the current one', () => {
52
+ expect(computeRemainingSteps(0, 5)).toBe(5);
53
+ expect(computeRemainingSteps(3, 5)).toBe(2);
54
+ expect(computeRemainingSteps(9, 5)).toBe(0);
55
+ expect(computeRemainingSteps(0, 0)).toBe(0);
56
+ });
57
+ });
58
+
59
+ // TEST_SCENARIOS.md §22.2
60
+ describe('step-index-clamp (§22.2)', () => {
61
+ it('clamps arbitrary indices into range', () => {
62
+ expect(clampStepIndex(0, 5)).toBe(0);
63
+ expect(clampStepIndex(4, 5)).toBe(4);
64
+ expect(clampStepIndex(10, 5)).toBe(4);
65
+ expect(clampStepIndex(-1, 5)).toBe(0);
66
+ expect(clampStepIndex(0, 0)).toBe(0);
67
+ });
68
+
69
+ it('advances without going past the last step', () => {
70
+ expect(advanceStepIndex(3, 5)).toBe(4);
71
+ expect(advanceStepIndex(4, 5)).toBe(4);
72
+ expect(advanceStepIndex(0, 0)).toBe(0);
73
+ });
74
+
75
+ it('retreats without going below the first step', () => {
76
+ expect(retreatStepIndex(0)).toBe(0);
77
+ expect(retreatStepIndex(3)).toBe(2);
78
+ });
79
+ });
80
+
81
+ // TEST_SCENARIOS.md §22.3
82
+ describe('swipe-gesture-curve (§22.3)', () => {
83
+ it('registers right swipes only above the threshold', () => {
84
+ expect(didCrossRightSwipeThreshold(50)).toBe(false);
85
+ expect(didCrossRightSwipeThreshold(120)).toBe(false); // boundary is not "above"
86
+ expect(didCrossRightSwipeThreshold(150)).toBe(true);
87
+ });
88
+
89
+ it('registers left swipes only below the negative threshold', () => {
90
+ expect(didCrossLeftSwipeThreshold(-50)).toBe(false);
91
+ expect(didCrossLeftSwipeThreshold(-150)).toBe(true);
92
+ });
93
+
94
+ it('exposes the threshold as a single named constant', () => {
95
+ expect(SWIPE_DECISION_THRESHOLD_PX).toBe(120);
96
+ });
97
+ });
98
+
99
+ // TEST_SCENARIOS.md §22.4
100
+ describe('slider-value-bounds (§22.4)', () => {
101
+ it('clamps values into bounds', () => {
102
+ expect(clampValueToBounds(50, 0, 100, 'x')).toBe(50);
103
+ expect(clampValueToBounds(-5, 0, 100, 'x')).toBe(0);
104
+ expect(clampValueToBounds(200, 0, 100, 'x')).toBe(100);
105
+ });
106
+
107
+ it('surfaces min > max misconfigurations via console.error, value as-is', () => {
108
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
109
+ try {
110
+ expect(clampValueToBounds(50, 100, 10, 'mood')).toBe(50);
111
+ expect(errorSpy).toHaveBeenCalledTimes(1);
112
+ expect(errorSpy.mock.calls[0][0]).toContain('mood');
113
+ expect(errorSpy.mock.calls[0][0]).toContain('min > max');
114
+ } finally {
115
+ errorSpy.mockRestore();
116
+ }
117
+ });
118
+
119
+ it('resolves unset bounds to sensible defaults', () => {
120
+ expect(resolveSliderBounds(undefined, undefined)).toEqual({ min: 0, max: 100 });
121
+ expect(areSliderBoundsValid(0, 100)).toBe(true);
122
+ expect(areSliderBoundsValid(100, 10)).toBe(false);
123
+ });
124
+ });
125
+
126
+ // TEST_SCENARIOS.md §22.5
127
+ describe('duration-equal-split (§22.5)', () => {
128
+ it('splits a total duration evenly', () => {
129
+ expect(splitTotalDuration(3000, 3)).toBe(1000);
130
+ });
131
+
132
+ it('falls back to the default per-step duration when unset or invalid', () => {
133
+ expect(splitTotalDuration(0, 3)).toBe(1500);
134
+ expect(splitTotalDuration(undefined, 3)).toBe(1500);
135
+ expect(getDefaultStepDurationMs()).toBe(1500);
136
+ });
137
+
138
+ it('never divides by zero', () => {
139
+ expect(splitTotalDuration(3000, 0)).toBe(3000);
140
+ });
141
+ });
142
+
143
+ // TEST_SCENARIOS.md §22.6
144
+ describe('count-with-locale (§22.6)', () => {
145
+ it('formats counts for human display', () => {
146
+ expect(formatCountWithLocale(250000, 'en-US')).toBe('250,000');
147
+ });
148
+
149
+ it('formats with an explicit non-English locale', () => {
150
+ expect(formatCountWithLocale(250000, 'de-DE')).toBe('250.000');
151
+ });
152
+
153
+ it('maps non-finite input to "0"', () => {
154
+ expect(formatCountWithLocale(Number.NaN)).toBe('0');
155
+ expect(formatCountWithLocale(Number.POSITIVE_INFINITY)).toBe('0');
156
+ });
157
+ });
158
+
159
+ // TEST_SCENARIOS.md §22.7
160
+ describe('elapsed-millis (§22.7)', () => {
161
+ it('returns the positive difference', () => {
162
+ const start = new Date('2026-01-01T10:00:00Z');
163
+ const end = new Date('2026-01-01T10:00:30Z');
164
+ expect(elapsedMillis(start, end)).toBe(30_000);
165
+ });
166
+
167
+ it('clamps negative differences to zero', () => {
168
+ const start = new Date('2026-01-01T10:00:30Z');
169
+ const end = new Date('2026-01-01T10:00:00Z');
170
+ expect(elapsedMillis(start, end)).toBe(0);
171
+ });
172
+ });
173
+
174
+ describe('card-stack-motion-curve', () => {
175
+ it('exposes consistent enter/exit states', () => {
176
+ expect(getCardEnterInitial()).toEqual({ opacity: 0, scale: 0.95 });
177
+ expect(getCardEnterAnimate()).toEqual({ opacity: 1, scale: 1 });
178
+ expect(getCardEnterExit()).toEqual({ opacity: 0, scale: 1.05 });
179
+ expect(getCardTransitionDurationMs()).toBeGreaterThan(0);
180
+ });
181
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Card stack motion math.
3
+ * Owns the scale/opacity transitions used by TinderCardsScreen and similar
4
+ * stack-style screens. Tuning these in one place keeps the stack feel consistent.
5
+ */
6
+
7
+ const MOTION_ENTER_DURATION_MS = 250;
8
+ const INITIAL_SCALE = 0.95;
9
+ const STEADY_SCALE = 1;
10
+ const EXIT_SCALE = 1.05;
11
+ const HIDDEN_OPACITY = 0;
12
+ const VISIBLE_OPACITY = 1;
13
+
14
+ export interface CardMotionState {
15
+ readonly opacity: number;
16
+ readonly scale: number;
17
+ }
18
+
19
+ export function getCardEnterInitial(): CardMotionState {
20
+ return { opacity: HIDDEN_OPACITY, scale: INITIAL_SCALE };
21
+ }
22
+
23
+ export function getCardEnterAnimate(): CardMotionState {
24
+ return { opacity: VISIBLE_OPACITY, scale: STEADY_SCALE };
25
+ }
26
+
27
+ export function getCardEnterExit(): CardMotionState {
28
+ return { opacity: HIDDEN_OPACITY, scale: EXIT_SCALE };
29
+ }
30
+
31
+ export function getCardTransitionDurationMs(): number {
32
+ return MOTION_ENTER_DURATION_MS;
33
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Locale-aware count formatting.
3
+ * Used wherever a large count (e.g. "Trusted by 250,000+") is shown to the user.
4
+ * The consumer never has to call .toLocaleString() directly.
5
+ */
6
+
7
+ /** Format a number for human display. e.g. 250000 -> "250,000" (en) / "250.000" (de-DE).
8
+ * An omitted locale uses the host default; an invalid locale falls back to en-US. */
9
+ export function formatCountWithLocale(value: number, locale?: string): string {
10
+ if (!Number.isFinite(value)) return '0';
11
+ try {
12
+ return value.toLocaleString(locale);
13
+ } catch {
14
+ return value.toLocaleString('en-US');
15
+ }
16
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Duration equal-split math.
3
+ * Splits a total processing duration into equal per-step slices.
4
+ * Falls back to a sensible default when the consumer doesn't provide a total.
5
+ */
6
+
7
+ const DEFAULT_STEP_DURATION_MS = 1500;
8
+ const MIN_PARTS = 1;
9
+
10
+ /**
11
+ * Per-step duration when a total is given.
12
+ * Returns 0 if the total is unset OR the parts list is empty —
13
+ * never divides by zero.
14
+ */
15
+ export function splitTotalDuration(totalMs: number | undefined, parts: number): number {
16
+ if (totalMs === undefined || totalMs <= 0) return DEFAULT_STEP_DURATION_MS;
17
+ const safeParts = Math.max(MIN_PARTS, parts);
18
+ return totalMs / safeParts;
19
+ }
20
+
21
+ /** The default per-step duration when the consumer omits the total. */
22
+ export function getDefaultStepDurationMs(): number {
23
+ return DEFAULT_STEP_DURATION_MS;
24
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Elapsed-millisecond math.
3
+ * Single source of truth for "how long did this take?".
4
+ */
5
+
6
+ const ZERO_MS = 0;
7
+
8
+ /** Milliseconds between two Dates. Never negative — clamps to 0. */
9
+ export function elapsedMillis(start: Date, end: Date): number {
10
+ const diff = end.getTime() - start.getTime();
11
+ return diff > ZERO_MS ? diff : ZERO_MS;
12
+ }
@@ -0,0 +1,36 @@
1
+ export {
2
+ computeProgressFraction,
3
+ computeProgressPercentage,
4
+ computeRemainingSteps,
5
+ } from './progress-percentage';
6
+ export {
7
+ clampStepIndex,
8
+ advanceStepIndex,
9
+ retreatStepIndex,
10
+ } from './step-index-clamp';
11
+ export {
12
+ SWIPE_DECISION_THRESHOLD_PX,
13
+ SWIPE_ROTATION_RANGE,
14
+ SWIPE_ROTATION_VALUES,
15
+ SWIPE_LEFT_OPACITY_INPUT,
16
+ SWIPE_LEFT_OPACITY_VALUES,
17
+ SWIPE_RIGHT_OPACITY_INPUT,
18
+ SWIPE_RIGHT_OPACITY_VALUES,
19
+ didCrossRightSwipeThreshold,
20
+ didCrossLeftSwipeThreshold,
21
+ } from './swipe-gesture-curve';
22
+ export type { CardMotionState } from './card-stack-motion-curve';
23
+ export {
24
+ getCardEnterInitial,
25
+ getCardEnterAnimate,
26
+ getCardEnterExit,
27
+ getCardTransitionDurationMs,
28
+ } from './card-stack-motion-curve';
29
+ export {
30
+ resolveSliderBounds,
31
+ areSliderBoundsValid,
32
+ clampValueToBounds,
33
+ } from './slider-value-bounds';
34
+ export { splitTotalDuration, getDefaultStepDurationMs } from './duration-equal-split';
35
+ export { formatCountWithLocale } from './count-with-locale';
36
+ export { elapsedMillis } from './elapsed-millis';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Progress percentage calculations.
3
+ * Owns the math for "how far are we through the flow?".
4
+ * Zero hardcoded fallbacks — every input is explicit.
5
+ */
6
+
7
+ const ZERO_FRACTION = 0;
8
+ const PERCENT_MULTIPLIER = 100;
9
+
10
+ function defensiveDenominator(totalSteps: number): number {
11
+ // Defensive: an empty flow must report 0%, never NaN or Infinity.
12
+ return totalSteps > 0 ? totalSteps : 1;
13
+ }
14
+
15
+ function defensiveCurrentStep(currentStep: number, totalSteps: number): number {
16
+ if (currentStep < 0) return 0;
17
+ if (currentStep > totalSteps) return totalSteps;
18
+ return currentStep;
19
+ }
20
+
21
+ /** Progress as a 0..1 fraction. `1` means "all steps visited". */
22
+ export function computeProgressFraction(currentStep: number, totalSteps: number): number {
23
+ if (totalSteps <= 0) return ZERO_FRACTION;
24
+ const safeCurrent = defensiveCurrentStep(currentStep, totalSteps);
25
+ return safeCurrent / defensiveDenominator(totalSteps);
26
+ }
27
+
28
+ /** Progress as a 0..100 percentage. Safe for the UI bar. */
29
+ export function computeProgressPercentage(currentStep: number, totalSteps: number): number {
30
+ return computeProgressFraction(currentStep, totalSteps) * PERCENT_MULTIPLIER;
31
+ }
32
+
33
+ /** Number of steps remaining (including the current one). */
34
+ export function computeRemainingSteps(currentStep: number, totalSteps: number): number {
35
+ if (totalSteps <= 0) return 0;
36
+ return Math.max(0, totalSteps - defensiveCurrentStep(currentStep, totalSteps));
37
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Slider value math.
3
+ * Owns the bounds enforcement for preference sliders.
4
+ * Misconfigured bounds (min > max) are surfaced via console — never silently masked.
5
+ */
6
+
7
+ const SLIDER_MIN_IF_UNSET = 0;
8
+ const SLIDER_MAX_IF_UNSET = 100;
9
+
10
+ /** Defensive bounds for a slider: if the config omits one, fall back to a sensible default. */
11
+ export function resolveSliderBounds(
12
+ min: number | undefined,
13
+ max: number | undefined,
14
+ ): { min: number; max: number } {
15
+ return {
16
+ min: min ?? SLIDER_MIN_IF_UNSET,
17
+ max: max ?? SLIDER_MAX_IF_UNSET,
18
+ };
19
+ }
20
+
21
+ /** Were the configured bounds logically valid? */
22
+ export function areSliderBoundsValid(min: number, max: number): boolean {
23
+ return min <= max;
24
+ }
25
+
26
+ /** Clamp a value into the [min, max] range, surfacing misconfigured bounds to the console. */
27
+ export function clampValueToBounds(
28
+ value: number,
29
+ min: number,
30
+ max: number,
31
+ preferenceId: string,
32
+ ): number {
33
+ if (!areSliderBoundsValid(min, max)) {
34
+ // Surface the misconfiguration — never silently mask.
35
+
36
+ console.error(
37
+ `[web-onboarding] preference "${preferenceId}" has min > max (${min} > ${max}); returning value as-is.`,
38
+ );
39
+ return value;
40
+ }
41
+ if (value < min) return min;
42
+ if (value > max) return max;
43
+ return value;
44
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Step index math.
3
+ * Owns the bounds for "where can the cursor be in the flow?".
4
+ * Pure, no React, no side effects.
5
+ */
6
+
7
+ const MIN_INDEX = 0;
8
+ const MIN_TOTAL_STEPS = 1;
9
+
10
+ /** Clamp an arbitrary index into the valid [0, totalSteps-1] range. */
11
+ export function clampStepIndex(index: number, totalSteps: number): number {
12
+ if (totalSteps < MIN_TOTAL_STEPS) return MIN_INDEX;
13
+ if (index < MIN_INDEX) return MIN_INDEX;
14
+ if (index > totalSteps - MIN_TOTAL_STEPS) return totalSteps - MIN_TOTAL_STEPS;
15
+ return index;
16
+ }
17
+
18
+ /** Move one step forward, stopping at the last step. */
19
+ export function advanceStepIndex(currentIndex: number, totalSteps: number): number {
20
+ if (totalSteps < MIN_TOTAL_STEPS) return MIN_INDEX;
21
+ const lastIndex = totalSteps - MIN_TOTAL_STEPS;
22
+ return Math.min(currentIndex + MIN_TOTAL_STEPS, lastIndex);
23
+ }
24
+
25
+ /** Move one step backward, stopping at the first step. */
26
+ export function retreatStepIndex(currentIndex: number): number {
27
+ return Math.max(MIN_INDEX, currentIndex - MIN_TOTAL_STEPS);
28
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Swipe gesture math.
3
+ * Owns every numeric value used by SwipeCard's drag/rotation/opacity curves.
4
+ * Centralizing them here means the gesture "feel" can be tuned in one place.
5
+ */
6
+
7
+ const SWIPE_THRESHOLD_PX = 120;
8
+ const DRAG_RANGE_PX = 200;
9
+ /** Drag distance (px) at which the decision badges reach half opacity. */
10
+ const OPACITY_MIDPOINT_PX = 50;
11
+ const FULL_OPACITY = 1;
12
+ const HALF_OPACITY = 0.5;
13
+ const ZERO_OPACITY = 0;
14
+ const MAX_ROTATION_DEG = 15;
15
+ const ZERO_ROTATION_DEG = 0;
16
+
17
+ /** Minimum horizontal distance the user must drag to commit a swipe decision. */
18
+ export const SWIPE_DECISION_THRESHOLD_PX = SWIPE_THRESHOLD_PX;
19
+
20
+ /** Card rotation expressed in degrees, mapped from -200..+200 px drag. */
21
+ export const SWIPE_ROTATION_RANGE: [number, number, number] = [
22
+ -DRAG_RANGE_PX,
23
+ ZERO_ROTATION_DEG,
24
+ DRAG_RANGE_PX,
25
+ ];
26
+
27
+ export const SWIPE_ROTATION_VALUES: [number, number, number] = [
28
+ -MAX_ROTATION_DEG,
29
+ ZERO_ROTATION_DEG,
30
+ MAX_ROTATION_DEG,
31
+ ];
32
+
33
+ /** DISAGREE badge opacity curve, mapped from -200..0 px drag. */
34
+ export const SWIPE_LEFT_OPACITY_INPUT: [number, number, number] = [
35
+ -DRAG_RANGE_PX,
36
+ -OPACITY_MIDPOINT_PX,
37
+ 0,
38
+ ];
39
+
40
+ export const SWIPE_LEFT_OPACITY_VALUES: [number, number, number] = [
41
+ FULL_OPACITY,
42
+ HALF_OPACITY,
43
+ ZERO_OPACITY,
44
+ ];
45
+
46
+ /** AGREE badge opacity curve, mapped from 0..+200 px drag. */
47
+ export const SWIPE_RIGHT_OPACITY_INPUT: [number, number, number] = [
48
+ 0,
49
+ OPACITY_MIDPOINT_PX,
50
+ DRAG_RANGE_PX,
51
+ ];
52
+
53
+ export const SWIPE_RIGHT_OPACITY_VALUES: [number, number, number] = [
54
+ ZERO_OPACITY,
55
+ HALF_OPACITY,
56
+ FULL_OPACITY,
57
+ ];
58
+
59
+ /** Did the gesture cross the threshold to register as a right-swipe? */
60
+ export function didCrossRightSwipeThreshold(offsetX: number): boolean {
61
+ return offsetX > SWIPE_THRESHOLD_PX;
62
+ }
63
+
64
+ /** Did the gesture cross the threshold to register as a left-swipe? */
65
+ export function didCrossLeftSwipeThreshold(offsetX: number): boolean {
66
+ return offsetX < -SWIPE_THRESHOLD_PX;
67
+ }