Merge pull request #1446 from feder-cr/hero-from-photos

docs: the README animation is built from photos in the tree, with new characters
This commit is contained in:
Federico authored and GitHub committed 2026-10-12 03:01:05 +02:00
commit 81eb3ba067
19 files changed
+1014

No files matched your search

+3
View File
@@ -30,3 +30,6 @@ vitest-report.json
# Editor and Python leftovers of the gates
__pycache__/
.venv/
# The README animation's frames: docs/hero/build.mjs writes them on every run, from the pages and photos beside it
/docs/hero/frames/
+57
View File
@@ -0,0 +1,57 @@
# The README animation
The six `docs/images/hero-*.gif` are built from this folder, in three layers, each
made from the one before:
| layer | where | what it is |
|---|---|---|
| photos | `photos/` | the pictures the animation shows |
| frames | `html/` | one page per layout that composes the photos; `renderLoop(t)` draws the frame at second `t` |
| GIFs | `docs/images/` | the frames, 12 a second, one palette per loop |
The repository's social preview, `docs/images/social-preview.png`, comes from the
same photos through one more page, `html/social.html`, as a single frame.
```sh
node docs/hero/build.mjs # all six GIFs and the social preview
node docs/hero/build.mjs wide # only hero-light.gif and hero-dark.gif
node docs/hero/build.mjs social # only the social preview
```
The social preview is not read from the tree: after a build it is uploaded by hand,
in the repository's Settings, under Social preview.
It needs the repository's `node_modules` (Playwright comes with `apps/web`), Python
with Pillow, NumPy and SciPy, and `ffmpeg` on `PATH`. The pages use Segoe UI and
Consolas, so the GIFs come out identical only on Windows. The frames land in
`docs/hero/frames/`, which is not tracked.
## The photos
| file | what it is | edit it? |
|---|---|---|
| `photos/characters.png` | the four characters on a white background | yes: this is the one to replace |
| `photos/characters-light.png` | the same, cut out for the light theme, shadows kept | no, `cut_out.py` writes it |
| `photos/characters-dark.png` | the same, cut out for the dark theme, shadows removed | no, `cut_out.py` writes it |
| `photos/trending.png` | the GitHub Trending page in the last scene's third card | yes |
New characters are a new `characters.png`, drawn on plain white, all four in one
row and about the same height. The build cuts them out again on every run.
## The pages
| page | builds | size |
|---|---|---|
| `html/wide.html` | `hero-light.gif`, `hero-dark.gif` | 1280 x 470 |
| `html/mobile.html` | `hero-mobile-light.gif`, `hero-mobile-dark.gif` | 720 x 620 |
| `html/small.html` | `hero-small-light.gif`, `hero-small-dark.gif` | 720 x 620 |
| `html/social.html` | `social-preview.png` | 1280 x 640 |
The three animated pages read `?theme=light` or `?theme=dark` and, opened in a browser, play
the whole loop as the GIF will: the scenes, their words and their timing are all
in the page. With `&frames` they stay still and wait for `renderLoop(t)`, which is
how the build takes one frame at a time. A change to a scene is a change to the
three pages, then a build.
The README picks the GIF by the reader's width and theme, in its `<picture>` at
the top.
+97
View File
@@ -0,0 +1,97 @@
// The README animation, rebuilt from its pieces: photos -> frames -> GIFs.
//
// 1. photos cut_out.py turns photos/characters.png into one cut-out per theme
// 2. frames each page in html/ composes the photos; renderLoop(t) draws the
// frame at second t, and Chromium takes a screenshot of it, 12 a second
// 3. GIFs ffmpeg turns each run of frames into docs/images/hero-*.gif
//
// html/social.html is a page of one frame: the repository's social preview, written
// to docs/images/social-preview.png.
//
// node docs/hero/build.mjs everything
// node docs/hero/build.mjs wide social only those pieces (wide, mobile, small, social)
//
// Needs the repository's node_modules (Playwright comes with apps/web), Python with
// Pillow, NumPy and SciPy, and ffmpeg on PATH. The pages use Segoe UI and Consolas,
// so the GIFs come out identical only on Windows.
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..", "..");
const { chromium } = createRequire(path.join(ROOT, "package.json"))("@playwright/test");
const FPS = 12;
const LAYOUTS = {
wide: { page: "wide.html", width: 1280, height: 470, gif: "hero" },
mobile: { page: "mobile.html", width: 720, height: 620, gif: "hero-mobile" },
small: { page: "small.html", width: 720, height: 620, gif: "hero-small" },
};
const THEMES = ["light", "dark"];
// Pictures of one frame. The social preview is uploaded by hand in the repository's
// Settings, so it is written where it can be found for that.
const STILLS = {
social: { page: "social.html", width: 1280, height: 640, out: "social-preview.png" },
};
function run(cmd, args) {
const r = spawnSync(cmd, args, { stdio: "inherit" });
if (r.status !== 0) throw new Error(`${cmd} failed (${r.error ? r.error.message : `exit ${r.status}`})`);
}
const wanted = process.argv.slice(2);
const known = [...Object.keys(LAYOUTS), ...Object.keys(STILLS)];
for (const name of wanted) if (!known.includes(name)) throw new Error(`unknown piece ${name}: ${known.join(", ")}`);
const layouts = (wanted.length ? wanted : Object.keys(LAYOUTS)).filter((n) => LAYOUTS[n]);
const stills = (wanted.length ? wanted : Object.keys(STILLS)).filter((n) => STILLS[n]);
// 1. photos
run(process.platform === "win32" ? "python" : "python3", [path.join(HERE, "cut_out.py")]);
const browser = await chromium.launch();
try {
for (const name of layouts) {
const { page: file, width, height, gif } = LAYOUTS[name];
for (const theme of THEMES) {
// 2. frames
const dir = path.join(HERE, "frames", `${name}-${theme}`);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
// ?frames stops the page's own playback: every frame is the one renderLoop(t) drew
await page.goto(`${pathToFileURL(path.join(HERE, "html", file)).href}?theme=${theme}&frames`);
await page.waitForLoadState("load");
const seconds = await page.evaluate(() => window.LOOP_SECONDS);
const count = Math.round(seconds * FPS);
for (let i = 0; i < count; i++) {
await page.evaluate((t) => window.renderLoop(t), i / FPS);
await page.screenshot({ path: path.join(dir, `f${String(i).padStart(5, "0")}.png`) });
}
await page.close();
// 3. GIF: one palette for the whole loop, built from every frame
const out = path.join(ROOT, "docs", "images", `${gif}-${theme}.gif`);
run("ffmpeg", [
"-v", "error", "-y", "-framerate", String(FPS), "-i", path.join(dir, "f%05d.png"),
"-vf", "split[a][b];[a]palettegen=max_colors=256:stats_mode=full[p];[b][p]paletteuse=dither=sierra2_4a:diff_mode=rectangle",
"-loop", "0", out,
]);
console.log(`${path.relative(ROOT, out)}: ${count} frames, ${seconds}s`);
}
}
for (const name of stills) {
const { page: file, width, height, out: picture } = STILLS[name];
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
await page.goto(pathToFileURL(path.join(HERE, "html", file)).href);
await page.waitForLoadState("load");
const out = path.join(ROOT, "docs", "images", picture);
await page.screenshot({ path: out });
await page.close();
console.log(`${path.relative(ROOT, out)}: ${width}x${height}`);
}
} finally {
await browser.close();
}
+88
View File
@@ -0,0 +1,88 @@
"""photos/characters.png -> photos/characters-light.png and photos/characters-dark.png.
The four characters arrive as one picture on a white background, the way an image
model draws them. The animation needs them without it, and differently per theme:
light only the white connected to the picture's edge turns transparent, through
the edge's own shades, so the soft shadows under the feet stay and the
fur fades into the page the way it faded into the white;
dark the same, and the pale grey of those shadows goes too, since a light
shadow on a dark page reads as a stain.
Only background CONNECTED TO THE EDGE is touched: the whites of the eyes and a lens
are as white as the background and stay opaque. Both outputs are cropped to the
characters, so the pages size them by width alone.
python docs/hero/cut_out.py
"""
from pathlib import Path
import numpy as np
from PIL import Image
from scipy import ndimage
HERE = Path(__file__).parent
SRC = HERE / "photos" / "characters.png"
#: How far from pure white a pixel may be and still be background (0-255, on the
#: channel furthest from white). Covers the paper-white of the picture and its
#: anti-aliased edge; fur is far past it.
PALE = 60
#: A pale pixel with less colour than this is a shadow, not fur fading into white.
GREY = 18
#: How many pixels past the background the fur still carries some of its white.
BAND = 3
#: How far from pure white the paper may be: within this it is no picture at all.
PAPER = 6
def cut(rgb: np.ndarray, keep_shadows: bool) -> np.ndarray:
a = rgb.astype(np.float64)
far = (255.0 - a).max(axis=2) # 0 = pure white
spread = a.max(axis=2) - a.min(axis=2) # 0 = no colour at all
labels, _ = ndimage.label(far <= PALE)
edge = np.unique(np.concatenate([labels[0], labels[-1], labels[:, 0], labels[:, -1]]))
bg = np.isin(labels, edge[edge > 0])
# The fur's outermost pixels are part fur, part white, and past PALE: left
# opaque they draw a thin light outline on a dark page. They are the band just
# outside the background, and colour to alpha is exact for them too.
band = ndimage.binary_dilation(bg, iterations=BAND)
# Colour to alpha against white: the least alpha that explains the pixel as
# some colour over white, and that colour with the white taken out.
alpha = np.where(band, far / 255.0, 1.0)
# The paper itself is not pure white (an image model draws it at 253-254), and
# colour to alpha turns that into an opacity of 1 or 2 out of 255 with made-up
# colours: a faint box around the characters on any page that is not white.
alpha = np.where(bg & (far <= PAPER), 0.0, alpha)
if not keep_shadows:
alpha = np.where(bg & (spread < GREY), 0.0, alpha)
safe = np.maximum(alpha, 1e-6)[..., None]
colour = np.where(band[..., None], 255.0 - (255.0 - a) / safe, a)
out = np.dstack([np.clip(colour, 0, 255), alpha * 255.0]).round().astype(np.uint8)
out[out[..., 3] == 0, :3] = 0
return out
def box(rgba: np.ndarray, margin: int = 8) -> tuple:
"""The characters' bounding box. ONE box for both themes: cropped apart, the
light one (shadows included) would be taller, and at the same width on the
page the characters would change size between the two themes."""
ys, xs = np.nonzero(rgba[..., 3] > 8)
return (max(0, ys.min() - margin), min(rgba.shape[0], ys.max() + margin + 1),
max(0, xs.min() - margin), min(rgba.shape[1], xs.max() + margin + 1))
def main() -> None:
rgb = np.asarray(Image.open(SRC).convert("RGB"))
cuts = {"light": cut(rgb, keep_shadows=True), "dark": cut(rgb, keep_shadows=False)}
y0, y1, x0, x1 = box(cuts["light"])
for theme, rgba in cuts.items():
out = rgba[y0:y1, x0:x1]
path = HERE / "photos" / f"characters-{theme}.png"
Image.fromarray(out, "RGBA").save(path, optimize=True)
print(f"{path.name}: {out.shape[1]}x{out.shape[0]}")
if __name__ == "__main__":
main()
+225
View File
@@ -0,0 +1,225 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
/* The phone version: 720x760, shown about 324-350 px wide, so nothing is smaller than 28 px here (~13 px there). */
:root.light { --bg:#ffffff; --card:#ffffff; --line:#d0d7de; --ink:#1f2328; --muted:#59636e; --green:#1a7f37; --bubble:#eef1f4;
--detail:#f6f8fa; --primary:#1f2328; --primary-ink:#ffffff; }
:root.dark { --bg:#0d1117; --card:#161b22; --line:#30363d; --ink:#e6edf3; --muted:#8d96a0; --green:#3fb950; --bubble:#21262d;
--detail:#0d1117; --primary:#e6edf3; --primary-ink:#0d1117; }
* { box-sizing:border-box; margin:0; padding:0; }
html, body { width:720px; height:620px; overflow:hidden; background:var(--bg); font-family:"Segoe UI", system-ui, sans-serif; color:var(--ink); }
.abs { position:absolute; }
.card { background:var(--card); border:2px solid var(--line); border-radius:24px; }
#name { left:0; width:720px; top:150px; text-align:center; font-size:100px; font-weight:800; letter-spacing:-.01em; }
#tag { display:none; left:40px; width:640px; top:190px; text-align:center; font-size:33px; font-weight:700; color:var(--muted); line-height:1.3; }
#four { left:80px; width:560px; top:300px; }
#green { display:none; left:0; width:720px; top:338px; text-align:center; font-size:33px; font-weight:800; color:var(--green); }
#head { left:40px; top:34px; width:640px; }
#step { font-size:28px; font-weight:800; color:var(--green); letter-spacing:.05em; }
#line { font-size:52px; font-weight:800; line-height:1.12; margin-top:10px; }
#chat { left:40px; top:156px; width:640px; height:440px; }
#bubble { right:26px; top:30px; width:520px; background:var(--bubble); border-radius:22px; padding:22px 26px; font-size:32px; font-weight:600; line-height:1.35; }
#composer { left:24px; right:24px; bottom:24px; min-height:84px; border:2px solid var(--line); border-radius:18px; padding:18px 22px; font-size:30px; font-weight:600; line-height:1.3; }
#thinking { left:26px; top:250px; font-size:30px; font-weight:600; color:var(--muted); }
.caret { display:inline-block; width:3px; height:32px; background:var(--ink); vertical-align:-6px; margin-left:2px; }
#computer { left:30px; top:150px; width:660px; height:400px; overflow:hidden; }
#computer .bar { height:52px; display:flex; align-items:center; padding:0 22px; border-bottom:2px solid var(--line); font-size:28px; font-weight:700; color:var(--muted); }
#screen { left:14px; top:66px; width:628px; height:320px; border-radius:12px; overflow:hidden; border:2px solid var(--line); background:#fff; }
#screen img { width:150%; margin-left:-25%; }
#badge { left:60px; top:540px; width:600px; text-align:center; background:var(--card); border:3px solid var(--green); color:var(--green); padding:12px 0; border-radius:999px; font-size:30px; font-weight:800; }
#approval { left:40px; top:170px; width:640px; padding:34px 34px; }
#approval .what { font-size:42px; font-weight:800; }
#approval .detail { margin-top:20px; font-size:32px; font-weight:600; background:var(--detail); border:2px solid var(--line); border-radius:16px; padding:20px 24px; line-height:1.35; }
#approval .btns { display:flex; gap:20px; margin-top:28px; }
.btn { flex:1; text-align:center; padding:18px 0; border-radius:16px; font-size:32px; font-weight:800; border:2px solid var(--line); }
.btn.primary { background:var(--primary); color:var(--primary-ink); border-color:var(--primary); }
#ripple { width:24px; height:24px; border-radius:50%; border:4px solid var(--ink); }
.msg { position:absolute; left:40px; width:600px; background:#182533; border-radius:24px 24px 24px 8px; padding:20px 26px; font-size:30px; line-height:1.35; color:#e9eef3; font-weight:600; }
.msg b { color:#7cc4ff; }
.msg .t { display:block; text-align:right; font-size:28px; color:#8fa3b6; margin-top:6px; }
#cursor { width:40px; height:40px; }
</style>
</head>
<body>
<div id="head" class="abs"><div id="step"></div><div id="line"></div></div>
<div id="chat" class="abs card">
<div id="bubble" class="abs"></div>
<div id="thinking" class="abs">Thinking...</div>
<div id="composer" class="abs"><span id="typed"></span><span class="caret" id="caret"></span></div>
</div>
<div id="computer" class="abs card">
<div class="bar">its computer · Firefox</div>
<div id="screen" class="abs"><img src="../photos/trending.png"></div>
</div>
<div id="badge" class="abs">built to be undetectable by anti-bots</div>
<div id="approval" class="abs card">
<div class="what">Add an automation?</div>
<div class="detail">Monthly: the shop's invoices, the total to Telegram</div>
<div class="btns"><div class="btn primary" id="allow">Allow</div><div class="btn">Reject</div></div>
</div>
<div id="ripple" class="abs"></div>
<div class="msg" id="m1"></div><div class="msg" id="m2"></div>
<svg id="cursor" class="abs" viewBox="0 0 22 22"><path d="M2 1 L2 18 L7 13.5 L10.5 21 L13.5 19.6 L10 12.4 L17 12.4 Z" fill="#fff" stroke="#000" stroke-width="1.4"/></svg>
<style>
#desk { left:30px; top:150px; width:660px; height:400px; overflow:hidden; background:#1d2b3a; border-radius:24px; border:2px solid var(--line); }
.win { position:absolute; border-radius:14px; overflow:hidden; border:2px solid #3a4652; }
.win .t { height:44px; background:#2b3540; color:#dfe7ee; font-size:28px; display:flex; align-items:center; padding:0 16px; font-weight:700; }
#term { left:16px; top:16px; width:628px; height:236px; background:#0b0f14; }
#term pre { margin:0; padding:10px 16px; color:#d6e2ea; font:600 28px/1.35 Consolas, "Cascadia Mono", monospace; white-space:pre; }
#term .p { color:#7ee787; }
#files { left:16px; top:266px; width:300px; height:118px; background:#f6f8fa; }
#files .row { padding:10px 16px; font:700 28px/1.3 "Segoe UI"; color:#1f2328; }
#ff { left:330px; top:266px; width:314px; height:118px; background:#fff; }
#ff img { width:200%; margin-left:-50%; margin-top:-30px; }
#skill { left:40px; top:170px; width:640px; }
#skill .t { height:60px; display:flex; align-items:center; padding:0 24px; border-bottom:2px solid var(--line); font:700 28px Consolas, monospace; color:var(--muted); }
#skill pre { margin:0; padding:20px 24px; font:600 30px/1.45 Consolas, "Cascadia Mono", monospace; color:var(--ink); white-space:pre-wrap; min-height:200px; }
#memo { left:40px; top:470px; width:640px; padding:18px 24px; font-size:30px; font-weight:600; line-height:1.35; }
#memo b { color:var(--green); }
.row3 { position:absolute; left:40px; width:640px; height:124px; padding:16px 22px; display:flex; flex-direction:column; justify-content:center; }
.row3 .nm { font-size:32px; font-weight:800; }
.row3 .jb { font-size:28px; color:var(--muted); font-weight:600; margin-top:4px; }
.row3 .vm { position:absolute; right:20px; top:18px; font-size:28px; font-weight:800; color:var(--green); }
</style>
<div id="desk" class="abs">
<div id="term" class="win"><div class="t">Terminal</div><pre id="termtext"></pre></div>
<div id="files" class="win"><div class="t">~/documents</div><div class="row">14 PDFs</div></div>
<div id="ff" class="win"><div class="t">Firefox</div><div style="font:600 24px/1.4 'Segoe UI';color:#1f2328"><div style="background:#7a2e1a;color:#fff;padding:12px 12px">Shop · ⬇ Invoices</div></div></div>
</div>
<div id="skill" class="abs card"><div class="t">~/skills/invoices.md</div><pre id="skilltext"></pre></div>
<div id="memo" class="abs card"><b>~/memory</b> · the total, not the list</div>
<div class="row3 card" id="r1" style="top:160px"><div class="nm">invoices</div><div class="jb">the shop's invoices, monthly</div><div class="vm">own VM</div></div>
<div class="row3 card" id="r2" style="top:300px"><div class="nm">fare-watch</div><div class="jb">cheapest flight, every morning</div><div class="vm">own VM</div></div>
<div class="row3 card" id="r3" style="top:440px"><div class="nm">researcher</div><div class="jb">GitHub Trending → Telegram</div><div class="vm">own VM</div></div>
<div id="logo" class="abs" style="inset:0">
<div id="name" class="abs">invisible_dots</div>
<div id="green" class="abs">AI agents with a computer of their own.</div>
<div id="tag" class="abs">Open-source, self-hosted alternative to OpenAI Dots, Meta Muse, Grok Bot, Manus Cue and Claude Cowork.</div>
<img id="four" class="abs">
</div>
<script>
const THEME = new URLSearchParams(location.search).get("theme") || "dark";
document.documentElement.className = THEME;
document.getElementById("four").src = `../photos/characters-${THEME}.png`;
const $ = (id) => document.getElementById(id);
const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
const ease = (x) => { x = clamp(x); return x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; };
const ramp = (t, a, b) => ease((t - a) / (b - a));
const win = (t, a, b, f = .35) => Math.min(ramp(t, a, a + f), 1 - ramp(t, b - f, b));
function show(id, o, dy = 0) { const e = $(id); e.style.opacity = o; e.style.transform = `translateY(${(1 - o) * dy}px)`; e.style.visibility = o > .001 ? "visible" : "hidden"; }
const typed = (text, s, a, b) => text.slice(0, Math.floor(clamp((s - a) / (b - a)) * text.length));
const ASK = "Every month, save my shop's invoices and send me the total on Telegram.";
const S = { ask: [0, 3.6], pc: [3.6, 8.4], learn: [8.4, 11.6], ask2: [11.6, 14.8], daily: [14.8, 17.8], fleet: [17.8, 21.2] };
const SCENES = [
[...S.ask, "1 · ASK ONCE", "Tell it what you need."],
[...S.pc, "2 · ITS OWN COMPUTER", "Its own desktop."],
[...S.learn, "3 · IT LEARNS", "It writes its own skills."],
[...S.ask2, "4 · YOU STAY IN CONTROL", "It asks before it acts."],
[...S.daily, "5 · ALWAYS ON", "It runs every month."],
[...S.fleet, "6 · ONE PER AGENT", "As many as you need."],
];
const TERM = [["$ ", "ls invoices | wc -l"], ["", "14"], ["$ ", "python3 total.py"], ["", "Total: €1,284.50"]];
const SKILL = "1. Log in to the shop\n2. Download this month's PDFs\n3. Telegram: the total";
const DAYS = [["Oct 1", "<b>October invoices</b><br>14 saved · Total €1,284.50"],
["Nov 1", "<b>November invoices</b><br>11 saved · Total €968.00"]];
function scenes(s, o) {
let cap = 0;
for (const [a, b, step, line] of SCENES) {
const w = win(s, a, b, .3);
if (w > cap) { cap = w; $("step").textContent = step; $("line").textContent = line; }
}
show("head", cap * o, 14);
show("computer", 0);
show("chat", win(s, .1, S.ask[1] + .5) * o, 30);
$("typed").textContent = s < 2.95 ? typed(ASK, s, .5, 2.7) : "";
$("caret").style.opacity = s < 2.95 && Math.floor(s * 3) % 2 === 0 ? 1 : 0;
show("composer", 1 - ramp(s, 2.95, 3.1));
$("bubble").textContent = ASK;
show("bubble", ramp(s, 2.95, 3.2), 20);
show("thinking", ramp(s, 3.25, 3.4));
const a = S.pc[0];
show("desk", win(s, a + .1, S.pc[1] + .2) * o, 40);
let out = "", t0 = a + .5;
for (const [prompt, cmd] of TERM) {
const dur = prompt ? .7 : .3;
if (s < t0) break;
out += (prompt ? `<span class="p">${prompt}</span>` : "") + typed(cmd, s, t0, t0 + dur) + "\n";
t0 += dur + .15;
}
$("termtext").innerHTML = out;
show("files", ramp(s, a + 2.2, a + 2.5), 20);
show("ff", ramp(s, a + 2.6, a + 2.9), 20);
show("badge", win(s, a + 2.9, S.pc[1] + .1, .25) * o, 16);
const l = S.learn[0];
show("skill", win(s, l + .1, S.learn[1] + .2) * o, 30);
$("skilltext").textContent = typed(SKILL, s, l + .4, l + 1.9);
show("memo", win(s, l + 2.0, S.learn[1] + .2, .3) * o, 16);
const c = S.ask2[0];
show("approval", win(s, c + .2, S.ask2[1] + .2) * o, 30);
const clicked = s >= c + 1.85;
$("allow").textContent = clicked ? "Allowed ✓" : "Allow";
$("allow").style.background = clicked ? "var(--green)" : "var(--primary)";
$("allow").style.borderColor = clicked ? "var(--green)" : "var(--primary)";
$("allow").style.color = clicked ? "#ffffff" : "var(--primary-ink)";
const r = clamp((s - c - 1.75) / .45);
$("ripple").style.left = (200 - r * 26) + "px"; $("ripple").style.top = (462 - r * 26) + "px";
$("ripple").style.width = $("ripple").style.height = (24 + r * 52) + "px";
$("ripple").style.opacity = s > c + 1.75 && s < c + 2.2 ? (1 - r) * o : 0;
let cx, cy, co = 0;
if (s > c + .3 && s < S.ask2[1]) { const k = ramp(s, c + .5, c + 1.6); cx = 560 - 360 * k; cy = 570 - 100 * k; co = win(s, c + .3, S.ask2[1], .3); }
const cur = $("cursor"); cur.style.opacity = co * o; if (co) { cur.style.left = cx + "px"; cur.style.top = cy + "px"; }
const d = S.daily[0];
DAYS.forEach(([dd, body], i) => {
const m = $("m" + (i + 1));
m.innerHTML = body + `<span class="t">${dd}, 09:00</span>`;
m.style.top = (156 + i * 222) + "px";
show("m" + (i + 1), Math.min(ramp(s, d + .4 + i * 1.0, d + .7 + i * 1.0), win(s, d, S.daily[1] + .2)) * o, 30);
});
["r1", "r2", "r3"].forEach((id, i) => show(id, Math.min(ramp(s, S.fleet[0] + .2 + i * .35, S.fleet[0] + .55 + i * .35), win(s, S.fleet[0], S.fleet[1] + .3)) * o, 24));
}
const HOLD = 3.0, OUT = .5, SCENE = 21.2, BACK = .8, END = 1.2;
window.LOOP_SECONDS = HOLD + SCENE + BACK + END;
window.renderLoop = (t) => {
let logo, s;
if (t < HOLD) { logo = 1 - ramp(t, HOLD - OUT, HOLD); s = 0; }
else if (t < HOLD + SCENE) { logo = 0; s = t - HOLD; }
else { logo = ramp(t, HOLD + SCENE, HOLD + SCENE + BACK); s = SCENE; }
const o = logo > 0 && t >= HOLD + SCENE ? 1 - logo : 1;
scenes(t < HOLD ? -1 : s, t < HOLD ? 0 : o);
show("logo", logo, -10);
$("four").style.transform = `translateY(${(1 - logo) * 60}px)`;
};
renderLoop(0);
// Opened in a browser, the page plays its own loop. build.mjs adds ?frames to the
// address and drives renderLoop(t) itself, one frame at a time, so nothing moves
// between two of its screenshots.
if (!new URLSearchParams(location.search).has("frames")) {
const start = performance.now();
const tick = (now) => { renderLoop(((now - start) / 1000) % window.LOOP_SECONDS); requestAnimationFrame(tick); };
requestAnimationFrame(tick);
}
</script>
</body>
</html>
+226
View File
@@ -0,0 +1,226 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
/* The phone version: 720x760, shown about 324-350 px wide, so nothing is smaller than 28 px here (~13 px there). */
:root.light { --bg:#ffffff; --card:#ffffff; --line:#d0d7de; --ink:#1f2328; --muted:#59636e; --green:#1a7f37; --bubble:#eef1f4;
--detail:#f6f8fa; --primary:#1f2328; --primary-ink:#ffffff; }
:root.dark { --bg:#0d1117; --card:#161b22; --line:#30363d; --ink:#e6edf3; --muted:#8d96a0; --green:#3fb950; --bubble:#21262d;
--detail:#0d1117; --primary:#e6edf3; --primary-ink:#0d1117; }
* { box-sizing:border-box; margin:0; padding:0; }
html, body { width:720px; height:620px; overflow:hidden; background:var(--bg); font-family:"Segoe UI", system-ui, sans-serif; color:var(--ink); }
.abs { position:absolute; }
.card { background:var(--card); border:2px solid var(--line); border-radius:24px; }
#name { left:0; width:720px; top:150px; text-align:center; font-size:100px; font-weight:800; letter-spacing:-.01em; }
#tag { display:none; left:40px; width:640px; top:190px; text-align:center; font-size:33px; font-weight:700; color:var(--muted); line-height:1.3; }
#four { left:80px; width:560px; top:300px; }
#green { display:none; left:0; width:720px; top:338px; text-align:center; font-size:33px; font-weight:800; color:var(--green); }
#head { left:40px; top:24px; width:640px; }
#step { font-size:36px; font-weight:800; color:var(--green); letter-spacing:.05em; }
#line { font-size:52px; font-weight:800; line-height:1.12; margin-top:4px; }
#chat { left:40px; top:156px; width:640px; height:440px; }
#bubble { right:24px; top:26px; width:560px; background:var(--bubble); border-radius:22px; padding:20px 24px; font-size:37px; font-weight:600; line-height:1.35; }
#composer { left:24px; right:24px; bottom:24px; min-height:84px; border:2px solid var(--line); border-radius:18px; padding:16px 20px; font-size:36px; font-weight:600; line-height:1.3; }
#thinking { left:26px; top:300px; font-size:36px; font-weight:600; color:var(--muted); }
.caret { display:inline-block; width:3px; height:32px; background:var(--ink); vertical-align:-6px; margin-left:2px; }
#computer { left:30px; top:150px; width:660px; height:400px; overflow:hidden; }
#computer .bar { height:52px; display:flex; align-items:center; padding:0 22px; border-bottom:2px solid var(--line); font-size:28px; font-weight:700; color:var(--muted); }
#screen { left:14px; top:66px; width:628px; height:320px; border-radius:12px; overflow:hidden; border:2px solid var(--line); background:#fff; }
#screen img { width:150%; margin-left:-25%; }
#badge { left:60px; top:540px; width:600px; text-align:center; background:var(--card); border:3px solid var(--green); color:var(--green); padding:12px 0; border-radius:999px; font-size:30px; font-weight:800; }
#approval { left:40px; top:160px; width:640px; padding:28px 30px; }
#approval .what { font-size:42px; font-weight:800; }
#approval .detail { margin-top:18px; font-size:37px; font-weight:600; background:var(--detail); border:2px solid var(--line); border-radius:16px; padding:20px 24px; line-height:1.35; }
#approval .btns { display:flex; gap:20px; margin-top:22px; }
.btn { flex:1; text-align:center; padding:14px 0; border-radius:16px; font-size:38px; font-weight:800; border:2px solid var(--line); }
.btn.primary { background:var(--primary); color:var(--primary-ink); border-color:var(--primary); }
#ripple { width:24px; height:24px; border-radius:50%; border:4px solid var(--ink); }
.msg { position:absolute; left:40px; width:640px; background:#182533; border-radius:24px 24px 24px 8px; padding:18px 24px; font-size:36px; line-height:1.35; color:#e9eef3; font-weight:600; }
.msg b { color:#7cc4ff; }
.msg .t { display:block; text-align:right; font-size:35px; color:#8fa3b6; margin-top:6px; }
#cursor { width:40px; height:40px; }
</style>
</head>
<body>
<div id="head" class="abs"><div id="step"></div><div id="line"></div></div>
<div id="chat" class="abs card">
<div id="bubble" class="abs"></div>
<div id="thinking" class="abs">Thinking...</div>
<div id="composer" class="abs"><span id="typed"></span><span class="caret" id="caret"></span></div>
</div>
<div id="computer" class="abs card">
<div class="bar">its computer · Firefox</div>
<div id="screen" class="abs"><img src="../photos/trending.png"></div>
</div>
<div id="badge" class="abs">built to be undetectable by anti-bots</div>
<div id="approval" class="abs card">
<div class="what">Add an automation?</div>
<div class="detail">Monthly: the shop's invoices, the total to Telegram</div>
<div class="btns"><div class="btn primary" id="allow">Allow</div><div class="btn">Reject</div></div>
</div>
<div id="ripple" class="abs"></div>
<div class="msg" id="m1"></div><div class="msg" id="m2"></div>
<svg id="cursor" class="abs" viewBox="0 0 22 22"><path d="M2 1 L2 18 L7 13.5 L10.5 21 L13.5 19.6 L10 12.4 L17 12.4 Z" fill="#fff" stroke="#000" stroke-width="1.4"/></svg>
<style>
#desk { left:30px; top:150px; width:660px; height:400px; overflow:hidden; background:#1d2b3a; border-radius:24px; border:2px solid var(--line); }
.win { position:absolute; border-radius:14px; overflow:hidden; border:2px solid #3a4652; }
.win .t { height:52px; background:#2b3540; color:#dfe7ee; font-size:36px; display:flex; align-items:center; padding:0 16px; font-weight:700; }
#term { left:16px; top:14px; width:628px; height:262px; background:#0b0f14; }
#term pre { margin:0; padding:10px 16px; color:#d6e2ea; font:600 36px/1.3 Consolas, "Cascadia Mono", monospace; white-space:pre; }
#term .p { color:#7ee787; }
#files { left:16px; top:288px; width:300px; height:104px; background:#f6f8fa; }
#files .row { padding:4px 16px; font:700 36px/1.25 "Segoe UI"; color:#1f2328; }
#ff { left:330px; top:288px; width:314px; height:104px; background:#fff; }
#ff img { width:200%; margin-left:-50%; margin-top:-30px; }
#skill { left:40px; top:170px; width:640px; }
#skill .t { height:62px; display:flex; align-items:center; padding:0 22px; border-bottom:2px solid var(--line); font:700 35px Consolas, monospace; color:var(--muted); }
#skill pre { margin:0; padding:16px 22px; font:600 35px/1.4 Consolas, "Cascadia Mono", monospace; color:var(--ink); white-space:pre-wrap; min-height:200px; }
#memo { left:40px; top:478px; width:640px; padding:14px 22px; font-size:36px; font-weight:600; line-height:1.35; }
#memo b { color:var(--green); }
.row3 { position:absolute; left:40px; width:640px; height:132px; padding:12px 22px; display:flex; flex-direction:column; justify-content:center; }
.row3 .nm { font-size:38px; font-weight:800; }
.row3 .jb { font-size:35px; color:var(--muted); font-weight:600; margin-top:4px; }
.row3 .vm { position:absolute; right:20px; top:16px; font-size:35px; font-weight:800; color:var(--green); }
</style>
<div id="desk" class="abs">
<div id="term" class="win"><div class="t">Terminal</div><pre id="termtext"></pre></div>
<div id="files" class="win"><div class="t">~/documents</div><div class="row">14 PDFs</div></div>
<div id="ff" class="win"><div class="t">Firefox</div><div style="font:700 35px/1.25 'Segoe UI';color:#1f2328"><div style="background:#7a2e1a;color:#fff;padding:4px 14px">⬇ Invoices</div></div></div>
</div>
<div id="skill" class="abs card"><div class="t">~/skills/invoices.md</div><pre id="skilltext"></pre></div>
<div id="memo" class="abs card"><b>~/memory</b> · the total, not the list</div>
<div class="row3 card" id="r1" style="top:150px"><div class="nm">invoices</div><div class="jb">the shop's invoices, monthly</div><div class="vm">own VM</div></div>
<div class="row3 card" id="r2" style="top:294px"><div class="nm">fare-watch</div><div class="jb">cheapest flight, every morning</div><div class="vm">own VM</div></div>
<div class="row3 card" id="r3" style="top:438px"><div class="nm">researcher</div><div class="jb">GitHub Trending → Telegram</div><div class="vm">own VM</div></div>
<div id="logo" class="abs" style="inset:0">
<div id="name" class="abs">invisible_dots</div>
<div id="green" class="abs">AI agents with a computer of their own.</div>
<div id="tag" class="abs">Open-source, self-hosted alternative to OpenAI Dots, Meta Muse, Grok Bot, Manus Cue and Claude Cowork.</div>
<img id="four" class="abs">
</div>
<script>
const THEME = new URLSearchParams(location.search).get("theme") || "dark";
document.documentElement.className = THEME;
document.getElementById("four").src = `../photos/characters-${THEME}.png`;
const $ = (id) => document.getElementById(id);
const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
const ease = (x) => { x = clamp(x); return x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; };
const ramp = (t, a, b) => ease((t - a) / (b - a));
const win = (t, a, b, f = .35) => Math.min(ramp(t, a, a + f), 1 - ramp(t, b - f, b));
function show(id, o, dy = 0) { const e = $(id); e.style.opacity = o; e.style.transform = `translateY(${(1 - o) * dy}px)`; e.style.visibility = o > .001 ? "visible" : "hidden"; }
const typed = (text, s, a, b) => text.slice(0, Math.floor(clamp((s - a) / (b - a)) * text.length));
const ASK = "Every month, save my shop's invoices and send me the total on Telegram.";
const S = { ask: [0, 3.6], pc: [3.6, 8.4], learn: [8.4, 11.6], ask2: [11.6, 14.8], daily: [14.8, 17.8], fleet: [17.8, 21.2] };
const SCENES = [
[...S.ask, "1 · ASK ONCE", "Tell it what you need."],
[...S.pc, "2 · ITS OWN COMPUTER", "Its own desktop."],
[...S.learn, "3 · IT LEARNS", "It writes its own skills."],
[...S.ask2, "4 · YOU STAY IN CONTROL", "It asks before it acts."],
[...S.daily, "5 · ALWAYS ON", "It runs every month."],
[...S.fleet, "6 · ONE PER AGENT", "As many as you need."],
];
const TERM = [["$ ", "ls invoices | wc -l"], ["", "14"], ["$ ", "python3 total.py"], ["", "Total: €1,284.50"]];
const SKILL = "1. Log in to the shop\n2. Download the PDFs\n3. Telegram: the total";
const DAYS = [["Oct 1", "<b>October invoices</b><br>14 saved · €1,284.50"],
["Nov 1", "<b>November invoices</b><br>11 saved · €968.00"]];
function scenes(s, o) {
let cap = 0;
for (const [a, b, step, line] of SCENES) {
const w = win(s, a, b, .3);
if (w > cap) { cap = w; $("step").textContent = step; $("line").textContent = line; }
}
show("head", cap * o, 14);
show("computer", 0);
show("chat", win(s, .1, S.ask[1] + .5) * o, 30);
$("typed").textContent = s < 2.95 ? typed(ASK, s, .5, 2.7) : "";
$("caret").style.opacity = s < 2.95 && Math.floor(s * 3) % 2 === 0 ? 1 : 0;
show("composer", 1 - ramp(s, 2.95, 3.1));
$("bubble").textContent = ASK;
show("bubble", ramp(s, 2.95, 3.2), 20);
show("thinking", ramp(s, 3.25, 3.4));
const a = S.pc[0];
show("desk", win(s, a + .1, S.pc[1] + .2) * o, 40);
let out = "", t0 = a + .5;
for (const [prompt, cmd] of TERM) {
const dur = prompt ? .7 : .3;
if (s < t0) break;
out += (prompt ? `<span class="p">${prompt}</span>` : "") + typed(cmd, s, t0, t0 + dur) + "\n";
t0 += dur + .15;
}
$("termtext").innerHTML = out;
show("files", ramp(s, a + 2.2, a + 2.5), 20);
show("ff", ramp(s, a + 2.6, a + 2.9), 20);
show("badge", win(s, a + 2.9, S.pc[1] + .1, .25) * o, 16);
const l = S.learn[0];
show("skill", win(s, l + .1, S.learn[1] + .2) * o, 30);
$("skilltext").textContent = typed(SKILL, s, l + .4, l + 1.9);
show("memo", win(s, l + 2.0, S.learn[1] + .2, .3) * o, 16);
const c = S.ask2[0];
show("approval", win(s, c + .2, S.ask2[1] + .2) * o, 30);
const clicked = s >= c + 1.85;
$("allow").textContent = clicked ? "Allowed ✓" : "Allow";
$("allow").style.background = clicked ? "var(--green)" : "var(--primary)";
$("allow").style.borderColor = clicked ? "var(--green)" : "var(--primary)";
$("allow").style.color = clicked ? "#ffffff" : "var(--primary-ink)";
const r = clamp((s - c - 1.75) / .45);
const br = $("allow").getBoundingClientRect(), bx = br.left + br.width * .4, by = br.top + br.height * .55;
$("ripple").style.left = (bx - 12 - r * 26) + "px"; $("ripple").style.top = (by - 12 - r * 26) + "px";
$("ripple").style.width = $("ripple").style.height = (24 + r * 52) + "px";
$("ripple").style.opacity = s > c + 1.75 && s < c + 2.2 ? (1 - r) * o : 0;
let cx, cy, co = 0;
if (s > c + .3 && s < S.ask2[1]) { const k = ramp(s, c + .5, c + 1.6); cx = 600 + (bx - 600) * k; cy = 600 + (by - 600) * k; co = win(s, c + .3, S.ask2[1], .3); }
const cur = $("cursor"); cur.style.opacity = co * o; if (co) { cur.style.left = cx + "px"; cur.style.top = cy + "px"; }
const d = S.daily[0];
DAYS.forEach(([dd, body], i) => {
const m = $("m" + (i + 1));
m.innerHTML = body + `<span class="t">${dd}, 09:00</span>`;
m.style.top = (150 + i * 232) + "px";
show("m" + (i + 1), Math.min(ramp(s, d + .4 + i * 1.0, d + .7 + i * 1.0), win(s, d, S.daily[1] + .2)) * o, 30);
});
["r1", "r2", "r3"].forEach((id, i) => show(id, Math.min(ramp(s, S.fleet[0] + .2 + i * .35, S.fleet[0] + .55 + i * .35), win(s, S.fleet[0], S.fleet[1] + .3)) * o, 24));
}
const HOLD = 3.0, OUT = .5, SCENE = 21.2, BACK = .8, END = 1.2;
window.LOOP_SECONDS = HOLD + SCENE + BACK + END;
window.renderLoop = (t) => {
let logo, s;
if (t < HOLD) { logo = 1 - ramp(t, HOLD - OUT, HOLD); s = 0; }
else if (t < HOLD + SCENE) { logo = 0; s = t - HOLD; }
else { logo = ramp(t, HOLD + SCENE, HOLD + SCENE + BACK); s = SCENE; }
const o = logo > 0 && t >= HOLD + SCENE ? 1 - logo : 1;
scenes(t < HOLD ? -1 : s, t < HOLD ? 0 : o);
show("logo", logo, -10);
$("four").style.transform = `translateY(${(1 - logo) * 60}px)`;
};
renderLoop(0);
// Opened in a browser, the page plays its own loop. build.mjs adds ?frames to the
// address and drives renderLoop(t) itself, one frame at a time, so nothing moves
// between two of its screenshots.
if (!new URLSearchParams(location.search).has("frames")) {
const start = performance.now();
const tick = (now) => { renderLoop(((now - start) / 1000) % window.LOOP_SECONDS); requestAnimationFrame(tick); };
requestAnimationFrame(tick);
}
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>invisible_dots social preview</title>
<style>
/* The repository's social preview, 1280 x 640: the picture GitHub, X and chat apps
show beside a link to the repository. It is uploaded by hand in the repository's
Settings, so build.mjs writes it to docs/images/social-preview.png for that. */
html, body { margin: 0; }
body { width: 1280px; height: 640px; overflow: hidden; position: relative;
background: #fafaf9; font-family: "Segoe UI", system-ui, sans-serif; color: #171717; }
.abs { position: absolute; }
#dots { left: 64px; top: 46px; display: flex; gap: 9px; }
#dots i { width: 21px; height: 21px; border-radius: 50%; background: #171717; }
#dots i:last-child { background: #c8c8c6; }
#name { left: 64px; top: 86px; font-size: 80px; font-weight: 700; line-height: 1; }
#line { left: 64px; top: 189px; font-size: 28px; font-weight: 700; line-height: 1.2; }
#green { left: 64px; top: 230px; font-size: 25px; font-weight: 700; line-height: 1.2; color: #16803d; }
#four { left: 160px; width: 960px; bottom: 11px; }
</style>
</head>
<body>
<div id="dots" class="abs"><i></i><i></i><i></i></div>
<div id="name" class="abs">invisible_dots</div>
<div id="line" class="abs">Open-source, self-hosted alternative to OpenAI Dots, Manus, Claude Cowork &amp; co.</div>
<div id="green" class="abs">AI agents with a computer of their own. Built to be undetectable by anti-bots.</div>
<img id="four" class="abs" src="../photos/characters-light.png" alt="">
</body>
</html>
+288
View File
@@ -0,0 +1,288 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
/* GitHub's own page colours, so the first and last frame sit on the page with no box: ?theme=light|dark */
:root.light { --bg:#ffffff; --card:#ffffff; --line:#d0d7de; --ink:#1f2328; --muted:#59636e; --green:#1a7f37; --bubble:#eef1f4;
--shadow:0 18px 50px rgba(31,35,40,.14); --detail:#f6f8fa; --primary:#1f2328; --primary-ink:#ffffff; }
:root.dark { --bg:#0d1117; --card:#161b22; --line:#30363d; --ink:#e6edf3; --muted:#8d96a0; --green:#3fb950; --bubble:#21262d;
--shadow:0 24px 60px rgba(0,0,0,.55); --detail:#0d1117; --primary:#e6edf3; --primary-ink:#0d1117; }
* { box-sizing:border-box; margin:0; padding:0; }
html, body { width:1280px; height:470px; overflow:hidden; background:var(--bg); font-family:"Segoe UI", system-ui, sans-serif; color:var(--ink); }
.abs { position:absolute; }
.card { background:var(--card); border:1px solid var(--line); border-radius:16px; box-shadow:var(--shadow); }
.avatar { width:32px; height:32px; border-radius:50%; border:2px solid #e8a33d; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:600; }
/* the logo card */
#logo { inset:0; }
#name { left:0; width:1280px; top:84px; text-align:center; font-size:96px; font-weight:800; letter-spacing:-.01em; }
#tag { display:none; left:0; width:1280px; top:176px; text-align:center; font-size:23px; font-weight:600; color:var(--muted); }
#four { left:320px; width:640px; top:206px; }
/* the scenes */
#brand { left:56px; top:22px; display:flex; align-items:center; gap:9px; font-size:20px; font-weight:700; }
.dot { width:11px; height:11px; border-radius:50%; background:var(--ink); display:inline-block; }
.dot.dim { background:var(--line); }
#caption { left:56px; top:96px; width:446px; }
#step { font-size:20px; font-weight:700; color:var(--green); letter-spacing:.05em; margin-bottom:12px; }
#line { font-size:46px; font-weight:800; line-height:1.18; }
#sub { font-size:24px; font-weight:600; color:var(--muted); margin-top:14px; line-height:1.45; }
#chat { left:520px; top:36px; width:690px; height:396px; }
#chat .head { height:56px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; padding:0 20px; font-weight:600; font-size:19px; }
#bubble { right:22px; top:76px; max-width:480px; background:var(--bubble); border-radius:14px; padding:14px 18px; font-size:21px; line-height:1.45; }
#thinking { left:22px; top:210px; display:flex; align-items:center; gap:10px; font-size:20px; color:var(--muted); }
#composer { left:18px; right:18px; bottom:18px; height:50px; border:1px solid var(--line); border-radius:12px; padding:12px 16px; font-size:20px; }
.caret { display:inline-block; width:2px; height:19px; background:var(--ink); vertical-align:-4px; margin-left:1px; }
#computer { left:540px; top:30px; width:680px; height:404px; overflow:hidden; }
#computer .bar { height:38px; display:flex; align-items:center; gap:8px; padding:0 14px; border-bottom:1px solid var(--line); font-size:17px; color:var(--muted); }
.light-dot { width:11px; height:11px; border-radius:50%; background:var(--line); }
#screen { left:12px; top:50px; width:656px; height:342px; border-radius:8px; overflow:hidden; background:#fff; border:1px solid var(--line); }
#screen img { width:100%; }
#badge { left:880px; top:414px; display:flex; align-items:center; gap:10px; background:var(--card); border:1px solid var(--green); color:var(--green); padding:10px 18px; border-radius:999px; font-size:20px; font-weight:700; box-shadow:var(--shadow); }
#approval { left:560px; top:90px; width:600px; padding:24px 26px; }
#approval .who { font-size:19px; color:var(--muted); }
#approval .what { font-size:30px; font-weight:800; margin-top:6px; }
#approval .detail { margin-top:14px; font-size:21px; background:var(--detail); border:1px solid var(--line); border-radius:10px; padding:13px 15px; line-height:1.5; }
#approval .btns { display:flex; gap:12px; margin-top:20px; }
.btn { padding:12px 22px; border-radius:10px; font-size:20px; font-weight:600; border:1px solid var(--line); }
.btn.primary { background:var(--primary); color:var(--primary-ink); border-color:var(--primary); }
#ripple { width:16px; height:16px; border-radius:50%; border:3px solid var(--ink); }
#phone { left:820px; top:8px; width:330px; height:454px; border-radius:38px; background:#0e1621; border:10px solid #1c1c22; overflow:hidden; }
#phone .top { height:60px; background:#17212b; display:flex; align-items:center; gap:12px; padding:0 16px; font-weight:600; font-size:19px; color:#e9eef3; }
#phone .clock { position:absolute; right:16px; top:19px; font-size:19px; color:#9fb3c8; font-weight:600; }
.msg { position:absolute; left:12px; right:22px; background:#182533; border-radius:14px 14px 14px 4px; padding:11px 14px; font-size:20px; line-height:1.4; color:#e9eef3; }
.msg b { color:#7cc4ff; }
.msg .t { display:block; text-align:right; font-size:19px; color:#7d8b99; margin-top:3px; }
#cursor { width:22px; height:22px; }
</style>
</head>
<body>
<div id="brand" class="abs"><span class="dot"></span><span class="dot"></span><span class="dot dim"></span>&nbsp;invisible_dots</div>
<div id="caption" class="abs"><div id="step"></div><div id="line"></div><div id="sub"></div></div>
<div id="chat" class="abs card">
<div class="head"><div class="avatar">I</div>invoices</div>
<div id="bubble" class="abs"></div>
<div id="thinking" class="abs"><div class="avatar" style="width:26px;height:26px;font-size:12px">I</div><span id="thinktext">Thinking</span></div>
<div id="composer" class="abs"><span id="typed"></span><span class="caret" id="caret"></span></div>
</div>
<div id="computer" class="abs card">
<div class="bar"><span class="light-dot"></span><span class="light-dot"></span><span class="light-dot"></span>&nbsp;researcher's computer · Firefox</div>
<div id="screen" class="abs"><img src="../photos/trending.png"></div>
</div>
<div id="badge" class="abs"><svg width="18" height="20" viewBox="0 0 20 22"><path d="M10 1 L19 5 V11 C19 16 15 20 10 21 C5 20 1 16 1 11 V5 Z" fill="currentColor"/><path d="M6 11 L9 14 L14 8" stroke="var(--card)" stroke-width="2.4" fill="none"/></svg>built to be undetectable by anti-bots</div>
<div id="approval" class="abs card">
<div class="who">invoices asks</div>
<div class="what">Add an automation</div>
<div class="detail">On the 1st of every month, 09:00<br>Download the shop's invoices, send the total to Telegram</div>
<div class="btns"><div class="btn primary" id="allow">Allow once</div><div class="btn">Always allow</div><div class="btn">Reject</div></div>
</div>
<div id="ripple" class="abs"></div>
<div id="phone" class="abs">
<div class="top"><div class="avatar" style="width:34px;height:34px">I</div>invoices</div>
<div class="clock" id="clock">08:00</div>
<div class="msg" id="m1"></div><div class="msg" id="m2"></div><div class="msg" id="m3"></div>
</div>
<svg id="cursor" class="abs" viewBox="0 0 22 22"><path d="M2 1 L2 18 L7 13.5 L10.5 21 L13.5 19.6 L10 12.4 L17 12.4 Z" fill="#fff" stroke="#000" stroke-width="1.4"/></svg>
<style>
/* v2: a whole computer, skills and memory, many Dots */
#desk { left:520px; top:24px; width:720px; height:420px; overflow:hidden; background:#1d2b3a; border-radius:16px; border:1px solid var(--line); }
#desk .taskbar { position:absolute; left:0; right:0; top:0; height:30px; background:#0f1822; color:#c8d3de; font-size:19px; display:flex; align-items:center; padding:0 12px; gap:14px; }
.win { position:absolute; border-radius:10px; overflow:hidden; box-shadow:0 10px 30px rgba(0,0,0,.45); border:1px solid #3a4652; }
.win .t { height:34px; background:#2b3540; color:#dfe7ee; font-size:19px; display:flex; align-items:center; padding:0 12px; font-weight:600; }
#term { left:16px; top:42px; width:470px; height:236px; background:#0b0f14; }
#term pre { margin:0; padding:10px 12px; color:#d6e2ea; font:20px/1.45 Consolas, "Cascadia Mono", monospace; white-space:pre-wrap; }
#term .p { color:#7ee787; }
#files { left:24px; top:290px; width:360px; height:122px; background:#f6f8fa; }
#files .row { display:flex; gap:18px; padding:12px 14px; }
.fi { width:96px; text-align:center; font-size:19px; color:#1f2328; font-weight:600; }
.fi .ic { width:44px; height:36px; margin:0 auto 4px; border-radius:6px; background:#54aeff; }
.fi .ic.doc { background:#d0d7de; width:34px; height:42px; }
#ff { left:420px; top:60px; width:288px; height:250px; background:#fff; }
#ff img { width:160%; margin-left:-30%; margin-top:-6px; }
#skill { left:540px; top:40px; width:660px; padding:0; }
#skill .t { height:46px; display:flex; align-items:center; padding:0 18px; border-bottom:1px solid var(--line); font:600 18px Consolas, monospace; color:var(--muted); }
#skill pre { margin:0; padding:16px 20px; font:20px/1.55 Consolas, "Cascadia Mono", monospace; color:var(--ink); white-space:pre-wrap; min-height:210px; }
#memo { left:560px; top:330px; width:620px; padding:14px 18px; font-size:21px; line-height:1.45; }
#memo b { color:var(--green); }
#badge { left:640px !important; top:404px !important; z-index:5; }
#fleet { left:520px; top:40px; width:720px; height:400px; }
.dotcard { position:absolute; top:0; width:228px; height:380px; padding:14px; }
.dotcard .nm { display:flex; align-items:center; gap:10px; font-size:21px; font-weight:700; }
.dotcard .st { font-size:19px; color:var(--muted); margin-top:6px; }
.mini { margin-top:14px; height:150px; border-radius:10px; overflow:hidden; background:#1d2b3a; position:relative; border:1px solid var(--line); }
.mini .mw { position:absolute; left:10px; right:10px; top:12px; bottom:10px; border-radius:6px; overflow:hidden; }
.dotcard .job { margin-top:12px; font-size:19px; line-height:1.45; }
.dotcard .job b { color:var(--green); }
</style>
<div id="desk" class="abs">
<div class="taskbar">Applications&nbsp;&nbsp;·&nbsp;&nbsp;invoices' computer</div>
<div id="files" class="win"><div class="t">~/documents/invoices/2026-10</div><div class="row">
<div class="fi"><div class="ic doc"></div>0412.pdf</div><div class="fi"><div class="ic doc"></div>0413.pdf</div><div class="fi"><div class="ic doc"></div>0414.pdf</div></div></div>
<div id="term" class="win"><div class="t">Terminal</div><pre id="termtext"></pre></div>
<div id="ff" class="win"><div class="t">Firefox · identity "shop"</div><div style="font:18px/1.45 'Segoe UI';color:#1f2328"><div style="background:#7a2e1a;color:#fff;padding:8px 12px;font-weight:700">Ceramiche Rossi · Admin</div><div style="padding:8px 12px;font-weight:700">Invoices · October 2026</div><div style="display:flex;justify-content:space-between;padding:6px 12px;border-top:1px solid #eee"><span>#0412 · €84.00</span><span style="color:#0969da;font-weight:700">⬇ PDF</span></div><div style="display:flex;justify-content:space-between;padding:6px 12px;border-top:1px solid #eee"><span>#0413 · €126.50</span><span style="color:#0969da;font-weight:700">⬇ PDF</span></div><div style="display:flex;justify-content:space-between;padding:6px 12px;border-top:1px solid #eee"><span>#0414 · €310.00</span><span style="color:#0969da;font-weight:700">⬇ PDF</span></div></div></div>
</div>
<div id="skill" class="abs card"><div class="t">~/skills/shop-invoices.md</div><pre id="skilltext"></pre></div>
<div id="memo" class="abs card"><b>~/memory</b> · Federico wants the total, not the list.</div>
<div id="fleet" class="abs">
<div class="dotcard card" style="left:0">
<div class="nm"><div class="avatar">I</div>invoices</div><div class="st">wakes on the 1st</div>
<div class="mini"><div class="mw" style="background:#f6f8fa;color:#1f2328;font:600 16px/1.6 'Segoe UI';padding:8px 10px">~/documents<br>📄 0412.pdf<br>📄 0413.pdf</div></div>
<div class="job"><b>Monthly</b><br>the shop's invoices → Telegram</div></div>
<div class="dotcard card" style="left:246px">
<div class="nm"><div class="avatar" style="border-color:#3fb950">F</div>fare-watch</div><div class="st">running · its own VM</div>
<div class="mini"><div class="mw" style="background:#0b0f14;color:#d6e2ea;font:16px/1.6 Consolas,monospace;padding:8px 10px">$ fares.py<br>07/10 €38<br>09/10 €31</div></div>
<div class="job"><b>Every morning</b><br>cheapest flight, Milan → Lisbon</div></div>
<div class="dotcard card" style="left:492px">
<div class="nm"><div class="avatar" style="border-color:#a371f7">R</div>researcher</div><div class="st">running · its own VM</div>
<div class="mini"><div class="mw" style="background:#fff"><img src="../photos/trending.png" style="width:170%;margin-left:-35%"></div></div>
<div class="job"><b>Every day 08:00</b><br>GitHub Trending → Telegram</div></div>
</div>
<div id="logo" class="abs">
<div id="name" class="abs">invisible_dots</div>
<div id="tag" class="abs">Open-source, self-hosted alternative to OpenAI Dots, Meta Muse, Grok Bot, Manus Cue and Claude Cowork.</div>
<img id="four" class="abs">
</div>
<script>
const THEME = new URLSearchParams(location.search).get("theme") || "dark";
document.documentElement.className = THEME;
document.getElementById("four").src = `../photos/characters-${THEME}.png`;
const $ = (id) => document.getElementById(id);
const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
const ease = (x) => { x = clamp(x); return x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; };
const ramp = (t, a, b) => ease((t - a) / (b - a));
const win = (t, a, b, f = .35) => Math.min(ramp(t, a, a + f), 1 - ramp(t, b - f, b));
function show(id, o, dy = 0) { const e = $(id); e.style.opacity = o; e.style.transform = `translateY(${(1 - o) * dy}px)`; e.style.visibility = o > .001 ? "visible" : "hidden"; }
const typed = (text, s, a, b) => text.slice(0, Math.floor(clamp((s - a) / (b - a)) * text.length));
const ASK = "On the 1st of every month, log in to my shop, save the invoices in ~/documents and send me the total on Telegram.";
// scene windows on scene time s
const S = { ask: [0, 3.6], pc: [3.6, 8.4], learn: [8.4, 11.6], ask2: [11.6, 14.8], daily: [14.8, 17.8], fleet: [17.8, 21.2] };
const SCENES = [
[...S.ask, "1 · ASK ONCE", "Tell your Dot what you need.", "In plain words, from the web UI, the command line or Telegram."],
[...S.pc, "2 · ITS OWN COMPUTER", "Its own desktop, terminal and files.", "A VM on your PC: a desktop, a terminal, files and a Firefox."],
[...S.learn, "3 · IT LEARNS", "It writes its own skills and memory.", "How-tos and notes in its home folder, read again next time."],
[...S.ask2, "4 · YOU STAY IN CONTROL", "It asks before it acts.", "Every tool is allow, ask or deny. You answer from the Inbox or a chat."],
[...S.daily, "5 · ALWAYS ON", "Then it does it every month.", "Its computer wakes for the job, even when you are away."],
[...S.fleet, "6 · ONE COMPUTER PER AGENT", "As many Dots as you need.", "On your PC, open source. Any model on OpenRouter."],
];
const TERM = [
["$ ", "cd ~/documents/invoices"],
["$ ", "ls *.pdf | wc -l"],
["", "14"],
["$ ", "python3 ~/total.py"],
["", "Total: €1,284.50"],
];
const SKILL = "# The shop's invoices, monthly\n1. Log in with the browser identity \"shop\"\n2. Invoices, this month: download every PDF\n3. Save them to ~/documents/invoices/<month>\n4. Telegram: how many, and the total";
const DAYS = [
["Oct 1", "<b>October invoices</b><br>14 PDFs · €1,284.50"],
["Nov 1", "<b>November invoices</b><br>11 PDFs · €968.00"],
["Dec 1", "<b>December invoices</b><br>19 PDFs · €2,410.75"],
];
function scenes(s, o) {
let cap = 0;
for (const [a, b, step, line, sub] of SCENES) {
const w = win(s, a, b, .3);
if (w > cap) { cap = w; $("step").textContent = step; $("line").textContent = line; $("sub").textContent = sub; }
}
show("caption", cap * o, 14); show("brand", o);
show("computer", 0); // v1's browser-only card is not used in v2
// 1 the chat
show("chat", win(s, .1, S.ask[1] + .5) * o, 30);
$("typed").textContent = s < 2.95 ? typed(ASK, s, .5, 2.7) : "Write a message.";
$("composer").style.color = s < 2.95 ? "var(--ink)" : "var(--muted)";
$("caret").style.opacity = s < 2.95 && Math.floor(s * 3) % 2 === 0 ? 1 : 0;
$("bubble").textContent = ASK;
show("bubble", ramp(s, 2.95, 3.2), 20);
show("thinking", ramp(s, 3.25, 3.4));
$("thinktext").textContent = "Thinking" + ".".repeat(1 + Math.floor(s * 3) % 3);
// 2 its computer: the terminal types, the files are there, Firefox on top
const a = S.pc[0];
show("desk", win(s, a + .1, S.pc[1] + .2) * o, 40);
let out = "", t0 = a + .5;
for (const [prompt, cmd] of TERM) {
const dur = prompt ? .7 : .3;
if (s < t0) break;
out += (prompt ? `<span class="p">${prompt}</span>` : "") + typed(cmd, s, t0, t0 + dur) + "\n";
t0 += dur + .15;
}
$("termtext").innerHTML = out;
show("ff", ramp(s, a + 2.6, a + 3.0), 30);
$("ff").lastElementChild.style.transform = `translateY(${-ramp(s, a + 3.2, a + 4.4) * 40}px)`;
show("badge", win(s, a + 2.9, S.pc[1] + .1, .25) * o, 16);
// 3 skills and memory
const l = S.learn[0];
show("skill", win(s, l + .1, S.learn[1] + .2) * o, 30);
$("skilltext").textContent = typed(SKILL, s, l + .4, l + 2.0);
show("memo", win(s, l + 2.0, S.learn[1] + .2, .3) * o, 16);
// 4 the approval
const c = S.ask2[0];
show("approval", win(s, c + .2, S.ask2[1] + .2) * o, 30);
const clicked = s >= c + 1.85;
$("allow").textContent = clicked ? "Allowed ✓" : "Allow once";
$("allow").style.background = clicked ? "var(--green)" : "var(--primary)";
$("allow").style.borderColor = clicked ? "var(--green)" : "var(--primary)";
$("allow").style.color = clicked ? "#ffffff" : "var(--primary-ink)";
const r = clamp((s - c - 1.75) / .45);
$("ripple").style.left = (628 - r * 18) + "px"; $("ripple").style.top = (318 - r * 18) + "px";
$("ripple").style.width = $("ripple").style.height = (16 + r * 36) + "px";
$("ripple").style.opacity = s > c + 1.75 && s < c + 2.2 ? (1 - r) * o : 0;
let cx, cy, co = 0;
if (s > c + .3 && s < S.ask2[1]) { const k = ramp(s, c + .5, c + 1.6); cx = 980 - 336 * k; cy = 460 - 130 * k; co = win(s, c + .3, S.ask2[1], .3); }
const cur = $("cursor"); cur.style.opacity = co * o; if (co) { cur.style.left = cx + "px"; cur.style.top = cy + "px"; }
// 5 every day, on Telegram
const d = S.daily[0];
show("phone", win(s, d + .1, S.daily[1] + .2) * o, 40);
const day = Math.min(2, Math.floor(clamp((s - d - .4) / 2.6) * 3));
$("clock").textContent = DAYS[day][0] + ", 09:00";
DAYS.forEach(([dd, body], i) => {
const m = $("m" + (i + 1));
m.innerHTML = body + `<span class="t">${dd}, 09:00</span>`;
m.style.top = (74 + i * 124) + "px";
show("m" + (i + 1), ramp(s, d + .5 + i * .8, d + .8 + i * .8), 24);
});
// 6 many Dots, one computer each
show("fleet", win(s, S.fleet[0] + .1, S.fleet[1] + .3) * o, 30);
document.querySelectorAll(".dotcard").forEach((e, i) => { const v = ramp(s, S.fleet[0] + .2 + i * .35, S.fleet[0] + .55 + i * .35); e.style.opacity = v; e.style.transform = `translateY(${(1 - v) * 24}px)`; });
}
const HOLD = 3.0, OUT = .5, SCENE = 21.2, BACK = .8, END = 1.2;
window.LOOP_SECONDS = HOLD + SCENE + BACK + END;
window.renderLoop = (t) => {
let logo, s;
if (t < HOLD) { logo = 1 - ramp(t, HOLD - OUT, HOLD); s = 0; }
else if (t < HOLD + SCENE) { logo = 0; s = t - HOLD; }
else { logo = ramp(t, HOLD + SCENE, HOLD + SCENE + BACK); s = SCENE; }
const o = logo > 0 && t >= HOLD + SCENE ? 1 - logo : 1;
scenes(t < HOLD ? -1 : s, t < HOLD ? 0 : o);
show("logo", logo, -10);
$("four").style.transform = `translateY(${(1 - logo) * 60}px)`;
};
renderLoop(0);
// Opened in a browser, the page plays its own loop. build.mjs adds ?frames to the
// address and drives renderLoop(t) itself, one frame at a time, so nothing moves
// between two of its screenshots.
if (!new URLSearchParams(location.search).has("frames")) {
const start = performance.now();
const tick = (now) => { renderLoop(((now - start) / 1000) % window.LOOP_SECONDS); requestAnimationFrame(tick); };
requestAnimationFrame(tick);
}
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB