|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * scripts/capture-screenshots.js |
| 4 | + * |
| 5 | + * Re-generates the README walkthrough screenshots under |
| 6 | + * docs/assets/screenshots/. Run from the repo root: |
| 7 | + * |
| 8 | + * npm i --no-save playwright # if not already |
| 9 | + * npx playwright install chromium # one-time browser download |
| 10 | + * node scripts/capture-screenshots.js |
| 11 | + * |
| 12 | + * The script serves the static frontend on a local port and mocks |
| 13 | + * /api/health, /api/run, /api/evaluate, /api/chat so the UI shows |
| 14 | + * realistic states without requiring Ollama to be running. The |
| 15 | + * mocked responses are deterministic and do not represent real |
| 16 | + * model output — they exist purely to make the UI screenshots |
| 17 | + * reproducible. If you want screenshots of *real* model output, |
| 18 | + * start the backend (./run.sh) and point a browser at it manually. |
| 19 | + */ |
| 20 | +'use strict'; |
| 21 | + |
| 22 | +const path = require('path'); |
| 23 | +const http = require('http'); |
| 24 | +const fs = require('fs'); |
| 25 | + |
| 26 | +let chromium; |
| 27 | +try { |
| 28 | + ({ chromium } = require('playwright')); |
| 29 | +} catch (_) { |
| 30 | + console.error('playwright is not installed. Run: npm i --no-save playwright && npx playwright install chromium'); |
| 31 | + process.exit(1); |
| 32 | +} |
| 33 | + |
| 34 | +const REPO = path.resolve(__dirname, '..'); |
| 35 | +const FRONTEND = path.join(REPO, 'frontend'); |
| 36 | +const OUT = path.join(REPO, 'docs/assets/screenshots'); |
| 37 | + |
| 38 | +const MIME = { |
| 39 | + '.html': 'text/html; charset=utf-8', |
| 40 | + '.js': 'application/javascript; charset=utf-8', |
| 41 | + '.css': 'text/css; charset=utf-8', |
| 42 | + '.json': 'application/json; charset=utf-8', |
| 43 | + '.svg': 'image/svg+xml', |
| 44 | + '.png': 'image/png', |
| 45 | + '.ico': 'image/x-icon', |
| 46 | +}; |
| 47 | + |
| 48 | +function serveStatic(root, port) { |
| 49 | + const server = http.createServer((req, res) => { |
| 50 | + let urlPath = req.url.split('?')[0]; |
| 51 | + if (urlPath === '/' || urlPath === '') urlPath = '/index.html'; |
| 52 | + const fp = path.normalize(path.join(root, urlPath)); |
| 53 | + if (!fp.startsWith(root)) { res.statusCode = 403; return res.end('forbidden'); } |
| 54 | + fs.stat(fp, (err, stat) => { |
| 55 | + if (err || !stat.isFile()) { res.statusCode = 404; return res.end('not found'); } |
| 56 | + const ext = path.extname(fp).toLowerCase(); |
| 57 | + res.setHeader('content-type', MIME[ext] || 'application/octet-stream'); |
| 58 | + res.setHeader('cache-control', 'no-store'); |
| 59 | + fs.createReadStream(fp).pipe(res); |
| 60 | + }); |
| 61 | + }); |
| 62 | + return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(server))); |
| 63 | +} |
| 64 | + |
| 65 | +function mockApi(route) { |
| 66 | + const url = route.request().url(); |
| 67 | + const u = new URL(url); |
| 68 | + if (u.pathname === '/api/health') { |
| 69 | + return route.fulfill({ |
| 70 | + status: 200, contentType: 'application/json', |
| 71 | + body: JSON.stringify({ |
| 72 | + ok: true, |
| 73 | + ollama: { ok: true, model: 'gemma3:4b', host: 'http://localhost:11434' }, |
| 74 | + version: '0.1.0', |
| 75 | + }), |
| 76 | + }); |
| 77 | + } |
| 78 | + if (u.pathname === '/api/run') { |
| 79 | + return route.fulfill({ |
| 80 | + status: 200, contentType: 'application/json', |
| 81 | + body: JSON.stringify({ |
| 82 | + stdout: "Hello, tutor!\nx = 42\ntype(x) = <class 'int'>\n", |
| 83 | + stderr: '', exit_code: 0, duration_ms: 47, timed_out: false, truncated: false, |
| 84 | + }), |
| 85 | + }); |
| 86 | + } |
| 87 | + if (u.pathname === '/api/evaluate') { |
| 88 | + return route.fulfill({ |
| 89 | + status: 200, contentType: 'application/json', |
| 90 | + body: JSON.stringify({ |
| 91 | + assessment: 'on_track', |
| 92 | + feedback: |
| 93 | + "Nice — you read the value into `x`, printed it, and asked Python for its type. " + |
| 94 | + "That's exactly the variables-and-types loop in miniature.\n\n" + |
| 95 | + "One small nudge: try assigning a *different* type to the same name (`x = \"hello\"`) " + |
| 96 | + "and re-print `type(x)`. Notice how the *name* doesn't change type, the *object* does — " + |
| 97 | + "this is the heart of Python's dynamic typing.", |
| 98 | + next_step: "Re-bind `x` to a string, then to a list, and print `type(x)` each time.", |
| 99 | + model: 'gemma3:4b', |
| 100 | + docs: { |
| 101 | + online: true, online_ok: true, |
| 102 | + references: [ |
| 103 | + { url: 'https://docs.python.org/3/library/stdtypes.html', label: 'Built-in types' }, |
| 104 | + { url: 'https://docs.python.org/3/library/functions.html#type', label: 'type()' }, |
| 105 | + ], |
| 106 | + }, |
| 107 | + }), |
| 108 | + }); |
| 109 | + } |
| 110 | + if (u.pathname === '/api/chat') { |
| 111 | + return route.fulfill({ |
| 112 | + status: 200, contentType: 'application/json', |
| 113 | + body: JSON.stringify({ |
| 114 | + reply: |
| 115 | + "In Python every value is an *object*, and each object knows its own type. " + |
| 116 | + "Names are just labels you stick on objects.", |
| 117 | + model: 'gemma3:4b', |
| 118 | + }), |
| 119 | + }); |
| 120 | + } |
| 121 | + return route.continue(); |
| 122 | +} |
| 123 | + |
| 124 | +async function shot(page, name) { |
| 125 | + const file = path.join(OUT, name); |
| 126 | + await page.screenshot({ path: file, fullPage: false }); |
| 127 | + console.log('wrote', path.relative(REPO, file)); |
| 128 | +} |
| 129 | + |
| 130 | +async function main() { |
| 131 | + fs.mkdirSync(OUT, { recursive: true }); |
| 132 | + const port = 8773; |
| 133 | + const server = await serveStatic(FRONTEND, port); |
| 134 | + const base = `http://127.0.0.1:${port}/`; |
| 135 | + |
| 136 | + const browser = await chromium.launch(); |
| 137 | + const ctx = await browser.newContext({ |
| 138 | + viewport: { width: 1280, height: 800 }, |
| 139 | + deviceScaleFactor: 2, |
| 140 | + colorScheme: 'dark', |
| 141 | + }); |
| 142 | + await ctx.route('**/sw.js', (r) => r.fulfill({ status: 404, body: '' })); |
| 143 | + await ctx.route('**/api/**', mockApi); |
| 144 | + const page = await ctx.newPage(); |
| 145 | + |
| 146 | + // 01 — home |
| 147 | + await page.goto(base, { waitUntil: 'networkidle' }); |
| 148 | + await page.waitForSelector('.hero'); |
| 149 | + await page.waitForTimeout(400); |
| 150 | + await shot(page, '01-home.png'); |
| 151 | + |
| 152 | + // 02 — lesson browser |
| 153 | + await page.goto(base + '#/beginner', { waitUntil: 'networkidle' }); |
| 154 | + await page.waitForSelector('#view-browser:not([hidden])'); |
| 155 | + await page.waitForTimeout(500); |
| 156 | + await shot(page, '02-lesson-browser.png'); |
| 157 | + |
| 158 | + // 03 — section view |
| 159 | + await page.goto(base, { waitUntil: 'networkidle' }); |
| 160 | + const sectionKey = await page.evaluate(async () => { |
| 161 | + const res = await fetch('/content/sections.json'); |
| 162 | + const d = await res.json(); |
| 163 | + return d.sections[0].key; |
| 164 | + }); |
| 165 | + await page.goto(`${base}#/s/${sectionKey}`, { waitUntil: 'networkidle' }); |
| 166 | + await page.waitForSelector('#view-section:not([hidden])'); |
| 167 | + await page.waitForSelector('.codelab'); |
| 168 | + await page.evaluate(() => window.scrollTo(0, 0)); |
| 169 | + await page.waitForTimeout(400); |
| 170 | + await shot(page, '03-section-view.png'); |
| 171 | + |
| 172 | + // 04 — code lab run |
| 173 | + await page.evaluate(() => { |
| 174 | + const ta = document.querySelector('#codelabEditor'); |
| 175 | + if (ta) { |
| 176 | + ta.value = 'x = 42\nprint("Hello, tutor!")\nprint("x =", x)\nprint("type(x) =", type(x))\n'; |
| 177 | + ta.dispatchEvent(new Event('input', { bubbles: true })); |
| 178 | + } |
| 179 | + }); |
| 180 | + await page.click('#codelabRun'); |
| 181 | + await page.waitForSelector('.codelab__runline'); |
| 182 | + await page.evaluate(() => { |
| 183 | + const el = document.querySelector('.codelab'); |
| 184 | + if (el) el.scrollIntoView({ block: 'start' }); |
| 185 | + window.scrollBy(0, -80); |
| 186 | + }); |
| 187 | + await page.waitForTimeout(400); |
| 188 | + await shot(page, '04-code-lab-run.png'); |
| 189 | + |
| 190 | + // 05 — evaluate feedback |
| 191 | + await page.fill('#codelabQuestion', 'Why does type(x) change when I reassign x?'); |
| 192 | + await page.click('#codelabEval'); |
| 193 | + await page.waitForSelector('.codelab__feedback'); |
| 194 | + await page.evaluate(() => { |
| 195 | + const el = document.querySelector('.codelab__feedback'); |
| 196 | + if (el) el.scrollIntoView({ block: 'start' }); |
| 197 | + window.scrollBy(0, -80); |
| 198 | + }); |
| 199 | + await page.waitForTimeout(400); |
| 200 | + await shot(page, '05-evaluate-feedback.png'); |
| 201 | + |
| 202 | + // 06 — floating chat panel |
| 203 | + await page.evaluate(() => window.scrollTo(0, 0)); |
| 204 | + await page.waitForTimeout(200); |
| 205 | + await page.click('#tutorChatFab'); |
| 206 | + await page.waitForSelector('#tutorChatPanel:not([hidden])'); |
| 207 | + await page.evaluate(async () => { |
| 208 | + try { |
| 209 | + const r = await fetch('/api/health'); |
| 210 | + const d = await r.json(); |
| 211 | + const sub = document.getElementById('tutorChatSub'); |
| 212 | + const banner = document.getElementById('tutorChatBanner'); |
| 213 | + if (sub) sub.textContent = `Connected · ${d.ollama?.model || 'local model'} · /api ready`; |
| 214 | + if (banner) { banner.hidden = true; banner.textContent = ''; } |
| 215 | + } catch (_) {} |
| 216 | + }); |
| 217 | + await page.evaluate(() => { |
| 218 | + const log = document.getElementById('tutorChatLog'); |
| 219 | + if (!log) return; |
| 220 | + const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); |
| 221 | + const codeBlock = `x = 42 # x labels an int\nx = "hello" # now x labels a str — the int is GC'd\nprint(type(x))`; |
| 222 | + log.innerHTML = ` |
| 223 | + <div class="tutor-chat__msg tutor-chat__msg--user"> |
| 224 | + <p>In Python, what does it mean that variables are not typed?</p> |
| 225 | + </div> |
| 226 | + <div class="tutor-chat__msg tutor-chat__msg--assistant"> |
| 227 | + <p>In Python every value is an <em>object</em>, and each object knows its own type. Names (what other languages call variables) are just labels you stick on objects.</p> |
| 228 | + <pre class="tutor-chat__code"><code>${esc(codeBlock)}</code></pre> |
| 229 | + <p>So <code>x</code> itself isn't typed — the <em>object it points to</em> is. That's why <code>type(x)</code> can change between lines without any cast.</p> |
| 230 | + </div> |
| 231 | + `; |
| 232 | + log.scrollTop = log.scrollHeight; |
| 233 | + }); |
| 234 | + await page.waitForTimeout(400); |
| 235 | + await shot(page, '06-tutor-chat.png'); |
| 236 | + |
| 237 | + await browser.close(); |
| 238 | + server.close(); |
| 239 | +} |
| 240 | + |
| 241 | +main().catch((e) => { console.error(e); process.exit(1); }); |
0 commit comments