@multiplatform.one/theme 6.6.0 → 7.0.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 (71) hide show
  1. package/package.json +18 -18
  2. package/src/dates.spec.ts +344 -1
  3. package/src/dates.ts +50 -3
  4. package/src/devWarn.ts +29 -1
  5. package/src/figma/figmaTokens.spec.ts +3 -3
  6. package/src/figma/figmaTokens.ts +6 -3
  7. package/src/keyboardFocusRing.ts +61 -5
  8. package/src/menuRow.spec.ts +2 -2
  9. package/src/menuRow.ts +5 -4
  10. package/src/theme/Surface.spec.tsx +146 -0
  11. package/src/theme/Surface.tsx +73 -0
  12. package/src/theme/createDefaultThemeConfig.spec.ts +152 -0
  13. package/src/theme/createDefaultThemeConfig.ts +47 -3
  14. package/src/theme/createThemes.ts +27 -4
  15. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +6 -2
  16. package/src/theme/focusState.ts +9 -1
  17. package/src/theme/groupGeometry.spec.ts +108 -0
  18. package/src/theme/groupGeometry.ts +119 -0
  19. package/src/theme/index.ts +3 -0
  20. package/src/theme/intent.spec.tsx +17 -0
  21. package/src/theme/knobs.ts +2 -2
  22. package/src/theme/layoutTokens.spec.ts +14 -0
  23. package/src/theme/layoutTokens.ts +29 -0
  24. package/src/theme/recipeInputs.ts +368 -0
  25. package/src/theme/recipes.ts +34 -0
  26. package/src/theme/resolveKnobs.spec.ts +91 -9
  27. package/src/theme/resolveKnobs.ts +55 -5
  28. package/src/theme/shared.tsx +11 -0
  29. package/src/theme/sizeRecipes.spec.ts +296 -0
  30. package/src/theme/sizeRecipes.ts +198 -0
  31. package/src/theme/useResolvedKnobs.ts +76 -24
  32. package/src/theme/useResolvedKnobsBehavior.spec.tsx +11 -3
  33. package/types/dates.d.ts +36 -2
  34. package/types/dates.d.ts.map +1 -1
  35. package/types/devWarn.d.ts +16 -1
  36. package/types/devWarn.d.ts.map +1 -1
  37. package/types/figma/figmaTokens.d.ts +1 -1
  38. package/types/figma/figmaTokens.d.ts.map +1 -1
  39. package/types/keyboardFocusRing.d.ts +19 -4
  40. package/types/keyboardFocusRing.d.ts.map +1 -1
  41. package/types/menuRow.d.ts +5 -4
  42. package/types/menuRow.d.ts.map +1 -1
  43. package/types/theme/Surface.d.ts +37 -0
  44. package/types/theme/Surface.d.ts.map +1 -0
  45. package/types/theme/createDefaultThemeConfig.d.ts +14 -3
  46. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  47. package/types/theme/createThemes.d.ts.map +1 -1
  48. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  49. package/types/theme/focusState.d.ts +8 -0
  50. package/types/theme/focusState.d.ts.map +1 -1
  51. package/types/theme/groupGeometry.d.ts +69 -0
  52. package/types/theme/groupGeometry.d.ts.map +1 -0
  53. package/types/theme/index.d.ts +3 -0
  54. package/types/theme/index.d.ts.map +1 -1
  55. package/types/theme/knobs.d.ts +2 -2
  56. package/types/theme/knobs.d.ts.map +1 -1
  57. package/types/theme/layoutTokens.d.ts +22 -0
  58. package/types/theme/layoutTokens.d.ts.map +1 -1
  59. package/types/theme/recipeInputs.d.ts +110 -0
  60. package/types/theme/recipeInputs.d.ts.map +1 -0
  61. package/types/theme/recipes.d.ts +47 -0
  62. package/types/theme/recipes.d.ts.map +1 -1
  63. package/types/theme/resolveKnobs.d.ts +7 -3
  64. package/types/theme/resolveKnobs.d.ts.map +1 -1
  65. package/types/theme/shared.d.ts +9 -0
  66. package/types/theme/shared.d.ts.map +1 -1
  67. package/types/theme/sizeRecipes.d.ts +68 -0
  68. package/types/theme/sizeRecipes.d.ts.map +1 -0
  69. package/types/theme/useResolvedKnobs.d.ts +10 -6
  70. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
  71. 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": "6.6.0",
3
+ "version": "7.0.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.0.0-rc.41",
48
- "@tamagui/config": "2.0.0-rc.41",
49
- "@tamagui/get-token": "2.0.0-rc.41",
50
- "@tamagui/shorthands": "2.0.0-rc.41",
51
- "@tamagui/theme-builder": "2.0.0-rc.41",
52
- "@tamagui/themes": "2.0.0-rc.41",
53
- "@tamagui/toast": "2.0.0-rc.41",
54
- "@tamagui/web": "2.0.0-rc.41",
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/platform": "6.6.0",
57
- "@multiplatform.one/store": "6.6.0"
56
+ "@multiplatform.one/platform": "7.0.0",
57
+ "@multiplatform.one/store": "7.0.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@tamagui/animations-css": "2.0.0-rc.41",
61
- "@tamagui/animations-reanimated": "2.0.0-rc.41",
62
- "@tamagui/font-inter": "2.0.0-rc.41",
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.0.0-rc.41",
69
+ "tamagui": "2.7.6",
70
70
  "typescript": "~5.9.3",
71
71
  "vitest": "^4.1.5",
72
- "@multiplatform.one/config": "6.6.0",
73
- "@multiplatform.one/test-utils": "6.6.0"
72
+ "@multiplatform.one/config": "7.0.0",
73
+ "@multiplatform.one/test-utils": "7.0.0"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "@tamagui/animations-css": "^2.0.0-rc",
@@ -92,7 +92,7 @@
92
92
  },
93
93
  "scripts": {
94
94
  "test": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=lcov --coverage.reporter=html",
95
- "typecheck": "tsc --noEmit",
95
+ "typecheck": "tsgo --noEmit",
96
96
  "build": "rm -rf types 2>/dev/null && tsc -p tsconfig.build.json"
97
97
  }
98
98
  }
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) and the tooltip form for
145
- * relative timestamps.
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: "small",
207
- large: "medium",
206
+ medium: "medium",
207
+ large: "large",
208
208
  });
209
209
  expect(result.knobs.density.compact.space).toEqual({
210
210
  small: "small",
@@ -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 { compactSizeMap, compactSpaceMap, resolveKnobs } from "../theme/resolveKnobs";
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 size/space down one level (applyDensity). */
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: { size: { ...compactSizeMap }, space: { ...compactSpaceMap } },
412
+ compact: {
413
+ size: { small: "small", medium: "medium", large: "large" },
414
+ space: { ...compactSpaceMap },
415
+ },
413
416
  },
414
417
  };
415
418
  }