@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.13

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,58 @@
1
+ import { useState } from 'react';
2
+ import { inferType } from '../types.js';
3
+ import { FieldRenderer } from './FieldRenderer.js';
4
+ import type { FieldEditorRegistry } from '../fieldEditors/types.js';
5
+
6
+ /**
7
+ * One row of the read-only value tree. A scalar renders `key: value` inline; a
8
+ * map/array renders a disclosure chevron + key that toggles its nested tree
9
+ * (collapsible, default expanded). Shared by the map + array displays so both
10
+ * lists and objects get the same chevron behavior.
11
+ */
12
+ export function TreeEntry({
13
+ label,
14
+ value,
15
+ path,
16
+ fieldEditors,
17
+ kind,
18
+ }: {
19
+ label: string;
20
+ value: unknown;
21
+ path: string;
22
+ fieldEditors: FieldEditorRegistry;
23
+ kind: 'key' | 'index';
24
+ }) {
25
+ const type = inferType(value);
26
+ const nested = type === 'map' || type === 'array';
27
+ const [expanded, setExpanded] = useState(true);
28
+ const labelAttr = kind === 'key' ? { 'data-pyric-tree-key': '' } : { 'data-pyric-tree-index': '' };
29
+ // Stable targeting hook for the entry (map key / array index).
30
+ const entryAttr = kind === 'key' ? { 'data-field-name': label } : { 'data-field-index': label };
31
+
32
+ if (!nested) {
33
+ return (
34
+ <li data-pyric-tree-entry {...entryAttr}>
35
+ <span data-pyric-tree-chevron data-pyric-tree-chevron-empty aria-hidden="true" />
36
+ <span {...labelAttr}>{label}</span>
37
+ <FieldRenderer value={value} path={path} fieldEditors={fieldEditors} />
38
+ </li>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <li data-pyric-tree-entry data-pyric-tree-nested {...entryAttr}>
44
+ <button
45
+ type="button"
46
+ data-pyric-tree-toggle
47
+ aria-expanded={expanded}
48
+ onClick={() => setExpanded((e) => !e)}
49
+ >
50
+ <span data-pyric-tree-chevron aria-hidden="true">
51
+
52
+ </span>
53
+ <span {...labelAttr}>{label}</span>
54
+ </button>
55
+ {expanded ? <FieldRenderer value={value} path={path} fieldEditors={fieldEditors} /> : null}
56
+ </li>
57
+ );
58
+ }
@@ -0,0 +1,25 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { DocumentReference } from 'pyric/firestore';
3
+
4
+ /**
5
+ * Cross-cutting display context threaded into individual field
6
+ * editors. Today it carries:
7
+ *
8
+ * - `onReferenceClick` — fires when the reference Display
9
+ * component is activated. Consumers wire navigation here.
10
+ *
11
+ * Read by the per-type Display components via `useDisplayContext`.
12
+ * Set by `<DocumentPreview>`'s wrapper. Editors that don't need it
13
+ * (string, number, …) simply ignore the context.
14
+ */
15
+ export interface DisplayContextValue {
16
+ onReferenceClick?: (ref: DocumentReference) => void;
17
+ }
18
+
19
+ const Ctx = createContext<DisplayContextValue>({});
20
+
21
+ export const DisplayContextProvider = Ctx.Provider;
22
+
23
+ export function useDisplayContext(): DisplayContextValue {
24
+ return useContext(Ctx);
25
+ }
@@ -0,0 +1,30 @@
1
+ import { TreeEntry } from '../components/TreeEntry.js';
2
+ import type { FieldEditorContract, FieldDisplayProps } from './types.js';
3
+
4
+ /**
5
+ * Read-only array renderer as an indented tree: each element is `index value`
6
+ * inline for scalars, or a collapsible disclosure (chevron + index) whose
7
+ * nested tree indents below for maps/arrays (so arrays-of-maps render through
8
+ * the same recursion). {@link TreeEntry} owns the collapse state + chevron.
9
+ */
10
+ function ArrayDisplay({ value, path, fieldEditors }: FieldDisplayProps<unknown[]>) {
11
+ return (
12
+ <ul data-pyric-field-type="array" data-pyric-tree data-pyric-field-path={path}>
13
+ {value.map((item, i) => (
14
+ <TreeEntry
15
+ key={i}
16
+ label={String(i)}
17
+ value={item}
18
+ path={path ? `${path}[${i}]` : `[${i}]`}
19
+ fieldEditors={fieldEditors ?? {}}
20
+ kind="index"
21
+ />
22
+ ))}
23
+ </ul>
24
+ );
25
+ }
26
+
27
+ export const arrayEditor: FieldEditorContract<unknown[]> = {
28
+ type: 'array',
29
+ Display: ArrayDisplay,
30
+ };
@@ -0,0 +1,39 @@
1
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
2
+
3
+ function BooleanDisplay({ value, path }: FieldDisplayProps<boolean>) {
4
+ return (
5
+ <span
6
+ data-pyric-field-type="boolean"
7
+ data-pyric-field-path={path}
8
+ data-value={value ? 'true' : 'false'}
9
+ >
10
+ {value ? 'true' : 'false'}
11
+ </span>
12
+ );
13
+ }
14
+
15
+ function BooleanEdit({ value, onChange, path }: FieldEditProps<boolean>) {
16
+ return (
17
+ <span
18
+ data-pyric-field-type="boolean"
19
+ data-pyric-field-path={path}
20
+ data-value={value ? 'true' : 'false'}
21
+ >
22
+ <select
23
+ value={value ? 'true' : 'false'}
24
+ onChange={(e) => onChange(e.target.value === 'true')}
25
+ aria-label="Boolean value"
26
+ data-pyric-boolean-select
27
+ >
28
+ <option value="true">true</option>
29
+ <option value="false">false</option>
30
+ </select>
31
+ </span>
32
+ );
33
+ }
34
+
35
+ export const booleanEditor: FieldEditorContract<boolean> = {
36
+ type: 'boolean',
37
+ Display: BooleanDisplay,
38
+ Edit: BooleanEdit,
39
+ };
@@ -0,0 +1,53 @@
1
+ import { Bytes } from 'pyric/firestore';
2
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
3
+
4
+ function BytesDisplay({ value, path }: FieldDisplayProps<Bytes>) {
5
+ const b64 = value.toBase64();
6
+ return (
7
+ <code
8
+ data-pyric-field-type="bytes"
9
+ data-pyric-field-path={path}
10
+ data-byte-length={String(value.toUint8Array().byteLength)}
11
+ >
12
+ {b64}
13
+ </code>
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Base64 textarea. Anything that's not legal base64 produces a
19
+ * thrown error when passed to `Bytes.fromBase64String`; we swallow
20
+ * it and leave the old value in place. The validator surfaces the
21
+ * stale state by not flagging it (the underlying Bytes is still
22
+ * valid), so consumers wanting stricter feedback need to swap the
23
+ * editor.
24
+ */
25
+ function BytesEdit({ value, onChange, error, path }: FieldEditProps<Bytes>) {
26
+ return (
27
+ <label
28
+ data-pyric-field-type="bytes"
29
+ data-pyric-field-path={path}
30
+ data-pyric-error={error ? '' : undefined}
31
+ >
32
+ <textarea
33
+ value={value.toBase64()}
34
+ onChange={(e) => {
35
+ try {
36
+ onChange(Bytes.fromBase64String(e.target.value));
37
+ } catch {
38
+ // Invalid base64 — keep the previous value. The user
39
+ // sees the bad text in the textarea until they fix it.
40
+ }
41
+ }}
42
+ aria-invalid={error ? 'true' : undefined}
43
+ />
44
+ {error ? <span data-pyric-error-message>{error}</span> : null}
45
+ </label>
46
+ );
47
+ }
48
+
49
+ export const bytesEditor: FieldEditorContract<Bytes> = {
50
+ type: 'bytes',
51
+ Display: BytesDisplay,
52
+ Edit: BytesEdit,
53
+ };
@@ -0,0 +1,71 @@
1
+ import { GeoPoint } from 'pyric/firestore';
2
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
3
+
4
+ function GeoPointDisplay({ value, path }: FieldDisplayProps<GeoPoint>) {
5
+ return (
6
+ <span
7
+ data-pyric-field-type="geopoint"
8
+ data-pyric-field-path={path}
9
+ data-lat={String(value.latitude)}
10
+ data-lng={String(value.longitude)}
11
+ >
12
+ {value.latitude}, {value.longitude}
13
+ </span>
14
+ );
15
+ }
16
+
17
+ function GeoPointEdit({ value, onChange, error, path }: FieldEditProps<GeoPoint>) {
18
+ return (
19
+ <span
20
+ data-pyric-field-type="geopoint"
21
+ data-pyric-field-path={path}
22
+ data-pyric-error={error ? '' : undefined}
23
+ >
24
+ <label>
25
+ <input
26
+ type="number"
27
+ step="any"
28
+ min={-90}
29
+ max={90}
30
+ value={value.latitude}
31
+ onChange={(e) => {
32
+ const lat = parseFloat(e.target.value);
33
+ try {
34
+ onChange(new GeoPoint(lat, value.longitude));
35
+ } catch {
36
+ // GeoPoint constructor throws on out-of-range. Leave
37
+ // the previous value in place; the validator will
38
+ // surface the issue on next render.
39
+ }
40
+ }}
41
+ aria-label="Latitude"
42
+ />
43
+ </label>
44
+ <label>
45
+ <input
46
+ type="number"
47
+ step="any"
48
+ min={-180}
49
+ max={180}
50
+ value={value.longitude}
51
+ onChange={(e) => {
52
+ const lng = parseFloat(e.target.value);
53
+ try {
54
+ onChange(new GeoPoint(value.latitude, lng));
55
+ } catch {
56
+ // see above
57
+ }
58
+ }}
59
+ aria-label="Longitude"
60
+ />
61
+ </label>
62
+ {error ? <span data-pyric-error-message>{error}</span> : null}
63
+ </span>
64
+ );
65
+ }
66
+
67
+ export const geopointEditor: FieldEditorContract<GeoPoint> = {
68
+ type: 'geopoint',
69
+ Display: GeoPointDisplay,
70
+ Edit: GeoPointEdit,
71
+ };
@@ -0,0 +1,33 @@
1
+ import { TreeEntry } from '../components/TreeEntry.js';
2
+ import type { FieldEditorContract, FieldDisplayProps } from './types.js';
3
+
4
+ /**
5
+ * Read-only map renderer as an indented tree (not a key/value grid): each entry
6
+ * is `key: value` inline for scalars, or a collapsible disclosure (chevron +
7
+ * key) whose nested tree indents below for maps/arrays. {@link TreeEntry} owns
8
+ * the per-node collapse state + chevron.
9
+ */
10
+ function MapDisplay({ value, path, fieldEditors }: FieldDisplayProps<Record<string, unknown>>) {
11
+ // Lexicographic key order so fields render in a stable sequence regardless of
12
+ // JS object enumeration order (matches firebase-tools-ui).
13
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
14
+ return (
15
+ <ul data-pyric-field-type="map" data-pyric-tree data-pyric-field-path={path}>
16
+ {entries.map(([key, childValue]) => (
17
+ <TreeEntry
18
+ key={key}
19
+ label={key}
20
+ value={childValue}
21
+ path={path ? `${path}.${key}` : key}
22
+ fieldEditors={fieldEditors ?? {}}
23
+ kind="key"
24
+ />
25
+ ))}
26
+ </ul>
27
+ );
28
+ }
29
+
30
+ export const mapEditor: FieldEditorContract<Record<string, unknown>> = {
31
+ type: 'map',
32
+ Display: MapDisplay,
33
+ };
@@ -0,0 +1,27 @@
1
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
2
+
3
+ function NullDisplay({ path }: FieldDisplayProps<null>) {
4
+ return (
5
+ <span data-pyric-field-type="null" data-pyric-field-path={path}>
6
+ null
7
+ </span>
8
+ );
9
+ }
10
+
11
+ // `null` has no editable surface — the user changes the type if they
12
+ // want a value. The Edit component renders a placeholder so the
13
+ // editor still emits a slot at the right path. Consumers style it
14
+ // however they like (e.g. ghost text).
15
+ function NullEdit({ path }: FieldEditProps<null>) {
16
+ return (
17
+ <span data-pyric-field-type="null" data-pyric-field-path={path}>
18
+ null
19
+ </span>
20
+ );
21
+ }
22
+
23
+ export const nullEditor: FieldEditorContract<null> = {
24
+ type: 'null',
25
+ Display: NullDisplay,
26
+ Edit: NullEdit,
27
+ };
@@ -0,0 +1,43 @@
1
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
2
+
3
+ function NumberDisplay({ value, path }: FieldDisplayProps<number>) {
4
+ return (
5
+ <span data-pyric-field-type="number" data-pyric-field-path={path}>
6
+ {String(value)}
7
+ </span>
8
+ );
9
+ }
10
+
11
+ function NumberEdit({ value, onChange, error, path }: FieldEditProps<number>) {
12
+ return (
13
+ <label
14
+ data-pyric-field-type="number"
15
+ data-pyric-field-path={path}
16
+ data-pyric-error={error ? '' : undefined}
17
+ >
18
+ <input
19
+ type="number"
20
+ // Render NaN as empty so the input doesn't show literally
21
+ // "NaN" — the underlying value still carries it until the
22
+ // user types something valid.
23
+ value={Number.isFinite(value) ? value : ''}
24
+ onChange={(e) => {
25
+ const raw = e.target.value;
26
+ // Empty input commits NaN so the reducer's validator
27
+ // flags it; this keeps the editor's UI in sync with the
28
+ // underlying value rather than swallowing the change.
29
+ const parsed = raw === '' ? Number.NaN : parseFloat(raw);
30
+ onChange(parsed);
31
+ }}
32
+ aria-invalid={error ? 'true' : undefined}
33
+ />
34
+ {error ? <span data-pyric-error-message>{error}</span> : null}
35
+ </label>
36
+ );
37
+ }
38
+
39
+ export const numberEditor: FieldEditorContract<number> = {
40
+ type: 'number',
41
+ Display: NumberDisplay,
42
+ Edit: NumberEdit,
43
+ };
@@ -0,0 +1,87 @@
1
+ import type { DocumentReference } from 'pyric/firestore';
2
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
3
+ import { useDisplayContext } from '../components/context.js';
4
+
5
+ function ReferenceDisplay({ value, path }: FieldDisplayProps<DocumentReference>) {
6
+ // Clickable when a consumer has wired `onReferenceClick` via the
7
+ // `<DocumentPreview onReferenceClick={…}>` prop. Otherwise renders
8
+ // as plain text — staying headless and avoiding fake interactivity.
9
+ const { onReferenceClick } = useDisplayContext();
10
+ if (onReferenceClick) {
11
+ return (
12
+ <button
13
+ type="button"
14
+ onClick={() => onReferenceClick(value)}
15
+ data-pyric-field-type="reference"
16
+ data-pyric-field-path={path}
17
+ data-target-path={value.path}
18
+ data-pyric-clickable=""
19
+ >
20
+ {value.path}
21
+ </button>
22
+ );
23
+ }
24
+ return (
25
+ <span
26
+ data-pyric-field-type="reference"
27
+ data-pyric-field-path={path}
28
+ data-target-path={value.path}
29
+ >
30
+ {value.path}
31
+ </span>
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Text-input reference editor. M3 only — the M5 `<ReferencePicker>`
37
+ * replaces this with a popover that browses the data plane. The
38
+ * editor commits a "ref-shaped" stand-in object (matching the
39
+ * structural check in `inferType` + `validateLeaf`) so the value
40
+ * flows through the reducer without needing a live Firestore handle
41
+ * at this layer.
42
+ */
43
+ function ReferenceEdit({ value, onChange, error, path }: FieldEditProps<DocumentReference>) {
44
+ return (
45
+ <label
46
+ data-pyric-field-type="reference"
47
+ data-pyric-field-path={path}
48
+ data-pyric-error={error ? '' : undefined}
49
+ >
50
+ <input
51
+ type="text"
52
+ value={value.path}
53
+ placeholder="users/alice"
54
+ onChange={(e) => {
55
+ const nextPath = e.target.value;
56
+ const segments = nextPath.split('/').filter(Boolean);
57
+ const id = segments[segments.length - 1] ?? '';
58
+ // Build a ref-shaped stand-in. Real refs are constructed
59
+ // by `<ReferencePicker>` (M5) once it can talk to a
60
+ // Firestore handle.
61
+ // Preserve the existing firestore handle if the current
62
+ // value already carries one (so it can be wired back when
63
+ // the M5 picker arrives). Cast through `unknown` because
64
+ // the pyric/firestore type union doesn't expose
65
+ // `.firestore` uniformly across chainable + modular.
66
+ const existingFirestore =
67
+ (value as unknown as { firestore?: unknown }).firestore ?? {};
68
+ const stand = {
69
+ path: nextPath,
70
+ id,
71
+ firestore: existingFirestore,
72
+ type: 'document',
73
+ } as unknown as DocumentReference;
74
+ onChange(stand);
75
+ }}
76
+ aria-invalid={error ? 'true' : undefined}
77
+ />
78
+ {error ? <span data-pyric-error-message>{error}</span> : null}
79
+ </label>
80
+ );
81
+ }
82
+
83
+ export const referenceEditor: FieldEditorContract<DocumentReference> = {
84
+ type: 'reference',
85
+ Display: ReferenceDisplay,
86
+ Edit: ReferenceEdit,
87
+ };
@@ -0,0 +1,45 @@
1
+ import { stringEditor } from './string.js';
2
+ import { numberEditor } from './number.js';
3
+ import { booleanEditor } from './boolean.js';
4
+ import { nullEditor } from './null.js';
5
+ import { timestampEditor } from './timestamp.js';
6
+ import { geopointEditor } from './geopoint.js';
7
+ import { referenceEditor } from './reference.js';
8
+ import { bytesEditor } from './bytes.js';
9
+ import { mapEditor } from './map.js';
10
+ import { arrayEditor } from './array.js';
11
+ import { vectorEditor } from './vector.js';
12
+ import type { FieldEditorRegistry } from './types.js';
13
+
14
+ /**
15
+ * Default registry. Covers every {@link FieldType}. Consumers extend
16
+ * or override by passing their own (partial) registry into
17
+ * `<DocumentPreview>` / `<FieldRenderer>` — the components merge
18
+ * overrides into these defaults so a consumer can swap just one
19
+ * editor without re-declaring the rest.
20
+ */
21
+ export const defaultFieldEditors: FieldEditorRegistry = {
22
+ string: stringEditor,
23
+ number: numberEditor,
24
+ boolean: booleanEditor,
25
+ null: nullEditor,
26
+ timestamp: timestampEditor,
27
+ geopoint: geopointEditor,
28
+ reference: referenceEditor,
29
+ bytes: bytesEditor,
30
+ map: mapEditor,
31
+ array: arrayEditor,
32
+ vector: vectorEditor,
33
+ };
34
+
35
+ /**
36
+ * Merge a consumer-supplied registry over the defaults. `undefined`
37
+ * input returns the defaults as-is. Used internally by
38
+ * `<DocumentPreview>` so consumers don't have to spread manually.
39
+ */
40
+ export function mergeFieldEditors(
41
+ override: FieldEditorRegistry | undefined,
42
+ ): FieldEditorRegistry {
43
+ if (!override) return defaultFieldEditors;
44
+ return { ...defaultFieldEditors, ...override };
45
+ }
@@ -0,0 +1,33 @@
1
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
2
+
3
+ function StringDisplay({ value, path }: FieldDisplayProps<string>) {
4
+ return (
5
+ <span data-pyric-field-type="string" data-pyric-field-path={path}>
6
+ {value}
7
+ </span>
8
+ );
9
+ }
10
+
11
+ function StringEdit({ value, onChange, error, path }: FieldEditProps<string>) {
12
+ return (
13
+ <label
14
+ data-pyric-field-type="string"
15
+ data-pyric-field-path={path}
16
+ data-pyric-error={error ? '' : undefined}
17
+ >
18
+ <input
19
+ type="text"
20
+ value={value}
21
+ onChange={(e) => onChange(e.target.value)}
22
+ aria-invalid={error ? 'true' : undefined}
23
+ />
24
+ {error ? <span data-pyric-error-message>{error}</span> : null}
25
+ </label>
26
+ );
27
+ }
28
+
29
+ export const stringEditor: FieldEditorContract<string> = {
30
+ type: 'string',
31
+ Display: StringDisplay,
32
+ Edit: StringEdit,
33
+ };
@@ -0,0 +1,75 @@
1
+ import { Timestamp } from 'pyric/firestore';
2
+ import type { FieldEditorContract, FieldDisplayProps, FieldEditProps } from './types.js';
3
+
4
+ /**
5
+ * Coerce a Timestamp value to a Date. Real instances use `.toDate()`; a value
6
+ * that crossed a worker / postMessage boundary arrives as a plain
7
+ * `{ seconds, nanoseconds }` (or `{ _seconds, _nanoseconds }`) and is rebuilt
8
+ * here, so the display + editor work in both in-process and served modes.
9
+ */
10
+ function coerceDate(value: unknown): Date {
11
+ if (value instanceof Timestamp) return value.toDate();
12
+ const o = value as Record<string, number> | null;
13
+ const seconds = o ? (o.seconds ?? o._seconds) : undefined;
14
+ const nanoseconds = o ? (o.nanoseconds ?? o._nanoseconds ?? 0) : 0;
15
+ if (typeof seconds === 'number') return new Date(seconds * 1000 + Math.floor(nanoseconds / 1e6));
16
+ return new Date(NaN);
17
+ }
18
+
19
+ function TimestampDisplay({ value, path }: FieldDisplayProps<Timestamp>) {
20
+ const date = coerceDate(value);
21
+ const iso = Number.isNaN(date.getTime()) ? '' : date.toISOString();
22
+ return (
23
+ <time
24
+ dateTime={iso}
25
+ data-pyric-field-type="timestamp"
26
+ data-pyric-field-path={path}
27
+ >
28
+ {iso}
29
+ </time>
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Native `<input type="datetime-local">` for editing. The element
35
+ * speaks local time (no zone offset); we convert to/from a UTC
36
+ * `Timestamp` at the boundary so the underlying value stays
37
+ * timezone-correct.
38
+ */
39
+ function TimestampEdit({ value, onChange, error, path }: FieldEditProps<Timestamp>) {
40
+ // Convert the value to the `YYYY-MM-DDTHH:MM` shape <input
41
+ // datetime-local> wants. Seconds and millis aren't part of the
42
+ // native input precision; round-trips through this editor lose
43
+ // sub-minute resolution intentionally — consumers needing higher
44
+ // fidelity should swap the editor out via the registry.
45
+ const dt = coerceDate(value);
46
+ const local = new Date(dt.getTime() - dt.getTimezoneOffset() * 60_000);
47
+ const inputValue = Number.isNaN(local.getTime()) ? '' : local.toISOString().slice(0, 16);
48
+
49
+ return (
50
+ <label
51
+ data-pyric-field-type="timestamp"
52
+ data-pyric-field-path={path}
53
+ data-pyric-error={error ? '' : undefined}
54
+ >
55
+ <input
56
+ type="datetime-local"
57
+ value={inputValue}
58
+ onChange={(e) => {
59
+ const next = e.target.value;
60
+ if (!next) return;
61
+ const localDate = new Date(next);
62
+ onChange(Timestamp.fromDate(localDate));
63
+ }}
64
+ aria-invalid={error ? 'true' : undefined}
65
+ />
66
+ {error ? <span data-pyric-error-message>{error}</span> : null}
67
+ </label>
68
+ );
69
+ }
70
+
71
+ export const timestampEditor: FieldEditorContract<Timestamp> = {
72
+ type: 'timestamp',
73
+ Display: TimestampDisplay,
74
+ Edit: TimestampEdit,
75
+ };