@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,125 @@
1
+ /**
2
+ * Pending (not-yet-materialized) folder prefixes — pure reducer, no React.
3
+ *
4
+ * MECHANISM DECISION (create-folder). GCS has no real folders: a
5
+ * "folder" exists only as a shared prefix of object names. Two honest
6
+ * ways to let a user create one before it contains anything:
7
+ *
8
+ * a) write a zero-byte `<path>/` placeholder object (the emulator-UI
9
+ * convention — `useObjectUpload.createFolder` does this): the
10
+ * folder survives reloads, but every created folder deposits a
11
+ * phantom object in the sandbox store, and the Studio worker
12
+ * `StorageApi` path has no channel for the trailing-slash ref;
13
+ *
14
+ * b) hold the created prefix as CLIENT-SIDE PENDING STATE and let the
15
+ * first upload into it materialize it for real. The store stays
16
+ * byte-for-byte clean — nothing is written until a file is — at
17
+ * the cost that a created-then-abandoned empty folder disappears
18
+ * on reload.
19
+ *
20
+ * Studio's create-folder flow uses (b): the sandbox store is the
21
+ * user's actual data and must not accrue placeholder objects, and the
22
+ * disappearing-empty-folder tradeoff is acceptable BECAUSE the UI
23
+ * labels pending rows as session-only ("empty" badge + empty-state
24
+ * copy in `StoragePane`). (a) remains available to consumers who want
25
+ * persistent empty folders in sandbox-only setups.
26
+ *
27
+ * State is a sorted list of normalized bucket-rooted prefix paths (no
28
+ * trailing slashes). Creating `stuff/things/cool` expands the whole
29
+ * chain — `stuff`, `stuff/things`, `stuff/things/cool` — so every
30
+ * ancestor level shows the folder while browsing (VS Code-style nested
31
+ * create). A successful upload MATERIALIZES its destination folder:
32
+ * the chain up to that folder leaves pending state (the real listing
33
+ * now surfaces those prefixes); pending descendants of other branches
34
+ * stay.
35
+ */
36
+
37
+ import { normalizeStoragePath } from './hooks/usePathState.js';
38
+
39
+ /** Sorted, deduped, normalized pending prefix paths. */
40
+ export type PendingPrefixState = readonly string[];
41
+
42
+ export type PendingPrefixAction =
43
+ /** Create a folder at `path` (absolute, bucket-rooted; nested paths
44
+ * allowed) — adds the full ancestor chain. */
45
+ | { type: 'create'; path: string }
46
+ /** An object now exists directly under `path`: drop `path` and its
47
+ * ancestors from pending (they are real prefixes now). */
48
+ | { type: 'materialize'; path: string }
49
+ /** Remove a session-only folder and every pending descendant beneath it. */
50
+ | { type: 'discard'; path: string }
51
+ | { type: 'clear' };
52
+
53
+ /** `'a/b/c'` → `['a', 'a/b', 'a/b/c']`; `''` → `[]`. */
54
+ export function expandPathChain(path: string): string[] {
55
+ const normalized = normalizeStoragePath(path);
56
+ if (normalized === '') return [];
57
+ const segments = normalized.split('/');
58
+ return segments.map((_, i) => segments.slice(0, i + 1).join('/'));
59
+ }
60
+
61
+ export const initialPendingPrefixes: PendingPrefixState = [];
62
+
63
+ export function pendingPrefixReducer(
64
+ state: PendingPrefixState,
65
+ action: PendingPrefixAction,
66
+ ): PendingPrefixState {
67
+ switch (action.type) {
68
+ case 'create': {
69
+ const chain = expandPathChain(action.path).filter((p) => !state.includes(p));
70
+ if (chain.length === 0) return state;
71
+ return [...state, ...chain].sort();
72
+ }
73
+ case 'materialize': {
74
+ const real = new Set(expandPathChain(action.path));
75
+ if (real.size === 0) return state;
76
+ const next = state.filter((p) => !real.has(p));
77
+ return next.length === state.length ? state : next;
78
+ }
79
+ case 'discard': {
80
+ const path = normalizeStoragePath(action.path);
81
+ if (path === '') return state;
82
+ const next = state.filter((entry) => entry !== path && !entry.startsWith(`${path}/`));
83
+ return next.length === state.length ? state : next;
84
+ }
85
+ case 'clear':
86
+ return state.length === 0 ? state : initialPendingPrefixes;
87
+ }
88
+ }
89
+
90
+ /** Direct-child folder NAMES pending under `parentPath` (`''` = root),
91
+ * sorted. The chain expansion guarantees every level is present, so a
92
+ * simple parent match is exact. */
93
+ export function pendingChildFolders(
94
+ state: PendingPrefixState,
95
+ parentPath: string,
96
+ ): string[] {
97
+ const parent = normalizeStoragePath(parentPath);
98
+ const prefix = parent === '' ? '' : `${parent}/`;
99
+ return state
100
+ .filter((p) => p.startsWith(prefix) && p !== parent && !p.slice(prefix.length).includes('/'))
101
+ .map((p) => p.slice(prefix.length));
102
+ }
103
+
104
+ /** Whether `path` itself is a pending (session-only) folder. */
105
+ export function isPendingPrefix(state: PendingPrefixState, path: string): boolean {
106
+ return state.includes(normalizeStoragePath(path));
107
+ }
108
+
109
+ /**
110
+ * Validate a create-folder input (relative to the current folder;
111
+ * nested `a/b/c` allowed — VS Code semantics). Returns an error
112
+ * message or `null` when valid. Normalization tolerates stray/repeat
113
+ * slashes; `.`/`..` segments are rejected (GCS object names have no
114
+ * dot-segment semantics — accepting them would create unreachable
115
+ * names).
116
+ */
117
+ export function folderInputError(input: string): string | null {
118
+ const normalized = normalizeStoragePath(input);
119
+ if (normalized === '') return 'Enter a folder name.';
120
+ const segments = normalized.split('/');
121
+ if (segments.some((s) => s === '.' || s === '..')) {
122
+ return 'Folder names may not be "." or "..".';
123
+ }
124
+ return null;
125
+ }
@@ -0,0 +1,120 @@
1
+ import { useEffect, useState, type ReactNode } from 'react';
2
+ import type { FullMetadata } from 'pyric/storage';
3
+
4
+ /** What a preview's `render` receives. `blob`/`blobUrl` are only
5
+ * populated for previews that declared `needsBlob`. */
6
+ export interface StoragePreviewContext {
7
+ metadata: FullMetadata;
8
+ blob: Blob | undefined;
9
+ blobUrl: string | undefined;
10
+ }
11
+
12
+ /**
13
+ * One entry in the content-type preview registry — the storage
14
+ * counterpart of the Firestore field-editor registry, keyed by a
15
+ * `match` predicate instead of a type name because content types are
16
+ * open-ended. First match wins; consumer previews run BEFORE the
17
+ * built-ins, so overriding `image/*` is just shipping your own
18
+ * matcher.
19
+ */
20
+ export interface StoragePreview {
21
+ /** Diagnostic id — also stamped on the preview container as
22
+ * `data-pyric-preview="<id>"`. */
23
+ id: string;
24
+ match: (metadata: FullMetadata) => boolean;
25
+ /** Ask the inspector to `loadBlob()` before rendering. Default
26
+ * `false` (metadata-only previews render immediately). */
27
+ needsBlob?: boolean;
28
+ /**
29
+ * Skip the preview (and the blob download) for objects larger than
30
+ * this — the inspector renders its `data-pyric-preview-too-large`
31
+ * fallback instead. `undefined` = no cap.
32
+ */
33
+ maxBytes?: number;
34
+ render: (ctx: StoragePreviewContext) => ReactNode;
35
+ }
36
+
37
+ /** 256KB — the section 3 default cap for the text-family preview. */
38
+ export const TEXT_PREVIEW_MAX_BYTES = 256 * 1024;
39
+
40
+ function contentTypeOf(metadata: FullMetadata): string {
41
+ // `text/plain;charset=utf-8` → `text/plain`.
42
+ return (metadata.contentType ?? '').split(';')[0].trim().toLowerCase();
43
+ }
44
+
45
+ /** `image/*` → blob-URL `<img>`. */
46
+ export const imagePreview: StoragePreview = {
47
+ id: 'image',
48
+ match: (md) => contentTypeOf(md).startsWith('image/'),
49
+ needsBlob: true,
50
+ render: ({ metadata, blobUrl }) =>
51
+ blobUrl ? (
52
+ <img data-pyric-preview-image src={blobUrl} alt={metadata.name} />
53
+ ) : null,
54
+ };
55
+
56
+ function TextPreviewBody({ blob, isJson }: { blob: Blob; isJson: boolean }) {
57
+ const [text, setText] = useState<string | undefined>(undefined);
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ blob.text().then((raw) => {
61
+ if (cancelled) return;
62
+ if (isJson) {
63
+ try {
64
+ setText(JSON.stringify(JSON.parse(raw), null, 2));
65
+ return;
66
+ } catch {
67
+ // Unparseable JSON falls through to the raw text.
68
+ }
69
+ }
70
+ setText(raw);
71
+ });
72
+ return () => {
73
+ cancelled = true;
74
+ };
75
+ }, [blob, isJson]);
76
+ if (text === undefined) return null;
77
+ return <pre data-pyric-preview-text>{text}</pre>;
78
+ }
79
+
80
+ /** `text/*` + `application/json` → text panel, 256KB cap (bigger
81
+ * objects fall through to the too-large fallback). JSON is
82
+ * pretty-printed when parseable. */
83
+ export const textPreview: StoragePreview = {
84
+ id: 'text',
85
+ match: (md) => {
86
+ const ct = contentTypeOf(md);
87
+ return ct.startsWith('text/') || ct === 'application/json';
88
+ },
89
+ needsBlob: true,
90
+ maxBytes: TEXT_PREVIEW_MAX_BYTES,
91
+ render: ({ metadata, blob }) =>
92
+ blob ? (
93
+ <TextPreviewBody
94
+ blob={blob}
95
+ isJson={contentTypeOf(metadata) === 'application/json'}
96
+ />
97
+ ) : null,
98
+ };
99
+
100
+ /** The section 3 defaults: image, text/json; everything else is
101
+ * metadata-only (the inspector's `data-pyric-preview-none` state). */
102
+ export const defaultStoragePreviews: ReadonlyArray<StoragePreview> = [
103
+ imagePreview,
104
+ textPreview,
105
+ ];
106
+
107
+ /**
108
+ * Pick the preview for `metadata`: consumer previews first (override
109
+ * channel), then the built-ins, first `match` wins. `undefined`
110
+ * means metadata-only.
111
+ */
112
+ export function selectStoragePreview(
113
+ metadata: FullMetadata,
114
+ consumerPreviews: ReadonlyArray<StoragePreview> | undefined,
115
+ ): StoragePreview | undefined {
116
+ for (const preview of [...(consumerPreviews ?? []), ...defaultStoragePreviews]) {
117
+ if (preview.match(metadata)) return preview;
118
+ }
119
+ return undefined;
120
+ }
@@ -0,0 +1,54 @@
1
+ import { createContext, createElement, useContext, type ReactNode } from 'react';
2
+ import { ref, listAll, getMetadata, getBlob, uploadBytes, deleteObject } from 'pyric/storage';
3
+
4
+ /**
5
+ * The modular Storage fns the browse/inspect hooks call, as an INJECTABLE
6
+ * bundle (same pattern as `@pyric/ui`'s FirestoreApi / AuthApi).
7
+ *
8
+ * Default = in-process `pyric/storage`, so existing consumers are unchanged.
9
+ * Pyric Studio served mode injects the SharedWorker client bundle so the Storage
10
+ * surface browses the live worker object store. These ops are already async, so
11
+ * no sync/async wrinkle (unlike auth `listUsers`); the worker handles/refs are
12
+ * runtime-compatible at the surface the hooks use (`.fullPath` / `.name`).
13
+ *
14
+ * `uploadBytes` rides the same seam so `useObjectUpload` follows the injected
15
+ * backend: in-process writes are uncapped; the worker client's `uploadBytes`
16
+ * (base64 `storage.putBytes` over the MessagePort) enforces an 8 MiB payload
17
+ * cap on both ends — an over-cap upload fails that file's task with the typed
18
+ * `storage/...` too-large error and the rest of the batch proceeds.
19
+ *
20
+ * NOTE the rules gate (`useStorageRulesGate`) is NOT here: it reads in-process
21
+ * rules internals and no-ops on a handle without them (worker handles), which is
22
+ * the correct degrade (the worker enforces read rules on `listAll` server-side).
23
+ */
24
+ export type StorageApi = Pick<
25
+ typeof import('pyric/storage'),
26
+ 'ref' | 'listAll' | 'getMetadata' | 'getBlob' | 'uploadBytes' | 'deleteObject'
27
+ >;
28
+
29
+ const inProcessStorageApi: StorageApi = {
30
+ ref,
31
+ listAll,
32
+ getMetadata,
33
+ getBlob,
34
+ uploadBytes,
35
+ deleteObject,
36
+ };
37
+
38
+ const StorageApiContext = createContext<StorageApi>(inProcessStorageApi);
39
+
40
+ /** Read the active Storage API bundle (defaults to in-process `pyric/storage`). */
41
+ export function useStorageApi(): StorageApi {
42
+ return useContext(StorageApiContext);
43
+ }
44
+
45
+ /** Provide a Storage API bundle to the subtree (Studio's worker client). */
46
+ export function StorageApiProvider({
47
+ value,
48
+ children,
49
+ }: {
50
+ value: StorageApi;
51
+ children: ReactNode;
52
+ }) {
53
+ return createElement(StorageApiContext.Provider, { value }, children);
54
+ }
@@ -0,0 +1,97 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { RuleHeatmapEntry } from '../hooks/useRuleHeatmap.js';
3
+
4
+ export interface RuleHeatmapProps {
5
+ /** Per-rule rollup from `useRuleHeatmap`. */
6
+ entries: RuleHeatmapEntry[];
7
+ /** Marks one rule row as the active selection. */
8
+ selectedRuleIndex?: number;
9
+ /**
10
+ * Fired when a rule row is clicked — wire this to the log filter
11
+ * for the cross-view "click a rule, see its traffic" interaction.
12
+ */
13
+ onSelectRule?: (ruleIndex: number) => void;
14
+ emptyState?: ReactNode;
15
+ className?: string;
16
+ }
17
+
18
+ /** Buckets the deny ratio into discrete heat levels for CSS. */
19
+ function heatBucket(denyRatio: number): 'none' | 'low' | 'medium' | 'high' {
20
+ if (denyRatio === 0) return 'none';
21
+ if (denyRatio <= 0.33) return 'low';
22
+ if (denyRatio <= 0.66) return 'medium';
23
+ return 'high';
24
+ }
25
+
26
+ /**
27
+ * Headless rule heatmap — one row per rule, busiest first. Each row
28
+ * exposes two styling channels:
29
+ *
30
+ * - `data-pyric-rule-heat` — a discrete bucket (`none`/`low`/
31
+ * `medium`/`high`) by deny ratio, for threshold-based coloring.
32
+ * - `--pyric-deny-ratio` — the raw 0–1 ratio as a CSS custom
33
+ * property, for a proportional bar / gradient.
34
+ *
35
+ * Counts render as separate elements (`data-pyric-rule-total`,
36
+ * `-allows`, `-denies`) so the consumer can show numbers, bars, or
37
+ * both.
38
+ *
39
+ * Styling hooks: `[data-pyric-ui="rule-heatmap"]`,
40
+ * `[data-pyric-rule-row]` (with `data-pyric-rule-index`,
41
+ * `data-pyric-rule-heat`, `data-pyric-selected`).
42
+ */
43
+ export function RuleHeatmap({
44
+ entries,
45
+ selectedRuleIndex,
46
+ onSelectRule,
47
+ emptyState,
48
+ className,
49
+ }: RuleHeatmapProps) {
50
+ if (entries.length === 0) {
51
+ return (
52
+ <div
53
+ className={className}
54
+ data-pyric-ui="rule-heatmap"
55
+ data-pyric-empty=""
56
+ >
57
+ {emptyState}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className={className} data-pyric-ui="rule-heatmap">
64
+ <ul data-pyric-rule-heatmap-items="">
65
+ {entries.map((entry) => {
66
+ const selected = entry.ruleIndex === selectedRuleIndex;
67
+ return (
68
+ <li key={entry.ruleIndex} data-pyric-rule-entry="">
69
+ <button
70
+ type="button"
71
+ onClick={() => onSelectRule?.(entry.ruleIndex)}
72
+ data-pyric-rule-row=""
73
+ data-pyric-rule-index={entry.ruleIndex}
74
+ data-pyric-rule-heat={heatBucket(entry.denyRatio)}
75
+ data-pyric-selected={selected ? '' : undefined}
76
+ style={
77
+ {
78
+ '--pyric-deny-ratio': entry.denyRatio,
79
+ } as React.CSSProperties
80
+ }
81
+ >
82
+ <span data-pyric-rule-label="">#{entry.ruleIndex}</span>
83
+ <span data-pyric-rule-operations="">
84
+ {entry.operations.join(', ')}
85
+ </span>
86
+ <span data-pyric-rule-total="">{entry.total}</span>
87
+ <span data-pyric-rule-allows="">{entry.allows}</span>
88
+ <span data-pyric-rule-denies="">{entry.denies}</span>
89
+ <span data-pyric-rule-bar="" aria-hidden="true" />
90
+ </button>
91
+ </li>
92
+ );
93
+ })}
94
+ </ul>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,160 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Badge } from '../../primitives/Badge.js';
3
+ import { JsonView } from '../../primitives/JsonView.js';
4
+ import type { TrafficEvent } from '../types.js';
5
+ import { defaultFormatTime, reasonVerdict } from './format.js';
6
+
7
+ export interface TrafficDetailProps {
8
+ event: TrafficEvent;
9
+ /** Fired by the back affordance. When absent, no back button. */
10
+ onBack?: () => void;
11
+ /**
12
+ * Render-prop slot below the header — the playground drops its
13
+ * denial overlay (classification + LLM analysis) here. The library
14
+ * doesn't own that analysis.
15
+ */
16
+ renderClassification?: (event: TrafficEvent) => ReactNode;
17
+ /** Override the timestamp rendering. Default is `HH:MM:SS`. */
18
+ formatTime?: (at: number) => string;
19
+ className?: string;
20
+ }
21
+
22
+ function Section({
23
+ label,
24
+ children,
25
+ }: {
26
+ label: string;
27
+ children: ReactNode;
28
+ }) {
29
+ return (
30
+ <section data-pyric-traffic-section="" data-pyric-section-label={label}>
31
+ <h3 data-pyric-section-heading="">{label}</h3>
32
+ {children}
33
+ </section>
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Headless drill-in panel for a single traffic event. Renders the
39
+ * header (result + origin + timestamp + method/path + matched rule),
40
+ * a consumer classification slot, then JSON sections for auth,
41
+ * request payload, and resource before/after via `<JsonView>`, plus
42
+ * the reasons list, `triggeredBy`, and `groupId`.
43
+ *
44
+ * `evalMs` appears here as a minor header field only — it is not a
45
+ * log column (local simulator; latency is de-featured per
46
+ * the design rationale).
47
+ *
48
+ * Styling hooks: `[data-pyric-ui="traffic-detail"]`,
49
+ * `[data-pyric-traffic-section]` (with `data-pyric-section-label`),
50
+ * `[data-pyric-traffic-reason]` (with `data-pyric-reason-verdict`).
51
+ */
52
+ export function TrafficDetail({
53
+ event,
54
+ onBack,
55
+ renderClassification,
56
+ formatTime = defaultFormatTime,
57
+ className,
58
+ }: TrafficDetailProps) {
59
+ const requestData = event.request?.resourceData ?? event.request?.data;
60
+ const durationMs = event.evalMs ?? event.durationMs ?? 0;
61
+
62
+ return (
63
+ <div className={className} data-pyric-ui="traffic-detail">
64
+ <header data-pyric-traffic-detail-header="">
65
+ {onBack ? (
66
+ <button
67
+ type="button"
68
+ onClick={onBack}
69
+ data-pyric-traffic-back=""
70
+ aria-label="Back to traffic log"
71
+ >
72
+ Back
73
+ </button>
74
+ ) : null}
75
+ <div data-pyric-traffic-detail-meta="">
76
+ <Badge kind={event.result}>{event.result}</Badge>
77
+ <span data-pyric-traffic-origin="">{event.origin}</span>
78
+ {event.service ? <span data-pyric-traffic-service="">{event.service}</span> : null}
79
+ <span data-pyric-traffic-time="">{formatTime(event.at)}</span>
80
+ <span data-pyric-traffic-eval-ms="">{durationMs.toFixed(1)}ms</span>
81
+ </div>
82
+ <p data-pyric-traffic-detail-title="">
83
+ <span data-pyric-traffic-method="">{event.method}</span>
84
+ <span data-pyric-traffic-path="">{event.path}</span>
85
+ </p>
86
+ {event.matchedRule ? (
87
+ <p data-pyric-traffic-matched-rule="">
88
+ matched rule #{event.matchedRule.ruleIndex} ·{' '}
89
+ {event.matchedRule.operations.join(', ')}
90
+ </p>
91
+ ) : null}
92
+ </header>
93
+
94
+ {renderClassification ? renderClassification(event) : null}
95
+
96
+ <Section label="AUTH">
97
+ <JsonView value={event.auth} />
98
+ </Section>
99
+
100
+ {requestData !== undefined ? (
101
+ <Section label="REQUEST · resource.data">
102
+ <JsonView value={requestData} />
103
+ </Section>
104
+ ) : null}
105
+
106
+ {event.resourceBefore !== undefined ? (
107
+ <Section label="RESOURCE BEFORE">
108
+ <JsonView
109
+ value={
110
+ event.resourceBefore.exists ? event.resourceBefore.data : null
111
+ }
112
+ />
113
+ </Section>
114
+ ) : null}
115
+
116
+ {event.resourceAfter !== undefined ? (
117
+ <Section label="RESOURCE AFTER">
118
+ <JsonView
119
+ value={
120
+ event.resourceAfter.exists ? event.resourceAfter.data : null
121
+ }
122
+ />
123
+ </Section>
124
+ ) : null}
125
+
126
+ {event.reasons.length > 0 ? (
127
+ <Section label="REASONS">
128
+ <ul data-pyric-traffic-reasons="">
129
+ {event.reasons.map((reason, i) => (
130
+ <li
131
+ key={i}
132
+ data-pyric-traffic-reason=""
133
+ data-pyric-reason-verdict={reasonVerdict(reason)}
134
+ >
135
+ {reason}
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ </Section>
140
+ ) : null}
141
+
142
+ {event.triggeredBy ? (
143
+ <Section label="TRIGGERED BY">
144
+ <p data-pyric-traffic-triggered-by="">
145
+ <span data-pyric-traffic-method="">
146
+ {event.triggeredBy.method}
147
+ </span>
148
+ <span data-pyric-traffic-path="">{event.triggeredBy.path}</span>
149
+ </p>
150
+ </Section>
151
+ ) : null}
152
+
153
+ {event.groupId ? (
154
+ <Section label="GROUP">
155
+ <p data-pyric-traffic-group="">{event.groupId}</p>
156
+ </Section>
157
+ ) : null}
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,91 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import type { TrafficEvent } from '../types.js';
3
+ import type { TrafficGroup } from '../hooks/useTrafficGroups.js';
4
+ import { TrafficRow } from './TrafficRow.js';
5
+ import type { TrafficGroupKind } from '../hooks/useTrafficGroups.js';
6
+
7
+ /** Header text per group kind — the raw kind slug stays on the
8
+ * `data-pyric-group-kind` attributes for styling/tests. */
9
+ const GROUP_KIND_LABELS: Record<TrafficGroupKind, string> = {
10
+ batch: 'batch write',
11
+ transaction: 'transaction',
12
+ 'listener-run': 'listener re-evals',
13
+ };
14
+
15
+ export interface TrafficGroupRowProps {
16
+ group: TrafficGroup;
17
+ /** Whether the group starts expanded. Default false — grouping
18
+ * exists to collapse volume, so collapsed is the useful default. */
19
+ defaultExpanded?: boolean;
20
+ onSelect?: (event: TrafficEvent) => void;
21
+ selectedId?: string;
22
+ /** Passed through to each member `<TrafficRow>`. */
23
+ renderClassification?: (event: TrafficEvent) => ReactNode;
24
+ /** Passed through to each member `<TrafficRow>`. */
25
+ formatTime?: (at: number) => string;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * A collapsible group row — one header summarizing a batch,
31
+ * transaction, or listener-run, expanding to the member rows. The
32
+ * header carries `data-pyric-group-kind` and a `data-pyric-group-*`
33
+ * count/deny rollup; expansion state is `data-pyric-expanded`.
34
+ *
35
+ * Styling hooks: `[data-pyric-traffic-group]`,
36
+ * `[data-pyric-traffic-group-header]` (with `data-pyric-group-kind`),
37
+ * `[data-pyric-traffic-group-members]`.
38
+ */
39
+ export function TrafficGroupRow({
40
+ group,
41
+ defaultExpanded = false,
42
+ onSelect,
43
+ selectedId,
44
+ renderClassification,
45
+ formatTime,
46
+ className,
47
+ }: TrafficGroupRowProps) {
48
+ const [expanded, setExpanded] = useState(defaultExpanded);
49
+
50
+ return (
51
+ <div
52
+ className={className}
53
+ data-pyric-traffic-group=""
54
+ data-pyric-group-kind={group.kind}
55
+ data-pyric-expanded={expanded ? '' : undefined}
56
+ >
57
+ <button
58
+ type="button"
59
+ onClick={() => setExpanded((e) => !e)}
60
+ data-pyric-traffic-group-header=""
61
+ data-pyric-group-kind={group.kind}
62
+ aria-expanded={expanded}
63
+ >
64
+ <span data-pyric-group-kind-label="">{GROUP_KIND_LABELS[group.kind]}</span>
65
+ <span data-pyric-group-count="">×{group.count}</span>
66
+ {group.denies > 0 ? (
67
+ <span data-pyric-group-denies="">{group.denies} denied</span>
68
+ ) : null}
69
+ </button>
70
+ {expanded ? (
71
+ <ul data-pyric-traffic-group-members="">
72
+ {group.events.map((event) => (
73
+ <li
74
+ key={event.id}
75
+ data-pyric-traffic-entry=""
76
+ data-pyric-traffic-id={event.id}
77
+ >
78
+ <TrafficRow
79
+ event={event}
80
+ selected={event.id === selectedId}
81
+ onSelect={onSelect}
82
+ renderClassification={renderClassification}
83
+ formatTime={formatTime}
84
+ />
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ ) : null}
89
+ </div>
90
+ );
91
+ }