atris 3.25.2 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/card.js ADDED
@@ -0,0 +1,120 @@
1
+ // atris card — turn one line of text into a beautiful, on-brand image.
2
+ // Pure: spec -> self-contained HTML string. Reuses the design system: the same
3
+ // themes as deck/site/html (incl. your .atris/theme.json brand), one accent,
4
+ // Fraunces display, restraint. Render to PNG with headless Chrome (commands/card.js).
5
+ //
6
+ // Kinds: statement (kicker + headline + sub), quote (quote + attribution),
7
+ // stat (big number + label). Sizes: og, wide, square, story.
8
+
9
+ const { mergedThemes } = require('./theme');
10
+ const { THEMES: HTML_THEMES, rich, esc } = require('./html-render');
11
+
12
+ const SIZES = {
13
+ og: { w: 1200, h: 630 }, // OpenGraph / link card
14
+ wide: { w: 1920, h: 1080 }, // slide / banner
15
+ square: { w: 1080, h: 1080 }, // feed
16
+ story: { w: 1080, h: 1920 }, // story / reel
17
+ };
18
+ const KINDS = ['statement', 'quote', 'stat'];
19
+
20
+ const plainLen = (s) => String(s == null ? '' : s).replace(/\*\*/g, '').length;
21
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
22
+
23
+ // shrink a display line so long text still fits the card
24
+ function fit(base, text, refChars) {
25
+ return Math.round(base * clamp(refChars / Math.max(plainLen(text), 1), 0.5, 1));
26
+ }
27
+
28
+ function fontStack(name, fallback) {
29
+ return `'${String(name || fallback).replace(/'/g, '')}', '${fallback}', ${fallback === 'Fraunces' ? 'Georgia, serif' : 'system-ui, sans-serif'}`;
30
+ }
31
+
32
+ function buildCard(spec = {}, opts = {}) {
33
+ const size = SIZES[spec.size] || SIZES.og;
34
+ const themes = opts.themes || mergedThemes(HTML_THEMES, opts.root || process.cwd());
35
+ const theme = themes[spec.theme] || HTML_THEMES.atris;
36
+ const c = theme.color;
37
+ const f = theme.fonts || {};
38
+ const kind = KINDS.includes(spec.kind) ? spec.kind : 'statement';
39
+ const u = Math.min(size.w, size.h);
40
+
41
+ const pad = Math.round(u * 0.088);
42
+ const px = {
43
+ kicker: Math.round(u * 0.030),
44
+ sub: Math.round(u * 0.040),
45
+ foot: Math.round(u * 0.028),
46
+ rule: Math.round(u * 0.010),
47
+ gap: Math.round(u * 0.030),
48
+ };
49
+
50
+ const brand = spec.brand || 'Atris';
51
+ const version = spec.version || '';
52
+ const display = fontStack(f.display, 'Fraunces');
53
+ const body = fontStack(f.body, 'Outfit');
54
+ const grotesk = "'Space Grotesk', system-ui, sans-serif";
55
+ const monoFont = "'IBM Plex Mono', ui-monospace, monospace";
56
+
57
+ const foot = `<div class="foot"><span class="mark">${esc(brand)}</span>${version ? `<span class="ver">${esc(version)}</span>` : ''}</div>`;
58
+ const kickerHtml = spec.kicker ? `<div class="kicker">${rich(spec.kicker)}</div>` : '';
59
+ const subHtml = spec.sub ? `<p class="sub">${rich(spec.sub)}</p>` : '';
60
+
61
+ let main = '';
62
+ let extraCss = '';
63
+ if (kind === 'quote') {
64
+ const text = spec.text || spec.headline || 'Your quote here';
65
+ const qSize = fit(Math.round(u * 0.090), text, 64);
66
+ main = `<div class="rule"></div>
67
+ <div class="qmark">&ldquo;</div>
68
+ <blockquote class="qtext">${rich(text)}</blockquote>
69
+ ${spec.by ? `<div class="by">${rich(spec.by)}</div>` : ''}`;
70
+ extraCss = `
71
+ .qmark{font-family:${display};color:${c.accent};font-size:${Math.round(u * 0.20)}px;line-height:.6;height:${Math.round(u * 0.10)}px}
72
+ .qtext{font-family:${display};font-weight:600;font-size:${qSize}px;line-height:1.08;letter-spacing:-1px;margin:${px.gap}px 0}
73
+ .by{font-family:${monoFont};font-size:${px.foot}px;color:${c.soft}}`;
74
+ } else if (kind === 'stat') {
75
+ const number = spec.number || spec.headline || '42';
76
+ const numSize = fit(Math.round(u * 0.34), number, 6);
77
+ main = `<div class="rule"></div>
78
+ ${kickerHtml}
79
+ <div class="big">${rich(number)}</div>
80
+ ${spec.label ? `<div class="statlabel">${rich(spec.label)}</div>` : ''}
81
+ ${subHtml}`;
82
+ extraCss = `
83
+ .big{font-family:${grotesk};font-weight:600;color:${c.accent};font-size:${numSize}px;line-height:.92;letter-spacing:-2px}
84
+ .statlabel{font-family:${display};font-weight:600;font-size:${Math.round(u * 0.058)}px;color:${c.ink};margin-top:${Math.round(px.gap * 0.5)}px}`;
85
+ } else {
86
+ const headline = spec.headline || spec.text || 'Your headline here';
87
+ const hSize = fit(Math.round(u * 0.135), headline, 34);
88
+ main = `<div class="rule"></div>
89
+ ${kickerHtml}
90
+ <h1 class="headline">${rich(headline)}</h1>
91
+ ${subHtml}`;
92
+ extraCss = `
93
+ .headline{font-family:${display};font-weight:600;font-size:${hSize}px;line-height:1.02;letter-spacing:-1.5px}`;
94
+ }
95
+
96
+ const html = `<!doctype html>
97
+ <html lang="en"><head><meta charset="utf-8">
98
+ <link rel="preconnect" href="https://fonts.googleapis.com">
99
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
100
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Outfit:wght@400;500;600&family=Space+Grotesk:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
101
+ <style>
102
+ *{margin:0;padding:0;box-sizing:border-box}
103
+ html,body{width:${size.w}px;height:${size.h}px;overflow:hidden}
104
+ body{background:${c.bg};color:${c.ink};font-family:${body};-webkit-font-smoothing:antialiased}
105
+ .card{width:${size.w}px;height:${size.h}px;padding:${pad}px;display:flex;flex-direction:column;justify-content:center;position:relative}
106
+ .rule{width:${Math.round(u * 0.085)}px;height:${px.rule}px;background:${c.accent};border-radius:${px.rule}px;margin-bottom:${Math.round(px.gap * 0.9)}px}
107
+ .kicker{font-family:${monoFont};font-size:${px.kicker}px;letter-spacing:2px;color:${c.soft};margin-bottom:${Math.round(px.gap * 0.6)}px}
108
+ .sub{font-size:${px.sub}px;line-height:1.4;color:${c.soft};margin-top:${px.gap}px;max-width:88%}
109
+ .foot{position:absolute;left:${pad}px;right:${pad}px;bottom:${pad}px;display:flex;align-items:baseline;justify-content:space-between}
110
+ .mark{font-family:${display};font-weight:600;font-size:${Math.round(u * 0.034)}px}
111
+ .ver{font-family:${monoFont};font-size:${px.foot}px;color:${c.soft};letter-spacing:1px}
112
+ .accent{color:${c.accent}}
113
+ ${extraCss}
114
+ </style></head>
115
+ <body><div class="card">${main}${foot}</div></body></html>`;
116
+
117
+ return { html, width: size.w, height: size.h, kind, theme: spec.theme || 'atris', size: spec.size || 'og' };
118
+ }
119
+
120
+ module.exports = { buildCard, SIZES, KINDS };
package/lib/clarity.js ADDED
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // The clarity interview. The front door of the product: draw out how the human
4
+ // works, write it down once, and let every agent read it so they prompt
5
+ // themselves well. A small, high-signal profile, not a survey.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ // One or two ideas at a time, plain English. Each answer is free text; the
11
+ // parenthetical is a nudge, not a fixed menu.
12
+ const QUESTIONS = [
13
+ { key: 'focus', q: 'what are you building, and who is it for?' },
14
+ { key: 'voice', q: 'how should output sound? (plain, terse, warm, formal)' },
15
+ { key: 'cadence', q: 'how do you like to work? (one idea at a time, batch, overnight autonomous)' },
16
+ { key: 'done', q: 'what does "done" mean to you? (tests green, shipped, you reviewed it)' },
17
+ { key: 'leash', q: 'how much should agents do without asking? (ask first, proceed and report, full auto)' },
18
+ ];
19
+
20
+ const KEYS = QUESTIONS.map((x) => x.key);
21
+
22
+ function isEmptyProfile(profile) {
23
+ return !profile || !KEYS.some((k) => profile[k]);
24
+ }
25
+
26
+ function renderClarityMd(profile = {}) {
27
+ const labels = {
28
+ focus: 'Focus',
29
+ voice: 'Voice',
30
+ cadence: 'Cadence',
31
+ done: 'Done means',
32
+ leash: 'Leash',
33
+ };
34
+ const lines = [
35
+ '# Clarity profile',
36
+ '',
37
+ 'How the operator works. Agents read this to prompt themselves well,',
38
+ 'so the human does not have to repeat themselves.',
39
+ '',
40
+ ];
41
+ for (const key of KEYS) {
42
+ if (profile[key]) lines.push(`- ${labels[key]}: ${profile[key]}`);
43
+ }
44
+ if (isEmptyProfile(profile)) {
45
+ lines.push('- (not set yet, run `atris clarity` to fill this in)');
46
+ }
47
+ lines.push('');
48
+ if (profile.updated_at) lines.push(`Updated: ${profile.updated_at}`);
49
+ lines.push('');
50
+ return lines.join('\n');
51
+ }
52
+
53
+ function profilePaths(root = process.cwd()) {
54
+ return {
55
+ json: path.join(root, '.atris', 'clarity.json'),
56
+ md: path.join(root, 'atris', 'CLARITY.md'),
57
+ };
58
+ }
59
+
60
+ function readProfile(root = process.cwd()) {
61
+ const { json } = profilePaths(root);
62
+ try {
63
+ return JSON.parse(fs.readFileSync(json, 'utf8'));
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ function writeProfile(root, profile) {
70
+ const { json, md } = profilePaths(root);
71
+ fs.mkdirSync(path.dirname(json), { recursive: true });
72
+ fs.mkdirSync(path.dirname(md), { recursive: true });
73
+ fs.writeFileSync(json, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
74
+ fs.writeFileSync(md, renderClarityMd(profile), 'utf8');
75
+ return { json, md };
76
+ }
77
+
78
+ // Merge new answers over an existing profile (so --set is incremental).
79
+ function mergeProfile(existing, answers, stamp) {
80
+ const merged = { ...existing };
81
+ for (const key of KEYS) {
82
+ if (typeof answers[key] === 'string' && answers[key].trim()) merged[key] = answers[key].trim();
83
+ }
84
+ merged.updated_at = stamp || merged.updated_at || null;
85
+ return merged;
86
+ }
87
+
88
+ module.exports = {
89
+ QUESTIONS,
90
+ KEYS,
91
+ mergeProfile,
92
+ isEmptyProfile,
93
+ renderClarityMd,
94
+ profilePaths,
95
+ readProfile,
96
+ writeProfile,
97
+ };
@@ -0,0 +1,110 @@
1
+ // Markdown -> deck spec. Lets a PM write an ordinary doc and get a designed deck.
2
+ // Pure: a markdown string in, a { theme, brand, slides } spec out (fed to buildDeck).
3
+ //
4
+ // Mapping (predictable on purpose):
5
+ // --- front matter --- theme / brand / accent
6
+ // # H1 (first) -> title slide (sub = the paragraph under it)
7
+ // ## H2 with 2-4 bullets -> columns slide (each bullet "**Lead** rest" -> a column)
8
+ // ## H2 with "**X** label" only -> bignumber slide
9
+ // ## H2 titled Close/CTA/Thanks -> close slide
10
+ // ## H2 otherwise -> statement slide (sub = the paragraph under it)
11
+ // Emphasis (**bold**) is preserved and rendered in the accent by the engine.
12
+
13
+ function parseFrontMatter(md) {
14
+ const out = { theme: null, brand: null, accent: null };
15
+ const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
16
+ if (!m) return { body: md, fm: out };
17
+ for (const line of m[1].split(/\r?\n/)) {
18
+ const kv = line.match(/^([A-Za-z_]+):\s*(.+?)\s*$/);
19
+ if (!kv) continue;
20
+ const k = kv[1].toLowerCase();
21
+ const v = kv[2].trim().replace(/^["']|["']$/g, '');
22
+ if (k === 'theme') out.theme = v;
23
+ else if (k === 'brand') out.brand = v;
24
+ else if (k === 'accent') out.accent = v;
25
+ }
26
+ return { body: md.slice(m[0].length), fm: out };
27
+ }
28
+
29
+ // group a section's lines into bullets[] and paragraphs[]
30
+ function splitBody(lines) {
31
+ const bullets = [];
32
+ const paras = [];
33
+ let buf = [];
34
+ const flush = () => { if (buf.length) { paras.push(buf.join(' ').trim()); buf = []; } };
35
+ for (const raw of lines) {
36
+ const line = raw.replace(/\s+$/, '');
37
+ if (/^\s*[-*+]\s+/.test(line)) { flush(); bullets.push(line.replace(/^\s*[-*+]\s+/, '').trim()); }
38
+ else if (/^\s*$/.test(line)) flush();
39
+ else if (/^\s*>\s+/.test(line)) { flush(); paras.push(line.replace(/^\s*>\s+/, '').trim()); }
40
+ else buf.push(line.trim());
41
+ }
42
+ flush();
43
+ return { bullets, paras: paras.filter(Boolean) };
44
+ }
45
+
46
+ // "**Lead** rest" / "Lead: rest" / "Lead - rest" -> { h, b }
47
+ function toColumn(bullet) {
48
+ let m = bullet.match(/^\*\*(.+?)\*\*[\s:.-]*(.*)$/);
49
+ if (m) return { h: m[1].trim(), b: m[2].trim() };
50
+ m = bullet.match(/^([^:]{2,48}):\s+(.+)$/);
51
+ if (m) return { h: m[1].trim(), b: m[2].trim() };
52
+ return { h: bullet.trim(), b: '' };
53
+ }
54
+
55
+ // a single "**value** label" paragraph -> big number slide
56
+ function asBigNumber(paras, bullets) {
57
+ if (bullets.length || paras.length !== 1) return null;
58
+ const m = paras[0].match(/^\*\*([^*]{1,18})\*\*\s+(.+)$/);
59
+ if (!m) return null;
60
+ return { type: 'bignumber', number: m[1].trim(), label: m[2].trim() };
61
+ }
62
+
63
+ const CLOSE_TITLES = /^(close|thanks|thank you|cta|call to action|get started|wrap up|next steps?)$/i;
64
+
65
+ function parseMarkdownToSpec(md, opts = {}) {
66
+ const { body, fm } = parseFrontMatter(String(md || ''));
67
+ const theme = opts.theme || fm.theme || 'terminal';
68
+ const brand = {
69
+ name: opts.brandName || fm.brand || 'Atris',
70
+ accent: opts.accent || fm.accent || '.',
71
+ };
72
+
73
+ // collect sections by heading
74
+ const sections = [];
75
+ let cur = null;
76
+ for (const raw of body.split(/\r?\n/)) {
77
+ const h1 = raw.match(/^#\s+(.+)/);
78
+ const h2 = raw.match(/^##\s+(.+)/);
79
+ if (h1) { cur = { level: 1, title: h1[1].trim(), lines: [] }; sections.push(cur); }
80
+ else if (h2) { cur = { level: 2, title: h2[1].trim(), lines: [] }; sections.push(cur); }
81
+ else if (cur) cur.lines.push(raw);
82
+ }
83
+
84
+ const slides = [];
85
+ sections.forEach((sec, idx) => {
86
+ const { bullets, paras } = splitBody(sec.lines);
87
+ const firstPara = paras[0] || '';
88
+
89
+ if (sec.level === 1 && slides.length === 0) {
90
+ slides.push({ type: 'title', headline: sec.title, sub: firstPara || undefined });
91
+ return;
92
+ }
93
+ if (CLOSE_TITLES.test(sec.title)) {
94
+ slides.push({ type: 'close', tagline: firstPara || sec.title, footer: bullets[0] || undefined });
95
+ return;
96
+ }
97
+ const big = asBigNumber(paras, bullets);
98
+ if (big) { slides.push(big); return; }
99
+ if (bullets.length >= 2 && bullets.length <= 4 && bullets.every((b) => b.length <= 160)) {
100
+ slides.push({ type: 'columns', heading: sec.title, columns: bullets.map(toColumn) });
101
+ return;
102
+ }
103
+ slides.push({ type: 'statement', text: sec.title, sub: firstPara || undefined });
104
+ });
105
+
106
+ if (!slides.length) slides.push({ type: 'statement', text: brand.name });
107
+ return { theme, brand, slides };
108
+ }
109
+
110
+ module.exports = { parseMarkdownToSpec, parseFrontMatter, splitBody, toColumn };
@@ -0,0 +1,257 @@
1
+ // Atris HTML renderer — same content model as the deck engine, rendered to
2
+ // beautiful, anti-slop HTML made of semantic blocks. Pure: spec -> HTML string.
3
+ //
4
+ // Connects with the web apps: the `atris` theme matches atrisos-web tokens
5
+ // (amber #f59e0b on warm dark, --brand-* CSS variables), every section carries
6
+ // data-atris-block="<type>", and renderBlock() emits the AppBlock html shape
7
+ // ({ type:'html', config:{ html } }) so the output drops into an Atris app.
8
+ //
9
+ // Anti-slop by construction: one accent, fluid type, restraint, no gradient
10
+ // text, no glassmorphism, em dashes sanitized (shares slides-deck helpers).
11
+
12
+ const { THEMES: DECK_THEMES, sanitize, parseEmph } = require('./slides-deck');
13
+
14
+ // HTML themes reuse the deck design-system shape, plus `atris` (matches the web app).
15
+ const THEMES = {
16
+ atris: { // warm dark, amber accent, matches atrisos-web (--brand-* tokens, TWKLausanne)
17
+ fonts: { display: 'TWKLausanne', body: 'TWKLausanne', mono: 'ui-monospace, SFMono-Regular, monospace' },
18
+ color: { bg: '#141110', panel: '#1E1915', panelAlt: '#2C2520', line: '#3D332D',
19
+ ink: '#EAE3D9', soft: '#A39B92', faint: '#7C736B',
20
+ accent: '#F59E0B', accent2: '#FBBF24', onAccent: '#141110',
21
+ sev: ['#F59E0B', '#FBBF24', '#7F97A4'] },
22
+ },
23
+ terminal: DECK_THEMES.terminal,
24
+ paper: DECK_THEMES.paper,
25
+ };
26
+
27
+ function esc(s) {
28
+ return String(s == null ? '' : s)
29
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
30
+ }
31
+
32
+ // **emphasis** -> <span class="accent">; everything else escaped. Sanitized first.
33
+ function rich(markup) {
34
+ const { plain, ranges } = parseEmph(sanitize(markup));
35
+ if (!ranges.length) return esc(plain);
36
+ let out = '', i = 0;
37
+ for (const r of ranges) {
38
+ out += esc(plain.slice(i, r.start));
39
+ out += `<span class="accent">${esc(plain.slice(r.start, r.end))}</span>`;
40
+ i = r.end;
41
+ }
42
+ out += esc(plain.slice(i));
43
+ return out;
44
+ }
45
+
46
+ function wordmark(brand) {
47
+ const name = esc((brand && brand.name) || 'Atris');
48
+ const ac = (brand && brand.accent) || '';
49
+ return `<div class="mark">${name}${ac ? `<b>${esc(ac)}</b>` : ''}</div>`;
50
+ }
51
+
52
+ function panelHtml(p) {
53
+ const rows = (p.rows || []).map((r, i) => `
54
+ <div class="row${r.active ? ' active' : ''}">
55
+ <span class="sev s${(r.sev != null ? r.sev : 0) % 3}"></span>
56
+ <div class="name">${rich(r.title || '')}${r.sub ? `<small>${rich(r.sub)}</small>` : ''}</div>
57
+ ${r.value != null ? `<div class="val"><b>${rich(String(r.value))}</b>${r.valueSub ? `<small>${rich(r.valueSub)}</small>` : ''}</div>` : ''}
58
+ </div>`).join('');
59
+ return `<div class="panel">
60
+ ${p.header ? `<div class="panel-head"><span>${rich(p.header.title || '')}</span>${p.header.meta ? `<span class="meta">${rich(p.header.meta)}</span>` : ''}</div>` : ''}
61
+ ${rows}
62
+ ${p.footer ? `<div class="panel-foot"><span>${rich(p.footer.left || '')}</span>${p.footer.right ? `<a>${rich(p.footer.right)}</a>` : ''}</div>` : ''}
63
+ </div>`;
64
+ }
65
+
66
+ const BLOCKS = {
67
+ title(s, spec) {
68
+ return `<section class="block hero" data-atris-block="hero">
69
+ <div class="lede">
70
+ ${wordmark(spec.brand)}
71
+ <div class="rule"></div>
72
+ <h1>${rich(s.headline || s.title || '')}</h1>
73
+ ${s.sub ? `<p class="sub">${rich(s.sub)}</p>` : ''}
74
+ </div>
75
+ ${s.panel ? panelHtml(s.panel) : ''}
76
+ </section>`;
77
+ },
78
+ statement(s) {
79
+ return `<section class="block statement" data-atris-block="statement">
80
+ <h2 class="big">${rich(s.text || s.headline || '')}</h2>
81
+ ${s.sub ? `<p class="sub">${rich(s.sub)}</p>` : ''}
82
+ </section>`;
83
+ },
84
+ columns(s) {
85
+ const cols = (s.columns || []).map((c) => `
86
+ <div class="col"><h3>${rich(c.h || c.title || '')}</h3>${(c.b || c.body) ? `<p>${rich(c.b || c.body)}</p>` : ''}</div>`).join('');
87
+ return `<section class="block columns" data-atris-block="columns">
88
+ ${s.heading ? `<h2 class="heading">${rich(s.heading)}</h2>` : ''}
89
+ <div class="cols c${Math.min((s.columns || []).length, 4)}">${cols}</div>
90
+ </section>`;
91
+ },
92
+ panel(s) {
93
+ return `<section class="block panel-block" data-atris-block="panel">
94
+ <div class="panel-lede">${s.heading ? `<h2>${rich(s.heading)}</h2>` : ''}${s.sub ? `<p>${rich(s.sub)}</p>` : ''}</div>
95
+ ${panelHtml(s.panel || { rows: [] })}
96
+ </section>`;
97
+ },
98
+ bignumber(s) {
99
+ return `<section class="block bignumber" data-atris-block="bignumber">
100
+ <div class="num">${rich(String(s.number || s.value || ''))}</div>
101
+ ${s.label ? `<div class="num-label">${rich(s.label)}</div>` : ''}
102
+ ${s.sub ? `<p class="sub">${rich(s.sub)}</p>` : ''}
103
+ </section>`;
104
+ },
105
+ quote(s) {
106
+ return `<section class="block quote" data-atris-block="quote">
107
+ <blockquote>${rich(s.text || s.quote || '')}</blockquote>
108
+ ${s.by ? `<cite>${rich(s.by)}</cite>` : ''}
109
+ </section>`;
110
+ },
111
+ toc(s) {
112
+ const items = (s.items || []).map((it) =>
113
+ `<a class="toc-item" href="${esc(it.href)}"><h3>${rich(it.title || it.href)}</h3>${it.summary ? `<p>${rich(it.summary)}</p>` : ''}</a>`).join('');
114
+ return `<section class="block toc" data-atris-block="toc">
115
+ ${s.heading ? `<h2 class="heading">${rich(s.heading)}</h2>` : ''}
116
+ <div class="toc-grid">${items}</div>
117
+ </section>`;
118
+ },
119
+ close(s, spec) {
120
+ const btns = (s.buttons || []).map((b) => `<a class="btn${b.primary ? ' primary' : ''}">${rich(b.label || 'Open')}</a>`).join('');
121
+ return `<section class="block close" data-atris-block="close">
122
+ <div class="rule center"></div>
123
+ ${wordmark(spec.brand)}
124
+ ${s.tagline ? `<p class="tagline">${rich(s.tagline)}</p>` : ''}
125
+ ${btns ? `<div class="btns">${btns}</div>` : ''}
126
+ ${s.footer ? `<p class="footer">${rich(s.footer)}</p>` : ''}
127
+ </section>`;
128
+ },
129
+ };
130
+
131
+ function css(theme) {
132
+ const c = theme.color, f = theme.fonts;
133
+ const gFonts = (f.display === 'Fraunces' || f.body === 'Outfit')
134
+ ? `<link rel="preconnect" href="https://fonts.googleapis.com"><link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=Outfit:wght@300;400;500;600&display=swap" rel="stylesheet">`
135
+ : '';
136
+ const style = `:root{
137
+ --brand-bg:${c.bg};--brand-card:${c.panel};--brand-surface:${c.panelAlt || c.panel};--brand-line:${c.line};
138
+ --brand-text:${c.ink};--brand-text-secondary:${c.soft};--brand-faint:${c.faint};
139
+ --brand-primary:${c.accent};--brand-primary-2:${c.accent2};
140
+ --font-display:${f.display === 'TWKLausanne' ? "'TWKLausanne',system-ui,sans-serif" : `'${f.display}',serif`};
141
+ --font-body:${f.body === 'TWKLausanne' ? "'TWKLausanne',system-ui,sans-serif" : `'${f.body}',sans-serif`};
142
+ --ease:cubic-bezier(.25,1,.5,1);--measure:60ch;
143
+ }
144
+ *{margin:0;box-sizing:border-box}
145
+ html{-webkit-font-smoothing:antialiased}
146
+ body{font-family:var(--font-body);color:var(--brand-text);background:
147
+ radial-gradient(120% 80% at 88% -8%, ${c.accent}1f, transparent 56%),
148
+ radial-gradient(70% 60% at -6% 4%, ${c.soft}14, transparent 52%), var(--brand-bg);
149
+ line-height:1.5;font-variant-numeric:tabular-nums}
150
+ .wrap{max-width:1140px;margin:0 auto;padding:clamp(28px,5vw,72px) clamp(20px,5vw,56px)}
151
+ .block{padding:clamp(40px,7vw,96px) 0;border-bottom:1px solid var(--brand-line)}
152
+ .block:last-child{border-bottom:0}
153
+ .mark{font-family:var(--font-display);font-weight:500;font-size:20px;letter-spacing:-.01em}
154
+ .mark b{color:var(--brand-primary);font-weight:500}
155
+ .rule{width:40px;height:2px;background:var(--brand-primary);margin:22px 0}.rule.center{margin:0 auto 22px}
156
+ .hero{display:grid;grid-template-columns:1.05fr .95fr;gap:clamp(32px,6vw,80px);align-items:center;border-bottom:0;padding-top:clamp(24px,4vw,48px)}
157
+ @media(max-width:860px){.hero{grid-template-columns:1fr}}
158
+ h1{font-family:var(--font-display);font-weight:300;font-size:clamp(2.6rem,6vw,4.6rem);line-height:.99;letter-spacing:-.02em}
159
+ .accent{color:var(--brand-primary-2);font-style:italic}
160
+ .sub{max-width:var(--measure);color:var(--brand-text-secondary);font-size:clamp(1rem,1.4vw,1.15rem);margin-top:22px}
161
+ .big{font-family:var(--font-display);font-weight:300;font-size:clamp(2.2rem,5vw,3.6rem);line-height:1.02;letter-spacing:-.02em}
162
+ .heading{font-family:var(--font-display);font-weight:400;font-size:clamp(1.6rem,3vw,2rem);margin-bottom:34px}
163
+ .cols{display:grid;gap:clamp(20px,4vw,48px)}.cols.c2{grid-template-columns:repeat(2,1fr)}.cols.c3{grid-template-columns:repeat(3,1fr)}.cols.c4{grid-template-columns:repeat(4,1fr)}
164
+ @media(max-width:720px){.cols{grid-template-columns:1fr!important}}
165
+ .col{border-top:1px solid var(--brand-line);padding-top:18px}
166
+ .col h3{font-family:var(--font-display);font-weight:400;font-size:1.25rem;margin-bottom:8px}
167
+ .col p{color:var(--brand-text-secondary);font-size:.97rem;max-width:36ch}
168
+ .panel-block{display:grid;grid-template-columns:.85fr 1.15fr;gap:clamp(24px,5vw,64px);align-items:center}
169
+ @media(max-width:760px){.panel-block{grid-template-columns:1fr}}
170
+ .panel-lede h2{font-family:var(--font-display);font-weight:400;font-size:1.7rem;margin-bottom:14px}
171
+ .panel-lede p{color:var(--brand-text-secondary);max-width:34ch}
172
+ .panel{background:var(--brand-card);border:1px solid var(--brand-line);border-radius:14px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.18)}
173
+ .panel-head{display:flex;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--brand-line);font-size:14px}
174
+ .panel-head .meta{color:var(--brand-faint);font-size:12.5px}
175
+ .row{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;padding:14px 18px;border-bottom:1px solid var(--brand-surface)}
176
+ .row:last-child{border-bottom:0}.row.active{border-left:2px solid var(--brand-primary);padding-left:16px}
177
+ .sev{width:8px;height:8px;border-radius:50%}.sev.s0{background:var(--brand-primary)}.sev.s1{background:var(--brand-primary-2)}.sev.s2{background:#7f97a4}
178
+ .name{font-size:14.5px}.name small{display:block;color:var(--brand-faint);font-size:12.5px;margin-top:3px}
179
+ .val{text-align:right;font-size:12.5px;color:var(--brand-faint)}.val b{display:block;color:var(--brand-text);font-size:15px}
180
+ .panel-foot{display:flex;justify-content:space-between;padding:13px 18px;font-size:13px;color:var(--brand-faint)}
181
+ .panel-foot a{color:var(--brand-primary-2);text-decoration:none}
182
+ .bignumber .num{font-family:var(--font-display);font-weight:300;font-size:clamp(3.5rem,11vw,7rem);line-height:1;color:var(--brand-primary-2)}
183
+ .num-label{font-size:1.05rem;margin-top:18px}
184
+ .quote blockquote{font-family:var(--font-display);font-weight:300;font-style:italic;font-size:clamp(1.6rem,3.4vw,2.4rem);line-height:1.25;max-width:24ch;quotes:'\\201C''\\201D'}
185
+ .quote blockquote::before{content:open-quote;color:var(--brand-primary)}.quote blockquote::after{content:close-quote;color:var(--brand-primary)}
186
+ .quote cite{display:block;margin-top:20px;color:var(--brand-text-secondary);font-style:normal;font-size:.95rem}
187
+ .close{text-align:center;border-bottom:0}
188
+ .close .mark{font-family:var(--font-display);font-size:clamp(2.4rem,6vw,3.4rem);font-weight:400}
189
+ .tagline{color:var(--brand-text-secondary);margin-top:10px}
190
+ .btns{display:flex;gap:12px;justify-content:center;margin-top:28px}
191
+ .btn{padding:12px 22px;border-radius:11px;border:1px solid var(--brand-line);color:var(--brand-text);text-decoration:none;font-weight:500;font-size:15px;transition:transform 160ms var(--ease),background-color 160ms var(--ease)}
192
+ .btn:hover{transform:translateY(-1px)}.btn.primary{background:var(--brand-text);color:var(--brand-bg);border-color:var(--brand-text)}
193
+ .footer{margin-top:30px;color:var(--brand-faint);font-size:13px}
194
+ .sitenav{display:flex;justify-content:space-between;align-items:baseline;max-width:1140px;margin:0 auto;padding:20px clamp(20px,5vw,56px);border-bottom:1px solid var(--brand-line)}
195
+ .sitenav .mark{font-size:18px;text-decoration:none;color:var(--brand-text)}
196
+ .sitenav .nav-links{display:flex;gap:24px;font-size:14px;color:var(--brand-text-secondary)}
197
+ .sitenav .nav-links a{color:inherit;text-decoration:none;transition:color 160ms var(--ease)}
198
+ .sitenav .nav-links a:hover{color:var(--brand-text)}
199
+ .toc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:clamp(16px,3vw,32px)}
200
+ .toc-item{display:block;border-top:1px solid var(--brand-line);padding-top:16px;text-decoration:none;color:inherit;transition:border-color 160ms var(--ease)}
201
+ .toc-item:hover{border-color:var(--brand-primary)}
202
+ .toc-item h3{font-family:var(--font-display);font-weight:400;font-size:1.15rem;margin-bottom:6px}
203
+ .toc-item p{color:var(--brand-text-secondary);font-size:.92rem;max-width:40ch}
204
+ @media(prefers-reduced-motion:reduce){*{transition:none!important}}`;
205
+ return { gFonts, style };
206
+ }
207
+
208
+ function renderBody(spec, opts = {}) {
209
+ const themes = opts.themes || THEMES;
210
+ const theme = themes[spec.theme] || THEMES.atris;
211
+ const blocks = (spec.slides || spec.blocks || [])
212
+ .map((s) => (BLOCKS[s.type] || BLOCKS.statement)(s, spec))
213
+ .join('\n ');
214
+ return { theme, html: `<div class="wrap">\n ${blocks}\n </div>` };
215
+ }
216
+
217
+ function renderNav(nav) {
218
+ const links = (nav.links || []).map((l) => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join('');
219
+ return `<nav class="sitenav"><a class="mark" href="${esc(nav.home || 'index.html')}">${esc(nav.label || 'Atris')}${nav.accent ? `<b>${esc(nav.accent)}</b>` : ''}</a>${links ? `<div class="nav-links">${links}</div>` : ''}</nav>`;
220
+ }
221
+
222
+ // full standalone page (opts.nav renders a site header, opts.themes injects brand themes)
223
+ function renderHtml(spec, opts = {}) {
224
+ const { theme, html } = renderBody(spec, opts);
225
+ const { gFonts, style } = css(theme);
226
+ const title = esc(sanitize(opts.title || (spec.brand && spec.brand.name) || 'Atris'));
227
+ return `<!doctype html>
228
+ <html lang="en">
229
+ <head>
230
+ <meta charset="utf-8">
231
+ <meta name="viewport" content="width=device-width, initial-scale=1">
232
+ <title>${title}</title>
233
+ ${gFonts}
234
+ <style>${style}</style>
235
+ </head>
236
+ <body data-atris-theme="${esc(spec.theme || 'atris')}">
237
+ ${opts.nav ? renderNav(opts.nav) : ''}
238
+ ${html}
239
+ </body>
240
+ </html>`;
241
+ }
242
+
243
+ // AppBlock html shape, ready to embed in an Atris app (ui_template 'html'/'block')
244
+ function renderBlock(spec, opts = {}) {
245
+ return {
246
+ type: 'html',
247
+ title: opts.title || (spec.brand && spec.brand.name) || 'Atris',
248
+ config: {
249
+ block_type: 'html',
250
+ title: opts.title || (spec.brand && spec.brand.name) || 'Atris',
251
+ html: renderHtml(spec, opts),
252
+ theme: spec.theme || 'atris',
253
+ },
254
+ };
255
+ }
256
+
257
+ module.exports = { renderHtml, renderBlock, renderBody, THEMES, BLOCKS, rich, esc };