atris 3.17.0 → 3.22.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/atris.md +3 -3
- package/bin/atris.js +14 -2
- package/commands/deck.js +77 -28
- package/commands/recap.js +16 -0
- package/commands/site.js +48 -0
- package/commands/slop.js +146 -12
- package/commands/theme.js +217 -0
- package/lib/deck-from-md.js +110 -0
- package/lib/html-render.js +257 -0
- package/lib/memory-view.js +95 -0
- package/lib/site.js +114 -0
- package/lib/slides-deck.js +3 -2
- package/lib/theme.js +264 -0
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Memory view — another way to look at memory updates. Reads the workspace's
|
|
2
|
+
// compounding memory (lessons.md, learnings.jsonl, task projection) and builds a
|
|
3
|
+
// content spec rendered as a beautiful HTML page by lib/html-render.
|
|
4
|
+
//
|
|
5
|
+
// Pure-ish: file reads in, a { theme, brand, slides } spec out. Anti-slop by
|
|
6
|
+
// construction (the renderer sanitizes, so em dashes in the raw lessons file
|
|
7
|
+
// never reach the page).
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
// "- **[2026-06-18] some-id** — pass — text..." -> { date, id, status, text }
|
|
13
|
+
function parseLessons(text) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const line of String(text || '').split('\n')) {
|
|
16
|
+
const m = line.match(/^-\s*\*\*\[([^\]]+)\]\s*([^*]+?)\*\*\s*[—:-]*\s*(pass|fail)?\s*[—:-]*\s*(.*)$/i);
|
|
17
|
+
if (!m) continue;
|
|
18
|
+
out.push({ date: m[1].trim(), id: m[2].trim(), status: (m[3] || '').toLowerCase(), text: m[4].trim() });
|
|
19
|
+
}
|
|
20
|
+
return out; // append-only file: oldest first
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseLearnings(text) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of String(text || '').split('\n')) {
|
|
26
|
+
if (!line.trim()) continue;
|
|
27
|
+
try { const j = JSON.parse(line); out.push({ ts: j.ts, type: j.type, key: j.key, insight: j.insight }); } catch {}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readFileSafe(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } }
|
|
33
|
+
|
|
34
|
+
function readTaskCounts(root) {
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(readFileSafe(path.join(root, '.atris', 'state', 'tasks.projection.json')));
|
|
37
|
+
const tasks = Array.isArray(j.tasks) ? j.tasks : [];
|
|
38
|
+
const by = (s) => tasks.filter((t) => (t.status || '').toLowerCase() === s).length;
|
|
39
|
+
return { total: tasks.length, done: by('done') + by('accepted'), active: by('active') + by('in_progress'), review: by('review') + by('ready') };
|
|
40
|
+
} catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gatherMemory(root = process.cwd()) {
|
|
44
|
+
const lessons = parseLessons(readFileSafe(path.join(root, 'atris', 'lessons.md')));
|
|
45
|
+
const learnings = parseLearnings(readFileSafe(path.join(root, 'atris', 'learnings.jsonl')));
|
|
46
|
+
const tasks = readTaskCounts(root);
|
|
47
|
+
return { lessons, learnings, tasks };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clip(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; }
|
|
51
|
+
function titleize(id) { return String(id || '').replace(/[-_]+/g, ' ').trim(); }
|
|
52
|
+
function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; }
|
|
53
|
+
|
|
54
|
+
function buildMemorySpec(root = process.cwd(), opts = {}) {
|
|
55
|
+
const theme = opts.theme || 'atris';
|
|
56
|
+
const brand = { name: opts.brand || 'Atris', accent: '.' };
|
|
57
|
+
const { lessons, learnings, tasks } = gatherMemory(root);
|
|
58
|
+
const recent = lessons.slice(-8).reverse(); // newest first
|
|
59
|
+
|
|
60
|
+
const slides = [];
|
|
61
|
+
slides.push({
|
|
62
|
+
type: 'title',
|
|
63
|
+
headline: 'What the workspace **learned**',
|
|
64
|
+
sub: `${plural(lessons.length, 'lesson')} and ${plural(learnings.length, 'note')} compounded into memory${tasks ? `, ${plural(tasks.done, 'task')} done` : ''}.`,
|
|
65
|
+
});
|
|
66
|
+
slides.push({ type: 'bignumber', number: String(lessons.length), label: 'lessons compounded into the workspace' });
|
|
67
|
+
|
|
68
|
+
if (recent.length) {
|
|
69
|
+
slides.push({
|
|
70
|
+
type: 'columns', heading: 'Most recent lessons',
|
|
71
|
+
columns: recent.slice(0, 3).map((l) => ({ h: titleize(l.id), b: clip(l.text, 150) })),
|
|
72
|
+
});
|
|
73
|
+
slides.push({
|
|
74
|
+
type: 'panel', heading: 'Memory updates', sub: 'Lessons and notes, newest first.',
|
|
75
|
+
panel: {
|
|
76
|
+
header: { title: 'Recent updates', meta: `${recent.length} shown` },
|
|
77
|
+
rows: recent.map((l, i) => ({
|
|
78
|
+
title: titleize(l.id), sub: l.date,
|
|
79
|
+
value: l.status || 'note', sev: l.status === 'fail' ? 0 : (l.status === 'pass' ? 2 : 1),
|
|
80
|
+
active: i === 0,
|
|
81
|
+
})),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (learnings.length) {
|
|
86
|
+
slides.push({
|
|
87
|
+
type: 'columns', heading: 'Notes from the field',
|
|
88
|
+
columns: learnings.slice(-3).reverse().map((n) => ({ h: titleize(n.key || n.type || 'note'), b: clip(n.insight, 150) })),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
slides.push({ type: 'close', tagline: 'Memory lives in the filesystem, not the model.', footer: `${brand.name} workspace memory` });
|
|
92
|
+
return { theme, brand, slides };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { buildMemorySpec, gatherMemory, parseLessons, parseLearnings, readTaskCounts };
|
package/lib/site.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Static site generator — point it at a folder of markdown (docs, your wiki,
|
|
2
|
+
// memory) and get a beautiful, navigable HTML site in the design system.
|
|
3
|
+
// Builds on lib/deck-from-md (parse) + lib/html-render (render).
|
|
4
|
+
//
|
|
5
|
+
// buildSite(input, opts) -> { outDir, indexPath, pages }. Pure file I/O.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const { parseMarkdownToSpec } = require('./deck-from-md');
|
|
11
|
+
const { renderHtml, THEMES: BUILTIN } = require('./html-render');
|
|
12
|
+
const { mergedThemes } = require('./theme');
|
|
13
|
+
|
|
14
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '.cache']);
|
|
15
|
+
const MD_EXTS = new Set(['.md', '.mdx']);
|
|
16
|
+
|
|
17
|
+
function collectMd(input) {
|
|
18
|
+
const stat = fs.statSync(input);
|
|
19
|
+
if (stat.isFile()) return MD_EXTS.has(path.extname(input)) ? [input] : [];
|
|
20
|
+
const out = [];
|
|
21
|
+
(function walk(dir) {
|
|
22
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
23
|
+
if (name.startsWith('.')) continue;
|
|
24
|
+
const full = path.join(dir, name);
|
|
25
|
+
let st; try { st = fs.statSync(full); } catch { continue; }
|
|
26
|
+
if (st.isDirectory()) { if (!SKIP_DIRS.has(name)) walk(full); }
|
|
27
|
+
else if (MD_EXTS.has(path.extname(name))) out.push(full);
|
|
28
|
+
}
|
|
29
|
+
})(input);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function slugFor(file, root) {
|
|
34
|
+
// base = the dir the site was built from (root may be a dir or a single .md file)
|
|
35
|
+
const base = MD_EXTS.has(path.extname(root)) ? path.dirname(root) : root;
|
|
36
|
+
const rel = path.relative(base, file).replace(/\.[^.]+$/, '');
|
|
37
|
+
return rel.replace(/[\\/]+/g, '-').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'page';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function firstHeading(md) {
|
|
41
|
+
const m = md.match(/^#{1,2}\s+(.+)$/m);
|
|
42
|
+
return m ? m[1].replace(/\*\*/g, '').trim() : null;
|
|
43
|
+
}
|
|
44
|
+
function firstParagraph(md) {
|
|
45
|
+
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '');
|
|
46
|
+
for (const line of body.split('\n')) {
|
|
47
|
+
const t = line.trim();
|
|
48
|
+
if (!t || /^[#<>]/.test(t) || /^[-*+]/.test(t) || /^\|/.test(t) || /^```/.test(t)) continue;
|
|
49
|
+
return t.replace(/\*\*/g, '');
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
function clip(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; }
|
|
54
|
+
function titleFromSlug(slug) { return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); }
|
|
55
|
+
|
|
56
|
+
function buildSite(input, opts = {}) {
|
|
57
|
+
const THEMES = mergedThemes(BUILTIN, opts.root || process.cwd());
|
|
58
|
+
const theme = THEMES[opts.theme] ? opts.theme : 'atris';
|
|
59
|
+
const siteTitle = opts.title || 'Atris';
|
|
60
|
+
const accent = opts.accent || '.';
|
|
61
|
+
const outDir = opts.out || 'dist';
|
|
62
|
+
const files = collectMd(input);
|
|
63
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
64
|
+
const nav = { home: 'index.html', label: siteTitle, accent };
|
|
65
|
+
|
|
66
|
+
const pages = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const md = fs.readFileSync(file, 'utf8');
|
|
70
|
+
const spec = parseMarkdownToSpec(md, { theme });
|
|
71
|
+
if (!THEMES[spec.theme]) spec.theme = theme;
|
|
72
|
+
let slug = slugFor(file, input);
|
|
73
|
+
while (seen.has(slug)) slug += '-1';
|
|
74
|
+
seen.add(slug);
|
|
75
|
+
const title = firstHeading(md) || titleFromSlug(slug);
|
|
76
|
+
const summary = firstParagraph(md);
|
|
77
|
+
const outFile = path.join(outDir, slug + '.html');
|
|
78
|
+
fs.writeFileSync(outFile, renderHtml(spec, { title, nav, themes: THEMES }));
|
|
79
|
+
pages.push({ src: file, out: outFile, href: slug + '.html', title, summary });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const indexSpec = {
|
|
83
|
+
theme, brand: { name: siteTitle, accent },
|
|
84
|
+
slides: [
|
|
85
|
+
{ type: 'title', headline: opts.headline || `${siteTitle} **docs**`, sub: opts.sub || `${pages.length} page${pages.length === 1 ? '' : 's'}, one design system.` },
|
|
86
|
+
{ type: 'toc', heading: 'Pages', items: pages.map((p) => ({ href: p.href, title: p.title, summary: clip(p.summary, 120) })) },
|
|
87
|
+
{ type: 'close', tagline: opts.tagline || 'One workspace, one design system.', footer: siteTitle },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
const indexPath = path.join(outDir, 'index.html');
|
|
91
|
+
fs.writeFileSync(indexPath, renderHtml(indexSpec, { title: siteTitle, themes: THEMES }));
|
|
92
|
+
return { outDir, indexPath, pages };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
|
|
96
|
+
|
|
97
|
+
// minimal static preview server for a built site dir (no deps)
|
|
98
|
+
function serveSite(dir, port = 4321) {
|
|
99
|
+
const root = path.resolve(dir);
|
|
100
|
+
const server = http.createServer((req, res) => {
|
|
101
|
+
let rel = decodeURIComponent((req.url || '/').split('?')[0]);
|
|
102
|
+
if (rel.endsWith('/')) rel += 'index.html';
|
|
103
|
+
const file = path.normalize(path.join(root, rel));
|
|
104
|
+
if (!file.startsWith(root)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
105
|
+
fs.readFile(file, (err, data) => {
|
|
106
|
+
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; }
|
|
107
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
|
|
108
|
+
res.end(data);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
return new Promise((resolve) => server.listen(port, () => resolve({ server, url: `http://localhost:${port}` })));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { buildSite, serveSite, collectMd, slugFor, firstHeading, firstParagraph };
|
package/lib/slides-deck.js
CHANGED
|
@@ -220,8 +220,9 @@ const ARCHE = {
|
|
|
220
220
|
};
|
|
221
221
|
|
|
222
222
|
// ---------- public: spec -> requests ----------
|
|
223
|
-
function buildDeck(spec) {
|
|
224
|
-
const
|
|
223
|
+
function buildDeck(spec, opts = {}) {
|
|
224
|
+
const themes = opts.themes || THEMES;
|
|
225
|
+
const theme = themes[spec.theme] || THEMES.terminal;
|
|
225
226
|
const ctx = makeCtx(theme);
|
|
226
227
|
const slideIds = [];
|
|
227
228
|
(spec.slides || []).forEach((s, i) => {
|
package/lib/theme.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Brand themes — a project defines its own theme in .atris/theme.json and every
|
|
2
|
+
// renderer (deck, HTML, site, memory view) uses it. Compounds like slop rules:
|
|
3
|
+
// describe your brand once, every deck and page is on-brand.
|
|
4
|
+
//
|
|
5
|
+
// theme.json shapes (both accepted):
|
|
6
|
+
// { "color": { "accent": "#00A88F", "bg": "#0B1410" }, "fonts": { "display": "Fraunces" } }
|
|
7
|
+
// { "themes": { "acme": { "color": {...} }, "acme-light": { "color": {...} } } }
|
|
8
|
+
// Partial themes inherit every missing token from the base (a full warm-dark theme).
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const PROJECT_THEME_FILE = path.join('.atris', 'theme.json');
|
|
14
|
+
|
|
15
|
+
// full base so a project can define just an accent + bg and inherit the rest
|
|
16
|
+
const BASE = {
|
|
17
|
+
fonts: { display: 'Fraunces', body: 'Outfit', mono: 'ui-monospace, SFMono-Regular, monospace' },
|
|
18
|
+
color: {
|
|
19
|
+
bg: '#141110', panel: '#1E1915', panelAlt: '#2C2520', line: '#3D332D',
|
|
20
|
+
ink: '#EAE3D9', soft: '#A39B92', faint: '#7C736B',
|
|
21
|
+
accent: '#F59E0B', accent2: '#FBBF24', onAccent: '#141110',
|
|
22
|
+
sev: ['#F59E0B', '#FBBF24', '#7F97A4'],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const COLOR_ROLES = ['bg', 'panel', 'panelAlt', 'line', 'ink', 'soft', 'faint', 'accent', 'accent2', 'onAccent'];
|
|
27
|
+
|
|
28
|
+
function normalizeTheme(t, base = BASE) {
|
|
29
|
+
const src = t && typeof t === 'object' ? t : {};
|
|
30
|
+
return {
|
|
31
|
+
fonts: { ...base.fonts, ...(src.fonts || {}) },
|
|
32
|
+
color: { ...base.color, ...(src.color || {}), sev: (src.color && src.color.sev) || base.color.sev },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isValidThemeShape(t) {
|
|
37
|
+
if (!t || typeof t !== 'object') return false;
|
|
38
|
+
const hasColor = t.color && typeof t.color === 'object';
|
|
39
|
+
const hasFonts = t.fonts && typeof t.fonts === 'object';
|
|
40
|
+
return Boolean(hasColor || hasFonts);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// returns { name: normalizedTheme } from .atris/theme.json (or {} if none/bad)
|
|
44
|
+
function loadProjectThemes(root = process.cwd()) {
|
|
45
|
+
let raw;
|
|
46
|
+
try { raw = JSON.parse(fs.readFileSync(path.join(root, PROJECT_THEME_FILE), 'utf8')); }
|
|
47
|
+
catch { return {}; }
|
|
48
|
+
const out = {};
|
|
49
|
+
if (raw && raw.themes && typeof raw.themes === 'object') {
|
|
50
|
+
for (const [name, t] of Object.entries(raw.themes)) if (isValidThemeShape(t)) out[name] = normalizeTheme(t);
|
|
51
|
+
} else if (isValidThemeShape(raw)) {
|
|
52
|
+
out[raw.name || 'brand'] = normalizeTheme(raw);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// merge built-in themes with project themes (project wins on name collision)
|
|
58
|
+
function mergedThemes(builtin, root = process.cwd()) {
|
|
59
|
+
return { ...builtin, ...loadProjectThemes(root) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeStarterTheme(root = process.cwd()) {
|
|
63
|
+
const file = path.join(root, PROJECT_THEME_FILE);
|
|
64
|
+
if (fs.existsSync(file)) return { file, already: true };
|
|
65
|
+
const starter = {
|
|
66
|
+
name: 'brand',
|
|
67
|
+
color: { accent: '#00A88F', accent2: '#3FD0B6', bg: '#0B1410', ink: '#E8F0EC' },
|
|
68
|
+
fonts: { display: 'Fraunces', body: 'Outfit' },
|
|
69
|
+
};
|
|
70
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
71
|
+
fs.writeFileSync(file, JSON.stringify(starter, null, 2) + '\n');
|
|
72
|
+
return { file, already: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------- color math (WCAG contrast — the tasteful guardrail) ----------
|
|
76
|
+
function hexToRgb(hex) {
|
|
77
|
+
const h = String(hex == null ? '' : hex).trim().replace(/^#/, '');
|
|
78
|
+
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
|
79
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
|
80
|
+
return { r: parseInt(full.slice(0, 2), 16), g: parseInt(full.slice(2, 4), 16), b: parseInt(full.slice(4, 6), 16) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isHex(hex) { return hexToRgb(hex) != null; }
|
|
84
|
+
|
|
85
|
+
function normHex(hex) {
|
|
86
|
+
const c = hexToRgb(hex);
|
|
87
|
+
if (!c) return null;
|
|
88
|
+
return '#' + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function relLuminance(hex) {
|
|
92
|
+
const c = hexToRgb(hex);
|
|
93
|
+
if (!c) return 0;
|
|
94
|
+
const lin = [c.r, c.g, c.b].map((v) => {
|
|
95
|
+
const s = v / 255;
|
|
96
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
97
|
+
});
|
|
98
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function contrastRatio(a, b) {
|
|
102
|
+
const hi = Math.max(relLuminance(a), relLuminance(b));
|
|
103
|
+
const lo = Math.min(relLuminance(a), relLuminance(b));
|
|
104
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// the readable ink to drop on top of a solid color (near-black vs near-white)
|
|
108
|
+
function bestOnColor(hex, dark = '#141110', light = '#FFFFFF') {
|
|
109
|
+
return contrastRatio(hex, dark) >= contrastRatio(hex, light) ? dark : light;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// blend two hex colors, t in [0,1] toward `b`
|
|
113
|
+
function mix(a, b, t) {
|
|
114
|
+
const ca = hexToRgb(a), cb = hexToRgb(b);
|
|
115
|
+
if (!ca || !cb) return normHex(a) || '#000000';
|
|
116
|
+
const m = (x, y) => Math.round(x + (y - x) * t);
|
|
117
|
+
return '#' + [m(ca.r, cb.r), m(ca.g, cb.g), m(ca.b, cb.b)].map((v) => v.toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- the tasteful vocabulary (moods, fonts) used by `atris theme create` ----------
|
|
121
|
+
const FONT_PERSONALITIES = {
|
|
122
|
+
'editorial-serif': { display: 'Fraunces', body: 'Outfit', mono: 'IBM Plex Mono' },
|
|
123
|
+
'clean-sans': { display: 'Space Grotesk', body: 'Outfit', mono: 'IBM Plex Mono' },
|
|
124
|
+
'mono-forward': { display: 'Space Grotesk', body: 'IBM Plex Mono', mono: 'IBM Plex Mono' },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// each mood is hand-tuned neutral scaffolding + a font pairing + curated accents.
|
|
128
|
+
// the user picks a feeling; we keep the surface tasteful and let their accent lead.
|
|
129
|
+
const MOODS = {
|
|
130
|
+
editorial: {
|
|
131
|
+
blurb: 'warm, literary, considered — Fraunces headlines on calm paper',
|
|
132
|
+
defaultMode: 'dark', fonts: 'editorial-serif',
|
|
133
|
+
swatches: ['#B5572E', '#D98E5C', '#9E3B2E', '#C08552'],
|
|
134
|
+
modes: {
|
|
135
|
+
dark: { bg: '#1E1A16', panel: '#261F19', panelAlt: '#322820', line: '#443629', ink: '#EDE4D6', soft: '#B0A595', faint: '#837868' },
|
|
136
|
+
light: { bg: '#FBF8F2', panel: '#F2ECE0', panelAlt: '#E9E1D2', line: '#DCD2C0', ink: '#2A241D', soft: '#6B6256', faint: '#938A7C' },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
technical: {
|
|
140
|
+
blurb: 'cool, precise, engineered — Space Grotesk on slate, mono accents',
|
|
141
|
+
defaultMode: 'dark', fonts: 'clean-sans',
|
|
142
|
+
swatches: ['#3B82F6', '#06B6D4', '#6366F1', '#14B8A6'],
|
|
143
|
+
modes: {
|
|
144
|
+
dark: { bg: '#0E1116', panel: '#161B22', panelAlt: '#1C2330', line: '#2A3340', ink: '#E6EDF3', soft: '#9DA7B3', faint: '#6B7682' },
|
|
145
|
+
light: { bg: '#F7F9FB', panel: '#EEF2F6', panelAlt: '#E3E9F0', line: '#D2DAE3', ink: '#1A2230', soft: '#5A6675', faint: '#8A95A3' },
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
noir: {
|
|
149
|
+
blurb: 'high-contrast, near-black, one electric accent — confident and stark',
|
|
150
|
+
defaultMode: 'dark', forceMode: 'dark', fonts: 'clean-sans',
|
|
151
|
+
swatches: ['#C8FF00', '#FF2D78', '#FFB000', '#7DD3FC'],
|
|
152
|
+
modes: {
|
|
153
|
+
dark: { bg: '#0A0A0B', panel: '#131316', panelAlt: '#1B1B20', line: '#2A2A30', ink: '#F4F4F5', soft: '#A1A1AA', faint: '#6E6E76' },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
paper: {
|
|
157
|
+
blurb: 'calm, light, low-key — ink on cream, nothing shouting',
|
|
158
|
+
defaultMode: 'light', forceMode: 'light', fonts: 'editorial-serif',
|
|
159
|
+
swatches: ['#2F4858', '#5B7553', '#B5572E', '#6B4E71'],
|
|
160
|
+
modes: {
|
|
161
|
+
light: { bg: '#FBF8F2', panel: '#F2ECE0', panelAlt: '#E9E1D2', line: '#DCD2C0', ink: '#2A241D', soft: '#6B6256', faint: '#938A7C' },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
vivid: {
|
|
165
|
+
blurb: 'saturated, playful, energetic — a bold accent that carries the page',
|
|
166
|
+
defaultMode: 'dark', fonts: 'clean-sans',
|
|
167
|
+
swatches: ['#FF5C5C', '#8B5CF6', '#10B981', '#F59E0B'],
|
|
168
|
+
modes: {
|
|
169
|
+
dark: { bg: '#121016', panel: '#1B1822', panelAlt: '#251F30', line: '#352C42', ink: '#F2EEF7', soft: '#ADA3BC', faint: '#7D7388' },
|
|
170
|
+
light: { bg: '#FCFAFF', panel: '#F3EFF9', panelAlt: '#E9E2F2', line: '#DAD0E6', ink: '#221B2C', soft: '#645A72', faint: '#938A9F' },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const MOOD_NAMES = Object.keys(MOODS);
|
|
176
|
+
|
|
177
|
+
function resolveFonts(spec, mood) {
|
|
178
|
+
const m = MOODS[mood] || MOODS.editorial;
|
|
179
|
+
if (spec && typeof spec === 'object') return { ...FONT_PERSONALITIES[m.fonts], ...spec };
|
|
180
|
+
const key = spec && FONT_PERSONALITIES[spec] ? spec : m.fonts;
|
|
181
|
+
return { ...FONT_PERSONALITIES[key] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveMode(mood, mode) {
|
|
185
|
+
const m = MOODS[mood] || MOODS.editorial;
|
|
186
|
+
if (m.forceMode) return m.forceMode;
|
|
187
|
+
if ((mode === 'light' || mode === 'dark') && m.modes[mode]) return mode;
|
|
188
|
+
return m.defaultMode;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// pure: tasteful answers -> a full, normalized theme. The heart of `atris theme create`.
|
|
192
|
+
// answers = { mood, mode?, accent?, accent2?, fonts? }
|
|
193
|
+
function buildTheme(answers = {}) {
|
|
194
|
+
const moodKey = MOODS[answers.mood] ? answers.mood : 'editorial';
|
|
195
|
+
const mood = MOODS[moodKey];
|
|
196
|
+
const mode = resolveMode(moodKey, answers.mode);
|
|
197
|
+
const surface = mood.modes[mode];
|
|
198
|
+
|
|
199
|
+
const accent = normHex(answers.accent) || mood.swatches[0];
|
|
200
|
+
const accent2 = normHex(answers.accent2) || mix(accent, surface.ink, 0.28);
|
|
201
|
+
const onAccent = bestOnColor(accent);
|
|
202
|
+
const fonts = resolveFonts(answers.fonts, moodKey);
|
|
203
|
+
|
|
204
|
+
return normalizeTheme({
|
|
205
|
+
color: { ...surface, accent, accent2, onAccent, sev: [accent, accent2, surface.faint] },
|
|
206
|
+
fonts,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// readability guardrail — human messages for low-contrast choices (empty array = clean)
|
|
211
|
+
function themeWarnings(theme) {
|
|
212
|
+
const c = normalizeTheme(theme).color;
|
|
213
|
+
const warn = [];
|
|
214
|
+
const body = contrastRatio(c.ink, c.bg);
|
|
215
|
+
if (body < 4.5) warn.push(`body text (ink on bg) is ${body.toFixed(1)}:1 — below the 4.5:1 readable floor`);
|
|
216
|
+
const onAcc = contrastRatio(c.onAccent, c.accent);
|
|
217
|
+
if (onAcc < 4.5) warn.push(`text on the accent is ${onAcc.toFixed(1)}:1 — below 4.5:1`);
|
|
218
|
+
const accentBg = contrastRatio(c.accent, c.bg);
|
|
219
|
+
if (accentBg < 2.0) warn.push(`the accent barely separates from the background (${accentBg.toFixed(1)}:1) — pick a bolder color`);
|
|
220
|
+
return warn;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// write/replace a named theme in .atris/theme.json (always the themes-map form),
|
|
224
|
+
// preserving every other theme. Throws (code EBADTHEME) if an existing file is present
|
|
225
|
+
// but unparseable, so we never clobber a hand-edited brand file.
|
|
226
|
+
function upsertProjectTheme(name, theme, root = process.cwd(), recipe = null) {
|
|
227
|
+
const themeName = (String(name == null ? '' : name).trim()) || 'brand';
|
|
228
|
+
const file = path.join(root, PROJECT_THEME_FILE);
|
|
229
|
+
let themes = {};
|
|
230
|
+
if (fs.existsSync(file)) {
|
|
231
|
+
let raw;
|
|
232
|
+
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
233
|
+
catch { const e = new Error(`${PROJECT_THEME_FILE} is not valid JSON — fix or remove it before saving`); e.code = 'EBADTHEME'; throw e; }
|
|
234
|
+
if (raw && raw.themes && typeof raw.themes === 'object') themes = { ...raw.themes };
|
|
235
|
+
else if (isValidThemeShape(raw)) themes[raw.name || 'brand'] = { name: raw.name, color: raw.color, fonts: raw.fonts };
|
|
236
|
+
}
|
|
237
|
+
const existed = Object.prototype.hasOwnProperty.call(themes, themeName);
|
|
238
|
+
const t = normalizeTheme(theme);
|
|
239
|
+
// recipe = the high-level answers (mood/mode/accent); inert to renderers, lets `theme edit` re-seed
|
|
240
|
+
const record = { name: themeName };
|
|
241
|
+
if (recipe && typeof recipe === 'object') record.recipe = recipe;
|
|
242
|
+
record.color = t.color; record.fonts = t.fonts;
|
|
243
|
+
themes[themeName] = record;
|
|
244
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
245
|
+
fs.writeFileSync(file, JSON.stringify({ themes }, null, 2) + '\n');
|
|
246
|
+
return { file, name: themeName, action: existed ? 'updated' : 'created', count: Object.keys(themes).length };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// the saved high-level answers for a theme, if present (for `theme edit` pre-fill)
|
|
250
|
+
function loadThemeRecipe(name, root = process.cwd()) {
|
|
251
|
+
let raw;
|
|
252
|
+
try { raw = JSON.parse(fs.readFileSync(path.join(root, PROJECT_THEME_FILE), 'utf8')); }
|
|
253
|
+
catch { return null; }
|
|
254
|
+
const rec = raw && raw.themes && raw.themes[name];
|
|
255
|
+
return rec && rec.recipe && typeof rec.recipe === 'object' ? rec.recipe : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = {
|
|
259
|
+
BASE, COLOR_ROLES, normalizeTheme, isValidThemeShape, loadProjectThemes, mergedThemes,
|
|
260
|
+
writeStarterTheme, PROJECT_THEME_FILE,
|
|
261
|
+
hexToRgb, isHex, normHex, relLuminance, contrastRatio, bestOnColor, mix,
|
|
262
|
+
FONT_PERSONALITIES, MOODS, MOOD_NAMES, resolveFonts, resolveMode,
|
|
263
|
+
buildTheme, themeWarnings, upsertProjectTheme, loadThemeRecipe,
|
|
264
|
+
};
|