@myrtilleio/redline 0.1.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 +281 -0
- package/bin/redline.js +375 -0
- package/client/boot.js +146 -0
- package/client/check.js +926 -0
- package/client/composer.js +384 -0
- package/client/core.js +1610 -0
- package/client/devices.js +273 -0
- package/client/edits.js +389 -0
- package/client/keyboard.js +110 -0
- package/client/panel.js +222 -0
- package/client/pen.js +324 -0
- package/client/references.js +114 -0
- package/client/select.js +180 -0
- package/client/shots.js +357 -0
- package/client/variants.js +117 -0
- package/examples/demo-site/hero.svg +18 -0
- package/examples/demo-site/index.html +96 -0
- package/examples/demo-site/styles.css +64 -0
- package/integrations/claude-code/redline.md +27 -0
- package/package.json +42 -0
- package/src/account.js +101 -0
- package/src/bundle.js +33 -0
- package/src/demo.js +137 -0
- package/src/format.js +346 -0
- package/src/guards.js +108 -0
- package/src/inject.js +15 -0
- package/src/mcp.js +380 -0
- package/src/report.js +199 -0
- package/src/server.js +477 -0
- package/src/shots.js +311 -0
- package/src/store.js +544 -0
- package/src/sync.js +126 -0
- package/src/taste.js +80 -0
- package/src/verify.js +71 -0
package/src/taste.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The taste ledger: every judgment the reviewer makes, kept across rounds.
|
|
2
|
+
//
|
|
3
|
+
// A review produces more than fixes. Each green mark, each accepted fix, each
|
|
4
|
+
// redo and each approval sent back says something about what this reviewer
|
|
5
|
+
// wants. Redline keeps those judgments in .redline/taste.json (never pruned by
|
|
6
|
+
// `redline clear`) and renders them as TASTE.md for the agent: keep what was
|
|
7
|
+
// approved, lean toward what was accepted, and don't try again what was rejected.
|
|
8
|
+
|
|
9
|
+
const cut = (s, n) => {
|
|
10
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
11
|
+
return t.length > n ? t.slice(0, n - 1) + '…' : t;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** One judgment about item `a`: keep | accepted | redo | sent-back | new-look | reading. */
|
|
15
|
+
export function tasteEntry(kind, a, extra = {}) {
|
|
16
|
+
if (!a) return null;
|
|
17
|
+
const e = {
|
|
18
|
+
kind,
|
|
19
|
+
at: new Date().toISOString(),
|
|
20
|
+
round: a.round,
|
|
21
|
+
page: a.page,
|
|
22
|
+
selector: a.target && a.target.selector,
|
|
23
|
+
text: a.target && cut(a.target.text, 80),
|
|
24
|
+
comment: cut(a.comment, 300),
|
|
25
|
+
resolution: cut(a.resolution, 300),
|
|
26
|
+
};
|
|
27
|
+
if (extra.reply) e.reply = cut(extra.reply, 300);
|
|
28
|
+
if (Array.isArray(extra.diff) && extra.diff.length) e.diff = extra.diff.slice(0, 6);
|
|
29
|
+
for (const k of Object.keys(e)) if (e[k] === undefined || e[k] === '') delete e[k];
|
|
30
|
+
return e;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const where = (e) => `${e.page && e.page !== '/' ? `\`${e.page}\` ` : ''}\`${e.selector || 'page'}\`${e.text ? ` “${cut(e.text, 50)}”` : ''}`;
|
|
34
|
+
|
|
35
|
+
/** The latest judgment per element, so a later decision replaces an earlier one. */
|
|
36
|
+
function latest(entries, kinds) {
|
|
37
|
+
const by = new Map();
|
|
38
|
+
for (const e of entries) if (kinds.includes(e.kind)) by.set(`${e.page}|${e.selector}|${e.kind}`, e);
|
|
39
|
+
return [...by.values()];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Render the ledger as Markdown for the agent. */
|
|
43
|
+
export function renderTaste(entries, rounds = []) {
|
|
44
|
+
const keep = latest(entries, ['keep', 'new-look']);
|
|
45
|
+
const accepted = latest(entries, ['accepted']);
|
|
46
|
+
const rejected = entries.filter((e) => e.kind === 'redo').slice(-30);
|
|
47
|
+
const sentBack = entries.filter((e) => e.kind === 'sent-back').slice(-20);
|
|
48
|
+
const reading = entries.filter((e) => e.kind === 'reading').at(-1);
|
|
49
|
+
const judged = rounds.filter((r) => (r.accepted || 0) + (r.redone || 0) > 0);
|
|
50
|
+
|
|
51
|
+
const out = [
|
|
52
|
+
'# What this reviewer approved and rejected',
|
|
53
|
+
'',
|
|
54
|
+
'Kept by Redline across every round (it survives `redline clear`). Read it before you change the design:',
|
|
55
|
+
'keep what was approved, lean toward what was accepted, and never try again what was rejected.',
|
|
56
|
+
];
|
|
57
|
+
if (judged.length) {
|
|
58
|
+
const rate = (r) => `${Math.round((100 * (r.accepted || 0)) / ((r.accepted || 0) + (r.redone || 0)))}%`;
|
|
59
|
+
out.push('', `**Accepted first time:** ${judged.map((r) => `round ${r.n} ${rate(r)}`).join(' → ')}`);
|
|
60
|
+
}
|
|
61
|
+
if (reading) out.push('', '## What the page is for', '', `In the reviewer's words: “${reading.reply}”`);
|
|
62
|
+
if (keep.length) {
|
|
63
|
+
out.push('', '## Keep exactly as it is', '');
|
|
64
|
+
for (const e of keep) out.push(`- ${where(e)}${e.comment ? `: ${e.comment}` : ''}${e.kind === 'new-look' ? ' (its current look was approved)' : ''}`);
|
|
65
|
+
}
|
|
66
|
+
if (accepted.length) {
|
|
67
|
+
out.push('', '## Accepted: the direction they like', '');
|
|
68
|
+
for (const e of accepted) out.push(`- ${where(e)}: asked “${e.comment || 'a change'}”, accepted “${e.resolution || 'the fix'}”`);
|
|
69
|
+
}
|
|
70
|
+
if (rejected.length) {
|
|
71
|
+
out.push('', "## Rejected: don't try these again", '');
|
|
72
|
+
for (const e of rejected) out.push(`- ${where(e)}: “${e.resolution || 'the fix'}” was not it${e.reply ? `. The reviewer said: “${e.reply}”` : ''}`);
|
|
73
|
+
}
|
|
74
|
+
if (sentBack.length) {
|
|
75
|
+
out.push('', '## Broke an approval (sent back)', '');
|
|
76
|
+
for (const e of sentBack) out.push(`- ${where(e)} changed after it was approved${e.diff ? `: ${e.diff.join('; ')}` : ''}`);
|
|
77
|
+
}
|
|
78
|
+
if (!keep.length && !accepted.length && !rejected.length && !sentBack.length && !reading) out.push('', 'Nothing yet.');
|
|
79
|
+
return out.join('\n') + '\n';
|
|
80
|
+
}
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Checking approvals on demand: photograph each one again, now, and compare it with
|
|
2
|
+
// how it was approved, at every width it's guarded at. Nothing is recorded: this is
|
|
3
|
+
// for the agent before it closes a round (catch your own regressions before the
|
|
4
|
+
// reviewer does) and for CI (`redline verify`), not the reviewer's record.
|
|
5
|
+
import { combineChecks, diffFingerprints, isGuarded, MOBILE } from './guards.js';
|
|
6
|
+
import { createRedlineServer } from './server.js';
|
|
7
|
+
import { ShotService } from './shots.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Results per approval: { a, status: held | changed | missing | no-baseline, diff }.
|
|
11
|
+
* `shots` is a ShotService that can load the site (see openVerifier).
|
|
12
|
+
*/
|
|
13
|
+
export async function verifyApprovals({ store, shots }) {
|
|
14
|
+
const results = [];
|
|
15
|
+
for (const a of (await store.all()).filter(isGuarded)) {
|
|
16
|
+
const g = a.guard || {};
|
|
17
|
+
if (!g.base) {
|
|
18
|
+
results.push({ a, status: 'no-baseline', diff: [] });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const check = async (key, base) => {
|
|
22
|
+
const found = await shots.capture(a.id, key, '', { dry: true }).catch(() => null);
|
|
23
|
+
const fp = found && found.fp;
|
|
24
|
+
const diff = diffFingerprints(base.fp, fp, { ignore: a.guardIgnore });
|
|
25
|
+
return { status: !fp ? 'missing' : diff.length ? 'changed' : 'held', diff, at: new Date().toISOString() };
|
|
26
|
+
};
|
|
27
|
+
const now = { base: g.base, check: await check('guard-now', g.base) };
|
|
28
|
+
if (g.mobile && g.mobile.base) now.mobile = { base: g.mobile.base, check: await check(`guard-now@${MOBILE}`, g.mobile.base) };
|
|
29
|
+
const { status, diff } = combineChecks(now);
|
|
30
|
+
results.push({ a, status, diff, baseShot: g.base.shot });
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A private server on a free port, just to photograph the site with: a folder (`root`) or a dev server (`target`). */
|
|
36
|
+
export async function openVerifier({ root, target, storeDir }) {
|
|
37
|
+
const server = createRedlineServer({ ...(root ? { root } : { target }), storeDir, shots: false });
|
|
38
|
+
await new Promise((ok, fail) => {
|
|
39
|
+
server.once('error', fail);
|
|
40
|
+
server.listen(0, '127.0.0.1', ok);
|
|
41
|
+
});
|
|
42
|
+
const shots = new ShotService({ store: server.store, baseUrl: () => `http://127.0.0.1:${server.address().port}` });
|
|
43
|
+
return {
|
|
44
|
+
store: server.store,
|
|
45
|
+
shots,
|
|
46
|
+
async close() {
|
|
47
|
+
await shots.close();
|
|
48
|
+
await new Promise((ok) => server.close(ok));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The results in words, for a terminal or a model. */
|
|
54
|
+
export function describeVerify(results, { storeDir = '.redline', color = false } = {}) {
|
|
55
|
+
const c = (code, s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
56
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
57
|
+
const lines = [];
|
|
58
|
+
for (const r of results) {
|
|
59
|
+
const name = `#${r.a.n} ${c(2, (r.a.target && r.a.target.selector) || r.a.page)}`;
|
|
60
|
+
if (r.status === 'held') lines.push(` ${c(32, '✓')} ${name} held`);
|
|
61
|
+
else if (r.status === 'no-baseline') lines.push(` ○ ${name} no baseline yet (it's taken while the review is open)`);
|
|
62
|
+
else {
|
|
63
|
+
lines.push(` ${c(31, '✗')} ${name} ${r.status === 'missing' ? 'is gone from the page' : 'changed'}`);
|
|
64
|
+
for (const d of r.diff) lines.push(` ${d.replace(/`/g, '')}`);
|
|
65
|
+
lines.push(` restore it as approved${r.baseShot ? ` (${storeDir}/${r.baseShot})` : ''}, without undoing other fixes`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const summary = [`${count('held')} held`, count('changed') && `${count('changed')} changed`, count('missing') && `${count('missing')} gone`,
|
|
69
|
+
count('no-baseline') && `${count('no-baseline')} without a baseline`].filter(Boolean).join(' · ');
|
|
70
|
+
return { lines, summary, bad: count('changed') + count('missing') };
|
|
71
|
+
}
|