@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.
@@ -0,0 +1,117 @@
1
+ // ---------------------------------------------------------------- options (variants)
2
+
3
+ // For "I'm not sure what it should be": when a mark could be fixed several ways,
4
+ // the agent proposes a few (CSS, and optionally new inner HTML, for the item's
5
+ // element) instead of guessing, and the reviewer flips between them live on the
6
+ // page and picks one. The pick becomes the instruction.
7
+
8
+ const variantSheets = []; // constructed stylesheets we added to the document
9
+ const variantHtml = new Map(); // element -> original innerHTML
10
+
11
+ let ttPolicy;
12
+ function trustedHtml(html) {
13
+ // Pages with Trusted Types need a policy; the HTML comes from the project's own agent.
14
+ if (window.trustedTypes && ttPolicy === undefined) {
15
+ try {
16
+ ttPolicy = trustedTypes.createPolicy('redline-options', { createHTML: (s) => s });
17
+ } catch {
18
+ ttPolicy = null;
19
+ }
20
+ }
21
+ return ttPolicy ? ttPolicy.createHTML(html) : html;
22
+ }
23
+
24
+ function clearVariantPreviews() {
25
+ if (variantSheets.length) {
26
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => !variantSheets.includes(s));
27
+ variantSheets.length = 0;
28
+ }
29
+ for (const [el, html] of variantHtml) el.innerHTML = trustedHtml(html);
30
+ variantHtml.clear();
31
+ }
32
+
33
+ // A preview must beat the page's own CSS whatever the selectors' specificity:
34
+ // mark every declaration important (recursing into @media and friends).
35
+ function forceImportant(rules) {
36
+ for (const rule of rules) {
37
+ if (rule.style) for (const prop of [...rule.style]) rule.style.setProperty(prop, rule.style.getPropertyValue(prop), 'important');
38
+ if (rule.cssRules) forceImportant(rule.cssRules);
39
+ }
40
+ }
41
+
42
+ /** Preview option `key` of item `a` on the page (on top of whatever is shown). */
43
+ function applyVariant(a, key) {
44
+ const v = (a.variants || []).find((x) => x.key === key);
45
+ if (!v) return;
46
+ const selector = (a.target && a.target.selector) || 'body';
47
+ if (v.css) {
48
+ try {
49
+ const sheet = new CSSStyleSheet();
50
+ sheet.replaceSync(v.css.replace(/&/g, selector));
51
+ forceImportant(sheet.cssRules);
52
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
53
+ variantSheets.push(sheet);
54
+ } catch {}
55
+ }
56
+ const el = anchorEl(a);
57
+ if (v.html && el) {
58
+ if (!variantHtml.has(el)) variantHtml.set(el, el.innerHTML);
59
+ el.innerHTML = trustedHtml(v.html);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The Options section of the composer. Before the agent has answered, it offers to
65
+ * ask for options; afterwards it lists them to preview and pick.
66
+ * onPreview(key) is called when the reviewer flips to an option (null = original).
67
+ */
68
+ function createOptions(a, { onPreview, onRequest, onPick }) {
69
+ const box = h('div', { class: 'options' });
70
+ let showing = a.chosen || null;
71
+
72
+ function render() {
73
+ if (!a.variants || !a.variants.length) {
74
+ // Nothing to show until the agent offers options (or a request made earlier is pending).
75
+ fill(box,
76
+ a.request
77
+ ? h('div', { class: 'opt-wait' }, `Waiting for ${a.request.variants} options from your agent…`,
78
+ h('button', { type: 'button', class: 'btn small', onclick: () => onRequest(null) }, "Don't ask"))
79
+ : null,
80
+ );
81
+ return;
82
+ }
83
+ const chip = (key, text, title) =>
84
+ h('button', {
85
+ type: 'button',
86
+ class: 'opt' + (a.chosen === key ? ' chosen' : ''),
87
+ 'aria-pressed': String(showing === key),
88
+ title: title || text,
89
+ onclick: () => {
90
+ showing = key;
91
+ onPreview(key);
92
+ render();
93
+ },
94
+ }, text);
95
+ const current = showing && a.variants.find((v) => v.key === showing);
96
+ fill(box,
97
+ h('div', { class: 'opt-row' },
98
+ chip(null, 'Original', 'The page as built'),
99
+ a.variants.map((v) => chip(v.key, `${v.key} · ${v.label}`, v.note || v.label)),
100
+ ),
101
+ current && current.note ? h('div', { class: 'opt-note' }, current.note) : null,
102
+ current
103
+ ? h('button', {
104
+ type: 'button',
105
+ class: 'btn small primary',
106
+ onclick: () => onPick(current.key),
107
+ }, a.chosen === current.key ? '✓ Picked' : `Pick option ${current.key}`)
108
+ : null,
109
+ );
110
+ }
111
+ render();
112
+ return {
113
+ node: box,
114
+ showing: () => showing,
115
+ rerender: render,
116
+ };
117
+ }
@@ -0,0 +1,18 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 520" font-family="system-ui, sans-serif">
2
+ <rect width="960" height="520" rx="20" fill="#fff"/>
3
+ <rect width="960" height="48" rx="20" fill="#f5f5f4"/>
4
+ <rect y="28" width="960" height="20" fill="#f5f5f4"/>
5
+ <circle cx="28" cy="24" r="6" fill="#fca5a5"/><circle cx="48" cy="24" r="6" fill="#fcd34d"/><circle cx="68" cy="24" r="6" fill="#86efac"/>
6
+ <rect x="32" y="80" width="260" height="400" rx="14" fill="#fff7ed" stroke="#fed7aa"/>
7
+ <text x="56" y="120" font-size="20" font-weight="700" fill="#7c2d12">Ethiopia Guji — V60</text>
8
+ <text x="56" y="160" font-size="15" fill="#78716c">Dose</text><text x="268" y="160" font-size="15" text-anchor="end" fill="#1c1917">15 g</text>
9
+ <text x="56" y="192" font-size="15" fill="#78716c">Water</text><text x="268" y="192" font-size="15" text-anchor="end" fill="#1c1917">250 g · 94°C</text>
10
+ <text x="56" y="224" font-size="15" fill="#78716c">Grind</text><text x="268" y="224" font-size="15" text-anchor="end" fill="#1c1917">Comandante 24</text>
11
+ <text x="56" y="256" font-size="15" fill="#78716c">Time</text><text x="268" y="256" font-size="15" text-anchor="end" fill="#1c1917">3:05</text>
12
+ <rect x="56" y="296" width="212" height="44" rx="22" fill="#9a3412"/>
13
+ <text x="162" y="324" font-size="15" font-weight="600" text-anchor="middle" fill="#fff">Log this brew</text>
14
+ <text x="332" y="112" font-size="18" font-weight="700" fill="#1c1917">Taste over the last 14 days</text>
15
+ <g stroke="#e7e5e4"><line x1="332" y1="160" x2="920" y2="160"/><line x1="332" y1="260" x2="920" y2="260"/><line x1="332" y1="360" x2="920" y2="360"/><line x1="332" y1="460" x2="920" y2="460"/></g>
16
+ <polyline fill="none" stroke="#9a3412" stroke-width="4" stroke-linejoin="round" points="332,380 390,350 448,360 506,300 564,310 622,250 680,270 738,210 796,220 854,180 912,170"/>
17
+ <polyline fill="none" stroke="#f59e0b" stroke-width="4" stroke-linejoin="round" points="332,420 390,410 448,380 506,390 564,360 622,370 680,340 738,330 796,300 854,310 912,290"/>
18
+ </svg>
@@ -0,0 +1,96 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Brewlog — track every cup</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <!-- A typical first draft from a coding agent: decent bones, a few things a
11
+ designer would want changed. Use it to try Redline. -->
12
+ <header class="nav">
13
+ <a class="logo" href="/">☕ Brewlog</a>
14
+ <nav>
15
+ <a href="#features">Features</a>
16
+ <a href="#pricing">Pricing</a>
17
+ <a href="#faq">FAQ</a>
18
+ </nav>
19
+ <a class="btn btn-small" href="#pricing">Start free</a>
20
+ </header>
21
+
22
+ <main>
23
+ <section class="hero">
24
+ <p class="eyebrow">For home baristas</p>
25
+ <h1 class="hero-title">THE ULTIMATE REVOLUTIONARY COFFEE TRACKING PLATFORM FOR EVERYONE</h1>
26
+ <p class="hero-sub">Log grind size, ratio and taste notes in seconds. See which beans and recipes you actually love, and dial in every new bag faster.</p>
27
+ <div class="hero-actions">
28
+ <a class="btn" href="#pricing">Start free</a>
29
+ <a class="btn btn-ghost" href="#features">See how it works</a>
30
+ </div>
31
+ <img class="hero-shot" src="hero.svg" alt="Brewlog app showing a pour-over recipe with a taste chart">
32
+ </section>
33
+
34
+ <section id="features" class="features">
35
+ <h2>Everything you need to brew better</h2>
36
+ <div class="grid">
37
+ <article class="card">
38
+ <div class="icon">⚖️</div>
39
+ <h3>Recipes that remember</h3>
40
+ <p>Save dose, water, temperature and time. Brewlog suggests the next tweak based on how the last cup tasted.</p>
41
+ </article>
42
+ <article class="card">
43
+ <div class="icon">📈</div>
44
+ <h3>Taste over time</h3>
45
+ <p>Rate sweetness, acidity and body. Charts show how each bag evolves from roast day to the last scoop.</p>
46
+ </article>
47
+ <article class="card card-odd">
48
+ <div class="icon">🫘</div>
49
+ <h3>Your bean library</h3>
50
+ <p>Scan the bag, and origin, process and roaster notes fill in automatically.</p>
51
+ </article>
52
+ </div>
53
+ </section>
54
+
55
+ <section class="quote">
56
+ <blockquote>“I finally know why Tuesday’s V60 was amazing and Wednesday’s wasn’t.”</blockquote>
57
+ <p class="who">— Mara K., home barista</p>
58
+ </section>
59
+
60
+ <section id="pricing" class="pricing">
61
+ <h2>Pricing</h2>
62
+ <div class="plans">
63
+ <div class="plan">
64
+ <h3>Free</h3>
65
+ <p class="price">$0</p>
66
+ <ul><li>50 brews / month</li><li>Basic charts</li><li>1 device</li></ul>
67
+ <a class="btn btn-ghost" href="#">Get started</a>
68
+ </div>
69
+ <div class="plan plan-featured">
70
+ <h3>Pro</h3>
71
+ <p class="price">$6<span>/mo</span></p>
72
+ <ul><li>Unlimited brews</li><li>Recipe suggestions</li><li>Sync everywhere</li><li>Export to CSV</li></ul>
73
+ <a class="btn" href="#">Go Pro</a>
74
+ </div>
75
+ <div class="plan">
76
+ <h3>Café</h3>
77
+ <p class="price">$29<span>/mo</span></p>
78
+ <ul><li>Up to 10 baristas</li><li>Shared recipes</li><li>Priority support</li></ul>
79
+ <a class="btn btn-ghost" href="#">Contact sales</a>
80
+ </div>
81
+ </div>
82
+ </section>
83
+
84
+ <section id="faq" class="faq">
85
+ <h2>Questions</h2>
86
+ <details><summary>Does it work offline?</summary><p>Yes. Brews sync when you're back online.</p></details>
87
+ <details><summary>Which brew methods are supported?</summary><p>Pour-over, espresso, AeroPress, French press, cold brew, and custom methods.</p></details>
88
+ <details><summary>Can I import from a spreadsheet?</summary><p>Pro plans can import CSV files from the settings page.</p></details>
89
+ </section>
90
+ </main>
91
+
92
+ <footer class="footer">
93
+ <p>© 2026 Brewlog · Made for people who weigh their coffee.</p>
94
+ </footer>
95
+ </body>
96
+ </html>
@@ -0,0 +1,64 @@
1
+ :root {
2
+ --ink: #1c1917;
3
+ --muted: #78716c;
4
+ --paper: #fffbf5;
5
+ --brand: #9a3412;
6
+ --line: #e7e5e4;
7
+ font-family: Georgia, "Times New Roman", serif;
8
+ color: var(--ink);
9
+ background: var(--paper);
10
+ }
11
+ * { box-sizing: border-box; }
12
+ body { margin: 0; line-height: 1.6; }
13
+ a { color: inherit; }
14
+
15
+ .nav { display: flex; align-items: center; gap: 32px; padding: 18px 40px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--paper); z-index: 5; }
16
+ .logo { font-weight: 700; text-decoration: none; font-size: 20px; }
17
+ .nav nav { display: flex; gap: 24px; margin-left: auto; }
18
+ .nav nav a { text-decoration: none; color: var(--muted); }
19
+
20
+ .btn { display: inline-block; background: var(--brand); color: #fff; text-decoration: none; padding: 12px 22px; border-radius: 999px; font-family: system-ui, sans-serif; font-weight: 600; font-size: 15px; }
21
+ .btn-small { padding: 8px 16px; font-size: 14px; }
22
+ .btn-ghost { background: transparent; color: var(--brand); border: 2px solid var(--brand); }
23
+
24
+ .hero { max-width: 980px; margin: 0 auto; padding: 80px 24px 40px; text-align: center; }
25
+ .eyebrow { text-transform: uppercase; letter-spacing: .12em; font: 600 13px system-ui, sans-serif; color: var(--brand); }
26
+ .hero-title { font-size: 76px; line-height: 1; margin: 12px 0 24px; letter-spacing: -.02em; color: #7c2d12; }
27
+ .hero-sub { font-size: 20px; color: var(--muted); max-width: 640px; margin: 0 auto 32px; }
28
+ .hero-actions { display: flex; gap: 12px; justify-content: center; }
29
+ .hero-shot { display: block; width: 100%; margin-top: 56px; border-radius: 20px; box-shadow: 0 30px 60px -20px rgba(124, 45, 18, .35); }
30
+
31
+ .features { max-width: 1100px; margin: 0 auto; padding: 80px 24px; }
32
+ .features h2, .pricing h2, .faq h2 { text-align: center; font-size: 40px; margin: 0 0 40px; }
33
+ .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
34
+ .card { background: #fff; border: 1px solid var(--line); border-radius: 16px; padding: 28px; }
35
+ .card-odd { background: #fde68a; border-radius: 2px; padding: 12px; }
36
+ .card .icon { font-size: 30px; }
37
+ .card h3 { margin: 12px 0 8px; }
38
+ .card p { color: var(--muted); margin: 0; }
39
+
40
+ .quote { background: var(--ink); color: #fafaf9; padding: 90px 24px; text-align: center; }
41
+ .quote blockquote { font-size: 32px; font-style: italic; max-width: 760px; margin: 0 auto 16px; }
42
+ .who { color: #a8a29e; font-family: system-ui, sans-serif; }
43
+
44
+ .pricing { max-width: 1100px; margin: 0 auto; padding: 90px 24px; }
45
+ .plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; align-items: start; }
46
+ .plan { border: 1px solid var(--line); border-radius: 16px; padding: 32px; background: #fff; }
47
+ .plan-featured { background: #fff7ed; border: 2px solid var(--brand); transform: translateY(-12px); }
48
+ .plan h3 { margin: 0; font-family: system-ui, sans-serif; }
49
+ .price { font-size: 44px; font-weight: 700; margin: 8px 0; }
50
+ .price span { font-size: 16px; color: var(--muted); }
51
+ .plan ul { padding-left: 18px; color: var(--muted); margin: 0 0 24px; }
52
+
53
+ .faq { max-width: 720px; margin: 0 auto; padding: 40px 24px 100px; }
54
+ details { border-bottom: 1px solid var(--line); padding: 16px 0; }
55
+ summary { cursor: pointer; font-weight: 700; }
56
+
57
+ .footer { text-align: center; padding: 32px; color: var(--muted); border-top: 1px solid var(--line); font-size: 14px; }
58
+
59
+ @media (max-width: 800px) {
60
+ .hero-title { font-size: 44px; }
61
+ .grid, .plans { grid-template-columns: 1fr; }
62
+ .nav { padding: 14px 16px; gap: 12px; }
63
+ .nav nav { display: none; }
64
+ }
@@ -0,0 +1,27 @@
1
+ ---
2
+ description: Apply the design feedback left in the Redline overlay, then mark each item resolved
3
+ allowed-tools: Bash(redline:*), Read, Edit, Write, Glob, Grep
4
+ ---
5
+
6
+ The user reviewed the site in the Redline overlay: they drew on the page or boxed components to say why, and each mark's colour says what they want: red **✗ Fix** (change it), green **✓ Good** (keep it) or amber **? Idea** (a suggestion or question).
7
+
8
+ Open feedback (it starts with this reviewer's taste; the full ledger is `.redline/TASTE.md`, read it if it exists):
9
+
10
+ !`redline feedback`
11
+
12
+ For each item:
13
+
14
+ 1. Find the element in the source. Match on the selector, classes and quoted text; the markup may have changed since the review.
15
+ 2. **Fix** items: make the change the comment asks for. If there's no comment, use the mark's shape and colour to infer the problem: crossed out means remove or rethink it, circled means that area needs attention.
16
+ 3. **Good** items: leave them alone. If a Fix would affect them, keep their look, and reuse their patterns where it fits. Redline checks every approval after each round and reports anything that changed. An item that says **Changed since the reviewer approved it** lists exactly what changed: put it back the way it was approved, without undoing other fixes.
17
+ 4. **Idea** items: apply the idea if it's clearly an improvement. If it's a question, answer it in the resolve note.
18
+ 5. **Proposed change** items carry exact values the reviewer tried live. Apply those values in the source, to every matching element when it says "on every".
19
+ 6. Look at the listed **screenshots** and **reference images** (Read the PNG/WebP files) before changing anything visual.
20
+ 7. When a mark could reasonably be fixed several ways and you can't tell which the reviewer wants (or they **asked for options**), don't guess and don't edit the code for it. Write 2–3 options to a JSON file as `[{"label", "css", "note"}]`, where `&` in css means the element and `html` optionally replaces its contents, then run `redline variants <id> --file options.json`. Once they have **picked** one, build that option in the source.
21
+ 8. If your fix restructured or moved the element, add `--target "<css selector>"` to the resolve command below, so the after-screenshot shows the new markup.
22
+ 9. The brief may show a **Conversation** under a mark: the reviewer is waiting for your answer, so reply with `redline reply <id> "…"`. If a mark is unclear, ask under it the same way instead of guessing.
23
+ 10. After changing the code, run `redline resolve <id> --note "<one line: what you changed>"`. The reviewer sees the note on the pin in their browser, and the page live-reloads.
24
+
25
+ Resolve Good items too, with a note saying the pattern was kept. Before closing the round, check you didn't break anything the reviewer approved: `redline verify <site folder>` (or `--url <dev server>`). It photographs every approval now and lists what changed, in words, without recording anything. Restore whatever it lists. Then close the round: `redline done --summary "what you changed" --reading "what you think this page is for, in two sentences" --question "…"` (at most 3 questions, only if you really need an answer). The reviewer checks each fix and accepts it or asks for a redo in their next round.
26
+
27
+ **Then wait for the next round, without the user having to prompt you.** Run `redline wait` as a background command (it blocks until the reviewer presses Send, then prints the new brief and exits; it returns at once if a round is already waiting). When it finishes, handle that round the same way, close it with `redline done`, and start `redline wait` in the background again. Keep looping until the user tells you to stop. Over MCP, the same loop is `wait_for_round` → `resolve` → `finish_round` → `wait_for_round`.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@myrtilleio/redline",
3
+ "version": "0.1.0",
4
+ "description": "Review the UI your AI agent built: mark it, send it, check each fix, accept it.",
5
+ "type": "module",
6
+ "bin": {
7
+ "redline": "bin/redline.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "client",
13
+ "integrations",
14
+ "examples/demo-site"
15
+ ],
16
+ "scripts": {
17
+ "test": "REDLINE_AUTH=off node --test test/*.test.js",
18
+ "test:account": "node --test test/account.test.js",
19
+ "demo": "node bin/redline.js demo --open",
20
+ "bundle": "node bin/redline.js bundle -o dist/redline.js",
21
+ "field-test": "cd examples/field-test && npm install --no-audit --no-fund && node run.mjs"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "keywords": [
27
+ "design",
28
+ "feedback",
29
+ "annotation",
30
+ "ai",
31
+ "agent",
32
+ "review",
33
+ "overlay"
34
+ ],
35
+ "license": "MIT",
36
+ "devDependencies": {
37
+ "playwright-core": "^1.63.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
package/src/account.js ADDED
@@ -0,0 +1,101 @@
1
+ // Your Redline account, from the command line: an API key made in the dashboard,
2
+ // saved once with `redline login`, and checked when a session starts. The key only
3
+ // proves who you are: your site, notes and screenshots never leave your machine.
4
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+
8
+ // The hosted Redline (Vercel + Neon). Self-hosting: redline login <key> --server <url>, or REDLINE_SERVER.
9
+ export const DEFAULT_SERVER = 'https://redline-nine-eta.vercel.app';
10
+ const OFFLINE_GRACE = 7 * 24 * 3600 * 1000; // a key checked in the last week works offline
11
+ const TIMEOUT = 4000;
12
+
13
+ export function credentialsPath() {
14
+ const base = process.env.REDLINE_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'redline');
15
+ return join(base, 'credentials.json');
16
+ }
17
+
18
+ export async function readCredentials() {
19
+ try {
20
+ return JSON.parse(await readFile(credentialsPath(), 'utf8'));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ async function writeCredentials(data) {
27
+ const file = credentialsPath();
28
+ await mkdir(dirname(file), { recursive: true });
29
+ await writeFile(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
30
+ await chmod(file, 0o600).catch(() => {});
31
+ }
32
+
33
+ export async function forget() {
34
+ await rm(credentialsPath(), { force: true });
35
+ }
36
+
37
+ /** The key and server to use: the environment wins (MCP clients pass REDLINE_API_KEY), then the saved login. */
38
+ export async function currentAccount() {
39
+ const saved = await readCredentials();
40
+ const key = process.env.REDLINE_API_KEY || (saved && saved.key) || null;
41
+ const server = (process.env.REDLINE_SERVER || (saved && saved.server) || DEFAULT_SERVER).replace(/\/$/, '');
42
+ return { key, server, saved, fromEnv: !!process.env.REDLINE_API_KEY };
43
+ }
44
+
45
+ /** Ask the accounts server who this key belongs to, and say what is starting. */
46
+ export async function verifyKey({ key, server, event, client = 'redline cli' }) {
47
+ let res;
48
+ try {
49
+ res = await fetch(`${server}/api/v1/whoami`, {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/json', 'x-api-key': key },
52
+ body: JSON.stringify({ event, client }),
53
+ signal: AbortSignal.timeout(TIMEOUT),
54
+ });
55
+ } catch (err) {
56
+ return { ok: false, offline: true, message: `Can't reach the Redline accounts server at ${server} (${err.cause?.code || err.name}).` };
57
+ }
58
+ const data = await res.json().catch(() => ({}));
59
+ if (!res.ok) return { ok: false, offline: false, message: data.message || `The accounts server said no (${res.status}).` };
60
+ return { ok: true, user: data.user, key: data.key };
61
+ }
62
+
63
+ export function onboardingHelp(server = DEFAULT_SERVER) {
64
+ return [
65
+ 'Redline needs a free account (it only unlocks the tool: your work stays on your machine).',
66
+ '',
67
+ ` 1. Open ${server} and create an account.`,
68
+ ' (Running your own? docker compose up -d in the Redline folder, then add --server http://localhost:4700)',
69
+ ' 2. Create an API key there, then:',
70
+ ` redline login <your-key>${server === DEFAULT_SERVER ? '' : ` --server ${server}`}`,
71
+ ].join('\n');
72
+ }
73
+
74
+ /** `redline login <key>`: check it, then remember it. */
75
+ export async function login(key, { server } = {}) {
76
+ const target = (server || process.env.REDLINE_SERVER || DEFAULT_SERVER).replace(/\/$/, '');
77
+ if (!key || !/^\S{8,}$/.test(key)) throw new Error(`usage: redline login <your-key> [--server ${DEFAULT_SERVER}]`);
78
+ const v = await verifyKey({ key, server: target, event: 'login' });
79
+ if (!v.ok) throw new Error(v.message + (v.offline ? '\nIs the accounts server running? (docker compose up -d)' : ''));
80
+ await writeCredentials({ server: target, key, user: v.user, verifiedAt: new Date().toISOString() });
81
+ return v.user;
82
+ }
83
+
84
+ /**
85
+ * Before a session starts (serve, proxy, demo, mcp): a valid key, or a clear way to
86
+ * get one. Offline, a key checked within the last week still works.
87
+ * Returns { ok, user?, warning?, message? }.
88
+ */
89
+ export async function requireAccount(event, { client } = {}) {
90
+ if (process.env.REDLINE_AUTH === 'off') return { ok: true, skipped: true };
91
+ const { key, server, saved, fromEnv } = await currentAccount();
92
+ if (!key) return { ok: false, message: onboardingHelp(server) };
93
+ const v = await verifyKey({ key, server, event, client });
94
+ if (v.ok) {
95
+ if (!fromEnv && saved && saved.key === key) await writeCredentials({ ...saved, user: v.user, verifiedAt: new Date().toISOString() }).catch(() => {});
96
+ return { ok: true, user: v.user };
97
+ }
98
+ const recent = saved && saved.key === key && saved.verifiedAt && Date.now() - Date.parse(saved.verifiedAt) < OFFLINE_GRACE;
99
+ if (v.offline && recent) return { ok: true, user: saved.user, warning: `${v.message} Working offline with the key checked ${new Date(saved.verifiedAt).toLocaleDateString()}.` };
100
+ return { ok: false, message: `${v.message}\n\n${onboardingHelp(server)}` };
101
+ }
package/src/bundle.js ADDED
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const read = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8');
4
+
5
+ // Overlay sources, in order. They share one function scope: later files use the
6
+ // core's helpers and state; boot.js must stay last.
7
+ export const CLIENT_FILES = [
8
+ 'core.js',
9
+ 'select.js',
10
+ 'pen.js',
11
+ 'edits.js',
12
+ 'references.js',
13
+ 'shots.js',
14
+ 'variants.js',
15
+ 'composer.js',
16
+ 'panel.js',
17
+ 'check.js',
18
+ 'devices.js',
19
+ 'keyboard.js',
20
+ 'boot.js',
21
+ ];
22
+
23
+ /**
24
+ * Build the browser overlay as one classic script: the shared formatter with
25
+ * its ES `export` removed, followed by the overlay files, all inside one IIFE.
26
+ */
27
+ export function buildClient() {
28
+ const format = read('./format.js').replace(/^export\s*\{[^}]*\};?\s*$/m, '');
29
+ const overlay = CLIENT_FILES.map((f) => `// ---- client/${f}\n` + read(`../client/${f}`)).join('\n');
30
+ const js = `/*! Redline design-feedback overlay */\n(function () {\n'use strict';\n${format}\n${overlay}\n})();\n`;
31
+ // Escape non-ASCII as \uXXXX so the script survives pages served without a charset.
32
+ return js.replace(/[^\x00-\x7f]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
33
+ }
package/src/demo.js ADDED
@@ -0,0 +1,137 @@
1
+ // `redline demo`: start at the payoff. Round 1 has already been played on a copy of
2
+ // the demo site: four marks were sent, three to fix and one to keep. A scripted agent
3
+ // fixed the three in the files and reported back, and, like agents do, quietly
4
+ // changed the one it was asked to keep. So the first thing a new user sees is
5
+ // "Check 4": three fixes to accept or redo, and a guard that caught the regression.
6
+ import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { createRedlineServer } from './server.js';
10
+
11
+ // A circle drawn around an element of about w×h, starting top-left and closing
12
+ // past its start (the way people circle things), relative to the element.
13
+ function circle(w, h, pad = 16) {
14
+ const pts = [];
15
+ for (let t = -0.35; t <= Math.PI * 2 + 0.2; t += 0.12) {
16
+ pts.push([Math.round(w / 2 + Math.cos(t + Math.PI) * (w / 2 + pad)), Math.round(h / 2 + Math.sin(t + Math.PI) * (h / 2 + pad))]);
17
+ }
18
+ return pts;
19
+ }
20
+
21
+ const CARD = { w: 334, h: 214, x: 814, y: 1062 }; // .card-odd at 1280px, near enough
22
+
23
+ /** The marks a reviewer might leave on the demo site's first draft, and how the agent fixed each. */
24
+ export const DEMO_ROUND = [
25
+ {
26
+ mark: {
27
+ kind: 'note',
28
+ verdict: 'fix',
29
+ comment: 'All caps feels shouty. Sentence case, and about half the size.',
30
+ target: { selector: 'h1.hero-title', html: '<h1 class="hero-title">', text: 'THE ULTIMATE REVOLUTIONARY COFFEE TRACKING PLATFORM FOR EVERYONE' },
31
+ section: { selector: 'section.hero', heading: 'THE ULTIMATE REVOLUTIONARY COFFEE TRACKING PLATFORM FOR EVERYONE' },
32
+ },
33
+ edits: [
34
+ ['index.html', 'THE ULTIMATE REVOLUTIONARY COFFEE TRACKING PLATFORM FOR EVERYONE', 'Dial in every cup'],
35
+ ['styles.css', '.hero-title { font-size: 76px;', '.hero-title { font-size: 48px;'],
36
+ ],
37
+ note: 'Headline is now "Dial in every cup", sentence case at 48px (44px on mobile)',
38
+ },
39
+ {
40
+ mark: {
41
+ kind: 'stroke',
42
+ verdict: 'fix',
43
+ comment: "Doesn't match the other two cards",
44
+ // A selector that still finds the card once its one-off class is gone.
45
+ target: { selector: '#features article:nth-of-type(3)', html: '<article class="card card-odd">', text: '🫘 Your bean library Scan the bag, and origin, process and roaster notes fill in automatically.', rect: { x: CARD.x, y: CARD.y, w: CARD.w, h: CARD.h } },
46
+ stroke: { local: [circle(CARD.w, CARD.h)], paths: [circle(CARD.w, CARD.h).map(([x, y]) => [x + CARD.x, y + CARD.y])], width: 4 },
47
+ section: { selector: '#features', heading: 'Everything you need to brew better' },
48
+ },
49
+ edits: [
50
+ ['index.html', '<article class="card card-odd">', '<article class="card">'],
51
+ ['styles.css', '.card-odd { background: #fde68a; border-radius: 2px; padding: 12px; }\n', ''],
52
+ ],
53
+ note: 'Removed the one-off yellow style: the bean library card now matches the other two',
54
+ },
55
+ {
56
+ mark: {
57
+ kind: 'note',
58
+ verdict: 'fix',
59
+ comment: 'The Pro card floats above the others. Line the tops up.',
60
+ target: { selector: 'div.plan.plan-featured', html: '<div class="plan plan-featured">', text: 'Pro $6/mo Unlimited brews Recipe suggestions Sync everywhere Export to CSV Go Pro' },
61
+ section: { selector: '#pricing', heading: 'Pricing' },
62
+ },
63
+ edits: [['styles.css', ' border: 2px solid var(--brand); transform: translateY(-12px); }', ' border: 2px solid var(--brand); }']],
64
+ note: 'Dropped the -12px lift, so all three plans start on the same line; the border still marks Pro',
65
+ },
66
+ {
67
+ // A keep. The agent doesn't resolve it (there was nothing to do), but its
68
+ // tidy-up of the type scale shrinks the quote anyway: the guard catches it.
69
+ mark: {
70
+ kind: 'note',
71
+ verdict: 'good',
72
+ comment: 'Love this quote. Keep it big and italic.',
73
+ target: { selector: 'section.quote > blockquote', html: '<blockquote>', text: '“I finally know why Tuesday’s V60 was amazing and Wednesday’s wasn’t.”' },
74
+ section: { selector: 'section.quote', heading: '' },
75
+ },
76
+ edits: [['styles.css', '.quote blockquote { font-size: 32px; font-style: italic;', '.quote blockquote { font-size: 24px; font-style: normal;']],
77
+ },
78
+ ];
79
+
80
+ export const DEMO_REPORT = {
81
+ summary: 'Calmer headline, one card style for all three features, the pricing cards aligned, and a tidier type scale.',
82
+ reading: 'A landing page that sells a coffee-logging app to home baristas, and gets them to start the free plan.',
83
+ questions: ['Should the Café plan offer a trial instead of "Contact sales"?'],
84
+ };
85
+
86
+ const until = async (fn, ms) => {
87
+ for (const t0 = Date.now(); Date.now() - t0 < ms; await new Promise((r) => setTimeout(r, 200))) if (await fn()) return true;
88
+ return false;
89
+ };
90
+
91
+ /**
92
+ * Play round 1 against a running server: send the marks, let the screenshots of the
93
+ * page as reviewed be taken, apply the fixes to `siteDir`, resolve and report.
94
+ */
95
+ export async function playDemoRound(server, siteDir, { shotWait = 45000 } = {}) {
96
+ const { store, shots } = server;
97
+ const items = [];
98
+ for (const { mark } of DEMO_ROUND) items.push(await store.create({ ...mark, page: '/', draft: true }));
99
+ await store.send();
100
+ // The "before" pictures (and the keep's baseline) must show the first draft, so wait for them before editing it.
101
+ const ready = (a) => a.shots?.before && (a.verdict !== 'good' || a.guard?.base);
102
+ if (shots.available) {
103
+ const ids = new Set(items.map((a) => a.id));
104
+ await until(async () => (await store.all()).filter((a) => ids.has(a.id) && ready(a)).length === ids.size, shotWait);
105
+ }
106
+ for (const { edits } of DEMO_ROUND) {
107
+ for (const [file, from, to] of edits) {
108
+ const path = join(siteDir, file);
109
+ const src = await readFile(path, 'utf8');
110
+ if (!src.includes(from)) throw new Error(`demo fix does not apply to ${file}: ${from.slice(0, 40)}`);
111
+ await writeFile(path, src.replace(from, to));
112
+ }
113
+ }
114
+ for (const [i, { note }] of DEMO_ROUND.entries()) if (note) await store.resolve(items[i].id, note);
115
+ await store.finishRound(DEMO_REPORT);
116
+ // Closing the round runs the guards: wait for the verdict on the keep.
117
+ if (shots.available) {
118
+ const kept = items.filter((a) => a.verdict === 'good').map((a) => a.id);
119
+ await until(async () => (await store.all()).filter((a) => kept.includes(a.id) && a.guard?.now).length === kept.length, shotWait);
120
+ }
121
+ return items;
122
+ }
123
+
124
+ /** Copy the demo site somewhere disposable, serve it, and play round 1. */
125
+ export async function startDemo({ port = 4545, host = '127.0.0.1', log = () => {} } = {}) {
126
+ const dir = await mkdtemp(join(tmpdir(), 'redline-demo-'));
127
+ const siteDir = join(dir, 'site');
128
+ await cp(new URL('../examples/demo-site', import.meta.url), siteDir, { recursive: true });
129
+ const server = createRedlineServer({ root: siteDir, storeDir: join(dir, '.redline'), log, demo: true });
130
+ await new Promise((ok, fail) => {
131
+ server.once('error', fail);
132
+ server.listen(port, host, ok);
133
+ });
134
+ const cleanup = () => rm(dir, { recursive: true, force: true });
135
+ server.on('close', cleanup);
136
+ return { server, dir, siteDir, ready: playDemoRound(server, siteDir) };
137
+ }