experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tagactions): add tag actions extension for configurable tag-based automation

+2797
+821
LEX_TEMPLATES_DESIGN.md
··· 1 + # Lex Studio: Configurable Template System Design 2 + 3 + ## Table of Contents 4 + 5 + 1. [Current State](#1-current-state) 6 + 2. [Template Set Data Model](#2-template-set-data-model) 7 + 3. [Fiddle UI Design](#3-fiddle-ui-design) 8 + 4. [Template Language Options](#4-template-language-options) 9 + 5. [Templates as Lexicons](#5-templates-as-lexicons) 10 + 6. [Widget System](#6-widget-system) 11 + 7. [Discovery and Sharing](#7-discovery-and-sharing) 12 + 8. [Implementation Phases](#8-implementation-phases) 13 + 9. [Open Questions](#9-open-questions) 14 + 15 + --- 16 + 17 + ## 1. Current State 18 + 19 + ### Lex Extension Architecture 20 + 21 + The lex extension lives in `extensions/lex/` and provides a "studio" interface for working with AT Protocol lexicon schemas. Based on the codebase structure: 22 + 23 + - **`manifest.json`** -- Extension manifest declaring the lex extension's capabilities, pages, and settings within Peek's extension system. 24 + - **`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. 25 + - **Background/home scripts** -- Handle lexicon loading, parsing, and data management. 26 + 27 + ### Current Form Generation 28 + 29 + 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. 30 + 31 + ### AT Protocol Lexicon Format 32 + 33 + Lexicons are JSON schema files with this structure: 34 + 35 + ```json 36 + { 37 + "lexicon": 1, 38 + "id": "com.example.calendar.event", 39 + "defs": { 40 + "main": { 41 + "type": "record", 42 + "description": "A calendar event", 43 + "record": { 44 + "type": "object", 45 + "required": ["title", "startDate"], 46 + "properties": { 47 + "title": { "type": "string", "maxLength": 256 }, 48 + "startDate": { "type": "string", "format": "datetime" }, 49 + "endDate": { "type": "string", "format": "datetime" }, 50 + "description": { "type": "string", "maxLength": 10000 }, 51 + "location": { "type": "string" }, 52 + "attendees": { 53 + "type": "array", 54 + "items": { "type": "string", "format": "did" } 55 + } 56 + } 57 + } 58 + } 59 + } 60 + } 61 + ``` 62 + 63 + **Lexicon field types available:** 64 + - Primitives: `string`, `integer`, `boolean`, `number` 65 + - Binary: `bytes`, `blob` 66 + - Structured: `object`, `array`, `ref`, `union` 67 + - String formats: `datetime`, `uri`, `at-uri`, `did`, `handle`, `cid`, `at-identifier`, `nsid`, `tid`, `record-key`, `language` 68 + - Constraints: `minLength`, `maxLength`, `minimum`, `maximum`, `enum`, `const`, `default` 69 + 70 + --- 71 + 72 + ## 2. Template Set Data Model 73 + 74 + ### Core Concept 75 + 76 + A **template set** is a bundle of three templates bound to one or more lexicon NSIDs: 77 + 78 + ``` 79 + TemplateSet 80 + |-- targets: [nsid, ...] // which lexicon(s) this applies to 81 + |-- list: ListTemplate // collection display 82 + |-- detail: DetailTemplate // single record display 83 + |-- edit: EditTemplate // record editor 84 + |-- metadata: { author, version, description, ... } 85 + ``` 86 + 87 + ### Schema 88 + 89 + ```json 90 + { 91 + "lexicon": 1, 92 + "id": "app.peek.lex.templateSet", 93 + "defs": { 94 + "main": { 95 + "type": "record", 96 + "record": { 97 + "type": "object", 98 + "required": ["targets", "name"], 99 + "properties": { 100 + "name": { "type": "string", "maxLength": 128 }, 101 + "description": { "type": "string", "maxLength": 1024 }, 102 + "version": { "type": "string", "maxLength": 32 }, 103 + "targets": { 104 + "type": "array", 105 + "items": { "type": "string", "format": "nsid" }, 106 + "description": "Lexicon NSIDs this template set applies to" 107 + }, 108 + "listTemplate": { "type": "ref", "ref": "#listTemplate" }, 109 + "detailTemplate": { "type": "ref", "ref": "#detailTemplate" }, 110 + "editTemplate": { "type": "ref", "ref": "#editTemplate" } 111 + } 112 + } 113 + }, 114 + "listTemplate": { 115 + "type": "object", 116 + "required": ["layout", "itemTemplate"], 117 + "properties": { 118 + "layout": { 119 + "type": "string", 120 + "knownValues": ["table", "grid", "calendar", "timeline", "kanban", "list"] 121 + }, 122 + "columns": { 123 + "type": "array", 124 + "items": { "type": "ref", "ref": "#columnDef" }, 125 + "description": "For table/grid layouts, column definitions" 126 + }, 127 + "itemTemplate": { 128 + "type": "string", 129 + "description": "HTML template string for each item in the list" 130 + }, 131 + "sortDefault": { "type": "string" }, 132 + "groupBy": { "type": "string" } 133 + } 134 + }, 135 + "detailTemplate": { 136 + "type": "object", 137 + "required": ["template"], 138 + "properties": { 139 + "template": { 140 + "type": "string", 141 + "description": "HTML template string for the detail view" 142 + }, 143 + "sections": { 144 + "type": "array", 145 + "items": { "type": "ref", "ref": "#sectionDef" } 146 + } 147 + } 148 + }, 149 + "editTemplate": { 150 + "type": "object", 151 + "required": ["fields"], 152 + "properties": { 153 + "fields": { 154 + "type": "array", 155 + "items": { "type": "ref", "ref": "#fieldDef" } 156 + }, 157 + "layout": { 158 + "type": "string", 159 + "knownValues": ["vertical", "horizontal", "grid", "tabs", "wizard"] 160 + }, 161 + "validation": { 162 + "type": "string", 163 + "description": "Optional extra validation rules beyond lexicon constraints" 164 + } 165 + } 166 + }, 167 + "fieldDef": { 168 + "type": "object", 169 + "required": ["path", "widget"], 170 + "properties": { 171 + "path": { 172 + "type": "string", 173 + "description": "Dot-notation path to the field in the record, e.g. 'startDate'" 174 + }, 175 + "widget": { 176 + "type": "string", 177 + "knownValues": [ 178 + "text", "textarea", "richtext", "number", "toggle", 179 + "date", "datetime", "time", "daterange", 180 + "select", "multiselect", "radio", "checkbox", 181 + "did-picker", "handle-picker", "uri-input", 182 + "blob-upload", "image-upload", 183 + "json-editor", "color-picker", "slider", 184 + "tag-picker", "location-picker" 185 + ] 186 + }, 187 + "label": { "type": "string" }, 188 + "placeholder": { "type": "string" }, 189 + "helpText": { "type": "string" }, 190 + "hidden": { "type": "boolean" }, 191 + "readOnly": { "type": "boolean" }, 192 + "width": { "type": "string", "knownValues": ["full", "half", "third", "quarter"] } 193 + } 194 + }, 195 + "columnDef": { 196 + "type": "object", 197 + "required": ["path"], 198 + "properties": { 199 + "path": { "type": "string" }, 200 + "label": { "type": "string" }, 201 + "width": { "type": "string" }, 202 + "sortable": { "type": "boolean" }, 203 + "formatter": { "type": "string", "description": "Named formatter: date, relative-time, truncate, etc." } 204 + } 205 + }, 206 + "sectionDef": { 207 + "type": "object", 208 + "required": ["title", "fields"], 209 + "properties": { 210 + "title": { "type": "string" }, 211 + "fields": { "type": "array", "items": { "type": "string" } }, 212 + "collapsed": { "type": "boolean" } 213 + } 214 + } 215 + } 216 + } 217 + ``` 218 + 219 + ### Template Resolution Order 220 + 221 + When displaying a lexicon record, resolve the template in this order: 222 + 223 + 1. **User-selected template** -- explicitly chosen for this lexicon in settings 224 + 2. **Pinned template** -- user has pinned a template for this NSID 225 + 3. **Community default** -- highest-rated template for this NSID (if discovery is available) 226 + 4. **Auto-generated** -- the current default form generation (always available as fallback) 227 + 228 + --- 229 + 230 + ## 3. Fiddle UI Design 231 + 232 + ### Layout: Four-Pane Split View 233 + 234 + ``` 235 + +------------------------------------------+ 236 + | Studio Toolbar | 237 + | [Lexicon Selector] [Template Selector] | 238 + +----------+----------+-------------------+ 239 + | | | | 240 + | Raw | Generated| Template | 241 + | JSON | Form | Editor | 242 + | | (current | | 243 + | | default)| | 244 + | | | | 245 + +----------+----------+-------------------+ 246 + | | | 247 + | Template Rendered | (Editor cont'd | 248 + | Preview | or Logs/Errors) | 249 + | | | 250 + +---------------------+-------------------+ 251 + ``` 252 + 253 + ### Pane Descriptions 254 + 255 + **Pane 1: Raw JSON (top-left)** 256 + - Shows the raw lexicon schema definition and/or a sample record instance 257 + - Toggle between schema view and record view 258 + - Editable -- changes propagate to the other panes live 259 + - Syntax highlighting with JSON validation 260 + - Can load example records from a PDS or generate sample data from the schema 261 + 262 + **Pane 2: Generated Form (top-center)** 263 + - The current auto-generated editor -- always available as baseline 264 + - Shows what the default `chain-form.js` produces 265 + - Non-editable in this context (it is the reference implementation) 266 + - Useful for comparing against the template-rendered version 267 + 268 + **Pane 3: Template Editor (right side, full height)** 269 + - Code editor for the active template (list, detail, or edit -- tab selector at top) 270 + - Template syntax with autocomplete for field paths from the active lexicon 271 + - Widget palette sidebar: drag-and-drop widgets onto the template 272 + - Validation indicators showing template errors 273 + - "Insert field" button that shows available fields from the lexicon schema 274 + 275 + **Pane 4: Template Rendered Preview (bottom-left)** 276 + - Live preview of the template applied to the current JSON data 277 + - Hot-reloads as the template editor or JSON changes 278 + - Toggle between list/detail/edit views 279 + - Shows the template as the end user would see it 280 + - Includes a sample data generator for list views (N items) 281 + 282 + ### Interaction Model 283 + 284 + - **Resize handles** between all panes (drag to resize) 285 + - **Collapse/expand** any pane (double-click divider or button) 286 + - **Tab bar** at top of template editor switches between list/detail/edit templates 287 + - **Sync indicator** shows when panes are in sync vs. when changes are pending 288 + - **Keyboard shortcuts**: Cmd+1/2/3/4 to focus panes, Cmd+S to save template, Cmd+P to toggle preview 289 + - **Error overlay** on preview pane when template has syntax errors, with clickable error messages that jump to the template editor line 290 + 291 + ### Data Flow 292 + 293 + ``` 294 + Lexicon Schema ──> Auto-generate sample data ──> Raw JSON pane 295 + | | 296 + v v 297 + Generated Form Template Engine 298 + (default chain-form) | 299 + v 300 + Rendered Preview 301 + ^ 302 + | 303 + Template Editor 304 + ``` 305 + 306 + --- 307 + 308 + ## 4. Template Language Options 309 + 310 + ### Option A: HTML Templates with Mustache-style Bindings 311 + 312 + ```html 313 + <!-- Detail template for a calendar event --> 314 + <div class="event-detail"> 315 + <h1>{{title}}</h1> 316 + <div class="event-dates"> 317 + <peek-date value="{{startDate}}" format="long" /> 318 + {{#if endDate}} 319 + <span> to </span> 320 + <peek-date value="{{endDate}}" format="long" /> 321 + {{/if}} 322 + </div> 323 + <div class="description">{{description}}</div> 324 + {{#each attendees}} 325 + <peek-identity did="{{this}}" /> 326 + {{/each}} 327 + </div> 328 + ``` 329 + 330 + **Pros:** 331 + - Familiar to web developers 332 + - Easy to implement (many template engines available: Handlebars, Mustache, lit-html) 333 + - Full styling control via CSS 334 + - Can reuse Peek's existing web component library (peek-card, peek-input, etc.) 335 + - Easy to render in a sandboxed context 336 + 337 + **Cons:** 338 + - Security concerns with arbitrary HTML (XSS risk if templates come from untrusted sources) 339 + - Requires sanitization layer 340 + - Templates can become complex and hard to validate 341 + - Not inherently typed -- template can reference fields that don't exist 342 + 343 + ### Option B: Declarative JSON Schema (Recommended for Edit Templates) 344 + 345 + ```json 346 + { 347 + "layout": "vertical", 348 + "fields": [ 349 + { "path": "title", "widget": "text", "label": "Event Title", "width": "full" }, 350 + { 351 + "layout": "horizontal", 352 + "fields": [ 353 + { "path": "startDate", "widget": "datetime", "label": "Start" }, 354 + { "path": "endDate", "widget": "datetime", "label": "End" } 355 + ] 356 + }, 357 + { "path": "description", "widget": "richtext", "label": "Description" }, 358 + { "path": "attendees", "widget": "did-picker", "label": "Attendees", "multiple": true }, 359 + { "path": "location", "widget": "location-picker", "label": "Location" } 360 + ] 361 + } 362 + ``` 363 + 364 + **Pros:** 365 + - Fully type-safe and validatable against the lexicon schema 366 + - No XSS risk -- it is pure data 367 + - Can be auto-generated from lexicon schema as a starting point 368 + - Easy to build a visual editor on top of (drag-and-drop) 369 + - Naturally serializable as a lexicon record (templates-as-lexicons) 370 + 371 + **Cons:** 372 + - Limited expressiveness for complex layouts 373 + - Custom styling requires a separate mechanism 374 + - Harder to do fully custom detail/list views 375 + 376 + ### Option C: DASL Web Tiles 377 + 378 + Web Tiles (from the DASL specification by Robin Berjon) are composable, sandboxed web documents packaged with content-addressed manifests. A tile is essentially: 379 + 380 + - A MASL manifest (CBOR metadata document) listing all resources by CID 381 + - HTML/CSS/JS content served from content-addressed storage 382 + - Sandboxed execution (no network egress beyond pre-listed resources) 383 + - Composable -- tiles can be nested and communicate via message passing 384 + 385 + ``` 386 + Tile Manifest (MASL): 387 + { 388 + name: "Calendar Event Template", 389 + resources: { 390 + "/": { $type: "blob", ref: { $link: "bafy..." }, mimeType: "text/html" }, 391 + "/style.css": { $type: "blob", ref: { $link: "bafy..." }, mimeType: "text/css" } 392 + } 393 + } 394 + ``` 395 + 396 + **Pros:** 397 + - Content-addressed and verifiable -- users can trust that a template has not been tampered with 398 + - Sandboxed -- no exfiltration risk even with arbitrary HTML/JS 399 + - Native to the atproto ecosystem (uses CIDs, CBOR, same primitives) 400 + - Excellent for discovery and sharing (tiles are self-contained packages) 401 + - `@dasl/tiles` npm package provides `renderCard()` and `renderContent()` APIs 402 + - Publishing via `atile publish` command 403 + 404 + **Cons:** 405 + - Heavy dependency for what could be simple templates 406 + - MASL is a metadata/manifest format, not a template language -- you still need HTML templates inside the tile 407 + - Content addressing adds complexity (CIDs, CAR files, IPFS) 408 + - Overkill for simple field-to-widget mappings 409 + - Young specification, limited ecosystem maturity 410 + - The tile sandbox may make it harder to integrate with Peek's existing component library 411 + 412 + ### Option D: Hybrid Approach (Recommended) 413 + 414 + Use a **layered system** with increasing expressiveness: 415 + 416 + | Layer | Format | Use Case | 417 + |-------|--------|----------| 418 + | **Field mappings** | Declarative JSON | Edit templates -- mapping lexicon fields to widgets | 419 + | **Layout templates** | HTML with Handlebars + Peek web components | Detail and list templates -- expressive rendering | 420 + | **Packaged templates** | DASL Web Tiles | Shared/published templates -- sandboxed, content-addressed | 421 + 422 + This gives: 423 + - Simple templates stay simple (JSON field mappings) 424 + - Complex templates have full HTML expressiveness 425 + - Published templates get the security and addressing benefits of Web Tiles 426 + - The fiddle UI can work at any layer 427 + 428 + ### MASL Evaluation Summary 429 + 430 + 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. 431 + 432 + **MASL is NOT suitable as a template definition format** because: 433 + - It describes metadata about resources, not how to render them 434 + - It has no concept of field bindings, layouts, or widgets 435 + - It is CBOR-based, not human-editable 436 + 437 + **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. 438 + 439 + --- 440 + 441 + ## 5. Templates as Lexicons (Meta-Circular Design) 442 + 443 + ### The Meta-Circular Property 444 + 445 + Template sets should themselves be lexicon records. This means: 446 + 447 + 1. A template set for `com.example.calendar.event` is itself a record of type `app.peek.lex.templateSet` 448 + 2. That record can be stored in a user's PDS 449 + 3. Other users can create template sets for `app.peek.lex.templateSet` itself (templates for templates) 450 + 4. The lex studio can use its own template system to render template records 451 + 452 + ### Storage Architecture 453 + 454 + ``` 455 + User's PDS Repository 456 + | 457 + |-- app.peek.lex.templateSet/ 458 + | |-- 3k2... (template set record for com.example.calendar.event) 459 + | |-- 3k3... (template set record for app.bsky.feed.post) 460 + | |-- 3k4... (template set record for another lexicon) 461 + | 462 + |-- app.peek.lex.templateSetPreference/ 463 + | |-- 3k5... (which template set is active for which lexicon) 464 + ``` 465 + 466 + ### Template Set Record Structure 467 + 468 + ```json 469 + { 470 + "$type": "app.peek.lex.templateSet", 471 + "name": "Calendar Pro", 472 + "description": "Rich calendar views for event lexicons", 473 + "version": "1.2.0", 474 + "author": "did:plc:abc123...", 475 + "targets": ["com.example.calendar.event"], 476 + "listTemplate": { 477 + "layout": "calendar", 478 + "itemTemplate": "<div class=\"cal-event\" style=\"background: {{color}}\">...", 479 + "sortDefault": "startDate" 480 + }, 481 + "detailTemplate": { 482 + "template": "<article class=\"event-detail\">...", 483 + "sections": [ 484 + { "title": "When", "fields": ["startDate", "endDate"] }, 485 + { "title": "Where", "fields": ["location"] }, 486 + { "title": "Who", "fields": ["attendees"] } 487 + ] 488 + }, 489 + "editTemplate": { 490 + "layout": "tabs", 491 + "fields": [ 492 + { "path": "title", "widget": "text", "label": "Event Title" }, 493 + { "path": "startDate", "widget": "datetime", "label": "Starts" }, 494 + { "path": "endDate", "widget": "datetime", "label": "Ends" }, 495 + { "path": "attendees", "widget": "did-picker", "label": "Invite", "multiple": true } 496 + ] 497 + }, 498 + "createdAt": "2026-02-23T10:00:00Z" 499 + } 500 + ``` 501 + 502 + ### Self-Hosting Bootstrap 503 + 504 + The system needs a bootstrap template for `app.peek.lex.templateSet` itself. This ships as a built-in default: 505 + 506 + ``` 507 + app.peek.lex.templateSet (the lexicon for template sets) 508 + |-- Built-in default template (list: table of template sets, detail: preview, edit: template editor) 509 + |-- Users can override with their own template for template sets 510 + ``` 511 + 512 + ### Sharing Flow 513 + 514 + 1. User creates a template set in the fiddle UI 515 + 2. Saves it to their PDS as an `app.peek.lex.templateSet` record 516 + 3. (Optional) Publishes as a Web Tile for content-addressed distribution 517 + 4. Other users discover via feed, search, or direct link 518 + 5. Other users save a copy to their own PDS (or reference the original) 519 + 520 + --- 521 + 522 + ## 6. Widget System 523 + 524 + ### Field Type to Widget Mapping 525 + 526 + The widget system maps lexicon field types and formats to appropriate UI widgets. Each widget handles both display (detail view) and input (edit view). 527 + 528 + | Lexicon Type | Format/Constraint | Default Widget | Alternative Widgets | 529 + |---|---|---|---| 530 + | `string` | (none) | `text` | `textarea`, `richtext`, `select` | 531 + | `string` | `maxLength > 256` | `textarea` | `richtext` | 532 + | `string` | `datetime` | `datetime` | `date`, `time`, `daterange` | 533 + | `string` | `uri` | `uri-input` | `text` | 534 + | `string` | `at-uri` | `at-uri-picker` | `text` | 535 + | `string` | `did` | `did-picker` | `handle-picker`, `text` | 536 + | `string` | `handle` | `handle-picker` | `text` | 537 + | `string` | `language` | `language-select` | `text` | 538 + | `string` | `enum` values | `select` | `radio` | 539 + | `integer` | (none) | `number` | `slider`, `rating` | 540 + | `integer` | `min`/`max` defined | `slider` | `number` | 541 + | `boolean` | (none) | `toggle` | `checkbox` | 542 + | `number` | (none) | `number` | `slider` | 543 + | `blob` | (none) | `blob-upload` | `image-upload` | 544 + | `bytes` | (none) | `blob-upload` | `text` (base64) | 545 + | `array` | items: string | `tag-picker` | `multiselect`, `text-list` | 546 + | `array` | items: ref | `ref-list` | `inline-editor` | 547 + | `ref` | (none) | `ref-picker` | `inline-editor` | 548 + | `union` | (none) | `union-selector` | (auto from variants) | 549 + | `object` | (none) | `nested-form` | `json-editor` | 550 + 551 + ### Widget Interface 552 + 553 + Each widget should implement a standard interface: 554 + 555 + ``` 556 + Widget Contract: 557 + - render(value, schema, options) -> DOM element 558 + - getValue() -> field value 559 + - validate(value, schema) -> { valid: boolean, errors: string[] } 560 + - onChange(callback) -> void 561 + - setReadOnly(boolean) -> void 562 + ``` 563 + 564 + ### Custom Widgets 565 + 566 + Users (and template authors) should be able to create custom widgets. A custom widget is essentially a small Web Tile: 567 + 568 + ```json 569 + { 570 + "widget": "custom", 571 + "customWidget": { 572 + "name": "color-swatch-picker", 573 + "tile": "bafy...abc", 574 + "config": { "palette": ["#ff0000", "#00ff00", "#0000ff"] } 575 + } 576 + } 577 + ``` 578 + 579 + ### Built-in Widget Library 580 + 581 + The following widgets should ship with the studio: 582 + 583 + **Text Inputs:** `text`, `textarea`, `richtext` (with markdown toolbar), `code` (with syntax highlighting) 584 + 585 + **Selectors:** `select` (dropdown), `multiselect` (tags), `radio`, `checkbox`, `toggle` 586 + 587 + **Date/Time:** `date`, `time`, `datetime`, `daterange` (two dates with visual range) 588 + 589 + **Identity:** `did-picker` (search for users by handle, shows avatar), `handle-picker` 590 + 591 + **Media:** `blob-upload` (drag-drop file), `image-upload` (with preview and crop) 592 + 593 + **Numeric:** `number`, `slider`, `rating` (star rating) 594 + 595 + **Structured:** `json-editor` (raw JSON with validation), `nested-form` (recursive form generation), `ref-picker` (search and select a record by AT URI) 596 + 597 + **Special:** `tag-picker` (integrates with Peek's tag system), `location-picker` (map-based), `color-picker` 598 + 599 + ### Display Formatters (for List/Detail Views) 600 + 601 + | Formatter | Input | Output | 602 + |---|---|---| 603 + | `date` | ISO datetime string | Localized date (e.g., "Feb 23, 2026") | 604 + | `relative-time` | ISO datetime string | Relative (e.g., "3 hours ago") | 605 + | `truncate` | Long string | First N chars with ellipsis | 606 + | `identity` | DID string | Display name + avatar | 607 + | `link` | URI string | Clickable link | 608 + | `image` | Blob ref | Thumbnail image | 609 + | `markdown` | Markdown string | Rendered HTML | 610 + | `json` | Any value | Syntax-highlighted JSON | 611 + | `filesize` | Integer (bytes) | Human-readable (e.g., "2.3 MB") | 612 + | `boolean` | Boolean | Check/cross icon | 613 + 614 + --- 615 + 616 + ## 7. Discovery and Sharing 617 + 618 + ### Storage Layers 619 + 620 + **Local Storage (Extension Settings)** 621 + - User's active template set preferences 622 + - Draft templates in progress 623 + - Recently used templates 624 + 625 + **PDS Storage (Lexicon Records)** 626 + - Published template sets as `app.peek.lex.templateSet` records 627 + - Template set preferences as `app.peek.lex.templateSetPreference` records 628 + - Synced across devices via Peek's sync infrastructure 629 + 630 + **Content-Addressed Distribution (Web Tiles)** 631 + - Published templates packaged as DASL Web Tiles 632 + - Immutable, verifiable, self-contained 633 + - Discoverable via CID 634 + 635 + ### Discovery Mechanisms 636 + 637 + **1. Built-in Template Gallery** 638 + - Ships with templates for common lexicon types (bsky post, bsky profile, calendar events, etc.) 639 + - Curated list maintained by Peek 640 + 641 + **2. Community Feed** 642 + - A dedicated feed/collection of template set records 643 + - Users can "like" or "repost" template sets 644 + - Sorted by popularity, recency, or relevance to the user's installed lexicons 645 + 646 + **3. Lexicon-Scoped Search** 647 + - When viewing a lexicon in the studio, show available templates for that NSID 648 + - "Find templates for this lexicon" button 649 + - Query user's PDS, followed templates, and (if available) a template index 650 + 651 + **4. Direct Sharing** 652 + - Share via AT URI: `at://did:plc:abc/app.peek.lex.templateSet/3k2...` 653 + - Share via CID (for Web Tile version) 654 + - Import from URL or file 655 + 656 + ### Publishing Flow 657 + 658 + ``` 659 + [Create in Fiddle] --> [Save to PDS] --> [Optional: Package as Web Tile] 660 + | | 661 + v v 662 + [Visible to followers] [Content-addressed, shareable] 663 + | 664 + v 665 + [Discoverable in gallery] 666 + ``` 667 + 668 + ### Trust and Safety 669 + 670 + - Templates from untrusted sources run in sandboxed iframes (Web Tile model) 671 + - Locally authored templates can run with full access to Peek components 672 + - Template metadata includes author DID for accountability 673 + - Users can report problematic templates 674 + - Built-in templates are signed/verified 675 + 676 + --- 677 + 678 + ## 8. Implementation Phases 679 + 680 + ### Phase 1: Foundation (Edit Templates Only) 681 + 682 + **Goal:** Replace the default generated form with a configurable one. 683 + 684 + - Define the `fieldDef` and `editTemplate` JSON schema 685 + - Build the widget registry with 10 core widgets: `text`, `textarea`, `number`, `toggle`, `select`, `date`, `datetime`, `did-picker`, `blob-upload`, `json-editor` 686 + - Implement auto-generation of an `editTemplate` from a lexicon schema (replacing current chain-form behavior with a configurable equivalent) 687 + - Add a "Customize Form" button that opens a simple field editor (reorder fields, change widgets, set labels) 688 + - Store custom edit templates in extension settings (local only) 689 + 690 + **Validates:** Widget system works, field mapping is correct, the customization UX is usable. 691 + 692 + ### Phase 2: Fiddle UI (Two-Pane Start) 693 + 694 + **Goal:** A side-by-side editor for templates. 695 + 696 + - Build a split-pane view: template JSON editor on one side, rendered preview on the other 697 + - Add the Raw JSON pane (schema + sample data) 698 + - Live preview updates as template changes 699 + - Tab bar for switching between edit/detail/list template editing 700 + - Basic detail template support using HTML with Handlebars syntax 701 + - Integrate display formatters for the detail view 702 + 703 + **Validates:** The fiddle interaction model, live preview, template editing workflow. 704 + 705 + ### Phase 3: List Templates and Layouts 706 + 707 + **Goal:** Collection-level display templates. 708 + 709 + - Implement list template rendering engine with layout types: `table`, `grid`, `list` 710 + - Column definitions with sortable headers 711 + - Item template rendering (HTML per item) 712 + - Calendar layout for datetime-heavy lexicons 713 + - Sample data generation: create N records from a schema for list preview 714 + 715 + **Validates:** List views work across layout types, template expressiveness is sufficient. 716 + 717 + ### Phase 4: Templates as Lexicons 718 + 719 + **Goal:** Store and sync templates via PDS. 720 + 721 + - Define and register `app.peek.lex.templateSet` lexicon 722 + - Save/load templates to/from user's PDS 723 + - Template set selection UI: per-lexicon template picker 724 + - Sync template preferences across devices via existing Peek sync 725 + - Import templates from AT URI 726 + 727 + **Validates:** Meta-circular storage works, sync integration, multi-device experience. 728 + 729 + ### Phase 5: Full Fiddle (Four-Pane) 730 + 731 + **Goal:** Complete the studio experience. 732 + 733 + - Four-pane layout with resizable panels 734 + - Generated form pane (reference view) 735 + - Widget palette with drag-and-drop into templates 736 + - Template validation and error reporting 737 + - Field autocomplete in the template editor 738 + - Keyboard shortcuts and power-user features 739 + 740 + **Validates:** Full editing workflow, developer experience is productive. 741 + 742 + ### Phase 6: Web Tile Packaging and Discovery 743 + 744 + **Goal:** Share templates with the community. 745 + 746 + - Integrate `@dasl/tiles` for packaging templates as Web Tiles 747 + - Publish workflow: template -> Web Tile -> sharable CID 748 + - Template gallery UI within the studio 749 + - Search and browse templates by target lexicon 750 + - Install templates from gallery to local PDS 751 + - Sandboxed rendering for third-party templates 752 + 753 + **Validates:** End-to-end sharing flow, security model for untrusted templates. 754 + 755 + ### Phase 7: Custom Widgets and Advanced Features 756 + 757 + **Goal:** Extensibility and polish. 758 + 759 + - Custom widget API: register new widgets as Web Tiles 760 + - Widget marketplace (discover and install community widgets) 761 + - Template inheritance: extend a template set rather than replacing it entirely 762 + - Template versioning and migration 763 + - Conditional field visibility in edit templates 764 + - Computed/derived fields in detail/list templates 765 + 766 + --- 767 + 768 + ## 9. Open Questions 769 + 770 + ### Template Language 771 + 772 + 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. 773 + 774 + 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? 775 + 776 + 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. 777 + 778 + ### Security 779 + 780 + 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? 781 + 782 + 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.) 783 + 784 + ### Data and Storage 785 + 786 + 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. 787 + 788 + 7. **Template versioning:** When a lexicon schema changes (new fields added), should existing templates auto-update? How to handle schema migrations in templates? 789 + 790 + 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? 791 + 792 + ### UX 793 + 794 + 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? 795 + 796 + 10. **Mobile/responsive:** How do templates behave on Peek mobile? Are separate mobile templates needed, or should templates be responsive by default? 797 + 798 + ### Ecosystem 799 + 800 + 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? 801 + 802 + 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? 803 + 804 + 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? 805 + 806 + 14. **Community governance:** Who curates the template gallery? How are templates moderated? What prevents a popular template from being updated maliciously? 807 + 808 + --- 809 + 810 + ## References 811 + 812 + - [AT Protocol Lexicon Specification](https://atproto.com/specs/lexicon) 813 + - [Lexicon Schema System (DeepWiki)](https://deepwiki.com/bluesky-social/atproto/2.3-lexicon-schema-system) 814 + - [DASL -- Data-Addressed Structures and Links](https://dasl.ing/) 815 + - [DASL: MASL Specification](https://dasl.ing/masl.html) 816 + - [DASL: Web Tiles Specification](https://dasl.ing/tiles.html) 817 + - [Web Tiles (webtil.es)](https://webtil.es/) 818 + - [Web Tiles Explainer (Robin Berjon)](https://berjon.com/web-tiles/) 819 + - [Web Tiles Explainer (HackMD)](https://hackmd.io/@robin-berjon/tiles) 820 + - [@dasl/tiles npm Package](https://www.npmjs.com/package/@dasl/tiles) 821 + - [Lexicon Style Guide Discussion](https://github.com/bluesky-social/atproto/discussions/4245)
+79
extensions/tag-actions/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Tag Actions Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + // Feature detection - check if Peek API is available 13 + const hasPeekAPI = typeof window.app !== 'undefined'; 14 + const api = hasPeekAPI ? window.app : null; 15 + const extId = extension.id; 16 + 17 + console.log(`[ext:${extId}] background.html loaded`); 18 + console.log(`[ext:${extId}] Peek API available:`, hasPeekAPI); 19 + 20 + if (hasPeekAPI) { 21 + // Running as a Peek extension - full functionality 22 + 23 + // Signal ready to main process 24 + api.publish('ext:ready', { 25 + id: extId, 26 + manifest: { 27 + id: extension.id, 28 + labels: extension.labels, 29 + version: '1.0.0' 30 + } 31 + }, api.scopes.SYSTEM); 32 + 33 + // Initialize extension 34 + if (extension.init) { 35 + console.log(`[ext:${extId}] calling init()`); 36 + extension.init(); 37 + } 38 + 39 + // Handle shutdown request from main process 40 + api.subscribe('app:shutdown', () => { 41 + console.log(`[ext:${extId}] received shutdown`); 42 + if (extension.uninit) { 43 + extension.uninit(); 44 + } 45 + }, api.scopes.SYSTEM); 46 + 47 + // Handle extension-specific shutdown 48 + api.subscribe(`ext:${extId}:shutdown`, () => { 49 + console.log(`[ext:${extId}] received extension shutdown`); 50 + if (extension.uninit) { 51 + extension.uninit(); 52 + } 53 + }, api.scopes.SYSTEM); 54 + 55 + } else { 56 + // Running as a regular website - limited functionality 57 + console.log(`[ext:${extId}] Running in standalone mode (no Peek API)`); 58 + 59 + if (extension.init) { 60 + extension.init(); 61 + } 62 + 63 + document.body.innerHTML = ` 64 + <div style="font-family: system-ui; padding: 20px; max-width: 600px; margin: 0 auto;"> 65 + <h1>Tag Actions Extension</h1> 66 + <p>Running in standalone mode (Peek API not available).</p> 67 + <p>When running inside Peek, this extension provides:</p> 68 + <ul> 69 + <li>Custom actions triggered by tags</li> 70 + <li>Icon badges, event publishing, URL opening, auto-tagging</li> 71 + <li>Global shortcut (Option+Shift+T to open)</li> 72 + </ul> 73 + <p><a href="./home.html">Open Tag Actions &rarr;</a></p> 74 + </div> 75 + `; 76 + } 77 + </script> 78 + </body> 79 + </html>
+516
extensions/tag-actions/background.js
··· 1 + /** 2 + * Tag Actions Extension - Background Script 3 + * 4 + * Listens to tag:item-added and tag:item-removed pubsub events, 5 + * matches against configured tag-action rules, and executes actions. 6 + * 7 + * Action types: 8 + * - icon: Visual badge on items (broadcast rules for other UIs to render) 9 + * - publish: Fire a custom pubsub event 10 + * - open-url: Open a URL template with item data substituted 11 + * - tag: Automatically add another tag 12 + * 13 + * Runs in isolated extension process (peek://ext/tag-actions/background.html) 14 + */ 15 + 16 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 17 + 18 + const api = window.app; 19 + const debug = api.debug; 20 + 21 + // Default settings 22 + const defaults = { 23 + prefs: { autoExecute: true }, 24 + actions: [] 25 + }; 26 + 27 + // In-memory cache 28 + let currentActions = []; 29 + let currentPrefs = { autoExecute: true }; 30 + 31 + // Circular trigger prevention: tracks action IDs currently being executed 32 + // within a single event chain to prevent infinite loops 33 + let executingActionIds = new Set(); 34 + 35 + // ==================== Settings ==================== 36 + 37 + /** 38 + * Load actions and prefs from extension settings 39 + */ 40 + const loadSettings = async () => { 41 + const result = await api.settings.get(); 42 + if (result.success && result.data) { 43 + currentActions = result.data.actions || defaults.actions; 44 + currentPrefs = result.data.prefs || defaults.prefs; 45 + } else { 46 + currentActions = defaults.actions; 47 + currentPrefs = defaults.prefs; 48 + } 49 + debug && console.log('[tag-actions] Loaded settings:', currentActions.length, 'actions'); 50 + }; 51 + 52 + /** 53 + * Save current state to extension settings 54 + */ 55 + const saveSettings = async () => { 56 + const result = await api.settings.set({ 57 + prefs: currentPrefs, 58 + actions: currentActions 59 + }); 60 + if (!result.success) { 61 + console.error('[tag-actions] Failed to save settings:', result.error); 62 + } 63 + return result; 64 + }; 65 + 66 + /** 67 + * Generate unique ID for a tag action 68 + */ 69 + const generateId = () => { 70 + return `ta_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; 71 + }; 72 + 73 + // ==================== Icon Rules ==================== 74 + 75 + /** 76 + * Broadcast current icon rules via pubsub so other UIs can render them. 77 + * Called on init and whenever icon actions change. 78 + */ 79 + const broadcastIconRules = () => { 80 + const rules = currentActions 81 + .filter(a => a.enabled && a.actionType === 'icon') 82 + .map(a => ({ 83 + tag: a.triggerTag, 84 + icon: a.actionConfig.icon || 'star', 85 + color: a.actionConfig.color || '#999999', 86 + tooltip: a.actionConfig.tooltip || '' 87 + })); 88 + 89 + api.publish('tag-actions:icon-rules', { rules }, api.scopes.GLOBAL); 90 + debug && console.log('[tag-actions] Broadcast icon rules:', rules.length); 91 + }; 92 + 93 + // ==================== Event Handling ==================== 94 + 95 + /** 96 + * Handle a tag event (add or remove) by matching against configured actions 97 + */ 98 + const handleTagEvent = async (eventType, msg) => { 99 + if (!currentPrefs.autoExecute) return; 100 + 101 + const { tagName, itemId, itemType } = msg; 102 + debug && console.log(`[tag-actions] Tag event: ${eventType}`, tagName, itemId); 103 + 104 + for (const action of currentActions) { 105 + if (!action.enabled) continue; 106 + if (action.triggerTag !== tagName) continue; 107 + if (action.triggerOn !== 'both' && action.triggerOn !== eventType) continue; 108 + 109 + // Item type filter 110 + if (action.itemTypes && action.itemTypes.length > 0 && !action.itemTypes.includes(itemType)) continue; 111 + 112 + // Filter tag (AND logic) - item must also have this second tag 113 + if (action.filterTag) { 114 + const itemTags = await api.datastore.getItemTags(itemId); 115 + if (!itemTags.success || !itemTags.data.some(t => t.name === action.filterTag)) continue; 116 + } 117 + 118 + // Circular trigger prevention 119 + if (executingActionIds.has(action.id)) { 120 + debug && console.log('[tag-actions] Skipping circular trigger:', action.name); 121 + continue; 122 + } 123 + 124 + await executeAction(action, msg); 125 + } 126 + }; 127 + 128 + /** 129 + * Execute a single action 130 + */ 131 + const executeAction = async (action, msg) => { 132 + debug && console.log('[tag-actions] Executing action:', action.name, action.actionType); 133 + 134 + executingActionIds.add(action.id); 135 + 136 + try { 137 + switch (action.actionType) { 138 + case 'icon': 139 + // Icons are rendered by list UIs based on broadcast rules. 140 + // Publish a change event so UIs can refresh. 141 + api.publish('tag-actions:icon-changed', { 142 + itemId: msg.itemId, 143 + actionId: action.id 144 + }, api.scopes.GLOBAL); 145 + break; 146 + 147 + case 'publish': 148 + await executePublish(action, msg); 149 + break; 150 + 151 + case 'open-url': 152 + await executeOpenUrl(action, msg); 153 + break; 154 + 155 + case 'tag': 156 + await executeAddTag(action, msg); 157 + break; 158 + 159 + default: 160 + console.warn('[tag-actions] Unknown action type:', action.actionType); 161 + } 162 + } catch (err) { 163 + console.error('[tag-actions] Action execution error:', action.name, err); 164 + } finally { 165 + executingActionIds.delete(action.id); 166 + } 167 + }; 168 + 169 + /** 170 + * Execute a "publish" action - fire a custom pubsub event 171 + */ 172 + const executePublish = async (action, msg) => { 173 + const config = action.actionConfig || {}; 174 + const payload = { 175 + itemId: msg.itemId, 176 + tagName: msg.tagName, 177 + actionId: action.id 178 + }; 179 + 180 + if (config.includeItemData) { 181 + const item = await api.datastore.getItem(msg.itemId); 182 + if (item.success && item.data) { 183 + payload.item = item.data; 184 + } 185 + } 186 + 187 + api.publish(config.topic || 'tag-actions:event', payload, api.scopes.GLOBAL); 188 + debug && console.log('[tag-actions] Published event:', config.topic); 189 + }; 190 + 191 + /** 192 + * Execute an "open-url" action - open a URL template with item data substituted 193 + */ 194 + const executeOpenUrl = async (action, msg) => { 195 + const config = action.actionConfig || {}; 196 + const item = await api.datastore.getItem(msg.itemId); 197 + 198 + if (!item.success || !item.data) { 199 + console.error('[tag-actions] Could not load item for open-url:', msg.itemId); 200 + return; 201 + } 202 + 203 + let url = config.urlTemplate || ''; 204 + url = url.replace('{url}', encodeURIComponent(item.data.content || '')); 205 + url = url.replace('{id}', encodeURIComponent(msg.itemId)); 206 + 207 + // Extract title from metadata 208 + let title = ''; 209 + if (item.data.metadata) { 210 + try { 211 + const meta = typeof item.data.metadata === 'string' 212 + ? JSON.parse(item.data.metadata) 213 + : item.data.metadata; 214 + title = meta.title || ''; 215 + } catch {} 216 + } 217 + url = url.replace('{title}', encodeURIComponent(title)); 218 + 219 + api.window.open(url, { 220 + role: 'content', 221 + width: 800, 222 + height: 600 223 + }); 224 + debug && console.log('[tag-actions] Opened URL:', url); 225 + }; 226 + 227 + /** 228 + * Execute an "add tag" action - automatically add another tag to the item 229 + */ 230 + const executeAddTag = async (action, msg) => { 231 + const config = action.actionConfig || {}; 232 + const addTagName = config.addTagName; 233 + 234 + if (!addTagName) { 235 + console.warn('[tag-actions] Add-tag action has no addTagName configured'); 236 + return; 237 + } 238 + 239 + const tagResult = await api.datastore.getOrCreateTag(addTagName); 240 + if (tagResult.success) { 241 + await api.datastore.tagItem(msg.itemId, tagResult.data.tag.id); 242 + debug && console.log('[tag-actions] Added tag:', addTagName, 'to item:', msg.itemId); 243 + } else { 244 + console.error('[tag-actions] Failed to add tag:', addTagName, tagResult.error); 245 + } 246 + }; 247 + 248 + // ==================== CRUD API ==================== 249 + 250 + /** 251 + * Create a new tag action 252 + */ 253 + const createAction = async (actionData) => { 254 + const now = Date.now(); 255 + const action = { 256 + id: generateId(), 257 + name: actionData.name || 'Untitled Action', 258 + enabled: actionData.enabled !== undefined ? actionData.enabled : true, 259 + triggerTag: actionData.triggerTag || '', 260 + filterTag: actionData.filterTag || null, 261 + triggerOn: actionData.triggerOn || 'add', 262 + actionType: actionData.actionType || 'publish', 263 + actionConfig: actionData.actionConfig || {}, 264 + itemTypes: actionData.itemTypes || null, 265 + createdAt: now, 266 + updatedAt: now 267 + }; 268 + 269 + currentActions.push(action); 270 + await saveSettings(); 271 + 272 + // Re-broadcast icon rules if this is an icon action 273 + if (action.actionType === 'icon') { 274 + broadcastIconRules(); 275 + } 276 + 277 + return { success: true, data: action }; 278 + }; 279 + 280 + /** 281 + * Update an existing tag action 282 + */ 283 + const updateAction = async (actionId, updates) => { 284 + const index = currentActions.findIndex(a => a.id === actionId); 285 + if (index === -1) { 286 + return { success: false, error: 'Action not found' }; 287 + } 288 + 289 + currentActions[index] = { 290 + ...currentActions[index], 291 + ...updates, 292 + updatedAt: Date.now() 293 + }; 294 + 295 + await saveSettings(); 296 + 297 + // Re-broadcast icon rules if relevant 298 + if (currentActions[index].actionType === 'icon' || updates.actionType === 'icon') { 299 + broadcastIconRules(); 300 + } 301 + 302 + return { success: true, data: currentActions[index] }; 303 + }; 304 + 305 + /** 306 + * Delete a tag action 307 + */ 308 + const deleteAction = async (actionId) => { 309 + const index = currentActions.findIndex(a => a.id === actionId); 310 + if (index === -1) { 311 + return { success: false, error: 'Action not found' }; 312 + } 313 + 314 + const wasIcon = currentActions[index].actionType === 'icon'; 315 + currentActions.splice(index, 1); 316 + await saveSettings(); 317 + 318 + if (wasIcon) { 319 + broadcastIconRules(); 320 + } 321 + 322 + return { success: true }; 323 + }; 324 + 325 + /** 326 + * Get all tag actions 327 + */ 328 + const getActions = () => { 329 + return { success: true, data: currentActions }; 330 + }; 331 + 332 + /** 333 + * Get a single tag action by ID 334 + */ 335 + const getAction = (actionId) => { 336 + const action = currentActions.find(a => a.id === actionId); 337 + if (!action) { 338 + return { success: false, error: 'Action not found' }; 339 + } 340 + return { success: true, data: action }; 341 + }; 342 + 343 + // ==================== UI ==================== 344 + 345 + /** 346 + * Open the tag actions home UI 347 + */ 348 + const openTagActions = () => { 349 + api.window.open('peek://ext/tag-actions/home.html', { 350 + role: 'workspace', 351 + key: 'tag-actions-home', 352 + width: 800, 353 + height: 600, 354 + title: 'Tag Actions' 355 + }); 356 + }; 357 + 358 + // ==================== Commands ==================== 359 + 360 + const registerCommands = () => { 361 + registerNoun({ 362 + name: 'tag actions', 363 + singular: 'tag action', 364 + description: 'Custom actions triggered by tags', 365 + 366 + query: async ({ search }) => { 367 + const result = getActions(); 368 + if (!result.success) return { success: false }; 369 + let actions = result.data; 370 + if (search) { 371 + const s = search.toLowerCase(); 372 + actions = actions.filter(a => 373 + a.name.toLowerCase().includes(s) || 374 + a.triggerTag.toLowerCase().includes(s) 375 + ); 376 + } 377 + if (actions.length === 0) { 378 + return { output: 'No tag actions found.', mimeType: 'text/plain' }; 379 + } 380 + return { 381 + success: true, 382 + output: { 383 + data: actions.map(a => ({ 384 + id: a.id, 385 + name: a.name, 386 + enabled: a.enabled, 387 + triggerTag: a.triggerTag, 388 + actionType: a.actionType 389 + })), 390 + mimeType: 'application/json', 391 + title: `Tag Actions (${actions.length})` 392 + } 393 + }; 394 + }, 395 + 396 + browse: async () => { openTagActions(); }, 397 + 398 + create: async ({ search }) => { 399 + const result = await createAction({ name: search || undefined }); 400 + if (result.success) { 401 + openTagActions(); 402 + } 403 + return result; 404 + }, 405 + 406 + produces: 'application/json' 407 + }); 408 + }; 409 + 410 + // ==================== Extension Lifecycle ==================== 411 + 412 + const init = async () => { 413 + debug && console.log('[tag-actions] init'); 414 + 415 + // Load settings from datastore 416 + await loadSettings(); 417 + 418 + // Register commands 419 + registerCommands(); 420 + 421 + // Subscribe to tag events 422 + api.subscribe('tag:item-added', async (msg) => { 423 + await handleTagEvent('add', msg); 424 + }, api.scopes.GLOBAL); 425 + 426 + api.subscribe('tag:item-removed', async (msg) => { 427 + await handleTagEvent('remove', msg); 428 + }, api.scopes.GLOBAL); 429 + 430 + // Respond to icon rule queries from other extensions 431 + api.subscribe('tag-actions:get-icons', async (msg) => { 432 + const { itemIds } = msg; 433 + if (!itemIds || !Array.isArray(itemIds)) return; 434 + 435 + const iconActions = currentActions.filter(a => a.enabled && a.actionType === 'icon'); 436 + const icons = {}; 437 + 438 + for (const itemId of itemIds) { 439 + const itemTags = await api.datastore.getItemTags(itemId); 440 + if (!itemTags.success) continue; 441 + 442 + const tagNames = new Set(itemTags.data.map(t => t.name)); 443 + const matchingIcons = iconActions 444 + .filter(a => tagNames.has(a.triggerTag)) 445 + .map(a => ({ 446 + icon: a.actionConfig.icon || 'star', 447 + color: a.actionConfig.color || '#999999', 448 + tooltip: a.actionConfig.tooltip || '' 449 + })); 450 + 451 + if (matchingIcons.length > 0) { 452 + icons[itemId] = matchingIcons; 453 + } 454 + } 455 + 456 + api.publish('tag-actions:icons-response', { icons }, api.scopes.GLOBAL); 457 + }, api.scopes.GLOBAL); 458 + 459 + // Pubsub API for the home UI to communicate with background 460 + api.subscribe('tag-actions:get-all', () => { 461 + api.publish('tag-actions:get-all:response', getActions(), api.scopes.GLOBAL); 462 + }, api.scopes.GLOBAL); 463 + 464 + api.subscribe('tag-actions:get', (msg) => { 465 + api.publish('tag-actions:get:response', getAction(msg.actionId), api.scopes.GLOBAL); 466 + }, api.scopes.GLOBAL); 467 + 468 + api.subscribe('tag-actions:create', async (msg) => { 469 + const result = await createAction(msg); 470 + api.publish('tag-actions:create:response', result, api.scopes.GLOBAL); 471 + }, api.scopes.GLOBAL); 472 + 473 + api.subscribe('tag-actions:update', async (msg) => { 474 + const result = await updateAction(msg.actionId, msg.updates); 475 + api.publish('tag-actions:update:response', result, api.scopes.GLOBAL); 476 + }, api.scopes.GLOBAL); 477 + 478 + api.subscribe('tag-actions:delete', async (msg) => { 479 + const result = await deleteAction(msg.actionId); 480 + api.publish('tag-actions:delete:response', result, api.scopes.GLOBAL); 481 + }, api.scopes.GLOBAL); 482 + 483 + api.subscribe('tag-actions:get-prefs', () => { 484 + api.publish('tag-actions:get-prefs:response', { 485 + success: true, 486 + data: currentPrefs 487 + }, api.scopes.GLOBAL); 488 + }, api.scopes.GLOBAL); 489 + 490 + api.subscribe('tag-actions:set-prefs', async (msg) => { 491 + currentPrefs = { ...currentPrefs, ...msg }; 492 + await saveSettings(); 493 + api.publish('tag-actions:set-prefs:response', { 494 + success: true, 495 + data: currentPrefs 496 + }, api.scopes.GLOBAL); 497 + }, api.scopes.GLOBAL); 498 + 499 + // Broadcast icon rules on init so any currently-open UIs get them 500 + broadcastIconRules(); 501 + 502 + debug && console.log('[tag-actions] initialized with', currentActions.length, 'actions'); 503 + }; 504 + 505 + const uninit = () => { 506 + unregisterNoun('tag actions'); 507 + }; 508 + 509 + export default { 510 + id: 'tag-actions', 511 + labels: { 512 + name: 'Tag Actions' 513 + }, 514 + init, 515 + uninit 516 + };
+396
extensions/tag-actions/home.css
··· 1 + /* Import theme variables */ 2 + @import url('peek://theme/variables.css'); 3 + 4 + * { 5 + box-sizing: border-box; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + 10 + html { 11 + font-family: var(--theme-font-sans); 12 + -webkit-font-smoothing: antialiased; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + body { 18 + background: var(--base00); 19 + color: var(--base05); 20 + min-height: 100vh; 21 + padding: 16px; 22 + } 23 + 24 + /* Header bar */ 25 + .header-bar { 26 + display: flex; 27 + align-items: center; 28 + justify-content: space-between; 29 + margin-bottom: 12px; 30 + } 31 + 32 + .page-title { 33 + font-size: 18px; 34 + font-weight: 600; 35 + color: var(--base05); 36 + } 37 + 38 + .add-action-btn { 39 + display: flex; 40 + align-items: center; 41 + justify-content: center; 42 + width: 28px; 43 + height: 28px; 44 + background: var(--base0D); 45 + border: none; 46 + border-radius: 5px; 47 + cursor: pointer; 48 + color: var(--base00); 49 + transition: all 0.15s; 50 + flex-shrink: 0; 51 + } 52 + 53 + .add-action-btn:hover { 54 + filter: brightness(1.1); 55 + } 56 + 57 + /* Search */ 58 + .search-container { 59 + margin-bottom: 12px; 60 + } 61 + 62 + peek-input.search-input { 63 + display: block; 64 + width: 100%; 65 + --peek-input-bg: var(--base01); 66 + --peek-input-border: var(--base02); 67 + --peek-input-height: 32px; 68 + } 69 + 70 + peek-input.search-input::part(input) { 71 + color: var(--base05); 72 + font-size: 13px; 73 + } 74 + 75 + /* Actions list */ 76 + .actions-list { 77 + display: flex; 78 + flex-direction: column; 79 + gap: 6px; 80 + } 81 + 82 + /* Action card */ 83 + .action-card { 84 + --peek-card-bg: var(--base01); 85 + --peek-card-hover-bg: var(--base02); 86 + --peek-card-border: transparent; 87 + --peek-card-radius: 6px; 88 + --peek-card-padding: 10px 12px; 89 + --peek-card-gap: 4px; 90 + } 91 + 92 + .action-card[selected] { 93 + --peek-card-bg: var(--base02); 94 + } 95 + 96 + .action-card-header { 97 + display: flex; 98 + align-items: center; 99 + gap: 10px; 100 + width: 100%; 101 + } 102 + 103 + .action-enabled-dot { 104 + width: 8px; 105 + height: 8px; 106 + border-radius: 50%; 107 + flex-shrink: 0; 108 + } 109 + 110 + .action-enabled-dot.enabled { 111 + background: var(--base0B); 112 + } 113 + 114 + .action-enabled-dot.disabled { 115 + background: var(--base03); 116 + } 117 + 118 + .action-card-name { 119 + font-size: 13px; 120 + font-weight: 600; 121 + color: var(--base05); 122 + flex: 1; 123 + min-width: 0; 124 + white-space: nowrap; 125 + overflow: hidden; 126 + text-overflow: ellipsis; 127 + } 128 + 129 + .action-card-summary { 130 + font-size: 11px; 131 + color: var(--base04); 132 + flex-shrink: 0; 133 + } 134 + 135 + .action-card-footer { 136 + display: flex; 137 + align-items: center; 138 + gap: 6px; 139 + } 140 + 141 + .action-type-badge { 142 + font-size: 10px; 143 + padding: 1px 6px; 144 + border-radius: 8px; 145 + background: var(--base02); 146 + color: var(--base04); 147 + } 148 + 149 + /* Empty state */ 150 + .empty-state { 151 + text-align: center; 152 + padding: 40px 16px; 153 + color: var(--base03); 154 + font-size: 13px; 155 + } 156 + 157 + /* Presets section */ 158 + .presets-section { 159 + margin-top: 24px; 160 + padding-top: 16px; 161 + border-top: 1px solid var(--base02); 162 + } 163 + 164 + .presets-header { 165 + font-size: 12px; 166 + font-weight: 600; 167 + color: var(--base04); 168 + text-transform: uppercase; 169 + letter-spacing: 0.5px; 170 + margin-bottom: 8px; 171 + } 172 + 173 + .presets-list { 174 + display: flex; 175 + flex-direction: column; 176 + gap: 6px; 177 + } 178 + 179 + .preset-card { 180 + --peek-card-bg: var(--base01); 181 + --peek-card-hover-bg: var(--base02); 182 + --peek-card-border: 1px dashed var(--base02); 183 + --peek-card-radius: 6px; 184 + --peek-card-padding: 10px 12px; 185 + --peek-card-gap: 4px; 186 + opacity: 0.8; 187 + } 188 + 189 + .preset-card:hover { 190 + opacity: 1; 191 + } 192 + 193 + /* Detail view */ 194 + #detail-view { 195 + display: flex; 196 + flex-direction: column; 197 + gap: 16px; 198 + } 199 + 200 + .detail-header { 201 + display: flex; 202 + align-items: center; 203 + } 204 + 205 + .back-btn { 206 + display: flex; 207 + align-items: center; 208 + gap: 4px; 209 + background: none; 210 + border: none; 211 + color: var(--base0D); 212 + cursor: pointer; 213 + font-size: 13px; 214 + padding: 4px 8px; 215 + border-radius: 4px; 216 + transition: all 0.15s; 217 + } 218 + 219 + .back-btn:hover { 220 + background: var(--base01); 221 + } 222 + 223 + /* Form */ 224 + .detail-form { 225 + display: flex; 226 + flex-direction: column; 227 + gap: 16px; 228 + max-width: 600px; 229 + } 230 + 231 + .form-group { 232 + display: flex; 233 + flex-direction: column; 234 + gap: 4px; 235 + } 236 + 237 + .form-label { 238 + font-size: 12px; 239 + font-weight: 600; 240 + color: var(--base04); 241 + } 242 + 243 + .form-hint { 244 + font-size: 11px; 245 + color: var(--base03); 246 + } 247 + 248 + .form-section { 249 + padding-top: 12px; 250 + border-top: 1px solid var(--base02); 251 + } 252 + 253 + .form-section-title { 254 + font-size: 13px; 255 + font-weight: 600; 256 + color: var(--base05); 257 + margin-bottom: 12px; 258 + } 259 + 260 + /* Form inputs */ 261 + .form-input { 262 + --peek-input-bg: var(--base01); 263 + --peek-input-border: var(--base02); 264 + --peek-input-height: 32px; 265 + } 266 + 267 + .form-input::part(input) { 268 + color: var(--base05); 269 + font-size: 13px; 270 + } 271 + 272 + .form-select { 273 + --peek-select-bg: var(--base01); 274 + --peek-select-border: var(--base02); 275 + --peek-select-height: 32px; 276 + } 277 + 278 + /* Trigger on radio group */ 279 + .radio-group { 280 + display: flex; 281 + gap: 12px; 282 + } 283 + 284 + .radio-option { 285 + display: flex; 286 + align-items: center; 287 + gap: 6px; 288 + cursor: pointer; 289 + font-size: 13px; 290 + color: var(--base05); 291 + } 292 + 293 + .radio-option input[type="radio"] { 294 + accent-color: var(--base0D); 295 + } 296 + 297 + /* Item type checkboxes */ 298 + .checkbox-group { 299 + display: flex; 300 + flex-wrap: wrap; 301 + gap: 10px; 302 + } 303 + 304 + .checkbox-option { 305 + display: flex; 306 + align-items: center; 307 + gap: 6px; 308 + cursor: pointer; 309 + font-size: 13px; 310 + color: var(--base05); 311 + } 312 + 313 + .checkbox-option input[type="checkbox"] { 314 + accent-color: var(--base0D); 315 + } 316 + 317 + /* Icon picker */ 318 + .icon-picker { 319 + display: flex; 320 + flex-wrap: wrap; 321 + gap: 6px; 322 + } 323 + 324 + .icon-option { 325 + display: flex; 326 + align-items: center; 327 + justify-content: center; 328 + width: 32px; 329 + height: 32px; 330 + border: 1px solid var(--base02); 331 + border-radius: 6px; 332 + background: var(--base01); 333 + cursor: pointer; 334 + transition: all 0.15s; 335 + color: var(--base04); 336 + } 337 + 338 + .icon-option:hover { 339 + background: var(--base02); 340 + color: var(--base05); 341 + } 342 + 343 + .icon-option.selected { 344 + border-color: var(--base0D); 345 + background: var(--base0D); 346 + color: var(--base00); 347 + } 348 + 349 + .icon-option svg { 350 + width: 16px; 351 + height: 16px; 352 + } 353 + 354 + /* Color picker */ 355 + .color-picker-row { 356 + display: flex; 357 + align-items: center; 358 + gap: 8px; 359 + } 360 + 361 + .color-picker-row input[type="color"] { 362 + width: 32px; 363 + height: 32px; 364 + border: 1px solid var(--base02); 365 + border-radius: 6px; 366 + padding: 2px; 367 + background: var(--base01); 368 + cursor: pointer; 369 + } 370 + 371 + .color-preview { 372 + font-size: 12px; 373 + color: var(--base04); 374 + font-family: monospace; 375 + } 376 + 377 + /* Form actions */ 378 + .form-actions { 379 + display: flex; 380 + justify-content: space-between; 381 + align-items: center; 382 + padding-top: 16px; 383 + border-top: 1px solid var(--base02); 384 + } 385 + 386 + .form-actions-right { 387 + display: flex; 388 + gap: 8px; 389 + } 390 + 391 + /* Enabled switch row */ 392 + .enabled-row { 393 + display: flex; 394 + align-items: center; 395 + justify-content: space-between; 396 + }
+76
extensions/tag-actions/home.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 + <title>Tag Actions</title> 8 + <link rel="stylesheet" type="text/css" href="home.css"> 9 + 10 + <!-- Import map for resolving bare module specifiers --> 11 + <script type="importmap"> 12 + { 13 + "imports": { 14 + "lit": "peek://node_modules/lit/index.js", 15 + "lit/": "peek://node_modules/lit/", 16 + "lit-html": "peek://node_modules/lit-html/lit-html.js", 17 + "lit-html/": "peek://node_modules/lit-html/", 18 + "lit-element": "peek://node_modules/lit-element/lit-element.js", 19 + "lit-element/": "peek://node_modules/lit-element/", 20 + "@lit/reactive-element": "peek://node_modules/@lit/reactive-element/reactive-element.js", 21 + "@lit/reactive-element/": "peek://node_modules/@lit/reactive-element/" 22 + } 23 + } 24 + </script> 25 + 26 + <!-- Import peek-components --> 27 + <script type="module"> 28 + import 'peek://app/components/peek-card.js'; 29 + import 'peek://app/components/peek-input.js'; 30 + import 'peek://app/components/peek-button.js'; 31 + import 'peek://app/components/peek-select.js'; 32 + import 'peek://app/components/peek-switch.js'; 33 + </script> 34 + </head> 35 + <body> 36 + <!-- List View --> 37 + <div id="list-view"> 38 + <div class="header-bar"> 39 + <h1 class="page-title">Tag Actions</h1> 40 + <button class="add-action-btn" title="Add new tag action"> 41 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 42 + <line x1="12" y1="5" x2="12" y2="19"></line> 43 + <line x1="5" y1="12" x2="19" y2="12"></line> 44 + </svg> 45 + </button> 46 + </div> 47 + <div class="search-container"> 48 + <peek-input 49 + class="search-input" 50 + placeholder="Search actions..." 51 + type="search" 52 + ></peek-input> 53 + </div> 54 + <div class="actions-list"></div> 55 + <div class="presets-section" style="display: none;"> 56 + <div class="presets-header">Suggested Actions</div> 57 + <div class="presets-list"></div> 58 + </div> 59 + </div> 60 + 61 + <!-- Detail/Edit View --> 62 + <div id="detail-view" style="display: none;"> 63 + <div class="detail-header"> 64 + <button class="back-btn"> 65 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 66 + <polyline points="15 18 9 12 15 6"></polyline> 67 + </svg> 68 + Back 69 + </button> 70 + </div> 71 + <div class="detail-form"></div> 72 + </div> 73 + 74 + <script type="module" src="home.js"></script> 75 + </body> 76 + </html>
+848
extensions/tag-actions/home.js
··· 1 + /** 2 + * Tag Actions - Settings/Management UI 3 + * 4 + * List view: shows all configured tag actions with enable/disable toggle 5 + * Detail view: create/edit form for a tag action 6 + * 7 + * Communicates with background.js via pubsub request/response pattern. 8 + */ 9 + 10 + const api = window.app; 11 + const debug = api.debug; 12 + 13 + // ==================== Icon SVGs ==================== 14 + 15 + const ICON_SVGS = { 16 + eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>', 17 + star: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 18 + flag: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>', 19 + bookmark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>', 20 + download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', 21 + archive: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>', 22 + clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>', 23 + check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>', 24 + heart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>', 25 + pin: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg>' 26 + }; 27 + 28 + const ICON_NAMES = Object.keys(ICON_SVGS); 29 + 30 + // ==================== Action Type Labels ==================== 31 + 32 + const ACTION_TYPE_LABELS = { 33 + 'icon': 'Icon', 34 + 'publish': 'Publish Event', 35 + 'open-url': 'Open URL', 36 + 'tag': 'Add Tag' 37 + }; 38 + 39 + const ACTION_TYPES = Object.keys(ACTION_TYPE_LABELS); 40 + 41 + // ==================== Item Types ==================== 42 + 43 + const ITEM_TYPES = ['url', 'text', 'tagset', 'image']; 44 + 45 + // ==================== Preset Actions ==================== 46 + 47 + const PRESET_ACTIONS = [ 48 + { 49 + name: 'Read Later Icon', 50 + triggerTag: 'read-later', 51 + triggerOn: 'add', 52 + actionType: 'icon', 53 + actionConfig: { icon: 'eye', color: '#4a9eff', tooltip: 'Read later' } 54 + }, 55 + { 56 + name: 'Archive to Wayback Machine', 57 + triggerTag: 'archive', 58 + triggerOn: 'add', 59 + actionType: 'open-url', 60 + actionConfig: { urlTemplate: 'https://web.archive.org/save/{url}', openInBackground: true }, 61 + itemTypes: ['url'] 62 + } 63 + ]; 64 + 65 + // ==================== State ==================== 66 + 67 + const VIEW_LIST = 'list'; 68 + const VIEW_DETAIL = 'detail'; 69 + 70 + let state = { 71 + view: VIEW_LIST, 72 + actions: [], 73 + selectedIndex: 0, 74 + searchQuery: '', 75 + editingAction: null, // null = new action, object = editing existing 76 + formData: {} 77 + }; 78 + 79 + // ==================== Pubsub Helpers ==================== 80 + 81 + /** 82 + * Send a pubsub request and wait for the response 83 + */ 84 + const request = (topic, data = {}) => { 85 + return new Promise((resolve) => { 86 + const handler = (msg) => { 87 + resolve(msg); 88 + }; 89 + api.subscribe(`${topic}:response`, handler, api.scopes.GLOBAL); 90 + api.publish(topic, data, api.scopes.GLOBAL); 91 + }); 92 + }; 93 + 94 + // ==================== Data Loading ==================== 95 + 96 + const loadActions = async () => { 97 + const result = await request('tag-actions:get-all'); 98 + if (result.success) { 99 + state.actions = result.data; 100 + } else { 101 + state.actions = []; 102 + } 103 + debug && console.log('[tag-actions:home] Loaded actions:', state.actions.length); 104 + }; 105 + 106 + // ==================== Escape Handler ==================== 107 + 108 + const handleEscape = () => { 109 + // If search has content, clear it first 110 + if (state.searchQuery) { 111 + state.searchQuery = ''; 112 + const searchInput = document.querySelector('peek-input.search-input'); 113 + if (searchInput) searchInput.value = ''; 114 + renderList(); 115 + return { handled: true }; 116 + } 117 + 118 + if (state.view === VIEW_DETAIL) { 119 + showListView(); 120 + return { handled: true }; 121 + } 122 + 123 + // At root - let window close 124 + return { handled: false }; 125 + }; 126 + 127 + // ==================== View Switching ==================== 128 + 129 + const showListView = async () => { 130 + state.view = VIEW_LIST; 131 + state.editingAction = null; 132 + state.formData = {}; 133 + document.getElementById('list-view').style.display = ''; 134 + document.getElementById('detail-view').style.display = 'none'; 135 + await loadActions(); 136 + renderList(); 137 + }; 138 + 139 + const showDetailView = (action = null) => { 140 + state.view = VIEW_DETAIL; 141 + state.editingAction = action; 142 + 143 + if (action) { 144 + // Editing existing 145 + state.formData = { ...action }; 146 + } else { 147 + // Creating new 148 + state.formData = { 149 + name: '', 150 + enabled: true, 151 + triggerTag: '', 152 + filterTag: '', 153 + triggerOn: 'add', 154 + actionType: 'icon', 155 + actionConfig: {}, 156 + itemTypes: [] 157 + }; 158 + } 159 + 160 + document.getElementById('list-view').style.display = 'none'; 161 + document.getElementById('detail-view').style.display = ''; 162 + renderDetailForm(); 163 + }; 164 + 165 + // ==================== List View Rendering ==================== 166 + 167 + const renderList = () => { 168 + const container = document.querySelector('.actions-list'); 169 + container.innerHTML = ''; 170 + 171 + let filtered = state.actions; 172 + if (state.searchQuery) { 173 + const q = state.searchQuery.toLowerCase(); 174 + filtered = filtered.filter(a => 175 + a.name.toLowerCase().includes(q) || 176 + a.triggerTag.toLowerCase().includes(q) || 177 + a.actionType.toLowerCase().includes(q) 178 + ); 179 + } 180 + 181 + if (filtered.length === 0 && !state.searchQuery) { 182 + container.innerHTML = '<div class="empty-state">No tag actions yet. Click + to create one.</div>'; 183 + showPresets(); 184 + return; 185 + } else if (filtered.length === 0) { 186 + container.innerHTML = '<div class="empty-state">No actions match your search.</div>'; 187 + hidePresets(); 188 + return; 189 + } 190 + 191 + filtered.forEach((action, index) => { 192 + const card = createActionCard(action, index); 193 + container.appendChild(card); 194 + }); 195 + 196 + // Show presets if there are no actions at all 197 + if (state.actions.length === 0) { 198 + showPresets(); 199 + } else { 200 + hidePresets(); 201 + } 202 + }; 203 + 204 + const createActionCard = (action, index) => { 205 + const card = document.createElement('peek-card'); 206 + card.className = 'action-card'; 207 + card.interactive = true; 208 + card.dataset.actionId = action.id; 209 + 210 + // Header: enabled dot + name + summary 211 + const header = document.createElement('div'); 212 + header.slot = 'header'; 213 + header.className = 'action-card-header'; 214 + 215 + const dot = document.createElement('div'); 216 + dot.className = `action-enabled-dot ${action.enabled ? 'enabled' : 'disabled'}`; 217 + 218 + const name = document.createElement('span'); 219 + name.className = 'action-card-name'; 220 + name.textContent = action.name || 'Untitled'; 221 + 222 + const summary = document.createElement('span'); 223 + summary.className = 'action-card-summary'; 224 + summary.textContent = `${action.triggerTag || '?'}`; 225 + 226 + header.appendChild(dot); 227 + header.appendChild(name); 228 + header.appendChild(summary); 229 + card.appendChild(header); 230 + 231 + // Footer: action type badge 232 + const footer = document.createElement('div'); 233 + footer.slot = 'footer'; 234 + footer.className = 'action-card-footer'; 235 + 236 + const badge = document.createElement('span'); 237 + badge.className = 'action-type-badge'; 238 + badge.textContent = ACTION_TYPE_LABELS[action.actionType] || action.actionType; 239 + footer.appendChild(badge); 240 + 241 + if (action.actionType === 'icon' && action.actionConfig.icon) { 242 + const iconPreview = document.createElement('span'); 243 + iconPreview.innerHTML = ICON_SVGS[action.actionConfig.icon] || ''; 244 + iconPreview.style.cssText = `display:inline-flex;width:14px;height:14px;color:${action.actionConfig.color || '#999'};`; 245 + footer.appendChild(iconPreview); 246 + } 247 + 248 + card.appendChild(footer); 249 + 250 + card.addEventListener('card-click', () => { 251 + showDetailView(action); 252 + }); 253 + 254 + return card; 255 + }; 256 + 257 + // ==================== Presets ==================== 258 + 259 + const showPresets = () => { 260 + const section = document.querySelector('.presets-section'); 261 + const list = document.querySelector('.presets-list'); 262 + section.style.display = ''; 263 + list.innerHTML = ''; 264 + 265 + // Filter out presets that already exist (by name + triggerTag) 266 + const existingNames = new Set(state.actions.map(a => `${a.name}:${a.triggerTag}`)); 267 + const available = PRESET_ACTIONS.filter(p => !existingNames.has(`${p.name}:${p.triggerTag}`)); 268 + 269 + if (available.length === 0) { 270 + section.style.display = 'none'; 271 + return; 272 + } 273 + 274 + available.forEach(preset => { 275 + const card = document.createElement('peek-card'); 276 + card.className = 'preset-card'; 277 + card.interactive = true; 278 + 279 + const header = document.createElement('div'); 280 + header.slot = 'header'; 281 + header.className = 'action-card-header'; 282 + 283 + const name = document.createElement('span'); 284 + name.className = 'action-card-name'; 285 + name.textContent = preset.name; 286 + 287 + const summary = document.createElement('span'); 288 + summary.className = 'action-card-summary'; 289 + summary.textContent = `${preset.triggerTag}`; 290 + 291 + header.appendChild(name); 292 + header.appendChild(summary); 293 + card.appendChild(header); 294 + 295 + const footer = document.createElement('div'); 296 + footer.slot = 'footer'; 297 + footer.className = 'action-card-footer'; 298 + 299 + const badge = document.createElement('span'); 300 + badge.className = 'action-type-badge'; 301 + badge.textContent = ACTION_TYPE_LABELS[preset.actionType] || preset.actionType; 302 + footer.appendChild(badge); 303 + 304 + const addLabel = document.createElement('span'); 305 + addLabel.style.cssText = 'font-size:11px;color:var(--base0D);'; 306 + addLabel.textContent = 'Click to add'; 307 + footer.appendChild(addLabel); 308 + 309 + card.appendChild(footer); 310 + 311 + card.addEventListener('card-click', async () => { 312 + await request('tag-actions:create', { ...preset }); 313 + await loadActions(); 314 + renderList(); 315 + }); 316 + 317 + list.appendChild(card); 318 + }); 319 + }; 320 + 321 + const hidePresets = () => { 322 + document.querySelector('.presets-section').style.display = 'none'; 323 + }; 324 + 325 + // ==================== Detail Form Rendering ==================== 326 + 327 + const renderDetailForm = () => { 328 + const container = document.querySelector('.detail-form'); 329 + container.innerHTML = ''; 330 + 331 + const fd = state.formData; 332 + 333 + // Name + Enabled row 334 + const nameGroup = createFormGroup('Name'); 335 + const nameInput = document.createElement('peek-input'); 336 + nameInput.className = 'form-input'; 337 + nameInput.value = fd.name || ''; 338 + nameInput.placeholder = 'Action name'; 339 + nameInput.addEventListener('input', (e) => { fd.name = e.target.value; }); 340 + nameGroup.appendChild(nameInput); 341 + container.appendChild(nameGroup); 342 + 343 + // Enabled toggle 344 + const enabledRow = document.createElement('div'); 345 + enabledRow.className = 'enabled-row'; 346 + const enabledLabel = document.createElement('span'); 347 + enabledLabel.className = 'form-label'; 348 + enabledLabel.textContent = 'Enabled'; 349 + const enabledSwitch = document.createElement('peek-switch'); 350 + enabledSwitch.checked = fd.enabled !== false; 351 + enabledSwitch.addEventListener('change', (e) => { 352 + fd.enabled = e.target.checked; 353 + }); 354 + enabledRow.appendChild(enabledLabel); 355 + enabledRow.appendChild(enabledSwitch); 356 + container.appendChild(enabledRow); 357 + 358 + // --- Trigger section --- 359 + const triggerSection = createFormSection('Trigger'); 360 + 361 + // Trigger tag 362 + const tagGroup = createFormGroup('Tag'); 363 + const tagInput = document.createElement('peek-input'); 364 + tagInput.className = 'form-input'; 365 + tagInput.value = fd.triggerTag || ''; 366 + tagInput.placeholder = 'Tag name that triggers this action'; 367 + tagInput.addEventListener('input', (e) => { fd.triggerTag = e.target.value; }); 368 + tagGroup.appendChild(tagInput); 369 + triggerSection.appendChild(tagGroup); 370 + 371 + // Filter tag (optional AND) 372 + const filterGroup = createFormGroup('Also requires tag (optional)'); 373 + const filterInput = document.createElement('peek-input'); 374 + filterInput.className = 'form-input'; 375 + filterInput.value = fd.filterTag || ''; 376 + filterInput.placeholder = 'Optional second tag required (AND logic)'; 377 + filterInput.addEventListener('input', (e) => { fd.filterTag = e.target.value || null; }); 378 + filterGroup.appendChild(filterInput); 379 + triggerSection.appendChild(filterGroup); 380 + 381 + // Trigger on: add / remove / both 382 + const triggerOnGroup = createFormGroup('Trigger on'); 383 + const radioGroup = document.createElement('div'); 384 + radioGroup.className = 'radio-group'; 385 + ['add', 'remove', 'both'].forEach(val => { 386 + const label = document.createElement('label'); 387 + label.className = 'radio-option'; 388 + const radio = document.createElement('input'); 389 + radio.type = 'radio'; 390 + radio.name = 'triggerOn'; 391 + radio.value = val; 392 + radio.checked = fd.triggerOn === val; 393 + radio.addEventListener('change', () => { fd.triggerOn = val; }); 394 + const text = document.createTextNode(val === 'add' ? 'Tag added' : val === 'remove' ? 'Tag removed' : 'Both'); 395 + label.appendChild(radio); 396 + label.appendChild(text); 397 + radioGroup.appendChild(label); 398 + }); 399 + triggerOnGroup.appendChild(radioGroup); 400 + triggerSection.appendChild(triggerOnGroup); 401 + 402 + // Item types filter 403 + const typesGroup = createFormGroup('Item types (leave unchecked for all)'); 404 + const checkboxGroup = document.createElement('div'); 405 + checkboxGroup.className = 'checkbox-group'; 406 + const selectedTypes = fd.itemTypes || []; 407 + ITEM_TYPES.forEach(type => { 408 + const label = document.createElement('label'); 409 + label.className = 'checkbox-option'; 410 + const cb = document.createElement('input'); 411 + cb.type = 'checkbox'; 412 + cb.value = type; 413 + cb.checked = selectedTypes.includes(type); 414 + cb.addEventListener('change', () => { 415 + if (cb.checked) { 416 + if (!fd.itemTypes) fd.itemTypes = []; 417 + fd.itemTypes.push(type); 418 + } else { 419 + fd.itemTypes = (fd.itemTypes || []).filter(t => t !== type); 420 + if (fd.itemTypes.length === 0) fd.itemTypes = null; 421 + } 422 + }); 423 + const text = document.createTextNode(type); 424 + label.appendChild(cb); 425 + label.appendChild(text); 426 + checkboxGroup.appendChild(label); 427 + }); 428 + typesGroup.appendChild(checkboxGroup); 429 + triggerSection.appendChild(typesGroup); 430 + 431 + container.appendChild(triggerSection); 432 + 433 + // --- Action section --- 434 + const actionSection = createFormSection('Action'); 435 + 436 + // Action type selector 437 + const typeGroup = createFormGroup('Type'); 438 + const typeSelect = document.createElement('select'); 439 + typeSelect.className = 'form-select-native'; 440 + typeSelect.style.cssText = 'background:var(--base01);border:1px solid var(--base02);border-radius:4px;color:var(--base05);padding:6px 8px;font-size:13px;width:100%;'; 441 + ACTION_TYPES.forEach(type => { 442 + const opt = document.createElement('option'); 443 + opt.value = type; 444 + opt.textContent = ACTION_TYPE_LABELS[type]; 445 + opt.selected = fd.actionType === type; 446 + typeSelect.appendChild(opt); 447 + }); 448 + typeSelect.addEventListener('change', () => { 449 + fd.actionType = typeSelect.value; 450 + fd.actionConfig = {}; 451 + renderDetailForm(); // Re-render to show type-specific config 452 + }); 453 + typeGroup.appendChild(typeSelect); 454 + actionSection.appendChild(typeGroup); 455 + 456 + // Type-specific config 457 + const configContainer = document.createElement('div'); 458 + renderActionConfig(configContainer, fd); 459 + actionSection.appendChild(configContainer); 460 + 461 + container.appendChild(actionSection); 462 + 463 + // --- Form actions --- 464 + const actions = document.createElement('div'); 465 + actions.className = 'form-actions'; 466 + 467 + if (state.editingAction) { 468 + const deleteBtn = document.createElement('peek-button'); 469 + deleteBtn.variant = 'ghost'; 470 + deleteBtn.textContent = 'Delete Action'; 471 + deleteBtn.style.color = 'var(--base08)'; 472 + deleteBtn.addEventListener('click', async () => { 473 + await request('tag-actions:delete', { actionId: state.editingAction.id }); 474 + showListView(); 475 + }); 476 + actions.appendChild(deleteBtn); 477 + } else { 478 + actions.appendChild(document.createElement('div')); // spacer 479 + } 480 + 481 + const actionsRight = document.createElement('div'); 482 + actionsRight.className = 'form-actions-right'; 483 + 484 + const cancelBtn = document.createElement('peek-button'); 485 + cancelBtn.variant = 'ghost'; 486 + cancelBtn.textContent = 'Cancel'; 487 + cancelBtn.addEventListener('click', () => showListView()); 488 + actionsRight.appendChild(cancelBtn); 489 + 490 + const saveBtn = document.createElement('peek-button'); 491 + saveBtn.variant = 'primary'; 492 + saveBtn.textContent = 'Save Action'; 493 + saveBtn.addEventListener('click', async () => { 494 + await saveAction(); 495 + }); 496 + actionsRight.appendChild(saveBtn); 497 + 498 + actions.appendChild(actionsRight); 499 + container.appendChild(actions); 500 + }; 501 + 502 + // ==================== Action Config Forms ==================== 503 + 504 + const renderActionConfig = (container, fd) => { 505 + const config = fd.actionConfig || {}; 506 + 507 + switch (fd.actionType) { 508 + case 'icon': 509 + renderIconConfig(container, config, fd); 510 + break; 511 + case 'publish': 512 + renderPublishConfig(container, config, fd); 513 + break; 514 + case 'open-url': 515 + renderOpenUrlConfig(container, config, fd); 516 + break; 517 + case 'tag': 518 + renderTagConfig(container, config, fd); 519 + break; 520 + } 521 + }; 522 + 523 + const renderIconConfig = (container, config, fd) => { 524 + // Icon picker 525 + const iconGroup = createFormGroup('Icon'); 526 + const picker = document.createElement('div'); 527 + picker.className = 'icon-picker'; 528 + ICON_NAMES.forEach(iconName => { 529 + const btn = document.createElement('button'); 530 + btn.className = `icon-option ${config.icon === iconName ? 'selected' : ''}`; 531 + btn.innerHTML = ICON_SVGS[iconName]; 532 + btn.title = iconName; 533 + btn.addEventListener('click', () => { 534 + if (!fd.actionConfig) fd.actionConfig = {}; 535 + fd.actionConfig.icon = iconName; 536 + // Update selection visuals 537 + picker.querySelectorAll('.icon-option').forEach(b => b.classList.remove('selected')); 538 + btn.classList.add('selected'); 539 + }); 540 + picker.appendChild(btn); 541 + }); 542 + iconGroup.appendChild(picker); 543 + container.appendChild(iconGroup); 544 + 545 + // Color picker 546 + const colorGroup = createFormGroup('Color'); 547 + const colorRow = document.createElement('div'); 548 + colorRow.className = 'color-picker-row'; 549 + const colorInput = document.createElement('input'); 550 + colorInput.type = 'color'; 551 + colorInput.value = config.color || '#4a9eff'; 552 + const colorPreview = document.createElement('span'); 553 + colorPreview.className = 'color-preview'; 554 + colorPreview.textContent = colorInput.value; 555 + colorInput.addEventListener('input', () => { 556 + if (!fd.actionConfig) fd.actionConfig = {}; 557 + fd.actionConfig.color = colorInput.value; 558 + colorPreview.textContent = colorInput.value; 559 + }); 560 + colorRow.appendChild(colorInput); 561 + colorRow.appendChild(colorPreview); 562 + colorGroup.appendChild(colorRow); 563 + container.appendChild(colorGroup); 564 + 565 + // Tooltip 566 + const tooltipGroup = createFormGroup('Tooltip'); 567 + const tooltipInput = document.createElement('peek-input'); 568 + tooltipInput.className = 'form-input'; 569 + tooltipInput.value = config.tooltip || ''; 570 + tooltipInput.placeholder = 'Hover text for the icon'; 571 + tooltipInput.addEventListener('input', (e) => { 572 + if (!fd.actionConfig) fd.actionConfig = {}; 573 + fd.actionConfig.tooltip = e.target.value; 574 + }); 575 + tooltipGroup.appendChild(tooltipInput); 576 + container.appendChild(tooltipGroup); 577 + }; 578 + 579 + const renderPublishConfig = (container, config, fd) => { 580 + // Topic 581 + const topicGroup = createFormGroup('Topic'); 582 + const topicInput = document.createElement('peek-input'); 583 + topicInput.className = 'form-input'; 584 + topicInput.value = config.topic || ''; 585 + topicInput.placeholder = 'Pubsub topic name (e.g. content:fetch-offline)'; 586 + topicInput.addEventListener('input', (e) => { 587 + if (!fd.actionConfig) fd.actionConfig = {}; 588 + fd.actionConfig.topic = e.target.value; 589 + }); 590 + topicGroup.appendChild(topicInput); 591 + container.appendChild(topicGroup); 592 + 593 + // Include item data 594 + const includeGroup = createFormGroup(''); 595 + const includeLabel = document.createElement('label'); 596 + includeLabel.className = 'checkbox-option'; 597 + const includeCb = document.createElement('input'); 598 + includeCb.type = 'checkbox'; 599 + includeCb.checked = config.includeItemData === true; 600 + includeCb.addEventListener('change', () => { 601 + if (!fd.actionConfig) fd.actionConfig = {}; 602 + fd.actionConfig.includeItemData = includeCb.checked; 603 + }); 604 + const includeText = document.createTextNode('Include item data in payload'); 605 + includeLabel.appendChild(includeCb); 606 + includeLabel.appendChild(includeText); 607 + includeGroup.appendChild(includeLabel); 608 + container.appendChild(includeGroup); 609 + }; 610 + 611 + const renderOpenUrlConfig = (container, config, fd) => { 612 + // URL template 613 + const urlGroup = createFormGroup('URL Template'); 614 + const urlInput = document.createElement('peek-input'); 615 + urlInput.className = 'form-input'; 616 + urlInput.value = config.urlTemplate || ''; 617 + urlInput.placeholder = 'https://example.com/save?url={url}'; 618 + urlInput.addEventListener('input', (e) => { 619 + if (!fd.actionConfig) fd.actionConfig = {}; 620 + fd.actionConfig.urlTemplate = e.target.value; 621 + }); 622 + urlGroup.appendChild(urlInput); 623 + 624 + const hint = document.createElement('div'); 625 + hint.className = 'form-hint'; 626 + hint.textContent = 'Placeholders: {url}, {title}, {id}'; 627 + urlGroup.appendChild(hint); 628 + container.appendChild(urlGroup); 629 + 630 + // Open in background 631 + const bgGroup = createFormGroup(''); 632 + const bgLabel = document.createElement('label'); 633 + bgLabel.className = 'checkbox-option'; 634 + const bgCb = document.createElement('input'); 635 + bgCb.type = 'checkbox'; 636 + bgCb.checked = config.openInBackground === true; 637 + bgCb.addEventListener('change', () => { 638 + if (!fd.actionConfig) fd.actionConfig = {}; 639 + fd.actionConfig.openInBackground = bgCb.checked; 640 + }); 641 + const bgText = document.createTextNode('Open in background'); 642 + bgLabel.appendChild(bgCb); 643 + bgLabel.appendChild(bgText); 644 + bgGroup.appendChild(bgLabel); 645 + container.appendChild(bgGroup); 646 + }; 647 + 648 + const renderTagConfig = (container, config, fd) => { 649 + // Tag name to add 650 + const tagGroup = createFormGroup('Tag to add'); 651 + const tagInput = document.createElement('peek-input'); 652 + tagInput.className = 'form-input'; 653 + tagInput.value = config.addTagName || ''; 654 + tagInput.placeholder = 'Tag name to automatically add'; 655 + tagInput.addEventListener('input', (e) => { 656 + if (!fd.actionConfig) fd.actionConfig = {}; 657 + fd.actionConfig.addTagName = e.target.value; 658 + }); 659 + tagGroup.appendChild(tagInput); 660 + 661 + const hint = document.createElement('div'); 662 + hint.className = 'form-hint'; 663 + hint.textContent = 'This tag will be added to items when the trigger tag is applied. Circular chains are prevented.'; 664 + tagGroup.appendChild(hint); 665 + container.appendChild(tagGroup); 666 + }; 667 + 668 + // ==================== Form Helpers ==================== 669 + 670 + const createFormGroup = (labelText) => { 671 + const group = document.createElement('div'); 672 + group.className = 'form-group'; 673 + if (labelText) { 674 + const label = document.createElement('div'); 675 + label.className = 'form-label'; 676 + label.textContent = labelText; 677 + group.appendChild(label); 678 + } 679 + return group; 680 + }; 681 + 682 + const createFormSection = (titleText) => { 683 + const section = document.createElement('div'); 684 + section.className = 'form-section'; 685 + const title = document.createElement('div'); 686 + title.className = 'form-section-title'; 687 + title.textContent = titleText; 688 + section.appendChild(title); 689 + return section; 690 + }; 691 + 692 + // ==================== Save Action ==================== 693 + 694 + const saveAction = async () => { 695 + const fd = state.formData; 696 + 697 + // Basic validation 698 + if (!fd.triggerTag || !fd.triggerTag.trim()) { 699 + debug && console.log('[tag-actions:home] Validation: trigger tag required'); 700 + return; 701 + } 702 + 703 + const actionData = { 704 + name: fd.name || `${fd.triggerTag} action`, 705 + enabled: fd.enabled !== false, 706 + triggerTag: fd.triggerTag.trim(), 707 + filterTag: fd.filterTag ? fd.filterTag.trim() : null, 708 + triggerOn: fd.triggerOn || 'add', 709 + actionType: fd.actionType, 710 + actionConfig: fd.actionConfig || {}, 711 + itemTypes: fd.itemTypes && fd.itemTypes.length > 0 ? fd.itemTypes : null 712 + }; 713 + 714 + if (state.editingAction) { 715 + // Update existing 716 + await request('tag-actions:update', { 717 + actionId: state.editingAction.id, 718 + updates: actionData 719 + }); 720 + } else { 721 + // Create new 722 + await request('tag-actions:create', actionData); 723 + } 724 + 725 + showListView(); 726 + }; 727 + 728 + // ==================== Keyboard Navigation ==================== 729 + 730 + const handleKeydown = (e) => { 731 + if (state.view !== VIEW_LIST) return; 732 + 733 + const searchInput = document.querySelector('peek-input.search-input'); 734 + const isSearchFocused = document.activeElement === searchInput || 735 + (searchInput && searchInput.shadowRoot?.activeElement); 736 + 737 + // Focus search with / or Cmd+F 738 + if ((e.key === '/' || (e.key === 'f' && (e.metaKey || e.ctrlKey))) && !isSearchFocused) { 739 + e.preventDefault(); 740 + searchInput.focus(); 741 + return; 742 + } 743 + 744 + // Don't intercept when typing in search (except arrow keys and enter) 745 + if (isSearchFocused && !['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { 746 + return; 747 + } 748 + 749 + const cards = Array.from(document.querySelectorAll('.actions-list peek-card')); 750 + if (cards.length === 0) return; 751 + 752 + switch (e.key) { 753 + case 'j': 754 + case 'ArrowDown': 755 + e.preventDefault(); 756 + if (state.selectedIndex < cards.length - 1) { 757 + state.selectedIndex++; 758 + updateSelection(cards); 759 + } 760 + break; 761 + case 'k': 762 + case 'ArrowUp': 763 + e.preventDefault(); 764 + if (state.selectedIndex > 0) { 765 + state.selectedIndex--; 766 + updateSelection(cards); 767 + } 768 + break; 769 + case 'Enter': 770 + e.preventDefault(); 771 + if (cards[state.selectedIndex]) { 772 + cards[state.selectedIndex].click(); 773 + } 774 + break; 775 + } 776 + }; 777 + 778 + const updateSelection = (cards) => { 779 + if (!cards) cards = Array.from(document.querySelectorAll('.actions-list peek-card')); 780 + cards.forEach((card, i) => { 781 + card.selected = (i === state.selectedIndex); 782 + }); 783 + const selected = cards[state.selectedIndex]; 784 + if (selected) { 785 + selected.focus(); 786 + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 787 + } 788 + }; 789 + 790 + // ==================== Init ==================== 791 + 792 + const init = async () => { 793 + debug && console.log('[tag-actions:home] init'); 794 + 795 + // Register escape handler 796 + api.escape.onEscape(handleEscape); 797 + 798 + // Load data 799 + await loadActions(); 800 + 801 + // Search input 802 + const searchInput = document.querySelector('peek-input.search-input'); 803 + searchInput.addEventListener('input', (e) => { 804 + state.searchQuery = e.target.value; 805 + state.selectedIndex = 0; 806 + renderList(); 807 + }); 808 + 809 + // Add action button 810 + document.querySelector('.add-action-btn').addEventListener('click', () => { 811 + showDetailView(null); 812 + }); 813 + 814 + // Back button 815 + document.querySelector('.back-btn').addEventListener('click', () => { 816 + showListView(); 817 + }); 818 + 819 + // Keyboard navigation 820 + document.addEventListener('keydown', handleKeydown); 821 + 822 + // Listen for action changes from background 823 + api.subscribe('tag-actions:create:response', async () => { 824 + if (state.view === VIEW_LIST) { 825 + await loadActions(); 826 + renderList(); 827 + } 828 + }, api.scopes.GLOBAL); 829 + 830 + api.subscribe('tag-actions:update:response', async () => { 831 + if (state.view === VIEW_LIST) { 832 + await loadActions(); 833 + renderList(); 834 + } 835 + }, api.scopes.GLOBAL); 836 + 837 + api.subscribe('tag-actions:delete:response', async () => { 838 + if (state.view === VIEW_LIST) { 839 + await loadActions(); 840 + renderList(); 841 + } 842 + }, api.scopes.GLOBAL); 843 + 844 + // Render initial list 845 + renderList(); 846 + }; 847 + 848 + document.addEventListener('DOMContentLoaded', init);
+34
extensions/tag-actions/manifest.json
··· 1 + { 2 + "id": "tag-actions", 3 + "shortname": "tag-actions", 4 + "name": "Tag Actions", 5 + "description": "Define custom actions triggered by tags", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true, 9 + "settingsSchema": "./settings-schema.json", 10 + "commands": [ 11 + { 12 + "name": "open tag actions", 13 + "description": "Open the tag actions manager", 14 + "action": { 15 + "type": "window", 16 + "url": "peek://ext/tag-actions/home.html", 17 + "options": { 18 + "role": "workspace", 19 + "key": "tag-actions-home", 20 + "width": 800, 21 + "height": 600, 22 + "title": "Tag Actions" 23 + } 24 + } 25 + } 26 + ], 27 + "shortcuts": [ 28 + { 29 + "keys": "Option+Shift+T", 30 + "command": "open tag actions", 31 + "global": true 32 + } 33 + ] 34 + }
+27
extensions/tag-actions/settings-schema.json
··· 1 + { 2 + "labels": { 3 + "prefs": { 4 + "autoExecute": "Auto-execute actions on tag add" 5 + } 6 + }, 7 + "prefs": { 8 + "type": "object", 9 + "properties": { 10 + "autoExecute": { 11 + "type": "boolean", 12 + "description": "Automatically execute actions when tags are added (vs. manual trigger only)", 13 + "default": true 14 + } 15 + } 16 + }, 17 + "storageKeys": { 18 + "PREFS": "prefs", 19 + "ACTIONS": "actions" 20 + }, 21 + "defaults": { 22 + "prefs": { 23 + "autoExecute": true 24 + }, 25 + "actions": [] 26 + } 27 + }