···11+# Lex Studio: Configurable Template System Design
22+33+## Table of Contents
44+55+1. [Current State](#1-current-state)
66+2. [Template Set Data Model](#2-template-set-data-model)
77+3. [Fiddle UI Design](#3-fiddle-ui-design)
88+4. [Template Language Options](#4-template-language-options)
99+5. [Templates as Lexicons](#5-templates-as-lexicons)
1010+6. [Widget System](#6-widget-system)
1111+7. [Discovery and Sharing](#7-discovery-and-sharing)
1212+8. [Implementation Phases](#8-implementation-phases)
1313+9. [Open Questions](#9-open-questions)
1414+1515+---
1616+1717+## 1. Current State
1818+1919+### Lex Extension Architecture
2020+2121+The lex extension lives in `extensions/lex/` and provides a "studio" interface for working with AT Protocol lexicon schemas. Based on the codebase structure:
2222+2323+- **`manifest.json`** -- Extension manifest declaring the lex extension's capabilities, pages, and settings within Peek's extension system.
2424+- **`chain-form.js`** -- The core form generation component. Handles dynamic form rendering from lexicon schema definitions. "Chain" likely refers to the chained/nested nature of lexicon definitions where objects contain refs to other definitions.
2525+- **Background/home scripts** -- Handle lexicon loading, parsing, and data management.
2626+2727+### Current Form Generation
2828+2929+The current system takes a lexicon schema definition and generates an editor form automatically. This is a "raw" editor -- it maps lexicon field types directly to basic HTML form inputs without any domain-specific customization. For example, a `datetime` field gets a plain text input rather than a date picker, and a `ref` to a user DID gets a text input rather than a contact selector.
3030+3131+### AT Protocol Lexicon Format
3232+3333+Lexicons are JSON schema files with this structure:
3434+3535+```json
3636+{
3737+ "lexicon": 1,
3838+ "id": "com.example.calendar.event",
3939+ "defs": {
4040+ "main": {
4141+ "type": "record",
4242+ "description": "A calendar event",
4343+ "record": {
4444+ "type": "object",
4545+ "required": ["title", "startDate"],
4646+ "properties": {
4747+ "title": { "type": "string", "maxLength": 256 },
4848+ "startDate": { "type": "string", "format": "datetime" },
4949+ "endDate": { "type": "string", "format": "datetime" },
5050+ "description": { "type": "string", "maxLength": 10000 },
5151+ "location": { "type": "string" },
5252+ "attendees": {
5353+ "type": "array",
5454+ "items": { "type": "string", "format": "did" }
5555+ }
5656+ }
5757+ }
5858+ }
5959+ }
6060+}
6161+```
6262+6363+**Lexicon field types available:**
6464+- Primitives: `string`, `integer`, `boolean`, `number`
6565+- Binary: `bytes`, `blob`
6666+- Structured: `object`, `array`, `ref`, `union`
6767+- String formats: `datetime`, `uri`, `at-uri`, `did`, `handle`, `cid`, `at-identifier`, `nsid`, `tid`, `record-key`, `language`
6868+- Constraints: `minLength`, `maxLength`, `minimum`, `maximum`, `enum`, `const`, `default`
6969+7070+---
7171+7272+## 2. Template Set Data Model
7373+7474+### Core Concept
7575+7676+A **template set** is a bundle of three templates bound to one or more lexicon NSIDs:
7777+7878+```
7979+TemplateSet
8080+ |-- targets: [nsid, ...] // which lexicon(s) this applies to
8181+ |-- list: ListTemplate // collection display
8282+ |-- detail: DetailTemplate // single record display
8383+ |-- edit: EditTemplate // record editor
8484+ |-- metadata: { author, version, description, ... }
8585+```
8686+8787+### Schema
8888+8989+```json
9090+{
9191+ "lexicon": 1,
9292+ "id": "app.peek.lex.templateSet",
9393+ "defs": {
9494+ "main": {
9595+ "type": "record",
9696+ "record": {
9797+ "type": "object",
9898+ "required": ["targets", "name"],
9999+ "properties": {
100100+ "name": { "type": "string", "maxLength": 128 },
101101+ "description": { "type": "string", "maxLength": 1024 },
102102+ "version": { "type": "string", "maxLength": 32 },
103103+ "targets": {
104104+ "type": "array",
105105+ "items": { "type": "string", "format": "nsid" },
106106+ "description": "Lexicon NSIDs this template set applies to"
107107+ },
108108+ "listTemplate": { "type": "ref", "ref": "#listTemplate" },
109109+ "detailTemplate": { "type": "ref", "ref": "#detailTemplate" },
110110+ "editTemplate": { "type": "ref", "ref": "#editTemplate" }
111111+ }
112112+ }
113113+ },
114114+ "listTemplate": {
115115+ "type": "object",
116116+ "required": ["layout", "itemTemplate"],
117117+ "properties": {
118118+ "layout": {
119119+ "type": "string",
120120+ "knownValues": ["table", "grid", "calendar", "timeline", "kanban", "list"]
121121+ },
122122+ "columns": {
123123+ "type": "array",
124124+ "items": { "type": "ref", "ref": "#columnDef" },
125125+ "description": "For table/grid layouts, column definitions"
126126+ },
127127+ "itemTemplate": {
128128+ "type": "string",
129129+ "description": "HTML template string for each item in the list"
130130+ },
131131+ "sortDefault": { "type": "string" },
132132+ "groupBy": { "type": "string" }
133133+ }
134134+ },
135135+ "detailTemplate": {
136136+ "type": "object",
137137+ "required": ["template"],
138138+ "properties": {
139139+ "template": {
140140+ "type": "string",
141141+ "description": "HTML template string for the detail view"
142142+ },
143143+ "sections": {
144144+ "type": "array",
145145+ "items": { "type": "ref", "ref": "#sectionDef" }
146146+ }
147147+ }
148148+ },
149149+ "editTemplate": {
150150+ "type": "object",
151151+ "required": ["fields"],
152152+ "properties": {
153153+ "fields": {
154154+ "type": "array",
155155+ "items": { "type": "ref", "ref": "#fieldDef" }
156156+ },
157157+ "layout": {
158158+ "type": "string",
159159+ "knownValues": ["vertical", "horizontal", "grid", "tabs", "wizard"]
160160+ },
161161+ "validation": {
162162+ "type": "string",
163163+ "description": "Optional extra validation rules beyond lexicon constraints"
164164+ }
165165+ }
166166+ },
167167+ "fieldDef": {
168168+ "type": "object",
169169+ "required": ["path", "widget"],
170170+ "properties": {
171171+ "path": {
172172+ "type": "string",
173173+ "description": "Dot-notation path to the field in the record, e.g. 'startDate'"
174174+ },
175175+ "widget": {
176176+ "type": "string",
177177+ "knownValues": [
178178+ "text", "textarea", "richtext", "number", "toggle",
179179+ "date", "datetime", "time", "daterange",
180180+ "select", "multiselect", "radio", "checkbox",
181181+ "did-picker", "handle-picker", "uri-input",
182182+ "blob-upload", "image-upload",
183183+ "json-editor", "color-picker", "slider",
184184+ "tag-picker", "location-picker"
185185+ ]
186186+ },
187187+ "label": { "type": "string" },
188188+ "placeholder": { "type": "string" },
189189+ "helpText": { "type": "string" },
190190+ "hidden": { "type": "boolean" },
191191+ "readOnly": { "type": "boolean" },
192192+ "width": { "type": "string", "knownValues": ["full", "half", "third", "quarter"] }
193193+ }
194194+ },
195195+ "columnDef": {
196196+ "type": "object",
197197+ "required": ["path"],
198198+ "properties": {
199199+ "path": { "type": "string" },
200200+ "label": { "type": "string" },
201201+ "width": { "type": "string" },
202202+ "sortable": { "type": "boolean" },
203203+ "formatter": { "type": "string", "description": "Named formatter: date, relative-time, truncate, etc." }
204204+ }
205205+ },
206206+ "sectionDef": {
207207+ "type": "object",
208208+ "required": ["title", "fields"],
209209+ "properties": {
210210+ "title": { "type": "string" },
211211+ "fields": { "type": "array", "items": { "type": "string" } },
212212+ "collapsed": { "type": "boolean" }
213213+ }
214214+ }
215215+ }
216216+}
217217+```
218218+219219+### Template Resolution Order
220220+221221+When displaying a lexicon record, resolve the template in this order:
222222+223223+1. **User-selected template** -- explicitly chosen for this lexicon in settings
224224+2. **Pinned template** -- user has pinned a template for this NSID
225225+3. **Community default** -- highest-rated template for this NSID (if discovery is available)
226226+4. **Auto-generated** -- the current default form generation (always available as fallback)
227227+228228+---
229229+230230+## 3. Fiddle UI Design
231231+232232+### Layout: Four-Pane Split View
233233+234234+```
235235++------------------------------------------+
236236+| Studio Toolbar |
237237+| [Lexicon Selector] [Template Selector] |
238238++----------+----------+-------------------+
239239+| | | |
240240+| Raw | Generated| Template |
241241+| JSON | Form | Editor |
242242+| | (current | |
243243+| | default)| |
244244+| | | |
245245++----------+----------+-------------------+
246246+| | |
247247+| Template Rendered | (Editor cont'd |
248248+| Preview | or Logs/Errors) |
249249+| | |
250250++---------------------+-------------------+
251251+```
252252+253253+### Pane Descriptions
254254+255255+**Pane 1: Raw JSON (top-left)**
256256+- Shows the raw lexicon schema definition and/or a sample record instance
257257+- Toggle between schema view and record view
258258+- Editable -- changes propagate to the other panes live
259259+- Syntax highlighting with JSON validation
260260+- Can load example records from a PDS or generate sample data from the schema
261261+262262+**Pane 2: Generated Form (top-center)**
263263+- The current auto-generated editor -- always available as baseline
264264+- Shows what the default `chain-form.js` produces
265265+- Non-editable in this context (it is the reference implementation)
266266+- Useful for comparing against the template-rendered version
267267+268268+**Pane 3: Template Editor (right side, full height)**
269269+- Code editor for the active template (list, detail, or edit -- tab selector at top)
270270+- Template syntax with autocomplete for field paths from the active lexicon
271271+- Widget palette sidebar: drag-and-drop widgets onto the template
272272+- Validation indicators showing template errors
273273+- "Insert field" button that shows available fields from the lexicon schema
274274+275275+**Pane 4: Template Rendered Preview (bottom-left)**
276276+- Live preview of the template applied to the current JSON data
277277+- Hot-reloads as the template editor or JSON changes
278278+- Toggle between list/detail/edit views
279279+- Shows the template as the end user would see it
280280+- Includes a sample data generator for list views (N items)
281281+282282+### Interaction Model
283283+284284+- **Resize handles** between all panes (drag to resize)
285285+- **Collapse/expand** any pane (double-click divider or button)
286286+- **Tab bar** at top of template editor switches between list/detail/edit templates
287287+- **Sync indicator** shows when panes are in sync vs. when changes are pending
288288+- **Keyboard shortcuts**: Cmd+1/2/3/4 to focus panes, Cmd+S to save template, Cmd+P to toggle preview
289289+- **Error overlay** on preview pane when template has syntax errors, with clickable error messages that jump to the template editor line
290290+291291+### Data Flow
292292+293293+```
294294+Lexicon Schema ──> Auto-generate sample data ──> Raw JSON pane
295295+ | |
296296+ v v
297297+ Generated Form Template Engine
298298+ (default chain-form) |
299299+ v
300300+ Rendered Preview
301301+ ^
302302+ |
303303+ Template Editor
304304+```
305305+306306+---
307307+308308+## 4. Template Language Options
309309+310310+### Option A: HTML Templates with Mustache-style Bindings
311311+312312+```html
313313+<!-- Detail template for a calendar event -->
314314+<div class="event-detail">
315315+ <h1>{{title}}</h1>
316316+ <div class="event-dates">
317317+ <peek-date value="{{startDate}}" format="long" />
318318+ {{#if endDate}}
319319+ <span> to </span>
320320+ <peek-date value="{{endDate}}" format="long" />
321321+ {{/if}}
322322+ </div>
323323+ <div class="description">{{description}}</div>
324324+ {{#each attendees}}
325325+ <peek-identity did="{{this}}" />
326326+ {{/each}}
327327+</div>
328328+```
329329+330330+**Pros:**
331331+- Familiar to web developers
332332+- Easy to implement (many template engines available: Handlebars, Mustache, lit-html)
333333+- Full styling control via CSS
334334+- Can reuse Peek's existing web component library (peek-card, peek-input, etc.)
335335+- Easy to render in a sandboxed context
336336+337337+**Cons:**
338338+- Security concerns with arbitrary HTML (XSS risk if templates come from untrusted sources)
339339+- Requires sanitization layer
340340+- Templates can become complex and hard to validate
341341+- Not inherently typed -- template can reference fields that don't exist
342342+343343+### Option B: Declarative JSON Schema (Recommended for Edit Templates)
344344+345345+```json
346346+{
347347+ "layout": "vertical",
348348+ "fields": [
349349+ { "path": "title", "widget": "text", "label": "Event Title", "width": "full" },
350350+ {
351351+ "layout": "horizontal",
352352+ "fields": [
353353+ { "path": "startDate", "widget": "datetime", "label": "Start" },
354354+ { "path": "endDate", "widget": "datetime", "label": "End" }
355355+ ]
356356+ },
357357+ { "path": "description", "widget": "richtext", "label": "Description" },
358358+ { "path": "attendees", "widget": "did-picker", "label": "Attendees", "multiple": true },
359359+ { "path": "location", "widget": "location-picker", "label": "Location" }
360360+ ]
361361+}
362362+```
363363+364364+**Pros:**
365365+- Fully type-safe and validatable against the lexicon schema
366366+- No XSS risk -- it is pure data
367367+- Can be auto-generated from lexicon schema as a starting point
368368+- Easy to build a visual editor on top of (drag-and-drop)
369369+- Naturally serializable as a lexicon record (templates-as-lexicons)
370370+371371+**Cons:**
372372+- Limited expressiveness for complex layouts
373373+- Custom styling requires a separate mechanism
374374+- Harder to do fully custom detail/list views
375375+376376+### Option C: DASL Web Tiles
377377+378378+Web Tiles (from the DASL specification by Robin Berjon) are composable, sandboxed web documents packaged with content-addressed manifests. A tile is essentially:
379379+380380+- A MASL manifest (CBOR metadata document) listing all resources by CID
381381+- HTML/CSS/JS content served from content-addressed storage
382382+- Sandboxed execution (no network egress beyond pre-listed resources)
383383+- Composable -- tiles can be nested and communicate via message passing
384384+385385+```
386386+Tile Manifest (MASL):
387387+{
388388+ name: "Calendar Event Template",
389389+ resources: {
390390+ "/": { $type: "blob", ref: { $link: "bafy..." }, mimeType: "text/html" },
391391+ "/style.css": { $type: "blob", ref: { $link: "bafy..." }, mimeType: "text/css" }
392392+ }
393393+}
394394+```
395395+396396+**Pros:**
397397+- Content-addressed and verifiable -- users can trust that a template has not been tampered with
398398+- Sandboxed -- no exfiltration risk even with arbitrary HTML/JS
399399+- Native to the atproto ecosystem (uses CIDs, CBOR, same primitives)
400400+- Excellent for discovery and sharing (tiles are self-contained packages)
401401+- `@dasl/tiles` npm package provides `renderCard()` and `renderContent()` APIs
402402+- Publishing via `atile publish` command
403403+404404+**Cons:**
405405+- Heavy dependency for what could be simple templates
406406+- MASL is a metadata/manifest format, not a template language -- you still need HTML templates inside the tile
407407+- Content addressing adds complexity (CIDs, CAR files, IPFS)
408408+- Overkill for simple field-to-widget mappings
409409+- Young specification, limited ecosystem maturity
410410+- The tile sandbox may make it harder to integrate with Peek's existing component library
411411+412412+### Option D: Hybrid Approach (Recommended)
413413+414414+Use a **layered system** with increasing expressiveness:
415415+416416+| Layer | Format | Use Case |
417417+|-------|--------|----------|
418418+| **Field mappings** | Declarative JSON | Edit templates -- mapping lexicon fields to widgets |
419419+| **Layout templates** | HTML with Handlebars + Peek web components | Detail and list templates -- expressive rendering |
420420+| **Packaged templates** | DASL Web Tiles | Shared/published templates -- sandboxed, content-addressed |
421421+422422+This gives:
423423+- Simple templates stay simple (JSON field mappings)
424424+- Complex templates have full HTML expressiveness
425425+- Published templates get the security and addressing benefits of Web Tiles
426426+- The fiddle UI can work at any layer
427427+428428+### MASL Evaluation Summary
429429+430430+MASL (Metadata for Arbitrary Structures and Links) is specifically a **metadata and manifest format**, not a template definition language. It describes resources (files, blobs) with their CIDs, sizes, and MIME types. It is the manifest format for Web Tiles, not the template language itself.
431431+432432+**MASL is NOT suitable as a template definition format** because:
433433+- It describes metadata about resources, not how to render them
434434+- It has no concept of field bindings, layouts, or widgets
435435+- It is CBOR-based, not human-editable
436436+437437+**However**, MASL is the right format for **packaging and distributing** template sets. A published template set would be a Web Tile with a MASL manifest, containing the actual template files (HTML, JSON, CSS) as content-addressed resources inside.
438438+439439+---
440440+441441+## 5. Templates as Lexicons (Meta-Circular Design)
442442+443443+### The Meta-Circular Property
444444+445445+Template sets should themselves be lexicon records. This means:
446446+447447+1. A template set for `com.example.calendar.event` is itself a record of type `app.peek.lex.templateSet`
448448+2. That record can be stored in a user's PDS
449449+3. Other users can create template sets for `app.peek.lex.templateSet` itself (templates for templates)
450450+4. The lex studio can use its own template system to render template records
451451+452452+### Storage Architecture
453453+454454+```
455455+User's PDS Repository
456456+ |
457457+ |-- app.peek.lex.templateSet/
458458+ | |-- 3k2... (template set record for com.example.calendar.event)
459459+ | |-- 3k3... (template set record for app.bsky.feed.post)
460460+ | |-- 3k4... (template set record for another lexicon)
461461+ |
462462+ |-- app.peek.lex.templateSetPreference/
463463+ | |-- 3k5... (which template set is active for which lexicon)
464464+```
465465+466466+### Template Set Record Structure
467467+468468+```json
469469+{
470470+ "$type": "app.peek.lex.templateSet",
471471+ "name": "Calendar Pro",
472472+ "description": "Rich calendar views for event lexicons",
473473+ "version": "1.2.0",
474474+ "author": "did:plc:abc123...",
475475+ "targets": ["com.example.calendar.event"],
476476+ "listTemplate": {
477477+ "layout": "calendar",
478478+ "itemTemplate": "<div class=\"cal-event\" style=\"background: {{color}}\">...",
479479+ "sortDefault": "startDate"
480480+ },
481481+ "detailTemplate": {
482482+ "template": "<article class=\"event-detail\">...",
483483+ "sections": [
484484+ { "title": "When", "fields": ["startDate", "endDate"] },
485485+ { "title": "Where", "fields": ["location"] },
486486+ { "title": "Who", "fields": ["attendees"] }
487487+ ]
488488+ },
489489+ "editTemplate": {
490490+ "layout": "tabs",
491491+ "fields": [
492492+ { "path": "title", "widget": "text", "label": "Event Title" },
493493+ { "path": "startDate", "widget": "datetime", "label": "Starts" },
494494+ { "path": "endDate", "widget": "datetime", "label": "Ends" },
495495+ { "path": "attendees", "widget": "did-picker", "label": "Invite", "multiple": true }
496496+ ]
497497+ },
498498+ "createdAt": "2026-02-23T10:00:00Z"
499499+}
500500+```
501501+502502+### Self-Hosting Bootstrap
503503+504504+The system needs a bootstrap template for `app.peek.lex.templateSet` itself. This ships as a built-in default:
505505+506506+```
507507+app.peek.lex.templateSet (the lexicon for template sets)
508508+ |-- Built-in default template (list: table of template sets, detail: preview, edit: template editor)
509509+ |-- Users can override with their own template for template sets
510510+```
511511+512512+### Sharing Flow
513513+514514+1. User creates a template set in the fiddle UI
515515+2. Saves it to their PDS as an `app.peek.lex.templateSet` record
516516+3. (Optional) Publishes as a Web Tile for content-addressed distribution
517517+4. Other users discover via feed, search, or direct link
518518+5. Other users save a copy to their own PDS (or reference the original)
519519+520520+---
521521+522522+## 6. Widget System
523523+524524+### Field Type to Widget Mapping
525525+526526+The widget system maps lexicon field types and formats to appropriate UI widgets. Each widget handles both display (detail view) and input (edit view).
527527+528528+| Lexicon Type | Format/Constraint | Default Widget | Alternative Widgets |
529529+|---|---|---|---|
530530+| `string` | (none) | `text` | `textarea`, `richtext`, `select` |
531531+| `string` | `maxLength > 256` | `textarea` | `richtext` |
532532+| `string` | `datetime` | `datetime` | `date`, `time`, `daterange` |
533533+| `string` | `uri` | `uri-input` | `text` |
534534+| `string` | `at-uri` | `at-uri-picker` | `text` |
535535+| `string` | `did` | `did-picker` | `handle-picker`, `text` |
536536+| `string` | `handle` | `handle-picker` | `text` |
537537+| `string` | `language` | `language-select` | `text` |
538538+| `string` | `enum` values | `select` | `radio` |
539539+| `integer` | (none) | `number` | `slider`, `rating` |
540540+| `integer` | `min`/`max` defined | `slider` | `number` |
541541+| `boolean` | (none) | `toggle` | `checkbox` |
542542+| `number` | (none) | `number` | `slider` |
543543+| `blob` | (none) | `blob-upload` | `image-upload` |
544544+| `bytes` | (none) | `blob-upload` | `text` (base64) |
545545+| `array` | items: string | `tag-picker` | `multiselect`, `text-list` |
546546+| `array` | items: ref | `ref-list` | `inline-editor` |
547547+| `ref` | (none) | `ref-picker` | `inline-editor` |
548548+| `union` | (none) | `union-selector` | (auto from variants) |
549549+| `object` | (none) | `nested-form` | `json-editor` |
550550+551551+### Widget Interface
552552+553553+Each widget should implement a standard interface:
554554+555555+```
556556+Widget Contract:
557557+ - render(value, schema, options) -> DOM element
558558+ - getValue() -> field value
559559+ - validate(value, schema) -> { valid: boolean, errors: string[] }
560560+ - onChange(callback) -> void
561561+ - setReadOnly(boolean) -> void
562562+```
563563+564564+### Custom Widgets
565565+566566+Users (and template authors) should be able to create custom widgets. A custom widget is essentially a small Web Tile:
567567+568568+```json
569569+{
570570+ "widget": "custom",
571571+ "customWidget": {
572572+ "name": "color-swatch-picker",
573573+ "tile": "bafy...abc",
574574+ "config": { "palette": ["#ff0000", "#00ff00", "#0000ff"] }
575575+ }
576576+}
577577+```
578578+579579+### Built-in Widget Library
580580+581581+The following widgets should ship with the studio:
582582+583583+**Text Inputs:** `text`, `textarea`, `richtext` (with markdown toolbar), `code` (with syntax highlighting)
584584+585585+**Selectors:** `select` (dropdown), `multiselect` (tags), `radio`, `checkbox`, `toggle`
586586+587587+**Date/Time:** `date`, `time`, `datetime`, `daterange` (two dates with visual range)
588588+589589+**Identity:** `did-picker` (search for users by handle, shows avatar), `handle-picker`
590590+591591+**Media:** `blob-upload` (drag-drop file), `image-upload` (with preview and crop)
592592+593593+**Numeric:** `number`, `slider`, `rating` (star rating)
594594+595595+**Structured:** `json-editor` (raw JSON with validation), `nested-form` (recursive form generation), `ref-picker` (search and select a record by AT URI)
596596+597597+**Special:** `tag-picker` (integrates with Peek's tag system), `location-picker` (map-based), `color-picker`
598598+599599+### Display Formatters (for List/Detail Views)
600600+601601+| Formatter | Input | Output |
602602+|---|---|---|
603603+| `date` | ISO datetime string | Localized date (e.g., "Feb 23, 2026") |
604604+| `relative-time` | ISO datetime string | Relative (e.g., "3 hours ago") |
605605+| `truncate` | Long string | First N chars with ellipsis |
606606+| `identity` | DID string | Display name + avatar |
607607+| `link` | URI string | Clickable link |
608608+| `image` | Blob ref | Thumbnail image |
609609+| `markdown` | Markdown string | Rendered HTML |
610610+| `json` | Any value | Syntax-highlighted JSON |
611611+| `filesize` | Integer (bytes) | Human-readable (e.g., "2.3 MB") |
612612+| `boolean` | Boolean | Check/cross icon |
613613+614614+---
615615+616616+## 7. Discovery and Sharing
617617+618618+### Storage Layers
619619+620620+**Local Storage (Extension Settings)**
621621+- User's active template set preferences
622622+- Draft templates in progress
623623+- Recently used templates
624624+625625+**PDS Storage (Lexicon Records)**
626626+- Published template sets as `app.peek.lex.templateSet` records
627627+- Template set preferences as `app.peek.lex.templateSetPreference` records
628628+- Synced across devices via Peek's sync infrastructure
629629+630630+**Content-Addressed Distribution (Web Tiles)**
631631+- Published templates packaged as DASL Web Tiles
632632+- Immutable, verifiable, self-contained
633633+- Discoverable via CID
634634+635635+### Discovery Mechanisms
636636+637637+**1. Built-in Template Gallery**
638638+- Ships with templates for common lexicon types (bsky post, bsky profile, calendar events, etc.)
639639+- Curated list maintained by Peek
640640+641641+**2. Community Feed**
642642+- A dedicated feed/collection of template set records
643643+- Users can "like" or "repost" template sets
644644+- Sorted by popularity, recency, or relevance to the user's installed lexicons
645645+646646+**3. Lexicon-Scoped Search**
647647+- When viewing a lexicon in the studio, show available templates for that NSID
648648+- "Find templates for this lexicon" button
649649+- Query user's PDS, followed templates, and (if available) a template index
650650+651651+**4. Direct Sharing**
652652+- Share via AT URI: `at://did:plc:abc/app.peek.lex.templateSet/3k2...`
653653+- Share via CID (for Web Tile version)
654654+- Import from URL or file
655655+656656+### Publishing Flow
657657+658658+```
659659+[Create in Fiddle] --> [Save to PDS] --> [Optional: Package as Web Tile]
660660+ | |
661661+ v v
662662+ [Visible to followers] [Content-addressed, shareable]
663663+ |
664664+ v
665665+ [Discoverable in gallery]
666666+```
667667+668668+### Trust and Safety
669669+670670+- Templates from untrusted sources run in sandboxed iframes (Web Tile model)
671671+- Locally authored templates can run with full access to Peek components
672672+- Template metadata includes author DID for accountability
673673+- Users can report problematic templates
674674+- Built-in templates are signed/verified
675675+676676+---
677677+678678+## 8. Implementation Phases
679679+680680+### Phase 1: Foundation (Edit Templates Only)
681681+682682+**Goal:** Replace the default generated form with a configurable one.
683683+684684+- Define the `fieldDef` and `editTemplate` JSON schema
685685+- Build the widget registry with 10 core widgets: `text`, `textarea`, `number`, `toggle`, `select`, `date`, `datetime`, `did-picker`, `blob-upload`, `json-editor`
686686+- Implement auto-generation of an `editTemplate` from a lexicon schema (replacing current chain-form behavior with a configurable equivalent)
687687+- Add a "Customize Form" button that opens a simple field editor (reorder fields, change widgets, set labels)
688688+- Store custom edit templates in extension settings (local only)
689689+690690+**Validates:** Widget system works, field mapping is correct, the customization UX is usable.
691691+692692+### Phase 2: Fiddle UI (Two-Pane Start)
693693+694694+**Goal:** A side-by-side editor for templates.
695695+696696+- Build a split-pane view: template JSON editor on one side, rendered preview on the other
697697+- Add the Raw JSON pane (schema + sample data)
698698+- Live preview updates as template changes
699699+- Tab bar for switching between edit/detail/list template editing
700700+- Basic detail template support using HTML with Handlebars syntax
701701+- Integrate display formatters for the detail view
702702+703703+**Validates:** The fiddle interaction model, live preview, template editing workflow.
704704+705705+### Phase 3: List Templates and Layouts
706706+707707+**Goal:** Collection-level display templates.
708708+709709+- Implement list template rendering engine with layout types: `table`, `grid`, `list`
710710+- Column definitions with sortable headers
711711+- Item template rendering (HTML per item)
712712+- Calendar layout for datetime-heavy lexicons
713713+- Sample data generation: create N records from a schema for list preview
714714+715715+**Validates:** List views work across layout types, template expressiveness is sufficient.
716716+717717+### Phase 4: Templates as Lexicons
718718+719719+**Goal:** Store and sync templates via PDS.
720720+721721+- Define and register `app.peek.lex.templateSet` lexicon
722722+- Save/load templates to/from user's PDS
723723+- Template set selection UI: per-lexicon template picker
724724+- Sync template preferences across devices via existing Peek sync
725725+- Import templates from AT URI
726726+727727+**Validates:** Meta-circular storage works, sync integration, multi-device experience.
728728+729729+### Phase 5: Full Fiddle (Four-Pane)
730730+731731+**Goal:** Complete the studio experience.
732732+733733+- Four-pane layout with resizable panels
734734+- Generated form pane (reference view)
735735+- Widget palette with drag-and-drop into templates
736736+- Template validation and error reporting
737737+- Field autocomplete in the template editor
738738+- Keyboard shortcuts and power-user features
739739+740740+**Validates:** Full editing workflow, developer experience is productive.
741741+742742+### Phase 6: Web Tile Packaging and Discovery
743743+744744+**Goal:** Share templates with the community.
745745+746746+- Integrate `@dasl/tiles` for packaging templates as Web Tiles
747747+- Publish workflow: template -> Web Tile -> sharable CID
748748+- Template gallery UI within the studio
749749+- Search and browse templates by target lexicon
750750+- Install templates from gallery to local PDS
751751+- Sandboxed rendering for third-party templates
752752+753753+**Validates:** End-to-end sharing flow, security model for untrusted templates.
754754+755755+### Phase 7: Custom Widgets and Advanced Features
756756+757757+**Goal:** Extensibility and polish.
758758+759759+- Custom widget API: register new widgets as Web Tiles
760760+- Widget marketplace (discover and install community widgets)
761761+- Template inheritance: extend a template set rather than replacing it entirely
762762+- Template versioning and migration
763763+- Conditional field visibility in edit templates
764764+- Computed/derived fields in detail/list templates
765765+766766+---
767767+768768+## 9. Open Questions
769769+770770+### Template Language
771771+772772+1. **Handlebars vs. lit-html vs. something else?** Handlebars is logic-less and safe but limited. lit-html is more powerful but requires a JS runtime in the template. A purpose-built mini-language could thread the needle.
773773+774774+2. **CSS in templates:** Should templates include their own CSS? Scoped styles? Or must they use a predefined set of utility classes / Peek's design tokens?
775775+776776+3. **Should edit templates also support HTML mode?** Or is the declarative JSON schema sufficient? Some edit UIs (e.g., a multi-step wizard with conditional fields) may need more expressiveness.
777777+778778+### Security
779779+780780+4. **Sandboxing model for HTML templates:** If a template includes `<script>`, how is it sandboxed? Web Tiles solve this for published templates, but what about locally-authored ones?
781781+782782+5. **Template validation:** How strictly should templates be validated? Can a template reference fields not in the lexicon schema? (Useful for forward-compatibility, risky for errors.)
783783+784784+### Data and Storage
785785+786786+6. **Template size limits:** How large can a template set be as a PDS record? Lexicon records have size constraints. Large templates with embedded CSS/images may need blob references.
787787+788788+7. **Template versioning:** When a lexicon schema changes (new fields added), should existing templates auto-update? How to handle schema migrations in templates?
789789+790790+8. **Template set inheritance:** Can a template set extend another? (e.g., "like Calendar Pro but with a dark theme") What is the override/merge model?
791791+792792+### UX
793793+794794+9. **Visual template editor:** Phase 1 uses a code editor for templates. Should there eventually be a WYSIWYG / drag-and-drop editor? How complex can that get before it becomes a full app builder?
795795+796796+10. **Mobile/responsive:** How do templates behave on Peek mobile? Are separate mobile templates needed, or should templates be responsive by default?
797797+798798+### Ecosystem
799799+800800+11. **Compatibility with other atproto clients:** If templates are stored as lexicon records, could other atproto apps also use them? Should the template format be standardized beyond Peek?
801801+802802+12. **DASL maturity:** Web Tiles is a young specification. Is it stable enough to build on? What is the fallback if the spec changes significantly?
803803+804804+13. **Relationship to existing atproto lexicon tooling:** Tools like `@atproto/lex` already generate types from lexicons. Can we leverage that for template validation and autocomplete?
805805+806806+14. **Community governance:** Who curates the template gallery? How are templates moderated? What prevents a popular template from being updated maliciously?
807807+808808+---
809809+810810+## References
811811+812812+- [AT Protocol Lexicon Specification](https://atproto.com/specs/lexicon)
813813+- [Lexicon Schema System (DeepWiki)](https://deepwiki.com/bluesky-social/atproto/2.3-lexicon-schema-system)
814814+- [DASL -- Data-Addressed Structures and Links](https://dasl.ing/)
815815+- [DASL: MASL Specification](https://dasl.ing/masl.html)
816816+- [DASL: Web Tiles Specification](https://dasl.ing/tiles.html)
817817+- [Web Tiles (webtil.es)](https://webtil.es/)
818818+- [Web Tiles Explainer (Robin Berjon)](https://berjon.com/web-tiles/)
819819+- [Web Tiles Explainer (HackMD)](https://hackmd.io/@robin-berjon/tiles)
820820+- [@dasl/tiles npm Package](https://www.npmjs.com/package/@dasl/tiles)
821821+- [Lexicon Style Guide Discussion](https://github.com/bluesky-social/atproto/discussions/4245)
+79
extensions/tag-actions/background.html
···11+<!DOCTYPE html>
22+<html>
33+<head>
44+ <meta charset="UTF-8">
55+ <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';">
66+ <title>Tag Actions Extension</title>
77+</head>
88+<body>
99+<script type="module">
1010+ import extension from './background.js';
1111+1212+ // Feature detection - check if Peek API is available
1313+ const hasPeekAPI = typeof window.app !== 'undefined';
1414+ const api = hasPeekAPI ? window.app : null;
1515+ const extId = extension.id;
1616+1717+ console.log(`[ext:${extId}] background.html loaded`);
1818+ console.log(`[ext:${extId}] Peek API available:`, hasPeekAPI);
1919+2020+ if (hasPeekAPI) {
2121+ // Running as a Peek extension - full functionality
2222+2323+ // Signal ready to main process
2424+ api.publish('ext:ready', {
2525+ id: extId,
2626+ manifest: {
2727+ id: extension.id,
2828+ labels: extension.labels,
2929+ version: '1.0.0'
3030+ }
3131+ }, api.scopes.SYSTEM);
3232+3333+ // Initialize extension
3434+ if (extension.init) {
3535+ console.log(`[ext:${extId}] calling init()`);
3636+ extension.init();
3737+ }
3838+3939+ // Handle shutdown request from main process
4040+ api.subscribe('app:shutdown', () => {
4141+ console.log(`[ext:${extId}] received shutdown`);
4242+ if (extension.uninit) {
4343+ extension.uninit();
4444+ }
4545+ }, api.scopes.SYSTEM);
4646+4747+ // Handle extension-specific shutdown
4848+ api.subscribe(`ext:${extId}:shutdown`, () => {
4949+ console.log(`[ext:${extId}] received extension shutdown`);
5050+ if (extension.uninit) {
5151+ extension.uninit();
5252+ }
5353+ }, api.scopes.SYSTEM);
5454+5555+ } else {
5656+ // Running as a regular website - limited functionality
5757+ console.log(`[ext:${extId}] Running in standalone mode (no Peek API)`);
5858+5959+ if (extension.init) {
6060+ extension.init();
6161+ }
6262+6363+ document.body.innerHTML = `
6464+ <div style="font-family: system-ui; padding: 20px; max-width: 600px; margin: 0 auto;">
6565+ <h1>Tag Actions Extension</h1>
6666+ <p>Running in standalone mode (Peek API not available).</p>
6767+ <p>When running inside Peek, this extension provides:</p>
6868+ <ul>
6969+ <li>Custom actions triggered by tags</li>
7070+ <li>Icon badges, event publishing, URL opening, auto-tagging</li>
7171+ <li>Global shortcut (Option+Shift+T to open)</li>
7272+ </ul>
7373+ <p><a href="./home.html">Open Tag Actions →</a></p>
7474+ </div>
7575+ `;
7676+ }
7777+</script>
7878+</body>
7979+</html>
+516
extensions/tag-actions/background.js
···11+/**
22+ * Tag Actions Extension - Background Script
33+ *
44+ * Listens to tag:item-added and tag:item-removed pubsub events,
55+ * matches against configured tag-action rules, and executes actions.
66+ *
77+ * Action types:
88+ * - icon: Visual badge on items (broadcast rules for other UIs to render)
99+ * - publish: Fire a custom pubsub event
1010+ * - open-url: Open a URL template with item data substituted
1111+ * - tag: Automatically add another tag
1212+ *
1313+ * Runs in isolated extension process (peek://ext/tag-actions/background.html)
1414+ */
1515+1616+import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js';
1717+1818+const api = window.app;
1919+const debug = api.debug;
2020+2121+// Default settings
2222+const defaults = {
2323+ prefs: { autoExecute: true },
2424+ actions: []
2525+};
2626+2727+// In-memory cache
2828+let currentActions = [];
2929+let currentPrefs = { autoExecute: true };
3030+3131+// Circular trigger prevention: tracks action IDs currently being executed
3232+// within a single event chain to prevent infinite loops
3333+let executingActionIds = new Set();
3434+3535+// ==================== Settings ====================
3636+3737+/**
3838+ * Load actions and prefs from extension settings
3939+ */
4040+const loadSettings = async () => {
4141+ const result = await api.settings.get();
4242+ if (result.success && result.data) {
4343+ currentActions = result.data.actions || defaults.actions;
4444+ currentPrefs = result.data.prefs || defaults.prefs;
4545+ } else {
4646+ currentActions = defaults.actions;
4747+ currentPrefs = defaults.prefs;
4848+ }
4949+ debug && console.log('[tag-actions] Loaded settings:', currentActions.length, 'actions');
5050+};
5151+5252+/**
5353+ * Save current state to extension settings
5454+ */
5555+const saveSettings = async () => {
5656+ const result = await api.settings.set({
5757+ prefs: currentPrefs,
5858+ actions: currentActions
5959+ });
6060+ if (!result.success) {
6161+ console.error('[tag-actions] Failed to save settings:', result.error);
6262+ }
6363+ return result;
6464+};
6565+6666+/**
6767+ * Generate unique ID for a tag action
6868+ */
6969+const generateId = () => {
7070+ return `ta_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
7171+};
7272+7373+// ==================== Icon Rules ====================
7474+7575+/**
7676+ * Broadcast current icon rules via pubsub so other UIs can render them.
7777+ * Called on init and whenever icon actions change.
7878+ */
7979+const broadcastIconRules = () => {
8080+ const rules = currentActions
8181+ .filter(a => a.enabled && a.actionType === 'icon')
8282+ .map(a => ({
8383+ tag: a.triggerTag,
8484+ icon: a.actionConfig.icon || 'star',
8585+ color: a.actionConfig.color || '#999999',
8686+ tooltip: a.actionConfig.tooltip || ''
8787+ }));
8888+8989+ api.publish('tag-actions:icon-rules', { rules }, api.scopes.GLOBAL);
9090+ debug && console.log('[tag-actions] Broadcast icon rules:', rules.length);
9191+};
9292+9393+// ==================== Event Handling ====================
9494+9595+/**
9696+ * Handle a tag event (add or remove) by matching against configured actions
9797+ */
9898+const handleTagEvent = async (eventType, msg) => {
9999+ if (!currentPrefs.autoExecute) return;
100100+101101+ const { tagName, itemId, itemType } = msg;
102102+ debug && console.log(`[tag-actions] Tag event: ${eventType}`, tagName, itemId);
103103+104104+ for (const action of currentActions) {
105105+ if (!action.enabled) continue;
106106+ if (action.triggerTag !== tagName) continue;
107107+ if (action.triggerOn !== 'both' && action.triggerOn !== eventType) continue;
108108+109109+ // Item type filter
110110+ if (action.itemTypes && action.itemTypes.length > 0 && !action.itemTypes.includes(itemType)) continue;
111111+112112+ // Filter tag (AND logic) - item must also have this second tag
113113+ if (action.filterTag) {
114114+ const itemTags = await api.datastore.getItemTags(itemId);
115115+ if (!itemTags.success || !itemTags.data.some(t => t.name === action.filterTag)) continue;
116116+ }
117117+118118+ // Circular trigger prevention
119119+ if (executingActionIds.has(action.id)) {
120120+ debug && console.log('[tag-actions] Skipping circular trigger:', action.name);
121121+ continue;
122122+ }
123123+124124+ await executeAction(action, msg);
125125+ }
126126+};
127127+128128+/**
129129+ * Execute a single action
130130+ */
131131+const executeAction = async (action, msg) => {
132132+ debug && console.log('[tag-actions] Executing action:', action.name, action.actionType);
133133+134134+ executingActionIds.add(action.id);
135135+136136+ try {
137137+ switch (action.actionType) {
138138+ case 'icon':
139139+ // Icons are rendered by list UIs based on broadcast rules.
140140+ // Publish a change event so UIs can refresh.
141141+ api.publish('tag-actions:icon-changed', {
142142+ itemId: msg.itemId,
143143+ actionId: action.id
144144+ }, api.scopes.GLOBAL);
145145+ break;
146146+147147+ case 'publish':
148148+ await executePublish(action, msg);
149149+ break;
150150+151151+ case 'open-url':
152152+ await executeOpenUrl(action, msg);
153153+ break;
154154+155155+ case 'tag':
156156+ await executeAddTag(action, msg);
157157+ break;
158158+159159+ default:
160160+ console.warn('[tag-actions] Unknown action type:', action.actionType);
161161+ }
162162+ } catch (err) {
163163+ console.error('[tag-actions] Action execution error:', action.name, err);
164164+ } finally {
165165+ executingActionIds.delete(action.id);
166166+ }
167167+};
168168+169169+/**
170170+ * Execute a "publish" action - fire a custom pubsub event
171171+ */
172172+const executePublish = async (action, msg) => {
173173+ const config = action.actionConfig || {};
174174+ const payload = {
175175+ itemId: msg.itemId,
176176+ tagName: msg.tagName,
177177+ actionId: action.id
178178+ };
179179+180180+ if (config.includeItemData) {
181181+ const item = await api.datastore.getItem(msg.itemId);
182182+ if (item.success && item.data) {
183183+ payload.item = item.data;
184184+ }
185185+ }
186186+187187+ api.publish(config.topic || 'tag-actions:event', payload, api.scopes.GLOBAL);
188188+ debug && console.log('[tag-actions] Published event:', config.topic);
189189+};
190190+191191+/**
192192+ * Execute an "open-url" action - open a URL template with item data substituted
193193+ */
194194+const executeOpenUrl = async (action, msg) => {
195195+ const config = action.actionConfig || {};
196196+ const item = await api.datastore.getItem(msg.itemId);
197197+198198+ if (!item.success || !item.data) {
199199+ console.error('[tag-actions] Could not load item for open-url:', msg.itemId);
200200+ return;
201201+ }
202202+203203+ let url = config.urlTemplate || '';
204204+ url = url.replace('{url}', encodeURIComponent(item.data.content || ''));
205205+ url = url.replace('{id}', encodeURIComponent(msg.itemId));
206206+207207+ // Extract title from metadata
208208+ let title = '';
209209+ if (item.data.metadata) {
210210+ try {
211211+ const meta = typeof item.data.metadata === 'string'
212212+ ? JSON.parse(item.data.metadata)
213213+ : item.data.metadata;
214214+ title = meta.title || '';
215215+ } catch {}
216216+ }
217217+ url = url.replace('{title}', encodeURIComponent(title));
218218+219219+ api.window.open(url, {
220220+ role: 'content',
221221+ width: 800,
222222+ height: 600
223223+ });
224224+ debug && console.log('[tag-actions] Opened URL:', url);
225225+};
226226+227227+/**
228228+ * Execute an "add tag" action - automatically add another tag to the item
229229+ */
230230+const executeAddTag = async (action, msg) => {
231231+ const config = action.actionConfig || {};
232232+ const addTagName = config.addTagName;
233233+234234+ if (!addTagName) {
235235+ console.warn('[tag-actions] Add-tag action has no addTagName configured');
236236+ return;
237237+ }
238238+239239+ const tagResult = await api.datastore.getOrCreateTag(addTagName);
240240+ if (tagResult.success) {
241241+ await api.datastore.tagItem(msg.itemId, tagResult.data.tag.id);
242242+ debug && console.log('[tag-actions] Added tag:', addTagName, 'to item:', msg.itemId);
243243+ } else {
244244+ console.error('[tag-actions] Failed to add tag:', addTagName, tagResult.error);
245245+ }
246246+};
247247+248248+// ==================== CRUD API ====================
249249+250250+/**
251251+ * Create a new tag action
252252+ */
253253+const createAction = async (actionData) => {
254254+ const now = Date.now();
255255+ const action = {
256256+ id: generateId(),
257257+ name: actionData.name || 'Untitled Action',
258258+ enabled: actionData.enabled !== undefined ? actionData.enabled : true,
259259+ triggerTag: actionData.triggerTag || '',
260260+ filterTag: actionData.filterTag || null,
261261+ triggerOn: actionData.triggerOn || 'add',
262262+ actionType: actionData.actionType || 'publish',
263263+ actionConfig: actionData.actionConfig || {},
264264+ itemTypes: actionData.itemTypes || null,
265265+ createdAt: now,
266266+ updatedAt: now
267267+ };
268268+269269+ currentActions.push(action);
270270+ await saveSettings();
271271+272272+ // Re-broadcast icon rules if this is an icon action
273273+ if (action.actionType === 'icon') {
274274+ broadcastIconRules();
275275+ }
276276+277277+ return { success: true, data: action };
278278+};
279279+280280+/**
281281+ * Update an existing tag action
282282+ */
283283+const updateAction = async (actionId, updates) => {
284284+ const index = currentActions.findIndex(a => a.id === actionId);
285285+ if (index === -1) {
286286+ return { success: false, error: 'Action not found' };
287287+ }
288288+289289+ currentActions[index] = {
290290+ ...currentActions[index],
291291+ ...updates,
292292+ updatedAt: Date.now()
293293+ };
294294+295295+ await saveSettings();
296296+297297+ // Re-broadcast icon rules if relevant
298298+ if (currentActions[index].actionType === 'icon' || updates.actionType === 'icon') {
299299+ broadcastIconRules();
300300+ }
301301+302302+ return { success: true, data: currentActions[index] };
303303+};
304304+305305+/**
306306+ * Delete a tag action
307307+ */
308308+const deleteAction = async (actionId) => {
309309+ const index = currentActions.findIndex(a => a.id === actionId);
310310+ if (index === -1) {
311311+ return { success: false, error: 'Action not found' };
312312+ }
313313+314314+ const wasIcon = currentActions[index].actionType === 'icon';
315315+ currentActions.splice(index, 1);
316316+ await saveSettings();
317317+318318+ if (wasIcon) {
319319+ broadcastIconRules();
320320+ }
321321+322322+ return { success: true };
323323+};
324324+325325+/**
326326+ * Get all tag actions
327327+ */
328328+const getActions = () => {
329329+ return { success: true, data: currentActions };
330330+};
331331+332332+/**
333333+ * Get a single tag action by ID
334334+ */
335335+const getAction = (actionId) => {
336336+ const action = currentActions.find(a => a.id === actionId);
337337+ if (!action) {
338338+ return { success: false, error: 'Action not found' };
339339+ }
340340+ return { success: true, data: action };
341341+};
342342+343343+// ==================== UI ====================
344344+345345+/**
346346+ * Open the tag actions home UI
347347+ */
348348+const openTagActions = () => {
349349+ api.window.open('peek://ext/tag-actions/home.html', {
350350+ role: 'workspace',
351351+ key: 'tag-actions-home',
352352+ width: 800,
353353+ height: 600,
354354+ title: 'Tag Actions'
355355+ });
356356+};
357357+358358+// ==================== Commands ====================
359359+360360+const registerCommands = () => {
361361+ registerNoun({
362362+ name: 'tag actions',
363363+ singular: 'tag action',
364364+ description: 'Custom actions triggered by tags',
365365+366366+ query: async ({ search }) => {
367367+ const result = getActions();
368368+ if (!result.success) return { success: false };
369369+ let actions = result.data;
370370+ if (search) {
371371+ const s = search.toLowerCase();
372372+ actions = actions.filter(a =>
373373+ a.name.toLowerCase().includes(s) ||
374374+ a.triggerTag.toLowerCase().includes(s)
375375+ );
376376+ }
377377+ if (actions.length === 0) {
378378+ return { output: 'No tag actions found.', mimeType: 'text/plain' };
379379+ }
380380+ return {
381381+ success: true,
382382+ output: {
383383+ data: actions.map(a => ({
384384+ id: a.id,
385385+ name: a.name,
386386+ enabled: a.enabled,
387387+ triggerTag: a.triggerTag,
388388+ actionType: a.actionType
389389+ })),
390390+ mimeType: 'application/json',
391391+ title: `Tag Actions (${actions.length})`
392392+ }
393393+ };
394394+ },
395395+396396+ browse: async () => { openTagActions(); },
397397+398398+ create: async ({ search }) => {
399399+ const result = await createAction({ name: search || undefined });
400400+ if (result.success) {
401401+ openTagActions();
402402+ }
403403+ return result;
404404+ },
405405+406406+ produces: 'application/json'
407407+ });
408408+};
409409+410410+// ==================== Extension Lifecycle ====================
411411+412412+const init = async () => {
413413+ debug && console.log('[tag-actions] init');
414414+415415+ // Load settings from datastore
416416+ await loadSettings();
417417+418418+ // Register commands
419419+ registerCommands();
420420+421421+ // Subscribe to tag events
422422+ api.subscribe('tag:item-added', async (msg) => {
423423+ await handleTagEvent('add', msg);
424424+ }, api.scopes.GLOBAL);
425425+426426+ api.subscribe('tag:item-removed', async (msg) => {
427427+ await handleTagEvent('remove', msg);
428428+ }, api.scopes.GLOBAL);
429429+430430+ // Respond to icon rule queries from other extensions
431431+ api.subscribe('tag-actions:get-icons', async (msg) => {
432432+ const { itemIds } = msg;
433433+ if (!itemIds || !Array.isArray(itemIds)) return;
434434+435435+ const iconActions = currentActions.filter(a => a.enabled && a.actionType === 'icon');
436436+ const icons = {};
437437+438438+ for (const itemId of itemIds) {
439439+ const itemTags = await api.datastore.getItemTags(itemId);
440440+ if (!itemTags.success) continue;
441441+442442+ const tagNames = new Set(itemTags.data.map(t => t.name));
443443+ const matchingIcons = iconActions
444444+ .filter(a => tagNames.has(a.triggerTag))
445445+ .map(a => ({
446446+ icon: a.actionConfig.icon || 'star',
447447+ color: a.actionConfig.color || '#999999',
448448+ tooltip: a.actionConfig.tooltip || ''
449449+ }));
450450+451451+ if (matchingIcons.length > 0) {
452452+ icons[itemId] = matchingIcons;
453453+ }
454454+ }
455455+456456+ api.publish('tag-actions:icons-response', { icons }, api.scopes.GLOBAL);
457457+ }, api.scopes.GLOBAL);
458458+459459+ // Pubsub API for the home UI to communicate with background
460460+ api.subscribe('tag-actions:get-all', () => {
461461+ api.publish('tag-actions:get-all:response', getActions(), api.scopes.GLOBAL);
462462+ }, api.scopes.GLOBAL);
463463+464464+ api.subscribe('tag-actions:get', (msg) => {
465465+ api.publish('tag-actions:get:response', getAction(msg.actionId), api.scopes.GLOBAL);
466466+ }, api.scopes.GLOBAL);
467467+468468+ api.subscribe('tag-actions:create', async (msg) => {
469469+ const result = await createAction(msg);
470470+ api.publish('tag-actions:create:response', result, api.scopes.GLOBAL);
471471+ }, api.scopes.GLOBAL);
472472+473473+ api.subscribe('tag-actions:update', async (msg) => {
474474+ const result = await updateAction(msg.actionId, msg.updates);
475475+ api.publish('tag-actions:update:response', result, api.scopes.GLOBAL);
476476+ }, api.scopes.GLOBAL);
477477+478478+ api.subscribe('tag-actions:delete', async (msg) => {
479479+ const result = await deleteAction(msg.actionId);
480480+ api.publish('tag-actions:delete:response', result, api.scopes.GLOBAL);
481481+ }, api.scopes.GLOBAL);
482482+483483+ api.subscribe('tag-actions:get-prefs', () => {
484484+ api.publish('tag-actions:get-prefs:response', {
485485+ success: true,
486486+ data: currentPrefs
487487+ }, api.scopes.GLOBAL);
488488+ }, api.scopes.GLOBAL);
489489+490490+ api.subscribe('tag-actions:set-prefs', async (msg) => {
491491+ currentPrefs = { ...currentPrefs, ...msg };
492492+ await saveSettings();
493493+ api.publish('tag-actions:set-prefs:response', {
494494+ success: true,
495495+ data: currentPrefs
496496+ }, api.scopes.GLOBAL);
497497+ }, api.scopes.GLOBAL);
498498+499499+ // Broadcast icon rules on init so any currently-open UIs get them
500500+ broadcastIconRules();
501501+502502+ debug && console.log('[tag-actions] initialized with', currentActions.length, 'actions');
503503+};
504504+505505+const uninit = () => {
506506+ unregisterNoun('tag actions');
507507+};
508508+509509+export default {
510510+ id: 'tag-actions',
511511+ labels: {
512512+ name: 'Tag Actions'
513513+ },
514514+ init,
515515+ uninit
516516+};