@ai-matrx/kit 0.7.3 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-09-07
4
+
5
+ **THE HOME FOR THE FLEET'S SHARED FORMATTERS.** Three new pure subpaths —
6
+ `./format`, `./html-escape`, `./uuid` — closing the census's headline finding
7
+ H1: *the packages were duplicating each other*, which is why ~65 host twins had
8
+ nowhere correct to point.
9
+
10
+ **Why kit is the home.** `node scripts/check_ts_sibling_graph.mjs --order`
11
+ reports `@ai-matrx/kit` with ZERO sibling dependencies — it is at the bottom of
12
+ the sibling DAG, so `meet`, `messaging`, `associations`, `diff`, `media` and
13
+ `print` can all depend on it without creating a cycle, and so can every host
14
+ app in every repo. It is also, by charter, the "little primitives every Matrx
15
+ app speaks" package. No new package was created (new packages need Arman's
16
+ approval), and nothing here re-exports from a sibling: these bodies now live in
17
+ exactly one place.
18
+
19
+ ### `./format` — durations, relative time, byte sizes, initials
20
+
21
+ Absorbed from `@ai-matrx/meet` (`formatDuration`, `getInitials`,
22
+ `avatarPaletteIndex`), `@ai-matrx/messaging` (`getInitials`,
23
+ `avatarPaletteIndex`), `@ai-matrx/associations/react` (`formatRelativeTime`),
24
+ `@ai-matrx/diff` (`formatRelativeTime`, `parseTimestamp`, `formatAbsoluteDate`)
25
+ and `@ai-matrx/media/react` (`formatFileSize`). Every behaviour those five
26
+ asserted is re-asserted in `src/format.test.ts` (60 tests across the three new
27
+ modules).
28
+
29
+ **THE UNIT LAW.** There is deliberately no `formatDuration`. The fleet's twins
30
+ variously took milliseconds, seconds and minutes behind an identical signature.
31
+ The unit is now in the name: `formatDurationMs`, `formatDurationSeconds`,
32
+ `formatDurationMinutes` — plus `durationMsBetween(from, to, now)` for the
33
+ "start, maybe-end, else now" pattern six surfaces had rebuilt.
34
+
35
+ **The display decisions, made once and deliberately:**
36
+
37
+ - **Three duration voices**, chosen by `style`, because the fleet genuinely
38
+ speaks three and pretending otherwise would have been the wrong unification:
39
+ `"clock"` (default) `0:00` / `9:04` / `1:02:33` for anything a human watches
40
+ tick; `"compact"` `250ms` / `5.2s` / `5m 30s` / `1h 02m` for elapsed work;
41
+ `"coarse"` `45 min` / `1h 30m` for lengths read at a glance. `compact` keeps
42
+ one decimal below ten seconds and whole seconds above it; `coarse` prints
43
+ `< 1 min` rather than a false `0 min`.
44
+ - **A negative duration clamps to zero** unless `signed: true`. A negative
45
+ elapsed time is nearly always a clock bug.
46
+ - **Unknown is never zero.** `null` / `undefined` / `NaN` / `Infinity` take an
47
+ explicit `fallback` (`"0:00"` for clock, `"—"` otherwise). A confident
48
+ `"0 B"` or `"0:00"` for "we do not know" is a screen telling a lie.
49
+ - **`formatFileSize`**: binary units with short labels (`B`/`KB`/…/`PB`, never
50
+ `"Bytes"`), one decimal below ten in a unit and whole numbers above
51
+ (`2.0 KB`, `15 KB`), whole bytes below 1 KB, em-dash for unknown/negative.
52
+ - **`formatRelativeTime`** unifies the two packages that disagreed about who
53
+ owned it, via `style`: `"short"` (`2m ago`, the dense default),
54
+ `"long"` (`2 minutes ago`), `"intl"` (`Intl.RelativeTimeFormat` with
55
+ `numeric: "auto"`, which is what the comments face wanted). `fallbackToInput`
56
+ echoes an unparseable string instead of an em-dash — degraded, never broken.
57
+ It runs over `parseTimestamp`, which carries diff's real correction: a
58
+ zone-less Postgres `timestamp without time zone` is read as UTC, not local,
59
+ killing the classic "times are off by N hours" report for every consumer.
60
+ - **`getInitials`** takes FIRST + LAST, not first + second: `"Ana Maria
61
+ Rivera"` is `AR`. Both package copies did this and both were tested; six
62
+ matrx-frontend twins printed `AM`.
63
+
64
+ ### `./html-escape` — ONE escaper, with a decided character set
65
+
66
+ Nine copies of `escapeHtml` were live across the fleet and **they did not
67
+ escape the same characters** — three did `& < >` only, two added `"`, four
68
+ added `'`. A string safe through one was an attribute break-out through
69
+ another. The decided set is all five: `& < > " '` →
70
+ `&amp; &lt; &gt; &quot; &#39;`, always, so the result is safe as element text
71
+ *and* inside a quoted attribute. It lives here rather than in
72
+ `@ai-matrx/print/core` because an HTML escaper must not drag in `jspdf` +
73
+ `html2canvas`.
74
+
75
+ ### `./uuid` — two predicates, named so you cannot pick the wrong one
76
+
77
+ The fleet had two different predicates both called `isUuid`, and the difference
78
+ was invisible at the call site. Now: `isUuidShape` (lax 8-4-4-4-12, the right
79
+ question for "id or slug?", accepts v7 and the nil UUID) and `isRfc4122Uuid`
80
+ (version 1–5 + variant 8/9/a/b, the right question for a validation door on a
81
+ public route, and it REJECTS the nil UUID and v6/v7/v8). A bare `isUuid` is
82
+ deliberately not exported — a caller must choose.
83
+
84
+ ### Notes
85
+
86
+ - All three subpaths are pure and **unstamped** (no `"use client"`): they are
87
+ imported by API routes, Server Components and Node scripts as often as by
88
+ client code. For the same reason they are **not** re-exported from the root
89
+ barrel, which carries the banner — import them by subpath.
90
+ - No dependency was added. No existing export changed.
91
+
3
92
  ## 0.7.3 — 2026-08-30
