Draft vector shapes feature, prune shipped feature docs

VECTOR_SHAPES_FEATURE.md plans a new shape layer type that subsumes
primitives (rect / rounded-rect / circle / ellipse / polygon / star /
line / custom SVG path) under a single discriminated kind, plus
solid / linear-gradient / radial-gradient fills, strokes, and clip
support that any layer type can use. JSON storage is purely additive,
backwards compatible with existing payloads, and forward compatible
for unknown shape kinds. Phased delivery: A primitives + solid fill,
B gradients, C strokes, D custom paths, E masking.

Pruned docs for features that have already shipped in this codebase:

- COLOR_AND_FONT_LAYERS_FEATURE.md — color layers + font picker
- CUSTOM_ART_MODE_FEATURE.md — custom-art template mode
- EDIT_DESIGN_FEATURE.md — replaceable-event edit-in-place
- LIGHTNING_ZAPS_FEATURE.md — LNURL-pay tip flow
- PROFILE_PAGES_FEATURE.md — /u/:npub profile pages
- STREAMING_RELAY_SYNC_FEATURE.md — progressive feed loading
- TEMPLATE_FORKING_FEATURE.md — Fork off! flow
- TRUST_AND_BLOCK_FEATURE.md — block / mute-sync / trust tiers / report
- WOT_SOFT_MODERATION_FEATURE.md — hop-1 mute/report blur
- ZAP_TRACKING_FEATURE.md — NIP-57 zap requests and receipt sums

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-18 18:47:22 -04:00
1 parent 556d5c5b09
commit c5a04423ad
11 files changed
+395 -2611

No files matched your search

