diff --git a/js/package.json b/js/package.json
index 11b7525..e7bc81a 100644
--- a/js/package.json
+++ b/js/package.json
@@ -31,6 +31,7 @@
"@electron-forge/plugin-vite": "^7.11.1",
"@electron/fuses": "^1.8.0",
"@tailwindcss/vite": "^4.2.0",
+ "@tanstack/eslint-plugin-query": "^5.91.4",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
@@ -41,10 +42,11 @@
"electron": "40.6.0",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.32.0",
- "typescript": "~4.5.4",
+ "typescript": "^5.9.3",
"vite": "^5.4.21"
},
"dependencies": {
+ "@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1",
@@ -58,6 +60,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0",
+ "zod": "^4.3.6",
"zustand": "^5.0.11"
}
}
diff --git a/js/src/App.tsx b/js/src/App.tsx
index 07c7215..72ffd96 100644
--- a/js/src/App.tsx
+++ b/js/src/App.tsx
@@ -1,40 +1,16 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
-import { useAppStore } from "@/stores/app-store";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
-import { StreamsPage } from "@/pages/streams-page";
-import { StreamPlayerPage } from "@/pages/stream-player-page";
import { } from "@/firebase";
+import {
+ QueryClient,
+ QueryClientProvider,
+} from '@tanstack/react-query'
+import PathResolver from "./pages/path-resolver";
-function AuthenticatedApp() {
- const fetchStartup = useAppStore((s) => s.fetchStartup);
- const isLoading = useAppStore((s) => s.isLoading);
-
- useEffect(() => {
- fetchStartup();
- }, [fetchStartup]);
-
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
- } />
- } />
-
-
-
- );
-}
+const queryClient = new QueryClient();
const App = () => {
const status = useAuthStore((s) => s.status);
@@ -59,4 +35,23 @@ const App = () => {
return ;
};
-export default App;
+function AuthenticatedApp() {
+ // NOTE: Hash router provides history, despite using catch-all
+ return (
+
+
+ } />
+
+
+ );
+}
+
+const AppWithProviders = () => (
+
+
+
+
+
+);
+
+export default AppWithProviders;
diff --git a/js/src/api/client.ts b/js/src/api/client.ts
index f08f0d8..d27afa0 100644
--- a/js/src/api/client.ts
+++ b/js/src/api/client.ts
@@ -1,17 +1,19 @@
import { useSessionStore } from "@/stores/session-store";
+import type { z } from "zod";
+import {
+ DepotObjectSchema,
+ HumanSchema,
+ ListNetworksResponseSchema,
+ NetworkSchema,
+ PrepareUploadResponseSchema,
+ SignInResponseSchema,
+} from "./types";
import type {
- CreateStreamParticleRequest,
- CreateStreamRequest,
- Human,
- MarkSeenBatchRequest,
+ AddMembersRequest,
+ CreateNetworkRequest,
PrepareUploadRequest,
- PrepareUploadResponse,
RequestCodeRequest,
SignInRequest,
- SignInResponse,
- StartupResponse,
- Stream,
- StreamParticle,
} from "./types";
export class ApiError extends Error {
@@ -37,11 +39,11 @@ class ApiClient {
this.config = config;
}
- private async request(
+ private async fetch(
method: string,
path: string,
body?: unknown,
- ): Promise {
+ ): Promise {
const headers: Record = {
"Content-Type": "application/json",
};
@@ -67,109 +69,106 @@ class ApiClient {
throw new ApiError(response.status, text);
}
- if (response.status === 204) {
- return undefined as T;
- }
+ return response;
+ }
- return response.json() as Promise;
+ private async request(
+ schema: z.ZodType,
+ method: string,
+ path: string,
+ body?: unknown,
+ ): Promise {
+ const response = await this.fetch(method, path, body);
+ const json = await response.json();
+ return schema.parse(json);
+ }
+
+ private async requestVoid(
+ method: string,
+ path: string,
+ body?: unknown,
+ ): Promise {
+ await this.fetch(method, path, body);
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise {
- await this.request("POST", "/auth/request-code", data);
+ await this.requestVoid("POST", "/auth/request-code", data);
}
- async signIn(data: SignInRequest): Promise {
- return this.request("POST", "/auth/sign-in", data);
+ async signIn(data: SignInRequest) {
+ return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
}
- async me(): Promise {
- return this.request("GET", "/auth/me");
+ async me() {
+ return this.request(HumanSchema, "GET", "/auth/me");
}
async signOut(): Promise {
- await this.request("POST", "/auth/sign-out");
+ await this.requestVoid("POST", "/auth/sign-out");
}
- // --- Startup ---
-
- async startup(): Promise {
- return this.request("GET", "/startup");
- }
-
- // --- Streams ---
-
- async createStream(
- networkId: string,
- data: CreateStreamRequest,
- ): Promise {
- return this.request(
- "POST",
- `/networks/${networkId}/streams`,
- data,
+ // TODO: security: require passing in the particle id once api deprecates this
+ async getParticleDownloadUrl(objectId: string): Promise {
+ const response = await this.fetch(
+ "GET",
+ `/particles/${objectId}/download`,
);
- }
-
- async createStreamParticle(
- streamId: string,
- data: CreateStreamParticleRequest,
- ): Promise {
- return this.request(
- "POST",
- `/streams/${streamId}/particles`,
- data,
- );
- }
-
- // --- Particles ---
-
- async markSeen(particleId: string): Promise {
- await this.request("POST", `/particles/${particleId}/seen`);
- }
-
- async ackParticle(particleId: string): Promise {
- await this.request("POST", `/particles/${particleId}/ack`);
- }
-
- async markSeenBatch(data: MarkSeenBatchRequest): Promise {
- await this.request("POST", "/particles/seen", data);
- }
-
- async getParticleDownloadUrl(particleId: string): Promise {
- const token = this.config.getToken();
- const headers: Record = {};
- if (token) {
- headers["Authorization"] = `Bearer ${token}`;
- }
-
- const response = await fetch(
- `${this.config.baseUrl}/particles/${particleId}/download`,
- { headers, redirect: "follow" },
- );
-
- if (response.status === 401) {
- this.config.onUnauthorized();
- throw new ApiError(401, "Unauthorized");
- }
-
- if (!response.ok) {
- throw new ApiError(response.status, "Failed to get download URL");
- }
-
return response.url;
}
// --- Depot ---
- async prepareUpload(
- data: PrepareUploadRequest,
- ): Promise {
- return this.request("POST", "/depot/upload", data);
+ async prepareUpload(data: PrepareUploadRequest) {
+ return this.request(
+ PrepareUploadResponseSchema,
+ "POST",
+ "/depot/upload",
+ data,
+ );
}
- async confirmUpload(objectId: string): Promise {
- await this.request("POST", `/depot/objects/${objectId}/confirm`);
+ async confirmUpload(objectId: string) {
+ return this.request(
+ DepotObjectSchema,
+ "POST",
+ `/depot/objects/${objectId}/confirm`,
+ );
+ }
+
+
+ // --- Networks ---
+
+ async listNetworks() {
+ return this.request(
+ ListNetworksResponseSchema,
+ "GET",
+ "/networks",
+ );
+ }
+
+ async createNetwork(data: CreateNetworkRequest) {
+ return this.request(NetworkSchema, "POST", "/networks", data);
+ }
+
+ async getNetwork(id: string) {
+ return this.request(NetworkSchema, "GET", `/networks/${id}`);
+ }
+
+ async addMembers(networkId: string, data: AddMembersRequest): Promise {
+ await this.requestVoid(
+ "POST",
+ `/networks/${networkId}/members`,
+ data,
+ );
+ }
+
+ async removeMember(networkId: string, email: string): Promise {
+ await this.requestVoid(
+ "DELETE",
+ `/networks/${networkId}/members/${email}`,
+ );
}
}
diff --git a/js/src/api/types.ts b/js/src/api/types.ts
index e0eb6fa..4c62545 100644
--- a/js/src/api/types.ts
+++ b/js/src/api/types.ts
@@ -1,168 +1,169 @@
-// --- Core entities ---
+import { z } from "zod";
-export interface Human {
- id: string | null;
- email: string;
- email_prefix: string;
- created_at: string | null;
-}
+export const HumanSchema = z.object({
+ id: z.string().nullable(),
+ created_at: z.coerce.date().nullable(),
+ email: z.string().email(),
+ email_prefix: z.string(),
+});
-export type StreamStatus = "open" | "closed" | "unspecified";
+export type Human = z.infer;
-export interface AckInfo {
- email: string;
- acked_at: string;
-}
+export const NetworkSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ admin_human: HumanSchema,
+ humans: z.array(HumanSchema),
+ created_at: z.coerce.date(),
+});
-// --- Particle types ---
+export type Network = z.infer;
-export type ParticleType =
- | "media"
- | "text"
- | "quest"
- | "paper"
- | "file"
- | "folder";
+export const ListNetworksResponseSchema = z.array(NetworkSchema);
+export type ListNetworksResponse = z.infer;
-export interface MediaParticleData {
- object_id: string;
- duration_ms: number;
- mime_type: string;
-}
+// --- Network request/response types ---
-export interface TextParticleData {
- content: string;
-}
+const CreateNetworkRequestSchema = z.object({
+ name: z.string(),
+});
+export type CreateNetworkRequest = z.infer;
-export interface QuestParticleData {
- title: string;
- description: string;
- status?: string;
- assigned_to?: string;
- due_date?: string;
-}
-
-export interface PaperParticleData {
- title: string;
- content: string;
-}
-
-export interface FileParticleData {
- object_id: string;
- filename: string;
- mime_type: string;
- size: number;
-}
-
-export interface FolderParticleData {
- name: string;
- color?: string;
-}
-
-export interface ParticleDataMap {
- media: MediaParticleData;
- text: TextParticleData;
- quest: QuestParticleData;
- paper: PaperParticleData;
- file: FileParticleData;
- folder: FolderParticleData;
-}
-
-export function getParticleData(
- particle: StreamParticle,
- type: T,
-): ParticleDataMap[T] {
- return particle.data as ParticleDataMap[T];
-}
-
-export interface StreamParticle {
- id: string;
- type: ParticleType;
- data: unknown;
- created_by_email: string;
- seen: boolean;
- acks: AckInfo[];
- updated_at: string;
- created_at: string;
-}
-
-export interface Stream {
- id: string;
- name: string;
- description: string;
- status: StreamStatus;
- members?: string[];
- particles: StreamParticle[];
- unseen_count: number;
- updated_at: string;
- created_at: string;
-}
-
-export interface Network {
- id: string;
- name: string;
- admin_human: Human;
- humans: Human[];
- open_stream_count: number;
- open_stream_capacity: number;
- created_at: string;
-}
-
-export interface NetworkWithStreams extends Network {
- streams: Stream[];
-}
+const AddMembersRequestSchema = z.object({
+ email_addresses: z.array(z.string().email()),
+});
+export type AddMembersRequest = z.infer;
// --- Depot types ---
-export interface PrepareUploadRequest {
- network_id: string;
- name: string;
- content_type: string;
- content_length: number;
+const PrepareUploadRequestSchema = z.object({
+ network_id: z.string(),
+ name: z.string(),
+ content_type: z.string(),
+ content_length: z.number(),
+});
+export type PrepareUploadRequest = z.infer;
+
+export const PrepareUploadResponseSchema = z.object({
+ object_id: z.string(),
+ upload_url: z.string(),
+ upload_headers: z.record(z.string(), z.string()),
+});
+export type PrepareUploadResponse = z.infer;
+
+export const DepotObjectSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ content_type: z.string(),
+ content_length: z.number(),
+ contains_content: z.boolean(),
+ created_at: z.coerce.date(),
+});
+export type DepotObject = z.infer;
+
+// --- Particle data schemas ---
+
+export const StreamParticleDataSchema = z.object({
+ name: z.string(),
+ status: z.enum(["open", "closed"]),
+ description: z.string().optional(),
+});
+export type StreamParticleData = z.infer;
+
+export const FolderParticleDataSchema = z.object({
+ name: z.string(),
+ color: z.string().optional(),
+});
+export type FolderParticleData = z.infer;
+
+export const MediaParticleDataSchema = z.object({
+ object_id: z.string(),
+ mime_type: z.string(),
+ duration_ms: z.number(),
+ size_bytes: z.number(),
+});
+export type MediaParticleData = z.infer;
+
+export const FileParticleDataSchema = z.object({
+ object_id: z.string(),
+ filename: z.string(),
+ mime_type: z.string(),
+ size_bytes: z.number(),
+});
+export type FileParticleData = z.infer;
+
+export const TextParticleDataSchema = z.object({
+ content: z.string(),
+});
+export type TextParticleData = z.infer;
+
+export const QuestParticleDataSchema = z.object({
+ title: z.string(),
+ description: z.string(),
+ status: z.string().optional(),
+ assigned_to: z.string().email().optional(),
+});
+export type QuestParticleData = z.infer;
+
+export const PaperParticleDataSchema = z.object({
+ title: z.string(),
+ content: z.string(),
+});
+export type PaperParticleData = z.infer;
+
+export interface ParticleDataMap {
+ stream: StreamParticleData;
+ folder: FolderParticleData;
+ media: MediaParticleData;
+ file: FileParticleData;
+ text: TextParticleData;
+ quest: QuestParticleData;
+ paper: PaperParticleData;
}
-export interface PrepareUploadResponse {
- object_id: string;
- upload_url: string;
- upload_headers: Record;
+// --- Unified Particle types ---
+
+interface ParticleBase {
+ id: string;
+ created_at: Date;
+ created_by: string;
}
-// --- Stream mutation types ---
+export type Particle = ParticleBase &
+ (
+ | { type: "stream"; data: StreamParticleData }
+ | { type: "folder"; data: FolderParticleData }
+ | { type: "media"; data: MediaParticleData }
+ | { type: "file"; data: FileParticleData }
+ | { type: "text"; data: TextParticleData }
+ | { type: "quest"; data: QuestParticleData }
+ | { type: "paper"; data: PaperParticleData }
+ );
-export interface CreateStreamRequest {
- name: string;
- description: string;
- visibility: "network_all" | "custom";
- member_emails?: string[];
-}
+export type ParticleType = Particle["type"];
-export interface CreateStreamParticleRequest {
- type: ParticleType;
- data: unknown;
-}
+/** Container types can have children subcollections */
+export const CONTAINER_TYPES: ReadonlySet = new Set(["stream", "folder"]);
-export interface MarkSeenBatchRequest {
- particle_ids: string[];
+export function isContainerType(type: ParticleType): boolean {
+ return CONTAINER_TYPES.has(type);
}
// --- Auth types ---
-export interface RequestCodeRequest {
- email: string;
-}
+const RequestCodeRequestSchema = z.object({
+ email: z.string().email(),
+});
+export type RequestCodeRequest = z.infer;
-export interface SignInRequest {
- email: string;
- code: string;
-}
+const SignInRequestSchema = z.object({
+ email: z.string().email(),
+ code: z.string(),
+});
+export type SignInRequest = z.infer;
-export interface SignInResponse {
- human: Human;
- token: string;
-}
-
-// --- Startup ---
-
-export interface StartupResponse {
- networks: NetworkWithStreams[];
-}
+export const SignInResponseSchema = z.object({
+ human: HumanSchema,
+ token: z.string(),
+});
+export type SignInResponse = z.infer;
diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx
new file mode 100644
index 0000000..2e8383a
--- /dev/null
+++ b/js/src/features/network-selector.tsx
@@ -0,0 +1,86 @@
+import { useNavigate } from "react-router-dom";
+import { LogOut } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Muted } from "@/components/ui/typography";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useAuthStore } from "@/stores/auth-store";
+import { WindowControls } from "@/components/window-controls";
+import { useQuery } from "@tanstack/react-query";
+import { apiClient } from "@/api/client";
+import { Progress } from "@/components/ui/progress";
+
+export function NetworkSelector() {
+ const navigate = useNavigate();
+ const signOut = useAuthStore((s) => s.signOut);
+ const user = useAuthStore((s) => s.user);
+
+ const { data, isPending, error } = useQuery({
+ queryKey: ["networks"],
+ queryFn: () => apiClient.listNetworks(),
+ });
+
+ if (isPending) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
Failed to load networks
+
{error.message}
+
+ );
+ }
+
+ if (data?.length === 0) {
+ return (
+
+
+ You don't have access to any networks yet. Please email us to get started.
+
+
+ team@flowylabs.ai
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {user &&
{user.email_prefix} }
+
+
+
+
+
+
+ navigate(`/${value}`)}>
+
+
+
+
+ {data?.map((network) => (
+
+ {network.name}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx
new file mode 100644
index 0000000..40b29ad
--- /dev/null
+++ b/js/src/features/particles/folder-view.tsx
@@ -0,0 +1,18 @@
+interface FolderViewProps {
+ networkId: string;
+ particleSegments: string[];
+}
+
+/**
+ * Folder-specific view — will eventually show children as cards/list.
+ * Placeholder for now.
+ */
+export function FolderView({ networkId, particleSegments }: FolderViewProps) {
+ return (
+
+
+ Folder view — {networkId}/{particleSegments.join("/")}
+
+
+ );
+}
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx
new file mode 100644
index 0000000..54f66cd
--- /dev/null
+++ b/js/src/features/particles/particle-list-view.tsx
@@ -0,0 +1,43 @@
+import { useParticleChildren } from "@/hooks/use-particle-children";
+
+interface ParticleListViewProps {
+ networkId: string;
+ particleSegments: string[];
+}
+
+/**
+ * Grid/list of child particles for a container (folder, stream root, or network root).
+ */
+export function ParticleListView({ networkId, particleSegments }: ParticleListViewProps) {
+ const { children, isLoading } = useParticleChildren(networkId, particleSegments);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (children.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children.map((child) => (
+
+
{child.id}
+
{child.type}
+
+ ))}
+
+ );
+}
diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx
new file mode 100644
index 0000000..af2bc77
--- /dev/null
+++ b/js/src/features/particles/particle-view-resolver.tsx
@@ -0,0 +1,65 @@
+import { useParticle } from "@/hooks/use-particle";
+import { StreamView } from "./stream-view";
+import { FolderView } from "./folder-view";
+import { ParticleListView } from "./particle-list-view";
+import { isContainerType } from "@/api/types";
+
+interface ParticleViewResolverProps {
+ networkId: string;
+ particleSegments: string[];
+}
+
+/**
+ * Resolves a particle by its path segments and renders the appropriate view
+ * based on particle type. This is the extensibility point for future particle types.
+ */
+export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) {
+ const { particle, isLoading, error } = useParticle(networkId, particleSegments);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Failed to load particle
+
+ );
+ }
+
+ // While the hook is stubbed, particle will be null — show a placeholder
+ if (!particle) {
+ return (
+
+
+ Particle: {particleSegments.join(" / ")}
+
+
+ );
+ }
+
+ switch (particle.type) {
+ case "stream":
+ return ;
+ case "folder":
+ return ;
+ default:
+ // For container types we haven't built a view for, fall back to list
+ if (isContainerType(particle.type)) {
+ return ;
+ }
+ // Leaf particle — placeholder
+ return (
+
+
+ {particle.type} particle: {particle.id}
+
+
+ );
+ }
+}
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
new file mode 100644
index 0000000..b853b46
--- /dev/null
+++ b/js/src/features/particles/stream-view.tsx
@@ -0,0 +1,18 @@
+interface StreamViewProps {
+ networkId: string;
+ particleSegments: string[];
+}
+
+/**
+ * Stream-specific view — will eventually show media/text children in playback order.
+ * Placeholder for now.
+ */
+export function StreamView({ networkId, particleSegments }: StreamViewProps) {
+ return (
+
+
+ Stream view — {networkId}/{particleSegments.join("/")}
+
+
+ );
+}
diff --git a/js/src/features/streams/stream-list.tsx b/js/src/features/streams/stream-list.tsx
deleted file mode 100644
index 0124621..0000000
--- a/js/src/features/streams/stream-list.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useNavigate } from "react-router-dom";
-import { Badge } from "@/components/ui/badge";
-import { Card } from "@/components/ui/card";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { useAppStore } from "@/stores/app-store";
-import { flattenStreams } from "@/lib/stream-utils";
-import { formatDistanceToNow } from "@/lib/time-utils";
-import { ParticlePreview } from "./particle-preview";
-
-function getInitials(email: string): string {
- const prefix = email.split("@")[0] ?? "";
- return prefix.slice(0, 2).toUpperCase();
-}
-
-export function StreamList() {
- const networks = useAppStore((s) => s.networks);
- const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
- const navigate = useNavigate();
-
- const streams = flattenStreams(networks, selectedNetworkId);
-
- if (streams.length === 0) {
- return (
-
- );
- }
-
- return (
-
- {streams.map((stream) => {
- const lastParticle =
- stream.particles.length > 0
- ? stream.particles[stream.particles.length - 1]
- : null;
-
- const timeSource = lastParticle?.created_at ?? stream.updated_at;
- const senderEmail = lastParticle?.created_by_email;
- const senderPrefix = senderEmail?.split("@")[0];
-
- return (
-
navigate(`/streams/${stream.id}`)}
- >
- {/* Preview hero area */}
-
- {lastParticle ? (
-
- ) : (
-
- )}
-
- {/* Unseen badge overlay */}
- {stream.unseen_count > 0 && (
-
- {stream.unseen_count}
-
- )}
-
-
- {/* Footer: avatar + stream info */}
-
- {senderEmail ? (
-
-
- {getInitials(senderEmail)}
-
-
- ) : (
-
- )}
-
-
-
{stream.name}
-
- {senderPrefix && {senderPrefix} }
- {senderPrefix && timeSource && · }
- {timeSource && {formatDistanceToNow(timeSource)} }
-
-
-
-
- );
- })}
-
- );
-}
diff --git a/js/src/hooks/use-particle-children.ts b/js/src/hooks/use-particle-children.ts
new file mode 100644
index 0000000..e2b6d44
--- /dev/null
+++ b/js/src/hooks/use-particle-children.ts
@@ -0,0 +1,29 @@
+import { firestorePath } from "@/lib/firestore-paths";
+import type { Particle } from "@/api/types";
+
+interface UseParticleChildrenResult {
+ children: Particle[];
+ isLoading: boolean;
+ error: Error | null;
+}
+
+/**
+ * Stub hook — returns placeholder data for the children of a container particle.
+ * Real Firestore reads will be wired up later.
+ */
+export function useParticleChildren(
+ networkId: string,
+ parentSegments: string[],
+): UseParticleChildrenResult {
+ // For children, append "/children" to the parent's doc path,
+ // or use the root collection if no parent segments.
+ const _collectionPath = parentSegments.length === 0
+ ? firestorePath(networkId, [])
+ : `${firestorePath(networkId, parentSegments)}/children`;
+
+ return {
+ children: [],
+ isLoading: false,
+ error: null,
+ };
+}
diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts
new file mode 100644
index 0000000..a629088
--- /dev/null
+++ b/js/src/hooks/use-particle.ts
@@ -0,0 +1,25 @@
+import { firestorePath } from "@/lib/firestore-paths";
+import type { Particle } from "@/api/types";
+
+interface UseParticleResult {
+ particle: Particle | null;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+/**
+ * Stub hook — returns placeholder data for a particle at the given path.
+ * Real Firestore reads will be wired up later.
+ */
+export function useParticle(
+ networkId: string,
+ segments: string[],
+): UseParticleResult {
+ const _path = firestorePath(networkId, segments);
+
+ return {
+ particle: null,
+ isLoading: false,
+ error: null,
+ };
+}
diff --git a/js/src/lib/firestore-paths.ts b/js/src/lib/firestore-paths.ts
new file mode 100644
index 0000000..12e245c
--- /dev/null
+++ b/js/src/lib/firestore-paths.ts
@@ -0,0 +1,23 @@
+/**
+ * Map URL segments to Firestore paths.
+ *
+ * Firestore structure:
+ * networks/{networkId}/particles/{particleId}
+ * networks/{networkId}/particles/{particleId}/children/{childId}
+ * ...and so on for arbitrary depth.
+ *
+ * Examples:
+ * segments = [] → "networks/{nid}/particles"
+ * segments = ["p1"] → "networks/{nid}/particles/p1"
+ * segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2"
+ */
+export function firestorePath(networkId: string, segments: string[]): string {
+ const base = `networks/${networkId}/particles`;
+ if (segments.length === 0) return base;
+
+ const parts: string[] = [base, segments[0]];
+ for (let i = 1; i < segments.length; i++) {
+ parts.push("children", segments[i]);
+ }
+ return parts.join("/");
+}
diff --git a/js/src/lib/stream-utils.ts b/js/src/lib/stream-utils.ts
deleted file mode 100644
index fb95abf..0000000
--- a/js/src/lib/stream-utils.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { NetworkWithStreams, Stream } from "@/api/types";
-
-export interface FlatStream extends Stream {
- networkId: string;
- networkName: string;
-}
-
-export function flattenStreams(
- networks: NetworkWithStreams[],
- selectedNetworkId: string | null,
-): FlatStream[] {
- const filtered = selectedNetworkId
- ? networks.filter((n) => n.id === selectedNetworkId)
- : networks;
-
- const streams: FlatStream[] = filtered.flatMap((n) =>
- n.streams.map((s) => ({
- ...s,
- networkId: n.id,
- networkName: n.name,
- })),
- );
-
- return streams.sort((a, b) => {
- const aTime = getLatestParticleTime(a);
- const bTime = getLatestParticleTime(b);
- return bTime - aTime;
- });
-}
-
-function getLatestParticleTime(stream: Stream): number {
- if (stream.particles.length === 0) {
- return new Date(stream.updated_at).getTime() || 0;
- }
- const last = stream.particles[stream.particles.length - 1];
- return new Date(last.created_at).getTime();
-}
diff --git a/js/src/lib/utils.ts b/js/src/lib/utils.ts
index b178ee7..fd4498c 100644
--- a/js/src/lib/utils.ts
+++ b/js/src/lib/utils.ts
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+export function getInitials(email: string): string {
+ const prefix = email.split("@")[0] ?? "";
+ return prefix.slice(0, 2).toUpperCase();
+}
diff --git a/js/src/pages/path-resolver.tsx b/js/src/pages/path-resolver.tsx
new file mode 100644
index 0000000..718a72e
--- /dev/null
+++ b/js/src/pages/path-resolver.tsx
@@ -0,0 +1,33 @@
+import { useLocation } from "react-router-dom";
+import { NetworkSelector } from "@/features/network-selector";
+import { ParticleListView } from "@/features/particles/particle-list-view";
+import { ParticleViewResolver } from "@/features/particles/particle-view-resolver";
+
+function parsePathSegments(path: string): string[] {
+ return path.split("/").filter(Boolean);
+}
+
+/**
+ * URL structure:
+ * / → network selector
+ * /:networkId → root particles for that network
+ * /:networkId/:p1/:p2/... → nested particle view (renders the parent which will use it's children)
+ */
+export default function PathResolver() {
+ const segments = parsePathSegments(useLocation().pathname);
+
+ // No segments → show network selector
+ if (segments.length === 0) {
+ return ;
+ }
+
+ const [networkId, ...particleSegments] = segments;
+
+ // /:networkId with no particle segments → root particle list
+ if (particleSegments.length === 0) {
+ return ;
+ }
+
+ // /:networkId/:p1/:p2/... → resolve and render the container particle
+ return ;
+}
diff --git a/js/src/pages/streams-page.tsx b/js/src/pages/streams-page.tsx
deleted file mode 100644
index 6b5dd23..0000000
--- a/js/src/pages/streams-page.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Plus, LogOut } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Muted } from "@/components/ui/typography";
-import { Progress } from "@/components/ui/progress";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { useAppStore } from "@/stores/app-store";
-import { useAuthStore } from "@/stores/auth-store";
-import { StreamList } from "@/features/streams/stream-list";
-import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
-import { WindowControls } from "@/components/window-controls";
-
-export function StreamsPage() {
- const networks = useAppStore((s) => s.networks);
- const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
- const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork);
- const signOut = useAuthStore((s) => s.signOut);
- const user = useAuthStore((s) => s.user);
-
- const selectedNetwork = selectedNetworkId
- ? networks.find((n) => n.id === selectedNetworkId)
- : null;
-
- return (
-
- {/* Top bar — draggable for frameless window */}
-
-
-
-
setSelectedNetwork(value)}
- >
-
-
-
-
- {networks.map((network) => (
-
- {network.name}
-
- ))}
-
-
-
-
-
- {selectedNetworkId && (
-
-
-
- New Stream
-
-
- )}
-
- {user && (
-
{user.email_prefix}
- )}
-
-
-
-
-
-
- {/* Stream list */}
-
-
-
-
- );
-}
diff --git a/js/src/stores/app-store.ts b/js/src/stores/app-store.ts
index f0b3034..1c7e56a 100644
--- a/js/src/stores/app-store.ts
+++ b/js/src/stores/app-store.ts
@@ -1,107 +1,15 @@
import { create } from "zustand";
-import { apiClient } from "@/api/client";
-import type {
- AckInfo,
- NetworkWithStreams,
- Stream,
- StreamParticle,
-} from "@/api/types";
+/**
+ * Minimal app-level store. Navigation state is now URL-driven via PathResolver.
+ * Stream/particle state will move to Firestore hooks.
+ */
interface AppState {
- networks: NetworkWithStreams[];
selectedNetworkId: string | null;
- isLoading: boolean;
-
- fetchStartup: () => Promise;
setSelectedNetwork: (id: string | null) => void;
- addStream: (networkId: string, stream: Stream) => void;
- addParticleToStream: (streamId: string, particle: StreamParticle) => void;
- markParticlesSeen: (particleIds: string[]) => void;
- ackParticle: (particleId: string, email: string) => void;
}
-export const useAppStore = create((set, get) => ({
- networks: [],
+export const useAppStore = create((set) => ({
selectedNetworkId: null,
- isLoading: false,
-
- fetchStartup: async () => {
- set({ isLoading: true });
- try {
- const data = await apiClient.startup();
- const state = get();
- const shouldAutoSelect =
- !state.selectedNetworkId && data.networks.length > 0;
- set({
- networks: data.networks,
- ...(shouldAutoSelect
- ? { selectedNetworkId: data.networks[0].id }
- : {}),
- });
- } finally {
- set({ isLoading: false });
- }
- },
-
- setSelectedNetwork: (id) => {
- set({ selectedNetworkId: id });
- },
-
- addStream: (networkId, stream) => {
- set({
- networks: get().networks.map((n) =>
- n.id === networkId ? { ...n, streams: [stream, ...n.streams] } : n,
- ),
- });
- },
-
- addParticleToStream: (streamId, particle) => {
- set({
- networks: get().networks.map((n) => ({
- ...n,
- streams: n.streams.map((s) =>
- s.id === streamId
- ? { ...s, particles: [...s.particles, particle] }
- : s,
- ),
- })),
- });
- },
-
- ackParticle: (particleId, email) => {
- const ack: AckInfo = { email, acked_at: new Date().toISOString() };
- set({
- networks: get().networks.map((n) => ({
- ...n,
- streams: n.streams.map((s) => ({
- ...s,
- particles: s.particles.map((p) =>
- p.id === particleId ? { ...p, acks: [...p.acks, ack] } : p,
- ),
- })),
- })),
- });
- },
-
- markParticlesSeen: (particleIds) => {
- const idSet = new Set(particleIds);
- set({
- networks: get().networks.map((n) => ({
- ...n,
- streams: n.streams.map((s) => {
- const unseenMarked = s.particles.filter(
- (p) => !p.seen && idSet.has(p.id),
- ).length;
- if (unseenMarked === 0) return s;
- return {
- ...s,
- unseen_count: Math.max(0, s.unseen_count - unseenMarked),
- particles: s.particles.map((p) =>
- idSet.has(p.id) ? { ...p, seen: true } : p,
- ),
- };
- }),
- })),
- });
- },
+ setSelectedNetwork: (id) => set({ selectedNetworkId: id }),
}));
diff --git a/js/yarn.lock b/js/yarn.lock
index ea9f191..40f0e72 100644
--- a/js/yarn.lock
+++ b/js/yarn.lock
@@ -814,7 +814,7 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
-"@eslint-community/eslint-utils@^4.2.0":
+"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
@@ -2649,6 +2649,25 @@
"@tailwindcss/oxide" "4.2.0"
tailwindcss "4.2.0"
+"@tanstack/eslint-plugin-query@^5.91.4":
+ version "5.91.4"
+ resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.4.tgz#b12f35280379aef0787074932ad698fd9bc621cc"
+ integrity sha512-8a+GAeR7oxJ5laNyYBQ6miPK09Hi18o5Oie/jx8zioXODv/AUFLZQecKabPdpQSLmuDXEBPKFh+W5DKbWlahjQ==
+ dependencies:
+ "@typescript-eslint/utils" "^8.48.0"
+
+"@tanstack/query-core@5.90.20":
+ version "5.90.20"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.20.tgz#e12128e39210715d4ce4fb299c33498ac297771e"
+ integrity sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==
+
+"@tanstack/react-query@^5.90.21":
+ version "5.90.21"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.90.21.tgz#e0eb40831a76510be438109435b8807ef63ab1b9"
+ integrity sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==
+ dependencies:
+ "@tanstack/query-core" "5.90.20"
+
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@@ -2875,6 +2894,15 @@
"@typescript-eslint/typescript-estree" "5.62.0"
debug "^4.3.4"
+"@typescript-eslint/project-service@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.1.tgz#16af9fe16eedbd7085e4fdc29baa73715c0c55c5"
+ integrity sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==
+ dependencies:
+ "@typescript-eslint/tsconfig-utils" "^8.57.1"
+ "@typescript-eslint/types" "^8.57.1"
+ debug "^4.4.3"
+
"@typescript-eslint/scope-manager@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c"
@@ -2883,6 +2911,19 @@
"@typescript-eslint/types" "5.62.0"
"@typescript-eslint/visitor-keys" "5.62.0"
+"@typescript-eslint/scope-manager@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz#4524d7e7b420cb501807499684d435ae129aaf35"
+ integrity sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==
+ dependencies:
+ "@typescript-eslint/types" "8.57.1"
+ "@typescript-eslint/visitor-keys" "8.57.1"
+
+"@typescript-eslint/tsconfig-utils@8.57.1", "@typescript-eslint/tsconfig-utils@^8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz#9233443ec716882a6f9e240fd900a73f0235f3d7"
+ integrity sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==
+
"@typescript-eslint/type-utils@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a"
@@ -2898,6 +2939,11 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
+"@typescript-eslint/types@8.57.1", "@typescript-eslint/types@^8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.1.tgz#54b27a8a25a7b45b4f978c3f8e00c4c78f11142c"
+ integrity sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==
+
"@typescript-eslint/typescript-estree@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
@@ -2911,6 +2957,21 @@
semver "^7.3.7"
tsutils "^3.21.0"
+"@typescript-eslint/typescript-estree@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz#a9fd28d4a0ec896aa9a9a7e0cead62ea24f99e76"
+ integrity sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==
+ dependencies:
+ "@typescript-eslint/project-service" "8.57.1"
+ "@typescript-eslint/tsconfig-utils" "8.57.1"
+ "@typescript-eslint/types" "8.57.1"
+ "@typescript-eslint/visitor-keys" "8.57.1"
+ debug "^4.4.3"
+ minimatch "^10.2.2"
+ semver "^7.7.3"
+ tinyglobby "^0.2.15"
+ ts-api-utils "^2.4.0"
+
"@typescript-eslint/utils@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
@@ -2925,6 +2986,16 @@
eslint-scope "^5.1.1"
semver "^7.3.7"
+"@typescript-eslint/utils@^8.48.0":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.57.1.tgz#e40f5a7fcff02fd24092a7b52bd6ec029fb50465"
+ integrity sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==
+ dependencies:
+ "@eslint-community/eslint-utils" "^4.9.1"
+ "@typescript-eslint/scope-manager" "8.57.1"
+ "@typescript-eslint/types" "8.57.1"
+ "@typescript-eslint/typescript-estree" "8.57.1"
+
"@typescript-eslint/visitor-keys@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
@@ -2933,6 +3004,14 @@
"@typescript-eslint/types" "5.62.0"
eslint-visitor-keys "^3.3.0"
+"@typescript-eslint/visitor-keys@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz#3af4f88118924d3be983d4b8ae84803f11fe4563"
+ integrity sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==
+ dependencies:
+ "@typescript-eslint/types" "8.57.1"
+ eslint-visitor-keys "^5.0.0"
+
"@ungap/structured-clone@^1.2.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
@@ -4375,6 +4454,11 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
+eslint-visitor-keys@^5.0.0:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
+ integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
+
eslint@^8.57.1:
version "8.57.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9"
@@ -4653,7 +4737,7 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
-fdir@^6.2.0:
+fdir@^6.2.0, fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
@@ -6162,6 +6246,13 @@ minimatch@^10.0.1:
dependencies:
brace-expansion "^5.0.2"
+minimatch@^10.2.2:
+ version "10.2.4"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
+ integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
+ dependencies:
+ brace-expansion "^5.0.2"
+
minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -6772,7 +6863,7 @@ picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
-picomatch@^4.0.2:
+picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
@@ -7391,7 +7482,7 @@ semver@^6.2.0, semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7:
+semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
@@ -7940,6 +8031,14 @@ tinyexec@^1.0.1:
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
+tinyglobby@^0.2.15:
+ version "0.2.15"
+ resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
+ integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
+ dependencies:
+ fdir "^6.5.0"
+ picomatch "^4.0.3"
+
tldts-core@^7.0.23:
version "7.0.23"
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309"
@@ -8002,6 +8101,11 @@ trim-repeated@^1.0.0:
dependencies:
escape-string-regexp "^1.0.2"
+ts-api-utils@^2.4.0:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8"
+ integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==
+
ts-morph@^26.0.0:
version "26.0.0"
resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-26.0.0.tgz#d435ccac9421d4615fde8be86fee782f18cd9f73"
@@ -8139,10 +8243,10 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6"
-typescript@~4.5.4:
- version "4.5.5"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
- integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
+typescript@^5.9.3:
+ version "5.9.3"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
+ integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
typescript@~5.4.5:
version "5.4.5"
@@ -8586,7 +8690,7 @@ zod@^3.24.1:
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
-"zod@^3.25 || ^4.0":
+"zod@^3.25 || ^4.0", zod@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==