4
93
 
5
94
  - Peer-dependency retrofit (register row X3 / law C23): the advisory-only
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/format.ts
21
+ var format_exports = {};
22
+ __export(format_exports, {
23
+ avatarPaletteIndex: () => avatarPaletteIndex,
24
+ durationMsBetween: () => durationMsBetween,
25
+ formatAbsoluteDate: () => formatAbsoluteDate,
26
+ formatDurationMinutes: () => formatDurationMinutes,
27
+ formatDurationMs: () => formatDurationMs,
28
+ formatDurationSeconds: () => formatDurationSeconds,
29
+ formatFileSize: () => formatFileSize,
30
+ formatRelativeTime: () => formatRelativeTime,
31
+ getInitials: () => getInitials,
32
+ parseTimestamp: () => parseTimestamp
33
+ });
34
+ module.exports = __toCommonJS(format_exports);
35
+ function hasTimezoneDesignator(value) {
36
+ if (/[zZ]$/.test(value)) return true;
37
+ if (/[+-]\d{2}(:?\d{2})?$/.test(value)) return true;
38
+ if (/\b(GMT|UTC)\b/i.test(value)) return true;
39
+ return false;
40
+ }
41
+ function normalizeTimestampString(raw) {
42
+ const value = raw.trim();
43
+ if (!value) return value;
44
+ if (!/\d{1,2}:\d{2}/.test(value)) return value;
45
+ if (hasTimezoneDesignator(value)) return value;
46
+ return `${value.replace(" ", "T")}Z`;
47
+ }
48
+ function parseTimestamp(value) {
49
+ if (value === null || value === void 0) return null;
50
+ if (value instanceof Date) {
51
+ return Number.isNaN(value.getTime()) ? null : value;
52
+ }
53
+ if (typeof value === "number") {
54
+ const fromNumber = new Date(value);
55
+ return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;
56
+ }
57
+ if (typeof value !== "string") return null;
58
+ const normalized = normalizeTimestampString(value);
59
+ if (!normalized) return null;
60
+ const parsed = new Date(normalized);
61
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
62
+ }
63
+ var DEFAULT_ABSOLUTE_OPTIONS = {
64
+ year: "numeric",
65
+ month: "short",
66
+ day: "numeric",
67
+ hour: "numeric",
68
+ minute: "2-digit"
69
+ };
70
+ function formatAbsoluteDate(value, options = DEFAULT_ABSOLUTE_OPTIONS, fallback = "\u2014") {
71
+ const parsed = parseTimestamp(value);
72
+ if (!parsed) return fallback;
73
+ return parsed.toLocaleString(void 0, options);
74
+ }
75
+ var RELATIVE_UNITS = [
76
+ { limit: 6e4, divisor: 1e3, short: "s", long: "second" },
77
+ { limit: 36e5, divisor: 6e4, short: "m", long: "minute" },
78
+ { limit: 864e5, divisor: 36e5, short: "h", long: "hour" },
79
+ { limit: 6048e5, divisor: 864e5, short: "d", long: "day" },
80
+ { limit: 2592e6, divisor: 6048e5, short: "w", long: "week" },
81
+ { limit: 31536e6, divisor: 2592e6, short: "mo", long: "month" }
82
+ ];
83
+ var INTL_UNITS = [
84
+ { unit: "year", ms: 365 * 24 * 60 * 60 * 1e3 },
85
+ { unit: "month", ms: 30 * 24 * 60 * 60 * 1e3 },
86
+ { unit: "week", ms: 7 * 24 * 60 * 60 * 1e3 },
87
+ { unit: "day", ms: 24 * 60 * 60 * 1e3 },
88
+ { unit: "hour", ms: 60 * 60 * 1e3 },
89
+ { unit: "minute", ms: 60 * 1e3 }
90
+ ];
91
+ function formatRelativeTime(value, options = {}) {
92
+ const style = options.style ?? "short";
93
+ const now = options.now ?? Date.now();
94
+ const parsed = parseTimestamp(value);
95
+ if (!parsed) {
96
+ if (options.fallbackToInput === true && typeof value === "string") return value;
97
+ return options.fallback ?? "\u2014";
98
+ }
99
+ if (style === "intl") {
100
+ const delta = parsed.getTime() - now;
101
+ const magnitude = Math.abs(delta);
102
+ if (magnitude < 6e4) return "just now";
103
+ const rtf = new Intl.RelativeTimeFormat(void 0, { numeric: "auto" });
104
+ for (const { unit, ms } of INTL_UNITS) {
105
+ if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);
106
+ }
107
+ return "just now";
108
+ }
109
+ const elapsed = now - parsed.getTime();
110
+ if (elapsed < 0) return "just now";
111
+ for (const unit of RELATIVE_UNITS) {
112
+ if (elapsed < unit.limit) {
113
+ const count = Math.max(1, Math.floor(elapsed / unit.divisor));
114
+ return style === "long" ? `${count} ${unit.long}${count === 1 ? "" : "s"} ago` : `${count}${unit.short} ago`;
115
+ }
116
+ }
117
+ return formatAbsoluteDate(parsed);
118
+ }
119
+ var pad2 = (value) => value.toString().padStart(2, "0");
120
+ function clockBody(totalSeconds) {
121
+ const seconds = totalSeconds % 60;
122
+ const minutes = Math.floor(totalSeconds / 60) % 60;
123
+ const hours = Math.floor(totalSeconds / 3600);
124
+ return hours > 0 ? `${hours}:${pad2(minutes)}:${pad2(seconds)}` : `${minutes}:${pad2(seconds)}`;
125
+ }
126
+ function compactBody(ms) {
127
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
128
+ const totalSeconds = ms / 1e3;
129
+ if (totalSeconds < 60) {
130
+ return totalSeconds < 10 ? `${totalSeconds.toFixed(1)}s` : `${Math.round(totalSeconds)}s`;
131
+ }
132
+ const whole = Math.round(totalSeconds);
133
+ const minutes = Math.floor(whole / 60);
134
+ if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;
135
+ const hours = Math.floor(minutes / 60);
136
+ return `${hours}h ${pad2(minutes % 60)}m`;
137
+ }
138
+ function coarseBody(ms) {
139
+ const minutes = Math.round(ms / 6e4);
140
+ if (minutes < 1) return "< 1 min";
141
+ if (minutes < 60) return `${minutes} min`;
142
+ const hours = Math.floor(minutes / 60);
143
+ const rest = minutes % 60;
144
+ return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
145
+ }
146
+ function formatDurationMs(ms, options = {}) {
147
+ const style = options.style ?? "clock";
148
+ const fallback = options.fallback ?? (style === "clock" ? "0:00" : "\u2014");
149
+ if (ms === null || ms === void 0 || !Number.isFinite(ms)) return fallback;
150
+ const negative = ms < 0;
151
+ if (negative && options.signed !== true) {
152
+ return style === "clock" ? clockBody(0) : fallback;
153
+ }
154
+ const magnitude = Math.abs(ms);
155
+ const body = style === "clock" ? clockBody(Math.floor(magnitude / 1e3)) : style === "compact" ? compactBody(magnitude) : coarseBody(magnitude);
156
+ return negative ? `-${body}` : body;
157
+ }
158
+ function formatDurationSeconds(seconds, options = {}) {
159
+ if (seconds === null || seconds === void 0 || !Number.isFinite(seconds)) {
160
+ return formatDurationMs(seconds, options);
161
+ }
162
+ return formatDurationMs(seconds * 1e3, options);
163
+ }
164
+ function formatDurationMinutes(minutes, options = {}) {
165
+ if (minutes === null || minutes === void 0 || !Number.isFinite(minutes)) {
166
+ return formatDurationMs(minutes, options);
167
+ }
168
+ return formatDurationMs(minutes * 6e4, options);
169
+ }
170
+ function durationMsBetween(from, to, now = Date.now()) {
171
+ const start = parseTimestamp(from);
172
+ if (!start) return null;
173
+ if (to === null || to === void 0) return Math.max(0, now - start.getTime());
174
+ const end = parseTimestamp(to);
175
+ if (!end) return null;
176
+ return Math.max(0, end.getTime() - start.getTime());
177
+ }
178
+ var SIZE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
179
+ function formatFileSize(bytes, options = {}) {
180
+ const fallback = options.fallback ?? "\u2014";
181
+ if (bytes === null || bytes === void 0 || !Number.isFinite(bytes) || bytes < 0) {
182
+ return fallback;
183
+ }
184
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
185
+ let value = bytes;
186
+ let unit = 0;
187
+ while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
188
+ value /= 1024;
189
+ unit += 1;
190
+ }
191
+ const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);
192
+ return `${rendered} ${SIZE_UNITS[unit]}`;
193
+ }
194
+ function getInitials(value, options = {}) {
195
+ const fallback = options.fallback ?? "?";
196
+ if (typeof value !== "string") return fallback;
197
+ const parts = value.trim().split(/\s+/).filter((part) => part.length > 0);
198
+ if (parts.length === 0) return fallback;
199
+ const first = parts[0]?.charAt(0) ?? "";
200
+ const last = parts.length > 1 ? parts[parts.length - 1]?.charAt(0) ?? "" : "";
201
+ return `${first}${last}`.toUpperCase() || fallback;
202
+ }
203
+ function avatarPaletteIndex(seed, buckets = 8) {
204
+ let hash = 0;
205
+ for (let index = 0; index < seed.length; index += 1) {
206
+ hash = hash * 31 + seed.charCodeAt(index) | 0;
207
+ }
208
+ return Math.abs(hash) % buckets;
209
+ }
210
+ //# sourceMappingURL=format.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/format.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/format — THE one home for the tiny display formatters that\n * every Matrx surface, in every repo, needs and that nothing owns.\n *\n * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These\n * formatters were duplicated *between packages*: `@ai-matrx/meet` and\n * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,\n * `@ai-matrx/associations` and `@ai-matrx/diff` each defined\n * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a\n * React upload component. Because the fleet's packages disagreed about who\n * owned them, ~65 host twins across matrx-frontend / matrx-extend /\n * matrx-local / matrx-games / aidream's two apps had *no correct package to\n * point at* — a settings page cannot take a dependency on the video-calling\n * package to print a duration.\n *\n * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with\n * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very\n * bottom of the sibling DAG: meet, messaging, associations, diff, media and\n * print can all depend on it without creating a cycle, and so can every host.\n * It is also, by charter, the \"little primitives every Matrx app speaks\"\n * package. This is not a new package — new packages need Arman's approval.\n *\n * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug\n * waiting to happen: the fleet's twins variously took milliseconds, seconds\n * and minutes, and two of them were wrong by 1000×-worth of confusion. There\n * is deliberately NO `formatDuration` here. The unit is in the name:\n * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.\n *\n * Everything in this module is pure, synchronous, and free of React, DOM and\n * `Intl` implicit-locale surprises except where a style explicitly asks for\n * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit\n * `now` so tests never depend on the clock.\n */\n\n// ───────────────────────── timestamps ─────────────────────────\n\nexport type TimestampInput = string | number | Date | null | undefined;\n\n/** True when a string already carries a timezone designator (Z or ±offset). */\nfunction hasTimezoneDesignator(value: string): boolean {\n if (/[zZ]$/.test(value)) return true;\n if (/[+-]\\d{2}(:?\\d{2})?$/.test(value)) return true;\n if (/\\b(GMT|UTC)\\b/i.test(value)) return true;\n return false;\n}\n\n/**\n * Normalise a raw timestamp string so `new Date()` parses it as an absolute\n * instant.\n *\n * THE CORRECTION THIS CARRIES (from `@ai-matrx/diff`, the richest of the\n * twins). Postgres serialises `timestamp with time zone` as\n * \"2026-06-13T16:32:26+00:00\" (parses correctly) and `timestamp without time\n * zone` as \"2026-06-13T16:32:26\" — which, per the ES spec, `new Date()` parses\n * as LOCAL time. Our backend writes those columns in UTC, so a bare\n * `new Date(...)` is wrong by the viewer's offset: the classic \"times are off\n * by N hours\" report. A zone-less string that HAS a time component is treated\n * as UTC; date-only strings are left untouched (a calendar day is not a\n * timestamp).\n */\nfunction normalizeTimestampString(raw: string): string {\n const value = raw.trim();\n if (!value) return value;\n if (!/\\d{1,2}:\\d{2}/.test(value)) return value;\n if (hasTimezoneDesignator(value)) return value;\n return `${value.replace(\" \", \"T\")}Z`;\n}\n\n/** Parse any backend timestamp into a Date, or `null` when unparseable. */\nexport function parseTimestamp(value: TimestampInput): Date | null {\n if (value === null || value === undefined) return null;\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value;\n }\n if (typeof value === \"number\") {\n const fromNumber = new Date(value);\n return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;\n }\n if (typeof value !== \"string\") return null;\n const normalized = normalizeTimestampString(value);\n if (!normalized) return null;\n const parsed = new Date(normalized);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nconst DEFAULT_ABSOLUTE_OPTIONS: Intl.DateTimeFormatOptions = {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n};\n\n/** Absolute local date+time, e.g. \"Jun 13, 2026, 9:32 AM\". */\nexport function formatAbsoluteDate(\n value: TimestampInput,\n options: Intl.DateTimeFormatOptions = DEFAULT_ABSOLUTE_OPTIONS,\n fallback = \"—\",\n): string {\n const parsed = parseTimestamp(value);\n if (!parsed) return fallback;\n return parsed.toLocaleString(undefined, options);\n}\n\n// ───────────────────────── relative time ─────────────────────────\n\nconst RELATIVE_UNITS: readonly {\n limit: number;\n divisor: number;\n short: string;\n long: string;\n}[] = [\n { limit: 60_000, divisor: 1000, short: \"s\", long: \"second\" },\n { limit: 3_600_000, divisor: 60_000, short: \"m\", long: \"minute\" },\n { limit: 86_400_000, divisor: 3_600_000, short: \"h\", long: \"hour\" },\n { limit: 604_800_000, divisor: 86_400_000, short: \"d\", long: \"day\" },\n { limit: 2_592_000_000, divisor: 604_800_000, short: \"w\", long: \"week\" },\n { limit: 31_536_000_000, divisor: 2_592_000_000, short: \"mo\", long: \"month\" },\n];\n\nconst INTL_UNITS: readonly { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [\n { unit: \"year\", ms: 365 * 24 * 60 * 60 * 1000 },\n { unit: \"month\", ms: 30 * 24 * 60 * 60 * 1000 },\n { unit: \"week\", ms: 7 * 24 * 60 * 60 * 1000 },\n { unit: \"day\", ms: 24 * 60 * 60 * 1000 },\n { unit: \"hour\", ms: 60 * 60 * 1000 },\n { unit: \"minute\", ms: 60 * 1000 },\n];\n\n/**\n * The three relative-time voices the fleet actually speaks. They are display\n * decisions, not implementations — one body serves all three.\n *\n * - `\"short\"` — \"2m ago\", \"3d ago\". The dense default (from `@ai-matrx/diff`).\n * - `\"long\"` — \"2 minutes ago\", \"3 days ago\".\n * - `\"intl\"` — `Intl.RelativeTimeFormat` with `numeric: \"auto\"`, so the\n * viewer's locale conventions apply and \"yesterday\" reads as \"yesterday\"\n * (from `@ai-matrx/associations`). Sub-minute reads \"just now\".\n */\nexport type RelativeTimeStyle = \"short\" | \"long\" | \"intl\";\n\nexport interface RelativeTimeOptions {\n /** Default `\"short\"`. */\n style?: RelativeTimeStyle | undefined;\n /** Injected clock. Default `Date.now()` — pass it in tests. */\n now?: number | undefined;\n /** Returned for null / unparseable input. Default `\"—\"`. */\n fallback?: string | undefined;\n /**\n * Echo the raw input instead of `fallback` when it cannot be parsed —\n * degraded, never broken. What the comments face wants: an odd server string\n * is more useful on screen than an em-dash that hides it.\n */\n fallbackToInput?: boolean | undefined;\n}\n\n/**\n * \"2m ago\" / \"2 minutes ago\" / a locale-aware \"2 minutes ago\", falling back to\n * an absolute local date past a year. Timezone-agnostic by construction (a\n * pure epoch difference over `parseTimestamp`'s corrected instant).\n *\n * Future timestamps read \"just now\" in `\"short\"`/`\"long\"` (a clock skew of a\n * few seconds must not print \"in 3 seconds\"); `\"intl\"` formats them properly\n * (\"in 5 minutes\") because that is the whole point of asking for `Intl`.\n */\nexport function formatRelativeTime(\n value: TimestampInput,\n options: RelativeTimeOptions = {},\n): string {\n const style = options.style ?? \"short\";\n const now = options.now ?? Date.now();\n const parsed = parseTimestamp(value);\n if (!parsed) {\n if (options.fallbackToInput === true && typeof value === \"string\") return value;\n return options.fallback ?? \"—\";\n }\n\n if (style === \"intl\") {\n const delta = parsed.getTime() - now;\n const magnitude = Math.abs(delta);\n if (magnitude < 60_000) return \"just now\";\n const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n for (const { unit, ms } of INTL_UNITS) {\n if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);\n }\n return \"just now\";\n }\n\n const elapsed = now - parsed.getTime();\n if (elapsed < 0) return \"just now\";\n for (const unit of RELATIVE_UNITS) {\n if (elapsed < unit.limit) {\n const count = Math.max(1, Math.floor(elapsed / unit.divisor));\n return style === \"long\"\n ? `${count} ${unit.long}${count === 1 ? \"\" : \"s\"} ago`\n : `${count}${unit.short} ago`;\n }\n }\n return formatAbsoluteDate(parsed);\n}\n\n// ───────────────────────── durations ─────────────────────────\n\n/**\n * The three duration voices the fleet actually speaks. Picked once here so a\n * call duration reads the same in the meeting, the history list and the\n * notification.\n *\n * - `\"clock\"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human\n * watches tick: recordings, calls, media players, timers.\n * - `\"compact\"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:\n * job runs, request timings, step durations. Sub-second is honest about\n * being sub-second rather than collapsing to `0:00`.\n * - `\"coarse\"` — `45 min`, `1h 30m`. For things measured in minutes and read\n * at a glance: podcast episodes, lesson lengths, estimates. Never prints\n * seconds.\n */\nexport type DurationStyle = \"clock\" | \"compact\" | \"coarse\";\n\nexport interface DurationOptions {\n /** Default `\"clock\"`. */\n style?: DurationStyle | undefined;\n /**\n * Returned for `null` / `undefined` / non-finite input. Default `\"0:00\"` for\n * `\"clock\"` and `\"—\"` for `\"compact\"` / `\"coarse\"`.\n */\n fallback?: string | undefined;\n /**\n * Keep the sign on a negative duration (`-1:23`) instead of clamping to\n * zero. Default `false` — a negative elapsed time is nearly always a clock\n * bug, and `0:00` is the honest reading. Opt in where a signed offset is the\n * actual quantity (a field format for a stored `interval`, say).\n */\n signed?: boolean | undefined;\n}\n\nconst pad2 = (value: number): string => value.toString().padStart(2, \"0\");\n\nfunction clockBody(totalSeconds: number): string {\n const seconds = totalSeconds % 60;\n const minutes = Math.floor(totalSeconds / 60) % 60;\n const hours = Math.floor(totalSeconds / 3600);\n return hours > 0\n ? `${hours}:${pad2(minutes)}:${pad2(seconds)}`\n : `${minutes}:${pad2(seconds)}`;\n}\n\nfunction compactBody(ms: number): string {\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const totalSeconds = ms / 1000;\n if (totalSeconds < 60) {\n // One decimal under 10s (0.1s is a visible difference at that scale),\n // whole seconds above it (nobody reads \"43.7s\" as more precise than \"44s\").\n return totalSeconds < 10\n ? `${totalSeconds.toFixed(1)}s`\n : `${Math.round(totalSeconds)}s`;\n }\n const whole = Math.round(totalSeconds);\n const minutes = Math.floor(whole / 60);\n if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;\n const hours = Math.floor(minutes / 60);\n return `${hours}h ${pad2(minutes % 60)}m`;\n}\n\nfunction coarseBody(ms: number): string {\n const minutes = Math.round(ms / 60_000);\n if (minutes < 1) return \"< 1 min\";\n if (minutes < 60) return `${minutes} min`;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;\n}\n\n/**\n * THE canonical duration formatter. Milliseconds in, a chosen voice out.\n *\n * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never\n * `NaN:NaN`, which is what a null start time produces, and never a confident\n * `0:00` for \"we do not know\".\n */\nexport function formatDurationMs(\n ms: number | null | undefined,\n options: DurationOptions = {},\n): string {\n const style = options.style ?? \"clock\";\n const fallback = options.fallback ?? (style === \"clock\" ? \"0:00\" : \"—\");\n if (ms === null || ms === undefined || !Number.isFinite(ms)) return fallback;\n\n const negative = ms < 0;\n if (negative && options.signed !== true) {\n return style === \"clock\" ? clockBody(0) : fallback;\n }\n const magnitude = Math.abs(ms);\n const body =\n style === \"clock\"\n ? clockBody(Math.floor(magnitude / 1000))\n : style === \"compact\"\n ? compactBody(magnitude)\n : coarseBody(magnitude);\n return negative ? `-${body}` : body;\n}\n\n/** Seconds in. See {@link formatDurationMs}. */\nexport function formatDurationSeconds(\n seconds: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) {\n return formatDurationMs(seconds as number | null | undefined, options);\n }\n return formatDurationMs(seconds * 1000, options);\n}\n\n/** Minutes in. See {@link formatDurationMs}. */\nexport function formatDurationMinutes(\n minutes: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) {\n return formatDurationMs(minutes as number | null | undefined, options);\n }\n return formatDurationMs(minutes * 60_000, options);\n}\n\n/**\n * Duration between two timestamps, in milliseconds — `null` when the start is\n * missing or either end is unparseable. An absent `to` means \"still running\",\n * so it measures to `now`. Pair with {@link formatDurationMs}.\n */\nexport function durationMsBetween(\n from: TimestampInput,\n to: TimestampInput,\n now: number = Date.now(),\n): number | null {\n const start = parseTimestamp(from);\n if (!start) return null;\n if (to === null || to === undefined) return Math.max(0, now - start.getTime());\n const end = parseTimestamp(to);\n if (!end) return null;\n return Math.max(0, end.getTime() - start.getTime());\n}\n\n// ───────────────────────── byte sizes ─────────────────────────\n\nconst SIZE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const;\n\nexport interface FileSizeOptions {\n /** Returned for `null` / `undefined` / non-finite / negative. Default `\"—\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `512` → `\"512 B\"`, `2048` → `\"2.0 KB\"`, `15360` → `\"15 KB\"`.\n *\n * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and\n * `@ai-matrx/media`'s copy, which was the richest):\n *\n * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet\n * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `\"Bytes\"`.\n * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or\n * above it (`15 KB`, `340 MB`) — three significant figures is the most a\n * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole\n * point of the unit.\n * 3. Whole bytes below 1 KB — `\"512 B\"`, never `\"512.0 B\"`.\n * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an\n * em-dash, NOT to `\"0 B\"`. A corrupt or mid-upload `size` row rendering as\n * a confident, wrong `\"0 B\"` is a screen telling a lie; \"size unknown\" is\n * the honest reading. (`0` itself is a real size and prints `\"0 B\"`.)\n */\nexport function formatFileSize(\n bytes: number | null | undefined,\n options: FileSizeOptions = {},\n): string {\n const fallback = options.fallback ?? \"—\";\n if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {\n return fallback;\n }\n if (bytes < 1024) return `${Math.round(bytes)} B`;\n let value = bytes;\n let unit = 0;\n while (value >= 1024 && unit < SIZE_UNITS.length - 1) {\n value /= 1024;\n unit += 1;\n }\n const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);\n return `${rendered} ${SIZE_UNITS[unit]}`;\n}\n\n// ───────────────────────── people ─────────────────────────\n\nexport interface InitialsOptions {\n /** Returned when nothing usable is left. Default `\"?\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `\"Ana Rivera\"` → `\"AR\"`, `\"Ana\"` → `\"A\"`, `\"Ana Maria Rivera\"` → `\"AR\"`,\n * `\"ana@example.com\"` → `\"A\"`, `\" \"` → `\"?\"`.\n *\n * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not\n * first + second. `\"Ana Maria Rivera\"` is `AR`, because the family name is the\n * half a reader recognises. (Six matrx-frontend twins took first + second and\n * printed `AM`; the two package copies both took first + last, and both were\n * tested. First + last wins.)\n *\n * Pass the email as the value when there is no name — a single token yields\n * its first character, which is exactly what the host twins did by hand.\n */\nexport function getInitials(\n value: string | null | undefined,\n options: InitialsOptions = {},\n): string {\n const fallback = options.fallback ?? \"?\";\n if (typeof value !== \"string\") return fallback;\n const parts = value.trim().split(/\\s+/).filter((part) => part.length > 0);\n if (parts.length === 0) return fallback;\n const first = parts[0]?.charAt(0) ?? \"\";\n const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? \"\") : \"\";\n return `${first}${last}`.toUpperCase() || fallback;\n}\n\n/**\n * A stable palette index for an avatar with no image. Deterministic on the\n * seed, so the same person is the same colour on every device and every\n * reload — a random colour per render is a surprisingly loud bug.\n */\nexport function avatarPaletteIndex(seed: string, buckets = 8): number {\n let hash = 0;\n for (let index = 0; index < seed.length; index += 1) {\n hash = (hash * 31 + seed.charCodeAt(index)) | 0;\n }\n return Math.abs(hash) % buckets;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @ai-matrx/kit/format — THE one home for the tiny display formatters that
3
+ * every Matrx surface, in every repo, needs and that nothing owns.
4
+ *
5
+ * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These
6
+ * formatters were duplicated *between packages*: `@ai-matrx/meet` and
7
+ * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,
8
+ * `@ai-matrx/associations` and `@ai-matrx/diff` each defined
9
+ * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a
10
+ * React upload component. Because the fleet's packages disagreed about who
11
+ * owned them, ~65 host twins across matrx-frontend / matrx-extend /
12
+ * matrx-local / matrx-games / aidream's two apps had *no correct package to
13
+ * point at* — a settings page cannot take a dependency on the video-calling
14
+ * package to print a duration.
15
+ *
16
+ * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with
17
+ * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very
18
+ * bottom of the sibling DAG: meet, messaging, associations, diff, media and
19
+ * print can all depend on it without creating a cycle, and so can every host.
20
+ * It is also, by charter, the "little primitives every Matrx app speaks"
21
+ * package. This is not a new package — new packages need Arman's approval.
22
+ *
23
+ * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug
24
+ * waiting to happen: the fleet's twins variously took milliseconds, seconds
25
+ * and minutes, and two of them were wrong by 1000×-worth of confusion. There
26
+ * is deliberately NO `formatDuration` here. The unit is in the name:
27
+ * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.
28
+ *
29
+ * Everything in this module is pure, synchronous, and free of React, DOM and
30
+ * `Intl` implicit-locale surprises except where a style explicitly asks for
31
+ * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit
32
+ * `now` so tests never depend on the clock.
33
+ */
34
+ type TimestampInput = string | number | Date | null | undefined;
35
+ /** Parse any backend timestamp into a Date, or `null` when unparseable. */
36
+ declare function parseTimestamp(value: TimestampInput): Date | null;
37
+ /** Absolute local date+time, e.g. "Jun 13, 2026, 9:32 AM". */
38
+ declare function formatAbsoluteDate(value: TimestampInput, options?: Intl.DateTimeFormatOptions, fallback?: string): string;
39
+ /**
40
+ * The three relative-time voices the fleet actually speaks. They are display
41
+ * decisions, not implementations — one body serves all three.
42
+ *
43
+ * - `"short"` — "2m ago", "3d ago". The dense default (from `@ai-matrx/diff`).
44
+ * - `"long"` — "2 minutes ago", "3 days ago".
45
+ * - `"intl"` — `Intl.RelativeTimeFormat` with `numeric: "auto"`, so the
46
+ * viewer's locale conventions apply and "yesterday" reads as "yesterday"
47
+ * (from `@ai-matrx/associations`). Sub-minute reads "just now".
48
+ */
49
+ type RelativeTimeStyle = "short" | "long" | "intl";
50
+ interface RelativeTimeOptions {
51
+ /** Default `"short"`. */
52
+ style?: RelativeTimeStyle | undefined;
53
+ /** Injected clock. Default `Date.now()` — pass it in tests. */
54
+ now?: number | undefined;
55
+ /** Returned for null / unparseable input. Default `"—"`. */
56
+ fallback?: string | undefined;
57
+ /**
58
+ * Echo the raw input instead of `fallback` when it cannot be parsed —
59
+ * degraded, never broken. What the comments face wants: an odd server string
60
+ * is more useful on screen than an em-dash that hides it.
61
+ */
62
+ fallbackToInput?: boolean | undefined;
63
+ }
64
+ /**
65
+ * "2m ago" / "2 minutes ago" / a locale-aware "2 minutes ago", falling back to
66
+ * an absolute local date past a year. Timezone-agnostic by construction (a
67
+ * pure epoch difference over `parseTimestamp`'s corrected instant).
68
+ *
69
+ * Future timestamps read "just now" in `"short"`/`"long"` (a clock skew of a
70
+ * few seconds must not print "in 3 seconds"); `"intl"` formats them properly
71
+ * ("in 5 minutes") because that is the whole point of asking for `Intl`.
72
+ */
73
+ declare function formatRelativeTime(value: TimestampInput, options?: RelativeTimeOptions): string;
74
+ /**
75
+ * The three duration voices the fleet actually speaks. Picked once here so a
76
+ * call duration reads the same in the meeting, the history list and the
77
+ * notification.
78
+ *
79
+ * - `"clock"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human
80
+ * watches tick: recordings, calls, media players, timers.
81
+ * - `"compact"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:
82
+ * job runs, request timings, step durations. Sub-second is honest about
83
+ * being sub-second rather than collapsing to `0:00`.
84
+ * - `"coarse"` — `45 min`, `1h 30m`. For things measured in minutes and read
85
+ * at a glance: podcast episodes, lesson lengths, estimates. Never prints
86
+ * seconds.
87
+ */
88
+ type DurationStyle = "clock" | "compact" | "coarse";
89
+ interface DurationOptions {
90
+ /** Default `"clock"`. */
91
+ style?: DurationStyle | undefined;
92
+ /**
93
+ * Returned for `null` / `undefined` / non-finite input. Default `"0:00"` for
94
+ * `"clock"` and `"—"` for `"compact"` / `"coarse"`.
95
+ */
96
+ fallback?: string | undefined;
97
+ /**
98
+ * Keep the sign on a negative duration (`-1:23`) instead of clamping to
99
+ * zero. Default `false` — a negative elapsed time is nearly always a clock
100
+ * bug, and `0:00` is the honest reading. Opt in where a signed offset is the
101
+ * actual quantity (a field format for a stored `interval`, say).
102
+ */
103
+ signed?: boolean | undefined;
104
+ }
105
+ /**
106
+ * THE canonical duration formatter. Milliseconds in, a chosen voice out.
107
+ *
108
+ * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never
109
+ * `NaN:NaN`, which is what a null start time produces, and never a confident
110
+ * `0:00` for "we do not know".
111
+ */
112
+ declare function formatDurationMs(ms: number | null | undefined, options?: DurationOptions): string;
113
+ /** Seconds in. See {@link formatDurationMs}. */
114
+ declare function formatDurationSeconds(seconds: number | null | undefined, options?: DurationOptions): string;
115
+ /** Minutes in. See {@link formatDurationMs}. */
116
+ declare function formatDurationMinutes(minutes: number | null | undefined, options?: DurationOptions): string;
117
+ /**
118
+ * Duration between two timestamps, in milliseconds — `null` when the start is
119
+ * missing or either end is unparseable. An absent `to` means "still running",
120
+ * so it measures to `now`. Pair with {@link formatDurationMs}.
121
+ */
122
+ declare function durationMsBetween(from: TimestampInput, to: TimestampInput, now?: number): number | null;
123
+ interface FileSizeOptions {
124
+ /** Returned for `null` / `undefined` / non-finite / negative. Default `"—"`. */
125
+ fallback?: string | undefined;
126
+ }
127
+ /**
128
+ * `512` → `"512 B"`, `2048` → `"2.0 KB"`, `15360` → `"15 KB"`.
129
+ *
130
+ * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and
131
+ * `@ai-matrx/media`'s copy, which was the richest):
132
+ *
133
+ * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet
134
+ * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `"Bytes"`.
135
+ * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or
136
+ * above it (`15 KB`, `340 MB`) — three significant figures is the most a
137
+ * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole
138
+ * point of the unit.
139
+ * 3. Whole bytes below 1 KB — `"512 B"`, never `"512.0 B"`.
140
+ * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an
141
+ * em-dash, NOT to `"0 B"`. A corrupt or mid-upload `size` row rendering as
142
+ * a confident, wrong `"0 B"` is a screen telling a lie; "size unknown" is
143
+ * the honest reading. (`0` itself is a real size and prints `"0 B"`.)
144
+ */
145
+ declare function formatFileSize(bytes: number | null | undefined, options?: FileSizeOptions): string;
146
+ interface InitialsOptions {
147
+ /** Returned when nothing usable is left. Default `"?"`. */
148
+ fallback?: string | undefined;
149
+ }
150
+ /**
151
+ * `"Ana Rivera"` → `"AR"`, `"Ana"` → `"A"`, `"Ana Maria Rivera"` → `"AR"`,
152
+ * `"ana@example.com"` → `"A"`, `" "` → `"?"`.
153
+ *
154
+ * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not
155
+ * first + second. `"Ana Maria Rivera"` is `AR`, because the family name is the
156
+ * half a reader recognises. (Six matrx-frontend twins took first + second and
157
+ * printed `AM`; the two package copies both took first + last, and both were
158
+ * tested. First + last wins.)
159
+ *
160
+ * Pass the email as the value when there is no name — a single token yields
161
+ * its first character, which is exactly what the host twins did by hand.
162
+ */
163
+ declare function getInitials(value: string | null | undefined, options?: InitialsOptions): string;
164
+ /**
165
+ * A stable palette index for an avatar with no image. Deterministic on the
166
+ * seed, so the same person is the same colour on every device and every
167
+ * reload — a random colour per render is a surprisingly loud bug.
168
+ */
169
+ declare function avatarPaletteIndex(seed: string, buckets?: number): number;
170
+
171
+ export { type DurationOptions, type DurationStyle, type FileSizeOptions, type InitialsOptions, type RelativeTimeOptions, type RelativeTimeStyle, type TimestampInput, avatarPaletteIndex, durationMsBetween, formatAbsoluteDate, formatDurationMinutes, formatDurationMs, formatDurationSeconds, formatFileSize, formatRelativeTime, getInitials, parseTimestamp };