-222
View File
@@ -1,222 +0,0 @@
# Color Fill Layers + Text Font Family Picker
## Goals
Expand the layer system with **two complementary additions**:
1. A new `color` layer type — a solid-color rectangle, useful as a tint, background, or accent block.
2. A **font family picker** on existing `text` layers so creators aren't locked into the browser default.
### Product goals
1. Add visual range without adding a new editor surface — both changes live in the existing layer panel.
2. Stay print-safe: no network-dependent fonts in v1.
3. No migration burden on existing payloads.
### Non-goals (v1)
- Gradient or pattern fills (color layers are flat fill only).
- Strokes / borders on color layers.
- Google Fonts or any web-fetched fonts.
- Per-character font styling (text layer is one font for the whole block, same as today).
- Drop shadows, blur, or other effects.
---
## Current state
### Existing layer types
| Type | Fields beyond standard | Where rendered |
|---|---|---|
| `image` | `src`, `fit?` | main canvas, cover preview, disc-sheet preview, jewel preview, disc-design preview |
| `text` | `html`, `fontSize`, `color`, `align`, `bold`, `italic` | same 5 places |
Standard fields shared by every layer: `id`, `type`, `name`, `target`, `x`, `y`, `w`, `h`, `rotate`, `opacity`, `z`.
### Existing UI structure
- **Per-target panels** (`renderLayerList`): one collapsible panel per region (page / cover-trim / back / spine / front / disc-top / disc-bottom / jewel-front / jewel-tray / designer-disc). Each has an "Add image" URL input and an "Add text" button.
- **Selected-layer controls** (`#layerControls`): one shared `<details>` block that conditionally shows `#layerImageControls` for image layers and `#textLayerControls` for text layers via display:block/none.
- **Renderers** in 5 locations:
- `renderLayers()` — main editor canvas
- `renderTemplatePreviewDOM()` — cover preview in the template browser
- `renderDiscSheetPreviewDOM()` — disc sheet preview
- `renderJewelPreviewDOM()` — jewel preview
- `renderDiscPreviewDOM()` — disc-design preview
Each renderer has the same `if (layer.type === 'text') { … } else if (layer.type === 'image' && layer.src) { … }` shape.
---
## Color layer
### Data model
```js
{
id: 'layer-…',
type: 'color',
name: 'Color block',
target: '<target key>',
color: '#3b82f6', // hex string, accent blue default
x, y, w, h, // inches, same as image/text
rotate: 0,
opacity: 0.6, // default less than 1 so it reads as a tint
z: <nextLayerZ()>
}
```
### `addColorLayer(targetOverride)` helper
Sits next to `addImageLayer` / `addTextLayer` in the script. Same pattern:
- Resolve target from override or current `#layerTarget` value.
- Get `bounds = targetBounds(target)`.
- Insert centered, 70%×70% of the target bounds (same as image/text default sizing).
### Add-button placement
Inside `renderLayerList`'s per-target panel, alongside the existing Add image / Add text controls. Markup roughly:
```
[ image url input ] [ + Image ] [ + Text ] [ + Color ]
```
Single inline button — no extra inputs needed at add time (color picker lives on the selected layer).
### Selected-layer controls
Add a third sub-panel to `#layerControls`:
```html
<div id="colorLayerControls" style="display:none">
<label for="colorLayerFill">Fill color</label>
<input id="colorLayerFill" type="color" />
</div>
```
Toggled in `updateSelectedLayerControls()` alongside the existing image/text panels.
The existing **opacity** input applies to all layer types, so users can fine-tune the tint there.
### Rendering
Each of the 5 render sites gets a new branch:
```js
} else if (layer.type === 'color') {
el.style.background = layer.color || '#000';
}
```
Place after the `text` branch, before the `image` branch (or wherever it fits each renderer's existing structure).
The element already has `opacity`, `transform: rotate(…)`, position, and size set by the shared code. No extra work needed.
### Defaults (locked in)
- Default color: `#3b82f6` (accent blue — obviously visible against most artwork).
- Default opacity: `0.6` (reads as tint, not as a blocker).
- Selectable / draggable / resizable just like image and text layers.
---
## Text font family picker
### Data model
Add one field to existing text layers:
```js
{ … , fontFamily: 'Arial, sans-serif' }
```
Existing text layers without `fontFamily` continue rendering with the browser default (no migration).
### Font list (locked in, web-safe only)
| Label | CSS value |
|---|---|
| System default | `inherit` |
| Sans-serif (Arial) | `Arial, Helvetica, sans-serif` |
| Sans-serif (Helvetica) | `Helvetica, Arial, sans-serif` |
| Sans-serif (Verdana) | `Verdana, Geneva, sans-serif` |
| Sans-serif (Trebuchet) | `"Trebuchet MS", sans-serif` |
| Sans-serif (Tahoma) | `Tahoma, Geneva, sans-serif` |
| Sans-serif (Arial Black) | `"Arial Black", sans-serif` |
| Sans-serif (Impact) | `Impact, "Arial Narrow Bold", sans-serif` |
| Serif (Times) | `"Times New Roman", Times, serif` |
| Serif (Georgia) | `Georgia, serif` |
| Serif (Palatino) | `"Palatino Linotype", "Book Antiqua", Palatino, serif` |
| Monospace (Courier) | `"Courier New", Courier, monospace` |
| Monospace (Consolas) | `Consolas, "Courier New", monospace` |
| Cursive (Comic Sans) | `"Comic Sans MS", "Comic Sans", cursive` |
| Cursive (Brush Script) | `"Brush Script MT", cursive` |
Each `<option>` previews itself via `style="font-family: <its-own-value>"` so the dropdown is its own preview surface.
### UI placement
Inside the existing `#textLayerControls` block, before the existing text size input:
```html
<label for="textFontFamily">Font</label>
<select id="textFontFamily"><!-- options --></select>
```
### Wiring
- `syncSelectedLayerControls`: read `selectedLayer.fontFamily` into the select (default to `'inherit'`).
- Change handler: `updateSelectedLayer('fontFamily', e.target.value)` + `render()`.
- Renderers (5 places): when `layer.type === 'text'`, apply `el.style.fontFamily = layer.fontFamily || 'inherit'`. One line added per renderer.
### Print-safety rationale
All fonts are either built into the OS or universally fallback-mappable. No external network request → no FOIT/FOUT during print → reliable spacing and metrics in PDF output. A "missing font" outcome would silently fall back to a similar metric within the same generic family (`sans-serif`, `serif`, `monospace`, `cursive`).
---
## Backward compatibility
- Old text layers without `fontFamily`: render with browser default (effectively `inherit`). No data migration.
- Old payloads with only `image`/`text` types: unchanged. The new `color` type only exists in payloads explicitly published after this lands.
- Old clients receiving a payload that contains a `color` layer: skip it (their renderers don't match `type === 'color'`, so they fall through and render nothing). Not great but not catastrophic — the rest of the template still renders. A future shared schema-version bump could signal "this template uses color layers."
---
## Implementation slices
### Slice 1 — Color layer (data + add + render)
1. `addColorLayer(targetOverride)` helper.
2. Add "Color" button to per-target panel rows in `renderLayerList`.
3. Renderer branches in all 5 places.
4. `#colorLayerControls` block + show/hide wiring in `updateSelectedLayerControls`.
5. Color-picker input handler.
### Slice 2 — Font family picker for text
1. Build out the `<select id="textFontFamily">` markup (with self-previewing options).
2. Read state in `syncSelectedLayerControls`.
3. Change handler.
4. Apply `style.fontFamily` in the 5 renderers.
Slices are independent — could ship in either order or together.
---
## Acceptance
### Color layer
- "Color" button appears in each target panel (and only the ones valid for the current template mode).
- Clicking it adds a 70%×70% rectangle filled with `#3b82f6` at 60% opacity in the relevant region.
- Selecting it shows a color picker that updates the layer fill live.
- Position/size/rotation/opacity controls all work (same as image/text).
- Color layer round-trips through save/load and through publish/Nostr-import without data loss.
- Color layer renders in all 4 preview surfaces (cover, disc-sheet, jewel, disc-design) and in the main editor canvas.
### Font family picker
- Text-layer panel shows a "Font" dropdown with the locked-in list.
- Selecting a font updates the selected text layer instantly (and the dropdown's options visibly preview their own font).
- Saved/loaded text layers keep their font choice.
- Text layer renders with the chosen font in all 4 preview surfaces and the main canvas.
- Text layers from before this change render with the previous (default) font.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Color block at 60% opacity could be mistaken for an image-loading state on first add | Distinct "MISSING IMAGE" badge is reserved for the broken-image case; new layers default to accent blue which is unmistakably solid color |
| Old clients see new `color` layers as no-ops | Acceptable for v1; bump schema version later if widespread |
| Picked font doesn't exist on the printer/viewer OS | CSS fallback chain ends in a generic family; visually similar metrics; acceptable for v1 |
| Color layer used as a full backdrop covers the artwork beneath | User controls opacity and z-order; same affordances as image layers |
| Font dropdown clutters the text controls panel | Single labeled select, collapsible if needed in a future polish pass |
---
## Out of scope / future enhancements
- Custom CSS `font-family` text input for power users.
- Gradient fills on color layers.
- Stroke/border on color layers (and image layers).
- Per-character or per-word formatting in text layers.
- Drop shadow / outline effects.
- Saved swatches / palette per project.
- Eyedropper to pick a color from the artwork beneath.
-398
View File
@@ -1,398 +0,0 @@
# Custom Art Mode
> **Status as of last update**: Phases A, B, and C are **shipped**. Custom Art is end-to-end functional — you can create, save, publish, share, browse, and export to JPEG. See [Status & remaining work](#status--remaining-work) below for the detailed breakdown and the Phase D wishlist.
## Goals
Add a fifth template mode — **Custom Art** — alongside the existing cover, disc, jewel, and disc-design modes. The aim is to let CaseWrap Studio be used for general-purpose digital art at arbitrary aspect ratios (wallpapers, banners, social posts, posters, etc.) while keeping everything else about the app — Nostr publish/share, layer system, soft-moderation, mini-preview browser — intact.
### Product goals
1. Let a user create a blank canvas at any reasonable size, add the same layer types as elsewhere (image, text, color), and publish it through the existing Nostr flow.
2. Browse, filter, and re-use custom-art designs in the community template browser.
3. Keep the codebase complexity bounded — reuse layer / preview / publish / share machinery as much as possible.
### Non-goals (v1)
- Vector tools (SVG paths, freehand drawing, brushes). Layers remain image/text/color.
- Inches/DPI handling for Custom Art — pixels only.
- Print-targeted features (bleed, trim, crop marks) — these are digital-only canvases.
- Real-time collaborative editing.
- Templates / starting decks (e.g., "Instagram post starter"). v1 has no in-app starting points beyond the size presets.
- Animated / multi-frame art.
- Larger than 8192×8192 canvases.
- Stickers / shape primitives (rectangles, circles, lines as drawn shapes — beyond the color-fill layer which is rectangular).
---
## Current state in the app
The four existing modes (cover, disc, jewel, disc-design) all use:
- **Inches** as the unit of measure (because the source product is physical media).
- **Fixed dimensions** specific to the product (e.g., DVD cover trim = 10.675 × 7.5 in).
- **Multiple slots** within the sheet (back/spine/front, disc-top/disc-bottom, jewel-front/jewel-tray).
- A `templateMode` value in state and payloads that controls editor behaviour, render code paths, publish d-tag prefix, and browse-filter visibility.
Layers are global to the design and assigned to a target region via `layer.target`. The render path applies layers within their target's coord space.
The Nostr publish path uses NIP-78 kind-30078 with a `t` tag (`casewrap`) plus a per-mode type tag (`casewrap-cover`, `casewrap-disc-design`, etc.) and a d-tag prefixed with the mode for uniqueness.
The template browser groups designs by `templateMode` for the type filter (`cover` / `disc` / `jewel` / `disc-design` / `any`).
---
## Custom Art mode at a glance
| Aspect | Existing modes | Custom Art mode |
|---|---|---|
| Unit | Inches | Pixels |
| Dimensions | Fixed per product | User-chosen (min 100×100, max 8192×8192) |
| Slots / regions | Multiple | One: the canvas |
| Background | Slot-specific artwork | Optional configurable fill color (default white) |
| Bleed / trim | Yes | No |
| Layer target | `back`, `front`, `disc-top`, etc. | `canvas` |
| Print-ready PDF | Yes | Not the primary use case (digital) |
| Export JPEG | Yes | Yes (primary use case) |
| Nostr publish | Yes | Yes (same kind, new type tag) |
| Mini-preview card silhouette | Cover-rect / disc-circle / jewel-sheet | Arbitrary aspect rectangle matching the canvas |
---
## Decisions (locked in)
1. **Units**: Pixels only. Fixed 96 px/in conversion when interacting with layer math that's already inch-based. Pixel-first API on the surface, internal inch-equivalent where the existing layer code needs it.
2. **Size limits**: 100 × 100 px minimum, 8192 × 8192 px maximum. Bounds enforced on input and clamped on load. (Avoids canvas memory crashes; 8K is the practical upper limit for `<img>` and `<canvas>` reliably across browsers.)
3. **Preset sizes** (built into a "preset" dropdown):
- 1920 × 1080 — Desktop wallpaper / 16:9
- 1080 × 1920 — Phone wallpaper / 9:16
- 2560 × 1440 — QHD wallpaper
- 3840 × 2160 — 4K wallpaper
- 1080 × 1080 — Square / 1:1 (Instagram post)
- 1080 × 1350 — 4:5 portrait (Instagram)
- 1500 × 500 — 3:1 banner (Twitter header)
- 850 × 1100 — US Letter portrait (digital)
- 1100 × 850 — US Letter landscape (digital)
- Custom… — exposes width/height inputs
4. **Browser size filter scope**: Only for Custom Art. The cover/disc/jewel modes already have well-defined product dimensions; a size filter would be noise there. Custom Art's size filter is a separate select that appears only when the Custom Art type filter is selected (or "Any").
5. **Canvas background**: Per-design configurable fill color (hex). Default white (`#ffffff`). Stored as `payload.customArt.background` in the payload.
6. **Nostr publish**: Same NIP-78 kind-30078 flow. `t` tag stays `casewrap`. New per-mode tag `casewrap-customart`. Existing template_type / category / language / title tags all apply.
7. **D-tag prefix**: `customart:<dValueBase>` where `dValueBase` is the user's imdbId / title / `width-x-height` fallback. Same pattern as the other modes (`cover:`, `disc-design:`, etc.) so addressing stays distinct from other content types for the same title.
8. **Phasing**: One spec, three implementation chunks.
- **Phase A** — Editor: state, mode switch, blank canvas editor, size controls, layer support, save/load JSON.
- **Phase B** — Publish + share: Nostr publish, share-link round-trip via `naddr`/`nevent`.
- **Phase C** — Browser: type filter option, size filter, mini-preview, full preview pane, JPEG export.
Each phase is independently shippable.
---
## Data model
### `state.customArt`
New top-level field, mirrors `state.discDesign` and `state.combined`:
```js
state.customArt = {
width: 1920, // px, 100–8192
height: 1080, // px, 100–8192
background: '#ffffff', // optional fill color
preset: '1920x1080' // key of last-applied preset, or 'custom'
};
```
### `state.meta`
Unchanged. The existing `title / identifiers / category / language` apply to custom art designs too.
### `state.layers`
Unchanged. New layers in custom-art mode get `target: 'canvas'`.
### Payload
```js
function customArtPayload() {
return {
version: SCHEMA_VERSION,
templateMode: 'customart',
templateType: 'customart',
meta: state.meta,
ui: { zoom: state.zoom, showGuides: state.showGuides },
customArt: { ...state.customArt },
layers: nonGeneratedLayers().filter(l => l.target === 'canvas'),
community: { /* ... */ }
};
}
```
### Mode detection (`normalizeTemplateMode`)
Extends the existing detector:
```js
if (raw === 'customart') return 'customart';
// fallback heuristic:
if (payload.customArt) return 'customart';
```
---
## UI changes
### Layout-mode tabs
Currently the sidebar has a row of layout-tab buttons: `Case cover`, `Disc labels`. (Jewel is hidden behind a preset selector.) Add a third button: `Custom art`.
### Custom Art controls fieldset (new)
New `<fieldset class="workflowStep template-only" id="customArtFieldset">` shown only when `state.templateMode === 'customart'`:
```
[ Canvas size ]
Preset: [ Custom… ▼ ] ← dropdown of presets + Custom
Width: [ ] px
Height: [ ] px
[ Background ]
Fill color: [color picker] Transparent: [ ] checkbox
```
Hidden in all other modes via the existing `template-cover` / `template-disc` / `template-jewel` body-class system, adding a new `template-customart` body class.
### Editor view
- Single rectangular canvas at the user's chosen pixel dimensions, centered in the preview shell.
- No bleed/trim/safe-area markers.
- Layers stack on the canvas with `target: 'canvas'`.
- Layer-target options dropdown shows only `Canvas` for this mode.
- Print/Save PDF and Export JPEG buttons available.
### Template browser
- "Type" filter dropdown gains `Custom art` option (alongside existing cover/disc/jewel/disc-design).
- When `Custom art` or `Any` is selected, a new "Size" filter dropdown appears:
```
Any size · 16:9 · 9:16 · 1:1 · 4:5 · 4:3 · 3:1 · Letter · Other
```
- Bucketing logic computes the aspect ratio of `(payload.customArt.width / payload.customArt.height)` and matches into the nearest bucket (within ±2% tolerance). "Other" catches anything outside the named buckets.
---
## Preview renderers
### `renderCustomArtPreviewDOM(container, payload, opts)`
- Reads `payload.customArt.{width,height,background}`.
- Creates a sized div with that pixel aspect ratio, scaled to fit the preview pane (full mode) or thumbnail (compact mode via the existing `fitMiniPreview` transform).
- Applies the background color (or white if transparent).
- Renders layers with `target === 'canvas'` using the same layer-positioning math as the disc renderer, but with `pxPerInch` derived from canvas-width-in-pixels: `pxPerInch = naturalWidth / (width_in_px / 96)`. *(Note: since layers in Custom Art are positioned in pixels, we may simplify to just pass `naturalWidth / width` as the scale factor for `applyPreviewLayerTextStyle`.)*
Compact mode: same as the others — no labels, no shadow.
---
## Layer positioning
Two reasonable approaches:
**Option 1**: Layers store positions in **pixels** (consistent with Custom Art's chosen unit). The renderer divides by canvas dimensions to get percentages.
- Pro: User-natural.
- Con: Inconsistent with the other modes which use inches.
**Option 2**: Layers store positions in **inches** at 96 DPI (consistent with existing layer code). The renderer treats canvas as `(width_px / 96)` × `(height_px / 96)` inches.
- Pro: Reuses every existing layer helper unchanged.
- Con: User thinks in pixels but the data is inches — slight cognitive mismatch.
**Pick: Option 2**. Lower implementation risk. The user interacts via the canvas-size picker in pixels; layers internally use inches at 96 DPI. The size input UI displays pixels.
For example: 1920 × 1080 canvas → internally `panelW = 20`, `panelH = 11.25` inches. Layer at (200px, 100px) on the canvas would be `(2.083, 1.042)` in inches.
A small helper `customArtCanvasInches(state.customArt) → { wIn, hIn }` exposes the inch dimensions to the layer math.
---
## Publish d-tag
Following the existing pattern:
```js
['d', `customart:${state.meta.imdbId || state.meta.title || `${state.customArt.width}x${state.customArt.height}`}`]
```
Allows the same title to have a custom-art design *plus* a cover/disc/disc-design without colliding.
---
## Export
### JPEG (primary)
- Already-built `exportArtboardAsJpeg` flow works with one tweak: for Custom Art, the export PDF uses `@page size: ${widthIn}in ${heightIn}in; margin: 0;` (where `widthIn = width_px / 96`). The trim-area crop logic that's specific to cover mode is skipped — Custom Art has no bleed.
- The resulting PDF is exactly the canvas at native resolution, which PDF.js converts to JPEG cleanly.
### PDF print
- Same `Print / Save PDF` path. Page size = canvas size in inches.
---
## Phased delivery
### Phase A — Editor (foundation)
**Changes**
- `state.customArt` default object.
- `state.layers` accepts `target: 'canvas'`.
- New `customArtPayload()` function.
- `normalizeTemplateMode` recognizes `customart`.
- New body class `template-customart` toggled by `syncInputs`.
- New `customArtFieldset` HTML with preset dropdown, width/height number inputs, background color picker.
- Wire size inputs to update `state.customArt`, re-render canvas.
- Layout-mode tab `mode-customart` added; clicking sets `templateMode = 'customart'`.
- Render the canvas: a div sized to `(width/96, height/96)` in inches with `position:relative; background:<fill>`, with `#layerOverlay` inside.
- `targetBounds('canvas')` returns `{x:0, y:0, w:wIn, h:hIn}`.
- `updateLayerTargetOptions` adds `Canvas` for customart mode.
- Save / load JSON round-trip.
**Acceptance**
- Switch to Custom Art tab → blank white canvas at default 1920×1080 visible.
- Change preset to 1:1 square → canvas reflows.
- Pick Custom and enter 600×900 → canvas reflows.
- Add a text layer + color layer → render and reposition them.
- Save project JSON → reload → state restored.
### Phase B — Publish & share
**Changes**
- Publish flow: `publishCurrentTemplateToNostr` adds branch for customart mode; uses `casewrap-customart` type tag, `customart:` d-tag prefix.
- `previewFromPayload` handles customart (returns first-image-layer src if any; otherwise empty).
- `templateEventDValue` / `templateEventAddress` already handle the d-tag tail.
- `shareIdForRow` works as-is (the existing prefix-aware naddr/nevent branching handles new modes).
**Acceptance**
- Publish a Custom Art design → fetch from another browser → it loads and renders.
- Share link round-trips: copy share URL → paste in new tab → opens the preview.
### Phase C — Template browser
**Changes**
- Type filter gains `Custom art` option in both `feedTypeFilter` rows and `welcomeType`.
- New "Size" filter dropdown appears when `Custom Art` or `Any` is selected.
- `buildRowsFromEvents` reads `payload.customArt` for aspect-ratio bucketing.
- New `renderCustomArtPreviewDOM(container, payload, opts)`.
- `openDesignPreview` adds dispatch for `mode === 'customart'`.
- Mini-preview cards: `buildFeedCard` dispatches to the new renderer in compact mode.
- JPEG export: `exportArtboardAsJpeg` and the print modal handle customart by sizing `@page` to canvas dimensions (no trim crop).
**Acceptance**
- Browse community → "Custom art" filter shows only customart designs.
- Aspect-ratio filter narrows to 16:9 etc.
- Each card mini-preview shows the canvas at its native aspect ratio (16:9 cards are wider than they are tall in the grid; portrait designs are letterboxed in the square thumb like the other modes).
- Clicking a card opens the full preview at the canvas's natural aspect.
- Export JPEG produces a flat 1920×1080 (or whatever) JPEG with the design's contents.
---
## Status & remaining work
### Phase A — Editor foundation — ✅ **SHIPPED** (commit `a49d0b0`)
- ✅ `state.customArt = { width, height, background, preset }` default object
- ✅ `CUSTOM_ART_PRESETS` constant (10 entries)
- ✅ `customArtCanvasInches()` pixel→inch helper
- ✅ `normalizeTemplateMode` recognizes `customart` + `payload.customArt` fallback
- ✅ `sheetW()` / `sheetH()` return canvas dimensions in inches for customart mode
- ✅ `targetBounds('canvas')` for the layer target
- ✅ `applyTargetMode` routes `canvas` target → customart mode
- ✅ `updateLayerTargetOptions` shows the `Canvas` option in customart mode
- ✅ `templateLayerTargetsForMode` returns `[{ key: 'canvas', label: 'Canvas' }]` for customart
- ✅ Body class `template-customart` toggled by `updateBodyClasses`
- ✅ CSS hides bleed/trim/fold/slot UI when in customart mode
- ✅ Layout-tab button `Custom art` in sidebar mode switcher
- ✅ `customArtFieldset` HTML with preset dropdown, width/height inputs, background picker
- ✅ Width/height range enforcement (100–8192 px)
- ✅ Preset → snap to matching preset key, or `custom` when manual
- ✅ Background color applied to `.sheet` inline; reset on mode change
- ✅ `customArtPayload()` for JSON / publish payloads
- ✅ `exportPayload()` includes `customArt` blob
- ✅ `downloadProject` saves as `custom_art_<wxh>.json`
- ✅ `loadProjectFromText` has a customart branch that restores state without clobbering other modes
### Phase B — Publish & share-link round-trip — ✅ **SHIPPED** (commit `f67467c`)
- ✅ `templateTypeForMode('customart') → 'customart'`
- ✅ `publishCurrentTemplateToNostr` customart branch with `casewrap-customart` type tag and `customart:<dValue>` d-tag (with `WxH` fallback)
- ✅ Pre-publish guard rejects an empty white canvas with no layers (so the feed doesn't fill with blank squares)
- ✅ `previewFromPayload(payload, 'customart')` returns first image-layer src as the thumbnail/broken-image-detection target
- ✅ `shareIdForRow` generates a NIP-19 `naddr` for new-style customart events (mode-prefixed d-tag); falls back to `nevent` for legacy
- ✅ `loadSharedDesign` queries by `naddr` / `nevent` and re-renders correctly for shared customart URLs
- ✅ `getTemplateTags` emits the standard `template_type`, `category`, `language`, and identifier tags (`['i', 'imdb:...']`, `['i', 'upc:...']`, etc.) for customart designs same as the other modes
### Phase C — Browser, preview, export — ✅ **SHIPPED** (commit `f67467c`)
- ✅ `Custom art` option added to the welcome modal's `Type` select
- ✅ `Custom art` option added to the community modal's search `Type` select
- ✅ `Custom Art` button added to the New / Popular / Mine feed-type filter rows
- ✅ `renderCustomArtPreviewDOM(container, payload, opts)` — full + compact mode, sorted layers by z, explicit z-index, image / text / color layers, background color
- ✅ `openDesignPreview` dispatches to the customart renderer
- ✅ `buildFeedCard` mini-preview dispatches to the customart renderer in compact mode
- ✅ JPEG export: `enterExportPrintMode` preserves the canvas background color and uses the canvas dims as the `@page` size (no trim crop, no bleed margin); PDF.js converts the resulting PDF to a flat JPEG at native resolution
---
### Phase D — Polish (not started)
These are deliberately deferred. Some are easy add-ons; others are bigger UX changes worth a separate spec.
#### Easy
- **Background: transparent** — checkbox in the canvas controls. Today background is always a solid color (default `#ffffff`). Useful for users who want a PNG with alpha (would need transparent-aware JPEG-or-PNG export though).
- **Aspect-ratio size filter** inside the browse view when `Custom Art` (or `Any`) is selected. Buckets: `16:9 · 9:16 · 1:1 · 4:5 · 4:3 · 3:1 · Letter · Other`. Was in the spec; skipped because shipping without it works fine.
- **Surface the canvas dimensions in the card metadata strip** (e.g., "Custom Art · 1920×1080"). Currently the card just shows the mode label.
- **Default to one of the preset names in the title field** when starting a fresh custom art design (e.g., "Untitled wallpaper" for 1920×1080).
- **Tooltip on the layout tab button** describing what Custom Art is good for, to onboard users from the existing case/disc community.
#### Medium
- **Pixel-native layer position inputs** alongside the existing inch inputs in the layer controls panel — so users who think in pixels don't have to mentally convert. Inch inputs stay for compat.
- **Background image** (instead of solid color). Image fills the canvas, layers stack on top. Same `fit/zoom/x/y/rotate` controls as slot artwork on the other modes.
- **Starter templates / preset compositions** — e.g., "Instagram post starter", "Twitter header starter", "YouTube thumbnail starter". Click to populate the canvas with a few placeholder layers.
- **Larger preset library** — add common social/streaming sizes (TikTok 1080×1920 ✓ already, YouTube thumbnail 1280×720, Discord banner 600×240, Twitch panel 320×100, etc.).
- **Export at chosen pixel resolution** — currently the JPEG is rasterized at PDF native size (a 1080-px canvas → PDF page that's ~11.25 inch wide → rasterized at PDF.js scale defaulting to ~2400-px long edge). Users may want explicit "Export at 50%" / "Export at 200%".
- **Pixel-art mode** with snap-to-grid for small designs (e.g., 64×64 sprite). Today rendering would work but layer-drag has no grid snap.
#### Larger
- **Shape primitives** — drawn rectangle / circle / line / arrow as new layer types (not just the color-fill rectangle). Probably needs a small toolbar in the editor.
- **Vector tools** — SVG path, freehand drawing, pen tool. Big lift; would require a new layer type with vector data and a renderer that converts SVG → canvas during export.
- **Pixel-native units everywhere** (not just at layer-position input) — switch the internal storage from inches to pixels for customart, with a one-time migration. Bigger code change but eliminates the implicit 96-DPI conversion.
- **Animated / multi-frame art** — render a sequence of canvases, export as GIF/APNG/MP4. Substantial.
- **Multi-page custom art** — a slidedeck-like flow where one design has multiple canvases. Would need significant payload schema changes.
- **Crop / trim tool on the canvas itself** — interactive bounding box that visually crops layers down to the new canvas bounds.
- **Layer effects** — drop shadow, blur, outline, color overlay. Per-layer styling that the canvas-export path would need to faithfully reproduce.
#### Schema / portability
- **NIP-73 identifier tags consumed at search time** — Phase B emits these but the community feed filter doesn't yet match against them. A user-typed UPC into the search box already finds matches via the haystack; a structured "find by external ID" filter would be a separate feature.
- **Format negotiation** — a `payload.customArt.exportFormat` ("png" | "jpeg") so the publisher can hint how their design should be flattened. Today everything goes through JPEG via the PDF intermediate.
---
## Risks / tradeoffs
| Risk | Mitigation |
|---|---|
| Memory / perf on 8K canvases with many layers | Hard-cap dimensions at 8192×8192; document recommendation to stay under 4K for portability. |
| User confusion between pixel-input and inch-stored layer positions | Surface pixels in UI; conversion is hidden. Layer position inputs in inches stay (consistent with other modes); could expose px alternative in a future polish pass. |
| Cover-mode JPEG export pipeline assumed trim-area cropping | Custom Art skips the bleed-trim logic; export pdf at exact canvas size with `@page size: <w>in <h>in; margin:0`. |
| Type-filter UI gets cluttered with "Size" | The size filter only renders when relevant (customart selected or "any"). |
| Custom Art designs cluttering the cover/disc-focused community feed | The "Type" filter already exists; users can ignore Custom Art if they want. Consider a future "default type filter" preference. |
| Existing rendered preview math assumes inches | Phase B / C work all goes through the same helpers — inch-based internally means no rewrites. |
| Storage bloat (a published 4K design with embedded data URLs gets huge) | Layers continue to store URLs by default; users who want to embed bytes can use the planned future "cache at insert" path, separate from this feature. |
| 8192×8192 canvases break JPEG export (browser canvas limits ~16M pixels) | Documented limit; lower the max to 4096 if browser support proves shaky. |
| D-tag collision risk after publish-d-tag-prefix change | New d-tag has `customart:` prefix, distinct from all other content types. Same protection as `cover:` / `disc:`. |
| Layer targeting in disc/cover modes accidentally bleeds into Custom Art if same target name | Custom Art uses `canvas` target, no other mode uses it; isolation is clean. |
---
## Out of scope / future enhancements
- Pixel-native layer position inputs in the right panel (alongside the current inch-based inputs) for users who think in pixels.
- Background image (rather than solid color).
- Shape primitives (drawn rectangle, circle, line, arrow) as new layer types.
- "Starter" templates / preset compositions (e.g., "Instagram post template").
- Multi-frame / animated export.
- Vector layers / SVG export.
- Native pixel-art mode with snap-to-grid.
- Print-targeted custom art with bleed / trim controls.
- Browser-side downscale options for export (e.g., "Export at 50%").
- Crop / trim tool on the canvas itself.
---
## Implementation order
Start with **Phase A** end-to-end, including JSON save/load. Once the editor works, **Phase B** (Nostr publish + share-link round-trip) is mostly hooking into existing flows. **Phase C** (browser filter + size filter + mini-preview + export) is the last piece, after which the feature is shippable.
Each phase ends with a commit that's testable on its own. The user gets value from each chunk independently — Phase A alone lets you make and save Custom Art designs locally, even before they can be shared on Nostr.
-209
View File
@@ -1,209 +0,0 @@
# Edit Published Designs
## Goals
Let a user **update an already-published design** in place — fix a dead image URL, retitle it, change the category, swap art — without leaving a trail of orphaned versions or duplicate posts behind.
Designs in CaseWrap are kind-30078 parameterized replaceable events (NIP-33). The protocol-level mechanism for replacement is already there: publish a new event with the same `kind` + `pubkey` + `d`-tag, and compliant relays drop the old one. We just have to wire that into the editor flow.
### Product goals
1. Owners can fix mistakes without forking.
2. Replacement happens at the protocol layer — one publish, zero NIP-09 deletions required for the replace itself.
3. Editing is visually distinct from forking, so users never accidentally publish over their own design when they meant to remix.
4. Old preview / preview-broken state for the supplanted version doesn't poison the new version.
### Non-goals (v1)
- Editing someone else's design "on their behalf". Owner-only.
- A revision history UI ("show me previous versions"). Replaceable events overwrite by design; once superseded, the old version is gone from relays. If a user wants version history they fork.
- Merging zaps or reactions from the old event id into the new one. Reactions and zaps are tagged by event id, and the new event has a new id even though it shares the d-tag. Stats reset to zero on edit. This is a real trade-off and we surface it in the confirm dialog.
- Soft moderation history transfer.
- Editing the publication date / `created_at` (always = now).
- Editing the `d`-tag itself (would defeat the whole point — that's just a new design with a delete-old).
---
## How Nostr replaces parameterized events
For kind 30000–39999, `(kind, pubkey, d-tag)` is the address. The relay keeps only the latest event by `created_at` at that address. Older events at the same address are dropped by compliant relays at publish time.
So: publishing a new kind-30078 event with the same `d`-tag from the same pubkey replaces the prior version. No `kind: 5` deletion is needed for the replacement itself.
(NIP-09 deletion *can* still be used to remove a design entirely — that's our existing "Delete" flow and is unchanged.)
### Side effects of replacement
- **Event id changes.** The new event has its own hash, different from the old one.
- **Reactions (kind-7) tagged by old event id remain on relays** but are orphaned — they point at an event that no longer exists. Most clients hide them.
- **Zap receipts (kind-9735) tagged by old event id are similarly orphaned.** New zaps targeting the new event id won't merge with old totals.
- **Share links pointing at the old event id (`nevent1…`) will not resolve.** `naddr1…` links (which are `(kind, pubkey, d-tag)` based) **continue to work** after an edit. We already mint `naddr` for mode-prefixed d-tags.
This is the part to surface clearly in the confirm dialog: "Editing replaces the original. Existing zaps and reactions will no longer be linked to this design."
---
## UX
### Entry point
- **My Designs tab**: each card the user owns gets a new `Edit` button next to `Fork off!`.
- **Profile page (own profile)**: same `Edit` button on each design card in the grid.
- **Design preview (own)**: an `Edit` button in the preview action row, next to Save JSON / Copy share link / Tip.
Edit buttons are owner-only — only visible when `row.e.pubkey === state.community.myPubkey`.
### Editor state
When the user clicks Edit:
1. The editor loads the design (same code path as Fork).
2. We set new state fields:
- `state.editingDTag = '<original d-tag>'`
- `state.editingKind = 30078`
- `state.editingEventId = '<original event id>'` (for orphan reactions / soft-mod warning copy)
3. A top-of-editor banner appears: `Editing published design — Cancel`.
4. The Save / Publish buttons relabel from "Save" → "Save changes" and "Publish" → "Publish update".
### Cancel
- Cancel button discards the editing context but keeps the editor open with whatever the user has typed. They can then choose to publish as a fork (new d-tag) or close.
- Optional second confirmation if they have unsaved changes.
### Publishing the edit
- On publish, we build the kind-30078 event with `tags: [['d', state.editingDTag], …]` instead of generating a new d-tag.
- `created_at = now()` (which signals "updated" to relays).
- Submit-and-verify (existing publish flow), then clear the editing context on success.
- A confirm dialog the first time only per session: "This replaces your published design. Existing zaps/reactions on the old version will be orphaned — they'll still exist on relays but won't show under this design anymore. Continue?"
### Visual cues
- Banner across the top of the editor while in edit mode: `Editing "<title>" · Cancel`
- Save button label: "Publish update" (instead of "Publish")
- After successful publish: toast "Design updated."
- The design's card in the feed should pick up the change as soon as the relay returns it (existing diff-render handles this).
---
## Engineering plan
### State additions
```js
state.editingDTag = '';
state.editingKind = 0;
state.editingEventId = '';
```
Reset on:
- Cancel edit
- After successful publish update
- "Close project" (already exists)
- Loading a fresh design from disk
### Helpers
- `enterEditMode(row)` — sets the three state fields, paints the banner.
- `exitEditMode()` — clears, hides the banner.
- `isEditing()` — boolean for templating.
### Publish-flow changes
Locate the existing publish function (the one that builds the kind-30078 event). Single insertion:
```js
const d = state.editingDTag || mintDTagFromTitleAndMode(...);
```
Plus on success:
```js
exitEditMode();
showToast('Design updated.');
```
No new event kinds, no new relay queries, no new infrastructure.
### Markup
One new banner element above the editor canvas:
```html
<div id="editingBanner" style="display:none">
Editing <span id="editingBannerTitle"></span>
<button id="editingBannerCancel">Cancel</button>
</div>
```
CSS: thin yellow stripe (`background:rgba(251,191,36,.12); border-bottom:1px solid rgba(251,191,36,.4); padding:8px 14px;`).
### Entry-point buttons
- `My Designs` tab: extend the card factory to insert an Edit button for owner-rows.
- `Profile page` grid: same factory is reused via `buildFeedCardForRow` — passing an `ownerActions` flag, or just always checking `row.e.pubkey === state.community.myPubkey` inside the factory.
- `Design preview` action row: insert Edit between existing buttons when owner.
### Wiring the load
The existing "Fork" path already calls `loadPayloadIntoEditor(row.payload, …)`. Edit calls the same function, then `enterEditMode(row)` immediately after.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| User accidentally overwrites their design when they meant to fork | Persistent banner + relabeled Publish button + first-time-per-session confirm dialog |
| Reactions / zap totals reset to zero after edit | Surface in the confirm copy. Acceptable cost of using replaceable events |
| User edits, then someone else's relay still serves the old version | Same risk as any publish. Some relays may lag; eventually the new event wins by `created_at` |
| Cancel + republish-as-fork generates a new d-tag, leaving the original untouched | Working as intended — Cancel exits edit context cleanly |
| User edits a design that was previously deleted via NIP-09 tombstone | Surface a warning: "You previously deleted this design. Editing republishes it." Or block editing — recommendation: warn but allow |
| Old share links (`nevent1…`) break | We already prefer `naddr1…` for mode-prefixed d-tags. `nevent` links are inherently version-pinned and can't survive any edit by design |
| Concurrent edit from two tabs / devices | Last write wins by `created_at` — same as any other multi-device usage. Not a v1 concern |
| Editing the original Fork — should we collapse with the original? | No. Forks are separate designs with separate d-tags; editing one doesn't affect the other |
| User publishes an edit, then later opens an old browser session that has the pre-edit state in localStorage | Same fork-vs-edit warning applies. If editing context is missing from state but the d-tag exists in My Designs, treat as fork (safer default) |
---
## Phased delivery
### Phase A — State, helpers, and banner
1. Add the three state fields and `enterEditMode` / `exitEditMode` / `isEditing` helpers.
2. Add the banner markup + CSS.
3. Wire the banner Cancel button.
4. Reset editing state on close-project and fresh-load.
**Acceptance**: setting state via the console shows the banner; clicking Cancel hides it.
### Phase B — Publish-flow rewire
1. Change the publish path to honor `state.editingDTag` if set.
2. Relabel buttons when editing.
3. First-time-per-session confirm dialog.
4. Toast on success; clear edit state.
**Acceptance**: manually entering edit mode and publishing replaces the original on relays (verified via `naddr` lookup returning the new content).
### Phase C — UI entry points
1. Owner-only Edit button on My Designs card.
2. Same on profile page grid (own profile).
3. Same in design preview action row when owner.
**Acceptance**: clicking Edit from any of the three surfaces loads the design and shows the banner.
### Phase D — Polish
1. Better toast / status text.
2. "Editing republishes a previously-deleted design" warning.
3. Test fork-then-edit and edit-then-fork flows for regressions.
---
## Open questions
1. **Should publishing an edit also bump `created_at` on relays that don't auto-replace?**
Yes — `created_at = now()` is the natural "this is newer" signal. Non-compliant relays may keep the old one but compliant ones will replace.
2. **Should we visually mark edited designs (e.g., "Updated 2 days ago")?**
v1: no. Designs already show created_at indirectly via sort order on the New tab; explicit "edited" labels are noise unless we keep history.
3. **Edit a design while it's still being published initially?**
Block: the publish flow disables the editor until success. Re-edit only after publish succeeds.
4. **Edit from the Editor itself (when the user remembers they need to fix the live version)?**
Not v1. Edit always starts from a card/preview/profile. Could be added later as a "Match published version → Edit" affordance.
5. **What about replies and reposts targeting the old event id?**
Same orphan fate as reactions and zaps. We don't try to rewrite or migrate them.
---
## Acceptance summary
- [ ] Owner-only Edit button on My Designs cards, profile grid (own), and design preview.
- [ ] Clicking Edit loads the design and shows the editing banner.
- [ ] Banner Cancel exits edit mode without losing the editor's current state.
- [ ] Publish in edit mode reuses the original d-tag and replaces the event on relays.
- [ ] First-time-per-session confirm dialog explains the zap/reaction tradeoff.
- [ ] Save buttons relabel to "Publish update" while editing.
- [ ] After successful update, the editor returns to normal mode and the user sees a confirmation.
- [ ] Existing fork flow is untouched — clicking Fork on someone else's design still mints a new d-tag and creates a separate post.
-361
View File
@@ -1,361 +0,0 @@
# Lightning Tips / Zaps (LNURL-pay v1)
## Goals
Ship the **smallest possible "send sats" surface** that works for any author with a Lightning address in their kind-0 profile.
- Pick an amount.
- Get a bolt11 invoice rendered as a QR code + copyable string.
- User pays from whatever wallet they want.
- Done.
No Nostr Wallet Connect. No WebLN auto-pay. No NIP-57 zap requests (kind-9734) or zap receipts (kind-9735) in v1. Just **LNURL-pay → bolt11 → QR**.
### Product goals
1. Get sats flowing to creators in the fewest possible steps.
2. Zero dependency on any extension, wallet integration, or signed-event flow.
3. Works for both **profile pages** (tip the author) and **design cards** (tip a specific piece of artwork) using the same modal.
4. Privacy-respectful: requests hit only the recipient's LNURL endpoint and (optionally) one QR-generator library — no analytics, no payment-relay middleman.
### Non-goals (v1)
- NIP-57 zap requests. Without a signed zap request, the payment is technically a **plain LNURL-pay tip**, not a zap-in-the-spec-sense. We use "Zap" as the user-facing label because it's the term users expect.
- Zap receipts / "Zapped ⚡" badges on designs.
- WebLN (`window.webln.sendPayment`) auto-pay. Could ship in a v1.1 polish if cheap.
- Nostr Wallet Connect (NWC) wallet binding.
- Payment confirmation tracking. We hand off the invoice and trust the user.
- Comment / message attached to the tip.
- Custom amounts beyond the LNURL-advertised min/max range.
---
## What we already have to work with
- `profile.lud16` (e.g. `[email protected]`) and/or `profile.lud06` (LNURL bech32 string) — pulled in via the existing `populateCommunityProfiles` (kind-0 fetch).
- A working modal pattern (`reportModal`, `closeProjectModal`, `pdfJpegModal`).
- An author surface in feed cards (we'll wire a "Tip" button onto every card).
- An author surface in the upcoming profile page.
---
## LNURL-pay primer (just the parts we need)
### Step 1 — Resolve the LNURL-pay endpoint
- `lud16` (Lightning address) `name@host` → `https://<host>/.well-known/lnurlp/<name>`.
- `lud06` (bech32 LNURL) decodes to the same kind of URL via the bech32 → URL trick.
### Step 2 — Fetch endpoint metadata
GET that URL. Response shape (the fields we care about):
```json
{
"callback": "https://host/lnurl/pay/callback?id=…",
"minSendable": 1000,
"maxSendable": 100000000,
"metadata": "[[\"text/plain\",\"Sats for alice\"]]",
"tag": "payRequest",
"commentAllowed": 0
}
```
- `minSendable` / `maxSendable` are in **millisats** (1 sat = 1000 msat).
- `commentAllowed`: max chars for an attached comment. Often 0. We skip comments in v1.
### Step 3 — Request the invoice
GET `<callback>?amount=<msats>` (URL-encode preserved). Response:
```json
{
"pr": "lnbc100n1p...",
"routes": []
}
```
- `pr` is the bolt11 invoice. That's all we need.
### Step 4 — Show it
Render `pr` as:
- A QR code (scan with a mobile wallet).
- A copyable string (paste into a desktop wallet).
- A `lightning:<pr>` link (OS hands off to a desktop wallet if registered).
We do not poll for payment confirmation. The modal stays open until the user closes it.
---
## UX
### Entry points
- Profile page action row: a `⚡ Tip` chip (only rendered if the profile has `lud16` or `lud06`).
- Feed card: a `⚡ Tip` button on each card whose author has a lightning address. Same modal, scoped to that author.
- Single-design preview: same chip in the actions row, when the author has a lightning address.
(Per-design tipping in v1 = tipping the **author** of that design. Splitting payments to multiple recipients or tagging the tip to a specific event id is out of scope.)
### The tip modal
```
┌─────────────────────────────────────┐
│ Tip [email protected] × │
│ │
│ Amount │
│ [ 21 ] [ 100 ] [ 500 ] [ 1000 ] │ ← quick-pick chips, sats
│ [ 5000 ] [ 21000 ] [ Custom ] │
│ │
│ Custom: [______] sats │ ← visible only when Custom selected
│ │
│ [ Get invoice ] │
└─────────────────────────────────────┘
```
After "Get invoice":
```
┌─────────────────────────────────────┐
│ Tip [email protected] × │
│ │
│ ┌───────────────────┐ │
│ │ │ │
│ │ ▓▓▓ QR CODE ▓▓▓ │ │ ← clickable → lightning:pr URL
│ │ │ │
│ └───────────────────┘ │
│ │
│ lnbc100n1p...3qx2 📋 │ ← truncated, full on copy
│ │
│ Scan with a Lightning wallet, │
│ or copy the invoice to paste. │
│ │
│ [ New amount ] [ Close ] │
└─────────────────────────────────────┘
```
- Default amounts (sats): 21, 100, 500, 1000, 5000, 21000. Plus "Custom".
- Custom respects `minSendable` / `maxSendable` from the endpoint (converted to sats). Out-of-range values render an inline hint, not a native alert.
- "Get invoice" is disabled while fetching; shows "Fetching invoice…".
- QR is clickable; click opens `lightning:<bolt11>` so desktop wallets registered on the scheme can pick it up. Mobile users scan.
- "New amount" returns to the picker without closing.
- ESC / × / backdrop click closes.
### Error states
| Case | Render |
|---|---|
| `lud16` / `lud06` missing or malformed | The Tip entry point isn't rendered at all. |
| LNURL-pay endpoint unreachable / 4xx / 5xx | "Couldn't reach the recipient's Lightning service. Try again later." Retry button. |
| Endpoint returns non-`payRequest` tag | "This isn't a Lightning tip endpoint." Close button. |
| Amount outside min/max | Inline hint: "Enter between X and Y sats." |
| Invoice fetch failed | "Couldn't generate an invoice. Try a different amount." |
| CORS blocks the lookup | Fallback: show the lightning address with a "Copy and pay manually" prompt. (See CORS section below.) |
No browser `alert()` ever fires.
---
## Engineering plan
### New code (all in the existing inline script)
```js
async function fetchLnurlPayEndpoint(lud16OrLud06) // returns { callback, minSendable, maxSendable, metadata } or throws
async function fetchLnurlInvoice(callback, msats) // returns bolt11 or throws
function openTipModal(opts: { ln: string, label?: string })
function renderTipAmountPicker()
function renderTipInvoiceView(bolt11)
function closeTipModal()
```
### LNURL helpers
```js
function lud16ToLnurlpUrl(ln) {
const [name, host] = ln.split('@');
if (!name || !host) throw new Error('Bad lightning address');
return `https://${host}/.well-known/lnurlp/${name}`;
}
function lud06ToUrl(bech32) {
// reuse the bech32 decoder we already use for npub/note/nevent
const decoded = decodeBech32(bech32);
return new TextDecoder().decode(decoded.data);
}
function resolveLnurlPayUrl(profile) {
if (profile.lud16) return lud16ToLnurlpUrl(profile.lud16.trim());
if (profile.lud06) return lud06ToUrl(profile.lud06.trim());
return '';
}
```
### Invoice fetch
```js
async function getLnurlPayMetadata(url) {
const r = await fetch(url, { headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error(`Lookup failed: ${r.status}`);
const j = await r.json();
if (j.tag !== 'payRequest') throw new Error('Not a pay endpoint');
return j;
}
async function getInvoice(callback, msats) {
const url = `${callback}${callback.includes('?') ? '&' : '?'}amount=${msats}`;
const r = await fetch(url, { headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error(`Invoice fetch failed: ${r.status}`);
const j = await r.json();
if (j.status === 'ERROR') throw new Error(j.reason || 'Service error');
if (!j.pr) throw new Error('No invoice in response');
return j.pr;
}
```
### QR rendering
We need an in-browser QR encoder. The repo currently has no build step.
**Recommendation**: vendor a tiny self-contained QR encoder as `src/vendor/qrcode.min.js` and load it with a `<script>` tag. Smallest options:
- `qrcode-generator` by Kazuhiko Arase — ~12 KB minified, MIT, no deps. Renders to canvas or SVG.
- `nayuki/QR-Code-generator` — slightly larger, also MIT.
Pick `qrcode-generator`. Wrap it:
```js
function renderQrInto(el, text, sizePx = 240) {
const qr = qrcode(0, 'M'); // auto type, medium error correction
qr.addData(text);
qr.make();
el.innerHTML = qr.createSvgTag({ scalable: true });
el.querySelector('svg').style.width = sizePx + 'px';
el.querySelector('svg').style.height = sizePx + 'px';
}
```
### Modal markup (sketch)
```html
<div class="modal" id="tipModal" aria-hidden="true" style="display:none">
<div class="modalInner" style="max-width:420px">
<button class="modalClose" id="tipCloseBtn" aria-label="Close">×</button>
<h2 id="tipTitle"></h2>
<p class="hint" id="tipSubtitle"></p>
<!-- Picker view -->
<div id="tipPickerView">
<div class="tipAmounts">
<button data-sats="21">21</button>
<button data-sats="100">100</button>
<button data-sats="500">500</button>
<button data-sats="1000">1k</button>
<button data-sats="5000">5k</button>
<button data-sats="21000">21k</button>
<button data-sats="custom">Custom</button>
</div>
<input type="number" id="tipCustomInput" placeholder="Sats" style="display:none" />
<p class="hint" id="tipPickerError" style="min-height:1.2em"></p>
<div class="buttonRow">
<button type="button" id="tipGetInvoiceBtn" class="primary">Get invoice</button>
</div>
</div>
<!-- Invoice view (display:none until ready) -->
<div id="tipInvoiceView" style="display:none">
<div id="tipQr" class="tipQr"></div>
<div class="tipBolt11">
<code id="tipBolt11"></code>
<button type="button" id="tipCopyBtn">📋</button>
</div>
<p class="hint">Scan with a Lightning wallet, or copy and paste.</p>
<div class="buttonRow">
<button type="button" id="tipBackBtn">New amount</button>
<button type="button" id="tipDoneBtn">Close</button>
</div>
</div>
</div>
</div>
```
### Wiring
- Profile page action row: render `⚡ Tip` chip when `resolveLnurlPayUrl(profile)` returns a non-empty URL.
- Feed card: same — only render when the author profile (from `_feedCache.profiles`) has `lud16` or `lud06`.
- Click handler: `openTipModal({ ln: resolveLnurlPayUrl(profile), label: profile.lud16 || profile.name || shortPubkey(pk) })`.
---
## CORS
LNURL-pay endpoints **usually** set `Access-Control-Allow-Origin: *`. The big providers (Wallet of Satoshi, Strike, getalby.com, Phoenix, Alby Hub) all do. But some self-hosted nodes don't, and that's not something we can fix client-side.
Mitigation:
1. Try the direct fetch first.
2. On CORS failure, show a graceful fallback: render the lightning address with "We can't auto-generate the invoice for this address from the browser. Paste it into your wallet to send sats." plus a copy button.
No public CORS proxy by default — proxies are a privacy and reliability risk. A user-configurable "trusted CORS proxy" setting could land later as an opt-in.
---
## Performance
- The LNURL metadata fetch is on-demand (only when the modal opens), so it doesn't slow profile or feed first paint.
- The QR encoder is loaded eagerly on app start (it's tiny). Acceptable.
- No persistent caching — each tip session re-fetches. Tip flows are rare and the data may change (e.g., min/max amounts adjusted by the recipient).
---
## Phased delivery
### Phase A — LNURL helpers + tip modal (profile page only)
1. Vendor `qrcode-generator` as `src/vendor/qrcode.min.js`; add `<script>` tag.
2. Implement `lud16ToLnurlpUrl`, `lud06ToUrl`, `resolveLnurlPayUrl`, `getLnurlPayMetadata`, `getInvoice`, `renderQrInto`.
3. Add `tipModal` markup + CSS.
4. Implement `openTipModal` / picker / invoice view / close.
5. Wire the chip into the profile page action row.
**Acceptance**: opening a profile that has `lud16` shows the Tip chip; clicking it lets me pick an amount and renders a working QR + bolt11. Scanning it in a wallet shows the right amount.
### Phase B — Feed card surface
1. Add tip button to feed-card action row when author has LN address.
2. Same handler.
**Acceptance**: every feed card whose author has a lightning address shows a tip button.
### Phase C — Single-design preview surface
1. Same chip in the preview header.
**Acceptance**: previewing a design from a tip-enabled author shows the chip.
### Phase D — Polish (optional, ship later)
1. WebLN auto-pay path: if `window.webln` is available, `await window.webln.enable()` and `await window.webln.sendPayment(bolt11)` from the invoice view as a one-click pay option.
2. Comment box if `commentAllowed > 0`.
3. Remember last amount in `localStorage`.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| CORS blocks the LNURL fetch | Fallback to "copy the address and pay manually" UI |
| Recipient's LNURL endpoint is slow/down | Timeout + Retry button; never spinner-forever |
| QR library bloats the bundle | Use `qrcode-generator` (~12 KB); load once |
| User picks an amount outside min/max | Validate before invoice fetch; inline hint |
| Custom amount parsing | Coerce to integer sats; reject non-positive |
| User pays the wrong amount because the bolt11 is locked to a specific amount | bolt11s from LNURL-pay are **amount-locked**; wallets show the amount before paying — low risk |
| User generates an invoice, walks away, comes back later | Invoices have expiration (usually ~10 min); modal copy explains "If this expires, just regenerate" — acceptable |
| Recipient's lightning address changes between when profile is cached and when user tips | Always resolve LNURL at modal open time, never cache the LNURL itself |
| Looks like a zap but isn't (no kind-9735 receipt published) | UI says "Tip" not "Zap" until we ship the real zap-request flow. Or we accept the loose terminology — most users don't distinguish |
| Self-hosted LN nodes with strict TLS / outdated certs | Browser error surfaces; we show a friendly message and the manual-paste fallback |
---
## Open questions
1. **Use "Tip" or "Zap" as the label?**
- Recommendation: **"Tip"** in v1, since no kind-9734 zap request is signed. Reserve "Zap" for when we ship the full NIP-57 flow.
2. **WebLN as a v1 nice-to-have or strictly later?**
- Recommendation: **Phase D**. It's ~20 lines and a real UX boost for desktop users with Alby installed, but adds a code path to maintain.
3. **Should we add a tip button to the single-design preview from day one?**
- Recommendation: **yes**. Same handler, trivial to wire, and people viewing a specific design are highly engaged.
4. **Comment / message field?**
- Recommendation: **skip v1**. Most LNURL endpoints have `commentAllowed: 0` anyway. Adds modal complexity for little payoff.
5. **Where to vendor the QR library?**
- Recommendation: `src/vendor/qrcode.min.js`, committed to the repo (no CDN dependency, works offline, no rugpull risk).
---
## Acceptance summary (v1 = Phase A + B + C)
- [ ] LNURL-pay endpoint resolves from `lud16` and `lud06`.
- [ ] Tip chip appears on profile / feed card / preview only when the author has a Lightning address.
- [ ] Amount picker offers 21 / 100 / 500 / 1k / 5k / 21k + Custom.
- [ ] Custom amount validates against endpoint min/max.
- [ ] Bolt11 invoice renders as both QR and copyable text.
- [ ] Clicking QR opens `lightning:<pr>` for desktop wallet handoff.
- [ ] Errors render inline; no `alert()`.
- [ ] CORS-blocked endpoints surface a manual-paste fallback.
- [ ] Profile page and feed first paint are not blocked by LNURL fetches.
-363
View File
@@ -1,363 +0,0 @@
# Profile Pages (`/u/:npub`)
## Goals
Turn every Nostr author who appears in CaseWrap Studio into a **first-class, linkable profile page**:
- Pulls kind-0 metadata: avatar, banner, display name, NIP-05, about, website, lightning address (`lud16` / `lud06`).
- Shows their published designs (templates + disc designs + custom art) using the existing feed-card renderer.
- Sharable via a real URL: `https://casewrap.studio/u/<npub>` resolves on cold load, on reload, and on link share.
- Surface `lud16` / `lud06` metadata so a future zap feature has a hook to bind to.
### Product goals
1. Make discovery feel like browsing channels — clicking an author goes somewhere instead of a dead-end menu.
2. Use only Nostr-native metadata that's already flowing through the app.
3. Don't break the existing `/share/:id` route or any current entry points.
4. Stay snappy: never block first paint waiting for a profile fetch.
### Non-goals (v1)
- Editing your own profile from inside the app (still done in Alby / Damus / nostrudel etc.).
- Following counts / following-back UI (we have `state.community.myFollows` already, but rendering follow graphs visually is its own feature).
- Reply / DM affordances on the profile page.
- Lightning zaps / tips — fully out of scope here. See `docs/LIGHTNING_ZAPS_FEATURE.md` for the separate plan.
- Open Graph / Twitter card meta for `/u/:npub` (SPA + static host = no per-route OG without a build step; ship later).
- "Pinned design" / profile-level featured artwork.
---
## URL design
### Canonical form
```
/u/<npub>
```
- `<npub>` is the NIP-19 bech32 encoding of the pubkey (`npub1…`). Lowercase, ~63 chars.
- Optional trailing slash tolerated.
- Optional query string preserved (`?tab=designs`, future).
### Why `/u/` (and not `/profile/` or `/p/`)
- Short, conventional (mastodon, Twitter, etc.). `/p/` already implies "post" on some platforms; avoid the ambiguity.
- Easy to type, easy to share verbally.
### Hash fallback
For environments that can't honor the Vercel rewrite (local file open, some static hosts), also accept:
```
/#/u/<npub>
```
The existing `/share/:id` route already does this — same pattern.
### Accepted identifiers in the route
- `npub1…` (canonical, what we emit)
- 64-char hex pubkey (fallback for hand-typed links)
- `nprofile1…` (NIP-19 profile with relay hints — decoded the same way as in `loadSharedDesign`)
If the identifier is unparseable, render an error state in-modal: "Not a recognized Nostr identifier. Double-check the link or paste an `npub1…`."
### Vercel routing
Add to `vercel.json`:
```json
{
"rewrites": [
{ "source": "/share/:id", "destination": "/" },
{ "source": "/share/:id/", "destination": "/" },
{ "source": "/u/:npub", "destination": "/" },
{ "source": "/u/:npub/", "destination": "/" }
]
}
```
Vercel returns `index.html` for any `/u/<...>` URL; the SPA reads `location.pathname` and dispatches to `openProfilePage`.
### URL ↔ UI sync (history API)
| Trigger | URL action |
|---|---|
| Click an author chip in the feed | `history.pushState({ profile: pubkey }, '', '/u/<npub>')` |
| Close the profile modal via "Back" or × | `history.back()` (lets the browser back stack handle it) |
| Open the modal directly via deep link | URL is already correct; just open the modal |
| `popstate` away from `/u/...` | Close the profile modal |
| `popstate` to a different `/u/...` | Swap the modal contents to the new pubkey |
This matches the existing `/share/:id` behavior — same `popstate` listener (line ~10327) gets extended.
---
## Data model
### What's already in state
- `state.community.profiles[pubkey]` — kind-0 content cache (used by feed cards).
- `_feedCache.profiles[pubkey]` — same shape, populated by feed loads.
- `state.community.myFollows[]` — viewer's hop-1 graph.
- `state.community.myMuted` — viewer's mute list.
- `_blockedAuthors` — local block set.
### New cache: `_profilePages`
A small in-memory map for profile-page-specific data so we don't re-fetch on every open:
```js
const _profilePages = new Map(); // pubkey -> { profile, designs, fetchedAt, status }
```
- `profile`: latest kind-0 content (merged from existing caches if present).
- `designs`: array of events authored by this pubkey, filtered to template/disc/customart kinds.
- `fetchedAt`: timestamp; TTL ~5 minutes — same scale as feed.
- `status`: `'loading' | 'ok' | 'empty' | 'error'` for the render layer.
### Lightning fields
From the kind-0 content JSON:
- `lud16`: `"[email protected]"` — display verbatim.
- `lud06`: `"LNURL1…"` — decode for display in v1 (just show "⚡ Lightning address available"); full LNURL-pay flow lands with zaps.
- Both can be present; prefer `lud16` if so.
### NIP-05 verification
Already partially handled in the codebase (`profile?.nip05` is read at line 3342). For the profile page:
- Display the raw `nip05` string under the display name.
- v1: trust-but-display — no on-demand verification fetch (NIP-05 spec says clients should verify via well-known, but it's optional and slow). Tag with a small "unverified" hint.
- v2 (deferrable): fire-and-forget GET to `/.well-known/nostr.json?name=…`; cache result in `_profilePages` entry.
---
## UX
### Layout (modal)
```
┌─────────────────────────────────────────────────────────────┐
│ ╔═══════════════════════════════════════════════════════╗ │ ← banner image
│ ║ (full-bleed kind-0 `banner` URL) ║ │ 16:5 aspect cap
│ ╚═══════════════════════════════════════════════════════╝ │
│ │
│ ┌──────┐ │
│ │ │ Display name Follow Block … │ ← actions row
│ │ AVA │ [email protected] (nip05) │
│ │ │ npub1abc…xyz 📋 │
│ └──────┘ │
│ │
│ About text wraps here for several lines if present. │
│ Website link, lightning address chip, etc. │
│ │
│ ⚡ [email protected] 📋 [ Zap (coming soon) ] │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ Designs (24) [filter ▾] │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ card │ │ card │ │ card │ │ card │ … │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Header components
- **Banner**: kind-0 `banner` URL, `object-fit: cover`. If missing, fall back to a gradient using the viewer's accent color so cards aren't ragged.
- **Avatar**: 96×96 circle, overlapping the banner bottom by ~30%. Fall back to `picture` → identicon (existing `shortPubkey`-based placeholder) → default.
- **Display name**: `display_name` || `name` || `shortPubkey(...)`.
- **NIP-05**: small line under name; muted color.
- **npub**: `npub1abc…xyz` (truncated middle), with a 📋 copy button.
- **About**: rendered as plain text (no HTML parsing in v1; mentions and links don't auto-link). Truncate to 4 lines with "Show more".
- **Website**: if `profile.website` is a valid http(s) URL, show as a chip-link.
- **Lightning chip**: if `lud16` present, shows the address with a ⚡ icon; click to copy. If only `lud06`, generic "⚡ Lightning enabled" chip. No payment action in v1 — that lives in a separate feature.
### Action row (chip buttons)
- **Follow / Unfollow** — only visible when signed in. Wires into existing `state.community.myFollows` + kind-3 publish. Optimistic update.
- **Block** — same `blockAuthor` + confirm dialog as feed cards.
- **Report** — opens existing `openReportModal(null, pubkey)` (we'll widen the existing report flow to accept a pubkey-only target; today it requires an event id).
- **Copy npub** — clipboard.
- **Open in nostr client** — opens `nostr:<npub>` URL scheme; lets the OS hand off to whatever client is registered.
(No zap button in v1 — handled in a separate feature.)
If the profile is **the viewer themselves** (`pubkey === state.community.myPubkey`), the action row collapses to just "Copy npub" + a note: "This is you."
### Designs grid
Reuses `buildFeedCard` and the existing card CSS. Sort: newest first.
Filters:
- All (default)
- Cover wrap
- Disc sheet
- Jewel insert
- Disc design
- Custom art
Filter dropdown reuses the existing category filter shape. Empty state: "No public designs yet."
### Loading sequence
1. Modal opens immediately; banner + avatar + "Loading…" appear.
2. If `_profilePages` has a fresh entry → render full state in one paint.
3. Else fire two parallel queries to current relay list (`state.community.relays`):
- `{ kinds: [0], authors: [pubkey], limit: 1 }`
- `{ kinds: [30078], authors: [pubkey], limit: 200 }` (the design kind)
4. Progressive paint: profile metadata fills as soon as kind-0 arrives; designs stream in as kind-30078 events arrive, deduped by `d`-tag (replaceable kind — keep latest).
5. Cache the result; expires after `PROFILE_TTL_MS = 5 * 60 * 1000`.
### Empty / error states
| Case | Render |
|---|---|
| pubkey valid, no kind-0 event found | Avatar = default; name = `shortPubkey(pk)`; about = blank. Designs query still runs. |
| pubkey valid, no designs published | Designs section shows "No public designs yet." |
| Identifier unparseable | Modal shows error message + Close button. |
| Relay query times out | Show what we have (cached or partial) + "Retry" affordance. |
| User is in `_blockedAuthors` | Show a banner: "You blocked this author. Unblock to see their designs." with an Unblock button. |
### Close behavior
- × button in top-right (matches welcome modal).
- ESC key closes (new — also fixes report/close-project modals while we're here, optional).
- Click on backdrop closes (matches existing modal pattern).
- Closing always calls `history.back()` if we pushed state; otherwise just hides the modal.
---
## Engineering plan
### New module-ish functions (all in the existing inline script)
```js
const PROFILE_TTL_MS = 5 * 60 * 1000;
const _profilePages = new Map();
function parseProfileIdFromLocation() // mirrors parseShareIdFromLocation
function decodeProfileIdentifier(raw) // npub / nprofile / hex → { pubkey, relays? }
async function openProfilePage(pubkeyOrId, opts = { pushState: true })
async function ensureProfilePageData(pubkey) // fetch profile + designs, cache
function renderProfilePage(pubkey) // DOM render against cache
function closeProfilePage() // hide modal, optionally history.back()
```
### Wiring points (existing code that needs to call these)
| Today | New behavior |
|---|---|
| Author chip click in `buildFeedCard` (currently no-op or opens profile in nostrudel) | `openProfilePage(row.e.pubkey)` |
| Author name in comment list | same |
| Author name in repost row | same |
| Author name on shared-design preview header | same |
| `init()` (line ~10321) — currently dispatches `parseShareIdFromLocation` only | also dispatch `parseProfileIdFromLocation` |
| `popstate` listener (line ~10327) | branch on which route the new URL matches |
### Markup (new modal)
```html
<div class="modal" id="profileModal" aria-hidden="true" style="display:none">
<div class="modalInner" style="max-width:880px;padding:0;overflow:hidden">
<button class="modalClose" id="profileCloseBtn" aria-label="Close">×</button>
<div id="profileBanner" class="profileBanner"></div>
<div class="profileHeader">
<img id="profileAvatar" class="profileAvatar" alt="" />
<div class="profileHeaderText">
<h2 id="profileName"></h2>
<p id="profileNip05" class="hint"></p>
<p id="profileNpub" class="hint"></p>
</div>
<div id="profileActions" class="profileActions"></div>
</div>
<p id="profileAbout" class="profileAbout"></p>
<div id="profileLnRow" class="profileLnRow" style="display:none"></div>
<hr />
<div class="profileDesignsHeader">
<h3>Designs (<span id="profileDesignCount">0</span>)</h3>
<select id="profileDesignFilter">…</select>
</div>
<div id="profileDesignsGrid" class="feedWall"></div>
<p id="profileStatus" class="hint"></p>
</div>
</div>
```
CSS additions are small — banner, avatar overlap, action chip row. Designs grid reuses `.feedWall`.
### Performance notes
- Profile fetch and designs fetch are parallel — first paint doesn't wait for either.
- `diffWallCards` (already used by the template browser) keeps the designs grid stable as events stream in.
- `_profilePages` is in-memory only — survives within a single page session, not across reloads. Good enough for v1; localStorage cache is a follow-up if we see usage justify it.
---
## Lightning surface (v1)
The profile page is **purely a metadata surface** for Lightning in v1 — no payment flow, no button, just display:
```js
const ln = profile.lud16 || decodeLud06(profile.lud06) || '';
if (ln) {
// render "⚡ <ln> 📋" chip — clicking copies to clipboard
}
```
When zaps land separately (`docs/LIGHTNING_ZAPS_FEATURE.md`), the chip becomes an entry point. The profile project does not block on zaps and does not preview them.
The action row in v1 contains **only** Follow, Block, Report, Copy npub, Open in nostr client. No zap button.
---
## Phased delivery
### Phase A — Route + skeleton modal (no live data)
1. Add `vercel.json` rewrites for `/u/:npub`.
2. New `profileModal` markup + CSS.
3. `parseProfileIdFromLocation`, `decodeProfileIdentifier`, `openProfilePage` (no fetch yet — just opens modal with placeholder).
4. Wire `popstate` and `init()`.
**Acceptance**: visiting `/u/npub1…` cold-loads with the modal open showing "Loading…"; closing returns to `/`.
### Phase B — Profile metadata
1. `ensureProfilePageData` fetches kind-0; merges into existing caches.
2. `renderProfilePage` paints header, about, lightning chip, action row.
3. Author chips across the app (feed cards, comments, reposts, preview header) wired to `openProfilePage`.
4. Block / Report / Follow chips reuse existing handlers; widen `openReportModal` to accept pubkey-only.
**Acceptance**: clicking any author opens their profile; reload preserves it; back returns to where you came from.
### Phase C — Designs grid
1. Query kind-30078 by author; dedupe by `d`-tag.
2. Reuse `buildFeedCard` + `diffWallCards`.
3. Filter dropdown.
4. Empty state copy.
**Acceptance**: profile page shows the author's designs, latest first, with a working filter. Streams in without flashing.
### Phase D — Polish
1. ESC closes modal.
2. NIP-05 verification (async, fire-and-forget).
3. "Open in nostr client" chip.
4. Better banner fallback gradient.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Long npubs make the URL ugly | Acceptable — `npub` is the conventional form; we could optionally also accept `nprofile` later for relay hints |
| Cold-loading `/u/:npub` flashes empty modal before kind-0 arrives | Render skeleton (gradient banner + initials avatar) immediately; swap in real data on arrival |
| Author has a banner URL that 404s or is huge | `loading="lazy"` + error fallback to gradient; we already do similar for design thumbnails |
| Designs query returns 100s of events for prolific authors | Cap initial render to first 60; "Show more" button paginates from cache |
| User-typed `/u/<garbage>` 404-like state | In-modal error message + close button (no native alert) |
| NIP-05 lookups would slow first paint if synchronous | Always async, never blocking; show unverified state first |
| Lightning address in metadata is malformed | Skip the chip entirely; don't crash; don't show a broken UI |
| Profile modal opens over a design preview — back-stack confusion | `pushState` per opening; `popstate` closes the topmost modal that matches the URL |
| Author has muted us / we've blocked them | Block check happens **before** modal opens, redirecting to unblock prompt |
---
## Open questions to resolve before build
1. **Should the profile modal replace or stack over the design preview?**
Recommendation: **stack**. Closing the profile returns to the preview. Use `history.pushState` so back works naturally.
2. **Should we render LN address as a payment intent (`lightning:` URL) in v1?**
Recommendation: **no**. v1 is copy-only; v2 introduces the proper zap flow. Avoid teaching users a half-flow.
3. **What about non-CaseWrap kind-0 fields like `bot: true` or `pronouns`?**
Recommendation: ignore for v1. They're not standard. Pronouns could be added later under display name.
4. **Should we surface follower count / following count?**
Recommendation: **no for v1**. Requires fetching the author's kind-3 (cheap) but rendering "followed by N" is its own UX puzzle (whose N?). Defer.
5. **Should we cache profile pages to localStorage so reloads are instant?**
Recommendation: **no for v1**, yes for v2. In-memory is fine to validate the feature.
---
## Acceptance summary
- [ ] `/u/<npub>` resolves on cold load, reload, and back/forward.
- [ ] Clicking any author surface in the app routes to their profile.
- [ ] Profile shows banner, avatar, name, NIP-05, about, npub (copyable), lightning chip (when present).
- [ ] Profile shows the author's published designs in a filterable grid.
- [ ] Follow / Block / Report chips work and reflect current state.
- [ ] Back button leaves the profile and restores the previous view.
- [ ] No browser-native alerts on any error path.
- [ ] Existing `/share/:id` route is unaffected.
-168
View File
@@ -1,168 +0,0 @@
# Streaming Relay Sync & Soft Cooldown Plan
## Goals
Make the Community template browser feel **fast and reliable** by rendering Nostr template events progressively as they arrive from each relay, instead of waiting for every relay (or a 4.5s timeout) before showing anything.
### Product goals
1. First template visible within ~300ms of opening Community (assuming any fast relay is reachable).
2. Slow or flaky relays no longer block the feed — their results trickle in late instead of being lost.
3. A single transient relay timeout does not silently shrink the relay pool for the next hour.
4. No regression in publish reliability or social action (react/follow/repost) flows.
### Non-goals (v1)
- Persistent local cache of fetched template events across sessions.
- Relay scoring / per-relay weighting beyond simple cooldown.
- Per-event retry on a different relay if its origin relay drops mid-stream.
- Switching to a Nostr library (e.g. NDK, nostr-tools pool). Stay with the in-file WebSocket implementation.
---
## Problem statement
**Symptoms reported:** Nostr designs (kind `30078` notes) often never appear in the template browser, or take very long to appear.
**Observed in `src/index.html`:**
1. `queryRelays()` (line 2980) opens a WebSocket per relay, sends a single `REQ`, and collects events into a `Map`. It uses `Promise.all` over every relay and only returns the merged result after **every relay either finishes (EOSE) or hits the timeout**. The default timeout is 3500ms; the template fetch uses 4500ms.
2. `fetchFeedAndReactions()` (line 5735) awaits that full result before populating `_feedCache.events`. `loadFeedTab()` (line 6217) waits for the entire cache to fill before building any rows.
3. `markRelayFailure()` (line 2685) is called on any timeout, error, or unexpected close, and pushes the relay into a cooldown that starts at **10 minutes** and escalates up to **1 hour**. State is persisted to `localStorage` under `casewrap-relay-health`, so failures bleed across sessions.
**Net effect:** if one or two relays are slow, the user stares at "Loading…" for the full timeout window, and any transient hiccup quietly removes that relay from the rotation for the rest of the hour — which compounds over time and explains the "sometimes never loads" report.
---
## Current architecture
```
loadFeedTab(tab)
└── await fetchFeedAndReactions()
├── await Promise.all([
│ queryRelays({ kinds:[30078], limit:200 }, 4500ms), ← blocks until all relays settle
│ queryRelays({ kinds:[5], limit:100 }, 3500ms)
│ ])
├── batch queryRelays({ kinds:[7], '#e':[...] }) ← reactions
└── batch queryRelays({ kinds:[0], authors:[...] }) ← profiles
└── buildRowsFromEvents(_feedCache.events, …)
└── render
```
Single render at the end. No partial UI.
---
## Proposed architecture
```
loadFeedTab(tab)
└── show skeleton
└── start fetchFeedAndReactions({ onProgress: rerender })
├── queryRelays({ kinds:[30078] }, { onEvent }) ────────────┐
│ onEvent → push into _feedCache.events Map │
│ trigger debounced onProgress │
│ ├── render fires repeatedly as
├── queryRelays({ kinds:[5] }, { onEvent }) ────────────┘ new templates arrive
├── (await both relay sets to settle)
├── batch reactions → onProgress
└── batch profiles → onProgress
```
Key shape changes:
- `queryRelays(relays, filters, { timeoutMs, onEvent, onRelayDone })` — new optional callbacks. Promise still resolves with the full event array when all relays settle, so non-streaming call sites are unchanged.
- `_feedCache.events` populated incrementally via a Map keyed by `event.id` (was: written once at the end).
- `fetchFeedAndReactions(opts)` accepts `{ onProgress }`. Calls it (debounced 150ms) whenever the cache changes.
- `loadFeedTab(tab)` passes an `onProgress` that re-runs `buildRowsFromEvents` and re-renders the current tab.
### Cooldown change (read vs publish)
`markRelayFailure(relay, error, { context })` gets a `context` param:
- `'publish'` (default, preserves current behavior): base **10 min**, max **1 hr**.
- `'read'`: base **30s**, max **5 min**.
`queryRelays` passes `{ context: 'read' }`. `publishToRelays` is unchanged (still defaults to `'publish'`).
Rationale: a single dropped read should not lock a relay out of the next 10 minutes of browsing. A repeated 30s skip is enough to deprioritize a genuinely-broken relay while keeping the pool wide for transient blips.
---
## Phased delivery plan
### Phase 1 — Soft cooldown for reads
Smallest, isolated change. High impact on its own because it stops the relay pool from silently shrinking.
**Changes**
- `src/index.html` constants block: add `RELAY_READ_FAILURE_COOLDOWN_MS = 30 * 1000` and `RELAY_READ_FAILURE_MAX_COOLDOWN_MS = 5 * 60 * 1000`.
- `markRelayFailure(relay, error, opts = {})`: accept `context`, pick cooldown base/max accordingly.
- `queryRelays`: pass `{ context: 'read' }` to every `markRelayFailure` call inside it.
- `publishToRelays`: no change (default `'publish'` context).
**Acceptance**
- Triggering a read-side timeout (e.g. by adding a known-dead relay URL) puts the relay in cooldown for ~30s, verifiable via `localStorage['casewrap-relay-health']`.
- Triggering a publish-side failure still produces a 10+ min cooldown.
### Phase 2 — Streaming `queryRelays`
Non-breaking extension of the existing function.
**Changes**
- Add `onEvent(event, relay)` and `onRelayDone({ relay, ok, error })` options to `queryRelays`.
- In the `message` handler, when a NEW `EVENT` arrives (not already in the dedupe Map), fire `onEvent` after inserting. Do not re-fire for duplicates.
- In `finish()`, fire `onRelayDone` once before resolving.
- Wrap callback calls in try/catch so a buggy listener can't kill the relay reader.
- Existing return value (sorted event array, resolves after all relays settle) is preserved.
**Acceptance**
- Existing call sites (publish verification, profile fetch, reaction batches, mute list, etc.) compile and behave identically.
- A new test invocation that passes `onEvent` receives events in arrival order across relays, with each `event.id` fired at most once.
### Phase 3 — Progressive feed render
Wire streaming into the template browser.
**Changes**
- Replace the array shape of `_feedCache.events` with a Map-backed accumulator. Add a getter helper `getFeedEvents()` that returns the merged array (cache + persisted templates) for use by `buildRowsFromEvents`.
- `fetchFeedAndReactions({ onProgress })`:
- Reset the accumulator at start.
- Run the template and deletion queries with `onEvent` callbacks that push into the accumulator and schedule a debounced `onProgress` (≈150ms).
- After both queries settle, fire `onProgress` once more, then run reaction and profile batches (each followed by a final `onProgress`).
- Continue to gate concurrent calls with `_feedCache.loading`.
- `loadFeedTab(tab)`:
- Render a one-time skeleton.
- Define `rerender()` that re-runs `buildRowsFromEvents`, sorts, paginates, and replaces the wall contents. Bail out if the user has switched to a different tab.
- Call `await fetchFeedAndReactions({ onProgress: rerender })`.
- One last `rerender()` after the await.
- Search and "Mine" tabs reuse the same pattern via a shared helper.
**Acceptance**
- With all six default relays in the list, first template appears in the wall before the slowest relay has responded.
- Switching tabs mid-stream cancels stale renders (no flicker into the wrong tab).
- Reaction counts and author avatars appear after the initial render without wiping the cards.
- "No templates found…" message only appears after both template-side relays have all settled with zero rows.
---
## Engineering notes
- **Debounce window:** 150ms is a balance between visible progressiveness and avoiding layout thrash when a fast relay dumps 50 events in one tick.
- **Dedupe:** must happen on `event.id`, not address — multiple relays will return the same event. Existing `events.set(id, …)` pattern already does this for the return value; the streaming callback must also gate on "not previously seen" to avoid double-rendering.
- **Cancellation:** if the user switches tabs, the in-flight queries keep running (they're cheap and we want the cache warm), but the `rerender` callback should check `state.community.currentTab` and no-op when stale.
- **Persisted templates:** `loadPersistedPublishedTemplateEvents()` should be merged once at the start (so the user's own published designs show even with zero relay results) and again each `onProgress`.
- **Backwards compat:** call sites that don't pass `onEvent` see no behavior change. `_feedCache.events` exposed as an array via the getter keeps `buildRowsFromEvents` callers unchanged.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Re-render flicker as events stream in | Debounce + key cards by `event.id` so the diff is additive, not a full wipe |
| User clicks a card that gets re-sorted under their cursor | Sort key is stable (`created_at` desc, then `id` desc); new cards append to the end of the current sort, not above |
| Faster cooldown means a genuinely dead relay gets retried more often | Worst case is a 30s WebSocket open attempt every ~30s per dead relay, which is cheap. Failure count still escalates the cooldown up to 5 min |
| Streaming exposes payload-parse errors more loudly | Parse errors stay quietly skipped inside `buildRowsFromEvents`; no UI change |
| Long-running WebSockets leak if relay never sends EOSE | Existing per-relay `timeoutMs` still applies and calls `finish()` even with streaming |
---
## Out of scope / future enhancements
- Single-shot retry on a different relay when the original relay drops mid-stream.
- Background "warm" subscription that stays open while the modal is closed, so opening Community is instant.
- Per-relay latency surface in the relay settings UI (so users can see which relays are slowing them down).
- Caching template events across reloads with a TTL (would need careful invalidation against kind-5 deletions).
-162
View File
@@ -1,162 +0,0 @@
# Template Forking / Remix Feature Plan
## Overview
Allow users to import any community template as a starting point for their own design,
with automatic attribution to the original creator baked into the Nostr event. Forks are
discoverable — anyone can see a template's remix lineage — and the original creator gets
notified via a Nostr mention when someone forks their work.
## Complexity
**Low–Moderate** — Estimated 2–4 hours
---
## How It Works
### Forking Flow
1. User clicks "Remix this design" on a community template card
2. App imports the template payload into the editor (same as existing "Import" flow)
3. When the user publishes their remixed version, the new event includes a reference
to the original template event ID in its tags
4. Original creator receives a Nostr notification (kind 1 mention) that their template
was remixed
### Nostr Event Tags for a Fork
Add two tags to the published kind 30078 event:
```json
{
"tags": [
["e", "<original-event-id>", "<relay-hint>", "fork"],
["p", "<original-creator-pubkey>"]
]
}
```
- `["e", ..., "fork"]` — marks this as a derivative work; the marker `"fork"` is a
custom convention (Nostr allows arbitrary `e` tag markers)
- `["p", ...]` — notifies the original creator; their client will surface this as a mention
### Attribution Display
In the template detail view, show a "Forked from" row when a `fork` e-tag is present:
```
Forked from: [Alice's Spider-Man Wrap] by [email protected] →
```
Clicking the arrow navigates to the original template in the feed.
---
## Key Components
### 1. Remix Button
Add "Remix this design" to the template card action menu alongside existing Import option.
Visually distinguish it (⑃ fork icon) so users understand it's different from a plain import.
```javascript
function remixTemplate(event) {
importDiscDesignFromPayload(event.content); // existing import function
state.community.forkSourceEventId = event.id;
state.community.forkSourcePubkey = event.pubkey;
showNotification('Template loaded — your remix will credit the original creator.');
}
```
### 2. Fork State
Track pending fork metadata in state so it can be attached at publish time:
```javascript
// Add to state:
fork: {
sourceEventId: null, // event ID of the template being remixed
sourcePubkey: null, // pubkey of the original creator
}
```
Clear `state.fork` when the user starts a fresh project or imports without remixing.
### 3. Publish Hook
In the existing publish function, inject fork tags when `state.fork.sourceEventId` is set:
```javascript
function buildPublishTags(existingTags) {
const tags = [...existingTags];
if (state.fork.sourceEventId) {
tags.push(['e', state.fork.sourceEventId, '', 'fork']);
tags.push(['p', state.fork.sourcePubkey]);
}
return tags;
}
```
### 4. Fork Detection on Render
When rendering a template detail card, check for a `fork` e-tag and fetch + display the
source template title and author:
```javascript
function getForkSource(event) {
const tag = event.tags?.find(t => t[0] === 'e' && t[3] === 'fork');
return tag ? { eventId: tag[1], relayHint: tag[2] } : null;
}
```
### 5. Remix Count
In the community feed, a template's remix count = number of kind 30078 events that
reference it with a `fork` e-tag. This can be fetched lazily:
```javascript
async function fetchRemixCount(eventId) {
const events = await queryRelays(relays, {
kinds: [30078],
'#e': [eventId],
}, { timeoutMs: 2000 });
return events.filter(e => e.tags.some(t => t[0] === 'e' && t[3] === 'fork')).length;
}
```
---
## UI Changes
- Template card action menu: add "⑃ Remix this design" below "Import"
- Editor header: show "Remixing: [original title]" badge while fork state is active
- Editor header: "Clear remix" link to discard attribution and start fresh
- Template detail panel: "Forked from" row (when fork e-tag present)
- Template detail panel: "X remixes" count below reaction row
---
## Performance Considerations
- Fork source lookup is a single relay query — run it lazily when the detail panel opens
- Remix count query can be skipped on initial feed load and fetched on hover/click
- No new relay subscriptions needed — reuses existing `queryRelays()` helper
---
## Limitations
- Fork attribution is opt-in convention — anyone can publish a derivative without tagging;
this feature makes attribution easy but cannot enforce it
- Relay hints in the `e` tag are best-effort; original event may not be on all relays
- No fork graph visualization (full lineage tree) — out of scope for initial version
---
## Future Enhancements
- Fork tree visualization: show the full remix lineage of popular templates
- "See all remixes" panel on template detail
- Notify original creator via kind 1 DM or push notification service
- Attribution in exported PDF metadata
---
## Testing Checklist
- [ ] "Remix this design" imports template content into editor correctly
- [ ] Fork state (`sourceEventId`, `sourcePubkey`) is set after remix
- [ ] Published event contains `["e", ..., "fork"]` and `["p", ...]` tags
- [ ] Fork state clears on new project / plain import
- [ ] "Remixing: [title]" badge shows in editor while fork is active
- [ ] "Clear remix" removes fork state and badge
- [ ] Template detail shows "Forked from" row when fork e-tag is present
- [ ] "Forked from" link navigates to source template in feed
- [ ] Remix count displays on template detail (fetched lazily)
- [ ] Publishing without remix produces no fork tags
-248
View File
@@ -1,248 +0,0 @@
# Trust, Mute, and Web-of-Trust (WoT) Plan
## Goals
Build an **abuse-resistant community feed** that still feels open and discoverable, using only Nostr-native data and client-side logic.
### Product goals
1. Reduce spam and low-quality content quickly.
2. Keep control in users’ hands (local + portable moderation).
3. Preserve feed performance for both logged-out and logged-in users.
4. Ship in small phases with measurable outcomes.
### Non-goals (v1)
- Centralized server moderation.
- Perfect Sybil resistance.
- Cross-relay consensus on reports.
---
## Current architecture assumptions
- Identity and signing via **NIP-07** browser extension.
- Community templates are already rendered from Nostr events.
- App can query multiple relays and currently has a feed render pipeline.
---
## Key Nostr primitives
| NIP | Kind | Usage in this feature |
|-----|------|------------------------|
| NIP-02 | `3` | Follow graph (hop-1, optional hop-2) |
| NIP-51 | `10000` | User mute list (`p` tags, optional words/events) |
| NIP-56 | `1984` | Abuse reports (advisory to relays/tools) |
---
## Phased delivery plan
## Phase 1 — Local block list (works logged out)
### Why first
Immediate abuse relief, zero protocol dependencies, and easy rollback.
### Data model
- `localStorage['casewrap:block:list:v1'] = string[]` (hex pubkeys)
- Normalize pubkeys to lowercase hex before insert.
- Keep a companion metadata object for analytics/debug (optional):
- `localStorage['casewrap:block:meta:v1'] = { [pubkey]: { createdAt, source } }`
### UX
- Card menu action: **Block author**.
- Settings section: list blocked pubkeys + unblock button.
- Inline feed toast: “Author blocked. Undo”.
- Badge in settings: “N authors hidden”.
### Engineering notes
- Block filter is applied as the first feed filter stage.
- Use a `Set` in memory to avoid repeated array scans.
- Add defensive guard for malformed pubkeys.
### Acceptance criteria
- Blocking hides author content immediately in all feed views.
- Unblock restores content without full refresh.
- Block state persists across refresh.
---
## Phase 2 — Mute list sync (NIP-51)
### Why second
Makes moderation portable across devices and compatible with other Nostr clients.
### Sync strategy
On successful NIP-07 login:
1. Fetch latest kind `10000` authored by viewer pubkey.
2. Parse `p` tags into `remoteMuted` set.
3. Read local block set `localMuted`.
4. Merge union -> `effectiveMuted`.
5. If union differs from remote, publish updated kind `10000`.
6. Cache `effectiveMuted` locally.
### Conflict policy
- **Union wins** by default (safer for abuse prevention).
- Never silently remove remote mutes.
- If publish fails, keep local behavior and surface non-blocking warning.
### Event shape
```json
{
"kind": 10000,
"tags": [["p", "<hex-pubkey>"]],
"content": ""
}
```
### Acceptance criteria
- Existing remote mute list is respected on login.
- New block actions publish updated mute list.
- Local filter still works if relays/signing fail.
---
## Phase 3 — WoT-aware ranking and filtering
### Why third
Highest feed-quality impact for signed-in users.
### Trust tiers
- **Blocked**: in effective mute set -> hidden.
- **Trusted (hop-1)**: directly followed by viewer.
- **Extended (hop-2)**: followed by someone viewer follows.
- **Unknown**: no graph evidence.
### Modes
- Default mode: **rank by trust**, don’t hard-hide unknown.
- Optional strict mode toggle: **show trusted only**.
- Optional toggle: enable/disable hop-2 expansion.
### Ranking algorithm (v1)
Sort key tuple:
1. Tier priority (`trusted` > `extended` > `unknown`)
2. Optional social score (# of hop-1 followers who follow author)
3. Existing recency score (keep current feed freshness behavior)
### Performance constraints
- Fetch hop-1 first; render immediately with partial trust state.
- Compute hop-2 only if user enables it.
- Cache graph in `sessionStorage` with TTL (e.g., 15 min).
- Hard cap hop-2 expansion fanout (e.g., max 500 hop-1 nodes queried).
- Use batched relay subscriptions; close on EOSE.
### Acceptance criteria
- Trust tiers display correctly for sampled known authors.
- “Trusted only” mode hides extended + unknown.
- Feed first paint is not blocked by hop-2 queries.
---
## Phase 4 — Reporting (NIP-56)
### Why fourth
Valuable ecosystem signal but lower immediate user impact than blocking/filtering.
### UX
- Card menu -> **Report**.
- Reason required (`spam`, `nudity`, `illegal`, `impersonation`, `other`).
- Optional note.
- Post-submit option: “Also block this author”.
### Event shape
```json
{
"kind": 1984,
"tags": [
["e", "<template-event-id>", "spam"],
["p", "<author-pubkey>"]
],
"content": "Optional detail"
}
```
### Acceptance criteria
- Signed event is published with valid target tags.
- Failures are surfaced to user; no silent drop.
---
## Cross-cutting concerns
### Security & abuse hardening
- Validate all incoming pubkeys/event ids before use.
- Debounce block/report UI actions to avoid duplicate publishes.
- Protect against relay poisoning by taking latest replaceable event only.
### Privacy
- WoT computation occurs client-side.
- Do not log raw social graph by default.
- If telemetry is added, aggregate counts only.
### Observability (recommended)
Track coarse metrics:
- `% feed hidden by block list`
- `% users enabling trusted-only`
- `median feed render time (logged in/out)`
- `mute sync success rate`
### Failure handling
- If NIP-07 unavailable: Phase 1 fully operational; hide sync/report controls.
- If relay read fails: preserve local cache and degrade gracefully.
- If relay publish fails: keep local state, show retry affordance.
---
## Implementation slices (suggested task breakdown)
1. **Data layer**: block list store + normalization + helpers.
2. **Feed pipeline**: filter + tiering + ranking hooks.
3. **UI**: card menu actions, settings controls, trust badges.
4. **Nostr sync**: kind 10000 read/merge/publish.
5. **Reporting**: kind 1984 modal and publish flow.
6. **Instrumentation**: lightweight counters/timers.
---
## Revised testing plan
### Unit tests
- Pubkey normalization and dedupe logic.
- Union merge behavior for local vs remote mute lists.
- Trust-tier classifier and rank sort stability.
### Integration tests
- Logged-out: block/unblock lifecycle across refresh.
- Logged-in: mute sync read/merge/publish happy path.
- Logged-in degraded: relay failure + signer rejection handling.
- Trusted-only toggle behavior with mixed-tier fixture feed.
### Performance tests
- Feed render timing with:
- 0 blocks
- 500 blocked pubkeys
- hop-1 only
- hop-1 + capped hop-2
### Manual QA checklist
- [ ] Block author from card and confirm immediate hide.
- [ ] Undo block toast restores card.
- [ ] Login merges local + remote mute sets without loss.
- [ ] Trust badges are accurate for controlled fixture accounts.
- [ ] Trusted-only mode does not show unknown authors.
- [ ] Report flow publishes kind 1984 and handles failure visibly.
---
## Is the original plan good enough?
Short answer: **good foundation, but not quite “as good as it could be” for production**.
### What it already did well
- Correct NIP choices.
- Practical phased rollout.
- Good focus on no-backend architecture.
### Biggest gaps this revision closes
- Explicit conflict/merge policy for mute sync.
- Concrete degradation behavior on relay/signing failure.
- Performance guardrails for hop-2 fanout.
- Better UX defaults (rank-first vs hide-first).
- Test strategy split by unit/integration/perf.
- Observability so you can prove impact after launch.
+395
View File
@@ -0,0 +1,395 @@
# Vector Shapes, Fills, and Masking
## Goals
Expand the layer system from "image / text / color rectangle" into a real **vector design surface**: shape primitives, custom paths, solid + gradient fills, strokes, and clip/mask composition. All purely client-side, all JSON-serializable, all backwards-compatible with existing templates and the existing print/PDF/JPEG export.
### Product goals
1. Open the door to creative work that requires more than photos + text — accents, frames, geometric overlays, stylized backgrounds, branded shapes.
2. Make masking (circle crops, star-shaped photos, gradient-faded artwork) possible inside the editor without external tools.
3. Stay print-safe: vectors render crisply at any output resolution.
4. Round-trip through the existing project JSON and Nostr publish flow with no migration step.
### Non-goals (v1)
- A pen tool / interactive bezier editing surface. Users paste an SVG `d=` string for custom paths.
- SVG import as a whole (parsing `<svg>` files, handling nested groups). Single `<path d=…>` only.
- Conic gradients, mesh gradients, patterns, image-fill of shapes.
- Outside / inside stroke positions (center stroke only — outside-positioned strokes require path inflation).
- Stroke dash editing UI. v1 picks from a small preset list.
- Mask groups (multiple shapes union'd as a single mask). v1 supports a single shape clip per layer or a single layer-as-mask reference.
- Animated gradients or shapes.
---
## Current state
Layers today are objects in `state.layers[]`:
```js
{ id, type, name, target, x, y, w, h, rotate, opacity, z, locked, …type-specific }
```
with three concrete types:
- `image` → `src`, `fit?`
- `text` → `html`, `fontSize`, `color`, `align`, `bold`, `italic`, `fontFamily`
- `color` → `color` (flat rectangle fill, currently the closest we have to a shape primitive)
Renderers branch on `type` in roughly five places:
1. `renderLayers` (main editor canvas)
2. `renderTemplatePreviewDOM` (cover preview)
3. `renderDiscSheetPreviewDOM` (disc-sheet preview)
4. `renderJewelPreviewDOM` (jewel preview)
5. `renderDiscPreviewDOM` (disc-design preview)
6. `renderCustomArtPreviewDOM` (custom art preview)
7. Canvas rendering for PDF/JPEG export
Each renderer has the same shape:
```js
if (layer.type === 'text') { … }
else if (layer.type === 'image' && layer.src) { … }
else if (layer.type === 'color') { … }
```
Coordinates are in **inches** for `x`, `y`, `w`, `h`.
---
## Data model
### New layer type: `shape`
A single mega-type covers every primitive plus custom paths, with a discriminated `shape.kind` field. This keeps the number of renderer branches at one (not seven, one per primitive).
```js
{
id: 'layer-…',
type: 'shape',
name: 'Star burst',
target: 'cover-trim' | 'back' | 'spine' | 'front' | 'disc-top' | 'disc-bottom' | 'jewel-front' | 'jewel-tray' | 'designer-disc' | 'canvas',
x, y, w, h, rotate, opacity, z, locked,
shape: {
kind: 'rect' | 'rounded-rect' | 'ellipse' | 'circle' | 'polygon' | 'star' | 'line' | 'path',
// kind-specific (all optional except where noted):
cornerRadius?: number, // rounded-rect (in shape-local units, 0..min(w,h)/2)
sides?: number, // polygon (default 6)
points?: number, // star (default 5)
innerRadiusRatio?: number, // star (0..1, default 0.45)
d?: string, // path — SVG path data normalized to a 0..1 viewbox
x1?, y1?, x2?, y2?: number // line endpoints (0..1 in shape-local)
},
fill: {
type: 'none' | 'solid' | 'linear-gradient' | 'radial-gradient',
color?: '#hex', // solid
stops?: [{ at: 0..1, color: '#hex' }, …], // gradients, at least 2 stops
angle?: number, // linear gradient, degrees
center?: { x: 0..1, y: 0..1 },// radial gradient center (in shape-local coords)
radius?: number // radial gradient radius (0..1)
},
stroke: {
type: 'none' | 'solid',
color?: '#hex',
width?: number, // inches
dash?: 'solid' | 'dashed' | 'dotted' | string // string = SVG dash array
},
clip?: {
kind: 'inline-shape',
shape: { /* full shape sub-object, same structure as `shape` above */ }
} | {
kind: 'layer-ref',
layerId: 'layer-…'
}
}
```
The `clip` field is **optional and can be applied to any layer type**, not just shapes. So an `image` layer can have `clip: { kind: 'inline-shape', shape: { kind: 'circle' } }` to mask a photo into a circle without needing a separate shape layer.
### Why one mega-type instead of one per primitive
- Keeps renderer code to a single `case 'shape':` branch in every renderer.
- Selected-layer controls dispatch on `shape.kind` for kind-specific params (corner radius, sides, etc.) but share the fill / stroke / clip panels.
- Easier to add a new `kind` later (e.g. `arrow`, `speech-bubble`) without touching every renderer.
### Why a discriminated `clip` object
- `kind: 'inline-shape'` covers 90% of users — "crop my photo to a circle / star / heart."
- `kind: 'layer-ref'` covers the power case — "let me use *that* hand-drawn vector shape as a mask for *this* image."
- A future `kind: 'union'` could combine multiple mask shapes; not v1.
### Backwards / forwards compat
- Existing `color` layers are untouched. A flat-color rectangle is now possible two ways: legacy `type: 'color'` (kept forever) and new `type: 'shape', shape: { kind: 'rect' }, fill: { type: 'solid' }`. The renderer handles both.
- Old clients seeing `type: 'shape'` skip the layer — the rest of the design still renders. Same fail-soft behavior as today's old clients ignoring `type: 'color'`.
- Future `shape.kind` values we haven't shipped yet are also skipped individually by clients that don't recognize them.
- `clip` on existing layer types is purely additive. Clients that don't know about it render the layer un-clipped, which is uglier but not broken.
### JSON example (one shape layer with a gradient and a clip)
```json
{
"id": "layer-1742000000-001",
"type": "shape",
"name": "Accent burst",
"target": "cover-trim",
"x": 1.5, "y": 0.5, "w": 1.0, "h": 1.0,
"rotate": 0, "opacity": 0.85, "z": 7,
"shape": {
"kind": "star",
"points": 8,
"innerRadiusRatio": 0.4
},
"fill": {
"type": "linear-gradient",
"angle": 135,
"stops": [
{ "at": 0, "color": "#fbbf24" },
{ "at": 1, "color": "#dc2626" }
]
},
"stroke": {
"type": "solid",
"color": "#7f1d1d",
"width": 0.01,
"dash": "solid"
}
}
```
That's it. No new top-level fields on the project JSON, no migration step, no external assets.
---
## Rendering
### DOM (editor + 6 preview surfaces)
Today each layer renders as a `<div>` positioned absolutely with `transform: translate() rotate()`. For shape layers, we keep the `<div>` wrapper (so the position / rotation / opacity / drag-handle code is unchanged), and inject an inline `<svg>` filling 100% of the wrapper.
The SVG sets `viewBox="0 0 100 100"` (a normalized coordinate space) so all shape math works in 0–100 units regardless of the actual layer w/h in inches. Stroke width converts: `(stroke.width / max(w_inches, h_inches)) * 100`.
```js
function renderShape(svgEl, layer) {
const { shape, fill, stroke } = layer;
const defs = [];
if (fill.type === 'linear-gradient' || fill.type === 'radial-gradient') {
defs.push(buildGradientDef(layer.id, fill));
}
const path = buildShapePath(shape); // returns SVG <path> / <circle> / etc.
applyFill(path, fill, layer.id);
applyStroke(path, stroke, layer);
svgEl.innerHTML = `<defs>${defs.join('')}</defs>${path}`;
}
```
`buildShapePath` is a switch on `shape.kind`:
- `rect`: `<rect width="100" height="100"/>` (with `rx` for rounded-rect)
- `circle` / `ellipse`: `<ellipse cx="50" cy="50" rx="50" ry="50"/>`
- `polygon`: compute `<polygon points="…">` from `sides`
- `star`: compute `<polygon points="…">` from `points` + `innerRadiusRatio`
- `line`: `<line x1=… y1=… x2=… y2=… />`
- `path`: `<path d="<sanitized d>" />`
Inline gradients are referenced via `fill="url(#grad-<layerId>)"`. The gradient `id` is namespaced by layer id so multiple shape layers don't collide.
### Canvas (PDF / JPEG export)
Canvas2D natively supports everything we need:
- `ctx.beginPath() / ellipse() / rect() / moveTo() / lineTo() / fill() / stroke()`
- `ctx.createLinearGradient(x0,y0,x1,y1) / addColorStop(...)`
- `ctx.createRadialGradient(...)` for radial
- `ctx.setLineDash([...])` for dash patterns
- `ctx.clip()` for masks (called after a fill or shape path is built but before the next layer draws — needs save/restore discipline)
The canvas renderer parallels the SVG renderer 1:1. Each shape kind has a "build the path on the context" function that the SVG renderer would translate to attributes.
### Print/PDF fidelity
- SVG renders crisply in `window.print()` PDF in all modern browsers. We've already verified this with the existing text layer rendering.
- `clip-path: path(…)` is well-supported in Chromium (Chrome, Edge, Brave) and Safari ≥ 16. Older Firefox versions had known bugs.
- Fallback: when print fidelity matters most (the JPEG export path), we route through canvas, where `ctx.clip()` is rock-solid.
---
## UX
### Adding shapes
Next to the existing "Add image / Add text / Add color block" buttons in each layer panel:
```
[ + Image ] [ + Text ] [ + Color ] [ + Shape ▾ ]
│
├─ Rectangle
├─ Rounded rect
├─ Circle
├─ Ellipse
├─ Triangle
├─ Polygon
├─ Star
├─ Line
└─ Custom path (SVG)…
```
Clicking a primitive inserts a centered shape at 50% of the target bounds with a sensible default fill (the existing accent blue at 100% opacity).
"Custom path (SVG)" opens a small modal:
- Textarea for the `d=` data
- Live preview as the user pastes
- Validate: parse `d` and reject anything containing `<`, `>`, or `script` (defensive; we never `innerHTML` the raw data, only set it as a `d` attribute)
- "Add to canvas" inserts it as a path-kind shape layer
### Selected-layer controls
When a shape layer is selected, the existing "Selected layer" panel grows three sub-panels (in addition to the existing position/rotation/opacity controls):
1. **Shape** — kind-specific params:
- rounded-rect: corner radius slider
- polygon: sides number (3..12)
- star: points number (3..12) + inner-radius slider
- line: stroke-width is the visible attribute; the path is built from endpoints
- path: a small "Edit path data…" button that reopens the SVG textarea modal
2. **Fill** — fill-type select (none / solid / linear / radial), with conditional sub-controls:
- solid → one `<input type="color">`
- linear → start color, end color, angle slider (0–360°)
- radial → start color, end color, center XY (two sliders), radius slider
- v1 limits gradients to **two stops** for UI simplicity; the JSON model supports N stops so a future picker can extend without a migration.
3. **Stroke** — stroke-type select (none / solid), with color, width (in inches with `.001` step), dash style (solid / dashed / dotted).
The same Fill/Stroke pattern can be added to existing `color` layers later as a quality-of-life pass, since the JSON already supports it.
### Clip / mask controls (any layer type)
A new "Clip to shape" toggle in the selected-layer controls applies to any layer:
- Off → render normally (default)
- On → small sub-panel:
- "Inline shape" tab: pick a primitive, set its kind-specific params. Renders the chosen shape as a clip-path over the current layer.
- "Another layer" tab: dropdown of other layers in the same target. The chosen layer's shape becomes the mask. Greyed-out if no other shape layer exists in the target.
### Discoverability
A small one-line hint under the layer list when no shape layer exists:
> "Tip: Shape layers let you make circular avatars, banner gradients, and custom vector accents."
---
## Engineering plan
### Pre-work (small refactor)
Each of the 6+ renderers branches on `type`. Adding a 7th branch for `shape` in each one is mechanical but expensive to maintain forever. Worth extracting a single `renderLayerVisualInto(el, layer, opts)` helper that all surface renderers call. The helper handles type-dispatch once; surface renderers handle bounds and positioning. Estimate ~1 day, no behavior change.
### Phase A — Shape primitives + solid fill
1. Add `type: 'shape'` to the layer schema.
2. `addShapeLayer(kind, targetOverride)` helper, mirroring `addColorLayer`.
3. "Add Shape" button + sub-menu in each layer panel.
4. Selected-layer controls: Shape sub-panel (kind-specific) + Fill (solid only) sub-panel.
5. SVG renderer that handles all 8 kinds with solid fill.
6. Canvas renderer that parallels the SVG one.
7. JSON round-trip — verify save/load and Nostr publish/import preserve everything.
8. The 6 preview surfaces all pick up shape rendering for free via the new `renderLayerVisualInto`.
**Ship gate**: a star with solid red fill drops onto a cover, gets resized + rotated, saves, reloads, exports to PDF, and ends up looking the same in all four places (editor / browser preview / saved JSON / PDF).
### Phase B — Gradients
1. Extend the Fill sub-panel with linear / radial pickers (start color, end color, angle / center / radius).
2. `buildGradientDef` for SVG, equivalent path on canvas.
3. v1: 2-stop only. JSON model already supports N stops for the future.
**Ship gate**: a linear-gradient circle exports to PDF without banding or color shift.
### Phase C — Strokes
1. Extend the Stroke sub-panel: color, width (inches), dash preset.
2. SVG renderer applies `stroke`, `stroke-width`, `stroke-dasharray`.
3. Canvas renderer applies `strokeStyle` + `lineWidth` + `setLineDash`.
**Ship gate**: a 0.05-inch dashed black stroke around a rounded rectangle renders identically in editor and PDF.
### Phase D — Custom SVG path
1. "Custom path (SVG)" modal with paste + validate.
2. Sanitization (only parse `d`; reject anything that looks like markup).
3. Render via SVG `<path d>` and Canvas2D `Path2D(d)`.
4. Optional: a small library of "shape presets" (heart, arrow, speech bubble, etc.) bundled as path data.
**Ship gate**: pasting a known SVG icon's `d` attribute produces a clean layer that exports correctly.
### Phase E — Masking
1. Add `clip` field to the universal layer schema (any layer type).
2. "Clip to shape" toggle + inline-shape picker in the controls panel.
3. SVG renderer: emit `<clipPath id="clip-<layerId>"><path d="…"/></clipPath>` in defs, set `clip-path: url(#clip-<layerId>)` via CSS on the wrapper.
4. Canvas renderer: `ctx.save() → buildShapePath → ctx.clip() → draw layer → ctx.restore()`.
5. "Layer reference" tab: resolves another layer's shape at render time. Reject reference cycles (defensive).
**Ship gate**: a photo of a person clipped to a circle exports as a circular thumbnail in PDF; same photo clipped to a custom heart `path` does too.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Six renderers to update for one new type | Pre-refactor to a shared `renderLayerVisualInto` first. Cost is paid once, benefits every future type. |
| Print fidelity for `clip-path` varies across older browsers | All four supported browsers (recent Chrome/Edge/Brave/Safari) handle it. We document the browser-version floor in the README. |
| `path d=` from user input is a security surface | We only set it as a `d` attribute (never `innerHTML`), and validate it's a parseable path string. No script execution surface — `d` attributes can't carry JS. |
| Gradient picker is the meatiest UI piece | Start with 2-stop only. JSON allows N stops so a v2 picker is non-breaking. |
| Stroke "position" (inside/center/outside) | Center only in v1. Outside-stroke needs path inflation, which is heavy. Document the limitation. |
| 100+ shape layers in one design | SVG handles this well; we already do similar with text layers. Canvas export is the main perf risk and can be deferred via progress UI. |
| Old clients see and skip our shape layers | Acceptable. The rest of the design still renders. We could add a top-level `schemaVersion` bump but it's not strictly needed — `type` is the existing discriminator. |
| Reference cycles in layer-ref clips | Detect at render time and break the cycle (render unclipped + console.warn). |
| Two ways to make a rectangle (`color` and `shape`) | Live with it. Document the recommendation: new templates should prefer `shape`; old `color` layers keep working forever. |
| Gradient color picking (no eyedropper) | Standard `<input type="color">`. Out of scope to build a custom picker in v1. |
---
## Out of scope (future work)
- **SVG file import** — let users drop an `.svg` file and we parse it into one or more shape layers. Big can of worms (groups, transforms, embedded `<style>`, `<image>` refs). Future feature.
- **Boolean operations** — union / intersect / subtract two shapes into one. Useful for masks. Out of scope.
- **Pattern fills** — repeating image fills inside a shape. Future.
- **Mesh / conic gradients** — niche, browser support uneven.
- **Drop shadows / blur / glow** — own feature doc (`docs/LAYER_EFFECTS_FEATURE.md`).
- **Interactive bezier pen tool** — significant editor work. Out of scope.
- **Animated SVG / CSS animations** — these are print-oriented designs; animation isn't a fit.
---
## Open questions
1. **Should `shape` deprecate `color`?**
No. Keep `color` as a shortcut. New templates can use either. Documenting the recommendation is enough.
2. **Should `clip` be on any layer or only image layers?**
Any layer. The model is general; the UI surfaces it for image / shape / text alike. Text-clip is a small win nobody asked for, but it's free.
3. **Stroke widths in inches or pixels?**
Inches, matching the rest of the coordinate system. Convert to SVG units at render time.
4. **Do we ship a curated shape preset library (heart, arrow, etc.)?**
v1: no — the basic primitives are enough. A small JSON file of preset `d=` strings would be a tiny, decoupled v2.
5. **Should the gradient picker support N stops out of the gate?**
No. 2-stop covers most real use. JSON supports N; UI grows later if asked.
6. **Should the layer panel show a small icon preview per layer (especially shapes)?**
Nice-to-have. Not required for the feature to ship.
---
## Acceptance summary
### Phase A
- [ ] Adding a shape from the layer panel produces a JSON-serializable shape layer with sensible defaults.
- [ ] All 6 surface renderers (editor + 5 previews) render the shape.
- [ ] Canvas PDF/JPEG export renders the shape.
- [ ] Save → load round-trips the shape correctly.
- [ ] Publish → fork → load round-trips the shape correctly.
- [ ] Selected-layer controls let the user change kind-specific params.
### Phase B
- [ ] Linear and radial gradient fills work in editor + previews + canvas export.
- [ ] Gradient picker UI (2-stop) is intuitive and doesn't fight existing fill controls.
### Phase C
- [ ] Solid strokes with width and dash render in editor + previews + canvas export.
- [ ] Stroke and fill compose correctly (fill underneath, stroke on top).
### Phase D
- [ ] Pasting an SVG `d=` string adds a valid path layer.
- [ ] Bad input rejects with a friendly error, never throws.
### Phase E
- [ ] Inline-shape clip works on image, text, color, and shape layers.
- [ ] Layer-ref clip works and detects cycles.
- [ ] Clipped layers export to PDF and JPEG with the same visual result as in the editor.
-239
View File
@@ -1,239 +0,0 @@
# Web-of-Trust Soft Moderation (Blur-by-WoT)
## Goals
Use the signals already produced by Phase 2 (NIP-51 mute lists) and Phase 4 (NIP-56 reports) to **passively dim** community-feed cards that people the viewer trusts have flagged — without centralizing moderation or hard-hiding content.
### Product goals
1. Reduce the visual surface area of suspected spam / abuse for logged-in users.
2. Use only Nostr-native data already being published.
3. Stay non-censorial: every soft-moderated card is one click away from full view.
4. Cost no more than ~1 extra relay round-trip per feed load.
### Non-goals (v1)
- Replacing the personal block list. Soft moderation is *additive* to personal blocks.
- Cross-relay consensus or scoring servers.
- Soft moderation for logged-out users (no WoT to weight against).
- Hiding the author entirely (that is what Phase 1 block is for).
- Punishing the reporter (no "reporter reputation" yet).
---
## Problem statement
Phase 4 added the ability for any user to publish kind-1984 reports, but those reports currently have **no consumer in the UI**. The same is true of mute lists from people the viewer follows — Phase 2 syncs the viewer's own mute list but doesn't read anyone else's.
That means abuse signal already flowing through the relays is being thrown on the floor. Soft moderation closes that loop by turning the signal into a visual cue the viewer can act on.
---
## Data sources
| Signal | Kind | Author scope | Weight (v1) |
|---|---|---|---|
| Mute by a hop-1 follow | `10000` | viewer's hop-1 set | **1** |
| Report by a hop-1 follow | `1984` | viewer's hop-1 set | **1** (any reason) |
| Mute by a hop-2 follow | `10000` | viewer's hop-2 set | **0** (ignored in v1) |
| Mute/report by stranger | either | anyone else | **0** (ignored) |
Reports use the `reason` tag (`spam`, `nudity`, `illegal`, `impersonation`, `other`) — surfaced in the overlay label so the viewer sees *why*, not just *that*, the card was flagged.
### Why hop-1 only in v1
Hop-2 introduces too much noise and a real mob-blur risk: three coordinated strangers two hops away can dim a creator for thousands of people. Hop-1 means **someone the viewer personally trusts** has made a moderation decision. That's a much stronger signal and a much harder graph to game.
Hop-2 can be added later behind a setting if hop-1 alone proves too sparse.
---
## Threshold model (v1)
A card is soft-moderated when:
```
hop1_mutes(author) + hop1_reports(author OR event) >= SOFT_BLUR_THRESHOLD
```
Where:
- `hop1_mutes` = number of distinct hop-1 follows whose latest kind-10000 includes this author's pubkey in a `p` tag.
- `hop1_reports` = number of distinct hop-1 follows who have published a kind-1984 against this author or this specific event.
- `SOFT_BLUR_THRESHOLD = 2` (constant for v1, tunable later).
**Rationale for 2:**
- 1 is too aggressive — a single grudge can dim a creator.
- 3+ is too rare in practice for most users' follow graphs (who may follow 50–500 people, of whom only a handful are active moderators).
- 2 means "at least two people I trust independently flagged this," which is the lowest threshold that demonstrably isn't a one-person decision.
### What is NOT moderated
- Authors the viewer themselves follows (`row.trust === 'trusted'` or `'self'`) — never blurred regardless of score. Trust beats crowd signal.
- The viewer's own templates.
- Authors already in the viewer's personal block list — already hidden by Phase 1, so blur is moot.
---
## UX
### Default state (blurred card)
```
┌────────────────────────────────────┐
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← blurred thumbnail (CSS filter)
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ │
│ ⚠ Flagged by 3 people you │
│ follow · spam │
│ │
│ [ Show anyway ] │
└────────────────────────────────────┘
Untitled template
Author · category
```
- **Thumbnail**: `filter: blur(14px) saturate(0.5);` — recognizable as content, not readable.
- **Title and author**: rendered normally below the thumbnail (so the viewer can decide based on author).
- **Overlay copy**: shows count + dominant reason. Dominant reason is the most-common `reason` tag among the hop-1 reports, falling back to "muted" if only mute-list signals are present.
- **Show button**: stops propagation, removes the blur class, persists for the session (see "Reveal persistence" below).
### Reveal persistence
- A revealed card stays revealed for the current modal session.
- Reload or close-and-reopen the Community modal → cards re-blur.
- No persistent "trust this card" affordance in v1. (Could be added later via a session/local key.)
### Settings
One new toggle under Community → Privacy:
```
[x] Soft-moderate by web of trust
Blur cards that ≥2 people you follow have muted or reported.
Only applies when signed in.
```
Default: **on** for logged-in users. Stored at `localStorage['casewrap:wot:softmod:enabled'] = '1' | '0'`.
### Logged-out behavior
- Toggle hidden.
- No soft moderation applied (no WoT to weight against).
- Personal block list (Phase 1) still works.
---
## Engineering notes
### Where it slots into the pipeline
```
buildRowsFromEvents
└── row.trust = trustTierForPubkey(...) ← already exists
└── row.softMod = computeSoftMod(row) ← NEW
├── if !state.community.myPubkey → null
├── if !softModEnabled → null
├── if row.trust === 'trusted' || 'self' → null
├── if _blockedAuthors.has(...) → never reaches here (filtered earlier)
└── return { score, reason, count } | null
```
`buildFeedCard` reads `row.softMod` and toggles a `.feedCardBlurred` class + injects the overlay HTML.
### Fetching the signal
Two new batched queries inside `fetchFeedAndReactions`, gated on `state.community.myFollows.length > 0`:
```javascript
// Hop-1 mute lists (latest kind-10000 per author)
queryRelays(relays, {
kinds: [10000],
authors: state.community.myFollows.slice(0, MAX_HOP1_FOR_SOFTMOD)
}, { timeoutMs: 3500, onEvent: onHop1MuteIncoming });
// Hop-1 reports (kind-1984 by anyone in hop-1, p-tagging any author in the feed)
queryRelays(relays, {
kinds: [1984],
authors: state.community.myFollows.slice(0, MAX_HOP1_FOR_SOFTMOD)
}, { timeoutMs: 3500, onEvent: onHop1ReportIncoming });
```
- `MAX_HOP1_FOR_SOFTMOD = 500` — same cap pattern as hop-2 follows.
- Both queries use the existing streaming `onEvent` callbacks so soft-mod state arrives progressively and `onProgress` re-renders pick it up.
- Results accumulate into a `Map<authorPubkey, { mutedBy: Set<reporterPubkey>, reportedBy: Map<reporterPubkey, reason>, eventReports: Map<eventId, Set<reporterPubkey>> }>` cached on `_feedCache.softMod`.
- For replaceable kind-10000, only the latest per author counts (handled by `created_at` desc + first-wins).
### Computing `row.softMod`
```javascript
function computeSoftMod(row) {
if (!softModEnabled() || !state.community.myPubkey) return null;
if (row.trust === 'self' || row.trust === 'trusted') return null;
const authorBucket = _feedCache.softMod?.get(row.e?.pubkey);
if (!authorBucket) return null;
const mutedBy = authorBucket.mutedBy?.size || 0;
const reportedBy = authorBucket.reportedBy?.size || 0;
const eventReports = authorBucket.eventReports?.get(row.e?.id)?.size || 0;
// Don't double-count: same reporter muting AND reporting = 1.
const reporters = new Set([
...(authorBucket.mutedBy || []),
...(authorBucket.reportedBy?.keys() || []),
...(authorBucket.eventReports?.get(row.e?.id) || [])
]);
if (reporters.size < SOFT_BLUR_THRESHOLD) return null;
return { count: reporters.size, reason: dominantReason(authorBucket), reporters };
}
```
### Caching
- Soft-mod state is per-modal-session. Cleared when the Community modal closes.
- Hop-1 mute lists could be cached longer (15 min like hop-2 follows) but in v1 we re-fetch each modal open. Cheap enough.
---
## Phased delivery
### Phase A — Data fetch only (no UI)
- Add `MAX_HOP1_FOR_SOFTMOD`, `SOFT_BLUR_THRESHOLD` constants.
- Add `_feedCache.softMod` Map and aggregation logic.
- Add the two streaming queries inside `fetchFeedAndReactions`.
- Gate on `state.community.myFollows.length > 0`.
**Acceptance**: with a known-flagged author, `_feedCache.softMod.get(author).mutedBy.size` reads correctly in DevTools.
### Phase B — Card rendering
- Add `.feedCardBlurred` CSS (blur + saturate on `.feedCardThumb img`).
- Add `.feedCardSoftModOverlay` CSS (absolute-positioned label + "Show anyway" button).
- `buildFeedCard` checks `row.softMod` and applies the class + overlay.
- "Show anyway" button removes the class and stores the event id in a session `Set<string>`.
**Acceptance**: with a fixture (manually publish a kind-1984 from two test accounts the viewer follows against an author), the card renders blurred, count and reason match, "Show anyway" reveals it, and reload re-blurs.
### Phase C — Settings toggle
- Add the checkbox under Community → Privacy.
- Wire `softModEnabled()` to read `localStorage`, default `true`.
- Hide toggle when logged out.
**Acceptance**: toggling off immediately removes all blurs without reload; toggling on re-applies after the next `rerender`.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Mob blur (small clique coordinates to dim a creator) | Threshold of 2 from *hop-1* — must be from people the viewer personally trusts, not strangers |
| False positives (someone mutes for personal beef, not policy) | Always one-click to reveal; never hide entirely; show count + reason so viewer can judge |
| Performance cost of extra hop-1 queries | Same streaming pipeline as templates; bounded by `MAX_HOP1_FOR_SOFTMOD = 500`; cacheable |
| Discoverability: revealed cards re-blur on reload | v1 trade-off for simplicity; v2 could add a per-event "trust this card" persisted set |
| Author follows the viewer's followers but they all mute that author | Unlikely in practice; if it happens, viewer can disable soft-mod or unfollow the muters |
| Reason tag missing or malformed on a kind-1984 | Skip that event for `dominantReason`; still count it toward `reporters.size` |
| Reporter compromise (someone gets phished, mutes everyone) | Threshold of ≥2 distinct reporters limits single-account damage |
---
## Out of scope / future enhancements
- **Reporter reputation**: weight reports from accounts the viewer follows more heavily, or downweight serial reporters.
- **Hop-2 expansion**: optional toggle to count hop-2 reports at a lower weight.
- **Reason-specific thresholds**: e.g., `illegal` triggers blur at count=1, `spam` requires count=3.
- **Persistent reveal**: per-event "I've decided this is fine" with `localStorage`.
- **Author-level overlay**: replace thumbnail blur with a full-card "Tap to expand" placeholder for severe categories.
- **Surface own reports**: "You and 2 others flagged this" feels different from "3 others flagged this".
- **Block-from-blur**: add a "Block author" button inside the overlay (currently the card's normal Block button does it).
---
## Open questions to resolve before build
1. **Should hop-1 mute counts include private mute lists?** NIP-51 supports encrypted `content` for private mutes — for v1 we only read public `p` tags.
2. **Should soft-mod re-fetch run on every modal open or only on first open per session?** Defaulting to every open for v1 — measure cost, downgrade to once-per-session if it hurts.
3. **Should the overlay show *who* (e.g., "Flagged by alice, bob")?** Probably not by default — turns moderation into social pressure. Leave as count + reason only.
4. **Does "Show anyway" persist across tab switches in the Community modal?** Yes — session-scoped, not tab-scoped.
-241
View File
@@ -1,241 +0,0 @@
# Tracked Zaps (NIP-57)
## Goals
Turn today's untracked LNURL-pay tips into **on-relay-recorded zaps** so we can sum them per design, per author, and per author's profile, and rank by them.
- A zap that fires today should generate a kind-9735 receipt on Nostr that *anyone* can later read.
- A design card should be able to display `⚡ N sats` and a count of zappers.
- The feed should be sortable by zap totals.
- Profile pages should show the total zaps that author has received (and, later, sent).
### Product goals
1. Make tipping discoverable. If a design has gotten 10k sats, that's a signal future viewers should see.
2. Preserve the existing plain-LNURL-pay flow as a fallback so tips never silently fail.
3. Stay Nostr-native: use NIP-57 directly, no analytics service, no central scoreboard.
### Non-goals (v1)
- Backfilling tips from the pre-tracking era. Those LN payments are gone from a Nostr perspective.
- Punishing or hiding designs with **few** zaps. We surface signal, not anti-signal.
- Showing **who** zapped (privacy + UI clutter); v1 only sums amounts. A "Zappers" list can come later.
- Anonymous zaps (`anon` tag in NIP-57). Adds branch complexity. Defer.
- WebLN auto-pay (would also need a NIP-57 path; deferrable).
- Zap leaderboards across all designs / authors. Once aggregation works, leaderboards are a small UI; not v1.
---
## Current state
- The existing tip flow (`openTipModal` → `fetchLnurlPayMetadata` → `fetchLnurlInvoice`) does **plain LNURL-pay**: fetches an amount-locked bolt11 and renders QR + copy. Nothing about that payment lands on Nostr.
- LN providers that support zaps publish a kind-9735 **iff** the LNURL-pay request carries a `nostr=<encoded-zap-request>` param. The plain flow omits it, so receipts are never created for our tips.
---
## NIP-57 in our terms
### What a zap request looks like (kind 9734)
```json
{
"kind": 9734,
"pubkey": "<tipper hex>",
"created_at": 1700000000,
"tags": [
["relays", "wss://relay.damus.io", "wss://nos.lol"],
["amount", "21000"], // millisats
["p", "<author hex>"], // recipient
["e", "<design event id>"], // OPTIONAL — present for design zaps; omitted for pure profile zaps
["a", "30078:<author>:<d-tag>"] // OPTIONAL — naddr coords for replaceable kinds
],
"content": "Optional comment"
}
```
Sent to the LNURL-pay endpoint as:
```
GET <callback>?amount=<msats>&nostr=<URL-encoded JSON of signed event>
```
### What a zap receipt looks like (kind 9735)
Published by the LN service (or by Alby's zap.land etc.) after the invoice settles:
```json
{
"kind": 9735,
"pubkey": "<LN-service zap pubkey>", // matches metadata.nostrPubkey
"created_at": 1700000000,
"tags": [
["bolt11", "lnbc..."],
["description", "<JSON string of original 9734>"],
["p", "<recipient>"],
["e", "<event id>"],
["a", "30078:..."]
],
"content": ""
}
```
Amount is parsed from the `bolt11` tag (HRP `lnbc<amount><multiplier>` encoding) or from the `amount` tag in the embedded zap request.
### Metadata flag
The LNURL-pay metadata endpoint advertises support via:
```json
{
"allowsNostr": true,
"nostrPubkey": "<hex pubkey of the zap-publisher>"
}
```
Both must be present and non-empty to use the zap path. `nostrPubkey` is also what we use to **verify** that incoming kind-9735 receipts are legitimate (the receipt's `pubkey` must match).
---
## Phase 1 — Sign zap requests when supported
### Decision tree at "Get invoice" click time
```
LNURL metadata advertises allowsNostr + nostrPubkey?
├── No → plain LNURL-pay (today's flow)
└── Yes
Tipper has NIP-07 signer available?
├── No → plain LNURL-pay (today's flow)
└── Yes
Sign kind-9734 with the right tags →
?nostr=<encoded> appended to callback →
bolt11 returned + QR rendered as before
```
The user-visible UX is identical for the picker / invoice view. The only difference is a tiny `⚡ Tracked zap` badge next to the amount once we know the zap path is engaged (so a user paying via QR/copy knows their tip will count).
### Tag rules per entry point
| Entry point | Tags on the 9734 |
|---|---|
| **Profile page Tip chip** | `p`=author, `relays`, `amount`. No `e`, no `a`. Pure profile zap. |
| **Feed card Tip button** | `p`=author, `e`=design event id, `a`=`<kind>:<author>:<d-tag>` if d-tag exists, `relays`, `amount`. |
| **Design preview Tip button** | Same as feed card. |
This lets us aggregate by author OR by design just from filters.
### Plumbing changes
- `openTipModal` accepts an optional `targetEvent: { id, kind, dTag, authorPubkey }`. Profile-page openings omit it; feed-card / preview openings pass it.
- New helper `buildZapRequestEvent({ recipient, amount, eventId, kind, dTag, relays, comment })` returns the unsigned 9734.
- New helper `tryZapPath(metadata, target, amount)` returns `{ encodedZapRequest, zapPubkey }` or `null` if any precondition fails.
- `fetchLnurlInvoice` gains an optional `nostr` param; when present, appends `&nostr=<urlencoded>` to the callback.
- `getTipInvoiceFlow` calls `tryZapPath` first; on success, signs and includes; on failure, falls through to plain.
### What never changes
- The bolt11 we hand the user is identical in structure either way.
- Fallback paths for malformed addresses, CORS errors, min/max range remain exactly as today.
- No new modal states. Just a tiny optional badge.
### Out of scope (still) in Phase 1
- Reading 9735s. That's Phase 2.
- Showing the tipper's pubkey to the recipient. NIP-57 receipts already do this naturally if the recipient queries — we don't add UI here.
- Encrypted zaps / private zaps.
### Acceptance
- A signed-in user tipping a zap-supporting author (e.g., Wallet of Satoshi) sees the same invoice/QR flow as before — but minutes later a kind-9735 receipt with our zap request inside appears on the relays.
- A signed-out user tipping the same author still gets a working bolt11 — the LN provider returns one even without `nostr=`, just no receipt.
- A signed-in user tipping a non-zap LN endpoint (`allowsNostr` absent or false) silently falls back — no error, no extra UI noise.
---
## Phase 2 — Aggregate & display
### Fetch strategy
- When the community feed renders, batch-query receipts for visible designs:
`{ kinds: [9735], '#e': [eventId1, eventId2, …], limit: 500 }`
- For author totals (profile page), query:
`{ kinds: [9735], '#p': [authorPubkey], limit: 500 }`
- Stream results into a `_zapCache` Map keyed by event id (and a separate one keyed by author for profile totals).
- Validate each receipt:
- Parse the embedded `description` JSON → must be a kind-9734.
- Receipt's `pubkey` must equal the recipient's metadata `nostrPubkey` if we know it (we may not, so this check is opportunistic, not required for v1).
- Sum `amount` from the zap request tag (or parse from `bolt11` as a fallback).
### Display
- **Feed card**: small chip `⚡ 5.2k` in the actions row when total > 0.
- **Profile page**: under the avatar, optional line `⚡ 84k sats received`.
- **Design preview**: same chip in the preview meta line.
Counts use abbreviated formatting (`1.2k`, `21k`, `1.4M`).
### Sort
- Feed sort dropdown gets a new option: **Most zapped**. Sums received within a sliding window (default: forever; could later add 7-day / 30-day).
- Ranking is purely client-side from the visible window of receipts. No global leaderboard.
### Caching
- Same TTL as feed reactions (~2 min). Cheap to refetch.
- Soft-mod cache and zap cache live independently. Both keyed by event id.
### Acceptance
- A design that has received zaps shows the total on its card and in its preview.
- Sorting the feed by "Most zapped" reorders cards correctly within the loaded set.
- Profile pages show the author's lifetime zap-received total.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Tipper not signed in → zap untracked | Show a one-line hint in the tip modal: "Sign in with Nostr to make this tip count toward this design's totals." Optional. |
| LN provider doesn't support zaps | Silent fallback. Don't surface failure as user-facing error — the tip still works. |
| Wallet publishes the 9735 to relays we don't query | Fetch from a broad relay set (use `relays` tag from the receipt's embedded zap request, plus the viewer's relays). |
| Forged zap receipts (someone publishes a fake 9735) | v1 trusts what we see. v2 adds the `pubkey === metadata.nostrPubkey` check by caching service pubkeys per recipient. |
| Sum inflated by duplicate receipts | Dedupe receipts by `id` (event id), not by zap request. |
| bolt11 amount mismatch with zap request amount | Trust the bolt11 amount (it's what actually got paid); zap-request amount is the *intent*. |
| Querying receipts for 50 designs at once → big subscription | Use existing `queryRelays` streaming with `onEvent`; batch in groups of 20 if relays balk. |
| Race between Phase 1 sign + endpoint response | Standard try/catch; on signing failure, fall back to plain flow. |
---
## Phased delivery (concrete)
### Phase 1 — Sign zap requests (this PR)
1. Add `buildZapRequestEvent`, `tryZapPath`, `signNostrEvent` reuse.
2. Extend `openTipModal` to accept `{ targetEvent }`.
3. Thread `targetEvent` through feed-card and preview Tip buttons.
4. Update `getTipInvoiceFlow` to attempt zap path; fall back on any failure.
5. Tiny `⚡ Tracked zap` badge on the invoice view when zap path engaged.
**Ship gate**: a tip from a signed-in user to a zap-enabled provider produces a kind-9735 receipt on the recipient's relays within a minute.
### Phase 2 — Aggregate and display
1. `_zapCache` keyed by event id and by author.
2. Fetch receipts during community feed loads and on profile page open.
3. Parse + validate + sum.
4. Render zap totals on feed cards / preview / profile.
5. Add "Most zapped" sort option.
**Ship gate**: zap totals appear on cards within a session; sort order changes when the new sort is selected.
---
## Open questions
1. **Should we publish the kind-9734 ourselves before sending to LNURL-pay?**
NIP-57 says clients MAY publish it themselves; not required. Recommendation: **no** — the receipt's `description` tag already proves it existed, and double-publishing pollutes relays.
2. **Use a fixed default-zap amount (e.g., 21 sats)?**
Not v1. Picker already handles this. Default zap amounts can land later as a profile preference.
3. **Should we surface "you zapped this" on cards (visible only to the zapper)?**
Nice but requires querying receipts whose embedded request has `pubkey === me`. Cheap; could be a small v2 add.
4. **Verify receipt service pubkey strictly?**
Recommend v1 = loose verification (any kind-9735 with a parseable embedded request counts). v2 = strict (cache `metadata.nostrPubkey` per recipient and require receipt.pubkey to match).
5. **Should the picker grow a "comment" box for zap-supporting endpoints?**
Many endpoints accept comments via `commentAllowed`. v1 = no, to keep UI quiet. v2 = optional.
---
## Acceptance summary
### Phase 1
- [ ] Profile-page tip flow signs and sends a zap request when supported; plain LNURL-pay otherwise.
- [ ] Feed-card tip flow does the same with `e` and `a` tags pointing at the design.
- [ ] Design-preview tip flow same.
- [ ] All three entry points fall back silently for non-zap endpoints and signed-out users.
- [ ] A `⚡ Tracked zap` badge appears in the invoice view when the zap path engaged.
### Phase 2
- [ ] Feed cards show `⚡ <total>` when receipts exist.
- [ ] Profile pages show author totals.
- [ ] Design preview shows the same chip.
- [ ] "Most zapped" sort option works.