# 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 `` files, handling nested groups). Single `` 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 `
` positioned absolutely with `transform: translate() rotate()`. For shape layers, we keep the `
` wrapper (so the position / rotation / opacity / drag-handle code is unchanged), and inject an inline `` 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 / / etc. applyFill(path, fill, layer.id); applyStroke(path, stroke, layer); svgEl.innerHTML = `${defs.join('')}${path}`; } ``` `buildShapePath` is a switch on `shape.kind`: - `rect`: `` (with `rx` for rounded-rect) - `circle` / `ellipse`: `` - `polygon`: compute `` from `sides` - `star`: compute `` from `points` + `innerRadiusRatio` - `line`: `` - `path`: `` Inline gradients are referenced via `fill="url(#grad-)"`. 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 `