Playbook Learnings — append-only log
This is the auto-update layer for the Design Builder Reference Library. Every build agent appends an entry here after producing a creative output (image, video, email, banner, deck, dashboard). Validated patterns get promoted into the matching playbook (effects_matrix.md / email_cta_playbook.md / dashboards_decks_playbook.md) on review.
How to log (append at the TOP, newest first)
### YYYY-MM-DD · <channel code> · <client> · <one-line what>
- **Tried:** what approach/effect/setting.
- **Result:** worked / rejected / mixed — with the why.
- **Reuse:** the rule to follow next time (or "avoid").
Keep entries short. Promote anything proven 2+ times into the relevant playbook + note it here.
2026-05-26 · L · all clients · Landing Page editor — section builder, L-lite effects, iframe-preview = export (marita-os #81)
- Tried: New
/landingpage (nav item, 🌐) + backendserver/routes/landing.jswith alanding_pagesSQLite table (id, title, client, theme, doc JSON, timestamps). A page ={ meta, sections:[ {id,type,...} ] }. 11-section library per the playbook L picks: hero (headline/sub/CTA, optional bg image OR auto mesh-radial gradient), logo/proof bar, feature grid (bento, CSS Grid), stat band (animated number counters), two-column text+image split (left/right flip), testimonial, FAQ accordion (<details>+ rotate-on-open), pricing/offer (featured tier highlight), big CTA band, footer. Add/reorder/delete + 3 templates (SaaS / Service Sv / Agency). Brand-aware off the brand kits (#84):mergedThemes(db)reads thebrand_kitstable andkitToTheme()maps primary/accent/dark/headlineFont/bodyFont/logo + dark-mode flag for Umbra/Social Scout — so switching the brand re-themes instantly (verified Salvo blue→EVG magenta w/ EVG's own font+logo). Effects kept L-LITE + FAST (playbook explicitly warns against over-effecting): subtle radial/linear gradients, soft layered shadows, hover lifts (translateY), scroll fade-up reveals via a single inline IntersectionObserver (no GSAP/library), number counters (snap-once on reveal, no loop), optional sticky nav (backdrop-blur) + sticky bottom CTA bar. Honorsprefers-reduced-motion. Self-contained: system-font-first (only loads a web font if the kit's font ≠ Inter), lazy images, one tiny inline<script>— watches LCP/CLS. Output: the section→HTML renderer lives in ONE place (serverpageHTML); the client preview is an<iframe srcDoc>of the exact/api/landing/exportoutput, so preview === export byte-for-byte. Copy HTML (clipboard) + Download .html (?download=1attachment). Desktop/mobile preview toggle (390px frame). DISTINCT from Email Studio (real responsive web page, not table+inline-CSS email). - Result: works. Single
docker compose up -d --buildclean boot (no crash). All dashboards 200 (/ /jobs /clients/salvo /video /assets /banners /decks /brand-kits /best-practices /landing). API verified:/api/landing/themes(kit-derived), list/create/get/delete round-trip,/api/landing/exportreturns valid 13KB self-contained HTML w/ reveal script + sticky CTA + counter + correct brand color; EVG export carries #EE1B9E. Confirmed servedindex.htmlreferences the freshly-built bundle and it contains the editor. - Reuse / gotchas: (1) iframe
srcDoc= the export is the cleanest way to make preview faithful AND let real CSS/scroll effects run (vs re-implementing the renderer client-side in React like DeckEditor does — single source, half the code). Debounce the export fetch ~280ms on edit. (2) Build serves from the IMAGE, not hostclient/dist. Afterdocker compose up --build, the hostclient/distcan be STALE — grepping it gives false negatives. Verify the real bundle withdocker exec marita-os grep -rl <unique-string> /app/client/dist/assets/andcurl localhost:3210/ | grep index-*.js. (3) Vite code-splits page components into separate chunks — a page's strings won't be inindex-*.js; only statically-imported-and-referenced labels (Sidebar nav) are. Don't conclude "not built" from the main chunk alone. (4) ESM.jscan't benode --check'd as CJS (Unexpected token 'export') —cp x.js x.mjs && node --check x.mjs. JSX can't be node-checked; let the Docker Vite build catch it. (5) Mirror the DeckEditor pattern for any new editor: top-bar (title + brand buttons from/themes+ template/open selects + save/export) · left rail (sections + add) · center preview · right inspector; sameCcolor tokens +api()helper +btnBase.
2026-05-26 · U · all clients · Brand-Kits editor + Logo wizard — one source feeding every editor (marita-os #84)
- Tried: New
/brand-kitspage (nav item, palette 🎨) + backendserver/routes/brand-kits.jswith abrand_kitsSQLite table (client PK, label, data JSON, updated_at). Each kit = named colors (hex + role), headline/body fonts, logo image(s) or monogram, voice, accentUsage, dos/donts. Seeded once-only defaults (never overwrite edited rows). Made it the SINGLE SOURCE: derived endpoints/api/brand-kits/derived/palettes(PostImageEditorBRAND_PALETTESshape) and/api/brand-kits/derived/deck-themes(decksTHEMESshape). PostImageEditor merges derived palettes into its in-placeBRAND_PALETTESobject on mount (let _bkLoadedguard + auseStatebump to re-render); decks.js merges DB-derived themes over hardcodedTHEMESserver-side (mergedThemes(db),deckHTMLnow accepts a resolved theme object OR a key); DeckEditor.jsx fetches/api/decks/themesinto athemesstate for picker + preview. Hardcoded tables stay as the OFFLINE FALLBACK. Logo wizard: monogram/wordmark via<canvas>(kit primary + headline font + shape) → toDataURL → POST/api/brand-kits/:client/logo; AI concepts via/api/brand-kits/logo-genproxy → studioPOST :8088/generate{prompt,aspect}→ returns{name,url:/out/x.png}, proxy hands back both the publicstudio.driftbymar.appURL and a canvas-safe base64 data_url. Gap-fills: real Social Scout palette (coral #F25C54 / teal #2A9D8F — was falling back to Salvo), Turno fleshed from the official brand guide (teal #29C2AF primary, ocean #2298AC, indigo #3D4465, tangerine #F58233 accent, Muli/Lato), Kuvo #8B5CF6, Umbra #6366F1 (monogram, dark theme), Salvo+EVG kept as-is. Real logos scp'd intoclient/public/logos/brands/(Turno horiz + white, Urban Air horiz+vert, Kuvo); Umbra + Social Scout = monogram only. - Result: works. Single
docker compose up -d --buildclean (🟢 v2 on 3210, no crash). All dashboards 200 (/ /jobs /salvo /video /assets /banners /decks /best-practices /brand-kits). API verified: kits list, derived palettes,/api/decks/themesnow returns Turno as Muli/Lato (was hardcoded Inter), logos serve 200, logo-gen returned a real concept + data_url end-to-end (~30s). - Reuse / gotchas: (1) Studio image-gen endpoint is
/generate({prompt, aspect:ASPECT_1_1|16_9, model:V_2_TURBO, client}→{name,url}), NOT/txt2imgor/sdxl(those 404). Probe:8088/openapi.jsonfor the live path list. (2) To make a hardcoded const palette table API-overridable without a TDZ/refactor risk, mutate the object in place (Object.assign/spread per key) behind a load-once guard + a state bump — cheaper than threading a context everywhere. (3) Heredocs with<<'EOF'break on JS files containing single quotes (datetime('now'), apostrophes in copy) — write the file locally andscp, or use a Python patch script run on the VPS;node --checkserver.jsbut JSX can't be node-checked (let Vite catch it). (4) Remaining duplication NOTED: PostImageEditor + DeckEditor still ship hardcoded fallback tables (intentional offline fallback) — brand_kits DB wins at runtime; if a client's colors change, edit the kit, not the consts.
2026-05-26 · S/R · Salvo Software · anniversary CINEMATIC — slide-2 selfie cutout-on-panel + bigger eyebrows + harder logo gap + longer close (Legion-only)
- Tried: 4 iteration fixes on
video/src/Cinematic.tsx+Root.tsx(compBrandSlideshowStory1080x1920, rendered locally on Legion). (1) Slide-2 = clean OUTDOOR-selfie cutout on a brand panel. POSTedsalvo-3.jpeg(outdoor selfie, WhatsApp 1.43.55 PM) to the studiohttp://100.68.70.102:8088/removebg(multipart-F file=@...via curl.exe → returns{name,url}), fetched the PNG fromhttps://studio.driftbymar.app/out/<name>.png→ savedpublic/salvo-cutout-selfie.png(1280x960 Format32bppArgb). Newcutout:trueslide flag +SubjectPanelrenderer composites the contained cutout on a solid navy→blue diagonal panel w/ radial glow behind, soft drop-shadow contact,saturate1.08 contrast1.06matte lift, + bottom wash for caption legibility — the "designed subject on solid color" look. The OUTDOOR selfie mattes far cleaner than the indoor dark-wall shot (which ghosted — see prior entry'ssalvo-cutout-indoor.png). (2) Logo gap hardened: reduced settled logo 250→200px (~10.4% of 1920), band top 3.5%/height 11 (center ~9%, bbox bottom ~14%), pushed anniv band 18.5%→22% (≈8%/150px hard gap), headline 29%→33%. (3) Eyebrows bumped 0.26x→0.46x of headline size, League Spartan 800, added accent tick; exact labels THE TEAM / ON THE GROUND / HOW WE BUILD (uppercased in the array). (4) Close 4.2s→5.6s (DURATION 22s→24s to keep beats full-length). Also nudged the ignition tagline marginTop 40%→52% (was lightly touching the logo wordmark). - Result: works. Typecheck clean, render exit 0, 16MB mp4 + ~30MB gif (24s @ 15fps palettegen), both opened. Multi-frame slide-1 sweep every ~0.5s (2.6→6.5s): NO logo/text overlap at ANY timestamp — logo top-pinned, "Happy Anniversary!" + headline well below with clear margin through the whole settle. Beat-2 frame = cutout reads clean on the panel, no box/halo. Beats 3/4 eyebrows big w/ correct labels. Close holds clearly at 20s AND 23s.
- Reuse / gotchas: (1) For rembg, prefer the OUTDOOR/clean-sky source —
removebgmattes outdoor selfies crisply but ghosts on busy indoor backgrounds; if rough, upscale source first then removebg. (2) Studio flow: POST multipart to:8088/removebg, then GETstudio.driftbymar.app/out/<name>.png(the IP:8088/out path also works). (3) System ffmpeg for palettegen lives atC:\Users\marit\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe(Gyan 8.1.1) — full filters, sofps=15,scale=...,palettegen/paletteusetwo-pass works (unlike Remotion's bundled--disable-filtersbuild). (4) Cutout-on-solid-color is a clean, reusable alternative to full-bleed photo beats — single designed subject + caption + brand panel.
2026-05-26 · S/R · Salvo Software · anniversary CINEMATIC — definitive NON-OVERLAPPING ZONE layout + dropped rembg cutouts (Legion-only pass)
- Tried: Recurring overlap bugs on the cinematic anniversary video (
video/src/Cinematic.tsx, compBrandSlideshowStory1080x1920). Fixed DEFINITIVELY with a disjoint-band zone system:getZones(isStory)returns{logo, anniv, headline, subject, subheadBottom}as %-of-frame-HEIGHT, each band's end ≤ next band's start so bounding boxes can't intersect. Logo pinned to a RESERVED top band (square 500x500 PNG → settled 250px ≈ 13% tall, centered ~9.5%, content bbox is the middle 64% so visible bottom ~16%); "Happy Anniversary!" band at 18.5%, headline at 29%, photo (FramedPlate) at 46-87%, subhead bottom-pinned. Killed the rembgTeamCutoutentirely — all beats now use the ORIGINAL photos (hero =containframed plate w/ blurred cover-fill behind; story beats = full-bleedcoverBgPlate w/ off-center Ken Burns). Selfie (salvo-3.jpeg= WhatsApp 1.43.55 PM) put on beat 2 as a prominent full-bleedcover. Close extended 2.2s→4.2s, logo in its own flex-column row (gap) so it never sits on the celebratory text. Edited + rendered LOCALLY on Legion (npx remotion render), verified by extracting frames. - Result: works, all 5 issues gone (verified frame-by-frame: f130/f160 hero = logo/anniv/headline/photo all in separate bands; f230 selfie reads full + clear; f350/f470 original photos no weird matte; f640 close logo centered with clear space). Typecheck clean, render exit 0, 16.2MB mp4 + 33MB gif, both opened.
- Reuse / gotchas: (1) CSS
padding-top: X%resolves against WIDTH, not height — this was the actual overlap root cause: annivpaddingTop:18.5%rendered at 18.5%×1080=10% of a 1920 frame and rode up onto the logo. FIX = position the element absolute withtop: X%(top/bottom % DO resolve against height) instead of paddingTop. Always use absolutetop:%for vertical band placement in portrait comps. (2) Stop rembg on team group shots — originals framed nicely (contain + blurred cover fill, or cover + focal) look better than ragged cutouts; reserve rembg for clean single-subject only. (3) Square logo PNG has ~18% transparent padding each side — measure the opaque bbox (GetPixel alpha scan) before sizing so the reserved band matches the VISIBLE mark, not the box. (4) Remotion's bundled ffmpeg (node_modules/@remotion/compositor-win32-x64-msvc/ffmpeg.exe, n7.1) is--disable-filterswith only a whitelist enabled —fps/format/selectare NOT present, so-vf fps=15,scale=...errors "No option name near '15'". For palettegen GIF use onlyscale,split,palettegen,paletteusein-filter_complexand control rate with-r(can't decimate withoutfps; shrink scale to cut size instead). (5) PowerShell mangles comma-containing-vfstrings even when quoted — but here the real fix was the missing filter, not quoting. (6) No CT110 sync this pass (Legion-only, per instruction).
2026-05-26 · S/A/E-img · all · marita-os image editor FX3 — expanded effect range (canvas-rendered, brand-aware)
- Tried: Built ON TOP of the existing PostImageEditor panels (photo-effect dropdown, studio-fx, #78 text-effects, brand palettes) — nothing removed. Added, all canvas-rendered so PNG exports stay clean: Photo treatments — halftone (grid luminance→dots on a paper wash), glitch/RGB-shift (channel split via screen-blend + torn scanlines), posterize (5-level quantize on getImageData), sepia + B&W, and 4 cross-process LUT presets (Faded Film / Teal&Orange / Vintage Warm / Mono Punch = stacked
ctx.filterstrings applied at draw time), light leak (twin screen-blended radial bursts, warm + brand accent). Backgrounds generated from the active brand palette to sit behind cutouts: mesh (lighter-blended radial blobs), aurora (rotated screen band), conic (createConicGradientw/ flat fallback), dot, grid, noise wash — drawn as the canvas base, replacing flat navy. Containers behind the text block (custom mode): glassmorphism (translucent + hairline + specular sheen), soft-shadow card, gradient-border ring. Typography: metallic/chrome (6-stop vertical gradient fill) + neon (saturated tight glow, white text + 2nd bloom pass). Grouped UI: photo dropdown uses<optgroup>, generated-BG select next to it, Metallic/Neon added to the Text Effects toggle row, Container button group in the Text BG row. All new state wired into save/restore + renderCanvas deps. - Result: worked. esbuild parse clean (only pre-existing dup-key warning),
vite buildclean →docker cp dist+ restart marita-os (port 3210), new bundleindex-DTz00ldL.jsserved, clean boot, dashboards / /jobs /salvo /video /assets all 200. Pixel-fx (halftone/glitch/posterize) read back viagetImageDataso they no-op silently on a CORS-tainted canvas. Pixel/LUT effects are frame-mode only (gated like the existing photoEffect); generated BG + metallic/neon/container work in their respective modes. Backed up.bak-fx3-20260526. - Reuse: canvas
getImageData/putImageDatais the right tool for export-safe pixel fx (halftone/posterize/glitch) — but always wrap in try/catch for tainted canvases and prefer studio/api/studio/*server fx when the source is a remote URL. Brand-palette-driven backgrounds (duotoneShadow base + primary/accent/highlight blobs) give "designed not snapshot" panels that pair with the rembg cutout. Metallic = multi-stop vertical gradient fill; neon = white text + saturated shadow glow + a second tighter bloom pass. Keep additive: extend the effect arrays +applyPhotoOverlay/new helpers, never rewrite renderCanvas.
2026-05-26 · infra · all · CT110 /mnt/nas-docs was empty local disk (#91) — fix = mount CIFS on pve host AT the LXC bind target
- Tried: CT110's existing
lxc.mount.entry: /mnt/mordor-docs mnt/nas-docs none bindpointed at an EMPTY local dir on the pve host, so all NAS writes (Umbra EOD etc.) fell back to local. Fix on the HOST (pve 192.168.68.10): root-only/root/.smbcreds-mordor(chmod 600),mount -t cifs //Mordor/Documents /mnt/mordor-docs -o credentials=...,uid=100000,gid=100000,vers=3.0(100000 = the LXC's root id-map so CT110 can write), plus an fstab line with_netdev,nofail,x-systemd.requires=tailscaled.service(Mordor resolves via Tailscale, not LAN DNS). - Result: worked, vers=3.0 mounted first try. No CT110 reboot needed — the LXC bind tracks the live host mountpoint, so populating the host dir made
/mnt/nas-docsshow real NAS dirs instantly. Round-trip write from inside CT110 confirmed on Z:. All CT110 docker (studio :8088, vikunja) healthy. Umbra EOD generator now writes to Z:\Clients\Umbra\EOD. - Reuse: for LXC NAS access, mount the CIFS share on the HOST exactly at the bind-source path and use
uid/gid=100000(LXC root map); don'tpct set -mpNa new mount if a workinglxc.mount.entrybind already exists — just fill its source. Mordor must be reachable via Tailscale +_netdev,nofailso a boot without NAS doesn't hang the host.
2026-05-26 · S/R · Salvo Software · anniversary video reworked SLIDESHOW → CINEMATIC (text-behind-subject, person-by-person team build)
- Tried: Marita rejected the 4-centered-photo slideshow ("it's a video, not a slideshow"). Rebuilt as one CONTINUOUS Remotion timeline (
Cinematic.tsx, new comp wired under the existingBrandSlideshowStoryid; old comp kept asBrandSlideshowLegacy). Structure: centered Salvo logo IGNITION (spring overshoot + accent sweep, then settles to a quiet ~0.5-opacity top-center mark) → text-behind-subject hero (big kinetic headline BEHIND the rembg'd indoor team cutout, which rises in front and reveals PERSON-BY-PERSON) → 3 flowing photo beats (full-bleed Ken Burns + parallax + diagonal mask-wipe, off-center/frame-breaking) → centered logo lockup. rembg the INDOOR formal group via studiohttp://100.68.70.102:8088/removebg(multipart file upload, returns/out/<hash>.png). Per-person reveal = slice the cutout into N vertical clip-columns, stagger each with a SNAP spring (scale+rise) — no opencv/face-detect needed, "approximate feel of individuals appearing" is enough and is fully deterministic. - Result: works, looks like a real brand sizzle. Render 275.7s on CT110 (2 cores), 1080x1920, 15MB mp4 + 19MB palettegen gif. Byte-synced Legion↔CT110 (md5 verified), opened on Legion, served 200 from CT110. Typecheck clean.
- Reuse / gotchas: (1) rembg on a DARK indoor background leaves a gray ghost halo (recessed wall panels matte as semi-transparent) — crop it off in-component via
cropTop ~0.24and liftcontrast(1.06)so residual haze fades into the navy world; bright-sky outdoor shots matte clean with no cleanup. (2) Per-person band reveal: wrapper MUST take the cutout's TRUE aspect (aspectRatio: srcAspect*cropW/cropH) anchored bottom, elseobject-fit:fillstretches legs vertically. Render the same cropped img once per column inside anoverflow:hiddenclip div, inner plane widthpeople*100%shifted-bandLeft*people%so all copies align — only the clip differs. (3) Diagonal mask-wipe clip-path must END fully-open (edgeinterpolates 6%→130%, never to a negative x) — a-8%end value collapses the polygon to empty and the whole plate vanishes (cost me a render). (4) Navy photo wash: keep it LIGHT (top ~`navy22, bottomnavyf2for copy legibility) + a bluescreenaccent; the old heavymultiply+brightness 0.55crushed photos to invisible. Use adepthparam: ~0.0 = soft far plate (blur 18, dim) behind the cutout, ~0.88 = sharp bright foreground plate for the story beats. (5) Reused existingdesign.tsx/Kinetic.tsxprimitives (BrandBg mesh, GrainVignette, GlitchSlices, Chromatic, KineticHeadline char-stagger) — don't rebuild, compose. (6) Sync path: tar src+asset → scp to Optiplex/tmp→pct push 110into/opt/dashboards/video/→docker compose up -d --build video-render` → POST localhost:8096/render inside the container.
2026-05-26 · S · all-brands · marita-os image editor #78 — brand-aware gradient/duotone + headline text effects
- Tried: Built ON TOP of the existing PostImageEditor effects panel (kept duotone/grayscale/grain/brightness/contrast + studio-fx server panel). (1) Replaced the hardcoded Salvo-navy duotone/gradient with a
BRAND_PALETTESmap (salvo/evg/turno/kuvo/umbra), each supplyingduotoneShadow(multiply),duotoneHighlight(screen),tint(overlay), and a 2-stopgradient. Added aneditorBrandselector with live swatches;applyPhotoOverlay(ctx,effect,w,h,palette)now reads the active palette. Renamed effectnavy-gradient→gradientwith a load-time migration. (2) Headline text effects on the canvas render: outline/stroke (strokeTextw/ round lineJoin, width scaled byheadlineSize/48), gradient fill (canvascreateLinearGradient= thebackground-clip:textequivalent; only when no per-word colors), glow (colored centeredshadowBlur), marker/highlight (pre-passfillRectbehind each wrapped line, no shadow), letter-spacing (ctx.letterSpacing), and tunable shadow color/blur/offset. Threaded astrokearg through bothwrapTextanddrawMultiColorHeadline; addeddrawHighlightBehind. All new state persists in the editor save/load + render deps. - Result: works. Vite build clean, container clean boot, all five dashboards (/ /jobs /salvo /video /assets) 200. Brand switch refreshes text-effect color defaults (gradFrom=primary, gradTo=accent, glow=primary, highlight=accent).
- Reuse / gotchas: (1) Canvas IS the
background-clip:textsubstitute — setctx.fillStyleto acreateLinearGradientspanning the text box, fill normally; no DOM/CSS needed for a flattened PNG export. (2)ctx.letterSpacing/ctx.strokeTextare well-supported in modern Chromium canvas — feature-guard letterSpacing with'letterSpacing' in ctxand always reset to'0px'after the headline so the subheadline isn't affected. (3) Stroke must be drawn BEFORE fill, withlineJoin:'round', and scaled to font size or it vanishes on large headlines. (4) Highlight bar must be drawn with shadow OFF first, else the marker gets a halo. (5)node --checkcan't parse.jsx(ERR_UNKNOWN_FILE_EXTENSION) — per #88 learning, usenpx esbuild file --bundle --loader:.jsx=jsx --outfile=/dev/nullfor a ~10ms JSX validate before the docker build; here the singledocker compose up -d --build(vite) was the validator and passed.
2026-05-26 · S · Salvo Software · India 1-yr anniversary video reworked to exact 4-slide spec
- Tried: Remotion 1080x1920. Restructured from title+4photo+closing into a strict 4-slide model (one photo + Montserrat Bold headline + Geist subhead each). Wide GROUP shots were getting cropped by
objectFit: cover→ added a per-slideframing: 'contain'that fits the whole image inside the card with a blurredcovercopy behind it (no dead letterbox bars). Ken Burns clamped near scale 1.0 in contain mode so the group never drifts out of frame. Added editableteamSize/projectsShippedstring props with placeholder defaults ("9"/"40") + a{token}replacer in copy (never render literal "[N]"). Geist added via@remotion/google-fonts/Geist(loaded with the actual 400/500/600 weights, same fix pattern as the League Spartan bug). Accent line/tick forced to brand.accent so it always contrasts the white headline (was collapsing to text color). - Result: works. Geist renders (verified in extracted frames — visibly distinct humanist sans vs Montserrat). Full team visible on every slide, no one cropped. Render 269.5s on CT110 (2 cores). Slower xfade (0.4s) + 22s total / 4 slides reads much less frantic.
- Reuse: For wide GROUP photos in vertical video, use
objectFit: contain+ a blurredcoverfill behind it — guarantees no one cropped, looks intentional. The studioremovebgcutout route is for single subjects, NOT whole groups. Always load google-fonts with the EXACT weights you render or it silently falls back to system 400. Keep editable numbers as string props with placeholder defaults + a token replacer, never bake "[N]" into rendered copy.
2026-05-26 · Bi · all · per-client analytics dashboards (#88) — data wired vs pending + embed gotchas
- Tried: one shared playbook-compliant dashboard component (
ClientAnalytics.jsx: hero KPI row w/ delta+threshold colors, inline-SVG trend line + horizontal-bar breakdowns, data-freshness footer, skeleton/empty/error states, explicit striped PLACEHOLDER cards that name the source to wire). Configured per client viaCLIENT_CONFIGS; same component reused in-app (inside AppShell) and chrome-free for iframe via a public route. - Result: WORKS, clean boot, all routes 200. Data wired (real): Salvo → existing
/api/salvo/public/dashboard?token=(HubSpot leads + GA4 traffic + GSC/Ahrefs SEO, already token-gated for Vercel share). EVG →/api/evg/email-stats/summary(101 campaigns, 12.7% open, by-park breakdown — real). Turno →/api/turno/deployments(real campaign deploy rows; bare array, fieldscampaign_name/send_date/email_number). Pending (placeholder cards): EVG GHL pipeline + Salesforce/Audaxis bookings; Turno Braze campaign/Canvas metrics (no Braze analytics endpoint in marita-os yet — only braze-router heartbeat/approvals). Umbra: no*.vercel.appor any dashboard URL exists in codebase, vault (Clients/Umbra/_context.md+Log.md), or NocoDB — built iframe page (UmbraAnalytics.jsx) with a labeledUMBRA_DASHBOARD_URLconfig slot; needs the URL from Marita. - Reuse / gotchas: (1) marita-os SPA gates ALL routes behind
if(!user)inrouter.jsx— for public/iframe dashboards, mount the route BEFORE the auth gate (I added awindow.location.pathname.startsWith('/embed/analytics/')early-return branch) and pass any required token via?token=read from the URL inside the component. (2) No chart lib in client (rechartsabsent) — reuse the existing inline-SVGSparkline/polyline + CSS-bar pattern fromSalvoAnalytics/EVGMetricsTab; viewBox +preserveAspectRatio="none"makes the line responsive in an iframe. (3) Perl-0pimangles JS template literals (${...}gets eaten) — use a Python heredoc for replacements that contain${}or build the string locally andscp. (4) Alwaysnpx esbuild <file> --bundle --loader:.jsx=jsx --outfile=/dev/nulleach edited JSX before the single docker build — catches brace/JSX errors in ~10ms without a 2-min rebuild. (5) Salvo already had its own public dashboard API + SEO analytics tab; added a NEWclient-dashboardtab rather than overwriting the existinganalytics(SEO) one — never replace working features.
2026-05-26 · E · Umbra · EOD generator dash-strip safety net
- Tried: prompt-only ban on em/en dashes in
/opt/umbra_eod.py(CT110) kept letting dashes slip into Moses drafts; added a deterministicstrip_dashes()post-process after the claude-bridge returns, plus reinforced SYSTEM+USER prompts. - Result: worked. Fresh bridge run produced zero em/en/figure dashes; time ranges render as "11:00 to 11:15 AM MT", clause dashes become commas.
- Reuse: never trust the model alone for the no-em-dash rule. Always pair the prompt with a regex strip net: time/number ranges to " to ", remaining U+2014/2013/2012 to ", ", then collapse double commas/spaces. Apply to any client-facing prose generator.
2026-05-26 · A · all · asset library wired to BOTH editors + CT110 render (#86)
- Tried: per-brand upload library shared by the image editor (PostImageEditor) and the video chat editor (/video). Storage =
/app/data/assets/<brand>/<file>(the./data:/app/datamounted volume), served static at/assets/, SQLite indexlibrary_assets. Uploads are base64 data-URLs over JSON (no multer; dedicated 80mb json parser on the upload route only). - Result: WORKS end-to-end (upload → index → fetchable URL → editor). Two gotchas, both load-bearing: (1) Vite outputs its JS/CSS bundles into
dist/assets/, so the line-146express.static(client/dist)301-redirects/assets→/assets/and shadows the SPA page. Fix = register the upload static + an explicitapp.get('/assets')→index.htmlBEFORE the dist static (brand subdir files like/assets/salvo/x.pngdon't exist in dist/assets, so Vite bundles still fall through and serve).redirect:falseon serve-static alone did NOT fix it. (2) Static mount MUST setAccess-Control-Allow-Origin: *or the image editor'scrossOrigin='anonymous'canvas taints. - Reuse: persist user uploads on the
data/volume (survivesdocker compose up --build; baked-inclient/public/does NOT). For the CT110 Remotion box, store the/assets/<brand>/<file>PATH in props and resolve it server-side to the marita-os tailnet originhttp://100.95.59.64:3210/assets/...(same pattern as/templates/salvo-photos/<file>— preserve the brand subdir, don't strip to basename). Mount any new upload-static route BEFORE the SPA/dist static and add CORS*.
2026-05-26 · S/R · Salvo · modernized video motion (validated)
- Tried: replaced Ken Burns + cross-fades with character-stagger reveals, mask-wipe (clip-path) text + photo reveals, scramble/decrypt keyword, squash-stretch type, spring/overshoot easing, glitch/RGB chromatic aberration, whip/jump-cut transitions, drifting mesh+grain bg, per-frame film grain, tighter beat pacing.
- Result: kills the "PowerPoint" feel; reads as editorial/social motion. The specific tell was critically-damped easing (
damping:200, zero bounce) — that's what made it look like slideshow fades. - Reuse: default brand video motion = kinetic typography + mask wipes + spring/overshoot easing + grain + glitch transitions. Never linear/critically-damped fades on brand video.
2026-05-26 · S · Salvo · anniversary social post design
- Tried: v1 = team photo + flat color block + headline.
- Result: REJECTED ("super basic"). v2 = steep diagonal navy→blue split, thin orange accent line, logo top-left, Montserrat-Black headline with ONE keyword popped orange, orange-checkbox callout, photo + text on SEPARATE diagonal halves = approved/on-brand.
- Reuse: Salvo social = diagonal + orange keyword-pop + logo + checkbox; never flat photo+block. Keep photo and text on separate halves so faces aren't covered.
2026-05-26 · S/A · all · local image generation vs Ideogram
- Tried: SDXL-Turbo 1-step @512 vs SDXL base 30-step @1024 vs Ideogram.
- Result: Turbo 1-step = abstract/unusable. SDXL base 1024/30 = good for photoreal BACKGROUNDS. NEITHER renders legible in-image text. Ideogram V_2 (NOT V_2_TURBO) needed for text-bearing client creative.
- Reuse: local SDXL for backgrounds/drafts (free); composite text via studio /textlayer or cutout+solid panel+text-behind; Ideogram V_2 for finished text banners.
2026-05-26 · S/R · video · "modern" motion vs slideshow
- Tried: Ken Burns + cross-fades on framed photos.
- Result: reads "old-school / PowerPoint." Modern social motion needs kinetic char/word stagger, mask-wipe reveals, scramble/decrypt text, grain + chromatic aberration, glitch/whip transitions, spring easing, tempo pacing (effects_matrix S/R picks).
- Reuse: default video motion = kinetic typography + mask wipes + grain, not fades.
2026-05-26 · video · Remotion gotchas
- Tried: Remotion render of brand slideshow + GIF.
- Result: (1) League Spartan/Montserrat silently fall back unless
loadFont()is called with EXPLICIT weights. (2) Remotion's bundled ffmpeg is--disable-filters→ palettegen GIF recipe fails → 70MB GIFs; install SYSTEM ffmpeg + palettegen/paletteuse → ~3MB. - Reuse: always load explicit font weights; use system ffmpeg for GIFs.
2026-05-26 · E · all · B2B email studio
- Tried: block-based composer, table layout + inline styles, 13 info-showcase modules.
- Result: works; B2B = info-rich (stat callouts, info cards, feature lists, data tables) per email_cta_playbook.
- Reuse: B2B email = table+inline HTML, code-your-own blocks (robust), follow email_cta_playbook CTA patterns (esp. Umbra text-optimized-for-clicks).
2026-05-26 · S/R · Salvo · cinematic anniversary video iteration (Legion local render)
- Tried: iterated
Cinematic.tsx(Legion engine, localnpx remotion render, no CT110 this pass) per Marita's notes — pulled more motion-graphics from effects_matrix (word/char-stagger spring builds, mask-wipe text+photo reveals, person-by-person cutout stagger, parallax Ken Burns, glitch/chromatic punches, confetti/sparkle burst). Added a 3rd type face (League Spartan display) alongside Montserrat + Geist; newConfetti+AnniversaryMarkcomponents. - Result: WORKS. Key fixes: (1) settled top logo size was a FIXED
130px regardless of205px * ls/2.4logoScale— that's why it always read tiny; tie settled size tologoScale(now `, default ls bumped 1.7→2.4). (2) accent line was blocking content — drop to 4px / opacity 0.4 / soft…55glow / push to side / zIndex 0 (behind). (3) big top type collides with the now-bigger logo — give the logo a clear band: Anniversary mark at ~24% top, headline at ~33%, verified withremotion still` frames before final render. (4) broken rembg cutout beat → just use the ORIGINAL photo (outdoor selfie) full-frame; only keep a cutout where it's actually clean. - Reuse: ALWAYS render
remotion still --frame=Nspot-checks (hero mid-settle ~f140, a mid beat, close ~f650) BEFORE the full render to catch logo/text overlap — cheap and catches collisions the props-panel never shows. When bumping logo prominence, check the SETTLED size path, not just the intro path. Brand video face contrast = League Spartan (display) + Montserrat (kinetic headline) + Geist (body). Anniversary close = centered prominent logo + League Spartan celebratory line + seededConfettiburst.
2026-05-26 · Bi · driftbymar · live wiki page for the Knowledge vault (#93)
- Tried: small Node/Express +
markedcontainer on CT110 (/opt/dashboards/wiki, port 3004, restart unless-stopped) that renders/mnt/nas-docs/Knowledge/Playbooks/**/*.mdto dark on-brand HTML LIVE each request (no build step, no duplication). Mounts the CT110 cifs NAS share read-only (/mnt/nas-docs:/mnt/nas-docs:ro). Caddy on VPS →wiki.driftbymar.app→ CT110 tailnet100.68.70.102:3004. driftbymar Best Practices nav item + iframe page. - Result: WORKS.
wiki.driftbymar.app(200, TLS) anddriftbymar.app/best-practices(200). Sidebar auto-lists Playbooks + KnowledgeBase + LEARNINGS; relative.mdlinks rewritten to/doc/<rel>;[wikilinks](wikilinks.md)supported; tables/code/blockquotes styled. Vault edits show on next request (single source of truth, read-only). - Reuse: VPS CANNOT read the NAS — render markdown FROM CT110 (which has
/mnt/nas-docs) and only reverse-proxy from the VPS. Append (>>) to the Caddyfile, never truncate-rewrite (inode/bind gotcha) thendocker restart n8n-self-hosting-files-caddy-1. New always-on services → Optiplex/CT110 under/opt/dashboards; driftbymar just iframes them. Pattern is reusable for any vault folder: point a second WIKI_ROOT/container at it.
2026-05-26 · E/B/A · all · banner editor = reuse the image editor (#80)
- Tried: instead of a new tool, added a
group:'banner'size tier toPostImageEditor'sASPECT_RATIOS(email 600x200, email-wide 1200x400@2x, blog header 1200x630, PR/press 1200x628, LI newsletter 1280x720 + 1200x627, LI cover 1128x191, X header 1500x500, FB cover 1640x624, YT 2048x1152) +bannerMode/initialBrandprops + a/bannerspage (BannerStudio.jsx, brand picker → editor) + Sidebar/router wiring. The grouped picker shows "Social" and "Banner" rows. - Result: WORKS — one editor does posts AND banners. Default
frameId='custom'→frameMode=false→ full custom UI (brand palette, asset-library bg, generated mesh/aurora bg, cutout+solid panel, text-fx, studio-fx) all render at banner aspect ratios unchanged because every draw path readscanvasW/canvasH. Export is pure client-sidecanvas.toBlobatcanvas.width=canvasW→ a banner-size PNG falls out of the samerenderCanvas()that drives the preview./banners200, all dashboards 200, clean boot. - Reuse: to add canvas-size presets to that editor, just push entries onto
ASPECT_RATIOSwith agrouptag — no other wiring needed. NOTE:node --checkcan't parse.jsx(ESM loader error) — validate JSX withnpx esbuild <f> --bundle --external:react... --loader:.jsx=jsx --outfile=/dev/null. Also: the Dockerfile builds the client INSIDE the image (multi-stage), so the HOSTclient/distis stale and misleading — to confirm a feature shipped, grepdocker exec marita-os ... /app/client/dist/assets/index-*.js, not the host dist. Banner channel reality (effects_matrix): email banners are E-img (any effect ships as flattened PNG, but watch image-blocking + always set alt text + fallback bg; 600px wide or 1200@2x retina); blog/PR/LI-newsletter banners are web hero images → keep effects subtle and brand-led; LI newsletter banner is a static hero, no motion.
2026-05-26 · video · AI video-clip path via fal.ai (#94)
- Tried: new
POST /ai-clipon the CT110 Remotion render service (/opt/dashboards/video/server/render-server.mjs, containervideo-render, port 8096) — body{prompt, image?, model="kling", durationSec=5, ratio="16:9", quality="standard"}. Reads key fromprocess.env.FAL_KEY(Marita adds later); if absent → 400{error:"FAL_KEY not configured — add it to the video-render env"}. Submits to fal's QUEUE (https://queue.fal.run/<model>/<text|image>-to-video), pollsstatus_urluntilCOMPLETED, fetchesresponse_url, downloads the MP4 into OUT_DIR, returns{url, model, durationSec, estCost}via the sameabs()/PUBLIC_BASE static-serve pattern as/render. durationSec hard-capped at 8; fal only accepts enum 5/10 so snap (>=10→"10" else "5"). quality:"high"→Veo/Runway map, standard→Kling/Hailuo. AddedFAL_KEY=${FAL_KEY:-}to the video-render env in/opt/dashboards/docker-compose.yml+ a commented.envplaceholder; rebuilt video-render. marita-os side: proxyPOST /api/video/ai-clipinserver/routes/video.jsforwards to CT110, then DOWNLOADS the returned MP4 and registers it as amotionrow inlibrary_assets(same dir/url scheme asroutes/assets.js, importsdb) so it lands in the asset list. UI (client/src/pages/VideoStudio.jsx): "+ Generate clip (AI)" button → modal (prompt textarea, optional image upload for image→video, model dropdown standard/high, brand bucket, duration slider ≤8s) → cost-confirm step shown BEFORE generation (~$ estimate) → on success drops the clip into the preview + refreshes the asset library. - Result: WORKS end-to-end with NO key set. CT110
/healthok;/ai-clip(no key) → the 400 message. marita-os rebuilt clean,/+/video+/api/video/propsall 200, and the proxy chain cleanly RELAYS "FAL_KEY not configured — add it to the video-render env" (proves marita-os→CT110 connectivity too). Nothing generated — waiting on Marita's key. - Reuse: fal.ai auth header is
Authorization: Key <FAL_KEY>(NOT Bearer). For long jobs use the QUEUE hostqueue.fal.run(submit→status_url/response_url), not the blockingfal.run. fal video duration is a discrete enum (5/10), not free integer — snap before sending. To "drop a generated file into the asset library", reuse the assets.js contract directly (write todata/assets/<brand>/, INSERT intolibrary_assetswithtype='motion', url/assets/<brand>/<file>) — it's served static + tailnet-reachable for the render box. Keep the API KEY only on CT110 (the box doing the call), never in marita-os; marita-os is a thin proxy that relays CT110's status codes so the "add the key" message surfaces in the UI verbatim. (Re-confirmed:node --checkcan't parse.jsx; trust the in-Docker vite build to catch JSX errors.)
2026-05-26 - Dk - all + personal - deck/presentation editor (#82)
- Tried: new
/deckseditor in marita-os following the Dashboards & Presentations playbook (Dk). Server:server/routes/decks.js(registerDeckRoutes(app, db)wired inindex.jsafterregisterVideoRoutes) - SQLitedeckstable (id/title/client/theme/doc-JSON, persists via the ./data:/app/data volume), CRUD endpoints, plus/api/decks/export/{pdf,png}that render server-side HTML through puppeteer-core + system Chromium (/usr/bin/chromium-browser, the same engine canva-automation/airtableBrowser use) viapage.setContent+page.pdf({width:1280px,height:720px})(one landscape page per 16:9 slide) /target.screenshot()at deviceScaleFactor 2 (2560x1440 retina PNG, per-slide for 9:16/social slices). Client:client/src/pages/DeckEditor.jsx- slide rail (add/reorder/delete), live canvas, inspector. 14 slide types from the taxonomy (title, section divider, single-stat hero, bento KPI, two-col, three-col, bullets-as-takeaway, quote, comparison-with-winner, chart-led in-house bar, full-bleed image, agenda, roadmap, closing/CTA). 2 templates shipped: the opinionated monthly performance report (title-as-message throughout, 3 section dividers, KPI bento, wins, risks, 3-priority plan, close) + a pitch/overview. Title-as-message enforced in the UI - every type's title field is labelled "the takeaway" and the inspector says so. 6 brand themes reuse the app's BRAND_PALETTES (salvo/evg/turno/kuvo/umbra) + a personal/driftbymar theme; playbook type rules baked in (one display+one body font = Inter, 3 brand colors+neutrals, one accent used sparingly, darkdark-bg slides for title/section/closing rhythm). - Result: WORKS. Single rebuild clean boot ("Marita OS v2 running on port 3210", no errors).
/api/decks/themes+/api/decks200; all required dashboards 200 (/ /jobs /salvo /video /assets /banners /best-practices+ new/decks). PDF export verified end-to-end: 3-slide doc -> validPDF document, version 1.4, 3 page(s), 40KB. PNG export verified:PNG image data, 2560 x 1440. Nothing published. - Reuse: KEY PATTERN - keep the slide->HTML renderer mirrored on BOTH sides (React
SlidePreviewin the page for live preview, plain-HTMLslideHTMLin the route for export) and drive both from the SAME THEME object + slide schema, so WYSIWYG holds. For HTML->PDF, puppeteer-core'spage.pdfwith@page { size: 1280px 720px; margin:0 }+.slide{page-break-after:always}gives exactly one slide per page. Export endpoints take the LIVE doc in the POST body (not just the stored deck) so unsaved edits export. Themes intentionally duplicated client+server (can't import a server module into the Vite bundle) - if you add/edit a theme, change it in BOTHserver/routes/decks.jsTHEMES andDeckEditor.jsxTHEMES. Re-confirmed:node --checkcannot parse.jsx(ESM loader error) - trust the in-Docker vite build as the JSX gate;node --check server/routes/*.jsis fine for the express side. To wire a nav item whose icon is a literal\u{...}escape, edit Sidebar.jsx with a python replace that matches the unambiguous path substring (not the icon) and emit the backslash via chr(92) to dodge shell/python unicode-escape mangling.