Merge pull request #1446 from feder-cr/hero-from-photos
docs: the README animation is built from photos in the tree, with new characters
No files matched your search
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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> 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> 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 · 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>
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 324 KiB |