atris 3.24.0 → 3.25.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/README.md +6 -6
- package/atris/atrisDev.md +717 -0
- package/atris/policies/outbound-artifact-gate.md +48 -0
- package/atris/skills/atris-feedback/SKILL.md +2 -3
- package/atris/wiki/sources/atris-labs-2026-05-10.txt +6 -9
- package/atris/wiki/sources/atris-labs-goals-2026-05-10.txt +4 -5
- package/atris.md +19 -43
- package/ax +1687 -95
- package/bin/atris.js +2 -38
- package/commands/aeo.js +5 -5
- package/commands/computer.js +0 -1
- package/commands/mission.js +2 -1
- package/commands/recap.js +0 -16
- package/commands/sync.js +2 -0
- package/commands/workflow.js +1 -2
- package/commands/youtube.js +183 -0
- package/lib/ax-chat-input.js +164 -0
- package/lib/ax-goal.js +307 -0
- package/lib/ax-prefs.js +70 -0
- package/lib/ax-shimmer.js +63 -0
- package/lib/context-gatherer.js +8 -26
- package/package.json +2 -1
- package/commands/card.js +0 -121
- package/commands/deck.js +0 -184
- package/commands/reel.js +0 -128
- package/commands/site.js +0 -48
- package/commands/slop.js +0 -307
- package/commands/theme.js +0 -217
- package/lib/card.js +0 -120
- package/lib/deck-from-md.js +0 -110
- package/lib/html-render.js +0 -257
- package/lib/memory-view.js +0 -95
- package/lib/reel.js +0 -52
- package/lib/site.js +0 -114
- package/lib/slides-deck.js +0 -237
- package/lib/theme.js +0 -264
package/lib/slides-deck.js
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
// Atris deck engine — turn a plain content spec into a premium, anti-slop
|
|
2
|
-
// Google Slides deck. Pure: spec -> batch-update requests (no network here).
|
|
3
|
-
//
|
|
4
|
-
// The product idea: PMs open Slides and get Arial-on-white slop by default.
|
|
5
|
-
// This engine gives them a described deck rendered in a committed design system
|
|
6
|
-
// (own backgrounds, distinctive fonts, one accent, real data panels). It is
|
|
7
|
-
// built so it CANNOT emit the usual AI tells: em dashes are sanitized, labels
|
|
8
|
-
// stay sentence case, no gradient text, no glassmorphism, one accent hue.
|
|
9
|
-
//
|
|
10
|
-
// Spec shape (see commands/deck.js for the CLI):
|
|
11
|
-
// { theme: 'terminal'|'paper',
|
|
12
|
-
// brand: { name: 'Sentinel', accent: '.' },
|
|
13
|
-
// slides: [ { type, ...fields } ] }
|
|
14
|
-
// Emphasis: wrap a phrase in **double asterisks** to render it in the accent.
|
|
15
|
-
|
|
16
|
-
// ---------- themes (OKLCH design system, flattened to sRGB hex) ----------
|
|
17
|
-
const THEMES = {
|
|
18
|
-
terminal: { // warm dark "premium terminal"
|
|
19
|
-
fonts: { display: 'Fraunces', body: 'Outfit', mono: 'Roboto Mono' },
|
|
20
|
-
color: { bg: '#1E1A16', panel: '#2A231C', panelAlt: '#2F271F', line: '#3A332B',
|
|
21
|
-
ink: '#ECE6DD', soft: '#BCB2A4', faint: '#968C7E',
|
|
22
|
-
accent: '#D98E5C', accent2: '#E3A06B', onAccent: '#1E1A16',
|
|
23
|
-
sev: ['#D98E5C', '#DBBE84', '#7F97A4'] },
|
|
24
|
-
},
|
|
25
|
-
paper: { // light "editorial paper instrument"
|
|
26
|
-
fonts: { display: 'Fraunces', body: 'Outfit', mono: 'Roboto Mono' },
|
|
27
|
-
color: { bg: '#FBF8F2', panel: '#FFFFFF', panelAlt: '#F4EEE4', line: '#E5DDCF',
|
|
28
|
-
ink: '#2B241B', soft: '#6B5F4F', faint: '#877B69',
|
|
29
|
-
accent: '#B5572E', accent2: '#9A4723', onAccent: '#FFFFFF',
|
|
30
|
-
sev: ['#B5572E', '#C0883A', '#5F7787'] },
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
const COLOR_ROLES = ['bg', 'panel', 'line', 'ink', 'soft', 'faint', 'accent', 'accent2', 'onAccent'];
|
|
34
|
-
|
|
35
|
-
const W = 720, H = 405, M = 48; // slide is 720 x 405 PT
|
|
36
|
-
|
|
37
|
-
// ---------- low-level builder ----------
|
|
38
|
-
function rgb(hex) { const n = parseInt(String(hex).slice(1), 16);
|
|
39
|
-
return { red: ((n >> 16) & 255) / 255, green: ((n >> 8) & 255) / 255, blue: (n & 255) / 255 }; }
|
|
40
|
-
|
|
41
|
-
// strip the AI tells the engine refuses to ship. Returns sanitized text.
|
|
42
|
-
function sanitize(t) {
|
|
43
|
-
return String(t == null ? '' : t)
|
|
44
|
-
.replace(/\s*[—]\s*/g, ', ') // em dash -> comma (top AI-writing tell)
|
|
45
|
-
.replace(/\s-\s/g, ', ') // spaced hyphen used as a dash
|
|
46
|
-
.replace(/\bAI-powered\b/gi, 'built for')
|
|
47
|
-
.replace(/\s{2,}/g, ' ');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// parse **emphasis** -> { plain, ranges:[{start,end}] } (indices into plain)
|
|
51
|
-
function parseEmph(text) {
|
|
52
|
-
const ranges = []; let plain = ''; let i = 0;
|
|
53
|
-
while (i < text.length) {
|
|
54
|
-
if (text[i] === '*' && text[i + 1] === '*') {
|
|
55
|
-
const close = text.indexOf('**', i + 2);
|
|
56
|
-
if (close !== -1) { const inner = text.slice(i + 2, close);
|
|
57
|
-
ranges.push({ start: plain.length, end: plain.length + inner.length });
|
|
58
|
-
plain += inner; i = close + 2; continue; }
|
|
59
|
-
}
|
|
60
|
-
plain += text[i]; i++;
|
|
61
|
-
}
|
|
62
|
-
return { plain, ranges };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function makeCtx(theme) {
|
|
66
|
-
const C = theme.color, F = theme.fonts, requests = [];
|
|
67
|
-
let uid = 0; const nid = (p) => `${p}_${String(++uid).padStart(4, '0')}`;
|
|
68
|
-
|
|
69
|
-
const createSlide = (id) => requests.push({ createSlide: { objectId: id, slideLayoutReference: { predefinedLayout: 'BLANK' } } });
|
|
70
|
-
const bg = (slide, hex) => requests.push({ updatePageProperties: { objectId: slide,
|
|
71
|
-
pageProperties: { pageBackgroundFill: { solidFill: { color: { rgbColor: rgb(hex) } } } },
|
|
72
|
-
fields: 'pageBackgroundFill.solidFill.color' } });
|
|
73
|
-
const shape = (type, slide, x, y, w, h) => { const id = nid(type.slice(0, 2).toLowerCase());
|
|
74
|
-
requests.push({ createShape: { objectId: id, shapeType: type, elementProperties: {
|
|
75
|
-
pageObjectId: slide, size: { width: { magnitude: w, unit: 'PT' }, height: { magnitude: h, unit: 'PT' } },
|
|
76
|
-
transform: { scaleX: 1, scaleY: 1, translateX: x, translateY: y, unit: 'PT' } } } }); return id; };
|
|
77
|
-
const fill = (id, hex, outlineHex, weight) => {
|
|
78
|
-
const props = { shapeBackgroundFill: { solidFill: { color: { rgbColor: rgb(hex) } } } };
|
|
79
|
-
let fields = 'shapeBackgroundFill.solidFill.color';
|
|
80
|
-
if (outlineHex) { props.outline = { outlineFill: { solidFill: { color: { rgbColor: rgb(outlineHex) } } }, weight: { magnitude: weight || 1, unit: 'PT' } }; fields += ',outline.outlineFill.solidFill.color,outline.weight'; }
|
|
81
|
-
else { props.outline = { propertyState: 'NOT_RENDERED' }; fields += ',outline.propertyState'; }
|
|
82
|
-
requests.push({ updateShapeProperties: { objectId: id, shapeProperties: props, fields } }); return id; };
|
|
83
|
-
function styleRange(id, s, e, o) { if (e <= s || !o) return;
|
|
84
|
-
const style = {}, f = [];
|
|
85
|
-
if (o.family) { style.fontFamily = o.family; f.push('fontFamily'); }
|
|
86
|
-
if (o.size) { style.fontSize = { magnitude: o.size, unit: 'PT' }; f.push('fontSize'); }
|
|
87
|
-
if (o.color) { style.foregroundColor = { opaqueColor: { rgbColor: rgb(o.color) } }; f.push('foregroundColor'); }
|
|
88
|
-
if (o.bold) { style.bold = true; f.push('bold'); }
|
|
89
|
-
if (o.italic) { style.italic = true; f.push('italic'); }
|
|
90
|
-
if (!f.length) return;
|
|
91
|
-
requests.push({ updateTextStyle: { objectId: id, textRange: { type: 'FIXED_RANGE', startIndex: s, endIndex: e }, style, fields: f.join(',') } }); }
|
|
92
|
-
function box(slide, x, y, w, h, markup, opts = {}) {
|
|
93
|
-
const { plain, ranges } = parseEmph(sanitize(markup));
|
|
94
|
-
if (!plain.length) return null;
|
|
95
|
-
const id = shape('TEXT_BOX', slide, x, y, w, h);
|
|
96
|
-
requests.push({ insertText: { objectId: id, text: plain } });
|
|
97
|
-
styleRange(id, 0, plain.length, opts);
|
|
98
|
-
const accentColor = opts.accent || C.accent2;
|
|
99
|
-
ranges.forEach((r) => styleRange(id, r.start, r.end, { family: opts.family, size: opts.size, color: accentColor, italic: opts.emphItalic }));
|
|
100
|
-
if (opts.align || opts.line != null) requests.push({ updateParagraphStyle: { objectId: id, textRange: { type: 'ALL' },
|
|
101
|
-
style: { ...(opts.align ? { alignment: opts.align } : {}), ...(opts.line != null ? { lineSpacing: opts.line } : {}) },
|
|
102
|
-
fields: [opts.align && 'alignment', opts.line != null && 'lineSpacing'].filter(Boolean).join(',') } });
|
|
103
|
-
if (opts.vmid) requests.push({ updateShapeProperties: { objectId: id, shapeProperties: { contentAlignment: 'MIDDLE' }, fields: 'contentAlignment' } });
|
|
104
|
-
return id; }
|
|
105
|
-
const rule = (slide, x, y, w, hex) => fill(shape('RECTANGLE', slide, x, y, w, 2), hex || C.accent);
|
|
106
|
-
|
|
107
|
-
function wordmark(slide, x, y, size, brand, center) {
|
|
108
|
-
const name = (brand && brand.name) || 'Atris';
|
|
109
|
-
const ac = (brand && brand.accent) || '';
|
|
110
|
-
const id = box(slide, x, y, center ? W - x * 2 : size * 9, size * 1.6, name + ac,
|
|
111
|
-
{ family: F.display, size, color: C.ink, align: center ? 'CENTER' : 'START' });
|
|
112
|
-
if (ac) styleRange(id, name.length, name.length + ac.length, { family: F.display, size, color: C.accent });
|
|
113
|
-
return id; }
|
|
114
|
-
|
|
115
|
-
// generalized data panel: header + rows + footer
|
|
116
|
-
function panel(slide, x, y, w, data) {
|
|
117
|
-
const rows = (data.rows || []).slice(0, 4);
|
|
118
|
-
const rowH = 38, headH = data.header ? 30 : 0, footH = data.footer ? 26 : 0;
|
|
119
|
-
const h = headH + rowH * rows.length + footH;
|
|
120
|
-
fill(shape('ROUND_RECTANGLE', slide, x, y, w, h), C.panel, C.line, 1);
|
|
121
|
-
if (data.header) {
|
|
122
|
-
box(slide, x + 14, y + 9, w * 0.6, 16, data.header.title || '', { family: F.body, size: 10.5, color: C.ink });
|
|
123
|
-
if (data.header.meta) box(slide, x + w - 120, y + 9, 106, 14, data.header.meta, { family: F.body, size: 8.5, color: C.faint, align: 'END' });
|
|
124
|
-
fill(shape('RECTANGLE', slide, x, y + headH, w, 0.75), C.line);
|
|
125
|
-
}
|
|
126
|
-
rows.forEach((r, i) => {
|
|
127
|
-
const ry = y + headH + i * rowH;
|
|
128
|
-
if (r.active) fill(shape('RECTANGLE', slide, x, ry, 2, rowH), C.accent);
|
|
129
|
-
const sev = C.sev[(r.sev != null ? r.sev : 0) % C.sev.length];
|
|
130
|
-
fill(shape('ELLIPSE', slide, x + 16, ry + rowH / 2 - 3.5, 7, 7), sev);
|
|
131
|
-
const nameTxt = sanitize(r.title || '') + (r.sub ? '\n' + sanitize(r.sub) : '');
|
|
132
|
-
const nb = box(slide, x + 30, ry + 6, w - 110, 28, nameTxt, { family: F.body, size: 11, color: C.ink, line: 108 });
|
|
133
|
-
if (r.sub && nb) styleRange(nb, sanitize(r.title || '').length + 1, nameTxt.length, { family: F.body, size: 9, color: C.faint });
|
|
134
|
-
if (r.value != null) {
|
|
135
|
-
const valTxt = String(r.value) + (r.valueSub ? '\n' + r.valueSub : '');
|
|
136
|
-
const bb = box(slide, x + w - 84, ry + 6, 70, 28, valTxt, { family: F.body, size: 11, color: C.ink, align: 'END' });
|
|
137
|
-
if (bb) { styleRange(bb, 0, String(r.value).length, { family: F.body, size: 13, color: C.ink, bold: true });
|
|
138
|
-
if (r.valueSub) styleRange(bb, String(r.value).length + 1, valTxt.length, { family: F.body, size: 8, color: C.faint }); }
|
|
139
|
-
}
|
|
140
|
-
if (i < rows.length - 1) fill(shape('RECTANGLE', slide, x, ry + rowH, w, 0.75), C.panelAlt);
|
|
141
|
-
});
|
|
142
|
-
if (data.footer) { const fy = y + headH + rowH * rows.length;
|
|
143
|
-
box(slide, x + 14, fy + 6, w * 0.62, 14, data.footer.left || '', { family: F.body, size: 9, color: C.faint });
|
|
144
|
-
if (data.footer.right) box(slide, x + w - 84, fy + 6, 70, 14, data.footer.right, { family: F.body, size: 9, color: C.accent2, align: 'END' }); }
|
|
145
|
-
return h; }
|
|
146
|
-
|
|
147
|
-
function chips(slide, x, y, list) { let cx = x;
|
|
148
|
-
(list || []).forEach((label) => { const t = sanitize(label); const w = 16 + t.length * 6.3;
|
|
149
|
-
fill(shape('ROUND_RECTANGLE', slide, cx, y, w, 24), C.panel, C.line, 1);
|
|
150
|
-
box(slide, cx, y, w, 24, t, { family: F.mono, size: 9.5, color: C.faint, align: 'CENTER', vmid: true });
|
|
151
|
-
cx += w + 10; }); }
|
|
152
|
-
|
|
153
|
-
function buttons(slide, y, list) {
|
|
154
|
-
const items = (list || []).slice(0, 3); const bw = 150, bh = 34, gap = 12;
|
|
155
|
-
const total = items.length * bw + (items.length - 1) * gap; let bx = (W - total) / 2;
|
|
156
|
-
items.forEach((b) => { const primary = b.primary;
|
|
157
|
-
fill(shape('ROUND_RECTANGLE', slide, bx, y, bw, bh), primary ? C.ink : C.bg, primary ? null : C.line, 1);
|
|
158
|
-
box(slide, bx, y, bw, bh, b.label || 'Button', { family: F.body, size: 12.5, color: primary ? C.bg : C.ink, align: 'CENTER', vmid: true });
|
|
159
|
-
bx += bw + gap; }); }
|
|
160
|
-
|
|
161
|
-
return { requests, C, F, nid, createSlide, bg, shape, fill, box, styleRange, rule, wordmark, panel, chips, buttons };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ---------- slide archetypes ----------
|
|
165
|
-
const ARCHE = {
|
|
166
|
-
title(ctx, slide, s, spec) {
|
|
167
|
-
ctx.wordmark(slide, M, 34, 15, spec.brand);
|
|
168
|
-
ctx.rule(slide, M, 96, 40);
|
|
169
|
-
const hasPanel = !!s.panel;
|
|
170
|
-
ctx.box(slide, M, 110, hasPanel ? 360 : 600, 180, s.headline || s.title || '',
|
|
171
|
-
{ family: ctx.F.display, size: 40, color: ctx.C.ink, line: 100, emphItalic: true });
|
|
172
|
-
if (s.sub) ctx.box(slide, M, hasPanel ? 300 : 300, hasPanel ? 350 : 540, 70, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 120 });
|
|
173
|
-
if (hasPanel) ctx.panel(slide, 432, 96, 240, s.panel);
|
|
174
|
-
},
|
|
175
|
-
statement(ctx, slide, s, spec) {
|
|
176
|
-
ctx.wordmark(slide, M, 34, 13, spec.brand);
|
|
177
|
-
ctx.rule(slide, M, 150, 40);
|
|
178
|
-
ctx.box(slide, M, 164, 600, 120, s.text || s.headline || '', { family: ctx.F.display, size: 46, color: ctx.C.ink, line: 100, emphItalic: true });
|
|
179
|
-
if (s.sub) ctx.box(slide, M, 286, 480, 70, s.sub, { family: ctx.F.body, size: 14, color: ctx.C.soft, line: 130 });
|
|
180
|
-
},
|
|
181
|
-
columns(ctx, slide, s, spec) {
|
|
182
|
-
ctx.wordmark(slide, M, 34, 13, spec.brand);
|
|
183
|
-
if (s.heading) ctx.box(slide, M, 110, 560, 34, s.heading, { family: ctx.F.display, size: 26, color: ctx.C.ink });
|
|
184
|
-
const cols = (s.columns || []).slice(0, 4); const n = cols.length || 1;
|
|
185
|
-
const span = W - M * 2, gap = 16, colW = (span - (n - 1) * gap) / n, cy = s.heading ? 188 : 150;
|
|
186
|
-
cols.forEach((c, i) => { const cx = M + i * (colW + gap);
|
|
187
|
-
if (i > 0) ctx.fill(ctx.shape('RECTANGLE', slide, cx - gap / 2, cy, 0.75, 120), ctx.C.line);
|
|
188
|
-
ctx.box(slide, cx, cy, colW - 8, 30, c.h || c.title || '', { family: ctx.F.display, size: 17, color: ctx.C.ink });
|
|
189
|
-
ctx.box(slide, cx, cy + 34, colW - 8, 120, c.b || c.body || '', { family: ctx.F.body, size: 11.5, color: ctx.C.soft, line: 134 }); });
|
|
190
|
-
},
|
|
191
|
-
panel(ctx, slide, s, spec) {
|
|
192
|
-
ctx.wordmark(slide, M, 34, 13, spec.brand);
|
|
193
|
-
if (s.heading) ctx.box(slide, M, 120, 260, 50, s.heading, { family: ctx.F.display, size: 30, color: ctx.C.ink });
|
|
194
|
-
if (s.sub) ctx.box(slide, M, 178, 260, 160, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 132 });
|
|
195
|
-
ctx.panel(slide, 360, 110, 312, s.panel || { rows: [] });
|
|
196
|
-
},
|
|
197
|
-
chips(ctx, slide, s, spec) {
|
|
198
|
-
ctx.wordmark(slide, M, 34, 13, spec.brand);
|
|
199
|
-
if (s.heading) ctx.box(slide, M, 110, 580, 34, s.heading, { family: ctx.F.display, size: 28, color: ctx.C.ink });
|
|
200
|
-
if (s.sub) ctx.box(slide, M, 162, 480, 64, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 132 });
|
|
201
|
-
ctx.chips(slide, M, 250, s.chips || []);
|
|
202
|
-
if (s.mono) ctx.box(slide, M, 300, 520, 24, s.mono, { family: ctx.F.mono, size: 12, color: ctx.C.accent2 });
|
|
203
|
-
},
|
|
204
|
-
bignumber(ctx, slide, s, spec) {
|
|
205
|
-
ctx.wordmark(slide, M, 34, 13, spec.brand);
|
|
206
|
-
ctx.box(slide, M, 138, W - M * 2, 120, s.number || s.value || '', { family: ctx.F.display, size: 92, color: ctx.C.accent2, line: 100 });
|
|
207
|
-
if (s.label) ctx.box(slide, M, 268, 520, 30, s.label, { family: ctx.F.body, size: 16, color: ctx.C.ink });
|
|
208
|
-
if (s.sub) ctx.box(slide, M, 300, 480, 50, s.sub, { family: ctx.F.body, size: 12.5, color: ctx.C.soft, line: 130 });
|
|
209
|
-
},
|
|
210
|
-
close(ctx, slide, s, spec) {
|
|
211
|
-
ctx.rule(slide, (W - 48) / 2, 150, 48);
|
|
212
|
-
const name = (spec.brand && spec.brand.name) || 'Atris';
|
|
213
|
-
const ac = (spec.brand && spec.brand.accent) || '';
|
|
214
|
-
const id = ctx.box(slide, 0, 168, W, 64, name + ac, { family: ctx.F.display, size: 52, color: ctx.C.ink, align: 'CENTER' });
|
|
215
|
-
if (ac && id) ctx.styleRange(id, name.length, name.length + ac.length, { family: ctx.F.display, size: 52, color: ctx.C.accent });
|
|
216
|
-
if (s.tagline) ctx.box(slide, 0, 244, W, 24, s.tagline, { family: ctx.F.body, size: 14, color: ctx.C.soft, align: 'CENTER' });
|
|
217
|
-
if (s.buttons) ctx.buttons(slide, 296, s.buttons);
|
|
218
|
-
if (s.footer) ctx.box(slide, 0, 360, W, 20, s.footer, { family: ctx.F.body, size: 10, color: ctx.C.faint, align: 'CENTER' });
|
|
219
|
-
},
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
// ---------- public: spec -> requests ----------
|
|
223
|
-
function buildDeck(spec, opts = {}) {
|
|
224
|
-
const themes = opts.themes || THEMES;
|
|
225
|
-
const theme = themes[spec.theme] || THEMES.terminal;
|
|
226
|
-
const ctx = makeCtx(theme);
|
|
227
|
-
const slideIds = [];
|
|
228
|
-
(spec.slides || []).forEach((s, i) => {
|
|
229
|
-
const sid = `deck_slide_${i + 1}`; slideIds.push(sid);
|
|
230
|
-
ctx.createSlide(sid);
|
|
231
|
-
ctx.bg(sid, theme.color.bg);
|
|
232
|
-
(ARCHE[s.type] || ARCHE.statement)(ctx, sid, s, spec);
|
|
233
|
-
});
|
|
234
|
-
return { requests: ctx.requests, slideIds };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
module.exports = { buildDeck, THEMES, ARCHE, sanitize, parseEmph, rgb, COLOR_ROLES };
|
package/lib/theme.js
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
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
|
-
};
|