atris 3.30.0 → 3.30.2
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/AGENTS.md +6 -0
- package/atris/AGENTS.md +11 -0
- package/atris/CLAUDE.md +5 -0
- package/atris/atris.md +19 -4
- package/atris/policies/atris-design.md +71 -0
- package/atris/policies/design-seed.md +187 -0
- package/atris/skills/atris/SKILL.md +26 -3
- package/atris/skills/design/SKILL.md +3 -2
- package/atris/skills/loop/SKILL.md +5 -3
- package/atris/team/_template/MEMBER.md +19 -0
- package/atris.md +63 -23
- package/ax +1434 -1698
- package/bin/atris.js +5 -1
- package/commands/agent-spawn.js +2 -2
- package/commands/brain.js +92 -7
- package/commands/brainstorm.js +62 -22
- package/commands/business-sync.js +92 -8
- package/commands/business.js +13 -7
- package/commands/chat-scan.js +102 -0
- package/commands/computer.js +758 -15
- package/commands/deck.js +505 -105
- package/commands/init.js +6 -1
- package/commands/launchpad.js +638 -0
- package/commands/log-sync.js +44 -13
- package/commands/loop-front.js +290 -0
- package/commands/member.js +124 -3
- package/commands/mission.js +653 -30
- package/commands/pull.js +37 -28
- package/commands/pulse.js +11 -8
- package/commands/run.js +79 -39
- package/commands/sync.js +36 -17
- package/commands/task.js +1072 -89
- package/commands/workflow.js +6 -6
- package/commands/xp.js +13 -1
- package/commands/youtube.js +221 -7
- package/decks/README.md +89 -0
- package/decks/archetype-catalog.json +180 -0
- package/decks/atris-antislop-pitch.json +48 -0
- package/decks/atris-archetypes-v2.json +83 -0
- package/decks/atris-one-loop-pitch.json +80 -0
- package/decks/atris-seed-pitch-v3.json +118 -0
- package/decks/atris-seed-pitch-v4-skeleton.json +106 -0
- package/decks/atris-seed-pitch-v5.json +109 -0
- package/decks/atris-seed-pitch-v6.json +137 -0
- package/decks/atris-seed-pitch-v7.json +133 -0
- package/decks/atris-single-shot-proof.json +74 -0
- package/decks/mark-pincus-narrative.json +102 -0
- package/decks/mark-pincus-sourcery.json +94 -0
- package/decks/yash-applied-compute-detailed.json +150 -0
- package/decks/yash-applied-compute-generalist.json +82 -0
- package/decks/yash-applied-compute-narrative.json +54 -0
- package/lib/ax-prefs.js +5 -12
- package/lib/chat-log-scan.js +377 -0
- package/lib/context-gatherer.js +35 -1
- package/lib/deck-compose.js +145 -0
- package/lib/deck-history.js +64 -0
- package/lib/deck-layout.js +169 -0
- package/lib/deck-review.js +431 -0
- package/lib/deck-schema.js +154 -0
- package/lib/file-ops.js +3 -3
- package/lib/functional-owner.js +189 -0
- package/lib/journal.js +7 -73
- package/lib/slides-deck.js +512 -58
- package/lib/task-db.js +128 -11
- package/package.json +2 -1
- package/templates/business-starter/team/START_HERE.md +12 -8
- package/utils/auth.js +4 -0
- package/utils/config.js +4 -0
- package/atris/atrisDev.md +0 -717
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Layout planner — map content shapes to slide archetypes deterministically.
|
|
2
|
+
//
|
|
3
|
+
// Agents (and `atris deck compose`) describe a deck as an ordered list of
|
|
4
|
+
// SECTIONS, each tagged with a `kind` (cover, quote, points, compare, metrics,
|
|
5
|
+
// process, proof, story, stat, close, ...). planLayout() turns those into a
|
|
6
|
+
// valid spec.slides, choosing the archetype by kind and style, while enforcing
|
|
7
|
+
// the taste rules the lint only warns about:
|
|
8
|
+
// - narrative style avoids boxed widgets entirely (downgrades to typography)
|
|
9
|
+
// - versus/receipt appear at most once per deck
|
|
10
|
+
// - total boxed slides stay under the template-fatigue threshold
|
|
11
|
+
// So compose output passes lint without manual editing.
|
|
12
|
+
|
|
13
|
+
const BOXED = new Set(['panel', 'receipt', 'versus', 'metricgrid', 'stack', 'chips']);
|
|
14
|
+
const ONCE = new Set(['versus', 'receipt']); // at most one of each per deck
|
|
15
|
+
const BOXED_BUDGET = 3; // stay under the lint's template-fatigue threshold (4)
|
|
16
|
+
|
|
17
|
+
// When a boxed type is not allowed (narrative style, budget hit, already used),
|
|
18
|
+
// fall back to a typography-first archetype that carries the same content.
|
|
19
|
+
const DOWNGRADE = {
|
|
20
|
+
versus: 'columns',
|
|
21
|
+
metricgrid: 'bignumber',
|
|
22
|
+
stack: 'bullets',
|
|
23
|
+
receipt: 'bullets',
|
|
24
|
+
chips: 'bullets',
|
|
25
|
+
panel: 'bullets',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function preferredType(kind, section = {}, style = 'dense') {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case 'cover': return style === 'narrative' ? 'lede' : 'title';
|
|
31
|
+
case 'chapter':
|
|
32
|
+
case 'interstitial': return 'interstitial';
|
|
33
|
+
case 'quote': return 'quote';
|
|
34
|
+
case 'claim':
|
|
35
|
+
case 'statement': return 'statement';
|
|
36
|
+
case 'story':
|
|
37
|
+
case 'prose': return 'prose';
|
|
38
|
+
case 'split': return 'split';
|
|
39
|
+
case 'points':
|
|
40
|
+
case 'list':
|
|
41
|
+
case 'bullets': return 'bullets';
|
|
42
|
+
case 'columns': return 'columns';
|
|
43
|
+
case 'compare':
|
|
44
|
+
case 'versus': return 'versus';
|
|
45
|
+
case 'metrics': return (section.metrics && section.metrics.length >= 2) ? 'metricgrid' : 'bignumber';
|
|
46
|
+
case 'stat':
|
|
47
|
+
case 'number':
|
|
48
|
+
case 'bignumber': return 'bignumber';
|
|
49
|
+
case 'process':
|
|
50
|
+
case 'steps':
|
|
51
|
+
case 'timeline': return (section.steps && section.steps.length > 5) ? 'stack' : 'timeline';
|
|
52
|
+
case 'stack': return 'stack';
|
|
53
|
+
case 'proof':
|
|
54
|
+
case 'receipt': return 'receipt';
|
|
55
|
+
case 'chips': return 'chips';
|
|
56
|
+
case 'close':
|
|
57
|
+
case 'cta': return style === 'narrative' ? 'hero' : 'close';
|
|
58
|
+
case 'hero': return 'hero';
|
|
59
|
+
default: return 'statement';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Best-effort conversion of whatever content a section carries into a flat
|
|
64
|
+
// item list, for downgrades into the box-free `bullets` archetype.
|
|
65
|
+
function asItems(section) {
|
|
66
|
+
if (Array.isArray(section.items)) return section.items;
|
|
67
|
+
if (Array.isArray(section.layers)) return section.layers.map((l) => (l.sub ? { text: l.title || l.h, sub: l.sub } : (l.title || l.h)));
|
|
68
|
+
if (Array.isArray(section.fields)) return section.fields.map((f) => ({ text: f.k, sub: f.v }));
|
|
69
|
+
if (Array.isArray(section.steps)) return section.steps.map((s) => (s.sub ? { text: s.label, sub: s.sub } : s.label));
|
|
70
|
+
if (Array.isArray(section.columns)) return section.columns.map((c) => (c.b || c.body ? { text: c.h || c.title, sub: c.b || c.body } : (c.h || c.title)));
|
|
71
|
+
if (Array.isArray(section.chips)) return section.chips;
|
|
72
|
+
if (Array.isArray(section.metrics)) return section.metrics.map((m) => ({ text: m.value, sub: m.label }));
|
|
73
|
+
if (section.left && section.right) {
|
|
74
|
+
return [
|
|
75
|
+
{ text: section.left.label, sub: (section.left.items || []).join('. ') },
|
|
76
|
+
{ text: section.right.label, sub: (section.right.items || []).join('. ') },
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function asColumns(section) {
|
|
83
|
+
if (Array.isArray(section.columns)) return section.columns;
|
|
84
|
+
if (section.left && section.right) {
|
|
85
|
+
return [
|
|
86
|
+
{ h: section.left.label || 'Then', b: (section.left.items || []).join('. ') },
|
|
87
|
+
{ h: section.right.label || 'Now', b: (section.right.items || []).join('. ') },
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(section.items)) {
|
|
91
|
+
return section.items.slice(0, 4).map((it) => (typeof it === 'string' ? { h: it, b: '' } : { h: it.text || it.h, b: it.sub || it.b || '' }));
|
|
92
|
+
}
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function firstText(section, ...keys) {
|
|
97
|
+
for (const k of keys) {
|
|
98
|
+
if (section[k] != null && String(section[k]).length) return section[k];
|
|
99
|
+
}
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mapToSlide(type, section) {
|
|
104
|
+
const heading = firstText(section, 'heading', 'title');
|
|
105
|
+
switch (type) {
|
|
106
|
+
case 'title': return { type, headline: firstText(section, 'headline', 'text', 'heading'), ...(section.sub ? { sub: section.sub } : {}), ...(section.panel ? { panel: section.panel } : {}) };
|
|
107
|
+
case 'lede': return { type, headline: firstText(section, 'headline', 'text', 'heading'), ...(section.dek || section.sub ? { dek: section.dek || section.sub } : {}), ...(section.byline ? { byline: section.byline } : {}) };
|
|
108
|
+
case 'interstitial': return { type, ...(section.kicker ? { kicker: section.kicker } : {}), text: firstText(section, 'text', 'heading', 'headline'), ...(section.sub ? { sub: section.sub } : {}), ...(section.align ? { align: section.align } : {}) };
|
|
109
|
+
case 'statement': return { type, text: firstText(section, 'text', 'heading', 'headline'), ...(section.sub ? { sub: section.sub } : {}) };
|
|
110
|
+
case 'quote': return { type, text: (section.quote && section.quote.text) || firstText(section, 'text'), ...((section.quote && section.quote.author) || section.author ? { author: (section.quote && section.quote.author) || section.author } : {}), ...((section.quote && section.quote.role) || section.role ? { role: (section.quote && section.quote.role) || section.role } : {}) };
|
|
111
|
+
case 'prose': return { type, ...(heading ? { heading } : {}), paragraphs: Array.isArray(section.paragraphs) ? section.paragraphs.slice(0, 3) : (section.text ? [section.text] : []) };
|
|
112
|
+
case 'split': return { type, ...(heading ? { heading } : {}), body: firstText(section, 'body', 'text'), ...(section.accent ? { accent: section.accent } : {}), ...(section.accentSub ? { accentSub: section.accentSub } : {}) };
|
|
113
|
+
case 'bullets': {
|
|
114
|
+
const items = asItems(section).slice(0, 7); // cap to the engine's bullet limit so planned output stays lint-clean
|
|
115
|
+
if (!items.length) return { type: 'statement', text: firstText(section, 'text', 'heading', 'headline') || 'Untitled' };
|
|
116
|
+
return { type, ...(heading ? { heading } : {}), items, ...(section.sub ? { sub: section.sub } : {}) };
|
|
117
|
+
}
|
|
118
|
+
case 'columns': return { type, ...(heading ? { heading } : {}), columns: asColumns(section).slice(0, 4) };
|
|
119
|
+
case 'versus': return { type, ...(heading ? { heading } : {}), left: section.left || { label: '', items: [] }, right: section.right || { label: '', items: [] } };
|
|
120
|
+
case 'metricgrid': return { type, ...(heading ? { heading } : {}), metrics: (section.metrics || []).slice(0, 4) };
|
|
121
|
+
case 'bignumber': {
|
|
122
|
+
const m = (section.metrics || [])[0] || {};
|
|
123
|
+
return { type, number: firstText(section, 'number', 'value') || m.value || '', ...(section.label || m.label ? { label: section.label || m.label } : {}), ...(section.sub ? { sub: section.sub } : {}) };
|
|
124
|
+
}
|
|
125
|
+
case 'timeline': return { type, ...(heading ? { heading } : {}), steps: (section.steps || []).slice(0, 5), ...(section.sub ? { sub: section.sub } : {}) };
|
|
126
|
+
case 'stack': {
|
|
127
|
+
const layers = Array.isArray(section.layers) ? section.layers : asItems(section).map((it) => (typeof it === 'string' ? { title: it } : { title: it.text, sub: it.sub }));
|
|
128
|
+
return { type, ...(heading ? { heading } : {}), layers: layers.slice(0, 4), ...(section.sub ? { sub: section.sub } : {}) };
|
|
129
|
+
}
|
|
130
|
+
case 'receipt': return { type, ...(heading ? { heading } : {}), ...(section.sub ? { sub: section.sub } : {}), ...(section.receiptTitle ? { receiptTitle: section.receiptTitle } : {}), fields: (section.fields || []).slice(0, 6), ...(section.stamp ? { stamp: section.stamp } : {}) };
|
|
131
|
+
case 'chips': return { type, ...(heading ? { heading } : {}), chips: section.chips || [], ...(section.mono ? { mono: section.mono } : {}) };
|
|
132
|
+
case 'hero': return { type, headline: firstText(section, 'headline', 'text', 'tagline'), ...(section.sub ? { sub: section.sub } : {}), ...(section.mono ? { mono: section.mono } : {}), ...(section.footer ? { footer: section.footer } : {}) };
|
|
133
|
+
case 'close': return { type, tagline: firstText(section, 'tagline', 'text'), ...(section.footer ? { footer: section.footer } : {}), ...(section.buttons ? { buttons: section.buttons } : {}) };
|
|
134
|
+
default: return { type: 'statement', text: firstText(section, 'text', 'heading', 'headline') || 'Untitled' };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Decide the final type for one section given style + running budget state.
|
|
139
|
+
function chooseType(section, style, state) {
|
|
140
|
+
let type = section.type || preferredType(section.kind, section, style);
|
|
141
|
+
if (style === 'narrative' && BOXED.has(type) && DOWNGRADE[type]) type = DOWNGRADE[type];
|
|
142
|
+
if (ONCE.has(type) && state.usedOnce.has(type) && DOWNGRADE[type]) type = DOWNGRADE[type];
|
|
143
|
+
if (BOXED.has(type) && state.boxed >= state.boxedBudget && DOWNGRADE[type]) type = DOWNGRADE[type];
|
|
144
|
+
if (ONCE.has(type)) state.usedOnce.add(type);
|
|
145
|
+
// a title with a data panel counts as boxed for fatigue, matching lintSpec
|
|
146
|
+
if (BOXED.has(type) || (type === 'title' && section.panel)) state.boxed += 1;
|
|
147
|
+
return type;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function planLayout(sections, opts = {}) {
|
|
151
|
+
const style = opts.style || 'dense';
|
|
152
|
+
const state = {
|
|
153
|
+
style,
|
|
154
|
+
boxedBudget: opts.boxedBudget != null ? opts.boxedBudget : BOXED_BUDGET,
|
|
155
|
+
usedOnce: new Set(),
|
|
156
|
+
boxed: 0,
|
|
157
|
+
};
|
|
158
|
+
return (sections || []).map((section) => mapToSlide(chooseType(section, style, state), section));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
BOXED,
|
|
163
|
+
ONCE,
|
|
164
|
+
BOXED_BUDGET,
|
|
165
|
+
DOWNGRADE,
|
|
166
|
+
preferredType,
|
|
167
|
+
mapToSlide,
|
|
168
|
+
planLayout,
|
|
169
|
+
};
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// Deck review loop — build -> thumbnail fetch -> agent visual review -> confirm.
|
|
2
|
+
//
|
|
3
|
+
// Agents should not call a deck "ready" until thumbnails pass visual review.
|
|
4
|
+
// This module downloads slide PNGs via the Atris Google Slides API and writes
|
|
5
|
+
// a review manifest agents can read (including image paths for vision review).
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const SCHEMA = 'atris.deck_review.v1';
|
|
13
|
+
const RECEIPT_SCHEMA = 'atris.deck_receipt.v1';
|
|
14
|
+
const DEFAULT_OUT_ROOT = path.join(os.homedir(), '.atris', 'deck-review');
|
|
15
|
+
const DEFAULT_RECEIPTS_PATH = path.join(os.homedir(), '.atris', 'deck-receipts.jsonl');
|
|
16
|
+
|
|
17
|
+
const AGENT_CHECKLIST = [
|
|
18
|
+
'Heading and sub do not overlap',
|
|
19
|
+
'Panel footers fully visible',
|
|
20
|
+
'No text clipped at slide edges',
|
|
21
|
+
'Column and chip wraps look intentional',
|
|
22
|
+
'No purple gradients, glass blur, or hype copy visible',
|
|
23
|
+
'No "it is not X, it is Y" contrast copy',
|
|
24
|
+
'Stack cards have no side accent bars',
|
|
25
|
+
'Avoid dashboard template fatigue (too many panel/receipt/versus/metricgrid slides)',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function slideCopyFields(slide) {
|
|
29
|
+
return [
|
|
30
|
+
slide.text,
|
|
31
|
+
slide.headline,
|
|
32
|
+
slide.heading,
|
|
33
|
+
slide.sub,
|
|
34
|
+
slide.tagline,
|
|
35
|
+
slide.label,
|
|
36
|
+
].filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasNotXCopy(text) {
|
|
40
|
+
const t = String(text || '');
|
|
41
|
+
if (/\bis not\b/i.test(t)) return true;
|
|
42
|
+
if (/\bnot\b[^.]{0,40}\.\s*it is\b/i.test(t)) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractPresentationId(input) {
|
|
47
|
+
const raw = String(input || '').trim();
|
|
48
|
+
const match = raw.match(/\/d\/([a-zA-Z0-9_-]+)/);
|
|
49
|
+
return match ? match[1] : raw;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function summarizeSlide(slide) {
|
|
53
|
+
if (!slide) return '';
|
|
54
|
+
return slide.headline || slide.text || slide.heading || slide.number || slide.type || '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const BOXED_TYPES = new Set(['panel', 'receipt', 'versus', 'metricgrid', 'stack', 'chips']);
|
|
58
|
+
|
|
59
|
+
// Clip thresholds. `warn` = check the thumbnail; `error` = it will clip or lose
|
|
60
|
+
// content, so block the build. `caps` mirror the .slice() limits the engine
|
|
61
|
+
// applies — exceeding one silently drops content, which is always an error.
|
|
62
|
+
// Pass `lintSpec(spec, { limits })` to override any of these.
|
|
63
|
+
const CLIP_LIMITS = {
|
|
64
|
+
statementSubWarn: 130,
|
|
65
|
+
statementSubError: 260,
|
|
66
|
+
columnBodyWarn: 110,
|
|
67
|
+
columnBodyError: 200,
|
|
68
|
+
panelHeadingWarn: 34,
|
|
69
|
+
panelHeadingError: 52,
|
|
70
|
+
caps: { columns: 4, panelRows: 4, metrics: 4, layers: 4, fields: 6, steps: 5, versusItems: 5, buttons: 3, bullets: 7 },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function lintSpec(spec, opts = {}) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
const L = { ...CLIP_LIMITS, ...(opts.limits || {}) };
|
|
76
|
+
L.caps = { ...CLIP_LIMITS.caps, ...((opts.limits && opts.limits.caps) || {}) };
|
|
77
|
+
const overflow = (slideNo, label, count, cap) => {
|
|
78
|
+
if (count > cap) {
|
|
79
|
+
findings.push({
|
|
80
|
+
severity: 'error',
|
|
81
|
+
slide: slideNo,
|
|
82
|
+
rule: 'content-truncated',
|
|
83
|
+
message: `${count} ${label} but only ${cap} render; ${count - cap} will be dropped — split the slide`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
let boxedCount = 0;
|
|
88
|
+
(spec.slides || []).forEach((slide, i) => {
|
|
89
|
+
const slideNo = i + 1;
|
|
90
|
+
if (BOXED_TYPES.has(slide.type) || (slide.type === 'title' && slide.panel)) {
|
|
91
|
+
boxedCount += 1;
|
|
92
|
+
}
|
|
93
|
+
slideCopyFields(slide).forEach((copy) => {
|
|
94
|
+
if (hasNotXCopy(copy)) {
|
|
95
|
+
findings.push({
|
|
96
|
+
severity: 'error',
|
|
97
|
+
slide: slideNo,
|
|
98
|
+
rule: 'not-x-contrast-copy',
|
|
99
|
+
message: 'Avoid "it is not X, it is Y" framing; state the positive claim directly',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (slide.type === 'panel') {
|
|
104
|
+
overflow(slideNo, 'panel rows', ((slide.panel && slide.panel.rows) || []).length, L.caps.panelRows);
|
|
105
|
+
const headingLen = (slide.heading || '').length;
|
|
106
|
+
if (headingLen > L.panelHeadingError) {
|
|
107
|
+
findings.push({
|
|
108
|
+
severity: 'error',
|
|
109
|
+
slide: slideNo,
|
|
110
|
+
rule: 'panel-heading-long',
|
|
111
|
+
message: 'Panel heading is too long; it will wrap into and overlap the sub',
|
|
112
|
+
});
|
|
113
|
+
} else if (headingLen > L.panelHeadingWarn) {
|
|
114
|
+
findings.push({
|
|
115
|
+
severity: 'warn',
|
|
116
|
+
slide: slideNo,
|
|
117
|
+
rule: 'panel-heading-long',
|
|
118
|
+
message: 'Panel heading may wrap and overlap the sub',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (slide.heading && slide.sub) {
|
|
122
|
+
findings.push({
|
|
123
|
+
severity: 'info',
|
|
124
|
+
slide: slideNo,
|
|
125
|
+
rule: 'panel-visual-review',
|
|
126
|
+
message: 'Panel slide: check thumbnail for heading/sub spacing',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const footerRight = slide.panel?.footer?.right || '';
|
|
130
|
+
if (footerRight.length > 12) {
|
|
131
|
+
findings.push({
|
|
132
|
+
severity: 'warn',
|
|
133
|
+
slide: slideNo,
|
|
134
|
+
rule: 'panel-footer-long',
|
|
135
|
+
message: 'Panel footer right text may clip; keep it short',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if ((slide.panel?.rows || []).length >= 4) {
|
|
139
|
+
findings.push({
|
|
140
|
+
severity: 'warn',
|
|
141
|
+
slide: slideNo,
|
|
142
|
+
rule: 'panel-many-rows',
|
|
143
|
+
message: 'Four-row panel: check footer clipping in thumbnail review',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (slide.type === 'statement') {
|
|
148
|
+
const subLen = (slide.sub || '').length;
|
|
149
|
+
if (subLen > L.statementSubError) {
|
|
150
|
+
findings.push({
|
|
151
|
+
severity: 'error',
|
|
152
|
+
slide: slideNo,
|
|
153
|
+
rule: 'statement-sub-long',
|
|
154
|
+
message: 'Statement sub is too long for the box and will clip; split it',
|
|
155
|
+
});
|
|
156
|
+
} else if (subLen > L.statementSubWarn) {
|
|
157
|
+
findings.push({
|
|
158
|
+
severity: 'warn',
|
|
159
|
+
slide: slideNo,
|
|
160
|
+
rule: 'statement-sub-long',
|
|
161
|
+
message: 'Long statement sub may clip; shorten or split',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (slide.type === 'columns') {
|
|
166
|
+
overflow(slideNo, 'columns', (slide.columns || []).length, L.caps.columns);
|
|
167
|
+
(slide.columns || []).forEach((col, j) => {
|
|
168
|
+
const bodyLen = (col.b || col.body || '').length;
|
|
169
|
+
if (bodyLen > L.columnBodyError) {
|
|
170
|
+
findings.push({
|
|
171
|
+
severity: 'error',
|
|
172
|
+
slide: slideNo,
|
|
173
|
+
rule: 'column-body-long',
|
|
174
|
+
message: `Column ${j + 1} body is too long and will clip; trim it`,
|
|
175
|
+
});
|
|
176
|
+
} else if (bodyLen > L.columnBodyWarn) {
|
|
177
|
+
findings.push({
|
|
178
|
+
severity: 'warn',
|
|
179
|
+
slide: slideNo,
|
|
180
|
+
rule: 'column-body-long',
|
|
181
|
+
message: `Column ${j + 1} body may clip`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (slide.type === 'metricgrid') {
|
|
187
|
+
overflow(slideNo, 'metrics', (slide.metrics || []).length, L.caps.metrics);
|
|
188
|
+
}
|
|
189
|
+
if (slide.type === 'receipt') {
|
|
190
|
+
overflow(slideNo, 'receipt fields', (slide.fields || []).length, L.caps.fields);
|
|
191
|
+
}
|
|
192
|
+
if (slide.type === 'close') {
|
|
193
|
+
overflow(slideNo, 'buttons', (slide.buttons || []).length, L.caps.buttons);
|
|
194
|
+
}
|
|
195
|
+
if (slide.type === 'versus') {
|
|
196
|
+
overflow(slideNo, 'left items', ((slide.left && slide.left.items) || []).length, L.caps.versusItems);
|
|
197
|
+
overflow(slideNo, 'right items', ((slide.right && slide.right.items) || []).length, L.caps.versusItems);
|
|
198
|
+
}
|
|
199
|
+
if (slide.type === 'bullets') {
|
|
200
|
+
overflow(slideNo, 'bullets', (slide.items || []).length, L.caps.bullets);
|
|
201
|
+
}
|
|
202
|
+
if (slide.type === 'chips' && (slide.chips || []).length > 4) {
|
|
203
|
+
findings.push({
|
|
204
|
+
severity: 'info',
|
|
205
|
+
slide: slideNo,
|
|
206
|
+
rule: 'chips-wrap-review',
|
|
207
|
+
message: 'Many chips: verify wrap spacing in thumbnail review',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (slide.type === 'timeline') {
|
|
211
|
+
overflow(slideNo, 'timeline steps', (slide.steps || []).length, L.caps.steps);
|
|
212
|
+
if ((slide.steps || []).length >= 5) {
|
|
213
|
+
findings.push({
|
|
214
|
+
severity: 'info',
|
|
215
|
+
slide: slideNo,
|
|
216
|
+
rule: 'timeline-edge-review',
|
|
217
|
+
message: 'Five-step timeline: check first/last labels in thumbnail review',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (slide.type === 'stack') {
|
|
222
|
+
const layerCount = (slide.layers || []).length;
|
|
223
|
+
overflow(slideNo, 'stack layers', layerCount, L.caps.layers);
|
|
224
|
+
// 4 full-height cards reach the slide floor; a sub below them clips.
|
|
225
|
+
if (layerCount >= L.caps.layers && slide.sub) {
|
|
226
|
+
findings.push({
|
|
227
|
+
severity: 'warn',
|
|
228
|
+
slide: slideNo,
|
|
229
|
+
rule: 'stack-sub-clip',
|
|
230
|
+
message: 'Four-layer stack plus a sub: the sub renders below the slide edge — drop a layer or the sub',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (layerCount >= 3) {
|
|
234
|
+
findings.push({
|
|
235
|
+
severity: 'info',
|
|
236
|
+
slide: slideNo,
|
|
237
|
+
rule: 'stack-layer-review',
|
|
238
|
+
message: 'Multi-layer stack: verify every layer title is readable',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (slide.type === 'hero' && (slide.mono || '').length > 52) {
|
|
243
|
+
findings.push({
|
|
244
|
+
severity: 'info',
|
|
245
|
+
slide: slideNo,
|
|
246
|
+
rule: 'hero-mono-long',
|
|
247
|
+
message: 'Long mono line: check wrapping in thumbnail review',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
if (boxedCount >= 4) {
|
|
252
|
+
findings.unshift({
|
|
253
|
+
severity: 'warn',
|
|
254
|
+
slide: 0,
|
|
255
|
+
rule: 'template-fatigue',
|
|
256
|
+
message: `${boxedCount} boxed slides — consider narrative types: interstitial, lede, prose, split, statement, bignumber`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return findings;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function downloadUrl(url, dest) {
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
const file = fs.createWriteStream(dest);
|
|
265
|
+
https.get(url, (res) => {
|
|
266
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
267
|
+
res.resume();
|
|
268
|
+
downloadUrl(res.headers.location, dest).then(resolve).catch(reject);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (res.statusCode >= 300) {
|
|
272
|
+
reject(new Error(`thumbnail download HTTP ${res.statusCode}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
res.pipe(file);
|
|
276
|
+
file.on('finish', () => file.close(() => resolve(dest)));
|
|
277
|
+
}).on('error', reject);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Cheap auto-pass over downloaded thumbnails: a missing or near-empty PNG means
|
|
282
|
+
// the slide failed to render (blank or API error), which a human reviewer would
|
|
283
|
+
// otherwise have to catch by eye. `vision` is an optional async hook
|
|
284
|
+
// (slide -> { ok, reason }) for deeper layout/overlap analysis later.
|
|
285
|
+
async function autoReviewSlides(slides, opts = {}) {
|
|
286
|
+
const minBytes = opts.minBytes != null ? opts.minBytes : 100;
|
|
287
|
+
const results = [];
|
|
288
|
+
for (const s of slides || []) {
|
|
289
|
+
let bytes = 0;
|
|
290
|
+
let ok = false;
|
|
291
|
+
let reason;
|
|
292
|
+
try { bytes = fs.statSync(s.thumbnail).size; } catch { bytes = 0; }
|
|
293
|
+
if (bytes === 0) reason = 'thumbnail missing or empty';
|
|
294
|
+
else if (bytes < minBytes) reason = `thumbnail only ${bytes}B (likely blank or an error image)`;
|
|
295
|
+
else { ok = true; reason = 'thumbnail present'; }
|
|
296
|
+
let vision = null;
|
|
297
|
+
if (ok && typeof opts.vision === 'function') {
|
|
298
|
+
try { vision = await opts.vision(s); } catch (e) { vision = { ok: true, reason: `vision skipped: ${e.message}` }; }
|
|
299
|
+
if (vision && vision.ok === false) { ok = false; reason = vision.reason || 'vision flagged a layout issue'; }
|
|
300
|
+
}
|
|
301
|
+
results.push({ index: s.index, type: s.type || null, ok, bytes, reason, ...(vision ? { vision } : {}) });
|
|
302
|
+
}
|
|
303
|
+
const failed = results.filter((r) => !r.ok);
|
|
304
|
+
return { ran: true, minBytes, passed: results.length - failed.length, failed: failed.length, results };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function reviewDeck({
|
|
308
|
+
presentationId,
|
|
309
|
+
spec = null,
|
|
310
|
+
specPath = null,
|
|
311
|
+
outRoot = DEFAULT_OUT_ROOT,
|
|
312
|
+
api,
|
|
313
|
+
token,
|
|
314
|
+
auto = false,
|
|
315
|
+
autoOpts = {},
|
|
316
|
+
}) {
|
|
317
|
+
if (!api || !token) throw new Error('reviewDeck requires api and token');
|
|
318
|
+
const id = extractPresentationId(presentationId);
|
|
319
|
+
const got = await api('GET', `/presentations/${id}`, null, token);
|
|
320
|
+
const slides = got.slides || (got.presentation && got.presentation.slides) || [];
|
|
321
|
+
const outDir = path.join(outRoot, id);
|
|
322
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
323
|
+
|
|
324
|
+
const specLint = spec ? lintSpec(spec) : [];
|
|
325
|
+
const slideReviews = [];
|
|
326
|
+
for (let i = 0; i < slides.length; i++) {
|
|
327
|
+
const pageId = slides[i].objectId;
|
|
328
|
+
const thumb = await api('GET', `/presentations/${id}/pages/${pageId}/thumbnail`, null, token);
|
|
329
|
+
const contentUrl = thumb.contentUrl;
|
|
330
|
+
if (!contentUrl) throw new Error(`missing thumbnail URL for ${pageId}`);
|
|
331
|
+
const file = path.join(outDir, `slide-${String(i + 1).padStart(2, '0')}.png`);
|
|
332
|
+
await downloadUrl(contentUrl, file);
|
|
333
|
+
slideReviews.push({
|
|
334
|
+
index: i + 1,
|
|
335
|
+
pageId,
|
|
336
|
+
type: (spec && spec.slides && spec.slides[i] && spec.slides[i].type) || null,
|
|
337
|
+
title: summarizeSlide(spec && spec.slides && spec.slides[i]),
|
|
338
|
+
thumbnail: file,
|
|
339
|
+
thumbnailUrl: contentUrl,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const autoReview = auto ? await autoReviewSlides(slideReviews, autoOpts) : null;
|
|
344
|
+
|
|
345
|
+
const packet = {
|
|
346
|
+
schema: SCHEMA,
|
|
347
|
+
presentationId: id,
|
|
348
|
+
url: `https://docs.google.com/presentation/d/${id}/edit`,
|
|
349
|
+
specPath: specPath || null,
|
|
350
|
+
generatedAt: new Date().toISOString(),
|
|
351
|
+
status: 'pending_visual_review',
|
|
352
|
+
specLint,
|
|
353
|
+
autoReview,
|
|
354
|
+
slides: slideReviews,
|
|
355
|
+
agentReview: {
|
|
356
|
+
instruction: 'Open each thumbnail and check layout before confirming the deck.',
|
|
357
|
+
checklist: AGENT_CHECKLIST,
|
|
358
|
+
},
|
|
359
|
+
ready: false,
|
|
360
|
+
confirmedAt: null,
|
|
361
|
+
confirmNote: null,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const manifestPath = path.join(outDir, 'review.json');
|
|
365
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(packet, null, 2)}\n`);
|
|
366
|
+
return { packet, manifestPath, outDir };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function loadReviewManifest(presentationId, outRoot = DEFAULT_OUT_ROOT) {
|
|
370
|
+
const id = extractPresentationId(presentationId);
|
|
371
|
+
const manifestPath = path.join(outRoot, id, 'review.json');
|
|
372
|
+
if (!fs.existsSync(manifestPath)) {
|
|
373
|
+
throw new Error(`review manifest not found: ${manifestPath}`);
|
|
374
|
+
}
|
|
375
|
+
return { manifestPath, packet: JSON.parse(fs.readFileSync(manifestPath, 'utf8')) };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Distilled, action-queue-ready artifact emitted when a deck passes review.
|
|
379
|
+
function buildReceipt(packet) {
|
|
380
|
+
return {
|
|
381
|
+
schema: RECEIPT_SCHEMA,
|
|
382
|
+
presentationId: packet.presentationId,
|
|
383
|
+
url: packet.url,
|
|
384
|
+
specPath: packet.specPath || null,
|
|
385
|
+
confirmedAt: packet.confirmedAt,
|
|
386
|
+
confirmNote: packet.confirmNote || null,
|
|
387
|
+
slideCount: (packet.slides || []).length,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function confirmReview(presentationId, note = 'visual review passed', outRoot = DEFAULT_OUT_ROOT, receiptsLedger = DEFAULT_RECEIPTS_PATH) {
|
|
392
|
+
const { manifestPath, packet } = loadReviewManifest(presentationId, outRoot);
|
|
393
|
+
if (packet.status === 'confirmed_visual_review') {
|
|
394
|
+
const receiptPath = path.join(path.dirname(manifestPath), 'receipt.json');
|
|
395
|
+
return { manifestPath, packet, alreadyConfirmed: true, receiptPath: fs.existsSync(receiptPath) ? receiptPath : null };
|
|
396
|
+
}
|
|
397
|
+
packet.status = 'confirmed_visual_review';
|
|
398
|
+
packet.ready = true;
|
|
399
|
+
packet.confirmedAt = new Date().toISOString();
|
|
400
|
+
packet.confirmNote = note;
|
|
401
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(packet, null, 2)}\n`);
|
|
402
|
+
|
|
403
|
+
// Emit a receipt: one next to the manifest for this deck, plus a line on the
|
|
404
|
+
// central ledger the send/action loop watches. Neither failure breaks confirm.
|
|
405
|
+
const receipt = buildReceipt(packet);
|
|
406
|
+
const receiptPath = path.join(path.dirname(manifestPath), 'receipt.json');
|
|
407
|
+
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
408
|
+
try {
|
|
409
|
+
fs.mkdirSync(path.dirname(receiptsLedger), { recursive: true });
|
|
410
|
+
fs.appendFileSync(receiptsLedger, `${JSON.stringify(receipt)}\n`);
|
|
411
|
+
} catch { /* ledger is best-effort */ }
|
|
412
|
+
return { manifestPath, packet, alreadyConfirmed: false, receiptPath, receipt };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
module.exports = {
|
|
416
|
+
SCHEMA,
|
|
417
|
+
RECEIPT_SCHEMA,
|
|
418
|
+
DEFAULT_OUT_ROOT,
|
|
419
|
+
DEFAULT_RECEIPTS_PATH,
|
|
420
|
+
AGENT_CHECKLIST,
|
|
421
|
+
CLIP_LIMITS,
|
|
422
|
+
buildReceipt,
|
|
423
|
+
extractPresentationId,
|
|
424
|
+
summarizeSlide,
|
|
425
|
+
lintSpec,
|
|
426
|
+
downloadUrl,
|
|
427
|
+
autoReviewSlides,
|
|
428
|
+
reviewDeck,
|
|
429
|
+
loadReviewManifest,
|
|
430
|
+
confirmReview,
|
|
431
|
+
};
|