`) unless the kind's content is actually free-form user prose. Quote-type content (highlights, snippets, citations) contains URLs and hashtags from the *source*, not the event author — tokenizing them is misleading.
+- Render plaintext with `whitespace-pre-wrap break-words` inside a `` instead.
+- Route any event-sourced URLs (`r` tags, media URLs, source links) through `sanitizeUrl()` from `@/lib/sanitizeUrl` before using them in `href`/`src`.
+- Support an `expanded` prop if the card looks different on the detail page than in the feed.
+
+### 2. Feed card dispatch (`src/components/NoteCard.tsx`)
+
+Three edits in this file:
+
+1. **Flag block** (around lines 384–435): add `const isMyKind = event.kind === XXXX;`.
+2. **`isTextNote` negation list** (around lines 440–475): add `&& !isMyKind`. Without this, unknown kinds fall through to `UnknownKindContent` (showing only the `alt` tag).
+3. **Content dispatch ternary** (around lines 578–692): add `) : isMyKind ? (`.
+4. **`KIND_HEADER_MAP`** (around lines 1710+): add an entry so the feed shows "Alice shared a *noun*" or similar. Pattern:
+ ```ts
+ 9802: {
+ icon: Highlighter,
+ action: "shared a",
+ noun: "highlight",
+ nounRoute: "/highlights", // omit if no dedicated page
+ },
+ ```
+5. Import the card component and any new lucide icons.
+
+### 3. Detail page dispatch (`src/pages/PostDetailPage.tsx`)
+
+Mirror the three NoteCard edits:
+
+1. **Flag block** (around lines 1021–1098): `const isMyKind = event.kind === XXXX;`.
+2. **`isTextNote` negation list**: add `&& !isMyKind`.
+3. **Content dispatch ternary** (around lines 2147–2251): add `) : isMyKind ? (`.
+
+The loading-state title uses `shellTitleForKind()`, which falls through to `KIND_LABELS` — no override needed unless the kind belongs to a group ("Music Details") or needs composite grammar.
+
+### 4. Central kind label (`src/lib/kindLabels.ts`)
+
+Add a **capitalized noun phrase, no articles** to the `KIND_LABELS` map:
+
+```ts
+9802: 'Highlight',
+```
+
+This is consumed by the detail-page loading title, nsite permission prompt, signer nudge toasts, and addressable-event preview headers. Ignoring this gives "Kind 9802" everywhere it appears.
+
+### 5. Context-specific label and icon maps
+
+Each of these maps exists because the surrounding UI needs a different grammatical form. They are **not derived** from `KIND_LABELS` and must be updated manually.
+
+- **`src/components/CommentContext.tsx`** — `KIND_LABELS` (uses articles: `'a highlight'`, `'an article'`) and `KIND_ICONS` (lucide component reference). Rendered as "Commenting on {label}". Without an entry you get "an unsupported event".
+- **`src/pages/NotificationsPage.tsx`** — `NOTIFICATION_KIND_NOUNS` (bare lowercase nouns: `'highlight'`, `'article'`). Rendered as "reacted to your {noun}". Without an entry you get "post" as a fallback.
+- **`src/components/NoteCard.tsx`** — `KIND_HEADER_MAP` (already covered in step 2).
+
+### 6. Embedded previews (`src/components/EmbeddedNote.tsx`)
+
+The quote-embed dispatcher in `EmbeddedNote` (around lines 65–110) routes kinds to dedicated compact cards. **Without a branch here, non-content kinds fall through to `EmbeddedNoteCard`, which either:**
+
+- Shows only the NIP-31 `alt` tag (if present), or
+- Tombstones as "This event kind is not supported", or
+- **Feeds the event's `content` through the kind-1 tokenizer** if the kind is mistakenly treated as a content-kind — auto-linkifying URLs and hashtags that weren't authored by the event creator. This is a security/UX bug.
+
+For any kind whose `content` isn't freeform user prose, add an explicit dispatch branch even if it just renders a minimal compact card. Pattern:
+
+```tsx
+if (event.kind === 9802) {
+ return ;
+}
+```
+
+Then define the compact card using `EmbeddedCardShell` for the author row + navigation, and render the kind-specific body inside. See `EmbeddedHighlightCard` and `EmbeddedBadgeAwardCard` for reference.
+
+`src/components/EmbeddedNaddr.tsx` works similarly for addressable kinds — add a branch there if your kind is addressable.
+
+### 7. Feed/sidebar registration (`src/lib/extraKinds.ts`)
+
+Only needed if you decided on "feed-toggle + dedicated page" above. Add an `ExtraKindDef`:
+
+```ts
+{
+ kind: 9802,
+ id: 'highlights',
+ showKey: 'showHighlights',
+ feedKey: 'feedIncludeHighlights',
+ label: 'Highlights',
+ description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
+ route: 'highlights', // omit for feed-only registration
+ addressable: false,
+ section: 'social', // feed | media | social | development | whimsy
+ blurb: 'Longer marketing copy shown in the info modal.',
+},
+```
+
+Then:
+
+- **Sidebar icon** (`src/lib/sidebarItems.tsx`) — add `{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter }` to `SIDEBAR_ITEMS`, and import the icon at the top. `CONTENT_KIND_ICONS` picks up the icon automatically from the sidebar definition.
+- **Route** (`src/AppRouter.tsx`) — add `const highlightsDef = getExtraKindDef("highlights")!;` at the top of the file and a `} />` above the catch-all `*` route.
+
+### 8. `AppConfig` triple (required if you added feed/sidebar toggle keys in step 7)
+
+Three files must stay in sync, or the build fails or the setting silently no-ops:
+
+1. **`src/contexts/AppContext.ts`** — add the fields to the `FeedSettings` interface with JSDoc comments.
+2. **`src/lib/schemas.ts`** — add the same fields to `FeedSettingsSchema` as `z.boolean().optional()`. `DittoConfigSchema` is derived from `AppConfigSchema` with `.strict()` mode, so any `ditto.json` field missing from Zod is a build error.
+3. **`src/App.tsx`** — add the default value in the initial `feedSettings` block.
+4. **`src/test/TestApp.tsx`** — mirror the default in test config so component tests work.
+
+Convention: `show*` toggles default to `true` (sidebar entries visible), `feedInclude*` toggles default to `false` for niche content, `true` for core feed content.
+
+### 9. Notification integration (if applicable)
+
+Load this step when the kind represents an interaction with the user's content (reactions, reposts, highlights, awards, etc.) — i.e. when an event author "does something with" another user's content via an `e`/`a`/`p` tag.
+
+**Six files** to update:
+
+1. **`src/hooks/useEncryptedSettings.ts`** — add `highlights?: boolean` (or equivalent) to the `notificationPreferences` object.
+2. **`src/lib/notificationKinds.ts`** — add the kind to `ALL_NOTIFICATION_KINDS` and add a `if (p.X !== false) kinds.push(XXXX);` line in `getEnabledNotificationKinds`.
+3. **`src/lib/notificationTemplates.ts`** — add a `NOTIFICATION_TEMPLATES` entry with a title and body for nostr-push server-side notifications.
+4. **`src/pages/NotificationSettings.tsx`** — extend `NotificationPrefKey` union, add a row to `NOTIFICATION_TYPES` with icon/label/description/kinds.
+5. **`src/hooks/useNotifications.ts`** — extend `groupKey` (decide if events of this kind group by referenced event or stand alone), and if it's a "did something to your content" kind, add it to the author-ownership filter so users only get notified for interactions with their own content.
+6. **`src/pages/NotificationsPage.tsx`** — add a case to `GroupedNotificationView`'s switch; write `MyKindNotification` + `MyKindNotificationGroup` components modeled on `RepostNotification` / `LikeNotification`.
+
+### 10. Spam guards (`src/lib/feedUtils.ts`)
+
+If the kind has required tags (NIP-spec-mandated references, minimum content, etc.), add a check in `shouldHideFeedEvent` to hide events that don't meet the minimum bar. This pre-filters events before `NoteCard` mounts them, avoiding layout shifts from components that would return `null`.
+
+Example:
+
+```ts
+// NIP-84 highlights with no excerpt AND no source reference.
+if (event.kind === 9802) {
+ const hasContent = event.content.trim().length > 0;
+ const hasSource = event.tags.some(([n]) => n === 'a' || n === 'e' || n === 'r');
+ if (!hasContent && !hasSource) return true;
+}
+```
+
+### 11. `NIP.md` (custom kinds only)
+
+If the kind is a Ditto-custom kind or a Ditto-specific extension of an existing NIP, document it in `NIP.md` — see the **`nostr-kind-design`** skill for the format. Standard NIPs (like NIP-84, NIP-23) do not go in `NIP.md`.
+
+## Validation
+
+After making changes, run `npm run test` — it runs `tsc --noEmit`, `eslint`, `vitest`, and `vite build` in sequence. All must pass. Additions to the `AppConfig` triple in particular frequently break the build if one of the four files is missed.
+
+## Why so many locations?
+
+These are genuinely different UI contexts (feed cards, detail pages, embedded previews, comment-context labels, notifications, sidebar routes) with different rendering requirements and grammar needs. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common "what to call this kind" case, but feed headers, comment-context text, and notification verbs each need their own grammar, and notification integration involves a whole independent subsystem.
+
+## Bugs that signal a missed step
+
+- **"Kind 12345" shown as a label** → step 4 (`KIND_LABELS`).
+- **"an unsupported event" in CommentContext** → step 5 (`CommentContext` maps).
+- **"reacted to your **post**"** when it should say "highlight" → step 5 (`NOTIFICATION_KIND_NOUNS`).
+- **No action header above a feed card** → step 2.4 (`KIND_HEADER_MAP`).
+- **Blank / `alt`-only card in quote embeds** → step 6 (`EmbeddedNote` dispatcher).
+- **URLs/hashtags in quoted text auto-linkified** → step 6 (embedded dispatcher forgot to bypass the kind-1 tokenizer).
+- **Kind doesn't appear in the home feed even with the toggle on** → step 7 (`ExtraKindDef` missing `feedKey`).
+- **Build error mentioning a missing `FeedSettings` field** → step 8 (one of the three files out of sync).
+- **Users not notified when their content is interacted with** → step 9 (notification stack).
diff --git a/.agents/skills/nostr-kinds/SKILL.md b/.agents/skills/nostr-kinds/SKILL.md
deleted file mode 100644
index f76509dd..00000000
--- a/.agents/skills/nostr-kinds/SKILL.md
+++ /dev/null
@@ -1,119 +0,0 @@
----
-name: nostr-kinds
-description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and register a new kind in Ditto's many UI touchpoints (feed cards, detail pages, embedded previews, kind-label maps).
----
-
-# Nostr Kinds — Design and Registration
-
-Use this skill when introducing a new kind to Ditto, extending an existing NIP with new tags, or deciding whether an existing NIP covers a feature. It covers the decision framework, schema rules, and — critically — the full list of places a new kind must be registered in Ditto's UI.
-
-## Choosing Between Existing NIPs and Custom Kinds
-
-1. **Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
-2. **Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
-3. **When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
-4. **Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
-5. **If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
-6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
-
-**Example decision:**
-
-```
-Need: Equipment marketplace for farmers
-Options:
- 1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
- 2. NIP-99 (Classifieds) — good fit, extensible with farming tags
- 3. Custom kind — perfect fit, no interoperability
-
-Decision: NIP-99 + farming-specific tags.
-```
-
-## Kind Ranges
-
-An event's kind number determines storage semantics:
-
-- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
-- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
-- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
-
-Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
-
-## Tag Design Principles
-
-- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
-- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
-- **Filter at the relay**, not in JavaScript:
-
- ```ts
- // ❌ Fetch everything, filter locally
- const events = await nostr.query([{ kinds: [30402] }]);
- const filtered = events.filter((e) => hasTag(e, 'product_type', 'electronics'));
-
- // ✅ Filter at the relay
- const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
- ```
-
-- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
-
-## Content vs. Tags
-
-- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
-- **Tags** — queryable metadata, structured data, anything you might filter on.
-- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
-- **If you need to filter by a field, it must be a tag** — relays don't index content.
-
-```json
-// ✅ Queryable
-{ "kind": 30402, "content": "",
- "tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
-
-// ❌ Structured data buried in content
-{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250}", "tags": [["d", "product-123"]] }
-```
-
-## `NIP.md`
-
-`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
-
-## Registering a New Kind in the Ditto UI
-
-When adding support for a new kind, the kind must be registered in **multiple locations** or it will render incorrectly in certain views (blank content in quote posts, "Kind 12345" as a label, missing action headers, etc.).
-
-### Checklist
-
-1. **Content card component** (`src/components/`) — create `` that renders the event's tags/content appropriately.
-
-2. **Feed rendering** (`src/components/NoteCard.tsx`):
- - Add `const isMyKind = event.kind === XXXX;`.
- - Include it in the appropriate group flag (e.g. `isDevKind`) or the `isTextNote` exclusion list.
- - Add the content dispatch: `isMyKind ? : …`.
- - Add an entry to `KIND_HEADER_MAP` for the action header (e.g. "deployed an nsite").
- - Import the new component and any new icons (e.g. `Globe` from `lucide-react`).
-
-3. **Detail page** (`src/pages/PostDetailPage.tsx`):
- - Mirror the `isMyKind` detection and group/exclusion flags from `NoteCard`.
- - Add the content dispatch for the detail view.
- - `shellTitleForKind()` falls through to the central `KIND_LABELS` registry, so adding a label there is sufficient for the loading-state title. Only add a manual override in `shellTitleForKind()` if the kind belongs to a group (e.g. music kinds → "Track Details") or needs a composite label (e.g. "Badge Collection").
- - Import the new component.
-
-4. **Feed registration** (`src/lib/extraKinds.ts`):
- - Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry.
-
-5. **Central kind label registry** (`src/lib/kindLabels.ts`):
- - Add an entry to the `KIND_LABELS` map with a short, user-facing label (capitalized noun phrase, no articles).
- - This registry is the single source of truth for kind→label mappings and is consumed by the nsite permission prompt, signer nudge toasts, detail page loading titles, and addressable event preview headers.
- - Some UI contexts maintain **context-specific** label maps that cannot use the central registry directly (they need different grammar):
- - `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` — uses articles ("a post", "an article") for "Commenting on {label}" text.
- - `NOTIFICATION_KIND_NOUNS` in `src/pages/NotificationsPage.tsx` — uses bare lowercase nouns for notification action text.
- - `KIND_HEADER_MAP` in `src/components/NoteCard.tsx` — uses action verbs + nouns for feed headers.
- - These context-specific maps must also be updated when adding a new kind.
-
-6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) — small preview cards shown inside quote posts, reply-context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal preview (author + title/content + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags (e.g. kind 20 photos via `imeta` tags) may need attachment-indicator logic added to `EmbeddedNoteCard`.
-
- > Do not confuse these with the `compact` prop on `NoteCard` — that just hides action buttons on the full `NoteCard`. `EmbeddedNote`/`EmbeddedNaddr` are entirely different components.
-
-7. **Reply composer** (`src/components/ReplyComposeModal.tsx`) — `EmbeddedPost` delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components, so no per-kind registration is needed here.
-
-### Why so many places?
-
-These are genuinely different UI contexts (feed cards, detail pages, embedded previews, reply previews, comment-context labels) with different rendering requirements. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common case, but several contexts need grammar-specific maps (articles, verbs, lowercase nouns) that can't be derived mechanically. **When in doubt, grep the codebase for an existing kind number like `30617`** — you'll find every registration point you need to mirror.
diff --git a/AGENTS.md b/AGENTS.md
index 4431b3b2..79fc5596 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@ Ditto is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui,
- `/public/` — static assets.
- `App.tsx` — **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`, `NWCContext`. Read before editing; changes are rarely needed.
- `AppRouter.tsx` — React Router configuration.
-- `NIP.md` — custom kinds documented by this project (see the `nostr-kinds` skill).
+- `NIP.md` — custom kinds documented by this project (see the `nostr-kind-design` skill).
**Always read an existing file before modifying it.** Never overwrite `App.tsx`, `AppRouter.tsx`, or `NostrProvider` without first reading their contents.
@@ -54,7 +54,10 @@ By default `nostr` uses the app's connection pool (reads from one relay, publish
### Kinds, Tags, and NIP.md
-When introducing a new kind, extending an existing NIP with new tags, or registering a kind in the UI (feed cards, detail pages, embedded previews, kind-label maps), load the **`nostr-kinds`** skill. It covers the NIP-vs-custom-kind decision framework, kind ranges, tag design (single-letter indexed tags, content vs. tags), the `NIP.md` documentation requirement, and Ditto's multi-location UI registration checklist.
+Two skills split the work of working with kinds:
+
+- **`nostr-kind-design`** — load when minting a new kind, extending an existing NIP with new tags, or deciding whether an existing NIP covers a use case. Covers the NIP-vs-custom decision framework, kind ranges, tag design (single-letter indexed tags, content vs. tags), and the `NIP.md` documentation requirement.
+- **`nostr-kind-rendering`** — load when adding UI for an event kind Ditto doesn't yet display, when asked to "support" / "display" / "render" a specific NIP or kind number, or when a kind renders blank / as "Kind 12345" / as "This event kind is not supported". Covers Ditto's multi-location UI registration checklist — feed cards, detail pages, embedded previews, kind-label maps (`KIND_LABELS`, `KIND_HEADER_MAP`, `NOTIFICATION_KIND_NOUNS`, `CommentContext`), notifications, routes, and the `AppConfig` triple that must stay in sync.
Summary rules: