@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.
- package/README.md +73 -0
- package/package.json +22 -9
- package/src/dates.spec.ts +75 -0
- package/src/dates.ts +130 -0
- package/src/devWarn.spec.ts +144 -0
- package/src/devWarn.ts +235 -0
- package/src/figma/figmaTokens.spec.ts +244 -0
- package/src/figma/figmaTokens.ts +503 -0
- package/src/font.ts +6 -8
- package/src/fonts/createFontLoader.web.ts +15 -4
- package/src/index.ts +7 -0
- package/src/keyboardFocusRing.ts +45 -0
- package/src/menuRow.spec.ts +32 -0
- package/src/menuRow.ts +31 -0
- package/src/theme/FontKnobStyles.native.tsx +24 -0
- package/src/theme/FontKnobStyles.tsx +256 -0
- package/src/theme/animations/reactNative.ts +6 -1
- package/src/theme/chartPalette.spec.ts +159 -0
- package/src/theme/chartPalette.ts +170 -0
- package/src/theme/colorRules.ts +33 -1
- package/src/theme/cookies.spec.ts +32 -0
- package/src/theme/cookiesPersistence.spec.ts +86 -0
- package/src/theme/cornerSmoothing.native.ts +16 -0
- package/src/theme/cornerSmoothing.spec.ts +27 -0
- package/src/theme/cornerSmoothing.ts +46 -0
- package/src/theme/createDefaultThemeConfig.ts +32 -8
- package/src/theme/createThemeConfig.spec.ts +137 -0
- package/src/theme/createThemes.ts +77 -2
- package/src/theme/defaults/accent.ts +39 -0
- package/src/theme/defaults/base.ts +63 -0
- package/src/theme/defaults/builderOptions.ts +174 -0
- package/src/theme/defaults/fonts.spec.ts +87 -0
- package/src/theme/defaults/fonts.ts +89 -0
- package/src/theme/defaults/index.ts +4 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
- package/src/theme/devtools/buildSnapshot.ts +11 -0
- package/src/theme/devtools/knobIcons.tsx +141 -0
- package/src/theme/devtools/vizConstants.ts +1 -1
- package/src/theme/focusState.spec.ts +174 -0
- package/src/theme/focusState.ts +112 -0
- package/src/theme/fontKnobStyles.spec.ts +98 -0
- package/src/theme/hairline.native.ts +43 -0
- package/src/theme/hairline.spec.ts +63 -0
- package/src/theme/hairline.ts +101 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/intent.spec.tsx +2 -2
- package/src/theme/intents.ts +6 -2
- package/src/theme/knobs.ts +134 -2
- package/src/theme/layoutTokens.spec.ts +75 -0
- package/src/theme/layoutTokens.ts +213 -0
- package/src/theme/layoutTokensHooks.spec.tsx +157 -0
- package/src/theme/presets.spec.ts +17 -0
- package/src/theme/presets.ts +22 -2
- package/src/theme/readableColor.ts +101 -0
- package/src/theme/recipes.ts +130 -7
- package/src/theme/resolveKnobs.spec.ts +560 -21
- package/src/theme/resolveKnobs.ts +267 -23
- package/src/theme/shared.tsx +26 -2
- package/src/theme/useResolvedKnobs.ts +67 -26
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
- package/types/audit/constraintAudit.d.ts +83 -0
- package/types/audit/constraintAudit.d.ts.map +1 -0
- package/types/audit/diffReport.d.ts +45 -0
- package/types/audit/diffReport.d.ts.map +1 -0
- package/types/audit/index.d.ts +5 -0
- package/types/audit/index.d.ts.map +1 -0
- package/types/dates.d.ts +52 -0
- package/types/dates.d.ts.map +1 -0
- package/types/devWarn.d.ts +104 -0
- package/types/devWarn.d.ts.map +1 -0
- package/types/figma/figmaTokens.d.ts +168 -0
- package/types/figma/figmaTokens.d.ts.map +1 -0
- package/types/font.d.ts +5 -0
- package/types/font.d.ts.map +1 -0
- package/types/fonts/createFontLoader.d.ts +3 -0
- package/types/fonts/createFontLoader.d.ts.map +1 -0
- package/types/fonts/createFontLoader.web.d.ts +3 -0
- package/types/fonts/createFontLoader.web.d.ts.map +1 -0
- package/types/fonts/index.d.ts +3 -0
- package/types/fonts/index.d.ts.map +1 -0
- package/types/fonts/types.d.ts +12 -0
- package/types/fonts/types.d.ts.map +1 -0
- package/types/index.d.ts +9 -0
- package/types/index.d.ts.map +1 -0
- package/types/keyboardFocusRing.d.ts +32 -0
- package/types/keyboardFocusRing.d.ts.map +1 -0
- package/types/menuRow.d.ts +32 -0
- package/types/menuRow.d.ts.map +1 -0
- package/types/theme/FontKnobStyles.d.ts +49 -0
- package/types/theme/FontKnobStyles.d.ts.map +1 -0
- package/types/theme/FontKnobStyles.native.d.ts +12 -0
- package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
- package/types/theme/Intent.d.ts +20 -0
- package/types/theme/Intent.d.ts.map +1 -0
- package/types/theme/Preset.d.ts +15 -0
- package/types/theme/Preset.d.ts.map +1 -0
- package/types/theme/PresetContext.d.ts +18 -0
- package/types/theme/PresetContext.d.ts.map +1 -0
- package/types/theme/Tint.d.ts +19 -0
- package/types/theme/Tint.d.ts.map +1 -0
- package/types/theme/animations/css.d.ts +24 -0
- package/types/theme/animations/css.d.ts.map +1 -0
- package/types/theme/animations/index.d.ts +2 -0
- package/types/theme/animations/index.d.ts.map +1 -0
- package/types/theme/animations/index.web.d.ts +2 -0
- package/types/theme/animations/index.web.d.ts.map +1 -0
- package/types/theme/animations/reactNative.d.ts +104 -0
- package/types/theme/animations/reactNative.d.ts.map +1 -0
- package/types/theme/chartPalette.d.ts +62 -0
- package/types/theme/chartPalette.d.ts.map +1 -0
- package/types/theme/colorRules.d.ts +69 -0
- package/types/theme/colorRules.d.ts.map +1 -0
- package/types/theme/cookies.d.ts +76 -0
- package/types/theme/cookies.d.ts.map +1 -0
- package/types/theme/cornerSmoothing.d.ts +31 -0
- package/types/theme/cornerSmoothing.d.ts.map +1 -0
- package/types/theme/cornerSmoothing.native.d.ts +14 -0
- package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
- package/types/theme/createDefaultThemeConfig.d.ts +37 -0
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
- package/types/theme/createThemes.d.ts +38 -0
- package/types/theme/createThemes.d.ts.map +1 -0
- package/types/theme/defaults/accent.d.ts +3 -0
- package/types/theme/defaults/accent.d.ts.map +1 -0
- package/types/theme/defaults/base.d.ts +3 -0
- package/types/theme/defaults/base.d.ts.map +1 -0
- package/types/theme/defaults/builderOptions.d.ts +3 -0
- package/types/theme/defaults/builderOptions.d.ts.map +1 -0
- package/types/theme/defaults/fonts.d.ts +7 -0
- package/types/theme/defaults/fonts.d.ts.map +1 -0
- package/types/theme/defaults/index.d.ts +5 -0
- package/types/theme/defaults/index.d.ts.map +1 -0
- package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
- package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
- package/types/theme/devtools/buildSnapshot.d.ts +12 -0
- package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
- package/types/theme/devtools/knobIcons.d.ts +33 -0
- package/types/theme/devtools/knobIcons.d.ts.map +1 -0
- package/types/theme/devtools/vizConstants.d.ts +5 -0
- package/types/theme/devtools/vizConstants.d.ts.map +1 -0
- package/types/theme/focusState.d.ts +42 -0
- package/types/theme/focusState.d.ts.map +1 -0
- package/types/theme/hairline.d.ts +88 -0
- package/types/theme/hairline.d.ts.map +1 -0
- package/types/theme/hairline.native.d.ts +42 -0
- package/types/theme/hairline.native.d.ts.map +1 -0
- package/types/theme/index.d.ts +29 -0
- package/types/theme/index.d.ts.map +1 -0
- package/types/theme/intents.d.ts +3 -0
- package/types/theme/intents.d.ts.map +1 -0
- package/types/theme/knobs.d.ts +236 -0
- package/types/theme/knobs.d.ts.map +1 -0
- package/types/theme/layoutTokens.d.ts +104 -0
- package/types/theme/layoutTokens.d.ts.map +1 -0
- package/types/theme/preset.types.d.ts +21 -0
- package/types/theme/preset.types.d.ts.map +1 -0
- package/types/theme/presets.d.ts +4 -0
- package/types/theme/presets.d.ts.map +1 -0
- package/types/theme/readableColor.d.ts +17 -0
- package/types/theme/readableColor.d.ts.map +1 -0
- package/types/theme/recipes.d.ts +200 -0
- package/types/theme/recipes.d.ts.map +1 -0
- package/types/theme/resolveKnobs.d.ts +22 -0
- package/types/theme/resolveKnobs.d.ts.map +1 -0
- package/types/theme/shared.d.ts +40 -0
- package/types/theme/shared.d.ts.map +1 -0
- package/types/theme/theme.d.ts +19 -0
- package/types/theme/theme.d.ts.map +1 -0
- package/types/theme/theme.native.d.ts +10 -0
- package/types/theme/theme.native.d.ts.map +1 -0
- package/types/theme/useColorScale.d.ts +7 -0
- package/types/theme/useColorScale.d.ts.map +1 -0
- package/types/theme/useColorScale.native.d.ts +6 -0
- package/types/theme/useColorScale.native.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +32 -0
- package/types/theme/useResolvedKnobs.d.ts.map +1 -0
- package/types/theme/useTheme.d.ts +31 -0
- package/types/theme/useTheme.d.ts.map +1 -0
- package/types/theme/useTheme.native.d.ts +10 -0
- package/types/theme/useTheme.native.d.ts.map +1 -0
- package/CHANGELOG.md +0 -53
- package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
- package/src/theme/pokemonScreen.spec.ts +0 -109
- package/tsconfig.json +0 -11
- 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.
|
|
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": "
|
|
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": "./
|
|
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": "./
|
|
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.
|
|
45
|
-
"@multiplatform.one/platform": "6.
|
|
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/
|
|
61
|
-
"@multiplatform.one/
|
|
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
|
+
});
|