From f328964fd0583c7f6ad00e61f7b387c2218a7071 Mon Sep 17 00:00:00 2001
From: Luke Curley <kixelated@gmail.com>
Date: Mon, 8 Jun 2026 10:36:44 -0700
Subject: [PATCH 1/2] feat(hang): generic base catalog with app-layer
 extensions

Make the base catalog carry only `video`/`audio` and let applications add
their own root sections (e.g. SCTE-35) without modifying hang. The mechanism
is plain composition on top of the already-generic `@moq/json`:

- JS: `z.extend(Catalog.RootSchema, { scte35: ... })`
- Rust: `#[serde(flatten)] base: hang::Catalog`

The base catalog ignores unknown sections, so an extended catalog stays
readable by a plain hang consumer.

App-specific sections (chat, user, preview, location, capabilities) and their
watch/publish implementations are removed; they belong in the application
layer (hang.live) and remain in git history for that move. `@moq/publish`'s
`Broadcast` gains a `sections` input (merged into the published catalog) and a
`tracks` map (serve app-defined tracks) so the app layer can reuse it; watch
needs no hook since the app subscribes to `catalog.json` with its own schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 doc/concept/layer/hang.md           | 33 ++++++++++++
 js/hang/src/catalog/capabilities.ts | 22 --------
 js/hang/src/catalog/chat.ts         |  9 ----
 js/hang/src/catalog/index.ts        |  5 --
 js/hang/src/catalog/location.ts     | 45 ----------------
 js/hang/src/catalog/preview.ts      | 15 ------
 js/hang/src/catalog/priority.ts     |  8 +--
 js/hang/src/catalog/root.test.ts    | 31 +++++++++++
 js/hang/src/catalog/root.ts         | 43 ++++-----------
 js/hang/src/catalog/user.ts         | 10 ----
 js/publish/src/broadcast.ts         | 77 ++++++++++++---------------
 js/publish/src/chat/index.ts        | 40 --------------
 js/publish/src/chat/message.ts      | 45 ----------------
 js/publish/src/chat/typing.ts       | 45 ----------------
 js/publish/src/index.ts             |  4 --
 js/publish/src/location/index.ts    | 44 ----------------
 js/publish/src/location/peers.ts    | 44 ----------------
 js/publish/src/location/window.ts   | 57 --------------------
 js/publish/src/preview.ts           | 42 ---------------
 js/publish/src/user.ts              | 46 ----------------
 js/watch/src/chat/index.ts          | 65 -----------------------
 js/watch/src/chat/message.ts        | 77 ---------------------------
 js/watch/src/chat/typing.ts         | 73 --------------------------
 js/watch/src/index.ts               |  4 --
 js/watch/src/location/index.ts      | 35 -------------
 js/watch/src/location/peers.ts      | 70 -------------------------
 js/watch/src/location/window.ts     | 81 -----------------------------
 js/watch/src/preview.ts             | 66 -----------------------
 js/watch/src/user.ts                | 48 -----------------
 rs/hang/src/catalog/root.rs         | 62 ++++++++++++++++++++++
 30 files changed, 169 insertions(+), 1077 deletions(-)
 delete mode 100644 js/hang/src/catalog/capabilities.ts
 delete mode 100644 js/hang/src/catalog/chat.ts
 delete mode 100644 js/hang/src/catalog/location.ts
 delete mode 100644 js/hang/src/catalog/preview.ts
 create mode 100644 js/hang/src/catalog/root.test.ts
 delete mode 100644 js/hang/src/catalog/user.ts
 delete mode 100644 js/publish/src/chat/index.ts
 delete mode 100644 js/publish/src/chat/message.ts
 delete mode 100644 js/publish/src/chat/typing.ts
 delete mode 100644 js/publish/src/location/index.ts
 delete mode 100644 js/publish/src/location/peers.ts
 delete mode 100644 js/publish/src/location/window.ts
 delete mode 100644 js/publish/src/preview.ts
 delete mode 100644 js/publish/src/user.ts
 delete mode 100644 js/watch/src/chat/index.ts
 delete mode 100644 js/watch/src/chat/message.ts
 delete mode 100644 js/watch/src/chat/typing.ts
 delete mode 100644 js/watch/src/location/index.ts
 delete mode 100644 js/watch/src/location/peers.ts
 delete mode 100644 js/watch/src/location/window.ts
 delete mode 100644 js/watch/src/preview.ts
 delete mode 100644 js/watch/src/user.ts

diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md
index c193638e38..7be24fab55 100644
--- a/doc/concept/layer/hang.md
+++ b/doc/concept/layer/hang.md
@@ -46,6 +46,39 @@ Here is Big Buck Bunny's `catalog.json` as of 2026-02-02:
 }
 ```
 
+### Extensions
+
+The base catalog only describes `video` and `audio`. Applications add their own root sections (for example `scte35` ad-splice signaling) without modifying hang: define a schema for the section and compose it onto the base catalog, then publish and subscribe through the same JSON snapshot/delta track helper.
+
+Because the base catalog ignores unknown sections, an extended catalog stays readable by a plain hang viewer; it just won't see the extra sections.
+
+In TypeScript, extend the schema and hand it to `@moq/json`:
+
+```ts
+import * as z from "zod/mini";
+import * as Catalog from "@moq/hang/catalog";
+import * as Json from "@moq/json";
+
+const Scte35Schema = z.object({ track: z.string() });
+const RootSchema = z.extend(Catalog.RootSchema, { scte35: z.optional(Scte35Schema) });
+type Root = z.infer<typeof RootSchema>;
+
+const consumer = new Json.Consumer<Root>(track, { schema: RootSchema });
+```
+
+In Rust, flatten the base catalog into your own type and use it with [`moq-json`](https://docs.rs/moq-json):
+
+```rust
+#[derive(serde::Serialize, serde::Deserialize)]
+struct AppCatalog {
+    #[serde(flatten)]
+    base: hang::Catalog,
+    scte35: Option<Scte35>,
+}
+```
+
+On the publish side, `@moq/publish`'s `Broadcast` accepts a `sections` input that is merged into the published catalog, plus a `tracks` map for serving any extra tracks a section references.
+
 ### Audio
 
 [See the latest schema](https://github.com/moq-dev/moq/blob/main/js/hang/src/catalog/audio.ts).
diff --git a/js/hang/src/catalog/capabilities.ts b/js/hang/src/catalog/capabilities.ts
deleted file mode 100644
index b1f4df8ce6..0000000000
--- a/js/hang/src/catalog/capabilities.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import * as z from "zod/mini";
-
-export const VideoCapabilitiesSchema = z.object({
-	hardware: z.optional(z.array(z.string())),
-	software: z.optional(z.array(z.string())),
-	unsupported: z.optional(z.array(z.string())),
-});
-
-export const AudioCapabilitiesSchema = z.object({
-	hardware: z.optional(z.array(z.string())),
-	software: z.optional(z.array(z.string())),
-	unsupported: z.optional(z.array(z.string())),
-});
-
-export const CapabilitiesSchema = z.object({
-	video: z.optional(VideoCapabilitiesSchema),
-	audio: z.optional(AudioCapabilitiesSchema),
-});
-
-export type Capabilities = z.infer<typeof CapabilitiesSchema>;
-export type VideoCapabilities = z.infer<typeof VideoCapabilitiesSchema>;
-export type AudioCapabilities = z.infer<typeof AudioCapabilitiesSchema>;
diff --git a/js/hang/src/catalog/chat.ts b/js/hang/src/catalog/chat.ts
deleted file mode 100644
index d012b96fef..0000000000
--- a/js/hang/src/catalog/chat.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import * as z from "zod/mini";
-import { TrackSchema } from "./track";
-
-export const ChatSchema = z.object({
-	message: z.optional(TrackSchema),
-	typing: z.optional(TrackSchema),
-});
-
-export type Chat = z.infer<typeof ChatSchema>;
diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts
index e63ea4f1a9..9c54e65348 100644
--- a/js/hang/src/catalog/index.ts
+++ b/js/hang/src/catalog/index.ts
@@ -1,13 +1,8 @@
 export * from "./audio";
-export * from "./capabilities";
-export * from "./chat";
 export * from "./container";
 export * from "./format";
 export * from "./integers";
-export * from "./location";
-export * from "./preview";
 export * from "./priority";
 export * from "./root";
 export * from "./track";
-export * from "./user";
 export * from "./video";
diff --git a/js/hang/src/catalog/location.ts b/js/hang/src/catalog/location.ts
deleted file mode 100644
index 52055e5117..0000000000
--- a/js/hang/src/catalog/location.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import * as z from "zod/mini";
-import { TrackSchema } from "./track";
-
-export const PositionSchema = z.object({
-	// The relative X position of the broadcast, from -1 to +1.
-	// This should be used for audio panning but can also be used for video positioning.
-	x: z.optional(z.number()),
-
-	// The relative Y position of the broadcast, from -1 to +1.
-	// This can be used for video positioning, and maybe audio panning.
-	y: z.optional(z.number()),
-
-	// The relative Z index of the broadcast, where larger values are closer to the viewer.
-	// This is used to break ties when there are multiple broadcasts at the same position.
-	z: z.optional(z.number()),
-
-	// The scale of the broadcast, where 1 is 100%
-	s: z.optional(z.number()),
-});
-
-export const LocationSchema = z.object({
-	// The initial position of the broadcaster, from -1 to +1 in both dimensions.
-	// If not provided, then the broadcaster is assumed to be at (0,0)
-	// This should be used for audio panning but can also be used for video positioning.
-	initial: z.optional(PositionSchema),
-
-	// If provided, then updates to the position are done via a separate Moq track.
-	// This is used to avoid a full catalog update every time we want to update a few bytes.
-	// TODO: These updates currently use JSON for simplicity, but we should use a binary format.
-	track: z.optional(TrackSchema),
-
-	// If set, then this broadcaster allows other peers to request position updates via this handle.
-	// We will have to discover and subscribe to their position updates.
-	handle: z.optional(z.string()),
-
-	// If provided, this broadcaster is signaling the location of other peers.
-	// The payload is a JSON blob keyed by handle for each peer.
-	peers: z.optional(TrackSchema),
-});
-
-export type Location = z.infer<typeof LocationSchema>;
-export type Position = z.infer<typeof PositionSchema>;
-
-export const PeersSchema = z.record(z.string(), PositionSchema);
-export type Peers = z.infer<typeof PeersSchema>;
diff --git a/js/hang/src/catalog/preview.ts b/js/hang/src/catalog/preview.ts
deleted file mode 100644
index ae8183b074..0000000000
--- a/js/hang/src/catalog/preview.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import * as z from "zod/mini";
-
-export const PreviewSchema = z.object({
-	name: z.optional(z.string()), // name
-	avatar: z.optional(z.string()), // avatar
-
-	audio: z.optional(z.boolean()), // audio enabled
-	video: z.optional(z.boolean()), // video enabled
-
-	typing: z.optional(z.boolean()), // actively typing
-	chat: z.optional(z.boolean()), // chatted recently
-	screen: z.optional(z.boolean()), // screen sharing
-});
-
-export type Preview = z.infer<typeof PreviewSchema>;
diff --git a/js/hang/src/catalog/priority.ts b/js/hang/src/catalog/priority.ts
index b92715878a..438a49d6f5 100644
--- a/js/hang/src/catalog/priority.ts
+++ b/js/hang/src/catalog/priority.ts
@@ -1,11 +1,7 @@
-// We define all of the priorities for tracks here.
-// That way it's easier to make sure they are in the right order.
+// Default priorities for the base catalog's tracks, kept together so the ordering is easy to
+// eyeball. Applications pick their own priorities for sections they add (slotting around these).
 export const PRIORITY = {
 	catalog: 100,
-	chat: 90,
 	audio: 80,
 	video: 60,
-	typing: 40,
-	location: 20,
-	preview: 10,
 } as const;
diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts
new file mode 100644
index 0000000000..bf8d4d8e0e
--- /dev/null
+++ b/js/hang/src/catalog/root.test.ts
@@ -0,0 +1,31 @@
+import { expect, test } from "bun:test";
+import * as z from "zod/mini";
+import { RootSchema } from "./root.ts";
+
+// An application-defined section, the kind that lives in the app layer (e.g. hang.live) rather
+// than in the base catalog.
+const Scte35Schema = z.object({
+	track: z.string(),
+	spliceCount: z.optional(z.number()),
+});
+
+// Compose the base catalog with the extension, exactly as an application would.
+const ExtendedSchema = z.extend(RootSchema, { scte35: z.optional(Scte35Schema) });
+
+test("base catalog drops unknown sections", () => {
+	// The base schema is closed: an app section round-trips to nothing, so you must extend it.
+	expect(RootSchema.parse({ scte35: { track: "splice.json" } })).toEqual({});
+});
+
+test("extended catalog preserves the section and the base fields", () => {
+	const parsed = ExtendedSchema.parse({
+		audio: { renditions: {} },
+		scte35: { track: "splice.json", spliceCount: 2 },
+	});
+	expect(parsed.audio).toEqual({ renditions: {} });
+	expect(parsed.scte35).toEqual({ track: "splice.json", spliceCount: 2 });
+});
+
+test("extended catalog still rejects an invalid section", () => {
+	expect(() => ExtendedSchema.parse({ scte35: { spliceCount: 1 } })).toThrow();
+});
diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts
index 22b4bbe3c9..0bd1bab5a0 100644
--- a/js/hang/src/catalog/root.ts
+++ b/js/hang/src/catalog/root.ts
@@ -1,45 +1,20 @@
-import type * as Moq from "@moq/net";
 import * as z from "zod/mini";
 
 import { AudioSchema } from "./audio";
-import { CapabilitiesSchema } from "./capabilities";
-import { ChatSchema } from "./chat";
-import { LocationSchema } from "./location";
-import { TrackSchema } from "./track";
-import { UserSchema } from "./user";
 import { VideoSchema } from "./video";
 
+// The base catalog: just the media tracks every hang broadcast carries.
+//
+// Applications layer their own sections on top with `z.extend`, e.g.
+//
+//   const MyRoot = z.extend(RootSchema, { scte35: z.optional(Scte35Schema) });
+//
+// and feed that schema to `@moq/json`'s Producer/Consumer to publish and subscribe with
+// the same snapshot/delta semantics and validation as the base catalog. App-specific sections
+// (chat, user, location, ...) live in the application layer, not here.
 export const RootSchema = z.object({
 	video: z.optional(VideoSchema),
 	audio: z.optional(AudioSchema),
-	location: z.optional(LocationSchema),
-	user: z.optional(UserSchema),
-	chat: z.optional(ChatSchema),
-	capabilities: z.optional(CapabilitiesSchema),
-	preview: z.optional(TrackSchema),
 });
 
 export type Root = z.infer<typeof RootSchema>;
-
-export function encode(root: Root): Uint8Array {
-	const encoder = new TextEncoder();
-	return encoder.encode(JSON.stringify(root));
-}
-
-export function decode(raw: Uint8Array): Root {
-	const decoder = new TextDecoder();
-	const str = decoder.decode(raw);
-	try {
-		const json = JSON.parse(str);
-		return RootSchema.parse(json);
-	} catch (error) {
-		console.warn("invalid catalog", str);
-		throw error;
-	}
-}
-
-export async function fetch(track: Moq.Track): Promise<Root | undefined> {
-	const frame = await track.readFrame();
-	if (!frame) return undefined;
-	return decode(frame);
-}
diff --git a/js/hang/src/catalog/user.ts b/js/hang/src/catalog/user.ts
deleted file mode 100644
index 7f4a699c2a..0000000000
--- a/js/hang/src/catalog/user.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as z from "zod/mini";
-
-export const UserSchema = z.object({
-	id: z.optional(z.string()),
-	name: z.optional(z.string()),
-	avatar: z.optional(z.string()), // TODO allow using a track instead of a URL?
-	color: z.optional(z.string()),
-});
-
-export type User = z.infer<typeof UserSchema>;
diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts
index ba086844ce..36545ee03e 100644
--- a/js/publish/src/broadcast.ts
+++ b/js/publish/src/broadcast.ts
@@ -1,24 +1,30 @@
 import * as Catalog from "@moq/hang/catalog";
 import * as Json from "@moq/json";
 import * as Moq from "@moq/net";
-import { Effect, Signal } from "@moq/signals";
+import { Effect, type Getter, Signal } from "@moq/signals";
 import * as Audio from "./audio";
-import * as Chat from "./chat";
-import * as Location from "./location";
-import { Preview, type PreviewProps } from "./preview";
-import * as User from "./user";
 import * as Video from "./video";
 
+// Serves a single application-defined track when subscribed. Same shape as the built-in
+// `serve` methods, so an extension can route its own tracks (e.g. a chat message track)
+// through the broadcast's request loop.
+export type ServeTrack = (track: Moq.Track, effect: Effect) => void;
+
 export type BroadcastProps = {
 	connection?: Moq.Connection.Established | Signal<Moq.Connection.Established | undefined>;
 	enabled?: boolean | Signal<boolean>;
 	name?: Moq.Path.Valid | Signal<Moq.Path.Valid>;
 	audio?: Audio.EncoderProps;
 	video?: Video.Props;
-	location?: Location.Props;
-	user?: User.Props;
-	chat?: Chat.Props;
-	preview?: PreviewProps;
+
+	// Extra catalog sections merged into the published catalog alongside `video`/`audio`.
+	// This is how the application layer adds its own root sections (chat, location, scte35, ...)
+	// without hang knowing about them.
+	sections?: Record<string, unknown> | Signal<Record<string, unknown> | undefined>;
+
+	// Handlers for application-defined tracks, keyed by track name. Consulted when a
+	// subscription arrives for a track the base broadcast doesn't recognize.
+	tracks?: Record<string, ServeTrack>;
 };
 
 export class Broadcast {
@@ -31,10 +37,9 @@ export class Broadcast {
 	audio: Audio.Encoder;
 	video: Video.Root;
 
-	location: Location.Root;
-	chat: Chat.Root;
-	preview: Preview;
-	user: User.Info;
+	// Application-supplied extensions, see `BroadcastProps`.
+	sections: Getter<Record<string, unknown> | undefined>;
+	tracks: Record<string, ServeTrack>;
 
 	signals = new Effect();
 
@@ -45,10 +50,9 @@ export class Broadcast {
 
 		this.audio = new Audio.Encoder(props?.audio);
 		this.video = new Video.Root({ ...props?.video, connection: this.connection });
-		this.location = new Location.Root(props?.location);
-		this.chat = new Chat.Root(props?.chat);
-		this.preview = new Preview(props?.preview);
-		this.user = new User.Info(props?.user);
+
+		this.sections = Signal.from(props?.sections);
+		this.tracks = props?.tracks ?? {};
 
 		this.signals.run(this.#run.bind(this));
 	}
@@ -85,22 +89,7 @@ export class Broadcast {
 
 				switch (request.track.name) {
 					case Broadcast.CATALOG_TRACK:
-						this.#serveCatalog(new Json.Producer<Catalog.Root>(request.track), effect);
-						break;
-					case Location.Window.TRACK:
-						this.location.window.serve(request.track, effect);
-						break;
-					case Location.Peers.TRACK:
-						this.location.peers.serve(request.track, effect);
-						break;
-					case Preview.TRACK:
-						this.preview.serve(request.track, effect);
-						break;
-					case Chat.Typing.TRACK:
-						this.chat.typing.serve(request.track, effect);
-						break;
-					case Chat.Message.TRACK:
-						this.chat.message.serve(request.track, effect);
+						this.#serveCatalog(new Json.Producer<Record<string, unknown>>(request.track), effect);
 						break;
 					case Audio.Encoder.TRACK:
 						this.audio.serve(request.track, effect);
@@ -111,42 +100,40 @@ export class Broadcast {
 					case Video.Root.TRACK_SD:
 						this.video.sd.serve(request.track, effect);
 						break;
-					default:
+					default: {
+						const handler = this.tracks[request.track.name];
+						if (handler) {
+							handler(request.track, effect);
+							break;
+						}
 						console.error("received subscription for unknown track", request.track.name);
 						request.track.close(new Error(`Unknown track: ${request.track.name}`));
 						break;
+					}
 				}
 			});
 		}
 	}
 
-	#serveCatalog(producer: Json.Producer<Catalog.Root>, effect: Effect): void {
+	#serveCatalog(producer: Json.Producer<Record<string, unknown>>, effect: Effect): void {
 		if (!effect.get(this.enabled)) {
 			// Clear the catalog.
 			producer.update({});
 			return;
 		}
 
-		// Create the new catalog.
 		const catalog: Catalog.Root = {
 			video: effect.get(this.video.catalog),
 			audio: effect.get(this.audio.catalog),
-			location: effect.get(this.location.catalog),
-			user: effect.get(this.user.catalog),
-			chat: effect.get(this.chat.catalog),
-			preview: effect.get(this.preview.catalog),
 		};
 
-		producer.update(catalog);
+		// Merge any application-defined sections on top of the base catalog.
+		producer.update({ ...catalog, ...effect.get(this.sections) });
 	}
 
 	close() {
 		this.signals.close();
 		this.audio.close();
 		this.video.close();
-		this.location.close();
-		this.chat.close();
-		this.preview.close();
-		this.user.close();
 	}
 }
diff --git a/js/publish/src/chat/index.ts b/js/publish/src/chat/index.ts
deleted file mode 100644
index 080bd9c232..0000000000
--- a/js/publish/src/chat/index.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type * as Catalog from "@moq/hang/catalog";
-import { Effect, type Getter, Signal } from "@moq/signals";
-import { Message, type MessageProps } from "./message";
-import { Typing, type TypingProps } from "./typing";
-
-export * from "./message";
-export * from "./typing";
-
-export type Props = {
-	message?: MessageProps;
-	typing?: TypingProps;
-};
-
-export class Root {
-	message: Message;
-	typing: Typing;
-
-	#catalog = new Signal<Catalog.Chat | undefined>(undefined);
-	readonly catalog: Getter<Catalog.Chat | undefined> = this.#catalog;
-
-	#signals = new Effect();
-
-	constructor(props?: Props) {
-		this.message = new Message(props?.message);
-		this.typing = new Typing(props?.typing);
-
-		this.#signals.run((effect) => {
-			this.#catalog.set({
-				message: effect.get(this.message.catalog),
-				typing: effect.get(this.typing.catalog),
-			});
-		});
-	}
-
-	close() {
-		this.#signals.close();
-		this.message.close();
-		this.typing.close();
-	}
-}
diff --git a/js/publish/src/chat/message.ts b/js/publish/src/chat/message.ts
deleted file mode 100644
index 1f6f5ce25a..0000000000
--- a/js/publish/src/chat/message.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import { Effect, Signal } from "@moq/signals";
-
-export type MessageProps = {
-	enabled?: boolean | Signal<boolean>;
-};
-
-export class Message {
-	static readonly TRACK = "chat/message.txt";
-	static readonly PRIORITY = Catalog.PRIORITY.chat;
-
-	enabled: Signal<boolean>;
-
-	// The latest message to publish.
-	latest: Signal<string>;
-
-	catalog = new Signal<Catalog.Track | undefined>(undefined);
-
-	#signals = new Effect();
-
-	constructor(props?: MessageProps) {
-		this.enabled = Signal.from(props?.enabled ?? false);
-		this.latest = new Signal<string>("");
-
-		this.#signals.run((effect) => {
-			const enabled = effect.get(this.enabled);
-			if (!enabled) return;
-
-			effect.set(this.catalog, { name: Message.TRACK });
-		});
-	}
-
-	serve(track: Moq.Track, effect: Effect): void {
-		const enabled = effect.get(this.enabled);
-		if (!enabled) return;
-
-		const latest = effect.get(this.latest);
-		track.writeString(latest ?? "");
-	}
-
-	close() {
-		this.#signals.close();
-	}
-}
diff --git a/js/publish/src/chat/typing.ts b/js/publish/src/chat/typing.ts
deleted file mode 100644
index cf47c8251e..0000000000
--- a/js/publish/src/chat/typing.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import { Effect, Signal } from "@moq/signals";
-
-export type TypingProps = {
-	enabled?: boolean | Signal<boolean>;
-};
-
-export class Typing {
-	static readonly TRACK = "chat/typing.bool";
-	static readonly PRIORITY = Catalog.PRIORITY.typing;
-
-	enabled: Signal<boolean>;
-
-	// Whether the user is typing.
-	active: Signal<boolean>;
-
-	catalog = new Signal<Catalog.Track | undefined>(undefined);
-
-	#signals = new Effect();
-
-	constructor(props?: TypingProps) {
-		this.enabled = Signal.from(props?.enabled ?? false);
-		this.active = new Signal<boolean>(false);
-
-		this.#signals.run((effect) => {
-			const enabled = effect.get(this.enabled);
-			if (!enabled) return;
-
-			effect.set(this.catalog, { name: Typing.TRACK });
-		});
-	}
-
-	serve(track: Moq.Track, effect: Effect): void {
-		const enabled = effect.get(this.enabled);
-		if (!enabled) return;
-
-		const active = effect.get(this.active);
-		track.writeBool(active);
-	}
-
-	close() {
-		this.#signals.close();
-	}
-}
diff --git a/js/publish/src/index.ts b/js/publish/src/index.ts
index 47278b160e..2f4ef5e21c 100644
--- a/js/publish/src/index.ts
+++ b/js/publish/src/index.ts
@@ -5,11 +5,7 @@ export * as Lite from "@moq/net";
 export * as Signals from "@moq/signals";
 export * as Audio from "./audio";
 export * from "./broadcast";
-export * as Chat from "./chat";
-export * as Location from "./location";
-export * from "./preview";
 export * as Source from "./source";
-export * as User from "./user";
 export * as Video from "./video";
 
 // NOTE: element is not exported from this module
diff --git a/js/publish/src/location/index.ts b/js/publish/src/location/index.ts
deleted file mode 100644
index 1063119347..0000000000
--- a/js/publish/src/location/index.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type * as Catalog from "@moq/hang/catalog";
-import { Effect, Signal } from "@moq/signals";
-import { Peers, type PeersProps } from "./peers";
-import { Window, type WindowProps } from "./window";
-
-export * from "./peers";
-export * from "./window";
-
-export type Props = {
-	window?: WindowProps;
-	peers?: PeersProps;
-};
-
-export class Root {
-	window: Window;
-	peers: Peers;
-
-	catalog = new Signal<Catalog.Location | undefined>(undefined);
-	signals = new Effect();
-
-	constructor(props?: Props) {
-		this.window = new Window(props?.window);
-		this.peers = new Peers(props?.peers);
-
-		this.signals.run(this.#run.bind(this));
-	}
-
-	#run(effect: Effect): void {
-		const myself = effect.get(this.window.catalog);
-		const peers = effect.get(this.peers.catalog);
-		if (!myself && !peers) return;
-
-		effect.set(this.catalog, {
-			peers: peers,
-			...myself,
-		});
-	}
-
-	close() {
-		this.signals.close();
-		this.window.close();
-		this.peers.close();
-	}
-}
diff --git a/js/publish/src/location/peers.ts b/js/publish/src/location/peers.ts
deleted file mode 100644
index 6c8915d8e2..0000000000
--- a/js/publish/src/location/peers.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import * as Zod from "@moq/net/zod";
-import { Effect, Signal } from "@moq/signals";
-
-export interface PeersProps {
-	enabled?: boolean | Signal<boolean>;
-	positions?: Record<string, Catalog.Position> | Signal<Record<string, Catalog.Position>>;
-}
-
-export class Peers {
-	static readonly TRACK = "location/peers.json";
-	static readonly PRIORITY = Catalog.PRIORITY.location;
-
-	enabled: Signal<boolean>;
-	positions = new Signal<Record<string, Catalog.Position>>({});
-
-	catalog = new Signal<Catalog.Track | undefined>(undefined);
-	signals = new Effect();
-
-	constructor(props?: PeersProps) {
-		this.enabled = Signal.from(props?.enabled ?? false);
-		this.positions = Signal.from(props?.positions ?? {});
-
-		this.signals.run((effect) => {
-			const enabled = effect.get(this.enabled);
-			if (!enabled) return;
-
-			effect.set(this.catalog, { name: Peers.TRACK });
-		});
-	}
-
-	serve(track: Moq.Track, effect: Effect): void {
-		const values = effect.getAll([this.enabled, this.positions]);
-		if (!values) return;
-		const [_, positions] = values;
-
-		Zod.write(track, positions, Catalog.PeersSchema);
-	}
-
-	close() {
-		this.signals.close();
-	}
-}
diff --git a/js/publish/src/location/window.ts b/js/publish/src/location/window.ts
deleted file mode 100644
index 41b549249b..0000000000
--- a/js/publish/src/location/window.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import * as Zod from "@moq/net/zod";
-import { Effect, Signal } from "@moq/signals";
-
-export type WindowProps = {
-	// If true, then we'll publish our position to the broadcast.
-	enabled?: boolean | Signal<boolean>;
-
-	// Our current position.
-	position?: Catalog.Position | Signal<Catalog.Position | undefined>;
-
-	// If set, then this broadcaster allows other peers to request position updates via this handle.
-	handle?: string | Signal<string | undefined>;
-};
-
-export class Window {
-	static readonly TRACK = "location/window.json";
-	static readonly PRIORITY = Catalog.PRIORITY.location;
-
-	enabled: Signal<boolean>;
-	position: Signal<Catalog.Position | undefined>;
-	handle: Signal<string | undefined>; // Allow other peers to request position updates via this handle.
-
-	catalog = new Signal<Catalog.Location | undefined>(undefined);
-
-	signals = new Effect();
-
-	constructor(props?: WindowProps) {
-		this.enabled = Signal.from(props?.enabled ?? false);
-		this.position = Signal.from(props?.position ?? undefined);
-		this.handle = Signal.from(props?.handle ?? undefined);
-
-		this.signals.run((effect) => {
-			const enabled = effect.get(this.enabled);
-			if (!enabled) return;
-
-			effect.set(this.catalog, {
-				initial: this.position.peek(),
-				track: { name: Window.TRACK },
-				handle: effect.get(this.handle),
-			});
-		});
-	}
-
-	serve(track: Moq.Track, effect: Effect): void {
-		const values = effect.getAll([this.enabled, this.position]);
-		if (!values) return;
-		const [_, position] = values;
-
-		Zod.write(track, position, Catalog.PositionSchema);
-	}
-
-	close() {
-		this.signals.close();
-	}
-}
diff --git a/js/publish/src/preview.ts b/js/publish/src/preview.ts
deleted file mode 100644
index f564f9fa50..0000000000
--- a/js/publish/src/preview.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import { Effect, Signal } from "@moq/signals";
-
-export type PreviewProps = {
-	enabled?: boolean | Signal<boolean>;
-	info?: Catalog.Preview | Signal<Catalog.Preview | undefined>;
-};
-
-export class Preview {
-	static readonly TRACK = "preview.json";
-	static readonly PRIORITY = Catalog.PRIORITY.preview;
-
-	enabled: Signal<boolean>;
-	info: Signal<Catalog.Preview | undefined>;
-
-	catalog = new Signal<Catalog.Track | undefined>(undefined);
-
-	signals = new Effect();
-
-	constructor(props?: PreviewProps) {
-		this.enabled = Signal.from(props?.enabled ?? false);
-		this.info = Signal.from(props?.info);
-
-		this.signals.run((effect) => {
-			if (!effect.get(this.enabled)) return;
-			effect.set(this.catalog, { name: Preview.TRACK });
-		});
-	}
-
-	serve(track: Moq.Track, effect: Effect): void {
-		const values = effect.getAll([this.enabled, this.info]);
-		if (!values) return;
-		const [_, info] = values;
-
-		track.writeJson(info);
-	}
-
-	close() {
-		this.signals.close();
-	}
-}
diff --git a/js/publish/src/user.ts b/js/publish/src/user.ts
deleted file mode 100644
index 417b197cef..0000000000
--- a/js/publish/src/user.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import type * as Catalog from "@moq/hang/catalog";
-import { Effect, Signal } from "@moq/signals";
-
-export type Props = {
-	enabled?: boolean | Signal<boolean>;
-	id?: string | Signal<string | undefined>;
-	name?: string | Signal<string | undefined>;
-	avatar?: string | Signal<string | undefined>;
-	color?: string | Signal<string | undefined>;
-};
-
-export class Info {
-	enabled: Signal<boolean>;
-
-	id: Signal<string | undefined>;
-	name: Signal<string | undefined>;
-	avatar: Signal<string | undefined>;
-	color: Signal<string | undefined>;
-
-	catalog = new Signal<Catalog.User | undefined>(undefined);
-
-	signals = new Effect();
-
-	constructor(props?: Props) {
-		this.enabled = Signal.from(props?.enabled ?? false);
-		this.id = Signal.from(props?.id);
-		this.name = Signal.from(props?.name);
-		this.avatar = Signal.from(props?.avatar);
-		this.color = Signal.from(props?.color);
-
-		this.signals.run((effect) => {
-			if (!effect.get(this.enabled)) return;
-
-			effect.set(this.catalog, {
-				id: effect.get(this.id),
-				name: effect.get(this.name),
-				avatar: effect.get(this.avatar),
-				color: effect.get(this.color),
-			});
-		});
-	}
-
-	close() {
-		this.signals.close();
-	}
-}
diff --git a/js/watch/src/chat/index.ts b/js/watch/src/chat/index.ts
deleted file mode 100644
index 55d349c5a3..0000000000
--- a/js/watch/src/chat/index.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import type * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals";
-import { Message, type MessageInput } from "./message";
-import { Typing, type TypingInput } from "./typing";
-
-// Signals the component reads. Whoever owns the backing Signal does the writing.
-type ChatInput = {
-	broadcast: Getter<Moq.Broadcast | undefined>;
-	catalog: Getter<Catalog.Root | undefined>;
-};
-
-type ChatOutput = {
-	catalog: Signal<Catalog.Chat | undefined>;
-};
-
-export class Chat {
-	readonly input: Readonlys<ChatInput>;
-
-	readonly #output: ChatOutput = {
-		catalog: new Signal<Catalog.Chat | undefined>(undefined),
-	};
-	readonly output = readonlys(this.#output);
-
-	message: Message;
-	typing: Typing;
-
-	#signals = new Effect();
-
-	constructor(props?: Inputs<ChatInput> & { message?: Inputs<MessageInput>; typing?: Inputs<TypingInput> }) {
-		this.input = {
-			broadcast: getter(props?.broadcast),
-			catalog: getter(props?.catalog),
-		};
-
-		this.message = new Message({
-			...props?.message,
-			broadcast: this.input.broadcast,
-			catalog: this.input.catalog,
-		});
-		this.typing = new Typing({
-			...props?.typing,
-			broadcast: this.input.broadcast,
-			catalog: this.input.catalog,
-		});
-
-		// Grab the chat section from the catalog (if it's changed).
-		this.#signals.run((effect) => {
-			const message = effect.get(this.message.output.catalog);
-			const typing = effect.get(this.typing.output.catalog);
-			if (!message && !typing) return;
-
-			effect.set(this.#output.catalog, {
-				message,
-				typing,
-			});
-		});
-	}
-
-	close() {
-		this.#signals.close();
-		this.message.close();
-		this.typing.close();
-	}
-}
diff --git a/js/watch/src/chat/message.ts b/js/watch/src/chat/message.ts
deleted file mode 100644
index fdb84d202f..0000000000
--- a/js/watch/src/chat/message.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals";
-
-// Signals the component reads. Whoever owns the backing Signal does the writing.
-export type MessageInput = {
-	broadcast: Getter<Moq.Broadcast | undefined>;
-
-	// The catalog to grab the chat section from.
-	catalog: Getter<Catalog.Root | undefined>;
-
-	// Whether to start downloading the chat.
-	// Defaults to false so you can make sure everything is ready before starting.
-	enabled: Getter<boolean>;
-};
-
-type MessageOutput = {
-	// Empty string is a valid message.
-	latest: Signal<string | undefined>;
-
-	catalog: Signal<Catalog.Track | undefined>;
-};
-
-export class Message {
-	readonly input: Readonlys<MessageInput>;
-
-	readonly #output: MessageOutput = {
-		latest: new Signal<string | undefined>(undefined),
-		catalog: new Signal<Catalog.Track | undefined>(undefined),
-	};
-	readonly output = readonlys(this.#output);
-
-	#signals = new Effect();
-
-	constructor(props?: Inputs<MessageInput>) {
-		this.input = {
-			broadcast: getter(props?.broadcast),
-			catalog: getter(props?.catalog),
-			enabled: getter(props?.enabled ?? false),
-		};
-
-		// Grab the chat section from the catalog (if it's changed).
-		this.#signals.run((effect) => {
-			if (!effect.get(this.input.enabled)) return;
-			this.#output.catalog.set(effect.get(this.input.catalog)?.chat?.message);
-		});
-
-		this.#signals.run(this.#run.bind(this));
-	}
-
-	#run(effect: Effect) {
-		const values = effect.getAll([this.input.enabled, this.#output.catalog, this.input.broadcast]);
-		if (!values) return;
-		const [_, catalog, broadcast] = values;
-
-		const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.chat);
-		effect.cleanup(() => track.close());
-
-		// Undefined is only when we're not subscribed to the track.
-		effect.set(this.#output.latest, "");
-		effect.cleanup(() => this.#output.latest.set(undefined));
-
-		effect.spawn(async () => {
-			for (;;) {
-				const frame = await track.readString();
-				if (frame === undefined) break;
-
-				// Use a function to avoid the dequal check.
-				this.#output.latest.set(frame);
-			}
-		});
-	}
-
-	close() {
-		this.#signals.close();
-	}
-}
diff --git a/js/watch/src/chat/typing.ts b/js/watch/src/chat/typing.ts
deleted file mode 100644
index b640edef15..0000000000
--- a/js/watch/src/chat/typing.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals";
-
-// Signals the component reads. Whoever owns the backing Signal does the writing.
-export type TypingInput = {
-	broadcast: Getter<Moq.Broadcast | undefined>;
-
-	// The catalog to grab the chat section from.
-	catalog: Getter<Catalog.Root | undefined>;
-
-	// Whether to start downloading the chat.
-	// Defaults to false so you can make sure everything is ready before starting.
-	enabled: Getter<boolean>;
-};
-
-type TypingOutput = {
-	active: Signal<boolean | undefined>;
-
-	catalog: Signal<Catalog.Track | undefined>;
-};
-
-export class Typing {
-	readonly input: Readonlys<TypingInput>;
-
-	readonly #output: TypingOutput = {
-		active: new Signal<boolean | undefined>(undefined),
-		catalog: new Signal<Catalog.Track | undefined>(undefined),
-	};
-	readonly output = readonlys(this.#output);
-
-	#signals = new Effect();
-
-	constructor(props?: Inputs<TypingInput>) {
-		this.input = {
-			broadcast: getter(props?.broadcast),
-			catalog: getter(props?.catalog),
-			enabled: getter(props?.enabled ?? false),
-		};
-
-		// Grab the chat section from the catalog (if it's changed).
-		this.#signals.run((effect) => {
-			if (!effect.get(this.input.enabled)) return;
-			this.#output.catalog.set(effect.get(this.input.catalog)?.chat?.typing);
-		});
-
-		this.#signals.run(this.#run.bind(this));
-	}
-
-	#run(effect: Effect) {
-		const values = effect.getAll([this.input.enabled, this.#output.catalog, this.input.broadcast]);
-		if (!values) return;
-		const [_, catalog, broadcast] = values;
-
-		const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.typing);
-		effect.cleanup(() => track.close());
-
-		effect.spawn(async () => {
-			for (;;) {
-				const value = await track.readBool();
-				if (value === undefined) break;
-
-				this.#output.active.set(value);
-			}
-		});
-
-		effect.cleanup(() => this.#output.active.set(undefined));
-	}
-
-	close() {
-		this.#signals.close();
-	}
-}
diff --git a/js/watch/src/index.ts b/js/watch/src/index.ts
index f686caffb7..6103f721f7 100644
--- a/js/watch/src/index.ts
+++ b/js/watch/src/index.ts
@@ -6,12 +6,8 @@ export * as Signals from "@moq/signals";
 export * as Audio from "./audio";
 export * from "./backend";
 export * from "./broadcast";
-export * as Chat from "./chat";
-export * as Location from "./location";
 export * as Mse from "./mse";
-export * from "./preview";
 export * from "./sync";
-export * as User from "./user";
 export * as Video from "./video";
 
 // NOTE: element is not exported from this module
diff --git a/js/watch/src/location/index.ts b/js/watch/src/location/index.ts
deleted file mode 100644
index 676b91f209..0000000000
--- a/js/watch/src/location/index.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import { Effect, type Getter, getter, type Inputs, type Readonlys } from "@moq/signals";
-import { Peers, type PeersInput } from "./peers";
-import { Window, type WindowInput } from "./window";
-
-type RootInput = {
-	broadcast: Getter<Moq.Broadcast | undefined>;
-	catalog: Getter<Catalog.Root | undefined>;
-};
-
-export class Root {
-	readonly input: Readonlys<RootInput>;
-
-	window: Window;
-	peers: Peers;
-
-	signals = new Effect();
-
-	constructor(props?: Inputs<RootInput> & { window?: Inputs<WindowInput>; peers?: Inputs<PeersInput> }) {
-		this.input = {
-			broadcast: getter(props?.broadcast),
-			catalog: getter(props?.catalog),
-		};
-
-		this.window = new Window({ ...props?.window, broadcast: this.input.broadcast, catalog: this.input.catalog });
-		this.peers = new Peers({ ...props?.peers, broadcast: this.input.broadcast, catalog: this.input.catalog });
-	}
-
-	close() {
-		this.signals.close();
-		this.window.close();
-		this.peers.close();
-	}
-}
diff --git a/js/watch/src/location/peers.ts b/js/watch/src/location/peers.ts
deleted file mode 100644
index d09f21f38b..0000000000
--- a/js/watch/src/location/peers.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import * as Zod from "@moq/net/zod";
-import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals";
-
-export type PeersInput = {
-	broadcast: Getter<Moq.Broadcast | undefined>;
-	catalog: Getter<Catalog.Root | undefined>;
-	enabled: Getter<boolean>;
-};
-
-type PeersOutput = {
-	positions: Signal<Record<string, Catalog.Position> | undefined>;
-};
-
-export class Peers {
-	readonly input: Readonlys<PeersInput>;
-
-	readonly #output: PeersOutput = {
-		positions: new Signal<Record<string, Catalog.Position> | undefined>(undefined),
-	};
-	readonly output = readonlys(this.#output);
-
-	#catalog = new Signal<Catalog.Track | undefined>(undefined);
-
-	signals = new Effect();
-
-	constructor(props?: Inputs<PeersInput>) {
-		this.input = {
-			broadcast: getter(props?.broadcast),
-			catalog: getter(props?.catalog),
-			enabled: getter(props?.enabled ?? false),
-		};
-
-		this.signals.run((effect) => {
-			this.#catalog.set(effect.get(this.input.catalog)?.location?.peers);
-		});
-
-		this.signals.run(this.#run.bind(this));
-	}
-
-	#run(effect: Effect) {
-		const values = effect.getAll([this.input.enabled, this.#catalog, this.input.broadcast]);
-		if (!values) return;
-		const [_, catalog, broadcast] = values;
-
-		const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.location);
-		effect.cleanup(() => track.close());
-
-		effect.spawn(this.#runTrack.bind(this, track));
-	}
-
-	async #runTrack(track: Moq.Track) {
-		try {
-			for (;;) {
-				const frame = await Zod.read(track, Catalog.PeersSchema);
-				if (!frame) break;
-
-				this.#output.positions.set(frame);
-			}
-		} finally {
-			this.#output.positions.set(undefined);
-			track.close();
-		}
-	}
-
-	close() {
-		this.signals.close();
-	}
-}
diff --git a/js/watch/src/location/window.ts b/js/watch/src/location/window.ts
deleted file mode 100644
index 1878bd084e..0000000000
--- a/js/watch/src/location/window.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import * as Zod from "@moq/net/zod";
-import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals";
-
-export type WindowInput = {
-	broadcast: Getter<Moq.Broadcast | undefined>;
-	catalog: Getter<Catalog.Root | undefined>;
-	enabled: Getter<boolean>;
-};
-
-type WindowOutput = {
-	handle: Signal<string | undefined>;
-	position: Signal<Catalog.Position | undefined>;
-};
-
-export class Window {
-	readonly input: Readonlys<WindowInput>;
-
-	readonly #output: WindowOutput = {
-		handle: new Signal<string | undefined>(undefined),
-		position: new Signal<Catalog.Position | undefined>(undefined),
-	};
-	readonly output = readonlys(this.#output);
-
-	#catalog = new Signal<Catalog.Location | undefined>(undefined);
-
-	signals = new Effect();
-
-	constructor(props?: Inputs<WindowInput>) {
-		this.input = {
-			broadcast: getter(props?.broadcast),
-			catalog: getter(props?.catalog),
-			enabled: getter(props?.enabled ?? false),
-		};
-
-		this.signals.run((effect) => {
-			this.#catalog.set(effect.get(this.input.catalog)?.location);
-		});
-
-		this.signals.run((effect) => {
-			if (!effect.get(this.input.enabled)) return;
-			this.#output.position.set(effect.get(this.#catalog)?.initial);
-		});
-
-		this.signals.run((effect) => {
-			this.#output.handle.set(effect.get(this.#catalog)?.handle);
-		});
-
-		this.signals.run((effect) => {
-			const broadcast = effect.get(this.input.broadcast);
-			if (!broadcast) return;
-
-			const updates = effect.get(this.#catalog)?.track;
-			if (!updates) return;
-
-			const track = broadcast.subscribe(updates.name, Catalog.PRIORITY.location);
-			effect.cleanup(() => track.close());
-
-			effect.spawn(this.#runTrack.bind(this, track));
-		});
-	}
-
-	async #runTrack(track: Moq.Track) {
-		try {
-			for (;;) {
-				const position = await Zod.read(track, Catalog.PositionSchema);
-				if (!position) break;
-
-				this.#output.position.set(position);
-			}
-		} finally {
-			this.#output.position.set(undefined);
-			track.close();
-		}
-	}
-
-	close() {
-		this.signals.close();
-	}
-}
diff --git a/js/watch/src/preview.ts b/js/watch/src/preview.ts
deleted file mode 100644
index d2d8a22e84..0000000000
--- a/js/watch/src/preview.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import * as Catalog from "@moq/hang/catalog";
-import type * as Moq from "@moq/net";
-import * as Zod from "@moq/net/zod";
-import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals";
-
-type PreviewInput = {
-	enabled: Getter<boolean>;
-	broadcast: Getter<Moq.Broadcast | undefined>;
-	catalog: Getter<Catalog.Root | undefined>;
-};
-
-type PreviewOutput = {
-	preview: Signal<Catalog.Preview | undefined>;
-};
-
-export class Preview {
-	readonly input: Readonlys<PreviewInput>;
-
-	readonly #output: PreviewOutput = {
-		preview: new Signal<Catalog.Preview | undefined>(undefined),
-	};
-	readonly output = readonlys(this.#output);
-
-	#catalog = new Signal<Catalog.Track | undefined>(undefined);
-
-	#signals = new Effect();
-
-	constructor(props?: Inputs<PreviewInput>) {
-		this.input = {
-			enabled: getter(props?.enabled ?? false),
-			broadcast: getter(props?.broadcast),
-			catalog: getter(props?.catalog),
-		};
-
-		this.#signals.run((effect) => {
-			this.#catalog.set(effect.get(this.input.catalog)?.preview);
-		});
-
-		this.#signals.run((effect) => {
-			const values = effect.getAll([this.input.enabled, this.input.broadcast, this.#catalog]);
-			if (!values) return;
-			const [_, broadcast, catalog] = values;
-
-			// Subscribe to the preview.json track directly
-			const track = broadcast.subscribe(catalog.name, Catalog.PRIORITY.preview);
-			effect.cleanup(() => track.close());
-
-			effect.spawn(async () => {
-				try {
-					const info = await Zod.read(track, Catalog.PreviewSchema);
-					if (!info) return;
-
-					this.#output.preview.set(info);
-				} catch (error) {
-					console.warn("Failed to parse preview JSON:", error);
-				}
-			});
-
-			effect.cleanup(() => this.#output.preview.set(undefined));
-		});
-	}
-
-	close() {
-		this.#signals.close();
-	}
-}
diff --git a/js/watch/src/user.ts b/js/watch/src/user.ts
deleted file mode 100644
index 65a914e7a3..0000000000
--- a/js/watch/src/user.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type * as Catalog from "@moq/hang/catalog";
-import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals";
-
-type InfoInput = {
-	enabled: Getter<boolean>;
-	catalog: Getter<Catalog.Root | undefined>;
-};
-
-type InfoOutput = {
-	id: Signal<string | undefined>;
-	name: Signal<string | undefined>;
-	avatar: Signal<string | undefined>;
-	color: Signal<string | undefined>;
-};
-
-export class Info {
-	readonly input: Readonlys<InfoInput>;
-
-	readonly #output: InfoOutput = {
-		id: new Signal<string | undefined>(undefined),
-		name: new Signal<string | undefined>(undefined),
-		avatar: new Signal<string | undefined>(undefined),
-		color: new Signal<string | undefined>(undefined),
-	};
-	readonly output = readonlys(this.#output);
-
-	signals = new Effect();
-
-	constructor(props?: Inputs<InfoInput>) {
-		this.input = {
-			enabled: getter(props?.enabled ?? false),
-			catalog: getter(props?.catalog),
-		};
-
-		this.signals.run((effect) => {
-			if (!effect.get(this.input.enabled)) return;
-
-			this.#output.id.set(effect.get(this.input.catalog)?.user?.id);
-			this.#output.name.set(effect.get(this.input.catalog)?.user?.name);
-			this.#output.avatar.set(effect.get(this.input.catalog)?.user?.avatar);
-			this.#output.color.set(effect.get(this.input.catalog)?.user?.color);
-		});
-	}
-
-	close() {
-		this.signals.close();
-	}
-}
diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs
index 7a4d5bce97..5ad80fa118 100644
--- a/rs/hang/src/catalog/root.rs
+++ b/rs/hang/src/catalog/root.rs
@@ -4,6 +4,24 @@ use crate::catalog::{Audio, Video};
 use serde::{Deserialize, Serialize};
 
 /// A catalog track, created by a broadcaster to describe the tracks available in a broadcast.
+///
+/// This is the base catalog: just the media tracks every hang broadcast carries. Applications
+/// layer their own sections on top by flattening it into their own type, e.g.
+///
+/// ```
+/// # use serde::{Serialize, Deserialize};
+/// #[derive(Serialize, Deserialize)]
+/// struct AppCatalog {
+///     #[serde(flatten)]
+///     base: hang::Catalog,
+///     scte35: Option<serde_json::Value>,
+/// }
+/// ```
+///
+/// and feeding that type to [`moq_json`](https://docs.rs/moq-json)'s producer/consumer to publish
+/// and subscribe with the same snapshot/delta semantics as the base catalog. The base catalog
+/// ignores unknown sections, so an extended catalog stays readable by a base consumer.
+/// App-specific sections (chat, user, location, ...) live in the application layer, not here.
 #[serde_with::serde_as]
 #[serde_with::skip_serializing_none]
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
@@ -245,4 +263,48 @@ mod test {
 		let output = catalog.to_string().expect("failed to encode");
 		assert_eq!(encoded, output, "encode mismatch");
 	}
+
+	/// Lock in the extension pattern: an application flattens the base catalog into its own type
+	/// and adds typed sections. The extension rides alongside the base fields in one JSON object,
+	/// and a base [`Catalog`] consumer still reads it, ignoring the unknown section.
+	#[test]
+	fn extension_roundtrip() {
+		use serde::{Deserialize, Serialize};
+
+		#[serde_with::skip_serializing_none]
+		#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
+		#[serde(default, rename_all = "camelCase")]
+		struct Scte35 {
+			track: String,
+			splice_count: Option<u32>,
+		}
+
+		#[serde_with::skip_serializing_none]
+		#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
+		#[serde(default, rename_all = "camelCase")]
+		struct AppCatalog {
+			#[serde(flatten)]
+			base: Catalog,
+			scte35: Option<Scte35>,
+		}
+
+		let app = AppCatalog {
+			base: Catalog::default(),
+			scte35: Some(Scte35 {
+				track: "splice.json".to_string(),
+				splice_count: Some(2),
+			}),
+		};
+
+		let encoded = serde_json::to_string(&app).expect("failed to encode");
+		assert!(encoded.contains(r#""scte35":{"track":"splice.json","spliceCount":2}"#));
+
+		// Round-trips through the application type.
+		let decoded: AppCatalog = serde_json::from_str(&encoded).expect("failed to decode");
+		assert_eq!(app, decoded);
+
+		// A base consumer reads the same bytes, ignoring the unknown section.
+		let base = Catalog::from_str(&encoded).expect("base failed to decode");
+		assert_eq!(base, Catalog::default());
+	}
 }

From 33a903d088d628d49ba616579178143eebed38dd Mon Sep 17 00:00:00 2001
From: Luke Curley <kixelated@gmail.com>
Date: Mon, 8 Jun 2026 10:54:43 -0700
Subject: [PATCH 2/2] chore(deny): ignore RUSTSEC-2026-0173 (proc-macro-error2
 unmaintained)

Freshly published advisory failing CI repo-wide, unrelated to the catalog
change. proc-macro-error2 is pulled transitively via foundations (quiche)
in moq-native; it's a build-time-only proc-macro with no safe upgrade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 deny.toml | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/deny.toml b/deny.toml
index b3548456fe..28005d4698 100644
--- a/deny.toml
+++ b/deny.toml
@@ -29,6 +29,11 @@ ignore = [
     # http-cache (via http-cache-reqwest in moq-relay) still depends on
     # bincode 1.x. Awaits upstream bump to bincode 2.x.
     "RUSTSEC-2025-0141",
+
+    # proc-macro-error2 (unmaintained) via getset -> neli -> local-ip-address
+    # -> cf-rustracing-jaeger -> foundations, reached through quiche in
+    # moq-native. Build-time only proc-macro; no safe upgrade is available.
+    "RUSTSEC-2026-0173",
 ]
 
 [licenses]
