-${escLite(persona.pageTitle)}
+${escLite(host ? persona.hostPageTitle : persona.pageTitle)}
diff --git a/packages/cardtile/serve/card-worker.test.mjs b/packages/cardtile/serve/card-worker.test.mjs
index 300b66f..da7729a 100644
--- a/packages/cardtile/serve/card-worker.test.mjs
+++ b/packages/cardtile/serve/card-worker.test.mjs
@@ -1057,6 +1057,9 @@ test('host mode: /edit?host=feelreef serves the editor with host opts, noindex,
assert.deepEqual(optsOf(body), { host, sandbox: false, locale: 'ja', tableBase: '/try/edit/t/ja/' });
assert.ok(body.includes('id="sandbox-banner" hidden'), 'host mode ships the sandbox banner hidden');
assert.match(body, /id="host-save"/);
+ // ๐ด host mode never ships the sandbox's "Try Card ยท feelreef" โ before the parent hands over
+ // a real card there is nothing to name yet, so the tab reads the bare product name.
+ assert.match(body, /Card ยท feelreef<\/title>/, 'host mode ships the placeholder title, not the sandbox\'s');
assert.deepEqual(touched, []);
}
}
diff --git a/packages/cardtile/serve/edit2-assets.mjs b/packages/cardtile/serve/edit2-assets.mjs
index 4af3859..6909d62 100644
--- a/packages/cardtile/serve/edit2-assets.mjs
+++ b/packages/cardtile/serve/edit2-assets.mjs
@@ -3,7 +3,7 @@
// verbatim as its editing table. See that file for what is and is not committed here.
export const EDIT2_BODY_HTML = "\n\n\n
\n \n \n \n
\n\n\n
cardtile-w2
\n \n
\n \n \n \n
\n \n
\n \n \n \n \n \n
\n\n\n\n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n
\n \n\n\n\n\n
\n
\n \n \n \n \n
\n
\n\n\n
\n
\n \n \n \n \n \n \n
\n \n \n
\n
\n \n \n
\n
\n
\n\n\n
\n
\n \n \n \n \n \n
\n \n \n
\n
\n
\n\n\n
\n
\n \n \n
\n \n \n
\n
\n
\n\n";
export const EDIT2_CSS = "/* cardtile-w's own chrome. Deliberately NOT the card's stylesheet โ the canvas is an iframe and the\n card's :root tokens stay in there, where they belong. Nothing here can reach the card, and nothing\n in the card can reach this. */\n\n:root {\n --ctw-bg: #12161c;\n --ctw-panel: #1a1f27;\n --ctw-line: #2b323d;\n --ctw-ink: #e6ecf3;\n --ctw-dim: #93a2b4;\n --ctw-go: #0556ff;\n --ctw-danger: #d3574a;\n color-scheme: dark;\n}\n@media (prefers-color-scheme: light) {\n :root { --ctw-bg:#f2f4f7; --ctw-panel:#fff; --ctw-line:#d8dee7; --ctw-ink:#111820; --ctw-dim:#5b6879; color-scheme: light; }\n}\n\n* { box-sizing: border-box; }\nbody {\n margin: 0; background: var(--ctw-bg); color: var(--ctw-ink);\n font: 15px/1.5 -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans TC\", sans-serif;\n height: 100vh; display: flex; flex-direction: column;\n}\n\n/* SANDBOX ONLY โ see index.html's #sandbox-banner and editor.mjs's bootSandbox(). Sits above the\n bar, on purpose: the one fact a sandbox visitor must see before anything else. */\n.ctw-sandbox-banner {\n display: flex; align-items: center; justify-content: center; gap: .9rem; flex-wrap: wrap;\n padding: .55rem 1rem; text-align: center;\n background: color-mix(in srgb, var(--ctw-go) 16%, var(--ctw-panel));\n border-bottom: 1px solid var(--ctw-line); font-size: .82rem; font-weight: 600;\n}\n.ctw-btn-ghost { background: transparent; border-color: color-mix(in srgb, var(--ctw-ink) 30%, transparent); }\n/* the banner carries BOTH sentences; the media query below picks one. Desktop reads the long form. */\n#sandbox-banner-status { display: none; }\n\n.ctw-bar { display: flex; align-items: center; gap: 1rem; padding: .6rem 1rem; border-bottom: 1px solid var(--ctw-line); background: var(--ctw-panel); }\n.ctw-bar h1 { font-size: .95rem; margin: 0; letter-spacing: .02em; opacity: .8; }\n/* ๐ฉธ THE SANDBOX'S MASTHEAD. The internal editor's
says `cardtile-w` and should โ it is a\n local ops tool and that is its name. The PUBLIC sandbox got the same
, so a visitor arriving\n from feelreef.com/store read an internal codename in the largest text on the page, with no\n feelreef anywhere on screen and no way back. card-worker.mjs swaps in this markup for the\n sandbox only; the link IS the way back. */\n.ctw-bar h1.ctw-brand { display: flex; align-items: baseline; gap: .38rem; opacity: 1; }\n.ctw-brand a { color: inherit; text-decoration: none; font-weight: 700; letter-spacing: -.01em; }\n.ctw-brand a:hover { text-decoration: underline; }\n.ctw-brand span { font-weight: 600; opacity: .7; }\n.ctw-brand span::before { content: 'ยท'; margin-right: .38rem; opacity: .55; }\n.ctw-lanes { display: flex; gap: .35rem; flex: 1; flex-wrap: wrap; }\n.ctw-actions { display: flex; gap: .4rem; }\n\n.ctw-tab, .ctw-btn {\n font: inherit; font-size: .85rem; padding: .35rem .7rem; border-radius: 8px;\n border: 1px solid var(--ctw-line); background: transparent; color: var(--ctw-ink); cursor: pointer;\n}\n.ctw-tab.is-on { background: color-mix(in srgb, var(--ctw-go) 20%, transparent); border-color: var(--ctw-go); }\n.ctw-tab-add { opacity: .7; }\n/* ๐ฉธ A SPECIFICITY LOSS, MEASURED AS WHITE TEXT ON NOTHING. `.ctw-btn:hover` is (0,2,0) and\n `.ctw-btn-go` below is (0,1,0), so hovering the primary button replaced its solid blue with\n `rgba(17,24,32,.08)` and left `color:#fff` behind: the door out of the sandbox read as an empty\n outline with white words that vanished into it. On a phone it is worse than a desktop hover โ\n after tapping ๅฎๆ in the bottom sheet the finger's leftover :hover lands on the fixed bottom bar\n in the same place, so the main call to action sat there transparent with the palette showing\n through it.\n `:not(.ctw-btn-go)` costs one selector and makes the two rules disjoint instead of ranked; the\n primary button gets its own hover, which brightens the blue rather than replacing it. */\n.ctw-btn:not(.ctw-btn-go):hover, .ctw-tab:hover { background: color-mix(in srgb, var(--ctw-ink) 8%, transparent); }\n.ctw-btn[disabled] { opacity: .4; cursor: default; }\n.ctw-btn-go { background: var(--ctw-go); border-color: var(--ctw-go); color: #fff; }\n.ctw-btn-go:hover { filter: brightness(1.08); }\n.ctw-btn-go[disabled]:hover { filter: none; }\n.ctw-btn-danger { color: var(--ctw-danger); border-color: color-mix(in srgb, var(--ctw-danger) 45%, transparent); }\n.ctw-file { display: inline-flex; align-items: center; }\n/* ๐ด PRE-EXISTING BUG, found while building the mobile menu (2026-09-05): `.ctw-file{display:\n inline-flex}` above and the browser's own `[hidden]{display:none}` have EQUAL specificity\n (0,1,0 vs 0,1,0) โ a tie the author stylesheet wins over the UA one, so `el('file-block').hidden\n = true` (bootSandbox(), for the real-editor-only \"้ๅ .md\"/Open .md button) was silently not\n hiding anything: the button stayed visible โ and unlocalised โ in every sandbox session, on\n desktop too, just easy to miss inline in a busy toolbar. It only became obvious once it showed up\n as its own row in the new mobile dropdown. `[hidden]` alone cannot out-specificity a class rule;\n this one line can, and needs nothing else to change. */\n.ctw-file[hidden] { display: none; }\n\n.ctw-main { flex: 1; display: grid; grid-template-columns: 340px 1fr; min-height: 0; }\n.ctw-side { border-right: 1px solid var(--ctw-line); padding: .9rem; overflow: auto; display: flex; flex-direction: column; gap: .6rem; background: var(--ctw-panel); }\n.ctw-side h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--ctw-dim); margin: .4rem 0 0; }\n\n.ctw-palette { display: grid; gap: .4rem; }\n.ctw-add { text-align: left; font: inherit; padding: .5rem .6rem; border-radius: 10px; border: 1px solid var(--ctw-line); background: transparent; color: inherit; cursor: pointer; display: grid; gap: .15rem; }\n.ctw-add:hover { border-color: var(--ctw-go); }\n.ctw-add b { font-size: .9rem; }\n.ctw-add span { font-size: .76rem; color: var(--ctw-dim); }\n\n.ctw-url { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: .45rem .55rem; border-radius: 8px; border: 1px solid var(--ctw-line); background: var(--ctw-bg); color: var(--ctw-ink); width: 100%; }\n.ctw-md { flex: 1; min-height: 180px; resize: vertical; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; padding: .6rem; border-radius: 10px; border: 1px solid var(--ctw-line); background: var(--ctw-bg); color: var(--ctw-ink); }\n.ctw-note { font-size: .76rem; color: var(--ctw-dim); margin: 0; }\n\n.ctw-canvas-wrap { min-height: 0; overflow: hidden; }\n.ctw-canvas { width: 100%; height: 100%; border: 0; display: block; }\n\n.ctw-modal { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1.2rem; }\n.ctw-modal[hidden] { display: none; }\n.ctw-modal-panel { position: relative; width: min(560px, 100%); max-height: 86vh; overflow: auto; background: var(--ctw-panel); border: 1px solid var(--ctw-line); border-radius: 16px; padding: 1.4rem 1.2rem 1rem; }\n/* top-LEFT, matching the card's own overlays โ one place to reach for \"close\", everywhere */\n.ctw-modal-close { position: absolute; top: .7rem; left: .7rem; width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--ctw-line); background: transparent; color: inherit; cursor: pointer; }\n.ctw-modal-panel h2 { margin: .2rem 0 .2rem 2.4rem; font-size: 1.05rem; }\n.ctw-modal-hint { margin: 0 0 1rem; color: var(--ctw-dim); font-size: .82rem; }\n\n.ctw-field { display: grid; gap: .25rem; margin-bottom: .85rem; }\n.ctw-field[hidden] { display: none; }\n.ctw-field > span { font-size: .82rem; font-weight: 600; }\n.ctw-field > em { font-size: .74rem; color: var(--ctw-dim); font-style: normal; }\n.ctw-field input[type=text], .ctw-field input[type=url], .ctw-field select, .ctw-field textarea {\n font: inherit; font-size: .88rem; padding: .45rem .55rem; border-radius: 8px;\n border: 1px solid var(--ctw-line); background: var(--ctw-bg); color: var(--ctw-ink); width: 100%;\n}\n/* ๐ฉธ WAS MONOSPACE. A prose cell's content box in Menlo reads as a code editor โ the review's\n first-time visitor met a programming font the moment they tried to write a sentence. The form\n asks for writing now, and it looks like somewhere you write. (Raw markdown still gets a\n monospace box; that one is Markdown mode's, and it is opt-in.) */\n.ctw-field textarea { font-size: .88rem; line-height: 1.5; resize: vertical; }\n.ctw-field input[type=checkbox] { width: 20px; height: 20px; }\n/* per-field validation, empty until saveCell fills it โ never an alert(), never a lost sheet */\n.ctw-err { font-size: .76rem; color: var(--ctw-danger); font-style: normal; }\n.ctw-err:empty { display: none; }\n\n/* a collapsed group of advanced rows (icon settings, \"add a heading\") โ closed by default, so the\n short form is the one a first-time visitor meets and the long one is still one tap away */\n.ctw-group { margin: 0 0 .9rem; border: 1px solid var(--ctw-line); border-radius: 10px; padding: .55rem .7rem; }\n.ctw-group > summary { cursor: pointer; font-size: .82rem; font-weight: 600; }\n.ctw-group[open] > summary { margin-bottom: .7rem; }\n\n/* โโ the picture picker (CONTROL.ASSET) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n `` inside the label is what opens a phone's photo library.\n The thumbnail is the confirmation โ before this, a picture field gave no feedback at all until\n the canvas repainted, and the canvas is below the fold on a phone. */\n.ctw-asset { display: grid; gap: .5rem; }\n.ctw-asset-shot { display: grid; place-items: center; min-height: 0; }\n.ctw-asset-shot:empty { display: none; }\n.ctw-asset-shot img { max-width: 100%; max-height: 8rem; border-radius: 10px; display: block; }\n.ctw-asset-acts { display: flex; gap: .4rem; }\n.ctw-asset-pick { display: inline-flex; align-items: center; justify-content: center; }\n.ctw-asset-orurl { display: grid; gap: .2rem; }\n.ctw-asset-orurl > span { font-size: .74rem; color: var(--ctw-dim); }\n.ctw-asset-err { font-size: .76rem; color: var(--ctw-danger); font-style: normal; }\n.ctw-asset-err:empty { display: none; }\n\n.ctw-raw { font-size: .78rem; color: var(--ctw-dim); margin-bottom: .8rem; }\n.ctw-raw input { width: 100%; font: 12px ui-monospace, Menlo, monospace; padding: .35rem .5rem; margin: .4rem 0 .2rem; border-radius: 6px; border: 1px solid var(--ctw-line); background: var(--ctw-bg); color: var(--ctw-dim); }\n.ctw-raw p { margin: 0; }\n\n/* move up / move down โ the reordering path that does not need a gesture (see editor.mjs) */\n.ctw-move { display: flex; gap: .5rem; margin: .2rem 0 .9rem; }\n.ctw-move[hidden] { display: none; }\n.ctw-move-btn { flex: 1; }\n\n.ctw-modal-foot { display: flex; justify-content: space-between; gap: .6rem; padding-top: .4rem; border-top: 1px solid var(--ctw-line); }\n\n/* the save confirmation โ deliberately plainer than the cell editor: it asks one question. */\n.ctw-pub { max-width: 30rem; }\n.ctw-pub-backup { display: flex; align-items: center; gap: .55rem; margin: .9rem 0 .5rem; font-size: .95rem; }\n.ctw-pub-backup input { width: 20px; height: 20px; }\n.ctw-pub-note { margin: 0 0 .9rem; font-size: .8rem; color: var(--ctw-dim); }\n.ctw-pub-result { font-size: .82rem; line-height: 1.55; margin-bottom: .9rem; padding: .6rem .7rem; border-radius: 8px; background: var(--ctw-bg); border: 1px solid var(--ctw-line); white-space: pre-wrap; }\n\n/* โโ Markdown mode โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n ONE page, TWO complete views, never both โ the same either/or as tugtile's header view toggle.\n Everything the card view owns is display:none'd rather than moved, so nothing about the form\n layout has to know this mode exists. */\n.ctw-md-toggle { display: inline-flex; align-items: center; gap: .4rem; }\n.ctw-md-ico { width: 16px; height: 16px; flex: 0 0 auto; }\n.ctw-md-ico-grid { display: none; }\n.ctw-md-toggle[aria-pressed=\"true\"] .ctw-md-ico-doc { display: none; }\n.ctw-md-toggle[aria-pressed=\"true\"] .ctw-md-ico-grid { display: inline; }\n\nbody[data-md=\"1\"] .ctw-main { grid-template-columns: 1fr; }\nbody[data-md=\"1\"] .ctw-canvas-wrap,\nbody[data-md=\"1\"] .ctw-palette,\nbody[data-md=\"1\"] #add-cell-h2,\nbody[data-md=\"1\"] #cardurl-block,\nbody[data-md=\"1\"] .ctw-lanes { display: none; }\nbody[data-md=\"1\"] .ctw-side { border-right: 0; }\nbody[data-md=\"1\"] #md-block { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: .5rem; }\n/* ๐ด raw markdown IS allowed a monospace box โ this one is opt-in and it is the file. What is not\n allowed is a monospace box in a FORM, which is what a first-time visitor met instead of a\n sentence. Two different questions, two different answers. */\nbody[data-md=\"1\"] .ctw-md { flex: 1; min-height: 55vh; min-height: 55dvh; }\n.ctw-md-error { margin: 0; font-size: .82rem; color: var(--ctw-danger); font-weight: 600; }\n.ctw-md-error[hidden] { display: none; }\n\n/* โโ mobile disclosure menu (\"toolbar collapsed into a menu\") โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n Native / โ no JS, so it costs nothing under prefers-reduced-motion (there is\n no animation to guard) and stays keyboard/AT operable for free. See index.html for the markup;\n this is the ONLY styling that decides whether it acts like a menu (mobile, below) or is\n invisible as a wrapper (desktop, further down). */\n.ctw-mobile-menu > summary { list-style: none; cursor: pointer; }\n.ctw-mobile-menu > summary::-webkit-details-marker { display: none; }\n.ctw-mobile-menu-body { display: flex; gap: .4rem; }\n\n/* โโ mobile layout (โค768px โ phones and small tablets in portrait; 1024px+ keeps the desktop grid\n unchanged, per the guardrail) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */\n@media (max-width: 768px) {\n html, body { overflow-x: hidden; } /* belt-and-braces: nothing below should ever need this, but a\n runaway child must never be able to open page-level h-scroll */\n\n /* ๐ฉธ ONE PAGE, ONE SCROLL โ and the last tile in the palette was UNREACHABLE without it.\n Measured at 390ร844: chrome 232px + canvas `min-height:45dvh` (380) + side `max-height:42dvh`\n (354) = 966px of boxes in an 844px viewport. The side panel's own frame hung 123px off the\n bottom, the fixed door button covered 55 more, and `document.scrollHeight` was exactly 844 โ so\n the PAGE could not scroll to bring any of it back. Scrolling inside the panel reached its own\n end while the last two buttons sat below the window. Three nested scrollers, and the one that\n mattered was the one that did not exist.\n The fix is to stop capping anything. The canvas is as tall as the card (set in JS from the\n iframe's own content โ see fitCanvas), the panel is as tall as its buttons, and the page\n scrolls, once, the way every other page on a phone does. */\n body { height: auto; min-height: 100vh; min-height: 100dvh; }\n .ctw-main { display: flex; flex-direction: column; min-height: 0; }\n .ctw-canvas-wrap { order: 1; flex: 0 0 auto; height: auto; min-height: 0; overflow: visible; }\n .ctw-canvas { height: auto; min-height: 40vh; min-height: 40dvh; }\n .ctw-side { order: 2; flex: 0 0 auto; max-height: none; overflow: visible; border-right: 0; border-top: 1px solid var(--ctw-line); }\n\n /* the banner drops to ONE line: 27% of the first screen was an explanation, not the card. Both\n sentences are in the markup and written server-side (a visitor with no JavaScript must still be\n able to read one), and this chooses which โ never a truncation. */\n #sandbox-banner-text { display: none; }\n #sandbox-banner-status { display: inline; }\n\n /* Markdown mode on a phone: the textarea IS the page. `dvh`, so an on-screen keyboard shrinks the\n space available instead of pushing the box off the bottom of it. */\n body[data-md=\"1\"] .ctw-md { min-height: 60vh; min-height: 60dvh; }\n body[data-md=\"1\"] #md-h2, body[data-md=\"1\"] #md-note { display: none; }\n .ctw-md-toggle .ctw-md-ico { width: 18px; height: 18px; }\n .ctw-sandbox-banner { padding: .45rem .8rem; font-size: .78rem; }\n\n /* top bar: title + lanes + menu can outgrow 390px in one row, so it wraps โ lanes get their own\n full-width, horizontally-scrollable row (never page-level h-scroll: this scrolls, the page\n does not) instead of cramming or wrapping tab text. */\n .ctw-bar { flex-wrap: wrap; row-gap: .5rem; }\n .ctw-lanes { order: 3; flex: 1 1 100%; flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }\n .ctw-tab { flex: 0 0 auto; }\n .ctw-actions { order: 2; margin-left: auto; }\n\n /* tap targets โฅ44px (buttons, tabs, the palette's add-block rows, the modal's close button) */\n .ctw-tab, .ctw-btn, .ctw-add { min-height: 44px; }\n .ctw-tab, .ctw-btn { padding: .6rem 1rem; font-size: .95rem; }\n .ctw-modal-close { width: 44px; height: 44px; }\n\n /* text inputs โฅ16px โ below that, iOS Safari zooms the whole page in on focus */\n .ctw-field input[type=text], .ctw-field input[type=url], .ctw-field select, .ctw-field textarea,\n .ctw-asset input[type=url],\n .ctw-url, .ctw-md, .ctw-raw input { font-size: 16px; }\n\n /* the mobile menu: a real dropdown, opened by tapping โฐ (native โ no script) */\n .ctw-mobile-menu { position: relative; }\n .ctw-mobile-menu > summary { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 8px; border: 1px solid var(--ctw-line); font-size: 1.15rem; }\n .ctw-mobile-menu-body { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; flex-direction: column; min-width: 11rem; padding: .5rem; background: var(--ctw-panel); border: 1px solid var(--ctw-line); border-radius: 10px; box-shadow: 0 10px 28px rgba(0,0,0,.28); }\n .ctw-mobile-menu-body .ctw-btn, .ctw-mobile-menu-body .ctw-file { width: 100%; justify-content: flex-start; }\n\n /* the door CTA becomes a fixed bottom action bar โ the one thing a sandbox visitor must always be\n able to reach without hunting for it. `data-sandbox=\"1\"` (set once, in editor.mjs's\n bootSandbox()) scopes this to the sandbox: the REAL internal editor's ๅญๅฐ็ทไธ button keeps its\n normal place in the top bar, unaffected โ it was never meant for a stranger's phone. */\n /* ๐ด the MEASURED height of the fixed bottom bar (44px floor + .9rem padding top and bottom),\n not a round number that happened to look right. Under it, the palette's last button hid behind\n the door. */\n body[data-sandbox=\"1\"] { padding-bottom: calc(3.6rem + env(safe-area-inset-bottom)); }\n body[data-sandbox=\"1\"] #publish:not([hidden]) {\n position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;\n width: 100%; margin: 0; border-radius: 0; min-height: 44px;\n padding: .9rem 1rem calc(.9rem + env(safe-area-inset-bottom));\n font-size: 1rem; box-shadow: 0 -4px 16px rgba(0,0,0,.28);\n }\n\n /* the cell editor (and the real editor's save-confirm) become a bottom sheet: anchored to the\n bottom edge instead of centred, full width, rounded top corners only. `dvh` (not `vh`) for the\n height cap so an on-screen keyboard shrinks the AVAILABLE space instead of the sheet spilling\n off-screen underneath it โ same fix release/obsidian-tugtile/styles.css uses for its own\n full-screen edit view (\"Dynamic viewport: shrinks with the on-screen keyboard so the editor\n stays inside the visible area\"). No open/close animation: there was none before, and adding one\n here would be exactly the kind of motion prefers-reduced-motion exists to catch โ easier to\n simply not add it than to guard it. */\n .ctw-modal { align-items: flex-end; padding: 0; }\n .ctw-modal-panel {\n width: 100%; max-width: 100%;\n max-height: 88vh; max-height: 88dvh;\n border-radius: 16px 16px 0 0;\n padding: 1.1rem 1rem calc(1rem + env(safe-area-inset-bottom));\n }\n /* the close button is 44px wide at this width, so 2.6rem left the heading touching it */\n .ctw-modal-panel h2 { margin-left: 3.2rem; }\n}\n\n/* desktop: the mobile-menu wrapper must add NOTHING to the layout โ `display: contents` makes the\n /
boxes disappear so undo/open/download sit directly in .ctw-actions, byte-for-byte\n the row that existed before this wrapper โ guardrail (4), desktop layout stays intact. */\n@media (min-width: 769px) {\n .ctw-mobile-menu { display: contents; }\n .ctw-mobile-menu > summary { display: none; }\n .ctw-mobile-menu-body { display: contents; }\n}\n\n/* @cvernet/signet โ CVER family corner shape (squircle).\n *\n * Apple-style continuous-curvature corners via CSS `corner-shape: squircle`\n * (a real superellipse, not a circular arc), sized by each element's own\n * `border-radius`. Browsers without `corner-shape` gracefully fall back to the\n * normal rounded arc โ pure progressive enhancement, no @supports needed.\n *\n * Extracted from liquidframe; this is the CANONICAL CVER-family corner rule.\n * Import once at the app root and every page inherits the family look:\n * import '@cvernet/signet/corners.css';\n *\n * Covers every CVER project regardless of how the radius is applied:\n * - Tailwind `.rounded-*` utilities (cver.net)\n * - inline / bespoke `border-radius` (feelreef)\n * Any element that already has a radius becomes a squircle; radius:0 is a no-op.\n */\n\n:where(*) {\n corner-shape: squircle;\n}\n\n/* Keep genuinely circular elements circular โ squircling a 50% radius (avatars,\n dots, circular badges) reads wrong. Opt an element back out with\n `corner-shape: round`. */\n:where(.rounded-full, [style*=\"border-radius:50%\"], [style*=\"border-radius: 50%\"]) {\n corner-shape: round;\n}\n\n/* feelreef's PORCH skin, mirrored for the Card editor's shell (owner ruling 2026-09-24: the\n sandbox visitor stands on our porch, so the shell wears feelreef.com/dashboard's look; the CARD\n keeps its own theme).\n\n MIRRORED, NOT IMPORTED โ tile never imports reef code. Every literal names the reef line it was\n copied from (reef/apps/feelreef/โฆ, as of 2026-09-24); w2/porch-tokens.test.mjs pins the ones that\n carry the look and, when the reef checkout is on the machine, diffs the rest against it live.\n\n ๐ด ONE LOOK, NO DARK COPY โ ON PURPOSE. reef has no `prefers-color-scheme` copy of the carrier\n block to mirror: app.css:468 ใThere is no second skinใ (chodaict 2026-08-15: feelreef ships ONE\n look) and reef's own one-look.test.ts forbids asking the OS about colour. The account dashboard\n (the porch) is therefore the same pale sea-green in both OS schemes, and so is this shell. The\n only dark-scheme carrier values reef has belong to `.console.owners-home` (console.css:441) โ\n the owner's home, which is the OTHER dashboard, not the porch. `color-scheme: light` below keeps\n native controls (scrollbars, checkboxes, selects) from turning dark on a light ground.\n prefers-contrast is honoured, as app.css does (accessibility, not taste). */\n:root {\n color-scheme: light;\n\n /* type โ app.css:101-106 */\n --font-body: 'Nunito Variable', Nunito, -apple-system, 'PingFang TC', 'Hiragino Sans', 'Microsoft JhengHei', 'Noto Sans CJK TC', sans-serif;\n --font-display-cjk: 'Songti TC', 'Hiragino Mincho ProN', 'Yu Mincho', 'Noto Serif CJK TC', 'Noto Serif TC', serif;\n --font-display: 'Young Serif', var(--font-display-cjk);\n\n /* geometry โ app.css:172-173 */\n --console-radius: 18px;\n --console-radius-sm: 12px;\n\n /* the carrier block โ app.css:237-289 */\n --carrier-bg: rgba(255, 255, 255, 0.72); /* app.css:237 */\n --carrier-bg-solid: #f4f6f6; /* app.css:242 */\n --carrier-text: #0c3a44; /* app.css:243 */\n --carrier-text-muted: #557077; /* app.css:244 */\n --carrier-heading: #0c3a44; /* app.css:245 */\n --carrier-border: rgba(12, 58, 68, 0.1); /* app.css:246 */\n --carrier-shadow: 0 8px 32px rgba(12, 58, 68, 0.1); /* app.css:247 */\n --carrier-accent: #0f7c89; /* app.css:274 */\n --carrier-accent-ink: #ffffff; /* app.css:275 */\n --carrier-input-bg: rgba(12, 58, 68, 0.04); /* app.css:276 */\n --carrier-link: #0f7c89; /* app.css:279 */\n --carrier-outline-border: #0f7c89; /* app.css:289 */\n\n /* feedback โ app.css:431-438 */\n --carrier-feedback-success: #1d8b4f; /* app.css:431 */\n --carrier-feedback-error: #c54a3a; /* app.css:432 */\n --carrier-feedback-warning: #b67a1e; /* app.css:433 */\n\n /* derived console surfaces โ app.css:450-451 */\n --console-panel: var(--carrier-bg);\n --console-tint: color-mix(in srgb, var(--carrier-accent) 9%, transparent);\n\n /* buttons โ app.css:359-428 (only what the shell uses) */\n --btn-ink: #0c3a44; /* app.css:359 */\n --btn-secondary-ink: #00768a; /* app.css:357 */\n --btn-surface: #ffffff; /* app.css:420 */\n --btn-border: #67858a; /* app.css:422 */\n --btn-border-hover: #0f8e9c; /* app.css:423 */\n --btn-radius: 12px; /* app.css:424 */\n --btn-radius-pill: 999px; /* app.css:425 */\n --btn-weight: 800; /* app.css:427 */\n --btn-ghost-hover: rgba(20, 163, 179, 0.06); /* app.css:362 */\n --btn-danger-ink: #b02e28; /* app.css:381 */\n --btn-danger-hover: rgba(220, 38, 38, 0.14); /* app.css:364 */\n\n /* motion โ Button.svelte's press (\"the press answers before the server does\") */\n --porch-press: translateY(1px) scale(0.985);\n\n /* the porch ground โ routes/dashboard/+page.svelte:2217 (`.screen`) */\n --porch-ground: linear-gradient(180deg, #f1fbfb 0%, #e6f5f3 42%, #f4fcfa 100%);\n}\n\n/* app.css (the @media (prefers-contrast: more) block after ใThere is no second skinใ) */\n@media (prefers-contrast: more) {\n :root { --carrier-border: var(--carrier-heading); }\n}\n\n/* feelreef's two faces, self-hosted the way reef does it (fontsource; see fonts/PROVENANCE.md).\n Latin + latin-ext only: everything else โ CJK, Korean โ falls through to the system stacks in\n --font-body / --font-display-cjk exactly as on feelreef.com. `swap`, so words never wait on a font.\n `./fonts/` is rewritten to the Worker's immutable asset path by serve/gen-edit2-assets.mjs. */\n@font-face {\n font-family: 'Nunito Variable'; font-style: normal; font-display: swap; font-weight: 200 1000;\n src: url(/try/edit/t/en/fonts/NunitoVariable-latin-ext.woff2) format('woff2-variations');\n unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;\n}\n@font-face {\n font-family: 'Nunito Variable'; font-style: normal; font-display: swap; font-weight: 200 1000;\n src: url(/try/edit/t/en/fonts/NunitoVariable-latin.woff2) format('woff2-variations');\n unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n}\n@font-face {\n font-family: 'Young Serif'; font-style: normal; font-display: swap; font-weight: 400;\n src: url(/try/edit/t/en/fonts/YoungSerif-latin-ext.woff2) format('woff2');\n unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;\n}\n@font-face {\n font-family: 'Young Serif'; font-style: normal; font-display: swap; font-weight: 400;\n src: url(/try/edit/t/en/fonts/YoungSerif-latin.woff2) format('woff2');\n unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;\n}\n\n/* cardtile-w2's own chrome โ LAYERED ON TOP of w/editor.css, never a copy of it.\n The banner, the buttons, the sheet, the fields, the picture picker and the modal shell all come\n from there, because the seven sheets are the same seven sheets. What is here is only what this\n surface adds: the table/preview split, the one-row icon picker, and the phone's single toggle.\n\n ๐ด RWD IS CORRECTNESS, not polish. The three things this file is answerable for at 390px: the\n page never scrolls sideways, every target a finger has to hit is at least 44px, and the table and\n the finished card are never both on screen pretending to fit. */\n\n.ctw-bar-spacer { flex: 1; }\n\n/* โโ the split โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n ONE grid, two iframes. Below 1024 the preview is a second full-screen view the toggle swaps to;\n at 1024 and up both are on screen and the toggle is not rendered. */\n.ctw-w2 {\n flex: 1;\n min-height: 0;\n display: grid;\n grid-template-columns: 1fr;\n overflow: hidden; /* the panes scroll; the page does not */\n}\n.ctw-w2 > section { min-width: 0; min-height: 0; display: flex; }\n.ctw-w2-frame {\n flex: 1; width: 100%; height: 100%; border: 0; background: var(--ctw-bg);\n /* the table fades in once its host has published __ready and been handed the card โ otherwise\n the engine's own SAMPLE board is visible for a beat, which reads as somebody else's data */\n opacity: 0; transition: opacity .18s ease;\n}\n#table.is-ready, #canvas { opacity: 1; }\n.ctw-w2-preview { border-left: 1px solid var(--ctw-line); background: #fff; }\n\n/* โฅ44px targets, everywhere a finger lands. Above the phone rules below, so that shrinking a ROW\n can never quietly shrink the target inside it โ same specificity, and the last one would win. */\n.ctw-bar .ctw-btn, .ctw-sandbox-banner .ctw-btn { min-height: 44px; }\n\n@media (max-width: 1023px) {\n /* a phone shows ONE view: the board, when chosen, stands in place of the card */\n body[data-view=\"board\"] .ctw-w2-preview { display: none; }\n .ctw-w2-preview { border-left: 0; }\n .ctw-w2-preview .ctw-w2-frame { overflow: auto; }\n\n /* the masthead: ONE row. The brand and the door, nothing else โ `.ctw-bar` wraps by default\n below 769px (w/editor.css), and with two children that is a second row for no reason. */\n .ctw-bar { flex-wrap: nowrap; gap: .6rem; padding: .3rem .8rem; }\n .ctw-bar h1 { font-size: .9rem; white-space: nowrap; }\n .ctw-bar .ctw-btn { padding: .45rem .8rem; font-size: .85rem; }\n\n /* the banner: ONE row too. ๐ฉธ 111px in ja (three wrapped lines plus ใใฏใใใใใใ็ดใใ on its\n own) against 59 in zh โ the widest language paid the most for the least. The sentence\n ellipsises and the button keeps its own width, so nothing wraps in any of the nine. Neither\n button gets shorter than 44px: winning ten pixels off the top of the screen by shrinking the\n thing a thumb has to hit is not a trade this file makes. */\n .ctw-sandbox-banner { flex-wrap: nowrap; gap: .5rem; padding: .25rem .7rem; font-size: .75rem; }\n #sandbox-banner-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .ctw-sandbox-banner .ctw-btn { flex: 0 0 auto; padding: .3rem .7rem; font-size: .78rem; white-space: nowrap; }\n}\n/* โโ the view switch: ๅก ยท ็ๆก ยท Markdown โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n A segmented control; the board pane exists only in the board view. Default (no data-view yet,\n or \"card\") hides it. */\nbody:not([data-view=\"board\"]) .ctw-w2-table { display: none; }\n.ctw-views {\n display: inline-flex; flex: 0 1 auto; min-width: 0; padding: 3px; gap: 2px;\n border: 1px solid var(--ctw-line); border-radius: 12px; background: var(--ctw-bg);\n}\n.ctw-view {\n min-height: 44px; padding: .3rem .8rem; font: inherit; font-size: .82rem; font-weight: 600;\n white-space: nowrap; border: 0; border-radius: 9px; background: transparent;\n color: var(--ctw-dim); cursor: pointer;\n}\n.ctw-view[aria-pressed=\"true\"] { background: var(--ctw-panel); color: var(--ctw-ink); box-shadow: 0 1px 3px rgba(0,0,0,.15); }\n.ctw-view:focus-visible { outline: 2px solid var(--ctw-go); outline-offset: 1px; }\n@media (max-width: 640px) {\n .ctw-bar h1 { display: none; }\n .ctw-view { padding: .3rem .55rem; font-size: .76rem; min-height: 44px; }\n}\n\n/* โโ card first, board second โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n The card is where editing happens now (tap a tile), so on a wide screen it takes the larger,\n centre column and the board is the titled panel beside it. The board keeps every behaviour; it is\n only smaller. The card column's background frames the card so it reads as THE object on screen. */\n.ctw-w2 > section.ctw-w2-table { flex-direction: column; }\n.ctw-w2-panel-title {\n flex: 0 0 auto; margin: 0; padding: .55rem .9rem;\n font-size: .78rem; font-weight: 700; letter-spacing: .02em; color: var(--ctw-dim);\n border-bottom: 1px solid var(--ctw-line); background: var(--ctw-panel);\n}\n.ctw-w2-preview { position: relative; }\n@media (min-width: 1024px) {\n body[data-view=\"board\"] .ctw-w2 { grid-template-columns: minmax(300px, 1fr) minmax(0, 1.7fr); }\n .ctw-w2-preview { background: var(--ctw-bg); padding: 1.2rem 1.5rem 0; justify-content: center; }\n .ctw-w2-preview .ctw-w2-frame {\n max-width: 560px; margin: 0 auto; background: #fff;\n border-radius: 18px 18px 0 0; box-shadow: 0 10px 40px rgba(0,0,0,.14);\n }\n .ctw-w2-table { border-right: 1px solid var(--ctw-line); }\n .ctw-w2-preview { border-left: 0; }\n}\n\n/* the one-time coach mark: a strip ABOVE the card frame, in the shell's flow โ never over it.\n ๐ฉธ It used to float over the top of the card (absolute, top:14px) and, at 390px in host mode,\n covered the person's name. In flow it pushes the card down instead, at every width. */\n.ctw-w2-preview { flex-direction: column; }\n.ctw-w2-preview .ctw-w2-frame { min-height: 0; }\n.ctw-coach {\n order: -1; flex: 0 0 auto; align-self: stretch; margin: .5rem .75rem; max-width: 560px; box-sizing: border-box;\n display: flex; align-items: center; justify-content: space-between; gap: .5rem;\n padding: .45rem .5rem .45rem .95rem; border-radius: 999px;\n font-size: .85rem; font-weight: 600; line-height: 1.35;\n background: rgba(20,20,20,.9); color: #fff; box-shadow: 0 6px 20px rgba(0,0,0,.25);\n}\n.ctw-coach[hidden] { display: none; }\n@media (min-width: 600px) { .ctw-coach { margin: .5rem auto; width: calc(100% - 1.5rem); } }\n.ctw-sandbox-banner[hidden] { display: none; }\n.ctw-coach-close {\n flex: 0 0 auto; min-width: 32px; min-height: 32px; border: 0; border-radius: 50%;\n background: rgba(255,255,255,.14); color: #fff; font: inherit; cursor: pointer;\n}\n\n/* โโ ใ๏ผๅ ไธๅผต็ใ: ONE ROW, seven icons โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n ๐ฉธ The palette this replaces was a COLUMN of seven cards, each with a title and a sentence of\n explanation โ 27% of a phone's first screen before the card itself began, and a scroll to reach\n the seventh. A row of icons with a name under each is the same seven choices in one glance.\n\n It WRAPS rather than scrolling sideways: a horizontally scrolling row hides its own last item,\n and the seventh kind is the one nobody would ever find. */\n.ctw-pick { width: min(520px, 100%); }\n.ctw-pick-row {\n display: flex; flex-wrap: wrap; gap: .5rem; justify-content: center; margin: .4rem 0 .6rem;\n}\n.ctw-pick-btn {\n flex: 0 0 auto; width: 4.4rem; min-height: 4.6rem;\n display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .3rem;\n font: inherit; font-size: .72rem; padding: .5rem .25rem;\n border: 1px solid var(--ctw-line); border-radius: 12px;\n background: transparent; color: var(--ctw-ink); cursor: pointer;\n}\n.ctw-pick-btn:hover, .ctw-pick-btn:focus-visible {\n background: color-mix(in srgb, var(--ctw-go) 14%, transparent); border-color: var(--ctw-go);\n}\n.ctw-pick-ico { display: block; width: 24px; height: 24px; color: var(--ctw-go); }\n.ctw-pick-ico svg { width: 100%; height: 100%; display: block; }\n.ctw-pick-name { text-align: center; line-height: 1.25; }\n/* each kind says what it is for: the row becomes a two-column list of icon ยท name ยท one line */\n.ctw-pick-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); justify-content: stretch; }\n.ctw-pick-btn { width: auto; min-height: 0; display: grid; grid-template-columns: 24px 1fr; column-gap: .6rem; row-gap: .1rem; align-items: start; justify-items: start; text-align: start; padding: .6rem .75rem; }\n.ctw-pick-btn .ctw-pick-ico { grid-row: span 2; }\n.ctw-pick-btn .ctw-pick-name { text-align: start; font-size: .85rem; }\n.ctw-pick-hint { font-weight: 400; font-size: .75rem; line-height: 1.35; color: var(--ctw-dim); }\n.ctw-w2-panel-hint {\n flex: 0 0 auto; margin: 0; padding: .45rem .9rem; font-size: .8rem; line-height: 1.4;\n color: var(--ctw-dim); border-bottom: 1px solid var(--ctw-line);\n}\n\n/* โโ whole-page Markdown mode โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n Opened by the board's OWN `file-text` header action (edit2.mjs intercepts it) โ one page, two\n complete ways to edit one card, never both at once. The text is the CARD, because the card is\n the file the board is a view of. */\n.ctw-md-panel { width: min(760px, 100%); }\n.ctw-md {\n width: 100%; min-height: 46vh; resize: vertical;\n font: 12.5px/1.6 ui-monospace, \"SF Mono\", Menlo, monospace;\n padding: .6rem .7rem; border-radius: 10px;\n border: 1px solid var(--ctw-line); background: var(--ctw-bg); color: var(--ctw-ink);\n}\n.ctw-md-error {\n margin: 0 0 .6rem; padding: .5rem .6rem; border-radius: 8px; font-size: .82rem;\n color: var(--ctw-danger); border: 1px solid color-mix(in srgb, var(--ctw-danger) 45%, transparent);\n}\n.ctw-md-error[hidden] { display: none; }\n\n/* the banner reads the long sentence on a wide screen and the short one on a phone โ same rule\n /try/edit uses, restated here because this document's banner is the same markup. */\n@media (max-width: 640px) {\n #sandbox-banner-text { display: none; }\n #sandbox-banner-status { display: inline; }\n}\n\n/* HOST MODE: the save status beside ใๅญใ. The failure text is the parent's own words, verbatim. */\n.ctw-host-status{font:500 13px/1.3 system-ui,sans-serif;opacity:.7;margin-right:10px;max-width:40ch;overflow-wrap:anywhere}\n.ctw-host-status[data-status=\"failed\"]{opacity:1;color:#c0392b}\nbody[data-host-waiting] .ctw-w2{visibility:hidden}\n\n/* โโ THE PORCH SKIN โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n Owner ruling 2026-09-24: the shell wears feelreef.com/dashboard's look (w2/porch-tokens.css); the\n CARD keeps its own theme โ nothing below reaches into either iframe. LAST in this file on purpose:\n it restyles w/editor.css's shared chrome for THIS surface only (w/ keeps its own look).\n\n Step one is the cheap, total one: w/editor.css paints everything from six --ctw-* tokens, so they\n are pointed at the carrier tokens here and every rule that reads them follows. Same specificity as\n editor.css's own :root (incl. its prefers-color-scheme copy) and later, so these win in BOTH OS\n schemes โ one look, as on the porch. Step two restyles what tokens alone cannot say (pill shapes,\n the segmented switch, Panel shadow, the display face). */\n:root {\n --ctw-bg: var(--carrier-bg-solid);\n --ctw-panel: var(--carrier-bg);\n --ctw-line: var(--carrier-border);\n --ctw-ink: var(--carrier-text);\n --ctw-dim: var(--carrier-text-muted);\n --ctw-go: var(--carrier-accent);\n --ctw-danger: var(--carrier-feedback-error);\n accent-color: var(--carrier-accent);\n}\nbody {\n font-family: var(--font-body); color: var(--carrier-text);\n background: var(--carrier-bg-solid) var(--porch-ground) no-repeat;\n background-size: 100% 100%;\n}\n::selection { background: rgba(255, 122, 92, 0.25); } /* dashboard +page.svelte:2220 */\n\n/* the banner and the bar: frosted carrier surfaces over the porch water */\n.ctw-sandbox-banner {\n background: var(--console-tint); color: var(--carrier-heading);\n border-bottom: 1px solid var(--carrier-border); font-weight: 700;\n}\n.ctw-bar {\n background: var(--carrier-bg); border-bottom: 1px solid var(--carrier-border);\n -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);\n}\n.ctw-bar h1, .ctw-bar h1.ctw-brand {\n font-family: var(--font-display); font-weight: 400; color: var(--carrier-heading);\n font-size: 1.12rem; letter-spacing: 0; opacity: 1;\n}\n.ctw-brand a { font-weight: 400; letter-spacing: 0; }\n.ctw-brand span { font-family: var(--font-body); font-weight: 700; font-size: .82rem; color: var(--carrier-text-muted); opacity: 1; }\n\n/* buttons โ Button.svelte: 1.5px border, weight 800, pill, the press transform */\n.ctw-btn, .ctw-tab {\n font-family: var(--font-body); font-weight: var(--btn-weight);\n border: 1.5px solid var(--carrier-outline-border); border-radius: var(--btn-radius-pill);\n background: var(--btn-surface); color: var(--btn-ink);\n transition: background .15s, border-color .15s, color .15s, transform 90ms ease;\n}\n.ctw-btn:not(.ctw-btn-go):hover, .ctw-tab:hover { background: var(--btn-ghost-hover); color: var(--btn-secondary-ink); }\n.ctw-btn-go { background: var(--carrier-accent); border-color: var(--carrier-accent); color: var(--carrier-accent-ink); }\n.ctw-btn-go:hover { filter: none; background: color-mix(in srgb, var(--carrier-accent) 86%, #000); }\n.ctw-btn-ghost { background: transparent; border-color: var(--carrier-outline-border); color: var(--carrier-link); }\n.ctw-btn-danger { background: transparent; color: var(--btn-danger-ink); border-color: color-mix(in srgb, var(--btn-danger-ink) 55%, transparent); }\n.ctw-btn-danger:not(.ctw-btn-go):hover { background: var(--btn-danger-hover); color: var(--btn-danger-ink); }\n.ctw-btn[disabled] { opacity: .55; }\n/* \"the press answers before the server does\" โ Button.svelte `.btn:active` */\n.ctw-btn:active:not(:disabled), .ctw-tab:active, .ctw-view:active, .ctw-pick-btn:active, .ctw-modal-close:active, .ctw-coach-close:active { transform: var(--porch-press); }\n@media (prefers-reduced-motion: reduce) {\n .ctw-btn:active:not(:disabled), .ctw-tab:active, .ctw-view:active, .ctw-pick-btn:active, .ctw-modal-close:active, .ctw-coach-close:active { transform: none; }\n}\n:where(.ctw-btn, .ctw-view, .ctw-pick-btn, .ctw-modal-close, .ctw-coach-close, .ctw-field input, .ctw-field select, .ctw-field textarea, .ctw-md):focus-visible {\n outline: 2px solid var(--btn-secondary-ink); outline-offset: 2px;\n}\n\n/* the view switch: a pill segmented control, accent fill on the chosen segment */\n.ctw-views { border: 1px solid var(--carrier-border); border-radius: var(--btn-radius-pill); background: var(--carrier-input-bg); }\n.ctw-view { border-radius: var(--btn-radius-pill); font-family: var(--font-body); font-weight: 700; color: var(--carrier-text-muted); }\n.ctw-view:hover:not([aria-pressed=\"true\"]) { color: var(--carrier-text); background: var(--btn-ghost-hover); }\n.ctw-view[aria-pressed=\"true\"] { background: var(--carrier-accent); color: var(--carrier-accent-ink); box-shadow: none; }\n\n/* sheets & modals โ console Panel: radius 18, carrier border + shadow, on the solid ground */\n.ctw-modal { background: rgba(12, 58, 68, .28); }\n.ctw-modal-panel {\n background: var(--carrier-bg-solid); color: var(--carrier-text);\n border: 1px solid var(--carrier-border); border-radius: var(--console-radius); box-shadow: var(--carrier-shadow);\n}\n.ctw-modal-panel h2 { font-family: var(--font-display); font-weight: 400; color: var(--carrier-heading); font-size: 1.2rem; }\n.ctw-modal-close { border: 1.5px solid var(--carrier-border); background: var(--btn-surface); color: var(--carrier-text); corner-shape: round; }\n.ctw-modal-close:hover { border-color: var(--carrier-outline-border); color: var(--carrier-link); }\n.ctw-modal-foot { border-top-color: var(--carrier-border); }\n.ctw-field > span { font-weight: 700; color: var(--carrier-heading); }\n.ctw-field input[type=text], .ctw-field input[type=url], .ctw-field select, .ctw-field textarea,\n.ctw-md, .ctw-url {\n background: var(--carrier-input-bg); border: 1px solid var(--carrier-border); border-radius: var(--console-radius-sm); color: var(--carrier-text);\n}\n.ctw-group, .ctw-pub-result { border-color: var(--carrier-border); border-radius: var(--console-radius-sm); background: var(--carrier-bg); }\n.ctw-err, .ctw-asset-err { color: var(--carrier-feedback-error); }\n.ctw-md-error { border-radius: var(--console-radius-sm); }\n.ctw-pick-btn { border-radius: var(--console-radius-sm); background: var(--btn-surface); border-color: var(--carrier-border); font-weight: 700; }\n.ctw-pick-btn:hover, .ctw-pick-btn:focus-visible { background: var(--console-tint); border-color: var(--carrier-accent); }\n.ctw-pick-ico { color: var(--carrier-accent); }\n\n/* the card column frames the card on the porch water; the board is a Panel beside it */\n.ctw-w2-preview { background: transparent; border-left-color: var(--carrier-border); }\n.ctw-w2-table { background: var(--carrier-bg); }\n.ctw-w2-panel-title {\n font-family: var(--font-display); font-weight: 400; font-size: .95rem; letter-spacing: 0;\n color: var(--carrier-heading); background: var(--carrier-bg); border-bottom-color: var(--carrier-border);\n}\n@media (min-width: 1024px) {\n .ctw-w2-preview { background: transparent; }\n .ctw-w2-preview .ctw-w2-frame { border-radius: var(--console-radius) var(--console-radius) 0 0; box-shadow: var(--carrier-shadow); }\n .ctw-w2-table { border-right-color: var(--carrier-border); }\n}\n\n/* the coach mark: a porch pill, not a black toast */\n.ctw-coach {\n background: var(--carrier-bg-solid); color: var(--carrier-heading);\n border: 1.5px solid var(--carrier-outline-border); box-shadow: var(--carrier-shadow); font-weight: 700;\n}\n.ctw-coach-close { background: var(--console-tint); color: var(--carrier-heading); corner-shape: round; }\n\n/* host-mode save status, in the same family */\n.ctw-host-status { font: 600 13px/1.3 var(--font-body); color: var(--carrier-text-muted); opacity: 1; }\n.ctw-host-status[data-status=\"failed\"] { color: var(--carrier-feedback-error); }\n\n/* โโ PHONE BAR: two rows at โค520px (measured 2026-09-24 at 390px: the third view segment clipped\n to ใMarkoโฆใ, the door wrapped to two lines, the banner ellipsised). Row 1 = brand + door (one\n line, never wraps); row 2 = the view switch, full width, three equal segments that never clip.\n The banner wraps (โค3 lines) instead of ellipsising. Every target stays โฅ44px. */\n@media (max-width: 520px) {\n .ctw-bar {\n display: grid; grid-template-columns: minmax(0, 1fr) auto;\n grid-template-areas: \"brand actions\" \"views views\";\n align-items: center; gap: .45rem .6rem; padding: .45rem .75rem .55rem;\n }\n .ctw-bar h1, .ctw-bar h1.ctw-brand { display: flex; grid-area: brand; min-width: 0; overflow: hidden; white-space: nowrap; font-size: 1.05rem; }\n .ctw-bar-spacer { display: none; }\n .ctw-actions { grid-area: actions; min-width: 0; }\n .ctw-bar .ctw-btn { white-space: nowrap; font-size: 14px; padding: .4rem .9rem; min-height: 44px; }\n .ctw-views { grid-area: views; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; box-sizing: border-box; }\n .ctw-view { min-width: 0; padding: .3rem .25rem; font-size: .8rem; min-height: 44px; white-space: nowrap; overflow: visible; text-overflow: clip; }\n\n .ctw-sandbox-banner { flex-wrap: wrap; gap: .3rem .6rem; padding: .4rem .75rem; font-size: .78rem; line-height: 1.35; }\n #sandbox-banner-status { white-space: normal; overflow: visible; text-overflow: clip; min-width: 0; flex: 1 1 14rem;\n display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }\n .ctw-sandbox-banner .ctw-btn { min-height: 44px; }\n}\n\n/* โโ the card FLOATS on the porch (โฅ1024px) โ see fitCanvas() in edit2.mjs. The iframe takes the\n card's own height; the column scrolls. min-height covers the beat before the card has loaded. */\n@media (min-width: 1024px) {\n .ctw-w2-preview { overflow-y: auto; align-items: flex-start; padding-bottom: 2rem; }\n .ctw-w2-preview .ctw-w2-frame {\n flex: 0 1 560px; height: auto; min-height: 320px;\n border-radius: var(--console-radius); background: transparent;\n }\n}\n\n/* Scrollbars (Safari drew a thick light-grey bar on the board pane and on the card column): the\n shell's scrolling panes get the same thin translucent bar the card frame already has (edit2.mjs\n CARD_EDIT_CSS). Editor page only โ this stylesheet ships nowhere else. The board's own document\n gets the same rule through board-bridge's BOARD_LIGHT_CSS. */\n.ctw-w2-preview, .ctw-w2-frame, .ctw-modal-panel, .ctw-md { scrollbar-width: thin; scrollbar-color: rgba(128,128,128,.45) transparent; }\n:is(.ctw-w2-preview, .ctw-w2-frame, .ctw-modal-panel, .ctw-md)::-webkit-scrollbar { width: 8px; height: 8px; background: transparent; }\n:is(.ctw-w2-preview, .ctw-w2-frame, .ctw-modal-panel, .ctw-md)::-webkit-scrollbar-track { background: transparent; }\n:is(.ctw-w2-preview, .ctw-w2-frame, .ctw-modal-panel, .ctw-md)::-webkit-scrollbar-thumb { background: rgba(128,128,128,.45); border-radius: 8px; }\n";
-export const EDIT2_JS = "(() => {\n // packages/tugtile/board-core.js\n function tileRenderText(tileLines) {\n const out = [];\n const m = tileLines[0].match(/^- \\[.\\] ?(.*)/);\n out.push((m ? m[1] : tileLines[0]).replace(/\\s*%%tg-home:.*?%%/, \"\"));\n for (let i = 1; i < tileLines.length; i++) {\n out.push(tileLines[i].replace(/^(?:\\t| {1,4})/, \"\"));\n }\n return out.join(\"\\n\");\n }\n function parseFile(text2) {\n const eol = /\\r\\n/.test(text2) ? \"\\r\\n\" : \"\\n\";\n const lines2 = text2.split(/\\r\\n|\\r|\\n/);\n const settingsIdx = lines2.findIndex((l) => l.indexOf(\"%% kanban:settings\") === 0 || l.indexOf(\"%% tugtile:settings\") === 0);\n const end = settingsIdx === -1 ? lines2.length : settingsIdx;\n let fmEnd = 0;\n if (lines2[0] === \"---\") {\n const c = lines2.indexOf(\"---\", 1);\n if (c !== -1) fmEnd = c + 1;\n }\n const isFence = (l) => /^(```|~~~)/.test(l);\n const isHr = (l) => /^\\*\\*\\*+[ \\t]*$/.test(l);\n const isHeading = (l) => l.indexOf(\"## \") === 0;\n let archiveIdx = -1;\n {\n let fence2 = false;\n for (let i = fmEnd; i < end; i++) {\n if (isFence(lines2[i])) {\n fence2 = !fence2;\n continue;\n }\n if (fence2) continue;\n if (isHr(lines2[i])) {\n let j = i + 1;\n while (j < end && lines2[j].trim() === \"\") j++;\n if (j < end && isHeading(lines2[j])) {\n archiveIdx = i;\n break;\n }\n }\n }\n }\n const laneEnd = archiveIdx === -1 ? end : archiveIdx;\n const archive = archiveIdx === -1 ? \"\" : lines2.slice(archiveIdx, end).join(\"\\n\").replace(/^\\s+/, \"\").replace(/\\s+$/, \"\");\n let firstCol = -1;\n {\n let fence2 = false;\n for (let i = fmEnd; i < laneEnd; i++) {\n if (isFence(lines2[i])) {\n fence2 = !fence2;\n continue;\n }\n if (!fence2 && isHeading(lines2[i])) {\n firstCol = i;\n break;\n }\n }\n }\n if (firstCol === -1) firstCol = laneEnd;\n const pre = lines2.slice(0, firstCol).join(\"\\n\");\n const post = settingsIdx === -1 ? \"\" : lines2.slice(settingsIdx).join(\"\\n\");\n const columns = [];\n let col = null, tileLines = null, fence = false;\n const flush = () => {\n if (tileLines && col) {\n const cl = tileLines.slice();\n while (cl.length && cl[cl.length - 1].trim() === \"\") cl.pop();\n const cm = /^- \\[(.)\\]/.exec(cl[0]);\n const bm = /\\s+\\^([A-Za-z0-9-]+)$/.exec(cl[0]);\n const clForText = cl.slice();\n if (bm) clForText[0] = cl[0].slice(0, bm.index);\n col.tiles.push({ raw: cl.join(\"\\n\"), text: tileRenderText(clForText), check: cm ? cm[1] : \" \", block: bm ? bm[1] : null });\n }\n tileLines = null;\n };\n for (let i = firstCol; i < laneEnd; i++) {\n const ln = lines2[i];\n if (isFence(ln)) {\n fence = !fence;\n if (tileLines) tileLines.push(ln);\n else if (col) col.lead.push(ln);\n continue;\n }\n if (!fence && isHeading(ln)) {\n flush();\n col = { header: ln, title: ln.slice(3).trim(), tiles: [], lead: [] };\n columns.push(col);\n continue;\n }\n if (!col) continue;\n if (!fence && /^- \\[.\\]/.test(ln)) {\n flush();\n tileLines = [ln];\n } else if (tileLines) tileLines.push(ln);\n else col.lead.push(ln);\n }\n flush();\n for (const c of columns) {\n while (c.lead.length && c.lead[0].trim() === \"\") c.lead.shift();\n while (c.lead.length && c.lead[c.lead.length - 1].trim() === \"\") c.lead.pop();\n }\n let settings = {};\n const sm = /```\\s*\\n([\\s\\S]*?)\\n```/.exec(post);\n if (sm) {\n try {\n settings = JSON.parse(sm[1]);\n } catch (e) {\n settings = {};\n }\n }\n return { pre, columns, archive, post, settings, eol };\n }\n function serializeFile(model) {\n const pre = (model.pre || \"\").replace(/\\s+$/, \"\");\n const body = model.columns.map((c) => {\n const lead = c.lead && c.lead.length ? c.lead.join(\"\\n\") + \"\\n\\n\" : \"\";\n return c.header + \"\\n\\n\" + lead + c.tiles.map((cd) => cd.raw).join(\"\\n\");\n }).join(\"\\n\\n\\n\");\n const archive = (model.archive || \"\").replace(/^\\s+/, \"\").replace(/\\s+$/, \"\");\n const post = (model.post || \"\").replace(/^\\s+/, \"\").replace(/\\s+$/, \"\");\n let out = (pre ? pre + \"\\n\\n\" : \"\") + body;\n if (archive) out += \"\\n\\n\\n\" + archive;\n if (post) out += \"\\n\\n\\n\" + post;\n out += \"\\n\";\n return model.eol === \"\\r\\n\" ? out.replace(/\\n/g, \"\\r\\n\") : out;\n }\n\n // packages/cssmd/cssmd.js\n var ESC_RE = /[&<>]/g;\n var ESC_MAP = { \"&\": \"&\", \"<\": \"<\", \">\": \">\" };\n function escHtml(s) {\n return String(s == null ? \"\" : s).replace(ESC_RE, (c) => ESC_MAP[c]);\n }\n\n // packages/sitetile/site-core.js\n function splitFrontmatter(text2) {\n const m = /^---\\n([\\s\\S]*?)\\n---\\n?/.exec(text2);\n if (!m) return { meta: {}, body: text2 };\n const meta = {};\n const lines2 = m[1].split(\"\\n\");\n for (let i = 0; i < lines2.length; i++) {\n const kv = /^([\\w-]+):\\s*(.*)$/.exec(lines2[i]);\n if (!kv) continue;\n const key = kv[1];\n const marker = kv[2].trim();\n if (marker === \"|\" || marker === \">\") {\n const block = [];\n let base = null;\n while (i + 1 < lines2.length) {\n const nxt = lines2[i + 1];\n if (nxt.trim() === \"\") {\n block.push(\"\");\n i++;\n continue;\n }\n const ind = (nxt.match(/^[ \\t]*/)[0] || \"\").length;\n if (base === null) base = ind;\n if (ind < base) break;\n block.push(nxt.slice(base));\n i++;\n }\n while (block.length && block[block.length - 1] === \"\") block.pop();\n meta[key] = marker === \">\" ? block.join(\" \") : block.join(\"\\n\");\n } else {\n meta[key] = marker;\n }\n }\n return { meta, body: text2.slice(m[0].length) };\n }\n function parseParams(raw) {\n const map = {};\n if (!raw) return map;\n const re = /(\\w+)=(\"[^\"]*\"(?:โ\\S+)?|\\S+)/g;\n let m;\n while (m = re.exec(raw)) {\n const key = m[1];\n const val = m[2];\n if (val.charAt(0) === '\"') {\n const lm = /^\"([^\"]*)\"(?:โ(\\S+))?$/.exec(val);\n if (lm) map[key] = lm[2] !== void 0 ? { label: lm[1], href: lm[2] } : lm[1];\n else map[key] = val;\n } else map[key] = val;\n }\n const bare = raw.replace(/\"[^\"]*\"(?:โ\\S+)?/g, (q) => \" \".repeat(q.length));\n let bm;\n const bre = /(?:^|\\s)([a-zA-Z]\\w*)(?=\\s|$)/g;\n while (bm = bre.exec(bare)) {\n const w = bm[1];\n if (!(w in map) && !new RegExp(\"\\\\b\" + w + \"\\\\s*=\").test(bare)) map[w] = true;\n }\n return map;\n }\n var RE_CELL_BULLET = /^\\s*[-*+ใปโข]\\s+/;\n var RE_TURN_BULLET = new RegExp(RE_CELL_BULLET.source + \"|^\\\\s*[\\u30FB\\u2022]\\\\S\");\n\n // packages/cardtile/card-core.js\n var DEFAULT_COLS = 6;\n var SPAN_MAX = 6;\n var DEFAULT_LANE = \"grid\";\n var RE_CARD_MARKER = /^%%\\s*card:\\s*(.*?)\\s*%%[ \\t]*([^\\n]*)\\n?([\\s\\S]*)$/;\n var clampSpan = (v, max) => {\n const n = Number(v);\n if (!Number.isFinite(n)) return 1;\n return Math.min(max, Math.max(1, Math.floor(n)));\n };\n function extractCell(text2) {\n const m = RE_CARD_MARKER.exec(text2 || \"\");\n if (!m) return { type: \"text\", params: {}, rawParams: \"\", body: (text2 || \"\").trim() };\n const sp = /^(\\S+)\\s*([\\s\\S]*)$/.exec(m[1].trim());\n const type = sp ? sp[1] : \"text\";\n const rawParams = sp ? sp[2].trim() : \"\";\n const body = (m[2] + (m[3] ? \"\\n\" + m[3] : \"\")).replace(/\\s+$/, \"\");\n return { type, params: parseParams(rawParams), rawParams, body: body.replace(/^\\n+/, \"\") };\n }\n function cellToRaw(cell) {\n const marker = \"%% card: \" + cell.type + (cell.rawParams ? \" \" + cell.rawParams : \"\") + \" %%\";\n const body = cell.body || \"\";\n if (!body) return \"- [ ] \" + marker;\n if (!body.includes(\"\\n\")) return \"- [ ] \" + marker + \" \" + body;\n return \"- [ ] \" + marker + body.split(\"\\n\").map((l) => \"\\n\t\" + l).join(\"\");\n }\n var RE_ASSET_LANE = /^assets$/i;\n var RE_DRAWER_LANE = /^drawer\\s*:\\s*(.+)$/i;\n var splitDrawer = (title) => {\n const m = RE_DRAWER_LANE.exec(String(title || \"\").trim());\n if (!m) return null;\n const bar = m[1].indexOf(\"|\");\n const id = (bar < 0 ? m[1] : m[1].slice(0, bar)).trim().toLowerCase().replace(/\\s+/g, \"-\");\n return { id, title: bar < 0 ? \"\" : m[1].slice(bar + 1).trim() };\n };\n function parseCard(md) {\n const m = parseFile(md);\n const { meta } = splitFrontmatter((m.pre || \"\") + \"\\n\");\n const cells = [];\n const blocks = [];\n const drawers = [];\n let faceLane = null;\n const assets = {};\n for (const col of m.columns) {\n const laneTitle = String(col.title || \"\").trim();\n if (RE_ASSET_LANE.test(laneTitle)) {\n for (const t of col.tiles) {\n const c = extractCell(t.text);\n const id = (c.params.id || \"\").trim();\n if (c.type === \"asset\" && id) assets[id] = { mime: c.params.mime || \"image/webp\", b64: (c.body || \"\").trim() };\n }\n continue;\n }\n const d = splitDrawer(col.title);\n const laneCells = col.tiles.map((t) => extractCell(t.text));\n if (d) {\n drawers.push({ id: d.id, title: d.title, cells: laneCells });\n continue;\n }\n const label2 = faceLane == null ? \"\" : String(col.title || \"\").trim();\n if (faceLane == null) faceLane = col.title;\n blocks.push({ label: label2, start: cells.length, count: laneCells.length });\n cells.push(...laneCells);\n }\n if (!blocks.length) blocks.push({ label: \"\", start: 0, count: 0 });\n return {\n pre: m.pre || \"\",\n post: m.post || \"\",\n cols: DEFAULT_COLS,\n // a Card is six columns. See the note on DEFAULT_COLS.\n lane: faceLane != null ? faceLane : DEFAULT_LANE,\n blocks,\n cells,\n drawers,\n assets\n };\n }\n function serializeCard(model) {\n const laneOf = (title, cells2) => ({\n header: \"## \" + title,\n title,\n lead: [],\n tiles: (cells2 || []).map((c) => ({ raw: cellToRaw(c), text: \"\", check: \" \", block: null }))\n });\n const cells = model.cells || [];\n const blocks = model.blocks && model.blocks.length ? model.blocks : [{ label: \"\", start: 0, count: cells.length }];\n const columns = blocks.map((b, i) => laneOf(\n i === 0 ? model.lane || DEFAULT_LANE : b.label || DEFAULT_LANE,\n cells.slice(b.start, b.start + b.count)\n ));\n for (const d of model.drawers || []) columns.push(laneOf(\"drawer: \" + d.id + (d.title ? \" | \" + d.title : \"\"), d.cells));\n const assetIds = Object.keys(model.assets || {});\n if (assetIds.length) {\n columns.push(laneOf(\"assets\", assetIds.sort().map((id) => ({\n type: \"asset\",\n rawParams: `id=${id} mime=${model.assets[id].mime}`,\n params: { id, mime: model.assets[id].mime },\n body: model.assets[id].b64\n }))));\n }\n return serializeFile({ pre: model.pre || \"\", columns, archive: \"\", post: model.post || \"\", eol: \"\\n\" });\n }\n function packGrid(model) {\n const cols = Math.max(1, model.cols || DEFAULT_COLS);\n const occ = [];\n const ensureRow = (r) => {\n while (occ.length <= r) occ.push(new Array(cols).fill(false));\n };\n const fits = (r, c, w, h) => {\n if (c + w > cols) return false;\n for (let dr = 0; dr < h; dr++) {\n ensureRow(r + dr);\n for (let dc = 0; dc < w; dc++) if (occ[r + dr][c + dc]) return false;\n }\n return true;\n };\n const mark = (r, c, w, h) => {\n for (let dr = 0; dr < h; dr++) {\n ensureRow(r + dr);\n for (let dc = 0; dc < w; dc++) occ[r + dr][c + dc] = true;\n }\n };\n const placements = [];\n (model.cells || []).forEach((cell, index) => {\n const w = Math.min(clampSpan(cell.params.w, SPAN_MAX), cols);\n const h = clampSpan(cell.params.h, SPAN_MAX);\n let r = 0, done = false;\n while (!done) {\n ensureRow(r);\n for (let c = 0; c < cols; c++) {\n if (fits(r, c, w, h)) {\n mark(r, c, w, h);\n placements.push({ cell, index, row: r, col: c, w, h, type: cell.type });\n done = true;\n break;\n }\n }\n r++;\n if (r > 1e4) {\n done = true;\n }\n }\n });\n return { placements, rows: occ.length, cols };\n }\n var VAL = '(\"[^\"]*\"(?:\\u2192\\\\S+)?|\\\\S+)';\n var tokenRe = (key) => new RegExp(\"(^|\\\\s)\" + key + \"=\" + VAL);\n function quoteParam(v) {\n const s = String(v);\n if (s === \"\") return '\"\"';\n return /[\\s\"]/.test(s) ? '\"' + s.replace(/\"/g, \"\") + '\"' : s;\n }\n function setToken(rp, key, val) {\n const re = tokenRe(key);\n if (re.test(rp)) return rp.replace(re, (m, pre) => pre + key + \"=\" + val);\n return (rp ? rp + \" \" : \"\") + key + \"=\" + val;\n }\n function removeToken(rp, key) {\n return rp.replace(tokenRe(key), \"\").replace(/^\\s+|\\s+$/g, \"\").replace(/\\s{2,}/g, \" \");\n }\n function normalizeCell(cell) {\n return parseCard(\"## \" + DEFAULT_LANE + \"\\n\" + cellToRaw(cell) + \"\\n\").cells[0];\n }\n function setTitle(model, name) {\n const pre = String(model.pre || \"\");\n const line = \"title: \" + String(name == null ? \"\" : name).trim();\n const fence = /^---\\r?\\n([\\s\\S]*?)(\\r?\\n)---[ \\t]*$/m.exec(pre);\n if (!fence) return { ...model, pre: \"---\\n\" + line + \"\\n---\\n\" + (pre ? \"\\n\" + pre.replace(/^\\n+/, \"\") : \"\") };\n const inner = fence[1];\n const next = /^title:.*$/m.test(inner) ? inner.replace(/^title:.*$/m, line) : inner ? line + \"\\n\" + inner : line;\n return { ...model, pre: pre.slice(0, fence.index) + \"---\\n\" + next + fence[2] + \"---\" + pre.slice(fence.index + fence[0].length) };\n }\n function reorder(model, from, to) {\n const cells = (model.cells || []).slice();\n if (from < 0 || from >= cells.length) return model;\n const [moved] = cells.splice(from, 1);\n const dest = Math.min(Math.max(0, to), cells.length);\n cells.splice(dest, 0, moved);\n return { ...model, cells };\n }\n\n // packages/cardtile/colour.mjs\n function parseColour(input) {\n const s = String(input == null ? \"\" : input).trim().toLowerCase();\n let m = /^#([0-9a-f]{3})$/.exec(s);\n if (m) return [...m[1]].map((c) => parseInt(c + c, 16));\n m = /^#([0-9a-f]{6})$/.exec(s);\n if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));\n m = /^rgba?\\(\\s*([\\d.]+)[\\s,]+([\\d.]+)[\\s,]+([\\d.]+)/.exec(s);\n if (m) return [1, 2, 3].map((i) => Math.max(0, Math.min(255, Math.round(Number(m[i])))));\n return null;\n }\n var toHex = (rgb) => \"#\" + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, \"0\")).join(\"\");\n function luminance(rgb) {\n const [r, g, b] = rgb.map((v) => {\n const c = v / 255;\n return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n });\n return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n }\n function contrast(a, b) {\n const la = luminance(a), lb = luminance(b);\n const [hi, lo] = la >= lb ? [la, lb] : [lb, la];\n return (hi + 0.05) / (lo + 0.05);\n }\n function mix(a, b, p) {\n const t = Math.max(0, Math.min(100, p)) / 100;\n return [0, 1, 2].map((i) => a[i] * t + b[i] * (1 - t));\n }\n var composite = (over, under, a) => mix(over, under, a * 100);\n var BODY_FLOOR = 4.5;\n var LARGE_FLOOR = 3;\n function inkFor(ground) {\n const dark = [17, 17, 17], light = [255, 255, 255];\n return contrast(ground, light) >= contrast(ground, dark) ? light : dark;\n }\n var isDarkGround = (ground) => inkFor(ground)[0] > 128;\n function scrimFloor(ground, ink, floor = BODY_FLOOR) {\n const extremes = [[0, 0, 0], [255, 255, 255]];\n for (let d = 0; d <= 100; d++) {\n const a = d / 100;\n if (extremes.every((photo) => contrast(composite(ground, photo, a), ink) >= floor)) return d;\n }\n return 100;\n }\n var DEFAULT_ACCENT = \"#0a8c8e\";\n var groundFrom = (accent) => palette(\"dark\", accent).ground;\n var inkFrom = (accent) => palette(\"dark\", accent).ink;\n var backdropDimDefault = (accent) => scrimFloor(groundFrom(accent), inkFrom(accent));\n function resolveBackdropDim(value, accent) {\n const fallback = backdropDimDefault(accent);\n if (value == null || String(value).trim() === \"\") return fallback;\n const n = Number(value);\n if (!Number.isFinite(n)) return fallback;\n return Math.max(0, Math.min(100, n));\n }\n function readableAccent(accent, grounds, floor = BODY_FLOOR) {\n const bgs = Array.isArray(grounds[0]) ? grounds : [grounds];\n const ok = (c) => bgs.every((b) => contrast(c, b) >= floor);\n if (ok(accent)) return accent;\n const target = isDarkGround(bgs[0]) ? [255, 255, 255] : [0, 0, 0];\n for (let p = 0; p <= 100; p += 2) {\n const c = mix(target, accent, p);\n if (ok(c)) return c;\n }\n return target;\n }\n var RAMP = {\n // [accent %, neutral base]\n dark: {\n ground: [8, \"#0c0c0c\"],\n surface: [10, \"#161616\"],\n \"surface-2\": [15, \"#1a1a1a\"],\n \"surface-hi\": [22, \"#1e1e1e\"],\n line: [14, \"transparent\"],\n \"line-hi\": [50, \"transparent\"],\n ink: [12, \"#ffffff\"],\n muted: [46, \"#898989\"],\n faint: [30, \"#6a6a6a\"],\n \"mist-a\": [11, \"#0c0c0c\"],\n \"mist-b\": [0, \"#0b0b0b\"],\n \"page-ink\": [28, \"#cecece\"]\n },\n light: {\n ground: [6, \"#f2f2f2\"],\n surface: [4, \"#ffffff\"],\n \"surface-2\": [7, \"#fafafa\"],\n \"surface-hi\": [12, \"#f4f4f4\"],\n line: [26, \"transparent\"],\n \"line-hi\": [60, \"transparent\"],\n ink: [10, \"#181818\"],\n muted: [34, \"#5e5e5e\"],\n faint: [26, \"#878787\"],\n \"mist-a\": [8, \"#f6f6f6\"],\n \"mist-b\": [0, \"#f8f8f8\"],\n \"page-ink\": [22, \"#3d3d3d\"]\n }\n };\n function palette(mode, accent) {\n const acc = parseColour(accent) || parseColour(DEFAULT_ACCENT);\n const out = { accent: acc };\n for (const [name, [p, base]] of Object.entries(RAMP[mode])) {\n if (base === \"transparent\") continue;\n out[name] = p === 0 ? parseColour(base) : mix(acc, parseColour(base), p);\n }\n const backs = [out.ground, out.surface, out[\"surface-2\"]];\n out[\"accent-bright\"] = readableAccent(acc, backs);\n out.muted = readableAccent(out.muted, backs);\n out[\"page-ink\"] = readableAccent(out[\"page-ink\"], [out.ground]);\n out.faint = readableAccent(out.faint, [out.surface, out[\"surface-2\"]], LARGE_FLOOR);\n return out;\n }\n var DERIVED = [\"accent-bright\", \"muted\", \"page-ink\", \"faint\"];\n function tokens(mode, accent) {\n const p = palette(mode, accent);\n const out = Object.entries(RAMP[mode]).filter(([name]) => !DERIVED.includes(name)).map(([name, [pc, base]]) => `--cp-${name}:` + (pc === 0 ? base : `color-mix(in srgb,var(--cp-accent) ${pc}%,${base})`));\n for (const name of DERIVED) out.push(`--cp-${name}:${toHex(p[name])}`);\n return out.join(\";\");\n }\n function themeCss(mode, accent) {\n const page = (m) => `html{background-color:var(--cp-ground);background-image:none}`;\n if (mode) return `:root{color-scheme:${mode};${tokens(mode, accent)}}${page(mode)}`;\n return `:root{color-scheme:light dark;${tokens(\"dark\", accent)}}${page(\"dark\")}@media(prefers-color-scheme:light){:root{${tokens(\"light\", accent)}}}`;\n }\n\n // packages/cardtile/yt.mjs\n var posterPath = (videoId) => `/_yt/${videoId}.jpg`;\n\n // packages/cardtile/marks.mjs\n var ICON_DOMAIN_RE = /^(?=.{4,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$/;\n var iconPath = (domain) => `/_icon/${domain}.ico`;\n\n // packages/dynamic-corals/shared/close-button.mjs\n var CLOSE_BUTTON_CSS = \"width:44px;height:44px;border-radius:50%;border:1px solid color-mix(in srgb,var(--cp-accent) 35%,transparent);background:color-mix(in srgb,var(--cp-ground) 66%,transparent);color:var(--cp-accent);font-size:1.2rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)\";\n var closeButtonStates = (sel) => `${sel}:hover{background:color-mix(in srgb,var(--cp-accent) 20%,transparent)}${sel}:focus-visible{outline:2px solid var(--cp-accent);outline-offset:2px}`;\n\n // packages/dynamic-corals/drawer/drawer-style.mjs\n var DRAWER_CSS = [\n // the layer is inert until a panel is targeted, so it never swallows clicks on the card\n \".dc-drawer-layer{position:fixed;inset:0;z-index:600;pointer-events:none}\",\n \".dc-drawer-layer:has(.dc-drawer:target){pointer-events:auto}\",\n \"body:has(.dc-drawer:target){overflow:hidden}\",\n // the scrim\n \".dc-drawer-scrim{position:absolute;inset:0;display:block;opacity:0;visibility:hidden;cursor:pointer;\",\n \"background:color-mix(in srgb,var(--cp-ground,#0a1628) 66%,transparent);\",\n \"-webkit-backdrop-filter:blur(16px) saturate(140%);backdrop-filter:blur(16px) saturate(140%);\",\n \"transition:opacity .3s,visibility 0s .3s}\",\n \".dc-drawer-layer:has(.dc-drawer:target) .dc-drawer-scrim{opacity:1;visibility:visible;transition:opacity .3s,visibility 0s 0s}\",\n // the panel: a bottom sheet on a phone, a centred dialog once there is room\n \".dc-drawer{position:absolute;inset:auto 0 0;max-height:88vh;max-height:88svh;overflow-y:auto;\",\n \"overscroll-behavior:contain;background:var(--cp-ground,#0a1628);\",\n \"border-top:1px solid var(--cp-accent-border,rgba(184,144,232,.25));\",\n \"border-radius:var(--r-xl,20px) var(--r-xl,20px) 0 0;\",\n \"padding:var(--s-6,1.5rem) var(--s-4,1rem) var(--s-8,2rem);\",\n \"transform:translateY(100%);visibility:hidden;\",\n \"transition:transform .4s cubic-bezier(.34,1.2,.64,1),visibility 0s .4s}\",\n \".dc-drawer:target{transform:translateY(0);visibility:visible;transition:transform .4s cubic-bezier(.34,1.2,.64,1),visibility 0s 0s}\",\n \"@media(min-width:720px){.dc-drawer{inset:50% auto auto 50%;width:min(680px,92vw);\",\n \"transform:translate(-50%,-45%) scale(.98);opacity:0;border-radius:var(--r-xl,20px);\",\n \"border:1px solid var(--cp-accent-border,rgba(184,144,232,.25));\",\n \"transition:transform .35s cubic-bezier(.34,1.2,.64,1),opacity .25s,visibility 0s .35s}\",\n \".dc-drawer:target{transform:translate(-50%,-50%) scale(1);opacity:1}}\",\n // the close control โ the QR popup's identity, this host's placement. It is a LINK, because\n // what it does is navigate back out of the fragment, and a link does that without script.\n //\n // ๐ด TOP-LEFT, matching the QR popup. chodaict: ใๆ่ฆบๅพ่ฉฒๅ จ้จๆพๅทฆไธ่ง,่ก็บๅฐฑๆฏ่ทqrไธๆจฃ้้ๅฏ้ใ\n // modalๅคไนๅฏ้ใ. It used to float RIGHT, so a reader who learned where โ lives from one overlay\n // had to hunt for it in the other โ and every overlay on a Card is the same promise, so it should\n // be the same gesture. The dismiss half already matched: the scrim is a clickable link, and the\n // QR overlay closes on a click that lands on itself.\n // ๐ด It sits in the LAYER now, not in the panel, so it can be genuinely fixed to the viewport\n // corner exactly like the QR's. Inside `.dc-drawer` it could not: that element carries a\n // `transform`, which creates a containing block for fixed descendants, so `position:fixed` there\n // still resolved against the panel and still scrolled away with the content.\n //\n // Same coordinates as the QR popup's โ `top:1rem;left:1rem` โ because they are the same promise.\n // Hidden and inert until something is actually open, or it would sit over the card.\n \".dc-drawer-close{position:fixed;top:1rem;left:1rem;z-index:602;text-decoration:none;\",\n \"opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;\" + CLOSE_BUTTON_CSS + \"}\",\n \".dc-drawer-layer:has(.dc-drawer:target) .dc-drawer-close{opacity:1;visibility:visible;transition:opacity .3s,visibility 0s 0s}\",\n closeButtonStates(\".dc-drawer-close\"),\n // the title no longer has to clear a button that is no longer beside it\n \".dc-drawer-title{margin:0 0 var(--s-4,1rem);font-size:1.05rem;font-weight:700;\",\n \"color:var(--cp-accent-bright,#d9c9f3);line-height:1.35}\",\n \"@media(prefers-reduced-motion:reduce){.dc-drawer,.dc-drawer-scrim{transition:none}}\"\n ].join(\"\");\n\n // packages/cardtile/card-render.mjs\n var esc = (s) => escHtml(s == null ? \"\" : s).replace(/\"/g, \""\");\n var mdInline = (s) => esc(s).replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^)\\s]+)\\)/g, '$1').replace(/\\*\\*(.+?)\\*\\*/g, \"$1\").replace(/\\*(.+?)\\*/g, \"$1\").replace(/\\n/g, \" \");\n var LABELS = {\n en: { prev: \"Previous\", next: \"Next\", slide: (n) => `Slide ${n}`, close: \"Close\", qr: \"Show QR code\", linkUnset: \"No address yet\", imgUnset: \"+ Add a picture\" },\n zh: { prev: \"\\u4E0A\\u4E00\\u5F35\", next: \"\\u4E0B\\u4E00\\u5F35\", slide: (n) => `\\u7B2C ${n} \\u5F35`, close: \"\\u95DC\\u9589\", qr: \"\\u986F\\u793A QR code\", linkUnset: \"\\u9084\\u6C92\\u586B\\u7DB2\\u5740\", imgUnset: \"\\uFF0B \\u52A0\\u4E00\\u5F35\\u5716\\u7247\" },\n ja: { prev: \"\\u524D\\u3078\", next: \"\\u6B21\\u3078\", slide: (n) => `${n} \\u679A\\u76EE`, close: \"\\u9589\\u3058\\u308B\", qr: \"QR \\u30B3\\u30FC\\u30C9\\u3092\\u8868\\u793A\", linkUnset: \"URL \\u304C\\u307E\\u3060\\u3067\\u3059\", imgUnset: \"\\uFF0B \\u753B\\u50CF\\u3092\\u8FFD\\u52A0\" },\n ko: { prev: \"\\uC774\\uC804\", next: \"\\uB2E4\\uC74C\", slide: (n) => `${n}\\uBC88\\uC9F8`, close: \"\\uB2EB\\uAE30\", qr: \"QR \\uCF54\\uB4DC \\uBCF4\\uAE30\", linkUnset: \"\\uC8FC\\uC18C\\uAC00 \\uC544\\uC9C1 \\uC5C6\\uC5B4\\uC694\", imgUnset: \"\\uFF0B \\uC0AC\\uC9C4 \\uCD94\\uAC00\" }\n };\n function label(ctx, key, ...args) {\n const override = ctx && ctx.editStrings && ctx.editStrings[key];\n if (typeof override === \"string\" && override) return override;\n const primary = String(ctx && ctx.lang || \"\").trim().toLowerCase().split(/[-_]/)[0];\n const table = LABELS[primary] || LABELS.en;\n const v = table[key] !== void 0 ? table[key] : LABELS.en[key];\n return typeof v === \"function\" ? v(...args) : v;\n }\n var mdBlock = (s) => String(s || \"\").split(/\\n{2,}/).map((para) => {\n const lines2 = para.split(\"\\n\").filter((l) => l.trim());\n if (!lines2.length) return \"\";\n if (lines2.every((l) => /^\\s*[-*]\\s+/.test(l))) {\n return \"
\" + lines2.map((l) => `
${mdInline(l.replace(/^\\s*[-*]\\s+/, \"\"))}
`).join(\"\") + \"
\";\n }\n if (lines2.length === 1 && /^\\s*([-*_])\\1{2,}\\s*$/.test(lines2[0])) return \"\";\n const h = /^(#{1,3})\\s+(.*)$/.exec(lines2[0]);\n if (h && lines2.length === 1) return `${mdInline(h[2])}`;\n return `
${lines2.map(mdInline).join(\" \")}
`;\n }).join(\"\");\n var BARE_URL_RE = /^(?:https?:\\/\\/|mailto:|tel:)\\S+$/i;\n var hostLabel = (url) => {\n try {\n return new URL(url).hostname.replace(/^www\\./, \"\") || url;\n } catch {\n return url;\n }\n };\n var linkOf = (body) => {\n const m = /^\\[([^\\]]*)\\]\\(([^)\\s]+)\\)/.exec(body || \"\");\n if (m) return { label: m[1], url: m[2] };\n const bare = (body || \"\").trim();\n if (BARE_URL_RE.test(bare)) return { label: hostLabel(bare), url: bare };\n return { label: bare, url: \"\" };\n };\n var cssUrl = (u) => String(u == null ? \"\" : u).replace(/[\\\\'\"()\\s]/g, encodeURIComponent).replace(/<\\/?/g, \"\");\n var ASSET_RE = /^asset:(.+)$/;\n function resolveAsset(url, ctx) {\n const m = ASSET_RE.exec(String(url || \"\").trim());\n if (!m) return url;\n const a = ctx.assets && ctx.assets[m[1].trim()];\n return a ? `data:${a.mime};base64,${a.b64}` : \"\";\n }\n var SOCIAL_FALLBACK = '';\n var IMG_ICON = (src, fallbacks = [], extraClass = \"\") => {\n const rest = fallbacks.map((d) => iconPath(d));\n const attr = rest.length ? ` data-iconfallback=\"${esc(rest.join(\" \"))}\"` : \"\";\n const onerr = 'var f=(this.dataset.iconfallback||\"\").split(\" \").filter(Boolean);if(f.length){this.dataset.iconfallback=f.slice(1).join(\" \");this.src=f[0]}else{this.onerror=null;this.style.display=\"none\"}';\n return ``;\n };\n var hostOf = (u) => {\n try {\n return new URL(u).hostname.replace(/^www\\./, \"\");\n } catch {\n return \"\";\n }\n };\n var ICON_ALIAS = { \"twitter.com\": \"x.com\", \"fb.com\": \"facebook.com\", \"goo.gl\": \"google.com\", \"youtu.be\": \"youtube.com\" };\n function iconDomains(host) {\n if (!host) return [];\n const out = [ICON_ALIAS[host] || host];\n const parts = host.split(\".\");\n if (parts.length > 2) out.push(parts.slice(-2).join(\".\"));\n return [...new Set(out)];\n }\n function linkIcon(p, url, ctx) {\n if (p.iconimg) return IMG_ICON(resolveAsset(p.iconimg, ctx), [], \"st-cell-icon-art\");\n if (p.icon && ctx.icons && ctx.icons[p.icon]) return ctx.icons[p.icon];\n const chain = p.iconhost ? [p.iconhost] : iconDomains(hostOf(url));\n if (chain.length) return IMG_ICON(iconPath(chain[0]), chain.slice(1));\n return ctx.icons && ctx.icons._default || \"\";\n }\n var arrowOf = (ctx, px, cls = \"st-cell-arrow\", dir = \"--up-right\") => (ctx.arrows === \"on\" ? ctx.arrow || \"\" : \"\").replace(/__SIZE__/g, String(px)).replace(\"__CLASS__\", cls).replace(\"__DIR__\", dir ? \" signet-arrow\" + dir : \"\");\n function renderEmbed(p, body, size, ctx = {}) {\n if (p.kind === \"slider\") {\n const imgs = (p.images ? String(p.images).split(/\\s*,\\s*/) : []).filter(Boolean).map((u) => resolveAsset(u, ctx));\n if (!imgs.length) return ``;\n const uid = \"sl\" + Math.abs([...String(p.images || \"\")].reduce((h, c) => h * 31 + c.charCodeAt(0) | 0, 7)).toString(36);\n const n = imgs.length;\n const slides = imgs.map((src, i) => {\n const prev = `#${uid}-${(i - 1 + n) % n}`;\n const next = `#${uid}-${(i + 1) % n}`;\n return `\\u2039\\u203A`;\n }).join(\"\");\n const dots = imgs.map((_, i) => ``).join(\"\");\n const css = [\n // ๐ด NO fixed aspect. chodaict read Incrediville's live page as `6x6(slide*5)` AND\n // `3x6(slide*4)` โ the same widget, two different heights, because the PICTURES differ. Pinning\n // 2/1 here would be ใwidth decides shapeใ all over again, four hours after I removed exactly\n // that rule from features. The first slide's natural height sets the track; the rest are\n // covered into it, so a set of squares is square and a set of banners is a banner.\n `.st-slider{position:relative;width:100%;overflow:hidden;border-radius:inherit}`,\n `.st-slider-track{display:flex;width:100%;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:none}`,\n `.st-slider-track::-webkit-scrollbar{display:none}`,\n `.st-slider figure{position:relative;flex:0 0 100%;margin:0;scroll-snap-align:center}`,\n `.st-slider figure:first-child img{height:auto}`,\n `.st-slider figure img{width:100%;height:100%;object-fit:cover;display:block}`,\n `.st-slide-nav{position:absolute;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:22px;line-height:1;text-decoration:none;color:#fff;background:rgba(0,0,0,.42);backdrop-filter:blur(2px);opacity:.85;transition:opacity .2s,background .2s}`,\n `.st-slide-nav:hover{opacity:1;background:rgba(0,0,0,.6)}`,\n `.st-slide-prev{left:10px}.st-slide-next{right:10px}`,\n `.st-slider-dots{position:absolute;left:0;right:0;bottom:9px;display:flex;gap:6px;justify-content:center}`,\n `.st-slide-dot{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.55);box-shadow:0 0 0 1px rgba(0,0,0,.18)}`,\n // one image needs no controls, and controls on a single slide read as broken\n n < 2 ? `.st-slide-nav,.st-slider-dots{display:none}` : \"\"\n ].join(\"\");\n return `
`;\n }\n }\n var NATIVE_CSS = [\n `.st-cell-icon-img{width:22px;height:22px;object-fit:contain;border-radius:5px;background:rgba(255,255,255,.06)}`,\n // an author's own artwork on a row โ 64px, `cover`, because it is a picture and not a mark.\n `.st-cell-icon-art{width:64px;height:64px;object-fit:cover;border-radius:10px;background:none}`,\n // ๐ฉธ โฆand that single-class rule LOSES. `.st-card [data-w=\"6\"].st-cell-tile .st-cell-icon-img` is\n // three selectors deep and was written to pin a FAVICON at 22px on exactly this cell โ a\n // full-width link row. Measured, not read: the source said 64px and getComputedStyle said 22px.\n // An outranked rule and a live one look identical in the file, which is why this needs a probe on\n // the computed value and not a second reading of the stylesheet.\n `.st-card .st-cell-icon-art{width:64px;height:64px}`,\n `.st-embed{overflow:hidden;padding:0}`,\n // a bento BLOCK: one section of the card, its own grid. Multiple blocks per card.\n `.st-grid{margin:0 0 var(--s-6,1.5rem)}`,\n // ๐ด accent-BRIGHT, not the raw accent. A lane label floats on the page ground with nothing behind\n // it, and the raw accent is a brand colour chosen to sit on a creator's artwork โ measured at\n // 2.17:1 on a light ground. accent-bright is the same hue moved just far enough to clear 4.5:1,\n // in whichever direction that ground requires.\n `.st-grid-label{font-weight:700;font-size:.9rem;letter-spacing:.03em;text-transform:uppercase;color:var(--cp-accent-bright,#0a8c8e);opacity:.9;margin:0 0 var(--s-3,.75rem);padding:0 var(--s-1,.25rem)}`,\n // full-bleed: escapes the card's frame + padding, spans the whole card width\n `.st-bleed{margin:0 0 var(--s-6,1.5rem);width:100%}`,\n `.st-bleed .st-cell{background:none;border:none;box-shadow:none;padding:0;border-radius:0}`,\n // prose cells (long-form text carried in from a migration, or written by hand)\n `.st-prose{display:block;font-size:.86rem;line-height:1.7;color:var(--carrier-text,#e0f0ff)}`,\n `.st-prose p{margin:0 0 .7em}.st-prose ul{margin:0 0 .7em;padding-left:1.1em}.st-prose li{margin:0 0 .3em}`,\n `.st-prose h3,.st-prose h4,.st-prose h5{margin:.2em 0 .5em;font-size:.95rem;color:var(--cp-accent-bright,#239899)}`,\n `.st-prose hr{border:0;height:1px;background:var(--cp-accent-border,rgba(10,140,142,.28));margin:1.2em 0}`,\n // ๐ด NOT the raw accent. A creator's brand colour is chosen to sit on their artwork, not on a\n // dark panel. Measured on a live card: #0556ff scored 3.45:1 against the drawer's ground,\n // under the 4.5:1 body-text floor. --cp-accent-bright โ the 72%-white mix the family already\n // derives for exactly this โ scores 5.57:1. The underline is not decoration either: colour alone\n // must never be the only signal that something is a link.\n `.st-prose a{color:var(--cp-accent-bright,#239899);text-decoration:underline;text-underline-offset:2px}`,\n // โโ text ON a picture is not themed โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // ๐ด A caption over a creator's artwork was wearing --cp-ink and --cp-accent-bright, which are\n // derived against the card's GROUND. The picture is not the ground. Measured on a live\n // cover: the title scored 1.76:1 in dark (already broken, today) and the CTA 1.51:1 in light.\n //\n // A picture can be any colour, so the answer is the one every platform reaches for and the same\n // one the backdrop uses: put a scrim under the caption and paint on THAT. Fixed light ink, not a\n // token โ the caption's context is the scrim, and the scrim is always dark whichever theme the\n // reader is in. Specificity matches card.css's `.st-card .st-gal-*` and wins on order, because\n // NATIVE_CSS is printed in the body.\n // ๐ด ONE gradient, not two. `.st-gal-scrim` already paints one over the whole tile; this element\n // painted a SECOND on top of it, so every title sat under two stacked scrims and the picture went\n // muddy. chodaict: ใๆผธๅฑคไธๆๅคช้,ๆ็ๅฐไฝ ็ไบๅฅฝๅนพๅฑคๆผธๅฑคใ. The scrim owns the darkening; the body\n // owns the text.\n // ๐ด THE SCRIM LIVES HERE, not only in card.css. Removing the caption's own gradient exposed that\n // the only bottom-heavy scrim was in the committed stylesheet โ so a render without it (the colour\n // suite's `renderWith`, and anything that ever forgets `css:`) had text on a photograph with no\n // backing at all. A legibility guarantee must not depend on an artifact this file does not own.\n `.st-gal-scrim{position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.92) 6%,rgba(0,0,0,.5) 34%,rgba(0,0,0,0) 64%);pointer-events:none}`,\n // ONE gradient, not two: the scrim above darkens, the caption only holds text. Stacked, the two\n // took the bottom of every tile to ~0.99 opaque โ ใๆผธๅฑคไธๆๅคช้ใ.\n `.st-gal-body{background:none;padding-top:var(--s-6,1.5rem)}`,\n `.st-card .st-gal-eyebrow{color:#f2f2f2;text-shadow:0 1px 6px rgba(0,0,0,.7)}`,\n // โฆand the same three lines again for a feature that has no picture to sit on. Ground-derived\n // ink, no shadow โ a shadow exists to separate text from artwork, and there is no artwork here.\n // palette() has already pushed --cp-muted and --cp-ink past the body floor for every accent in\n // the space, so this is legible by construction rather than by a hardcoded pale grey.\n `.st-card .st-gal-cell-flat .st-gal-eyebrow{color:var(--cp-muted);text-shadow:none}`,\n `.st-card .st-gal-cell-flat .st-gal-title{color:var(--cp-ink);text-shadow:none}`,\n `.st-card .st-gal-cell-flat .st-gal-body{background:none}`,\n // ๐ด 14px. card.css sets 26px on `.st-card .st-gal-title` โ same specificity as this rule, so this\n // one wins only because it comes later. Measured, not assumed: see the .st-cell-icon-art incident\n // earlier today, where an identical-specificity override placed too early lost silently.\n `.st-card .st-gal-title{font-size:14px;color:#fff;text-shadow:0 2px 20px rgba(0,0,0,.75)}`,\n `.st-card .st-gal-title{font-size:14px}`,\n // ๐ด The card itself: no border, and PURE WHITE in light mode.\n //\n // `--cp-ground` is `color-mix(accent 6%, #f2f2f2)` โ a lavender off-white carrying 6% of the\n // creator's colour. On Incrediville that computes to srgb(0.936 0.916 0.937), which is what\n // chodaict was looking at when he asked for ใ็ด็ฝใ. The token stays as it is (the page backdrop\n // and the QR overlay both want the tint); only the CARD goes white.\n //\n // ๐ด Scoped to light. `data-theme=\"dark\"` and a reader-preferred dark card must NOT turn white, so\n // this rides the same two selectors the palette does rather than a blanket rule.\n `.st-card{border:none}`,\n `:root[data-theme=\"light\"] .st-card{background:#fff}`,\n `@media(prefers-color-scheme:light){:root:not([data-theme=\"dark\"]) .st-card{background:#fff}}`,\n // ๐ด The avatar wears NO frame. Measured before changing anything (2026-07-30): the decoration is\n // not spread over many elements โ it is `img.cp-icon` carrying exactly two properties, a 2px\n // accent border and a 4px accent ring, plus `.st-figure`'s own. Two places, not a stack, which is\n // why this is two lines and not a new `avatar:` switch: ใไธไบบ่ฆโ่ฎ้้,ไธไบบ่ฆโ้ฃๆฏ Siteใ, and\n // there is no stacking bug here for a switch to paper over.\n //\n // (My first probe reported NINE framed elements. It read `outline-width` without reading\n // `outline-style` โ every element inherits a 3px width for a style of `none`, so nothing was drawn.\n // The ruler, again.)\n `.st-card .st-figure{border:none;box-shadow:none}`,\n `.st-card .st-hero .cp-icon{border:none;box-shadow:none}`,\n // ๐ด The social row is a CELL, so if it shows a box the box is rounded like every other one.\n // Its own rule says `background:none;border:none` โ but that is a single class (0,1,0) and\n // card.css's `.st-card .st-cell{background:โฆ;border:โฆ}` is (0,2,0), so on some cards it loses and\n // the row becomes a visible box with square corners. Found on PeachyBoys: 648ร96, and nothing\n // above it clips. ใ่ฆๅ่งๅฐฑๆฏๅ จๅ่งใ็ด่งๅฐฑๅพๅ จ็ด่งไธ็ถๆ้ใ.\n `.st-card .st-cell.st-social{border-radius:18px}`,\n // ๐ด ZERO, not 16. The card's own 16px padding already holds every side; a margin here ADDS to it,\n // so the bottom read 32 against 16 on the other three. My first attempt set 16 ใto match the\n // sidesใ and measured 32 โ matching a number is not matching a GAP when the two stack.\n //\n // Measured on three cards after the change: ไธ16 ๅณ16 ไธ16 ๅทฆ16.\n `.st-card .st-grid{margin:0}`,\n // ๐ด every cell rounded, including the ones that clip their own picture. `.st-gal-img` inherits\n // the cell's radius only if the cell actually clips โ hence overflow:hidden alongside it.\n `.st-card .st-cell,.st-card .st-embed,.st-card .st-embed-video{border-radius:18px;overflow:hidden}`,\n `.st-card .st-gal-img,.st-card .st-slider{border-radius:inherit}`,\n `.st-card .st-gal-cta{color:#fff;background:color-mix(in srgb,var(--cp-accent,#0a8c8e) 26%,rgba(0,0,0,.62));border-color:rgba(255,255,255,.38)}`,\n // the video's caption sits on a poster frame โ same reasoning, same treatment\n // ๐ด The label sits on a POSTER โ a photograph we did not choose โ so its scrim runs to the top of\n // its own box and each glyph carries a black halo as well. The gradient alone measured 3.67:1\n // against a 4.5 floor once the harness started scoring the pixels ADJACENT to the glyphs instead\n // of the one underneath their centre: the scrim's own fade meant the upper line of a two-line\n // label sat on the picture. Not a regression โ a thing the previous measure structurally could\n // not see, because it sampled the background with the text lifted off.\n `.st-embed-video-label{background:linear-gradient(0deg,rgba(0,0,0,.92),rgba(0,0,0,.82) 55%,rgba(0,0,0,.6) 85%,rgba(0,0,0,.35));text-shadow:0 0 4px rgba(0,0,0,.95),0 1px 6px rgba(0,0,0,.9),0 0 14px rgba(0,0,0,.8)}`,\n // card.css carries `.st-footer a{color:var(--cp-accent)!important}` โ the raw brand colour, which\n // measured 2.17:1 on a light ground. Same specificity, later in the document, and the !important\n // has to come along because the rule being corrected has one.\n `.st-footer a{color:var(--cp-accent-bright,#0a8c8e)!important}`,\n // a bare image tile: the artwork IS the tile. No scrim, no CTA, no card chrome behind it.\n // ๐ด `.st-card .st-cell.st-gal-cell-bare`, not `.st-gal-cell-bare`. card.css declares\n // `.st-card .st-cell{border:1px solid var(--cp-line)}` at (0,2,0) and this was written at (0,1,0),\n // so the `border:none` here NEVER APPLIED โ every bare picture carried a 1px line and sat inset by\n // exactly 1px inside its own cell, which is the frame table's \"picture\" row quietly not holding.\n // Same shape of bug as `.st-embed-video-noposter`: a rule that reads like the fix, outranked by the\n // thing it was written to correct, therefore never once in effect. verify/frame-table.mjs found it.\n `.st-card .st-cell.st-gal-cell-bare{background:none;border:none;box-shadow:none;padding:0;overflow:hidden}`,\n `.st-gal-cell-bare .st-gal-link{display:block;width:100%;height:100%;position:relative}`,\n `.st-gal-cell-bare .st-gal-img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .3s ease}`,\n `.st-gal-cell-bare:hover .st-gal-img{transform:scale(1.02)}`,\n // click-to-load video\n `.st-embed-video{padding:0;overflow:hidden}`,\n `.st-embed-video-btn{display:block;position:relative;width:100%;height:100%;padding:0;border:0;background:#000;cursor:pointer;overflow:hidden}`,\n // โโ the cell layer: width is authored, height is discovered โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // The old model: the author writes w AND h, packGrid computes an (x, y), the grid hands each cell\n // a fixed box, and `overflow:hidden` cuts off anything that did not fit. `h` was a knob every card\n // had to guess at.\n //\n // ๐ด One model, no opt-in. This shipped for one commit scoped to cells that authored no `h`, so\n // three existing cards could keep their old path byte for byte. Nothing has been published, so\n // carrying two layout models to protect an unpublished look was cost with no payer โ the CARDS\n // moved instead (`h` removed from all three) and the compatibility path came out. Which also\n // removes the question every future rule would otherwise have to answer: which path does this\n // belong to?\n //\n // The new model: CSS packs (`row dense`), a\n // row is `minmax(one unit, auto)` โ at least a square, taller if what is in it needs more โ and a\n // picture carries its own `aspect-ratio` instead of being stretched into a box the grid supplied.\n // A square picture at w=N then lands on exactly N units by arithmetic, so the old rhythm survives\n // without `h` existing.\n //\n // ๐ด Two things this cost, both found by measuring rather than by reasoning:\n //\n // 1. `!important` on the track sizing. card.css declares `grid-auto-rows:calc(โฆ)!important`, so\n // nothing without it can replace the tracks. Without it the tracks stayed a flat 43.5px while\n // cells carried a 158px minimum โ 15 overlapping cells and a card collapsed to under one\n // screen. The computed style said 43.5px where the source said minmax.\n // 2. It is NOT a free change for cards that stated a height. Applied to all cells it made\n // one live cardโs hero 9px shorter and its cover 8px taller โ because a 6ร3 span is 331ร158.5,\n // which is 2.09:1 and not the 2:1 an `aspect-ratio` would give. Close enough to look like\n // nothing and far enough to be a change nobody asked for on a live customer's card.\n `.st-card .st-cells{--cp-unit:calc((100cqw - 5 * 14px) / 6);grid-auto-flow:row dense!important;grid-auto-rows:minmax(var(--cp-unit),auto)!important}`,\n ...[1, 2, 3, 4, 5, 6].map((w) => `.st-card [data-w=\"${w}\"]{grid-column:span ${w}}`),\n `.st-card .st-cell{grid-row:auto;align-self:start;overflow:visible;min-height:var(--cp-unit)}`,\n `.st-card .st-gal-cell,.st-card .st-embed,.st-card .st-embed-video{overflow:hidden}`,\n `.st-card .st-gal-link{height:auto;display:block;position:relative}`,\n `.st-card .st-gal-img{position:static;inset:auto;width:100%;height:auto;aspect-ratio:1;object-fit:cover;display:block}`,\n // ๐ฉธ WAS: `[data-w=\"6\"] .st-gal-img{aspect-ratio:2/1}` โ ใa full-width picture is a banner, not a\n // 390ร390 header photoใ. True for the card that rule was written on, and WRONG as a general law:\n // it made WIDTH decide SHAPE, so every full-width picture became a banner whether or not it was\n // one, and `object-fit:cover` then threw away the other half.\n //\n // Measured 2026-07-30 on HTL. chodaict read his own live page off the screen as ๆน(6x6)ใๆน(6x6)ใ\n // ็ดฐ(1x6)ใ้ทๆน(3x6)ใ้ทๆน(3x6)ใ็ดฐ(1x6) and said we had it wrong. The source images say the same\n // thing: 1200ร1200, 1280ร1280, then 562ร281 and 1000ร500. Two of his pictures are SQUARE and were\n // being cropped in half; the two that looked right were 2:1 already โ correct by coincidence.\n //\n // The rule that actually holds: a tile SHARING a row needs a common height so its neighbours line\n // up; a tile that OWNS its row has nothing to line up with, so the picture carries its own ratio.\n // That is ใ้ซๅบฆ็ฑๅ งๅฎนๆฑบๅฎใ applied where it was not applied before, not a new idea.\n `.st-card [data-w=\"6\"] .st-gal-img{aspect-ratio:auto}`,\n // ๐ด A video and a slider have no in-flow content to be measured โ the play mark is\n // `position:absolute;inset:0`, and the slider's frames are stacked. Under the old model the grid\n // handed them a height from `h`; with `h` gone they collapsed to one unit and rendered as an\n // empty rounded box. A video is 16:9 because a video is 16:9, so it says so itself.\n // ๐ด The ratio belongs to the CELL, not to the button. The button is `replaceWith`n by the iframe\n // on click, so an `aspect-ratio` living on it leaves with it โ and the cell, whose `min-height:0`\n // removes the one-unit floor, collapsed to the iframe's own tiny intrinsic height. The video\n // played, at about 1ร6. Whatever holds the shape has to be the thing that survives the swap.\n `.st-card .st-embed-video{aspect-ratio:16/9;min-height:0}`,\n `.st-card .st-embed-video .st-embed-video-btn{width:100%;height:100%}`,\n `.st-card .st-embed-video iframe{display:block;width:100%;height:100%;border:0}`,\n // ๐ด โฆbut the slider is NOT in that situation, and pinning it to 2/1 squashed it flat.\n // `kind=slider` is the only embed there is, and it builds `.st-carousel`, which declares\n // `aspect-ratio:1/1` on ITSELF โ the frames stack inside a box that already knows it is square.\n // The cell asking for 2:1 on top of that crops a square into a letterbox. Its content does know\n // its own height, so the cell takes it: `min-height:0` undoes the one-unit floor `.st-cell` sets,\n // and nothing dictates a ratio from outside.\n `.st-card .st-embed:not(.st-embed-video){min-height:0}`,\n // โโ a picture that carries its name โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // ๐ด The cell's budget does not change. The picture shrinks, stays SQUARE, and gains side inset;\n // the label takes the rest. That is what iOS does to the icon โ measured, it loses 17.1% of its\n // side when labels are on โ without iOS's other half, which re-derives the whole grid metric\n // (turning labels off there moves vertical pitch โ5.7% and horizontal +6.1%). We cannot afford\n // that: a Card has arbitrary content and mixed widths, and re-deriving per card destroys the\n // arithmetic that `w=N โ N units` rests on.\n //\n // The label sits OUTSIDE the picture's frame, plain, which is both the launcher's shape and the\n // rule locked earlier: the frame marks what is pressable, and here that is the picture.\n // ๐ด The BUDGET is on the link, and the picture takes what the label leaves. The first version\n // put `aspect-ratio:1` on the picture and let the cell size itself: labelled cells came out 150px\n // where unlabelled ones were 159, so turning the label on quietly shrank the tile and broke the\n // rhythm the whole grid rests on. `aspect-ratio` belongs to the SPACE, not to the picture.\n // ๐ด The picture's width is a fixed share of the cell โ 70%, which is what iOS gives an icon when\n // its label is showing (99.5 of a 142.5 column pitch, measured). It has to be a share and not\n // `flex:1 1 auto`: left to grow, the picture took the whole box and pushed the label out the\n // bottom, so a labelled tile came out 178px where an unlabelled one was 159. The budget is the\n // link's `aspect-ratio`; the picture takes its share of it and the label takes the rest.\n `.st-card .st-gal-labelled .st-gal-link{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;aspect-ratio:1;height:auto;padding:0}`,\n `.st-card .st-gal-labelled .st-gal-img{flex:0 0 auto;width:70%;height:auto;aspect-ratio:1;border-radius:16px}`,\n // ๐ด fixed small type, never scaled to the cell โ a w=6 banner would otherwise grow a giant\n // caption. One line, truncated in the paint; the full string stays in `alt` for assistive tech.\n `.st-gal-label{font-size:.72rem;line-height:1.25;font-weight:600;color:var(--cp-ink,#fff);max-width:92%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;flex:0 0 auto}`,\n // A full-width picture is a banner, and a banner with a caption under it is just that โ no square\n // budget to divide, so the link takes its natural height instead of an aspect-ratio.\n `.st-card [data-w=\"6\"].st-gal-labelled .st-gal-link{aspect-ratio:auto;padding:0}`,\n `.st-card [data-w=\"6\"].st-gal-labelled .st-gal-img{width:100%;aspect-ratio:2/1}`,\n // โโ a full-width link is a ROW โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // \"A row is what a link is. A tile is what a link becomes when it has a picture worth looking at.\"\n // Five independent sources, four methods: `list` on 84% of 38 Portaly pages; Linktree 93.6% rows\n // with 109/109 links full-width and zero grids; 6 Carrd sites pure rows; and the ancestor โ\n // hand-written, no platform palette, no paywall โ 27 rows to 1 tile.\n //\n // A `w=6` link stops stacking icon-above-label and lays outๆจช like a Settings row: mark, text,\n // chevron. Nothing below applies at w<6, so a tile stays a tile and no existing card moves.\n `.st-card [data-w=\"6\"].st-cell-tile{padding:0}`,\n // ๐ด `flex-direction:row` explicitly. card.css sets `column` on `.st-cell-tile .st-cell-link` โ\n // that is what stacks icon above label in a TILE โ and setting only `display:flex` here inherited\n // it, so the first version of this \"row\" was 124px tall with everything still stacked. It looked\n // like a padding bug; it was a direction bug.\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-link{display:flex;flex-direction:row;align-items:center;gap:var(--s-3,.75rem);min-height:44px;padding:var(--s-3,.75rem) var(--s-4,1rem);height:auto;justify-content:flex-start}`,\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-head{flex:0 0 auto;display:contents}`,\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-body{flex:1 1 auto;margin-top:0;min-width:0}`,\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-icon,.st-card [data-w=\"6\"].st-cell-tile .st-cell-icon-img{order:0;width:22px;height:22px}`,\n // ๐ฉธ IMMEDIATELY AFTER the rule above, and that placement IS the fix. Both selectors are (0,3,1) โ\n // identical specificity โ so SOURCE ORDER decides, and the art rule sitting earlier in the array\n // lost silently. Two failed attempts before this one: a single-class rule (obviously outranked),\n // then a matching-specificity rule in the wrong position (looks right, still loses).\n //\n // ๐ด The answer did not come from reading the stylesheet a third time. It came from asking the\n // BROWSER which rules match โ `el.matches(r.selectorText)` over document.styleSheets โ which\n // printed both rules at 64px and 22px side by side and made the tie obvious in one shot.\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-icon-art{order:0;width:64px;height:64px}`,\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-body{order:1}`,\n // ๐ด the chevron is the affordance, not the border โ so it goes last and it stays.\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-arrow{order:2;margin-left:auto;flex:0 0 auto}`,\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-title{font-size:.95rem;white-space:normal}`,\n `.st-card [data-w=\"6\"].st-cell-tile .st-cell-sub{display:block;font-size:.75rem}`,\n // โโ and a RUN of them is one surface, not a stack of buttons โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // The frame belongs to the LANE. iOS Settings is the proof it works: plain rows in one rounded\n // card, a hairline between each, and nobody has ever been confused about whether a row is\n // tappable. The ancestor did the same by hand โ which is how 27 links fit in a 1945px page.\n //\n // ๐ด The run is a real element (`.st-run`, see renderGrid), so the surface, the border and the\n // radius belong to IT and the rows inside are plain. This replaces closing the grid's row gap with\n // `margin-top:-14px` on every adjacent row โ which restated the gap as a magic number in a second\n // place and then lost to subpixel rounding: measured on a live card, row tops were 488, 549, 611,\n // 672, so row 2 ended at 610 and row 3 began at 611 and a 1px sliver of page showed through. The\n // divider read as DOUBLED from the third row on while the first two looked perfect, which is a very\n // good disguise for \"this mechanism does not actually work\".\n //\n // ใ้ค้ๅฟ ่ฆไธ็ถ CSS ๅฅๅฏซ่ฒ ๆธใ โ chodaict, and the bug is the argument. A negative offset that has\n // to equal another value is a constraint the layout is not expressing.\n `.st-card .st-run{grid-column:1 / -1;display:flex;flex-direction:column;background:var(--cp-surface);border:1px solid var(--cp-line);border-radius:18px;overflow:hidden}`,\n // ๐ด `align-self:stretch` undoes `.st-card .st-cell{align-self:start}`, which is right for a GRID\n // item (a short cell must not stretch to its track) and means \"shrink to content width\" for a flex\n // column child. Without it every row is only as wide as its own text: the divider stops mid-card at\n // a different x per row, and the chevron's `margin-left:auto` has no space left to push into, so it\n // sits against the label instead of at the right edge.\n `.st-card .st-run > .st-cell-tile{align-self:stretch;width:100%;background:none;border:0;border-radius:0;box-shadow:none;border-top:1px solid var(--cp-line)}`,\n `.st-card .st-run > .st-cell-tile:first-child{border-top:0}`,\n // โโ THE RUN-CORNER RULE (ruler: tile-geometry.test.mjs + verify/tile-geometry.mjs) โโโโโโโโโโโโโโโ\n // A run is a grouped list, like an inset-grouped list on iOS: the run's OUTER corners take the\n // card's tile radius (18px, what `.st-card .st-cell` resolves to), the inner seams are square, and\n // the dividers stay as they are. A tile alone in its run is therefore fully rounded โ it has no\n // seam to share. `.st-run` is `flex-direction:column`, so a run's ends are its top and bottom:\n // the first row rounds its two TOP corners, the last row its two BOTTOM corners, a lone row all\n // four. (All rows used to be `border-radius:0`, so a single link was a square box with nothing to\n // join, next to its own empty state drawn rounded.)\n `.st-card .st-run > .st-cell-tile:first-child{border-top-left-radius:18px;border-top-right-radius:18px}`,\n `.st-card .st-run > .st-cell-tile:last-child{border-bottom-left-radius:18px;border-bottom-right-radius:18px}`,\n // A link's head (icon + arrow) with nothing to SHOW must not hold the title down: the link is a\n // `space-between` column, so a zero-height head still pins the body to the bottom. Nothing to show\n // = no children at all (an unset link, arrows off) OR only children the favicon's onerror hid with\n // `style.display=\"none\"` (IMG_ICON) โ the common case, since every filled link gets a derived\n // favicon. Either way the head leaves the flow and the title sits at the top. `:has` on the\n // inline style rather than a new class: live markup must not change for this.\n `.st-card .st-cell-tile .st-cell-head:not(:has(> :not([style*=\"display: none\"]))){display:none}`,\n `.st-card .st-cell-tile .st-cell-head:not(:has(> :not([style*=\"display: none\"]))) + .st-cell-body{margin-top:0}`,\n `.st-card .st-run > .st-cell-tile:hover{background:var(--cp-surface-2)}`,\n // a row lifting on hover would break the run apart; the surface change is the feedback instead\n `.st-card [data-w=\"6\"].st-cell-tile:hover{transform:none;background:var(--cp-surface-2)}`,\n // โโ ไธ่ฝๆ็ไธๅ้ทๅพๅๆ้ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // The frame marks a region of PRESSABLE things, in both directions: what cannot be pressed must\n // not look like a button, and what can must look like one. Prose is the only cell that is neither\n // a link nor a picture, and it was wearing the identical surface, border and radius as a link โ\n // a paragraph shaped like something you tap.\n //\n // This is what iOS does with a grouped table's footer text: it sits OUTSIDE the rounded card,\n // plain on the background. Removing the frame here also removes a double frame inside a drawer,\n // where the panel is already the surface.\n //\n // Kept: the padding, so the text still has a margin; and `min-height:0`, or the flex centring the\n // .st-cell rule applies would vertically centre a long passage.\n `.st-card .st-cell.st-prose{background:none;border:none;box-shadow:none;backdrop-filter:none;display:block;padding:var(--s-2,.5rem) var(--s-1,.25rem)}`,\n // โโ the avatar is a RATIO, never a pixel count โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // Measured across 10 pages and 3 platforms: every platform picks one ratio of the card's width and\n // holds it โ Linktree 96px = 24.6% on 14 of 16 pages identically, Portaly 118px = 30.3%, lit.link\n // 80px = 20.5%. There is no universal figure, only a range and a choice inside it.\n //\n // Ours was `width:78px`, and on the 390px viewport all of that evidence was gathered at, 78px IS\n // 23.6% of the card โ the choice was already right and only the UNIT was wrong. So this is the\n // same number in a unit that travels: identical to the pixel on a phone, and it stops collapsing\n // on a desktop card, which is the whole defect. (23.6% also lands between lit.link's 20.5% and\n // Linktree's 24.6%, so it is inside the band rather than at its edge.)\n //\n // ๐ด The ratio is of `.st-cells-wrap` โ 331px inside a 390px viewport, not 358 and not 390. The\n // first attempt used 21.8, computed against the wrong box, and shrank every avatar by 6px.\n //\n // cqw resolves against `.st-cells-wrap`, the container-query context the grid already establishes\n // for its square base unit โ the hero is a cell inside it, so this is the card's own width and not\n // the viewport's. min-width keeps a face on the avatar if a card is ever rendered very narrow.\n `.st-card .st-hero .cp-icon{width:23.6cqw;height:23.6cqw;min-width:56px;min-height:56px}`,\n // โโ and the avatar's POSITION, which the ratio fix left alone โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // The survey is not ambiguous: 10 pages across 3 platforms, **10/10 centre the avatar**, median\n // header height รท card width **0.977** โ near square. Ours was avatar-left / text-right, measuring\n // 0.453 on a phone and 0.288 on a desktop card, with the avatar 109โ230px off the centreline. The\n // SIZE was corrected to a ratio and the POSITION was simply never revisited.\n //\n // A column, centred, is the whole change โ there is no new box and no new element. Everything here\n // overrides card.css at equal specificity by being printed in the , which is also why none of\n // it carries !important.\n //\n // ๐ด `.st-figure` is untouched on purpose. It carries `data-dynamic-coral=\"qr\"` and the coral\n // positions its panel against that box; restyling it to help the layout would reach into the one\n // component that is explicitly not ours to move.\n // Symmetric padding again. The extra 28px at the bottom existed to give the veil's gradient a fade\n // zone to land in; with the veil gone it is a number with nothing behind it.\n `.st-card .st-hero{flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:16px;padding:24px 16px}`,\n // โโ and the header wears NO FRAME, which the frame table decided and nothing implemented โโโโโโโโ\n // `header / avatar / tagline โ none`. Prose was stripped to text on the ground (88c07c0); the\n // header never was, and kept `.st-cell`'s surface, border and radius plus its own gradient. It is\n // the same defect prose had โ a thing you cannot tap, shaped like a thing you can โ and it survived\n // because no check encodes that table. verify/frame-table.mjs is that check, added with this.\n //\n // A name and a face do not need a container to read as a heading; every platform in the survey puts\n // them straight on the page. Padding is kept, exactly as prose kept its own, so the text keeps a\n // margin instead of colliding with the first cell.\n // ๐ด `var(--cp-hero-veil, none)` rather than a plain `none`, and the indirection is load-bearing.\n // BACKDROP_CSS is emitted BEFORE this block and at lower specificity, so a rule it writes for\n // `.st-hero` loses twice over and silently does nothing โ the first attempt at the veil measured\n // 1.89:1 unchanged, because it never applied. A custom property does not care about either: the\n // page declares the veil only when it has a backdrop, and this stays the single place the header's\n // frame is decided. Cards with no backdrop resolve the fallback and are frameless, full stop.\n // ๐ด `border-radius:0` is not redundant. `.st-cell` sets 18px, and with no background and no border\n // a radius is invisible โ on a card with a BACKDROP it is not, because it rounds the veil's corners\n // and the veil becomes a panel. verify/frame-table.mjs caught this on its first run, which is the\n // argument for the file: three of us had looked at this header and none of us saw the radius.\n // ๐ด `background:none`, full stop โ no veil, no `var()` hatch for one. card.css puts a\n // `linear-gradient(120deg, surface-2, surface)` here and this is what removes it, on every card.\n // A backdrop card no longer gets a slab of ground colour over the artwork either; its text carries\n // its own legibility instead (FLOATING_INK, in BACKDROP_CSS).\n `.st-card .st-cell.st-hero{background:none;border:none;border-radius:0;box-shadow:none;backdrop-filter:none}`,\n `.st-card .st-hero-text{text-align:center;display:flex;flex-direction:column;align-items:center}`,\n `.st-card .st-hero-meta{justify-content:center}`,\n // โโ `header: left` โ the other arrangement, kept because a card may want it โโโโโโโโโโโโโโโโโโโโโ\n // Not a second header: the same element, the same avatar ratio (23.6cqw applies either way โ that\n // was a separate fix about the UNIT, not about the composition), only the axis and the alignment\n // change back. Written as overrides of the centred default so the default needs no class at all.\n `.st-card.st-card-head-left .st-hero{flex-direction:row;align-items:center;justify-content:flex-start;text-align:left;gap:18px;padding:16px}`,\n `.st-card.st-card-head-left .st-hero-text{text-align:left;align-items:flex-start}`,\n `.st-card.st-card-head-left .st-hero-meta{justify-content:flex-start}`,\n `.st-card.st-card-head-left .st-hero-tagline{margin-left:0;margin-right:0}`,\n // โโ `icons: mono` โ the fetched brand marks go grey โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // ๐ด Scoped to IMG marks only. Our own drawn glyphs (the envelope, the house) are already themed;\n // greying them would desaturate a colour we chose. This is why the row reads as mixed today โ not\n // because the brands have colour, but because ours are accent-blue while theirs are their own. In\n // mono both halves finally answer to the same rule.\n `.st-card.st-card-marks-mono .st-cell-icon-img,.st-card.st-card-marks-mono .st-social img{filter:grayscale(1)}`,\n // hover restores the brand, so the colour is one gesture away rather than gone\n `.st-card.st-card-marks-mono .st-cell-link:hover .st-cell-icon-img,.st-card.st-card-marks-mono .st-social-btn:hover img{filter:none}`,\n `@media(prefers-reduced-motion:no-preference){.st-card.st-card-marks-mono .st-cell-icon-img,.st-card.st-card-marks-mono .st-social img{transition:filter .25s}}`,\n // The tagline is the one run that must not stretch to the card's full width when centred: a\n // centred paragraph running the whole 646px of a desktop card reads as a banner, not a bio.\n // ๐ด `word-break:keep-all` is the whole fix, and it is about CJK rather than about any one card. A\n // Chinese or Japanese line breaks between ANY two characters by default, so a centred tagline\n // splits ใๆ็ซๅไฝใ down the middle โ a word cut in half, which in Latin script nobody would ship.\n // keep-all confines breaks to spaces, and a tagline written as ใA ยท B ยท Cใ already puts its\n // spaces exactly where the meaning divides. `overflow-wrap:break-word` stays as the escape hatch\n // for a single run genuinely longer than the line, or keep-all would overflow instead of wrapping.\n // `text-wrap:balance` then evens the remaining lines instead of leaving one orphan.\n `.st-card .st-hero-tagline{max-width:34ch;margin-left:auto;margin-right:auto;word-break:keep-all;overflow-wrap:break-word;text-wrap:balance}`,\n // โโ a video with no poster โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n // ๐ด There was already a rule for this โ `.st-embed-video-noposter{background:linear-gradient(...)}`\n // declared one line ABOVE `.st-embed-video-btn{background:#000}`, same specificity, so the black\n // won every time. It had never once had an effect, which is why a channel-mode video rendered as a\n // black rectangle with a play button in it: not \"loading\", not \"unavailable\", just broken-looking.\n // The two-class selector below outranks the button rather than relying on source order.\n //\n // A live block \"degrades to a static state that does not look broken\" โ the admissibility rule.\n // So the degraded state is designed rather than absent: the card's own surface, its own line, the\n // mark, and the title read as ordinary text. It looks like a video you have not opened yet, which\n // is exactly what it is.\n `.st-embed-video-btn.st-embed-video-noposter{background:linear-gradient(160deg,var(--cp-surface-2,#1a1a1a),var(--cp-surface,#161616));border:1px solid var(--cp-line,rgba(10,140,142,.28))}`,\n `.st-embed-video-noposter .st-embed-video-play{filter:none;opacity:.94}`,\n // the label's dark scrim exists to sit on a photograph. With no photograph it is a smudge, and the\n // ink can simply be the card's ink on the card's surface โ which the colour sweep already covers.\n `.st-embed-video-noposter .st-embed-video-label{background:none;color:var(--cp-ink,#fff);text-shadow:none;padding:12px}`,\n `.st-embed-video-poster{width:100%;height:100%;object-fit:cover;display:block;opacity:.82;transition:opacity .25s,transform .3s}`,\n `.st-embed-video-btn:hover .st-embed-video-poster{opacity:1;transform:scale(1.02)}`,\n `.st-embed-video-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;filter:drop-shadow(0 2px 10px rgba(0,0,0,.5));transition:transform .2s}`,\n `.st-embed-video-btn:hover .st-embed-video-play{transform:scale(1.08)}`,\n `.st-embed-video-label{position:absolute;left:0;right:0;bottom:0;padding:22px 12px 10px;font-size:.78rem;font-weight:600;color:#fff;text-align:left;background:linear-gradient(0deg,rgba(0,0,0,.72),transparent)}`,\n `.st-embed-video iframe{width:100%;height:100%;border:0;display:block}`,\n // social mark row\n `.st-social{display:flex;flex-wrap:wrap;gap:var(--s-2,.5rem);align-items:center;justify-content:center;background:none;border:none;box-shadow:none;padding:0;min-height:0}`,\n `.st-social-btn{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;border:1px solid var(--cp-accent-border,rgba(10,140,142,.28));background:color-mix(in srgb,var(--cp-accent,#0a8c8e) 8%,transparent);color:var(--cp-accent,#0a8c8e);transition:background .2s,transform .2s}`,\n `.st-social-btn:hover{background:color-mix(in srgb,var(--cp-accent,#0a8c8e) 20%,transparent);transform:translateY(-2px)}`,\n `.st-social-btn .st-cell-icon-img{width:20px;height:20px;background:none}`,\n // the drawn last resort โ revealed by the sibling combinator the instant onerror hides the img,\n // so it needs no class toggle and works with JS already done running.\n `.st-social-bare{display:none}`,\n `.st-social-btn .st-cell-icon-img[style*=\"none\"] + .st-social-bare{display:block}`,\n // square-corner skin (a creator whose platform set square corners keeps them)\n `.st-card.st-card-square .st-cell,.st-card.st-card-square .st-gal-img,.st-card.st-card-square .st-embed-video-btn{border-radius:0}`\n ].join(\"\");\n var PICTURE_GLYPH = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='16' rx='3'/%3E%3Ccircle cx='9' cy='10' r='1.6'/%3E%3Cpath d='m21 16-5-5-9 9'/%3E%3C/svg%3E\")`;\n var EDIT_ONLY_CSS = [\n `.st-card .st-cell-unset{border-style:dashed;border-color:var(--cp-line-hi,currentColor)}`,\n // one ROW = one grid unit (`.st-cell`'s own min-height), the same height an empty link takes; the\n // gallery cell has no border of its own, so the dashed outline is drawn here in full.\n `.st-card .st-gal-labelled.st-cell-unset{min-height:var(--cp-unit);border:1px dashed var(--cp-line-hi,currentColor);cursor:pointer}`,\n `.st-card .st-gal-labelled.st-cell-unset .st-gal-link{flex-direction:row;justify-content:center;aspect-ratio:auto;height:auto;min-height:44px;gap:8px;padding:var(--s-3,.75rem) var(--s-4,1rem)}`,\n `.st-card .st-gal-labelled.st-cell-unset .st-gal-img{position:static;flex:0 0 auto;width:20px;height:20px;aspect-ratio:1;border:0;border-radius:0;background:currentColor;opacity:.75;-webkit-mask:${PICTURE_GLYPH} center/contain no-repeat;mask:${PICTURE_GLYPH} center/contain no-repeat}`,\n `.st-card .st-gal-labelled.st-cell-unset .st-gal-label{max-width:none;font-size:.85rem}`\n ].join(\"\");\n var HALO = \"text-shadow:0 0 4px var(--cp-ground),0 0 9px var(--cp-ground),0 1px 14px var(--cp-ground)\";\n var FLOATING_INK = [\n `.st-card .st-hero-name,.st-card .st-hero-tagline{color:var(--cp-ink);${HALO}}`,\n `.st-card .st-hero-tagline em{color:var(--cp-accent-bright);${HALO}}`,\n `.st-card .st-chip{color:var(--cp-accent-bright);${HALO}}`,\n // ๐ด `--cp-ink`, not `--cp-page-ink`. The page-ink is derived for text on the page MIST; over a\n // backdrop the footer is on artwork, and it measured 2.86 there against a 4.5 floor. Same mistake\n // the tagline had with `--cp-muted`: a colour derived for one surface, used on another.\n `.st-footer,.st-footer>div{color:var(--cp-ink)!important;${HALO}}`,\n `.st-footer a{color:var(--cp-accent-bright)!important;${HALO}}`\n ].join(\"\");\n var BACKDROP_CSS = (img, dim) => `.st-backdrop{position:fixed;inset:0;z-index:0;background-image:url(\"${cssUrl(img)}\");background-size:cover;background-position:center;background-attachment:fixed}\nhtml{scrollbar-gutter:auto}\n.st-wrap,.st-footer{position:relative;z-index:1}\n.st-backdrop::after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,color-mix(in srgb,var(--cp-ground,#0a1628) ${dim}%,transparent),color-mix(in srgb,var(--cp-ground,#0a1628) ${Math.min(96, dim + 18)}%,transparent))}\n.st-grid-label{text-shadow:0 1px 3px rgba(0,0,0,.85),0 0 12px rgba(0,0,0,.6)}\n${FLOATING_INK}\n@media(max-width:720px){.st-backdrop{background-attachment:scroll}}`;\n function renderGrid(model, ctx = {}) {\n ctx = { name: \"\", icons: {}, ...ctx };\n const cols = model.cols;\n const parts = [];\n const isRow = (pl) => pl.cell.type === \"link\" && pl.w === cols;\n const groupRuns = (placements) => {\n const out = [];\n for (const pl of placements) {\n const last = out[out.length - 1];\n if (isRow(pl) && last && last.run) last.items.push(pl);\n else out.push(isRow(pl) ? { run: true, items: [pl] } : { run: false, items: [pl] });\n }\n return out;\n };\n const emit = (label2, run) => {\n if (!run.length) return;\n const g = packGrid({ cells: run.map((x) => x.cell), cols });\n const inner = groupRuns(g.placements).map((grp) => {\n const html = grp.items.map((pl) => renderCell(pl.cell, { ...pl, abs: run[pl.index].abs }, ctx)).join(\"\");\n return grp.run ? `
`;\n }).join(\"\");\n const close = `\\u2715`;\n return `
${panels}${close}
`;\n }\n function renderPage(model, ctx = {}) {\n const backdropUrl = resolveAsset(ctx.backdrop, ctx);\n const backdrop = backdropUrl ? `` : \"\";\n const grid = backdrop + renderGrid(model, ctx) + renderDrawers(model, ctx);\n const css = ctx.css || \"\";\n const qr = ctx.qrScriptInline ? `\n\n\n\n","tugtile.css":"/* tugtile โ markdown kanban (prototype, read-only renderer) */\n\n/* Squircle (continuous-curvature) corners โ progressive enhancement.\n corner-shape is Chromium-only as of mid-2026; Safari/WebKit & Firefox ignore it and keep the\n border-radius arc, so this is ZERO visual regression. Requires a non-zero border-radius (still\n set per-surface below). Token lives here because corner-shape is NOT inherited but a custom\n property IS. Applied only to finite-radius card/button/panel/input surfaces โ never pills/circles. */\n:root { --corner: squircle; }\n:where(\n .tugtile-brand, .tugtile__lane, .tugtile__tile, .tugtile__num, .tugtile__popup,\n .tugtile__popup-d, .tugtile__popup-t, .tugtile__add-btn,\n .tugtile-prompt-field, .tugtile-iconbtn, .tugtile-ed-find-i,\n .tugtile-viewcycle, .tugtile__archivebar, .tugtile__lane-chevron,\n .tugtile-archive-row, .tugtile__addcol-btn\n) { corner-shape: var(--corner); }\n\n.tugtile {\n height: 100%;\n padding: 0;\n position: relative; /* Contain the view-swap snapshot overlay */\n}\n/* Search/Undo/Redo live in the native back/forward container (repurposed by setupLeftActions), so they line up with the left-sidebar toggle above. */\n.tugtile-leftacts { display: flex; align-items: center; gap: 2px; padding-left: 4px; } /* Nudge right to line up the first icon with the left-sidebar toggle above (auto-measure couldn't find the toggle on iPad) */\n/* iPad only, Obsidian sets `.is-mobile .view-action { margin: auto 0 }` (specificity (0,2,0)) on these addAction\n icons, which misaligns Search/Undo/Redo. This (0,3,0) compound out-specifies it and resets the margin without\n !important โ the children always carry both classes (addAction โ .clickable-icon.view-action). */\n.tugtile-leftacts > .clickable-icon.view-action { margin: 0; }\n/* Boardโtable swap: the outgoing snapshot slides up & out while the new view rises from below (two panels shuffling). */\n.tugtile-viewsnap {\n position: absolute; inset: 0; z-index: 3;\n background: var(--background-primary); overflow: hidden; pointer-events: none;\n animation: tugtile-view-out 0.38s cubic-bezier(.7, 0, .3, 1) forwards; /* ease-in-out: slow โ fast โ slow */\n}\n@keyframes tugtile-view-out { from { transform: translateY(0); } to { transform: translateY(-100%); } }\n.tugtile--switching .tugtile__board,\n.tugtile--switching .tugtile__table-wrap { animation: tugtile-view-in 0.38s cubic-bezier(.7, 0, .3, 1); }\n@keyframes tugtile-view-in { from { transform: translateY(100%); } to { transform: translateY(0); } }\n/* Phone: the centered control (view-cycle ยท lock) sits in a top-bar in the content, not the cramped header */\n/* Solid bg + z-index above the view-swap snapshot (z-index 3): the bar stays put on top while board/table\n slide beneath it, so the up-slide animation never leaks through the control row. */\n.tugtile__ctlbar { flex: 0 0 auto; width: 100%; box-sizing: border-box; display: flex; justify-content: center; padding: 2px 8px 8px; position: relative; z-index: 4; background: var(--background-primary); }\n/* Phone: kill the view-content top padding so the control bar sits flush against the header โ otherwise that\n strip sits ABOVE the bar and the view-swap snapshot (which covers the whole content) leaks through it. */\nbody.is-phone .workspace-leaf-content[data-type=\"tugtile-board\"] .view-content { padding: 0; } /* All sides: the left/right padding inset the content so the full-width bar still left gaps on both edges */\n/* Greyed-out (disabled-looking) header action โ used for undo/redo when their stack is empty, instead of hiding the button */\n.tugtile-act-off { opacity: 0.35; pointer-events: none; }\n/* Dimmed brand suffix appended to the centered header title: \" ยท tugtile-ing\" */\n/* Centered header controls: [view-cycle] ยท [lock]. Sized up (font-size) to roughly match the right-hand toolbar icons. */\n.tugtile-headerctl {\n display: inline-flex; align-items: center; gap: 6px; vertical-align: middle;\n font-size: 18px;\n}\n.tugtile-sep { color: var(--text-faint); } /* Neutral separator, NOT a button โ equal button padding + the flex gap keep its two sides symmetric */\n/* Brand suffix + lock icon = one button (t('brandSuffix') + padlock) that toggles the read-only lock */\n.tugtile-brand {\n display: inline-flex; align-items: center; gap: 5px;\n color: var(--text-faint); font-weight: var(--font-normal);\n cursor: pointer; border-radius: var(--radius-s); padding: 2px 6px;\n}\n.tugtile-brand:hover { background: var(--background-modifier-hover); color: var(--text-muted); }\n\n/* TileEditModal (tugtile's big editor) control strip โ scoped to the modal so marktile's own strip is untouched.\n A uniform gray bar spanning the full width: โ pinned far left, โ far right, the modeยทlock centered between. */\n.tugtile-edit-modal-full .tugtile__ctlbar { background: var(--background-secondary); }\n.tugtile-edit-modal-full .tugtile-headerctl { display: flex; width: 100%; }\n.tugtile-edit-modal-full .tugtile-headerctl > .tugtile-iconbtn:first-child { margin-right: auto; } /* โ โ far left */\n.tugtile-edit-modal-full .tugtile-headerctl > .tugtile-iconbtn:last-child { margin-left: auto; } /* โ โ far right */\n.tugtile-lock-icon { display: inline-flex; }\n.tugtile-lock-icon svg { width: 0.9em; height: 0.9em; }\n/* Locked board (read-only): hide add/edit affordances, disable checkboxes, drop drag cursors. View-only actions (collapse, view switch, search) still work. */\n.tugtile--locked .tugtile__add,\n.tugtile--locked .tugtile__addcol,\n.tugtile--locked .tugtile__tile-more,\n.tugtile--locked .tugtile__lane-more { display: none; }\n.tugtile--locked .tugtile__check,\n.tugtile--locked .tugtile__row-title input { pointer-events: none; }\n.tugtile--locked .tugtile__tile,\n.tugtile--locked .tugtile__lane-head { cursor: default; }\n.tugtile__empty {\n padding: 16px;\n color: var(--text-muted);\n}\n.tugtile__board {\n display: flex;\n gap: 12px;\n align-items: flex-start;\n height: 100%;\n overflow-x: auto;\n padding: 12px;\n}\n.tugtile__lane {\n flex: 0 0 auto;\n width: var(--tugtile-lane-width, 300px); /* Uniform fixed column width (configurable in settings); all lanes line up evenly */\n max-height: 100%;\n display: flex;\n flex-direction: column;\n background: var(--background-secondary);\n border-radius: var(--radius-m);\n padding: 8px;\n overflow: hidden; /* Cleanly clips content into the changing lane width during collapse/expand (dragging uses a clone on body and is unaffected) */\n --tg-d1: 0.26s; /* Stage 1: lane narrows + horizontal title tucks into the chevron (collapse) / widens + title grows back (expand) */\n --tg-d2: 0.24s; /* Stage 2: vertical title grows down out of the chevron (collapse) / retracts up into it (expand) */\n transition: width var(--tg-d1) cubic-bezier(.4, 0, .2, 1); /* Smooth width change on collapse/expand */\n}\n.tugtile__lane-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 4px 0 8px; /* Horizontal 0 โ header rails line up with the card column (cards sit at the lane's own 8px padding on both sides) */\n font-weight: 700;\n cursor: grab; /* Header acts as the drag handle for the lane */\n}\n.tugtile__lane-head:active { cursor: grabbing; }\n.tugtile__lane--ghost { opacity: 0.4; }\n.tugtile__lane--chosen { box-shadow: 0 0 0 2px var(--interactive-accent); }\n.tugtile__lane--drag { /* Slightly tilts and lifts the lane when dragging the entire column */\n rotate: 2deg;\n box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5);\n}\n.tugtile__lane-count {\n flex: 0 0 auto;\n opacity: 0.55;\n font-size: 0.85em;\n transition: opacity var(--tg-d1) ease;\n}\n.tugtile__lane-count--over { /* WIP: Warning indicator when exceeding limits */\n color: var(--text-error);\n opacity: 1;\n font-weight: 700;\n}\n.tugtile--hide-counts .tugtile__lane-count {\n display: none;\n}\n.tugtile__list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n overflow-y: auto;\n /* Scrollbar gutter without changing the card layout: the list extends 8px to the right (eating the lane's right padding) via the negative margin, while padding-right keeps the cards in their original position. The iOS/iPadOS overlay scrollbar then sits in that right padding instead of on top of the cards. */\n margin-right: -8px;\n padding-right: 8px;\n}\n.tugtile__tile:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--interactive-accent); } /* keyboard-focused card (arrow keys move it) */\n.tugtile__tile {\n position: relative;\n background: var(--background-primary);\n border: 1px solid var(--background-modifier-border);\n border-radius: var(--radius-m);\n padding: 8px;\n cursor: grab;\n}\n.tugtile__tile:active {\n cursor: grabbing;\n}\n.tugtile__tile:hover {\n border-color: var(--background-modifier-border-hover, var(--interactive-accent));\n}\n/* Dragging state (corresponding to the Sortable class in main.js) */\n/* Placeholder left behind: faded out with a dashed border, similar to a Trello card slot */\n.tugtile__tile--ghost {\n opacity: 0.4;\n background: var(--background-modifier-hover);\n border-style: dashed;\n}\n.tugtile__tile--ghost > * { visibility: hidden; }\n.tugtile__tile--chosen {\n box-shadow: inset 0 0 0 2px var(--interactive-accent); /* Inner ring โ an outer ring's left/right 2px gets clipped by the list's overflow (cards are full-width with no horizontal room) */\n}\n/* The card following the cursor feels \"picked up\": slightly tilted, scaled up, with a floating shadow.\n Uses standalone rotate/scale properties (not transform) to avoid overwriting translations applied by Sortable. */\n.tugtile__tile--drag {\n opacity: 0.97;\n rotate: 3deg;\n scale: 1.03;\n box-shadow: 0 14px 32px rgba(0, 0, 0, 0.5);\n cursor: grabbing;\n}\n/* Index number: a rounded inline chip prepended to the card text.\n Key detail: keeping font-size unscaled (matching body text size) aligns the number and adjacent text to the same baseline,\n achieving natural vertical alignment without magic numbers, font, line-height, or scale issues. The background chip sits snug\n around the number (slight horizontal, minimal vertical padding), centering itself on the first line and wrapping naturally. */\n.tugtile__num {\n display: inline-block;\n margin-right: 0.4em;\n padding: 0 0.4em;\n background: var(--interactive-accent);\n color: var(--text-on-accent);\n border-radius: var(--radius-s);\n font-weight: 700;\n line-height: 1.25;\n}\n/* Floating control area in the top-right: checkbox + โฏ. Using float allows the first line of text to wrap around it, keeping content aligned to the top without wasting space. */\n.tugtile__tile-ctrls {\n float: right;\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0 0 2px 8px;\n}\n.tugtile__tile-body {\n padding-top: 0.15em; /* Subtle top padding for breathing room (kept smaller than the control height so the first line still wraps around it) */\n overflow-wrap: anywhere; /* Alphanumeric text/URLs do not overflow the lane */\n contain: layout paint; /* The body's internal layout is self-contained, so animating the tile's height doesn't re-flow the rendered markdown every frame โ kills the stutter on content-heavy tiles */\n}\n/* Long card collapse: hides the full text when folded, showing only the first line of the title (Approach C, without clipping) */\n.tugtile__tile-title {\n display: none;\n padding-top: 0.15em;\n font-weight: 600;\n}\n.tugtile__tile-title > :first-child { margin-top: 0; }\n.tugtile__tile-title > :last-child { margin-bottom: 0; }\n.tugtile__tile--folded .tugtile__tile-body { display: none; }\n.tugtile__tile--folded .tugtile__tile-title { display: block; }\n/* On expand the tile height snaps (one reflow), then the body slides + fades in on the GPU โ no per-frame\n layout, so it stays smooth no matter how much content the tile holds. */\n.tugtile__tile--revealing .tugtile__tile-body { animation: tugtile-body-reveal 0.24s cubic-bezier(.4, 0, .2, 1) both; }\n@keyframes tugtile-body-reveal {\n from { opacity: 0; transform: translateY(-6px); }\n to { opacity: 1; transform: none; }\n}.tugtile__tile-body > :first-child {\n margin-top: 0;\n}\n.tugtile__tile-body > :last-child {\n margin-bottom: 0;\n}\n/* Date and time picker popup */\n.tugtile__popup {\n position: fixed;\n z-index: 9999;\n background: var(--background-primary);\n border: 1px solid var(--background-modifier-border);\n border-radius: var(--radius-m);\n box-shadow: var(--shadow-l);\n padding: 8px;\n user-select: none;\n}\n.tugtile__popup-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-weight: 600;\n padding: 2px 4px 8px;\n}\n.tugtile__popup-nav {\n cursor: pointer;\n padding: 0 8px;\n display: inline-flex;\n align-items: center;\n}\n.tugtile__popup-nav svg { width: 1.15em; height: 1.15em; }\n.tugtile__popup-nav:hover { color: var(--text-accent); }\n.tugtile__popup-grid {\n display: grid;\n grid-template-columns: repeat(7, 2em);\n gap: 2px;\n text-align: center;\n}\n.tugtile__popup-w { opacity: 0.5; font-size: 0.8em; }\n.tugtile__popup-d {\n cursor: pointer;\n border-radius: var(--radius-s);\n padding: 3px 0;\n line-height: 1.4;\n}\n.tugtile__popup-d:hover { background: var(--background-modifier-hover); }\n.tugtile__popup-d--today { color: var(--text-accent); font-weight: 700; }\n.tugtile__popup--time {\n max-height: 240px;\n overflow-y: auto;\n padding: 4px;\n}\n.tugtile__popup-t {\n cursor: pointer;\n padding: 4px 16px;\n border-radius: var(--radius-s);\n}\n.tugtile__popup-t:hover { background: var(--background-modifier-hover); }\n/* Tag chips at the bottom of the card (when move-tags-to-card-footer is enabled) */\n.tugtile__tags {\n margin-top: 6px;\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n}\n.tugtile__tag {\n font-size: 0.78em;\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--background-modifier-hover);\n color: var(--text-accent);\n cursor: pointer;\n}\n.tugtile__tag:hover { filter: brightness(1.1); }\n/* Date/time chips at the bottom of the card */\n.tugtile__date {\n margin-top: 6px;\n font-size: 0.78em;\n color: var(--text-muted);\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px; /* separates the date and time chips (replaces the old full-width-space spacer) */\n}\n.tugtile__date-d, .tugtile__date-t { display: inline-flex; align-items: center; gap: 3px; }\n.tugtile__date svg { width: 1em; height: 1em; } /* Lucide calendar/clock, sized to the chip text */\n.tugtile__archivebar-i { display: inline-flex; }\n.tugtile__archivebar svg { width: 1.1em; height: 1.1em; } /* Lucide archive icon (was a ๐ฅ emoji) */\n/* Date status colors: red for overdue, accent for today, yellow for upcoming (โค7 days), and green for safe (>7 days); colored whenever a date exists */\n.tugtile__date--overdue { color: var(--color-red); font-weight: 600; }\n.tugtile__date--today { color: var(--text-accent); font-weight: 600; }\n.tugtile__date--soon { color: var(--color-yellow); font-weight: 600; }\n.tugtile__date--safe { color: var(--color-green); }\n.tugtile__date--unknown { color: var(--text-muted); } /* Neutral color used when the date cannot be parsed */\n\n/* Freeze container scrolling during dragging (adding .is-dragging in main.js onStart) to prevent native mobile scrolling from fighting the drag gesture */\n.tugtile.is-dragging .tugtile__board,\n.tugtile.is-dragging .tugtile__list {\n /* (0,2,0) already out-specifies the base .tugtile__board/__list (0,1,0) in this file, so no !important needed */\n overflow: hidden;\n touch-action: none;\n}\n/* Add card button and input */\n.tugtile__add {\n padding-top: 8px; /* Match the 8px gap between cards so the add button keeps the same vertical rhythm */\n}\n.tugtile__add-btn {\n width: 100%;\n text-align: left;\n background: transparent;\n border: 1px dashed var(--background-modifier-border);\n border-radius: var(--radius-m);\n color: var(--text-muted);\n padding: 8px; /* Match card padding and the add-lane button (consistent 8px rhythm); left-aligned \"+\" lines up with the card number chip */\n cursor: pointer;\n}\n.tugtile__add-btn:hover {\n background: var(--background-modifier-hover);\n color: var(--text-normal);\n}\n/* โฏ More actions */\n.tugtile__tile-more {\n color: var(--text-faint);\n line-height: 1;\n cursor: pointer;\n opacity: 0.3;\n display: inline-flex;\n align-items: center;\n}\n.tugtile__tile-more svg { width: 1em; height: 1em; }\n.tugtile__tile-more:hover {\n opacity: 1;\n color: var(--text-normal);\n}\n/* Card checkbox */\n.tugtile__check {\n margin: 0;\n flex: none;\n cursor: pointer;\n}\n/* \"Undo\" button for toast notifications (to restore deleted/archived items in one tap) */\n.tugtile-toast-undo {\n margin-left: 10px;\n font-weight: 700;\n text-decoration: underline;\n cursor: pointer;\n color: var(--text-accent);\n}\n/* Focused editing sheet (integrated with iOS 26 rounded sheet design; hides native โ to use our own left โ and right โ) */\n.tugtile-edit-modal-full {\n width: 100vw;\n max-width: 100vw;\n height: 100dvh; /* Dynamic viewport: shrinks with the on-screen keyboard so the editor stays inside the visible area and the textarea can scroll. dvh is supported on every Obsidian-supported renderer (Electron + iOS/Android webview), so no 100vh fallback is needed. */\n max-height: 100dvh;\n border-radius: 0; /* Full-screen focus editor โ nothing of the board shows behind */\n /* Inset content past the iPad status bar / notch / home indicator; env() = 0 on desktop. The solid background still bleeds into these strips. */\n padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);\n overflow: hidden;\n transform-origin: center;\n animation: tugtile-pop-in 0.4s cubic-bezier(.34, 1.56, .64, 1); /* Keep the bounce โ full-screen โ unrefined */\n}\n/* Backdrop darkens to solid FAST (so the board vanishes quickly, not lingering behind the bounce); zero container padding */\n.modal-container.tugtile-edit-host { padding: 0; align-items: flex-start; } /* Top-pinned, so shrinking the editor to the visual viewport (keyboard) keeps it above the keyboard. Class set in TileEditModal.onOpen โ a direct container target, no parent-match selector. */\n/* The status-bar / home-indicator strips show --background-secondary (white in light, grey in dark) and sit\n outside the modal's reach. So instead of fighting to cover them, dye the WHOLE editor --background-secondary\n to match โ seamless in both modes (the old --background-primary only matched in light mode). */\n.modal-container.tugtile-edit-host .modal-bg { background: var(--background-secondary); opacity: 1; animation: tugtile-bg-in 0.12s ease both; }\n.tugtile-edit-modal-full,\n.tugtile-edit-modal-full .tugtile-edit-modal { background: var(--background-secondary); }\n/* macOS: push the editor down so its top bar (โ) isn't covered by the window traffic lights (class is .mod-macos) */\n.mod-macos .tugtile-edit-modal-full { padding-top: 28px; }\n@keyframes tugtile-bg-in { from { opacity: 0; } to { opacity: 1; } }\n.tugtile-edit-modal-full.tugtile-ed-closing {\n animation: tugtile-pop-out 0.28s ease-in forwards; /* Exit transition: reverse scaling animation */\n}\n/* Opacity reaches 1 early (~0.1s) so the editor covers the board fast โ the scale still bounces to settle. Fixes the \"laggy / see-through during the animation\" feel. */\n@keyframes tugtile-pop-in {\n 0% { opacity: 0; transform: scale(0.92); }\n 35% { opacity: 1; }\n 100% { opacity: 1; transform: scale(1); }\n}\n@keyframes tugtile-pop-out {\n from { opacity: 1; transform: scale(1); }\n to { opacity: 0; transform: scale(0.92); }\n}\n/* In-content text bars (search ยท rename ยท add-lane) โ none of these use an Obsidian Modal any more. The view\n renders a strip at the top of its content: .tugtile-searchbar is persistent (toggled by .tugtile--searching),\n .tugtile-promptbar is transient (built on demand for rename / add-lane). Both reuse the .tugtile-prompt-row/\n -field/-input styles below, and the JS freezes the board height while they're open so the virtual keyboard\n can't collapse it into a black gap. On phone they take over the .tugtile__ctlbar slot. */\n.tugtile-searchbar { display: none; }\n.tugtile--searching .tugtile-searchbar,\n.tugtile-promptbar {\n flex: 0 0 auto;\n padding: 4px 8px 8px;\n background: var(--background-primary);\n}\n.tugtile--searching .tugtile-searchbar { display: block; }\nbody.is-phone .tugtile--searching .tugtile__ctlbar,\nbody.is-phone .tugtile--prompting .tugtile__ctlbar { display: none; }\n.tugtile-prompt-row { display: flex; align-items: center; gap: 8px; }\n/* The input is wrapped in a rounded \"field\" so a leading icon (search) can sit inside it */\n.tugtile-prompt-field {\n flex: 1; min-width: 0;\n display: flex; align-items: center; gap: 8px;\n box-sizing: border-box; height: 40px; padding: 0 12px;\n background: var(--background-modifier-form-field);\n border: 1px solid var(--background-modifier-border);\n border-radius: var(--radius-m);\n overflow: hidden; /* Clip the input to the rounded box so it can never poke out of the corners */\n}\n.tugtile-prompt-field:focus-within { border-color: var(--interactive-accent); }\n.tugtile-prompt-fieldicon { flex: 0 0 auto; display: inline-flex; align-items: center; color: var(--text-muted); }\n.tugtile-prompt-fieldicon svg { width: 1.15em; height: 1.15em; }\n/* The input is our OWN contenteditable element (not a native ), so Obsidian's input chrome never\n applies โ no overrides, no !important. The .tugtile-prompt-field IS the visible box; this is the bare\n single-line text surface inside it. plaintext-only keeps paste/IME clean (no rich markup, CJK-safe). */\n.tugtile-prompt-input {\n flex: 1; min-width: 0;\n outline: none;\n white-space: pre; overflow-x: auto; overflow-y: hidden; /* single line: scroll sideways, never wrap */\n font-size: 16px; color: var(--text-normal); /* 16px prevents iOS auto-zoom on focus */\n -webkit-user-modify: read-write-plaintext-only;\n}\n.tugtile-prompt-input::-webkit-scrollbar { display: none; } /* hide the horizontal scrollbar on the one-line field */\n.tugtile-prompt-input:empty::before {\n content: attr(data-placeholder);\n color: var(--text-faint);\n pointer-events: none; /* placeholder text isn't selectable / doesn't block the caret */\n}\n/* .tugtile-prompt-x / -ok inherit from .tugtile-iconbtn (the โ colour override lives up there) */\n.tugtile-edit-modal-full .modal-close-button { display: none; } /* Hides the native top-right โ button in favor of our custom โ button with safety checks */\n.tugtile-edit-modal {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n padding: 0;\n background: var(--background-primary); /* Opaque: in the marktile pane there's no modal-bg behind it, so without this the area the iOS keyboard reveals shows through as a black gap */\n}\n/* marktile lives in a leaf, not a modal โ fill the pane opaquely and use the dynamic viewport so the editor\n shrinks WITH the on-screen keyboard instead of leaving a black void below it. */\n.workspace-leaf-content[data-type=\"marktile-editor\"] .view-content {\n padding: 0;\n background: var(--background-primary);\n}\n.workspace-leaf-content[data-type=\"marktile-editor\"] .tugtile-ed-scroll { max-height: 100dvh; }\n.tugtile-ed-bar {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px 10px;\n border-bottom: 1px solid var(--background-modifier-border);\n}\n/* โโ Single source of truth for EVERY icon button โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n editor โ/โ, toolbar tools, find/replace, search โ/โ. 40ร40, flat, non-flex centering. Two hard\n rules baked in here so they can never drift again and re-break iPad:\n 1) NO display:flex on the button โ iPad WebKit blanks an inline svg that's a flex item.\n 2) the svg is nested in a (done in JS) โ iPad WebKit won't render an svg that's a direct\n button child. So a size rule on the svg is safe.\n Per-button classes below only override what's genuinely unique (colour of โ, hover colour of โ). */\n.tugtile-iconbtn {\n flex: 0 0 auto;\n -webkit-appearance: none; appearance: none;\n position: relative;\n width: 40px; height: 40px;\n border: none; border-radius: var(--radius-m);\n background: transparent; color: var(--text-muted);\n cursor: pointer;\n}\n/* The icon's svg is wrapped in a (the iPad svg-in-button fix). Absolutely-centre that span โ true\n pixel-centre, and NO flex on the button (flex on a