Initial commit
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

This commit is contained in:
Your Name committed 2026-10-11 18:09:42 +02:00
1 parent 1ba45f88aa
commit 9e6804c1ab
77 files changed
+3888

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
'use strict';
// Runs the real Ink app against a seeded demo vault and saves 16:10 screenshots.
// electron scripts/capture/capture.cjs --ozone-platform=headless --force-device-scale-factor=1.5
// Env: INK_APP (path to the Ink repo, read only), OUT (output dir), THEMES (comma list), SCENES (comma list)
const { app, BrowserWindow } = require('electron');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const INK = process.env.INK_APP || path.resolve(__dirname, '../../../../App/Ink');
const OUT = process.env.OUT || path.resolve(__dirname, '../../src/assets/screens');
const THEMES = (process.env.THEMES || 'dark,light').split(',');
const ONLY = process.env.SCENES ? process.env.SCENES.split(',') : null;
const { seedVault } = require('./seed-vault.cjs');
const scenes = require('./scenes.cjs');
const W = 1600, H = 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const theme = process.env.THEME || THEMES[0];
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-shots-'));
const vault = path.join(tmp, 'Notes');
fs.mkdirSync(vault);
const seeded = seedVault(vault);
// No network: a link card gets a fixed title and description instead of fetching.
const linkpreview = require(path.join(INK, 'electron/linkpreview.cjs'));
linkpreview.hooks.lookup = async () => [{ address: '93.184.216.34' }];
linkpreview.hooks.fetch = async () => new Response(
'<html><head><title>JSON Canvas</title><meta property="og:title" content="JSON Canvas"><meta name="description" content="An open file format for infinite canvas data.">' +
'</head><body></body></html>', { status: 200, headers: { 'content-type': 'text/html' } });
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({
vault, lastPage: seeded.lastPage, theme, bounds: { width: W, height: H },
fontSize: 16, outline: false, sidebarCollapsed: false, tagsOpen: true,
vaults: { [vault]: seeded.vaultSettings },
}));
app.commandLine.appendSwitch('lang', 'en-US');
require(path.join(INK, 'electron/main.cjs'));
app.whenReady().then(async () => {
let win;
for (let i = 0; i < 100 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
win.setContentSize(W, H);
const wc = win.webContents;
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); };
const type = async (t, d = 45) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(d); } await sleep(200); };
const click = async (x, y) => { wc.sendInputEvent({ type: 'mouseMove', x, y }); wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 }); wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 }); await sleep(250); };
const shot = async (name) => {
await sleep(500);
const img = await wc.capturePage();
const file = path.join(OUT, `${name}-${theme}.png`);
fs.mkdirSync(OUT, { recursive: true });
fs.writeFileSync(file, img.toPNG());
const s = img.getSize();
console.log(`saved ${path.basename(file)} ${s.width}x${s.height}`);
};
const ctx = { win, wc, js, key, type, click, shot, sleep, vault, W, H };
try {
await sleep(2200);
for (const [name, fn] of Object.entries(scenes)) {
if (ONLY && !ONLY.includes(name)) continue;
try { await fn(ctx); } catch (e) { console.log(`FAILED ${name}: ${e.message}`); }
}
} finally {
app.exit(0);
}
});
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
# Captures all website screenshots from the real Ink app (read only) into src/assets/screens/.
# Usage: scripts/capture/run.sh (env: INK_APP, OUT, SCENES)
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
INK_APP="${INK_APP:-$HERE/../../../../App/Ink}"
INK_APP="$(cd "$INK_APP" && pwd)"
export INK_APP
for theme in dark light; do
THEME=$theme "$INK_APP/node_modules/.bin/electron" "$HERE/capture.cjs" \
--ozone-platform=headless --force-device-scale-factor=1.5 --no-sandbox 2>&1 | grep -E 'saved|FAILED|Error' || true
done
+135
View File
@@ -0,0 +1,135 @@
'use strict';
// Each scene leaves the app looking the way the website screenshot should, then calls shot(name).
// Scenes run in order in one session; each one starts by getting back to a known state.
// Cosmetic: show the default notes location instead of the temp folder this run uses.
const tidyPath = (js) => js(`
for (const el of document.querySelectorAll('.ws-path, .mono')) {
if (/^\\/tmp\\/ink-shots-[^/]+\\/Notes$/.test(el.textContent.trim())) { el.textContent = '~/Notes'; el.title = '~/Notes'; }
}`);
const reset = async ({ key, sleep, js }) => {
await tidyPath(js);
for (let i = 0; i < 3; i++) await key('Escape');
await js(`document.activeElement?.blur?.()`);
await sleep(150);
};
// Open a page the way a user would: Ctrl+K, type its name, Enter.
const openFromSidebar = async ({ key, type, sleep }, text) => {
await key('K', ['control']);
await sleep(400);
await type(text, 30);
await sleep(700);
await key('Return');
await sleep(1000);
return true;
};
// The element that actually scrolls the page.
const scrollPage = (js, y) => js(`
const c = [...document.querySelectorAll('*')].filter((e) => { const s = getComputedStyle(e); return /(auto|scroll)/.test(s.overflowY) && e.scrollHeight > e.clientHeight + 20 && e.clientWidth > 600; })
.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight)[0];
if (c) { c.scrollTop = ${y}; return c.className; } return null;`);
module.exports = {
async editor(ctx) {
const { js, sleep, shot } = ctx;
await tidyPath(js);
await reset(ctx);
await openFromSidebar(ctx, 'Roadmap');
await scrollPage(js, 120);
await sleep(300);
await shot('editor');
},
async 'slash-menu'(ctx) {
const { js, type, sleep, shot, key } = ctx;
await reset(ctx);
await openFromSidebar(ctx, 'Launch plan');
await scrollPage(js, 170);
await sleep(300);
await js(`document.querySelector('.tail').click()`);
await sleep(400);
await type('/');
await sleep(500);
await shot('slash-menu');
await key('Escape');
},
async linking(ctx) {
const { js, sleep, shot } = ctx;
await reset(ctx);
await openFromSidebar(ctx, 'Reading list');
await scrollPage(js, 2000);
await sleep(600);
await shot('linking');
},
async canvas(ctx) {
const { sleep, shot, key } = ctx;
await reset(ctx);
await openFromSidebar(ctx, 'Project map');
await sleep(1200);
await key('1', ['shift']);
await sleep(900);
await shot('canvas');
},
async board(ctx) {
const { sleep, shot } = ctx;
await reset(ctx);
await openFromSidebar(ctx, 'Sprint board');
await sleep(900);
await scrollPage(ctx.js, 200);
await sleep(400);
await shot('board');
},
async graph(ctx) {
const { js, sleep, shot, key } = ctx;
await reset(ctx);
await js(`[...document.querySelectorAll('.sidebar .qa')].find((b) => b.textContent.includes('Graph view')).click()`);
await sleep(3500);
await js(`document.querySelector('.graph-top [aria-label="Graph settings"]')?.click()`);
await sleep(900);
await shot('graph');
await key('Escape');
},
async palette(ctx) {
const { sleep, shot, key, type } = ctx;
await reset(ctx);
await openFromSidebar(ctx, 'Roadmap');
await key('K', ['control']);
await sleep(500);
await type('tag:work road');
await sleep(700);
await shot('palette');
await key('Escape');
},
async settings(ctx) {
const { sleep, shot, key } = ctx;
await reset(ctx);
await openFromSidebar(ctx, 'Roadmap');
await key(',', ['control']);
await sleep(900);
await tidyPath(js);
await sleep(200);
await shot('settings');
await key('Escape');
},
async history(ctx) {
const { js, sleep, shot, key } = ctx;
await reset(ctx);
await openFromSidebar(ctx, 'Roadmap');
await js(`document.querySelector('[aria-label="Page options"]')?.click()`);
await sleep(500);
await js(`[...document.querySelectorAll('[role="menuitem"]')].find((m) => m.textContent.includes('Version history'))?.click()`);
await sleep(1200);
await shot('history');
await key('Escape');
},
};
+227
View File
@@ -0,0 +1,227 @@
'use strict';
// Builds a believable notes folder for the website screenshots. Usage: seedVault(dir)
const fs = require('node:fs');
const path = require('node:path');
const w = (root, rel, text) => {
const f = path.join(root, rel);
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, text);
};
const fm = (o) => {
const lines = Object.entries(o).map(([k, v]) => `${k}: ${Array.isArray(v) ? `[${v.join(', ')}]` : v}`);
return `---\n${lines.join('\n')}\n---\n`;
};
// [title, icon, tags, links, one-line body]
const STUBS = [
['Meeting notes', '🗓️', ['work'], ['Roadmap', 'Release checklist'], 'Weekly sync. Decided to ship Linux first and test the Windows installer on a clean machine before announcing it.'],
['Release checklist', '✅', ['work', 'ink'], ['Roadmap', 'Windows build', 'Docs'], 'Version bump, changelog, AppImage, checksum, website config.'],
['Windows build', '🪟', ['work', 'ink'], ['Release checklist', 'Roadmap'], 'NSIS installer is configured. Needs a test pass on a clean Windows install.'],
['Docs', '📖', ['work', 'ink'], ['Roadmap', 'Keyboard shortcuts'], 'Where notes live, Obsidian compatibility, shortcuts, privacy.'],
['Keyboard shortcuts', '⌨️', ['ink'], ['Docs'], 'Ctrl+K palette, Ctrl+N new page, Ctrl+G graph.'],
['Ink website', '🌐', ['work', 'ink'], ['Roadmap', 'Docs', 'Typography'], 'Static site. Calm, dark first, no analytics.'],
['Typography', '🔤', ['learning', 'ideas'], ['Ink website', 'Colour notes'], 'Inter for UI, a mono for code. Keep the measure around 66 characters.'],
['Colour notes', '🎨', ['learning', 'ideas'], ['Typography', 'Ink website'], 'Warm blush accent on near-black. Keep contrast above 4.5:1.'],
['Ideas', '💡', ['ideas'], ['Canvas experiments', 'Plugin ideas', 'Roadmap'], 'Things I might build one day.'],
['Canvas experiments', '🧪', ['ideas', 'ink'], ['Ideas', 'Project map'], 'Alignment guides, grouping, export. What else would make a canvas calmer?'],
['Plugin ideas', '🧩', ['ideas'], ['Ideas'], 'Probably not. Keep the core small.'],
['Reading list', '📚', ['reading'], ['The Pragmatic Programmer', 'A Pattern Language', 'Designing Data-Intensive Applications', 'Meeting notes'], 'Books and articles I want to get to.'],
['The Pragmatic Programmer', '📘', ['reading'], ['Reading list', 'Software craft'], 'Tracer bullets, orthogonality, DRY. Re-read every few years.'],
['A Pattern Language', '🏛️', ['reading', 'learning'], ['Reading list', 'Software craft'], 'Patterns that make places feel alive. Applies to software more than expected.'],
['Designing Data-Intensive Applications', '📗', ['reading', 'learning'], ['Reading list', 'SQLite notes'], 'Storage engines, replication, the trouble with distributed clocks.'],
['Software craft', '🛠️', ['learning'], ['The Pragmatic Programmer', 'A Pattern Language', 'Code review'], 'Small tools, sharp edges, patient iteration.'],
['Code review', '🔍', ['learning', 'work'], ['Software craft'], 'Review the change, not the person. Ask questions first.'],
['SQLite notes', '🗃️', ['learning', 'ink'], ['Designing Data-Intensive Applications', 'Full-text search'], 'FTS5, WAL mode, and why a single file is enough.'],
['Full-text search', '🔎', ['learning', 'ink'], ['SQLite notes', 'Roadmap'], 'Index lives next to the notes and can be rebuilt any time.'],
['Trip to Lisbon', '✈️', ['personal'], ['Packing list', 'Food to try'], 'Four days in April. Trams, tiles and custard tarts.'],
['Packing list', '🎒', ['personal'], ['Trip to Lisbon'], 'Light jacket, walking shoes, charger, notebook.'],
['Food to try', '🥐', ['personal'], ['Trip to Lisbon', 'Sourdough'], 'Pastéis de nata, grilled sardines, ginjinha.'],
['Sourdough', '🍞', ['personal'], ['Food to try'], '500 g flour, 350 g water, 100 g starter, 10 g salt. Fold every 30 minutes.'],
['Garden', '🌱', ['personal'], ['Sourdough', 'Planting calendar'], 'Tomatoes, basil, and a lemon tree that refuses to flower.'],
['Planting calendar', '📆', ['personal'], ['Garden'], 'March: sow tomatoes indoors. May: harden off and plant out.'],
['Running', '🏃', ['personal'], ['Training plan'], 'Easy runs mostly. Pace is a distraction.'],
['Training plan', '📈', ['personal'], ['Running'], 'Three runs a week, one long. Add 10% at most.'],
['Book club', '☕', ['reading', 'personal'], ['Reading list', 'A Pattern Language'], 'First Thursday of the month. Next: The Overstory.'],
['Gift ideas', '🎁', ['personal', 'ideas'], ['Book club'], 'Good pens, a nice notebook, tickets for something.'],
['Home projects', '🔧', ['personal'], ['Garden', 'Gift ideas'], 'Fix the squeaky door. Paint the hallway.'],
['Learning Rust', '🦀', ['learning'], ['Software craft', 'SQLite notes'], 'Ownership clicked after drawing it on a canvas.'],
['Weekly review', '🗒️', ['work', 'personal'], ['Roadmap', 'Meeting notes', 'Training plan'], 'What went well, what did not, what is next.'],
];
function seedVault(root) {
const stubTitles = new Set(STUBS.map((s) => s[0]));
// The hero page.
w(root, 'Roadmap.md', fm({ icon: '🗺️', tags: ['work', 'ink'], created: '2026-09-02' }) + `Where Ink is heading this quarter. Details live in [[Projects]], and every meeting ends up in [[Meeting notes]].
## Q4 goals
- [x] Ship the first public build as an AppImage
- [x] Canvas export to PNG, SVG and PDF
- [x] Version history for pages and canvases
- [ ] Test the Windows installer on a clean machine
- [ ] Write the docs, starting with [[Keyboard shortcuts]]
- [ ] Follow the [[Launch plan]]
> [!tip]
> Keep releases small. A calm app needs fewer surprises, not more features.
\`\`\`js
const release = {
version: '0.1.0',
platforms: ['linux'],
notes: 'plain .md files, offline, no account',
};
console.log(\`Shipping Ink \${release.version}\`);
\`\`\`
<details>
<summary>Why Linux first?</summary>
The AppImage is the only target built and tested so far. Windows and macOS are configured but still need a proper pass.
</details>
`);
w(root, 'Projects.md', fm({ icon: '📁', tags: ['work'] }) + `# Projects
- [[Ink website]]
- [[Windows build]]
- [[Docs]]
- [[Release checklist]]
Back to the [[Roadmap]]. Work in progress lives on the [[Sprint board]].
`);
w(root, 'Projects/Ink website.md', fm({ icon: '🌐', tags: ['work', 'ink'] }) + 'Static site, calm and dark-first. See [[Typography]] and [[Colour notes]].\n');
// Linking demo page.
w(root, 'Reading list.md', fm({ icon: '📚', tags: ['reading'] }) + `# Reading list
Books and articles I want to get to. I keep notes on each in [[The Pragmatic Programmer]] and [[A Pattern Language]], and the monthly [[Book club]] picks the next one.
## Currently
- [x] [[The Pragmatic Programmer]]
- [ ] [[A Pattern Language]]
- [ ] [[Designing Data-Intensive Applications]]
Highlights from the weekly sync are in [[Roadmap#Q4 goals]].
![[Meeting notes]]
`);
w(root, 'Meeting notes.md', fm({ icon: '🗓️', tags: ['work'] }) + `# Meeting notes
Weekly sync, 7 October.
- Ship Linux first, announce Windows once it is tested
- Keep the docs short and honest
- Review the website copy on Friday
Follow-ups go into [[Roadmap]], [[Release checklist]] and the [[Sprint board]].
`);
for (const [title, icon, tags, links, body] of STUBS) {
if (['Meeting notes', 'Reading list'].includes(title)) continue;
const rel = ['Ink website'].includes(title) ? null : `${title}.md`;
if (!rel) continue;
const refs = links.filter((l) => stubTitles.has(l) || ['Roadmap', 'Projects', 'Project map'].includes(l)).map((l) => `[[${l}]]`);
w(root, rel, fm({ icon, tags }) + `${body}\n\nSee also ${refs.join(', ')}.\n`);
}
// Daily notes.
w(root, 'Daily/2026-10-10.md', fm({ icon: '📅', tags: ['personal'] }) + '- Morning run, easy pace\n- Reviewed the [[Roadmap]]\n- Sourdough went well: see [[Sourdough]]\n');
w(root, 'Daily/2026-10-11.md', fm({ icon: '📅', tags: ['personal'] }) + '- [ ] Write the release notes\n- [ ] Pack for the weekend\n- [x] Call about the [[Trip to Lisbon]] flights\n');
// Board.
w(root, 'Sprint board.md', `---
kanban-plugin: basic
icon: 🗂️
tags: [work, ink]
---
## To do
- [ ] Windows installer test pass
- [ ] macOS dmg build
- [ ] Docs: where notes are stored
- [ ] Verify checksum instructions
## Doing
- [ ] Website screenshots
- [ ] Changelog for 0.1.0
- [ ] Spellcheck language picker polish
## Done
- [x] AppImage build
- [x] Canvas export (PNG, SVG, PDF)
- [x] Version history for canvases
- [x] Graph view filters
%% kanban:settings
\`\`\`
{"kanban-plugin":"basic"}
\`\`\`
%%
`);
// Canvas: cards, a live note preview, a link card, a group, and arrows.
const id = (n) => `n${String(n).padStart(15, '0')}`;
const canvas = {
nodes: [
{ id: id(1), type: 'group', label: 'Q4', x: -60, y: -60, width: 760, height: 520, color: '6' },
{ id: id(2), type: 'text', text: '## Research\n\n- user interviews\n- survey of 40 people\n- what makes notes calm?', x: 0, y: 20, width: 300, height: 190 },
{ id: id(3), type: 'text', text: '## Principles\n\n1. Plain files\n2. Offline first\n3. No lock-in', x: 0, y: 270, width: 300, height: 160, color: '4' },
{ id: id(4), type: 'file', file: 'Roadmap.md', subpath: '#Q4 goals', x: 400, y: 20, width: 270, height: 330 },
{ id: id(5), type: 'link', url: 'https://jsoncanvas.org', x: 860, y: 40, width: 300, height: 150 },
{ id: id(6), type: 'text', text: '## Ship it\n\nAppImage first. Windows and macOS when tested.', x: 860, y: 300, width: 300, height: 140, color: '1' },
],
edges: [
{ id: 'e0000000000000a1', fromNode: id(2), fromSide: 'right', toNode: id(4), toSide: 'left', label: 'informs' },
{ id: 'e0000000000000a2', fromNode: id(3), fromSide: 'right', toNode: id(4), toSide: 'bottom' },
{ id: 'e0000000000000a3', fromNode: id(4), fromSide: 'right', toNode: id(5), toSide: 'left', label: 'format' },
{ id: 'e0000000000000a4', fromNode: id(4), fromSide: 'bottom', toNode: id(6), toSide: 'left' },
],
};
w(root, 'Project map.canvas', JSON.stringify(canvas, null, '\t'));
// A short page for the slash-menu shot, with room under the last line.
w(root, 'Launch plan.md', fm({ icon: '🚀', tags: ['work', 'ink'] }) + `Everything we need before the first public build goes out.
The release is a single AppImage, so the checklist is short: bump the version, write the changelog and publish the checksum next to the download.
Once that is done, the website can go live with a plain download button and nothing else.
`);
// Earlier versions of the Roadmap page, so version history has something to show.
const now = Date.now();
const hist = [
[20 * 60e3, 'Where Ink is heading this quarter.\n\n## Q4 goals\n\n- [x] Ship the first public build as an AppImage\n- [x] Canvas export to PNG, SVG and PDF\n- [x] Version history for pages and canvases\n- [ ] Test the Windows installer on a clean machine\n'],
[3 * 3600e3, 'Where Ink is heading this quarter.\n\n## Q4 goals\n\n- [x] Ship the first public build as an AppImage\n- [x] Canvas export to PNG, SVG and PDF\n- [ ] Version history for pages and canvases\n- [ ] Test the Windows installer on a clean machine\n'],
[26 * 3600e3, 'Where Ink is heading this quarter.\n\n## Q4 goals\n\n- [x] Ship the first public build as an AppImage\n- [ ] Canvas export to PNG, SVG and PDF\n- [ ] Windows installer\n'],
[49 * 3600e3, 'Roadmap for the quarter.\n\n- [ ] AppImage\n- [ ] Canvas export\n- [ ] Windows installer\n'],
[4 * 86400e3, 'Roadmap\n\n- first build\n- canvas\n'],
[9 * 86400e3, 'Ideas for later.\n'],
];
for (const [ago, body] of hist) {
w(root, `.notes/history/Roadmap/${now - ago}.md`, fm({ icon: '🗺️', tags: ['work', 'ink'], created: '2026-09-02' }) + body);
}
// Sidebar niceties and tag colours are settings, returned for the caller.
return {
lastPage: 'Roadmap',
vaultSettings: {
pins: ['Roadmap', 'Reading list', 'Sprint board', 'Project map.canvas'],
recent: ['Roadmap', 'Reading list', 'Sprint board'],
tagColors: { work: 'blue', ink: 'pink', reading: 'yellow', learning: 'teal', personal: 'green', ideas: 'purple' },
},
};
}
module.exports = { seedVault };
+59
View File
@@ -0,0 +1,59 @@
// Regenerates favicons, the small touch icons and placeholder screenshots.
// Run: bun run assets (placeholders never overwrite a file that already exists)
import sharp from 'sharp';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { screens } from '../src/lib/screens.ts';
const root = new URL('..', import.meta.url).pathname;
const NIB =
'M330 340 C330 296 362 266 406 266 H618 C662 266 694 296 694 340 C694 448 646 528 598 600 C560 656 530 724 512 806 C494 724 464 656 426 600 C378 528 330 448 330 340 Z';
// Favicon: the app's nib on a flat tile (the full icon's filters are too heavy for 16px).
const favicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 100 824 824">
<defs><linearGradient id="n" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f7d3c9"/><stop offset=".6" stop-color="#e4ada5"/><stop offset="1" stop-color="#c97a6d"/></linearGradient></defs>
<rect x="100" y="100" width="824" height="824" rx="190" fill="#1a1a1c"/>
<path d="${NIB}" fill="url(#n)"/>
<path d="M512 520 V800" stroke="#2a1a16" stroke-width="22" stroke-linecap="round"/>
<circle cx="512" cy="500" r="34" fill="#2a1a16"/>
</svg>`;
writeFileSync(`${root}public/favicon.svg`, favicon);
for (const [file, size] of [['favicon-32.png', 32], ['apple-touch-icon.png', 180], ['icon-192.png', 192], ['icon-512.png', 512]] as const) {
await sharp(Buffer.from(favicon), { density: 384 }).resize(size, size).png().toFile(`${root}public/${file}`);
}
// Placeholder screenshots: 16:10 wireframes that say what to capture.
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
function wrap(text: string, max: number): string[] {
const out: string[] = [];
let line = '';
for (const w of text.split(' ')) {
if ((line + ' ' + w).trim().length > max) { out.push(line); line = w; } else line = (line + ' ' + w).trim();
}
return line ? [...out, line] : out;
}
function placeholder(name: string, capture: string, theme: 'dark' | 'light') {
const c = theme === 'dark'
? { ground: '#0e0e0f', island: '#161617', bar: 'rgba(255,255,255,.07)', ink: '#ececed', mute: '#a1a1a6', accent: '#e4ada5' }
: { ground: '#e7e7e9', island: '#fbfbfb', bar: 'rgba(0,0,0,.07)', ink: '#1c1c1e', mute: '#5c5c61', accent: '#b8695f' };
const lines = wrap(capture, 62).slice(0, 5);
const side = [0, 1, 2, 3, 4, 5, 6].map((i) => `<rect x="48" y="${100 + i * 44}" width="${150 - (i % 3) * 30}" height="14" rx="7" fill="${c.bar}"/>`).join('');
const text = [0, 1, 2, 3].map((i) => `<rect x="${340}" y="${280 + i * 40}" width="${760 - (i % 2) * 160}" height="12" rx="6" fill="${c.bar}"/>`).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000" font-family="Inter, system-ui, sans-serif">
<rect width="1600" height="1000" fill="${c.ground}"/>
<rect x="20" y="20" width="248" height="960" rx="14" fill="${c.island}"/>${side}
<rect x="288" y="20" width="1292" height="960" rx="14" fill="${c.island}"/>
<rect x="340" y="110" width="420" height="30" rx="8" fill="${c.bar}"/>${text}
<text x="800" y="640" text-anchor="middle" font-size="30" font-weight="600" fill="${c.accent}">Placeholder: ${esc(name)}-${theme}.png</text>
${lines.map((l, i) => `<text x="800" y="${690 + i * 30}" text-anchor="middle" font-size="20" fill="${c.mute}">${esc(l)}</text>`).join('')}
</svg>`;
}
mkdirSync(`${root}src/assets/screens`, { recursive: true });
for (const [name, spec] of Object.entries(screens)) {
for (const theme of ['dark', 'light'] as const) {
const path = `${root}src/assets/screens/${name}-${theme}.svg`;
// Real captures (see scripts/capture) win; don't recreate a placeholder they shadow.
const real = ['png', 'webp', 'jpg'].some((e) => existsSync(path.replace(/\.svg$/, `.${e}`)));
if (!real && !existsSync(path)) writeFileSync(path, placeholder(name, spec.capture, theme));
}
}
console.log('assets done');