saga-cli 0.1.0__py3-none-any.whl
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.
- saga/__init__.py +0 -0
- saga/__main__.py +4 -0
- saga/assets/base.css +41 -0
- saga/assets/saga.css +224 -0
- saga/assets/saga.js +459 -0
- saga/assets/tokens.css +141 -0
- saga/cli.py +100 -0
- saga/comments.py +258 -0
- saga/diff.py +79 -0
- saga/generate.py +182 -0
- saga/model.py +253 -0
- saga/prompts/saga.md +50 -0
- saga/render.py +144 -0
- saga_cli-0.1.0.dist-info/METADATA +158 -0
- saga_cli-0.1.0.dist-info/RECORD +18 -0
- saga_cli-0.1.0.dist-info/WHEEL +4 -0
- saga_cli-0.1.0.dist-info/entry_points.txt +2 -0
- saga_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
saga/assets/saga.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// PR saga: chapter-based review of one change set.
|
|
2
|
+
//
|
|
3
|
+
// Standalone static build: the saga payload is inlined into the page as
|
|
4
|
+
// window.__sagaData (verdict + chapters, each with its reconstructed
|
|
5
|
+
// diff). This file renders it — a table of contents (the entry point) and a
|
|
6
|
+
// chapter reader — tracks per-chapter mark-as-read in localStorage, and lets a
|
|
7
|
+
// reviewer leave inline / per-file / overall comments (also drafted in
|
|
8
|
+
// localStorage) that Export downloads as a saga.comments.json sidecar. No
|
|
9
|
+
// server, no fetch.
|
|
10
|
+
|
|
11
|
+
(function () {
|
|
12
|
+
const data = window.__sagaData || {chapters: [], verdict: null, branch: ''};
|
|
13
|
+
const slug = data.branch || 'saga';
|
|
14
|
+
const CONFIG = {
|
|
15
|
+
drawFileList: false, matching: 'lines',
|
|
16
|
+
outputFormat: 'line-by-line', highlight: true, colorScheme: 'dark',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let chapters = [];
|
|
20
|
+
let current = 0;
|
|
21
|
+
|
|
22
|
+
const $ = (id) => document.getElementById(id);
|
|
23
|
+
|
|
24
|
+
// --- theme (light/dark) --------------------------------------------
|
|
25
|
+
// Follows the OS unless the reader forces a choice via the header toggle,
|
|
26
|
+
// stored globally (not per-saga) so the preference sticks across files.
|
|
27
|
+
|
|
28
|
+
const THEME_KEY = 'saga-theme';
|
|
29
|
+
|
|
30
|
+
function effectiveTheme() {
|
|
31
|
+
const forced = document.documentElement.getAttribute('data-theme');
|
|
32
|
+
if (forced === 'light' || forced === 'dark') return forced;
|
|
33
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
|
|
34
|
+
? 'light' : 'dark';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setThemeIcon() {
|
|
38
|
+
const btn = $('saga-theme');
|
|
39
|
+
if (btn) btn.textContent = effectiveTheme() === 'dark' ? '☀' : '☾';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function initTheme() {
|
|
43
|
+
const btn = $('saga-theme');
|
|
44
|
+
if (!btn) return;
|
|
45
|
+
setThemeIcon();
|
|
46
|
+
btn.addEventListener('click', () => {
|
|
47
|
+
const next = effectiveTheme() === 'dark' ? 'light' : 'dark';
|
|
48
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
49
|
+
try { localStorage.setItem(THEME_KEY, next); } catch (e) {}
|
|
50
|
+
setThemeIcon();
|
|
51
|
+
// Re-draw the open chapter so diff2html's own colour-scheme class
|
|
52
|
+
// (and the residual diff colours our CSS doesn't override) match.
|
|
53
|
+
if (!$('saga-reader').hidden) openChapter(current);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function esc(s) {
|
|
58
|
+
return String(s == null ? '' : s)
|
|
59
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- mark-as-read (localStorage) -----------------------------------
|
|
63
|
+
|
|
64
|
+
function readKey() { return 'saga-read:' + slug; }
|
|
65
|
+
function readSet() {
|
|
66
|
+
try { return new Set(JSON.parse(localStorage.getItem(readKey()) || '[]')); }
|
|
67
|
+
catch (e) { return new Set(); }
|
|
68
|
+
}
|
|
69
|
+
function isRead(id) { return readSet().has(id); }
|
|
70
|
+
function setRead(id, read) {
|
|
71
|
+
const s = readSet();
|
|
72
|
+
if (read) s.add(id); else s.delete(id);
|
|
73
|
+
localStorage.setItem(readKey(), JSON.stringify([...s]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- comments (localStorage draft, exported as a sidecar) ----------
|
|
77
|
+
|
|
78
|
+
let comments = {files: {}, overall: ''};
|
|
79
|
+
|
|
80
|
+
function commentsKey() { return 'saga-comments:' + slug; }
|
|
81
|
+
function loadComments() {
|
|
82
|
+
try {
|
|
83
|
+
const c = JSON.parse(localStorage.getItem(commentsKey()) || '{}');
|
|
84
|
+
if (!c.files || typeof c.files !== 'object') c.files = {};
|
|
85
|
+
if (typeof c.overall !== 'string') c.overall = '';
|
|
86
|
+
return c;
|
|
87
|
+
} catch (e) { return {files: {}, overall: ''}; }
|
|
88
|
+
}
|
|
89
|
+
function persist() {
|
|
90
|
+
localStorage.setItem(commentsKey(), JSON.stringify(comments));
|
|
91
|
+
updateCount();
|
|
92
|
+
}
|
|
93
|
+
function fileEntry(path) {
|
|
94
|
+
if (!comments.files[path]) comments.files[path] = {file_comment: '', file_anchor: null, inline: []};
|
|
95
|
+
return comments.files[path];
|
|
96
|
+
}
|
|
97
|
+
function inlineFor(path, line, side) {
|
|
98
|
+
const e = comments.files[path];
|
|
99
|
+
if (!e || !e.inline) return [];
|
|
100
|
+
return e.inline.filter((c) => c.line === line && c.side === side);
|
|
101
|
+
}
|
|
102
|
+
function commentCount() {
|
|
103
|
+
let n = 0;
|
|
104
|
+
Object.values(comments.files).forEach((f) => {
|
|
105
|
+
n += (f.inline ? f.inline.length : 0);
|
|
106
|
+
if (f.file_comment && f.file_comment.trim()) n += 1;
|
|
107
|
+
});
|
|
108
|
+
if (comments.overall && comments.overall.trim()) n += 1;
|
|
109
|
+
return n;
|
|
110
|
+
}
|
|
111
|
+
function updateCount() {
|
|
112
|
+
const el = $('saga-cmt-count');
|
|
113
|
+
if (!el) return;
|
|
114
|
+
const n = commentCount();
|
|
115
|
+
el.textContent = n === 1 ? '1 comment' : n + ' comments';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read (file, line, side) from a diff2html line-by-line row. Added/context
|
|
119
|
+
// lines anchor to the new-file number (RIGHT); pure deletions to the old
|
|
120
|
+
// number (LEFT). Hunk-header / spacer rows have no number and return null.
|
|
121
|
+
function lineAnchor(tr) {
|
|
122
|
+
const cell = tr.querySelector('td.d2h-code-linenumber');
|
|
123
|
+
if (!cell) return null;
|
|
124
|
+
const n1 = cell.querySelector('.line-num1');
|
|
125
|
+
const n2 = cell.querySelector('.line-num2');
|
|
126
|
+
const l2 = n2 && n2.textContent.trim();
|
|
127
|
+
const l1 = n1 && n1.textContent.trim();
|
|
128
|
+
if (l2) return {line: parseInt(l2, 10), side: 'RIGHT'};
|
|
129
|
+
if (l1) return {line: parseInt(l1, 10), side: 'LEFT'};
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function firstAnchor(fw) {
|
|
133
|
+
let anchor = null;
|
|
134
|
+
fw.querySelectorAll('tr').forEach((tr) => {
|
|
135
|
+
if (!anchor) anchor = lineAnchor(tr);
|
|
136
|
+
});
|
|
137
|
+
return anchor || {line: 1, side: 'RIGHT'};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Render the comment thread + composer for one line into its cell.
|
|
141
|
+
function renderThread(td, path, line, side, row) {
|
|
142
|
+
td.innerHTML = '';
|
|
143
|
+
inlineFor(path, line, side).forEach((c) => {
|
|
144
|
+
const item = document.createElement('div');
|
|
145
|
+
item.className = 'saga-cmt';
|
|
146
|
+
const body = document.createElement('div');
|
|
147
|
+
body.className = 'saga-cmt-body';
|
|
148
|
+
body.innerHTML = renderMarkdown(c.body);
|
|
149
|
+
const del = document.createElement('button');
|
|
150
|
+
del.className = 'saga-cmt-del';
|
|
151
|
+
del.textContent = '✕';
|
|
152
|
+
del.title = 'Delete comment';
|
|
153
|
+
del.addEventListener('click', () => {
|
|
154
|
+
const e = fileEntry(path);
|
|
155
|
+
e.inline.splice(e.inline.indexOf(c), 1);
|
|
156
|
+
persist();
|
|
157
|
+
renderThread(td, path, line, side, row);
|
|
158
|
+
});
|
|
159
|
+
item.appendChild(body);
|
|
160
|
+
item.appendChild(del);
|
|
161
|
+
td.appendChild(item);
|
|
162
|
+
});
|
|
163
|
+
const composer = document.createElement('div');
|
|
164
|
+
composer.className = 'saga-cmt-composer';
|
|
165
|
+
const ta = document.createElement('textarea');
|
|
166
|
+
ta.className = 'saga-cmt-input';
|
|
167
|
+
ta.placeholder = 'Comment on line ' + line + ' (' + side + ')…';
|
|
168
|
+
const save = document.createElement('button');
|
|
169
|
+
save.className = 'saga-btn saga-cmt-save';
|
|
170
|
+
save.textContent = 'Comment';
|
|
171
|
+
save.addEventListener('click', () => {
|
|
172
|
+
const v = ta.value.trim();
|
|
173
|
+
if (!v) return;
|
|
174
|
+
fileEntry(path).inline.push({line: line, side: side, body: v});
|
|
175
|
+
persist();
|
|
176
|
+
renderThread(td, path, line, side, row);
|
|
177
|
+
});
|
|
178
|
+
const cancel = document.createElement('button');
|
|
179
|
+
cancel.className = 'saga-btn saga-cmt-cancel';
|
|
180
|
+
cancel.textContent = 'Cancel';
|
|
181
|
+
cancel.addEventListener('click', () => {
|
|
182
|
+
if (!inlineFor(path, line, side).length) row.remove();
|
|
183
|
+
else ta.value = '';
|
|
184
|
+
});
|
|
185
|
+
composer.appendChild(ta);
|
|
186
|
+
composer.appendChild(save);
|
|
187
|
+
composer.appendChild(cancel);
|
|
188
|
+
td.appendChild(composer);
|
|
189
|
+
return ta;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function insertComposerRow(tr, path, line, side) {
|
|
193
|
+
const next = tr.nextSibling;
|
|
194
|
+
if (next && next.classList && next.classList.contains('saga-cmt-row') &&
|
|
195
|
+
next.dataset.line === String(line) && next.dataset.side === side) {
|
|
196
|
+
const ta = next.querySelector('.saga-cmt-input');
|
|
197
|
+
if (ta) ta.focus();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const row = document.createElement('tr');
|
|
201
|
+
row.className = 'saga-cmt-row';
|
|
202
|
+
row.dataset.line = String(line);
|
|
203
|
+
row.dataset.side = side;
|
|
204
|
+
const td = document.createElement('td');
|
|
205
|
+
td.colSpan = tr.children.length;
|
|
206
|
+
td.className = 'saga-cmt-cell';
|
|
207
|
+
row.appendChild(td);
|
|
208
|
+
tr.parentNode.insertBefore(row, tr.nextSibling);
|
|
209
|
+
renderThread(td, path, line, side, row);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Per-file comment control injected into a diff2html file header.
|
|
213
|
+
function wireFileComment(fw, path) {
|
|
214
|
+
const header = fw.querySelector('.d2h-file-header');
|
|
215
|
+
if (!header) return;
|
|
216
|
+
const btn = document.createElement('button');
|
|
217
|
+
btn.className = 'saga-file-cmt-btn';
|
|
218
|
+
btn.textContent = '💬 File comment';
|
|
219
|
+
header.appendChild(btn);
|
|
220
|
+
const panel = document.createElement('div');
|
|
221
|
+
panel.className = 'saga-file-cmt-panel';
|
|
222
|
+
const ta = document.createElement('textarea');
|
|
223
|
+
ta.className = 'saga-cmt-input';
|
|
224
|
+
ta.placeholder = 'Comment on the whole file…';
|
|
225
|
+
const entry = comments.files[path];
|
|
226
|
+
ta.value = entry && entry.file_comment ? entry.file_comment : '';
|
|
227
|
+
const save = document.createElement('button');
|
|
228
|
+
save.className = 'saga-btn';
|
|
229
|
+
save.textContent = 'Save file comment';
|
|
230
|
+
save.addEventListener('click', () => {
|
|
231
|
+
const v = ta.value.trim();
|
|
232
|
+
const e = fileEntry(path);
|
|
233
|
+
e.file_comment = v;
|
|
234
|
+
if (v && !e.file_anchor) e.file_anchor = firstAnchor(fw);
|
|
235
|
+
if (!v) e.file_anchor = null;
|
|
236
|
+
persist();
|
|
237
|
+
});
|
|
238
|
+
panel.appendChild(ta);
|
|
239
|
+
panel.appendChild(save);
|
|
240
|
+
panel.hidden = !(entry && entry.file_comment);
|
|
241
|
+
header.parentNode.insertBefore(panel, header.nextSibling);
|
|
242
|
+
btn.addEventListener('click', () => { panel.hidden = !panel.hidden; });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// After a chapter's diff is drawn, make its lines and files commentable.
|
|
246
|
+
function wireComments(container) {
|
|
247
|
+
container.querySelectorAll('.d2h-file-wrapper').forEach((fw) => {
|
|
248
|
+
const nameEl = fw.querySelector('.d2h-file-name');
|
|
249
|
+
const path = nameEl ? nameEl.textContent.trim() : '';
|
|
250
|
+
if (!path) return;
|
|
251
|
+
wireFileComment(fw, path);
|
|
252
|
+
fw.querySelectorAll('tr').forEach((tr) => {
|
|
253
|
+
const lnCell = tr.querySelector('td.d2h-code-linenumber');
|
|
254
|
+
if (!lnCell) return;
|
|
255
|
+
const anchor = lineAnchor(tr);
|
|
256
|
+
if (!anchor) return;
|
|
257
|
+
lnCell.classList.add('saga-linenum');
|
|
258
|
+
lnCell.title = 'Comment on this line';
|
|
259
|
+
lnCell.addEventListener('click', () => insertComposerRow(tr, path, anchor.line, anchor.side));
|
|
260
|
+
if (inlineFor(path, anchor.line, anchor.side).length) {
|
|
261
|
+
insertComposerRow(tr, path, anchor.line, anchor.side);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function exportComments() {
|
|
268
|
+
const files = {};
|
|
269
|
+
Object.keys(comments.files).forEach((p) => {
|
|
270
|
+
const e = comments.files[p];
|
|
271
|
+
const inline = (e.inline || []).filter((c) => c.body && c.body.trim());
|
|
272
|
+
const fc = (e.file_comment || '').trim();
|
|
273
|
+
if (!inline.length && !fc) return;
|
|
274
|
+
const out = {};
|
|
275
|
+
if (fc) {
|
|
276
|
+
out.file_comment = fc;
|
|
277
|
+
if (e.file_anchor) out.file_anchor = e.file_anchor;
|
|
278
|
+
}
|
|
279
|
+
if (inline.length) out.inline = inline;
|
|
280
|
+
files[p] = out;
|
|
281
|
+
});
|
|
282
|
+
const sidecar = {
|
|
283
|
+
branch: data.branch || '',
|
|
284
|
+
base: data.base || '',
|
|
285
|
+
generated_at: new Date().toISOString(),
|
|
286
|
+
overall: (comments.overall || '').trim(),
|
|
287
|
+
files: files,
|
|
288
|
+
};
|
|
289
|
+
const blob = new Blob([JSON.stringify(sidecar, null, 2)], {type: 'application/json'});
|
|
290
|
+
const url = URL.createObjectURL(blob);
|
|
291
|
+
const a = document.createElement('a');
|
|
292
|
+
a.href = url;
|
|
293
|
+
a.download = 'saga.comments.json';
|
|
294
|
+
document.body.appendChild(a);
|
|
295
|
+
a.click();
|
|
296
|
+
a.remove();
|
|
297
|
+
URL.revokeObjectURL(url);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- entry ----------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
function show() {
|
|
303
|
+
chapters = data.chapters || [];
|
|
304
|
+
comments = loadComments();
|
|
305
|
+
initTheme();
|
|
306
|
+
renderVerdict(data.verdict);
|
|
307
|
+
renderTOC();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- verdict line --------------------------------------------------
|
|
311
|
+
|
|
312
|
+
function renderVerdict(v) {
|
|
313
|
+
if (!v) return;
|
|
314
|
+
const parts = [
|
|
315
|
+
v.chapters + (v.chapters === 1 ? ' chapter' : ' chapters'),
|
|
316
|
+
v.deviations + (v.deviations === 1 ? ' deviation' : ' deviations'),
|
|
317
|
+
v.low_confidence + ' low-confidence',
|
|
318
|
+
];
|
|
319
|
+
if (v.qa && v.qa !== 'n/a') parts.push('QA ' + v.qa);
|
|
320
|
+
$('saga-verdict').textContent = parts.join(' · ');
|
|
321
|
+
// Shift the top status rail to the loudest state present:
|
|
322
|
+
// deviation (red) > attention (amber) > clear (green).
|
|
323
|
+
const rail = $('saga-rail');
|
|
324
|
+
if (rail) {
|
|
325
|
+
rail.classList.remove('saga-rail-ok', 'saga-rail-attn', 'saga-rail-dev');
|
|
326
|
+
if (v.deviations > 0) rail.classList.add('saga-rail-dev');
|
|
327
|
+
else if (v.low_confidence > 0 || v.qa === 'attention') rail.classList.add('saga-rail-attn');
|
|
328
|
+
else rail.classList.add('saga-rail-ok');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- table of contents ---------------------------------------------
|
|
333
|
+
|
|
334
|
+
function badges(ch, read) {
|
|
335
|
+
const out = [];
|
|
336
|
+
if (ch.plan_step) out.push('<span class="saga-badge saga-plan">' + esc(ch.plan_step) + '</span>');
|
|
337
|
+
if (ch.deviation) out.push('<span class="saga-badge saga-dev">⚠ Deviation</span>');
|
|
338
|
+
if (ch.confidence === 'low') out.push('<span class="saga-badge saga-low">Low confidence</span>');
|
|
339
|
+
if (ch.qa && ch.qa.status === 'green') out.push('<span class="saga-badge saga-qa">✓ QA</span>');
|
|
340
|
+
if (read) out.push('<span class="saga-badge saga-read">✓ Read</span>');
|
|
341
|
+
return out.join('');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function renderTOC() {
|
|
345
|
+
const toc = $('saga-toc');
|
|
346
|
+
$('saga-reader').hidden = true;
|
|
347
|
+
toc.hidden = false;
|
|
348
|
+
const rd = readSet();
|
|
349
|
+
let html =
|
|
350
|
+
'<div class="saga-review">' +
|
|
351
|
+
'<h2 class="saga-toc-title">Review</h2>' +
|
|
352
|
+
'<textarea id="saga-overall" class="saga-cmt-input saga-overall" placeholder="Overall review comment…"></textarea>' +
|
|
353
|
+
'<div class="saga-review-actions">' +
|
|
354
|
+
'<span class="saga-cmt-count" id="saga-cmt-count"></span>' +
|
|
355
|
+
'<button class="saga-btn" id="saga-export">Export comments</button>' +
|
|
356
|
+
'</div>' +
|
|
357
|
+
'</div>' +
|
|
358
|
+
'<h2 class="saga-toc-title">Chapters</h2>';
|
|
359
|
+
chapters.forEach((ch, i) => {
|
|
360
|
+
html +=
|
|
361
|
+
'<button class="saga-toc-item" data-i="' + i + '">' +
|
|
362
|
+
'<div class="saga-toc-head"><span class="saga-num">' + (i + 1) + '</span>' +
|
|
363
|
+
'<span class="saga-toc-titletext">' + esc(ch.title) + '</span></div>' +
|
|
364
|
+
'<div class="saga-toc-summary">' + esc(ch.summary) + '</div>' +
|
|
365
|
+
'<div class="saga-badges">' + badges(ch, rd.has(ch.id)) + '</div>' +
|
|
366
|
+
'</button>';
|
|
367
|
+
});
|
|
368
|
+
toc.innerHTML = html;
|
|
369
|
+
toc.querySelectorAll('.saga-toc-item').forEach((b) => {
|
|
370
|
+
b.addEventListener('click', () => openChapter(parseInt(b.dataset.i, 10)));
|
|
371
|
+
});
|
|
372
|
+
const overall = $('saga-overall');
|
|
373
|
+
if (overall) {
|
|
374
|
+
overall.value = comments.overall || '';
|
|
375
|
+
overall.addEventListener('input', () => { comments.overall = overall.value; persist(); });
|
|
376
|
+
}
|
|
377
|
+
const exp = $('saga-export');
|
|
378
|
+
if (exp) exp.addEventListener('click', exportComments);
|
|
379
|
+
updateCount();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// --- chapter reader ------------------------------------------------
|
|
383
|
+
|
|
384
|
+
function openChapter(i) {
|
|
385
|
+
current = i;
|
|
386
|
+
const ch = chapters[i];
|
|
387
|
+
if (!ch) return;
|
|
388
|
+
$('saga-toc').hidden = true;
|
|
389
|
+
const reader = $('saga-reader');
|
|
390
|
+
reader.hidden = false;
|
|
391
|
+
|
|
392
|
+
const devBanner = ch.deviation
|
|
393
|
+
? '<div class="saga-deviation"><strong>⚠ Deviation from plan.</strong> ' + esc(ch.deviation) + '</div>'
|
|
394
|
+
: '';
|
|
395
|
+
const lowBanner = ch.confidence === 'low'
|
|
396
|
+
? '<div class="saga-lowconf">Low confidence — this chapter needs close review.</div>'
|
|
397
|
+
: '';
|
|
398
|
+
const qaLine = ch.qa && ch.qa.note
|
|
399
|
+
? '<div class="saga-qanote">' + (ch.qa.status === 'green' ? '✓ ' : '') + esc(ch.qa.note) + '</div>'
|
|
400
|
+
: '';
|
|
401
|
+
|
|
402
|
+
// Class-based (not id-based) so the same nav can render at top and bottom.
|
|
403
|
+
const navRow = (place) =>
|
|
404
|
+
'<div class="saga-reader-nav ' + place + '">' +
|
|
405
|
+
'<button class="saga-btn saga-toc-link">☰ Contents</button>' +
|
|
406
|
+
'<span class="saga-progress">Chapter ' + (i + 1) + ' of ' + chapters.length + '</span>' +
|
|
407
|
+
'<span class="saga-nav-spacer"></span>' +
|
|
408
|
+
'<button class="saga-btn saga-prev"' + (i === 0 ? ' disabled' : '') + '>← Prev</button>' +
|
|
409
|
+
'<button class="saga-btn saga-next"' + (i === chapters.length - 1 ? ' disabled' : '') + '>Next →</button>' +
|
|
410
|
+
'</div>';
|
|
411
|
+
|
|
412
|
+
reader.innerHTML =
|
|
413
|
+
navRow('saga-nav-top') +
|
|
414
|
+
'<div class="saga-chapter-head">' +
|
|
415
|
+
'<span class="saga-num">' + (i + 1) + '</span>' +
|
|
416
|
+
'<h2 class="saga-chapter-title">' + esc(ch.title) + '</h2>' +
|
|
417
|
+
'<div class="saga-badges">' + badges(ch, isRead(ch.id)) + '</div>' +
|
|
418
|
+
'</div>' +
|
|
419
|
+
devBanner + lowBanner +
|
|
420
|
+
'<div class="saga-narration">' + renderMarkdown(ch.narration) + '</div>' +
|
|
421
|
+
qaLine +
|
|
422
|
+
'<label class="saga-readmark"><input type="checkbox" id="saga-read-cb"' +
|
|
423
|
+
(isRead(ch.id) ? ' checked' : '') + '> Mark chapter as read</label>' +
|
|
424
|
+
'<div id="saga-chapter-diff" class="saga-chapter-diff"></div>' +
|
|
425
|
+
navRow('saga-nav-bottom');
|
|
426
|
+
|
|
427
|
+
reader.querySelectorAll('.saga-toc-link').forEach((b) => b.addEventListener('click', renderTOC));
|
|
428
|
+
reader.querySelectorAll('.saga-prev').forEach((b) => b.addEventListener('click', () => openChapter(i - 1)));
|
|
429
|
+
reader.querySelectorAll('.saga-next').forEach((b) => b.addEventListener('click', () => openChapter(i + 1)));
|
|
430
|
+
$('saga-read-cb').addEventListener('change', (e) => setRead(ch.id, e.target.checked));
|
|
431
|
+
|
|
432
|
+
renderChapterDiff(ch);
|
|
433
|
+
window.scrollTo(0, 0);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function renderMarkdown(text) {
|
|
437
|
+
if (window.marked && text) return window.marked.parse(text);
|
|
438
|
+
return esc(text || '');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function renderChapterDiff(ch) {
|
|
442
|
+
const container = $('saga-chapter-diff');
|
|
443
|
+
if (!ch.diff || !ch.diff.trim()) {
|
|
444
|
+
container.innerHTML = '<div class="saga-empty">No diff hunks in this chapter.</div>';
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
CONFIG.colorScheme = effectiveTheme();
|
|
448
|
+
const ui = new Diff2HtmlUI(container, ch.diff, CONFIG);
|
|
449
|
+
ui.draw();
|
|
450
|
+
ui.highlightCode();
|
|
451
|
+
wireComments(container);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (document.readyState === 'loading') {
|
|
455
|
+
document.addEventListener('DOMContentLoaded', show);
|
|
456
|
+
} else {
|
|
457
|
+
show();
|
|
458
|
+
}
|
|
459
|
+
})();
|
saga/assets/tokens.css
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* Saga — design tokens ("instrument ink").
|
|
2
|
+
*
|
|
3
|
+
* Single source of truth for the saga page. A cool CYAN accent means
|
|
4
|
+
* "a human can act here" (links, focus), and a strict three-light status system
|
|
5
|
+
* — green (clear) · amber (attention) · red (deviation) — is reserved for the
|
|
6
|
+
* agent's own state and never used as chrome.
|
|
7
|
+
*
|
|
8
|
+
* Every colour is a light-dark(light, dark) pair. The page follows the OS by
|
|
9
|
+
* default (color-scheme: light dark); the reader's toggle forces a scheme by
|
|
10
|
+
* stamping [data-theme] on <html>, which flips every token below at once.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
color-scheme: light dark;
|
|
15
|
+
|
|
16
|
+
/* --- ink: layered graphite (dark) / cool near-white (light) --- */
|
|
17
|
+
--bg: light-dark(#f4f6f9, #0b0e13); /* page canvas */
|
|
18
|
+
--surface: light-dark(#ffffff, #141922); /* cards, panels, headers */
|
|
19
|
+
--surface-2: light-dark(#eef1f5, #1d2430); /* raised: buttons, code */
|
|
20
|
+
--surface-accent:light-dark(#e6f3f8, #16232b); /* cyan-tinted raise */
|
|
21
|
+
--hover: light-dark(rgba(20,40,70,0.05), rgba(120,150,180,0.07));
|
|
22
|
+
|
|
23
|
+
/* --- lines --- */
|
|
24
|
+
--line: light-dark(#d5dbe3, #2a323f); /* default border */
|
|
25
|
+
--line-soft: light-dark(#e6eaef, #20262f); /* quiet divider */
|
|
26
|
+
|
|
27
|
+
/* --- text --- */
|
|
28
|
+
--text: light-dark(#1a1f28, #e7ecf3); /* primary */
|
|
29
|
+
--text-dim: light-dark(#55606f, #9aa7b8); /* secondary / labels */
|
|
30
|
+
--text-faint: light-dark(#8a94a3, #5f6b7d); /* tertiary / line numbers */
|
|
31
|
+
|
|
32
|
+
/* --- accent: signal cyan = "a human can act here" --- */
|
|
33
|
+
--accent: light-dark(#0e7490, #57c7e3);
|
|
34
|
+
--accent-bright: light-dark(#0b5c72, #8ad9ef); /* hover / brighter link */
|
|
35
|
+
--accent-solid: light-dark(#1592b5, #2b93b3); /* solid fills (primary button)*/
|
|
36
|
+
--accent-ink: light-dark(#ffffff, #04141b); /* text on a solid accent fill */
|
|
37
|
+
--accent-tint: light-dark(rgba(14,116,144,0.10), rgba(87,199,227,0.12));
|
|
38
|
+
--focus-ring: light-dark(rgba(14,116,144,0.45), rgba(87,199,227,0.40));
|
|
39
|
+
|
|
40
|
+
/* --- status lights (agent state only) --- */
|
|
41
|
+
--ok: light-dark(#1a7f43, #4ec98a); /* clear */
|
|
42
|
+
--ok-bright: light-dark(#146b39, #7ee7b0);
|
|
43
|
+
--ok-solid: light-dark(#1a7f43, #2f9e63);
|
|
44
|
+
--ok-ink: light-dark(#ffffff, #04140c);
|
|
45
|
+
--ok-tint: light-dark(rgba(26,127,67,0.12), rgba(78,201,138,0.15));
|
|
46
|
+
|
|
47
|
+
--warn: light-dark(#9a6700, #e0a83e); /* attention */
|
|
48
|
+
--warn-solid: light-dark(#8a5d00, #c78a2a);
|
|
49
|
+
--warn-ink: light-dark(#ffffff, #1b1200); /* text on a solid amber fill */
|
|
50
|
+
--warn-tint: light-dark(rgba(154,103,0,0.12), rgba(224,168,62,0.15));
|
|
51
|
+
|
|
52
|
+
--danger: light-dark(#c62828, #f2685f); /* deviation */
|
|
53
|
+
--danger-bright: light-dark(#a51f1f, #ff9c94);
|
|
54
|
+
--danger-ink: light-dark(#5c1512, #ffe1de); /* text on a loud red fill */
|
|
55
|
+
--danger-tint: light-dark(rgba(198,40,40,0.10), rgba(242,104,95,0.14));
|
|
56
|
+
--danger-loud: light-dark(#fbe3e1, #b62324); /* loud deviation fill */
|
|
57
|
+
--danger-loud-bg:light-dark(#fdecea, #3d1518); /* loud deviation panel bg */
|
|
58
|
+
--danger-loud-border:light-dark(#c62828, #f2685f);
|
|
59
|
+
|
|
60
|
+
/* --- diff shading (derived from status hues) --- */
|
|
61
|
+
--diff-add-bg: light-dark(rgba(26,127,67,0.12), rgba(78,201,138,0.10));
|
|
62
|
+
--diff-add-strong: light-dark(rgba(26,127,67,0.25), rgba(78,201,138,0.30));
|
|
63
|
+
--diff-add-gutter: light-dark(rgba(26,127,67,0.18), rgba(78,201,138,0.15));
|
|
64
|
+
--diff-del-bg: light-dark(rgba(198,40,40,0.10), rgba(242,104,95,0.10));
|
|
65
|
+
--diff-del-strong: light-dark(rgba(198,40,40,0.22), rgba(242,104,95,0.30));
|
|
66
|
+
--diff-del-gutter: light-dark(rgba(198,40,40,0.15), rgba(242,104,95,0.15));
|
|
67
|
+
--diff-info-bg: light-dark(rgba(14,116,144,0.10), rgba(87,199,227,0.10));
|
|
68
|
+
|
|
69
|
+
/* --- type --- */
|
|
70
|
+
--font-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
71
|
+
"Inter", Helvetica, Arial, sans-serif;
|
|
72
|
+
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono",
|
|
73
|
+
Menlo, Consolas, monospace;
|
|
74
|
+
|
|
75
|
+
--fs-eyebrow: 11px; /* uppercase instrument labels */
|
|
76
|
+
--fs-xs: 12px;
|
|
77
|
+
--fs-sm: 13px;
|
|
78
|
+
--fs-base: 14px;
|
|
79
|
+
--fs-md: 15px;
|
|
80
|
+
--fs-lg: 16px;
|
|
81
|
+
--fs-xl: 20px;
|
|
82
|
+
--fs-2xl: 22px;
|
|
83
|
+
|
|
84
|
+
--tracking-eyebrow: 0.08em;
|
|
85
|
+
--tracking-mono: -0.01em;
|
|
86
|
+
|
|
87
|
+
/* --- geometry --- */
|
|
88
|
+
--radius-sm: 4px;
|
|
89
|
+
--radius: 6px;
|
|
90
|
+
--radius-lg: 8px;
|
|
91
|
+
--radius-pill: 999px;
|
|
92
|
+
|
|
93
|
+
--space-1: 4px;
|
|
94
|
+
--space-2: 8px;
|
|
95
|
+
--space-3: 12px;
|
|
96
|
+
--space-4: 16px;
|
|
97
|
+
--space-5: 24px;
|
|
98
|
+
--space-6: 32px;
|
|
99
|
+
|
|
100
|
+
--shadow: light-dark(0 8px 24px rgba(20,40,70,0.12), 0 8px 24px rgba(0,0,0,0.35));
|
|
101
|
+
--shadow-up: light-dark(0 -4px 12px rgba(20,40,70,0.10), 0 -4px 12px rgba(0,0,0,0.30));
|
|
102
|
+
|
|
103
|
+
--transition: 120ms ease;
|
|
104
|
+
|
|
105
|
+
/* the status rail defaults to accent; pages recolor it via a modifier */
|
|
106
|
+
--rail: var(--accent);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* The reader's toggle forces a scheme; the light-dark() tokens above then
|
|
110
|
+
resolve to it. With no explicit choice, `color-scheme: light dark` follows
|
|
111
|
+
the OS. */
|
|
112
|
+
:root[data-theme="light"] { color-scheme: light; }
|
|
113
|
+
:root[data-theme="dark"] { color-scheme: dark; }
|
|
114
|
+
|
|
115
|
+
/* --- reset shared by every page --- */
|
|
116
|
+
* { box-sizing: border-box; }
|
|
117
|
+
|
|
118
|
+
/* Visible, on-brand keyboard focus everywhere; never suppressed. */
|
|
119
|
+
:focus-visible {
|
|
120
|
+
outline: 2px solid var(--accent);
|
|
121
|
+
outline-offset: 2px;
|
|
122
|
+
border-radius: var(--radius-sm);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@media (prefers-reduced-motion: reduce) {
|
|
126
|
+
* { transition-duration: 0.01ms !important; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* --- signature: the status rail ------------------------------------
|
|
130
|
+
* A thin backlit edge across the top of the page. It shifts colour to
|
|
131
|
+
* the most important state present:
|
|
132
|
+
* accent (idle) · green (clear) · amber (attention) · red (deviation).
|
|
133
|
+
*/
|
|
134
|
+
.saga-rail {
|
|
135
|
+
height: 2px;
|
|
136
|
+
background: var(--rail);
|
|
137
|
+
box-shadow: 0 0 12px 0 color-mix(in srgb, var(--rail) 55%, transparent);
|
|
138
|
+
}
|
|
139
|
+
.saga-rail-ok { --rail: var(--ok); }
|
|
140
|
+
.saga-rail-attn { --rail: var(--warn); }
|
|
141
|
+
.saga-rail-dev { --rail: var(--danger); }
|