@multiplatform.one/theme 6.1.0 → 6.4.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 (187) hide show
  1. package/README.md +73 -0
  2. package/package.json +22 -9
  3. package/src/dates.spec.ts +75 -0
  4. package/src/dates.ts +130 -0
  5. package/src/devWarn.spec.ts +144 -0
  6. package/src/devWarn.ts +235 -0
  7. package/src/figma/figmaTokens.spec.ts +244 -0
  8. package/src/figma/figmaTokens.ts +503 -0
  9. package/src/font.ts +6 -8
  10. package/src/fonts/createFontLoader.web.ts +15 -4
  11. package/src/index.ts +7 -0
  12. package/src/keyboardFocusRing.ts +45 -0
  13. package/src/menuRow.spec.ts +32 -0
  14. package/src/menuRow.ts +31 -0
  15. package/src/theme/FontKnobStyles.native.tsx +24 -0
  16. package/src/theme/FontKnobStyles.tsx +256 -0
  17. package/src/theme/animations/reactNative.ts +6 -1
  18. package/src/theme/chartPalette.spec.ts +159 -0
  19. package/src/theme/chartPalette.ts +170 -0
  20. package/src/theme/colorRules.ts +33 -1
  21. package/src/theme/cookies.spec.ts +32 -0
  22. package/src/theme/cookiesPersistence.spec.ts +86 -0
  23. package/src/theme/cornerSmoothing.native.ts +16 -0
  24. package/src/theme/cornerSmoothing.spec.ts +27 -0
  25. package/src/theme/cornerSmoothing.ts +46 -0
  26. package/src/theme/createDefaultThemeConfig.ts +32 -8
  27. package/src/theme/createThemeConfig.spec.ts +137 -0
  28. package/src/theme/createThemes.ts +77 -2
  29. package/src/theme/defaults/accent.ts +39 -0
  30. package/src/theme/defaults/base.ts +63 -0
  31. package/src/theme/defaults/builderOptions.ts +174 -0
  32. package/src/theme/defaults/fonts.spec.ts +87 -0
  33. package/src/theme/defaults/fonts.ts +89 -0
  34. package/src/theme/defaults/index.ts +4 -0
  35. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
  36. package/src/theme/devtools/buildSnapshot.ts +11 -0
  37. package/src/theme/devtools/knobIcons.tsx +141 -0
  38. package/src/theme/devtools/vizConstants.ts +1 -1
  39. package/src/theme/focusState.spec.ts +174 -0
  40. package/src/theme/focusState.ts +112 -0
  41. package/src/theme/fontKnobStyles.spec.ts +98 -0
  42. package/src/theme/hairline.native.ts +43 -0
  43. package/src/theme/hairline.spec.ts +63 -0
  44. package/src/theme/hairline.ts +101 -0
  45. package/src/theme/index.ts +8 -0
  46. package/src/theme/intent.spec.tsx +2 -2
  47. package/src/theme/intents.ts +6 -2
  48. package/src/theme/knobs.ts +134 -2
  49. package/src/theme/layoutTokens.spec.ts +75 -0
  50. package/src/theme/layoutTokens.ts +213 -0
  51. package/src/theme/layoutTokensHooks.spec.tsx +157 -0
  52. package/src/theme/presets.spec.ts +17 -0
  53. package/src/theme/presets.ts +22 -2
  54. package/src/theme/readableColor.ts +101 -0
  55. package/src/theme/recipes.ts +130 -7
  56. package/src/theme/resolveKnobs.spec.ts +560 -21
  57. package/src/theme/resolveKnobs.ts +267 -23
  58. package/src/theme/shared.tsx +26 -2
  59. package/src/theme/useResolvedKnobs.ts +67 -26
  60. package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
  61. package/types/audit/constraintAudit.d.ts +83 -0
  62. package/types/audit/constraintAudit.d.ts.map +1 -0
  63. package/types/audit/diffReport.d.ts +45 -0
  64. package/types/audit/diffReport.d.ts.map +1 -0
  65. package/types/audit/index.d.ts +5 -0
  66. package/types/audit/index.d.ts.map +1 -0
  67. package/types/dates.d.ts +52 -0
  68. package/types/dates.d.ts.map +1 -0
  69. package/types/devWarn.d.ts +104 -0
  70. package/types/devWarn.d.ts.map +1 -0
  71. package/types/figma/figmaTokens.d.ts +168 -0
  72. package/types/figma/figmaTokens.d.ts.map +1 -0
  73. package/types/font.d.ts +5 -0
  74. package/types/font.d.ts.map +1 -0
  75. package/types/fonts/createFontLoader.d.ts +3 -0
  76. package/types/fonts/createFontLoader.d.ts.map +1 -0
  77. package/types/fonts/createFontLoader.web.d.ts +3 -0
  78. package/types/fonts/createFontLoader.web.d.ts.map +1 -0
  79. package/types/fonts/index.d.ts +3 -0
  80. package/types/fonts/index.d.ts.map +1 -0
  81. package/types/fonts/types.d.ts +12 -0
  82. package/types/fonts/types.d.ts.map +1 -0
  83. package/types/index.d.ts +9 -0
  84. package/types/index.d.ts.map +1 -0
  85. package/types/keyboardFocusRing.d.ts +32 -0
  86. package/types/keyboardFocusRing.d.ts.map +1 -0
  87. package/types/menuRow.d.ts +32 -0
  88. package/types/menuRow.d.ts.map +1 -0
  89. package/types/theme/FontKnobStyles.d.ts +49 -0
  90. package/types/theme/FontKnobStyles.d.ts.map +1 -0
  91. package/types/theme/FontKnobStyles.native.d.ts +12 -0
  92. package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
  93. package/types/theme/Intent.d.ts +20 -0
  94. package/types/theme/Intent.d.ts.map +1 -0
  95. package/types/theme/Preset.d.ts +15 -0
  96. package/types/theme/Preset.d.ts.map +1 -0
  97. package/types/theme/PresetContext.d.ts +18 -0
  98. package/types/theme/PresetContext.d.ts.map +1 -0
  99. package/types/theme/Tint.d.ts +19 -0
  100. package/types/theme/Tint.d.ts.map +1 -0
  101. package/types/theme/animations/css.d.ts +24 -0
  102. package/types/theme/animations/css.d.ts.map +1 -0
  103. package/types/theme/animations/index.d.ts +2 -0
  104. package/types/theme/animations/index.d.ts.map +1 -0
  105. package/types/theme/animations/index.web.d.ts +2 -0
  106. package/types/theme/animations/index.web.d.ts.map +1 -0
  107. package/types/theme/animations/reactNative.d.ts +104 -0
  108. package/types/theme/animations/reactNative.d.ts.map +1 -0
  109. package/types/theme/chartPalette.d.ts +62 -0
  110. package/types/theme/chartPalette.d.ts.map +1 -0
  111. package/types/theme/colorRules.d.ts +69 -0
  112. package/types/theme/colorRules.d.ts.map +1 -0
  113. package/types/theme/cookies.d.ts +76 -0
  114. package/types/theme/cookies.d.ts.map +1 -0
  115. package/types/theme/cornerSmoothing.d.ts +31 -0
  116. package/types/theme/cornerSmoothing.d.ts.map +1 -0
  117. package/types/theme/cornerSmoothing.native.d.ts +14 -0
  118. package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
  119. package/types/theme/createDefaultThemeConfig.d.ts +37 -0
  120. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
  121. package/types/theme/createThemes.d.ts +38 -0
  122. package/types/theme/createThemes.d.ts.map +1 -0
  123. package/types/theme/defaults/accent.d.ts +3 -0
  124. package/types/theme/defaults/accent.d.ts.map +1 -0
  125. package/types/theme/defaults/base.d.ts +3 -0
  126. package/types/theme/defaults/base.d.ts.map +1 -0
  127. package/types/theme/defaults/builderOptions.d.ts +3 -0
  128. package/types/theme/defaults/builderOptions.d.ts.map +1 -0
  129. package/types/theme/defaults/fonts.d.ts +7 -0
  130. package/types/theme/defaults/fonts.d.ts.map +1 -0
  131. package/types/theme/defaults/index.d.ts +5 -0
  132. package/types/theme/defaults/index.d.ts.map +1 -0
  133. package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
  134. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
  135. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
  136. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
  137. package/types/theme/devtools/buildSnapshot.d.ts +12 -0
  138. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
  139. package/types/theme/devtools/knobIcons.d.ts +33 -0
  140. package/types/theme/devtools/knobIcons.d.ts.map +1 -0
  141. package/types/theme/devtools/vizConstants.d.ts +5 -0
  142. package/types/theme/devtools/vizConstants.d.ts.map +1 -0
  143. package/types/theme/focusState.d.ts +42 -0
  144. package/types/theme/focusState.d.ts.map +1 -0
  145. package/types/theme/hairline.d.ts +88 -0
  146. package/types/theme/hairline.d.ts.map +1 -0
  147. package/types/theme/hairline.native.d.ts +42 -0
  148. package/types/theme/hairline.native.d.ts.map +1 -0
  149. package/types/theme/index.d.ts +29 -0
  150. package/types/theme/index.d.ts.map +1 -0
  151. package/types/theme/intents.d.ts +3 -0
  152. package/types/theme/intents.d.ts.map +1 -0
  153. package/types/theme/knobs.d.ts +236 -0
  154. package/types/theme/knobs.d.ts.map +1 -0
  155. package/types/theme/layoutTokens.d.ts +104 -0
  156. package/types/theme/layoutTokens.d.ts.map +1 -0
  157. package/types/theme/preset.types.d.ts +21 -0
  158. package/types/theme/preset.types.d.ts.map +1 -0
  159. package/types/theme/presets.d.ts +4 -0
  160. package/types/theme/presets.d.ts.map +1 -0
  161. package/types/theme/readableColor.d.ts +17 -0
  162. package/types/theme/readableColor.d.ts.map +1 -0
  163. package/types/theme/recipes.d.ts +200 -0
  164. package/types/theme/recipes.d.ts.map +1 -0
  165. package/types/theme/resolveKnobs.d.ts +22 -0
  166. package/types/theme/resolveKnobs.d.ts.map +1 -0
  167. package/types/theme/shared.d.ts +40 -0
  168. package/types/theme/shared.d.ts.map +1 -0
  169. package/types/theme/theme.d.ts +19 -0
  170. package/types/theme/theme.d.ts.map +1 -0
  171. package/types/theme/theme.native.d.ts +10 -0
  172. package/types/theme/theme.native.d.ts.map +1 -0
  173. package/types/theme/useColorScale.d.ts +7 -0
  174. package/types/theme/useColorScale.d.ts.map +1 -0
  175. package/types/theme/useColorScale.native.d.ts +6 -0
  176. package/types/theme/useColorScale.native.d.ts.map +1 -0
  177. package/types/theme/useResolvedKnobs.d.ts +32 -0
  178. package/types/theme/useResolvedKnobs.d.ts.map +1 -0
  179. package/types/theme/useTheme.d.ts +31 -0
  180. package/types/theme/useTheme.d.ts.map +1 -0
  181. package/types/theme/useTheme.native.d.ts +10 -0
  182. package/types/theme/useTheme.native.d.ts.map +1 -0
  183. package/CHANGELOG.md +0 -53
  184. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
  185. package/src/theme/pokemonScreen.spec.ts +0 -109
  186. package/tsconfig.json +0 -11
  187. package/vitest.config.mjs +0 -9
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @multiplatform.one/theme
2
+
3
+ The multiplatform.one theming engine: Tamagui color themes plus the **knobs
4
+ system** — structural styling (radius, spacing, elevation, typography, animation)
5
+ resolved independently of color, so one knob flip restyles a whole screen.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @multiplatform.one/theme
11
+ ```
12
+
13
+ Requires `react` and `tamagui` as peers.
14
+
15
+ ## What it owns
16
+
17
+ - **Knobs** — `useResolvedKnobs()`, `resolveKnobs()` (the only canonical mapping
18
+ from abstract knobs to Tamagui props), knob types and defaults
19
+ - **Presets** — `<Preset preset="…" theme="…">` bundles knobs + intents + theme;
20
+ nested presets cascade, `overrides` merge by key
21
+ - **Intents** — contextual overrides (`error`, `accent`, `warning`, `success`),
22
+ including intent + component overrides
23
+ - **Recipes** — control/text/elevation state recipes (hover, press, focus-visible)
24
+ - **Optics** — the `cornerSmoothing` knob (`round` | `smooth` squircle corners:
25
+ web progressive enhancement via CSS `corner-shape`, native stays round),
26
+ `hairline` separator treatments (0.5 device-pixel rules with a 1px low-DPI
27
+ fallback), and tracking-by-size in the default font faces
28
+ - **Chart palette** — `useChartPalette()`: identity-led series colors
29
+ (single-series = theme identity, categorical cycle, semantic ramps) that
30
+ re-anchor under tints; explicit data colors always pass through
31
+ - Theme creation (`createThemes`, `createDefaultThemeConfig`), `Tint`, layout
32
+ tokens, focus state, cookie persistence for SSR-safe knobs
33
+
34
+ ## What it must not do
35
+
36
+ - No app screens and no component catalog — components live in
37
+ `@multiplatform.one/components`, fields in `@multiplatform.one/forms`
38
+
39
+ ## Usage
40
+
41
+ ```tsx
42
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
43
+ import { View } from "@multiplatform.one/components";
44
+
45
+ export function Surface({ intent, compact, ...props }) {
46
+ const { knobProps, control } = useResolvedKnobs({ intent, component: "Button", compact });
47
+ return (
48
+ <View
49
+ {...knobProps.surface}
50
+ {...knobProps.borderRadius}
51
+ {...knobProps.panelPadding}
52
+ {...knobProps.gap}
53
+ hoverStyle={{ ...control.hoverKnobProps }}
54
+ pressStyle={{ ...control.pressKnobProps }}
55
+ {...props}
56
+ />
57
+ );
58
+ }
59
+ ```
60
+
61
+ Rules that keep knobs sound:
62
+
63
+ - Always spread complete `knobProps.*` fragments — never cherry-pick resolved tokens.
64
+ - Consumer `{...props}` spread last (the only styling eject).
65
+ - Resolution order: Preset → intent → intent+component → size → density →
66
+ reduced-motion → `resolveKnobs()` override callback.
67
+
68
+ Full standard: `agent-os/standards/frontend/knobs-system.md` in the
69
+ [multiplatform.one repo](https://gitlab.com/bitspur/frappe/multiplatform.one).
70
+
71
+ ## License
72
+
73
+ Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "6.1.0",
3
+ "version": "6.4.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -10,21 +10,33 @@
10
10
  ],
11
11
  "license": "Apache-2.0",
12
12
  "author": "BitSpur",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://gitlab.com/bitspur/frappe/multiplatform.one.git",
16
+ "directory": "public/theme"
17
+ },
13
18
  "source": "src/index.ts",
19
+ "files": [
20
+ "src",
21
+ "!src/**/__snapshots__",
22
+ "types",
23
+ "!types/.tsbuildinfo"
24
+ ],
14
25
  "sideEffects": true,
15
26
  "main": "src/index.ts",
16
27
  "module": "src/index.ts",
17
- "types": "src/index.ts",
28
+ "types": "types/index.d.ts",
18
29
  "exports": {
19
30
  "./package.json": "./package.json",
31
+ "./src/*": "./src/*",
20
32
  ".": {
21
33
  "source": "./src/index.ts",
22
- "types": "./src/index.ts",
34
+ "types": "./types/index.d.ts",
23
35
  "default": "./src/index.ts"
24
36
  },
25
37
  "./audit": {
26
38
  "source": "./src/audit/index.ts",
27
- "types": "./src/audit/index.ts",
39
+ "types": "./types/audit/index.d.ts",
28
40
  "default": "./src/audit/index.ts"
29
41
  }
30
42
  },
@@ -41,8 +53,8 @@
41
53
  "@tamagui/toast": "2.0.0-rc.41",
42
54
  "@tamagui/web": "2.0.0-rc.41",
43
55
  "react-cookie": "^8.1.2",
44
- "@multiplatform.one/store": "6.1.0",
45
- "@multiplatform.one/platform": "6.1.0"
56
+ "@multiplatform.one/store": "6.4.0",
57
+ "@multiplatform.one/platform": "6.4.0"
46
58
  },
47
59
  "devDependencies": {
48
60
  "@tamagui/animations-css": "2.0.0-rc.41",
@@ -57,8 +69,8 @@
57
69
  "tamagui": "2.0.0-rc.41",
58
70
  "typescript": "~5.9.3",
59
71
  "vitest": "^4.1.5",
60
- "@multiplatform.one/test-utils": "6.1.0",
61
- "@multiplatform.one/config": "6.1.0"
72
+ "@multiplatform.one/config": "6.4.0",
73
+ "@multiplatform.one/test-utils": "6.4.0"
62
74
  },
63
75
  "peerDependencies": {
64
76
  "@tamagui/animations-css": "^2.0.0-rc",
@@ -80,6 +92,7 @@
80
92
  },
81
93
  "scripts": {
82
94
  "test": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=lcov --coverage.reporter=html",
83
- "typecheck": "tsc --noEmit"
95
+ "typecheck": "tsc --noEmit",
96
+ "build": "rm -rf types 2>/dev/null && tsc -p tsconfig.build.json"
84
97
  }
85
98
  }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatAbsoluteDate, formatAbsoluteDateTime, formatRelativeTimestamp } from "./dates";
3
+
4
+ // Pin locale so assertions don't depend on the host machine.
5
+ const enUS = { locale: "en-US" } as const;
6
+ // Reference "today" for current-year elision.
7
+ const now = new Date(2026, 7, 12); // Aug 12, 2026
8
+
9
+ describe("formatAbsoluteDate (LC-12 compact absolute register)", () => {
10
+ it("elides the current year by default (display register)", () => {
11
+ expect(formatAbsoluteDate(new Date(2026, 6, 1), { ...enUS, now })).toBe("Jul 1");
12
+ });
13
+
14
+ it("adds the year when not current", () => {
15
+ expect(formatAbsoluteDate(new Date(2025, 10, 1), { ...enUS, now })).toBe("Nov 1, 2025");
16
+ });
17
+
18
+ it("always shows the year for editing surfaces (year: 'always')", () => {
19
+ expect(formatAbsoluteDate(new Date(2026, 6, 1), { ...enUS, now, year: "always" })).toBe(
20
+ "Jul 1, 2026",
21
+ );
22
+ });
23
+
24
+ it("formats ISO date-only strings in UTC (no timezone day-shift)", () => {
25
+ expect(formatAbsoluteDate("2026-07-01", { ...enUS, now })).toBe("Jul 1");
26
+ expect(formatAbsoluteDate("2026-12-31", { ...enUS, now, year: "always" })).toBe("Dec 31, 2026");
27
+ });
28
+
29
+ it("renders empty input as empty string", () => {
30
+ expect(formatAbsoluteDate(null, enUS)).toBe("");
31
+ expect(formatAbsoluteDate(undefined, enUS)).toBe("");
32
+ expect(formatAbsoluteDate("", enUS)).toBe("");
33
+ });
34
+
35
+ it("passes unparseable strings through unchanged (honest passthrough)", () => {
36
+ expect(formatAbsoluteDate("not-a-date", enUS)).toBe("not-a-date");
37
+ });
38
+
39
+ it("never emits seconds (LC-12 VERIFY)", () => {
40
+ const out = formatAbsoluteDateTime(new Date(2026, 7, 9, 15, 30, 45), { ...enUS, now });
41
+ expect(out).not.toMatch(/:\d{2}:\d{2}/);
42
+ });
43
+ });
44
+
45
+ describe("formatAbsoluteDateTime (system-event register)", () => {
46
+ it("renders the compact absolute form", () => {
47
+ expect(formatAbsoluteDateTime(new Date(2026, 7, 9, 15, 30), { ...enUS, now })).toBe(
48
+ "Aug 9, 3:30 PM",
49
+ );
50
+ });
51
+
52
+ it("adds the year when not current", () => {
53
+ expect(formatAbsoluteDateTime(new Date(2025, 7, 9, 15, 30), { ...enUS, now })).toBe(
54
+ "Aug 9, 2025, 3:30 PM",
55
+ );
56
+ });
57
+ });
58
+
59
+ describe("formatRelativeTimestamp (feed/social register)", () => {
60
+ const base = Date.UTC(2026, 7, 12, 12, 0, 0);
61
+
62
+ it("covers the ladder from 'just now' to years", () => {
63
+ const at = (msAgo: number) => new Date(base - msAgo).toISOString();
64
+ expect(formatRelativeTimestamp(at(10_000), base)).toBe("just now");
65
+ expect(formatRelativeTimestamp(at(5 * 60_000), base)).toBe("5m ago");
66
+ expect(formatRelativeTimestamp(at(2 * 3_600_000), base)).toBe("2h ago");
67
+ expect(formatRelativeTimestamp(at(10 * 86_400_000), base)).toBe("10 days ago");
68
+ expect(formatRelativeTimestamp(at(60 * 86_400_000), base)).toBe("2mo ago");
69
+ expect(formatRelativeTimestamp(at(400 * 86_400_000), base)).toBe("1y ago");
70
+ });
71
+
72
+ it("passes unparseable strings through unchanged", () => {
73
+ expect(formatRelativeTimestamp("garbage", base)).toBe("garbage");
74
+ });
75
+ });
package/src/dates.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Canonical timestamp/date registers (LC-12 TIMESTAMP-REGISTER, Axiom 9).
3
+ *
4
+ * One formatter owns every absolute date the catalog renders — Timeline
5
+ * timestamps, table date cells (FieldDisplay), DatePicker display values —
6
+ * so the same data class never speaks in multiple voices ("2026-07-01" vs
7
+ * "Aug 9, 3:30 PM" vs "11/01/2026").
8
+ *
9
+ * Content class picks the register (paired with the `timestampStyle` knob):
10
+ * - feeds/social → relative ("2h ago") via `formatRelativeTimestamp`
11
+ * - tables/system → compact absolute ("Jul 1" / "Aug 9, 3:30 PM"),
12
+ * current year elided (`year: "auto"`)
13
+ * - editing surfaces → compact absolute with the year always shown
14
+ * ("Jul 1, 2026") — pickers are precision contexts
15
+ * No default may emit verbose locale output with seconds.
16
+ */
17
+
18
+ export type DateInput = Date | string | number | null | undefined;
19
+
20
+ export interface AbsoluteDateFormatOptions {
21
+ /**
22
+ * `"auto"` (default) elides the current year — the display register for
23
+ * tables, feeds, and system events. `"always"` keeps it — the editing
24
+ * register for picker display values.
25
+ */
26
+ year?: "auto" | "always";
27
+ /** Include the time component ("Aug 9, 3:30 PM"). Default false. */
28
+ withTime?: boolean;
29
+ /** BCP-47 locale forwarded to Intl. Default: system locale. */
30
+ locale?: string;
31
+ /** Reference date for current-year elision (injectable for tests). */
32
+ now?: Date;
33
+ }
34
+
35
+ /** ISO date-only shape ("YYYY-MM-DD"). */
36
+ export const ISO_DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
37
+
38
+ interface CoercedDate {
39
+ date: Date;
40
+ /**
41
+ * ISO date-only strings parse as UTC midnight, so local-time formatting
42
+ * shifts them a day in negative-offset timezones. Format those in UTC.
43
+ */
44
+ dateOnly: boolean;
45
+ }
46
+
47
+ function coerceDateInput(value: DateInput): CoercedDate | null {
48
+ if (value == null || value === "") return null;
49
+ if (value instanceof Date) {
50
+ return Number.isNaN(value.getTime()) ? null : { date: value, dateOnly: false };
51
+ }
52
+ if (typeof value === "string") {
53
+ const dateOnly = ISO_DATE_ONLY.test(value.trim());
54
+ const date = new Date(value);
55
+ return Number.isNaN(date.getTime()) ? null : { date, dateOnly };
56
+ }
57
+ const date = new Date(value);
58
+ return Number.isNaN(date.getTime()) ? null : { date, dateOnly: false };
59
+ }
60
+
61
+ /**
62
+ * Compact absolute date — the house register for every absolute date.
63
+ * "Jul 1" / "Jul 1, 2025" (year auto-elided when current), or with
64
+ * `withTime` "Aug 9, 3:30 PM". Empty input renders "" and an unparseable
65
+ * string passes through unchanged (honest passthrough — never invents).
66
+ */
67
+ export function formatAbsoluteDate(
68
+ value: DateInput,
69
+ options: AbsoluteDateFormatOptions = {},
70
+ ): string {
71
+ const coerced = coerceDateInput(value);
72
+ if (!coerced) return typeof value === "string" ? value : "";
73
+ const { date, dateOnly } = coerced;
74
+ const { year = "auto", withTime = false, locale, now } = options;
75
+ const referenceYear = (now ?? new Date()).getFullYear();
76
+ const displayYear = dateOnly ? date.getUTCFullYear() : date.getFullYear();
77
+ const intlOptions: Intl.DateTimeFormatOptions = {
78
+ month: "short",
79
+ day: "numeric",
80
+ };
81
+ if (year === "always" || displayYear !== referenceYear) {
82
+ intlOptions.year = "numeric";
83
+ }
84
+ if (withTime) {
85
+ intlOptions.hour = "numeric";
86
+ intlOptions.minute = "2-digit";
87
+ }
88
+ if (dateOnly) {
89
+ intlOptions.timeZone = "UTC";
90
+ }
91
+ try {
92
+ return date.toLocaleString(locale, intlOptions);
93
+ } catch {
94
+ return typeof value === "string" ? value : date.toISOString();
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Compact absolute date-time ("Aug 9, 3:30 PM"; year added when not
100
+ * current). The system-event register (LC-12) and the tooltip form for
101
+ * relative timestamps.
102
+ */
103
+ export function formatAbsoluteDateTime(
104
+ value: DateInput,
105
+ options: Omit<AbsoluteDateFormatOptions, "withTime"> = {},
106
+ ): string {
107
+ return formatAbsoluteDate(value, { ...options, withTime: true });
108
+ }
109
+
110
+ /**
111
+ * Relative timestamp ("just now", "5m ago", "2h ago", "10 days ago") — the
112
+ * feed/social register (LC-12), selected by `timestampStyle: "relative"`.
113
+ */
114
+ export function formatRelativeTimestamp(value: DateInput, now: number = Date.now()): string {
115
+ if (value == null || value === "") return "";
116
+ const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
117
+ if (Number.isNaN(t)) return typeof value === "string" ? value : "";
118
+ const sec = Math.floor(Math.max(0, now - t) / 1000);
119
+ if (sec < 60) return "just now";
120
+ const min = Math.floor(sec / 60);
121
+ if (min < 60) return `${min}m ago`;
122
+ const hr = Math.floor(min / 60);
123
+ if (hr < 24) return `${hr}h ago`;
124
+ const day = Math.floor(hr / 24);
125
+ if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`;
126
+ const mo = Math.floor(day / 30);
127
+ if (mo < 12) return `${mo}mo ago`;
128
+ const yr = Math.floor(day / 365);
129
+ return `${Math.max(1, yr)}y ago`;
130
+ }
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetDevWarnSeen,
4
+ devWarn,
5
+ warnBannedErrorWords,
6
+ warnBareDisabled,
7
+ warnCancelDisabled,
8
+ warnDialogTooManyActions,
9
+ warnOutlineNone,
10
+ warnPlaceholderAsLabel,
11
+ warnPositiveTabIndex,
12
+ warnSelectTooFewOptions,
13
+ warnTooltipOnDisabled,
14
+ } from "./devWarn";
15
+
16
+ describe("devWarn catalog (blueprint §5)", () => {
17
+ let warnSpy: ReturnType<typeof vi.spyOn>;
18
+ let prevEnv: string | undefined;
19
+
20
+ beforeEach(() => {
21
+ __resetDevWarnSeen();
22
+ prevEnv = process.env.NODE_ENV;
23
+ process.env.NODE_ENV = "development";
24
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
25
+ });
26
+
27
+ afterEach(() => {
28
+ warnSpy.mockRestore();
29
+ __resetDevWarnSeen();
30
+ process.env.NODE_ENV = prevEnv;
31
+ });
32
+
33
+ it("no-ops in production", () => {
34
+ process.env.NODE_ENV = "production";
35
+ devWarn("two-primaries", { component: "ActionBar" });
36
+ expect(warnSpy).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it("dedupes identical keys", () => {
40
+ devWarn("two-primaries", { component: "ActionBar", id: "r1" });
41
+ devWarn("two-primaries", { component: "ActionBar", id: "r1" });
42
+ expect(warnSpy).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("placeholder-as-label", () => {
46
+ warnPlaceholderAsLabel({ placeholder: "Name", component: "FieldLayout", id: "n" });
47
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("placeholder-as-label"));
48
+ __resetDevWarnSeen();
49
+ warnSpy.mockClear();
50
+ warnPlaceholderAsLabel({ placeholder: "Name", label: "Name" });
51
+ expect(warnSpy).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("banned-error-word covers DG-COPY-02 list including error", () => {
55
+ warnBannedErrorWords("Invalid email", { component: "Toast" });
56
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("banned-error-word"));
57
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("word=invalid"));
58
+ __resetDevWarnSeen();
59
+ warnSpy.mockClear();
60
+ warnBannedErrorWords("An error occurred", { component: "Alert" });
61
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("word=error"));
62
+ __resetDevWarnSeen();
63
+ warnSpy.mockClear();
64
+ warnBannedErrorWords("Enter an email address");
65
+ expect(warnSpy).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("bare-disabled", () => {
69
+ warnBareDisabled({ disabled: true, component: "Button" });
70
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("bare-disabled"));
71
+ __resetDevWarnSeen();
72
+ warnSpy.mockClear();
73
+ warnBareDisabled({ disabled: true, disabledReason: "Needs approval" });
74
+ expect(warnSpy).not.toHaveBeenCalled();
75
+ warnBareDisabled({ disabled: true, skip: true });
76
+ expect(warnSpy).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("cancel-disabled (DG-ACT-05) fires only for a truthy disable", () => {
80
+ warnCancelDisabled({ disabled: true, component: "ConfirmDialog" });
81
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("cancel-disabled"));
82
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("component=ConfirmDialog"));
83
+ __resetDevWarnSeen();
84
+ warnSpy.mockClear();
85
+ warnCancelDisabled({ disabled: false, component: "ActionBar" });
86
+ warnCancelDisabled({ component: "ActionBar" });
87
+ expect(warnSpy).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("dialog-too-many-actions (DG-OVL-04) caps at 2 with allowManyActions eject", () => {
91
+ warnDialogTooManyActions({ count: 3, component: "ActionBar", id: "dlg" });
92
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("dialog-too-many-actions"));
93
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("count=3"));
94
+ __resetDevWarnSeen();
95
+ warnSpy.mockClear();
96
+ warnDialogTooManyActions({ count: 2, component: "ActionBar" });
97
+ warnDialogTooManyActions({ count: 1, component: "ConfirmDialog" });
98
+ warnDialogTooManyActions({ count: 5, allowManyActions: true, component: "ConfirmDialog" });
99
+ expect(warnSpy).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it("select-too-few-options suggests RadioGroup / ToggleGroup", () => {
103
+ warnSelectTooFewOptions({ count: 3, component: "Select" });
104
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("select-too-few-options"));
105
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("suggest=RadioGroup"));
106
+ __resetDevWarnSeen();
107
+ warnSpy.mockClear();
108
+ warnSelectTooFewOptions({ count: 2 });
109
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("suggest=ToggleGroup"));
110
+ __resetDevWarnSeen();
111
+ warnSpy.mockClear();
112
+ warnSelectTooFewOptions({ count: 6 });
113
+ warnSelectTooFewOptions({ count: 3, multiple: true });
114
+ expect(warnSpy).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("positive-tabindex", () => {
118
+ warnPositiveTabIndex({ value: 2, component: "Button" });
119
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("positive-tabindex"));
120
+ __resetDevWarnSeen();
121
+ warnSpy.mockClear();
122
+ warnPositiveTabIndex({ value: 0 });
123
+ warnPositiveTabIndex({ value: -1 });
124
+ expect(warnSpy).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it("tooltip-on-disabled", () => {
128
+ warnTooltipOnDisabled({ childDisabled: true });
129
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("tooltip-on-disabled"));
130
+ __resetDevWarnSeen();
131
+ warnSpy.mockClear();
132
+ warnTooltipOnDisabled({ childDisabled: false });
133
+ expect(warnSpy).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("outline-none (W12 / DG-A11Y-01)", () => {
137
+ warnOutlineNone({ outlineStyle: "none" }, { component: "Input.Box" });
138
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("outline-none"));
139
+ __resetDevWarnSeen();
140
+ warnSpy.mockClear();
141
+ warnOutlineNone({ outlineWidth: 2, outlineStyle: "solid", outlineColor: "$outlineColor" });
142
+ expect(warnSpy).not.toHaveBeenCalled();
143
+ });
144
+ });