@pyric/ui 0.1.0-alpha.10 → 0.1.0-alpha.12

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 (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
@@ -0,0 +1,197 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { AnyActivityEvent } from '../types.js';
3
+ import {
4
+ computeActivityDigest,
5
+ type ActivityBandWithGroups,
6
+ type ActivityDigest,
7
+ type ActivityDigestOptions,
8
+ type ActivityRow,
9
+ } from '../digest.js';
10
+ import { ActivityGridRow } from './ActivityGridRow.js';
11
+
12
+ export interface ActivityGridProps {
13
+ /**
14
+ * The unified event stream — `sandbox.history()` and/or a live buffer
15
+ * (see `useActivityStream`). The grid folds it into the banded digest
16
+ * internally. Mutually exclusive with `digest`.
17
+ */
18
+ events?: readonly AnyActivityEvent[];
19
+ /**
20
+ * A precomputed digest from `useActivityDigest` / `computeActivity
21
+ * Digest`. Pass this when the host already memoizes the fold (so the
22
+ * grid doesn't recompute). Mutually exclusive with `events`.
23
+ */
24
+ digest?: ActivityDigest;
25
+ /** Grouping / ordering options, forwarded to the reducer when the grid
26
+ * folds `events` itself. Ignored when `digest` is supplied. */
27
+ options?: ActivityDigestOptions & { now?: number };
28
+ /** The selected row id (`data-pyric-selected`). */
29
+ selectedId?: string;
30
+ onSelect?: (row: ActivityRow) => void;
31
+ /** Clamp rows shown per band; the rest collapse into a "N more" stub.
32
+ * Independent of the reducer's `rowsPerBand` (which trims the data) —
33
+ * this is a pure display clamp that keeps the true count visible.
34
+ * Default: show all rows. */
35
+ maxRowsPerBand?: number;
36
+ /** Render the per-band overflow stub. Default renders a
37
+ * `[data-pyric-band-more]` element reading "N more {label}". */
38
+ renderBandMore?: (band: ActivityBandWithGroups, hidden: number) => ReactNode;
39
+ /** Override the `when` column rendering. */
40
+ formatWhen?: (at: number, now: number) => string;
41
+ /** "Now" anchor for `formatWhen`. */
42
+ now?: number;
43
+ /** Shown when the digest has no rows. */
44
+ emptyState?: ReactNode;
45
+ className?: string;
46
+ /** Render a leading column-header row (`target change for lens when`).
47
+ * Default true — matches the mock's `.colhead`. */
48
+ showColumnHeader?: boolean;
49
+ }
50
+
51
+ const COLUMN_HEADERS = ['target', 'change', 'for', 'as', 'when'] as const;
52
+
53
+ /**
54
+ * Headless activity grid over the unified `SandboxEvent` stream. Folds
55
+ * events into category bands (Denied / Added / Updated / Removed /
56
+ * Signed in / …), each band a `target · change · for · lens · when`
57
+ * column grid grouped under a `label · count · attribution` header.
58
+ * Denials lead (lead-with-consequence) and are flagged first-class.
59
+ *
60
+ * Ships ZERO styling — the host (Pyric Studio) applies the rigid column
61
+ * grid + band typography via the `data-pyric-*` contract:
62
+ * - `[data-pyric-ui="activity-grid"]` — the root.
63
+ * - `[data-pyric-band]` — a band header, with `data-pyric-band-key`,
64
+ * `data-pyric-band-count`, `data-pyric-band-denied` (on the denied
65
+ * band). Children: `[data-pyric-band-label]`, `[data-pyric-band-n]`,
66
+ * `[data-pyric-band-attr]` (omitted when attribution is mixed).
67
+ * - `[data-pyric-band-rows]` — the row container; with grouping,
68
+ * `[data-pyric-band-subgroup]` (+ `data-pyric-subgroup-key`) wraps
69
+ * each pivot bucket.
70
+ * - `[data-pyric-band-more]` — the "N more" overflow stub.
71
+ * - the `<ActivityGridRow>` contract for each row.
72
+ */
73
+ export function ActivityGrid({
74
+ events,
75
+ digest,
76
+ options,
77
+ selectedId,
78
+ onSelect,
79
+ maxRowsPerBand,
80
+ renderBandMore,
81
+ formatWhen,
82
+ now,
83
+ emptyState,
84
+ className,
85
+ showColumnHeader = true,
86
+ }: ActivityGridProps): ReactNode {
87
+ const model: ActivityDigest =
88
+ digest ?? computeActivityDigest(events ?? [], options ?? {});
89
+
90
+ if (model.total === 0) {
91
+ return (
92
+ <div
93
+ className={className}
94
+ data-pyric-ui="activity-grid"
95
+ data-pyric-empty=""
96
+ >
97
+ {emptyState}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ const renderRow = (row: ActivityRow): ReactNode => (
103
+ <ActivityGridRow
104
+ key={row.id}
105
+ row={row}
106
+ selected={row.id === selectedId}
107
+ onSelect={onSelect}
108
+ formatWhen={formatWhen}
109
+ now={now}
110
+ />
111
+ );
112
+
113
+ const renderBandBody = (band: ActivityBandWithGroups): ReactNode => {
114
+ const shown =
115
+ maxRowsPerBand !== undefined
116
+ ? band.rows.slice(0, maxRowsPerBand)
117
+ : band.rows;
118
+ const hidden = band.count - shown.length;
119
+
120
+ // Grouped: render pivot buckets, preserving the band's row order
121
+ // within each. We re-bucket the *shown* rows so the display clamp
122
+ // and the pivot compose.
123
+ let body: ReactNode;
124
+ if (band.subgroups) {
125
+ const shownIds = new Set(shown.map((r) => r.id));
126
+ const buckets = band.subgroups
127
+ .map((g) => ({
128
+ ...g,
129
+ rows: g.rows.filter((r) => shownIds.has(r.id)),
130
+ }))
131
+ .filter((g) => g.rows.length > 0);
132
+ body = buckets.map((g) => (
133
+ <div
134
+ key={g.key}
135
+ data-pyric-band-subgroup=""
136
+ data-pyric-subgroup-key={g.key}
137
+ data-pyric-subgroup-count={g.count}
138
+ >
139
+ {g.rows.map(renderRow)}
140
+ </div>
141
+ ));
142
+ } else {
143
+ body = shown.map(renderRow);
144
+ }
145
+
146
+ return (
147
+ <div data-pyric-band-rows="">
148
+ {body}
149
+ {hidden > 0
150
+ ? renderBandMore
151
+ ? renderBandMore(band, hidden)
152
+ : (
153
+ <div data-pyric-band-more="">
154
+ {hidden} more {band.label.toLowerCase()}
155
+ </div>
156
+ )
157
+ : null}
158
+ </div>
159
+ );
160
+ };
161
+
162
+ return (
163
+ <div className={className} data-pyric-ui="activity-grid">
164
+ {showColumnHeader ? (
165
+ <div data-pyric-event-colhead="">
166
+ {COLUMN_HEADERS.map((h) => (
167
+ <span key={h} data-pyric-event-col={h}>
168
+ {h}
169
+ </span>
170
+ ))}
171
+ </div>
172
+ ) : null}
173
+
174
+ {model.bands.map((band) => (
175
+ <section
176
+ key={band.key}
177
+ data-pyric-band-section=""
178
+ data-pyric-band-key={band.key}
179
+ >
180
+ <header
181
+ data-pyric-band=""
182
+ data-pyric-band-key={band.key}
183
+ data-pyric-band-count={band.count}
184
+ data-pyric-band-denied={band.key === 'denied' ? '' : undefined}
185
+ >
186
+ <span data-pyric-band-label="">{band.label}</span>
187
+ <span data-pyric-band-n="">{band.count}</span>
188
+ {band.attribution ? (
189
+ <span data-pyric-band-attr="">{band.attribution}</span>
190
+ ) : null}
191
+ </header>
192
+ {renderBandBody(band)}
193
+ </section>
194
+ ))}
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,65 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ActivityRow } from '../digest.js';
3
+ import { defaultFormatWhen } from './format.js';
4
+
5
+ export interface ActivityGridRowProps {
6
+ row: ActivityRow;
7
+ /** Marks the row as the active selection (`data-pyric-selected`). */
8
+ selected?: boolean;
9
+ onSelect?: (row: ActivityRow) => void;
10
+ /** Override the `when` rendering. Receives `(at, now)`; default is the
11
+ * session-relative duration. */
12
+ formatWhen?: (at: number, now: number) => string;
13
+ /** The "now" anchor passed to `formatWhen` (and `row.when` is used as
14
+ * a fallback when omitted). */
15
+ now?: number;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * One activity grid row — the `target · change · for · lens · when`
21
+ * column contract from `c-result.html`, plus a trailing drill affordance
22
+ * column. Headless: every cell is a `data-pyric-event-*` span the host
23
+ * styles into the rigid column grid.
24
+ *
25
+ * Styling / data contract:
26
+ * - `[data-pyric-event-row]` — the row, with `data-pyric-event-band`,
27
+ * `data-pyric-event-service`, `data-pyric-event-lens`,
28
+ * `data-pyric-event-denied` (present only on denials), and
29
+ * `data-pyric-selected` when active.
30
+ * - `[data-pyric-event-target]` / `-change` / `-for` / `-lens` / `-when`
31
+ * — the five columns, in order.
32
+ */
33
+ export function ActivityGridRow({
34
+ row,
35
+ selected,
36
+ onSelect,
37
+ formatWhen,
38
+ now,
39
+ className,
40
+ }: ActivityGridRowProps): ReactNode {
41
+ const when = formatWhen
42
+ ? formatWhen(row.at, now ?? Date.now())
43
+ : row.when || defaultFormatWhen(row.at, now ?? Date.now());
44
+
45
+ return (
46
+ <button
47
+ type="button"
48
+ onClick={() => onSelect?.(row)}
49
+ className={className}
50
+ data-pyric-event-row=""
51
+ data-pyric-event-band={row.band}
52
+ data-pyric-event-service={row.service}
53
+ data-pyric-event-lens={row.authLens.mode}
54
+ data-pyric-event-actor={row.actor.kind}
55
+ data-pyric-event-denied={row.denied ? '' : undefined}
56
+ data-pyric-selected={selected ? '' : undefined}
57
+ >
58
+ <span data-pyric-event-target="">{row.target}</span>
59
+ <span data-pyric-event-change="">{row.change}</span>
60
+ <span data-pyric-event-for="">{row.for}</span>
61
+ <span data-pyric-event-lens="">{row.lens}</span>
62
+ <span data-pyric-event-when="">{when}</span>
63
+ </button>
64
+ );
65
+ }
@@ -0,0 +1,175 @@
1
+ import { useMemo, type ReactNode } from 'react';
2
+ import { truncateVectorsForDisplay } from '../../firestore/index.js';
3
+
4
+ /**
5
+ * One field-level change in a staged proposal. A UI-level diff row: the host
6
+ * adapts its backend diff (e.g. the sandbox's `Divergence[]`) into these so the
7
+ * component stays decoupled from any backend type.
8
+ */
9
+ export interface FieldChange {
10
+ /** Full document path, e.g. `notes/abc123`. */
11
+ docPath: string;
12
+ /** The field that changed. */
13
+ field: string;
14
+ before: unknown;
15
+ after: unknown;
16
+ /** `added` = new field, `removed` = cleared, `changed` = value differs. */
17
+ kind: 'added' | 'changed' | 'removed';
18
+ }
19
+
20
+ /**
21
+ * An auth user a staged proposal creates (a sign-in account, distinct from
22
+ * Firestore documents). The host adapts its backend request (e.g. a
23
+ * `CreateUserRequest`) into this UI-level shape.
24
+ */
25
+ export interface CreatedAuthUser {
26
+ uid: string;
27
+ email?: string;
28
+ displayName?: string;
29
+ provider?: string;
30
+ emailVerified?: boolean;
31
+ }
32
+
33
+ export interface ProposedChangeDiffProps {
34
+ changes: FieldChange[];
35
+ /** Auth users the proposal creates, shown as a leading "auth users" group. */
36
+ authUsers?: readonly CreatedAuthUser[];
37
+ className?: string;
38
+ /** Rendered when there are no changes. Defaults to nothing. */
39
+ emptyState?: ReactNode;
40
+ /** Format a value for display. Default: JSON-ish, empty for `undefined`. */
41
+ formatValue?: (value: unknown) => ReactNode;
42
+ }
43
+
44
+ interface DocGroup {
45
+ docPath: string;
46
+ docId: string;
47
+ fields: FieldChange[];
48
+ }
49
+ interface CollectionGroup {
50
+ collection: string;
51
+ docs: DocGroup[];
52
+ }
53
+
54
+ function groupChanges(changes: FieldChange[]): CollectionGroup[] {
55
+ const byCollection = new Map<string, Map<string, FieldChange[]>>();
56
+ for (const change of changes) {
57
+ const slash = change.docPath.lastIndexOf('/');
58
+ const collection = slash > 0 ? change.docPath.slice(0, slash) : change.docPath;
59
+ let docs = byCollection.get(collection);
60
+ if (!docs) {
61
+ docs = new Map();
62
+ byCollection.set(collection, docs);
63
+ }
64
+ let fields = docs.get(change.docPath);
65
+ if (!fields) {
66
+ fields = [];
67
+ docs.set(change.docPath, fields);
68
+ }
69
+ fields.push(change);
70
+ }
71
+ return [...byCollection.entries()].map(([collection, docs]) => ({
72
+ collection,
73
+ docs: [...docs.entries()].map(([docPath, fields]) => ({
74
+ docPath,
75
+ docId: docPath.slice(docPath.lastIndexOf('/') + 1),
76
+ fields,
77
+ })),
78
+ }));
79
+ }
80
+
81
+ /** A one-line account summary: "alice@x.dev · Alice · verified". */
82
+ function formatAuthUser(u: CreatedAuthUser): string {
83
+ const parts = [u.email, u.displayName, u.provider, u.emailVerified ? 'verified' : null].filter(
84
+ Boolean,
85
+ );
86
+ return parts.length ? (parts as string[]).join(' · ') : 'new account';
87
+ }
88
+
89
+ function defaultFormatValue(value: unknown): string {
90
+ if (value === undefined) return '';
91
+ if (typeof value === 'string') return JSON.stringify(value);
92
+ try {
93
+ // Truncate vectors so a real embedding never dumps its full array here.
94
+ return JSON.stringify(truncateVectorsForDisplay(value));
95
+ } catch {
96
+ return String(value);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Headless renderer for a staged change: the documents a proposal touches, with
102
+ * per-field before/after. Grouped by collection. Ships zero styling; the host
103
+ * styles the `data-pyric-*` contract (`proposed-change-diff` /
104
+ * `data-pyric-change-*`). The c-review diff grid is this, styled.
105
+ */
106
+ export function ProposedChangeDiff({
107
+ changes,
108
+ authUsers = [],
109
+ className,
110
+ emptyState = null,
111
+ formatValue = defaultFormatValue,
112
+ }: ProposedChangeDiffProps) {
113
+ const groups = useMemo(() => groupChanges(changes), [changes]);
114
+ if (groups.length === 0 && authUsers.length === 0) return <>{emptyState}</>;
115
+
116
+ return (
117
+ <div className={className} data-pyric-ui="proposed-change-diff">
118
+ {authUsers.length > 0 ? (
119
+ <section data-pyric-change-group data-pyric-change-authgroup data-pyric-change-collection="auth users">
120
+ <header data-pyric-change-grouphead>
121
+ <span data-pyric-change-collname>auth users</span>
122
+ <span data-pyric-change-count>{authUsers.length}</span>
123
+ </header>
124
+ <ul data-pyric-change-docs>
125
+ {authUsers.map((user) => (
126
+ <li key={user.uid} data-pyric-change-doc data-pyric-change-authuser>
127
+ <span data-pyric-change-docid>{user.uid}</span>
128
+ <span data-pyric-change-authmeta>{formatAuthUser(user)}</span>
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ </section>
133
+ ) : null}
134
+ {groups.map((group) => (
135
+ <section
136
+ key={group.collection}
137
+ data-pyric-change-group
138
+ data-pyric-change-collection={group.collection}
139
+ >
140
+ <header data-pyric-change-grouphead>
141
+ <span data-pyric-change-collname>{group.collection}</span>
142
+ <span data-pyric-change-count>{group.docs.length}</span>
143
+ </header>
144
+ <ul data-pyric-change-docs>
145
+ {group.docs.map((doc) => (
146
+ <li key={doc.docPath} data-pyric-change-doc>
147
+ <span data-pyric-change-docid>{doc.docId}</span>
148
+ <ul data-pyric-change-fields>
149
+ {doc.fields.map((field) => (
150
+ <li
151
+ key={field.field}
152
+ data-pyric-change-field
153
+ data-pyric-change-kind={field.kind}
154
+ >
155
+ <span data-pyric-change-key>{field.field}</span>
156
+ {field.kind !== 'added' ? (
157
+ <span data-pyric-change-before>{formatValue(field.before)}</span>
158
+ ) : null}
159
+ <span data-pyric-change-arrow aria-hidden>
160
+ {'→'}
161
+ </span>
162
+ {field.kind !== 'removed' ? (
163
+ <span data-pyric-change-after>{formatValue(field.after)}</span>
164
+ ) : null}
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ </li>
169
+ ))}
170
+ </ul>
171
+ </section>
172
+ ))}
173
+ </div>
174
+ );
175
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Default `when`-column formatter: a session-relative duration
3
+ * (`now` / `12s` / `3m` / `1h`), matching the mock's `c-when` strings.
4
+ * The grid is session-scoped (see design-ideation "FRAME CORRECTION")
5
+ * so anchors are relative, never absolute calendar dates.
6
+ *
7
+ * Override via `<ActivityGrid formatWhen={...} />` when the host has a
8
+ * better clock anchor (e.g. a ticking "session now").
9
+ */
10
+ export function defaultFormatWhen(at: number, now: number = Date.now()): string {
11
+ const deltaMs = Math.max(0, now - at);
12
+ const s = Math.floor(deltaMs / 1000);
13
+ if (s < 1) return 'now';
14
+ if (s < 60) return `${s}s`;
15
+ const m = Math.floor(s / 60);
16
+ if (m < 60) return `${m}m`;
17
+ const h = Math.floor(m / 60);
18
+ if (h < 24) return `${h}h`;
19
+ const d = Math.floor(h / 24);
20
+ return `${d}d`;
21
+ }
@@ -0,0 +1,17 @@
1
+ export { ActivityGrid, type ActivityGridProps } from './ActivityGrid.js';
2
+ export {
3
+ ActivityGridRow,
4
+ type ActivityGridRowProps,
5
+ } from './ActivityGridRow.js';
6
+ export {
7
+ ActivityActionItems,
8
+ type ActivityActionItemsProps,
9
+ type ActivityActionItem,
10
+ } from './ActivityActionItems.js';
11
+ export {
12
+ ProposedChangeDiff,
13
+ type ProposedChangeDiffProps,
14
+ type FieldChange,
15
+ type CreatedAuthUser,
16
+ } from './ProposedChangeDiff.js';
17
+ export { defaultFormatWhen } from './format.js';