@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,231 @@
1
+ import { Timestamp, GeoPoint, Bytes } from 'pyric/firestore';
2
+
3
+ /**
4
+ * The set of value types `@pyric/ui` knows how to display + edit.
5
+ * Maps 1:1 to Firestore's serializable value shapes; consumers can
6
+ * extend the registry but the built-in editors cover these.
7
+ */
8
+ export type FieldType =
9
+ | 'string'
10
+ | 'number'
11
+ | 'boolean'
12
+ | 'null'
13
+ | 'timestamp'
14
+ | 'geopoint'
15
+ | 'reference'
16
+ | 'bytes'
17
+ | 'map'
18
+ | 'array'
19
+ | 'vector';
20
+
21
+ /**
22
+ * Runtime-classify a value into one of the {@link FieldType}s.
23
+ *
24
+ * The discrimination order matters:
25
+ * - `null` checked before `typeof === 'object'` (null is an object)
26
+ * - vector (a typed embedding wrapper) checked before `Array.isArray`
27
+ * and before generic objects — its wire-sentinel shape is a plain
28
+ * object, and a bare `number[]` must stay `array`, not `vector`
29
+ * - `Array.isArray` checked before generic objects
30
+ * - Firestore special types (Timestamp/GeoPoint/Bytes/DocumentRef)
31
+ * checked before falling through to `map`
32
+ *
33
+ * `undefined` values aren't legal Firestore field values; we coerce
34
+ * them to `'null'` rather than throw — the caller can decide whether
35
+ * to display or filter.
36
+ */
37
+ export function inferType(value: unknown): FieldType {
38
+ if (value === null || value === undefined) return 'null';
39
+ if (typeof value === 'string') return 'string';
40
+ if (typeof value === 'number') return 'number';
41
+ if (typeof value === 'boolean') return 'boolean';
42
+
43
+ // Vector (embedding) before array + map: a vector arrives as one of
44
+ // several typed shapes (see {@link asVectorView}), one of which is a
45
+ // plain `{__type__:'__vector__', value}` object. A bare numeric array
46
+ // is deliberately NOT a vector — only the typed VectorValue / wrapper
47
+ // / sentinel shapes classify here, so ordinary `number[]` data stays
48
+ // `array`.
49
+ if (typeof value === 'object' && asVectorView(value) !== null) return 'vector';
50
+
51
+ if (Array.isArray(value)) return 'array';
52
+
53
+ // Firestore SDK value types — these are class instances at runtime.
54
+ // `instanceof` works against the same import pyric/firestore
55
+ // re-exports (Bytes / GeoPoint from firebase/firestore directly;
56
+ // Timestamp from either backend's compatible class).
57
+ if (value instanceof Timestamp) return 'timestamp';
58
+ if (value instanceof GeoPoint) return 'geopoint';
59
+ if (value instanceof Bytes) return 'bytes';
60
+
61
+ // Serialized Timestamp / GeoPoint: crossing a worker / postMessage boundary
62
+ // strips the class, leaving plain `{ seconds, nanoseconds }` /
63
+ // `{ latitude, longitude }`. Detect them structurally (same rationale as
64
+ // references below) rather than rendering them as maps of internal fields.
65
+ if (typeof value === 'object' && isTimestampShape(value)) return 'timestamp';
66
+ if (typeof value === 'object' && isGeoPointShape(value)) return 'geopoint';
67
+
68
+ // DocumentReference has no shared class identity across the two
69
+ // backends (sandbox-chainable vs. firebase/firestore). Use a
70
+ // structural check on the brand-bearing fields. Any object that
71
+ // looks reference-shaped (path + firestore handle + id) is
72
+ // classified as a reference; the alternative is rendering it as
73
+ // a map of those three fields, which is strictly worse.
74
+ if (typeof value === 'object' && isDocumentReferenceShape(value)) {
75
+ return 'reference';
76
+ }
77
+
78
+ if (typeof value === 'object') return 'map';
79
+
80
+ // Unreachable for Firestore-shaped data, but TS wants exhaustivity.
81
+ return 'null';
82
+ }
83
+
84
+ /**
85
+ * A serialized Timestamp: exactly `{ seconds, nanoseconds }` (or the firebase
86
+ * `{ _seconds, _nanoseconds }` variant), both numbers, no other keys. The
87
+ * "exactly two keys" guard keeps a genuine map that merely contains those
88
+ * fields from being misclassified.
89
+ */
90
+ export function isTimestampShape(v: object): boolean {
91
+ const obj = v as Record<string, unknown>;
92
+ const keys = Object.keys(obj);
93
+ if (keys.length !== 2) return false;
94
+ return (
95
+ (typeof obj.seconds === 'number' && typeof obj.nanoseconds === 'number') ||
96
+ (typeof obj._seconds === 'number' && typeof obj._nanoseconds === 'number')
97
+ );
98
+ }
99
+
100
+ /** A serialized GeoPoint: exactly `{ latitude, longitude }`, both numbers. The
101
+ * GeoPoint display reads `.latitude` / `.longitude`, so the plain shape renders
102
+ * unchanged. */
103
+ export function isGeoPointShape(v: object): boolean {
104
+ const obj = v as Record<string, unknown>;
105
+ const keys = Object.keys(obj);
106
+ return keys.length === 2 && typeof obj.latitude === 'number' && typeof obj.longitude === 'number';
107
+ }
108
+
109
+ function isDocumentReferenceShape(v: object): boolean {
110
+ const obj = v as Record<string, unknown>;
111
+ if (typeof obj.path !== 'string' || typeof obj.id !== 'string') return false;
112
+ // The Web SDK ref exposes a `.firestore` handle; the chainable
113
+ // (sandbox) ref exposes `.env` instead. Either signals "this is a
114
+ // ref, not a plain map that happens to have `path` + `id` fields."
115
+ // Also accept `.type === 'document'` which both backends set on
116
+ // their ref class instances.
117
+ const hasFirestore = typeof obj.firestore === 'object' && obj.firestore !== null;
118
+ const hasEnv = typeof obj.env === 'object' && obj.env !== null;
119
+ const hasDocBrand = obj.type === 'document';
120
+ return hasFirestore || hasEnv || hasDocBrand;
121
+ }
122
+
123
+ /**
124
+ * Normalized read-side view of a Firestore vector (embedding) value.
125
+ * Editors and the renderer work against this rather than the raw shape
126
+ * so they don't have to care which backend produced the value.
127
+ */
128
+ export interface VectorView {
129
+ /** The embedding components. Defensive copy — safe to read freely. */
130
+ readonly values: number[];
131
+ /** Number of components. Equivalent to `values.length`; surfaced
132
+ * separately because that's what the UI labels (`vector · <dims>`). */
133
+ readonly dimension: number;
134
+ }
135
+
136
+ /**
137
+ * Detect + normalize a Firestore vector value, or return `null` if the
138
+ * value isn't a vector. Vectors reach `@pyric/ui` in several runtime
139
+ * shapes depending on the backend the snapshot came from — there is no
140
+ * single `VectorValue` class `pyric/firestore` re-exports, so we match
141
+ * structurally (the same strategy {@link isDocumentReferenceShape} uses
142
+ * for refs):
143
+ *
144
+ * 1. **pyric `Vector` wrapper** — frozen `.value: number[]` array plus
145
+ * a `.dimension` getter (sandbox / rules-side reads).
146
+ * 2. **firebase/firestore (web) `VectorValue`** — exposes `.toArray()`
147
+ * and nothing else publicly.
148
+ * 3. **firebase-admin `VectorValue`** — internal `._values: number[]`
149
+ * (also a `.toArray()`).
150
+ * 4. **wire sentinel** — `{ __type__: '__vector__', value: number[] }`,
151
+ * the plain-object encoded form a discover crawler / seed emits.
152
+ *
153
+ * A bare `number[]` is intentionally NOT a vector — those stay `array`.
154
+ * Only the typed/branded shapes above match.
155
+ */
156
+ export function asVectorView(value: unknown): VectorView | null {
157
+ if (value === null || typeof value !== 'object') return null;
158
+ const obj = value as Record<string, unknown>;
159
+
160
+ // (1) pyric Vector wrapper: branded by `typeName === 'vector'` with a
161
+ // numeric `.value` array. `.dimension` is a getter on the class.
162
+ if (obj.typeName === 'vector' && isNumberArray(obj.value)) {
163
+ return freezeView(obj.value as number[]);
164
+ }
165
+
166
+ // (4) wire sentinel: `{ __type__: '__vector__', value: number[] }`.
167
+ if (obj.__type__ === '__vector__' && isNumberArray(obj.value)) {
168
+ return freezeView(obj.value as number[]);
169
+ }
170
+
171
+ // (3) firebase-admin VectorValue: internal `_values` array.
172
+ if (isNumberArray(obj._values)) {
173
+ return freezeView(obj._values as number[]);
174
+ }
175
+
176
+ // (2) firebase/firestore (web) VectorValue: only a `.toArray()`. Guard
177
+ // the call so a plain object with an unrelated `toArray` can't throw —
178
+ // a non-numeric result fails the `isNumberArray` gate below.
179
+ if (typeof obj.toArray === 'function') {
180
+ try {
181
+ const arr = (obj.toArray as () => unknown)();
182
+ if (isNumberArray(arr)) return freezeView(arr as number[]);
183
+ } catch {
184
+ // Not a vector — fall through.
185
+ }
186
+ }
187
+
188
+ return null;
189
+ }
190
+
191
+ function isNumberArray(v: unknown): v is number[] {
192
+ return Array.isArray(v) && v.every((n) => typeof n === 'number');
193
+ }
194
+
195
+ function freezeView(values: number[]): VectorView {
196
+ const copy = values.slice();
197
+ return { values: copy, dimension: copy.length };
198
+ }
199
+
200
+ /** How many leading components to show in a truncated vector preview. */
201
+ const VECTOR_PREVIEW_COUNT = 4;
202
+
203
+ /** Compact, display-safe rendering of a vector: `vector · <dim> [a, b, c, …]`.
204
+ * So a real embedding never dumps its full array into a diff, a rules trace, or
205
+ * a debugger panel. */
206
+ export function vectorPreview(view: VectorView): string {
207
+ const head = view.values.slice(0, VECTOR_PREVIEW_COUNT).join(', ');
208
+ const tail = view.values.length > VECTOR_PREVIEW_COUNT ? ', …' : '';
209
+ return `vector · ${view.dimension} [${head}${tail}]`;
210
+ }
211
+
212
+ /** Deep-replace any vector-shaped value with a compact preview STRING, so the
213
+ * result can be `JSON.stringify`'d / formatted without dumping full embeddings.
214
+ * Recurses plain objects + arrays; class instances (Timestamp/GeoPoint) and
215
+ * scalars pass through untouched. Vector instances/sentinels are caught first. */
216
+ export function truncateVectorsForDisplay(value: unknown): unknown {
217
+ const view = asVectorView(value);
218
+ if (view) return vectorPreview(view);
219
+ if (Array.isArray(value)) return value.map(truncateVectorsForDisplay);
220
+ if (value && typeof value === 'object') {
221
+ const proto = Object.getPrototypeOf(value);
222
+ if (proto === Object.prototype || proto === null) {
223
+ const out: Record<string, unknown> = {};
224
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
225
+ out[k] = truncateVectorsForDisplay(v);
226
+ }
227
+ return out;
228
+ }
229
+ }
230
+ return value;
231
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Firestore collection/document id validation (create-collection /
3
+ * create-document / JSON-import flows).
4
+ *
5
+ * Pure, dependency-free, and deliberately narrow: it mirrors the rules the
6
+ * real backend enforces (see Firestore's "Rules and limits" docs), so a form
7
+ * can reject an invalid id BEFORE the write round-trip rather than
8
+ * surfacing a raw backend error string.
9
+ *
10
+ * Rules (both collection and document ids):
11
+ * - non-empty
12
+ * - no `/` (that's a path separator, not part of an id)
13
+ * - not solely `.` or `..`
14
+ * - doesn't match `__.*__` (reserved for internal use)
15
+ * - at most 1500 bytes (UTF-8) — Firestore caps BOTH collection ids and
16
+ * document ids at 1500 bytes ("Rules and limits": "Maximum size for a
17
+ * collection ID" / "Maximum size for a document ID", both 1,500 bytes).
18
+ */
19
+
20
+ const RESERVED_DUNDER = /^__.*__$/;
21
+
22
+ /** Shared structural checks common to both collection and document ids. */
23
+ function structuralError(id: string): string | undefined {
24
+ if (id.length === 0) return 'Cannot be empty';
25
+ if (id.includes('/')) return 'Cannot contain "/"';
26
+ if (id === '.' || id === '..') return 'Cannot be "." or ".."';
27
+ if (RESERVED_DUNDER.test(id)) return 'Cannot match __.*__ (reserved)';
28
+ if (utf8ByteLength(id) > 1500) return 'Cannot exceed 1500 bytes';
29
+ return undefined;
30
+ }
31
+
32
+ /** Validate a collection id. Returns an error message, or `undefined` when valid. */
33
+ export function validateCollectionId(id: string): string | undefined {
34
+ return structuralError(id);
35
+ }
36
+
37
+ /** UTF-8 byte length of a string (Firestore's 1500-byte id cap). */
38
+ function utf8ByteLength(s: string): number {
39
+ return new TextEncoder().encode(s).length;
40
+ }
41
+
42
+ /** Validate a document id. Returns an error message, or `undefined` when valid. */
43
+ export function validateDocumentId(id: string): string | undefined {
44
+ return structuralError(id);
45
+ }
@@ -0,0 +1,43 @@
1
+ interface FirestoreComparable {
2
+ isEqual(other: unknown): boolean;
3
+ }
4
+
5
+ function hasFirestoreEquality(value: unknown): value is FirestoreComparable {
6
+ return (
7
+ typeof value === 'object' &&
8
+ value !== null &&
9
+ 'isEqual' in value &&
10
+ typeof (value as { isEqual?: unknown }).isEqual === 'function'
11
+ );
12
+ }
13
+
14
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
15
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
16
+ const prototype = Object.getPrototypeOf(value);
17
+ return prototype === Object.prototype || prototype === null;
18
+ }
19
+
20
+ /** Firestore-aware structural equality for values delivered by either the
21
+ * in-process SDK or the SharedWorker serializer. */
22
+ export function firestoreValuesEqual(previous: unknown, next: unknown): boolean {
23
+ if (Object.is(previous, next)) return true;
24
+ if (hasFirestoreEquality(previous)) return previous.isEqual(next);
25
+ if (hasFirestoreEquality(next)) return next.isEqual(previous);
26
+ if (previous instanceof Uint8Array || next instanceof Uint8Array) {
27
+ if (!(previous instanceof Uint8Array) || !(next instanceof Uint8Array)) return false;
28
+ if (previous.length !== next.length) return false;
29
+ return previous.every((byte, index) => byte === next[index]);
30
+ }
31
+ if (Array.isArray(previous) || Array.isArray(next)) {
32
+ if (!Array.isArray(previous) || !Array.isArray(next)) return false;
33
+ if (previous.length !== next.length) return false;
34
+ return previous.every((value, index) => firestoreValuesEqual(value, next[index]));
35
+ }
36
+ if (!isPlainRecord(previous) || !isPlainRecord(next)) return false;
37
+ const previousKeys = Object.keys(previous);
38
+ const nextKeys = Object.keys(next);
39
+ if (previousKeys.length !== nextKeys.length) return false;
40
+ return previousKeys.every(
41
+ (key) => key in next && firestoreValuesEqual(previous[key], next[key]),
42
+ );
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Intentionally empty. Consumers import from subpaths:
2
+ // @pyric/ui/primitives
3
+ // @pyric/ui/firestore
4
+ // @pyric/ui/firestore/hooks
5
+ // @pyric/ui/rtdb
6
+ //
7
+ // This forces deliberate dependency choices — a consumer that only
8
+ // uses `useFirestoreDoc` doesn't pull in any primitive components,
9
+ // and vice versa.
10
+ export {};
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface BadgeProps {
4
+ /** Badge content — usually a short word like "ALLOW" or "GET". */
5
+ children: ReactNode;
6
+ /**
7
+ * Freeform category surfaced as `data-pyric-badge-kind`. The
8
+ * library doesn't enumerate kinds — the consumer decides what
9
+ * values exist (`allow`, `deny`, `get`, `update`, …) and styles
10
+ * them via `[data-pyric-badge-kind="…"]`.
11
+ */
12
+ kind?: string;
13
+ /** Forwarded to the underlying `<span>`. */
14
+ className?: string;
15
+ /**
16
+ * Accessible label. When set, the visible text becomes
17
+ * `aria-hidden` and screen readers announce this instead — useful
18
+ * when the badge is a terse glyph but the meaning is longer.
19
+ */
20
+ ariaLabel?: string;
21
+ }
22
+
23
+ /**
24
+ * Headless pill / tag. Renders an inline `<span>` carrying
25
+ * `data-pyric-badge` and (when `kind` is set) `data-pyric-badge-kind`
26
+ * so consumers can style categories with attribute selectors. Ships
27
+ * no visual styling of its own.
28
+ */
29
+ export function Badge({ children, kind, className, ariaLabel }: BadgeProps) {
30
+ return (
31
+ <span
32
+ data-pyric-badge=""
33
+ data-pyric-badge-kind={kind}
34
+ className={className}
35
+ aria-label={ariaLabel}
36
+ >
37
+ {ariaLabel ? <span aria-hidden="true">{children}</span> : children}
38
+ </span>
39
+ );
40
+ }
@@ -0,0 +1,137 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ export interface ConfirmDialogProps {
5
+ /** Controlled open state. */
6
+ open: boolean;
7
+ /** Called when the user dismisses via overlay click, Escape, or
8
+ * the cancel button. NOT called by `onConfirm`. */
9
+ onOpenChange: (open: boolean) => void;
10
+ /** Heading. */
11
+ title: string;
12
+ /** Body content — explanation, consequences, paths affected. */
13
+ body?: ReactNode;
14
+ /** When `true`, the confirm button carries `data-pyric-destructive`
15
+ * so consumers can style it differently (e.g. red). */
16
+ destructive?: boolean;
17
+ confirmLabel?: string;
18
+ cancelLabel?: string;
19
+ /** Fires when the user presses confirm. The component does NOT
20
+ * close itself on confirm — the consumer typically dismisses
21
+ * after the destructive action resolves. */
22
+ onConfirm: () => void;
23
+ /** Forwarded to the content node so consumers can style. */
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * Headless confirmation dialog. Hand-rolled (we evaluated Radix
29
+ * Dialog at M4 but Radix's Presence + Portal stack doesn't render
30
+ * under our bun:test + JSDOM env — see plan section 7 risk #1).
31
+ *
32
+ * Provides:
33
+ * - Portal to `document.body` (so the dialog can escape parent
34
+ * stacking contexts)
35
+ * - Escape-to-close
36
+ * - Overlay click to close
37
+ * - ARIA `role="dialog" aria-modal="true"` wiring
38
+ * - Focus restoration to the previously-focused element on close
39
+ * - Initial focus on the confirm button when opening
40
+ *
41
+ * Ships no visual CSS. Consumers style via the structural
42
+ * `data-pyric-*` attributes.
43
+ */
44
+ export function ConfirmDialog({
45
+ open,
46
+ onOpenChange,
47
+ title,
48
+ body,
49
+ destructive,
50
+ confirmLabel = 'Confirm',
51
+ cancelLabel = 'Cancel',
52
+ onConfirm,
53
+ className,
54
+ }: ConfirmDialogProps) {
55
+ const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
56
+ const previouslyFocused = useRef<Element | null>(null);
57
+
58
+ // Escape-to-close.
59
+ useEffect(() => {
60
+ if (!open) return;
61
+ const handler = (e: KeyboardEvent) => {
62
+ if (e.key === 'Escape') {
63
+ e.stopPropagation();
64
+ onOpenChange(false);
65
+ }
66
+ };
67
+ window.addEventListener('keydown', handler);
68
+ return () => window.removeEventListener('keydown', handler);
69
+ }, [open, onOpenChange]);
70
+
71
+ // Focus management: capture the previously-focused element on
72
+ // open, restore it on close. Initial focus goes to the confirm
73
+ // button so destructive actions don't dispatch by accident
74
+ // (consumers wanting "Cancel" as the default can refocus in an
75
+ // effect).
76
+ useEffect(() => {
77
+ if (!open) return;
78
+ previouslyFocused.current = document.activeElement;
79
+ confirmButtonRef.current?.focus();
80
+ return () => {
81
+ const prev = previouslyFocused.current as HTMLElement | null;
82
+ prev?.focus?.();
83
+ };
84
+ }, [open]);
85
+
86
+ if (!open) return null;
87
+ if (typeof document === 'undefined') return null;
88
+
89
+ return createPortal(
90
+ <div
91
+ role="presentation"
92
+ data-pyric-ui="confirm-portal"
93
+ onClick={(e) => {
94
+ if (e.target === e.currentTarget) onOpenChange(false);
95
+ }}
96
+ >
97
+ <div data-pyric-ui="confirm-overlay" aria-hidden="true" />
98
+ <div
99
+ role="dialog"
100
+ aria-modal="true"
101
+ aria-labelledby="pyric-confirm-title"
102
+ aria-describedby={body ? 'pyric-confirm-body' : undefined}
103
+ className={className}
104
+ data-pyric-ui="confirm-dialog"
105
+ data-pyric-destructive={destructive ? '' : undefined}
106
+ >
107
+ <div id="pyric-confirm-title" data-pyric-confirm-title>
108
+ {title}
109
+ </div>
110
+ {body ? (
111
+ <div id="pyric-confirm-body" data-pyric-confirm-body>
112
+ {body}
113
+ </div>
114
+ ) : null}
115
+ <div data-pyric-confirm-actions>
116
+ <button
117
+ type="button"
118
+ data-pyric-confirm-cancel
119
+ onClick={() => onOpenChange(false)}
120
+ >
121
+ {cancelLabel}
122
+ </button>
123
+ <button
124
+ ref={confirmButtonRef}
125
+ type="button"
126
+ data-pyric-confirm-confirm
127
+ data-pyric-destructive={destructive ? '' : undefined}
128
+ onClick={onConfirm}
129
+ >
130
+ {confirmLabel}
131
+ </button>
132
+ </div>
133
+ </div>
134
+ </div>,
135
+ document.body,
136
+ );
137
+ }
@@ -0,0 +1,62 @@
1
+ import { useState, type ReactNode } from 'react';
2
+
3
+ export interface CopyButtonProps {
4
+ /** Text to copy to the clipboard on click. */
5
+ text: string;
6
+ /** Optional content to render inside the button. Defaults to a
7
+ * short text label that toggles between "Copy" and "Copied". */
8
+ children?: ReactNode;
9
+ /** Milliseconds before the `data-copied` state attribute clears.
10
+ * Defaults to 2000. */
11
+ resetMs?: number;
12
+ /** Forwarded to the underlying `<button>`. Consumers compose
13
+ * Tailwind classes, CSS-module classes, or whatever they want. */
14
+ className?: string;
15
+ /** Forwarded as the button's accessible label when in the idle
16
+ * state. Defaults to "Copy to clipboard". The copied state uses
17
+ * a hard-coded "Copied" label so screen readers announce the
18
+ * state change consistently. */
19
+ ariaLabel?: string;
20
+ }
21
+
22
+ /**
23
+ * Headless copy-to-clipboard button. Exposes its `copied` state via
24
+ * the `data-copied` attribute on the underlying `<button>` so
25
+ * consumers can style the success state with `[data-copied]` (or
26
+ * `data-[copied]:bg-green-500` in Tailwind's arbitrary-variant
27
+ * syntax). Ships no visual styling of its own.
28
+ */
29
+ export function CopyButton({
30
+ text,
31
+ children,
32
+ resetMs = 2000,
33
+ className,
34
+ ariaLabel = 'Copy to clipboard',
35
+ }: CopyButtonProps) {
36
+ const [copied, setCopied] = useState(false);
37
+
38
+ async function handleClick() {
39
+ try {
40
+ await navigator.clipboard.writeText(text);
41
+ } catch {
42
+ // Clipboard write can fail (insecure context, denied permission).
43
+ // The library doesn't surface this here — callers wrap in a
44
+ // toast / callout if they want feedback.
45
+ return;
46
+ }
47
+ setCopied(true);
48
+ window.setTimeout(() => setCopied(false), resetMs);
49
+ }
50
+
51
+ return (
52
+ <button
53
+ type="button"
54
+ onClick={handleClick}
55
+ className={className}
56
+ data-copied={copied ? '' : undefined}
57
+ aria-label={copied ? 'Copied' : ariaLabel}
58
+ >
59
+ {children ?? (copied ? 'Copied' : 'Copy')}
60
+ </button>
61
+ );
62
+ }