@jossuealcala/madre 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +265 -1
- package/README.md +33 -1
- package/docs/SDK.md +173 -6
- package/docs/sdk/connector-module.mjs +186 -0
- package/package.json +6 -2
- package/public/abduction.js +384 -0
- package/public/app.js +3450 -540
- package/public/assets/abduction-cat-blink.png +0 -0
- package/public/assets/abduction-cat-control-left.png +0 -0
- package/public/assets/abduction-cat-control-right.png +0 -0
- package/public/assets/abduction-cat.png +0 -0
- package/public/es.js +354 -54
- package/public/index.html +65 -19
- package/public/routing.js +74 -0
- package/public/styles.css +788 -77
- package/src/adapters/claude.mjs +6 -2
- package/src/adapters/gemini.mjs +26 -5
- package/src/adapters/madre.mjs +5 -0
- package/src/choices.mjs +64 -0
- package/src/dataset.mjs +11 -6
- package/src/distiller.mjs +12 -1
- package/src/file-ops.mjs +45 -0
- package/src/lease.mjs +37 -2
- package/src/mcp/smtp-server.mjs +189 -0
- package/src/memory-dashboard.mjs +69 -0
- package/src/memory.mjs +222 -18
- package/src/modules/ahp.mjs +1 -1
- package/src/modules/ash-policy.mjs +67 -0
- package/src/modules/ash.mjs +1 -1
- package/src/modules/choices.mjs +34 -0
- package/src/modules/correo.mjs +107 -0
- package/src/modules/index.mjs +48 -4
- package/src/modules/playwright.mjs +5 -1
- package/src/modules/ripley.mjs +13 -1
- package/src/modules/sdk.mjs +44 -9
- package/src/outbound.mjs +37 -3
- package/src/ports.mjs +91 -0
- package/src/request-guard.mjs +26 -0
- package/src/room/archivist.mjs +2 -1
- package/src/room/economy.mjs +83 -1
- package/src/room/prompt.mjs +14 -2
- package/src/room.mjs +150 -18
- package/src/sentinel-errors.mjs +5 -1
- package/src/server.mjs +188 -8
- package/src/smtp.mjs +122 -0
- package/src/vault.mjs +100 -0
- package/src/whats-new.mjs +140 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// CONNECTOR · a complete MADRE connector in one file, no imports from npm, no build.
|
|
2
|
+
//
|
|
3
|
+
// This one really works: it sends you a Telegram message. Getting the credential takes about a
|
|
4
|
+
// minute (talk to @BotFather, /newbot, copy the token; then message your bot once and read your
|
|
5
|
+
// chat id from https://api.telegram.org/bot<TOKEN>/getUpdates). Install it, paste the token, and
|
|
6
|
+
// an agent in #4 AIRLOCK can write to you.
|
|
7
|
+
//
|
|
8
|
+
// Copy it to ~/.pulse/modules/telegram.mjs (every project) or <project>/.madre/modules/
|
|
9
|
+
// (this project), then MODULES → RELOAD. Or just ask for one: /module <what it should do>.
|
|
10
|
+
//
|
|
11
|
+
// ─── The one shape worth copying ──────────────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// A connector needs two things that look like they need two files: a module MADRE imports, and
|
|
14
|
+
// an MCP server MADRE runs as its own process. They are the same file. It is the spec when
|
|
15
|
+
// imported and the server when executed, and it tells the two apart at the bottom.
|
|
16
|
+
//
|
|
17
|
+
// ─── Four rules, and the reason for each ──────────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// 1. Import nothing but node: builtins. MADRE loads your file from a scratch folder to check it
|
|
20
|
+
// before installing, and there is no node_modules there. `node:fs`, `node:url`, `node:tls`
|
|
21
|
+
// and friends always resolve; anything else fails the install. And never copy
|
|
22
|
+
// `import { defineModule } from './sdk.mjs'` out of MADRE's own modules — they live inside
|
|
23
|
+
// the package and you do not. That is the one mistake that looks right and never works.
|
|
24
|
+
//
|
|
25
|
+
// 2. Declare your secrets; never invent a place to put them. MADRE draws the field, keeps the
|
|
26
|
+
// value in its vault under your id (0600 inside 0700), accepts it only from this computer,
|
|
27
|
+
// and hands it back only to you through ctx.vault. The value goes into the environment of
|
|
28
|
+
// the process you spawn and nowhere else — not the prompt, not the brief, not a tool result.
|
|
29
|
+
//
|
|
30
|
+
// 3. Declare where you reach. Four answers, the same ones MADRE gives for its own addresses.
|
|
31
|
+
// Without them your traffic reads in the log as «an address nothing here declares», and the
|
|
32
|
+
// human cannot tell your connector from something going wrong.
|
|
33
|
+
//
|
|
34
|
+
// 4. Say which of your tools SEND. Anything whose effect does not come back — a message, an
|
|
35
|
+
// order, a row in somebody's system — goes in `sends`, and MADRE withholds it below
|
|
36
|
+
// #4 AIRLOCK. You do not enforce this; the core does. Declaring is the whole of your part.
|
|
37
|
+
|
|
38
|
+
import { realpath } from 'node:fs/promises';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
42
|
+
const SECRET = 'bot-token';
|
|
43
|
+
const API = 'api.telegram.org';
|
|
44
|
+
|
|
45
|
+
export default {
|
|
46
|
+
id: 'telegram',
|
|
47
|
+
name: 'TELEGRAM',
|
|
48
|
+
vendor: 'you',
|
|
49
|
+
version: '0.1.0',
|
|
50
|
+
summary: 'Lets an agent send you one Telegram message through your own bot. Sending is only offered in #4 AIRLOCK, and a sent message cannot be unsent.',
|
|
51
|
+
creates: ['nothing in the project', 'an entry in ~/.pulse/config.json', 'your bot token in MADRE’s vault'],
|
|
52
|
+
requires: ['a bot token from @BotFather', 'your chat id'],
|
|
53
|
+
|
|
54
|
+
// Plain settings. MADRE draws these on the card and saves them in your block of config.json.
|
|
55
|
+
settings: { enabled: false, chatId: '' },
|
|
56
|
+
controls: [{ key: 'chatId', label: 'CHAT ID', type: 'text', note: 'Who the message goes to. Message your bot once, then read it from /getUpdates.' }],
|
|
57
|
+
|
|
58
|
+
// RULE 2 · the secret, declared. MADRE draws a password field, keeps the value, and shows the
|
|
59
|
+
// card only its length — never the value, not even back to you.
|
|
60
|
+
secrets: [{
|
|
61
|
+
name: SECRET,
|
|
62
|
+
label: 'BOT TOKEN',
|
|
63
|
+
note: 'From @BotFather. Revoke it there any time without touching your account.',
|
|
64
|
+
where: 'https://t.me/botfather',
|
|
65
|
+
}],
|
|
66
|
+
|
|
67
|
+
// RULE 3 · where this reaches, in the four answers.
|
|
68
|
+
reaches: [{
|
|
69
|
+
id: 'api',
|
|
70
|
+
host: API,
|
|
71
|
+
to: 'Telegram, through your own bot',
|
|
72
|
+
what: 'The text of the message you asked to send, and your bot token to sign in.',
|
|
73
|
+
when: 'Only when an agent in #4 AIRLOCK calls send_telegram, and only for that one message.',
|
|
74
|
+
where: 'Switch off TELEGRAM, or forget its token, and nothing can leave.',
|
|
75
|
+
}],
|
|
76
|
+
|
|
77
|
+
async status(ctx) {
|
|
78
|
+
const held = await ctx.vault.held();
|
|
79
|
+
const problems = [];
|
|
80
|
+
if (!held.some((one) => one.name === SECRET)) problems.push('No bot token yet: talk to @BotFather, then paste it above.');
|
|
81
|
+
if (!String(ctx.settings.chatId ?? '').trim()) problems.push('No chat id yet: message your bot once, then read it from /getUpdates.');
|
|
82
|
+
return {
|
|
83
|
+
status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (problems.length ? 'on · not configured' : `on · chat ${ctx.settings.chatId}`) : 'off' },
|
|
84
|
+
preflight: { ok: !problems.length, problems },
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// The tool, for one turn. Check here what could be missing rather than leave the model to find
|
|
89
|
+
// out: a tool that is offered and always fails is worse than no tool at all.
|
|
90
|
+
async toolsForTurn(ctx) {
|
|
91
|
+
const chatId = String(ctx.settings.chatId ?? '').trim();
|
|
92
|
+
const token = await ctx.vault.get(SECRET);
|
|
93
|
+
if (!chatId || !token) return [];
|
|
94
|
+
return [{
|
|
95
|
+
name: 'telegram',
|
|
96
|
+
command: process.execPath,
|
|
97
|
+
args: [SELF], // ← the same file, run as a process. See the bottom.
|
|
98
|
+
env: { TELEGRAM_TOKEN: token, TELEGRAM_CHAT: chatId },
|
|
99
|
+
tools: ['send_telegram'],
|
|
100
|
+
sends: ['send_telegram'], // RULE 4 · withheld below #4 AIRLOCK, by the core
|
|
101
|
+
brief: `You can send the human one Telegram message with the MCP tool send_telegram (server telegram). It reaches them personally and cannot be unsent: say in one line what you are about to send, then send it.`,
|
|
102
|
+
}];
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ─── From here down, this file is the MCP server ──────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
const TOOL = {
|
|
109
|
+
name: 'send_telegram',
|
|
110
|
+
description: 'Send the human one short Telegram message. This cannot be undone: state in one line what it says before calling it.',
|
|
111
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The message, as plain text.' } }, required: ['text'] },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
async function sendMessage(text, env = process.env) {
|
|
115
|
+
if (!String(text ?? '').trim()) throw Object.assign(new Error('There is nothing to send.'), { code: 'EMPTY' });
|
|
116
|
+
const response = await fetch(`https://${API}/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT, text: String(text).slice(0, 4000) }),
|
|
120
|
+
});
|
|
121
|
+
const body = await response.json().catch(() => ({}));
|
|
122
|
+
// Telegram answers 200 with ok:false, so the status alone is not the answer.
|
|
123
|
+
if (!response.ok || body.ok !== true) throw Object.assign(new Error(body.description ?? `Telegram answered HTTP ${response.status}.`), { code: `TELEGRAM_${response.status}` });
|
|
124
|
+
return { chat: env.TELEGRAM_CHAT, chars: String(text).length };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Minimal JSON-RPC 2.0 over stdio: one newline-delimited message per line.
|
|
128
|
+
export function handleRequest(message, { env = process.env, send = sendMessage } = {}) {
|
|
129
|
+
const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
|
|
130
|
+
switch (message.method) {
|
|
131
|
+
case 'initialize':
|
|
132
|
+
return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'telegram', version: '0.1.0' } }));
|
|
133
|
+
case 'notifications/initialized':
|
|
134
|
+
case 'initialized':
|
|
135
|
+
return Promise.resolve(null);
|
|
136
|
+
case 'ping':
|
|
137
|
+
return Promise.resolve(reply({}));
|
|
138
|
+
case 'tools/list':
|
|
139
|
+
return Promise.resolve(reply({ tools: [TOOL] }));
|
|
140
|
+
case 'tools/call': {
|
|
141
|
+
const { name, arguments: args = {} } = message.params ?? {};
|
|
142
|
+
if (name !== TOOL.name) return Promise.resolve(reply({ content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }));
|
|
143
|
+
return send(args.text, env)
|
|
144
|
+
.then((sent) => reply({ content: [{ type: 'text', text: `Sent ${sent.chars} characters to chat ${sent.chat}.` }], isError: false }))
|
|
145
|
+
// A failure comes back as an error RESULT, never as a sentence that reads like success.
|
|
146
|
+
.catch((error) => reply({ content: [{ type: 'text', text: `Nothing was sent (${error.code ?? 'ERROR'}): ${error.message}` }], isError: true }));
|
|
147
|
+
}
|
|
148
|
+
default:
|
|
149
|
+
return Promise.resolve(message.id === undefined ? null : { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function serve({ input = process.stdin, output = process.stdout, env = process.env } = {}) {
|
|
154
|
+
let buffer = '';
|
|
155
|
+
let pending = 0;
|
|
156
|
+
let ended = false;
|
|
157
|
+
const finish = () => { if (ended && pending === 0) process.exit(0); };
|
|
158
|
+
input.setEncoding('utf8');
|
|
159
|
+
input.on('data', (chunk) => {
|
|
160
|
+
buffer += chunk;
|
|
161
|
+
let index;
|
|
162
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
163
|
+
const line = buffer.slice(0, index).trim();
|
|
164
|
+
buffer = buffer.slice(index + 1);
|
|
165
|
+
if (!line) continue;
|
|
166
|
+
let message;
|
|
167
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
168
|
+
pending += 1;
|
|
169
|
+
void handleRequest(message, { env })
|
|
170
|
+
.then((answer) => { if (answer) output.write(`${JSON.stringify(answer)}\n`); })
|
|
171
|
+
.finally(() => { pending -= 1; finish(); });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
input.on('end', () => { ended = true; finish(); });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Imported, or executed? Resolve BOTH paths before comparing them.
|
|
178
|
+
//
|
|
179
|
+
// `process.argv[1] === SELF` looks equivalent and mostly is: when MADRE spawns this, it passes
|
|
180
|
+
// the very SELF computed below, already resolved by Node's loader. But the moment YOU run the
|
|
181
|
+
// file by hand to try it — `node ~/.pulse/modules/telegram.mjs`, or anything through /tmp, which
|
|
182
|
+
// on macOS is a link to /private/tmp — argv[1] keeps the spelling you typed and SELF does not.
|
|
183
|
+
// The comparison is false, this branch never runs, the process starts, answers nothing and exits
|
|
184
|
+
// successfully. Nothing is printed and nothing is wrong on paper. Two lines buy you that.
|
|
185
|
+
const real = async (path) => (path ? realpath(path).catch(() => path) : null);
|
|
186
|
+
if ((await real(process.argv[1])) === (await real(SELF))) serve();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jossuealcala/madre",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on your project together, share one memory you can see and explore, and answer to you. Read-only by default, with per-message permission modes and checkpoints you can undo. Nothing leaves your machine on its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
7
7
|
"codex",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"pulse"
|
|
21
21
|
],
|
|
22
22
|
"homepage": "https://github.com/jossuealcacao-exe/madre#readme",
|
|
23
|
+
"funding": {
|
|
24
|
+
"type": "github",
|
|
25
|
+
"url": "https://github.com/sponsors/jossuealcacao-exe"
|
|
26
|
+
},
|
|
23
27
|
"repository": {
|
|
24
28
|
"type": "git",
|
|
25
29
|
"url": "git+https://github.com/jossuealcacao-exe/madre.git"
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// ABDUCTION — the one joke in the room, and the only thing here that is not work.
|
|
2
|
+
//
|
|
3
|
+
// Type MADRE into the box, alone, and send it. Not @madre: that is the local agent and it has a
|
|
4
|
+
// turn to answer. Just the word, nothing else in the field, nothing attached. The room takes it
|
|
5
|
+
// as being called by name, and what answers is a flying saucer with a cat in it.
|
|
6
|
+
//
|
|
7
|
+
// It never reaches a provider, never enters the ledger, never costs a token. The field comes back
|
|
8
|
+
// exactly as it was. The one image ships with MADRE; no font or network request is involved.
|
|
9
|
+
|
|
10
|
+
// Called by name: the word on its own, in any casing, with nothing around it but space. An @ in
|
|
11
|
+
// front is an agent being addressed and belongs to the room, not here.
|
|
12
|
+
export function calledByName(text, { attachments = 0 } = {}) {
|
|
13
|
+
if (attachments > 0) return false;
|
|
14
|
+
return /^\s*madre\s*$/i.test(String(text ?? ''));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PHASES = { arrive: 700, beam: 1050, lift: 2000, swallow: 2400, leave: 3000, done: 3400 };
|
|
18
|
+
const SHIP_FRAMES = Object.freeze({
|
|
19
|
+
idle: '/assets/abduction-cat.png',
|
|
20
|
+
blink: '/assets/abduction-cat-blink.png',
|
|
21
|
+
controlLeft: '/assets/abduction-cat-control-left.png',
|
|
22
|
+
controlRight: '/assets/abduction-cat-control-right.png',
|
|
23
|
+
});
|
|
24
|
+
const ease = (t) => (t < 0 ? 0 : t > 1 ? 1 : t * t * (3 - 2 * t));
|
|
25
|
+
const span = (now, from, to) => ease((now - from) / (to - from));
|
|
26
|
+
const bounce = (t) => 1 + 0.22 * Math.sin(Math.PI * 2 * t) * (1 - t);
|
|
27
|
+
|
|
28
|
+
// The saucer, and the passenger. Local space is 0..900 across, 0..520 down; the belly the beam
|
|
29
|
+
// comes out of is (450, 452), which is the one number the page positions everything else from.
|
|
30
|
+
function shipSVG(doc) {
|
|
31
|
+
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
32
|
+
svg.setAttribute('viewBox', '0 0 900 520');
|
|
33
|
+
svg.setAttribute('class', 'ab-svg');
|
|
34
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
35
|
+
svg.innerHTML = `
|
|
36
|
+
<defs>
|
|
37
|
+
<linearGradient id="ab-hull" x1="0" y1="0" x2="0" y2="1">
|
|
38
|
+
<stop offset="0" stop-color="#cfd8e2"/>
|
|
39
|
+
<stop offset=".3" stop-color="#8c9aab"/>
|
|
40
|
+
<stop offset=".62" stop-color="#4a5666"/>
|
|
41
|
+
<stop offset="1" stop-color="#232c38"/>
|
|
42
|
+
</linearGradient>
|
|
43
|
+
<linearGradient id="ab-rim" x1="0" y1="0" x2="0" y2="1">
|
|
44
|
+
<stop offset="0" stop-color="#f2f6fa"/>
|
|
45
|
+
<stop offset=".5" stop-color="#9fb0c2"/>
|
|
46
|
+
<stop offset="1" stop-color="#596675"/>
|
|
47
|
+
</linearGradient>
|
|
48
|
+
<radialGradient id="ab-glass" cx=".36" cy=".26" r=".8">
|
|
49
|
+
<stop offset="0" stop-color="#ffffff" stop-opacity=".55"/>
|
|
50
|
+
<stop offset=".45" stop-color="#bfe8ff" stop-opacity=".18"/>
|
|
51
|
+
<stop offset="1" stop-color="#8ed0f0" stop-opacity=".1"/>
|
|
52
|
+
</radialGradient>
|
|
53
|
+
<linearGradient id="ab-fur" x1=".3" y1="0" x2=".6" y2="1">
|
|
54
|
+
<stop offset="0" stop-color="#cdf266"/>
|
|
55
|
+
<stop offset=".35" stop-color="#8ad434"/>
|
|
56
|
+
<stop offset=".78" stop-color="#4f9c1c"/>
|
|
57
|
+
<stop offset="1" stop-color="#2f6d12"/>
|
|
58
|
+
</linearGradient>
|
|
59
|
+
<linearGradient id="ab-muzzle" x1="0" y1="0" x2="0" y2="1">
|
|
60
|
+
<stop offset="0" stop-color="#e2fb93"/>
|
|
61
|
+
<stop offset="1" stop-color="#9ad93e"/>
|
|
62
|
+
</linearGradient>
|
|
63
|
+
<radialGradient id="ab-bulb">
|
|
64
|
+
<stop offset="0" stop-color="#ffffff"/>
|
|
65
|
+
<stop offset=".4" stop-color="#dcff7a"/>
|
|
66
|
+
<stop offset="1" stop-color="#7ac41f"/>
|
|
67
|
+
</radialGradient>
|
|
68
|
+
<linearGradient id="ab-beam" x1="0" y1="0" x2="0" y2="1">
|
|
69
|
+
<stop offset="0" stop-color="#d9ff8a" stop-opacity=".85"/>
|
|
70
|
+
<stop offset=".45" stop-color="#9ee23f" stop-opacity=".34"/>
|
|
71
|
+
<stop offset="1" stop-color="#7ac41f" stop-opacity=".06"/>
|
|
72
|
+
</linearGradient>
|
|
73
|
+
<!-- fur: the silhouette's edge is pushed about by noise, which is cheaper and softer than
|
|
74
|
+
drawing several hundred hairs, and it never looks repeated -->
|
|
75
|
+
<filter id="ab-furry" x="-15%" y="-15%" width="130%" height="130%">
|
|
76
|
+
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="3" seed="7" result="n"/>
|
|
77
|
+
<feDisplacementMap in="SourceGraphic" in2="n" scale="9" xChannelSelector="R" yChannelSelector="G"/>
|
|
78
|
+
</filter>
|
|
79
|
+
<filter id="ab-soft" x="-40%" y="-40%" width="180%" height="180%">
|
|
80
|
+
<feGaussianBlur stdDeviation="14"/>
|
|
81
|
+
</filter>
|
|
82
|
+
<filter id="ab-lamp-glow" x="-350%" y="-350%" width="800%" height="800%">
|
|
83
|
+
<feGaussianBlur stdDeviation="6" result="blur"/>
|
|
84
|
+
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
85
|
+
</filter>
|
|
86
|
+
<filter id="ab-mask-soft" x="-8%" y="-10%" width="116%" height="120%">
|
|
87
|
+
<feGaussianBlur stdDeviation="10"/>
|
|
88
|
+
</filter>
|
|
89
|
+
<mask id="ab-silhouette" maskUnits="userSpaceOnUse" x="0" y="0" width="900" height="520">
|
|
90
|
+
<rect width="900" height="520" fill="#000"/>
|
|
91
|
+
<g fill="#fff" filter="url(#ab-mask-soft)">
|
|
92
|
+
<!-- Deliberately wider than the photographed silhouette: the fade happens in empty
|
|
93
|
+
backdrop, never through the metal, glass, antennae or emitter. -->
|
|
94
|
+
<ellipse cx="450" cy="345" rx="438" ry="174"/>
|
|
95
|
+
<ellipse cx="450" cy="178" rx="252" ry="218"/>
|
|
96
|
+
<ellipse cx="450" cy="462" rx="112" ry="58"/>
|
|
97
|
+
</g>
|
|
98
|
+
</mask>
|
|
99
|
+
</defs>
|
|
100
|
+
|
|
101
|
+
<!-- Four registered photographs make one tiny performance: idle, blink and two controls.
|
|
102
|
+
The vector remains underneath as a local fallback if the base frame cannot be decoded. -->
|
|
103
|
+
<g class="ab-photos" mask="url(#ab-silhouette)">
|
|
104
|
+
<image class="ab-photo" data-frame="idle" href="${SHIP_FRAMES.idle}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
|
|
105
|
+
<image class="ab-photo" data-frame="blink" href="${SHIP_FRAMES.blink}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
|
|
106
|
+
<image class="ab-photo" data-frame="controlLeft" href="${SHIP_FRAMES.controlLeft}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
|
|
107
|
+
<image class="ab-photo" data-frame="controlRight" href="${SHIP_FRAMES.controlRight}" x="60" y="0" width="780" height="520" preserveAspectRatio="xMidYMid meet"/>
|
|
108
|
+
</g>
|
|
109
|
+
<!-- These highlights stay vector-sharp so the hull feels powered rather than baked into a
|
|
110
|
+
still image. Their positions follow the practical lamps in every registered frame. -->
|
|
111
|
+
<g class="ab-live-lamps"></g>
|
|
112
|
+
<ellipse class="ab-live-emitter" cx="450" cy="452" rx="45" ry="9" fill="#ddff9a" opacity=".25" filter="url(#ab-lamp-glow)"/>
|
|
113
|
+
<path class="ab-glass-sheen" d="M300 265 C318 150 384 91 455 83" fill="none" stroke="#efffe2" stroke-width="8" stroke-linecap="round" opacity=".08"/>
|
|
114
|
+
<g class="ab-ship ab-vector">
|
|
115
|
+
<!-- the glow the hull sits in -->
|
|
116
|
+
<ellipse cx="450" cy="372" rx="300" ry="54" fill="#9ee23f" opacity=".25" filter="url(#ab-soft)"/>
|
|
117
|
+
|
|
118
|
+
<!-- the glass dome, and the passenger inside it -->
|
|
119
|
+
<g class="ab-cabin">
|
|
120
|
+
<path d="M250 320 C 250 176 340 86 450 86 C 560 86 650 176 650 320 Z" fill="#0b1118" opacity=".55"/>
|
|
121
|
+
<g class="ab-cat">
|
|
122
|
+
<!-- antennae -->
|
|
123
|
+
<g class="ab-antennae">
|
|
124
|
+
<path d="M392 196 C 376 160 366 134 364 110" fill="none" stroke="#7ac41f" stroke-width="11" stroke-linecap="round"/>
|
|
125
|
+
<path d="M508 196 C 524 160 534 134 536 110" fill="none" stroke="#7ac41f" stroke-width="11" stroke-linecap="round"/>
|
|
126
|
+
<circle class="ab-bulb" cx="364" cy="102" r="14" fill="url(#ab-bulb)"/>
|
|
127
|
+
<circle class="ab-bulb" cx="536" cy="102" r="14" fill="url(#ab-bulb)"/>
|
|
128
|
+
</g>
|
|
129
|
+
<!-- head and body, one furry silhouette -->
|
|
130
|
+
<g filter="url(#ab-furry)">
|
|
131
|
+
<path d="M450 302 C 370 302 338 272 338 224 C 338 162 384 126 450 126 C 516 126 562 162 562 224 C 562 272 530 302 450 302 Z" fill="url(#ab-fur)"/>
|
|
132
|
+
<path d="M398 288 C 372 300 356 318 352 340 L548 340 C 544 318 528 300 502 288 Z" fill="url(#ab-fur)"/>
|
|
133
|
+
</g>
|
|
134
|
+
<!-- muzzle -->
|
|
135
|
+
<ellipse cx="450" cy="252" rx="46" ry="26" fill="url(#ab-muzzle)" opacity=".5"/>
|
|
136
|
+
<!-- the eyes: big, black, almond, and the only part that has to be exactly right -->
|
|
137
|
+
<g class="ab-eyes">
|
|
138
|
+
<ellipse class="ab-eye" cx="410" cy="224" rx="33" ry="24" fill="#06120a" transform="rotate(-17 410 224)"/>
|
|
139
|
+
<ellipse class="ab-eye" cx="490" cy="224" rx="33" ry="24" fill="#06120a" transform="rotate(17 490 224)"/>
|
|
140
|
+
<circle cx="399" cy="214" r="7" fill="#ffffff" opacity=".92"/>
|
|
141
|
+
<circle cx="479" cy="214" r="7" fill="#ffffff" opacity=".92"/>
|
|
142
|
+
<circle cx="421" cy="232" r="3.4" fill="#ffffff" opacity=".45"/>
|
|
143
|
+
<circle cx="501" cy="232" r="3.4" fill="#ffffff" opacity=".45"/>
|
|
144
|
+
</g>
|
|
145
|
+
<!-- the lids, dropped only to blink -->
|
|
146
|
+
<g class="ab-lids">
|
|
147
|
+
<ellipse class="ab-lid" cx="410" cy="224" rx="35" ry="26" fill="url(#ab-fur)" transform="rotate(-17 410 224)"/>
|
|
148
|
+
<ellipse class="ab-lid" cx="490" cy="224" rx="35" ry="26" fill="url(#ab-fur)" transform="rotate(17 490 224)"/>
|
|
149
|
+
</g>
|
|
150
|
+
<path d="M443 248 L457 248 L450 256 Z" fill="#2f6d12"/>
|
|
151
|
+
<path d="M450 256 C 450 264 442 266 436 262" fill="none" stroke="#2f6d12" stroke-width="3" stroke-linecap="round"/>
|
|
152
|
+
<path d="M450 256 C 450 264 458 266 464 262" fill="none" stroke="#2f6d12" stroke-width="3" stroke-linecap="round"/>
|
|
153
|
+
<g stroke="#e8ffb4" stroke-width="2" stroke-linecap="round" opacity=".6">
|
|
154
|
+
<path d="M408 254 L356 246"/><path d="M408 260 L358 262"/>
|
|
155
|
+
<path d="M492 254 L544 246"/><path d="M492 260 L542 262"/>
|
|
156
|
+
</g>
|
|
157
|
+
<!-- paws on the glass -->
|
|
158
|
+
<ellipse cx="398" cy="336" rx="22" ry="13" fill="url(#ab-fur)"/>
|
|
159
|
+
<ellipse cx="502" cy="336" rx="22" ry="13" fill="url(#ab-fur)"/>
|
|
160
|
+
</g>
|
|
161
|
+
<path d="M250 320 C 250 176 340 86 450 86 C 560 86 650 176 650 320 Z" fill="url(#ab-glass)"/>
|
|
162
|
+
<path d="M300 300 C 302 196 352 120 430 104 C 372 136 334 210 330 300 Z" fill="#ffffff" opacity=".3"/>
|
|
163
|
+
<path d="M250 320 C 250 176 340 86 450 86 C 560 86 650 176 650 320" fill="none" stroke="#dfeaf4" stroke-opacity=".7" stroke-width="4"/>
|
|
164
|
+
</g>
|
|
165
|
+
|
|
166
|
+
<!-- the hull -->
|
|
167
|
+
<ellipse cx="450" cy="322" rx="330" ry="34" fill="url(#ab-rim)"/>
|
|
168
|
+
<path d="M120 322 C 120 362 258 392 450 392 C 642 392 780 362 780 322 Z" fill="url(#ab-hull)"/>
|
|
169
|
+
<path d="M140 330 C 180 352 300 368 450 368 C 600 368 720 352 760 330" fill="none" stroke="#0e141c" stroke-opacity=".35" stroke-width="6"/>
|
|
170
|
+
<g class="ab-lamps"></g>
|
|
171
|
+
<!-- the emitter the beam leaves from -->
|
|
172
|
+
<ellipse cx="450" cy="388" rx="58" ry="14" fill="#0a0f15"/>
|
|
173
|
+
<ellipse class="ab-emitter" cx="450" cy="386" rx="46" ry="10" fill="#c9ff79"/>
|
|
174
|
+
</g>`;
|
|
175
|
+
for (const photo of svg.querySelectorAll('.ab-photo')) {
|
|
176
|
+
photo.addEventListener('error', () => {
|
|
177
|
+
if (photo.dataset.frame === 'idle') svg.classList.add('missing-photo');
|
|
178
|
+
else photo.remove();
|
|
179
|
+
}, { once: true });
|
|
180
|
+
}
|
|
181
|
+
return svg;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A light laid over each practical bulb makes the photographed ship feel powered. They chase in
|
|
185
|
+
// order, but never go fully dark: this is a living console, not a Christmas garland.
|
|
186
|
+
function growLamps(svg) {
|
|
187
|
+
const host = svg.querySelector('.ab-live-lamps');
|
|
188
|
+
if (!host) return [];
|
|
189
|
+
const doc = svg.ownerDocument;
|
|
190
|
+
const lamps = [];
|
|
191
|
+
const points = [[110, 331], [203, 325], [319, 326], [450, 330], [590, 326], [708, 325], [797, 331]];
|
|
192
|
+
for (const [x, y] of points) {
|
|
193
|
+
const lamp = doc.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
|
|
194
|
+
lamp.setAttribute('cx', String(x));
|
|
195
|
+
lamp.setAttribute('cy', String(y));
|
|
196
|
+
lamp.setAttribute('rx', '10');
|
|
197
|
+
lamp.setAttribute('ry', '7');
|
|
198
|
+
lamp.setAttribute('fill', '#e6ffad');
|
|
199
|
+
host.append(lamp);
|
|
200
|
+
lamps.push(lamp);
|
|
201
|
+
}
|
|
202
|
+
return lamps;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function styleOnce(doc) {
|
|
206
|
+
if (doc.querySelector('#ab-style')) return;
|
|
207
|
+
const style = doc.createElement('style');
|
|
208
|
+
style.id = 'ab-style';
|
|
209
|
+
style.textContent = `
|
|
210
|
+
.ab-stage { position: fixed; inset: 0; z-index: 60; pointer-events: none; opacity: 0; overflow: hidden; }
|
|
211
|
+
.ab-sky { position: absolute; inset: 0; background: radial-gradient(75% 55% at 50% 18%, rgba(171,255,91,.18), rgba(4,12,6,.08) 50%, rgba(0,0,0,.18)); backdrop-filter: blur(1px) saturate(.82); opacity: 0; }
|
|
212
|
+
.ab-wrap { position: absolute; will-change: transform; }
|
|
213
|
+
.ab-svg { display: block; width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 28px 46px rgba(0,0,0,.42)) drop-shadow(0 0 18px rgba(158,226,63,.12)); }
|
|
214
|
+
.ab-vector { display: none; }
|
|
215
|
+
.ab-svg.missing-photo .ab-vector { display: inline; }
|
|
216
|
+
.ab-svg.missing-photo .ab-photos, .ab-svg.missing-photo .ab-live-lamps, .ab-svg.missing-photo .ab-live-emitter, .ab-svg.missing-photo .ab-glass-sheen { display: none; }
|
|
217
|
+
.ab-photo { opacity: 0; transform-origin: 50% 60%; }
|
|
218
|
+
.ab-photo[data-frame="idle"] { opacity: 1; }
|
|
219
|
+
.ab-live-lamps { filter: url(#ab-lamp-glow); mix-blend-mode: screen; }
|
|
220
|
+
.ab-glass-sheen { mix-blend-mode: screen; }
|
|
221
|
+
.ab-beam { position: absolute; transform-origin: 50% 0%; will-change: transform, opacity; }
|
|
222
|
+
.ab-mote { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: #b7ec5a; box-shadow: 0 0 10px #9ee23f; will-change: transform, opacity; }
|
|
223
|
+
.ab-taken { will-change: transform, filter, opacity; }
|
|
224
|
+
@media (prefers-reduced-motion: reduce) { .ab-svg { filter: none; } }`;
|
|
225
|
+
doc.head.append(style);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function abduct({ composer, shell = document.querySelector('.shell'), doc = document, reduced = false } = {}) {
|
|
229
|
+
if (!composer) return;
|
|
230
|
+
// The asset lives on disk with the room. Warm it before the first frame so the ship never
|
|
231
|
+
// arrives as an empty glow on a cold cache; failure falls through to the vector below it.
|
|
232
|
+
await Promise.all(Object.values(SHIP_FRAMES).map((source) => new Promise((ready) => {
|
|
233
|
+
const preload = doc.createElement('img');
|
|
234
|
+
preload.src = source;
|
|
235
|
+
if (preload.complete) { ready(); return; }
|
|
236
|
+
preload.addEventListener('load', ready, { once: true });
|
|
237
|
+
preload.addEventListener('error', ready, { once: true });
|
|
238
|
+
})));
|
|
239
|
+
styleOnce(doc);
|
|
240
|
+
const box = composer.getBoundingClientRect();
|
|
241
|
+
const stage = doc.createElement('div');
|
|
242
|
+
stage.className = 'ab-stage';
|
|
243
|
+
const sky = doc.createElement('div');
|
|
244
|
+
sky.className = 'ab-sky';
|
|
245
|
+
|
|
246
|
+
const shipW = Math.min(760, Math.max(420, box.width * 0.62));
|
|
247
|
+
const scale = shipW / 900;
|
|
248
|
+
const shipH = 520 * scale;
|
|
249
|
+
const wrap = doc.createElement('div');
|
|
250
|
+
wrap.className = 'ab-wrap';
|
|
251
|
+
wrap.style.width = `${shipW}px`;
|
|
252
|
+
wrap.style.height = `${shipH}px`;
|
|
253
|
+
// Centred over the field, hovering a ship's height above it: the beam has to have somewhere to
|
|
254
|
+
// fall through, and the cat has to be able to see what it is taking.
|
|
255
|
+
const centreX = box.left + box.width / 2;
|
|
256
|
+
const bellyY = Math.max(96, box.top - shipH * 0.52);
|
|
257
|
+
wrap.style.left = `${centreX - shipW / 2}px`;
|
|
258
|
+
wrap.style.top = `${bellyY - 452 * scale}px`;
|
|
259
|
+
const svg = shipSVG(doc);
|
|
260
|
+
wrap.append(svg);
|
|
261
|
+
|
|
262
|
+
// The beam: a cone, widening from the emitter down to the field.
|
|
263
|
+
const beam = doc.createElement('div');
|
|
264
|
+
beam.className = 'ab-beam';
|
|
265
|
+
const beamH = Math.max(40, box.top + box.height / 2 - bellyY);
|
|
266
|
+
beam.style.left = `${centreX - box.width * 0.34}px`;
|
|
267
|
+
beam.style.top = `${bellyY}px`;
|
|
268
|
+
beam.style.width = `${box.width * 0.68}px`;
|
|
269
|
+
beam.style.height = `${beamH}px`;
|
|
270
|
+
beam.style.clipPath = 'polygon(42% 0%, 58% 0%, 100% 100%, 0% 100%)';
|
|
271
|
+
beam.style.background = 'linear-gradient(to bottom, rgba(217,255,138,.9), rgba(158,226,63,.34) 45%, rgba(122,196,31,.06))';
|
|
272
|
+
beam.style.opacity = '0';
|
|
273
|
+
|
|
274
|
+
stage.append(sky, beam, wrap);
|
|
275
|
+
doc.body.append(stage);
|
|
276
|
+
const lamps = growLamps(svg);
|
|
277
|
+
const lids = [...svg.querySelectorAll('.ab-lid')];
|
|
278
|
+
const emitter = svg.querySelector('.ab-live-emitter') ?? svg.querySelector('.ab-emitter');
|
|
279
|
+
const photos = [...svg.querySelectorAll('.ab-photo')];
|
|
280
|
+
const photoByFrame = new Map(photos.map((photo) => [photo.dataset.frame, photo]));
|
|
281
|
+
const sheen = svg.querySelector('.ab-glass-sheen');
|
|
282
|
+
composer.classList.add('ab-taken');
|
|
283
|
+
|
|
284
|
+
// Motes drifting up the beam, which is what sells a beam as a beam.
|
|
285
|
+
const motes = [];
|
|
286
|
+
const drift = () => {
|
|
287
|
+
const mote = doc.createElement('div');
|
|
288
|
+
mote.className = 'ab-mote';
|
|
289
|
+
const spread = box.width * 0.3;
|
|
290
|
+
mote.style.left = `${centreX + (Math.random() - 0.5) * spread}px`;
|
|
291
|
+
mote.style.top = `${box.top + box.height / 2}px`;
|
|
292
|
+
stage.append(mote);
|
|
293
|
+
mote.animate([
|
|
294
|
+
{ transform: 'translate(0,0) scale(1)', opacity: .9 },
|
|
295
|
+
{ transform: `translate(${(Math.random() - .5) * 40}px, ${-beamH}px) scale(.3)`, opacity: 0 },
|
|
296
|
+
], { duration: 900 + Math.random() * 500, easing: 'cubic-bezier(.3,.1,.5,1)', fill: 'forwards' });
|
|
297
|
+
motes.push(mote);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const total = reduced ? 1500 : PHASES.done;
|
|
301
|
+
const started = performance.now();
|
|
302
|
+
let lastMote = 0;
|
|
303
|
+
let blinked = false;
|
|
304
|
+
let activeFrame = 'idle';
|
|
305
|
+
|
|
306
|
+
await new Promise((resolve) => {
|
|
307
|
+
const frame = (stamp) => {
|
|
308
|
+
const now = reduced ? ((stamp - started) / total) * PHASES.done : stamp - started;
|
|
309
|
+
stage.style.opacity = String(Math.min(1, span(now, 0, 160) + 0.001));
|
|
310
|
+
sky.style.opacity = String(0.9 * span(now, 80, PHASES.beam) * (1 - span(now, PHASES.leave, PHASES.done)));
|
|
311
|
+
|
|
312
|
+
// The ship drops in, holds while it works, then tips and goes.
|
|
313
|
+
const arrive = span(now, 0, PHASES.arrive);
|
|
314
|
+
const gone = span(now, PHASES.leave, PHASES.done);
|
|
315
|
+
const hover = Math.sin(now / 420) * 6 * (1 - gone);
|
|
316
|
+
wrap.style.transform = `translate(${260 * gone}px, ${-shipH * 1.6 * (1 - arrive) + hover - shipH * 2.2 * gone}px) rotate(${-2 + 4 * Math.sin(now / 700) - 16 * gone}deg)`;
|
|
317
|
+
|
|
318
|
+
// Rim lamps chase around the ring; the emitter breathes while the beam is on.
|
|
319
|
+
for (const [i, lamp] of lamps.entries()) {
|
|
320
|
+
const phase = (now / 720 - i / lamps.length) % 1;
|
|
321
|
+
const pulse = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2);
|
|
322
|
+
lamp.setAttribute('opacity', String(0.18 + 0.82 * pulse));
|
|
323
|
+
lamp.setAttribute('rx', String(9 + 2.5 * pulse));
|
|
324
|
+
lamp.setAttribute('ry', String(6 + 1.5 * pulse));
|
|
325
|
+
}
|
|
326
|
+
const live = span(now, PHASES.arrive, PHASES.beam) * (1 - span(now, PHASES.swallow, PHASES.leave));
|
|
327
|
+
if (emitter) emitter.setAttribute('opacity', String(0.22 + 0.7 * live * (0.78 + 0.22 * Math.sin(now / 95))));
|
|
328
|
+
if (sheen) sheen.setAttribute('opacity', String(0.05 + 0.08 * (0.5 + 0.5 * Math.sin(now / 480))));
|
|
329
|
+
|
|
330
|
+
// The beam opens, then shuts once the field is aboard.
|
|
331
|
+
beam.style.opacity = String(live * (0.75 + 0.25 * Math.sin(now / 90)));
|
|
332
|
+
beam.style.transform = `scaleX(${0.2 + 0.8 * live}) scaleY(${0.3 + 0.7 * live})`;
|
|
333
|
+
|
|
334
|
+
// The photographic performance: a quick blink, then two small cockpit actions. Hard cuts
|
|
335
|
+
// keep the registered hull crisp; only the living passenger changes between frames.
|
|
336
|
+
let wantedFrame = 'idle';
|
|
337
|
+
if (now >= 1180 && now < 1300) wantedFrame = 'blink';
|
|
338
|
+
else if (now >= 1460 && now < 1540) wantedFrame = 'blink';
|
|
339
|
+
else if (now >= 1680 && now < 1980) wantedFrame = 'controlLeft';
|
|
340
|
+
else if (now >= 1980 && now < 2280) wantedFrame = 'controlRight';
|
|
341
|
+
else if (now >= 2280 && now < 2400) wantedFrame = 'controlLeft';
|
|
342
|
+
if (!photoByFrame.has(wantedFrame)) wantedFrame = 'idle';
|
|
343
|
+
if (wantedFrame !== activeFrame) {
|
|
344
|
+
for (const photo of photos) photo.style.opacity = photo.dataset.frame === wantedFrame ? '1' : '0';
|
|
345
|
+
activeFrame = wantedFrame;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The vector fallback still gets its own blink.
|
|
349
|
+
const blink = Math.max(0, Math.sin((now - PHASES.beam - 150) / 90));
|
|
350
|
+
const lidOn = now > PHASES.beam + 150 && now < PHASES.beam + 430;
|
|
351
|
+
for (const lid of lids) lid.setAttribute('opacity', String(lidOn ? blink : 0));
|
|
352
|
+
if (!blinked && lidOn) blinked = true;
|
|
353
|
+
// It leans toward what it is taking.
|
|
354
|
+
for (const photo of photos) photo.setAttribute('transform', `translate(0 ${3 * live}) scale(${1 + 0.004 * live})`);
|
|
355
|
+
|
|
356
|
+
// The field goes up. It wobbles on the way, because nothing rides a tractor beam steadily.
|
|
357
|
+
const rise = span(now, PHASES.beam, PHASES.lift);
|
|
358
|
+
const eaten = span(now, PHASES.lift, PHASES.swallow);
|
|
359
|
+
const backHome = span(now, PHASES.leave, PHASES.done);
|
|
360
|
+
const climbed = (box.top + box.height / 2 - bellyY) * rise;
|
|
361
|
+
const shrink = (1 - 0.72 * rise) * (1 - eaten) * bounce(rise);
|
|
362
|
+
if (backHome > 0) {
|
|
363
|
+
composer.style.transform = '';
|
|
364
|
+
composer.style.opacity = '';
|
|
365
|
+
composer.style.filter = '';
|
|
366
|
+
} else {
|
|
367
|
+
composer.style.transform = `translateY(${-climbed}px) scale(${Math.max(0.001, shrink)}) rotate(${Math.sin(now / 130) * 3 * rise}deg)`;
|
|
368
|
+
composer.style.opacity = String(1 - eaten);
|
|
369
|
+
composer.style.filter = `drop-shadow(0 0 ${18 * rise}px rgba(158,226,63,.75)) brightness(${1 + 0.35 * rise})`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!reduced && live > 0.4 && now - lastMote > 70 && eaten < 1) { lastMote = now; drift(); }
|
|
373
|
+
if (now < total) { requestAnimationFrame(frame); return; }
|
|
374
|
+
resolve();
|
|
375
|
+
};
|
|
376
|
+
requestAnimationFrame(frame);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Nothing of this survives: the field is exactly as it was before the word was typed.
|
|
380
|
+
for (const mote of motes) mote.remove();
|
|
381
|
+
stage.remove();
|
|
382
|
+
composer.classList.remove('ab-taken');
|
|
383
|
+
for (const property of ['transform', 'opacity', 'filter']) composer.style[property] = '';
|
|
384
|
+
}
|