@multiplatform.one/theme 6.7.0 → 7.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.
- package/package.json +17 -17
- package/src/dates.spec.ts +344 -1
- package/src/dates.ts +50 -3
- package/src/devWarn.ts +29 -1
- package/src/figma/figmaTokens.spec.ts +3 -3
- package/src/figma/figmaTokens.ts +6 -3
- package/src/keyboardFocusRing.ts +61 -5
- package/src/menuRow.spec.ts +2 -2
- package/src/menuRow.ts +5 -4
- package/src/theme/Surface.spec.tsx +146 -0
- package/src/theme/Surface.tsx +73 -0
- package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
- package/src/theme/createDefaultThemeConfig.ts +47 -3
- package/src/theme/createThemes.ts +27 -4
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
- package/src/theme/focusState.ts +9 -1
- package/src/theme/groupGeometry.spec.ts +108 -0
- package/src/theme/groupGeometry.ts +119 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/intent.spec.tsx +17 -0
- package/src/theme/knobs.ts +2 -2
- package/src/theme/layoutTokens.spec.ts +14 -0
- package/src/theme/layoutTokens.ts +29 -0
- package/src/theme/recipeInputs.ts +368 -0
- package/src/theme/recipes.ts +34 -0
- package/src/theme/resolveKnobs.spec.ts +91 -9
- package/src/theme/resolveKnobs.ts +55 -5
- package/src/theme/shared.tsx +11 -0
- package/src/theme/sizeRecipes.spec.ts +296 -0
- package/src/theme/sizeRecipes.ts +198 -0
- package/src/theme/useResolvedKnobs.ts +76 -24
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
- package/types/dates.d.ts +36 -2
- package/types/dates.d.ts.map +1 -1
- package/types/devWarn.d.ts +16 -1
- package/types/devWarn.d.ts.map +1 -1
- package/types/figma/figmaTokens.d.ts +1 -1
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/keyboardFocusRing.d.ts +19 -4
- package/types/keyboardFocusRing.d.ts.map +1 -1
- package/types/menuRow.d.ts +5 -4
- package/types/menuRow.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +37 -0
- package/types/theme/Surface.d.ts.map +1 -0
- package/types/theme/createDefaultThemeConfig.d.ts +14 -3
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/focusState.d.ts +8 -0
- package/types/theme/focusState.d.ts.map +1 -1
- package/types/theme/groupGeometry.d.ts +69 -0
- package/types/theme/groupGeometry.d.ts.map +1 -0
- package/types/theme/index.d.ts +3 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +2 -2
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +22 -0
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +110 -0
- package/types/theme/recipeInputs.d.ts.map +1 -0
- package/types/theme/recipes.d.ts +47 -0
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +7 -3
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/shared.d.ts +9 -0
- package/types/theme/shared.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +68 -0
- package/types/theme/sizeRecipes.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +10 -6
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- package/src/audit/baselines/home-pokemon.json +0 -355
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/theme",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"description": "Tamagui theme system for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"multiplatform",
|
|
@@ -44,33 +44,33 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@tamagui/colors": "2.
|
|
48
|
-
"@tamagui/config": "2.
|
|
49
|
-
"@tamagui/get-token": "2.
|
|
50
|
-
"@tamagui/shorthands": "2.
|
|
51
|
-
"@tamagui/theme-builder": "2.
|
|
52
|
-
"@tamagui/themes": "2.
|
|
53
|
-
"@tamagui/toast": "2.
|
|
54
|
-
"@tamagui/web": "2.
|
|
47
|
+
"@tamagui/colors": "2.7.6",
|
|
48
|
+
"@tamagui/config": "2.7.6",
|
|
49
|
+
"@tamagui/get-token": "2.7.6",
|
|
50
|
+
"@tamagui/shorthands": "2.7.6",
|
|
51
|
+
"@tamagui/theme-builder": "2.7.6",
|
|
52
|
+
"@tamagui/themes": "2.7.6",
|
|
53
|
+
"@tamagui/toast": "2.7.6",
|
|
54
|
+
"@tamagui/web": "2.7.6",
|
|
55
55
|
"react-cookie": "^8.1.2",
|
|
56
|
-
"@multiplatform.one/
|
|
57
|
-
"@multiplatform.one/
|
|
56
|
+
"@multiplatform.one/platform": "7.1.0",
|
|
57
|
+
"@multiplatform.one/store": "7.1.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@tamagui/animations-css": "2.
|
|
61
|
-
"@tamagui/animations-reanimated": "2.
|
|
62
|
-
"@tamagui/font-inter": "2.
|
|
60
|
+
"@tamagui/animations-css": "2.7.6",
|
|
61
|
+
"@tamagui/animations-reanimated": "2.7.6",
|
|
62
|
+
"@tamagui/font-inter": "2.7.6",
|
|
63
63
|
"@testing-library/react": "^16.3.2",
|
|
64
64
|
"@types/react": "~19.2.14",
|
|
65
65
|
"@vitejs/plugin-react": "^6.0.1",
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.5",
|
|
67
67
|
"react": "19.2.5",
|
|
68
68
|
"react-dom": "19.2.5",
|
|
69
|
-
"tamagui": "2.
|
|
69
|
+
"tamagui": "2.7.6",
|
|
70
70
|
"typescript": "~5.9.3",
|
|
71
71
|
"vitest": "^4.1.5",
|
|
72
|
-
"@multiplatform.one/test-utils": "
|
|
73
|
-
"@multiplatform.one/config": "
|
|
72
|
+
"@multiplatform.one/test-utils": "7.1.0",
|
|
73
|
+
"@multiplatform.one/config": "7.1.0"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"@tamagui/animations-css": "^2.0.0-rc",
|
package/src/dates.spec.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
formatAbsoluteDate,
|
|
4
4
|
formatAbsoluteDateTime,
|
|
5
|
+
formatCalendarDate,
|
|
5
6
|
formatDayLong,
|
|
6
7
|
formatDayOfMonth,
|
|
7
8
|
formatMonthYear,
|
|
@@ -48,7 +49,7 @@ describe("formatAbsoluteDate (LC-12 compact absolute register)", () => {
|
|
|
48
49
|
expect(formatAbsoluteDate("not-a-date", enUS)).toBe("not-a-date");
|
|
49
50
|
});
|
|
50
51
|
|
|
51
|
-
it("never emits seconds (LC-12 VERIFY)", () => {
|
|
52
|
+
it("never emits seconds unless asked (LC-12 VERIFY)", () => {
|
|
52
53
|
const out = formatAbsoluteDateTime(new Date(2026, 7, 9, 15, 30, 45), { ...enUS, now });
|
|
53
54
|
expect(out).not.toMatch(/:\d{2}:\d{2}/);
|
|
54
55
|
});
|
|
@@ -66,6 +67,49 @@ describe("formatAbsoluteDateTime (system-event register)", () => {
|
|
|
66
67
|
"Aug 9, 2025, 3:30 PM",
|
|
67
68
|
);
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
// The editing register: both DatetimePicker halves format through this, so
|
|
72
|
+
// the picker's timeFormat / showSeconds contract has to live here or the two
|
|
73
|
+
// halves compose their own and drift (they drifted by the Intl comma).
|
|
74
|
+
describe("editing register (datetime picker display)", () => {
|
|
75
|
+
const at = new Date(2026, 2, 5, 14, 30, 45);
|
|
76
|
+
|
|
77
|
+
it("is the same string both platform halves emit for the same value", () => {
|
|
78
|
+
const web = formatAbsoluteDateTime(at, { ...enUS, year: "always", hour12: true });
|
|
79
|
+
const native = formatAbsoluteDateTime(at, { ...enUS, year: "always" });
|
|
80
|
+
expect(web).toBe("Mar 5, 2026, 2:30 PM");
|
|
81
|
+
expect(native).toBe(web);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("pins the 24-hour clock on request", () => {
|
|
85
|
+
expect(formatAbsoluteDateTime(at, { ...enUS, year: "always", hour12: false })).toBe(
|
|
86
|
+
"Mar 5, 2026, 14:30",
|
|
87
|
+
);
|
|
88
|
+
// Midnight reads 00, not 24 — h23, the clock a picker column shows.
|
|
89
|
+
expect(
|
|
90
|
+
formatAbsoluteDateTime(new Date(2026, 2, 5, 0, 5), {
|
|
91
|
+
...enUS,
|
|
92
|
+
year: "always",
|
|
93
|
+
hour12: false,
|
|
94
|
+
}),
|
|
95
|
+
).toBe("Mar 5, 2026, 00:05");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("appends seconds on request, in both clocks", () => {
|
|
99
|
+
expect(
|
|
100
|
+
formatAbsoluteDateTime(at, { ...enUS, year: "always", hour12: true, seconds: true }),
|
|
101
|
+
).toBe("Mar 5, 2026, 2:30:45 PM");
|
|
102
|
+
expect(
|
|
103
|
+
formatAbsoluteDateTime(at, { ...enUS, year: "always", hour12: false, seconds: true }),
|
|
104
|
+
).toBe("Mar 5, 2026, 14:30:45");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("ignores the time options when there is no time component", () => {
|
|
108
|
+
expect(
|
|
109
|
+
formatAbsoluteDate(at, { ...enUS, year: "always", hour12: false, seconds: true }),
|
|
110
|
+
).toBe("Mar 5, 2026");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
69
113
|
});
|
|
70
114
|
|
|
71
115
|
describe("formatRelativeTimestamp (feed/social register)", () => {
|
|
@@ -230,3 +274,302 @@ describe("parseCalendarDate (day-grid parse)", () => {
|
|
|
230
274
|
expect(inZone("UTC", () => parseCalendarDate("0050-03-10")?.getFullYear())).toBe(50);
|
|
231
275
|
});
|
|
232
276
|
});
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// formatCalendarDate — the write counterpart to parseCalendarDate
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* TWO instants, because one cannot discriminate.
|
|
284
|
+
*
|
|
285
|
+
* The span from UTC−7 to UTC+14 is 21 hours — under a day — so no single
|
|
286
|
+
* instant has UTC disagreeing with both extremes at once. `A` is the evening
|
|
287
|
+
* in Los Angeles (UTC has already rolled over); `B` is the small hours in
|
|
288
|
+
* Kiritimati (UTC has not yet). Together they catch a UTC rendering in either
|
|
289
|
+
* hemisphere.
|
|
290
|
+
*/
|
|
291
|
+
const INSTANT_A = "2026-08-15T05:00:00Z";
|
|
292
|
+
const INSTANT_B = "2026-08-14T11:00:00Z";
|
|
293
|
+
|
|
294
|
+
/** UTC day, local day, and the local wall clock the value carries per zone. */
|
|
295
|
+
const LOCAL_READINGS: Record<string, Record<string, { day: string; clock: string }>> = {
|
|
296
|
+
[INSTANT_A]: {
|
|
297
|
+
UTC: { day: "2026-08-15", clock: "05:00:00" },
|
|
298
|
+
"America/Los_Angeles": { day: "2026-08-14", clock: "22:00:00" },
|
|
299
|
+
"America/New_York": { day: "2026-08-15", clock: "01:00:00" },
|
|
300
|
+
"Pacific/Kiritimati": { day: "2026-08-15", clock: "19:00:00" },
|
|
301
|
+
},
|
|
302
|
+
[INSTANT_B]: {
|
|
303
|
+
UTC: { day: "2026-08-14", clock: "11:00:00" },
|
|
304
|
+
"America/Los_Angeles": { day: "2026-08-14", clock: "04:00:00" },
|
|
305
|
+
"America/New_York": { day: "2026-08-14", clock: "07:00:00" },
|
|
306
|
+
"Pacific/Kiritimati": { day: "2026-08-15", clock: "01:00:00" },
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
describe("formatCalendarDate (local-parts wire serializer)", () => {
|
|
311
|
+
it("names the local calendar day at both instants in every timezone", () => {
|
|
312
|
+
const rows = [INSTANT_A, INSTANT_B].flatMap((instant) =>
|
|
313
|
+
ZONES.map((timeZone) => ({
|
|
314
|
+
instant,
|
|
315
|
+
timeZone,
|
|
316
|
+
wrote: inZone(timeZone, () => formatCalendarDate(new Date(instant))),
|
|
317
|
+
})),
|
|
318
|
+
);
|
|
319
|
+
expect(rows).toEqual(
|
|
320
|
+
rows.map(({ instant, timeZone }) => ({
|
|
321
|
+
instant,
|
|
322
|
+
timeZone,
|
|
323
|
+
wrote: LOCAL_READINGS[instant][timeZone].day,
|
|
324
|
+
})),
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("negative control: the toISOString() formulation fails this expectation", () => {
|
|
329
|
+
// The deleted expression, verbatim, through the identical matrix. Asserting
|
|
330
|
+
// the disagreements as a LIST proves two things at once: the old code is
|
|
331
|
+
// wrong where the fix is right, and it agrees in the other six rows — so
|
|
332
|
+
// the matrix above is not passing vacuously on instants where every zone
|
|
333
|
+
// happens to land on the same day.
|
|
334
|
+
const isoDay = (date: Date) => date.toISOString().slice(0, 10);
|
|
335
|
+
const disagreements = [INSTANT_A, INSTANT_B]
|
|
336
|
+
.flatMap((instant) =>
|
|
337
|
+
ZONES.map((timeZone) =>
|
|
338
|
+
inZone(timeZone, () => {
|
|
339
|
+
const wrote = isoDay(new Date(instant));
|
|
340
|
+
const shouldBe = LOCAL_READINGS[instant][timeZone].day;
|
|
341
|
+
return wrote === shouldBe ? null : { instant, timeZone, wrote, shouldBe };
|
|
342
|
+
}),
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
.filter(Boolean);
|
|
346
|
+
expect(disagreements).toEqual([
|
|
347
|
+
{
|
|
348
|
+
instant: INSTANT_A,
|
|
349
|
+
timeZone: "America/Los_Angeles",
|
|
350
|
+
wrote: "2026-08-15",
|
|
351
|
+
shouldBe: "2026-08-14",
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
instant: INSTANT_B,
|
|
355
|
+
timeZone: "Pacific/Kiritimati",
|
|
356
|
+
wrote: "2026-08-14",
|
|
357
|
+
shouldBe: "2026-08-15",
|
|
358
|
+
},
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("host guard: process.env.TZ actually moves the local reading in-process", () => {
|
|
363
|
+
// Without this, a host ignoring TZ would collapse `inZone` to a no-op and
|
|
364
|
+
// the matrices above would stop meaning anything. The negative control
|
|
365
|
+
// alone cannot cover it — `toISOString()` is UTC on a broken host too, so
|
|
366
|
+
// it would keep "failing" correctly.
|
|
367
|
+
expect(inZone("America/Los_Angeles", () => new Date(INSTANT_A).getDate())).toBe(14);
|
|
368
|
+
expect(inZone("Pacific/Kiritimati", () => new Date(INSTANT_B).getDate())).toBe(15);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("writes the local wall clock for datetime and time shapes", () => {
|
|
372
|
+
for (const instant of [INSTANT_A, INSTANT_B]) {
|
|
373
|
+
for (const timeZone of ZONES) {
|
|
374
|
+
const { day, clock } = LOCAL_READINGS[instant][timeZone];
|
|
375
|
+
expect({
|
|
376
|
+
instant,
|
|
377
|
+
timeZone,
|
|
378
|
+
datetime: inZone(timeZone, () => formatCalendarDate(new Date(instant), "datetime")),
|
|
379
|
+
time: inZone(timeZone, () => formatCalendarDate(new Date(instant), "time")),
|
|
380
|
+
}).toEqual({ instant, timeZone, datetime: `${day} ${clock}`, time: clock });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("negative control: toISOString() shifts a datetime by the whole offset", () => {
|
|
386
|
+
// The other deleted branch: `.slice(0, 16).replace("T", " ")`. Reading it
|
|
387
|
+
// back as local wall clock walks the instant forward on every save.
|
|
388
|
+
const isoDatetime = (date: Date) => date.toISOString().slice(0, 16).replace("T", " ");
|
|
389
|
+
expect(inZone("America/Los_Angeles", () => isoDatetime(new Date(INSTANT_A)))).toBe(
|
|
390
|
+
"2026-08-15 05:00",
|
|
391
|
+
);
|
|
392
|
+
expect(
|
|
393
|
+
inZone("America/Los_Angeles", () => formatCalendarDate(new Date(INSTANT_A), "datetime")),
|
|
394
|
+
).toBe("2026-08-14 22:00:00");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("round-trips through parseCalendarDate in every timezone", () => {
|
|
398
|
+
for (const timeZone of ZONES) {
|
|
399
|
+
for (const wire of ["2026-08-15", "2026-08-15 14:30:00"]) {
|
|
400
|
+
const shape = wire.includes(":") ? "datetime" : "date";
|
|
401
|
+
expect({
|
|
402
|
+
timeZone,
|
|
403
|
+
wire,
|
|
404
|
+
back: inZone(timeZone, () => formatCalendarDate(wire, shape)),
|
|
405
|
+
}).toEqual({ timeZone, wire, back: wire });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("serializes the input shapes the call sites hand it", () => {
|
|
411
|
+
// Built AND read inside the zone: a Date is an instant, so constructing it
|
|
412
|
+
// on the host clock and reading it at UTC+14 would name a different day.
|
|
413
|
+
const at = (make: () => DateInput, shape?: "date" | "datetime" | "time") =>
|
|
414
|
+
inZone("Pacific/Kiritimati", () => formatCalendarDate(make(), shape));
|
|
415
|
+
const picked = () => new Date(2026, 7, 15, 14, 30, 5);
|
|
416
|
+
// A Date — what every replaced copy took.
|
|
417
|
+
expect(at(picked)).toBe("2026-08-15");
|
|
418
|
+
expect(at(picked, "datetime")).toBe("2026-08-15 14:30:05");
|
|
419
|
+
expect(at(picked, "time")).toBe("14:30:05");
|
|
420
|
+
// Already on the wire — idempotent, not shifted a day by a UTC re-read.
|
|
421
|
+
expect(at(() => "2026-08-15")).toBe("2026-08-15");
|
|
422
|
+
expect(at(() => "2026-08-15 14:30:05", "datetime")).toBe("2026-08-15 14:30:05");
|
|
423
|
+
// Empty and unparseable are "", the honest passthrough the formatters use.
|
|
424
|
+
expect(at(() => null)).toBe("");
|
|
425
|
+
expect(at(() => undefined)).toBe("");
|
|
426
|
+
expect(at(() => "")).toBe("");
|
|
427
|
+
expect(at(() => "not-a-date")).toBe("");
|
|
428
|
+
expect(at(() => new Date("nonsense"))).toBe("");
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// The consolidation: one body where there were eight
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Each replaced copy's string-building body, pasted VERBATIM from the tree
|
|
438
|
+
* before this change. The point is not that they look alike — it is that the
|
|
439
|
+
* canonical helper emits the same bytes for the same input, so repointing the
|
|
440
|
+
* call sites moved no behavior.
|
|
441
|
+
*/
|
|
442
|
+
const COPIES = {
|
|
443
|
+
// public/frappe-ui/src/fields/Date/wire.ts — toFrappeDateValue
|
|
444
|
+
"frappe-ui wire.ts": (date: Date, shape: string) => {
|
|
445
|
+
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
446
|
+
const datePart = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
447
|
+
const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
|
448
|
+
if (shape === "time") return timePart;
|
|
449
|
+
if (shape === "datetime") return `${datePart} ${timePart}`;
|
|
450
|
+
return datePart;
|
|
451
|
+
},
|
|
452
|
+
// public/frappe-ui/src/FrappeCalendar.tsx — toFrappeFieldValue
|
|
453
|
+
"frappe-ui FrappeCalendar.tsx": (next: Date, shape: string) => {
|
|
454
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
455
|
+
const datePart = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
|
|
456
|
+
if (shape === "datetime") {
|
|
457
|
+
return `${datePart} ${pad(next.getHours())}:${pad(next.getMinutes())}:${pad(next.getSeconds())}`;
|
|
458
|
+
}
|
|
459
|
+
return datePart;
|
|
460
|
+
},
|
|
461
|
+
// public/frappe-ui/src/FrappeGantt.tsx — toFrappeFieldValue (identical body)
|
|
462
|
+
"frappe-ui FrappeGantt.tsx": (next: Date, shape: string) => {
|
|
463
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
464
|
+
const datePart = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
|
|
465
|
+
if (shape === "datetime") {
|
|
466
|
+
return `${datePart} ${pad(next.getHours())}:${pad(next.getMinutes())}:${pad(next.getSeconds())}`;
|
|
467
|
+
}
|
|
468
|
+
return datePart;
|
|
469
|
+
},
|
|
470
|
+
// public/frappe-ui/src/FrappeReportView.tsx — toFrappeDate
|
|
471
|
+
"frappe-ui FrappeReportView.tsx": (date: Date) => {
|
|
472
|
+
const y = date.getFullYear();
|
|
473
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
474
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
475
|
+
return `${y}-${m}-${d}`;
|
|
476
|
+
},
|
|
477
|
+
// public/backoffice/src/views/CalendarView.tsx — toFrappeDateValue
|
|
478
|
+
"backoffice CalendarView.tsx": (date: Date, shape: string) => {
|
|
479
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
480
|
+
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
481
|
+
if (shape !== "datetime") return datePart;
|
|
482
|
+
return `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
483
|
+
},
|
|
484
|
+
// public/backoffice/src/views/dashboard-data.tsx — isoDate
|
|
485
|
+
"backoffice dashboard-data.tsx": (date: Date) => {
|
|
486
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
487
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
488
|
+
},
|
|
489
|
+
// public/backoffice/src/form/formLayout.ts — todayISO / nowTime
|
|
490
|
+
"backoffice formLayout.ts": (now: Date, shape: string) => {
|
|
491
|
+
if (shape === "time") {
|
|
492
|
+
const hours = String(now.getHours()).padStart(2, "0");
|
|
493
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
494
|
+
const seconds = String(now.getSeconds()).padStart(2, "0");
|
|
495
|
+
return `${hours}:${minutes}:${seconds}`;
|
|
496
|
+
}
|
|
497
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
498
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
499
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
500
|
+
},
|
|
501
|
+
// public/backoffice/src/quickEntry/quickEntryPlan.ts — todayISO
|
|
502
|
+
"backoffice quickEntryPlan.ts": (now: Date) => {
|
|
503
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
504
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
505
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
506
|
+
},
|
|
507
|
+
// public/components/src/views/Calendar.tsx — formatDateKey
|
|
508
|
+
"components Calendar.tsx": (date: Date) => {
|
|
509
|
+
const year = date.getFullYear();
|
|
510
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
511
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
512
|
+
return `${year}-${month}-${day}`;
|
|
513
|
+
},
|
|
514
|
+
} satisfies Record<string, (date: Date, shape: string) => string>;
|
|
515
|
+
|
|
516
|
+
/** Which shapes each copy could emit — nothing is claimed beyond that. */
|
|
517
|
+
const COPY_SHAPES: Record<keyof typeof COPIES, readonly ("date" | "datetime" | "time")[]> = {
|
|
518
|
+
"frappe-ui wire.ts": ["date", "datetime", "time"],
|
|
519
|
+
"frappe-ui FrappeCalendar.tsx": ["date", "datetime"],
|
|
520
|
+
"frappe-ui FrappeGantt.tsx": ["date", "datetime"],
|
|
521
|
+
"frappe-ui FrappeReportView.tsx": ["date"],
|
|
522
|
+
"backoffice CalendarView.tsx": ["date", "datetime"],
|
|
523
|
+
"backoffice dashboard-data.tsx": ["date"],
|
|
524
|
+
"backoffice formLayout.ts": ["date", "time"],
|
|
525
|
+
"backoffice quickEntryPlan.ts": ["date"],
|
|
526
|
+
"components Calendar.tsx": ["date"],
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
describe("formatCalendarDate is byte-identical to the copies it replaced", () => {
|
|
530
|
+
// Every Date the copies could be handed: instants either side of the UTC
|
|
531
|
+
// date boundary, a midnight, a zero-padding case, and the unpadded-year
|
|
532
|
+
// quirk all nine shared (`${getFullYear()}` never pads below 1000).
|
|
533
|
+
const SAMPLES = [
|
|
534
|
+
new Date(INSTANT_A),
|
|
535
|
+
new Date(INSTANT_B),
|
|
536
|
+
new Date(2026, 7, 15, 0, 0, 0),
|
|
537
|
+
new Date(2026, 0, 5, 9, 8, 7),
|
|
538
|
+
new Date(2026, 11, 31, 23, 59, 59),
|
|
539
|
+
(() => {
|
|
540
|
+
const early = new Date(2026, 2, 10);
|
|
541
|
+
early.setFullYear(99);
|
|
542
|
+
return early;
|
|
543
|
+
})(),
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
for (const [name, copy] of Object.entries(COPIES)) {
|
|
547
|
+
it(`${name}`, () => {
|
|
548
|
+
const disagreements: unknown[] = [];
|
|
549
|
+
for (const timeZone of ZONES) {
|
|
550
|
+
for (const sample of SAMPLES) {
|
|
551
|
+
for (const shape of COPY_SHAPES[name as keyof typeof COPIES]) {
|
|
552
|
+
inZone(timeZone, () => {
|
|
553
|
+
const was = copy(sample, shape);
|
|
554
|
+
const now = formatCalendarDate(sample, shape);
|
|
555
|
+
if (was !== now) disagreements.push({ timeZone, shape, was, now });
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
expect(disagreements).toEqual([]);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
it("the samples are discriminating — a UTC rendering would fail this", () => {
|
|
565
|
+
// Guards the loop above: if every sample landed on the same day in every
|
|
566
|
+
// zone, "byte-identical" would be true of a broken helper too.
|
|
567
|
+
const utcRendering = (date: Date) => date.toISOString().slice(0, 10);
|
|
568
|
+
const disagreements = ZONES.flatMap((timeZone) =>
|
|
569
|
+
SAMPLES.filter(
|
|
570
|
+
(sample) => inZone(timeZone, () => formatCalendarDate(sample)) !== utcRendering(sample),
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
expect(disagreements.length).toBeGreaterThan(0);
|
|
574
|
+
});
|
|
575
|
+
});
|
package/src/dates.ts
CHANGED
|
@@ -33,6 +33,19 @@ export interface AbsoluteDateFormatOptions {
|
|
|
33
33
|
year?: "auto" | "always" | "never";
|
|
34
34
|
/** Include the time component ("Aug 9, 3:30 PM"). Default false. */
|
|
35
35
|
withTime?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Append seconds to the time component ("Aug 9, 3:30:45 PM"). Off by
|
|
38
|
+
* default — no register emits seconds unless a surface asks. Only the
|
|
39
|
+
* editing register does: a `showSeconds` picker has to display the second
|
|
40
|
+
* it lets you set. Ignored without `withTime`.
|
|
41
|
+
*/
|
|
42
|
+
seconds?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Pin the 12/24-hour clock instead of following the locale — the
|
|
45
|
+
* `timeFormat` contract a datetime picker exposes to its consumers.
|
|
46
|
+
* Ignored without `withTime`.
|
|
47
|
+
*/
|
|
48
|
+
hour12?: boolean;
|
|
36
49
|
/** BCP-47 locale forwarded to Intl. Default: system locale. */
|
|
37
50
|
locale?: string;
|
|
38
51
|
/** Reference date for current-year elision (injectable for tests). */
|
|
@@ -102,6 +115,36 @@ export function parseCalendarDate(value: DateInput): Date | null {
|
|
|
102
115
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
/** Timezone-naive wire shapes: Frappe's `Date`, `Datetime` and `Time`. */
|
|
119
|
+
export type CalendarDateShape = "date" | "datetime" | "time";
|
|
120
|
+
|
|
121
|
+
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Serialize a date onto the LOCAL calendar parts it names — the write
|
|
125
|
+
* counterpart to `parseCalendarDate`, and the only place the repo builds a
|
|
126
|
+
* timezone-naive wire string.
|
|
127
|
+
*
|
|
128
|
+
* `"date"` emits `YYYY-MM-DD`, `"datetime"` emits `YYYY-MM-DD HH:mm:ss`, and
|
|
129
|
+
* `"time"` emits `HH:mm:ss` — the shapes Frappe stores, none of which carries
|
|
130
|
+
* an offset. `toISOString()` renders the UTC instant instead, so it names the
|
|
131
|
+
* wrong day whenever the value's local time of day sits across the UTC date
|
|
132
|
+
* boundary, and shifts a wall clock by the whole offset. Reading such a string
|
|
133
|
+
* back through `parseCalendarDate` then compounds the error, because the read
|
|
134
|
+
* side treats it as local.
|
|
135
|
+
*
|
|
136
|
+
* Accepts anything `parseCalendarDate` does, so a value already on the wire
|
|
137
|
+
* round-trips unchanged; empty and unparseable input serialize to "".
|
|
138
|
+
*/
|
|
139
|
+
export function formatCalendarDate(value: DateInput, shape: CalendarDateShape = "date"): string {
|
|
140
|
+
const date = parseCalendarDate(value);
|
|
141
|
+
if (!date) return "";
|
|
142
|
+
const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
|
143
|
+
if (shape === "time") return timePart;
|
|
144
|
+
const datePart = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
145
|
+
return shape === "datetime" ? `${datePart} ${timePart}` : datePart;
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
/**
|
|
106
149
|
* Compact absolute date — the house register for every absolute date.
|
|
107
150
|
* "Jul 1" / "Jul 1, 2025" (year auto-elided when current), or with
|
|
@@ -115,7 +158,7 @@ export function formatAbsoluteDate(
|
|
|
115
158
|
const coerced = coerceDateInput(value);
|
|
116
159
|
if (!coerced) return typeof value === "string" ? value : "";
|
|
117
160
|
const { date, dateOnly } = coerced;
|
|
118
|
-
const { year = "auto", withTime = false, locale, now } = options;
|
|
161
|
+
const { year = "auto", withTime = false, seconds = false, hour12, locale, now } = options;
|
|
119
162
|
const referenceYear = (now ?? new Date()).getFullYear();
|
|
120
163
|
const displayYear = dateOnly ? date.getUTCFullYear() : date.getFullYear();
|
|
121
164
|
const intlOptions: Intl.DateTimeFormatOptions = {
|
|
@@ -128,6 +171,8 @@ export function formatAbsoluteDate(
|
|
|
128
171
|
if (withTime) {
|
|
129
172
|
intlOptions.hour = "numeric";
|
|
130
173
|
intlOptions.minute = "2-digit";
|
|
174
|
+
if (seconds) intlOptions.second = "2-digit";
|
|
175
|
+
if (hour12 !== undefined) intlOptions.hour12 = hour12;
|
|
131
176
|
}
|
|
132
177
|
if (dateOnly) {
|
|
133
178
|
intlOptions.timeZone = "UTC";
|
|
@@ -141,8 +186,10 @@ export function formatAbsoluteDate(
|
|
|
141
186
|
|
|
142
187
|
/**
|
|
143
188
|
* Compact absolute date-time ("Aug 9, 3:30 PM"; year added when not
|
|
144
|
-
* current). The system-event register (LC-12)
|
|
145
|
-
*
|
|
189
|
+
* current). The system-event register (LC-12), the tooltip form for relative
|
|
190
|
+
* timestamps, and — with `year: "always"` — the datetime picker display on
|
|
191
|
+
* BOTH platform halves, which is why it takes `hour12` / `seconds`: the two
|
|
192
|
+
* halves used to compose their own and drifted by a comma.
|
|
146
193
|
*/
|
|
147
194
|
export function formatAbsoluteDateTime(
|
|
148
195
|
value: DateInput,
|
package/src/devWarn.ts
CHANGED
|
@@ -19,7 +19,14 @@ export type DevWarnCode =
|
|
|
19
19
|
| "outline-none"
|
|
20
20
|
// DG-A11Y-02 — a second level-1 heading mounted under one page scope.
|
|
21
21
|
// Emitted by PageHeadingScope in @multiplatform.one/components.
|
|
22
|
-
| "multiple-h1"
|
|
22
|
+
| "multiple-h1"
|
|
23
|
+
// Stacked-group geometry (theme groupGeometry): a group container could
|
|
24
|
+
// not resolve its items' first/middle/last positions because an item is
|
|
25
|
+
// hidden behind a component boundary — corners fall back to "only"
|
|
26
|
+
// (uniformly rounded), which is the defect the geometry rule bans.
|
|
27
|
+
| "opaque-stack-group"
|
|
28
|
+
/** Numeric height/paddingHorizontal on a recipe control without sizeRecipeEscape. */
|
|
29
|
+
| "size-recipe-escape";
|
|
23
30
|
|
|
24
31
|
export type DevWarnDetail = {
|
|
25
32
|
component?: string;
|
|
@@ -164,6 +171,27 @@ export function warnBareDisabled(args: {
|
|
|
164
171
|
devWarn("bare-disabled", { component: args.component, id: args.id });
|
|
165
172
|
}
|
|
166
173
|
|
|
174
|
+
/**
|
|
175
|
+
* LC-65 — numeric `height` / `paddingHorizontal` on a size-recipe control
|
|
176
|
+
* (Button, Input.Box) must opt in with `sizeRecipeEscape="reason"`. Warns
|
|
177
|
+
* once per component+id like `warnBareDisabled`; does not invent a general
|
|
178
|
+
* style escape.
|
|
179
|
+
*/
|
|
180
|
+
export function warnSizeRecipeEscape(args: {
|
|
181
|
+
height?: unknown;
|
|
182
|
+
paddingHorizontal?: unknown;
|
|
183
|
+
sizeRecipeEscape?: string | null;
|
|
184
|
+
component?: string;
|
|
185
|
+
id?: string;
|
|
186
|
+
}): void {
|
|
187
|
+
if (!isDev()) return;
|
|
188
|
+
const numericHeight = typeof args.height === "number";
|
|
189
|
+
const numericPad = typeof args.paddingHorizontal === "number";
|
|
190
|
+
if (!numericHeight && !numericPad) return;
|
|
191
|
+
if (typeof args.sizeRecipeEscape === "string" && args.sizeRecipeEscape.trim()) return;
|
|
192
|
+
devWarn("size-recipe-escape", { component: args.component, id: args.id });
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
/**
|
|
168
196
|
* DG-ACT-05 — the cancel/dismiss action never renders disabled (while confirm
|
|
169
197
|
* may be). Enforcing callers strip the prop and report the attempt here.
|
|
@@ -199,12 +199,12 @@ describe("buildFigmaTokens", () => {
|
|
|
199
199
|
expect(result.knobs.cornerSmoothing.modes).toEqual({ round: 0, smooth: 0.6 });
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
-
it("exports density compact stepping", () => {
|
|
202
|
+
it("exports density compact stepping for space only (size is uncoupled)", () => {
|
|
203
203
|
expect(result.knobs.density.default).toBe("comfortable");
|
|
204
204
|
expect(result.knobs.density.compact.size).toEqual({
|
|
205
205
|
small: "small",
|
|
206
|
-
medium: "
|
|
207
|
-
large: "
|
|
206
|
+
medium: "medium",
|
|
207
|
+
large: "large",
|
|
208
208
|
});
|
|
209
209
|
expect(result.knobs.density.compact.space).toEqual({
|
|
210
210
|
small: "small",
|
package/src/figma/figmaTokens.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { defaultBaseTheme } from "../theme/defaults/base";
|
|
|
32
32
|
import { defaultBuilderOptions } from "../theme/defaults/builderOptions";
|
|
33
33
|
import { defaultBodyFont, defaultHeadingFont } from "../theme/defaults/fonts";
|
|
34
34
|
import { type Knobs, defaultKnobs } from "../theme/knobs";
|
|
35
|
-
import {
|
|
35
|
+
import { compactSpaceMap, resolveKnobs } from "../theme/resolveKnobs";
|
|
36
36
|
|
|
37
37
|
// ── Names / constants ─────────────────────────────────────────
|
|
38
38
|
|
|
@@ -124,7 +124,7 @@ export interface FigmaKnobsExport {
|
|
|
124
124
|
cornerSmoothing: { default: string; modes: Record<string, number> };
|
|
125
125
|
density: {
|
|
126
126
|
default: Knobs["density"];
|
|
127
|
-
/** compact steps
|
|
127
|
+
/** compact steps space only (applyDensity); size is an independent axis. */
|
|
128
128
|
compact: { size: Record<string, string>; space: Record<string, string> };
|
|
129
129
|
};
|
|
130
130
|
}
|
|
@@ -409,7 +409,10 @@ function buildKnobs(): FigmaKnobsExport {
|
|
|
409
409
|
},
|
|
410
410
|
density: {
|
|
411
411
|
default: defaultKnobs.density,
|
|
412
|
-
compact: {
|
|
412
|
+
compact: {
|
|
413
|
+
size: { small: "small", medium: "medium", large: "large" },
|
|
414
|
+
space: { ...compactSpaceMap },
|
|
415
|
+
},
|
|
413
416
|
},
|
|
414
417
|
};
|
|
415
418
|
}
|
package/src/keyboardFocusRing.ts
CHANGED
|
@@ -36,10 +36,66 @@ export function wasKeyboardFocus(): boolean {
|
|
|
36
36
|
return lastInputWasKeyboard;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
import { FOCUS_VISIBLE_RING } from "./theme/focusState";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Standard manual ring props — the SAME geometry as `FOCUS_VISIBLE_RING`
|
|
43
|
+
* (2px solid `$outlineColor`, inset −2), spread here so manually-painted
|
|
44
|
+
* rings and `focusVisibleStyle` rings can never drift apart.
|
|
45
|
+
*/
|
|
40
46
|
export const keyboardFocusRingProps = {
|
|
41
|
-
|
|
42
|
-
outlineWidth: 2,
|
|
43
|
-
outlineStyle: "solid",
|
|
44
|
-
outlineOffset: -2,
|
|
47
|
+
...FOCUS_VISIBLE_RING,
|
|
45
48
|
} as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* LC-71 RING-ANATOMY — CSS-synchronous ring on a composite's outer boundary.
|
|
52
|
+
*
|
|
53
|
+
* `:has(:focus-visible)` paints the same frame the keyboard heuristic would,
|
|
54
|
+
* with zero React latency (the prior `focused` JS path lagged one frame and
|
|
55
|
+
* spawned the inner-Area ring cascade). Inner targets suppress their own
|
|
56
|
+
* outline so the box carries exactly one ring. Class: `mp-composite-ring`.
|
|
57
|
+
*/
|
|
58
|
+
export const compositeFocusRingCss = `/* LC-71: ring on the composite outer box; never on the inner field.
|
|
59
|
+
Inset (−2px): these frames use overflow:hidden to clip children to radius,
|
|
60
|
+
which severs an outer ring. Do NOT use :has(:focus-visible) on every
|
|
61
|
+
descendant — that would ring the whole SearchInput when the clear ✕ is
|
|
62
|
+
focused (two focusables → two rings, chip-dismiss rule). */
|
|
63
|
+
.mp-composite-ring:focus-visible,
|
|
64
|
+
.mp-composite-ring:has(input:focus),
|
|
65
|
+
.mp-composite-ring:has(textarea:focus),
|
|
66
|
+
.mp-composite-ring:has(.mp-input-area:focus) {
|
|
67
|
+
outline: 2px solid var(--outlineColor, var(--c-outlineColor, CanvasText));
|
|
68
|
+
outline-offset: -2px;
|
|
69
|
+
}
|
|
70
|
+
.mp-composite-ring-deep:has(:focus-visible) {
|
|
71
|
+
outline: 2px solid var(--outlineColor, var(--c-outlineColor, CanvasText));
|
|
72
|
+
outline-offset: -2px;
|
|
73
|
+
}
|
|
74
|
+
.mp-composite-ring input,
|
|
75
|
+
.mp-composite-ring textarea,
|
|
76
|
+
.mp-composite-ring [contenteditable],
|
|
77
|
+
.mp-input-area,
|
|
78
|
+
.mp-input-area:focus,
|
|
79
|
+
.mp-input-area:focus-visible {
|
|
80
|
+
outline: none !important;
|
|
81
|
+
box-shadow: none !important;
|
|
82
|
+
}
|
|
83
|
+
.mp-chip-dismiss:focus-visible {
|
|
84
|
+
outline: none !important;
|
|
85
|
+
}
|
|
86
|
+
.mp-chip-dismiss:focus-visible .mp-chip-dismiss-ring {
|
|
87
|
+
outline: 2px solid var(--outlineColor, var(--c-outlineColor, CanvasText));
|
|
88
|
+
outline-offset: 2px;
|
|
89
|
+
}`;
|
|
90
|
+
|
|
91
|
+
const COMPOSITE_RING_STYLE_ID = "mp-composite-focus-ring";
|
|
92
|
+
|
|
93
|
+
/** Idempotently mount the composite-boundary focus-ring stylesheet. */
|
|
94
|
+
export function ensureCompositeFocusRing(): void {
|
|
95
|
+
if (typeof document === "undefined") return;
|
|
96
|
+
if (document.getElementById(COMPOSITE_RING_STYLE_ID)) return;
|
|
97
|
+
const tag = document.createElement("style");
|
|
98
|
+
tag.id = COMPOSITE_RING_STYLE_ID;
|
|
99
|
+
tag.textContent = compositeFocusRingCss;
|
|
100
|
+
document.head.appendChild(tag);
|
|
101
|
+
}
|