···11+---
22+name: lexicons
33+description: Reference for the AT Protocol lexicon system. Use when working with lexicons, adding new record types, or modifying AT Protocol schemas.
44+user-invocable: false
55+---
66+77+# Lexicon System
88+99+## Overview
1010+1111+Lexicons define the schema for AT Protocol records. This project has two namespaces:
1212+- **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.)
1313+- **`site.standard.*`** - Standard site lexicons for interoperability
1414+1515+The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`.
1616+1717+## Key Files
1818+1919+- **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons
2020+- **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained)
2121+- **`lexicons/build.ts`** - Builds TypeScript sources to JSON
2222+- **`lexicons/api/`** - Generated TypeScript types and client
2323+- **`package.json`** - Contains `lexgen` script
2424+2525+## Running Lexicon Generation
2626+2727+```bash
2828+npm run lexgen
2929+```
3030+3131+This runs:
3232+1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript
3333+2. `lex gen-api` - Generates TypeScript types from all JSON lexicons
3434+3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions
3535+3636+## Adding a New pub.leaflet Lexicon
3737+3838+### 1. Create the Source Definition
3939+4040+Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`):
4141+4242+```typescript
4343+import { LexiconDoc } from "@atproto/lexicon";
4444+4545+export const PubLeafletMyLexicon: LexiconDoc = {
4646+ lexicon: 1,
4747+ id: "pub.leaflet.myLexicon",
4848+ defs: {
4949+ main: {
5050+ type: "record", // or "object" for non-record types
5151+ key: "tid",
5252+ record: {
5353+ type: "object",
5454+ required: ["field1"],
5555+ properties: {
5656+ field1: { type: "string", maxLength: 1000 },
5757+ field2: { type: "integer", minimum: 0 },
5858+ optionalRef: { type: "ref", ref: "other.lexicon#def" },
5959+ },
6060+ },
6161+ },
6262+ // Additional defs for sub-objects
6363+ subType: {
6464+ type: "object",
6565+ properties: {
6666+ nested: { type: "string" },
6767+ },
6868+ },
6969+ },
7070+};
7171+```
7272+7373+### 2. Add to Build
7474+7575+Update `lexicons/build.ts`:
7676+7777+```typescript
7878+import { PubLeafletMyLexicon } from "./src/myLexicon";
7979+8080+const lexicons = [
8181+ // ... existing lexicons
8282+ PubLeafletMyLexicon,
8383+];
8484+```
8585+8686+### 3. Update lexgen Command (if needed)
8787+8888+If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`:
8989+9090+```json
9191+"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..."
9292+```
9393+9494+Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included.
9595+9696+### 4. Add to authFullPermissions (for record types)
9797+9898+If your lexicon is a record type that users should be able to create/update/delete, add it to the `authFullPermissions` permission set in `lexicons/src/authFullPermissions.ts`:
9999+100100+```typescript
101101+import { PubLeafletMyLexicon } from "./myLexicon";
102102+103103+// In the permissions collection array:
104104+collection: [
105105+ // ... existing lexicons
106106+ PubLeafletMyLexicon.id,
107107+],
108108+```
109109+110110+### 5. Regenerate Types
111111+112112+```bash
113113+npm run lexgen
114114+```
115115+116116+### 6. Use the Generated Types
117117+118118+```typescript
119119+import { PubLeafletMyLexicon } from "lexicons/api";
120120+121121+// Type for the record
122122+type MyRecord = PubLeafletMyLexicon.Record;
123123+124124+// Validation
125125+const result = PubLeafletMyLexicon.validateRecord(data);
126126+if (result.success) {
127127+ // result.value is typed
128128+}
129129+130130+// Type guard
131131+if (PubLeafletMyLexicon.isRecord(data)) {
132132+ // data is typed as Record
133133+}
134134+```
135135+136136+## Adding a New site.standard Lexicon
137137+138138+### 1. Create the JSON Definition
139139+140140+Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`):
141141+142142+```json
143143+{
144144+ "lexicon": 1,
145145+ "id": "site.standard.myType",
146146+ "defs": {
147147+ "main": {
148148+ "type": "record",
149149+ "key": "tid",
150150+ "record": {
151151+ "type": "object",
152152+ "required": ["field1"],
153153+ "properties": {
154154+ "field1": {
155155+ "type": "string",
156156+ "maxLength": 1000
157157+ }
158158+ }
159159+ }
160160+ }
161161+ }
162162+}
163163+```
164164+165165+### 2. Regenerate Types
166166+167167+```bash
168168+npm run lexgen
169169+```
170170+171171+The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files.
172172+173173+## Common Lexicon Patterns
174174+175175+### Referencing Other Lexicons
176176+177177+```typescript
178178+// Reference another lexicon's main def
179179+{ type: "ref", ref: "pub.leaflet.publication" }
180180+181181+// Reference a specific def within a lexicon
182182+{ type: "ref", ref: "pub.leaflet.publication#theme" }
183183+184184+// Reference within the same lexicon
185185+{ type: "ref", ref: "#myDef" }
186186+```
187187+188188+### Union Types
189189+190190+```typescript
191191+{
192192+ type: "union",
193193+ refs: [
194194+ "pub.leaflet.pages.linearDocument",
195195+ "pub.leaflet.pages.canvas",
196196+ ],
197197+}
198198+199199+// Open union (allows unknown types)
200200+{
201201+ type: "union",
202202+ closed: false, // default is true
203203+ refs: ["pub.leaflet.content"],
204204+}
205205+```
206206+207207+### Blob Types (for images/files)
208208+209209+```typescript
210210+{
211211+ type: "blob",
212212+ accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"]
213213+ maxSize: 1000000, // bytes
214214+}
215215+```
216216+217217+### Color Types
218218+219219+The project has color types defined:
220220+- `pub.leaflet.theme.color#rgb` / `#rgba`
221221+- `site.standard.theme.color#rgb` / `#rgba`
222222+223223+```typescript
224224+// In lexicons/src/theme.ts
225225+export const ColorUnion = {
226226+ type: "union",
227227+ refs: [
228228+ "pub.leaflet.theme.color#rgba",
229229+ "pub.leaflet.theme.color#rgb",
230230+ ],
231231+};
232232+```
233233+234234+## Normalization Between Formats
235235+236236+Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats:
237237+238238+```typescript
239239+import {
240240+ normalizeDocument,
241241+ normalizePublication,
242242+ isLeafletDocument,
243243+ isStandardDocument,
244244+ getDocumentPages,
245245+} from "lexicons/src/normalize";
246246+247247+// Normalize a document from either format
248248+const normalized = normalizeDocument(record);
249249+if (normalized) {
250250+ // normalized is always in site.standard.document format
251251+ console.log(normalized.title, normalized.site);
252252+253253+ // Get pages if content is pub.leaflet.content
254254+ const pages = getDocumentPages(normalized);
255255+}
256256+257257+// Normalize a publication
258258+const pub = normalizePublication(record);
259259+if (pub) {
260260+ console.log(pub.name, pub.url);
261261+}
262262+```
263263+264264+## Handling in Appview (Firehose Consumer)
265265+266266+When processing records from the firehose in `appview/index.ts`:
267267+268268+```typescript
269269+import { ids } from "lexicons/api/lexicons";
270270+import { PubLeafletMyLexicon } from "lexicons/api";
271271+272272+// In filterCollections:
273273+filterCollections: [
274274+ ids.PubLeafletMyLexicon,
275275+ // ...
276276+],
277277+278278+// In handleEvent:
279279+if (evt.collection === ids.PubLeafletMyLexicon) {
280280+ if (evt.event === "create" || evt.event === "update") {
281281+ let record = PubLeafletMyLexicon.validateRecord(evt.record);
282282+ if (!record.success) return;
283283+284284+ // Store in database
285285+ await supabase.from("my_table").upsert({
286286+ uri: evt.uri.toString(),
287287+ data: record.value as Json,
288288+ });
289289+ }
290290+ if (evt.event === "delete") {
291291+ await supabase.from("my_table").delete().eq("uri", evt.uri.toString());
292292+ }
293293+}
294294+```
295295+296296+## Publishing Lexicons
297297+298298+To publish lexicons to an AT Protocol PDS:
299299+300300+```bash
301301+npm run publish-lexicons
302302+```
303303+304304+This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
+298
.claude/skills/lexicons.md
···11+# Lexicon System
22+33+## Overview
44+55+Lexicons define the schema for AT Protocol records. This project has two namespaces:
66+- **`pub.leaflet.*`** - Leaflet-specific lexicons (documents, publications, blocks, etc.)
77+- **`site.standard.*`** - Standard site lexicons for interoperability
88+99+The lexicons are defined as TypeScript in `lexicons/src/`, built to JSON in `lexicons/pub/leaflet/` and `lexicons/site/standard/`, and TypeScript types are generated in `lexicons/api/`.
1010+1111+## Key Files
1212+1313+- **`lexicons/src/*.ts`** - Source definitions for `pub.leaflet.*` lexicons
1414+- **`lexicons/site/standard/**/*.json`** - JSON definitions for `site.standard.*` lexicons (manually maintained)
1515+- **`lexicons/build.ts`** - Builds TypeScript sources to JSON
1616+- **`lexicons/api/`** - Generated TypeScript types and client
1717+- **`package.json`** - Contains `lexgen` script
1818+1919+## Running Lexicon Generation
2020+2121+```bash
2222+npm run lexgen
2323+```
2424+2525+This runs:
2626+1. `tsx ./lexicons/build.ts` - Builds `pub.leaflet.*` JSON from TypeScript
2727+2. `lex gen-api` - Generates TypeScript types from all JSON lexicons
2828+3. `tsx ./lexicons/fix-extensions.ts` - Fixes import extensions
2929+3030+## Adding a New pub.leaflet Lexicon
3131+3232+### 1. Create the Source Definition
3333+3434+Create a file in `lexicons/src/` (e.g., `lexicons/src/myLexicon.ts`):
3535+3636+```typescript
3737+import { LexiconDoc } from "@atproto/lexicon";
3838+3939+export const PubLeafletMyLexicon: LexiconDoc = {
4040+ lexicon: 1,
4141+ id: "pub.leaflet.myLexicon",
4242+ defs: {
4343+ main: {
4444+ type: "record", // or "object" for non-record types
4545+ key: "tid",
4646+ record: {
4747+ type: "object",
4848+ required: ["field1"],
4949+ properties: {
5050+ field1: { type: "string", maxLength: 1000 },
5151+ field2: { type: "integer", minimum: 0 },
5252+ optionalRef: { type: "ref", ref: "other.lexicon#def" },
5353+ },
5454+ },
5555+ },
5656+ // Additional defs for sub-objects
5757+ subType: {
5858+ type: "object",
5959+ properties: {
6060+ nested: { type: "string" },
6161+ },
6262+ },
6363+ },
6464+};
6565+```
6666+6767+### 2. Add to Build
6868+6969+Update `lexicons/build.ts`:
7070+7171+```typescript
7272+import { PubLeafletMyLexicon } from "./src/myLexicon";
7373+7474+const lexicons = [
7575+ // ... existing lexicons
7676+ PubLeafletMyLexicon,
7777+];
7878+```
7979+8080+### 3. Update lexgen Command (if needed)
8181+8282+If your lexicon is at the top level of `pub/leaflet/` (not in a subdirectory), add it to the `lexgen` script in `package.json`:
8383+8484+```json
8585+"lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/myLexicon.json ./lexicons/pub/leaflet/*/* ..."
8686+```
8787+8888+Note: Files in subdirectories (`pub/leaflet/*/*`) are automatically included.
8989+9090+### 4. Add to authFullPermissions (for record types)
9191+9292+If your lexicon is a record type that users should be able to create/update/delete, add it to the `authFullPermissions` permission set in `lexicons/src/authFullPermissions.ts`:
9393+9494+```typescript
9595+import { PubLeafletMyLexicon } from "./myLexicon";
9696+9797+// In the permissions collection array:
9898+collection: [
9999+ // ... existing lexicons
100100+ PubLeafletMyLexicon.id,
101101+],
102102+```
103103+104104+### 5. Regenerate Types
105105+106106+```bash
107107+npm run lexgen
108108+```
109109+110110+### 6. Use the Generated Types
111111+112112+```typescript
113113+import { PubLeafletMyLexicon } from "lexicons/api";
114114+115115+// Type for the record
116116+type MyRecord = PubLeafletMyLexicon.Record;
117117+118118+// Validation
119119+const result = PubLeafletMyLexicon.validateRecord(data);
120120+if (result.success) {
121121+ // result.value is typed
122122+}
123123+124124+// Type guard
125125+if (PubLeafletMyLexicon.isRecord(data)) {
126126+ // data is typed as Record
127127+}
128128+```
129129+130130+## Adding a New site.standard Lexicon
131131+132132+### 1. Create the JSON Definition
133133+134134+Create a file in `lexicons/site/standard/` (e.g., `lexicons/site/standard/myType.json`):
135135+136136+```json
137137+{
138138+ "lexicon": 1,
139139+ "id": "site.standard.myType",
140140+ "defs": {
141141+ "main": {
142142+ "type": "record",
143143+ "key": "tid",
144144+ "record": {
145145+ "type": "object",
146146+ "required": ["field1"],
147147+ "properties": {
148148+ "field1": {
149149+ "type": "string",
150150+ "maxLength": 1000
151151+ }
152152+ }
153153+ }
154154+ }
155155+ }
156156+}
157157+```
158158+159159+### 2. Regenerate Types
160160+161161+```bash
162162+npm run lexgen
163163+```
164164+165165+The `site/*/* site/*/*/*` globs in the lexgen command automatically pick up new files.
166166+167167+## Common Lexicon Patterns
168168+169169+### Referencing Other Lexicons
170170+171171+```typescript
172172+// Reference another lexicon's main def
173173+{ type: "ref", ref: "pub.leaflet.publication" }
174174+175175+// Reference a specific def within a lexicon
176176+{ type: "ref", ref: "pub.leaflet.publication#theme" }
177177+178178+// Reference within the same lexicon
179179+{ type: "ref", ref: "#myDef" }
180180+```
181181+182182+### Union Types
183183+184184+```typescript
185185+{
186186+ type: "union",
187187+ refs: [
188188+ "pub.leaflet.pages.linearDocument",
189189+ "pub.leaflet.pages.canvas",
190190+ ],
191191+}
192192+193193+// Open union (allows unknown types)
194194+{
195195+ type: "union",
196196+ closed: false, // default is true
197197+ refs: ["pub.leaflet.content"],
198198+}
199199+```
200200+201201+### Blob Types (for images/files)
202202+203203+```typescript
204204+{
205205+ type: "blob",
206206+ accept: ["image/*"], // or specific types like ["image/png", "image/jpeg"]
207207+ maxSize: 1000000, // bytes
208208+}
209209+```
210210+211211+### Color Types
212212+213213+The project has color types defined:
214214+- `pub.leaflet.theme.color#rgb` / `#rgba`
215215+- `site.standard.theme.color#rgb` / `#rgba`
216216+217217+```typescript
218218+// In lexicons/src/theme.ts
219219+export const ColorUnion = {
220220+ type: "union",
221221+ refs: [
222222+ "pub.leaflet.theme.color#rgba",
223223+ "pub.leaflet.theme.color#rgb",
224224+ ],
225225+};
226226+```
227227+228228+## Normalization Between Formats
229229+230230+Use `lexicons/src/normalize.ts` to convert between `pub.leaflet` and `site.standard` formats:
231231+232232+```typescript
233233+import {
234234+ normalizeDocument,
235235+ normalizePublication,
236236+ isLeafletDocument,
237237+ isStandardDocument,
238238+ getDocumentPages,
239239+} from "lexicons/src/normalize";
240240+241241+// Normalize a document from either format
242242+const normalized = normalizeDocument(record);
243243+if (normalized) {
244244+ // normalized is always in site.standard.document format
245245+ console.log(normalized.title, normalized.site);
246246+247247+ // Get pages if content is pub.leaflet.content
248248+ const pages = getDocumentPages(normalized);
249249+}
250250+251251+// Normalize a publication
252252+const pub = normalizePublication(record);
253253+if (pub) {
254254+ console.log(pub.name, pub.url);
255255+}
256256+```
257257+258258+## Handling in Appview (Firehose Consumer)
259259+260260+When processing records from the firehose in `appview/index.ts`:
261261+262262+```typescript
263263+import { ids } from "lexicons/api/lexicons";
264264+import { PubLeafletMyLexicon } from "lexicons/api";
265265+266266+// In filterCollections:
267267+filterCollections: [
268268+ ids.PubLeafletMyLexicon,
269269+ // ...
270270+],
271271+272272+// In handleEvent:
273273+if (evt.collection === ids.PubLeafletMyLexicon) {
274274+ if (evt.event === "create" || evt.event === "update") {
275275+ let record = PubLeafletMyLexicon.validateRecord(evt.record);
276276+ if (!record.success) return;
277277+278278+ // Store in database
279279+ await supabase.from("my_table").upsert({
280280+ uri: evt.uri.toString(),
281281+ data: record.value as Json,
282282+ });
283283+ }
284284+ if (evt.event === "delete") {
285285+ await supabase.from("my_table").delete().eq("uri", evt.uri.toString());
286286+ }
287287+}
288288+```
289289+290290+## Publishing Lexicons
291291+292292+To publish lexicons to an AT Protocol PDS:
293293+294294+```bash
295295+npm run publish-lexicons
296296+```
297297+298298+This runs `lexicons/publish.ts` which publishes lexicons to the configured PDS.
+150
.claude/skills/notifications/SKILL.md
···11+---
22+name: notifications
33+description: Reference for the notification system. Use when adding new notification types or modifying notification handling.
44+user-invocable: false
55+---
66+77+# Notification System
88+99+## Overview
1010+1111+Notifications are stored in the database and hydrated with related data before being rendered. The system supports multiple notification types (comments, subscriptions, etc.) that are processed in parallel.
1212+1313+## Key Files
1414+1515+- **`src/notifications.ts`** - Core notification types and hydration logic
1616+- **`app/(home-pages)/notifications/NotificationList.tsx`** - Renders all notification types
1717+- **`app/(home-pages)/notifications/Notification.tsx`** - Base notification component
1818+- Individual notification components (e.g., `CommentNotification.tsx`, `FollowNotification.tsx`)
1919+2020+## Adding a New Notification Type
2121+2222+### 1. Update Notification Data Types (`src/notifications.ts`)
2323+2424+Add your type to the `NotificationData` union:
2525+2626+```typescript
2727+export type NotificationData =
2828+ | { type: "comment"; comment_uri: string; parent_uri?: string }
2929+ | { type: "subscribe"; subscription_uri: string }
3030+ | { type: "your_type"; your_field: string }; // Add here
3131+```
3232+3333+Add to the `HydratedNotification` union:
3434+3535+```typescript
3636+export type HydratedNotification =
3737+ | HydratedCommentNotification
3838+ | HydratedSubscribeNotification
3939+ | HydratedYourNotification; // Add here
4040+```
4141+4242+### 2. Create Hydration Function (`src/notifications.ts`)
4343+4444+```typescript
4545+export type HydratedYourNotification = Awaited<
4646+ ReturnType<typeof hydrateYourNotifications>
4747+>[0];
4848+4949+async function hydrateYourNotifications(notifications: NotificationRow[]) {
5050+ const yourNotifications = notifications.filter(
5151+ (n): n is NotificationRow & { data: ExtractNotificationType<"your_type"> } =>
5252+ (n.data as NotificationData)?.type === "your_type",
5353+ );
5454+5555+ if (yourNotifications.length === 0) return [];
5656+5757+ // Fetch related data with joins
5858+ const { data } = await supabaseServerClient
5959+ .from("your_table")
6060+ .select("*, related_table(*)")
6161+ .in("uri", yourNotifications.map((n) => n.data.your_field));
6262+6363+ return yourNotifications.map((notification) => ({
6464+ id: notification.id,
6565+ recipient: notification.recipient,
6666+ created_at: notification.created_at,
6767+ type: "your_type" as const,
6868+ your_field: notification.data.your_field,
6969+ yourData: data?.find((d) => d.uri === notification.data.your_field)!,
7070+ }));
7171+}
7272+```
7373+7474+Add to `hydrateNotifications` parallel array:
7575+7676+```typescript
7777+const [commentNotifications, subscribeNotifications, yourNotifications] = await Promise.all([
7878+ hydrateCommentNotifications(notifications),
7979+ hydrateSubscribeNotifications(notifications),
8080+ hydrateYourNotifications(notifications), // Add here
8181+]);
8282+8383+const allHydrated = [...commentNotifications, ...subscribeNotifications, ...yourNotifications];
8484+```
8585+8686+### 3. Trigger the Notification (in your action file)
8787+8888+```typescript
8989+import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
9090+import { v7 } from "uuid";
9191+9292+// When the event occurs:
9393+const recipient = /* determine who should receive it */;
9494+if (recipient !== currentUser) {
9595+ const notification: Notification = {
9696+ id: v7(),
9797+ recipient,
9898+ data: {
9999+ type: "your_type",
100100+ your_field: "value",
101101+ },
102102+ };
103103+ await supabaseServerClient.from("notifications").insert(notification);
104104+ await pingIdentityToUpdateNotification(recipient);
105105+}
106106+```
107107+108108+### 4. Create Notification Component
109109+110110+Create a new component (e.g., `YourNotification.tsx`):
111111+112112+```typescript
113113+import { HydratedYourNotification } from "src/notifications";
114114+import { Notification } from "./Notification";
115115+116116+export const YourNotification = (props: HydratedYourNotification) => {
117117+ // Extract data from props.yourData
118118+119119+ return (
120120+ <Notification
121121+ timestamp={props.created_at}
122122+ href={/* link to relevant page */}
123123+ icon={/* icon or avatar */}
124124+ actionText={<>Message to display</>}
125125+ content={/* optional additional content */}
126126+ />
127127+ );
128128+};
129129+```
130130+131131+### 5. Update NotificationList (`NotificationList.tsx`)
132132+133133+Import and render your notification type:
134134+135135+```typescript
136136+import { YourNotification } from "./YourNotification";
137137+138138+// In the map function:
139139+if (n.type === "your_type") {
140140+ return <YourNotification key={n.id} {...n} />;
141141+}
142142+```
143143+144144+## Example: Subscribe Notifications
145145+146146+See the implementation in:
147147+- `src/notifications.ts:88-125` - Hydration logic
148148+- `app/lish/subscribeToPublication.ts:55-68` - Trigger
149149+- `app/(home-pages)/notifications/FollowNotification.tsx` - Component
150150+- `app/(home-pages)/notifications/NotificationList.tsx:40-42` - Rendering
+85
.claude/skills/spec/SKILL.md
···11+---
22+name: spec
33+description: Draft design documents and specs with research-informed questioning
44+disable-model-invocation: true
55+---
66+77+# Spec Writing
88+99+## When to Use
1010+1111+Use this skill when the user explicitly requests a spec, design document, or similar planning artifact. Do not auto-trigger for routine tasks.
1212+1313+## Process
1414+1515+### 1. Initial Scoping
1616+1717+When starting a spec:
1818+- Ask the user for a brief description of what they want to build/change
1919+- Reason about which question categories matter for this particular workโderive these from the problem, not a checklist
2020+2121+### 2. Interrogation Loop
2222+2323+Conduct open-ended questioning until the designer signals completion:
2424+2525+**Research-informed questions**: Before asking about an area, do just-in-time codebase research. Find relevant files, understand current patterns, identify constraints. Reference specific files and functions in questions when it adds clarity.
2626+2727+**Adaptive scope**: When the designer marks something as out of scope, update your mental model and don't revisit. However, flag when you believe there are gaps that could cause problems:
2828+- "This approach assumes Xโis that intentional?"
2929+- "The current implementation of Y in `src/foo.ts:42` handles Z differently. Should we align or diverge?"
3030+3131+**Question style**:
3232+- Ask one focused question at a time, or a small related cluster
3333+- Ground questions in what you've learned from the codebase
3434+- Avoid hypotheticals that don't apply to this codebase
3535+- When presenting options, describe tradeoffs without recommending unless asked
3636+3737+### 3. Spec Writing
3838+3939+When the designer indicates readiness, produce the spec document.
4040+4141+## Output
4242+4343+Save to: `/specs/YYYY-MM-DD-short-name.md`
4444+4545+### Required Elements
4646+4747+**Title and status** (draft | approved | implemented)
4848+4949+**Goal**: What this achieves and why. 1-3 sentences.
5050+5151+**Design**: The substance of what will be built/changed. For each significant component or concern:
5252+- Describe the approach
5353+- State key decisions with their rationale inline
5454+- Reference specific files, functions, and types where relevant
5555+- Structure subsections to fit the problemโno fixed format
5656+5757+**Implementation**: Ordered steps that an agent or developer can execute. Each step should:
5858+- Be concrete and actionable
5959+- Reference specific files/functions to modify or create
6060+- Be sequenced correctly (dependencies before dependents)
6161+6262+### Optional
6363+6464+- Background context (only if necessary for understanding)
6565+- Open questions (only if unresolved items remain)
6666+6767+Add other sections if the problem demands it.
6868+6969+## Writing Style
7070+7171+- No filler, hedging, or preamble
7272+- No "This document describes..." or "In this spec we will..."
7373+- Start sections with substance, not meta-commentary
7474+- Use precise technical language
7575+- Keep decisions and rationale tightโone sentence each when possible
7676+- Code references use `file/path.ts:lineNumber` or `functionName` in backticks
7777+- Prefer concrete over abstract; specific over general
7878+7979+## Status Lifecycle
8080+8181+- **draft**: Work in progress. Should not be merged to main.
8282+- **approved**: Designer has signed off. Ready for implementation.
8383+- **implemented**: Work is complete. Spec is now historical record.
8484+8585+Update status in the document as it progresses. Specs are point-in-time snapshotsโdo not update content after implementation begins except to change status.
···33import { AtpBaseClient } from "lexicons/api";
44import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
55import { getIdentityData } from "actions/getIdentityData";
66-import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
66+import {
77+ restoreOAuthSession,
88+ OAuthSessionError,
99+} from "src/atproto-oauth";
710import { TID } from "@atproto/common";
811import { supabaseServerClient } from "supabase/serverClient";
912import { revalidatePath } from "next/cache";
···7679 }
77807881 let bsky = new BskyAgent(credentialSession);
7979- let [profile, resolveDid] = await Promise.all([
8282+ let [prefs, profile, resolveDid] = await Promise.all([
8383+ bsky.app.bsky.actor.getPreferences(),
8084 bsky.app.bsky.actor.profile
8185 .get({
8286 repo: credentialSession.did!,
···9296 handle: resolveDid?.alsoKnownAs?.[0]?.slice(5),
9397 });
9498 }
9999+ let savedFeeds = prefs.data.preferences.find(
100100+ (pref) => pref.$type === "app.bsky.actor.defs#savedFeedsPrefV2",
101101+ ) as AppBskyActorDefs.SavedFeedsPrefV2;
95102 revalidatePath("/lish/[did]/[publication]", "layout");
96103 return {
97104 success: true,
9898- hasFeed: true,
105105+ hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
99106 };
100107}
101108···104111 | { success: false; error: OAuthSessionError };
105112106113export async function unsubscribeToPublication(
107107- publication: string,
114114+ publication: string
108115): Promise<UnsubscribeResult> {
109116 let identity = await getIdentityData();
110117 if (!identity || !identity.atp_did) {
···137144 // Delete from both collections (old and new schema) - one or both may exist
138145 let rkey = new AtUri(existingSubscription.uri).rkey;
139146 await Promise.all([
140140- agent.pub.leaflet.graph.subscription
141141- .delete({ repo: credentialSession.did!, rkey })
142142- .catch(() => {}),
143143- agent.site.standard.graph.subscription
144144- .delete({ repo: credentialSession.did!, rkey })
145145- .catch(() => {}),
147147+ agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
148148+ agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
146149 ]);
147150148151 await supabaseServerClient
+1-18
appview/index.ts
···109109 data: record.value as Json,
110110 });
111111 if (docResult.error) console.log(docResult.error);
112112- await inngest.send({
113113- name: "appview/sync-document-metadata",
114114- data: {
115115- document_uri: evt.uri.toString(),
116116- bsky_post_uri: record.value.postRef?.uri,
117117- },
118118- });
119112 if (record.value.publication) {
120113 let publicationURI = new AtUri(record.value.publication);
121114···276269 data: record.value as Json,
277270 });
278271 if (docResult.error) console.log(docResult.error);
279279- await inngest.send({
280280- name: "appview/sync-document-metadata",
281281- data: {
282282- document_uri: evt.uri.toString(),
283283- bsky_post_uri: record.value.bskyPostRef?.uri,
284284- },
285285- });
286272287273 // site.standard.document uses "site" field to reference the publication
288274 // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey)
···392378393379 // Now validate the record since we know it contains our quote param
394380 let record = AppBskyFeedPost.validateRecord(evt.record);
395395- if (!record.success) {
396396- console.log(record.error);
397397- return;
398398- }
381381+ if (!record.success) return;
399382400383 let embed: string | null = null;
401384 if (
···2828export type NormalizedDocument = SiteStandardDocument.Record & {
2929 // Keep the original theme for components that need leaflet-specific styling
3030 theme?: PubLeafletPublication.Theme;
3131- preferences?: SiteStandardPublication.Preferences;
3231};
33323433// Normalized publication type - uses the generated site.standard.publication type
···5150 * Checks if the record is a pub.leaflet.document
5251 */
5352export function isLeafletDocument(
5454- record: unknown,
5353+ record: unknown
5554): record is PubLeafletDocument.Record {
5655 if (!record || typeof record !== "object") return false;
5756 const r = record as Record<string, unknown>;
···6665 * Checks if the record is a site.standard.document
6766 */
6867export function isStandardDocument(
6969- record: unknown,
6868+ record: unknown
7069): record is SiteStandardDocument.Record {
7170 if (!record || typeof record !== "object") return false;
7271 const r = record as Record<string, unknown>;
···7776 * Checks if the record is a pub.leaflet.publication
7877 */
7978export function isLeafletPublication(
8080- record: unknown,
7979+ record: unknown
8180): record is PubLeafletPublication.Record {
8281 if (!record || typeof record !== "object") return false;
8382 const r = record as Record<string, unknown>;
···9291 * Checks if the record is a site.standard.publication
9392 */
9493export function isStandardPublication(
9595- record: unknown,
9494+ record: unknown
9695): record is SiteStandardPublication.Record {
9796 if (!record || typeof record !== "object") return false;
9897 const r = record as Record<string, unknown>;
···107106 | $Typed<PubLeafletThemeColor.Rgba>
108107 | $Typed<PubLeafletThemeColor.Rgb>
109108 | { $type: string }
110110- | undefined,
109109+ | undefined
111110): { r: number; g: number; b: number } | undefined {
112111 if (!color || typeof color !== "object") return undefined;
113112 const c = color as Record<string, unknown>;
···125124 * Converts a pub.leaflet theme to a site.standard.theme.basic format
126125 */
127126export function leafletThemeToBasicTheme(
128128- theme: PubLeafletPublication.Theme | undefined,
127127+ theme: PubLeafletPublication.Theme | undefined
129128): SiteStandardThemeBasic.Main | undefined {
130129 if (!theme) return undefined;
131130132131 const background = extractRgb(theme.backgroundColor);
133133- const accent =
134134- extractRgb(theme.accentBackground) || extractRgb(theme.primary);
132132+ const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary);
135133 const accentForeground = extractRgb(theme.accentText);
136134137135 // If we don't have the required colors, return undefined
···162160 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records
163161 * @returns A normalized document in site.standard format, or null if invalid/unrecognized
164162 */
165165-export function normalizeDocument(
166166- record: unknown,
167167- uri?: string,
168168-): NormalizedDocument | null {
163163+export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null {
169164 if (!record || typeof record !== "object") return null;
170165171166 // Pass through site.standard records directly (theme is already in correct format if present)
172167 if (isStandardDocument(record)) {
173173- const preferences = record.preferences as
174174- | SiteStandardPublication.Preferences
175175- | undefined;
176168 return {
177169 ...record,
178170 theme: record.theme,
179179- preferences,
180171 } as NormalizedDocument;
181172 }
182173···203194 }
204195 : undefined;
205196206206- // Extract preferences if present (available after lexicon rebuild)
207207- const leafletPrefs = (record as Record<string, unknown>)
208208- .preferences as SiteStandardPublication.Preferences | undefined;
209209-210197 return {
211198 $type: "site.standard.document",
212199 title: record.title,
···219206 bskyPostRef: record.postRef,
220207 content,
221208 theme: record.theme,
222222- preferences: leafletPrefs
223223- ? { ...leafletPrefs, $type: "site.standard.publication#preferences" as const }
224224- : undefined,
225209 };
226210 }
227211···235219 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized
236220 */
237221export function normalizePublication(
238238- record: unknown,
222222+ record: unknown
239223): NormalizedPublication | null {
240224 if (!record || typeof record !== "object") return null;
241225···284268 showComments: record.preferences.showComments,
285269 showMentions: record.preferences.showMentions,
286270 showPrevNext: record.preferences.showPrevNext,
287287- showRecommends: record.preferences.showRecommends,
288271 }
289272 : undefined;
290273···307290 * Type guard to check if a normalized document has leaflet content
308291 */
309292export function hasLeafletContent(
310310- doc: NormalizedDocument,
293293+ doc: NormalizedDocument
311294): doc is NormalizedDocument & {
312295 content: $Typed<PubLeafletContent.Main>;
313296} {
···321304 * Gets the pages array from a normalized document, handling both formats
322305 */
323306export function getDocumentPages(
324324- doc: NormalizedDocument,
307307+ doc: NormalizedDocument
325308): PubLeafletContent.Main["pages"] | undefined {
326309 if (!doc.content) return undefined;
327310
···11+# Pro Tier Subscription System
22+33+**Status**: draft
44+55+## Goal
66+77+Add a paid Pro tier to Leaflet using Stripe for billing, with an entitlements-based architecture that decouples feature access from subscription state.
88+99+## Design
1010+1111+### Data Model
1212+1313+Two new tables separate Stripe subscription state from feature access:
1414+1515+**`user_subscriptions`** โ Stripe sync state
1616+- `identity_id` (UUID, FK to identities, PK)
1717+- `stripe_customer_id` (text, unique)
1818+- `stripe_subscription_id` (text, unique, nullable)
1919+- `plan` (text) โ Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd`
2020+- `status` (text) โ mirrors Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid`
2121+- `current_period_end` (timestamp)
2222+- `created_at`, `updated_at`
2323+2424+**`user_entitlements`** โ Feature access grants
2525+- `identity_id` (UUID, FK to identities)
2626+- `entitlement_key` (text) โ e.g., `analytics`
2727+- `granted_at` (timestamp)
2828+- `expires_at` (timestamp, nullable) โ null means permanent
2929+- `source` (text) โ provenance, e.g., `stripe:sub_xxx`, `manual:admin`, `promo:launch2026`
3030+- `metadata` (jsonb, nullable) โ for limits or additional config
3131+- Primary key: `(identity_id, entitlement_key)` โ one entitlement per key per user
3232+3333+The unique constraint on `(identity_id, entitlement_key)` means writes are upserts. The most recent write wins; `source` tracks provenance.
3434+3535+### SKU โ Entitlements Mapping
3636+3737+Entitlements for each Stripe Product are stored in Stripe's product metadata, not locally. Example product metadata:
3838+3939+```json
4040+{
4141+ "entitlements": "{\"analytics\": true}"
4242+}
4343+```
4444+4545+This keeps Stripe as the source of truth for what each SKU grants.
4646+4747+### Stripe Product Sync
4848+4949+Products and prices are defined in code and synced to Stripe via a GitHub Action. This provides version control, reproducibility, and enforces immutability.
5050+5151+**Directory structure:**
5252+```
5353+stripe/
5454+โโโ products.ts # Product/price definitions with entitlement metadata
5555+โโโ sync.ts # Script that ensures Stripe matches definitions
5656+```
5757+5858+**Product definition format** (`stripe/products.ts`):
5959+```typescript
6060+export const products = [
6161+ {
6262+ id: "pro_monthly_v1",
6363+ name: "Leaflet Pro (Monthly)",
6464+ prices: [
6565+ {
6666+ id: "pro_monthly_v1_usd",
6767+ currency: "usd",
6868+ unit_amount: 900, // $9.00
6969+ recurring: { interval: "month" },
7070+ },
7171+ ],
7272+ metadata: {
7373+ entitlements: JSON.stringify({ publication_analytics: true }),
7474+ },
7575+ },
7676+];
7777+```
7878+7979+**Sync script behavior** (`stripe/sync.ts`):
8080+1. Fetch existing products and prices by `id` (`stripe.products.retrieve(id)`, `stripe.prices.retrieve(id)`)
8181+2. For each definition:
8282+ - If missing in Stripe โ create it with the custom `id`
8383+ - If exists and matches โ no-op
8484+ - If exists but differs โ update it to match the definition
8585+3. Never deletes products/prices
8686+8787+Both products and prices use custom `id` for idempotent matching (set at creation). Same definitions apply to both test and live modeโthe script targets whichever mode the API key belongs to.
8888+8989+**GitHub Action** (`.github/workflows/stripe-sync.yml`):
9090+- Triggers on push to `stripe/` directory
9191+- Runs sync against test mode automatically
9292+- Live mode sync requires manual workflow dispatch with approval
9393+9494+**Versioning/grandfathering:**
9595+To grandfather existing subscribers on different terms, create a new product with a new `id` (e.g., `pro_monthly_v2`). Existing subscribers stay on v1. New subscribers get v2. For additive changes (new entitlements, metadata updates), just update the existing product definition.
9696+9797+### Stripe Sync Strategy
9898+9999+**Webhook-driven** with **optimistic updates**:
100100+101101+1. **Optimistic**: After successful Checkout Session completion on the client, immediately call a server action to write `user_subscriptions` and `user_entitlements` based on the session data. User gets instant access.
102102+103103+2. **Durable**: Stripe webhooks confirm and reconcile state. Handles edge cases (payment failures, disputes, subscription updates from Stripe dashboard).
104104+105105+Webhooks to handle:
106106+- `checkout.session.completed` โ initial subscription created
107107+- `customer.subscription.created` โ backup for subscription creation
108108+- `customer.subscription.updated` โ plan changes, renewals, status changes
109109+- `customer.subscription.deleted` โ subscription ended
110110+- `invoice.payment_failed` โ payment issues
111111+- `customer.subscription.trial_will_end` โ trial ending reminder (optional, for notifications)
112112+113113+### Entitlement Lifecycle
114114+115115+**Grant flow** (subscription activates or trial starts):
116116+1. Webhook receives event with subscription and product data
117117+2. Parse `entitlements` from product metadata
118118+3. Upsert `user_entitlements` rows with `expires_at` = `current_period_end`, `source` = `stripe:{subscription_id}`
119119+120120+**Renewal flow** (subscription renews):
121121+1. `customer.subscription.updated` webhook fires
122122+2. Update `expires_at` on all entitlements with matching `source`
123123+124124+**Cancellation flow** (user cancels but period remains):
125125+1. `customer.subscription.updated` with `cancel_at_period_end: true`
126126+2. Update `user_subscriptions.status`
127127+3. Entitlements remain valid until `expires_at` (already set to period end)
128128+129129+**Expiration flow** (subscription ends):
130130+1. `customer.subscription.deleted` webhook fires
131131+2. No action needed on `user_entitlements` โ they naturally expire via `expires_at`
132132+3. Update `user_subscriptions.status` to `canceled`
133133+134134+Soft expiration via `expires_at` preserves audit history and handles the canceled-but-paid-through-period case without additional webhooks.
135135+136136+### Trials
137137+138138+Stripe-managed trials:
139139+1. Trial starts โ subscription created with `status: trialing`
140140+2. Entitlements granted with `expires_at` = trial end date
141141+3. Trial converts โ `expires_at` updated to subscription period end
142142+4. Trial lapses โ entitlements expire naturally, no action needed
143143+144144+### Entitlement Checks
145145+146146+Extend `getIdentityData()` to join against `user_entitlements` and return active entitlements:
147147+148148+```typescript
149149+type IdentityData = {
150150+ id: string;
151151+ email: string;
152152+ atp_did: string;
153153+ // ... existing fields
154154+ entitlements: Record<string, {
155155+ granted_at: string;
156156+ expires_at: string | null;
157157+ source: string;
158158+ metadata: Record<string, unknown> | null;
159159+ }>;
160160+};
161161+```
162162+163163+Query filters to `expires_at IS NULL OR expires_at > NOW()`. The `useIdentityData()` hook exposes the same shape client-side.
164164+165165+Feature gating in server actions:
166166+167167+```typescript
168168+export async function getAnalyticsData() {
169169+ const identity = await getIdentityData();
170170+ if (!identity?.entitlements.analytics) {
171171+ return err("Pro subscription required");
172172+ }
173173+ // ... fetch analytics
174174+}
175175+```
176176+177177+### Initial Pro Features
178178+179179+Single entitlement at launch:
180180+- `publication_analytics` (boolean) โ access to analytics features for
181181+ publications
182182+183183+### Webhook Endpoint
184184+185185+New API route at `app/api/webhooks/stripe/route.ts`:
186186+- Verify Stripe signature using `STRIPE_WEBHOOK_SECRET`
187187+- Dispatch to Inngest functions for processing (keeps webhook response fast, enables retries)
188188+189189+### Inngest Functions
190190+191191+New functions in `app/api/inngest/functions/`:
192192+- `stripe/handle-checkout-completed` โ process successful checkout
193193+- `stripe/handle-subscription-updated` โ sync subscription changes to entitlements
194194+- `stripe/handle-subscription-deleted` โ update subscription status
195195+196196+Events to add to Inngest client:
197197+```typescript
198198+"stripe/checkout.session.completed": { data: { sessionId: string } }
199199+"stripe/customer.subscription.updated": { data: { subscriptionId: string } }
200200+"stripe/customer.subscription.deleted": { data: { subscriptionId: string } }
201201+```
202202+203203+### Environment Variables
204204+205205+```
206206+STRIPE_SECRET_KEY=sk_...
207207+STRIPE_WEBHOOK_SECRET=whsec_...
208208+```
209209+210210+Price IDs are defined in `stripe/products.ts` and imported directly โ no env var needed.
211211+212212+## Open Questions
213213+214214+- **UI**: Where upgrade prompts appear, pricing page design, checkout flow UX โ to be designed separately
215215+- **Multiple plans**: Current design supports one subscription per user. If we add team plans or multiple concurrent subscriptions, `user_subscriptions` would need to become one-to-many
216216+217217+## Implementation
218218+219219+1. **Add Stripe packages**: Install `stripe` npm package
220220+221221+2. **Database migration**: Create `user_subscriptions` and `user_entitlements` tables with indexes on `identity_id` and `expires_at`
222222+223223+3. **Drizzle schema**: Add table definitions to `drizzle/schema.ts`
224224+225225+4. **Environment setup**: Add Stripe env vars to `.env.local` and deployment config
226226+227227+5. **Stripe webhook endpoint**: Create `app/api/webhooks/stripe/route.ts` with signature verification, dispatch events to Inngest
228228+229229+6. **Inngest events and functions**: Add event types to `app/api/inngest/client.ts`, create handler functions for checkout completed, subscription updated, subscription deleted
230230+231231+7. **Extend getIdentityData**: Join against `user_entitlements`, filter expired, return entitlements object
232232+233233+8. **Extend useIdentityData**: Ensure client-side hook receives entitlements from server
234234+235235+9. **Optimistic update action**: Create `actions/subscriptions/activateSubscription.ts` for immediate access after checkout
236236+237237+10. **Stripe sync infrastructure**:
238238+ - Create `stripe/products.ts` with Pro product definitions and entitlement metadata
239239+ - Create `stripe/sync.ts` script using Stripe Node SDK to reconcile definitions with Stripe
240240+ - Add `.github/workflows/stripe-sync.yml` for automated test mode sync on push, manual live mode sync
241241+242242+11. **Initial product sync**: Run sync script against both test and live mode to create Pro products
243243+244244+12. **Gate analytics**: Add entitlement check to analytics server actions
···1919 const uri = new AtUri(atUri);
20202121 if (isPublicationCollection(uri.collection)) {
2222- return `/lish/uri/${encodeURIComponent(atUri)}`;
2222+ // Publication URL: /lish/{did}/{rkey}
2323+ return `/lish/${uri.host}/${uri.rkey}`;
2324 } else if (isDocumentCollection(uri.collection)) {
2525+ // Document URL - we need to resolve this via the API
2626+ // For now, create a redirect route that will handle it
2427 return `/lish/uri/${encodeURIComponent(atUri)}`;
2528 }
2629···3942export function handleMentionClick(
4043 e: MouseEvent | React.MouseEvent,
4144 type: "did" | "at-uri",
4242- value: string,
4545+ value: string
4346) {
4447 e.preventDefault();
4548 e.stopPropagation();
···11-ALTER TABLE documents ADD COLUMN recommend_count integer NOT NULL DEFAULT 0;
22-33-UPDATE documents d
44-SET recommend_count = (
55- SELECT COUNT(*) FROM recommends_on_documents r WHERE r.document = d.uri
66-);
77-88-CREATE OR REPLACE FUNCTION update_recommend_count() RETURNS trigger AS $$
99-BEGIN
1010- IF TG_OP = 'INSERT' THEN
1111- UPDATE documents SET recommend_count = recommend_count + 1
1212- WHERE uri = NEW.document;
1313- ELSIF TG_OP = 'DELETE' THEN
1414- UPDATE documents SET recommend_count = recommend_count - 1
1515- WHERE uri = OLD.document;
1616- END IF;
1717- RETURN NULL;
1818-END;
1919-$$ LANGUAGE plpgsql;
2020-2121-CREATE TRIGGER trg_recommend_count
2222-AFTER INSERT OR DELETE ON recommends_on_documents
2323-FOR EACH ROW EXECUTE FUNCTION update_recommend_count();