+ We are honored to introduce the board of Fenja AI. Together, they bring
+ decades of experience in research, scholarship, and stewardship —
+ and the patience to do this work well.
+
+
+ "A study in stillness, and in counsel. We are grateful, every one of us, that they said yes."
+
+
+
+
+
+
+ fenja.ai
+
+
+
+
+
+
The board, in order of seating
+
+ {MEMBERS.map((m) => (
+
+ ))}
+
+
+
+ );
+}
+
+// ───────────────────────────────────────────────────────────────────────
+// Canvas
+// ───────────────────────────────────────────────────────────────────────
+function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/advisory-board-post/colors_and_type.css b/advisory-board-post/colors_and_type.css
new file mode 100644
index 0000000..547e18b
--- /dev/null
+++ b/advisory-board-post/colors_and_type.css
@@ -0,0 +1,346 @@
+/* =============================================================
+ Fenja AI — Nordic Editorial Design System
+ "The Digital Archivist"
+ ============================================================= */
+
+/* ---------- Fonts ------------------------------------------ */
+@font-face {
+ font-family: "Manrope";
+ font-weight: 200;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-ExtraLight.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 300;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Light.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Regular.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Medium.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 600;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-SemiBold.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Bold.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 800;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-ExtraBold.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Newsreader-Regular.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+ src: url("./fonts/Newsreader-Italic.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Newsreader-Bold.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 700;
+ font-style: italic;
+ font-display: swap;
+ src: url("./fonts/Newsreader-BoldItalic.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 800;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Newsreader-ExtraBold.ttf") format("truetype");
+}
+
+/* ---------- Tokens ----------------------------------------- */
+:root {
+ /* --- Core neutrals (unbleached paper, clay, slate) --- */
+ --background: #faf6ee; /* base canvas — warm paper */
+ --surface: #faf6ee;
+ --surface-container-lowest: #fffcf7; /* most-elevated — unbleached paper, never pure white */
+ --surface-container-low: #f6f2e8;
+ --surface-container: #efeadc;
+ --surface-container-high: #e7e1d0;
+ --surface-container-highest: #ddd6c3;
+ --surface-variant: #ddd6c3;
+
+ --on-surface: #383831; /* charcoal slate */
+ --on-surface-variant: #5f5e5e;
+ --on-surface-muted: #8a887f;
+
+ --primary: #5f5e5e;
+ --on-primary: #fffcf7;
+
+ --secondary: #785f53; /* hand-rubbed wood */
+ --secondary-dim: #6b5348;
+ --on-secondary: #ffffff;
+ --secondary-fixed-dim: #9a8679;
+
+ --outline: #babab0;
+ --outline-variant: #babab0; /* used at 15% for ghost borders */
+
+ /* --- Archival Pigment accent palette (flat, matte inks) --- */
+ --pigment-terracotta: #b96b58; /* warnings, critical */
+ --pigment-copper: #6d8c7c; /* success, growth */
+ --pigment-ochre: #c29d59; /* cautions, tertiary */
+ --pigment-indigo: #5a6d83; /* info, neutral data */
+ --pigment-heather: #8d7a85; /* categorical, supportive */
+
+ /* --- Semantic state mappings --- */
+ --color-success: var(--pigment-copper);
+ --color-warning: var(--pigment-ochre);
+ --color-danger: var(--pigment-terracotta);
+ --color-info: var(--pigment-indigo);
+
+ /* --- Type families --- */
+ --font-serif: "Newsreader", "Source Serif Pro", Georgia, "Times New Roman", serif;
+ --font-sans: "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+
+ /* --- Type scale (clamped for responsive) --- */
+ --text-display-xl: clamp(3.5rem, 6vw, 5.5rem); /* 56–88 */
+ --text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 48–72 */
+ --text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 40–56 */
+ --text-headline-lg: 2.25rem; /* 36 */
+ --text-headline-md: 1.75rem; /* 28 */
+ --text-headline-sm: 1.375rem; /* 22 */
+ --text-title-lg: 1.125rem; /* 18 */
+ --text-title-md: 1rem; /* 16 */
+ --text-body-lg: 1.0625rem; /* 17 */
+ --text-body-md: 1rem; /* 16 */
+ --text-body-sm: 0.875rem; /* 14 */
+ --text-label-md: 0.8125rem; /* 13 */
+ --text-label-sm: 0.75rem; /* 12 */
+
+ /* Letter-spacing */
+ --tracking-tight: -0.02em;
+ --tracking-snug: -0.01em;
+ --tracking-normal: 0;
+ --tracking-wide: 0.04em;
+ --tracking-wider: 0.08em;
+
+ /* Line-heights */
+ --leading-tight: 1.1;
+ --leading-snug: 1.25;
+ --leading-normal: 1.5;
+ --leading-relaxed: 1.6;
+ --leading-loose: 1.75;
+
+ /* --- Spacing scale (editorial, generous) --- */
+ --space-1: 0.25rem; /* 4 */
+ --space-2: 0.5rem; /* 8 */
+ --space-3: 0.75rem; /* 12 */
+ --space-4: 1rem; /* 16 */
+ --space-5: 1.5rem; /* 24 */
+ --space-6: 2rem; /* 32 — list separator default */
+ --space-7: 2.5rem; /* 40 */
+ --space-8: 2.75rem; /* 44 — hero-card padding */
+ --space-10: 4rem; /* 64 */
+ --space-12: 5rem; /* 80 */
+ --space-16: 6rem; /* 96 */
+ --space-20: 7rem; /* 112 — desktop lateral margin */
+ --space-24: 8rem; /* 128 */
+
+ /* --- Radii --- */
+ --radius-none: 0;
+ --radius-sm: 0.375rem; /* 6 */
+ --radius-md: 0.75rem; /* 12 — primary */
+ --radius-lg: 1.25rem; /* 20 */
+ --radius-full: 9999px;
+
+ /* --- Elevation (atmospheric, warm) --- */
+ --shadow-none: none;
+ --shadow-ambient: 0 12px 32px -12px rgba(56, 56, 49, 0.06);
+ --shadow-float: 0 24px 48px -16px rgba(56, 56, 49, 0.05), 0 4px 12px -4px rgba(56, 56, 49, 0.04);
+ --shadow-modal: 0 40px 64px -24px rgba(56, 56, 49, 0.08), 0 8px 16px -6px rgba(56, 56, 49, 0.04);
+
+ /* --- Ghost border (WCAG fallback only) --- */
+ --ghost-border-color: rgba(186, 186, 176, 0.15);
+ --ghost-border: 1px solid var(--ghost-border-color);
+
+ /* --- Glass --- */
+ --glass-blur: blur(16px);
+ --glass-surface: rgba(255, 252, 247, 0.8);
+
+ /* --- Motion --- */
+ --ease-standard: cubic-bezier(0.2, 0.0, 0, 1);
+ --ease-entrance: cubic-bezier(0, 0, 0, 1);
+ --ease-exit: cubic-bezier(0.3, 0, 1, 1);
+ --duration-fast: 140ms;
+ --duration-med: 240ms;
+ --duration-slow: 420ms;
+
+ /* --- Layout --- */
+ --content-max: 72rem; /* 1152 */
+ --reading-max: 42rem; /* 672 */
+}
+
+/* ---------- Base semantic styles --------------------------- */
+html {
+ font-family: var(--font-sans);
+ color: var(--on-surface);
+ background: var(--background);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+body {
+ margin: 0;
+ font-size: var(--text-body-md);
+ line-height: var(--leading-relaxed);
+ color: var(--on-surface);
+ background: var(--background);
+}
+
+/* Display — serif, tight, left-aligned editorial intent */
+.display-xl,
+.display-lg,
+.display-md {
+ font-family: var(--font-serif);
+ font-weight: 400;
+ letter-spacing: var(--tracking-tight);
+ line-height: var(--leading-tight);
+ color: var(--on-surface);
+ margin: 0 0 var(--space-5) 0;
+}
+.display-xl { font-size: var(--text-display-xl); }
+.display-lg { font-size: var(--text-display-lg); }
+.display-md { font-size: var(--text-display-md); }
+
+/* Headlines — serif, authoritative */
+h1, .headline-lg,
+h2, .headline-md,
+h3, .headline-sm {
+ font-family: var(--font-serif);
+ font-weight: 400;
+ color: var(--on-surface);
+ letter-spacing: var(--tracking-snug);
+ line-height: var(--leading-snug);
+ margin: 0 0 var(--space-4) 0;
+}
+h1, .headline-lg { font-size: var(--text-headline-lg); }
+h2, .headline-md { font-size: var(--text-headline-md); }
+h3, .headline-sm { font-size: var(--text-headline-sm); }
+
+/* Titles — sans, precise structural labels */
+h4, .title-lg,
+h5, .title-md {
+ font-family: var(--font-sans);
+ font-weight: 600;
+ color: var(--on-surface);
+ letter-spacing: var(--tracking-normal);
+ line-height: var(--leading-snug);
+ margin: 0 0 var(--space-3) 0;
+}
+h4, .title-lg { font-size: var(--text-title-lg); }
+h5, .title-md { font-size: var(--text-title-md); }
+
+/* Body */
+p, .body-md {
+ font-family: var(--font-sans);
+ font-weight: 400;
+ font-size: var(--text-body-md);
+ line-height: var(--leading-relaxed);
+ color: var(--on-surface);
+ margin: 0 0 var(--space-4) 0;
+ text-wrap: pretty;
+}
+.body-lg {
+ font-size: var(--text-body-lg);
+ line-height: var(--leading-relaxed);
+}
+.body-sm {
+ font-size: var(--text-body-sm);
+ line-height: var(--leading-normal);
+ color: var(--on-surface-variant);
+}
+
+/* Labels — muted, small caps optional */
+.label-md,
+.label-sm {
+ font-family: var(--font-sans);
+ font-weight: 500;
+ color: var(--on-surface-variant);
+ letter-spacing: var(--tracking-wider);
+ text-transform: uppercase;
+}
+.label-md { font-size: var(--text-label-md); }
+.label-sm { font-size: var(--text-label-sm); }
+
+/* Editorial lead — serif italic, subtle */
+.lead {
+ font-family: var(--font-serif);
+ font-style: italic;
+ font-size: var(--text-body-lg);
+ color: var(--on-surface-variant);
+ line-height: var(--leading-relaxed);
+}
+
+/* Inline code / mono */
+code, kbd, samp, pre, .mono {
+ font-family: var(--font-mono);
+ font-size: 0.92em;
+ color: var(--on-surface);
+}
+
+/* Links — editorial, no underline until hover */
+a {
+ color: var(--secondary);
+ text-decoration: none;
+ border-bottom: 1px solid rgba(120, 95, 83, 0.3);
+ transition: border-color var(--duration-fast) var(--ease-standard),
+ color var(--duration-fast) var(--ease-standard);
+}
+a:hover {
+ color: var(--secondary-dim);
+ border-bottom-color: currentColor;
+}
+
+/* Selection — warm, not blue */
+::selection {
+ background: rgba(120, 95, 83, 0.18);
+ color: var(--on-surface);
+}
+
+/* Utility: ghost border fallback */
+.ghost-border { border: var(--ghost-border); }
+.ghost-border-bottom { border-bottom: var(--ghost-border); }
diff --git a/advisory-board-post/design-canvas.jsx b/advisory-board-post/design-canvas.jsx
new file mode 100644
index 0000000..fa1f93e
--- /dev/null
+++ b/advisory-board-post/design-canvas.jsx
@@ -0,0 +1,966 @@
+
+// DesignCanvas.jsx — Figma-ish design canvas wrapper
+// Warm gray grid bg + Sections + Artboards + PostIt notes.
+// Artboards are reorderable (grip-drag), deletable, labels/titles are
+// inline-editable, and any artboard can be opened in a fullscreen focus
+// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
+// via the host bridge. No assets, no deps.
+//
+// Usage:
+//
+//
+// …
+// …
+//
+//
+
+const DC = {
+ bg: '#f0eee9',
+ grid: 'rgba(0,0,0,0.06)',
+ label: 'rgba(60,50,40,0.7)',
+ title: 'rgba(40,30,20,0.85)',
+ subtitle: 'rgba(60,50,40,0.6)',
+ postitBg: '#fef4a8',
+ postitText: '#5a4a2a',
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
+};
+
+// One-time CSS injection (classes are dc-prefixed so they don't collide with
+// the hosted design's own styles).
+if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
+ const s = document.createElement('style');
+ s.id = 'dc-styles';
+ s.textContent = [
+ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
+ '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
+ '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
+ '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
+ '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
+ // isolation:isolate contains artboard content's z-indexes so a
+ // z-indexed child (sticky navbar etc.) can't paint over .dc-header or
+ // the .dc-menu popover that drops into the top of the card.
+ '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
+ '.dc-card *{scrollbar-width:none}',
+ '.dc-card *::-webkit-scrollbar{display:none}',
+ // Per-artboard header: grip + label on the left, delete/expand on the
+ // right. Single flex row; when the artboard's on-screen width is too
+ // narrow for both the label yields (ellipsis, then hidden entirely below
+ // ~4ch via the container query) and the buttons stay on the row.
+ '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
+ ' display:flex;align-items:center;container-type:inline-size}',
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
+ '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
+ '.dc-grip:active{cursor:grabbing}',
+ '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
+ ' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
+ // Below ~4ch of label room: hide the label entirely, and drop the grip to
+ // hover-only (same reveal rule as .dc-btns) so a narrow header is clean
+ // until the card is moused.
+ '@container (max-width: 110px){',
+ ' .dc-labeltext{display:none}',
+ ' .dc-grip{opacity:0}',
+ ' [data-dc-slot]:hover .dc-grip{opacity:1}',
+ '}',
+ '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
+ '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
+ '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
+ '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
+ '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
+ '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
+ ' font:inherit;transition:background .12s,color .12s}',
+ '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
+ // Slot hosting an open menu floats above later siblings (which otherwise
+ // paint on top — same z-index:auto, later DOM order) so the popup isn't
+ // clipped by the next card.
+ '[data-dc-slot]:has(.dc-menu){z-index:10}',
+ '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
+ ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
+ '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
+ ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
+ ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
+ '.dc-menu button:hover{background:rgba(0,0,0,.05)}',
+ '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
+ '.dc-menu .dc-danger{color:#c96442}',
+ '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
+ // Chrome (titles / labels / buttons) counter-scales against the viewport
+ // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
+ // DCViewport on every transform update and inherits to all descendants —
+ // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
+ // it the same way.
+ //
+ // The header uses transform:scale (out-of-flow, so layout impact doesn't
+ // matter) with its world-space width set to card-width / inv-zoom so that
+ // after counter-scaling its on-screen width exactly matches the card's —
+ // that's what lets the container query + text-overflow behave against the
+ // card's visible edge at every zoom level.
+ //
+ // The section head uses CSS zoom instead of transform so its layout box
+ // grows with the counter-scale, pushing the card row down — otherwise the
+ // constant-screen-size title would overflow into the (shrinking) world-
+ // space gap and overlap the artboard headers at low zoom.
+ '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
+ ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
+ '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
+ ].join('\n');
+ document.head.appendChild(s);
+}
+
+const DCCtx = React.createContext(null);
+
+// Recursively unwrap React.Fragment so <>…> grouping doesn't hide
+// DCSection/DCArtboard children from the type-based walks below.
+function dcFlatten(children) {
+ const out = [];
+ React.Children.forEach(children, (c) => {
+ if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
+ else out.push(c);
+ });
+ return out;
+}
+
+// ─────────────────────────────────────────────────────────────
+// DesignCanvas — stateful wrapper around the pan/zoom viewport.
+// Owns runtime state (per-section order, renamed titles/labels, hidden
+// artboards, focused artboard). Order/titles/labels/hidden persist to a
+// .design-canvas.state.json
+// sidecar next to the HTML. Reads go via plain fetch() so the saved
+// arrangement is visible anywhere the HTML + sidecar are served together
+// (omelette preview, direct link, downloaded zip). Writes go through the
+// host's window.omelette bridge — editing requires the omelette runtime.
+// Focus is ephemeral.
+// ─────────────────────────────────────────────────────────────
+const DC_STATE_FILE = '.design-canvas.state.json';
+
+function DesignCanvas({ children, minScale, maxScale, style }) {
+ const [state, setState] = React.useState({ sections: {}, focus: null });
+ // Hold rendering until the sidecar read settles so the saved order/titles
+ // appear on first paint (no source-order flash). didRead gates writes until
+ // the read settles so the empty initial state can't clobber a slow read;
+ // skipNextWrite suppresses the one echo-write that would otherwise follow
+ // hydration.
+ const [ready, setReady] = React.useState(false);
+ const didRead = React.useRef(false);
+ const skipNextWrite = React.useRef(false);
+
+ React.useEffect(() => {
+ let off = false;
+ fetch('./' + DC_STATE_FILE)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((saved) => {
+ if (off || !saved || !saved.sections) return;
+ skipNextWrite.current = true;
+ setState((s) => ({ ...s, sections: saved.sections }));
+ })
+ .catch(() => {})
+ .finally(() => { didRead.current = true; if (!off) setReady(true); });
+ const t = setTimeout(() => { if (!off) setReady(true); }, 150);
+ return () => { off = true; clearTimeout(t); };
+ }, []);
+
+ React.useEffect(() => {
+ if (!didRead.current) return;
+ if (skipNextWrite.current) { skipNextWrite.current = false; return; }
+ const t = setTimeout(() => {
+ window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
+ }, 250);
+ return () => clearTimeout(t);
+ }, [state.sections]);
+
+ // Build registries synchronously from children so FocusOverlay can read
+ // them in the same render. Fragments are flattened; wrapping in other
+ // elements still opts out of focus/reorder.
+ const registry = {}; // slotId -> { sectionId, artboard }
+ const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
+ const sectionOrder = [];
+ dcFlatten(children).forEach((sec) => {
+ if (!sec || sec.type !== DCSection) return;
+ const sid = sec.props.id ?? sec.props.title;
+ if (!sid) return;
+ sectionOrder.push(sid);
+ const persisted = state.sections[sid] || {};
+ const abs = [];
+ dcFlatten(sec.props.children).forEach((ab) => {
+ if (!ab || ab.type !== DCArtboard) return;
+ const aid = ab.props.id ?? ab.props.label;
+ if (aid) abs.push([aid, ab]);
+ });
+ // hidden is scoped to one source revision — when the agent regenerates
+ // (artboard-ID set changes), prior deletes don't apply to new content.
+ const srcKey = abs.map(([k]) => k).join('\x1f');
+ const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
+ const srcIds = [];
+ abs.forEach(([aid, ab]) => {
+ if (hidden.includes(aid)) return;
+ registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
+ srcIds.push(aid);
+ });
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
+ sectionMeta[sid] = {
+ title: persisted.title ?? sec.props.title,
+ subtitle: sec.props.subtitle,
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
+ };
+ });
+
+ const api = React.useMemo(() => ({
+ state,
+ section: (id) => state.sections[id] || {},
+ patchSection: (id, p) => setState((s) => ({
+ ...s,
+ sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
+ })),
+ setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
+ }), [state]);
+
+ // Esc exits focus; any outside pointerdown commits an in-progress rename.
+ React.useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
+ const onPd = (e) => {
+ const ae = document.activeElement;
+ if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
+ };
+ document.addEventListener('keydown', onKey);
+ document.addEventListener('pointerdown', onPd, true);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('pointerdown', onPd, true);
+ };
+ }, [api]);
+
+ return (
+
+ {ready && children}
+ {state.focus && registry[state.focus] && (
+
+ )}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// DCViewport — transform-based pan/zoom (internal)
+//
+// Input mapping (Figma-style):
+// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
+// • trackpad scroll → pan (two-finger)
+// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
+// • middle-drag / primary-drag-on-bg → pan
+//
+// Transform state lives in a ref and is written straight to the DOM
+// (translate3d + will-change) so wheel ticks don't go through React —
+// keeps pans at 60fps on dense canvases.
+// ─────────────────────────────────────────────────────────────
+function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
+ const vpRef = React.useRef(null);
+ const worldRef = React.useRef(null);
+ const tf = React.useRef({ x: 0, y: 0, scale: 1 });
+ // Persist viewport across reloads so the user lands back where they were
+ // after an agent edit or browser refresh. The sandbox origin is already
+ // per-project; pathname keeps multiple canvas files in one project apart.
+ const tfKey = 'dc-viewport:' + location.pathname;
+ const saveT = React.useRef(0);
+
+ const lastPostedScale = React.useRef();
+ const apply = React.useCallback(() => {
+ const { x, y, scale } = tf.current;
+ const el = worldRef.current;
+ if (!el) return;
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
+ // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
+ el.style.setProperty('--dc-inv-zoom', String(1 / scale));
+ // Keep the host toolbar's % readout in sync with the canvas scale. Pan
+ // ticks leave scale unchanged — skip the cross-frame post for those.
+ if (lastPostedScale.current !== scale) {
+ lastPostedScale.current = scale;
+ window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
+ }
+ clearTimeout(saveT.current);
+ saveT.current = setTimeout(() => {
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
+ }, 200);
+ }, [tfKey]);
+
+ React.useLayoutEffect(() => {
+ const flush = () => {
+ clearTimeout(saveT.current);
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
+ };
+ try {
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
+ tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
+ apply();
+ }
+ } catch {}
+ // Flush on pagehide and unmount so a reload within the 200ms debounce
+ // window doesn't drop the last pan/zoom.
+ window.addEventListener('pagehide', flush);
+ return () => { window.removeEventListener('pagehide', flush); flush(); };
+ }, []);
+
+ React.useEffect(() => {
+ const vp = vpRef.current;
+ if (!vp) return;
+
+ const zoomAt = (cx, cy, factor) => {
+ const r = vp.getBoundingClientRect();
+ const px = cx - r.left, py = cy - r.top;
+ const t = tf.current;
+ const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
+ const k = next / t.scale;
+ // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
+ // marginBottom) reflow on every scale change, vertically shifting the
+ // world layout — so a world point mathematically pinned under the cursor
+ // drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
+ // Anchor the DOM element under the cursor instead: record its screen Y,
+ // apply the transform + --dc-inv-zoom, then cancel whatever vertical
+ // drift the reflow introduced so it stays put on screen.
+ let marker = null, markerY0 = 0;
+ if (k !== 1) {
+ const hit = document.elementFromPoint(cx, cy);
+ marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
+ if (marker) markerY0 = marker.getBoundingClientRect().top;
+ }
+ // keep the world point under the cursor fixed
+ t.x = px - (px - t.x) * k;
+ t.y = py - (py - t.y) * k;
+ t.scale = next;
+ apply();
+ if (marker) {
+ // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
+ // departure after the --dc-inv-zoom reflow is the layout drift.
+ const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
+ if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
+ }
+ };
+
+ // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
+ // line-mode deltas (Firefox) or large integer pixel deltas with no X
+ // component (Chrome/Safari, typically multiples of 100/120). Trackpad
+ // two-finger scroll sends small/fractional pixel deltas, often with
+ // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
+ const isMouseWheel = (e) =>
+ e.deltaMode !== 0 ||
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
+
+ const onWheel = (e) => {
+ e.preventDefault();
+ if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
+ if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
+ // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
+ // wheels fall through to the fixed-step branch below.
+ zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
+ } else if (isMouseWheel(e)) {
+ // notched mouse wheel — fixed-ratio step per click
+ zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
+ } else {
+ // trackpad two-finger scroll — pan
+ tf.current.x -= e.deltaX;
+ tf.current.y -= e.deltaY;
+ apply();
+ }
+ };
+
+ // Safari sends native gesture* events for trackpad pinch with a smooth
+ // e.scale; preferring these over the ctrl+wheel fallback gives a much
+ // better feel there. No-ops on other browsers. Safari also fires
+ // ctrlKey wheel events during the same pinch — isGesturing makes
+ // onWheel drop those entirely so they neither zoom nor pan.
+ let gsBase = 1;
+ let isGesturing = false;
+ const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
+ const onGestureChange = (e) => {
+ e.preventDefault();
+ zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
+ };
+ const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
+
+ // Drag-pan: middle button anywhere, or primary button on canvas
+ // background (anything that isn't an artboard or an inline editor).
+ let drag = null;
+ const onPointerDown = (e) => {
+ const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
+ e.preventDefault();
+ vp.setPointerCapture(e.pointerId);
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
+ vp.style.cursor = 'grabbing';
+ };
+ const onPointerMove = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ tf.current.x += e.clientX - drag.lx;
+ tf.current.y += e.clientY - drag.ly;
+ drag.lx = e.clientX; drag.ly = e.clientY;
+ apply();
+ };
+ const onPointerUp = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ vp.releasePointerCapture(e.pointerId);
+ drag = null;
+ vp.style.cursor = '';
+ };
+
+ // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
+ // visible midpoint stays fixed — matching the host's iframe-zoom feel.
+ const onHostMsg = (e) => {
+ const d = e.data;
+ if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
+ const r = vp.getBoundingClientRect();
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
+ } else if (d && d.type === '__dc_probe') {
+ // Host's [readyGen] reset asks whether a canvas is present; it
+ // fires on the iframe's native 'load', which for canvases with
+ // images/fonts is after our mount-time announce, so re-announce.
+ // Clear the pan-tick guard so apply() re-posts the current scale
+ // even if it's unchanged — the host just reset dcScale to 1.
+ window.parent.postMessage({ type: '__dc_present' }, '*');
+ lastPostedScale.current = undefined;
+ apply();
+ }
+ };
+ window.addEventListener('message', onHostMsg);
+ // Announce canvas mode so the host toolbar proxies its % control here
+ // instead of scaling the iframe element (which would just shrink the
+ // viewport window of an infinite canvas). The apply() that follows emits
+ // the initial __dc_zoom so the toolbar % is correct before first pinch.
+ // lastPostedScale reset mirrors the __dc_probe handler: the layout
+ // effect's restore-path apply() may already have posted the restored
+ // scale (before __dc_present), so clear the guard to re-post it in order.
+ window.parent.postMessage({ type: '__dc_present' }, '*');
+ lastPostedScale.current = undefined;
+ apply();
+
+ vp.addEventListener('wheel', onWheel, { passive: false });
+ vp.addEventListener('gesturestart', onGestureStart, { passive: false });
+ vp.addEventListener('gesturechange', onGestureChange, { passive: false });
+ vp.addEventListener('gestureend', onGestureEnd, { passive: false });
+ vp.addEventListener('pointerdown', onPointerDown);
+ vp.addEventListener('pointermove', onPointerMove);
+ vp.addEventListener('pointerup', onPointerUp);
+ vp.addEventListener('pointercancel', onPointerUp);
+ return () => {
+ window.removeEventListener('message', onHostMsg);
+ vp.removeEventListener('wheel', onWheel);
+ vp.removeEventListener('gesturestart', onGestureStart);
+ vp.removeEventListener('gesturechange', onGestureChange);
+ vp.removeEventListener('gestureend', onGestureEnd);
+ vp.removeEventListener('pointerdown', onPointerDown);
+ vp.removeEventListener('pointermove', onPointerMove);
+ vp.removeEventListener('pointerup', onPointerUp);
+ vp.removeEventListener('pointercancel', onPointerUp);
+ };
+ }, [apply, minScale, maxScale]);
+
+ const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// DCSection — editable title + h-row of artboards in persisted order
+// ─────────────────────────────────────────────────────────────
+function DCSection({ id, title, subtitle, children, gap = 48 }) {
+ const ctx = React.useContext(DCCtx);
+ const sid = id ?? title;
+ const all = React.Children.toArray(dcFlatten(children));
+ const artboards = all.filter((c) => c && c.type === DCArtboard);
+ const rest = all.filter((c) => !(c && c.type === DCArtboard));
+ const sec = (ctx && sid && ctx.section(sid)) || {};
+ // Must match DesignCanvas's srcKey computation exactly (it filters falsy
+ // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
+ const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
+ const srcKey = allIds.join('\x1f');
+ const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
+ const srcOrder = allIds.filter((k) => !hidden.includes(k));
+
+ const order = React.useMemo(() => {
+ const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
+ return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
+ }, [sec.order, srcOrder.join('|')]);
+
+ const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
+
+ // marginBottom counter-scales so the on-screen gap between sections stays
+ // constant — otherwise at low zoom the (world-space) gap collapses while
+ // the screen-constant sectionhead below it doesn't, and the title reads as
+ // belonging to the section above. paddingBottom below is just enough for
+ // the 24px artboard-header (abs-positioned above each card) plus ~8px, so
+ // the title sits tight against its own row at every zoom.
+ return (
+
+
+ {/* card centered, label + index below — only the card itself stops
+ propagation so any backdrop click (including the margins around
+ the card) exits focus */}
+
+ >
+ );
+}
diff --git a/advisory-board-post/fonts/Manrope-Bold.ttf b/advisory-board-post/fonts/Manrope-Bold.ttf
new file mode 100644
index 0000000..62a6183
Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Bold.ttf differ
diff --git a/advisory-board-post/fonts/Manrope-ExtraBold.ttf b/advisory-board-post/fonts/Manrope-ExtraBold.ttf
new file mode 100644
index 0000000..2fa671c
Binary files /dev/null and b/advisory-board-post/fonts/Manrope-ExtraBold.ttf differ
diff --git a/advisory-board-post/fonts/Manrope-ExtraLight.ttf b/advisory-board-post/fonts/Manrope-ExtraLight.ttf
new file mode 100644
index 0000000..c55745a
Binary files /dev/null and b/advisory-board-post/fonts/Manrope-ExtraLight.ttf differ
diff --git a/advisory-board-post/fonts/Manrope-Light.ttf b/advisory-board-post/fonts/Manrope-Light.ttf
new file mode 100644
index 0000000..8a771c2
Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Light.ttf differ
diff --git a/advisory-board-post/fonts/Manrope-Medium.ttf b/advisory-board-post/fonts/Manrope-Medium.ttf
new file mode 100644
index 0000000..c6d28de
Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Medium.ttf differ
diff --git a/advisory-board-post/fonts/Manrope-Regular.ttf b/advisory-board-post/fonts/Manrope-Regular.ttf
new file mode 100644
index 0000000..9a108f1
Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Regular.ttf differ
diff --git a/advisory-board-post/fonts/Manrope-SemiBold.ttf b/advisory-board-post/fonts/Manrope-SemiBold.ttf
new file mode 100644
index 0000000..46a13d6
Binary files /dev/null and b/advisory-board-post/fonts/Manrope-SemiBold.ttf differ
diff --git a/advisory-board-post/fonts/Newsreader-Bold.ttf b/advisory-board-post/fonts/Newsreader-Bold.ttf
new file mode 100644
index 0000000..6d9e20a
Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-Bold.ttf differ
diff --git a/advisory-board-post/fonts/Newsreader-BoldItalic.ttf b/advisory-board-post/fonts/Newsreader-BoldItalic.ttf
new file mode 100644
index 0000000..bc57925
Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-BoldItalic.ttf differ
diff --git a/advisory-board-post/fonts/Newsreader-ExtraBold.ttf b/advisory-board-post/fonts/Newsreader-ExtraBold.ttf
new file mode 100644
index 0000000..69a726d
Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-ExtraBold.ttf differ
diff --git a/advisory-board-post/fonts/Newsreader-Italic.ttf b/advisory-board-post/fonts/Newsreader-Italic.ttf
new file mode 100644
index 0000000..477facd
Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-Italic.ttf differ
diff --git a/advisory-board-post/fonts/Newsreader-Regular.ttf b/advisory-board-post/fonts/Newsreader-Regular.ttf
new file mode 100644
index 0000000..9fe7694
Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-Regular.ttf differ
diff --git a/advisory-board-post/image-slot.js b/advisory-board-post/image-slot.js
new file mode 100644
index 0000000..d1eb01b
--- /dev/null
+++ b/advisory-board-post/image-slot.js
@@ -0,0 +1,641 @@
+/**
+ * — user-fillable image placeholder.
+ *
+ * Drop this into a deck, mockup, or page wherever you want the user to
+ * supply an image. You control the slot's shape and size; the user fills it
+ * by dragging an image file onto it (or clicking to browse). The dropped
+ * image persists across reloads via a .image-slots.state.json sidecar —
+ * same read-via-fetch / write-via-window.omelette pattern as
+ * design_canvas.jsx, so the filled slot shows on share links, downloaded
+ * zips, and PPTX export. Outside the omelette runtime the slot is read-only.
+ *
+ * The host bridge only allows sidecar writes at the project root, so the
+ * HTML that uses this component is assumed to live at the project root too
+ * (same constraint as design_canvas.jsx).
+ *
+ * Attributes:
+ * id Persistence key. REQUIRED for the drop to survive reload —
+ * every slot on the page needs a distinct id.
+ * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded')
+ * 'circle' applies 50% border-radius; on a non-square slot
+ * that's an ellipse — set equal width and height for a true
+ * circle.
+ * radius Corner radius in px for 'rounded'. (default 12)
+ * mask Any CSS clip-path value. Overrides `shape` — use this for
+ * hexagons, blobs, arbitrary polygons.
+ * fit object-fit: cover | contain | fill. (default 'cover')
+ * With cover (the default) double-clicking the filled slot
+ * enters a reframe mode: the whole image spills past the mask
+ * (translucent outside, opaque inside), drag to reposition,
+ * corner-drag to scale. The crop persists alongside the image
+ * in the sidecar. contain/fill stay static.
+ * position object-position for fit=contain|fill. (default '50% 50%')
+ * placeholder Empty-state caption. (default 'Drop an image')
+ * src Optional initial/fallback image URL. A user drop overrides
+ * it; clearing the drop reveals src again.
+ *
+ * Size and layout come from ordinary CSS on the element — width/height
+ * inline or from a parent grid — so it composes with any layout.
+ *
+ * Usage:
+ *
+ *
+ *
+ *
+ */
+
+(() => {
+ const STATE_FILE = '.image-slots.state.json';
+ // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the
+ // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB.
+ const MAX_DIM = 1200;
+ // Raster formats only. SVG is excluded (can carry script; createImageBitmap
+ // on SVG blobs is inconsistent). GIF is excluded because the canvas
+ // re-encode keeps only the first frame, so an animated GIF would silently
+ // go still — better to reject than surprise.
+ const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
+
+ // ── Shared sidecar store ────────────────────────────────────────────────
+ // One fetch + immediate write-on-change for every on the
+ // page. Reads via fetch() so viewing works anywhere the HTML and sidecar
+ // are served together; writes go through window.omelette.writeFile, which
+ // the host allowlists to *.state.json basenames only.
+ const subs = new Set();
+ let slots = {};
+ // ids explicitly cleared before the sidecar fetch resolved — otherwise
+ // the merge below can't tell "never set" from "just deleted" and would
+ // resurrect the sidecar's stale value.
+ const tombstones = new Set();
+ let loaded = false;
+ let loadP = null;
+
+ function load() {
+ if (loadP) return loadP;
+ loadP = fetch(STATE_FILE)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((j) => {
+ // Merge: sidecar loses to any in-memory change that raced ahead of
+ // the fetch (drop or clear) so neither is clobbered by hydration.
+ if (j && typeof j === 'object') {
+ const merged = Object.assign({}, j, slots);
+ // A framing-only write that raced ahead of hydration must not
+ // drop a user image that's only on disk — inherit u from the
+ // sidecar for any in-memory entry that lacks one.
+ for (const k in slots) {
+ if (merged[k] && !merged[k].u && j[k]) {
+ merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u;
+ }
+ }
+ for (const id of tombstones) delete merged[id];
+ slots = merged;
+ }
+ tombstones.clear();
+ })
+ .catch(() => {})
+ .then(() => { loaded = true; subs.forEach((fn) => fn()); });
+ return loadP;
+ }
+
+ // Serialize writes so two near-simultaneous drops on different slots
+ // can't reorder at the backend and leave the sidecar with only the
+ // first. A save requested mid-flight just marks dirty and re-fires on
+ // completion with the then-current slots.
+ let saving = false;
+ let saveDirty = false;
+ function save() {
+ if (saving) { saveDirty = true; return; }
+ const w = window.omelette && window.omelette.writeFile;
+ if (!w) return;
+ saving = true;
+ Promise.resolve(w(STATE_FILE, JSON.stringify(slots)))
+ .catch(() => {})
+ .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } });
+ }
+
+ const S_MAX = 5;
+ const clampS = (s) => Math.max(1, Math.min(S_MAX, s));
+
+ // Normalize a stored slot value. Pre-reframe sidecars stored a bare
+ // data-URL string; newer ones store {u, s, x, y}. Either shape is valid.
+ function getSlot(id) {
+ const v = slots[id];
+ if (!v) return null;
+ return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v;
+ }
+
+ function setSlot(id, val) {
+ if (!id) return;
+ if (val) { slots[id] = val; tombstones.delete(id); }
+ else { delete slots[id]; if (!loaded) tombstones.add(id); }
+ subs.forEach((fn) => fn());
+ // A drop is rare + high-value — write immediately so nav-away can't lose
+ // it. Gate on the initial read so we don't overwrite a sidecar we haven't
+ // merged yet; the merge in load() keeps this change once the read lands.
+ if (loaded) save(); else load().then(save);
+ }
+
+ // ── Image downscale ─────────────────────────────────────────────────────
+ // Encode through a canvas so the sidecar carries resized bytes, not the
+ // raw upload. Longest side is capped at 2× the slot's rendered width
+ // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG
+ // for photos, so there's no need for per-image format picking.
+ async function toDataUrl(file, targetW) {
+ const bitmap = await createImageBitmap(file);
+ try {
+ const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM);
+ const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height));
+ const w = Math.max(1, Math.round(bitmap.width * scale));
+ const h = Math.max(1, Math.round(bitmap.height * scale));
+ const canvas = document.createElement('canvas');
+ canvas.width = w; canvas.height = h;
+ canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
+ return canvas.toDataURL('image/webp', 0.85);
+ } finally {
+ bitmap.close && bitmap.close();
+ }
+ }
+
+ // ── Custom element ──────────────────────────────────────────────────────
+ const stylesheet =
+ ':host{display:inline-block;position:relative;vertical-align:top;' +
+ ' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' +
+ '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' +
+ // .frame img (clipped) and .spill (unclipped ghost + handles) share the
+ // same left/top/width/height in frame-%, computed by _applyView(), so the
+ // inside-mask crop and the outside-mask spill stay pixel-aligned.
+ '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' +
+ ' -webkit-user-drag:none;user-select:none;touch-action:none}' +
+ // Reframe mode (double-click): the full image spills past the mask. The
+ // spill layer is sized to the IMAGE bounds so its corners are where the
+ // resize handles belong. The ghost inside is translucent; the real
+ // clipped underneath shows the opaque in-mask crop.
+ '.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' +
+ ' cursor:grab;touch-action:none}' +
+ ':host([data-panning]) .spill{cursor:grabbing}' +
+ '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' +
+ ' pointer-events:none;-webkit-user-drag:none;user-select:none;' +
+ ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' +
+ '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' +
+ ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' +
+ ' transform:translate(-50%,-50%)}' +
+ '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' +
+ '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' +
+ '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' +
+ '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' +
+ ':host([data-reframe]){z-index:10}' +
+ ':host([data-reframe]) .spill{display:block}' +
+ ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' +
+ '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
+ ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
+ ' cursor:pointer;user-select:none}' +
+ '.empty svg{opacity:.45}' +
+ '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' +
+ '.empty .sub{font-size:11px}' +
+ '.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' +
+ '.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' +
+ ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' +
+ ' background:rgba(201,100,66,.10)}' +
+ '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' +
+ ' transition:border-color .12s}' +
+ ':host([data-over]) .ring{border-color:#c96442}' +
+ ':host([data-filled]) .ring{display:none}' +
+ // Controls sit BELOW the mask (top:100%), absolutely positioned so the
+ // author-declared slot height is unaffected. The gap is padding, not a
+ // top offset, so the hover target stays contiguous with the frame.
+ '.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' +
+ ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
+ ' white-space:nowrap}' +
+ ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' +
+ ' {opacity:1;pointer-events:auto}' +
+ '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' +
+ ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' +
+ ' backdrop-filter:blur(6px)}' +
+ '.ctl button:hover{background:rgba(0,0,0,.8)}' +
+ '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' +
+ ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}';
+
+ const icon =
+ '';
+
+ class ImageSlot extends HTMLElement {
+ static get observedAttributes() {
+ return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id'];
+ }
+
+ constructor() {
+ super();
+ const root = this.attachShadow({ mode: 'open' });
+ // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
+ // on the frame (circle, pill, rounded) can't clip them.
+ root.innerHTML =
+ '' +
+ '
' +
+ ' ' +
+ '
' + icon +
+ ' ' +
+ '
or browse files
' +
+ ' ' +
+ '
' +
+ '
' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '';
+ this._frame = root.querySelector('.frame');
+ this._ring = root.querySelector('.ring');
+ this._img = root.querySelector('.frame img');
+ this._empty = root.querySelector('.empty');
+ this._cap = root.querySelector('.cap');
+ this._sub = root.querySelector('.sub');
+ this._spill = root.querySelector('.spill');
+ this._ghost = root.querySelector('.ghost');
+ this._err = null;
+ this._input = root.querySelector('input');
+ this._depth = 0;
+ this._gen = 0;
+ this._view = { s: 1, x: 0, y: 0 };
+ this._subFn = () => this._render();
+ // Shadow-DOM listeners live with the shadow DOM — bound once here so
+ // disconnect/reconnect (e.g. React remount) doesn't stack handlers.
+ this._empty.addEventListener('click', () => this._input.click());
+ root.addEventListener('click', (e) => {
+ const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
+ if (act === 'replace') { this._exitReframe(true); this._input.click(); }
+ if (act === 'clear') {
+ this._exitReframe(false);
+ this._gen++;
+ this._local = null;
+ if (this.id) setSlot(this.id, null); else this._render();
+ }
+ });
+ this._input.addEventListener('change', () => {
+ const f = this._input.files && this._input.files[0];
+ if (f) this._ingest(f);
+ this._input.value = '';
+ });
+ // naturalWidth/Height aren't known until load — re-apply so the cover
+ // baseline is computed from real dimensions, not the 100%×100% fallback.
+ this._img.addEventListener('load', () => this._applyView());
+ // Gated on editable + fit=cover so share links and contain/fill slots
+ // stay static.
+ this.addEventListener('dblclick', (e) => {
+ if (!this.hasAttribute('data-editable') || !this._reframes()) return;
+ e.preventDefault();
+ if (this.hasAttribute('data-reframe')) this._exitReframe(true);
+ else this._enterReframe();
+ });
+ // Pan + resize both originate on the spill layer. A handle pointerdown
+ // drives an aspect-locked resize anchored at the opposite corner; any
+ // other pointerdown on the spill pans. Offsets are frame-% so a
+ // reframed slot survives responsive resize / PPTX export.
+ this._spill.addEventListener('pointerdown', (e) => {
+ if (e.button !== 0 || !this.hasAttribute('data-reframe')) return;
+ e.preventDefault();
+ e.stopPropagation();
+ this._spill.setPointerCapture(e.pointerId);
+ const rect = this.getBoundingClientRect();
+ const fw = rect.width || 1, fh = rect.height || 1;
+ const corner = e.target.getAttribute && e.target.getAttribute('data-c');
+ let move;
+ if (corner) {
+ // Resize about the OPPOSITE corner. Viewport-px throughout (rect
+ // fw/fh, not clientWidth) so the math survives a transform:scale()
+ // ancestor — deck_stage renders slides scaled-to-fit.
+ const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1;
+ const base = Math.max(fw / iw, fh / ih);
+ const sx = corner.includes('e') ? 1 : -1;
+ const sy = corner.includes('s') ? 1 : -1;
+ const s0 = this._view.s;
+ const w0 = iw * base * s0, h0 = ih * base * s0;
+ const cx0 = (50 + this._view.x) / 100 * fw;
+ const cy0 = (50 + this._view.y) / 100 * fh;
+ const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2;
+ const diag0 = Math.hypot(w0, h0);
+ const ux = sx * w0 / diag0, uy = sy * h0 / diag0;
+ move = (ev) => {
+ const proj = (ev.clientX - rect.left - ox) * ux +
+ (ev.clientY - rect.top - oy) * uy;
+ const s = clampS(s0 * proj / diag0);
+ const d = diag0 * s / s0;
+ this._view.s = s;
+ this._view.x = (ox + ux * d / 2) / fw * 100 - 50;
+ this._view.y = (oy + uy * d / 2) / fh * 100 - 50;
+ this._clampView();
+ this._applyView();
+ };
+ } else {
+ this.setAttribute('data-panning', '');
+ const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y };
+ move = (ev) => {
+ this._view.x = start.x + (ev.clientX - start.px) / fw * 100;
+ this._view.y = start.y + (ev.clientY - start.py) / fh * 100;
+ this._clampView();
+ this._applyView();
+ };
+ }
+ const up = () => {
+ try { this._spill.releasePointerCapture(e.pointerId); } catch {}
+ this._spill.removeEventListener('pointermove', move);
+ this._spill.removeEventListener('pointerup', up);
+ this._spill.removeEventListener('pointercancel', up);
+ this.removeAttribute('data-panning');
+ this._dragUp = null;
+ };
+ // Stashed so _exitReframe (Escape / outside-click mid-drag) can
+ // tear the capture + listeners down synchronously.
+ this._dragUp = up;
+ this._spill.addEventListener('pointermove', move);
+ this._spill.addEventListener('pointerup', up);
+ this._spill.addEventListener('pointercancel', up);
+ });
+ // Wheel zoom stays available inside reframe mode as a trackpad nicety —
+ // zooms toward the cursor (offset' = cursor·(1-k) + offset·k).
+ this.addEventListener('wheel', (e) => {
+ if (!this.hasAttribute('data-reframe')) return;
+ e.preventDefault();
+ const r = this.getBoundingClientRect();
+ const cx = (e.clientX - r.left) / r.width * 100 - 50;
+ const cy = (e.clientY - r.top) / r.height * 100 - 50;
+ const prev = this._view.s;
+ const next = clampS(prev * Math.pow(1.0015, -e.deltaY));
+ if (next === prev) return;
+ const k = next / prev;
+ this._view.s = next;
+ this._view.x = cx * (1 - k) + this._view.x * k;
+ this._view.y = cy * (1 - k) + this._view.y * k;
+ this._clampView();
+ this._applyView();
+ }, { passive: false });
+ }
+
+ connectedCallback() {
+ // Warn once per page — an id-less slot works for the session but
+ // cannot persist, and two id-less slots would share nothing.
+ if (!this.id && !ImageSlot._warned) {
+ ImageSlot._warned = true;
+ console.warn(' without an id will not persist its dropped image.');
+ }
+ this.addEventListener('dragenter', this);
+ this.addEventListener('dragover', this);
+ this.addEventListener('dragleave', this);
+ this.addEventListener('drop', this);
+ subs.add(this._subFn);
+ // width%/height% in _applyView encode the frame aspect at call time —
+ // a host resize (responsive grid, pane divider) would stretch the
+ // image until the next _render. Re-render on size change: _render()
+ // re-seeds _view from stored before clamp/apply, so a shrink→grow
+ // cycle round-trips instead of ratcheting x/y toward the narrower
+ // frame's clamp range.
+ this._ro = new ResizeObserver(() => this._render());
+ this._ro.observe(this);
+ load();
+ this._render();
+ }
+
+ disconnectedCallback() {
+ subs.delete(this._subFn);
+ this.removeEventListener('dragenter', this);
+ this.removeEventListener('dragover', this);
+ this.removeEventListener('dragleave', this);
+ this.removeEventListener('drop', this);
+ if (this._ro) { this._ro.disconnect(); this._ro = null; }
+ this._exitReframe(false);
+ }
+
+ _enterReframe() {
+ if (this.hasAttribute('data-reframe')) return;
+ this.setAttribute('data-reframe', '');
+ this._applyView();
+ // Close on click outside (the spill handler stopPropagation()s so
+ // in-image drags don't reach this) and on Escape. Listeners are held
+ // on the instance so _exitReframe / disconnectedCallback can detach
+ // exactly what was attached.
+ this._outside = (e) => {
+ if (e.composedPath && e.composedPath().includes(this)) return;
+ this._exitReframe(true);
+ };
+ this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); };
+ document.addEventListener('pointerdown', this._outside, true);
+ document.addEventListener('keydown', this._esc, true);
+ }
+
+ _exitReframe(commit) {
+ if (!this.hasAttribute('data-reframe')) return;
+ if (this._dragUp) this._dragUp();
+ this.removeAttribute('data-reframe');
+ this.removeAttribute('data-panning');
+ if (this._outside) document.removeEventListener('pointerdown', this._outside, true);
+ if (this._esc) document.removeEventListener('keydown', this._esc, true);
+ this._outside = this._esc = null;
+ if (commit) this._commitView();
+ }
+
+ attributeChangedCallback() { if (this.shadowRoot) this._render(); }
+
+ // handleEvent — one listener object for all four drag events keeps the
+ // add/remove symmetric and the depth counter correct.
+ handleEvent(e) {
+ if (e.type === 'dragenter' || e.type === 'dragover') {
+ // Without preventDefault the browser never fires 'drop'.
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
+ if (e.type === 'dragenter') this._depth++;
+ this.setAttribute('data-over', '');
+ } else if (e.type === 'dragleave') {
+ // dragenter/leave fire for every descendant crossing — count depth
+ // so hovering the icon inside the empty state doesn't flicker.
+ if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); }
+ } else if (e.type === 'drop') {
+ e.preventDefault();
+ e.stopPropagation();
+ this._depth = 0;
+ this.removeAttribute('data-over');
+ const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+ if (f) this._ingest(f);
+ }
+ }
+
+ async _ingest(file) {
+ this._setError(null);
+ if (!file || ACCEPT.indexOf(file.type) < 0) {
+ this._setError('Drop a PNG, JPEG, WebP, or AVIF image.');
+ return;
+ }
+ // toDataUrl can take hundreds of ms on a large photo. A Clear or a
+ // newer drop during that window would be clobbered when this await
+ // resumes — bump + capture a generation so stale encodes bail.
+ const gen = ++this._gen;
+ try {
+ const w = this.clientWidth || this.offsetWidth || MAX_DIM;
+ const url = await toDataUrl(file, w);
+ if (gen !== this._gen) return;
+ // Only exit reframe once the new image is in hand — a rejected type
+ // or decode failure leaves the in-progress crop untouched.
+ this._exitReframe(false);
+ const val = { u: url, s: 1, x: 0, y: 0 };
+ setSlot(this.id || '', val);
+ // Keep a session-local copy for id-less slots so the drop still
+ // shows, even though it cannot persist.
+ if (!this.id) { this._local = val; this._render(); }
+ } catch (err) {
+ if (gen !== this._gen) return;
+ this._setError('Could not read that image.');
+ console.warn(' ingest failed:', err);
+ }
+ }
+
+ _setError(msg) {
+ if (this._err) { this._err.remove(); this._err = null; }
+ if (!msg) return;
+ const d = document.createElement('div');
+ d.className = 'err'; d.textContent = msg;
+ this.shadowRoot.appendChild(d);
+ this._err = d;
+ setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000);
+ }
+
+ // Reframing (pan/resize) is only meaningful for fit=cover — contain/fill
+ // keep the old object-fit path and double-click is a no-op.
+ _reframes() {
+ return this.hasAttribute('data-filled') &&
+ (this.getAttribute('fit') || 'cover') === 'cover';
+ }
+
+ // Cover-baseline geometry, shared by clamp/apply/resize. Null until the
+ // img has loaded (naturalWidth is 0 before that) or when the slot has no
+ // layout box — ResizeObserver fires with a 0×0 rect under display:none,
+ // and clamping against a degenerate 1×1 frame would silently pull the
+ // stored pan toward zero.
+ _geom() {
+ const iw = this._img.naturalWidth, ih = this._img.naturalHeight;
+ const fw = this.clientWidth, fh = this.clientHeight;
+ if (!iw || !ih || !fw || !fh) return null;
+ return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) };
+ }
+
+ _clampView() {
+ // Pan range on each axis is half the overflow past the frame edge.
+ const g = this._geom();
+ if (!g) return;
+ const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50);
+ const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50);
+ this._view.x = Math.max(-mx, Math.min(mx, this._view.x));
+ this._view.y = Math.max(-my, Math.min(my, this._view.y));
+ }
+
+ _applyView() {
+ const g = this._geom();
+ const fit = this.getAttribute('fit') || 'cover';
+ if (fit !== 'cover' || !g) {
+ // Non-cover, or dimensions not known yet (before img load).
+ this._img.style.width = '100%';
+ this._img.style.height = '100%';
+ this._img.style.left = '50%';
+ this._img.style.top = '50%';
+ this._img.style.objectFit = fit;
+ this._img.style.objectPosition = this.getAttribute('position') || '50% 50%';
+ return;
+ }
+ // Cover baseline: img fills the frame on its tighter axis at s=1, so
+ // pan works immediately on the overflowing axis without zooming first.
+ // Width/height and left/top are all frame-% — depends only on the
+ // frame aspect ratio, so a responsive resize keeps the same crop. The
+ // spill layer mirrors the same box so its corners = image corners.
+ const k = g.base * this._view.s;
+ const w = (g.iw * k / g.fw * 100) + '%';
+ const h = (g.ih * k / g.fh * 100) + '%';
+ const l = (50 + this._view.x) + '%';
+ const t = (50 + this._view.y) + '%';
+ this._img.style.width = w; this._img.style.height = h;
+ this._img.style.left = l; this._img.style.top = t;
+ this._img.style.objectFit = '';
+ this._spill.style.width = w; this._spill.style.height = h;
+ this._spill.style.left = l; this._spill.style.top = t;
+ }
+
+ _commitView() {
+ const v = { s: this._view.s, x: this._view.x, y: this._view.y };
+ if (this._userUrl) v.u = this._userUrl;
+ // Framing-only (no u) persists too so an author-src slot remembers its
+ // crop; clearing the sidecar still falls through to src=.
+ if (this.id) setSlot(this.id, v);
+ else { this._local = v; }
+ }
+
+ _render() {
+ // Shape / mask. Presets use border-radius so the dashed ring can
+ // follow the rounded outline; clip-path is only applied for an
+ // explicit `mask` (the ring is hidden there since a rectangle
+ // dashed border chopped by an arbitrary polygon looks broken).
+ const mask = this.getAttribute('mask');
+ const shape = (this.getAttribute('shape') || 'rounded').toLowerCase();
+ let radius = '';
+ if (shape === 'circle') radius = '50%';
+ else if (shape === 'pill') radius = '9999px';
+ else if (shape === 'rounded') {
+ const n = parseFloat(this.getAttribute('radius'));
+ radius = (Number.isFinite(n) ? n : 12) + 'px';
+ }
+ this._frame.style.borderRadius = mask ? '' : radius;
+ this._frame.style.clipPath = mask || '';
+ this._ring.style.borderRadius = mask ? '' : radius;
+ this._ring.style.display = mask ? 'none' : '';
+
+ // Controls and reframe entry gate on this so share links stay read-only.
+ const editable = !!(window.omelette && window.omelette.writeFile);
+ this.toggleAttribute('data-editable', editable);
+ this._sub.style.display = editable ? '' : 'none';
+
+ // Content. The sidecar is also writable by the agent's write_file
+ // tool, so its value isn't guaranteed canvas-originated — only accept
+ // data:image/ URLs from it. The `src` attribute is author-controlled
+ // (Claude wrote it into the HTML) so it passes through unchanged.
+ let stored = this.id ? getSlot(this.id) : this._local;
+ if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null;
+ const srcAttr = this.getAttribute('src') || '';
+ this._userUrl = (stored && stored.u) || null;
+ const url = this._userUrl || srcAttr;
+ // Don't clobber an in-flight reframe with a store-triggered re-render.
+ if (!this.hasAttribute('data-reframe')) {
+ this._view = {
+ s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1,
+ x: stored && Number.isFinite(stored.x) ? stored.x : 0,
+ y: stored && Number.isFinite(stored.y) ? stored.y : 0,
+ };
+ }
+ this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image';
+ // Toggle via style.display — the [hidden] attribute alone loses to
+ // the display:flex / display:block rules in the stylesheet above.
+ if (url) {
+ if (this._img.getAttribute('src') !== url) {
+ this._img.src = url;
+ this._ghost.src = url;
+ }
+ this._img.style.display = 'block';
+ this._empty.style.display = 'none';
+ this.setAttribute('data-filled', '');
+ this._clampView();
+ this._applyView();
+ } else {
+ this._img.style.display = 'none';
+ this._img.removeAttribute('src');
+ this._ghost.removeAttribute('src');
+ this._empty.style.display = 'flex';
+ this.removeAttribute('data-filled');
+ }
+ }
+ }
+
+ if (!customElements.get('image-slot')) {
+ customElements.define('image-slot', ImageSlot);
+ }
+})();
diff --git a/advisory-board-post/index.html b/advisory-board-post/index.html
new file mode 100644
index 0000000..77cb9a9
--- /dev/null
+++ b/advisory-board-post/index.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ Board post · Fenja AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/advisory-board-post/screenshots/01-diag-overview.png b/advisory-board-post/screenshots/01-diag-overview.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/advisory-board-post/screenshots/01-diag-overview.png differ
diff --git a/advisory-board-post/screenshots/02-diag-overview.png b/advisory-board-post/screenshots/02-diag-overview.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/advisory-board-post/screenshots/02-diag-overview.png differ
diff --git a/advisory-board-post/screenshots/diag-a.png b/advisory-board-post/screenshots/diag-a.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/advisory-board-post/screenshots/diag-a.png differ
diff --git a/advisory-board-post/screenshots/diag-canvas.png b/advisory-board-post/screenshots/diag-canvas.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/advisory-board-post/screenshots/diag-canvas.png differ
diff --git a/advisory-board-post/uploads/fenja-logo-1000x1000.png b/advisory-board-post/uploads/fenja-logo-1000x1000.png
new file mode 100644
index 0000000..7982b9b
Binary files /dev/null and b/advisory-board-post/uploads/fenja-logo-1000x1000.png differ
diff --git a/assets/fenja-icon-black.svg b/assets/fenja-icon-black.svg
new file mode 100644
index 0000000..98c2247
--- /dev/null
+++ b/assets/fenja-icon-black.svg
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/assets/fenja-icon-white.svg b/assets/fenja-icon-white.svg
new file mode 100644
index 0000000..4878705
--- /dev/null
+++ b/assets/fenja-icon-white.svg
@@ -0,0 +1,20 @@
+
+
+
+
\ No newline at end of file
diff --git a/assets/fenja-logo-full.png b/assets/fenja-logo-full.png
new file mode 100644
index 0000000..7982b9b
Binary files /dev/null and b/assets/fenja-logo-full.png differ
diff --git a/assets/fenja-wordmark-black.svg b/assets/fenja-wordmark-black.svg
new file mode 100644
index 0000000..6bee4c1
--- /dev/null
+++ b/assets/fenja-wordmark-black.svg
@@ -0,0 +1,19 @@
+
+
+
+
\ No newline at end of file
diff --git a/assets/fenja-wordmark-white.svg b/assets/fenja-wordmark-white.svg
new file mode 100644
index 0000000..5bf9780
--- /dev/null
+++ b/assets/fenja-wordmark-white.svg
@@ -0,0 +1,23 @@
+
+
+
+
\ No newline at end of file
diff --git a/assets/reference-boulderer.png b/assets/reference-boulderer.png
new file mode 100644
index 0000000..78272dd
Binary files /dev/null and b/assets/reference-boulderer.png differ
diff --git a/assets/reference-flowerman-ochre.png b/assets/reference-flowerman-ochre.png
new file mode 100644
index 0000000..259dfc9
Binary files /dev/null and b/assets/reference-flowerman-ochre.png differ
diff --git a/assets/reference-flowerman-white.png b/assets/reference-flowerman-white.png
new file mode 100644
index 0000000..f633125
Binary files /dev/null and b/assets/reference-flowerman-white.png differ
diff --git a/assets/reference-waves.png b/assets/reference-waves.png
new file mode 100644
index 0000000..ce8bb8e
Binary files /dev/null and b/assets/reference-waves.png differ
diff --git a/board-posts.jsx b/board-posts.jsx
new file mode 100644
index 0000000..5bf50fc
--- /dev/null
+++ b/board-posts.jsx
@@ -0,0 +1,258 @@
+// board-posts.jsx — Four LinkedIn-ready layout variations for an 8-person
+// board reveal post. Each variation is a self-contained, sized artboard
+// rendered inside the design canvas so they can be compared side-by-side
+// and any one can be opened fullscreen.
+//
+// Shared image-slot ids ("member-1"..."member-8") mean once you drop a
+// portrait it appears in every variation. Edit the MEMBERS array below to
+// fill in real names and role-at-company lines.
+
+const MEMBERS = [
+ { id: 'member-1', name: '[ Full Name ]', title: 'Former CTO', company: '[ Company ]' },
+ { id: 'member-2', name: '[ Full Name ]', title: 'Professor', company: '[ Institution ]' },
+ { id: 'member-3', name: '[ Full Name ]', title: 'CEO', company: '[ Company ]' },
+ { id: 'member-4', name: '[ Full Name ]', title: 'Head of Research', company: '[ Institute ]' },
+ { id: 'member-5', name: '[ Full Name ]', title: 'Partner', company: '[ Firm ]' },
+ { id: 'member-6', name: '[ Full Name ]', title: 'CEO', company: '[ Company ]' },
+ { id: 'member-7', name: '[ Full Name ]', title: 'Chief Scientist', company: '[ Company ]' },
+ { id: 'member-8', name: '[ Full Name ]', title: 'Founder', company: '[ Company ]' },
+];
+
+// Reusable portrait + caption block. `size` is the portrait square edge.
+function Member({ m, size, captionAlign = 'left' }) {
+ return (
+
+
+
{m.name}
+
{m.title}
+
{m.company}
+
+ );
+}
+
+// Footer brand mark — used across variations
+function Mark({ light = false }) {
+ return (
+
+ We are honored to introduce the board of Fenja AI. Together, they bring
+ decades of experience in research, scholarship, and stewardship —
+ and the patience to do this work well.
+
+
+ "A study in stillness, and in counsel. We are grateful, every one of us, that they said yes."
+
+
+
+
+
+
+ fenja.ai
+
+
+
+
+
+
The board, in order of seating
+
+ {MEMBERS.map((m) => (
+
+ ))}
+
+
+
+ );
+}
+
+// ───────────────────────────────────────────────────────────────────────
+// Canvas
+// ───────────────────────────────────────────────────────────────────────
+function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/colors_and_type.css b/colors_and_type.css
new file mode 100644
index 0000000..547e18b
--- /dev/null
+++ b/colors_and_type.css
@@ -0,0 +1,346 @@
+/* =============================================================
+ Fenja AI — Nordic Editorial Design System
+ "The Digital Archivist"
+ ============================================================= */
+
+/* ---------- Fonts ------------------------------------------ */
+@font-face {
+ font-family: "Manrope";
+ font-weight: 200;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-ExtraLight.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 300;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Light.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Regular.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Medium.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 600;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-SemiBold.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-Bold.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Manrope";
+ font-weight: 800;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Manrope-ExtraBold.ttf") format("truetype");
+}
+
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Newsreader-Regular.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+ src: url("./fonts/Newsreader-Italic.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Newsreader-Bold.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 700;
+ font-style: italic;
+ font-display: swap;
+ src: url("./fonts/Newsreader-BoldItalic.ttf") format("truetype");
+}
+@font-face {
+ font-family: "Newsreader";
+ font-weight: 800;
+ font-style: normal;
+ font-display: swap;
+ src: url("./fonts/Newsreader-ExtraBold.ttf") format("truetype");
+}
+
+/* ---------- Tokens ----------------------------------------- */
+:root {
+ /* --- Core neutrals (unbleached paper, clay, slate) --- */
+ --background: #faf6ee; /* base canvas — warm paper */
+ --surface: #faf6ee;
+ --surface-container-lowest: #fffcf7; /* most-elevated — unbleached paper, never pure white */
+ --surface-container-low: #f6f2e8;
+ --surface-container: #efeadc;
+ --surface-container-high: #e7e1d0;
+ --surface-container-highest: #ddd6c3;
+ --surface-variant: #ddd6c3;
+
+ --on-surface: #383831; /* charcoal slate */
+ --on-surface-variant: #5f5e5e;
+ --on-surface-muted: #8a887f;
+
+ --primary: #5f5e5e;
+ --on-primary: #fffcf7;
+
+ --secondary: #785f53; /* hand-rubbed wood */
+ --secondary-dim: #6b5348;
+ --on-secondary: #ffffff;
+ --secondary-fixed-dim: #9a8679;
+
+ --outline: #babab0;
+ --outline-variant: #babab0; /* used at 15% for ghost borders */
+
+ /* --- Archival Pigment accent palette (flat, matte inks) --- */
+ --pigment-terracotta: #b96b58; /* warnings, critical */
+ --pigment-copper: #6d8c7c; /* success, growth */
+ --pigment-ochre: #c29d59; /* cautions, tertiary */
+ --pigment-indigo: #5a6d83; /* info, neutral data */
+ --pigment-heather: #8d7a85; /* categorical, supportive */
+
+ /* --- Semantic state mappings --- */
+ --color-success: var(--pigment-copper);
+ --color-warning: var(--pigment-ochre);
+ --color-danger: var(--pigment-terracotta);
+ --color-info: var(--pigment-indigo);
+
+ /* --- Type families --- */
+ --font-serif: "Newsreader", "Source Serif Pro", Georgia, "Times New Roman", serif;
+ --font-sans: "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+
+ /* --- Type scale (clamped for responsive) --- */
+ --text-display-xl: clamp(3.5rem, 6vw, 5.5rem); /* 56–88 */
+ --text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 48–72 */
+ --text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 40–56 */
+ --text-headline-lg: 2.25rem; /* 36 */
+ --text-headline-md: 1.75rem; /* 28 */
+ --text-headline-sm: 1.375rem; /* 22 */
+ --text-title-lg: 1.125rem; /* 18 */
+ --text-title-md: 1rem; /* 16 */
+ --text-body-lg: 1.0625rem; /* 17 */
+ --text-body-md: 1rem; /* 16 */
+ --text-body-sm: 0.875rem; /* 14 */
+ --text-label-md: 0.8125rem; /* 13 */
+ --text-label-sm: 0.75rem; /* 12 */
+
+ /* Letter-spacing */
+ --tracking-tight: -0.02em;
+ --tracking-snug: -0.01em;
+ --tracking-normal: 0;
+ --tracking-wide: 0.04em;
+ --tracking-wider: 0.08em;
+
+ /* Line-heights */
+ --leading-tight: 1.1;
+ --leading-snug: 1.25;
+ --leading-normal: 1.5;
+ --leading-relaxed: 1.6;
+ --leading-loose: 1.75;
+
+ /* --- Spacing scale (editorial, generous) --- */
+ --space-1: 0.25rem; /* 4 */
+ --space-2: 0.5rem; /* 8 */
+ --space-3: 0.75rem; /* 12 */
+ --space-4: 1rem; /* 16 */
+ --space-5: 1.5rem; /* 24 */
+ --space-6: 2rem; /* 32 — list separator default */
+ --space-7: 2.5rem; /* 40 */
+ --space-8: 2.75rem; /* 44 — hero-card padding */
+ --space-10: 4rem; /* 64 */
+ --space-12: 5rem; /* 80 */
+ --space-16: 6rem; /* 96 */
+ --space-20: 7rem; /* 112 — desktop lateral margin */
+ --space-24: 8rem; /* 128 */
+
+ /* --- Radii --- */
+ --radius-none: 0;
+ --radius-sm: 0.375rem; /* 6 */
+ --radius-md: 0.75rem; /* 12 — primary */
+ --radius-lg: 1.25rem; /* 20 */
+ --radius-full: 9999px;
+
+ /* --- Elevation (atmospheric, warm) --- */
+ --shadow-none: none;
+ --shadow-ambient: 0 12px 32px -12px rgba(56, 56, 49, 0.06);
+ --shadow-float: 0 24px 48px -16px rgba(56, 56, 49, 0.05), 0 4px 12px -4px rgba(56, 56, 49, 0.04);
+ --shadow-modal: 0 40px 64px -24px rgba(56, 56, 49, 0.08), 0 8px 16px -6px rgba(56, 56, 49, 0.04);
+
+ /* --- Ghost border (WCAG fallback only) --- */
+ --ghost-border-color: rgba(186, 186, 176, 0.15);
+ --ghost-border: 1px solid var(--ghost-border-color);
+
+ /* --- Glass --- */
+ --glass-blur: blur(16px);
+ --glass-surface: rgba(255, 252, 247, 0.8);
+
+ /* --- Motion --- */
+ --ease-standard: cubic-bezier(0.2, 0.0, 0, 1);
+ --ease-entrance: cubic-bezier(0, 0, 0, 1);
+ --ease-exit: cubic-bezier(0.3, 0, 1, 1);
+ --duration-fast: 140ms;
+ --duration-med: 240ms;
+ --duration-slow: 420ms;
+
+ /* --- Layout --- */
+ --content-max: 72rem; /* 1152 */
+ --reading-max: 42rem; /* 672 */
+}
+
+/* ---------- Base semantic styles --------------------------- */
+html {
+ font-family: var(--font-sans);
+ color: var(--on-surface);
+ background: var(--background);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+body {
+ margin: 0;
+ font-size: var(--text-body-md);
+ line-height: var(--leading-relaxed);
+ color: var(--on-surface);
+ background: var(--background);
+}
+
+/* Display — serif, tight, left-aligned editorial intent */
+.display-xl,
+.display-lg,
+.display-md {
+ font-family: var(--font-serif);
+ font-weight: 400;
+ letter-spacing: var(--tracking-tight);
+ line-height: var(--leading-tight);
+ color: var(--on-surface);
+ margin: 0 0 var(--space-5) 0;
+}
+.display-xl { font-size: var(--text-display-xl); }
+.display-lg { font-size: var(--text-display-lg); }
+.display-md { font-size: var(--text-display-md); }
+
+/* Headlines — serif, authoritative */
+h1, .headline-lg,
+h2, .headline-md,
+h3, .headline-sm {
+ font-family: var(--font-serif);
+ font-weight: 400;
+ color: var(--on-surface);
+ letter-spacing: var(--tracking-snug);
+ line-height: var(--leading-snug);
+ margin: 0 0 var(--space-4) 0;
+}
+h1, .headline-lg { font-size: var(--text-headline-lg); }
+h2, .headline-md { font-size: var(--text-headline-md); }
+h3, .headline-sm { font-size: var(--text-headline-sm); }
+
+/* Titles — sans, precise structural labels */
+h4, .title-lg,
+h5, .title-md {
+ font-family: var(--font-sans);
+ font-weight: 600;
+ color: var(--on-surface);
+ letter-spacing: var(--tracking-normal);
+ line-height: var(--leading-snug);
+ margin: 0 0 var(--space-3) 0;
+}
+h4, .title-lg { font-size: var(--text-title-lg); }
+h5, .title-md { font-size: var(--text-title-md); }
+
+/* Body */
+p, .body-md {
+ font-family: var(--font-sans);
+ font-weight: 400;
+ font-size: var(--text-body-md);
+ line-height: var(--leading-relaxed);
+ color: var(--on-surface);
+ margin: 0 0 var(--space-4) 0;
+ text-wrap: pretty;
+}
+.body-lg {
+ font-size: var(--text-body-lg);
+ line-height: var(--leading-relaxed);
+}
+.body-sm {
+ font-size: var(--text-body-sm);
+ line-height: var(--leading-normal);
+ color: var(--on-surface-variant);
+}
+
+/* Labels — muted, small caps optional */
+.label-md,
+.label-sm {
+ font-family: var(--font-sans);
+ font-weight: 500;
+ color: var(--on-surface-variant);
+ letter-spacing: var(--tracking-wider);
+ text-transform: uppercase;
+}
+.label-md { font-size: var(--text-label-md); }
+.label-sm { font-size: var(--text-label-sm); }
+
+/* Editorial lead — serif italic, subtle */
+.lead {
+ font-family: var(--font-serif);
+ font-style: italic;
+ font-size: var(--text-body-lg);
+ color: var(--on-surface-variant);
+ line-height: var(--leading-relaxed);
+}
+
+/* Inline code / mono */
+code, kbd, samp, pre, .mono {
+ font-family: var(--font-mono);
+ font-size: 0.92em;
+ color: var(--on-surface);
+}
+
+/* Links — editorial, no underline until hover */
+a {
+ color: var(--secondary);
+ text-decoration: none;
+ border-bottom: 1px solid rgba(120, 95, 83, 0.3);
+ transition: border-color var(--duration-fast) var(--ease-standard),
+ color var(--duration-fast) var(--ease-standard);
+}
+a:hover {
+ color: var(--secondary-dim);
+ border-bottom-color: currentColor;
+}
+
+/* Selection — warm, not blue */
+::selection {
+ background: rgba(120, 95, 83, 0.18);
+ color: var(--on-surface);
+}
+
+/* Utility: ghost border fallback */
+.ghost-border { border: var(--ghost-border); }
+.ghost-border-bottom { border-bottom: var(--ghost-border); }
diff --git a/design-canvas.jsx b/design-canvas.jsx
new file mode 100644
index 0000000..fa1f93e
--- /dev/null
+++ b/design-canvas.jsx
@@ -0,0 +1,966 @@
+
+// DesignCanvas.jsx — Figma-ish design canvas wrapper
+// Warm gray grid bg + Sections + Artboards + PostIt notes.
+// Artboards are reorderable (grip-drag), deletable, labels/titles are
+// inline-editable, and any artboard can be opened in a fullscreen focus
+// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
+// via the host bridge. No assets, no deps.
+//
+// Usage:
+//
+//
+// …
+// …
+//
+//
+
+const DC = {
+ bg: '#f0eee9',
+ grid: 'rgba(0,0,0,0.06)',
+ label: 'rgba(60,50,40,0.7)',
+ title: 'rgba(40,30,20,0.85)',
+ subtitle: 'rgba(60,50,40,0.6)',
+ postitBg: '#fef4a8',
+ postitText: '#5a4a2a',
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
+};
+
+// One-time CSS injection (classes are dc-prefixed so they don't collide with
+// the hosted design's own styles).
+if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
+ const s = document.createElement('style');
+ s.id = 'dc-styles';
+ s.textContent = [
+ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
+ '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
+ '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
+ '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
+ '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
+ // isolation:isolate contains artboard content's z-indexes so a
+ // z-indexed child (sticky navbar etc.) can't paint over .dc-header or
+ // the .dc-menu popover that drops into the top of the card.
+ '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
+ '.dc-card *{scrollbar-width:none}',
+ '.dc-card *::-webkit-scrollbar{display:none}',
+ // Per-artboard header: grip + label on the left, delete/expand on the
+ // right. Single flex row; when the artboard's on-screen width is too
+ // narrow for both the label yields (ellipsis, then hidden entirely below
+ // ~4ch via the container query) and the buttons stay on the row.
+ '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
+ ' display:flex;align-items:center;container-type:inline-size}',
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
+ '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
+ '.dc-grip:active{cursor:grabbing}',
+ '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
+ ' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
+ // Below ~4ch of label room: hide the label entirely, and drop the grip to
+ // hover-only (same reveal rule as .dc-btns) so a narrow header is clean
+ // until the card is moused.
+ '@container (max-width: 110px){',
+ ' .dc-labeltext{display:none}',
+ ' .dc-grip{opacity:0}',
+ ' [data-dc-slot]:hover .dc-grip{opacity:1}',
+ '}',
+ '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
+ '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
+ '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
+ '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
+ '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
+ '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
+ ' font:inherit;transition:background .12s,color .12s}',
+ '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
+ // Slot hosting an open menu floats above later siblings (which otherwise
+ // paint on top — same z-index:auto, later DOM order) so the popup isn't
+ // clipped by the next card.
+ '[data-dc-slot]:has(.dc-menu){z-index:10}',
+ '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
+ ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
+ '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
+ ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
+ ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
+ '.dc-menu button:hover{background:rgba(0,0,0,.05)}',
+ '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
+ '.dc-menu .dc-danger{color:#c96442}',
+ '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
+ // Chrome (titles / labels / buttons) counter-scales against the viewport
+ // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
+ // DCViewport on every transform update and inherits to all descendants —
+ // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
+ // it the same way.
+ //
+ // The header uses transform:scale (out-of-flow, so layout impact doesn't
+ // matter) with its world-space width set to card-width / inv-zoom so that
+ // after counter-scaling its on-screen width exactly matches the card's —
+ // that's what lets the container query + text-overflow behave against the
+ // card's visible edge at every zoom level.
+ //
+ // The section head uses CSS zoom instead of transform so its layout box
+ // grows with the counter-scale, pushing the card row down — otherwise the
+ // constant-screen-size title would overflow into the (shrinking) world-
+ // space gap and overlap the artboard headers at low zoom.
+ '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
+ ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
+ '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
+ ].join('\n');
+ document.head.appendChild(s);
+}
+
+const DCCtx = React.createContext(null);
+
+// Recursively unwrap React.Fragment so <>…> grouping doesn't hide
+// DCSection/DCArtboard children from the type-based walks below.
+function dcFlatten(children) {
+ const out = [];
+ React.Children.forEach(children, (c) => {
+ if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children));
+ else out.push(c);
+ });
+ return out;
+}
+
+// ─────────────────────────────────────────────────────────────
+// DesignCanvas — stateful wrapper around the pan/zoom viewport.
+// Owns runtime state (per-section order, renamed titles/labels, hidden
+// artboards, focused artboard). Order/titles/labels/hidden persist to a
+// .design-canvas.state.json
+// sidecar next to the HTML. Reads go via plain fetch() so the saved
+// arrangement is visible anywhere the HTML + sidecar are served together
+// (omelette preview, direct link, downloaded zip). Writes go through the
+// host's window.omelette bridge — editing requires the omelette runtime.
+// Focus is ephemeral.
+// ─────────────────────────────────────────────────────────────
+const DC_STATE_FILE = '.design-canvas.state.json';
+
+function DesignCanvas({ children, minScale, maxScale, style }) {
+ const [state, setState] = React.useState({ sections: {}, focus: null });
+ // Hold rendering until the sidecar read settles so the saved order/titles
+ // appear on first paint (no source-order flash). didRead gates writes until
+ // the read settles so the empty initial state can't clobber a slow read;
+ // skipNextWrite suppresses the one echo-write that would otherwise follow
+ // hydration.
+ const [ready, setReady] = React.useState(false);
+ const didRead = React.useRef(false);
+ const skipNextWrite = React.useRef(false);
+
+ React.useEffect(() => {
+ let off = false;
+ fetch('./' + DC_STATE_FILE)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((saved) => {
+ if (off || !saved || !saved.sections) return;
+ skipNextWrite.current = true;
+ setState((s) => ({ ...s, sections: saved.sections }));
+ })
+ .catch(() => {})
+ .finally(() => { didRead.current = true; if (!off) setReady(true); });
+ const t = setTimeout(() => { if (!off) setReady(true); }, 150);
+ return () => { off = true; clearTimeout(t); };
+ }, []);
+
+ React.useEffect(() => {
+ if (!didRead.current) return;
+ if (skipNextWrite.current) { skipNextWrite.current = false; return; }
+ const t = setTimeout(() => {
+ window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
+ }, 250);
+ return () => clearTimeout(t);
+ }, [state.sections]);
+
+ // Build registries synchronously from children so FocusOverlay can read
+ // them in the same render. Fragments are flattened; wrapping in other
+ // elements still opts out of focus/reorder.
+ const registry = {}; // slotId -> { sectionId, artboard }
+ const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
+ const sectionOrder = [];
+ dcFlatten(children).forEach((sec) => {
+ if (!sec || sec.type !== DCSection) return;
+ const sid = sec.props.id ?? sec.props.title;
+ if (!sid) return;
+ sectionOrder.push(sid);
+ const persisted = state.sections[sid] || {};
+ const abs = [];
+ dcFlatten(sec.props.children).forEach((ab) => {
+ if (!ab || ab.type !== DCArtboard) return;
+ const aid = ab.props.id ?? ab.props.label;
+ if (aid) abs.push([aid, ab]);
+ });
+ // hidden is scoped to one source revision — when the agent regenerates
+ // (artboard-ID set changes), prior deletes don't apply to new content.
+ const srcKey = abs.map(([k]) => k).join('\x1f');
+ const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
+ const srcIds = [];
+ abs.forEach(([aid, ab]) => {
+ if (hidden.includes(aid)) return;
+ registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
+ srcIds.push(aid);
+ });
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
+ sectionMeta[sid] = {
+ title: persisted.title ?? sec.props.title,
+ subtitle: sec.props.subtitle,
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
+ };
+ });
+
+ const api = React.useMemo(() => ({
+ state,
+ section: (id) => state.sections[id] || {},
+ patchSection: (id, p) => setState((s) => ({
+ ...s,
+ sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
+ })),
+ setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
+ }), [state]);
+
+ // Esc exits focus; any outside pointerdown commits an in-progress rename.
+ React.useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
+ const onPd = (e) => {
+ const ae = document.activeElement;
+ if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
+ };
+ document.addEventListener('keydown', onKey);
+ document.addEventListener('pointerdown', onPd, true);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('pointerdown', onPd, true);
+ };
+ }, [api]);
+
+ return (
+
+ {ready && children}
+ {state.focus && registry[state.focus] && (
+
+ )}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// DCViewport — transform-based pan/zoom (internal)
+//
+// Input mapping (Figma-style):
+// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
+// • trackpad scroll → pan (two-finger)
+// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
+// • middle-drag / primary-drag-on-bg → pan
+//
+// Transform state lives in a ref and is written straight to the DOM
+// (translate3d + will-change) so wheel ticks don't go through React —
+// keeps pans at 60fps on dense canvases.
+// ─────────────────────────────────────────────────────────────
+function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
+ const vpRef = React.useRef(null);
+ const worldRef = React.useRef(null);
+ const tf = React.useRef({ x: 0, y: 0, scale: 1 });
+ // Persist viewport across reloads so the user lands back where they were
+ // after an agent edit or browser refresh. The sandbox origin is already
+ // per-project; pathname keeps multiple canvas files in one project apart.
+ const tfKey = 'dc-viewport:' + location.pathname;
+ const saveT = React.useRef(0);
+
+ const lastPostedScale = React.useRef();
+ const apply = React.useCallback(() => {
+ const { x, y, scale } = tf.current;
+ const el = worldRef.current;
+ if (!el) return;
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
+ // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
+ el.style.setProperty('--dc-inv-zoom', String(1 / scale));
+ // Keep the host toolbar's % readout in sync with the canvas scale. Pan
+ // ticks leave scale unchanged — skip the cross-frame post for those.
+ if (lastPostedScale.current !== scale) {
+ lastPostedScale.current = scale;
+ window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
+ }
+ clearTimeout(saveT.current);
+ saveT.current = setTimeout(() => {
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
+ }, 200);
+ }, [tfKey]);
+
+ React.useLayoutEffect(() => {
+ const flush = () => {
+ clearTimeout(saveT.current);
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
+ };
+ try {
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
+ tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
+ apply();
+ }
+ } catch {}
+ // Flush on pagehide and unmount so a reload within the 200ms debounce
+ // window doesn't drop the last pan/zoom.
+ window.addEventListener('pagehide', flush);
+ return () => { window.removeEventListener('pagehide', flush); flush(); };
+ }, []);
+
+ React.useEffect(() => {
+ const vp = vpRef.current;
+ if (!vp) return;
+
+ const zoomAt = (cx, cy, factor) => {
+ const r = vp.getBoundingClientRect();
+ const px = cx - r.left, py = cy - r.top;
+ const t = tf.current;
+ const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
+ const k = next / t.scale;
+ // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
+ // marginBottom) reflow on every scale change, vertically shifting the
+ // world layout — so a world point mathematically pinned under the cursor
+ // drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
+ // Anchor the DOM element under the cursor instead: record its screen Y,
+ // apply the transform + --dc-inv-zoom, then cancel whatever vertical
+ // drift the reflow introduced so it stays put on screen.
+ let marker = null, markerY0 = 0;
+ if (k !== 1) {
+ const hit = document.elementFromPoint(cx, cy);
+ marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null;
+ if (marker) markerY0 = marker.getBoundingClientRect().top;
+ }
+ // keep the world point under the cursor fixed
+ t.x = px - (px - t.x) * k;
+ t.y = py - (py - t.y) * k;
+ t.scale = next;
+ apply();
+ if (marker) {
+ // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any
+ // departure after the --dc-inv-zoom reflow is the layout drift.
+ const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);
+ if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); }
+ }
+ };
+
+ // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
+ // line-mode deltas (Firefox) or large integer pixel deltas with no X
+ // component (Chrome/Safari, typically multiples of 100/120). Trackpad
+ // two-finger scroll sends small/fractional pixel deltas, often with
+ // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
+ const isMouseWheel = (e) =>
+ e.deltaMode !== 0 ||
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
+
+ const onWheel = (e) => {
+ e.preventDefault();
+ if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
+ if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
+ // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
+ // wheels fall through to the fixed-step branch below.
+ zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
+ } else if (isMouseWheel(e)) {
+ // notched mouse wheel — fixed-ratio step per click
+ zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
+ } else {
+ // trackpad two-finger scroll — pan
+ tf.current.x -= e.deltaX;
+ tf.current.y -= e.deltaY;
+ apply();
+ }
+ };
+
+ // Safari sends native gesture* events for trackpad pinch with a smooth
+ // e.scale; preferring these over the ctrl+wheel fallback gives a much
+ // better feel there. No-ops on other browsers. Safari also fires
+ // ctrlKey wheel events during the same pinch — isGesturing makes
+ // onWheel drop those entirely so they neither zoom nor pan.
+ let gsBase = 1;
+ let isGesturing = false;
+ const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
+ const onGestureChange = (e) => {
+ e.preventDefault();
+ zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
+ };
+ const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
+
+ // Drag-pan: middle button anywhere, or primary button on canvas
+ // background (anything that isn't an artboard or an inline editor).
+ let drag = null;
+ const onPointerDown = (e) => {
+ const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
+ e.preventDefault();
+ vp.setPointerCapture(e.pointerId);
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
+ vp.style.cursor = 'grabbing';
+ };
+ const onPointerMove = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ tf.current.x += e.clientX - drag.lx;
+ tf.current.y += e.clientY - drag.ly;
+ drag.lx = e.clientX; drag.ly = e.clientY;
+ apply();
+ };
+ const onPointerUp = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ vp.releasePointerCapture(e.pointerId);
+ drag = null;
+ vp.style.cursor = '';
+ };
+
+ // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
+ // visible midpoint stays fixed — matching the host's iframe-zoom feel.
+ const onHostMsg = (e) => {
+ const d = e.data;
+ if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
+ const r = vp.getBoundingClientRect();
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
+ } else if (d && d.type === '__dc_probe') {
+ // Host's [readyGen] reset asks whether a canvas is present; it
+ // fires on the iframe's native 'load', which for canvases with
+ // images/fonts is after our mount-time announce, so re-announce.
+ // Clear the pan-tick guard so apply() re-posts the current scale
+ // even if it's unchanged — the host just reset dcScale to 1.
+ window.parent.postMessage({ type: '__dc_present' }, '*');
+ lastPostedScale.current = undefined;
+ apply();
+ }
+ };
+ window.addEventListener('message', onHostMsg);
+ // Announce canvas mode so the host toolbar proxies its % control here
+ // instead of scaling the iframe element (which would just shrink the
+ // viewport window of an infinite canvas). The apply() that follows emits
+ // the initial __dc_zoom so the toolbar % is correct before first pinch.
+ // lastPostedScale reset mirrors the __dc_probe handler: the layout
+ // effect's restore-path apply() may already have posted the restored
+ // scale (before __dc_present), so clear the guard to re-post it in order.
+ window.parent.postMessage({ type: '__dc_present' }, '*');
+ lastPostedScale.current = undefined;
+ apply();
+
+ vp.addEventListener('wheel', onWheel, { passive: false });
+ vp.addEventListener('gesturestart', onGestureStart, { passive: false });
+ vp.addEventListener('gesturechange', onGestureChange, { passive: false });
+ vp.addEventListener('gestureend', onGestureEnd, { passive: false });
+ vp.addEventListener('pointerdown', onPointerDown);
+ vp.addEventListener('pointermove', onPointerMove);
+ vp.addEventListener('pointerup', onPointerUp);
+ vp.addEventListener('pointercancel', onPointerUp);
+ return () => {
+ window.removeEventListener('message', onHostMsg);
+ vp.removeEventListener('wheel', onWheel);
+ vp.removeEventListener('gesturestart', onGestureStart);
+ vp.removeEventListener('gesturechange', onGestureChange);
+ vp.removeEventListener('gestureend', onGestureEnd);
+ vp.removeEventListener('pointerdown', onPointerDown);
+ vp.removeEventListener('pointermove', onPointerMove);
+ vp.removeEventListener('pointerup', onPointerUp);
+ vp.removeEventListener('pointercancel', onPointerUp);
+ };
+ }, [apply, minScale, maxScale]);
+
+ const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// DCSection — editable title + h-row of artboards in persisted order
+// ─────────────────────────────────────────────────────────────
+function DCSection({ id, title, subtitle, children, gap = 48 }) {
+ const ctx = React.useContext(DCCtx);
+ const sid = id ?? title;
+ const all = React.Children.toArray(dcFlatten(children));
+ const artboards = all.filter((c) => c && c.type === DCArtboard);
+ const rest = all.filter((c) => !(c && c.type === DCArtboard));
+ const sec = (ctx && sid && ctx.section(sid)) || {};
+ // Must match DesignCanvas's srcKey computation exactly (it filters falsy
+ // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
+ const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
+ const srcKey = allIds.join('\x1f');
+ const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
+ const srcOrder = allIds.filter((k) => !hidden.includes(k));
+
+ const order = React.useMemo(() => {
+ const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
+ return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
+ }, [sec.order, srcOrder.join('|')]);
+
+ const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
+
+ // marginBottom counter-scales so the on-screen gap between sections stays
+ // constant — otherwise at low zoom the (world-space) gap collapses while
+ // the screen-constant sectionhead below it doesn't, and the title reads as
+ // belonging to the section above. paddingBottom below is just enough for
+ // the 24px artboard-header (abs-positioned above each card) plus ~8px, so
+ // the title sits tight against its own row at every zoom.
+ return (
+
+
+ {/* card centered, label + index below — only the card itself stops
+ propagation so any backdrop click (including the margins around
+ the card) exits focus */}
+
+ >
+ );
+}
diff --git a/fonts/Manrope-Bold.ttf b/fonts/Manrope-Bold.ttf
new file mode 100644
index 0000000..62a6183
Binary files /dev/null and b/fonts/Manrope-Bold.ttf differ
diff --git a/fonts/Manrope-ExtraBold.ttf b/fonts/Manrope-ExtraBold.ttf
new file mode 100644
index 0000000..2fa671c
Binary files /dev/null and b/fonts/Manrope-ExtraBold.ttf differ
diff --git a/fonts/Manrope-ExtraLight.ttf b/fonts/Manrope-ExtraLight.ttf
new file mode 100644
index 0000000..c55745a
Binary files /dev/null and b/fonts/Manrope-ExtraLight.ttf differ
diff --git a/fonts/Manrope-Light.ttf b/fonts/Manrope-Light.ttf
new file mode 100644
index 0000000..8a771c2
Binary files /dev/null and b/fonts/Manrope-Light.ttf differ
diff --git a/fonts/Manrope-Medium.ttf b/fonts/Manrope-Medium.ttf
new file mode 100644
index 0000000..c6d28de
Binary files /dev/null and b/fonts/Manrope-Medium.ttf differ
diff --git a/fonts/Manrope-Regular.ttf b/fonts/Manrope-Regular.ttf
new file mode 100644
index 0000000..9a108f1
Binary files /dev/null and b/fonts/Manrope-Regular.ttf differ
diff --git a/fonts/Manrope-SemiBold.ttf b/fonts/Manrope-SemiBold.ttf
new file mode 100644
index 0000000..46a13d6
Binary files /dev/null and b/fonts/Manrope-SemiBold.ttf differ
diff --git a/fonts/Newsreader-Bold.ttf b/fonts/Newsreader-Bold.ttf
new file mode 100644
index 0000000..6d9e20a
Binary files /dev/null and b/fonts/Newsreader-Bold.ttf differ
diff --git a/fonts/Newsreader-BoldItalic.ttf b/fonts/Newsreader-BoldItalic.ttf
new file mode 100644
index 0000000..bc57925
Binary files /dev/null and b/fonts/Newsreader-BoldItalic.ttf differ
diff --git a/fonts/Newsreader-ExtraBold.ttf b/fonts/Newsreader-ExtraBold.ttf
new file mode 100644
index 0000000..69a726d
Binary files /dev/null and b/fonts/Newsreader-ExtraBold.ttf differ
diff --git a/fonts/Newsreader-Italic.ttf b/fonts/Newsreader-Italic.ttf
new file mode 100644
index 0000000..477facd
Binary files /dev/null and b/fonts/Newsreader-Italic.ttf differ
diff --git a/fonts/Newsreader-Regular.ttf b/fonts/Newsreader-Regular.ttf
new file mode 100644
index 0000000..9fe7694
Binary files /dev/null and b/fonts/Newsreader-Regular.ttf differ
diff --git a/image-slot.js b/image-slot.js
new file mode 100644
index 0000000..d1eb01b
--- /dev/null
+++ b/image-slot.js
@@ -0,0 +1,641 @@
+/**
+ * — user-fillable image placeholder.
+ *
+ * Drop this into a deck, mockup, or page wherever you want the user to
+ * supply an image. You control the slot's shape and size; the user fills it
+ * by dragging an image file onto it (or clicking to browse). The dropped
+ * image persists across reloads via a .image-slots.state.json sidecar —
+ * same read-via-fetch / write-via-window.omelette pattern as
+ * design_canvas.jsx, so the filled slot shows on share links, downloaded
+ * zips, and PPTX export. Outside the omelette runtime the slot is read-only.
+ *
+ * The host bridge only allows sidecar writes at the project root, so the
+ * HTML that uses this component is assumed to live at the project root too
+ * (same constraint as design_canvas.jsx).
+ *
+ * Attributes:
+ * id Persistence key. REQUIRED for the drop to survive reload —
+ * every slot on the page needs a distinct id.
+ * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded')
+ * 'circle' applies 50% border-radius; on a non-square slot
+ * that's an ellipse — set equal width and height for a true
+ * circle.
+ * radius Corner radius in px for 'rounded'. (default 12)
+ * mask Any CSS clip-path value. Overrides `shape` — use this for
+ * hexagons, blobs, arbitrary polygons.
+ * fit object-fit: cover | contain | fill. (default 'cover')
+ * With cover (the default) double-clicking the filled slot
+ * enters a reframe mode: the whole image spills past the mask
+ * (translucent outside, opaque inside), drag to reposition,
+ * corner-drag to scale. The crop persists alongside the image
+ * in the sidecar. contain/fill stay static.
+ * position object-position for fit=contain|fill. (default '50% 50%')
+ * placeholder Empty-state caption. (default 'Drop an image')
+ * src Optional initial/fallback image URL. A user drop overrides
+ * it; clearing the drop reveals src again.
+ *
+ * Size and layout come from ordinary CSS on the element — width/height
+ * inline or from a parent grid — so it composes with any layout.
+ *
+ * Usage:
+ *
+ *
+ *
+ *
+ */
+
+(() => {
+ const STATE_FILE = '.image-slots.state.json';
+ // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the
+ // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB.
+ const MAX_DIM = 1200;
+ // Raster formats only. SVG is excluded (can carry script; createImageBitmap
+ // on SVG blobs is inconsistent). GIF is excluded because the canvas
+ // re-encode keeps only the first frame, so an animated GIF would silently
+ // go still — better to reject than surprise.
+ const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
+
+ // ── Shared sidecar store ────────────────────────────────────────────────
+ // One fetch + immediate write-on-change for every on the
+ // page. Reads via fetch() so viewing works anywhere the HTML and sidecar
+ // are served together; writes go through window.omelette.writeFile, which
+ // the host allowlists to *.state.json basenames only.
+ const subs = new Set();
+ let slots = {};
+ // ids explicitly cleared before the sidecar fetch resolved — otherwise
+ // the merge below can't tell "never set" from "just deleted" and would
+ // resurrect the sidecar's stale value.
+ const tombstones = new Set();
+ let loaded = false;
+ let loadP = null;
+
+ function load() {
+ if (loadP) return loadP;
+ loadP = fetch(STATE_FILE)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((j) => {
+ // Merge: sidecar loses to any in-memory change that raced ahead of
+ // the fetch (drop or clear) so neither is clobbered by hydration.
+ if (j && typeof j === 'object') {
+ const merged = Object.assign({}, j, slots);
+ // A framing-only write that raced ahead of hydration must not
+ // drop a user image that's only on disk — inherit u from the
+ // sidecar for any in-memory entry that lacks one.
+ for (const k in slots) {
+ if (merged[k] && !merged[k].u && j[k]) {
+ merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u;
+ }
+ }
+ for (const id of tombstones) delete merged[id];
+ slots = merged;
+ }
+ tombstones.clear();
+ })
+ .catch(() => {})
+ .then(() => { loaded = true; subs.forEach((fn) => fn()); });
+ return loadP;
+ }
+
+ // Serialize writes so two near-simultaneous drops on different slots
+ // can't reorder at the backend and leave the sidecar with only the
+ // first. A save requested mid-flight just marks dirty and re-fires on
+ // completion with the then-current slots.
+ let saving = false;
+ let saveDirty = false;
+ function save() {
+ if (saving) { saveDirty = true; return; }
+ const w = window.omelette && window.omelette.writeFile;
+ if (!w) return;
+ saving = true;
+ Promise.resolve(w(STATE_FILE, JSON.stringify(slots)))
+ .catch(() => {})
+ .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } });
+ }
+
+ const S_MAX = 5;
+ const clampS = (s) => Math.max(1, Math.min(S_MAX, s));
+
+ // Normalize a stored slot value. Pre-reframe sidecars stored a bare
+ // data-URL string; newer ones store {u, s, x, y}. Either shape is valid.
+ function getSlot(id) {
+ const v = slots[id];
+ if (!v) return null;
+ return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v;
+ }
+
+ function setSlot(id, val) {
+ if (!id) return;
+ if (val) { slots[id] = val; tombstones.delete(id); }
+ else { delete slots[id]; if (!loaded) tombstones.add(id); }
+ subs.forEach((fn) => fn());
+ // A drop is rare + high-value — write immediately so nav-away can't lose
+ // it. Gate on the initial read so we don't overwrite a sidecar we haven't
+ // merged yet; the merge in load() keeps this change once the read lands.
+ if (loaded) save(); else load().then(save);
+ }
+
+ // ── Image downscale ─────────────────────────────────────────────────────
+ // Encode through a canvas so the sidecar carries resized bytes, not the
+ // raw upload. Longest side is capped at 2× the slot's rendered width
+ // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG
+ // for photos, so there's no need for per-image format picking.
+ async function toDataUrl(file, targetW) {
+ const bitmap = await createImageBitmap(file);
+ try {
+ const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM);
+ const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height));
+ const w = Math.max(1, Math.round(bitmap.width * scale));
+ const h = Math.max(1, Math.round(bitmap.height * scale));
+ const canvas = document.createElement('canvas');
+ canvas.width = w; canvas.height = h;
+ canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
+ return canvas.toDataURL('image/webp', 0.85);
+ } finally {
+ bitmap.close && bitmap.close();
+ }
+ }
+
+ // ── Custom element ──────────────────────────────────────────────────────
+ const stylesheet =
+ ':host{display:inline-block;position:relative;vertical-align:top;' +
+ ' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' +
+ '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' +
+ // .frame img (clipped) and .spill (unclipped ghost + handles) share the
+ // same left/top/width/height in frame-%, computed by _applyView(), so the
+ // inside-mask crop and the outside-mask spill stay pixel-aligned.
+ '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' +
+ ' -webkit-user-drag:none;user-select:none;touch-action:none}' +
+ // Reframe mode (double-click): the full image spills past the mask. The
+ // spill layer is sized to the IMAGE bounds so its corners are where the
+ // resize handles belong. The ghost inside is translucent; the real
+ // clipped underneath shows the opaque in-mask crop.
+ '.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' +
+ ' cursor:grab;touch-action:none}' +
+ ':host([data-panning]) .spill{cursor:grabbing}' +
+ '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' +
+ ' pointer-events:none;-webkit-user-drag:none;user-select:none;' +
+ ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' +
+ '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' +
+ ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' +
+ ' transform:translate(-50%,-50%)}' +
+ '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' +
+ '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' +
+ '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' +
+ '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' +
+ ':host([data-reframe]){z-index:10}' +
+ ':host([data-reframe]) .spill{display:block}' +
+ ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' +
+ '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
+ ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
+ ' cursor:pointer;user-select:none}' +
+ '.empty svg{opacity:.45}' +
+ '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' +
+ '.empty .sub{font-size:11px}' +
+ '.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' +
+ '.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' +
+ ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' +
+ ' background:rgba(201,100,66,.10)}' +
+ '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' +
+ ' transition:border-color .12s}' +
+ ':host([data-over]) .ring{border-color:#c96442}' +
+ ':host([data-filled]) .ring{display:none}' +
+ // Controls sit BELOW the mask (top:100%), absolutely positioned so the
+ // author-declared slot height is unaffected. The gap is padding, not a
+ // top offset, so the hover target stays contiguous with the frame.
+ '.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' +
+ ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
+ ' white-space:nowrap}' +
+ ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' +
+ ' {opacity:1;pointer-events:auto}' +
+ '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' +
+ ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' +
+ ' backdrop-filter:blur(6px)}' +
+ '.ctl button:hover{background:rgba(0,0,0,.8)}' +
+ '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' +
+ ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}';
+
+ const icon =
+ '';
+
+ class ImageSlot extends HTMLElement {
+ static get observedAttributes() {
+ return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id'];
+ }
+
+ constructor() {
+ super();
+ const root = this.attachShadow({ mode: 'open' });
+ // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
+ // on the frame (circle, pill, rounded) can't clip them.
+ root.innerHTML =
+ '' +
+ '
' +
+ ' ' +
+ '
' + icon +
+ ' ' +
+ '
or browse files
' +
+ ' ' +
+ '
' +
+ '
' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '';
+ this._frame = root.querySelector('.frame');
+ this._ring = root.querySelector('.ring');
+ this._img = root.querySelector('.frame img');
+ this._empty = root.querySelector('.empty');
+ this._cap = root.querySelector('.cap');
+ this._sub = root.querySelector('.sub');
+ this._spill = root.querySelector('.spill');
+ this._ghost = root.querySelector('.ghost');
+ this._err = null;
+ this._input = root.querySelector('input');
+ this._depth = 0;
+ this._gen = 0;
+ this._view = { s: 1, x: 0, y: 0 };
+ this._subFn = () => this._render();
+ // Shadow-DOM listeners live with the shadow DOM — bound once here so
+ // disconnect/reconnect (e.g. React remount) doesn't stack handlers.
+ this._empty.addEventListener('click', () => this._input.click());
+ root.addEventListener('click', (e) => {
+ const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
+ if (act === 'replace') { this._exitReframe(true); this._input.click(); }
+ if (act === 'clear') {
+ this._exitReframe(false);
+ this._gen++;
+ this._local = null;
+ if (this.id) setSlot(this.id, null); else this._render();
+ }
+ });
+ this._input.addEventListener('change', () => {
+ const f = this._input.files && this._input.files[0];
+ if (f) this._ingest(f);
+ this._input.value = '';
+ });
+ // naturalWidth/Height aren't known until load — re-apply so the cover
+ // baseline is computed from real dimensions, not the 100%×100% fallback.
+ this._img.addEventListener('load', () => this._applyView());
+ // Gated on editable + fit=cover so share links and contain/fill slots
+ // stay static.
+ this.addEventListener('dblclick', (e) => {
+ if (!this.hasAttribute('data-editable') || !this._reframes()) return;
+ e.preventDefault();
+ if (this.hasAttribute('data-reframe')) this._exitReframe(true);
+ else this._enterReframe();
+ });
+ // Pan + resize both originate on the spill layer. A handle pointerdown
+ // drives an aspect-locked resize anchored at the opposite corner; any
+ // other pointerdown on the spill pans. Offsets are frame-% so a
+ // reframed slot survives responsive resize / PPTX export.
+ this._spill.addEventListener('pointerdown', (e) => {
+ if (e.button !== 0 || !this.hasAttribute('data-reframe')) return;
+ e.preventDefault();
+ e.stopPropagation();
+ this._spill.setPointerCapture(e.pointerId);
+ const rect = this.getBoundingClientRect();
+ const fw = rect.width || 1, fh = rect.height || 1;
+ const corner = e.target.getAttribute && e.target.getAttribute('data-c');
+ let move;
+ if (corner) {
+ // Resize about the OPPOSITE corner. Viewport-px throughout (rect
+ // fw/fh, not clientWidth) so the math survives a transform:scale()
+ // ancestor — deck_stage renders slides scaled-to-fit.
+ const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1;
+ const base = Math.max(fw / iw, fh / ih);
+ const sx = corner.includes('e') ? 1 : -1;
+ const sy = corner.includes('s') ? 1 : -1;
+ const s0 = this._view.s;
+ const w0 = iw * base * s0, h0 = ih * base * s0;
+ const cx0 = (50 + this._view.x) / 100 * fw;
+ const cy0 = (50 + this._view.y) / 100 * fh;
+ const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2;
+ const diag0 = Math.hypot(w0, h0);
+ const ux = sx * w0 / diag0, uy = sy * h0 / diag0;
+ move = (ev) => {
+ const proj = (ev.clientX - rect.left - ox) * ux +
+ (ev.clientY - rect.top - oy) * uy;
+ const s = clampS(s0 * proj / diag0);
+ const d = diag0 * s / s0;
+ this._view.s = s;
+ this._view.x = (ox + ux * d / 2) / fw * 100 - 50;
+ this._view.y = (oy + uy * d / 2) / fh * 100 - 50;
+ this._clampView();
+ this._applyView();
+ };
+ } else {
+ this.setAttribute('data-panning', '');
+ const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y };
+ move = (ev) => {
+ this._view.x = start.x + (ev.clientX - start.px) / fw * 100;
+ this._view.y = start.y + (ev.clientY - start.py) / fh * 100;
+ this._clampView();
+ this._applyView();
+ };
+ }
+ const up = () => {
+ try { this._spill.releasePointerCapture(e.pointerId); } catch {}
+ this._spill.removeEventListener('pointermove', move);
+ this._spill.removeEventListener('pointerup', up);
+ this._spill.removeEventListener('pointercancel', up);
+ this.removeAttribute('data-panning');
+ this._dragUp = null;
+ };
+ // Stashed so _exitReframe (Escape / outside-click mid-drag) can
+ // tear the capture + listeners down synchronously.
+ this._dragUp = up;
+ this._spill.addEventListener('pointermove', move);
+ this._spill.addEventListener('pointerup', up);
+ this._spill.addEventListener('pointercancel', up);
+ });
+ // Wheel zoom stays available inside reframe mode as a trackpad nicety —
+ // zooms toward the cursor (offset' = cursor·(1-k) + offset·k).
+ this.addEventListener('wheel', (e) => {
+ if (!this.hasAttribute('data-reframe')) return;
+ e.preventDefault();
+ const r = this.getBoundingClientRect();
+ const cx = (e.clientX - r.left) / r.width * 100 - 50;
+ const cy = (e.clientY - r.top) / r.height * 100 - 50;
+ const prev = this._view.s;
+ const next = clampS(prev * Math.pow(1.0015, -e.deltaY));
+ if (next === prev) return;
+ const k = next / prev;
+ this._view.s = next;
+ this._view.x = cx * (1 - k) + this._view.x * k;
+ this._view.y = cy * (1 - k) + this._view.y * k;
+ this._clampView();
+ this._applyView();
+ }, { passive: false });
+ }
+
+ connectedCallback() {
+ // Warn once per page — an id-less slot works for the session but
+ // cannot persist, and two id-less slots would share nothing.
+ if (!this.id && !ImageSlot._warned) {
+ ImageSlot._warned = true;
+ console.warn(' without an id will not persist its dropped image.');
+ }
+ this.addEventListener('dragenter', this);
+ this.addEventListener('dragover', this);
+ this.addEventListener('dragleave', this);
+ this.addEventListener('drop', this);
+ subs.add(this._subFn);
+ // width%/height% in _applyView encode the frame aspect at call time —
+ // a host resize (responsive grid, pane divider) would stretch the
+ // image until the next _render. Re-render on size change: _render()
+ // re-seeds _view from stored before clamp/apply, so a shrink→grow
+ // cycle round-trips instead of ratcheting x/y toward the narrower
+ // frame's clamp range.
+ this._ro = new ResizeObserver(() => this._render());
+ this._ro.observe(this);
+ load();
+ this._render();
+ }
+
+ disconnectedCallback() {
+ subs.delete(this._subFn);
+ this.removeEventListener('dragenter', this);
+ this.removeEventListener('dragover', this);
+ this.removeEventListener('dragleave', this);
+ this.removeEventListener('drop', this);
+ if (this._ro) { this._ro.disconnect(); this._ro = null; }
+ this._exitReframe(false);
+ }
+
+ _enterReframe() {
+ if (this.hasAttribute('data-reframe')) return;
+ this.setAttribute('data-reframe', '');
+ this._applyView();
+ // Close on click outside (the spill handler stopPropagation()s so
+ // in-image drags don't reach this) and on Escape. Listeners are held
+ // on the instance so _exitReframe / disconnectedCallback can detach
+ // exactly what was attached.
+ this._outside = (e) => {
+ if (e.composedPath && e.composedPath().includes(this)) return;
+ this._exitReframe(true);
+ };
+ this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); };
+ document.addEventListener('pointerdown', this._outside, true);
+ document.addEventListener('keydown', this._esc, true);
+ }
+
+ _exitReframe(commit) {
+ if (!this.hasAttribute('data-reframe')) return;
+ if (this._dragUp) this._dragUp();
+ this.removeAttribute('data-reframe');
+ this.removeAttribute('data-panning');
+ if (this._outside) document.removeEventListener('pointerdown', this._outside, true);
+ if (this._esc) document.removeEventListener('keydown', this._esc, true);
+ this._outside = this._esc = null;
+ if (commit) this._commitView();
+ }
+
+ attributeChangedCallback() { if (this.shadowRoot) this._render(); }
+
+ // handleEvent — one listener object for all four drag events keeps the
+ // add/remove symmetric and the depth counter correct.
+ handleEvent(e) {
+ if (e.type === 'dragenter' || e.type === 'dragover') {
+ // Without preventDefault the browser never fires 'drop'.
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
+ if (e.type === 'dragenter') this._depth++;
+ this.setAttribute('data-over', '');
+ } else if (e.type === 'dragleave') {
+ // dragenter/leave fire for every descendant crossing — count depth
+ // so hovering the icon inside the empty state doesn't flicker.
+ if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); }
+ } else if (e.type === 'drop') {
+ e.preventDefault();
+ e.stopPropagation();
+ this._depth = 0;
+ this.removeAttribute('data-over');
+ const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+ if (f) this._ingest(f);
+ }
+ }
+
+ async _ingest(file) {
+ this._setError(null);
+ if (!file || ACCEPT.indexOf(file.type) < 0) {
+ this._setError('Drop a PNG, JPEG, WebP, or AVIF image.');
+ return;
+ }
+ // toDataUrl can take hundreds of ms on a large photo. A Clear or a
+ // newer drop during that window would be clobbered when this await
+ // resumes — bump + capture a generation so stale encodes bail.
+ const gen = ++this._gen;
+ try {
+ const w = this.clientWidth || this.offsetWidth || MAX_DIM;
+ const url = await toDataUrl(file, w);
+ if (gen !== this._gen) return;
+ // Only exit reframe once the new image is in hand — a rejected type
+ // or decode failure leaves the in-progress crop untouched.
+ this._exitReframe(false);
+ const val = { u: url, s: 1, x: 0, y: 0 };
+ setSlot(this.id || '', val);
+ // Keep a session-local copy for id-less slots so the drop still
+ // shows, even though it cannot persist.
+ if (!this.id) { this._local = val; this._render(); }
+ } catch (err) {
+ if (gen !== this._gen) return;
+ this._setError('Could not read that image.');
+ console.warn(' ingest failed:', err);
+ }
+ }
+
+ _setError(msg) {
+ if (this._err) { this._err.remove(); this._err = null; }
+ if (!msg) return;
+ const d = document.createElement('div');
+ d.className = 'err'; d.textContent = msg;
+ this.shadowRoot.appendChild(d);
+ this._err = d;
+ setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000);
+ }
+
+ // Reframing (pan/resize) is only meaningful for fit=cover — contain/fill
+ // keep the old object-fit path and double-click is a no-op.
+ _reframes() {
+ return this.hasAttribute('data-filled') &&
+ (this.getAttribute('fit') || 'cover') === 'cover';
+ }
+
+ // Cover-baseline geometry, shared by clamp/apply/resize. Null until the
+ // img has loaded (naturalWidth is 0 before that) or when the slot has no
+ // layout box — ResizeObserver fires with a 0×0 rect under display:none,
+ // and clamping against a degenerate 1×1 frame would silently pull the
+ // stored pan toward zero.
+ _geom() {
+ const iw = this._img.naturalWidth, ih = this._img.naturalHeight;
+ const fw = this.clientWidth, fh = this.clientHeight;
+ if (!iw || !ih || !fw || !fh) return null;
+ return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) };
+ }
+
+ _clampView() {
+ // Pan range on each axis is half the overflow past the frame edge.
+ const g = this._geom();
+ if (!g) return;
+ const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50);
+ const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50);
+ this._view.x = Math.max(-mx, Math.min(mx, this._view.x));
+ this._view.y = Math.max(-my, Math.min(my, this._view.y));
+ }
+
+ _applyView() {
+ const g = this._geom();
+ const fit = this.getAttribute('fit') || 'cover';
+ if (fit !== 'cover' || !g) {
+ // Non-cover, or dimensions not known yet (before img load).
+ this._img.style.width = '100%';
+ this._img.style.height = '100%';
+ this._img.style.left = '50%';
+ this._img.style.top = '50%';
+ this._img.style.objectFit = fit;
+ this._img.style.objectPosition = this.getAttribute('position') || '50% 50%';
+ return;
+ }
+ // Cover baseline: img fills the frame on its tighter axis at s=1, so
+ // pan works immediately on the overflowing axis without zooming first.
+ // Width/height and left/top are all frame-% — depends only on the
+ // frame aspect ratio, so a responsive resize keeps the same crop. The
+ // spill layer mirrors the same box so its corners = image corners.
+ const k = g.base * this._view.s;
+ const w = (g.iw * k / g.fw * 100) + '%';
+ const h = (g.ih * k / g.fh * 100) + '%';
+ const l = (50 + this._view.x) + '%';
+ const t = (50 + this._view.y) + '%';
+ this._img.style.width = w; this._img.style.height = h;
+ this._img.style.left = l; this._img.style.top = t;
+ this._img.style.objectFit = '';
+ this._spill.style.width = w; this._spill.style.height = h;
+ this._spill.style.left = l; this._spill.style.top = t;
+ }
+
+ _commitView() {
+ const v = { s: this._view.s, x: this._view.x, y: this._view.y };
+ if (this._userUrl) v.u = this._userUrl;
+ // Framing-only (no u) persists too so an author-src slot remembers its
+ // crop; clearing the sidecar still falls through to src=.
+ if (this.id) setSlot(this.id, v);
+ else { this._local = v; }
+ }
+
+ _render() {
+ // Shape / mask. Presets use border-radius so the dashed ring can
+ // follow the rounded outline; clip-path is only applied for an
+ // explicit `mask` (the ring is hidden there since a rectangle
+ // dashed border chopped by an arbitrary polygon looks broken).
+ const mask = this.getAttribute('mask');
+ const shape = (this.getAttribute('shape') || 'rounded').toLowerCase();
+ let radius = '';
+ if (shape === 'circle') radius = '50%';
+ else if (shape === 'pill') radius = '9999px';
+ else if (shape === 'rounded') {
+ const n = parseFloat(this.getAttribute('radius'));
+ radius = (Number.isFinite(n) ? n : 12) + 'px';
+ }
+ this._frame.style.borderRadius = mask ? '' : radius;
+ this._frame.style.clipPath = mask || '';
+ this._ring.style.borderRadius = mask ? '' : radius;
+ this._ring.style.display = mask ? 'none' : '';
+
+ // Controls and reframe entry gate on this so share links stay read-only.
+ const editable = !!(window.omelette && window.omelette.writeFile);
+ this.toggleAttribute('data-editable', editable);
+ this._sub.style.display = editable ? '' : 'none';
+
+ // Content. The sidecar is also writable by the agent's write_file
+ // tool, so its value isn't guaranteed canvas-originated — only accept
+ // data:image/ URLs from it. The `src` attribute is author-controlled
+ // (Claude wrote it into the HTML) so it passes through unchanged.
+ let stored = this.id ? getSlot(this.id) : this._local;
+ if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null;
+ const srcAttr = this.getAttribute('src') || '';
+ this._userUrl = (stored && stored.u) || null;
+ const url = this._userUrl || srcAttr;
+ // Don't clobber an in-flight reframe with a store-triggered re-render.
+ if (!this.hasAttribute('data-reframe')) {
+ this._view = {
+ s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1,
+ x: stored && Number.isFinite(stored.x) ? stored.x : 0,
+ y: stored && Number.isFinite(stored.y) ? stored.y : 0,
+ };
+ }
+ this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image';
+ // Toggle via style.display — the [hidden] attribute alone loses to
+ // the display:flex / display:block rules in the stylesheet above.
+ if (url) {
+ if (this._img.getAttribute('src') !== url) {
+ this._img.src = url;
+ this._ghost.src = url;
+ }
+ this._img.style.display = 'block';
+ this._empty.style.display = 'none';
+ this.setAttribute('data-filled', '');
+ this._clampView();
+ this._applyView();
+ } else {
+ this._img.style.display = 'none';
+ this._img.removeAttribute('src');
+ this._ghost.removeAttribute('src');
+ this._empty.style.display = 'flex';
+ this.removeAttribute('data-filled');
+ }
+ }
+ }
+
+ if (!customElements.get('image-slot')) {
+ customElements.define('image-slot', ImageSlot);
+ }
+})();
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..77cb9a9
--- /dev/null
+++ b/index.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ Board post · Fenja AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/screenshots/01-diag-overview.png b/screenshots/01-diag-overview.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/screenshots/01-diag-overview.png differ
diff --git a/screenshots/02-diag-overview.png b/screenshots/02-diag-overview.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/screenshots/02-diag-overview.png differ
diff --git a/screenshots/diag-a.png b/screenshots/diag-a.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/screenshots/diag-a.png differ
diff --git a/screenshots/diag-canvas.png b/screenshots/diag-canvas.png
new file mode 100644
index 0000000..3eae68a
Binary files /dev/null and b/screenshots/diag-canvas.png differ
diff --git a/uploads/fenja-logo-1000x1000.png b/uploads/fenja-logo-1000x1000.png
new file mode 100644
index 0000000..7982b9b
Binary files /dev/null and b/uploads/fenja-logo-1000x1000.png differ