Skip to content

Instantly share code, notes, and snippets.

@PandaWhoCodes
Created September 21, 2026 12:15
Show Gist options
  • Select an option

  • Save PandaWhoCodes/4a369f1d6c961c93b3a8125c4cea127d to your computer and use it in GitHub Desktop.

Select an option

Save PandaWhoCodes/4a369f1d6c961c93b3a8125c4cea127d to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>window.__resources = window.__resources || {};
// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`.
"use strict";
(() => {
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/react.ts
function getReact() {
const R = window.React;
if (!R) throw new Error("dc-runtime: window.React is not available yet");
return R;
}
function getReactDOM() {
const RD = window.ReactDOM;
if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet");
return RD;
}
var h = ((...args) => getReact().createElement(
...args
));
// src/parse.ts
function parseDcDocument(doc) {
const dc = doc.querySelector("x-dc");
if (!dc) return null;
const scriptEl = doc.querySelector("script[data-dc-script]");
const { props, preview } = parseDataProps(
scriptEl?.getAttribute("data-props") ?? null
);
return {
template: dc.innerHTML,
js: scriptEl ? scriptEl.textContent || "" : "",
props,
preview
};
}
function parseDcText(src) {
const openMatch = /<x-dc(?:\s[^>]*)?>/.exec(src);
if (!openMatch) return null;
const close = src.lastIndexOf("</x-dc>");
if (close === -1 || close < openMatch.index) return null;
const template = src.slice(openMatch.index + openMatch[0].length, close);
const doc = new DOMParser().parseFromString(src, "text/html");
const scriptEl = doc.querySelector("script[data-dc-script]");
const { props, preview } = parseDataProps(
scriptEl?.getAttribute("data-props") ?? null
);
return {
template,
js: scriptEl ? scriptEl.textContent || "" : "",
props,
preview
};
}
function parseDataProps(raw) {
if (!raw) return { props: null, preview: null };
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return { props: null, preview: null };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { props: null, preview: null };
}
const obj = parsed;
const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null;
const rest = {};
for (const k of Object.keys(obj)) {
if (k[0] !== "$") rest[k] = obj[k];
}
return { props: Object.keys(rest).length ? rest : null, preview };
}
function dcNameFromPath(pathname) {
let p = pathname || "";
try {
p = decodeURIComponent(p);
} catch {
}
const base = p.split("/").pop() || "Root";
return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root";
}
// src/boot.ts
var BASE_CSS = `
.sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent);
border:1px solid color-mix(in srgb,currentColor 50%,transparent);
border-radius:2px;box-sizing:border-box;overflow:hidden}
@keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}}
html.sc-dc-streaming .sc-placeholder,
html.sc-dc-streaming .sc-interp.sc-missing{position:relative;
background:color-mix(in srgb,currentColor 5%,transparent);
border-color:transparent}
html.sc-dc-streaming .sc-placeholder::before,
html.sc-dc-streaming .sc-interp.sc-missing::before{content:'';
position:absolute;inset:0;pointer-events:none;
background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%);
background-size:400% 100%;animation:sc-shine 1.4s ease infinite}
html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before,
html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none;
background:color-mix(in srgb,currentColor 8%,transparent)}
.sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace;
color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word}
.sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden;
vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5);
border-radius:2px;box-sizing:border-box;color:transparent;
user-select:none}
.sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em;
color:color-mix(in srgb,currentColor 50%,transparent);
background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px;
padding:0 3px}
.sc-host.sc-has-error{position:relative}
.sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch;
padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace;
border-radius:4px;white-space:pre-wrap;pointer-events:none}
/* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both
in sync until dc-runtime regains a build step. */
@media print {
@page { margin: 0.5cm; }
figure, table { break-inside: avoid; }
#dc-root, #dc-root > .sc-host { height: auto; }
*, *::before, *::after {
print-color-adjust: exact; -webkit-print-color-adjust: exact;
backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
animation-delay: -99s !important; animation-duration: .001s !important;
animation-iteration-count: 1 !important; animation-fill-mode: both !important;
animation-play-state: running !important; transition-duration: 0s !important;
}
}
`;
var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}";
function rootNameForDocument(doc, loc) {
let bootPath = loc.pathname || "";
if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) {
try {
bootPath = new URL(doc.baseURI || "/").pathname;
} catch {
}
}
return dcNameFromPath(bootPath);
}
function safeDecode(s) {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
function boot(runtime, doc = document) {
const parsed = parseDcDocument(doc);
if (!parsed) return null;
const React = getReact();
const rootName = rootNameForDocument(doc, location);
runtime.markFetched(rootName);
runtime.setRootName(rootName);
runtime.adoptParsed(rootName, parsed);
if (!window.__resources) {
fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => {
const raw = t ? parseDcText(t) : null;
if (raw?.template) runtime.updateHtml(rootName, raw.template);
}).catch(() => {
});
}
const dc = doc.querySelector("x-dc");
const hostEl = doc.createElement("div");
hostEl.id = "dc-root";
dc.replaceWith(hostEl);
if (!parsed.preview) {
const s = doc.createElement("style");
s.textContent = FULL_PAGE_CSS;
doc.head.appendChild(s);
}
const Root = runtime.getDC(rootName);
const entry = runtime.registry.get(rootName);
function StandaloneRoot() {
const [, setTick] = React.useState(0);
React.useEffect(() => {
const sub = () => setTick((n) => n + 1);
entry.subs.add(sub);
return () => {
entry.subs.delete(sub);
};
}, []);
const defaults = React.useMemo(() => {
const d = {};
for (const k in entry.propsMeta || {}) {
const v = entry.propsMeta?.[k]?.default;
if (v !== void 0) d[k] = v;
}
return d;
}, [entry.propsMeta]);
return h(Root, { ...defaults, ...entry.propOverrides || {} });
}
const ReactDOM = getReactDOM();
if (ReactDOM.createRoot)
ReactDOM.createRoot(hostEl).render(h(StandaloneRoot));
else ReactDOM.render(h(StandaloneRoot), hostEl);
return rootName;
}
// src/expr.ts
var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/;
var NUMBER_RE = /^-?\d+(\.\d+)?$/;
function resolve(vals, src) {
const expr = String(src).trim();
if (!expr) return void 0;
if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) {
return resolve(vals, expr.slice(1, -1));
}
const eq = findTopLevelEquality(expr);
if (eq) {
const lv = resolve(vals, expr.slice(0, eq.index));
const rv = resolve(vals, expr.slice(eq.index + eq.op.length));
switch (eq.op) {
case "===":
return lv === rv;
case "!==":
return lv !== rv;
case "==":
return lv == rv;
default:
return lv != rv;
}
}
if (expr[0] === "!") return !resolve(vals, expr.slice(1));
if (expr === "true") return true;
if (expr === "false") return false;
if (expr === "null") return null;
if (expr === "undefined") return void 0;
if (NUMBER_RE.test(expr)) return Number(expr);
if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) {
return expr.slice(1, -1);
}
return resolvePath(vals, expr);
}
function parensWrapWhole(expr) {
let depth = 0;
for (let i = 0; i < expr.length - 1; i++) {
if (expr[i] === "(") depth++;
else if (expr[i] === ")") {
depth--;
if (depth === 0) return false;
}
}
return true;
}
function findTopLevelEquality(expr) {
let depth = 0;
for (let i = 0; i < expr.length; i++) {
const c = expr[i];
if (c === "[" || c === "(") depth++;
else if (c === "]" || c === ")") depth--;
else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") {
if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue;
if (!expr.slice(0, i).trim()) continue;
const op = expr[i + 2] === "=" ? c + "==" : c + "=";
return { index: i, op };
}
}
return null;
}
function resolvePath(vals, expr) {
const head = expr.match(IDENT_RE);
if (!head) return void 0;
let cur = vals == null ? void 0 : vals[head[0]];
let i = head[0].length;
while (i < expr.length) {
if (expr[i] === ".") {
const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/);
if (!m) return void 0;
cur = cur == null ? void 0 : cur[m[0]];
i += 1 + m[0].length;
} else if (expr[i] === "[") {
let depth = 1;
let j = i + 1;
while (j < expr.length && depth > 0) {
if (expr[j] === "[") depth++;
else if (expr[j] === "]") {
depth--;
if (depth === 0) break;
}
j++;
}
if (depth !== 0) return void 0;
const key = resolve(vals, expr.slice(i + 1, j));
cur = cur == null ? void 0 : cur[key];
i = j + 1;
} else {
return void 0;
}
}
return cur;
}
// src/encode.ts
var CAMEL_ATTR = "sc-camel-";
var INLINE_TEXT_TAGS = new Set(
"a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split(
" "
)
);
var RAW_WRAP = {
select: "sc-raw-select",
table: "sc-raw-table",
tbody: "sc-raw-tbody",
thead: "sc-raw-thead",
tfoot: "sc-raw-tfoot",
tr: "sc-raw-tr",
td: "sc-raw-td",
th: "sc-raw-th",
caption: "sc-raw-caption"
};
var RAW_UNWRAP = Object.fromEntries(
Object.entries(RAW_WRAP).map(([k, v]) => [v, k])
);
var EVENT_MAP = {
onclick: "onClick",
onchange: "onChange",
oninput: "onInput",
onsubmit: "onSubmit",
onkeydown: "onKeyDown",
onkeyup: "onKeyUp",
onkeypress: "onKeyPress",
onmousedown: "onMouseDown",
onmouseup: "onMouseUp",
onmouseenter: "onMouseEnter",
onmouseleave: "onMouseLeave",
onfocus: "onFocus",
onblur: "onBlur",
ondoubleclick: "onDoubleClick",
oncontextmenu: "onContextMenu",
onmousemove: "onMouseMove",
onmouseover: "onMouseOver",
onmouseout: "onMouseOut",
onpointerdown: "onPointerDown",
onpointerup: "onPointerUp",
onpointermove: "onPointerMove",
onpointerenter: "onPointerEnter",
onpointerleave: "onPointerLeave",
onpointercancel: "onPointerCancel",
onpointerover: "onPointerOver",
onpointerout: "onPointerOut",
ongotpointercapture: "onGotPointerCapture",
onlostpointercapture: "onLostPointerCapture",
ontouchstart: "onTouchStart",
ontouchend: "onTouchEnd",
ontouchmove: "onTouchMove",
ontouchcancel: "onTouchCancel",
ondragstart: "onDragStart",
ondragend: "onDragEnd",
ondragenter: "onDragEnter",
ondragleave: "onDragLeave",
ondragover: "onDragOver",
onanimationstart: "onAnimationStart",
onanimationend: "onAnimationEnd",
onanimationiteration: "onAnimationIteration",
ontransitionend: "onTransitionEnd"
};
var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
var IMPORT_SELF_CLOSE_RE = new RegExp(
"<(x-import|dc-import)(" + ATTRS + ")/>",
"gi"
);
var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g;
function encodeCamelAttrs(html) {
return html.replace(
CAMEL_ATTR_RE,
(_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq
);
}
function encodeCase(html) {
html = html.replace(
IMPORT_SELF_CLOSE_RE,
(_, t, a) => "<" + t + a + "></" + t + ">"
);
html = html.replace(/<helmet(\s|>)/gi, "<sc-helmet$1");
html = html.replace(/<\/helmet\s*>/gi, "</sc-helmet>");
html = encodeCamelAttrs(html);
for (const [real, alias] of Object.entries(RAW_WRAP)) {
html = html.replace(
new RegExp("(</?)" + real + "(?=[\\s>])", "gi"),
"$1" + alias
);
}
return html;
}
function kebabToCamel(s) {
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
function cssToObj(css) {
const o = {};
for (const decl of css.split(";")) {
const i = decl.indexOf(":");
if (i < 0) continue;
const prop = decl.slice(0, i).trim();
o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim();
}
return o;
}
function compileAttr(raw) {
const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/);
if (whole) {
const path = whole[1];
return (vals) => resolve(vals, path);
}
if (raw.includes("{{")) {
const parts = raw.split(/\{\{([\s\S]+?)\}\}/g);
return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join("");
}
return () => raw;
}
// src/compile.ts
function collectProps(node, kind, host) {
const propGetters = [];
const pseudoClasses = [];
let hintSize = null;
for (const { name, value } of [...node.attributes]) {
if (name === "sc-name" || name === "data-dc-tpl") continue;
let key = name;
if (key.startsWith(CAMEL_ATTR))
key = kebabToCamel(key.slice(CAMEL_ATTR.length));
if (key === "hint-size") {
hintSize = value;
continue;
}
if (key.startsWith("style-")) {
pseudoClasses.push(host.pseudoClass(key.slice(6), value));
continue;
}
if (kind !== "dom") {
if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-"))))
key = kebabToCamel(key);
} else {
if (key === "class") key = "className";
else if (key === "for") key = "htmlFor";
else if (key.startsWith("on"))
key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3);
}
propGetters.push([key, compileAttr(value)]);
}
return { propGetters, pseudoClasses, hintSize };
}
var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([
"position",
"left",
"right",
"top",
"bottom",
"inset",
"width",
"height",
"z-index",
"transform"
]);
function hostPositionStyle(style) {
const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null;
if (!all) return void 0;
const out = {};
for (const [k, v] of Object.entries(all)) {
const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
if (HOST_STYLE_PROPS.has(kebab)) out[k] = v;
}
return Object.keys(out).length ? out : void 0;
}
function compileTemplate(html, host) {
const tpl = document.createElement("template");
//! nosemgrep: direct-inner-html-assignment
tpl.innerHTML = encodeCase(html);
let tplN = 0;
(function stamp(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
node.setAttribute("data-dc-tpl", String(tplN++));
}
for (const c of node.childNodes) stamp(c);
})(tpl.content);
const builders = walkChildren(tpl.content, host);
const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i)));
render.__annotated = tpl.innerHTML;
return render;
}
function walkChildren(node, host) {
return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null);
}
var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/;
var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/;
var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/;
function isDeckMountTag(el) {
if (el.localName === "deck-stage") return true;
return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage";
}
function walkDeckChildren(el, host) {
const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null);
const kids = pairs.map((p) => p.b);
const seen = /* @__PURE__ */ new Set();
const wsSeen = /* @__PURE__ */ new Map();
const keys = [];
const nextSlideId = new Array(pairs.length);
{
let upcoming = null;
for (let j = pairs.length - 1; j >= 0; j--) {
const n = pairs[j].c;
if (n.nodeType === Node.ELEMENT_NODE) {
const t = n.localName;
upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null;
}
nextSlideId[j] = upcoming;
}
}
for (let j = 0; j < pairs.length; j++) {
const { c } = pairs[j];
if (c.nodeType === Node.TEXT_NODE) {
if ((c.nodeValue ?? "").trim() === "") {
const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux";
const n = wsSeen.get(base) ?? 0;
wsSeen.set(base, n + 1);
keys.push(n === 0 ? base : base + ":" + n);
continue;
}
return { kids, keys: null };
}
if (c.nodeType !== Node.ELEMENT_NODE) {
keys.push(j);
continue;
}
const child = c;
const tag = child.localName;
if (DECK_AUX_RE.test(tag)) {
keys.push(j);
continue;
}
if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null };
const v = child.getAttribute("data-om-slide-id");
if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) {
return { kids, keys: null };
}
seen.add(v);
keys.push("omid:" + v);
}
return { kids, keys };
}
function renderDeckKids(kids, kidKeys, vals, ctx) {
return kids.map((b, j) => {
const k = kidKeys ? kidKeys[j] : j;
const out = b(vals, ctx, k);
return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out;
});
}
function walk(node, host) {
if (node.nodeType === Node.TEXT_NODE) return walkText(node);
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const el = node;
const tag = el.tagName.toLowerCase();
if (tag === "sc-for") return walkFor(el, host);
if (tag === "sc-if") return walkIf(el, host);
if (tag === "x-import") return walkXImport(el, host);
if (tag === "sc-helmet") return host.helmet(el);
if (tag === "dc-import") return walkComponent(el, host);
return walkElement(el, host);
}
var warnedHoles = /* @__PURE__ */ new Set();
function warnUnresolved(ctx, what) {
const key = (ctx?.__name || "?") + "\0" + what;
if (warnedHoles.has(key)) return;
warnedHoles.add(key);
console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what);
}
function walkText(node) {
const txt = node.nodeValue ?? "";
if (!txt.includes("{{")) {
if (!txt.trim() && !txt.includes(" ")) return null;
return () => txt;
}
const parts = txt.split(/\{\{([\s\S]+?)\}\}/g);
return (vals, ctx, key) => h(
getReact().Fragment,
{ key },
...parts.map((p, i) => {
if (!(i & 1)) return p;
const v = resolve(vals, p);
if (v === void 0) {
if (!ctx?.__streamingNow) {
if (document.body?.hasAttribute("data-dc-editor-on")) {
return h(
"span",
{ key: i, className: "sc-interp sc-unresolved" },
"{{ " + p.trim() + " }}"
);
}
warnUnresolved(
ctx,
"{{ " + p.trim() + " }} never resolved \u2014 rendered as empty"
);
return null;
}
return h(
"span",
{ key: i, className: "sc-interp sc-missing" },
p.trim()
);
}
if (getReact().isValidElement(v) || Array.isArray(v)) {
return h(getReact().Fragment, { key: i }, v);
}
if (v === null || typeof v === "boolean") return null;
return h("span", { key: i, className: "sc-interp" }, String(v));
})
);
}
function walkFor(el, host) {
const listGet = compileAttr(el.getAttribute("list") || "");
const asName = el.getAttribute("as") || "item";
const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10);
const kids = walkChildren(el, host);
const listSrc = el.getAttribute("list") || "";
return (vals, ctx, key) => {
let list = listGet(vals);
if (!Array.isArray(list)) {
if (!ctx?.__streamingNow) {
if (list !== void 0 && list !== null) {
warnUnresolved(
ctx,
'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")"
);
}
list = [];
} else {
list = hintN > 0 ? Array(hintN).fill(void 0) : [];
}
}
return h(
getReact().Fragment,
{ key },
list.map((item, i) => {
const sub = { ...vals, [asName]: item, $index: i };
return h(
getReact().Fragment,
{ key: i },
kids.map((b, j) => b(sub, ctx, j))
);
})
);
};
}
function walkIf(el, host) {
const valGet = compileAttr(el.getAttribute("value") || "");
const hintRaw = el.getAttribute("hint-placeholder-val");
const hintGet = hintRaw != null ? compileAttr(hintRaw) : null;
const kids = walkChildren(el, host);
return (vals, ctx, key) => {
let v = valGet(vals);
if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals);
return v ? h(
getReact().Fragment,
{ key },
kids.map((b, j) => b(vals, ctx, j))
) : null;
};
}
function walkComponent(el, host) {
const name = el.getAttribute("name") || el.getAttribute("component") || "";
el.removeAttribute("name");
el.removeAttribute("component");
const tplId = el.getAttribute("data-dc-tpl");
const styleRaw = el.getAttribute("style");
el.removeAttribute("style");
const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
const { propGetters, hintSize } = collectProps(el, "dc-import", host);
const kids = walkChildren(el, host);
return (vals, ctx, key) => {
const props = {
key,
__hintSize: hintSize,
__tplId: tplId,
__hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0
};
for (const [k, g] of propGetters) {
const v = g(vals);
if (k === "dcProps") {
if (v && typeof v === "object") Object.assign(props, v);
continue;
}
props[k] = v;
}
if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j));
return h(host.component(name), props);
};
}
function walkXImport(el, host) {
const globalNameGet = compileAttr(
el.getAttribute("component-from-global-scope") || ""
);
const exportNameGet = compileAttr(
el.getAttribute("component") || el.getAttribute("name") || ""
);
const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || "");
const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : [];
const url = urls.length ? urls[urls.length - 1] : "";
const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js";
const tplId = el.getAttribute("data-dc-tpl");
const styleRaw = el.getAttribute("style");
el.removeAttribute("style");
const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
const wrap = tplId != null || styleGet != null;
const { propGetters, hintSize } = collectProps(el, "x-import", host);
const hasContent = el.children.length > 0 || !!(el.textContent || "").trim();
const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null;
const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : [];
const kidKeys = deckKeyed?.keys ?? null;
const urlBindable = fromRaw.includes("{{");
if (urls.length && !urlBindable) {
let prev;
for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev);
}
const evalName = (g, vals) => {
const v = g(vals);
const s = v == null ? "" : String(v);
return s.includes("{{") ? "" : s;
};
return (vals, ctx, key) => {
const globalName = evalName(globalNameGet, vals);
const name = globalName || evalName(exportNameGet, vals);
const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name);
const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0;
const wrapper = wrap ? {
key,
className: "sc-host-x",
"data-dc-tpl": tplId,
style: hostStyle || { display: "contents" }
} : null;
if (!C) {
const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name);
const ph = host.placeholder({
key: wrapper ? void 0 : key,
name,
hintSize,
error
});
return wrapper ? h("div", wrapper, ph) : ph;
}
const props = wrapper ? {} : { key };
let unresolvedHole = false;
for (const [k, g] of propGetters) {
if (k === "component" || k === "componentFromGlobalScope" || k === "from") {
continue;
}
const v = g(vals);
if (v === void 0) unresolvedHole = true;
if (k === "dcProps") {
if (v && typeof v === "object") Object.assign(props, v);
continue;
}
props[k] = v;
}
if (unresolvedHole && ctx?.__htmlStreamingNow) {
const ph = host.placeholder({
key: wrapper ? void 0 : key,
name,
hintSize,
error: null
});
return wrapper ? h("div", wrapper, ph) : ph;
}
if (kids.length) {
props.children = renderDeckKids(kids, kidKeys, vals, ctx);
}
return wrapper ? h("div", wrapper, h(C, props)) : h(C, props);
};
}
function contentKey(el) {
const clone = el.cloneNode(true);
for (const d of clone.querySelectorAll("*")) {
while (d.attributes.length) d.removeAttribute(d.attributes[0].name);
}
const s = clone.innerHTML;
let h2 = 5381;
for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0;
return s.length + "." + (h2 >>> 0).toString(36);
}
var NEVER_CONTENT_KEYED = new Set(
"script style textarea option title select canvas iframe video audio".split(
" "
)
);
var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")";
function walkElement(el, host) {
const realTag = RAW_UNWRAP[el.localName] || el.localName;
const tplId = el.getAttribute("data-dc-tpl");
const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null;
const keySuffix = inlineOnly ? "|" + contentKey(el) : "";
const { propGetters, pseudoClasses } = collectProps(el, "dom", host);
const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null;
const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host);
const kidKeys = deckKeyed?.keys ?? null;
return (vals, ctx, key) => {
const props = {
key: key + keySuffix,
"data-dc-tpl": tplId
};
for (const [k, g] of propGetters) {
let v = g(vals);
if (k === "style" && typeof v === "string") v = cssToObj(v);
if ((k === "value" || k === "checked") && v === void 0) {
v = k === "checked" ? false : "";
}
props[k] = v;
}
if (pseudoClasses.length) {
props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" ");
}
return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx));
};
}
// src/logic.ts
var StreamableLogic = class {
constructor(props) {
__publicField(this, "props");
__publicField(this, "state", {});
/** Back-pointer to the wrapper component, installed after construction. */
__publicField(this, "__host");
this.props = props || {};
}
setState(update, cb) {
this.__host && this.__host.__setLogicState(update, cb);
}
forceUpdate() {
this.__host && this.__host.forceUpdate();
}
componentDidMount() {
}
componentDidUpdate(_prevProps) {
}
componentWillUnmount() {
}
/** The flat object the template renders against (merged over props). */
renderVals() {
return {};
}
};
function evalDcLogic(src) {
//! nosemgrep: eval-and-function-constructor
const fn = new Function(
"DCLogic",
"StreamableLogic",
"React",
src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;'
);
return fn(StreamableLogic, StreamableLogic, getReact());
}
// src/component.ts
function shallowEqual(a, b) {
if (!b) return false;
const ak = Object.keys(a).filter((k) => k !== "children");
const bk = Object.keys(b).filter((k) => k !== "children");
if (ak.length !== bk.length) return false;
for (const k of ak) if (a[k] !== b[k]) return false;
return true;
}
function Placeholder({
name,
hintSize,
streaming,
error
}) {
const [w, hgt] = (hintSize || "100%,60px").split(",");
return h(
"div",
{
className: "sc-placeholder" + (streaming ? " sc-streaming" : ""),
style: { width: w.trim(), height: hgt && hgt.trim() },
title: name
},
error ? h(
"div",
{ className: "sc-placeholder-error" },
(name ? name + ": " : "") + error
) : null
);
}
function hintToMin(hint) {
if (!hint) return void 0;
const [w, hgt] = hint.split(",");
return { minWidth: w.trim(), minHeight: hgt && hgt.trim() };
}
function createComponentFactory(registry, ensureFetched) {
const React = getReact();
const AncestorContext = React.createContext([]);
class StreamableComponent extends React.Component {
constructor(props) {
super(props);
__publicField(this, "__name");
__publicField(this, "__sub");
__publicField(this, "__needsDidMount", false);
/** Snapshot of the registry's streaming flags taken at render time —
* builders read it off the RenderCtx (this) to pick placeholder vs
* render-nothing for unresolved values. */
__publicField(this, "__streamingNow", false);
__publicField(this, "__htmlStreamingNow", false);
/** When a construct throws, remember the (class, registry.ver, props)
* triple so render-time reconcile doesn't re-attempt it on every parent
* re-render. A registry bump (new class, template, external module
* resolving via bumpAll) changes `ver` and breaks the memo so an
* env-dependent constructor can self-heal. */
__publicField(this, "__failedLogic", null);
__publicField(this, "__failedUserProps", null);
__publicField(this, "__failedVer", -1);
/** Per-instance constructor error — kept here (not on the registry entry)
* so one instance's successful construct can't hide a sibling's failure,
* and a construct can never wipe an eval error `updateJs` recorded on
* `r.logicError`. */
__publicField(this, "__ctorError", null);
__publicField(this, "logic");
this.__name = props.__name;
this.state = { __v: 0, __err: null };
this.__sub = () => {
if (this.state.__err) this.setState({ __err: null });
this.forceUpdate();
};
this.__makeLogic(registry.get(this.__name).Logic, null);
ensureFetched(this.__name);
}
/** Error-boundary hook: a render crash anywhere in this DC's subtree
* (its own template, an x-import'd component, a child DC without its
* own deeper boundary) lands here instead of unmounting the page. */
static getDerivedStateFromError(e) {
return { __err: e instanceof Error && e.message ? e.message : String(e) };
}
componentDidCatch(e, info) {
console.error(
"[dc-runtime] render error in <" + this.__name + ">:",
e,
info?.componentStack || ""
);
}
/** Instantiate the logic class (or the no-op base) and adopt `prevState`
* over its initial state — used both at mount and on hot-swap. */
__makeLogic(Logic, prevState) {
const L = Logic || StreamableLogic;
try {
this.logic = new L(this.__userProps());
this.__failedLogic = null;
this.__failedUserProps = null;
this.__ctorError = null;
} catch (e) {
console.error(e);
this.__failedLogic = Logic;
this.__failedUserProps = this.__userProps();
this.__failedVer = registry.get(this.__name).ver;
this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e));
this.logic = new StreamableLogic(
this.__userProps()
);
}
this.logic.__host = this;
if (prevState)
this.logic.state = { ...this.logic.state || {}, ...prevState };
}
/** The props the author's logic + template see — internal __-prefixed
* wiring stripped. */
__userProps() {
const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props;
return rest;
}
__setLogicState(update, cb) {
const prev = this.logic.state;
const patch = typeof update === "function" ? update(prev) : update;
this.logic.state = { ...prev, ...patch };
this.setState((s) => ({ __v: s.__v + 1 }), cb);
}
/** Swap the logic instance when the registry's Logic class changed
* (streaming completion, hot reload). State carries over; didMount
* re-fires after the swap commits so refs exist. */
__reconcileLogic() {
const r = registry.get(this.__name);
const Next = r.Logic;
const Cur = this.logic.constructor;
if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) {
return;
}
if (!this.__needsDidMount) {
try {
this.logic.componentWillUnmount();
} catch (e) {
console.error(e);
}
}
this.__makeLogic(Next, this.logic.state);
this.__needsDidMount = true;
}
componentDidMount() {
registry.get(this.__name).subs.add(this.__sub);
try {
this.logic.componentDidMount();
} catch (e) {
console.error(e);
}
}
componentDidUpdate(prevProps) {
this.logic.props = this.__userProps();
if (this.__needsDidMount) {
if (this.state.__err || !registry.get(this.__name).tpl) return;
this.__needsDidMount = false;
try {
this.logic.componentDidMount();
} catch (e) {
console.error(e);
}
} else {
try {
this.logic.componentDidUpdate(prevProps);
} catch (e) {
console.error(e);
}
}
}
componentWillUnmount() {
registry.get(this.__name).subs.delete(this.__sub);
if (!this.__needsDidMount) {
try {
this.logic.componentWillUnmount();
} catch (e) {
console.error(e);
}
}
}
render() {
const r = registry.get(this.__name);
const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : "");
const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0;
const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0;
const hostBase = {
className: cls,
style: hostStyle,
"data-sc-name": this.__name,
"data-dc-tpl": this.props.__tplId
};
const chain = Array.isArray(this.context) ? this.context : [];
if (chain.includes(this.__name)) {
const cycle = [
...chain.slice(chain.indexOf(this.__name)),
this.__name
].join(" \u2192 ");
return h(
"div",
{ ...hostBase, className: cls + " sc-has-error" },
h(Placeholder, {
name: this.__name,
hintSize: this.props.__hintSize,
error: "circular import: " + cycle
})
);
}
if (this.state.__err) {
return h(
"div",
{ ...hostBase, className: cls + " sc-has-error" },
h(
"div",
{ className: "sc-logic-error", "data-omelette-chrome": "" },
this.__name + ": " + this.state.__err
),
h(Placeholder, {
name: this.__name,
hintSize: this.props.__hintSize,
error: this.state.__err
})
);
}
this.__reconcileLogic();
if (!r.tpl) {
return h(
"div",
hostBase,
h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize })
);
}
const userProps = this.__userProps();
this.logic.props = userProps;
let vals = userProps;
let renderErr = r.logicError || this.__ctorError;
try {
vals = { ...userProps, ...this.logic.renderVals() || {} };
} catch (e) {
console.error(e);
renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e));
}
this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming);
this.__htmlStreamingNow = !!r.htmlStreaming;
return h(
"div",
{ ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") },
renderErr && h(
"div",
{ className: "sc-logic-error", "data-omelette-chrome": "" },
renderErr
),
h(
AncestorContext.Provider,
{ value: [...chain, this.__name] },
r.tpl(vals, this)
)
);
}
}
__publicField(StreamableComponent, "contextType", AncestorContext);
const named = /* @__PURE__ */ new Map();
function getDC(name) {
const hit = named.get(name);
if (hit) return hit;
function Dispatcher(p) {
const [, setTick] = React.useState(0);
React.useEffect(() => {
const sub = () => setTick((n) => n + 1);
registry.get(name).subs.add(sub);
return () => {
registry.get(name).subs.delete(sub);
};
}, []);
ensureFetched(name);
return h(StreamableComponent, { ...p, __name: name });
}
Dispatcher.displayName = name;
named.set(name, Dispatcher);
return Dispatcher;
}
return {
getDC,
StreamableComponent
};
}
// src/bundled.ts
function bundledBlob(url) {
const blobs = window.__resourceBlobs;
const b = blobs ? blobs[url.split("#")[0]] : void 0;
return b instanceof Blob ? b : null;
}
// src/cdn.ts
var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js";
var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z";
var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js";
var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1";
var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js";
var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y";
function cdnScriptFor(url, sri) {
const res = window.__resources;
const v = res ? res[url] : void 0;
return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri };
}
// src/external.ts
var isCustomElementName = (n) => !n.includes(".") && n.includes("-");
function isRenderableType(g) {
if (typeof g === "function") return !isElementClass(g);
return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol";
}
function resolveDottedPath(root, name) {
let cur = root;
for (const seg of name.split(".")) {
if (cur == null) return void 0;
cur = cur[seg];
}
return cur;
}
var GLOBAL_POLL_INTERVAL_MS = 50;
var GLOBAL_POLL_TIMEOUT_MS = 3e4;
function createExternalModules(onResolved) {
const cache = /* @__PURE__ */ new Map();
let babelLoading = null;
const reportedMissing = /* @__PURE__ */ new Map();
const polling = /* @__PURE__ */ new Set();
function ensureBabel() {
if (window.Babel) return Promise.resolve();
if (babelLoading) return babelLoading;
const babel = cdnScriptFor(BABEL_URL, BABEL_SRI);
babelLoading = new Promise((res, rej) => {
const s = document.createElement("script");
s.src = babel.src;
if (babel.integrity) {
s.integrity = babel.integrity;
s.crossOrigin = "anonymous";
}
s.onload = () => res();
s.onerror = rej;
document.head.appendChild(s);
});
return babelLoading;
}
const pending = /* @__PURE__ */ new Map();
function load(kind, url, after) {
const existing = pending.get(url);
if (existing) return existing;
cache.set(url, null);
console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")");
const ready = Promise.all([
kind === "jsx" ? ensureBabel() : Promise.resolve(),
after ?? Promise.resolve()
]);
const p = ready.then(() => {
const pre = bundledBlob(url);
if (pre) return pre.text();
return fetch(url).then((r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.text();
});
}).then((src) => {
const code = kind === "jsx" ? window.Babel.transform(src, {
filename: url,
presets: ["react", "typescript"]
}).code : src;
const module = { exports: {} };
const before = new Set(Object.keys(window));
//! nosemgrep: eval-and-function-constructor
new Function("React", "module", "exports", "require", code)(
getReact(),
module,
module.exports,
() => ({})
);
const globals = {};
for (const k of Object.keys(window)) {
if (!before.has(k) && typeof window[k] === "function") {
globals[k] = window[k];
}
}
cache.set(url, { mod: module.exports, globals });
console.info(
"[dc-runtime] x-import: loaded",
url,
"\u2014 exports:",
Object.keys(module.exports),
"window globals:",
Object.keys(globals)
);
onResolved();
}).catch((e) => {
cache.set(url, {
mod: {},
globals: {},
error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e))
});
console.error(
"[dc-runtime] x-import: FAILED to load",
url,
"(" + kind + ")",
e
);
onResolved();
});
pending.set(url, p);
return p;
}
function resolve2(url, name) {
const entry = cache.get(url);
if (!entry) return null;
const { mod, globals } = entry;
const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default;
if (typeof C === "function") return C;
const key = url + "\0" + name;
if (!reportedMissing.has(key)) {
reportedMissing.set(
key,
entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")"
);
console.error(
"[dc-runtime] x-import: module",
url,
"loaded but has no component named",
JSON.stringify(name),
"\u2014 available exports:",
Object.keys(mod),
"window globals:",
Object.keys(globals),
". The module must `module.exports = {" + name + "}` or set `window." + name + "`."
);
}
return null;
}
function waitForGlobal(name) {
if (polling.has(name)) return;
polling.add(name);
const started = Date.now();
const isCE = isCustomElementName(name);
const tick = () => {
const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name));
if (found) {
polling.delete(name);
onResolved();
return;
}
if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) {
console.warn(
"[dc-runtime] x-import: global",
JSON.stringify(name),
"never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms"
);
return;
}
setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
};
setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
}
function resolveGlobal(url, name) {
const isCE = isCustomElementName(name);
if (!url) {
if (isCE) {
if (customElements.get(name)) return name;
waitForGlobal(name);
return null;
}
const g2 = resolveDottedPath(window, name);
if (isRenderableType(g2)) return g2;
waitForGlobal(name);
return null;
}
const entry = cache.get(url);
if (!entry) return null;
if (isCE && customElements.get(name)) return name;
const g = entry.globals[name] ?? resolveDottedPath(window, name);
if (isRenderableType(g)) return g;
if (name.includes(".")) return null;
const key = url + "\0global\0" + name;
if (!reportedMissing.has(key)) {
reportedMissing.set(key, null);
if (isCE && !customElements.get(name)) {
console.warn(
"[dc-runtime] x-import:",
url,
"loaded but no custom element",
JSON.stringify(name),
"is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element."
);
}
}
return name;
}
function getError(url, name) {
const entry = cache.get(url);
if (entry?.error) return entry.error;
return reportedMissing.get(url + "\0" + name) || null;
}
return { load, resolve: resolve2, resolveGlobal, getError };
}
function isElementClass(g) {
try {
return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement;
} catch {
return false;
}
}
// src/atomics.ts
var ATOMIC_CSS = (
// layout
".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}"
);
// src/helmet.ts
var DESIGN_DOC_MODE_RE = /<meta\b[^>]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i;
var CANVAS_BG_LIGHT = "#f0eee6";
var CANVAS_BG_DARK = "#2e2c26";
function createHelmetManager(doc, isStreaming) {
const mounted = /* @__PURE__ */ new Set();
const live = /* @__PURE__ */ new Map();
let designDocMode = null;
let canvasStyleEl = null;
let appTheme = "light";
try {
const ds = doc.documentElement.dataset.theme;
appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get(
"theme"
) === "dark" ? "dark" : "light";
} catch {
}
function applyCanvasBg() {
if (!canvasStyleEl) return;
const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT;
canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`;
}
function postDesignMode(mode) {
if (window.parent === window) return;
try {
window.parent.postMessage({ type: "__dc_design_mode", mode }, "*");
} catch {
}
}
function setDesignDocMode(mode) {
if (mode === designDocMode) return;
designDocMode = mode;
postDesignMode(mode);
if (mode === "canvas") {
doc.documentElement.setAttribute("data-dc-canvas", "");
canvasStyleEl = doc.createElement("style");
canvasStyleEl.setAttribute("data-dc-canvas", "");
applyCanvasBg();
doc.head.appendChild(canvasStyleEl);
} else {
doc.documentElement.removeAttribute("data-dc-canvas");
canvasStyleEl?.remove();
canvasStyleEl = null;
}
}
window.addEventListener("message", (e) => {
const type = e.data && e.data.type;
if (type === "__dc_theme") {
const t = e.data.theme;
if (t === "light" || t === "dark") {
appTheme = t;
applyCanvasBg();
}
return;
}
if (!designDocMode || type !== "__dc_probe") return;
postDesignMode(designDocMode);
});
function compile(node) {
const raw = [...node.children];
const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null;
if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) {
mounted.add("__dc-atomics");
const el = doc.createElement("style");
el.id = "__dc-atomics";
el.textContent = ATOMIC_CSS;
doc.head.appendChild(el);
}
return (_vals, ctx) => {
const name = ctx && ctx.__name || "";
const streaming = !!(name && isStreaming(name));
for (let i = 0; i < raw.length; i++) {
const child = raw[i];
const tag = child.tagName;
const mayBePartial = streaming && !helmetClosed && i === raw.length - 1;
if (tag === "SCRIPT") {
if (mayBePartial) continue;
const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || "");
if (mounted.has(key)) continue;
mounted.add(key);
const el = doc.createElement("script");
for (const { name: an, value } of [...child.attributes])
el.setAttribute(an, value);
if (child.textContent) el.textContent = child.textContent;
doc.head.appendChild(el);
} else if (tag === "LINK" || tag === "META") {
if (mayBePartial) continue;
const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML);
if (mounted.has(key)) continue;
mounted.add(key);
if (tag === "LINK") {
const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/);
const href = (child.getAttribute("href") || "").trim();
const res = window.__resources;
const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0;
const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null;
if (blob) {
const el = doc.createElement("style");
if (child.hasAttribute("disabled")) {
el.setAttribute("media", "not all");
} else if (child.getAttribute("media")) {
el.setAttribute("media", child.getAttribute("media"));
}
if (child.getAttribute("title"))
el.setAttribute("title", child.getAttribute("title"));
void blob.text().then((css) => {
el.textContent = css;
});
doc.head.appendChild(el);
continue;
}
}
doc.head.appendChild(child.cloneNode(true));
} else {
const key = name + "|" + i;
let el = live.get(key);
if (!el || el.tagName !== tag) {
if (el) el.remove();
el = doc.createElement(tag.toLowerCase());
live.set(key, el);
doc.head.appendChild(el);
}
for (const { name: an, value } of [...child.attributes]) {
if (el.getAttribute(an) !== value) el.setAttribute(an, value);
}
if (el.textContent !== child.textContent)
el.textContent = child.textContent;
}
}
return null;
};
}
return { compile, setDesignDocMode };
}
// src/pseudo.ts
function scanUnquotedUrl(css, i) {
if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) {
return -1;
}
let j = i + 4;
while (j < css.length && /\s/.test(css[j])) j++;
if (css[j] === '"' || css[j] === "'") return -1;
while (j < css.length && css[j] !== ")") {
if (css[j] === "\\") j++;
j++;
}
return j < css.length ? j + 1 : css.length;
}
function stripComments(css) {
let out = "";
let quote = "";
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (quote) {
if (c === "\\") {
out += c + (css[i + 1] ?? "");
i++;
continue;
}
if (c === quote) quote = "";
out += c;
} else if (c === "'" || c === '"') {
quote = c;
out += c;
} else if (c === "/" && css[i + 1] === "*") {
const end = css.indexOf("*/", i + 2);
i = end === -1 ? css.length : end + 1;
out += " ";
} else {
const end = scanUnquotedUrl(css, i);
if (end === -1) out += c;
else {
out += css.slice(i, end);
i = end - 1;
}
}
}
return out;
}
function importantify(css) {
css = stripComments(css);
const decls = [];
let start = 0;
let depth = 0;
let quote = "";
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = "";
} else if (c === "'" || c === '"') quote = c;
else if (c === "(") depth++;
else if (c === ")") depth = Math.max(0, depth - 1);
else if (c === ";" && depth === 0) {
decls.push(css.slice(start, i));
start = i + 1;
} else {
const end = scanUnquotedUrl(css, i);
if (end !== -1) i = end - 1;
}
}
decls.push(css.slice(start));
return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";");
}
function createPseudoSheet(doc) {
let el = null;
const cache = /* @__PURE__ */ new Map();
let n = 0;
return (pseudo, css) => {
const k = pseudo + "|" + css;
const hit = cache.get(k);
if (hit) return hit;
if (!el) {
el = doc.createElement("style");
doc.head.appendChild(el);
}
const cls = "scp" + (n++).toString(36);
const isPseudoElement = pseudo === "before" || pseudo === "after";
const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo;
el.sheet.insertRule(
sel + "{" + (isPseudoElement ? css : importantify(css)) + "}",
el.sheet.cssRules.length
);
cache.set(k, cls);
return cls;
};
}
// src/registry.ts
function createRegistry() {
const entries = /* @__PURE__ */ Object.create(null);
function get(name) {
return entries[name] || (entries[name] = {
html: "",
tpl: null,
Logic: null,
jsStreaming: false,
htmlStreaming: false,
ver: 0,
subs: /* @__PURE__ */ new Set(),
fetched: false
});
}
function bump(name) {
const r = get(name);
r.ver++;
for (const fn of r.subs) fn();
}
return {
entries,
get,
bump,
bumpAll() {
for (const n in entries) bump(n);
}
};
}
// src/runtime.ts
var COMPONENT_DIR = ".";
function createRuntime(doc = document) {
const registry = createRegistry();
const pseudoClass = createPseudoSheet(doc);
const helmet = createHelmetManager(
doc,
(name) => registry.get(name).htmlStreaming
);
const external = createExternalModules(() => registry.bumpAll());
const factory = createComponentFactory(registry, ensureFetched);
const host = {
component: (name) => factory.getDC(name),
placeholder: (props) => h(Placeholder, props),
helmet: (node) => helmet.compile(node),
loadExternal: (kind, url, after) => external.load(kind, url, after),
resolveExternal: (url, name) => external.resolve(url, name),
resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name),
resolveExternalError: (url, name) => external.getError(url, name),
pseudoClass
};
function ensureFetched(name) {
const r = registry.get(name);
if (r.fetched) return;
r.fetched = true;
const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html";
const res = window.__resources;
const pre = res ? res[url] : void 0;
const target = typeof pre === "string" && pre ? pre : url;
const blob = bundledBlob(target);
(blob ? blob.text() : fetch(target).then((res2) => {
if (!res2.ok) {
console.error(
'[dc-runtime] sibling fetch for "' + name + '" failed:',
url,
"returned",
res2.status,
"\u2014 the reference renders as an empty placeholder."
);
return "";
}
return res2.text();
})).then((t) => {
if (!t) return;
const parsed = parseDcText(t);
if (!parsed) {
console.error(
'[dc-runtime] sibling fetch for "' + name + '":',
url,
"has no <x-dc> block \u2014 not a Design Component."
);
return;
}
if (parsed.props) r.propsMeta = parsed.props;
if (parsed.preview) r.preview = parsed.preview;
if (parsed.template && !r.html) updateHtml(name, parsed.template);
if (parsed.js && !r.Logic) updateJs(name, parsed.js);
}).catch(
(e) => console.error(
'[dc-runtime] sibling fetch for "' + name + '" threw:',
url,
e
)
);
}
let rootName = null;
function updateHtml(name, html) {
const r = registry.get(name);
r.html = html;
if (name === rootName) {
const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null;
if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode);
}
try {
r.tpl = compileTemplate(html, host);
} catch (e) {
console.error("[dc-runtime] template compile FAILED for", name, e);
}
registry.bump(name);
}
function updateJs(name, src) {
const r = registry.get(name);
const seq = r.jsSeq = (r.jsSeq || 0) + 1;
try {
const Cls = evalDcLogic(src);
if (r.jsSeq !== seq) return;
if (typeof Cls !== "function") {
r.logicError = name + ".dc.html: <script data-dc-script> must define `class Component extends DCLogic`";
} else {
r.logicError = null;
r.Logic = Cls;
}
} catch (e) {
if (r.jsSeq !== seq) return;
console.error(
"[dc-runtime] logic class eval FAILED for",
name,
"\u2014 the template renders with props only.",
e
);
r.logicError = name + ": " + (e instanceof Error && e.message ? e.message : String(e));
}
registry.bump(name);
}
function setStreaming(name, kind, on) {
const r = registry.get(name);
if (kind === "html") r.htmlStreaming = !!on;
else r.jsStreaming = !!on;
let any = false;
for (const n in registry.entries) {
const e = registry.entries[n];
if (e && (e.htmlStreaming || e.jsStreaming)) {
any = true;
break;
}
}
doc.documentElement.classList.toggle("sc-dc-streaming", any);
registry.bump(name);
}
function dcUpdate(name, kind, content, streaming) {
if (streaming) registry.get(name).fetched = true;
if (kind === "html") {
setStreaming(name, "html", !!streaming);
updateHtml(name, content);
} else if (kind === "js") {
setStreaming(name, "js", !!streaming);
if (!streaming) updateJs(name, content);
} else if (kind === "props") {
const { props, preview } = parseDataProps(content);
const r = registry.get(name);
r.propsMeta = props ?? void 0;
r.preview = preview;
registry.bump(name);
}
}
function setProps(name, overrides) {
registry.get(name).propOverrides = overrides && typeof overrides === "object" ? { ...overrides } : null;
registry.bump(name);
}
function adoptParsed(name, parsed) {
if (!parsed) return;
const r = registry.get(name);
if (parsed.props) r.propsMeta = parsed.props;
if (parsed.preview) r.preview = parsed.preview;
if (parsed.template) updateHtml(name, parsed.template);
if (parsed.js) updateJs(name, parsed.js);
}
return {
registry,
getDC: factory.getDC,
updateHtml,
updateJs,
dcUpdate,
setProps,
adoptParsed,
setRootName: (name) => {
rootName = name;
},
markFetched: (name) => {
registry.get(name).fetched = true;
},
annotatedTemplate: (name) => {
const r = registry.get(name);
return r.tpl && r.tpl.__annotated || null;
},
templateSource: (name) => registry.get(name).html || null,
StreamableLogic
};
}
// src/stream-state.ts
function createStreamTracker(staleMs = 6e4, now = Date.now) {
const since = /* @__PURE__ */ new Map();
const liveOne = (n) => {
const t = since.get(n);
if (t === void 0) return false;
if (now() - t > staleMs) {
since.delete(n);
return false;
}
return true;
};
return {
push(name, streaming, viewportKey) {
if (viewportKey === "dc-model") return;
if (streaming) since.set(name, now());
else since.delete(name);
},
live(name) {
if (name !== void 0) return liveOne(name);
for (const n of [...since.keys()]) if (liveOne(n)) return true;
return false;
}
};
}
// src/index.ts
function hideRawTemplate() {
const s = document.createElement("style");
s.textContent = "x-dc{display:none!important}";
document.head.appendChild(s);
}
function loadScript(src, integrity) {
return new Promise((resolve2, reject) => {
//! nosemgrep: create-script-element
const s = document.createElement("script");
s.src = src;
if (integrity) {
s.integrity = integrity;
s.crossOrigin = "anonymous";
}
s.async = false;
s.onload = () => resolve2();
s.onerror = () => reject(new Error(`failed to load ${src}`));
document.head.appendChild(s);
});
}
function loadReactUmd() {
const w = window;
if (w.React && w.ReactDOM) return Promise.resolve();
const react = cdnScriptFor(REACT_URL, REACT_SRI);
const reactDom = cdnScriptFor(REACT_DOM_URL, REACT_DOM_SRI);
return Promise.all([
loadScript(react.src, react.integrity),
loadScript(reactDom.src, reactDom.integrity)
]).then(() => void 0);
}
function init() {
const runtime = createRuntime(document);
let rootName = "Root";
const baseCss = document.createElement("style");
baseCss.textContent = BASE_CSS;
document.head.prepend(baseCss);
const notifyHost = () => {
if (window.parent === window) return;
const r = runtime.registry.entries[rootName];
try {
window.parent.postMessage(
{
type: "__dc_booted",
rootName,
propsMeta: r && r.propsMeta || null,
preview: r && r.preview || null
},
"*"
);
} catch {
}
};
const streams = createStreamTracker();
const api = {
__dcUpdate: (name, kind, content, streaming, viewportKey) => {
streams.push(name, streaming, viewportKey);
runtime.dcUpdate(name, kind, content, streaming);
if (name === rootName && !streaming && kind === "props") notifyHost();
},
__dcStreaming: (name) => streams.live(name),
__dcSetProps: (name, overrides) => runtime.setProps(name, overrides),
/** Name of the component currently mounted as the page root — DC tools
* push their template-stream here when targeting "the open page". */
__dcRootName: () => rootName,
/** Editor bridge — the encoded, `data-dc-tpl`-annotated template source.
* The host editor parses this into its own template DOM so it can map a
* rendered node (carrying the same `data-dc-tpl`) back to the source
* node that emitted it. Returns the encoded form (`sc-camel-*` attrs,
* `<sc-raw-*>`/`<sc-helmet>` tags); the editor decodes on serialize. */
__dcAnnotatedTemplate: (name) => runtime.annotatedTemplate(name),
/** Editor bridge — the *original* (decoded) template source. */
__dcTemplateSource: (name) => runtime.templateSource(name),
__dcBoot: () => {
rootName = boot(runtime, document) ?? rootName;
notifyHost();
},
__dcRegistry: runtime.registry.entries,
getDC: (name) => runtime.getDC(name),
// `DCLogic` is the documented base class name; `StreamableLogic` is the
// implementation alias kept for any project that already references it.
DCLogic: runtime.StreamableLogic,
StreamableLogic: runtime.StreamableLogic
};
Object.assign(window, api);
window.__dcContentKeyed = true;
if (document.readyState !== "loading") api.__dcBoot();
else document.addEventListener("DOMContentLoaded", () => api.__dcBoot());
}
hideRawTemplate();
loadReactUmd().then(init).catch((err) => {
console.error("[dc] failed to load React or boot:", err);
throw err;
});
})();
</script>
<script>
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
/**
* <image-slot> — user-fillable image placeholder.
*
* Drop this into a deck, mockup, or page wherever a design needs an image.
* You control the slot's shape; it sizes to its container by default. When the search_stock_photos tool
* is available, prefill the slot by default — write the photo's URL into
* src (with credit/credit-href); the user can still fill or replace 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 sidecar is a SIBLING of the HTML file that uses this component: the
* read is a document-relative fetch, and the host resolves the bridge's
* sidecar writes into the previewed file's directory to match (same
* contract as design_canvas.jsx). Pages in the same directory share one
* sidecar; keep slot ids distinct across them.
*
* 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 Initial framing baseline: cover | contain. (default 'cover')
* cover starts the image filling the frame (overflow cropped);
* contain starts it fully visible (letterboxed). Either way the
* user can always pan/scale from there — double-click, or the
* Edit control, enters reframe mode (drag to move, scroll or
* corner-handles to scale; Escape / click-out commits). The
* crop persists alongside the image in the sidecar.
* placeholder Empty-state caption. (default 'Drop an image')
* src Optional initial/fallback image URL. Prefill it with a real
* photo via search_stock_photos when that tool is available
* (set credit/credit-href from the result). A user drop
* overrides it; clearing the drop reveals src again.
* credit Attribution text shown as a small overlay at the
* bottom-left of the filled slot. REQUIRED whenever src
* points at any Unsplash host (images.unsplash.com,
* plus.unsplash.com, …): an Unsplash src with no credit
* renders an error tile INSTEAD of the photo (Unsplash
* terms forbid showing their photos unattributed). Use the
* exact form 'Photo by {photographer name} on Unsplash' —
* the overlay then links the name to credit-href and
* 'Unsplash' to the Unsplash homepage, and links back to
* unsplash.com automatically get the required utm referral
* params appended at render time. The credit belongs to
* the src image, so it only shows while src is what's
* displayed — a user-dropped image hides it.
* credit-href Link for the photographer's name in the credit overlay
* (their Unsplash profile URL from the stock-photo search
* results). http(s) URLs only — anything else renders the
* name as plain text.
*
* Sizing: the slot fills its container by default (width/height 100%).
* Put it in a sized wrapper — absolutely positioned, a grid cell, a fixed
* frame — and it takes exactly that box. When the parent's height is
* indefinite (ordinary flow), it falls back to full width at a 3:2 aspect
* ratio instead of collapsing. In a shrink-to-fit parent (a float,
* width:max-content, an unsized absolute wrapper), percentages have
* nothing to resolve against — size the slot or its wrapper explicitly
* there. For a fixed-size slot, set
* width/height on the element itself (inline style), which overrides the
* default. When
* layering content above a slot (full-bleed layouts), make the overlay
* click-through — pointer-events: none on scrims/text plates, re-enabled
* on interactive children — so the slot's hover controls stay reachable.
* Keep the slot's bottom-left corner visually clear as well: the credit
* overlay renders there, and a dark fade or text plate covering it hides
* the attribution Unsplash's terms require — end the fade above that
* corner, or keep it nearly transparent where the credit sits.
*
* Usage:
* <div style="position:relative;width:100%;height:100%"> <!-- full-bleed: -->
* <image-slot id="bg" shape="rect"></image-slot> <!-- fills the wrapper -->
* </div>
* <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20"
* placeholder="Drop a hero image"></image-slot>
* <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
* <image-slot id="kite" style="width:300px;height:300px"
* mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
*/
/* END USAGE */
(() => {
const STATE_FILE = '.image-slots.state.json';
// Unsplash terms require visible attribution wherever their photos
// display, and every link back to unsplash.com must carry utm referral
// params. Two render-time rules enforce that here:
// - an Unsplash-src slot with NO credit attribute renders an error
// tile INSTEAD of the photo (an uncredited Unsplash photo on screen
// is itself the terms violation, so it never renders bare);
// - rendered credit links pointing at unsplash.com get the referral
// params appended when absent (credit-href values live in page
// content that can't be edited after the fact).
// Keep the utm_source value in sync with UTM_SOURCE in
// platform/web-agent/unsplash.ts — this file is a project-local
// artifact and cannot import it (equality is pinned by tests).
const UNSPLASH_HOMEPAGE_HREF =
'https://unsplash.com/?utm_source=claude_design&utm_medium=referral';
// Host rule mirrors the hotlink validator that admits Unsplash srcs into
// pages in the first place (cdn$ in unsplash.ts: apex or any subdomain)
// — Unsplash+ results serve from plus.unsplash.com, not just images.*,
// and an admitted-but-uncredited photo must error whatever unsplash
// host it rides on.
// Trailing-dot FQDNs (images.unsplash.com.) are the same host to the
// browser but would miss the regex — strip one dot so the check fails
// CLOSED (unrecognized-but-real Unsplash srcs must error, not render).
const isUnsplashHost = (u) => {
try {
return /(^|\.)unsplash\.com$/.test(
new URL(u, document.baseURI).hostname.replace(/\.$/, '')
);
} catch {
return false;
}
};
// Render-time referral normalization for links back to Unsplash:
// appends utm_source/utm_medium when absent, preserves every existing
// query param, never overwrites an existing utm_source, and passes
// non-Unsplash URLs through untouched. Input is an ABSOLUTE validated
// http(s) URL (the credit render funnel resolves + validates first).
const withReferral = (href) => {
try {
const u = new URL(href);
if (!/(^|\.)unsplash\.com$/.test(u.hostname.replace(/\.$/, ''))) {
return href;
}
if (!u.searchParams.has('utm_source')) {
u.searchParams.set('utm_source', 'claude_design');
}
if (!u.searchParams.has('utm_medium')) {
u.searchParams.set('utm_medium', 'referral');
}
return u.toString();
} catch (e) {
return href;
}
};
// 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 <image-slot> 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;
// Unload-time flush: save()'s serialization defers a mid-RTT re-fire to a
// .then that never runs in an unloading document, silently dropping a
// pagehide commit. Post the current slots immediately instead — content
// is a superset snapshot of any in-flight save's, the write is a
// whole-file last-writer-wins replace, and postMessage FIFO delivers it
// to the host after the in-flight one, so a backend-side reorder at
// worst reproduces the dropped-commit outcome this flush improves on.
// Guarded on the initial sidecar read: pre-hydration slots can miss
// other slots' persisted entries, and flushing it would clobber them —
// that narrow case stays best-effort (the in-memory merge in load()
// cannot happen in an unloading document anyway).
function flushNow() {
if (!loaded) return;
const w = window.omelette && window.omelette.writeFile;
if (!w) return;
try { Promise.resolve(w(STATE_FILE, JSON.stringify(slots))).catch(() => {}); } catch (e) {}
}
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 =
// Fill the container by default: slots are usually placed inside a
// sized wrapper (a hero frame, a grid cell, an inset:0 layer) and are
// expected to take that box — a fixed intrinsic size would render as
// a small tile in the corner of a full-bleed wrapper instead.
// aspect-ratio is the companion fallback that keeps a bare slot
// visible when the parent's height is indefinite: height:100%
// resolves to auto there, and the ratio then derives height from
// width instead of letting the slot collapse to zero height.
// Explicit width/height on the element override all of this.
// color:inherit (not a fixed near-black): the placeholder chrome —
// empty-state icon/caption (currentColor) and the dashed ring — must
// read on dark decks too, and the slide's own text color is the one
// color guaranteed to contrast with the slide background. The soft
// look comes from opacity on those parts, not from a baked-in alpha.
':host{display:block;position:relative;' +
' font:13px/1.3 system-ui,-apple-system,sans-serif;' +
' width:100%;height:100%;aspect-ratio:3/2}' +
'.empty .cap,.empty .sub{opacity:.75}' +
'.frame{position:absolute;inset:0;overflow:hidden;background:rgba(127,127,127,.08)}' +
// .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 <img> inside is translucent; the real
// clipped <img> underneath shows the opaque in-mask crop.
// popover=manual promotes the spill to the top layer on reframe, so it is
// not clipped by any overflow:hidden / clip-path / scroll-container
// ancestor (a plain z-index can't escape overflow clipping). UA popover
// defaults (inset:0;margin:auto) are reset; _applyView sets viewport px.
'.spill{position:fixed;margin:0;inset:auto;border:0;padding:0;background:transparent;' +
' overflow:visible;transform:translate(-50%,-50%);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]) .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}' +
'.empty:hover .sub{opacity:1}' +
':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 currentColor;' +
' opacity:.35;transition:border-color .12s,opacity .12s}' +
':host([data-over]) .ring{border-color:#c96442;opacity:1}' +
':host([data-filled]) .ring{display:none}' +
// Controls overlay INSIDE the frame, pinned to the top-right corner, so
// a full-bleed slot in an overflow:hidden container still shows them
// (the old below-mask placement got clipped). Credit sits bottom-left,
// so top-right avoids collision. The blurred pill background keeps them
// legible over the image.
// The UA [popover] base rule styles the element in EVERY state (only
// display:none is gated on :not(:popover-open), and the display:flex
// below overrides that) — so the UA resets live HERE, like .spill's,
// or the ordinary hover-state strip renders as a bordered Canvas box
// centered by margin:auto. inset:auto precedes top/right (shorthand).
'.ctl{position:absolute;inset:auto;top:8px;right:8px;margin:0;border:0;padding:0;' +
' background:transparent;overflow:visible;' +
' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
' white-space:nowrap}' +
// While reframing, the spill owns the top layer and would swallow every
// click on the in-frame controls. Promoting .ctl into the top layer
// ABOVE the spill (shown after it — later popovers stack higher) keeps
// Edit-as-toggle and Replace clickable mid-reframe. _applyView pins it
// to the frame's top-right in viewport px (translateX(-100%)
// right-aligns against the computed left edge); inset:auto clears the
// base rule's top/right so the inline left/top position it alone.
'.ctl:popover-open{position:fixed;inset:auto;transform:translateX(-100%)}' +
':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}' +
// Replacement in flight: after a src swap the browser keeps painting
// the PREVIOUS image until the new one decodes, so a Replace would
// flash the old photo and then pop. Hide the stale frame (visibility,
// not display — _applyView geometry still applies) and spin until the
// new image reports in (load/error clears data-swapping).
':host([data-swapping]) .frame img{visibility:hidden}' +
'.loading{position:absolute;inset:0;display:none;align-items:center;' +
' justify-content:center;pointer-events:none}' +
':host([data-swapping]) .loading{display:flex}' +
'.loading::after{content:"";width:22px;height:22px;border-radius:50%;' +
' border:2px solid rgba(127,127,127,.25);border-top-color:currentColor;' +
' animation:om-slot-spin .7s linear infinite}' +
'@keyframes om-slot-spin{to{transform:rotate(360deg)}}' +
// Reduced motion: the static two-tone ring still reads as "working".
'@media (prefers-reduced-motion:reduce){.loading::after{animation:none}}' +
'.credit{position:absolute;left:6px;bottom:6px;max-width:calc(100% - 12px);display:none;' +
' padding:3px 7px;border-radius:5px;background:rgba(0,0,0,.55);color:#fff;' +
' font:10px/1.2 system-ui,-apple-system,sans-serif;text-decoration:none;' +
' white-space:nowrap;overflow:hidden;text-overflow:ellipsis;backdrop-filter:blur(6px)}' +
// The credit is a SPAN holding one or two <a>s (Unsplash's prescribed
// form links the photographer AND Unsplash) — anchors style inline so
// the overlay reads as one line of text.
'.credit a{color:inherit;text-decoration:none}' +
'.credit a:hover,.credit a:focus-visible{text-decoration:underline}' +
':host([data-filled][data-credit]) .credit{display:block}' +
// Exports must ship JUST the image — no hover controls, no credit chip
// (the host marks <html data-om-exporting> for the capture window; the
// page-level hide script can't reach shadow DOM, this rule can).
':host-context([data-om-exporting]) .ctl,' +
':host-context([data-om-exporting]) .credit{display:none !important}' +
// Print must ship just the image too: the hover-gated controls can be
// mid-hover when print() fires, and the credit chip is screen chrome —
// the same rule the capture window gets, keyed on print media instead
// of the host's data-om-exporting mark (the print path sets no mark).
'@media print{.ctl,.credit{display:none !important}}' +
// No export-window mask rules here on purpose: the export capture
// releases the replacement mask by REMOVING data-swapping (the
// shadow-root pass in pages/export/shared.ts HIDE_EXPORT_CHROME_SCRIPT)
// — attribute removal works in every engine (:host-context is
// Chromium-only), is scoped by construction to slots actually
// mid-swap, and hides the spinner through the same gate. A masked img
// would otherwise be silently dropped from PPTX decks (the capture
// walk skips visibility:hidden imgs).
// Attribution error tile: REPLACES the photo when an Unsplash src has
// no credit attribute — rendering the photo uncredited is the terms
// violation, so the photo must not appear at all.
// Calm and neutral on purpose (review feedback): the tile informs the
// user; the fix instructions are machine-facing (usage docblock, tool
// description, and the turn-end scan's bounce copy name the attributes
// for the agent).
'.attr-error{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;' +
' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
' background:#f2f1ef;color:#6e6c66;user-select:none;' +
' font:13px/1.45 system-ui,-apple-system,sans-serif}' +
'.attr-error svg{opacity:.55}' +
'.attr-error .cap{max-width:92%;font-weight:500;letter-spacing:.01em}' +
':host([data-attribution-error]) .attr-error{display:flex}' +
':host([data-attribution-error]) .ring{display:none}';
const icon =
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
'<path d="m21 15-5-5L5 21"/></svg>';
const warnIcon =
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>' +
'<path d="M12 9v4"/><path d="M12 17h.01"/></svg>';
class ImageSlot extends HTMLElement {
static get observedAttributes() {
return ['shape', 'radius', 'mask', 'fit', 'placeholder', 'src', 'id', 'credit', 'credit-href'];
}
/** Duplicate-slide hook (called by deck-stage, see its
* _remintDuplicateIds): copy this id's stored image, if any, under a
* freshly minted key and return that key — so a duplicated slide's
* slot keeps its dropped photo instead of reverting to the
* placeholder. 'isFree' is the caller's uniqueness check (document
* ids); candidates must ALSO be unused in the sidecar, which can
* hold keys from other pages sharing the project root. (An EMPTY
* slot on another page leaves no sidecar entry, so its id is not
* detectable here — a minted key can collide with it and that slot
* would show this photo. Same blast radius as two pages reusing an
* id by hand, which the shared sidecar already permits.) Returns null
* when no id could be minted (caller strips the id, today's
* behavior). */
static cloneSlot(fromId, isFree) {
if (typeof fromId !== 'string' || !fromId) return null;
// Pre-hydration the store can't veto candidates or source the copy
// — degrade to the strip (today's behavior) rather than mint
// against keys we can't see yet. Any rendered (= droppable) slot
// means load() has already settled.
if (!loaded) return null;
const stem = fromId.replace(/-\d+$/, '') || fromId;
for (let n = 2; n < 100; n++) {
const toId = stem + '-' + n;
if (toId === fromId) continue;
if (slots[toId] !== undefined) {
// Reuse a key holding this exact value (bytes AND crop) if no
// live element here owns it — a duplicate op the host refused
// after minting leaves such a key behind, and reusing keeps
// refused retries from accumulating one orphaned copy per
// attempt. Full equality (not just bytes) so a byte-identical
// key another PAGE owns with its own crop is stepped past, not
// adopted or rewritten. (Entries without .u never match.)
const prev = getSlot(toId);
const cur = getSlot(fromId);
if (!(prev && cur && prev.u && prev.u === cur.u &&
prev.s === cur.s && prev.x === cur.x && prev.y === cur.y &&
(typeof isFree !== 'function' || isFree(toId)))) continue;
return toId;
}
if (typeof isFree === 'function' && !isFree(toId)) continue;
const v = getSlot(fromId);
if (v) setSlot(toId, Object.assign({}, v));
return toId;
}
return null;
}
constructor() {
super();
// clonable: rail thumbnails deep-clone slides and carry this shadow
// along; reuse an already-cloned root so upgrade-after-clone works.
// (Deliberately NOT serializable — a getHTML consumer would embed
// multi-MB sidecar data-URLs into serialized page HTML.)
const root = this.shadowRoot ||
this.attachShadow({ mode: 'open', clonable: true });
// .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
// on the frame (circle, pill, rounded) can't clip them.
root.innerHTML =
'<style>' + stylesheet + '</style>' +
'<div class="frame" part="frame">' +
' <img part="image" alt="" draggable="false" style="display:none">' +
' <div class="empty" part="empty">' + icon +
' <div class="cap"></div>' +
' <div class="sub">or <u>browse files</u></div></div>' +
' <div class="attr-error" part="attribution-error">' + warnIcon +
' <div class="cap">This photo needs attribution</div></div>' +
' <div class="loading" part="loading"></div>' +
' <div class="ring" part="ring"></div>' +
'</div>' +
// Outside .frame, like .spill/.ctl — the frame's overflow:hidden +
// border-radius/clip-path would cut the credit off on circle/pill/mask.
// A SPAN, not an <a>: the prescribed Unsplash credit holds two links
// (photographer + Unsplash), built per-render in _render().
'<span class="credit" part="credit"></span>' +
'<div class="spill" popover="manual" data-dc-edit-transparent>' +
' <img class="ghost" alt="" draggable="false">' +
' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
'</div>' +
// data-dc-edit-transparent: the DC editor's edit-mode picker lets
// clicks through for chrome marked with it (EDIT_TRANSPARENT_SEL)
// — without it, Replace/Edit clicks in Edit mode are swallowed by
// element selection and the controls look dead.
'<div class="ctl" popover="manual" data-dc-edit-transparent><button data-act="replace" title="Replace image">Replace</button>' +
' <button data-act="edit" title="Reframe image">Edit</button></div>' +
'<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
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._ctl = root.querySelector('.ctl');
this._credit = root.querySelector('.credit');
this._attrError = root.querySelector('.attr-error');
// Credit clicks open the link, not browse/reframe.
this._credit.addEventListener('click', (e) => e.stopPropagation());
this._credit.addEventListener('dblclick', (e) => e.stopPropagation());
this._ghost = root.querySelector('.ghost');
this._err = null;
this._input = root.querySelector('input');
this._depth = 0;
this._gen = 0;
// Encode-in-flight marker (the owning _ingest generation): while set,
// the same-src "nothing in flight" clear in _render must not fire —
// the stored value still points at the OLD image until the encode
// lands, so that clear would unmask the stale image mid-replace.
this._swapGen = 0;
// Render-owned swap in flight: set when _render assigns a new src,
// cleared only by the img's own load/error (or the empty branch).
// img.complete CANNOT stand in for this — setting src only QUEUES
// the current-request swap (a microtask), so synchronously after an
// assignment, complete still reports the OLD settled request. The
// pick path does exactly that: the host sets src, credit, and
// credit-href back-to-back in one task, and renders #2/#3 would
// read the stale complete === true and drop the mask one render
// after it was set.
this._loadPending = false;
// See _render's empty branch: a transient attribution-error wipe of a
// showing image must make the follow-up render a replacement (spinner),
// not a first fill (blank frame).
this._hidShowing = false;
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) return;
// The hidden controls are opacity-0 but still tabbable — without
// this gate a keyboard user could drive them on a read-only share
// link (mirrors the dblclick handler's editable gate).
if (!this.hasAttribute('data-editable')) return;
if (act === 'replace') {
this._exitReframe(true);
// Host-owned picker (Unsplash modal; it also offers local import).
this.dispatchEvent(new CustomEvent('image-slot:pick', {
bubbles: true, composed: true, detail: { id: this.id || null }
}));
}
if (act === 'edit') {
if (!this._reframes()) return;
if (this.hasAttribute('data-reframe')) this._exitReframe(true);
else this._enterReframe();
}
});
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.
// load/error also release the replacement-in-flight mask (via the
// single discipline in _releaseMask): the swap is only revealed once
// the new image can actually paint (on error the frame shows its
// background, same as a fresh slot with a broken src).
this._img.addEventListener('load', () => {
this._loadPending = false;
this._releaseMask(true);
this._applyView();
});
this._img.addEventListener('error', () => {
this._loadPending = false;
this._releaseMask(true);
});
// Gated only on editable — any filled slot can be repositioned/scaled,
// regardless of fit. Share links (no writeFile) 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 contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain';
const base = contain ? Math.min(fw / iw, fh / ih) : 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('<image-slot> 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);
// The host may inject window.omelette.writeFile AFTER the first render;
// re-render on hover so the editable-gated controls reliably appear.
this.addEventListener('pointerenter', 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('pointerenter', 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; }
// commit=false: a disconnect is not a user intent — committing here
// would persist whatever half-finished drag a React remount or DOM
// splice happened to interrupt. Deliberate exits commit on their own
// paths (Escape/click-out/toggle), and unloads commit via pagehide.
this._exitReframe(false);
}
_enterReframe() {
if (this.hasAttribute('data-reframe')) return;
this.setAttribute('data-reframe', '');
this._signalReframe(true);
// Best-effort commit when the document unloads mid-reframe (a host
// navigation racing the enter signal, a manual reload, tab close):
// the sidecar write rides the host bridge, which outlives this
// document, so the crop survives even though the mode dies with the
// DOM. Held on the instance so _exitReframe detaches exactly what
// was attached.
this._pagehide = () => { this._exitReframe(true); flushNow(); };
window.addEventListener('pagehide', this._pagehide);
// Promote spill to the top layer, then keep it pinned over the frame:
// scroll/resize cover the common cases, and a per-frame rect check
// catches layout shifts that fire neither (an image above finishing
// load, streamed DOM pushing the slot down, an ancestor transform
// change) so the overlay can't detach from the frame.
try { this._spill.showPopover(); } catch {}
// After the spill, so the controls stack above it in the top layer.
try { this._ctl.showPopover(); } catch {}
this._reposition = () => { if (this.hasAttribute('data-reframe')) this._applyView(); };
window.addEventListener('scroll', this._reposition, true);
window.addEventListener('resize', this._reposition);
this._lastRect = '';
this._watch = () => {
if (!this.hasAttribute('data-reframe')) return;
const r = this.getBoundingClientRect();
const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height;
if (key !== this._lastRect) { this._lastRect = key; this._applyView(); }
this._watchId = requestAnimationFrame(this._watch);
};
this._watchId = requestAnimationFrame(this._watch);
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 (this._reposition) {
window.removeEventListener('scroll', this._reposition, true);
window.removeEventListener('resize', this._reposition);
this._reposition = null;
}
if (this._watchId) { cancelAnimationFrame(this._watchId); this._watchId = 0; }
if (this._pagehide) {
window.removeEventListener('pagehide', this._pagehide);
this._pagehide = null;
}
try { this._spill.hidePopover(); } catch {}
try { this._ctl.hidePopover(); } catch {}
this._ctl.style.left = ''; this._ctl.style.top = '';
if (commit) this._commitView();
this._signalReframe(false);
}
// Reframe state lives only in this DOM until commit, invisible to the
// host's dirty signals — announce enter/exit so the host can hold
// auto-reloads for exactly the gesture (the guest bundle forwards
// image-slot:reframe to the host as imageSlotReframe). Dispatched on
// the element (composed, so it escapes shadow roots) while connected;
// a disconnected exit (disconnectedCallback) falls back to document so
// the host still hears it.
_signalReframe(active) {
const target = this.isConnected ? this : document;
target.dispatchEvent(new CustomEvent('image-slot:reframe', {
bubbles: true, composed: true,
detail: { active: active, id: this.id || null }
}));
}
// Public: host's "Import from computer" calls this to run local browse.
openFilePicker() { this._exitReframe(true); this._input.click(); }
// A src write is a newer intent for this slot's content — the host
// pick path (setImageSlotImage) or an agent edit — so it must win
// over any encode still in flight from an earlier drop: left live,
// that encode lands later, passes _ingest's gen guard, and its
// setSlot silently overwrites the pick (the stored value shadows
// src in _render). Bumping _gen kills the encode before its own
// _swapGen clear runs, so clear the dead claim here too — otherwise
// _releaseMask (gated on !_swapGen) never fires and the pick's
// spinner is stranded. src ONLY: the pick sets credit/credit-href
// in the same task, and clearing _swapGen on those would let the
// same-src branch unmask the old image mid-encode.
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'src' && oldVal !== newVal) {
this._gen++;
this._swapGen = 0;
}
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;
// Replacing a shown image: surface the swap through the encode too,
// not just the decode — otherwise the old photo sits there with no
// feedback while the canvas re-encode runs. An empty slot keeps its
// placeholder (no spinner) until the encode lands, as before.
// _swapGen guards the mask against re-renders DURING the encode
// (pointerenter, ResizeObserver, another slot's store write): the
// stored value still resolves to the old image there, so _render's
// same-src clear would otherwise unmask it mid-replace.
if (this.hasAttribute('data-filled')) {
this.setAttribute('data-swapping', '');
this._swapGen = 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);
// Clear BEFORE setSlot: its synchronous re-render must see no
// pending encode, so a byte-identical re-upload (same data URL, no
// load event coming) still clears the mask via the complete branch.
this._swapGen = 0;
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._swapGen = 0;
// Reveal the kept old image — unless another replacement (a
// remote pick's src swap) is still in flight, in which case the
// mask stays until THAT image settles (its load/error releases).
this._releaseMask();
this._setError('Could not read that image.');
console.warn('<image-slot> 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 available on any filled slot — the user can
// always reposition/scale. `fit` only sets the initial baseline (see
// _geom): contain starts fully-visible, cover starts frame-filling.
_reframes() {
return this.hasAttribute('data-filled');
}
// The single release discipline for the replacement-in-flight mask
// (data-swapping). The mask comes off only when BOTH hold:
// - no encode is pending (_swapGen) — mid-encode the stored value
// still resolves to the old image, so any reveal paints it;
// - the frame img has settled on its current src — an unsettled src
// means some replacement is still in flight (e.g. a remote pick),
// whoever started it, and revealing would paint the previous
// frame. The load/error listeners pass settled=true (the event IS
// the settlement signal, per spec complete is true by then);
// other callers rely on the complete flag (covers loaded AND
// failed).
// Every release path funnels through here EXCEPT _render's empty
// branch (the img is being cleared — nothing will ever settle).
_releaseMask(settled) {
if (
!this._swapGen &&
!this._loadPending &&
(settled || this._img.complete)
) {
this.removeAttribute('data-swapping');
}
}
// Baseline geometry, shared by clamp/apply/resize. `base` is the scale at
// view-scale s=1: cover = fill the frame (overflow on the looser axis),
// contain = fit fully inside (letterboxed). Zooming a contain image past
// s where it overflows naturally becomes a crop. 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;
const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain';
const base = contain
? Math.min(fw / iw, fh / ih)
: Math.max(fw / iw, fh / ih);
return { iw, ih, fw, fh, base };
}
_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();
// Top-layer controls: pin to the frame's top-right in viewport px
// (the same 8px inset as the in-frame layout; unscaled — top-layer UI
// reads as chrome, not page content). BEFORE the geometry branch:
// placement needs only the frame rect, and a not-yet-loaded or broken
// src must not leave the promoted strip floating unpositioned. Gated
// on the popover actually being open: without the Popover API,
// showPopover() threw (swallowed in _enterReframe), .ctl stays in
// its in-frame absolute layout, and viewport-px coordinates would
// shove it off-frame — and matches(':popover-open') itself throws
// there (unknown pseudo-class), hence the try/catch.
if (this.hasAttribute('data-reframe')) {
let onTop = false;
try { onTop = this._ctl.matches(':popover-open'); } catch {}
if (onTop) {
const r = this.getBoundingClientRect();
this._ctl.style.left = (r.right - 8) + 'px';
this._ctl.style.top = (r.top + 8) + 'px';
}
}
if (!g) {
// Dimensions not known yet (before img load) — centered fit so there
// is no flash of an unpositioned image before the geometry lands.
const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain';
this._img.style.width = '100%';
this._img.style.height = '100%';
this._img.style.left = '50%';
this._img.style.top = '50%';
this._img.style.objectFit = contain ? 'contain' : 'cover';
return;
}
// Baseline (cover-fill or contain-fit) × view scale. 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 = '';
if (this.hasAttribute('data-reframe')) {
// Top-layer spill: position in viewport px over the frame. The top
// layer escapes ancestor transforms entirely, so EVERY term must be
// in viewport units: getBoundingClientRect gives the frame's scaled
// origin AND size, and the rect/layout ratio rescales the ghost —
// sizing from layout px alone renders it 1/scale too large under a
// scaled deck slide. Inner ghost + handles stay box-relative.
const r = this.getBoundingClientRect();
const sx = g.fw ? r.width / g.fw : 1;
const sy = g.fh ? r.height / g.fh : 1;
this._spill.style.width = (g.iw * k * sx) + 'px';
this._spill.style.height = (g.ih * k * sy) + 'px';
this._spill.style.left = (r.left + (50 + this._view.x) / 100 * r.width) + 'px';
this._spill.style.top = (r.top + (50 + this._view.y) / 100 * r.height) + 'px';
}
}
_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.
// An Unsplash src with no credit attribute must NOT render — showing
// the photo uncredited is the Unsplash-terms violation itself. The
// error tile replaces the photo until the credit is written. A
// user-dropped image is the user's own content and always renders.
// Trimmed: credit is agent/user-editable content, and a whitespace-
// only value must count as missing — otherwise it would suppress the
// error tile AND render an empty credit box (no text, no links),
// exactly the unattributed state this gate exists to prevent.
const credit = (this.getAttribute('credit') || '').trim();
const attrError = !!(
!credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr)
);
this.toggleAttribute('data-attribution-error', attrError);
if (url && !attrError) {
const prev = this._img.getAttribute('src');
if (prev !== url) {
// Replacing an already-shown image: mark the swap BEFORE setting
// src so the stale frame is never revealed (see the data-swapping
// stylesheet rules). First fill (prev empty) keeps the existing
// placeholder-until-load behavior — no spinner. _hidShowing
// covers the pick path's transient attribution-error wipe: prev
// is gone, but an image WAS showing, so this is a replacement.
if (prev || this._hidShowing) this.setAttribute('data-swapping', '');
// Mark the swap BEFORE assigning src: complete keeps reporting
// the old settled request until the browser's
// update-the-image-data microtask runs, so same-task re-renders
// (the pick path's credit/credit-href setAttributes) need this
// flag, not complete, to know a load is in flight.
this._loadPending = true;
this._img.src = url;
this._ghost.src = url;
} else {
// Same-src re-render — release if settled, so an ingest-set
// spinner can't stick after a byte-identical re-upload (same
// data URL, no further load event ever fires).
this._releaseMask();
}
this._hidShowing = false;
this._img.style.display = 'block';
this._empty.style.display = 'none';
this.setAttribute('data-filled', '');
this._clampView();
this._applyView();
} else {
this.removeAttribute('data-swapping');
// The src is being removed — no load/error will ever fire for it.
this._loadPending = false;
// A transient attribution-error wipe of a showing image happens on
// the pick path: the host sets src one setAttribute before credit,
// so render N hides the old image (attrError) and render N+1
// restores a URL. Remember the wipe so that restore renders as a
// replacement (spinner), not a first fill (blank frame).
this._hidShowing = attrError && !!this._img.getAttribute('src');
this._img.style.display = 'none';
this._img.removeAttribute('src');
this._ghost.removeAttribute('src');
// The error tile owns the blocked-photo state; .empty stays for
// the genuinely-empty slot.
this._empty.style.display = attrError ? 'none' : 'flex';
this.removeAttribute('data-filled');
}
// Credit belongs to the author src, so a user drop hides it.
// textContent + the http(s)-only funnel keep external strings inert.
const showCredit = !!(url && credit && !this._userUrl && !attrError);
this._credit.textContent = '';
if (showCredit) {
// Validate once (resolved against the document, http(s) only),
// then append the terms-required utm referral params to links
// that point back at unsplash.com.
let href = '';
const rawHref = this.getAttribute('credit-href') || '';
if (rawHref) {
try {
const u = new URL(rawHref, document.baseURI);
if (u.protocol === 'http:' || u.protocol === 'https:') {
href = withReferral(u.href);
}
} catch {}
}
const mkLink = (text, linkHref) => {
const a = document.createElement('a');
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
a.setAttribute('href', linkHref);
a.textContent = text;
return a;
};
// Unsplash's prescribed credit is TWO links — the photographer's
// name to their profile (credit-href) and 'Unsplash' to the
// homepage. Render that split whenever the text has the canonical
// shape; other text keeps the legacy single-link rendering.
const m = /^Photo by (.+) on Unsplash$/.exec(credit);
if (m) {
this._credit.appendChild(document.createTextNode('Photo by '));
this._credit.appendChild(
href ? mkLink(m[1], href) : document.createTextNode(m[1])
);
this._credit.appendChild(document.createTextNode(' on '));
this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF));
} else if (href) {
this._credit.appendChild(mkLink(credit, href));
} else {
this._credit.textContent = credit;
}
}
this.toggleAttribute('data-credit', showCredit);
}
}
if (!customElements.get('image-slot')) {
customElements.define('image-slot', ImageSlot);
}
})();
</script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vollkorn:ital,wght@0,500;0,700;0,900;1,500;1,700&family=Alegreya+Sans:wght@400;500;700&family=Noto+Serif+Tamil:wght@600;800&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: #fbf3e4; color: #2b1d1e; font-family: 'Alegreya Sans', sans-serif; font-size: 17px; overflow-x: hidden; }
a { color: #c8542b; } a:hover { color: #2b1d1e; }
@keyframes wordIn { from { opacity: 0; transform: translateY(60px) rotate(-6deg) skewX(-6deg); filter: blur(6px); } to { opacity: 1; transform: none; filter: none; } }
@keyframes cardIn { from { opacity: 0; transform: translateY(80px) rotate(-14deg) scale(.9); } to { opacity: 1; transform: rotate(-4deg); } }
@keyframes riseIn { from { opacity: 0; transform: translateY(70px); } to { opacity: 1; transform: none; } }
@keyframes slideL { from { opacity: 0; transform: translateX(-120px) rotate(-8deg); } to { opacity: 1; } }
@keyframes slideR { from { opacity: 0; transform: translateX(120px) rotate(8deg); } to { opacity: 1; } }
@keyframes drift { from { transform: translateY(80px); } to { transform: translateY(-80px); } }
@keyframes driftSlow { from { transform: translateY(40px); } to { transform: translateY(-40px); } }
@keyframes heroSettle { to { transform: translateY(120px) rotate(-9deg) scale(.86); opacity: .35; } }
@keyframes marqueeL { to { transform: translateX(-50%); } }
@keyframes marqueeR { from { transform: translateX(-50%); } to { transform: translateX(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes bob { 0%, 100% { transform: translateY(0) rotate(12deg); } 50% { transform: translateY(-10px) rotate(9deg); } }
@keyframes sunPulse { 0%, 100% { opacity: .75; transform: scale(1); } 50% { opacity: 1; transform: scale(1.06); } }
@keyframes cue { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(10px); } }
@keyframes inkWipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
[data-motion="calm"] * { animation: none !important; }
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; } }
</style>
</helmet>
<div ref="{{ rootRef }}" style="position:relative;">
<sc-if value="{{ grain }}" hint-placeholder-val="{{ true }}">
<div aria-hidden="true" style="position:fixed;inset:0;z-index:200;pointer-events:none;opacity:.32;mix-blend-mode:multiply;background-image:url(&quot;data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>&quot;);"></div>
</sc-if>
<nav style="position:fixed;top:14px;left:0;right:0;z-index:100;display:flex;justify-content:space-between;align-items:flex-start;gap:12px 20px;padding:0 clamp(14px,3vw,36px);pointer-events:none;">
<a href="#top" style="pointer-events:auto;display:flex;flex-direction:column;line-height:1;text-decoration:none;color:#2b1d1e;background:#fff8ec;border:2px solid #2b1d1e;padding:10px 16px 12px;transform:rotate(-2deg);box-shadow:4px 4px 0 #2b1d1e;">
<span style="font-family:'Vollkorn',serif;font-weight:900;font-size:19px;">New Life Fellowship</span>
<span style="font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c8542b;margin-top:5px;">Chennai City Church</span>
</a>
<div style="display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
<a href="#word" style="pointer-events:auto;font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#2b1d1e;text-decoration:none;background:#fff8ec;border:2px solid #2b1d1e;padding:9px 14px;transform:rotate(2deg);transition:transform .25s,background .25s;" style-hover="transform:rotate(0) translateY(-3px);background:#ffe2a8;">Sundays</a>
<a href="#family" style="pointer-events:auto;font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#2b1d1e;text-decoration:none;background:#fff8ec;border:2px solid #2b1d1e;padding:9px 14px;transform:rotate(-3deg) translateY(6px);transition:transform .25s,background .25s;" style-hover="transform:rotate(0) translateY(3px);background:#ffe2a8;">Our family</a>
<a href="#visit" style="pointer-events:auto;font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#2b1d1e;text-decoration:none;background:#fff8ec;border:2px solid #2b1d1e;padding:9px 14px;transform:rotate(1deg);transition:transform .25s,background .25s;" style-hover="transform:rotate(0) translateY(-3px);background:#ffe2a8;">Visit</a>
<a href="#contact" style="pointer-events:auto;font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#fff8ec;text-decoration:none;background:#c8542b;border:2px solid #2b1d1e;padding:9px 14px;transform:rotate(-2deg) translateY(4px);box-shadow:3px 3px 0 #2b1d1e;transition:transform .25s,background .25s;" style-hover="transform:rotate(0) translateY(1px);background:#2b1d1e;">Contact</a>
</div>
</nav>
<section id="top" data-screen-label="Hero" style="position:relative;min-height:100vh;padding:120px clamp(16px,4vw,56px) 60px;overflow:hidden;background:radial-gradient(ellipse 60% 50% at 75% 15%, rgba(233,165,58,.4), transparent 70%), #fbf3e4;">
<div aria-hidden="true" style="position:absolute;inset:0;background-image:url(&quot;data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='72' height='72' viewBox='0 0 72 72' fill='none' stroke='%232b1d1e' stroke-width='1.3'><g fill='%232b1d1e' stroke='none'><circle cx='18' cy='18' r='1.7'/><circle cx='54' cy='18' r='1.7'/><circle cx='18' cy='54' r='1.7'/><circle cx='54' cy='54' r='1.7'/></g><path d='M36 4C46 4 68 26 68 36C68 46 46 68 36 68C26 68 4 46 4 36C4 26 26 4 36 4Z'/><path d='M36 20C44 20 52 28 52 36C52 44 44 52 36 52C28 52 20 44 20 36C20 28 28 20 36 20Z'/><path d='M0 36C4 30 10 30 12 36C10 42 4 42 0 36ZM72 36C68 30 62 30 60 36C62 42 68 42 72 36ZM36 0C30 4 30 10 36 12C42 10 42 4 36 0ZM36 72C30 68 30 62 36 60C42 62 42 68 36 72Z'/></svg>&quot;);background-size:72px 72px;opacity:.14;mask-image:radial-gradient(ellipse at 50% 60%, #000 30%, transparent 75%);-webkit-mask-image:radial-gradient(ellipse at 50% 60%, #000 30%, transparent 75%);"></div>
<div aria-hidden="true" style="position:absolute;inset:0;opacity:.09;background-image:repeating-linear-gradient(90deg,#c8542b 0 14px,transparent 14px 44px,#4f6a2e 44px 50px,transparent 50px 96px),repeating-linear-gradient(0deg,#c8542b 0 14px,transparent 14px 44px,#e9a53a 44px 50px,transparent 50px 96px);mask-image:linear-gradient(180deg,transparent 35%,#000 100%);-webkit-mask-image:linear-gradient(180deg,transparent 35%,#000 100%);pointer-events:none;"></div>
<div aria-hidden="true" style="position:absolute;top:-140px;right:-100px;width:560px;height:560px;border-radius:50%;background:radial-gradient(circle, rgba(255,214,120,.7), rgba(255,214,120,0) 70%);animation:sunPulse 7s ease-in-out infinite;pointer-events:none;"></div>
<div aria-hidden="true" style="position:absolute;left:-2vw;bottom:-4vw;font-family:'Noto Serif Tamil',serif;font-weight:800;font-size:clamp(9rem,26vw,24rem);line-height:.8;color:transparent;-webkit-text-stroke:2px rgba(200,84,43,.22);letter-spacing:-.02em;pointer-events:none;white-space:nowrap;user-select:none;">&#2997;&#2979;&#2965;&#3021;&#2965;&#2990;&#3021;</div>
<div style="position:relative;max-width:1240px;margin:0 auto;display:grid;grid-template-columns:repeat(12,1fr);align-items:center;min-height:calc(100vh - 180px);">
<div style="grid-column:1 / span 12;grid-row:1;position:relative;z-index:2;padding-left:clamp(0px,3vw,60px);">
<div style="display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;animation:wordIn .8s cubic-bezier(.2,.8,.2,1) both;">
<span style="font-family:'Noto Serif Tamil',serif;font-weight:600;font-size:clamp(1.2rem,1.8vw,1.5rem);color:#c8542b;">&#2997;&#2979;&#2965;&#3021;&#2965;&#2990;&#3021;</span>
<span style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Welcome home</span>
<span style="display:inline-block;font-family:'Noto Serif Tamil',serif;font-weight:600;font-size:15px;background:#2b1d1e;color:#ffe2a8;padding:3px 12px 5px;transform:rotate(-2deg);margin-left:6px;">&#2984;&#2990;&#3021;&#2990; &#2970;&#3014;&#2985;&#3021;&#2985;&#3016; <span style="font-family:'Alegreya Sans',sans-serif;font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;opacity:.85;">&middot; Namma Chennai</span></span>
</div>
<h1 style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(4.4rem,13vw,11rem);line-height:.86;letter-spacing:-.03em;margin:18px 0 0;color:#2b1d1e;display:flex;flex-direction:column;align-items:flex-start;">
<span style="display:inline-block;text-shadow:5px 5px 0 #e9a53a;animation:wordIn .9s cubic-bezier(.2,.8,.2,1) .1s both;">Meet.</span>
<span style="display:inline-block;margin-left:clamp(30px,9vw,150px);transform:rotate(-2deg);text-shadow:5px 5px 0 #4f6a2e;animation:wordIn .9s cubic-bezier(.2,.8,.2,1) .28s both;">Belong.</span>
<span style="display:inline-block;margin-left:clamp(10px,4vw,60px);color:#c8542b;text-shadow:5px 5px 0 #2b1d1e;animation:wordIn .9s cubic-bezier(.2,.8,.2,1) .46s both;">Grow.</span>
</h1>
</div>
<figure style="grid-column:7 / span 6;grid-row:1;position:relative;z-index:1;margin:0;justify-self:end;width:clamp(260px,34vw,440px);transform:rotate(-4deg);animation:cardIn 1.2s cubic-bezier(.2,.8,.2,1) .3s both;">
<div style="animation:heroSettle linear both;animation-timeline:scroll();animation-range:0 90vh;">
<div style="background:#f6ecd9;border:2px solid #2b1d1e;box-shadow:12px 12px 0 #e9a53a;padding:clamp(12px,1.6vw,22px);">
<svg viewBox="0 0 600 640" role="img" aria-label="Print of the burning bush: bare wine-coloured branches inside a soft peach flame, rock shards at its foot" style="width:100%;height:auto;display:block;">
<defs>
<linearGradient id="bbFlame" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stop-color="#f9c79b"></stop><stop offset=".55" stop-color="#f4aa88"></stop><stop offset="1" stop-color="#f2a08a"></stop></linearGradient>
<filter id="bbGrain" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency=".035" numOctaves="2" seed="3" result="w"></feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="w" scale="10" xChannelSelector="R" yChannelSelector="G" result="d"></feDisplacementMap>
<feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="1" seed="8" result="n"></feTurbulence>
<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.2 2.6" result="m"></feColorMatrix>
<feComposite in="d" in2="m" operator="in"></feComposite>
</filter>
<filter id="bbSpeck" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency=".6" numOctaves="2" seed="5" result="n"></feTurbulence>
<feColorMatrix in="n" type="matrix" values="0 0 0 0 .96 0 0 0 0 .93 0 0 0 0 .85 0 0 0 -22 13" result="s"></feColorMatrix>
<feComposite in="s" in2="SourceGraphic" operator="in"></feComposite>
</filter>
</defs>
<g filter="url(#bbGrain)">
<path d="M150 500C120 470 100 420 108 370C114 330 130 290 122 250C118 220 140 190 160 175C150 150 165 110 190 80C198 110 210 140 225 150C232 110 250 70 262 30C278 60 300 90 300 120C310 80 335 50 356 30C372 60 372 100 385 130C400 110 420 90 445 80C450 120 460 150 470 180C490 170 510 200 500 240C498 280 520 320 512 360C505 400 490 450 460 500C420 530 380 545 300 540C230 545 180 530 150 500Z" fill="url(#bbFlame)"></path>
<path d="M262 30C278 60 300 90 300 120C296 96 280 78 270 68Z M356 30C372 60 372 100 385 130C376 106 362 92 354 84Z" fill="#f6ecd9" opacity=".7"></path>
<path d="M150 500C120 470 100 420 108 370C114 330 130 290 122 250C118 220 140 190 160 175C150 150 165 110 190 80C198 110 210 140 225 150C232 110 250 70 262 30C278 60 300 90 300 120C310 80 335 50 356 30C372 60 372 100 385 130C400 110 420 90 445 80C450 120 460 150 470 180C490 170 510 200 500 240C498 280 520 320 512 360C505 400 490 450 460 500C420 530 380 545 300 540C230 545 180 530 150 500Z" fill="#f6ecd9" filter="url(#bbSpeck)" opacity=".7"></path>
</g>
<g fill="none" stroke="#7d2652" stroke-linejoin="round" stroke-linecap="butt">
<path d="M300 562L282 536L304 508L296 478" stroke-width="17"></path>
<path d="M296 478L252 450L236 405L212 370L190 330M296 478L344 452L380 420L400 380L426 352" stroke-width="11"></path>
<path d="M296 478L272 430L280 380L262 330L270 280M296 478L318 420L312 360L328 300L322 240" stroke-width="10"></path>
<path d="M252 450L210 440L172 446L140 430M344 452L392 448L432 458L462 440" stroke-width="8"></path>
<path d="M236 405L200 392L170 398M318 420L344 404L364 368M380 420L410 408L440 414M344 452L376 468L398 466M280 380L300 340L296 300" stroke-width="7"></path>
<path d="M212 370L176 362M190 330L166 322L140 330M190 330L184 296L200 268M280 380L246 356L240 320M262 330L236 300M312 360L340 330L336 290M400 380L434 364L456 368M426 352L452 326L456 296M426 352L412 316L424 282M400 380L392 340L404 310M172 446L142 462M432 458L470 470" stroke-width="6"></path>
<path d="M236 405L228 372M270 280L250 240M270 280L292 244L288 214M328 300L352 262L346 222M322 240L302 204M322 240L344 208M456 296L476 270M462 440L484 414M140 430L114 408M172 446L160 412M200 268L190 244M336 290L354 268M240 320L222 300M184 296L164 284M424 282L438 256M364 368L386 350" stroke-width="5"></path>
</g>
<g fill="#7d2652">
<path d="M266 574L300 558L334 566L322 584L280 586Z"></path>
<path d="M196 588L220 568L250 580L244 594Z M338 604L366 588L394 594L388 608Z M416 572L440 556L468 566L460 580Z M254 616L278 602L296 610L290 620Z M136 604L154 594L170 600L166 610Z"></path>
</g>
<ellipse cx="300" cy="612" rx="200" ry="10" fill="#7d2652" opacity=".08"></ellipse>
</svg>
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-top:12px;padding-top:10px;border-top:1px solid #2b1d1e;font-family:'Vollkorn',serif;">
<span style="font-style:italic;font-size:14px;color:#5b4a43;">&ldquo;The bush burned with fire, and the bush was not consumed.&rdquo;</span>
<span style="font-weight:700;font-size:12px;letter-spacing:2px;text-transform:uppercase;white-space:nowrap;">Exodus III : 2</span>
</div>
</div>
</div>
<div aria-hidden="true" style="position:absolute;top:-48px;right:-40px;width:128px;height:128px;animation:bob 5s ease-in-out infinite;">
<svg viewBox="0 0 128 128" style="width:100%;height:100%;display:block;overflow:visible;">
<circle cx="64" cy="64" r="60" fill="#e9a53a" stroke="#2b1d1e" stroke-width="2"></circle>
<g style="transform-origin:64px 64px;animation:spin 22s linear infinite;">
<defs><path id="ring" d="M64 64m-46 0a46 46 0 1 1 92 0a46 46 0 1 1 -92 0"></path></defs>
<text font-family="Alegreya Sans, sans-serif" font-weight="700" font-size="12.5" letter-spacing="2.4" fill="#2b1d1e"><textPath href="#ring">SUNDAYS 4 PM &#183; EVERYONE WELCOME &#183; CHENNAI &#183; </textPath></text>
</g>
<text x="64" y="72" text-anchor="middle" font-family="Vollkorn, serif" font-weight="900" font-size="30" fill="#2b1d1e">4</text>
</svg>
</div>
</figure>
<div style="grid-column:1 / span 7;grid-row:2;margin-top:clamp(20px,3vw,44px);position:relative;z-index:2;display:flex;flex-direction:column;align-items:flex-start;gap:26px;animation:wordIn .9s cubic-bezier(.2,.8,.2,1) .7s both;">
<p style="font-family:'Vollkorn',serif;font-size:clamp(1.25rem,1.9vw,1.6rem);line-height:1.45;color:#4a3a37;max-width:520px;margin:0;text-wrap:pretty;background:#fff8ec;padding:18px 22px;border:2px solid #2b1d1e;transform:rotate(1deg);box-shadow:6px 6px 0 #c8542b;">God still meets ordinary people in ordinary places. We are a church family in Chennai, and there is a seat saved for you this Sunday.</p>
<div style="display:flex;align-items:center;gap:22px;flex-wrap:wrap;">
<a href="#visit" style="display:inline-block;padding:17px 36px;background:#c8542b;color:#fff8ec;font-size:14px;font-weight:700;letter-spacing:2px;text-transform:uppercase;text-decoration:none;border:2px solid #2b1d1e;box-shadow:5px 5px 0 #2b1d1e;transform:rotate(-1.5deg);transition:transform .2s,box-shadow .2s,background .2s;" style-hover="transform:rotate(0) translate(-2px,-2px);box-shadow:8px 8px 0 #2b1d1e;background:#2b1d1e;">Plan your Sunday <span style="font-family:'Noto Serif Tamil',serif;font-weight:600;text-transform:none;letter-spacing:0;margin-left:10px;opacity:.9;">&middot; &#2997;&#3006;&#2969;&#3021;&#2965;</span></a>
<a href="#word" style="font-size:14px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#2b1d1e;text-decoration:none;border-bottom:3px solid #e9a53a;padding-bottom:3px;" style-hover="color:#c8542b;">This week&rsquo;s word</a>
</div>
</div>
</div>
<div aria-hidden="true" style="position:absolute;bottom:26px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:6px;font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">
<span>Scroll</span><span style="display:block;width:2px;height:34px;background:#c8542b;animation:cue 1.6s ease-in-out infinite;"></span>
</div>
</section>
<div aria-hidden="true" style="position:relative;z-index:3;transform:rotate(-2deg);margin:-30px -4vw 0;">
<div style="height:14px;background:repeating-conic-gradient(#2b1d1e 0 25%,#e9a53a 0 50%) 0 0/28px 28px;border-top:2px solid #2b1d1e;"></div>
<div style="background:#e9a53a;color:#2b1d1e;padding:14px 0;overflow:hidden;white-space:nowrap;font-family:'Vollkorn',serif;font-weight:700;font-size:18px;border-top:2px solid #2b1d1e;border-bottom:2px solid #2b1d1e;">
<div style="display:inline-flex;width:max-content;animation:marqueeL 34s linear infinite;">
<span style="padding-right:40px;">Sundays 4 PM &nbsp;&#10022;&nbsp; Namma Chennai &nbsp;&#10022;&nbsp; <span style="font-family:'Noto Serif Tamil',serif;font-weight:600;">&#2997;&#2979;&#2965;&#3021;&#2965;&#2990;&#3021;</span> &nbsp;&#10022;&nbsp; Everyone welcome &nbsp;&#10022;&nbsp; Come as you are &nbsp;&#10022;&nbsp; Filter kaapi after &nbsp;&#10022;&nbsp; Veyil or mazhai, we meet &nbsp;&#10022;&nbsp; T. Nagar to Tambaram, Adyar to Anna Nagar &nbsp;&#10022;&nbsp; Meet &middot; Belong &middot; Grow &nbsp;&#10022;&nbsp;</span>
<span style="padding-right:40px;">Sundays 4 PM &nbsp;&#10022;&nbsp; Namma Chennai &nbsp;&#10022;&nbsp; <span style="font-family:'Noto Serif Tamil',serif;font-weight:600;">&#2997;&#2979;&#2965;&#3021;&#2965;&#2990;&#3021;</span> &nbsp;&#10022;&nbsp; Everyone welcome &nbsp;&#10022;&nbsp; Come as you are &nbsp;&#10022;&nbsp; Filter kaapi after &nbsp;&#10022;&nbsp; Veyil or mazhai, we meet &nbsp;&#10022;&nbsp; T. Nagar to Tambaram, Adyar to Anna Nagar &nbsp;&#10022;&nbsp; Meet &middot; Belong &middot; Grow &nbsp;&#10022;&nbsp;</span>
</div>
</div>
<div style="background:#2b1d1e;color:#fff8ec;padding:10px 0;overflow:hidden;white-space:nowrap;font-size:13px;font-weight:700;letter-spacing:3px;text-transform:uppercase;margin-top:-2px;">
<div style="display:inline-flex;width:max-content;animation:marqueeR 46s linear infinite;">
<span style="padding-right:48px;">A church family for the whole city &nbsp;&#8226;&nbsp; No one walks alone &nbsp;&#8226;&nbsp; Open Bible, honest questions &nbsp;&#8226;&nbsp; Bring the kids, bring the neighbours &nbsp;&#8226;&nbsp;</span>
<span style="padding-right:48px;">A church family for the whole city &nbsp;&#8226;&nbsp; No one walks alone &nbsp;&#8226;&nbsp; Open Bible, honest questions &nbsp;&#8226;&nbsp; Bring the kids, bring the neighbours &nbsp;&#8226;&nbsp;</span>
</div>
</div>
</div>
<section id="visit" data-screen-label="Visit" style="position:relative;padding:clamp(110px,14vw,180px) clamp(16px,4vw,56px) clamp(90px,11vw,150px);background:radial-gradient(ellipse 60% 50% at 80% 10%, rgba(233,165,58,.3), transparent 70%), #fbf3e4;overflow:hidden;">
<div style="max-width:1240px;margin:0 auto;display:grid;grid-template-columns:repeat(12,1fr);gap:24px;align-items:start;">
<div data-reveal style="grid-column:1 / span 6;position:relative;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c8542b;">Where &amp; when</div>
<h2 style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(2.6rem,5.6vw,4.6rem);line-height:.98;letter-spacing:-.02em;margin:14px 0 0;text-wrap:balance;">Come home this Sunday</h2>
<div style="display:inline-flex;flex-direction:column;margin-top:30px;padding:20px 30px 24px;background:#e9a53a;border:2px solid #2b1d1e;box-shadow:8px 8px 0 #2b1d1e;transform:rotate(-3deg);">
<span style="font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#2b1d1e;">Every Sunday at</span>
<span style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(3rem,6vw,5rem);line-height:1;margin-top:4px;">4:00 PM</span>
<span style="font-size:13px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#2b1d1e;margin-top:8px;">In English &middot; Sunday school for kids just before</span>
</div>
<address style="font-style:normal;margin-top:30px;font-size:1.1rem;line-height:1.65;color:#5b4a43;">
<b style="color:#2b1d1e;">NLF Chennai City Church</b><br>Right next to Spencer Plaza, Anna Salai<br>Chennai &middot; <b style="color:#2b1d1e;">Service in English</b><br>
<span style="font-family:'Vollkorn',serif;font-style:italic;color:#2b1d1e;">Veshti, saree, jeans or shorts. Come as you are. We&rsquo;ll save you a seat.</span>
</address>
<div style="display:inline-block;margin-top:24px;background:#fff8ec;border:2px solid #2b1d1e;padding:8px 16px 10px;transform:rotate(2.5deg);box-shadow:4px 4px 0 #4f6a2e;font-family:'Vollkorn',serif;font-style:italic;font-size:1.05rem;">Veyil or mazhai, we still meet. <span style="font-family:'Noto Serif Tamil',serif;font-style:normal;font-weight:600;font-size:.95rem;color:#c8542b;">&#2997;&#3006;&#2969;&#3021;&#2965;!</span></div>
</div>
<div data-reveal style="grid-column:7 / span 6;position:relative;transform:rotate(2deg);animation:slideR linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="border:2px solid #2b1d1e;box-shadow:12px 12px 0 #c8542b;background:#f0e6d2;padding:10px;">
<iframe title="NLF Chennai City Church on the map" src="https://maps.google.com/maps?q=NLF%20Chennai%20City%20Church&t=&z=14&ie=UTF8&iwloc=&output=embed" loading="lazy" referrerpolicy="no-referrer-when-downgrade" style="width:100%;height:clamp(300px,44vh,480px);border:0;display:block;filter:sepia(.35) saturate(.9);"></iframe>
</div>
<div aria-hidden="true" style="position:absolute;top:-22px;left:-18px;background:#fff8ec;border:2px solid #2b1d1e;padding:8px 14px;font-family:'Vollkorn',serif;font-style:italic;font-size:15px;transform:rotate(-6deg);box-shadow:3px 3px 0 #2b1d1e;">You are here &#8600;</div>
</div>
<div data-reveal style="grid-column:2 / span 10;margin-top:clamp(30px,5vw,70px);display:flex;flex-wrap:wrap;gap:18px;justify-content:center;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="flex:1 1 240px;max-width:340px;background:#e9a53a;border:2px solid #2b1d1e;padding:20px 22px;transform:rotate(2deg) translateY(-10px);"><div style="font-family:'Vollkorn',serif;font-weight:900;font-size:2rem;line-height:1;">Before 4</div><div style="margin-top:10px;line-height:1.55;color:#2b1d1e;"><b>Sunday school.</b> Kids gather for their own time in the Word before the service begins.</div></div>
<div style="flex:1 1 240px;max-width:340px;background:#fff8ec;border:2px dashed #2b1d1e;padding:20px 22px;transform:rotate(-1.5deg);"><div style="font-family:'Vollkorn',serif;font-weight:900;font-size:2rem;line-height:1;">4:00</div><div style="margin-top:10px;line-height:1.55;color:#5b4a43;"><b style="color:#2b1d1e;">Worship.</b> We sing like we mean it.</div></div>
<div style="flex:1 1 240px;max-width:340px;background:#fff8ec;border:2px dashed #2b1d1e;padding:20px 22px;transform:rotate(1deg) translateY(14px);"><div style="font-family:'Vollkorn',serif;font-weight:900;font-size:2rem;line-height:1;">4:40</div><div style="margin-top:10px;line-height:1.55;color:#5b4a43;"><b style="color:#2b1d1e;">The Word.</b> Honest, open-Bible preaching.</div></div>
<div style="flex:1 1 240px;max-width:340px;background:#fff8ec;border:2px dashed #2b1d1e;padding:20px 22px;transform:rotate(-2.5deg);"><div style="font-family:'Vollkorn',serif;font-weight:900;font-size:2rem;line-height:1;">5:45</div><div style="margin-top:10px;line-height:1.55;color:#5b4a43;"><b style="color:#2b1d1e;">Kaapi &amp; conversations.</b> Stay a while; this is where friendships begin.</div></div>
</div>
<div aria-hidden="true" style="grid-column:1 / span 12;display:flex;justify-content:center;margin-top:clamp(20px,3vw,40px);"><a href="#word" style="font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;text-decoration:none;border-bottom:2px solid #e9a53a;padding-bottom:3px;" style-hover="color:#c8542b;">Curious what we&rsquo;re hearing on Sundays? &darr;</a></div>
</div>
</section>
<section id="word" data-screen-label="The Word" style="position:relative;padding:clamp(110px,14vw,180px) clamp(16px,4vw,56px) clamp(80px,10vw,140px);background:linear-gradient(180deg,#fbf3e4,#f6e7cf);overflow:hidden;">
<div aria-hidden="true" style="position:absolute;top:8%;right:-6%;font-family:'Vollkorn',serif;font-weight:900;font-style:italic;font-size:clamp(8rem,22vw,20rem);line-height:1;color:rgba(200,84,43,.08);pointer-events:none;user-select:none;animation:driftSlow linear both;animation-timeline:view();">John</div>
<div style="max-width:1200px;margin:0 auto;position:relative;">
<div style="display:grid;grid-template-columns:repeat(12,1fr);gap:20px;align-items:start;">
<div data-reveal style="grid-column:1 / span 5;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c8542b;">The Word</div>
<h2 style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(2.6rem,5.6vw,4.6rem);line-height:.98;letter-spacing:-.02em;margin:14px 0 0;text-wrap:balance;">What we are hearing on Sundays</h2>
<p style="margin:22px 0 0;font-size:1.15rem;line-height:1.6;color:#5b4a43;max-width:420px;">We are reading slowly through the Gospel of John, one Sunday at a time. Open-Bible preaching, honest questions welcome.</p>
</div>
<blockquote data-reveal style="grid-column:6 / span 7;margin:clamp(30px,5vw,80px) 0 0;position:relative;background:#fff8ec;padding:clamp(28px,4vw,52px) clamp(24px,3.5vw,48px);transform:rotate(1.4deg);clip-path:polygon(0 3%,4% 0,22% 1.5%,40% 0,61% 2%,80% 0,96% 1%,100% 4%,99% 30%,100% 55%,98.5% 78%,100% 97%,96% 100%,74% 98.5%,52% 100%,30% 98.5%,10% 100%,0 97%,1.5% 70%,0 40%);box-shadow:0 30px 60px -30px rgba(43,29,30,.35);animation:slideR linear both;animation-timeline:view();animation-range:entry 0% entry 60%;">
<p style="font-family:'Vollkorn',serif;font-weight:500;font-size:clamp(1.5rem,2.7vw,2.4rem);line-height:1.32;margin:0;color:#2b1d1e;text-wrap:pretty;">&ldquo;For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life. For God sent not his Son into the world to condemn the world; but that the world through him might be saved.&rdquo;</p>
<footer style="margin-top:22px;font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c8542b;">John 3 : 16&ndash;17</footer>
</blockquote>
</div>
<div style="position:relative;margin-top:clamp(60px,9vw,130px);">
<div data-reveal style="display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<h3 style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(2rem,4vw,3.2rem);margin:0;display:inline-block;background:#2b1d1e;color:#fff8ec;padding:6px 18px 10px;transform:rotate(-1.5deg);">Love Overflow</h3>
<span style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c8542b;">Current series &middot; The Gospel of John</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:22px 30px;margin-top:40px;padding:0 2vw;">
<div data-reveal style="background:#c8542b;color:#fff8ec;padding:26px 24px 30px;border:2px solid #2b1d1e;box-shadow:7px 7px 0 #2b1d1e;transform:rotate(-3deg);transition:transform .35s cubic-bezier(.2,.8,.2,1);animation:slideL linear both;animation-timeline:view();animation-range:entry 0% entry 70%;" style-hover="transform:rotate(0) translateY(-8px) scale(1.03);"><div style="font-family:'Vollkorn',serif;font-weight:700;font-size:13px;letter-spacing:2px;">PART I</div><div style="font-family:'Vollkorn',serif;font-size:1.5rem;line-height:1.15;margin-top:12px;">Loved before we knew</div><div style="margin-top:16px;font-size:14px;font-weight:700;letter-spacing:1.5px;opacity:.85;">JOHN 3 : 16</div></div>
<div data-reveal style="background:#e9a53a;color:#2b1d1e;padding:26px 24px 30px;border:2px solid #2b1d1e;box-shadow:7px 7px 0 #2b1d1e;transform:rotate(2deg) translateY(28px);transition:transform .35s cubic-bezier(.2,.8,.2,1);animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;" style-hover="transform:rotate(0) translateY(20px) scale(1.03);"><div style="font-family:'Vollkorn',serif;font-weight:700;font-size:13px;letter-spacing:2px;">PART II</div><div style="font-family:'Vollkorn',serif;font-size:1.5rem;line-height:1.15;margin-top:12px;">Love that abides</div><div style="margin-top:16px;font-size:14px;font-weight:700;letter-spacing:1.5px;opacity:.85;">JOHN 15 : 9</div></div>
<div data-reveal style="background:#4f6a2e;color:#fff8ec;padding:26px 24px 30px;border:2px solid #2b1d1e;box-shadow:7px 7px 0 #2b1d1e;transform:rotate(-1.5deg) translateY(-10px);transition:transform .35s cubic-bezier(.2,.8,.2,1);animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;" style-hover="transform:rotate(0) translateY(-18px) scale(1.03);"><div style="font-family:'Vollkorn',serif;font-weight:700;font-size:13px;letter-spacing:2px;">PART III</div><div style="font-family:'Vollkorn',serif;font-size:1.5rem;line-height:1.15;margin-top:12px;">Love one another</div><div style="margin-top:16px;font-size:14px;font-weight:700;letter-spacing:1.5px;opacity:.85;">JOHN 13 : 34</div></div>
<div data-reveal style="background:#2b1d1e;color:#fff8ec;padding:26px 24px 30px;border:2px solid #2b1d1e;box-shadow:7px 7px 0 #e9a53a;transform:rotate(3.5deg) translateY(18px);transition:transform .35s cubic-bezier(.2,.8,.2,1);animation:slideR linear both;animation-timeline:view();animation-range:entry 0% entry 70%;" style-hover="transform:rotate(0) translateY(10px) scale(1.03);"><div style="font-family:'Vollkorn',serif;font-weight:700;font-size:13px;letter-spacing:2px;">PART IV</div><div style="font-family:'Vollkorn',serif;font-size:1.5rem;line-height:1.15;margin-top:12px;">Love made complete</div><div style="margin-top:16px;font-size:14px;font-weight:700;letter-spacing:1.5px;opacity:.85;">1 JOHN 4 : 12</div></div>
</div>
</div>
</div>
</section>
<section id="family" data-screen-label="Our family" style="position:relative;padding:clamp(80px,10vw,140px) clamp(16px,4vw,56px) clamp(120px,14vw,200px);background:#fbf3e4;overflow:hidden;">
<div aria-hidden="true" style="position:absolute;inset:0;opacity:.1;background-image:url(&quot;data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='72' height='72' viewBox='0 0 72 72' fill='none' stroke='%232b1d1e' stroke-width='1.3'><g fill='%232b1d1e' stroke='none'><circle cx='18' cy='18' r='1.7'/><circle cx='54' cy='18' r='1.7'/><circle cx='18' cy='54' r='1.7'/><circle cx='54' cy='54' r='1.7'/></g><path d='M36 4C46 4 68 26 68 36C68 46 46 68 36 68C26 68 4 46 4 36C4 26 26 4 36 4Z'/><path d='M36 20C44 20 52 28 52 36C52 44 44 52 36 52C28 52 20 44 20 36C20 28 28 20 36 20Z'/><path d='M0 36C4 30 10 30 12 36C10 42 4 42 0 36ZM72 36C68 30 62 30 60 36C62 42 68 42 72 36ZM36 0C30 4 30 10 36 12C42 10 42 4 36 0ZM36 72C30 68 30 62 36 60C42 62 42 68 36 72Z'/></svg>&quot;);background-size:72px 72px;"></div>
<div style="max-width:1240px;margin:0 auto;position:relative;">
<div data-reveal style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:clamp(24px,4vw,60px);align-items:end;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div>
<div style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c8542b;">Our family</div>
<h2 style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(2.6rem,5.6vw,4.6rem);line-height:.98;letter-spacing:-.02em;margin:14px 0 0;text-wrap:balance;">Walking in feels like <em style="font-style:italic;color:#c8542b;">coming home</em></h2>
</div>
<p style="margin:0;font-size:1.15rem;line-height:1.65;color:#5b4a43;max-width:480px;">No one walks alone here. From shared meals to shared burdens, we are families of the church doing life together across ages, languages and neighbourhoods of Chennai.</p>
</div>
<div style="position:relative;display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:minmax(60px,auto);gap:0;margin-top:clamp(40px,6vw,80px);">
<div data-reveal style="grid-column:1 / span 7;grid-row:1 / span 5;position:relative;transform:rotate(-2.5deg);animation:drift linear both;animation-timeline:view();">
<div style="background:#fff8ec;padding:12px 12px 44px;border:2px solid #2b1d1e;box-shadow:10px 10px 0 #e9a53a;transition:transform .4s cubic-bezier(.2,.8,.2,1);" style-hover="transform:rotate(1deg) scale(1.02);">
<div style="aspect-ratio:16/10;position:relative;overflow:hidden;background:#f0e6d2;"><image-slot id="nlf-worship" shape="rect" placeholder="Photo: Sunday worship"></image-slot></div>
<div style="position:absolute;left:24px;bottom:12px;font-family:'Vollkorn',serif;font-style:italic;font-size:16px;color:#5b4a43;">Sunday, 4 PM. Singing like we mean it.</div>
<div aria-hidden="true" style="position:absolute;top:-14px;left:50%;width:110px;height:28px;margin-left:-55px;background:rgba(233,165,58,.75);transform:rotate(-3deg);"></div>
</div>
</div>
<div data-reveal style="grid-column:7 / span 6;grid-row:2 / span 5;position:relative;z-index:2;transform:rotate(3deg);animation:driftSlow linear both;animation-timeline:view();">
<div style="background:#fff8ec;padding:12px 12px 44px;border:2px solid #2b1d1e;box-shadow:10px 10px 0 #4f6a2e;transition:transform .4s cubic-bezier(.2,.8,.2,1);" style-hover="transform:rotate(-1deg) scale(1.02);">
<div style="aspect-ratio:1;position:relative;overflow:hidden;background:#f0e6d2;"><image-slot id="nlf-chai" shape="rect" placeholder="Photo: filter kaapi after the service"></image-slot></div>
<div style="position:absolute;left:24px;bottom:12px;font-family:'Vollkorn',serif;font-style:italic;font-size:16px;color:#5b4a43;">Filter kaapi, and the conversations that follow.</div>
<div aria-hidden="true" style="position:absolute;top:-12px;right:30px;width:96px;height:26px;background:rgba(200,84,43,.7);transform:rotate(6deg);"></div>
</div>
</div>
<div data-reveal style="grid-column:2 / span 4;grid-row:6 / span 5;position:relative;z-index:3;transform:rotate(-4deg);animation:drift linear both;animation-timeline:view();">
<div style="background:#fff8ec;padding:12px 12px 44px;border:2px solid #2b1d1e;box-shadow:10px 10px 0 #c8542b;transition:transform .4s cubic-bezier(.2,.8,.2,1);" style-hover="transform:rotate(0) scale(1.02);">
<div style="aspect-ratio:4/5;position:relative;overflow:hidden;background:#f0e6d2;"><image-slot id="nlf-kids" shape="rect" placeholder="Photo: kids and families"></image-slot></div>
<div style="position:absolute;left:24px;bottom:12px;font-family:'Vollkorn',serif;font-style:italic;font-size:16px;color:#5b4a43;">The little ones lead the way.</div>
<div aria-hidden="true" style="position:absolute;top:-14px;left:24px;width:90px;height:26px;background:rgba(79,106,46,.6);transform:rotate(-8deg);"></div>
</div>
</div>
<div data-reveal style="grid-column:6 / span 7;grid-row:7 / span 5;position:relative;z-index:1;transform:rotate(1.5deg);animation:driftSlow linear both;animation-timeline:view();">
<div style="background:#fff8ec;padding:12px 12px 44px;border:2px solid #2b1d1e;box-shadow:10px 10px 0 #2b1d1e;transition:transform .4s cubic-bezier(.2,.8,.2,1);" style-hover="transform:rotate(-1deg) scale(1.02);">
<div style="aspect-ratio:16/9;position:relative;overflow:hidden;background:#f0e6d2;"><image-slot id="nlf-city" shape="rect" placeholder="Photo: Bible study or outreach in the city"></image-slot></div>
<div style="position:absolute;left:24px;bottom:12px;font-family:'Vollkorn',serif;font-style:italic;font-size:16px;color:#5b4a43;">Out in the streets we call home.</div>
<div aria-hidden="true" style="position:absolute;top:-12px;left:50%;width:120px;height:28px;margin-left:-60px;background:rgba(233,165,58,.75);transform:rotate(2deg);"></div>
</div>
</div>
<div aria-hidden="true" style="grid-column:10 / span 3;grid-row:1;justify-self:end;transform:rotate(8deg);background:#e9a53a;border:2px solid #2b1d1e;padding:12px 18px;font-family:'Noto Serif Tamil',serif;font-weight:600;font-size:1.3rem;box-shadow:4px 4px 0 #2b1d1e;animation:bob 6s ease-in-out infinite;">&#2986;&#3007;&#2995;&#3021;&#2995;&#3016;&#2965;&#2995;&#3021; <span style="font-family:'Vollkorn',serif;font-style:italic;font-weight:500;font-size:1rem;">&middot; kids</span></div>
</div>
<div data-reveal style="margin-top:clamp(70px,9vw,120px);display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:clamp(20px,4vw,50px);align-items:center;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div>
<div style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c8542b;">Wherever you&rsquo;re coming from</div>
<h3 style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(1.9rem,3.4vw,2.8rem);line-height:1.05;margin:12px 0 0;text-wrap:balance;">One city, a hundred neighbourhoods, one table.</h3>
<p style="margin:16px 0 0;font-size:1.05rem;line-height:1.6;color:#5b4a43;max-width:400px;">Bus, train, auto or two-wheeler. However you get here, the door is open.</p>
</div>
<div style="display:flex;flex-wrap:wrap;gap:12px 10px;align-items:center;">
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.25rem;background:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(-3deg);">Mylapore</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1rem;background:#e9a53a;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(2deg);">T. Nagar</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.4rem;background:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(1.5deg) translateY(6px);">Velachery</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.1rem;background:#c8542b;color:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(-2deg);">Anna Nagar</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1rem;background:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(4deg);">Tambaram</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.3rem;background:#4f6a2e;color:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(-1deg) translateY(-6px);">Adyar</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.05rem;background:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(2.5deg);">Porur</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.2rem;background:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(-3.5deg) translateY(4px);">Perambur</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1rem;background:#e9a53a;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(1deg);">Chromepet</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.35rem;background:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(-1.5deg);">Besant Nagar</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1rem;background:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(3deg) translateY(-4px);">Kilpauk</span>
<span style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.1rem;background:#2b1d1e;color:#fff8ec;border:2px solid #2b1d1e;padding:6px 14px;transform:rotate(-2deg);">Sholinganallur</span>
<span style="font-family:'Vollkorn',serif;font-style:italic;font-weight:500;font-size:1.1rem;background:transparent;border:2px dashed #2b1d1e;padding:6px 14px;transform:rotate(1.5deg);color:#5b4a43;">your street here</span>
</div>
</div>
</div>
</section>
<section id="vision" data-screen-label="Vision" style="position:relative;padding:clamp(100px,12vw,160px) clamp(16px,4vw,56px);background:#2b1d1e;color:#fff8ec;overflow:hidden;clip-path:polygon(0 3vw,100% 0,100% 100%,0 100%);margin-top:-3vw;">
<div aria-hidden="true" style="position:absolute;top:-200px;left:50%;width:900px;height:900px;margin-left:-450px;border-radius:50%;background:radial-gradient(circle, rgba(233,165,58,.22), transparent 65%);animation:sunPulse 8s ease-in-out infinite;pointer-events:none;"></div>
<div style="max-width:1200px;margin:0 auto;position:relative;">
<div data-reveal style="max-width:1000px;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#e9a53a;">The vision of our church</div>
<p style="font-family:'Vollkorn',serif;font-weight:500;font-size:clamp(1.8rem,4.2vw,3.6rem);line-height:1.22;margin:18px 0 0;text-wrap:pretty;">God wants to <em style="color:#e9a53a;font-style:italic;">meet</em> us, bring us into His <em style="color:#e9a53a;font-style:italic;">family</em>, form us into <em style="color:#e9a53a;font-style:italic;">disciples</em> and <em style="color:#e9a53a;font-style:italic;">witnesses</em>, to send <em style="color:#e9a53a;font-style:italic;">ripples</em> of renewal around the city and the nation.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(12,1fr);gap:24px 20px;margin-top:clamp(56px,7vw,100px);">
<div data-reveal style="grid-column:1 / span 5;background:#fff8ec;color:#2b1d1e;padding:30px 26px 32px;border:2px solid #2b1d1e;box-shadow:8px 8px 0 #c8542b;transform:rotate(-2deg);animation:slideL linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="display:flex;align-items:baseline;gap:14px;"><span style="font-family:'Vollkorn',serif;font-weight:900;font-size:3rem;line-height:1;color:#c8542b;">01</span><span style="font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Encounter</span></div>
<h3 style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.6rem;line-height:1.15;margin:16px 0 12px;">God wants to meet us</h3>
<p style="margin:0;line-height:1.65;color:#5b4a43;">On holy ground, Moses met God in a bush that burned but was not consumed. God still draws near. He speaks, and invites each of us to know Him personally. Every Sunday is an open door.</p>
</div>
<div data-reveal style="grid-column:7 / span 5;margin-top:clamp(20px,6vw,90px);background:#fff8ec;color:#2b1d1e;padding:30px 26px 32px;border:2px solid #2b1d1e;box-shadow:8px 8px 0 #e9a53a;transform:rotate(1.5deg);animation:slideR linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="display:flex;align-items:baseline;gap:14px;"><span style="font-family:'Vollkorn',serif;font-weight:900;font-size:3rem;line-height:1;color:#e9a53a;">02</span><span style="font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Belong</span></div>
<h3 style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.6rem;line-height:1.15;margin:16px 0 12px;">He brings us into His family</h3>
<p style="margin:0;line-height:1.65;color:#5b4a43;">Faith was never meant to be lived alone. God places us in a family where we are known, prayed for and carried through every season of life.</p>
</div>
<div data-reveal style="grid-column:2 / span 5;margin-top:-10px;background:#fff8ec;color:#2b1d1e;padding:30px 26px 32px;border:2px solid #2b1d1e;box-shadow:8px 8px 0 #4f6a2e;transform:rotate(2.5deg);animation:slideL linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="display:flex;align-items:baseline;gap:14px;"><span style="font-family:'Vollkorn',serif;font-weight:900;font-size:3rem;line-height:1;color:#4f6a2e;">03</span><span style="font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Become</span></div>
<h3 style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.6rem;line-height:1.15;margin:16px 0 12px;">He forms us into disciples &amp; witnesses</h3>
<p style="margin:0;line-height:1.65;color:#5b4a43;">Through the Word, Bible studies and serving together, we grow up into Christ and are sent out as witnesses to our city. Formed to be sent.</p>
</div>
<div data-reveal style="grid-column:8 / span 5;margin-top:clamp(10px,4vw,60px);background:#fff8ec;color:#2b1d1e;padding:30px 26px 32px;border:2px solid #2b1d1e;box-shadow:8px 8px 0 #7a3b8f;transform:rotate(-1.5deg);animation:slideR linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="display:flex;align-items:baseline;gap:14px;"><span style="font-family:'Vollkorn',serif;font-weight:900;font-size:3rem;line-height:1;color:#7a3b8f;">04</span><span style="font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Sent</span></div>
<h3 style="font-family:'Vollkorn',serif;font-weight:700;font-size:1.6rem;line-height:1.15;margin:16px 0 12px;">He sends ripples of renewal</h3>
<p style="margin:0;line-height:1.65;color:#5b4a43;">What begins in one heart moves outward through streets, homes and workplaces, carrying renewal across Chennai and the nation. Like a flame that will not be put out, it does not stay still.</p>
</div>
</div>
</div>
</section>
<section id="contact" data-screen-label="Contact" style="position:relative;padding:clamp(100px,13vw,170px) clamp(16px,4vw,56px);background:radial-gradient(ellipse at 50% 0%, rgba(233,165,58,.35), transparent 55%), #c8542b;color:#fff8ec;text-align:center;clip-path:polygon(0 0,100% 3vw,100% 100%,0 100%);overflow:hidden;">
<div aria-hidden="true" style="position:absolute;right:-4%;bottom:-8%;font-family:'Noto Serif Tamil',serif;font-weight:800;font-size:clamp(7rem,18vw,16rem);line-height:.9;color:rgba(255,248,236,.08);pointer-events:none;user-select:none;white-space:nowrap;">&#2970;&#3014;&#2985;&#3021;&#2985;&#3016;</div>
<div data-reveal style="position:relative;max-width:760px;margin:0 auto;display:flex;flex-direction:column;align-items:center;animation:riseIn linear both;animation-timeline:view();animation-range:entry 0% entry 70%;">
<div style="font-size:14px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#ffe2a8;">Contact</div>
<h2 style="font-family:'Vollkorn',serif;font-weight:900;font-size:clamp(2.6rem,5.6vw,4.6rem);line-height:.98;letter-spacing:-.02em;margin:14px 0 0;text-wrap:balance;">Say hello before Sunday</h2>
<p style="margin:18px 0 0;max-width:520px;font-size:1.1rem;line-height:1.65;color:#ffe9d2;">Tell us a little about yourself and someone from the family will write back. Not a bot, a person. Usually within a day or two.</p>
<sc-if value="{{ notSent }}" hint-placeholder-val="{{ true }}">
<form onSubmit="{{ submitForm }}" style="margin-top:40px;width:min(520px,92vw);background:#fff8ec;color:#2b1d1e;padding:clamp(26px,4vw,36px);text-align:left;border:2px solid #2b1d1e;box-shadow:8px 8px 0 #2b1d1e;transform:rotate(1deg);display:flex;flex-direction:column;gap:18px;">
<label style="display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Your name
<input name="name" required autocomplete="name" style="font-family:'Vollkorn',serif;font-size:1.2rem;padding:10px 12px;border:2px solid #2b1d1e;background:#fff;color:#2b1d1e;outline:none;" style-focus="border-color:#c8542b;box-shadow:3px 3px 0 #e9a53a;"></input>
</label>
<label style="display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Phone or email
<input name="reach" required autocomplete="tel" style="font-family:'Vollkorn',serif;font-size:1.2rem;padding:10px 12px;border:2px solid #2b1d1e;background:#fff;color:#2b1d1e;outline:none;" style-focus="border-color:#c8542b;box-shadow:3px 3px 0 #e9a53a;"></input>
</label>
<label style="display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">A line about you
<textarea name="about" rows="4" required placeholder="Where you're from, what brings you here, anything you'd like to ask" style="font-family:'Vollkorn',serif;font-size:1.1rem;line-height:1.5;padding:10px 12px;border:2px solid #2b1d1e;background:#fff;color:#2b1d1e;outline:none;resize:vertical;" style-focus="border-color:#c8542b;box-shadow:3px 3px 0 #e9a53a;"></textarea>
</label>
<label style="display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#7a6358;">Which city are we in? <span style="font-weight:500;letter-spacing:0;text-transform:none;font-family:'Vollkorn',serif;font-style:italic;color:#5b4a43;">(keeps the bots out)</span>
<input name="city" required autocomplete="off" style="font-family:'Vollkorn',serif;font-size:1.2rem;padding:10px 12px;border:2px solid #2b1d1e;background:#fff;color:#2b1d1e;outline:none;" style-focus="border-color:#c8542b;box-shadow:3px 3px 0 #e9a53a;"></input>
</label>
<input name="website" tabIndex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0;"></input>
<sc-if value="{{ isWrong }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Vollkorn',serif;font-style:italic;font-size:1.05rem;color:#c8542b;">Hmm, not that city. The hint is all over this page.</div>
</sc-if>
<button type="submit" style="align-self:flex-start;margin-top:6px;padding:15px 34px;font-family:'Alegreya Sans',sans-serif;font-size:14px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#fff8ec;background:#c8542b;border:2px solid #2b1d1e;box-shadow:5px 5px 0 #2b1d1e;cursor:pointer;transform:rotate(-1.5deg);transition:transform .2s,background .2s,box-shadow .2s;" style-hover="transform:rotate(0) translate(-2px,-2px);box-shadow:8px 8px 0 #2b1d1e;background:#2b1d1e;">Send it over</button>
</form>
</sc-if>
<sc-if value="{{ isSent }}" hint-placeholder-val="{{ false }}">
<div style="margin-top:40px;width:min(520px,92vw);background:#fff8ec;color:#2b1d1e;padding:clamp(26px,4vw,36px);text-align:left;border:2px solid #2b1d1e;box-shadow:8px 8px 0 #2b1d1e;transform:rotate(-1deg);animation:riseIn .6s cubic-bezier(.2,.8,.2,1) both;">
<div style="font-family:'Noto Serif Tamil',serif;font-weight:600;font-size:1.3rem;color:#c8542b;">&#2984;&#2985;&#3021;&#2993;&#3007;!</div>
<div style="font-family:'Vollkorn',serif;font-weight:900;font-size:2rem;line-height:1.05;margin:10px 0 14px;">Thank you, {{ sentName }}.</div>
<p style="margin:0;line-height:1.6;color:#5b4a43;">Your note is on its way. Someone from the family will reach out soon. And whether or not we manage to talk first, the door is open this Sunday at 4.</p>
</div>
</sc-if>
</div>
</section>
<footer style="background:#2b1d1e;color:#c9b3a6;padding:44px clamp(20px,5vw,64px) 40px;">
<div style="max-width:1200px;margin:0 auto;display:flex;justify-content:space-between;align-items:flex-end;gap:20px 40px;flex-wrap:wrap;">
<div style="display:flex;flex-direction:column;gap:6px;">
<span style="font-family:'Vollkorn',serif;font-weight:900;font-size:24px;color:#fff8ec;">New Life Fellowship</span>
<span style="font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;">NLF Chennai City Church &nbsp;&middot;&nbsp; Meet &middot; Belong &middot; Grow &nbsp;&middot;&nbsp; Sundays 4 PM &nbsp;&middot;&nbsp; Namma Chennai</span>
</div>
<span style="font-family:'Noto Serif Tamil',serif;font-weight:600;font-size:20px;color:#e9a53a;">&#2997;&#2979;&#2965;&#3021;&#2965;&#2990;&#3021; <span style="font-family:'Vollkorn',serif;font-style:italic;font-weight:500;color:#c9b3a6;font-size:18px;">welcome home.</span></span>
</div>
</footer>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;motion&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;full&quot;,&quot;calm&quot;],&quot;default&quot;:&quot;full&quot;,&quot;tsType&quot;:&quot;'full' | 'calm'&quot;,&quot;section&quot;:&quot;Feel&quot;},&quot;paperGrain&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Feel&quot;},&quot;formEndpoint&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Contact form&quot;}}">
class Component extends DCLogic {
state = { sent: false, wrong: false, sentName: '' };
componentDidMount() { this._applyMotion(); this._fallbackReveal(); }
componentDidUpdate() { this._applyMotion(); }
_applyMotion() {
if (this._root) this._root.setAttribute('data-motion', this.props.motion ?? 'full');
}
// Browsers without scroll-driven animations: reveal with IntersectionObserver instead.
_fallbackReveal() {
if (this._fb) return; this._fb = true;
const supported = typeof CSS !== 'undefined' && CSS.supports && CSS.supports('animation-timeline: view()');
if (supported || !('IntersectionObserver' in window)) return;
const els = Array.prototype.slice.call(document.querySelectorAll('[data-reveal]'));
els.forEach(el => { el.style.animation = 'none'; el.style.opacity = '0'; el.style.transition = 'opacity .9s ease, transform .9s ease'; });
const io = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) { e.target.style.opacity = '1'; io.unobserve(e.target); } });
}, { threshold: 0.12 });
els.forEach(el => io.observe(el));
}
renderVals() {
return {
rootRef: el => this._root = el,
grain: this.props.paperGrain ?? true,
notSent: !this.state.sent, isSent: this.state.sent,
isWrong: this.state.wrong, sentName: this.state.sentName,
submitForm: (e) => {
e.preventDefault();
const f = new FormData(e.target);
if (f.get('website')) return; // honeypot: silently drop bots
const city = String(f.get('city') || '').toLowerCase().replace(/\s+/g, '');
if (!/chennai|madras|சென்னை/.test(city)) { this.setState({ wrong: true }); return; }
const name = String(f.get('name') || '').trim();
// Wire to your form backend here (Formspree, Netlify Forms, Google Apps Script…).
const to = this.props.formEndpoint ?? '';
if (to) fetch(to, { method: 'POST', headers: { 'Accept': 'application/json' }, body: f }).catch(() => {});
this.setState({ sent: true, wrong: false, sentName: name.split(' ')[0] || 'friend' });
}
};
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment