@jossuealcala/madre 0.6.0 → 0.7.1
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 +193 -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/app.js +2342 -506
- package/public/es.js +184 -44
- package/public/index.html +17 -16
- package/public/styles.css +410 -67
- package/src/adapters/claude.mjs +6 -2
- package/src/adapters/codex.mjs +3 -1
- package/src/adapters/gemini.mjs +26 -5
- package/src/adapters/madre.mjs +5 -0
- package/src/adapters/process.mjs +5 -2
- package/src/dataset.mjs +11 -6
- package/src/distiller.mjs +12 -1
- 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/correo.mjs +107 -0
- package/src/modules/index.mjs +47 -4
- package/src/modules/sdk.mjs +44 -9
- package/src/outbound.mjs +37 -3
- package/src/ports.mjs +23 -3
- package/src/request-guard.mjs +26 -0
- package/src/room/archivist.mjs +2 -1
- package/src/room/prompt.mjs +1 -1
- package/src/room.mjs +59 -9
- package/src/sentinel-errors.mjs +5 -1
- package/src/server.mjs +67 -7
- package/src/smtp.mjs +122 -0
- package/src/vault.mjs +100 -0
- package/src/whats-new.mjs +140 -0
package/public/app.js
CHANGED
|
@@ -84,7 +84,7 @@ const PLACEHOLDERS = {
|
|
|
84
84
|
};
|
|
85
85
|
let winkTimer = null;
|
|
86
86
|
const LINE_PX = 21;
|
|
87
|
-
const MAX_ROWS =
|
|
87
|
+
const MAX_ROWS = 14;
|
|
88
88
|
|
|
89
89
|
// Permission modes: chosen per message, capped per agent in CONNECTIONS.
|
|
90
90
|
// The names are the ship's and are never translated; what each one lets an agent do is.
|
|
@@ -234,6 +234,21 @@ const MEMORY_COLORS = { decision: '#ffdc3c', fact: '#dfeeff', preference: '#5cf0
|
|
|
234
234
|
const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
|
|
235
235
|
const kindWord = (kind) => KIND_WORDS[kind] ?? String(kind).toUpperCase();
|
|
236
236
|
|
|
237
|
+
// Where a memory stands aboard, which is a different question from what it is — so it is said in
|
|
238
|
+
// words and not in a second hue: the map already spends colour on the kind, and two palettes on
|
|
239
|
+
// one dot is how a map stops being readable. What a zone changes on the map is whether the star
|
|
240
|
+
// is lit at all, because that is what the zone means.
|
|
241
|
+
const ZONE_WORDS = { hold: t('HOLD'), bridge: t('BRIDGE'), medbay: t('MED BAY'), jettisoned: t('JETTISONED') };
|
|
242
|
+
const ZONE_NOTE = {
|
|
243
|
+
hold: t('Distilled and nobody has judged it. It travels into turns.'),
|
|
244
|
+
bridge: t('You marked the reply it came from as good. It travels, confirmed.'),
|
|
245
|
+
medbay: t('Quarantined: it is false, or it came from a reply you marked bad. It never travels.'),
|
|
246
|
+
jettisoned: t('Put out of circulation and kept so it is not learned again. It never travels.'),
|
|
247
|
+
};
|
|
248
|
+
const UNDERWAY = new Set(['hold', 'bridge']);
|
|
249
|
+
const zoneWord = (zone) => ZONE_WORDS[zone] ?? String(zone ?? '').toUpperCase();
|
|
250
|
+
const travels = (memory) => UNDERWAY.has(memory?.zone ?? 'hold');
|
|
251
|
+
|
|
237
252
|
const formatTime = (iso) => {
|
|
238
253
|
const date = iso ? new Date(iso) : new Date();
|
|
239
254
|
return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
@@ -836,17 +851,34 @@ async function browserServers() {
|
|
|
836
851
|
if (!menu.hidden) { portsMenuShown(false); return; }
|
|
837
852
|
menu.replaceChildren(el('div', 'ports-note', t('READING…')));
|
|
838
853
|
portsMenuShown(true);
|
|
839
|
-
let
|
|
840
|
-
try {
|
|
841
|
-
catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT READ THE PORTS'))); return; }
|
|
854
|
+
let read;
|
|
855
|
+
try { read = await fetch('/api/ports').then((response) => response.json()); }
|
|
856
|
+
catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT READ THE PORTS'))); browser.servers?.setAttribute('data-live', 'no'); return; }
|
|
842
857
|
menu.replaceChildren();
|
|
858
|
+
// The answer's own words when it refuses. Reading `?.ports ?? []` straight past an error turned
|
|
859
|
+
// «RIPLEY is off» into «nothing is answering on this computer», which is a different thing and
|
|
860
|
+
// sends the human to look at their server instead of at the switch.
|
|
861
|
+
if (read?.error) {
|
|
862
|
+
browser.servers?.setAttribute('data-live', 'no');
|
|
863
|
+
menu.append(el('div', 'ports-note', read.error));
|
|
864
|
+
const go = el('button', 'ports-row');
|
|
865
|
+
go.type = 'button';
|
|
866
|
+
go.append(el('b', null, '⚙'), el('span', null, t('OPEN MODULES')));
|
|
867
|
+
go.addEventListener('click', () => { portsMenuShown(false); document.querySelector('#modules-button, [aria-controls="modules"]')?.click(); });
|
|
868
|
+
menu.append(go);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const ports = read?.ports ?? [];
|
|
872
|
+
const wider = read?.wider ?? [];
|
|
843
873
|
// The lamp on the button keeps what the reading found, so the state survives the menu closing.
|
|
844
874
|
browser.servers?.setAttribute('data-live', ports.length ? 'yes' : 'no');
|
|
845
|
-
if (!ports.length) { menu.append(el('div', 'ports-note', t('NOTHING IS ANSWERING ON THIS COMPUTER · START YOUR SERVER AND OPEN THIS AGAIN'))); return; }
|
|
875
|
+
if (!ports.length && !wider.length) { menu.append(el('div', 'ports-note', t('NOTHING IS ANSWERING ON THIS COMPUTER · START YOUR SERVER AND OPEN THIS AGAIN'))); return; }
|
|
846
876
|
for (const found of ports) {
|
|
847
877
|
const row = el('button', 'ports-row');
|
|
848
878
|
row.type = 'button';
|
|
849
|
-
|
|
879
|
+
// A server that sends no Content-Type used to leave the separator hanging: «:5173 node · 200 · ».
|
|
880
|
+
const kind = found.html ? '' : (found.contentType ?? '').trim();
|
|
881
|
+
const what = `${found.command} · ${found.status}${kind ? ` · ${kind}` : ''}`;
|
|
850
882
|
row.append(el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} · refuses to be framed', { what }) : what));
|
|
851
883
|
if (found.framable === false) row.classList.add('refuses');
|
|
852
884
|
row.addEventListener('click', () => {
|
|
@@ -870,6 +902,15 @@ async function browserServers() {
|
|
|
870
902
|
});
|
|
871
903
|
menu.append(row);
|
|
872
904
|
}
|
|
905
|
+
// Found and not offered. Node and python bind every interface by default, so this is the common
|
|
906
|
+
// case — and before this it was discarded without a word, which is what made a busy computer
|
|
907
|
+
// report as an empty one.
|
|
908
|
+
for (const one of wider) {
|
|
909
|
+
const row = el('div', 'ports-row wider');
|
|
910
|
+
row.append(el('b', null, `:${one.port}`), el('span', null, t('{command} · listens on the whole network', { command: one.command })));
|
|
911
|
+
row.title = t('RIPLEY only opens what is bound to 127.0.0.1. Start it with --host 127.0.0.1 (or listen(port, "127.0.0.1")) and it will show up above.');
|
|
912
|
+
menu.append(row);
|
|
913
|
+
}
|
|
873
914
|
menu.append(el('div', 'ports-note', t('ONLY WHAT LISTENS ON THIS COMPUTER AND ANSWERS HTTP · MADRE RUNS NOTHING TO FIND THEM')));
|
|
874
915
|
}
|
|
875
916
|
|
|
@@ -1916,11 +1957,23 @@ function iconButton(svg, title, className) {
|
|
|
1916
1957
|
}
|
|
1917
1958
|
const ICON_GOOD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 7.5v5.5H3.2a.7.7 0 0 1-.7-.7V8.2a.7.7 0 0 1 .7-.7h2.3Zm0 0 2.6-4.6a1.3 1.3 0 0 1 2.4.8L10 6.8h2.6a1.3 1.3 0 0 1 1.3 1.5l-.8 3.9a1.3 1.3 0 0 1-1.3 1H5.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
|
|
1918
1959
|
const ICON_BAD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 8.5V3H12.8a.7.7 0 0 1 .7.7v4.1a.7.7 0 0 1-.7.7h-2.3Zm0 0-2.6 4.6a1.3 1.3 0 0 1-2.4-.8L6 9.2H3.4a1.3 1.3 0 0 1-1.3-1.5l.8-3.9A1.3 1.3 0 0 1 4.2 3h6.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
|
|
1919
|
-
|
|
1960
|
+
const ICON_PREFER = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.6l1.6 3.3 3.6.5-2.6 2.5.6 3.6L8 10.8l-3.2 1.7.6-3.6L2.8 6.4l3.6-.5Z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
|
|
1961
|
+
const ICON_NEVER = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.6" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.4 11.6 11.6 4.4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
1962
|
+
|
|
1963
|
+
// The human's verdict on a reply, and the only place in the room where four of them are said.
|
|
1964
|
+
// Two of them are the everyday act; the other two are the same thing meant harder, so they are
|
|
1965
|
+
// not shown until the one they escalate has been pressed. At rest the bubble carries no more
|
|
1966
|
+
// chrome than it did, and the stronger word turns up exactly when it means something.
|
|
1920
1967
|
function ratingButtons(messageId, sender) {
|
|
1921
1968
|
const good = iconButton(ICON_GOOD, t('Good reply · keep it for MADRE AI'), 'rate good');
|
|
1969
|
+
const prefer = iconButton(ICON_PREFER, t('This is how I want it done · the room keeps it as a preference'), 'rate prefer');
|
|
1922
1970
|
const bad = iconButton(ICON_BAD, t('Bad reply · keep it out of the dataset'), 'rate bad');
|
|
1923
|
-
const
|
|
1971
|
+
const never = iconButton(ICON_NEVER, t('Never do this again · what the room remembers from it is put out'), 'rate never');
|
|
1972
|
+
const apply = (rating) => {
|
|
1973
|
+
for (const [button, verdict] of [[good, 'good'], [prefer, 'preference'], [bad, 'bad'], [never, 'never']]) button.classList.toggle('on', rating === verdict);
|
|
1974
|
+
prefer.hidden = !(rating === 'good' || rating === 'preference');
|
|
1975
|
+
never.hidden = !(rating === 'bad' || rating === 'never');
|
|
1976
|
+
};
|
|
1924
1977
|
apply(state.ratings.get(messageId) ?? null);
|
|
1925
1978
|
const send = async (rating) => {
|
|
1926
1979
|
const current = state.ratings.get(messageId) ?? null;
|
|
@@ -1930,9 +1983,10 @@ function ratingButtons(messageId, sender) {
|
|
|
1930
1983
|
if (payload.error) throw new Error(payload.error);
|
|
1931
1984
|
} catch (error) { toast(t('The rating was not saved: {error}', { error: error.message })); }
|
|
1932
1985
|
};
|
|
1933
|
-
good
|
|
1934
|
-
|
|
1935
|
-
|
|
1986
|
+
for (const [button, verdict] of [[good, 'good'], [prefer, 'preference'], [bad, 'bad'], [never, 'never']]) {
|
|
1987
|
+
button.addEventListener('click', (event) => { event.stopPropagation(); void send(verdict); });
|
|
1988
|
+
}
|
|
1989
|
+
return { good, prefer, bad, never, apply, sender };
|
|
1936
1990
|
}
|
|
1937
1991
|
function bubbleActions({ text, sender, sequence, messageId = null }) {
|
|
1938
1992
|
const bar = el('div', 'bubble-actions');
|
|
@@ -1957,7 +2011,7 @@ function bubbleActions({ text, sender, sequence, messageId = null }) {
|
|
|
1957
2011
|
if (messageId && sender !== 'madre') {
|
|
1958
2012
|
const rating = ratingButtons(messageId, sender);
|
|
1959
2013
|
state.ratingNodes.set(messageId, rating);
|
|
1960
|
-
bar.append(rating.good, rating.bad);
|
|
2014
|
+
bar.append(rating.good, rating.prefer, rating.bad, rating.never);
|
|
1961
2015
|
}
|
|
1962
2016
|
bar.append(copy, reply);
|
|
1963
2017
|
return bar;
|
|
@@ -2195,7 +2249,25 @@ function renderThinking(event) {
|
|
|
2195
2249
|
const status = el('span', 'status');
|
|
2196
2250
|
const tokens = el('span', 'tokens');
|
|
2197
2251
|
const elapsed = el('span', 'elapsed');
|
|
2198
|
-
|
|
2252
|
+
// One answer, not the room. PARAR TODO is the master brake and takes down every plan and every
|
|
2253
|
+
// agent; this one is for the turn in front of you — the agent that hung, or started down the
|
|
2254
|
+
// wrong road, or hit an error and is still being waited on.
|
|
2255
|
+
const stop = el('button', 'stop-turn', t('STOP'));
|
|
2256
|
+
stop.type = 'button';
|
|
2257
|
+
stop.title = t('Stop this answer · the room and the other agents keep going');
|
|
2258
|
+
stop.addEventListener('click', async () => {
|
|
2259
|
+
stop.disabled = true;
|
|
2260
|
+
stop.textContent = t('STOPPING');
|
|
2261
|
+
try {
|
|
2262
|
+
const reply = await fetch('/api/stop-turn', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ messageId }) }).then((response) => response.json());
|
|
2263
|
+
if (reply.error) throw new Error(reply.error);
|
|
2264
|
+
} catch (error) {
|
|
2265
|
+
stop.disabled = false;
|
|
2266
|
+
stop.textContent = t('STOP');
|
|
2267
|
+
toast(`MU/TH/UR › ${error.message}`);
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
bubble.append(status, tokens, elapsed, stop);
|
|
2199
2271
|
node.append(bubble);
|
|
2200
2272
|
const startedAt = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();
|
|
2201
2273
|
const phrases = workingPhrases(agent, state.userMessages.get(messageId)?.text ?? '');
|
|
@@ -2280,8 +2352,11 @@ function renderModuleInstalledOrRemoved(event) {
|
|
|
2280
2352
|
// lived in a panel somebody had to go back to. So it is offered, and answered, here.
|
|
2281
2353
|
function renderLocalModel(payload, { asked = false } = {}) {
|
|
2282
2354
|
const node = el('div', 'system local-model');
|
|
2283
|
-
|
|
2284
|
-
|
|
2355
|
+
// One line, held together: the card is a grid, and loose words in a grid each take a row.
|
|
2356
|
+
const head = el('span', 'head');
|
|
2357
|
+
head.append('local · ', el('b', 'who', t('@madre')));
|
|
2358
|
+
if (payload.model) head.append(` · ${payload.model}`);
|
|
2359
|
+
node.append(head);
|
|
2285
2360
|
const line = el('p', 'says');
|
|
2286
2361
|
const action = el('div', 'acts');
|
|
2287
2362
|
|
|
@@ -2471,7 +2546,7 @@ function renderForgotten(event) {
|
|
|
2471
2546
|
const { kind, text, remaining } = event.payload;
|
|
2472
2547
|
const node = el('div', 'system memory forgotten');
|
|
2473
2548
|
node.append(t('memory · the human forgot a '));
|
|
2474
|
-
node.append(el('b', 'who', kind));
|
|
2549
|
+
node.append(el('b', 'who', kindWord(kind)));
|
|
2475
2550
|
node.append(`: “${text}”${Number.isFinite(remaining) ? t(' · {n} left in the archive', { n: remaining }) : ''}`);
|
|
2476
2551
|
return node;
|
|
2477
2552
|
}
|
|
@@ -2552,6 +2627,19 @@ function renderLeaseRefused(event) {
|
|
|
2552
2627
|
/* ---------- CONTROL: the project itself, between two checkpoints ---------- */
|
|
2553
2628
|
|
|
2554
2629
|
// create · @codex · 2 existing files put back: CREATE only adds (README.md, src/index.astro)
|
|
2630
|
+
// The reply named files and the disk does not have them. Said as the fact it is, without
|
|
2631
|
+
// deciding what the agent meant: an answer that claims a file it never wrote and an answer that
|
|
2632
|
+
// suggests one both leave the human better off knowing it is not there.
|
|
2633
|
+
function renderCreateMissing(event) {
|
|
2634
|
+
const { agent, files = [] } = event.payload;
|
|
2635
|
+
const node = el('div', 'system memory warn');
|
|
2636
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
2637
|
+
node.append(t('create · '), el('b', 'who', `@${agent}`));
|
|
2638
|
+
node.append(t(' · this reply names {n} file(s) that are not on disk: {files}', { n: files.length, files: files.slice(0, 4).join(', ') + (files.length > 4 ? '…' : '') }));
|
|
2639
|
+
node.append(t(' · nothing was written this turn'));
|
|
2640
|
+
return node;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2555
2643
|
function renderCreateReverted(event) {
|
|
2556
2644
|
const { agent, existing = [], forbidden = [] } = event.payload;
|
|
2557
2645
|
const node = el('div', 'system memory warn');
|
|
@@ -2965,13 +3053,20 @@ function renderPlanEvent(event) {
|
|
|
2965
3053
|
node.append(el('b', null, t('plan · ')));
|
|
2966
3054
|
if (event.type === 'plan.created') {
|
|
2967
3055
|
node.id = `plan-${planId}`;
|
|
2968
|
-
|
|
3056
|
+
// One sentence for the catalogue, not three fragments: the agents are chips and cannot travel
|
|
3057
|
+
// inside a string, so the sentence is translated whole around a marker and the chips go back
|
|
3058
|
+
// where the marker was. Built from literals, the Spanish room read «@MADRE PUTS @CODEX#1 TO
|
|
3059
|
+
// WORK, THEN CLOSES».
|
|
3060
|
+
const [before, after] = (closing
|
|
3061
|
+
? t('@{who} puts {agents} to work, then closes', { who: orchestrator, agents: '\u0000' })
|
|
3062
|
+
: t('@{who} puts {agents} to work', { who: orchestrator, agents: '\u0000' })).split('\u0000');
|
|
3063
|
+
node.append(before);
|
|
2969
3064
|
steps.forEach((step, index) => {
|
|
2970
3065
|
if (index) node.append(', ');
|
|
2971
3066
|
node.append(`@${step.agent}`);
|
|
2972
3067
|
if (Number.isInteger(step.mode)) node.append(el('span', `step-mode m${step.mode}`, `#${step.mode}`));
|
|
2973
3068
|
});
|
|
2974
|
-
node.append(
|
|
3069
|
+
node.append(after ?? '');
|
|
2975
3070
|
const stop = el('button', 'stop', t('STOP'));
|
|
2976
3071
|
stop.type = 'button';
|
|
2977
3072
|
stop.title = t('Stop the remaining steps of this plan');
|
|
@@ -2983,9 +3078,10 @@ function renderPlanEvent(event) {
|
|
|
2983
3078
|
node.append(stop);
|
|
2984
3079
|
} else {
|
|
2985
3080
|
document.getElementById(`plan-${planId}`)?.querySelector('.stop')?.remove();
|
|
3081
|
+
const howMany = stepsRun === 1 ? t('{n} step', { n: stepsRun }) : t('{n} steps', { n: stepsRun });
|
|
2986
3082
|
node.append(event.type === 'plan.completed'
|
|
2987
|
-
?
|
|
2988
|
-
:
|
|
3083
|
+
? t('@{who} finished · {steps}', { who: orchestrator, steps: howMany })
|
|
3084
|
+
: t('@{who} stopped after {steps} · {why}', { who: orchestrator, steps: howMany, why: reason ?? '' }));
|
|
2989
3085
|
}
|
|
2990
3086
|
state.lastSender = null;
|
|
2991
3087
|
return node;
|
|
@@ -3047,6 +3143,21 @@ function renderCleared(event) {
|
|
|
3047
3143
|
// Anything that wants to hear the room live hooks in here. NOSTROMO does, while a card is open;
|
|
3048
3144
|
// it is set further down, once that section exists, so a replay at boot reaches nobody too early.
|
|
3049
3145
|
let liveWatcher = null;
|
|
3146
|
+
// A system line's first <b> is its title, set on a line of its own (.system > b:first-child).
|
|
3147
|
+
// Written inline, the title kept the separator that joined it to the rest of the sentence —
|
|
3148
|
+
// «claro · » — and on a line of its own that dot is all that is left of the join, hanging off the
|
|
3149
|
+
// end and pulling a centred title off its centre. And a <b> with words before it is part of the
|
|
3150
|
+
// sentence, never its title: CSS sees the first element, not the first word, so it is marked.
|
|
3151
|
+
function tidySystemLine(node) {
|
|
3152
|
+
if (!node?.classList?.contains('system')) return node;
|
|
3153
|
+
const head = node.firstElementChild;
|
|
3154
|
+
if (!head || head.tagName !== 'B') return node;
|
|
3155
|
+
if (node.firstChild !== head) { head.classList.add('inline'); return node; }
|
|
3156
|
+
const last = head.lastChild;
|
|
3157
|
+
if (last?.nodeType === 3) last.textContent = last.textContent.replace(/[\s·›:]+$/u, '');
|
|
3158
|
+
return node;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3050
3161
|
function renderEvent(event) {
|
|
3051
3162
|
if (state.seen.has(event.id)) return;
|
|
3052
3163
|
liveWatcher?.(event);
|
|
@@ -3173,6 +3284,7 @@ void renderOpenQuestions(); return;
|
|
|
3173
3284
|
case 'mode.requested': node = renderModeRequest(event); break;
|
|
3174
3285
|
case 'control.started': node = renderControlStarted(event); break;
|
|
3175
3286
|
case 'create.reverted': node = renderCreateReverted(event); break;
|
|
3287
|
+
case 'create.missing': node = renderCreateMissing(event); break;
|
|
3176
3288
|
case 'control.changed': markTouched(event.payload.agent, event.payload.files); node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
|
|
3177
3289
|
case 'control.reverted': node = renderControlReverted(event); break;
|
|
3178
3290
|
case 'mode.granted':
|
|
@@ -3190,7 +3302,7 @@ void renderOpenQuestions(); return;
|
|
|
3190
3302
|
if (!node) return;
|
|
3191
3303
|
removeEmpty();
|
|
3192
3304
|
const stickToBottom = els.thread.scrollHeight - els.thread.scrollTop - els.thread.clientHeight < 120;
|
|
3193
|
-
els.column.append(node);
|
|
3305
|
+
els.column.append(tidySystemLine(node));
|
|
3194
3306
|
followTurn(event, node, stickToBottom);
|
|
3195
3307
|
}
|
|
3196
3308
|
|
|
@@ -3659,7 +3771,7 @@ function armExpendable() {
|
|
|
3659
3771
|
line.append(el('b', null, t('MU/TH/UR › ')));
|
|
3660
3772
|
line.append(t('end of record. nothing else is down here, human. crew status under review.'));
|
|
3661
3773
|
removeEmpty();
|
|
3662
|
-
els.column.append(line);
|
|
3774
|
+
els.column.append(tidySystemLine(line));
|
|
3663
3775
|
scrollToEnd();
|
|
3664
3776
|
}
|
|
3665
3777
|
|
|
@@ -3705,7 +3817,7 @@ document.querySelector('#mother-button')?.addEventListener('mouseleave', endHove
|
|
|
3705
3817
|
document.querySelector('#mother-button')?.addEventListener('click', endHover);
|
|
3706
3818
|
|
|
3707
3819
|
// Debug surface for tests and for the curious: window.__pulse.trackHold(true, t)
|
|
3708
|
-
globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, runSlashCommand, syncModuleComposer, openCore: (...args) => openCore(...args), get core() { return core; } };
|
|
3820
|
+
globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, renderModulesDev, runSlashCommand, syncModuleComposer, openCore: (...args) => openCore(...args), get core() { return core; } };
|
|
3709
3821
|
|
|
3710
3822
|
els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
|
|
3711
3823
|
let touchY = null;
|
|
@@ -3725,7 +3837,21 @@ const initial = await fetch('/api/state').then((response) => response.json());
|
|
|
3725
3837
|
// line the page invents for itself is a copyright line that drifts from the one that counts.
|
|
3726
3838
|
if (initial.version) {
|
|
3727
3839
|
const corner = document.querySelector('#colophon');
|
|
3728
|
-
if (corner)
|
|
3840
|
+
if (corner) {
|
|
3841
|
+
corner.replaceChildren(document.createTextNode(`MADRE ${initial.version} · © 2026 Jossue Alcalá · `));
|
|
3842
|
+
// A LINK, and nothing more. It opens a tab when a human clicks it; the room asks the network
|
|
3843
|
+
// for nothing, so there is no destination to declare in outbound.mjs and nothing appears in
|
|
3844
|
+
// the outbound log. The day this were to FETCH anything — a sponsor count, a tier, a badge —
|
|
3845
|
+
// it would stop being a link and would have to be declared like every other address, with its
|
|
3846
|
+
// own switch. It never nags, it never knows whether you clicked, and nothing in the product
|
|
3847
|
+
// changes either way.
|
|
3848
|
+
const back = el('a', 'colophon-back', t('SUPPORT'));
|
|
3849
|
+
back.href = 'https://github.com/sponsors/jossuealcacao-exe';
|
|
3850
|
+
back.target = '_blank';
|
|
3851
|
+
back.rel = 'noopener noreferrer';
|
|
3852
|
+
back.title = t('MADRE is free and stays free · this only opens GitHub Sponsors in a tab');
|
|
3853
|
+
corner.append(back);
|
|
3854
|
+
}
|
|
3729
3855
|
}
|
|
3730
3856
|
adoptLanguage(initial.language);
|
|
3731
3857
|
els.project.textContent = initial.projectRoot.split('/').filter(Boolean).at(-1) || initial.projectRoot;
|
|
@@ -3884,7 +4010,7 @@ function moduleModeNotice() {
|
|
|
3884
4010
|
arm.addEventListener('click', () => { setMode(2, { wink: true }); els.input.focus(); });
|
|
3885
4011
|
node.append(arm);
|
|
3886
4012
|
removeEmpty();
|
|
3887
|
-
els.column.append(node);
|
|
4013
|
+
els.column.append(tidySystemLine(node));
|
|
3888
4014
|
node.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
3889
4015
|
}
|
|
3890
4016
|
|
|
@@ -4036,15 +4162,44 @@ function renderMenu() {
|
|
|
4036
4162
|
}
|
|
4037
4163
|
const items = found.kind === '@'
|
|
4038
4164
|
? knownAgentIds().filter((id) => id.startsWith(found.query)).map((id) => ({ key: `@${id}`, insert: `@${id} `, what: state.agents.get(id)?.ready ? label(id) : t('{label} · not ready', { label: label(id) }), color: brandOf(id).color, off: !state.agents.get(id)?.ready }))
|
|
4039
|
-
|
|
4165
|
+
// A command matches while you are still typing it AND once you have typed past it: somebody
|
|
4166
|
+
// reaching for /module types /modules about as often, and an empty menu reads as "no such
|
|
4167
|
+
// thing" rather than "close, try again".
|
|
4168
|
+
: allCommands().filter((item) => item.name.startsWith(found.query) || found.query.startsWith(item.name)).map((item) => ({ key: item.usage ?? `/${item.name}`, insert: `/${item.name} `, what: item.available ? item.summary : t('{title} is not available here · see MODULES', { title: item.title }), off: !item.available }));
|
|
4169
|
+
if (found.kind === '/' && items.length === 1 && items[0].insert === '/module ') return showMenu([...items, ...moduleStarters()], { ...found, hint: t('MODULE · ↑↓ · TAB OR ENTER · pick one and edit it') });
|
|
4040
4170
|
if (!items.length) return closeMenu();
|
|
4041
4171
|
showMenu(items, found);
|
|
4042
4172
|
}
|
|
4173
|
+
// /module on its own does nothing until you say what the module should DO, and a blank
|
|
4174
|
+
// instruction is the most common way the flow dies. These are real ones, two of each shape a
|
|
4175
|
+
// module can take, written to be edited rather than sent: pick one and the text lands in the box.
|
|
4176
|
+
// Each wears its own 8-bit mark and a tag in the colour of its shape, so the four shapes read as
|
|
4177
|
+
// four kinds of thing before a word is read.
|
|
4178
|
+
const STARTER_SHAPES = {
|
|
4179
|
+
command: { label: t('command'), about: t('a slash command · runs in the room, answers as a card') },
|
|
4180
|
+
connector: { label: t('connector'), about: t('a connector · one key, pasted on its card') },
|
|
4181
|
+
sends: { label: t('sends · #4 only'), about: t('a connector that sends · only offered in #4 AIRLOCK') },
|
|
4182
|
+
card: { label: t('card of its own'), about: t('a card of your own · its own panel inside MODULES') },
|
|
4183
|
+
};
|
|
4184
|
+
function moduleStarters() {
|
|
4185
|
+
return [
|
|
4186
|
+
['branch', 'command', t('a command that tells me what changed in the repo this week')],
|
|
4187
|
+
['check', 'command', t('a command that lists the TODOs still left in the code')],
|
|
4188
|
+
['search', 'connector', t('connect me to my Notion so the agents can search my pages')],
|
|
4189
|
+
['db', 'connector', t('connect me to my database so the agents can read it, never write')],
|
|
4190
|
+
['bell', 'sends', t('ping me on Telegram when a turn fails')],
|
|
4191
|
+
['link', 'sends', t('post a summary to Slack when a long plan finishes')],
|
|
4192
|
+
['grid', 'card', t('a card showing which of my services are up')],
|
|
4193
|
+
['chip', 'card', t('a card with what my store sold today')],
|
|
4194
|
+
].map(([icon, shape, what]) => ({ key: '/module', insert: `/module ${what}`, what, icon, shape, starter: true }));
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4043
4197
|
function showMenu(items, found) {
|
|
4044
4198
|
Object.assign(menu, { items, index: Math.min(menu.index, items.length - 1), kind: found.kind, start: found.start, end: found.end });
|
|
4045
4199
|
els.slashMenu.replaceChildren();
|
|
4200
|
+
els.slashMenu.classList.toggle('with-starters', items.some((item) => item.starter));
|
|
4046
4201
|
items.forEach((item, index) => {
|
|
4047
|
-
const button = el('button', `item${item.off ? ' off' : ''}`);
|
|
4202
|
+
const button = el('button', `item${item.off ? ' off' : ''}${item.starter ? ' starter' : ''}`);
|
|
4048
4203
|
button.type = 'button';
|
|
4049
4204
|
button.setAttribute('role', 'option');
|
|
4050
4205
|
button.setAttribute('aria-selected', String(index === menu.index));
|
|
@@ -4053,11 +4208,17 @@ function showMenu(items, found) {
|
|
|
4053
4208
|
// a usage string like `/git [status|log|diff|commit "message"|push [confirm]]` is wider than
|
|
4054
4209
|
// the menu and used to push the description out of the row entirely — leaving a command with
|
|
4055
4210
|
// no explanation, which is the one thing this menu exists to give.
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4211
|
+
if (item.starter) {
|
|
4212
|
+
const shape = STARTER_SHAPES[item.shape];
|
|
4213
|
+
button.append(pixelIcon(item.icon, 'pixel-icon starter-icon'), el('span', 'idea', item.what), el('span', `shape ${item.shape}`, shape.label));
|
|
4214
|
+
button.title = `/module ${item.what} · ${shape.about}`;
|
|
4215
|
+
} else {
|
|
4216
|
+
const [name, ...rest] = String(item.key).split(' ');
|
|
4217
|
+
const args = rest.join(' ');
|
|
4218
|
+
button.append(el('span', 'key', name), el('span', 'what', item.what));
|
|
4219
|
+
if (args) button.append(el('span', 'args', args));
|
|
4220
|
+
button.title = `${item.key} · ${item.what}`;
|
|
4221
|
+
}
|
|
4061
4222
|
button.addEventListener('mousedown', (event) => { event.preventDefault(); pickMenu(index); });
|
|
4062
4223
|
els.slashMenu.append(button);
|
|
4063
4224
|
});
|
|
@@ -4140,11 +4301,24 @@ async function runSlashCommand(text) {
|
|
|
4140
4301
|
return { handled: true };
|
|
4141
4302
|
}
|
|
4142
4303
|
|
|
4304
|
+
// Enter sends. The line break is ⌥ Enter — Option on macOS, Alt on Linux and Windows, which is
|
|
4305
|
+
// the same physical key and the same flag on the event, so there is one rule and not one per
|
|
4306
|
+
// platform. The break is inserted by hand rather than left to the browser: with the modifier
|
|
4307
|
+
// held, what a textarea does with Enter is not the same everywhere, and a key that works on one
|
|
4308
|
+
// machine and does nothing on the next is worse than no shortcut.
|
|
4309
|
+
els.input.title = t('Enter sends · ⌥ Enter (Alt on Linux) breaks the line');
|
|
4143
4310
|
els.input.addEventListener('keydown', (event) => {
|
|
4144
|
-
if (event.key
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4311
|
+
if (event.key !== 'Enter') return;
|
|
4312
|
+
event.preventDefault();
|
|
4313
|
+
if (!event.altKey) { els.composer.requestSubmit(); return; }
|
|
4314
|
+
const from = els.input.selectionStart ?? els.input.value.length;
|
|
4315
|
+
const to = els.input.selectionEnd ?? from;
|
|
4316
|
+
if (typeof els.input.setRangeText === 'function') els.input.setRangeText('\n', from, to, 'end');
|
|
4317
|
+
else {
|
|
4318
|
+
els.input.value = `${els.input.value.slice(0, from)}\n${els.input.value.slice(to)}`;
|
|
4319
|
+
els.input.selectionStart = els.input.selectionEnd = from + 1;
|
|
4320
|
+
}
|
|
4321
|
+
els.input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
4148
4322
|
});
|
|
4149
4323
|
|
|
4150
4324
|
const STOPALL = /^\/?stop\s*all!?$/i;
|
|
@@ -4603,6 +4777,7 @@ function renderMotherRecorded() {
|
|
|
4603
4777
|
try { collapsed = localStorage.getItem('pulse.mother.log') === 'collapsed'; } catch { /* no storage */ }
|
|
4604
4778
|
const shown = state.failures.filter((failure) => !dismissed.has(failureKey(failure)));
|
|
4605
4779
|
const body = folding(mother.recorded, t('RECORDED CONDITIONS · THIS ROOM · {n}', { n: shown.length }), {
|
|
4780
|
+
icon: 'fault',
|
|
4606
4781
|
key: 'recorded', open: !collapsed,
|
|
4607
4782
|
});
|
|
4608
4783
|
// Everything dismissed is still in the ledger; this only stops it from being shown again.
|
|
@@ -4696,6 +4871,7 @@ function renderMotherKnown(list = allConditions(), hits = new Set(), agent = nul
|
|
|
4696
4871
|
// An inquiry that narrowed the list, or a fix chosen from the log, always shows its answer, whatever the stored state.
|
|
4697
4872
|
if (list.length !== CONDITIONS.length || hits.size) collapsed = false;
|
|
4698
4873
|
const body = folding(mother.known, t('KNOWN CONDITIONS · {n} OF {total} · {os} / {shell}', { n: list.length, total: CONDITIONS.length, os: PLATFORMS[mother.platform].label.toUpperCase(), shell: PLATFORMS[mother.platform].shell.toUpperCase() }), {
|
|
4874
|
+
icon: 'list',
|
|
4699
4875
|
key: 'known', open: !collapsed,
|
|
4700
4876
|
});
|
|
4701
4877
|
const grid = el('div', 'mother-grid');
|
|
@@ -5099,6 +5275,55 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
5099
5275
|
return { card, panel, actions };
|
|
5100
5276
|
}
|
|
5101
5277
|
|
|
5278
|
+
// FLOOR 5, for a connector: the secrets it declared. The field is a password field, it posts to
|
|
5279
|
+
// the module's own vault route, and it is never filled back in — what comes back from the server
|
|
5280
|
+
// is the name and the length, which is all MADRE itself is allowed to know. FORGET removes it.
|
|
5281
|
+
function cardSecrets(panel, item) {
|
|
5282
|
+
if (!item.secrets?.length) return;
|
|
5283
|
+
const box = cardBlock(panel, t('KEYS'));
|
|
5284
|
+
for (const secret of item.secrets) {
|
|
5285
|
+
const held = (item.held ?? []).find((one) => one.name === secret.name);
|
|
5286
|
+
const row = el('div', 'card-secret');
|
|
5287
|
+
const field = el('label', 'control');
|
|
5288
|
+
field.append(el('span', null, secret.label));
|
|
5289
|
+
const input = el('input');
|
|
5290
|
+
input.type = 'password';
|
|
5291
|
+
input.autocomplete = 'off';
|
|
5292
|
+
input.placeholder = held ? t('kept · {n} characters', { n: held.bytes }) : t('paste it here');
|
|
5293
|
+
input.title = secret.note;
|
|
5294
|
+
const keep = async (value) => {
|
|
5295
|
+
input.disabled = true;
|
|
5296
|
+
const response = await fetch(`/api/x/${item.id}/secret`, {
|
|
5297
|
+
method: value === null ? 'DELETE' : 'POST',
|
|
5298
|
+
headers: { 'content-type': 'application/json' },
|
|
5299
|
+
body: JSON.stringify(value === null ? { name: secret.name } : { name: secret.name, secret: value }),
|
|
5300
|
+
});
|
|
5301
|
+
const result = await response.json().catch(() => ({}));
|
|
5302
|
+
input.value = '';
|
|
5303
|
+
if (!response.ok) toast(`MU/TH/UR › ${result.error ?? t('{name} could not keep {label}.', { name: item.name, label: secret.label })}`);
|
|
5304
|
+
await refreshModules();
|
|
5305
|
+
};
|
|
5306
|
+
input.addEventListener('change', () => { if (input.value.trim()) void keep(input.value.trim()); });
|
|
5307
|
+
field.append(input);
|
|
5308
|
+
row.append(field);
|
|
5309
|
+
if (held) {
|
|
5310
|
+
const forget = el('button', 'ghost', t('FORGET IT'));
|
|
5311
|
+
forget.type = 'button';
|
|
5312
|
+
forget.addEventListener('click', () => void keep(null));
|
|
5313
|
+
row.append(forget);
|
|
5314
|
+
}
|
|
5315
|
+
box.append(row);
|
|
5316
|
+
if (secret.note) box.append(el('p', 'note', secret.note));
|
|
5317
|
+
if (secret.where) {
|
|
5318
|
+
const link = el('a', 'note', t('WHERE TO GET IT'));
|
|
5319
|
+
link.href = secret.where;
|
|
5320
|
+
link.target = '_blank';
|
|
5321
|
+
link.rel = 'noreferrer';
|
|
5322
|
+
box.append(link);
|
|
5323
|
+
}
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5102
5327
|
// FLOOR 4, the part every module can have without writing a line of interface: the settings it
|
|
5103
5328
|
// declared in the SDK. MADRE draws them and saves them into the module's own block of config.
|
|
5104
5329
|
function cardControls(panel, item) {
|
|
@@ -5264,6 +5489,13 @@ function openEconomy() {
|
|
|
5264
5489
|
|
|
5265
5490
|
economyPanel.close?.addEventListener('click', () => economyPanel.dialog.close());
|
|
5266
5491
|
|
|
5492
|
+
// A card's heading, with the glyph that says which card it is before the words are read.
|
|
5493
|
+
function cardHead(icon, title) {
|
|
5494
|
+
const head = el('h5', 'card-head');
|
|
5495
|
+
head.append(pixelIcon(icon, 'pixel-icon card-icon'), el('span', null, title));
|
|
5496
|
+
return head;
|
|
5497
|
+
}
|
|
5498
|
+
|
|
5267
5499
|
async function renderEconomy() {
|
|
5268
5500
|
const box = economyPanel.body;
|
|
5269
5501
|
if (!box) return;
|
|
@@ -5289,7 +5521,7 @@ async function renderEconomy() {
|
|
|
5289
5521
|
[count(totals.output), t('OUTPUT CHARGED'), t('tokens written back · the dearer half')],
|
|
5290
5522
|
[count(saved.tokens), t('NEVER CHARGED'), t('read back from a cache, plus what was never sent')],
|
|
5291
5523
|
[pct(totals.cacheShare), t('CAME FROM CACHE'), t('of everything read, the share nobody charged twice')],
|
|
5292
|
-
[totals.charsPerInputToken ? totals.charsPerInputToken.toFixed(2) : '—', t('
|
|
5524
|
+
[totals.charsPerInputToken ? totals.charsPerInputToken.toFixed(2) : '—', t('CHARACTERS PER TOKEN'), t('what MADRE wrote against what the CLIs were charged for reading · not a tokenizer')],
|
|
5293
5525
|
]) {
|
|
5294
5526
|
const cell = el('div', 'economy-stat');
|
|
5295
5527
|
cell.title = hint;
|
|
@@ -5300,8 +5532,9 @@ async function renderEconomy() {
|
|
|
5300
5532
|
|
|
5301
5533
|
// 1 · The only money on this screen.
|
|
5302
5534
|
const first = el('div', 'card-block');
|
|
5303
|
-
first.append(
|
|
5535
|
+
first.append(cardHead('free', t('NEVER CHARGED FOR')));
|
|
5304
5536
|
first.append(el('div', 'economy-headline', count(saved.tokens)));
|
|
5537
|
+
first.append(el('p', 'unit-note', t('tokens this room never paid for')));
|
|
5305
5538
|
metrics(first, [
|
|
5306
5539
|
[t('FROM THE CACHE'), count(saved.cachedTokens), t('the CLI said it read these back instead of charging them again · measured')],
|
|
5307
5540
|
[t('NEVER SENT'), count(saved.unsentTokens), t('briefing a turn had no use for · estimated from characters, not measured')],
|
|
@@ -5314,7 +5547,7 @@ async function renderEconomy() {
|
|
|
5314
5547
|
// scale of a paid turn beside it so the count means something.
|
|
5315
5548
|
if (read.free?.turns) {
|
|
5316
5549
|
const free = el('div', 'card-block');
|
|
5317
|
-
free.append(
|
|
5550
|
+
free.append(cardHead('home', t('TURNS THE ROOM ANSWERED ITSELF')));
|
|
5318
5551
|
metrics(free, [
|
|
5319
5552
|
[t('FREE TURNS'), count(read.free.turns), t('answered by @madre on this computer · no provider, no bill')],
|
|
5320
5553
|
[t('OF EVERY TURN'), pct(read.free.share), t('of the turns weighed in this room')],
|
|
@@ -5326,7 +5559,7 @@ async function renderEconomy() {
|
|
|
5326
5559
|
|
|
5327
5560
|
// 2 · Ash, observed rather than claimed.
|
|
5328
5561
|
const second = el('div', 'card-block');
|
|
5329
|
-
second.append(
|
|
5562
|
+
second.append(cardHead('cut', t('WHAT ASH DOES TO AN ANSWER')));
|
|
5330
5563
|
// The price before the promise. Ash concatenates an instruction to every prompt it is on, so it
|
|
5331
5564
|
// ALWAYS costs input — and the room measures that block exactly, because it is the block the
|
|
5332
5565
|
// room itself wrote. Showing only the shorter answer below, which is observed and conditional,
|
|
@@ -5334,7 +5567,7 @@ async function renderEconomy() {
|
|
|
5334
5567
|
const block = read.blocks.find((one) => one.id === 'ash');
|
|
5335
5568
|
if (block) {
|
|
5336
5569
|
metrics(second, [
|
|
5337
|
-
[t('ASH ADDS'),
|
|
5570
|
+
[t('ASH ADDS'), t('{n} characters', { n: block.perTurn }), t('to every prompt it is on · measured, not estimated')],
|
|
5338
5571
|
[t('IN TOKENS'), `≈${Math.round(block.perTurn / CH_PER_TOKEN)}`, t('at {rate} characters per token · an estimate', { rate: CH_PER_TOKEN })],
|
|
5339
5572
|
[t('TURNS CARRYING IT'), count(block.turns), t('of the {n} weighed in this room', { n: count(read.turns) })],
|
|
5340
5573
|
]);
|
|
@@ -5346,7 +5579,7 @@ async function renderEconomy() {
|
|
|
5346
5579
|
|
|
5347
5580
|
// 3 · Where what WAS paid for went.
|
|
5348
5581
|
const third = el('div', 'card-block');
|
|
5349
|
-
third.append(
|
|
5582
|
+
third.append(cardHead('bars', t('WHERE EACH PROMPT GOES')));
|
|
5350
5583
|
economyBlocks(third, read);
|
|
5351
5584
|
third.append(el('p', 'note', t('{pct} of each prompt is the unchanging head a cache can match. MADRE wrote {chars} characters of the {input} input tokens you were charged for; the rest is what the CLIs read on their own.', { pct: pct(read.totals.prefixShare), chars: count(read.totals.chars), input: count(read.totals.input) })));
|
|
5352
5585
|
box.append(third);
|
|
@@ -5356,11 +5589,13 @@ async function renderEconomy() {
|
|
|
5356
5589
|
// which is a table and not a row of bars. A bar can only carry one measure honestly, and these
|
|
5357
5590
|
// are read by looking one agent up, not by comparing lengths.
|
|
5358
5591
|
const fourth = el('div', 'card-block wide');
|
|
5359
|
-
fourth.append(
|
|
5592
|
+
fourth.append(cardHead('grid', t('BY AGENT')));
|
|
5360
5593
|
const table = el('table', 'economy-table');
|
|
5361
5594
|
const head = el('tr');
|
|
5362
|
-
for (const column of [t('AGENT'), t('TURNS'), t('INPUT'), t('OUTPUT'), t('CACHE'), t('CACHED %'), t('
|
|
5363
|
-
|
|
5595
|
+
for (const column of [t('AGENT'), t('TURNS'), t('INPUT'), t('OUTPUT'), t('CACHE'), t('CACHED %'), t('CHARS / TOKEN')]) head.append(el('th', null, column));
|
|
5596
|
+
const thead = el('thead');
|
|
5597
|
+
thead.append(head);
|
|
5598
|
+
table.append(thead);
|
|
5364
5599
|
const body = el('tbody');
|
|
5365
5600
|
for (const agent of read.agents) {
|
|
5366
5601
|
const row = el('tr');
|
|
@@ -5378,13 +5613,17 @@ async function renderEconomy() {
|
|
|
5378
5613
|
// The blocks a prompt is made of, widest first. Shared by the card and the panel so the two
|
|
5379
5614
|
// cannot drift into saying different things about the same room.
|
|
5380
5615
|
function economyBlocks(box, read) {
|
|
5616
|
+
const count = (value) => (Number.isFinite(value) ? value.toLocaleString() : '—');
|
|
5381
5617
|
const widest = read.blocks[0]?.chars || 1;
|
|
5618
|
+
// The unit belongs on the card once, not stamped on ten rows: ten copies of «CH/TURN» is most
|
|
5619
|
+
// of what made this screen unreadable, and none of them said what CH was.
|
|
5620
|
+
box.append(el('p', 'unit-note', t('Characters each block adds to one prompt.')));
|
|
5382
5621
|
const bars = el('div', 'ash-blocks');
|
|
5383
5622
|
for (const block of read.blocks.slice(0, 10)) {
|
|
5384
5623
|
const row = el('div', 'ash-block');
|
|
5385
5624
|
const bar = el('i');
|
|
5386
5625
|
bar.style.setProperty('--fill', `${Math.max(2, Math.round((block.chars / widest) * 100))}%`);
|
|
5387
|
-
row.append(el('b', null, block.id.toUpperCase()), bar, el('span', null, `${block.perTurn}
|
|
5626
|
+
row.append(el('b', null, block.id.toUpperCase()), bar, el('span', null, `${count(block.perTurn)}${block.always ? t(' · ALWAYS') : ''}`));
|
|
5388
5627
|
bars.append(row);
|
|
5389
5628
|
}
|
|
5390
5629
|
box.append(bars);
|
|
@@ -5472,6 +5711,7 @@ function builtinCard(item) {
|
|
|
5472
5711
|
const fixed = Boolean(item.fixed);
|
|
5473
5712
|
const { card, panel, actions } = cardShell(item, { on, state: on ? t('ON') : t('OFF') });
|
|
5474
5713
|
cardControls(panel, item);
|
|
5714
|
+
cardSecrets(panel, item);
|
|
5475
5715
|
|
|
5476
5716
|
if (item.id === 'ollama') {
|
|
5477
5717
|
const info = item.ollama ?? { running: false, models: [], settings: {} };
|
|
@@ -5650,6 +5890,7 @@ function moduleCard(item) {
|
|
|
5650
5890
|
const installed = Boolean(item.status?.installed);
|
|
5651
5891
|
const { card, panel, actions } = cardShell(item, { on: installed, state: running ? t('INSTALLING') : installed ? t('ON') : t('OFF') });
|
|
5652
5892
|
cardControls(panel, item);
|
|
5893
|
+
cardSecrets(panel, item);
|
|
5653
5894
|
|
|
5654
5895
|
const blocked = item.preflight && !item.preflight.ok;
|
|
5655
5896
|
if (blocked) {
|
|
@@ -5725,13 +5966,31 @@ function renderModulesDev() {
|
|
|
5725
5966
|
const glyph = el('div', 'dev-glyph', '</>');
|
|
5726
5967
|
const body = el('div', 'dev-body');
|
|
5727
5968
|
body.append(el('h4', null, t('Would you like to develop for MADRE?')));
|
|
5728
|
-
body.append(el('p', null, t('
|
|
5969
|
+
body.append(el('p', null, t('A module is one file: no build, no dependencies, nothing to register. A switch, settings, a slash command, a tool for the agents, a card of your own \u2014 or a connector to something you already use. Drop it in a folder, press RELOAD, and it is there with its own card.')));
|
|
5970
|
+
// The easiest door is the one nobody finds on their own: ask for it here, in the room.
|
|
5971
|
+
const ask = el('p', 'dev-ask');
|
|
5972
|
+
ask.append(el('span', null, t('Or ask for one. In the composer: ')), el('code', null, '/module \u2026'), el('span', null, t(' \u2014 the agent reads the SDK and writes the file; you review it and decide whether it is installed.')));
|
|
5973
|
+
body.append(ask);
|
|
5974
|
+
body.append(el('p', 'note', t('Connecting to a service from outside is almost always possible: the limit is not MADRE, it is whether that service lets you get your own key in minutes and revoke it on its own. Your module declares the key it needs and MADRE draws the field; declares where it reaches and the outbound log answers for it; declares which of its tools SEND, and the room withholds those below #4 AIRLOCK.')));
|
|
5729
5975
|
const where = el('div', 'dev-where');
|
|
5730
5976
|
const folders = modules.folders ?? {};
|
|
5731
5977
|
where.append(el('code', null, folders.user ? `${folders.user}/` : '~/.pulse/modules/'), el('span', 'note', t(' every project · ')), el('code', null, folders.project ? `${folders.project}/` : '<project>/.madre/modules/'), el('span', 'note', t(' this project')));
|
|
5732
5978
|
body.append(where);
|
|
5733
5979
|
const row = el('div', 'dev-row');
|
|
5734
5980
|
const read = el('a', 'mother-close', t('READ THE SDK ↗')); read.href = modules.sdk ?? 'https://github.com/jossuealcacao-exe/madre/blob/main/docs/SDK.md'; read.target = '_blank'; read.rel = 'noopener noreferrer';
|
|
5981
|
+
// Straight into the composer, with the panel out of the way: the gap between reading about
|
|
5982
|
+
// the SDK and trying it should be one click, not a file path to go find.
|
|
5983
|
+
const build = el('button', 'mother-close', t('ASK FOR ONE'));
|
|
5984
|
+
build.type = 'button';
|
|
5985
|
+
build.title = t('Puts /module in the composer, in the room');
|
|
5986
|
+
build.addEventListener('click', () => {
|
|
5987
|
+
document.querySelector('#modules-button, [aria-controls="modules"]')?.click();
|
|
5988
|
+
els.input.value = '/module ';
|
|
5989
|
+
autosize();
|
|
5990
|
+
syncModuleComposer();
|
|
5991
|
+
els.input.focus();
|
|
5992
|
+
renderMenu();
|
|
5993
|
+
});
|
|
5735
5994
|
const reload = el('button', 'mother-close', t('RELOAD MODULES')); reload.type = 'button'; reload.title = t('Load your module files again without restarting the room');
|
|
5736
5995
|
reload.addEventListener('click', async () => {
|
|
5737
5996
|
reload.disabled = true;
|
|
@@ -5742,7 +6001,7 @@ function renderModulesDev() {
|
|
|
5742
6001
|
renderModules();
|
|
5743
6002
|
} catch (error) { toast(t('The reload failed: {error}', { error: error.message })); } finally { reload.disabled = false; }
|
|
5744
6003
|
});
|
|
5745
|
-
row.append(read, reload);
|
|
6004
|
+
row.append(build, read, reload);
|
|
5746
6005
|
body.append(row);
|
|
5747
6006
|
const yours = modules.items.filter((item) => item.external);
|
|
5748
6007
|
if (yours.length) {
|
|
@@ -5889,6 +6148,10 @@ const settingsUI = {
|
|
|
5889
6148
|
loginLogs: new Map(),
|
|
5890
6149
|
};
|
|
5891
6150
|
|
|
6151
|
+
// MEMORY is its own destination in the bar, and the two are mutually exclusive: both are full
|
|
6152
|
+
// screens of the same panel, so opening one closes the other instead of stacking them.
|
|
6153
|
+
const memoryUI = { button: document.querySelector('#memory-button'), section: document.querySelector('#mother-memory'), open: false };
|
|
6154
|
+
|
|
5892
6155
|
function renderLoginEvent(event) {
|
|
5893
6156
|
const { agent, command, session, code, error } = event.payload;
|
|
5894
6157
|
const finished = event.type === 'connection.login.finished';
|
|
@@ -6474,43 +6737,274 @@ document.querySelector('#core-copy')?.addEventListener('click', async () => {
|
|
|
6474
6737
|
} catch { toast(t('The clipboard is not available here.')); }
|
|
6475
6738
|
});
|
|
6476
6739
|
|
|
6477
|
-
/* ----------
|
|
6478
|
-
|
|
6479
|
-
// One sentence and one thing to do. Everything that decides which sentence was already being
|
|
6480
|
-
// measured; what was missing was somebody choosing. The nine numbers are still here, a fold away,
|
|
6481
|
-
// for the day someone wants them — but nobody should have to read nine numbers to know whether
|
|
6482
|
-
// their room is working.
|
|
6483
|
-
async function drawVerdict(box) {
|
|
6484
|
-
box.replaceChildren(el('p', 'note', t('READING…')));
|
|
6485
|
-
let payload;
|
|
6486
|
-
try { payload = await fetch('/api/maturity').then((response) => response.json()); }
|
|
6487
|
-
catch { box.replaceChildren(el('p', 'note', t('THE READING IS UNAVAILABLE'))); return; }
|
|
6488
|
-
const m = payload.maturity;
|
|
6489
|
-
const v = payload.verdict;
|
|
6490
|
-
box.replaceChildren();
|
|
6491
|
-
if (!m || !v) { box.append(el('p', 'note', t('NOTHING TO READ YET: THE ROOM HAS NO ARCHIVE.'))); return; }
|
|
6740
|
+
/* ---------- the MEMORY dashboard ---------- */
|
|
6492
6741
|
|
|
6493
|
-
|
|
6494
|
-
|
|
6742
|
+
// What the archive holds and how it is doing, in one screen that is read before it is touched.
|
|
6743
|
+
// Everything on it is a count the room already keeps (/api/memory/dashboard) or a reading it
|
|
6744
|
+
// already takes (/api/maturity). No memory's text is on this screen, which is why it opens
|
|
6745
|
+
// without the designation NOSTROMO asks for.
|
|
6746
|
+
//
|
|
6747
|
+
// The charts are plain elements, not a library: a column is a box with a height, which is all a
|
|
6748
|
+
// phosphor screen needs, and it stays crisp at any width. One green, because every chart here
|
|
6749
|
+
// compares amounts of one thing; where an entity already has a colour of its own (a memory's kind
|
|
6750
|
+
// on the NOSTROMO map) it travels as a small key beside the word, never as the only way to tell.
|
|
6751
|
+
const memFmt = (n) => Number(n ?? 0).toLocaleString(language());
|
|
6752
|
+
const memPct = (value) => `${Math.round(Number(value ?? 0) * 100)}%`;
|
|
6753
|
+
const memDay = (day) => {
|
|
6754
|
+
const [y, m, d] = String(day).split('-').map(Number);
|
|
6755
|
+
return new Date(y, m - 1, d).toLocaleDateString(language(), { day: 'numeric', month: 'short' }).replace('.', '').toUpperCase();
|
|
6756
|
+
};
|
|
6757
|
+
|
|
6758
|
+
function memCard(parent, title, className = '') {
|
|
6759
|
+
const card = el('section', `mem-card${className ? ` ${className}` : ''}`);
|
|
6760
|
+
card.append(el('h4', null, title));
|
|
6761
|
+
parent.append(card);
|
|
6762
|
+
return card;
|
|
6763
|
+
}
|
|
6764
|
+
|
|
6765
|
+
// A row of columns from a baseline. Empty days keep their slot and show a hairline stub, so a
|
|
6766
|
+
// quiet stretch reads as quiet instead of closing up; the last column is today.
|
|
6767
|
+
function memColumns(values, className = 'mem-cols') {
|
|
6768
|
+
const box = el('div', className);
|
|
6769
|
+
const top = Math.max(1, ...values);
|
|
6770
|
+
for (const [i, n] of values.entries()) {
|
|
6771
|
+
const col = el('i', n ? null : 'zero');
|
|
6772
|
+
if (n) col.style.setProperty('--h', `${Math.max(6, (n / top) * 100)}%`);
|
|
6773
|
+
if (i === values.length - 1) col.classList.add('now');
|
|
6774
|
+
box.append(col);
|
|
6775
|
+
}
|
|
6776
|
+
return box;
|
|
6777
|
+
}
|
|
6778
|
+
|
|
6779
|
+
// A filled bar against its own track: how much of a whole, or how far toward a mark.
|
|
6780
|
+
function memMeter(value, { mark = null } = {}) {
|
|
6781
|
+
const track = el('span', 'mem-meter');
|
|
6782
|
+
const fill = el('i');
|
|
6783
|
+
fill.style.width = `${Math.min(100, Math.max(0, value * 100))}%`;
|
|
6784
|
+
track.append(fill);
|
|
6785
|
+
if (mark !== null) {
|
|
6786
|
+
const line = el('b', 'mark');
|
|
6787
|
+
line.style.left = `${Math.min(100, Math.max(0, mark * 100))}%`;
|
|
6788
|
+
track.append(line);
|
|
6789
|
+
}
|
|
6790
|
+
return track;
|
|
6791
|
+
}
|
|
6792
|
+
|
|
6793
|
+
// Amounts of one thing, longest first, each with its number at the end of its bar.
|
|
6794
|
+
function memBars(card, rows) {
|
|
6795
|
+
const list = el('div', 'mem-bars');
|
|
6796
|
+
const shown = rows.filter((row) => row.n > 0).sort((a, b) => b.n - a.n);
|
|
6797
|
+
if (!shown.length) { card.append(el('p', 'note', t('NOTHING YET'))); return; }
|
|
6798
|
+
const top = Math.max(...shown.map((row) => row.n));
|
|
6799
|
+
const total = shown.reduce((sum, row) => sum + row.n, 0);
|
|
6800
|
+
for (const row of shown) {
|
|
6801
|
+
const line = el('div', 'mem-bar');
|
|
6802
|
+
const name = el('span', 'name');
|
|
6803
|
+
if (row.swatch) { const key = el('i', 'swatch'); key.style.background = row.swatch; name.append(key); }
|
|
6804
|
+
name.append(row.label);
|
|
6805
|
+
const track = el('span', 'track');
|
|
6806
|
+
const fill = el('i');
|
|
6807
|
+
fill.style.width = `${(row.n / top) * 100}%`;
|
|
6808
|
+
track.append(fill);
|
|
6809
|
+
line.append(name, track, el('span', 'value', `${memFmt(row.n)} · ${memPct(row.n / total)}`));
|
|
6810
|
+
if (row.title) line.title = row.title;
|
|
6811
|
+
list.append(line);
|
|
6812
|
+
}
|
|
6813
|
+
card.append(list);
|
|
6814
|
+
}
|
|
6815
|
+
|
|
6816
|
+
function memTile(parent, { label, value, sub = null, title = null, spark = null, meter = null }) {
|
|
6817
|
+
const tile = el('div', 'mem-tile');
|
|
6818
|
+
tile.append(el('span', 'label', label), el('strong', 'value', value));
|
|
6819
|
+
if (sub) tile.append(el('span', 'sub', sub));
|
|
6820
|
+
if (spark) tile.append(memColumns(spark, 'mem-cols spark'));
|
|
6821
|
+
if (meter !== null) tile.append(memMeter(meter));
|
|
6822
|
+
if (title) tile.title = title;
|
|
6823
|
+
parent.append(tile);
|
|
6824
|
+
return tile;
|
|
6825
|
+
}
|
|
6826
|
+
|
|
6827
|
+
// This week, and which way it went against the one before. An arrow and a number, never a
|
|
6828
|
+
// percentage: a rise from two to four is not «+100%» in any sense worth reading.
|
|
6829
|
+
function memWeek(week) {
|
|
6830
|
+
if (!week) return null;
|
|
6831
|
+
const turn = week.delta > 0 ? `▲ ${memFmt(week.delta)}` : week.delta < 0 ? `▼ ${memFmt(-week.delta)}` : '=';
|
|
6832
|
+
return `${t('{n} THIS WEEK', { n: memFmt(week.last) })} · ${turn}`;
|
|
6833
|
+
}
|
|
6834
|
+
const memWeekTitle = (week) => (week ? t('{last} in the last 7 days, {before} in the 7 before', { last: memFmt(week.last), before: memFmt(week.before) }) : null);
|
|
6835
|
+
|
|
6836
|
+
// Three series of one window, stacked so their days line up: each strip has its own height
|
|
6837
|
+
// because an exchange and a recall are not the same unit, and one axis for the three would lie
|
|
6838
|
+
// about one of them. One crosshair finds the day in all three at once.
|
|
6839
|
+
function memActivity(card, dash) {
|
|
6840
|
+
const rows = [['entries', t('EXCHANGES')], ['memories', t('NEW MEMORIES')], ['recalls', t('TURNS THAT RECALLED')]];
|
|
6841
|
+
const n = dash.days.length;
|
|
6842
|
+
const chart = el('div', 'mem-activity');
|
|
6843
|
+
chart.tabIndex = 0;
|
|
6844
|
+
chart.setAttribute('role', 'img');
|
|
6845
|
+
const total = (id) => dash.series[id].reduce((sum, v) => sum + v, 0);
|
|
6846
|
+
chart.setAttribute('aria-label', t('{entries} exchanges, {memories} new memories and {recalls} turns that recalled in the last {n} days', { entries: total('entries'), memories: total('memories'), recalls: total('recalls'), n }));
|
|
6847
|
+
for (const [id, label] of rows) {
|
|
6848
|
+
const strip = el('div', 'mem-strip');
|
|
6849
|
+
const head = el('div', 'mem-strip-head');
|
|
6850
|
+
head.append(el('b', null, label), el('span', null, t('PEAK {n}', { n: memFmt(Math.max(0, ...dash.series[id])) })));
|
|
6851
|
+
strip.append(head, memColumns(dash.series[id], 'mem-cols big'));
|
|
6852
|
+
chart.append(strip);
|
|
6853
|
+
}
|
|
6854
|
+
const axis = el('div', 'mem-axis');
|
|
6855
|
+
axis.append(el('span', null, memDay(dash.days[0])), el('span', null, memDay(dash.days[Math.floor(n / 2)])), el('span', null, t('TODAY')));
|
|
6856
|
+
chart.append(axis);
|
|
6857
|
+
const cross = el('div', 'mem-cross');
|
|
6858
|
+
const tip = el('div', 'mem-tip');
|
|
6859
|
+
cross.hidden = true; tip.hidden = true;
|
|
6860
|
+
chart.append(cross, tip);
|
|
6861
|
+
let at = -1;
|
|
6862
|
+
const show = (i) => {
|
|
6863
|
+
at = i;
|
|
6864
|
+
for (const cols of chart.querySelectorAll('.mem-cols')) for (const [j, col] of [...cols.children].entries()) col.classList.toggle('on', j === i);
|
|
6865
|
+
if (i < 0) { cross.hidden = true; tip.hidden = true; return; }
|
|
6866
|
+
const col = chart.querySelector('.mem-cols').children[i];
|
|
6867
|
+
const left = col.offsetLeft + col.offsetWidth / 2;
|
|
6868
|
+
cross.style.left = `${left}px`;
|
|
6869
|
+
cross.hidden = false;
|
|
6870
|
+
tip.replaceChildren(el('b', 'when', i === n - 1 ? t('TODAY') : memDay(dash.days[i])));
|
|
6871
|
+
for (const [id, label] of rows) {
|
|
6872
|
+
const line = el('div');
|
|
6873
|
+
line.append(el('strong', null, memFmt(dash.series[id][i])), el('span', null, label));
|
|
6874
|
+
tip.append(line);
|
|
6875
|
+
}
|
|
6876
|
+
tip.hidden = false;
|
|
6877
|
+
tip.style.left = left > chart.clientWidth / 2 ? `${left - tip.offsetWidth - 14}px` : `${left + 14}px`;
|
|
6878
|
+
};
|
|
6879
|
+
chart.addEventListener('pointermove', (event) => {
|
|
6880
|
+
const box = chart.querySelector('.mem-cols').getBoundingClientRect();
|
|
6881
|
+
if (!box.width) return;
|
|
6882
|
+
const i = Math.floor(((event.clientX - box.left) / box.width) * n);
|
|
6883
|
+
show(i < 0 || i >= n ? -1 : i);
|
|
6884
|
+
});
|
|
6885
|
+
chart.addEventListener('pointerleave', () => show(-1));
|
|
6886
|
+
chart.addEventListener('focus', () => show(n - 1));
|
|
6887
|
+
chart.addEventListener('blur', () => show(-1));
|
|
6888
|
+
chart.addEventListener('keydown', (event) => {
|
|
6889
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
|
6890
|
+
event.preventDefault();
|
|
6891
|
+
show(Math.min(n - 1, Math.max(0, (at < 0 ? n - 1 : at) + (event.key === 'ArrowLeft' ? -1 : 1))));
|
|
6892
|
+
});
|
|
6893
|
+
card.append(chart);
|
|
6894
|
+
|
|
6895
|
+
// The same numbers without pointing at anything: every value the crosshair shows is here too.
|
|
6896
|
+
const table = cardFold(card, t('BY DAY · TABLE'), { key: 'madre.memory.table' });
|
|
6897
|
+
const lived = dash.days.map((day, i) => [day, ...rows.map(([id]) => dash.series[id][i])]).filter(([, ...values]) => values.some(Boolean)).reverse();
|
|
6898
|
+
if (!lived.length) { table.append(el('p', 'note', t('NOTHING HAPPENED IN THESE DAYS.'))); return; }
|
|
6899
|
+
const grid = el('table', 'mem-table');
|
|
6900
|
+
const headRow = el('tr');
|
|
6901
|
+
for (const word of [t('DAY'), ...rows.map(([, label]) => label)]) headRow.append(el('th', null, word));
|
|
6902
|
+
grid.append(headRow);
|
|
6903
|
+
for (const [day, ...values] of lived) {
|
|
6904
|
+
const tr = el('tr');
|
|
6905
|
+
tr.append(el('td', null, memDay(day)));
|
|
6906
|
+
for (const value of values) tr.append(el('td', null, memFmt(value)));
|
|
6907
|
+
grid.append(tr);
|
|
6908
|
+
}
|
|
6909
|
+
table.append(grid);
|
|
6910
|
+
}
|
|
6911
|
+
|
|
6912
|
+
async function drawMemoryDashboard(box) {
|
|
6913
|
+
// A refresh keeps the frame it has and dims it, instead of blanking the screen under the reader.
|
|
6914
|
+
if (box.childElementCount) box.classList.add('loading');
|
|
6915
|
+
else box.replaceChildren(el('p', 'note', t('READING…')));
|
|
6916
|
+
let read; let dash;
|
|
6917
|
+
try {
|
|
6918
|
+
[read, dash] = await Promise.all([
|
|
6919
|
+
fetch('/api/maturity').then((response) => response.json()),
|
|
6920
|
+
fetch('/api/memory/dashboard').then((response) => response.json()).then((payload) => payload?.dashboard ?? null),
|
|
6921
|
+
]);
|
|
6922
|
+
} catch { box.classList.remove('loading'); box.replaceChildren(el('p', 'note', t('THE DASHBOARD IS UNAVAILABLE'))); return; }
|
|
6923
|
+
box.classList.remove('loading');
|
|
6924
|
+
box.replaceChildren();
|
|
6925
|
+
const m = read?.maturity;
|
|
6926
|
+
const v = read?.verdict;
|
|
6927
|
+
|
|
6928
|
+
const head = el('div', 'mem-dash-head');
|
|
6929
|
+
// The title is the fold's; what is left here is when it was read and a way to read it again.
|
|
6930
|
+
head.append(el('span', 'when', t('READ {time}', { time: formatTime(new Date().toISOString()) })));
|
|
6931
|
+
const refresh = el('button', null, t('REFRESH'));
|
|
6932
|
+
refresh.type = 'button';
|
|
6933
|
+
refresh.addEventListener('click', () => { void drawMemoryDashboard(box); });
|
|
6934
|
+
head.append(refresh);
|
|
6495
6935
|
box.append(head);
|
|
6936
|
+
if (!dash || !m || !v) { box.append(el('p', 'note', t('NOTHING TO READ YET: THE ROOM HAS NO ARCHIVE.'))); return; }
|
|
6937
|
+
|
|
6938
|
+
// The figures, then the one sentence about them, then what they are made of.
|
|
6939
|
+
const kpis = el('div', 'mem-kpis');
|
|
6940
|
+
memTile(kpis, { label: t('EXCHANGES'), value: memFmt(dash.totals.entries), sub: memWeek(dash.weeks.entries), title: memWeekTitle(dash.weeks.entries), spark: dash.series.entries.slice(-14) });
|
|
6941
|
+
memTile(kpis, { label: t('MEMORIES'), value: memFmt(dash.totals.memories), sub: memWeek(dash.weeks.memories), title: memWeekTitle(dash.weeks.memories), spark: dash.series.memories.slice(-14) });
|
|
6942
|
+
memTile(kpis, { label: t('TURNS THAT RECALLED'), value: memFmt(dash.totals.turns), sub: memWeek(dash.weeks.recalls), title: memWeekTitle(dash.weeks.recalls), spark: dash.series.recalls.slice(-14) });
|
|
6943
|
+
memTile(kpis, { label: t('WAITING'), value: memFmt(dash.totals.pending), sub: dash.totals.pending ? t('EXCHANGES NOBODY HAS DISTILLED YET') : t('THE ARCHIVIST IS UP TO DATE') });
|
|
6944
|
+
memTile(kpis, { label: t('MATURITY'), value: memPct(m.score), sub: m.stage.label, meter: m.score, title: m.stage.says });
|
|
6945
|
+
box.append(kpis);
|
|
6946
|
+
|
|
6947
|
+
const verdict = el('div', `mem-verdict stage-${m.stage.id}`);
|
|
6948
|
+
const says = el('p', 'says');
|
|
6949
|
+
says.append(el('b', null, v.headline ?? m.stage.label), ` ${v.says}`);
|
|
6496
6950
|
const next = el('p', 'maturity-next');
|
|
6497
6951
|
next.append(el('b', null, t('NEXT')), ` ${v.next.text}`);
|
|
6498
6952
|
if (v.next.where) next.append(el('span', 'where', ` → ${v.next.where}`));
|
|
6499
|
-
|
|
6953
|
+
verdict.append(says, next);
|
|
6954
|
+
box.append(verdict);
|
|
6500
6955
|
|
|
6501
|
-
|
|
6502
|
-
|
|
6956
|
+
const grid = el('div', 'mem-grid');
|
|
6957
|
+
memActivity(memCard(grid, t('ACTIVITY · LAST {n} DAYS', { n: dash.days.length }), 'span-7'), dash);
|
|
6958
|
+
const made = memCard(grid, t('WHAT THE ARCHIVE IS MADE OF'), 'span-5');
|
|
6503
6959
|
for (const signal of m.signals) {
|
|
6504
|
-
const row = el('div', `
|
|
6505
|
-
const
|
|
6506
|
-
|
|
6507
|
-
row.append(
|
|
6960
|
+
const row = el('div', `mem-signal${signal.id === m.weakest ? ' weakest' : ''}`);
|
|
6961
|
+
const top = el('div', 'top');
|
|
6962
|
+
top.append(el('b', null, signal.label), el('span', null, memPct(signal.value)));
|
|
6963
|
+
row.append(top, memMeter(signal.value), el('span', 'detail', signal.detail));
|
|
6508
6964
|
row.title = signal.next;
|
|
6509
6965
|
made.append(row);
|
|
6510
6966
|
}
|
|
6511
|
-
|
|
6512
|
-
const
|
|
6513
|
-
|
|
6967
|
+
|
|
6968
|
+
const kinds = memCard(grid, t('BY KIND'), 'span-3');
|
|
6969
|
+
memBars(kinds, Object.entries(dash.kinds).map(([kind, n]) => ({ label: kindWord(kind), n, swatch: `var(--mem-${kind}, var(--ph))` })));
|
|
6970
|
+
const agents = memCard(grid, t('BY ARCHIVIST'), 'span-3');
|
|
6971
|
+
memBars(agents, Object.entries(dash.agents).map(([agent, n]) => ({ label: `@${agent.toUpperCase()}`, n })));
|
|
6972
|
+
const zones = memCard(grid, t('BY ZONE'), 'span-3');
|
|
6973
|
+
memBars(zones, Object.entries(dash.zones).map(([zone, n]) => ({ label: zoneWord(zone), n, title: ZONE_NOTE[zone] ?? null })));
|
|
6974
|
+
|
|
6975
|
+
const recall = memCard(grid, t('RECALL AND EMBEDDINGS'), 'span-3');
|
|
6976
|
+
const carried = dash.via.search + dash.via.cascade;
|
|
6977
|
+
if (carried) {
|
|
6978
|
+
const split = el('div', 'mem-split');
|
|
6979
|
+
split.title = t('How recalled memories arrived: found by the search, or carried along by the company they keep.');
|
|
6980
|
+
for (const [id, n] of [['search', dash.via.search], ['cascade', dash.via.cascade]]) {
|
|
6981
|
+
const part = el('i', id);
|
|
6982
|
+
part.style.flexGrow = String(n);
|
|
6983
|
+
split.append(part);
|
|
6984
|
+
}
|
|
6985
|
+
recall.append(split);
|
|
6986
|
+
const legend = el('div', 'mem-legend');
|
|
6987
|
+
for (const [id, label, n] of [['search', t('SEARCH'), dash.via.search], ['cascade', t('CASCADE'), dash.via.cascade]]) {
|
|
6988
|
+
const key = el('span', `k ${id}`);
|
|
6989
|
+
key.append(el('i'), `${label} `, el('b', null, `${memFmt(n)} · ${memPct(n / carried)}`));
|
|
6990
|
+
legend.append(key);
|
|
6991
|
+
}
|
|
6992
|
+
recall.append(legend);
|
|
6993
|
+
} else recall.append(el('p', 'note', t('NOTHING YET')));
|
|
6994
|
+
if (dash.vectors) {
|
|
6995
|
+
for (const [label, have, of] of [[t('MEMORIES WITH A VECTOR'), dash.vectors.memories, dash.totals.memories], [t('EXCHANGES WITH A VECTOR'), dash.vectors.entries, dash.totals.entries]]) {
|
|
6996
|
+
const row = el('div', 'mem-signal');
|
|
6997
|
+
const top = el('div', 'top');
|
|
6998
|
+
top.append(el('b', null, label), el('span', null, `${memFmt(have)} / ${memFmt(of)}`));
|
|
6999
|
+
row.append(top, memMeter(of ? have / of : 0));
|
|
7000
|
+
recall.append(row);
|
|
7001
|
+
}
|
|
7002
|
+
recall.append(el('span', 'detail', String(dash.vectors.model).toUpperCase()));
|
|
7003
|
+
} else recall.append(el('p', 'note', t('EMBEDDINGS OFF · RECALL MATCHES WORDS ONLY')));
|
|
7004
|
+
|
|
7005
|
+
const tests = memCard(grid, t('THE THREE TESTS'), 'span-12');
|
|
7006
|
+
tests.append(examsBlock(read.exams, { pass: dash.pass }));
|
|
7007
|
+
box.append(grid);
|
|
6514
7008
|
}
|
|
6515
7009
|
|
|
6516
7010
|
/* ---------- the three tests ---------- */
|
|
@@ -6526,7 +7020,7 @@ const EXAM_ABOUT = {
|
|
|
6526
7020
|
match: t('Real questions answered here by a frontier CLI, asked again of the local model with this archive behind it, and compared against the answer given at the time. It takes minutes and spends nothing.'),
|
|
6527
7021
|
};
|
|
6528
7022
|
|
|
6529
|
-
function examRow(id, state, run) {
|
|
7023
|
+
function examRow(id, state, run, pass = null) {
|
|
6530
7024
|
const last = state.last?.[id] ?? null;
|
|
6531
7025
|
const can = state.can?.[id] ?? { ok: false };
|
|
6532
7026
|
const row = el('div', `exam exam-${id}${last?.ran ? (last.passed ? ' passed' : ' failed') : ''}`);
|
|
@@ -6541,6 +7035,13 @@ function examRow(id, state, run) {
|
|
|
6541
7035
|
const when = last.at ? ` · ${new Date(last.at).toLocaleString()}` : '';
|
|
6542
7036
|
const how = last.ran && last.rate !== undefined ? `${Math.round(last.rate * 100)}% · ${t('{n} CASES', { n: last.n })}${last.method ? t(' · BY {method}', { method: last.method.toUpperCase() }) : ''}${last.bar ? t(' · BAR {bar}', { bar: last.bar }) : ''}${when}` : when.replace(' · ', '');
|
|
6543
7037
|
row.append(said);
|
|
7038
|
+
// The rate against the line it has to reach: the bar fills to what it scored, the mark
|
|
7039
|
+
// stands where it passes.
|
|
7040
|
+
if (last.ran && last.rate !== undefined && pass?.[id] !== undefined) {
|
|
7041
|
+
const bullet = el('div', 'exam-bullet');
|
|
7042
|
+
bullet.append(memMeter(last.rate, { mark: pass[id] }), el('span', null, `${memPct(last.rate)} · ${t('PASSES AT {n}', { n: memPct(pass[id]) })}`));
|
|
7043
|
+
row.append(bullet);
|
|
7044
|
+
}
|
|
6544
7045
|
if (how) row.append(el('p', 'note', how.toUpperCase()));
|
|
6545
7046
|
}
|
|
6546
7047
|
const actions = el('div', 'actions');
|
|
@@ -6556,12 +7057,12 @@ function examRow(id, state, run) {
|
|
|
6556
7057
|
return row;
|
|
6557
7058
|
}
|
|
6558
7059
|
|
|
6559
|
-
function examsBlock(known = null) {
|
|
7060
|
+
function examsBlock(known = null, { pass = null } = {}) {
|
|
6560
7061
|
const box = el('div', 'full exams');
|
|
6561
7062
|
const draw = (state) => {
|
|
6562
7063
|
box.replaceChildren();
|
|
6563
7064
|
box.append(el('p', 'note', t('NOTHING HERE SPENDS A PROVIDER TURN. THE FIRST TWO ARE FREE; THE THIRD TAKES MINUTES AND STOPS WHEN YOU SAY.')));
|
|
6564
|
-
for (const id of ['coverage', 'consistency', 'match']) box.append(examRow(id, state, run));
|
|
7065
|
+
for (const id of ['coverage', 'consistency', 'match']) box.append(examRow(id, state, run, pass));
|
|
6565
7066
|
if (state.running === 'match') {
|
|
6566
7067
|
const stop = el('button', null, t('STOP'));
|
|
6567
7068
|
stop.type = 'button';
|
|
@@ -6600,13 +7101,19 @@ function rememberFold(key, open) {
|
|
|
6600
7101
|
}
|
|
6601
7102
|
// One button for the whole panel. It says what it will do, not what the panel is: if anything is
|
|
6602
7103
|
// still shut, it opens everything; once everything is open it closes it again.
|
|
6603
|
-
|
|
7104
|
+
// Only the screen on show: a fold behind CONNECTIONS or MEMORY is not one the reader can see move.
|
|
7105
|
+
function everyFold() { return [...document.querySelectorAll('.mother-section:not([hidden]) .fold')]; }
|
|
6604
7106
|
function syncFoldAll() {
|
|
6605
7107
|
const button = document.querySelector('#fold-all');
|
|
6606
7108
|
if (!button) return;
|
|
6607
7109
|
const folds = everyFold();
|
|
7110
|
+
const row = button.parentElement;
|
|
7111
|
+
if (row) row.hidden = folds.length < 2;
|
|
6608
7112
|
button.hidden = folds.length < 2;
|
|
6609
|
-
|
|
7113
|
+
// It acts on the canvas below it rather than going anywhere, so it must not look like one more
|
|
7114
|
+
// destination in the bar: no frame, the voice of the fold carets it moves, at their edge.
|
|
7115
|
+
const shut = folds.some((fold) => !fold.open);
|
|
7116
|
+
button.replaceChildren(pixelIcon(shut ? 'unfold' : 'fold', 'pixel-icon'), el('span', null, shut ? t('EXPAND ALL') : t('COLLAPSE ALL')));
|
|
6610
7117
|
}
|
|
6611
7118
|
document.querySelector('#fold-all')?.addEventListener('click', () => {
|
|
6612
7119
|
const folds = everyFold();
|
|
@@ -6617,11 +7124,73 @@ document.querySelector('#fold-all')?.addEventListener('click', () => {
|
|
|
6617
7124
|
|
|
6618
7125
|
// A section that folds. A badge belongs on the header only when it says something the header
|
|
6619
7126
|
// does not: the same number twice is noise, and a mark that is always there stops being a mark.
|
|
6620
|
-
|
|
7127
|
+
// Every block of a MU/TH/UR screen. The mark in front of the title used to be the same green bar
|
|
7128
|
+
// on all of them, which told the eye there was a heading and nothing else; a glyph per section
|
|
7129
|
+
// says WHICH section without reading the words, and the rule above each block goes: a line and an
|
|
7130
|
+
// icon are two ways of saying «a new block starts here» and one of them is enough.
|
|
7131
|
+
|
|
7132
|
+
// 8-bit. Each icon is an 8×8 bitmap written as eight rows of text — `#` is a lit pixel — and
|
|
7133
|
+
// drawn as unit squares on an 8×8 viewBox with crisp edges, so it scales without ever softening
|
|
7134
|
+
// into a curve. Written this way because a pixel drawing should be readable as a pixel drawing in
|
|
7135
|
+
// the source too: you can see the icon in the string.
|
|
7136
|
+
const PIXEL_ICONS = {
|
|
7137
|
+
unfold: ['........', '#......#', '##....##', '.######.', '..####..', '...##...', '........', '........'],
|
|
7138
|
+
fold: ['........', '........', '...##...', '..####..', '.######.', '##....##', '#......#', '........'],
|
|
7139
|
+
release: ['...##...', '..####..', '.######.', '##.##.##', '...##...', '...##...', '...##...', '...##...'],
|
|
7140
|
+
alert: ['...##...', '..#..#..', '..#..#..', '.#.##.#.', '.#.##.#.', '#......#', '#..##..#', '########'],
|
|
7141
|
+
fault: ['##....##', '###..###', '.######.', '..####..', '..####..', '.######.', '###..###', '##....##'],
|
|
7142
|
+
list: ['#..#####', '........', '#..#####', '........', '#..#####', '........', '#..#####', '........'],
|
|
7143
|
+
link: ['........', '......#.', '########', '......#.', '........', '.#......', '########', '.#......'],
|
|
7144
|
+
gear: ['..#..#..', '..#..#..', '.######.', '###..###', '###..###', '.######.', '..#..#..', '..#..#..'],
|
|
7145
|
+
chip: ['..#..#..', '########', '#......#', '#.####.#', '#.####.#', '#......#', '########', '..#..#..'],
|
|
7146
|
+
lock: ['..####..', '.#....#.', '.#....#.', '########', '###..###', '###..###', '########', '........'],
|
|
7147
|
+
free: ['..####..', '.#....#.', '#....#.#', '#...#..#', '#..#...#', '#.#....#', '.#....#.', '..####..'],
|
|
7148
|
+
home: ['...##...', '..####..', '.######.', '########', '##....##', '##.##.##', '##.##.##', '##.##.##'],
|
|
7149
|
+
cut: ['#......#', '.#....#.', '..#..#..', '...##...', '..#..#..', '.#....#.', '##....##', '##....##'],
|
|
7150
|
+
bars: ['........', '######..', '........', '####....', '........', '########', '........', '###.....'],
|
|
7151
|
+
grid: ['########', '##.##.##', '########', '##.##.##', '########', '##.##.##', '########', '........'],
|
|
7152
|
+
orbit: ['..####..', '.#....#.', '#..##..#', '#.####.#', '#.####.#', '#..##..#', '.#....#.', '..####..'],
|
|
7153
|
+
star: ['...#....', '...#....', '..###...', '#######.', '..###...', '...#....', '...#....', '........'],
|
|
7154
|
+
mail: ['........', '########', '##....##', '#.#..#.#', '#..##..#', '#......#', '########', '........'],
|
|
7155
|
+
stop: ['........', '.######.', '.######.', '.######.', '.######.', '.######.', '.######.', '........'],
|
|
7156
|
+
ufo: ['........', '...##...', '..####..', '.######.', '########', '.#.##.#.', '..#..#..', '.#....#.'],
|
|
7157
|
+
branch: ['.#......', '.#...#..', '.#..###.', '.#...#..', '.#..#...', '.###....', '.#......', '.#......'],
|
|
7158
|
+
check: ['........', '.......#', '......##', '#....##.', '##..##..', '.####...', '..##....', '........'],
|
|
7159
|
+
search: ['..###...', '.#...#..', '#.....#.', '#.....#.', '.#...#..', '..####..', '......#.', '.......#'],
|
|
7160
|
+
db: ['.######.', '#......#', '.######.', '#......#', '.######.', '#......#', '.######.', '........'],
|
|
7161
|
+
bell: ['...##...', '..####..', '.######.', '.######.', '.######.', '########', '........', '...##...'],
|
|
7162
|
+
};
|
|
7163
|
+
|
|
7164
|
+
// One run of lit pixels becomes one horizontal bar of the path, so a full row is a single
|
|
7165
|
+
// rectangle and not eight. Written out as markup like every other icon in this file, which also
|
|
7166
|
+
// keeps it off `createElementNS` — the replay harness has a document without it.
|
|
7167
|
+
function pixelPath(name) {
|
|
7168
|
+
const rows = PIXEL_ICONS[name] ?? PIXEL_ICONS.list;
|
|
7169
|
+
let d = '';
|
|
7170
|
+
for (const [y, row] of rows.entries()) {
|
|
7171
|
+
let run = 0;
|
|
7172
|
+
for (let x = 0; x <= 8; x += 1) {
|
|
7173
|
+
if (row[x] === '#') { run += 1; continue; }
|
|
7174
|
+
if (run) d += `M${x - run} ${y}h${run}v1h-${run}z`;
|
|
7175
|
+
run = 0;
|
|
7176
|
+
}
|
|
7177
|
+
}
|
|
7178
|
+
return d;
|
|
7179
|
+
}
|
|
7180
|
+
|
|
7181
|
+
// The icon carries no box: a badge around it was a second frame saying what the drawing says.
|
|
7182
|
+
function pixelIcon(name, className = 'pixel-icon') {
|
|
7183
|
+
const holder = el('span', className);
|
|
7184
|
+
holder.innerHTML = `<svg viewBox="0 0 8 8" aria-hidden="true"><path d="${pixelPath(name)}" fill="currentColor"/></svg>`;
|
|
7185
|
+
return holder;
|
|
7186
|
+
}
|
|
7187
|
+
|
|
7188
|
+
function folding(section, title, { key, open = false, badge = null, icon = 'list' } = {}) {
|
|
6621
7189
|
const box = el('details', 'fold');
|
|
6622
7190
|
box.dataset.fold = key;
|
|
6623
7191
|
box.open = foldState()[key] ?? open;
|
|
6624
7192
|
const head = el('summary');
|
|
7193
|
+
head.append(pixelIcon(icon, 'pixel-icon fold-icon'));
|
|
6625
7194
|
head.append(el('h3', null, title));
|
|
6626
7195
|
if (badge) {
|
|
6627
7196
|
const mark = el('span', 'fold-badge', badge.text ?? '');
|
|
@@ -6822,6 +7391,135 @@ function connectionCard(agent) {
|
|
|
6822
7391
|
return card;
|
|
6823
7392
|
}
|
|
6824
7393
|
|
|
7394
|
+
// MEMORY, emancipated. It used to be a fold inside CONNECTIONS, which is where you go to wire an
|
|
7395
|
+
// agent up — a different errand entirely. What the archive holds, who distils it and how well it
|
|
7396
|
+
// is answering is a subject of its own, so it gets its own button in the MU/TH/UR bar, beside
|
|
7397
|
+
// NOSTROMO, and the whole panel instead of a fold inside somebody else's.
|
|
7398
|
+
function renderMemoryPanel(data) {
|
|
7399
|
+
if (!memoryUI.section) return;
|
|
7400
|
+
memoryUI.section.replaceChildren();
|
|
7401
|
+
const mem = data?.settings?.memory;
|
|
7402
|
+
if (!mem) { memoryUI.section.append(el('p', 'mother-answer', t('THIS ROOM HAS NO MEMORY INDEX.'))); return; }
|
|
7403
|
+
// It reads first and is configured second. The dashboard is what someone opens this screen to
|
|
7404
|
+
// see, so it starts open; the knobs that change how the archive is kept sit under it. All three
|
|
7405
|
+
// are folds like every other block of MU/TH/UR, so COLLAPSE ALL moves what is on the screen.
|
|
7406
|
+
const board = el('div', 'mem-dash');
|
|
7407
|
+
folding(memoryUI.section, t('MEMORY · THE ARCHIVE AT A GLANCE'), { key: 'memory-dashboard', icon: 'chip', open: true }).append(board);
|
|
7408
|
+
void drawMemoryDashboard(board);
|
|
7409
|
+
const memoryBody = folding(memoryUI.section, t('ARCHIVIST AND RECALL'), { key: 'memory-settings', icon: 'gear' });
|
|
7410
|
+
memoryBody.append(el('p', 'note', t('THE ARCHIVIST READS WHAT NOBODY HAS DISTILLED AND KEEPS THE FEW NOTES WORTH REMEMBERING. THE CHEAPEST ALLOWED AGENT GOES FIRST; A LOCAL MODEL COSTS NOTHING AND KEEPS EVERYTHING ON THIS MACHINE.')));
|
|
7411
|
+
const mform = el('form', 'room-form memory-form');
|
|
7412
|
+
const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(t('Memory setting was not saved: {error}', { error: error.message })); } };
|
|
7413
|
+
const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
|
|
7414
|
+
const archivist = el('select');
|
|
7415
|
+
for (const [value, text] of [['auto', t('AUTO · cheapest allowed')], ...mem.candidates.map((c) => [c.id, `@${c.id}${c.local ? ` · ${c.label}` : ''}`])]) { const option = el('option', null, text); option.value = value; if (value === mem.archivist) option.selected = true; archivist.append(option); }
|
|
7416
|
+
archivist.addEventListener('change', () => save({ archivist: archivist.value }, archivist.value === 'auto' ? t('archivist: the cheapest allowed agent goes first.') : t('archivist: @{agent} distils first.', { agent: archivist.value })));
|
|
7417
|
+
mform.append(field(t('ARCHIVIST'), archivist));
|
|
7418
|
+
const every = el('input'); every.type = 'number'; every.min = '1'; every.step = '1'; every.value = String(mem.every);
|
|
7419
|
+
wireInstantNumber(every, { min: 1, toPatch: (value) => ({ memory: { every: value } }), describe: (value) => t('distil every {n} exchanges.', { n: value }) });
|
|
7420
|
+
mform.append(field(t('DISTIL EVERY · EXCHANGES'), every));
|
|
7421
|
+
const idle = el('input'); idle.type = 'number'; idle.min = '1'; idle.step = '1'; idle.value = String(mem.idleMinutes);
|
|
7422
|
+
wireInstantNumber(idle, { min: 1, toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => t('or after {n} quiet minutes.', { n: value }) });
|
|
7423
|
+
mform.append(field(t('OR AFTER · QUIET MINUTES'), idle));
|
|
7424
|
+
const share = el('input'); share.type = 'number'; share.min = '0'; share.max = '60'; share.step = '5'; share.value = String(Math.round(mem.recallShare * 100));
|
|
7425
|
+
wireInstantNumber(share, { min: 0, toPatch: (value) => ({ memory: { recallShare: Math.min(60, value) / 100 } }), describe: (value) => t("recall may take {n}% of each turn's context.", { n: Math.min(60, value) }) });
|
|
7426
|
+
mform.append(field(t('RECALL · % OF CONTEXT'), share));
|
|
7427
|
+
// Spreading activation. Two memories that keep arriving in the same turn are associated by
|
|
7428
|
+
// the room's own work, not by how they read; a couple of slots in each recall are kept for
|
|
7429
|
+
// that. It is the one part of recall that owes nothing to wording, so it can be switched off.
|
|
7430
|
+
const cascade = el('label', 'toggle full');
|
|
7431
|
+
const cascadeBox = el('input'); cascadeBox.type = 'checkbox'; cascadeBox.checked = mem.cascade !== false;
|
|
7432
|
+
cascadeBox.addEventListener('change', () => save({ cascade: cascadeBox.checked }, cascadeBox.checked
|
|
7433
|
+
? t('recall also carries what a memory keeps arriving with.')
|
|
7434
|
+
: t('recall carries only what the words and the meaning match.')));
|
|
7435
|
+
cascade.append(cascadeBox, el('span', null, t('CARRY WHAT A MEMORY KEEPS ARRIVING WITH')));
|
|
7436
|
+
cascade.title = t('Two memories that keep travelling into the same turn are associated, however differently they read. Recall keeps a couple of slots for that company; the search never loses a slot to it.');
|
|
7437
|
+
mform.append(cascade);
|
|
7438
|
+
const embed = el('select');
|
|
7439
|
+
const embedOptions = [['auto', t('AUTO · Ollama if running, else Gemini')], ['ollama', `OLLAMA · ${t('local')}${mem.ollama.embedModel ? ` · ${mem.ollama.embedModel}` : t(' · no model yet')}`], ['gemini', t('GEMINI · needs your key')], ['off', t('OFF · words only')]];
|
|
7440
|
+
for (const [value, text] of embedOptions) { const option = el('option', null, text); option.value = value; if (value === (mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto')) option.selected = true; embed.append(option); }
|
|
7441
|
+
embed.addEventListener('change', () => save({ embedProvider: embed.value }, t('embeddings: {what}.', { what: embed.options[embed.selectedIndex].textContent.toLowerCase() })));
|
|
7442
|
+
const embedLabel = field(t('EMBEDDINGS · NOW {what}', { what: mem.embedder ? mem.embedder.toUpperCase() : 'OFF' }), embed);
|
|
7443
|
+
mform.append(embedLabel);
|
|
7444
|
+
const who = el('div', 'full');
|
|
7445
|
+
who.append(el('span', 'note', t('MAY DISTIL:')));
|
|
7446
|
+
const allowed = new Set(mem.archivists ?? mem.candidates.map((c) => c.id));
|
|
7447
|
+
for (const candidate of mem.candidates) {
|
|
7448
|
+
const toggle = el('label', 'toggle');
|
|
7449
|
+
const box = el('input'); box.type = 'checkbox'; box.checked = allowed.has(candidate.id);
|
|
7450
|
+
box.addEventListener('change', () => {
|
|
7451
|
+
if (box.checked) allowed.add(candidate.id); else allowed.delete(candidate.id);
|
|
7452
|
+
if (!allowed.size) { box.checked = true; allowed.add(candidate.id); toast(t('MU/TH/UR › someone has to keep the archive.')); return; }
|
|
7453
|
+
void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, t('archivists: {who}.', { who: [...allowed].map((id) => `@${id}`).join(', ') }));
|
|
7454
|
+
});
|
|
7455
|
+
toggle.append(box, `@${candidate.id.toUpperCase()}${candidate.local ? t(' · LOCAL · FREE') : ''}`);
|
|
7456
|
+
who.append(toggle);
|
|
7457
|
+
}
|
|
7458
|
+
mform.append(who);
|
|
7459
|
+
if (mem.envWins) mform.append(el('span', 'note full', t('ENVIRONMENT VARIABLES ARE SET FOR MEMORY; THEY WIN OVER THESE VALUES ON THE NEXT LAUNCH.')));
|
|
7460
|
+
mform.addEventListener('submit', (event) => event.preventDefault());
|
|
7461
|
+
memoryBody.append(mform);
|
|
7462
|
+
// The dataset behind MADRE AI and the recipe that trains on it: a separate errand from how
|
|
7463
|
+
// the archive is kept, and the longest block on this screen, so it folds on its own.
|
|
7464
|
+
const trainBody = folding(memoryUI.section, t('DATASET AND TRAINING'), { key: 'memory-train', icon: 'bars' });
|
|
7465
|
+
const tform = el('div', 'room-form memory-form');
|
|
7466
|
+
// The dataset behind MADRE AI: export what the room kept, train outside, @madre picks the result up.
|
|
7467
|
+
const dataset = el('div', 'full dataset-row');
|
|
7468
|
+
const exportButton = el('button', null, t('EXPORT DATASET'));
|
|
7469
|
+
exportButton.type = 'button';
|
|
7470
|
+
exportButton.title = t('Write train.jsonl and valid.jsonl next to the ledger, redacted, in chat format for mlx-lm');
|
|
7471
|
+
const datasetNote = el('span', 'note', t('LOADING…'));
|
|
7472
|
+
const showDataset = (payload) => {
|
|
7473
|
+
const d = payload?.dataset;
|
|
7474
|
+
const exported = d ? t('LAST EXPORT {when} · TRAIN {train} · VALID {valid}', { when: new Date(d.exportedAt).toLocaleString(), train: d.train, valid: d.valid }) : t('NOT EXPORTED YET');
|
|
7475
|
+
const trained = payload?.trained ? t('TRAINED MODEL {model} IN USE', { model: payload.trained.toUpperCase() }) : t('NO TRAINED MODEL YET · SEE docs/training');
|
|
7476
|
+
datasetNote.textContent = [exported, trained].filter(Boolean).join(' · ');
|
|
7477
|
+
};
|
|
7478
|
+
fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => { datasetNote.textContent = t('DATASET UNAVAILABLE'); });
|
|
7479
|
+
exportButton.addEventListener('click', async () => {
|
|
7480
|
+
exportButton.disabled = true;
|
|
7481
|
+
try { const payload = await fetch('/api/dataset', { method: 'POST' }).then((response) => response.json()); showDataset(payload); toast(t('MU/TH/UR › dataset exported: {n} pairs in {dir}', { n: payload.dataset.pairs, dir: payload.dir })); }
|
|
7482
|
+
catch (error) { toast(t('Dataset export failed: {error}', { error: error.message })); }
|
|
7483
|
+
finally { exportButton.disabled = false; }
|
|
7484
|
+
});
|
|
7485
|
+
dataset.append(exportButton, datasetNote);
|
|
7486
|
+
tform.append(dataset);
|
|
7487
|
+
// TRAIN: the recipe, with this room's paths and this project's model name filled in. Training runs outside MADRE.
|
|
7488
|
+
const train = el('div', 'full train-card');
|
|
7489
|
+
const trainHead = el('div', 'train-head', t('TRAIN MADRE AI · LOCAL, WITH MLX ON APPLE SILICON · NOTHING LEAVES THIS MACHINE'));
|
|
7490
|
+
const trainNote = el('p', 'note', t('EXPORT THE DATASET FIRST. EACH STEP IS ONE COMMAND FOR YOUR TERMINAL; COPY, RUN, COME BACK. WHEN THE MODEL EXISTS IN OLLAMA, @MADRE SWITCHES TO IT AT THE NEXT RECHECK AND EVERY AGENT IS TOLD TO ASK IT FIRST.'));
|
|
7491
|
+
const steps = el('ol', 'train-steps');
|
|
7492
|
+
train.append(trainHead, trainNote, steps);
|
|
7493
|
+
const renderTraining = (payload) => {
|
|
7494
|
+
// `info`, not `t`: t() is how this page speaks, and a local name that shadows it would
|
|
7495
|
+
// take the language away from everything inside this function.
|
|
7496
|
+
const info = payload?.training;
|
|
7497
|
+
steps.replaceChildren();
|
|
7498
|
+
if (!info) { steps.append(el('li', null, t('TRAINING INFO UNAVAILABLE'))); return; }
|
|
7499
|
+
const quote = (path) => `"${path.replace(/\/$/, '')}"`;
|
|
7500
|
+
const items = [
|
|
7501
|
+
[t('ONCE · A PYTHON ENVIRONMENT WITH MLX-LM'), 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'],
|
|
7502
|
+
[t('TRAIN THE LORA · BASE {model} FOR {gb} GB', { model: info.baseModel, gb: info.memoryGb }), `source ~/.madre-train/bin/activate && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${info.baseModel}`],
|
|
7503
|
+
[t('FUSE THE ADAPTER INTO THE BASE'), `source ~/.madre-train/bin/activate && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${info.baseModel} --adapter-path adapters --save-path fused`],
|
|
7504
|
+
[t('REGISTER IN OLLAMA AS {model}', { model: info.modelName }), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile`],
|
|
7505
|
+
];
|
|
7506
|
+
for (const [label, command] of items) {
|
|
7507
|
+
const li = el('li');
|
|
7508
|
+
const head = el('div', 'train-step-label', label);
|
|
7509
|
+
const row = el('div', 'update-command');
|
|
7510
|
+
const code = el('code', null, command);
|
|
7511
|
+
const copy = el('button', null, t('COPY')); copy.type = 'button';
|
|
7512
|
+
copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(command); copy.textContent = t('COPIED'); setTimeout(() => { copy.textContent = t('COPY'); }, 1400); } catch { toast(t('MU/TH/UR › select the command and copy it.')); } });
|
|
7513
|
+
row.append(code, copy);
|
|
7514
|
+
li.append(head, row);
|
|
7515
|
+
steps.append(li);
|
|
7516
|
+
}
|
|
7517
|
+
steps.append(el('li', 'note', t('QWEN NEEDS A GGUF BEFORE OLLAMA READS IT: {readme} · SECTION 3 HAS THE TWO LINES. THEN ASK @MADRE TEN THINGS THE ROOM DECIDED AND FIVE IT NEVER DISCUSSED BEFORE TRUSTING IT.', { readme: quote(`${info.recipeDir}README.md`) })));
|
|
7518
|
+
};
|
|
7519
|
+
fetch('/api/dataset').then((response) => response.json()).then(renderTraining).catch(() => renderTraining(null));
|
|
7520
|
+
tform.append(train);
|
|
7521
|
+
trainBody.append(tform);
|
|
7522
|
+
}
|
|
6825
7523
|
function renderSettings() {
|
|
6826
7524
|
const { data } = settingsUI;
|
|
6827
7525
|
if (!data) return;
|
|
@@ -6830,6 +7528,7 @@ function renderSettings() {
|
|
|
6830
7528
|
const signedIn = Object.values(data.sessions ?? {}).filter((s) => s.state === 'signed-in').length;
|
|
6831
7529
|
const out = data.agents.length - signedIn;
|
|
6832
7530
|
const crew = folding(section, t('CONNECTIONS · {n} OF {total} SIGNED IN', { n: signedIn, total: data.agents.length }) + (data.sessionsAt ? t(' · CHECKED {time}', { time: formatTime(data.sessionsAt) }) : ''), {
|
|
7531
|
+
icon: 'link',
|
|
6833
7532
|
key: 'connections',
|
|
6834
7533
|
badge: out > 0 ? { text: String(out), urgent: true, title: t('{n} agents with no session', { n: out }) } : null,
|
|
6835
7534
|
});
|
|
@@ -6839,7 +7538,7 @@ function renderSettings() {
|
|
|
6839
7538
|
for (const agent of data.agents) grid.append(connectionCard(agent));
|
|
6840
7539
|
crew.append(grid);
|
|
6841
7540
|
|
|
6842
|
-
const settingsBody = folding(section, t('ROOM SETTINGS'), { key: 'room-settings' });
|
|
7541
|
+
const settingsBody = folding(section, t('ROOM SETTINGS'), { key: 'room-settings', icon: 'gear' });
|
|
6843
7542
|
const form = el('form', 'room-form');
|
|
6844
7543
|
const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
|
|
6845
7544
|
const num = (name, value, min, step) => { const input = el('input'); input.type = 'number'; input.name = name; input.value = String(value); input.min = String(min); input.step = String(step); return input; };
|
|
@@ -6915,132 +7614,14 @@ function renderSettings() {
|
|
|
6915
7614
|
});
|
|
6916
7615
|
settingsBody.append(form);
|
|
6917
7616
|
|
|
6918
|
-
// MEMORY
|
|
6919
|
-
|
|
6920
|
-
if (mem) {
|
|
6921
|
-
const memoryBody = folding(section, `${t('MEMORY')} · ${mem.stats ? t('{entries} EXCHANGES · {memories} MEMORIES · {pending} WAITING', { entries: mem.stats.entries, memories: mem.stats.memories, pending: mem.stats.pending }) : t('NO INDEX')}`, { key: 'memory' });
|
|
6922
|
-
memoryBody.append(el('p', 'note', t('THE ARCHIVIST READS WHAT NOBODY HAS DISTILLED AND KEEPS THE FEW NOTES WORTH REMEMBERING. THE CHEAPEST ALLOWED AGENT GOES FIRST; A LOCAL MODEL COSTS NOTHING AND KEEPS EVERYTHING ON THIS MACHINE.')));
|
|
6923
|
-
const mform = el('form', 'room-form memory-form');
|
|
6924
|
-
const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(t('Memory setting was not saved: {error}', { error: error.message })); } };
|
|
6925
|
-
const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
|
|
6926
|
-
const archivist = el('select');
|
|
6927
|
-
for (const [value, text] of [['auto', t('AUTO · cheapest allowed')], ...mem.candidates.map((c) => [c.id, `@${c.id}${c.local ? ` · ${c.label}` : ''}`])]) { const option = el('option', null, text); option.value = value; if (value === mem.archivist) option.selected = true; archivist.append(option); }
|
|
6928
|
-
archivist.addEventListener('change', () => save({ archivist: archivist.value }, archivist.value === 'auto' ? t('archivist: the cheapest allowed agent goes first.') : t('archivist: @{agent} distils first.', { agent: archivist.value })));
|
|
6929
|
-
mform.append(field(t('ARCHIVIST'), archivist));
|
|
6930
|
-
const every = el('input'); every.type = 'number'; every.min = '1'; every.step = '1'; every.value = String(mem.every);
|
|
6931
|
-
wireInstantNumber(every, { min: 1, toPatch: (value) => ({ memory: { every: value } }), describe: (value) => t('distil every {n} exchanges.', { n: value }) });
|
|
6932
|
-
mform.append(field(t('DISTIL EVERY · EXCHANGES'), every));
|
|
6933
|
-
const idle = el('input'); idle.type = 'number'; idle.min = '1'; idle.step = '1'; idle.value = String(mem.idleMinutes);
|
|
6934
|
-
wireInstantNumber(idle, { min: 1, toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => t('or after {n} quiet minutes.', { n: value }) });
|
|
6935
|
-
mform.append(field(t('OR AFTER · QUIET MINUTES'), idle));
|
|
6936
|
-
const share = el('input'); share.type = 'number'; share.min = '0'; share.max = '60'; share.step = '5'; share.value = String(Math.round(mem.recallShare * 100));
|
|
6937
|
-
wireInstantNumber(share, { min: 0, toPatch: (value) => ({ memory: { recallShare: Math.min(60, value) / 100 } }), describe: (value) => t("recall may take {n}% of each turn's context.", { n: Math.min(60, value) }) });
|
|
6938
|
-
mform.append(field(t('RECALL · % OF CONTEXT'), share));
|
|
6939
|
-
// Spreading activation. Two memories that keep arriving in the same turn are associated by
|
|
6940
|
-
// the room's own work, not by how they read; a couple of slots in each recall are kept for
|
|
6941
|
-
// that. It is the one part of recall that owes nothing to wording, so it can be switched off.
|
|
6942
|
-
const cascade = el('label', 'toggle full');
|
|
6943
|
-
const cascadeBox = el('input'); cascadeBox.type = 'checkbox'; cascadeBox.checked = mem.cascade !== false;
|
|
6944
|
-
cascadeBox.addEventListener('change', () => save({ cascade: cascadeBox.checked }, cascadeBox.checked
|
|
6945
|
-
? t('recall also carries what a memory keeps arriving with.')
|
|
6946
|
-
: t('recall carries only what the words and the meaning match.')));
|
|
6947
|
-
cascade.append(cascadeBox, el('span', null, t('CARRY WHAT A MEMORY KEEPS ARRIVING WITH')));
|
|
6948
|
-
cascade.title = t('Two memories that keep travelling into the same turn are associated, however differently they read. Recall keeps a couple of slots for that company; the search never loses a slot to it.');
|
|
6949
|
-
mform.append(cascade);
|
|
6950
|
-
const embed = el('select');
|
|
6951
|
-
const embedOptions = [['auto', t('AUTO · Ollama if running, else Gemini')], ['ollama', `OLLAMA · ${t('local')}${mem.ollama.embedModel ? ` · ${mem.ollama.embedModel}` : t(' · no model yet')}`], ['gemini', t('GEMINI · needs your key')], ['off', t('OFF · words only')]];
|
|
6952
|
-
for (const [value, text] of embedOptions) { const option = el('option', null, text); option.value = value; if (value === (mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto')) option.selected = true; embed.append(option); }
|
|
6953
|
-
embed.addEventListener('change', () => save({ embedProvider: embed.value }, t('embeddings: {what}.', { what: embed.options[embed.selectedIndex].textContent.toLowerCase() })));
|
|
6954
|
-
const embedLabel = field(t('EMBEDDINGS · NOW {what}', { what: mem.embedder ? mem.embedder.toUpperCase() : 'OFF' }), embed);
|
|
6955
|
-
mform.append(embedLabel);
|
|
6956
|
-
const who = el('div', 'full');
|
|
6957
|
-
who.append(el('span', 'note', t('MAY DISTIL:')));
|
|
6958
|
-
const allowed = new Set(mem.archivists ?? mem.candidates.map((c) => c.id));
|
|
6959
|
-
for (const candidate of mem.candidates) {
|
|
6960
|
-
const toggle = el('label', 'toggle');
|
|
6961
|
-
const box = el('input'); box.type = 'checkbox'; box.checked = allowed.has(candidate.id);
|
|
6962
|
-
box.addEventListener('change', () => {
|
|
6963
|
-
if (box.checked) allowed.add(candidate.id); else allowed.delete(candidate.id);
|
|
6964
|
-
if (!allowed.size) { box.checked = true; allowed.add(candidate.id); toast(t('MU/TH/UR › someone has to keep the archive.')); return; }
|
|
6965
|
-
void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, t('archivists: {who}.', { who: [...allowed].map((id) => `@${id}`).join(', ') }));
|
|
6966
|
-
});
|
|
6967
|
-
toggle.append(box, `@${candidate.id.toUpperCase()}${candidate.local ? t(' · LOCAL · FREE') : ''}`);
|
|
6968
|
-
who.append(toggle);
|
|
6969
|
-
}
|
|
6970
|
-
mform.append(who);
|
|
6971
|
-
// The dataset behind MADRE AI: export what the room kept, train outside, @madre picks the result up.
|
|
6972
|
-
const dataset = el('div', 'full dataset-row');
|
|
6973
|
-
const exportButton = el('button', null, t('EXPORT DATASET'));
|
|
6974
|
-
exportButton.type = 'button';
|
|
6975
|
-
exportButton.title = t('Write train.jsonl and valid.jsonl next to the ledger, redacted, in chat format for mlx-lm');
|
|
6976
|
-
const datasetNote = el('span', 'note', t('LOADING…'));
|
|
6977
|
-
// How grown this archive is, read from what can actually be counted about it. It replaced a
|
|
6978
|
-
// bar that filled toward a number borrowed from somebody else's paper: a room can reach that
|
|
6979
|
-
// number and still be narrow, unjudged and lopsided, and the bar would have said it was ready.
|
|
6980
|
-
const verdict = el('div', 'maturity');
|
|
6981
|
-
const showDataset = (payload) => {
|
|
6982
|
-
const d = payload?.dataset;
|
|
6983
|
-
const exported = d ? t('LAST EXPORT {when} · TRAIN {train} · VALID {valid}', { when: new Date(d.exportedAt).toLocaleString(), train: d.train, valid: d.valid }) : t('NOT EXPORTED YET');
|
|
6984
|
-
const trained = payload?.trained ? t('TRAINED MODEL {model} IN USE', { model: payload.trained.toUpperCase() }) : t('NO TRAINED MODEL YET · SEE docs/training');
|
|
6985
|
-
datasetNote.textContent = [exported, trained].filter(Boolean).join(' · ');
|
|
6986
|
-
};
|
|
6987
|
-
fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => { datasetNote.textContent = t('DATASET UNAVAILABLE'); });
|
|
6988
|
-
exportButton.addEventListener('click', async () => {
|
|
6989
|
-
exportButton.disabled = true;
|
|
6990
|
-
try { const payload = await fetch('/api/dataset', { method: 'POST' }).then((response) => response.json()); showDataset(payload); toast(t('MU/TH/UR › dataset exported: {n} pairs in {dir}', { n: payload.dataset.pairs, dir: payload.dir })); }
|
|
6991
|
-
catch (error) { toast(t('Dataset export failed: {error}', { error: error.message })); }
|
|
6992
|
-
finally { exportButton.disabled = false; }
|
|
6993
|
-
});
|
|
6994
|
-
dataset.append(exportButton, datasetNote);
|
|
6995
|
-
mform.append(dataset);
|
|
6996
|
-
memoryBody.append(verdict);
|
|
6997
|
-
drawVerdict(verdict);
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
// TRAIN: the recipe, with this room's paths and this project's model name filled in. Training runs outside MADRE.
|
|
7001
|
-
const train = el('div', 'full train-card');
|
|
7002
|
-
const trainHead = el('div', 'train-head', t('TRAIN MADRE AI · LOCAL, WITH MLX ON APPLE SILICON · NOTHING LEAVES THIS MACHINE'));
|
|
7003
|
-
const trainNote = el('p', 'note', t('EXPORT THE DATASET FIRST. EACH STEP IS ONE COMMAND FOR YOUR TERMINAL; COPY, RUN, COME BACK. WHEN THE MODEL EXISTS IN OLLAMA, @MADRE SWITCHES TO IT AT THE NEXT RECHECK AND EVERY AGENT IS TOLD TO ASK IT FIRST.'));
|
|
7004
|
-
const steps = el('ol', 'train-steps');
|
|
7005
|
-
train.append(trainHead, trainNote, steps);
|
|
7006
|
-
const renderTraining = (payload) => {
|
|
7007
|
-
// `info`, not `t`: t() is how this page speaks, and a local name that shadows it would
|
|
7008
|
-
// take the language away from everything inside this function.
|
|
7009
|
-
const info = payload?.training;
|
|
7010
|
-
steps.replaceChildren();
|
|
7011
|
-
if (!info) { steps.append(el('li', null, t('TRAINING INFO UNAVAILABLE'))); return; }
|
|
7012
|
-
const quote = (path) => `"${path.replace(/\/$/, '')}"`;
|
|
7013
|
-
const items = [
|
|
7014
|
-
[t('ONCE · A PYTHON ENVIRONMENT WITH MLX-LM'), 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'],
|
|
7015
|
-
[t('TRAIN THE LORA · BASE {model} FOR {gb} GB', { model: info.baseModel, gb: info.memoryGb }), `source ~/.madre-train/bin/activate && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${info.baseModel}`],
|
|
7016
|
-
[t('FUSE THE ADAPTER INTO THE BASE'), `source ~/.madre-train/bin/activate && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${info.baseModel} --adapter-path adapters --save-path fused`],
|
|
7017
|
-
[t('REGISTER IN OLLAMA AS {model}', { model: info.modelName }), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile`],
|
|
7018
|
-
];
|
|
7019
|
-
for (const [label, command] of items) {
|
|
7020
|
-
const li = el('li');
|
|
7021
|
-
const head = el('div', 'train-step-label', label);
|
|
7022
|
-
const row = el('div', 'update-command');
|
|
7023
|
-
const code = el('code', null, command);
|
|
7024
|
-
const copy = el('button', null, t('COPY')); copy.type = 'button';
|
|
7025
|
-
copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(command); copy.textContent = t('COPIED'); setTimeout(() => { copy.textContent = t('COPY'); }, 1400); } catch { toast(t('MU/TH/UR › select the command and copy it.')); } });
|
|
7026
|
-
row.append(code, copy);
|
|
7027
|
-
li.append(head, row);
|
|
7028
|
-
steps.append(li);
|
|
7029
|
-
}
|
|
7030
|
-
steps.append(el('li', 'note', t('QWEN NEEDS A GGUF BEFORE OLLAMA READS IT: {readme} · SECTION 3 HAS THE TWO LINES. THEN ASK @MADRE TEN THINGS THE ROOM DECIDED AND FIVE IT NEVER DISCUSSED BEFORE TRUSTING IT.', { readme: quote(`${info.recipeDir}README.md`) })));
|
|
7031
|
-
};
|
|
7032
|
-
fetch('/api/dataset').then((response) => response.json()).then(renderTraining).catch(() => renderTraining(null));
|
|
7033
|
-
mform.append(train);
|
|
7034
|
-
if (mem.envWins) mform.append(el('span', 'note full', t('ENVIRONMENT VARIABLES ARE SET FOR MEMORY; THEY WIN OVER THESE VALUES ON THE NEXT LAUNCH.')));
|
|
7035
|
-
mform.addEventListener('submit', (event) => event.preventDefault());
|
|
7036
|
-
memoryBody.append(mform);
|
|
7037
|
-
}
|
|
7617
|
+
// MEMORY has its own panel now, rendered from this same answer.
|
|
7618
|
+
renderMemoryPanel(data);
|
|
7038
7619
|
|
|
7039
7620
|
// PRIVACY: terms that never travel through the room. Replaced at every hop: agent replies,
|
|
7040
7621
|
// the index, the notes, the dataset. PURGE does the same to what the room already holds.
|
|
7041
7622
|
const priv = data.settings.privacy;
|
|
7042
7623
|
if (priv) {
|
|
7043
|
-
const privacyBody = folding(section, `PRIVACY · ${priv.terms.length ? t('{n} PRIVATE TERMS', { n: priv.terms.length }) : t('NO PRIVATE TERMS')}`, { key: 'privacy' });
|
|
7624
|
+
const privacyBody = folding(section, `PRIVACY · ${priv.terms.length ? t('{n} PRIVATE TERMS', { n: priv.terms.length }) : t('NO PRIVATE TERMS')}`, { key: 'privacy', icon: 'lock' });
|
|
7044
7625
|
privacyBody.append(el('p', 'note', t('AN AGENT\'S OWN CONFIGURATION CAN LEAK INTO ITS REPLY: A COMPANY, A BRAND, A DOMAIN. NAME THEM HERE AND MADRE REPLACES THEM BEFORE THE LEDGER, THE ARCHIVIST, THE OTHER AGENTS OR THE DATASET SEE THEM. THE TERMS STAY IN CONFIG.JSON; THE ROOM ONLY EVER RECORDS HOW MANY.')));
|
|
7045
7626
|
const pform = el('form', 'room-form privacy-form');
|
|
7046
7627
|
const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
|
|
@@ -7341,6 +7922,13 @@ function paintLegend() {
|
|
|
7341
7922
|
chip.title = `${DWARF_CLASS[kind]} · ${kindWord(kind)}`;
|
|
7342
7923
|
chips.push(chip);
|
|
7343
7924
|
}
|
|
7925
|
+
// The zones do not get a palette of their own: the hue on a star is its kind and the chips above
|
|
7926
|
+
// say so. What a zone changes is whether the star is lit, which is one more thing to name here
|
|
7927
|
+
// rather than a second legend to read.
|
|
7928
|
+
const dim = el('span', 'k dim');
|
|
7929
|
+
dim.append(dwarfChip(hexMix(MEMORY_COLORS.fact, '#0b0f16', 0.52), 14), el('i', null, t('OUT OF CIRCULATION')));
|
|
7930
|
+
dim.title = t('A faded star is in MED BAY or JETTISONED: the archive keeps it and never hands it to a turn.');
|
|
7931
|
+
chips.push(dim);
|
|
7344
7932
|
host.replaceChildren(...chips);
|
|
7345
7933
|
host.dataset.lit = 'yes';
|
|
7346
7934
|
}
|
|
@@ -7487,7 +8075,7 @@ function buildNostromo(data) {
|
|
|
7487
8075
|
const angle = (sector / kinds.length) * Math.PI * 2 + ((index % 7) / 7 - 0.5) * (Math.PI / 2.4) + Math.random() * 0.2;
|
|
7488
8076
|
const distance = 0.42 + Math.random() * 0.5;
|
|
7489
8077
|
const span = Math.max(1, (memory.throughSequence ?? 0) - (memory.fromSequence ?? 0));
|
|
7490
|
-
return { memory, cold: memory.cold ?? null, angle, distance, activity: activityOf(raw[index], top), lit: 0, charge: 0, x: 0, y: 0, vx: 0, vy: 0, r: MEMORY_SCALE * (7 + Math.min(11, Math.log2(span + 1) * 2.2 + memory.sources.length * 0.6)), scale: 1, seed: Math.random() * Math.PI * 2, rate: 0.5 + Math.random() * 0.9, color: MEMORY_COLORS[memory.kind] ?? MEMORY_COLORS.fact, placed: false };
|
|
8078
|
+
return { memory, cold: memory.cold ?? null, angle, distance, activity: activityOf(raw[index], top), lit: 0, charge: 0, x: 0, y: 0, vx: 0, vy: 0, r: MEMORY_SCALE * (7 + Math.min(11, Math.log2(span + 1) * 2.2 + memory.sources.length * 0.6)), scale: 1, seed: Math.random() * Math.PI * 2, rate: 0.5 + Math.random() * 0.9, color: travels(memory) ? (MEMORY_COLORS[memory.kind] ?? MEMORY_COLORS.fact) : hexMix(MEMORY_COLORS[memory.kind] ?? MEMORY_COLORS.fact, '#0b0f16', 0.52), placed: false };
|
|
7491
8079
|
});
|
|
7492
8080
|
const stats = data.stats ?? {};
|
|
7493
8081
|
const alive = memories.filter((memory) => Number(memory.recalled ?? 0) > 0).length;
|
|
@@ -7515,6 +8103,328 @@ function buildNostromo(data) {
|
|
|
7515
8103
|
// camera maps the world to the canvas. The camera follows the whole system
|
|
7516
8104
|
// until the human takes the wheel (drag, wheel), and RECENTER hands it back.
|
|
7517
8105
|
const NOSTROMO_ORBIT = 300;
|
|
8106
|
+
|
|
8107
|
+
// The archive is not a ball. It is the shape of the thing that holds it, seen from the side:
|
|
8108
|
+
// a brain, facing left. One function says how far the shell is at a given bearing, and BOTH the
|
|
8109
|
+
// first placement and the force that holds the system use it — which is the whole trick. The
|
|
8110
|
+
// ring force used to pull every memory onto a circle, so any shape given at placement was undone
|
|
8111
|
+
// within a second or two of physics. Shape the target and the shape is what the system settles
|
|
8112
|
+
// into instead of what it drifts away from.
|
|
8113
|
+
//
|
|
8114
|
+
// Bearings are canvas bearings: 0 points right (occipital, the back of the head), PI/2 points
|
|
8115
|
+
// DOWN (temporal lobe and cerebellum), PI points left (frontal pole), 3PI/2 points up (parietal,
|
|
8116
|
+
// the crown).
|
|
8117
|
+
function brainRadius(angle) {
|
|
8118
|
+
const a = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
|
8119
|
+
const cos = Math.cos(a);
|
|
8120
|
+
const sin = Math.sin(a);
|
|
8121
|
+
// Wider than tall, which is what stops it reading as a circle before anything else is added.
|
|
8122
|
+
let r = 1 / Math.hypot(cos / 1.0, sin / 0.78);
|
|
8123
|
+
// The frontal pole is blunt and sits a little forward of a pure ellipse.
|
|
8124
|
+
r += 0.07 * Math.max(0, -cos) ** 2;
|
|
8125
|
+
// The base is flat: a brain rests on something. Everything below the midline comes in, most
|
|
8126
|
+
// of all straight down.
|
|
8127
|
+
r -= 0.13 * Math.max(0, sin) ** 1.6;
|
|
8128
|
+
// The temporal lobe hangs at the lower front, separated from the frontal lobe above it.
|
|
8129
|
+
r += 0.09 * Math.exp(-(((a - 2.45) / 0.42) ** 2));
|
|
8130
|
+
// And the cerebellum is its own smaller mass at the lower back.
|
|
8131
|
+
r += 0.12 * Math.exp(-(((a - 0.95) / 0.34) ** 2));
|
|
8132
|
+
return r;
|
|
8133
|
+
}
|
|
8134
|
+
|
|
8135
|
+
// The longitudinal fissure, the groove that makes two hemispheres out of one mass. It runs
|
|
8136
|
+
// front to back down the middle, so it lies in depth: invisible from the side, plain the moment
|
|
8137
|
+
// the brain is turned to face you. A density gap rather than a wall, half as wide as this, in
|
|
8138
|
+
// orbit units: memories are nudged out of it and never across it.
|
|
8139
|
+
const FISSURE = 0.07;
|
|
8140
|
+
|
|
8141
|
+
// ---- The third dimension.
|
|
8142
|
+
//
|
|
8143
|
+
// The archive is a body, not a drawing of one. Every memory lives at (wx, wy, wz): the outline
|
|
8144
|
+
// above is the brain seen from the side, and BRAIN_WIDTH says how far it reaches toward each
|
|
8145
|
+
// temple. The physics runs there. Each frame the whole body is turned by the human's hand and
|
|
8146
|
+
// projected, and the projection is written into x and y, so everything that draws, links,
|
|
8147
|
+
// sends pulses or answers a click keeps working on the screen it always worked on.
|
|
8148
|
+
const BRAIN_WIDTH = 0.84; // ear to ear, against front to back
|
|
8149
|
+
const VIEW_DISTANCE = 1500; // world units from the eye to MOTHER: enough to read depth, not to distort
|
|
8150
|
+
const PITCH_LIMIT = 1.1; // the head tips forward and back, never over
|
|
8151
|
+
const TURN_PER_PIXEL = 0.008;
|
|
8152
|
+
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
|
8153
|
+
// How deep under the surface a memory sits. Thin, on purpose: a thick shell fills the outline in
|
|
8154
|
+
// and the brain becomes a disc; a thin one leaves the silhouette to the memories along its edge.
|
|
8155
|
+
const shellDepth = (node) => 1.12 + node.distance * 0.18;
|
|
8156
|
+
const frac = (value) => value - Math.floor(value);
|
|
8157
|
+
|
|
8158
|
+
// ---- Forgetting.
|
|
8159
|
+
//
|
|
8160
|
+
// A memory that is forgotten is not switched off: the universe takes it. It swells once, a last
|
|
8161
|
+
// breath; a horizon opens where it was, light bending round its rim; what it was made of falls
|
|
8162
|
+
// in, spiralling faster the closer it gets and drawn out into threads; the body collapses, there
|
|
8163
|
+
// is one point of light as it goes, and a faint ring runs out into the dark and is gone. Its
|
|
8164
|
+
// links do not snap. They come apart into dust, from its end outward like a wave, and the dust
|
|
8165
|
+
// drifts off into the field, glinting, and goes out. Aberrations go the same way.
|
|
8166
|
+
const IMPLOSION = 2.4; // seconds, from the last breath to nothing
|
|
8167
|
+
const DUST_CAP = 1400; // a ceiling, so forgetting a hub does not cost a frame
|
|
8168
|
+
const DEBRIS = 40;
|
|
8169
|
+
|
|
8170
|
+
// How big the body still is, 0..1 of the way through: a swell, then a collapse that accelerates.
|
|
8171
|
+
function implosionScale(p) {
|
|
8172
|
+
if (p < 0.12) return 1 + 0.22 * Math.sin((p / 0.12) * (Math.PI / 2));
|
|
8173
|
+
if (p < 0.62) return 1.22 * (1 - ((p - 0.12) / 0.5) ** 2.2);
|
|
8174
|
+
return 0;
|
|
8175
|
+
}
|
|
8176
|
+
|
|
8177
|
+
// Points along a wire, as the curve it is drawn with, with where along it each one is.
|
|
8178
|
+
function wireDust(x0, y0, cx, cy, x1, y1, from, to, born, count) {
|
|
8179
|
+
const out = [];
|
|
8180
|
+
for (let i = 0; i < count; i += 1) {
|
|
8181
|
+
const u = (i + Math.random()) / count;
|
|
8182
|
+
const at = alongCurve(x0, y0, cx, cy, x1, y1, u);
|
|
8183
|
+
const drift = 0.25 + Math.random() * 0.55;
|
|
8184
|
+
const heading = Math.random() * Math.PI * 2;
|
|
8185
|
+
out.push({
|
|
8186
|
+
x: at.x, y: at.y, vx: Math.cos(heading) * drift, vy: Math.sin(heading) * drift,
|
|
8187
|
+
// The wave runs from the forgotten end outward: dust is born where it has reached.
|
|
8188
|
+
born: born + 0.15 + 0.55 * u, life: 1.4 + Math.random() * 1.8,
|
|
8189
|
+
size: 0.6 + Math.random() * 1.3, colour: hexMix(from, to, u), phase: Math.random() * 6.3,
|
|
8190
|
+
});
|
|
8191
|
+
}
|
|
8192
|
+
return out;
|
|
8193
|
+
}
|
|
8194
|
+
|
|
8195
|
+
function beginImplosion(node, t) {
|
|
8196
|
+
node.forgetting = { at: t };
|
|
8197
|
+
node.debris = Array.from({ length: DEBRIS }, () => ({ a: Math.random() * Math.PI * 2, d: 1.4 + Math.random() * 2.8, w: 0.5 + Math.random() * 1.5, s: 0.6 + Math.random() * 0.9 }));
|
|
8198
|
+
const id = node.memory.id;
|
|
8199
|
+
const byId = new Map(nostromo.nodes.map((other) => [other.memory.id, other]));
|
|
8200
|
+
const dust = nostromo.dust ?? (nostromo.dust = []);
|
|
8201
|
+
const fading = nostromo.fading ?? (nostromo.fading = []);
|
|
8202
|
+
const wires = [];
|
|
8203
|
+
for (const link of nostromo.links) {
|
|
8204
|
+
if (link.a !== id && link.b !== id) continue;
|
|
8205
|
+
const other = byId.get(link.a === id ? link.b : link.a);
|
|
8206
|
+
if (!other) continue;
|
|
8207
|
+
wires.push({ x0: node.x, y0: node.y, cx: link.cx ?? (node.x + other.x) / 2, cy: link.cy ?? (node.y + other.y) / 2, x1: other.x, y1: other.y, from: node.color, to: other.color });
|
|
8208
|
+
}
|
|
8209
|
+
// The wire from MOTHER, too: it comes apart from the memory's end back toward her.
|
|
8210
|
+
if (node.rimX !== undefined) wires.push({ x0: node.x, y0: node.y, cx: node.cx ?? node.x / 2, cy: node.cy ?? node.y / 2, x1: node.rimX, y1: node.rimY, from: node.color, to: '#e22816' });
|
|
8211
|
+
for (const wire of wires) {
|
|
8212
|
+
const length = Math.hypot(wire.x1 - wire.x0, wire.y1 - wire.y0);
|
|
8213
|
+
const count = Math.max(8, Math.min(60, Math.round(length / 9)));
|
|
8214
|
+
if (dust.length < DUST_CAP) dust.push(...wireDust(wire.x0, wire.y0, wire.cx, wire.cy, wire.x1, wire.y1, wire.from, wire.to, t, count));
|
|
8215
|
+
fading.push({ ...wire, born: t });
|
|
8216
|
+
}
|
|
8217
|
+
nostromo.links = nostromo.links.filter((link) => link.a !== id && link.b !== id);
|
|
8218
|
+
nostromo.witness = node;
|
|
8219
|
+
}
|
|
8220
|
+
|
|
8221
|
+
function drawImplosion(ctx, node, t, scale, reduced) {
|
|
8222
|
+
const p = Math.min(1, (t - node.forgetting.at) / IMPLOSION);
|
|
8223
|
+
const R0 = node.r;
|
|
8224
|
+
const x = node.x;
|
|
8225
|
+
const y = node.y;
|
|
8226
|
+
const px = 1 / scale;
|
|
8227
|
+
ctx.save();
|
|
8228
|
+
// The horizon: a disc of nothing that opens as the body begins to go and closes after it.
|
|
8229
|
+
const open = p < 0.38 ? smooth(0.06, 0.38, p) : 1 - smooth(0.5, 0.82, p);
|
|
8230
|
+
if (open > 0.01) {
|
|
8231
|
+
const hole = R0 * 0.95 * open;
|
|
8232
|
+
const dark = ctx.createRadialGradient(x, y, 0, x, y, hole * 1.15);
|
|
8233
|
+
dark.addColorStop(0, 'rgba(0, 0, 0, 1)');
|
|
8234
|
+
dark.addColorStop(0.82, 'rgba(0, 0, 0, .96)');
|
|
8235
|
+
dark.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
8236
|
+
ctx.fillStyle = dark;
|
|
8237
|
+
ctx.beginPath(); ctx.arc(x, y, hole * 1.15, 0, Math.PI * 2); ctx.fill();
|
|
8238
|
+
// Light bent round its rim, in arcs that turn and do not quite close.
|
|
8239
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
8240
|
+
ctx.lineCap = 'round';
|
|
8241
|
+
for (let k = 0; k < 3; k += 1) {
|
|
8242
|
+
const from = (reduced ? 0 : t * (2.4 + k * 0.7)) + k * 2.1;
|
|
8243
|
+
ctx.strokeStyle = k === 1 ? hexAlpha(node.color, 0.7 * open) : `rgba(214, 228, 255, ${0.55 * open})`;
|
|
8244
|
+
ctx.lineWidth = (1.6 - 0.35 * k) * px;
|
|
8245
|
+
ctx.beginPath(); ctx.arc(x, y, hole * (1.06 + 0.05 * k), from, from + 1.6 + 0.6 * Math.sin(t + k)); ctx.stroke();
|
|
8246
|
+
}
|
|
8247
|
+
}
|
|
8248
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
8249
|
+
// What it was made of, falling in: faster the closer it gets, drawn out into threads.
|
|
8250
|
+
if (!reduced && p < 0.66) {
|
|
8251
|
+
const fall = Math.min(1, p / 0.62);
|
|
8252
|
+
const where = (bit, f) => {
|
|
8253
|
+
const reach = R0 * bit.d * (1 - f) ** 1.4;
|
|
8254
|
+
const angle = bit.a + (bit.w * f * 3.2) / (0.12 + (1 - f));
|
|
8255
|
+
return [x + Math.cos(angle) * reach, y + Math.sin(angle) * reach];
|
|
8256
|
+
};
|
|
8257
|
+
ctx.lineCap = 'round';
|
|
8258
|
+
for (const bit of node.debris ?? []) {
|
|
8259
|
+
const f = Math.min(1, fall * bit.s + fall * (1 - bit.s) * fall);
|
|
8260
|
+
const alpha = Math.min(1, f * 7) * (1 - smooth(0.82, 1, f));
|
|
8261
|
+
if (alpha <= 0.01) continue;
|
|
8262
|
+
const [ax, ay] = where(bit, Math.max(0, f - 0.05));
|
|
8263
|
+
const [bx, by] = where(bit, f);
|
|
8264
|
+
ctx.strokeStyle = hexAlpha(hexMix(node.color, '#ffffff', f), 0.85 * alpha);
|
|
8265
|
+
ctx.lineWidth = (0.8 + 1.2 * f) * px;
|
|
8266
|
+
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
|
|
8267
|
+
}
|
|
8268
|
+
}
|
|
8269
|
+
// The point of light as it goes, and the ring that runs out into the dark.
|
|
8270
|
+
const gone = p - 0.6;
|
|
8271
|
+
if (gone > 0 && gone < 0.2) {
|
|
8272
|
+
const flash = (1 - gone / 0.2) ** 2;
|
|
8273
|
+
const glow = ctx.createRadialGradient(x, y, 0, x, y, R0 * (0.6 + gone * 14));
|
|
8274
|
+
glow.addColorStop(0, `rgba(255, 255, 255, ${flash})`);
|
|
8275
|
+
glow.addColorStop(0.25, hexAlpha(hexMix(node.color, '#ffffff', 0.6), 0.6 * flash));
|
|
8276
|
+
glow.addColorStop(1, hexAlpha(node.color, 0));
|
|
8277
|
+
ctx.fillStyle = glow;
|
|
8278
|
+
ctx.beginPath(); ctx.arc(x, y, R0 * (0.6 + gone * 14), 0, Math.PI * 2); ctx.fill();
|
|
8279
|
+
}
|
|
8280
|
+
if (gone > 0) {
|
|
8281
|
+
const q = gone / 0.4;
|
|
8282
|
+
ctx.strokeStyle = `rgba(206, 222, 255, ${0.32 * (1 - q) ** 1.5})`;
|
|
8283
|
+
ctx.lineWidth = (1.4 * (1 - q) + 0.3) * px;
|
|
8284
|
+
ctx.beginPath(); ctx.arc(x, y, R0 * (1 + q * 16), 0, Math.PI * 2); ctx.stroke();
|
|
8285
|
+
}
|
|
8286
|
+
ctx.restore();
|
|
8287
|
+
}
|
|
8288
|
+
|
|
8289
|
+
// The links coming apart: what is left of each wire, beyond the wave, and the dust behind it.
|
|
8290
|
+
function drawDust(ctx, t, scale) {
|
|
8291
|
+
const px = 1 / scale;
|
|
8292
|
+
ctx.save();
|
|
8293
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
8294
|
+
for (const wire of nostromo.fading ?? []) {
|
|
8295
|
+
const age = t - wire.born;
|
|
8296
|
+
const wave = Math.min(1, Math.max(0, (age - 0.15) / 0.55));
|
|
8297
|
+
const fade = 1 - smooth(0.5, 1.2, age);
|
|
8298
|
+
if (fade <= 0 || wave >= 1) continue;
|
|
8299
|
+
const line = ctx.createLinearGradient(wire.x0, wire.y0, wire.x1, wire.y1);
|
|
8300
|
+
line.addColorStop(0, hexAlpha(wire.from, 0));
|
|
8301
|
+
line.addColorStop(Math.min(0.999, wave + 0.001), hexAlpha(wire.from, 0));
|
|
8302
|
+
line.addColorStop(Math.min(1, wave + 0.06), hexAlpha(hexMix(wire.from, wire.to, wave), 0.55 * fade));
|
|
8303
|
+
line.addColorStop(1, hexAlpha(wire.to, 0.45 * fade));
|
|
8304
|
+
ctx.strokeStyle = line;
|
|
8305
|
+
ctx.lineWidth = 0.9 * px;
|
|
8306
|
+
ctx.beginPath(); ctx.moveTo(wire.x0, wire.y0); ctx.quadraticCurveTo(wire.cx, wire.cy, wire.x1, wire.y1); ctx.stroke();
|
|
8307
|
+
}
|
|
8308
|
+
for (const mote of nostromo.dust ?? []) {
|
|
8309
|
+
const age = t - mote.born;
|
|
8310
|
+
if (age < 0 || age >= mote.life) continue;
|
|
8311
|
+
const glint = 0.55 + 0.45 * Math.sin(t * 9 + mote.phase);
|
|
8312
|
+
const alpha = Math.min(1, age * 8) * (1 - age / mote.life) ** 1.5 * glint;
|
|
8313
|
+
if (alpha <= 0.01) continue;
|
|
8314
|
+
ctx.fillStyle = hexAlpha(mote.colour, Math.min(1, alpha));
|
|
8315
|
+
ctx.beginPath(); ctx.arc(mote.x, mote.y, mote.size * px, 0, Math.PI * 2); ctx.fill();
|
|
8316
|
+
}
|
|
8317
|
+
ctx.restore();
|
|
8318
|
+
}
|
|
8319
|
+
|
|
8320
|
+
// ---- The sight.
|
|
8321
|
+
//
|
|
8322
|
+
// What marks a dwarf under the pointer and a dwarf that has been chosen: a gunsight, the way a
|
|
8323
|
+
// fighter's head-up display draws one. Four corner brackets that close in on the target when it
|
|
8324
|
+
// is chosen, a broken ring turning around it, range ticks at the four bearings and the lock
|
|
8325
|
+
// written beside it. The pointer gets the brackets only, dimmer: a sight that is looking, not
|
|
8326
|
+
// one that has locked. Drawn in the world but sized in screen pixels, so it reads the same at
|
|
8327
|
+
// every zoom.
|
|
8328
|
+
const HUD_GREEN = '57, 255, 110';
|
|
8329
|
+
// Written out here and not inside the renderer, where `t` is the clock and not the language.
|
|
8330
|
+
const lockedLabel = (memory) => `${t('TARGET LOCKED')} · #${memory.id}`;
|
|
8331
|
+
function drawSight(ctx, x, y, radius, t, scale, { locked = false, age = 1, label = '', still = false } = {}) {
|
|
8332
|
+
const px = 1 / scale;
|
|
8333
|
+
const close = locked ? 1 - (1 - Math.min(1, age / 0.35)) ** 3 : 1;
|
|
8334
|
+
const R = radius + 9 * px + (1 - close) * 46 * px;
|
|
8335
|
+
const blink = locked && age < 0.45 ? (Math.floor(age * 14) % 2 ? 0.35 : 1) : 1;
|
|
8336
|
+
const alpha = (locked ? 0.95 : 0.5) * blink;
|
|
8337
|
+
const arm = Math.max(7 * px, R * 0.34);
|
|
8338
|
+
ctx.save();
|
|
8339
|
+
ctx.lineCap = 'square';
|
|
8340
|
+
ctx.shadowColor = `rgba(${HUD_GREEN}, .7)`;
|
|
8341
|
+
ctx.shadowBlur = locked ? 6 : 3;
|
|
8342
|
+
ctx.strokeStyle = `rgba(${HUD_GREEN}, ${alpha})`;
|
|
8343
|
+
ctx.lineWidth = (locked ? 1.6 : 1.1) * px;
|
|
8344
|
+
ctx.beginPath();
|
|
8345
|
+
for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
|
|
8346
|
+
ctx.moveTo(x + sx * R, y + sy * (R - arm));
|
|
8347
|
+
ctx.lineTo(x + sx * R, y + sy * R);
|
|
8348
|
+
ctx.lineTo(x + sx * (R - arm), y + sy * R);
|
|
8349
|
+
}
|
|
8350
|
+
ctx.stroke();
|
|
8351
|
+
if (locked) {
|
|
8352
|
+
// The broken ring, turning; and the ticks at the four bearings.
|
|
8353
|
+
const ring = R * 1.22;
|
|
8354
|
+
const spin = still ? 0 : t * 0.6;
|
|
8355
|
+
ctx.lineWidth = 1.1 * px;
|
|
8356
|
+
ctx.strokeStyle = `rgba(${HUD_GREEN}, ${0.7 * blink})`;
|
|
8357
|
+
for (let k = 0; k < 4; k += 1) {
|
|
8358
|
+
const from = spin + k * (Math.PI / 2) + 0.22;
|
|
8359
|
+
ctx.beginPath(); ctx.arc(x, y, ring, from, from + Math.PI / 2 - 0.44); ctx.stroke();
|
|
8360
|
+
}
|
|
8361
|
+
ctx.beginPath();
|
|
8362
|
+
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
|
|
8363
|
+
ctx.moveTo(x + dx * ring * 1.08, y + dy * ring * 1.08);
|
|
8364
|
+
ctx.lineTo(x + dx * (ring * 1.08 + 9 * px), y + dy * (ring * 1.08 + 9 * px));
|
|
8365
|
+
}
|
|
8366
|
+
ctx.stroke();
|
|
8367
|
+
if (label) {
|
|
8368
|
+
ctx.shadowBlur = 0;
|
|
8369
|
+
ctx.font = `${10 * px}px ${getComputedStyle(nostromo.canvas).getPropertyValue('--mono') || 'monospace'}`;
|
|
8370
|
+
ctx.fillStyle = `rgba(${HUD_GREEN}, ${0.9 * blink})`;
|
|
8371
|
+
ctx.fillText(label, x + R + 6 * px, y - R + 3 * px);
|
|
8372
|
+
}
|
|
8373
|
+
}
|
|
8374
|
+
ctx.restore();
|
|
8375
|
+
}
|
|
8376
|
+
|
|
8377
|
+
// ---- Focus.
|
|
8378
|
+
//
|
|
8379
|
+
// Choosing a dwarf turns the archive in the hand until that dwarf faces you, brings the camera
|
|
8380
|
+
// to it and lets everything else fall back into the dark, so the one being read is the one that
|
|
8381
|
+
// is seen. Dragging while it is held hands the turning back to the human; letting it go brings
|
|
8382
|
+
// the rest of the archive back up.
|
|
8383
|
+
const FOCUS_TURN = 0.07; // how much of the remaining turn is taken each frame
|
|
8384
|
+
const FOCUS_VEIL = 0.7; // how far everything else falls back
|
|
8385
|
+
|
|
8386
|
+
function focusNostromo(dt, t) {
|
|
8387
|
+
// A memory being forgotten keeps the stage until it is gone: the dark stays down around it.
|
|
8388
|
+
const witness = nostromo.witness?.forgetting && nostromo.nodes.includes(nostromo.witness) ? nostromo.witness : null;
|
|
8389
|
+
const held = (nostromo.selected && nostromo.nodes.includes(nostromo.selected) ? nostromo.selected : null) ?? witness;
|
|
8390
|
+
const lock = nostromo.lock ?? (nostromo.lock = { node: null, at: 0, fade: 0, aim: false });
|
|
8391
|
+
if (held && held !== lock.node) { lock.node = held; lock.at = t; lock.aim = held === nostromo.selected; }
|
|
8392
|
+
if (held?.forgetting) lock.aim = false;
|
|
8393
|
+
lock.fade += ((held ? 1 : 0) - lock.fade) * Math.min(1, 0.12 * dt);
|
|
8394
|
+
if (!held) { lock.aim = false; if (lock.fade < 0.01) lock.node = null; }
|
|
8395
|
+
if (!lock.aim || !held || held.wx === undefined) return;
|
|
8396
|
+
// The turn that brings this dwarf to face the eye: about the vertical axis until it sits on
|
|
8397
|
+
// the line of sight, then tipped until it is level with it.
|
|
8398
|
+
const across = Math.hypot(held.wx, held.wz) || 1;
|
|
8399
|
+
const yawTo = Math.atan2(-held.wx, held.wz);
|
|
8400
|
+
const pitchTo = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, Math.atan2(held.wy, across)));
|
|
8401
|
+
const yaw = nostromo.yaw ?? 0;
|
|
8402
|
+
const short = ((yawTo - yaw + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
|
|
8403
|
+
const step = Math.min(1, FOCUS_TURN * dt);
|
|
8404
|
+
nostromo.yaw = yaw + short * step;
|
|
8405
|
+
nostromo.pitch = (nostromo.pitch ?? 0) + (pitchTo - (nostromo.pitch ?? 0)) * step;
|
|
8406
|
+
}
|
|
8407
|
+
|
|
8408
|
+
function projectNostromo() {
|
|
8409
|
+
const yaw = nostromo.yaw ?? 0;
|
|
8410
|
+
const pitch = nostromo.pitch ?? 0;
|
|
8411
|
+
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
|
|
8412
|
+
for (const node of nostromo.nodes) {
|
|
8413
|
+
if (node.wx === undefined) { node.wx = node.x; node.wy = node.y; node.wz = 0; }
|
|
8414
|
+
node.r3 ??= node.r;
|
|
8415
|
+
// Turned about the vertical axis, then tipped about the horizontal one: the same order the
|
|
8416
|
+
// core's own longitude and lean are applied in, so the star and the body turn as one thing.
|
|
8417
|
+
const x1 = node.wx * cy + node.wz * sy;
|
|
8418
|
+
const z1 = node.wz * cy - node.wx * sy;
|
|
8419
|
+
const y2 = node.wy * cp - z1 * sp;
|
|
8420
|
+
const z2 = node.wy * sp + z1 * cp;
|
|
8421
|
+
const near = VIEW_DISTANCE / Math.max(200, VIEW_DISTANCE - z2);
|
|
8422
|
+
node.x = x1 * near;
|
|
8423
|
+
node.y = y2 * near;
|
|
8424
|
+
node.depth = z2;
|
|
8425
|
+
node.r = node.r3 * near;
|
|
8426
|
+
}
|
|
8427
|
+
}
|
|
7518
8428
|
const HEART_PERIOD = 1.15; // seconds per beat: slow, deliberate, alive
|
|
7519
8429
|
const WAVE_SPEED = 260; // world units per second a beat travels outward
|
|
7520
8430
|
|
|
@@ -7526,11 +8436,28 @@ function sizeNostromo() {
|
|
|
7526
8436
|
nostromo.size = { w: rect.width, h: rect.height, dpr };
|
|
7527
8437
|
canvas.width = Math.round(rect.width * dpr);
|
|
7528
8438
|
canvas.height = Math.round(rect.height * dpr);
|
|
7529
|
-
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
|
|
7533
|
-
|
|
8439
|
+
// Spread evenly over the whole surface, so no part of the brain is bald and none is a crowd.
|
|
8440
|
+
// In the plane a crowd pushed itself round the ring; in a body it escapes into depth instead
|
|
8441
|
+
// and stays where it started, so the start has to be even already. A golden-angle spiral runs
|
|
8442
|
+
// from the frontal pole to the back of the head, and the memories walk it grouped by kind:
|
|
8443
|
+
// each kind takes a band of its own, a lobe as wide as it has memories.
|
|
8444
|
+
const kinds = Object.keys(MEMORY_COLORS);
|
|
8445
|
+
const waiting = nostromo.nodes.filter((node) => !node.placed)
|
|
8446
|
+
.sort((a, b) => (kinds.indexOf(a.memory.kind) - kinds.indexOf(b.memory.kind)) || (a.angle - b.angle));
|
|
8447
|
+
for (const [i, node] of waiting.entries()) {
|
|
8448
|
+
const along = 1 - (2 * (i + 0.5)) / waiting.length;
|
|
8449
|
+
const ring = Math.sqrt(1 - along * along);
|
|
8450
|
+
const turn = i * GOLDEN_ANGLE;
|
|
8451
|
+
let ux = -along;
|
|
8452
|
+
let uy = ring * Math.cos(turn);
|
|
8453
|
+
let uz = ring * Math.sin(turn);
|
|
8454
|
+
// Never on the midline, where the fissure is.
|
|
8455
|
+
if (Math.abs(uz) < FISSURE) { uz = Math.sign(uz || 1) * FISSURE; const fit = Math.hypot(ux, uy, uz); ux /= fit; uy /= fit; uz /= fit; }
|
|
8456
|
+
const radius = NOSTROMO_ORBIT * brainRadius(Math.atan2(uy, ux)) * shellDepth(node);
|
|
8457
|
+
node.wx = ux * radius;
|
|
8458
|
+
node.wy = uy * radius;
|
|
8459
|
+
node.wz = uz * BRAIN_WIDTH * radius;
|
|
8460
|
+
node.wvx = 0; node.wvy = 0; node.wvz = 0;
|
|
7534
8461
|
node.placed = true;
|
|
7535
8462
|
}
|
|
7536
8463
|
return true;
|
|
@@ -7541,6 +8468,9 @@ function startNostromo() {
|
|
|
7541
8468
|
if (typeof requestAnimationFrame !== 'function' || !nostromo.canvas?.getContext) return;
|
|
7542
8469
|
if (!sizeNostromo()) return;
|
|
7543
8470
|
nostromo.cam = { x: 0, y: 0, scale: 1, manual: false };
|
|
8471
|
+
// Every boarding starts from the side view, the way the camera starts from the whole system.
|
|
8472
|
+
nostromo.yaw = 0;
|
|
8473
|
+
nostromo.pitch = 0;
|
|
7544
8474
|
nostromo.rings = [];
|
|
7545
8475
|
nostromo.lastPhase = 0;
|
|
7546
8476
|
nostromo.alarm = null;
|
|
@@ -7552,6 +8482,8 @@ function startNostromo() {
|
|
|
7552
8482
|
nostromo.last = now;
|
|
7553
8483
|
const clock = now / 1000;
|
|
7554
8484
|
stepNostromo(dt, clock);
|
|
8485
|
+
focusNostromo(dt, clock);
|
|
8486
|
+
projectNostromo();
|
|
7555
8487
|
fitNostromo();
|
|
7556
8488
|
drawNostromo(clock);
|
|
7557
8489
|
nostromo.raf = requestAnimationFrame(frame);
|
|
@@ -7648,6 +8580,10 @@ const CIRCLE_SIN = Array.from({ length: CIRCLE_STEPS + 1 }, (_, k) => Math.sin((
|
|
|
7648
8580
|
// strip is built per class, the first time that class is asked for, and every memory of that
|
|
7649
8581
|
// class wears it. The strip repeats left to right, so a body can turn inside it without a seam.
|
|
7650
8582
|
const dwarfSkins = new Map();
|
|
8583
|
+
// A dwarf's face is laid on in columns across its width, each as wide as a sphere is at that
|
|
8584
|
+
// longitude: the sine of evenly spaced longitudes from one limb to the other.
|
|
8585
|
+
const SKIN_COLUMNS = 8;
|
|
8586
|
+
const SKIN_EDGES = Array.from({ length: SKIN_COLUMNS + 1 }, (_, k) => Math.sin(-Math.PI / 2 + (k / SKIN_COLUMNS) * Math.PI));
|
|
7651
8587
|
function dwarfTexture(hex) {
|
|
7652
8588
|
if (dwarfSkins.has(hex)) return dwarfSkins.get(hex);
|
|
7653
8589
|
let skin = null;
|
|
@@ -7697,6 +8633,497 @@ function dwarfTexture(hex) {
|
|
|
7697
8633
|
return skin;
|
|
7698
8634
|
}
|
|
7699
8635
|
|
|
8636
|
+
// ---- A dwarf, drawn the way a star looks.
|
|
8637
|
+
//
|
|
8638
|
+
// A dwarf makes its own light, so nothing out here lights it and nothing shades it: there is
|
|
8639
|
+
// no side turned away. What makes it round is what makes the Sun round in a white-light photo.
|
|
8640
|
+
//
|
|
8641
|
+
// Limb darkening. The disc is brightest in the middle and dims toward the edge, the same all
|
|
8642
|
+
// the way round: at the limb the eye sees into higher, cooler gas. It dims more in blue than
|
|
8643
|
+
// in red, so the edge is also warmer than the centre.
|
|
8644
|
+
// Granulation. The surface is cells: hot plasma rising in bright cores, cooling gas sinking in
|
|
8645
|
+
// the dark lanes between them. Seen toward the limb the cells foreshorten and the pattern fades.
|
|
8646
|
+
// Spots. A dark umbra inside a paler penumbra, where the field holds the heat down.
|
|
8647
|
+
// Faculae. Bright patches around the spots, plain near the limb and nearly lost in the middle.
|
|
8648
|
+
//
|
|
8649
|
+
// Each class wears these in its own proportion: a white dwarf is smooth and white-hot, a yellow
|
|
8650
|
+
// one is a sun with granules and spots. The surface is a map of the whole body in latitude and
|
|
8651
|
+
// longitude, built once per class from noise sampled on the sphere itself, so it has no seam and
|
|
8652
|
+
// nothing stretches at the poles; turning the body is sliding along it. How hard a dwarf burns
|
|
8653
|
+
// is how much the room has made of it, as before.
|
|
8654
|
+
//
|
|
8655
|
+
// The geometry of a disc of a given size is worked out once and kept. A body is painted again
|
|
8656
|
+
// only when it has turned or changed enough to show.
|
|
8657
|
+
const ALBEDO_W = 256;
|
|
8658
|
+
const ALBEDO_H = 128;
|
|
8659
|
+
// How much each channel dims at the limb, red to blue: the blue dims most.
|
|
8660
|
+
const LIMB_DARKENING = [0.68, 0.8, 0.9];
|
|
8661
|
+
// What each class of dwarf is made of. `granules` is how strongly the cells show, `spots` how
|
|
8662
|
+
// many of them are marked, `faculae` how bright the patches around them are, `white` how far the
|
|
8663
|
+
// centre runs toward white, `power` how much light it gives for its size: a white dwarf is the
|
|
8664
|
+
// smallest and the most intense. A class not named here is drawn as a sun.
|
|
8665
|
+
const STAR_TRAITS = {
|
|
8666
|
+
decision: { granules: 1, spots: 1, faculae: 1, white: 0.5, power: 1 },
|
|
8667
|
+
fact: { granules: 0.3, spots: 0, faculae: 0.35, white: 0.9, power: 1.35 },
|
|
8668
|
+
preference: { granules: 0.9, spots: 0.7, faculae: 0.9, white: 0.45, power: 1 },
|
|
8669
|
+
question: { granules: 0.7, spots: 0.4, faculae: 0.7, white: 0.65, power: 1.15 },
|
|
8670
|
+
};
|
|
8671
|
+
const SUN_TRAITS = { granules: 0.8, spots: 0.6, faculae: 0.8, white: 0.5, power: 1 };
|
|
8672
|
+
// And what a memory's standing does to that surface. A dwarf you confirmed is clean; one nobody
|
|
8673
|
+
// has judged carries its class's spots; one held as false is blotched, the spots spreading as
|
|
8674
|
+
// `reach` drops; one put out of circulation is the worst of all. `faculae` scales the bright
|
|
8675
|
+
// patches. A dwarf the archive has never once carried is cooling: its granules barely move, it
|
|
8676
|
+
// gives less light (`glow`) and its middle is no longer white-hot (`white`).
|
|
8677
|
+
const SURFACE_BY_ZONE = {
|
|
8678
|
+
bridge: { spots: 0, reach: 0.77, faculae: 1.3 },
|
|
8679
|
+
hold: { spots: 1, reach: 0.77, faculae: 1 },
|
|
8680
|
+
medbay: { spots: 1, reach: 0.67, faculae: 0.55, sick: true },
|
|
8681
|
+
jettisoned: { spots: 1, reach: 0.62, faculae: 0.3, sick: true },
|
|
8682
|
+
};
|
|
8683
|
+
const COLD_GRANULES = 0.3;
|
|
8684
|
+
const COLD_GLOW = 0.55;
|
|
8685
|
+
const COLD_WHITE = 0.2;
|
|
8686
|
+
function surfaceOf(memory, traits = SUN_TRAITS, cold = false) {
|
|
8687
|
+
const zone = SURFACE_BY_ZONE[memory?.zone ?? 'hold'] ?? SURFACE_BY_ZONE.hold;
|
|
8688
|
+
return {
|
|
8689
|
+
spots: zone.sick ? 1 : traits.spots * zone.spots,
|
|
8690
|
+
reach: zone.reach,
|
|
8691
|
+
faculae: traits.faculae * zone.faculae,
|
|
8692
|
+
granules: traits.granules * (cold ? COLD_GRANULES : 1),
|
|
8693
|
+
glow: cold ? COLD_GLOW : 1,
|
|
8694
|
+
white: cold ? COLD_WHITE : 1,
|
|
8695
|
+
};
|
|
8696
|
+
}
|
|
8697
|
+
const dwarfAlbedos = new Map();
|
|
8698
|
+
const sphereTables = new Map();
|
|
8699
|
+
|
|
8700
|
+
// Gradient noise, the improved kind: continuous in every direction, with no grid showing
|
|
8701
|
+
// through it the way value noise shows its cells. The permutation is fixed, so every room grows
|
|
8702
|
+
// the same surfaces.
|
|
8703
|
+
const NOISE_PERM = (() => {
|
|
8704
|
+
const order = Array.from({ length: 256 }, (_, i) => i);
|
|
8705
|
+
let seed = 1013;
|
|
8706
|
+
for (let i = 255; i > 0; i -= 1) {
|
|
8707
|
+
seed = (seed * 16807) % 2147483647;
|
|
8708
|
+
const j = seed % (i + 1);
|
|
8709
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
8710
|
+
}
|
|
8711
|
+
return Uint8Array.from([...order, ...order]);
|
|
8712
|
+
})();
|
|
8713
|
+
function gradientNoise(x, y, z) {
|
|
8714
|
+
const P = NOISE_PERM;
|
|
8715
|
+
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
|
|
8716
|
+
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
|
|
8717
|
+
const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
|
|
8718
|
+
const u = fade(x), v = fade(y), w = fade(z);
|
|
8719
|
+
const grad = (hash, gx, gy, gz) => {
|
|
8720
|
+
const h = hash & 15;
|
|
8721
|
+
const a = h < 8 ? gx : gy;
|
|
8722
|
+
const b = h < 4 ? gy : h === 12 || h === 14 ? gx : gz;
|
|
8723
|
+
return ((h & 1) ? -a : a) + ((h & 2) ? -b : b);
|
|
8724
|
+
};
|
|
8725
|
+
const lerp = (t, a, b) => a + t * (b - a);
|
|
8726
|
+
const A = P[X] + Y, AA = P[A] + Z, AB = P[A + 1] + Z, B = P[X + 1] + Y, BA = P[B] + Z, BB = P[B + 1] + Z;
|
|
8727
|
+
return lerp(w,
|
|
8728
|
+
lerp(v, lerp(u, grad(P[AA], x, y, z), grad(P[BA], x - 1, y, z)), lerp(u, grad(P[AB], x, y - 1, z), grad(P[BB], x - 1, y - 1, z))),
|
|
8729
|
+
lerp(v, lerp(u, grad(P[AA + 1], x, y, z - 1), grad(P[BA + 1], x - 1, y, z - 1)), lerp(u, grad(P[AB + 1], x, y - 1, z - 1), grad(P[BB + 1], x - 1, y - 1, z - 1))));
|
|
8730
|
+
}
|
|
8731
|
+
// Octaves of it, folded into 0..1.
|
|
8732
|
+
function fractalNoise(x, y, z, octaves) {
|
|
8733
|
+
let sum = 0, amp = 0.5, freq = 1, norm = 0;
|
|
8734
|
+
for (let i = 0; i < octaves; i += 1) { sum += amp * gradientNoise(x * freq, y * freq, z * freq); norm += amp; amp *= 0.5; freq *= 2.03; }
|
|
8735
|
+
return 0.5 + 0.5 * Math.max(-1, Math.min(1, (sum / norm) * 1.6));
|
|
8736
|
+
}
|
|
8737
|
+
// Cellular noise: the distance to the nearest and the second nearest of a scatter of points,
|
|
8738
|
+
// one jittered into each unit cell. Where the two are nearly equal is the border between two
|
|
8739
|
+
// cells, which is exactly the shape of a granule's dark lane.
|
|
8740
|
+
function cellNoise(x, y, z) {
|
|
8741
|
+
const P = NOISE_PERM;
|
|
8742
|
+
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
|
8743
|
+
let near = 9, next = 9;
|
|
8744
|
+
for (let dx = -1; dx <= 1; dx += 1) for (let dy = -1; dy <= 1; dy += 1) for (let dz = -1; dz <= 1; dz += 1) {
|
|
8745
|
+
const h = P[(P[(P[(xi + dx) & 255] + ((yi + dy) & 255)) & 255] + ((zi + dz) & 255)) & 255];
|
|
8746
|
+
const ox = xi + dx + P[h] / 255 - x;
|
|
8747
|
+
const oy = yi + dy + P[(h + 85) & 255] / 255 - y;
|
|
8748
|
+
const oz = zi + dz + P[(h + 170) & 255] / 255 - z;
|
|
8749
|
+
const d = ox * ox + oy * oy + oz * oz;
|
|
8750
|
+
if (d < near) { next = near; near = d; } else if (d < next) next = d;
|
|
8751
|
+
}
|
|
8752
|
+
return [Math.sqrt(near), Math.sqrt(next)];
|
|
8753
|
+
}
|
|
8754
|
+
const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
|
|
8755
|
+
|
|
8756
|
+
// The whole surface of one class: how bright the granulation is at each texel, and how strong
|
|
8757
|
+
// the magnetic field is there. Spots and faculae are read from the field when a dwarf is
|
|
8758
|
+
// painted, so how many a dwarf shows can follow the memory's standing instead of being baked in.
|
|
8759
|
+
// Each class has a surface of its own. Built a band of rows at a time when asked to be (`rows`),
|
|
8760
|
+
// so the work can be spread over frames; the map is handed out only once it is whole.
|
|
8761
|
+
function dwarfAlbedo(kind, rows = ALBEDO_H) {
|
|
8762
|
+
let build = dwarfAlbedos.get(kind);
|
|
8763
|
+
if (build?.done) return build.map;
|
|
8764
|
+
if (!build) {
|
|
8765
|
+
const offset = [...String(kind)].reduce((sum, letter) => sum + letter.charCodeAt(0), 0) * 0.37;
|
|
8766
|
+
build = { offset, map: new Float32Array(ALBEDO_W * ALBEDO_H * 2), next: 0, done: false };
|
|
8767
|
+
dwarfAlbedos.set(kind, build);
|
|
8768
|
+
}
|
|
8769
|
+
const { offset, map } = build;
|
|
8770
|
+
const until = Math.min(ALBEDO_H, build.next + rows);
|
|
8771
|
+
for (let row = build.next; row < until; row += 1) {
|
|
8772
|
+
const lat = ((row + 0.5) / ALBEDO_H - 0.5) * Math.PI;
|
|
8773
|
+
const cl = Math.cos(lat), sl = Math.sin(lat);
|
|
8774
|
+
for (let col = 0; col < ALBEDO_W; col += 1) {
|
|
8775
|
+
const lon = ((col + 0.5) / ALBEDO_W) * Math.PI * 2;
|
|
8776
|
+
const px = cl * Math.cos(lon) + offset, py = sl, pz = cl * Math.sin(lon);
|
|
8777
|
+
// Granules: bright where a cell's middle is, dark along the lanes between cells, and a
|
|
8778
|
+
// slow unevenness over the lot so no two stretches of the surface match.
|
|
8779
|
+
const drift = fractalNoise(px * 3 + 4, py * 3, pz * 3, 3) - 0.5;
|
|
8780
|
+
const [near, next] = cellNoise(px * 21 + drift, py * 21, pz * 21 - drift);
|
|
8781
|
+
const lane = smooth(0, 0.32, next - near);
|
|
8782
|
+
const middle = 1 - Math.min(1, near * 0.9);
|
|
8783
|
+
const granule = 0.5 + 0.5 * (lane * (0.6 + 0.4 * middle) - 0.55) + 0.18 * (fractalNoise(px * 6, py * 6 + 2, pz * 6, 3) - 0.5);
|
|
8784
|
+
// The field: where it is strongest, spots; in a ring around them, faculae.
|
|
8785
|
+
const field = fractalNoise(px * 2.3 + 11, py * 2.3, pz * 2.3, 4) + 0.06 * (fractalNoise(px * 9, py * 9, pz * 9, 2) - 0.5);
|
|
8786
|
+
const at = (row * ALBEDO_W + col) * 2;
|
|
8787
|
+
map[at] = Math.min(1, Math.max(0, granule));
|
|
8788
|
+
map[at + 1] = field;
|
|
8789
|
+
}
|
|
8790
|
+
}
|
|
8791
|
+
build.next = until;
|
|
8792
|
+
build.done = until >= ALBEDO_H;
|
|
8793
|
+
return build.done ? map : null;
|
|
8794
|
+
}
|
|
8795
|
+
|
|
8796
|
+
// Every pixel of a disc `size` pixels across: which way it faces, where on the map it reads,
|
|
8797
|
+
// and how much of it the disc covers, so the edge is smooth without a second pass.
|
|
8798
|
+
function sphereTable(size) {
|
|
8799
|
+
if (sphereTables.has(size)) return sphereTables.get(size);
|
|
8800
|
+
const half = size / 2;
|
|
8801
|
+
const cells = [];
|
|
8802
|
+
for (let py = 0; py < size; py += 1) {
|
|
8803
|
+
for (let px = 0; px < size; px += 1) {
|
|
8804
|
+
const x = (px + 0.5 - half) / half;
|
|
8805
|
+
const y = (py + 0.5 - half) / half;
|
|
8806
|
+
const rr = x * x + y * y;
|
|
8807
|
+
const edge = (1 - Math.sqrt(rr)) * half + 0.5;
|
|
8808
|
+
if (edge <= 0) continue;
|
|
8809
|
+
const z = Math.sqrt(Math.max(0, 1 - rr));
|
|
8810
|
+
const lon = Math.atan2(x, z);
|
|
8811
|
+
const lat = Math.asin(Math.max(-1, Math.min(1, y)));
|
|
8812
|
+
const row = Math.max(0, Math.min(ALBEDO_H - 1.001, (lat / Math.PI + 0.5) * ALBEDO_H - 0.5));
|
|
8813
|
+
cells.push(py * size + px, x, y, z, (lon / (Math.PI * 2)) * ALBEDO_W, row, Math.min(1, edge));
|
|
8814
|
+
}
|
|
8815
|
+
}
|
|
8816
|
+
const table = { size, cells: Float32Array.from(cells) };
|
|
8817
|
+
sphereTables.set(size, table);
|
|
8818
|
+
return table;
|
|
8819
|
+
}
|
|
8820
|
+
|
|
8821
|
+
// The shading itself. Pure: a table, a map and the star in, RGBA out. `colour` is the class's
|
|
8822
|
+
// colour as three numbers 0..1, `surface` what its standing does to it (surfaceOf), `turn` how
|
|
8823
|
+
// far the body has spun, `burn` how hard it burns.
|
|
8824
|
+
function shadeDwarf(out, table, map, { colour, traits = SUN_TRAITS, surface = surfaceOf(null, traits), turn, burn }) {
|
|
8825
|
+
out.fill(0);
|
|
8826
|
+
const shift = (((turn / (Math.PI * 2)) % 1) + 1) % 1 * ALBEDO_W;
|
|
8827
|
+
const energy = (0.62 + 0.62 * burn) * (traits.power ?? 1);
|
|
8828
|
+
const whiten = traits.white * (0.5 + 0.5 * burn);
|
|
8829
|
+
// Toward the edge the class's colour deepens: cooler gas, the same hue, more of it.
|
|
8830
|
+
const deep = colour.map((value) => value ** 1.6);
|
|
8831
|
+
const cells = table.cells;
|
|
8832
|
+
const sample = new Float32Array(2);
|
|
8833
|
+
const { spots, reach, faculae, granules } = surface;
|
|
8834
|
+
const energyNow = energy * (surface.glow ?? 1);
|
|
8835
|
+
const whitenNow = whiten * (surface.white ?? 1);
|
|
8836
|
+
for (let i = 0; i < cells.length; i += 7) {
|
|
8837
|
+
const mu = cells[i + 3];
|
|
8838
|
+
// Read between texels, never from one: the map is smaller than a close-up body, and a body
|
|
8839
|
+
// read texel by texel shows them as squares.
|
|
8840
|
+
const u = cells[i + 4] + shift;
|
|
8841
|
+
const u0 = Math.floor(u);
|
|
8842
|
+
const fu = u - u0;
|
|
8843
|
+
const c0 = ((u0 % ALBEDO_W) + ALBEDO_W) % ALBEDO_W;
|
|
8844
|
+
const c1 = (c0 + 1) % ALBEDO_W;
|
|
8845
|
+
const v = cells[i + 5];
|
|
8846
|
+
const r0 = Math.floor(v);
|
|
8847
|
+
const fv = v - r0;
|
|
8848
|
+
const r1 = Math.min(ALBEDO_H - 1, r0 + 1);
|
|
8849
|
+
for (let k = 0; k < 2; k += 1) {
|
|
8850
|
+
const top = map[(r0 * ALBEDO_W + c0) * 2 + k] * (1 - fu) + map[(r0 * ALBEDO_W + c1) * 2 + k] * fu;
|
|
8851
|
+
const bottom = map[(r1 * ALBEDO_W + c0) * 2 + k] * (1 - fu) + map[(r1 * ALBEDO_W + c1) * 2 + k] * fu;
|
|
8852
|
+
sample[k] = top * (1 - fv) + bottom * fv;
|
|
8853
|
+
}
|
|
8854
|
+
const [granule, field] = sample;
|
|
8855
|
+
// Spots where the field passes `reach`, umbra deeper in; faculae in the ring just short of it.
|
|
8856
|
+
const ring = smooth(reach, reach + 0.07, field);
|
|
8857
|
+
const umbra = smooth(reach + 0.07, reach + 0.12, field) * spots;
|
|
8858
|
+
const penumbra = ring * spots;
|
|
8859
|
+
const facula = Math.max(0, smooth(reach - 0.11, reach - 0.01, field) - ring) * faculae;
|
|
8860
|
+
// The cells foreshorten toward the limb and their contrast goes with them.
|
|
8861
|
+
const cells_ = 1 + granules * 0.62 * (granule - 0.5) * Math.sqrt(mu);
|
|
8862
|
+
const bright = 1 + 0.6 * facula * (1 - mu) ** 0.7;
|
|
8863
|
+
const dark = 1 - 0.8 * umbra - 0.4 * Math.max(0, penumbra - umbra);
|
|
8864
|
+
// White-hot in the middle, its own colour toward the edge, deepening at the limb.
|
|
8865
|
+
const heat = whitenNow * mu * mu;
|
|
8866
|
+
const edge = (1 - mu) ** 1.5;
|
|
8867
|
+
const p = cells[i] * 4;
|
|
8868
|
+
for (let k = 0; k < 3; k += 1) {
|
|
8869
|
+
const hue = colour[k] + (deep[k] - colour[k]) * edge;
|
|
8870
|
+
const tint = hue + (1 - hue) * heat;
|
|
8871
|
+
const limb = 1 - LIMB_DARKENING[k] * (1 - mu);
|
|
8872
|
+
const value = tint * limb * cells_ * bright * dark * energyNow;
|
|
8873
|
+
out[p + k] = 255 * (1 - Math.exp(-value * 2.6));
|
|
8874
|
+
}
|
|
8875
|
+
out[p + 3] = 255 * cells[i + 6];
|
|
8876
|
+
}
|
|
8877
|
+
return out;
|
|
8878
|
+
}
|
|
8879
|
+
|
|
8880
|
+
// One dwarf's painted face, kept on its node and painted again only when it has changed enough
|
|
8881
|
+
// to be seen. Null where there is no pixel buffer to paint into, and the body is drawn the
|
|
8882
|
+
// older way instead.
|
|
8883
|
+
//
|
|
8884
|
+
// What it costs is bounded per frame. A surface map is built one class a frame, so opening the
|
|
8885
|
+
// map never stalls; until its class is ready a dwarf is drawn the older way. And only so many
|
|
8886
|
+
// faces are painted again each frame, in the order they are drawn; the rest keep last frame's
|
|
8887
|
+
// face for one more, which at the pace these bodies turn nobody can tell from a fresh one.
|
|
8888
|
+
const SHADE_BUDGET = 28;
|
|
8889
|
+
const ALBEDO_ROWS_PER_FRAME = 12;
|
|
8890
|
+
const shading = { frame: -1, painted: 0, built: false };
|
|
8891
|
+
function dwarfSprite(node, radius, burn, pixels, frame) {
|
|
8892
|
+
if (typeof ImageData !== 'function') return null;
|
|
8893
|
+
if (shading.frame !== frame) { shading.frame = frame; shading.painted = 0; shading.built = false; }
|
|
8894
|
+
const size = Math.max(8, Math.min(200, 2 * Math.round(radius * pixels)));
|
|
8895
|
+
const kind = node.memory.kind;
|
|
8896
|
+
if (!dwarfAlbedos.get(kind)?.done) {
|
|
8897
|
+
if (shading.built) return null;
|
|
8898
|
+
shading.built = true;
|
|
8899
|
+
if (!dwarfAlbedo(kind, ALBEDO_ROWS_PER_FRAME)) return null;
|
|
8900
|
+
}
|
|
8901
|
+
const map = dwarfAlbedo(kind);
|
|
8902
|
+
const colour = [1, 3, 5].map((at) => Number.parseInt(String(node.color).slice(at, at + 2), 16) / 255);
|
|
8903
|
+
if (colour.some((value) => !Number.isFinite(value))) return null;
|
|
8904
|
+
let sprite = node.sprite;
|
|
8905
|
+
// A body that has never been painted at this size waits its turn like any other: when a whole
|
|
8906
|
+
// class becomes ready at once it is introduced a few dozen a frame, not all in one.
|
|
8907
|
+
if ((!sprite || sprite.size !== size) && shading.painted >= SHADE_BUDGET) return sprite?.canvas ?? null;
|
|
8908
|
+
if (!sprite || sprite.size !== size) {
|
|
8909
|
+
const canvas = document.createElement('canvas');
|
|
8910
|
+
canvas.width = size;
|
|
8911
|
+
canvas.height = size;
|
|
8912
|
+
const paint = canvas.getContext('2d');
|
|
8913
|
+
if (!paint?.createImageData) return null;
|
|
8914
|
+
sprite = node.sprite = { canvas, paint, image: paint.createImageData(size, size), size, turn: NaN, burn: -1, colour: '', standing: '' };
|
|
8915
|
+
}
|
|
8916
|
+
const turn = node.spin * 0.3 + node.seed;
|
|
8917
|
+
// Painted again once the middle of its face has moved half a pixel, so a small body is painted
|
|
8918
|
+
// rarely and a large one, close up, as often as it needs.
|
|
8919
|
+
const fresh = !(sprite.turn === sprite.turn);
|
|
8920
|
+
const traits = STAR_TRAITS[kind] ?? SUN_TRAITS;
|
|
8921
|
+
const standing = `${node.memory.zone ?? 'hold'}${node.cold ? ':cold' : ''}`;
|
|
8922
|
+
const moved = fresh
|
|
8923
|
+
|| Math.abs(turn - sprite.turn) * size > 1
|
|
8924
|
+
|| Math.abs(burn - sprite.burn) > 0.04
|
|
8925
|
+
|| sprite.colour !== node.color
|
|
8926
|
+
|| sprite.standing !== standing;
|
|
8927
|
+
if (moved && shading.painted < SHADE_BUDGET) {
|
|
8928
|
+
shading.painted += 1;
|
|
8929
|
+
shadeDwarf(sprite.image.data, sphereTable(size), map, { colour, traits, surface: surfaceOf(node.memory, traits, Boolean(node.cold)), turn, burn });
|
|
8930
|
+
sprite.paint.putImageData(sprite.image, 0, 0);
|
|
8931
|
+
sprite.turn = turn;
|
|
8932
|
+
sprite.burn = burn;
|
|
8933
|
+
sprite.colour = node.color;
|
|
8934
|
+
sprite.standing = standing;
|
|
8935
|
+
}
|
|
8936
|
+
return sprite.canvas;
|
|
8937
|
+
}
|
|
8938
|
+
|
|
8939
|
+
// ---- MOTHER's body, as molten rock.
|
|
8940
|
+
//
|
|
8941
|
+
// A crust of cooling rock, nearly black, broken into plates; between the plates, cracks running
|
|
8942
|
+
// white-hot; under the crust, pools where it has melted through and the lava shows. The crust
|
|
8943
|
+
// rides the body's turn, the lava underneath flows at a pace of its own, and where the two
|
|
8944
|
+
// disagree the surface looks like it is moving rather than painted. Every so often something
|
|
8945
|
+
// bursts inside: a flash, a white core, a ring of fire running out from it and fading.
|
|
8946
|
+
//
|
|
8947
|
+
// The map is built once, the way the dwarfs' are, a band at a time; what changes frame to frame
|
|
8948
|
+
// is only where it is read and what is bursting.
|
|
8949
|
+
const MAGMA_W = 512;
|
|
8950
|
+
const MAGMA_H = 256;
|
|
8951
|
+
const MAGMA_SIZE = 220; // the largest the body is ever painted; beyond it, it is scaled
|
|
8952
|
+
const BLAST_EVERY = 1.6; // seconds between bursts, on average
|
|
8953
|
+
const BLAST_LIFE = 2.2;
|
|
8954
|
+
// Rock to white heat: black crust, dark red, orange, yellow, white.
|
|
8955
|
+
const LAVA_RAMP = [[0, [0.05, 0.012, 0.008]], [0.3, [0.42, 0.04, 0.012]], [0.55, [0.92, 0.24, 0.03]], [0.78, [1, 0.62, 0.16]], [1, [1, 0.94, 0.78]]];
|
|
8956
|
+
const magmaBuild = { map: null, next: 0, done: false };
|
|
8957
|
+
let magmaSprite = null;
|
|
8958
|
+
|
|
8959
|
+
function lavaColour(heat, out) {
|
|
8960
|
+
const h = Math.min(1, Math.max(0, heat));
|
|
8961
|
+
for (let i = 1; i < LAVA_RAMP.length; i += 1) {
|
|
8962
|
+
const [at, colour] = LAVA_RAMP[i];
|
|
8963
|
+
if (h > at) continue;
|
|
8964
|
+
const [from, below] = LAVA_RAMP[i - 1];
|
|
8965
|
+
const f = (h - from) / (at - from);
|
|
8966
|
+
for (let k = 0; k < 3; k += 1) out[k] = below[k] + (colour[k] - below[k]) * f;
|
|
8967
|
+
return out;
|
|
8968
|
+
}
|
|
8969
|
+
for (let k = 0; k < 3; k += 1) out[k] = LAVA_RAMP[LAVA_RAMP.length - 1][1][k];
|
|
8970
|
+
return out;
|
|
8971
|
+
}
|
|
8972
|
+
|
|
8973
|
+
// Four numbers a texel: the plates' cracks, the finer cracks inside them, where the crust has
|
|
8974
|
+
// melted through, and grain for the rock itself.
|
|
8975
|
+
function magmaMap(rows = MAGMA_H) {
|
|
8976
|
+
if (magmaBuild.done) return magmaBuild.map;
|
|
8977
|
+
magmaBuild.map ??= new Float32Array(MAGMA_W * MAGMA_H * 4);
|
|
8978
|
+
const map = magmaBuild.map;
|
|
8979
|
+
const until = Math.min(MAGMA_H, magmaBuild.next + rows);
|
|
8980
|
+
for (let row = magmaBuild.next; row < until; row += 1) {
|
|
8981
|
+
const lat = ((row + 0.5) / MAGMA_H - 0.5) * Math.PI;
|
|
8982
|
+
const cl = Math.cos(lat), sl = Math.sin(lat);
|
|
8983
|
+
for (let col = 0; col < MAGMA_W; col += 1) {
|
|
8984
|
+
const lon = ((col + 0.5) / MAGMA_W) * Math.PI * 2;
|
|
8985
|
+
const px = cl * Math.cos(lon), py = sl, pz = cl * Math.sin(lon);
|
|
8986
|
+
const warp = fractalNoise(px * 2.2 + 5, py * 2.2, pz * 2.2, 3) - 0.5;
|
|
8987
|
+
const [near, next] = cellNoise(px * 6 + warp, py * 6, pz * 6 - warp);
|
|
8988
|
+
// Fine cracks at a frequency the map can carry: any finer and the cells alias into streaks.
|
|
8989
|
+
const [fineNear, fineNext] = cellNoise(px * 10 + warp * 2, py * 10, pz * 10);
|
|
8990
|
+
const pools = fractalNoise(px * 2.6 + warp * 1.5, py * 2.6 + 9, pz * 2.6, 5);
|
|
8991
|
+
const at = (row * MAGMA_W + col) * 4;
|
|
8992
|
+
map[at] = next - near;
|
|
8993
|
+
map[at + 1] = fineNext - fineNear;
|
|
8994
|
+
map[at + 2] = pools;
|
|
8995
|
+
map[at + 3] = fractalNoise(px * 11, py * 11, pz * 11 + 3, 3);
|
|
8996
|
+
}
|
|
8997
|
+
}
|
|
8998
|
+
magmaBuild.next = until;
|
|
8999
|
+
magmaBuild.done = until >= MAGMA_H;
|
|
9000
|
+
return magmaBuild.done ? map : null;
|
|
9001
|
+
}
|
|
9002
|
+
|
|
9003
|
+
// The shading. Pure: a table (sphereTable), the map, and the moment in; RGBA out. `turn` is how
|
|
9004
|
+
// far the crust has turned, `flow` how far the lava under it has, `beat` the heartbeat, `blasts`
|
|
9005
|
+
// the bursts alive now, each a direction on the visible face in view space and an age 0..1.
|
|
9006
|
+
// Written for speed: it runs on the largest body on screen, so every read of the map is shared
|
|
9007
|
+
// between the channels that use the same place, and the limb is worked out once per size.
|
|
9008
|
+
const magmaLimbs = new Map();
|
|
9009
|
+
function shadeMagma(out, table, map, { turn, flow, beat = 0, blasts = [] }) {
|
|
9010
|
+
out.fill(0);
|
|
9011
|
+
const crustShift = (((turn / (Math.PI * 2)) % 1) + 1) % 1 * MAGMA_W;
|
|
9012
|
+
const lavaShift = (((flow / (Math.PI * 2)) % 1) + 1) % 1 * MAGMA_W;
|
|
9013
|
+
const cells = table.cells;
|
|
9014
|
+
let limbs = magmaLimbs.get(table);
|
|
9015
|
+
if (!limbs) {
|
|
9016
|
+
limbs = new Float32Array(cells.length / 7);
|
|
9017
|
+
for (let i = 0, j = 0; i < cells.length; i += 7, j += 1) limbs[j] = 0.32 + 0.68 * cells[i + 3] ** 0.55;
|
|
9018
|
+
magmaLimbs.set(table, limbs);
|
|
9019
|
+
}
|
|
9020
|
+
const scale = MAGMA_W / ALBEDO_W;
|
|
9021
|
+
const rowScale = MAGMA_H / ALBEDO_H;
|
|
9022
|
+
const colour = [0, 0, 0];
|
|
9023
|
+
const seamWidth = 0.07 + 0.025 * beat;
|
|
9024
|
+
for (let i = 0, j = 0; i < cells.length; i += 7, j += 1) {
|
|
9025
|
+
const nx = cells[i + 1], ny = cells[i + 2], mu = cells[i + 3];
|
|
9026
|
+
const base = cells[i + 4] * scale;
|
|
9027
|
+
const v = Math.min(MAGMA_H - 1.001, cells[i + 5] * rowScale);
|
|
9028
|
+
const r0 = v | 0;
|
|
9029
|
+
const fv = v - r0;
|
|
9030
|
+
const top = r0 * MAGMA_W;
|
|
9031
|
+
const bottom = Math.min(MAGMA_H - 1, r0 + 1) * MAGMA_W;
|
|
9032
|
+
// The crust: one place on the map, three things read from it.
|
|
9033
|
+
let u = base + crustShift;
|
|
9034
|
+
let u0 = Math.floor(u);
|
|
9035
|
+
let fu = u - u0;
|
|
9036
|
+
let c0 = u0 % MAGMA_W; if (c0 < 0) c0 += MAGMA_W;
|
|
9037
|
+
let c1 = c0 + 1 === MAGMA_W ? 0 : c0 + 1;
|
|
9038
|
+
let a = (top + c0) * 4, b = (top + c1) * 4, c = (bottom + c0) * 4, d = (bottom + c1) * 4;
|
|
9039
|
+
const w00 = (1 - fu) * (1 - fv), w10 = fu * (1 - fv), w01 = (1 - fu) * fv, w11 = fu * fv;
|
|
9040
|
+
const crack = map[a] * w00 + map[b] * w10 + map[c] * w01 + map[d] * w11;
|
|
9041
|
+
const fine = map[a + 1] * w00 + map[b + 1] * w10 + map[c + 1] * w01 + map[d + 1] * w11;
|
|
9042
|
+
const grain = map[a + 3] * w00 + map[b + 3] * w10 + map[c + 3] * w01 + map[d + 3] * w11;
|
|
9043
|
+
// The lava under it, read where it has flowed to.
|
|
9044
|
+
u = base + lavaShift;
|
|
9045
|
+
u0 = Math.floor(u);
|
|
9046
|
+
fu = u - u0;
|
|
9047
|
+
c0 = u0 % MAGMA_W; if (c0 < 0) c0 += MAGMA_W;
|
|
9048
|
+
c1 = c0 + 1 === MAGMA_W ? 0 : c0 + 1;
|
|
9049
|
+
a = (top + c0) * 4; b = (top + c1) * 4; c = (bottom + c0) * 4; d = (bottom + c1) * 4;
|
|
9050
|
+
const pools = map[a + 2] * (1 - fu) * (1 - fv) + map[b + 2] * fu * (1 - fv) + map[c + 2] * (1 - fu) * fv + map[d + 2] * fu * fv;
|
|
9051
|
+
// White-hot along the plates' edges, hotter still where the beat pushes through; a fainter
|
|
9052
|
+
// web of finer cracks inside each plate; pools wherever the crust has melted through.
|
|
9053
|
+
const seam = 1 - smooth(0, seamWidth, crack);
|
|
9054
|
+
const web = (1 - smooth(0, 0.045, fine)) * 0.55;
|
|
9055
|
+
const melt = smooth(0.52, 0.78, pools + 0.06 * beat);
|
|
9056
|
+
let heat = Math.max(seam * 0.95, web, melt * 0.82) + 0.08 * grain;
|
|
9057
|
+
// Bursts: a small white core where it went off, and a ring of fire running out from it.
|
|
9058
|
+
for (let k = 0; k < blasts.length; k += 1) {
|
|
9059
|
+
const blast = blasts[k];
|
|
9060
|
+
const near = 1 - (nx * blast.x + ny * blast.y + mu * blast.z);
|
|
9061
|
+
if (near > 0.2) continue;
|
|
9062
|
+
const age = blast.age;
|
|
9063
|
+
const flash = Math.exp(-age * 7) * Math.exp(-near / (0.0015 + 0.006 * age));
|
|
9064
|
+
const front = 0.004 + 0.07 * age;
|
|
9065
|
+
const ring = Math.exp(-((near - front) ** 2) / (0.00012 + 0.0006 * age)) * (1 - age);
|
|
9066
|
+
heat += 1.1 * flash + 1.1 * ring;
|
|
9067
|
+
}
|
|
9068
|
+
lavaColour(heat, colour);
|
|
9069
|
+
// The edge is seen through more of its own smoke: darker and redder toward the limb.
|
|
9070
|
+
const limb = limbs[j];
|
|
9071
|
+
const p = cells[i] * 4;
|
|
9072
|
+
out[p] = 255 * Math.min(1, colour[0] * limb * (1.04 + 0.1 * beat));
|
|
9073
|
+
out[p + 1] = 255 * Math.min(1, colour[1] * limb * limb);
|
|
9074
|
+
out[p + 2] = 255 * Math.min(1, colour[2] * limb * limb * limb);
|
|
9075
|
+
out[p + 3] = 255 * cells[i + 6];
|
|
9076
|
+
}
|
|
9077
|
+
return out;
|
|
9078
|
+
}
|
|
9079
|
+
|
|
9080
|
+
// The bursts alive now: born at random on the face, more often while MOTHER is alarmed, none for
|
|
9081
|
+
// someone who asked for stillness.
|
|
9082
|
+
function magmaBlasts(t, { alarm = 0, reduced = false } = {}) {
|
|
9083
|
+
const blasts = (nostromo.blasts ??= []);
|
|
9084
|
+
if (!reduced && Math.random() < (1 / BLAST_EVERY) * (1 + 3 * alarm) / 60) {
|
|
9085
|
+
const z = 0.35 + 0.6 * Math.random();
|
|
9086
|
+
const angle = Math.random() * Math.PI * 2;
|
|
9087
|
+
const ring = Math.sqrt(1 - z * z);
|
|
9088
|
+
blasts.push({ born: t, x: Math.cos(angle) * ring, y: Math.sin(angle) * ring, z });
|
|
9089
|
+
}
|
|
9090
|
+
nostromo.blasts = blasts.filter((blast) => t - blast.born < BLAST_LIFE);
|
|
9091
|
+
return nostromo.blasts.map((blast) => ({ x: blast.x, y: blast.y, z: blast.z, age: (t - blast.born) / BLAST_LIFE }));
|
|
9092
|
+
}
|
|
9093
|
+
|
|
9094
|
+
// The body's face, painted into a buffer of its own and laid on the disc. Null where there is no
|
|
9095
|
+
// pixel buffer, or until the map is whole: the star is drawn the older way until then.
|
|
9096
|
+
function magmaFace(radius, pixels, t, { turn, beat, alarm, reduced }) {
|
|
9097
|
+
if (typeof ImageData !== 'function') return null;
|
|
9098
|
+
const map = magmaMap(12);
|
|
9099
|
+
if (!map) return null;
|
|
9100
|
+
const size = Math.max(32, Math.min(MAGMA_SIZE, 2 * Math.round(radius * pixels)));
|
|
9101
|
+
if (!magmaSprite || magmaSprite.size !== size) {
|
|
9102
|
+
const canvas = document.createElement('canvas');
|
|
9103
|
+
canvas.width = size;
|
|
9104
|
+
canvas.height = size;
|
|
9105
|
+
const paint = canvas.getContext('2d');
|
|
9106
|
+
if (!paint?.createImageData) return null;
|
|
9107
|
+
magmaSprite = { canvas, paint, image: paint.createImageData(size, size), size };
|
|
9108
|
+
}
|
|
9109
|
+
// Painted every other frame: rock this slow, and bursts this short, read the same at half the
|
|
9110
|
+
// rate, and the frames between are free for the dwarfs.
|
|
9111
|
+
magmaSprite.frame = (magmaSprite.frame ?? 0) + 1;
|
|
9112
|
+
if (magmaSprite.painted && magmaSprite.frame % 2) return magmaSprite.canvas;
|
|
9113
|
+
magmaSprite.painted = true;
|
|
9114
|
+
const flowTime = reduced ? 0 : t;
|
|
9115
|
+
shadeMagma(magmaSprite.image.data, sphereTable(size), map, {
|
|
9116
|
+
turn,
|
|
9117
|
+
flow: turn * 0.82 + flowTime * 0.018,
|
|
9118
|
+
beat,
|
|
9119
|
+
blasts: magmaBlasts(t, { alarm, reduced }),
|
|
9120
|
+
});
|
|
9121
|
+
magmaSprite.paint.putImageData(magmaSprite.image, 0, 0);
|
|
9122
|
+
return magmaSprite.canvas;
|
|
9123
|
+
}
|
|
9124
|
+
|
|
9125
|
+
|
|
9126
|
+
|
|
7700
9127
|
// Masses of molten matter riding the surface: `size` is how much of the face one covers, `rate`
|
|
7701
9128
|
// how slowly it swells and settles, `drift` how it crawls against the turning body. None of the
|
|
7702
9129
|
// rates match, so the face is never the same face twice.
|
|
@@ -7717,53 +9144,93 @@ const CORE_FLOWS = [
|
|
|
7717
9144
|
const MEMORY_SCALE = 0.8;
|
|
7718
9145
|
|
|
7719
9146
|
function stepNostromo(dt, t) {
|
|
7720
|
-
|
|
9147
|
+
// What is being forgotten holds still while it goes.
|
|
9148
|
+
const nodes = nostromo.nodes.filter((node) => node.scale > 0.01 && !node.forgetting);
|
|
7721
9149
|
const byId = new Map(nodes.map((node) => [node.memory.id, node]));
|
|
9150
|
+
for (const node of nodes) {
|
|
9151
|
+
if (node.wx === undefined) { node.wx = node.x; node.wy = node.y; node.wz = 0; }
|
|
9152
|
+
}
|
|
7722
9153
|
for (const node of nodes) {
|
|
7723
9154
|
let fx = 0;
|
|
7724
9155
|
let fy = 0;
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
9156
|
+
let fz = 0;
|
|
9157
|
+
const dist = Math.hypot(node.wx, node.wy, node.wz) || 1;
|
|
9158
|
+
// A soft shell around the core, in the shape of a brain: too close is pushed out, too far
|
|
9159
|
+
// pulled in. The outline gives the reach at this bearing seen from the side; the width
|
|
9160
|
+
// gives it toward the temples. `shell` is 1 exactly on the surface.
|
|
9161
|
+
const target = NOSTROMO_ORBIT * brainRadius(Math.atan2(node.wy, node.wx)) * shellDepth(node);
|
|
9162
|
+
const shell = Math.hypot(Math.hypot(node.wx, node.wy) / target, node.wz / (BRAIN_WIDTH * target)) || 1;
|
|
9163
|
+
const pull = (1 / shell - 1) * dist * 0.004;
|
|
9164
|
+
fx += (node.wx / dist) * pull;
|
|
9165
|
+
fy += (node.wy / dist) * pull;
|
|
9166
|
+
fz += (node.wz / dist) * pull;
|
|
9167
|
+
if (dist < CORE_R * 1.7) { const push = (CORE_R * 1.7 - dist) * 0.02; fx += (node.wx / dist) * push; fy += (node.wy / dist) * push; fz += (node.wz / dist) * push; }
|
|
9168
|
+
// The fissure: out of the midline, back toward the hemisphere it already leans into.
|
|
9169
|
+
const gap = FISSURE * NOSTROMO_ORBIT;
|
|
9170
|
+
if (Math.abs(node.wz) < gap) fz += Math.sign(node.wz || (frac(node.seed * 0.618) < 0.5 ? -1 : 1)) * (gap - Math.abs(node.wz)) * 0.02;
|
|
9171
|
+
const own = node.r3 ?? node.r;
|
|
9172
|
+
// Every pair, every frame: the far ones are turned away on the squared distance, before any
|
|
9173
|
+
// square root is taken, and the rest without Math.hypot, which costs several times as much.
|
|
7732
9174
|
for (const other of nodes) {
|
|
7733
9175
|
if (other === node) continue;
|
|
7734
|
-
const ox = node.
|
|
7735
|
-
const oy = node.
|
|
7736
|
-
const
|
|
7737
|
-
const
|
|
7738
|
-
|
|
9176
|
+
const ox = node.wx - other.wx;
|
|
9177
|
+
const oy = node.wy - other.wy;
|
|
9178
|
+
const oz = node.wz - other.wz;
|
|
9179
|
+
const reach = (own + (other.r3 ?? other.r) + 30) * 2.2;
|
|
9180
|
+
const square = ox * ox + oy * oy + oz * oz;
|
|
9181
|
+
if (square >= reach * reach) continue;
|
|
9182
|
+
const d = Math.sqrt(square) || 1;
|
|
9183
|
+
const push = ((reach - d) / reach) * 0.9;
|
|
9184
|
+
fx += (ox / d) * push; fy += (oy / d) * push; fz += (oz / d) * push;
|
|
9185
|
+
}
|
|
9186
|
+
if (!nostromo.reduced) {
|
|
9187
|
+
fx += Math.sin(node.seed + t * 0.38) * 0.02;
|
|
9188
|
+
fy += Math.cos(node.seed * 1.3 + t * 0.32) * 0.02;
|
|
9189
|
+
fz += Math.sin(node.seed * 0.7 + t * 0.29) * 0.02;
|
|
7739
9190
|
}
|
|
7740
|
-
if (!nostromo.reduced) { fx += Math.sin(node.seed + t * 0.38) * 0.02; fy += Math.cos(node.seed * 1.3 + t * 0.32) * 0.02; }
|
|
7741
9191
|
node.fx = fx;
|
|
7742
9192
|
node.fy = fy;
|
|
9193
|
+
node.fz = fz;
|
|
7743
9194
|
}
|
|
7744
9195
|
for (const link of nostromo.links) {
|
|
7745
9196
|
const a = byId.get(link.a);
|
|
7746
9197
|
const b = byId.get(link.b);
|
|
7747
9198
|
if (!a || !b) continue;
|
|
7748
|
-
const dx = b.
|
|
7749
|
-
const dy = b.
|
|
7750
|
-
const
|
|
7751
|
-
const
|
|
9199
|
+
const dx = b.wx - a.wx;
|
|
9200
|
+
const dy = b.wy - a.wy;
|
|
9201
|
+
const dz = b.wz - a.wz;
|
|
9202
|
+
const d = Math.hypot(dx, dy, dz) || 1;
|
|
9203
|
+
const rest = (a.r3 ?? a.r) + (b.r3 ?? b.r) + 70;
|
|
7752
9204
|
const k = (d - rest) * 0.0035 * link.weight;
|
|
7753
|
-
a.fx += (dx / d) * k; a.fy += (dy / d) * k;
|
|
7754
|
-
b.fx -= (dx / d) * k; b.fy -= (dy / d) * k;
|
|
9205
|
+
a.fx += (dx / d) * k; a.fy += (dy / d) * k; a.fz += (dz / d) * k;
|
|
9206
|
+
b.fx -= (dx / d) * k; b.fy -= (dy / d) * k; b.fz -= (dz / d) * k;
|
|
7755
9207
|
}
|
|
7756
9208
|
for (const node of nodes) {
|
|
7757
|
-
node.
|
|
7758
|
-
node.
|
|
7759
|
-
node.
|
|
7760
|
-
node.
|
|
9209
|
+
node.wvx = ((node.wvx ?? 0) + node.fx * dt) * 0.86;
|
|
9210
|
+
node.wvy = ((node.wvy ?? 0) + node.fy * dt) * 0.86;
|
|
9211
|
+
node.wvz = ((node.wvz ?? 0) + node.fz * dt) * 0.86;
|
|
9212
|
+
node.wx += node.wvx * dt;
|
|
9213
|
+
node.wy += node.wvy * dt;
|
|
9214
|
+
node.wz += node.wvz * dt;
|
|
7761
9215
|
node.spin = (node.spin ?? node.seed) + dt * 0.012 * (1 + (node.seed % 1));
|
|
7762
9216
|
}
|
|
7763
|
-
|
|
7764
|
-
|
|
9217
|
+
// A forgotten memory goes on the clock, not by a fixed step a frame, so the implosion takes
|
|
9218
|
+
// the same time on any screen; the dust drifts and goes out on the same clock.
|
|
9219
|
+
for (const node of nostromo.nodes) if (node.forgetting) node.scale = implosionScale((t - node.forgetting.at) / IMPLOSION);
|
|
9220
|
+
nostromo.nodes = nostromo.nodes.filter((node) => !(node.forgetting && t - node.forgetting.at >= IMPLOSION));
|
|
9221
|
+
if (nostromo.dust?.length) {
|
|
9222
|
+
for (const mote of nostromo.dust) {
|
|
9223
|
+
if (t < mote.born || nostromo.reduced) continue;
|
|
9224
|
+
mote.x += mote.vx * dt * 0.35;
|
|
9225
|
+
mote.y += mote.vy * dt * 0.35;
|
|
9226
|
+
mote.vx *= 0.995; mote.vy *= 0.995;
|
|
9227
|
+
}
|
|
9228
|
+
nostromo.dust = nostromo.dust.filter((mote) => t - mote.born < mote.life);
|
|
9229
|
+
}
|
|
9230
|
+
if (nostromo.fading?.length) nostromo.fading = nostromo.fading.filter((wire) => t - wire.born < 1.3);
|
|
7765
9231
|
// The core turns on its own axis, slowly, the way a body does.
|
|
7766
|
-
|
|
9232
|
+
// About one turn in twenty seconds: slow enough to be a body, fast enough to be seen turning.
|
|
9233
|
+
nostromo.spin = (nostromo.spin ?? 0) + dt * 0.0052 * (nostromo.cage ? 2.2 : 1);
|
|
7767
9234
|
// A new beat sends a wave out from the core.
|
|
7768
9235
|
const { phase } = heartbeat(t);
|
|
7769
9236
|
if (phase < nostromo.lastPhase && !nostromo.reduced) nostromo.rings.push({ born: t });
|
|
@@ -7859,19 +9326,35 @@ function alongCurve(x0, y0, cx, cy, x1, y1, u) {
|
|
|
7859
9326
|
// The camera follows the whole system until the human takes over.
|
|
7860
9327
|
function fitNostromo() {
|
|
7861
9328
|
const cam = nostromo.cam;
|
|
7862
|
-
if (!cam
|
|
9329
|
+
if (!cam) return;
|
|
9330
|
+
// A held dwarf is where the camera goes, whoever last moved it.
|
|
9331
|
+
const witness = nostromo.witness?.forgetting && nostromo.nodes.includes(nostromo.witness) ? nostromo.witness : null;
|
|
9332
|
+
const held = (nostromo.selected && nostromo.nodes.includes(nostromo.selected) ? nostromo.selected : null) ?? witness;
|
|
9333
|
+
if (held) {
|
|
9334
|
+
// Centred in the part of the map the card leaves free, not under the card.
|
|
9335
|
+
const card = nostromo.card && !nostromo.card.hidden ? nostromo.card.getBoundingClientRect() : null;
|
|
9336
|
+
const canvas = nostromo.canvas.getBoundingClientRect();
|
|
9337
|
+
const covered = card && card.width ? Math.max(0, canvas.right - card.left) : 0;
|
|
9338
|
+
const aimX = held.x + covered / 2 / cam.scale;
|
|
9339
|
+
cam.x += (aimX - cam.x) * 0.08;
|
|
9340
|
+
cam.y += (held.y - cam.y) * 0.08;
|
|
9341
|
+
}
|
|
9342
|
+
if (cam.manual) return;
|
|
7863
9343
|
const { w, h } = nostromo.size;
|
|
7864
9344
|
let minX = -CORE_R * 2.4, maxX = CORE_R * 2.4, minY = -CORE_R * 2.4, maxY = CORE_R * 2.4;
|
|
9345
|
+
// Across, the frame holds the body at every turn it can take, so turning it never makes the
|
|
9346
|
+
// camera breathe in and out; up and down it follows what is on screen.
|
|
9347
|
+
const near = VIEW_DISTANCE / (VIEW_DISTANCE - NOSTROMO_ORBIT * BRAIN_WIDTH);
|
|
7865
9348
|
for (const node of nostromo.nodes) {
|
|
7866
|
-
|
|
9349
|
+
const reach = Math.hypot(node.wx ?? node.x, node.wz ?? 0) * near + node.r * 3;
|
|
9350
|
+
minX = Math.min(minX, -reach); maxX = Math.max(maxX, reach);
|
|
7867
9351
|
minY = Math.min(minY, node.y - node.r * 3); maxY = Math.max(maxY, node.y + node.r * 3);
|
|
7868
9352
|
}
|
|
7869
9353
|
const pad = 70;
|
|
7870
9354
|
const scale = Math.min(1.4, Math.max(0.3, Math.min((w - pad * 2) / (maxX - minX || 1), (h - pad * 2 - 40) / (maxY - minY || 1))));
|
|
7871
9355
|
const cx = (minX + maxX) / 2;
|
|
7872
9356
|
const cy = (minY + maxY) / 2 - 20 / scale;
|
|
7873
|
-
cam.x += (cx - cam.x) * 0.05;
|
|
7874
|
-
cam.y += (cy - cam.y) * 0.05;
|
|
9357
|
+
if (!held) { cam.x += (cx - cam.x) * 0.05; cam.y += (cy - cam.y) * 0.05; }
|
|
7875
9358
|
cam.scale += (scale - cam.scale) * 0.05;
|
|
7876
9359
|
}
|
|
7877
9360
|
function toScreen(x, y) { const { w, h } = nostromo.size; const cam = nostromo.cam; return { x: w / 2 + (x - cam.x) * cam.scale, y: h / 2 + (y - cam.y) * cam.scale }; }
|
|
@@ -8056,6 +9539,7 @@ function drawNostromo(t) {
|
|
|
8056
9539
|
// Plasma from the core to every memory. The filament a memory is spoken to often burns a
|
|
8057
9540
|
// little brighter than one nobody has needed.
|
|
8058
9541
|
for (const node of nostromo.nodes) {
|
|
9542
|
+
if (node.forgetting) continue; // its wire is dust now
|
|
8059
9543
|
const d = Math.hypot(node.x, node.y) || 1;
|
|
8060
9544
|
const nx = -node.y / d, ny = node.x / d;
|
|
8061
9545
|
const bow = Math.sin(t * 1.7 + node.seed) * Math.min(60, d * 0.18);
|
|
@@ -8116,6 +9600,155 @@ function drawNostromo(t) {
|
|
|
8116
9600
|
ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.stroke();
|
|
8117
9601
|
}
|
|
8118
9602
|
|
|
9603
|
+
// One memory, drawn where the projection put it. Called twice over the list: the ones behind
|
|
9604
|
+
// MOTHER before her, so she hides them, and the ones in front after her.
|
|
9605
|
+
const drawMemory = (node) => {
|
|
9606
|
+
const micro = 1 + 0.05 * Math.sin(t * node.rate + node.seed);
|
|
9607
|
+
const r = node.r * node.scale * micro;
|
|
9608
|
+
if (r <= 0) return;
|
|
9609
|
+
const arrive = Math.min(1.2, node.lit ?? 0);
|
|
9610
|
+
// Two things decide how brightly a dwarf burns. Maturity is what the room has made of it
|
|
9611
|
+
// over time: how often it has been reached for, how lately, how woven into the rest. Feeding
|
|
9612
|
+
// is what it is being given right now, by MOTHER and by its neighbours at once. A young
|
|
9613
|
+
// memory nobody feeds is nearly out; an old one the room leans on runs white.
|
|
9614
|
+
const grown = node.activity ?? 0;
|
|
9615
|
+
const fed = Math.min(1, (node.charge ?? 0) + 0.25 * arrive);
|
|
9616
|
+
const burn = Math.min(1, 0.4 * grown + 0.75 * fed);
|
|
9617
|
+
|
|
9618
|
+
// A collapsed body. This one is not a star and it is not a furnace either: it is a claim the
|
|
9619
|
+
// room established is false, and what it looks like is absence. A round black nothing, the
|
|
9620
|
+
// dust and stars of the field standing around it, and the faint cold light of what passes
|
|
9621
|
+
// close enough to be bent round the rim instead of going straight.
|
|
9622
|
+
//
|
|
9623
|
+
// There is no disc and nothing burns. The whole of it is the dark, and the dark is the point.
|
|
9624
|
+
if (node.memory.kind === COLLAPSED) {
|
|
9625
|
+
const hole = r * 1.35;
|
|
9626
|
+
const field = hole * 2.4;
|
|
9627
|
+
const turn = node.seed + node.spin * 0.12;
|
|
9628
|
+
const lit = 0.45 + 0.55 * burn;
|
|
9629
|
+
|
|
9630
|
+
// The field it sits in: specks of dust and far stars, each on its own slow flicker.
|
|
9631
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
9632
|
+
for (let i = 0; i < VOID_DUST.length; i += 1) {
|
|
9633
|
+
const speck = VOID_DUST[i];
|
|
9634
|
+
const at = hole * 1.06 + (field - hole * 1.06) * speck.out;
|
|
9635
|
+
const angle = speck.angle + turn;
|
|
9636
|
+
const x = node.x + Math.cos(angle) * at;
|
|
9637
|
+
const y = node.y + Math.sin(angle) * at;
|
|
9638
|
+
// Fading outward, so the field thins into the dark rather than stopping.
|
|
9639
|
+
const fade = (1 - speck.out * 0.72) * lit * (0.45 + 0.55 * Math.abs(Math.sin(t * speck.rate + speck.phase)));
|
|
9640
|
+
const size = speck.size * (1 + 0.25 * burn) / cam.scale;
|
|
9641
|
+
const glow = ctx.createRadialGradient(x, y, 0, x, y, size * 3.2);
|
|
9642
|
+
glow.addColorStop(0, `rgba(${speck.colour}, ${fade})`);
|
|
9643
|
+
glow.addColorStop(0.35, `rgba(${speck.colour}, ${fade * 0.3})`);
|
|
9644
|
+
glow.addColorStop(1, `rgba(${speck.colour}, 0)`);
|
|
9645
|
+
ctx.fillStyle = glow;
|
|
9646
|
+
ctx.beginPath(); ctx.arc(x, y, size * 3.2, 0, Math.PI * 2); ctx.fill();
|
|
9647
|
+
}
|
|
9648
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
9649
|
+
|
|
9650
|
+
// The void. Black all the way through, with just enough softness at the rim that it reads
|
|
9651
|
+
// as depth rather than as a sticker.
|
|
9652
|
+
const pit = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, hole * 1.1);
|
|
9653
|
+
pit.addColorStop(0, 'rgba(0, 0, 0, 1)');
|
|
9654
|
+
pit.addColorStop(0.86, 'rgba(0, 0, 0, 1)');
|
|
9655
|
+
pit.addColorStop(0.95, 'rgba(0, 0, 2, .82)');
|
|
9656
|
+
pit.addColorStop(1, 'rgba(0, 0, 4, 0)');
|
|
9657
|
+
ctx.fillStyle = pit;
|
|
9658
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, hole * 1.1, 0, Math.PI * 2); ctx.fill();
|
|
9659
|
+
|
|
9660
|
+
// What passes close enough is bent round instead of going straight: a cold ring of
|
|
9661
|
+
// smeared light gripping the rim, in a few arcs that do not quite close.
|
|
9662
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
9663
|
+
const halo = ctx.createRadialGradient(node.x, node.y, hole * 0.94, node.x, node.y, hole * 1.3);
|
|
9664
|
+
halo.addColorStop(0, 'rgba(150, 190, 255, 0)');
|
|
9665
|
+
halo.addColorStop(0.3, `rgba(168, 204, 255, ${0.26 * lit})`);
|
|
9666
|
+
halo.addColorStop(0.55, `rgba(120, 160, 230, ${0.12 * lit})`);
|
|
9667
|
+
halo.addColorStop(1, 'rgba(90, 130, 210, 0)');
|
|
9668
|
+
ctx.fillStyle = halo;
|
|
9669
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, hole * 1.3, 0, Math.PI * 2); ctx.fill();
|
|
9670
|
+
ctx.lineCap = 'round';
|
|
9671
|
+
for (let arc = 0; arc < 4; arc += 1) {
|
|
9672
|
+
const at = hole * (1.005 + arc * 0.028);
|
|
9673
|
+
const from = turn * (1 + arc * 0.35) + arc * 1.9;
|
|
9674
|
+
const span = 0.9 + 0.5 * Math.sin(t * 0.21 + arc);
|
|
9675
|
+
ctx.strokeStyle = `rgba(${arc % 2 ? '198, 222, 255' : '146, 186, 250'}, ${(0.3 - 0.05 * arc) * lit})`;
|
|
9676
|
+
ctx.lineWidth = (1.4 - 0.25 * arc) / cam.scale;
|
|
9677
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, at, from, from + span); ctx.stroke();
|
|
9678
|
+
}
|
|
9679
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
9680
|
+
|
|
9681
|
+
if (node === nostromo.hover && !nostromo.selected) drawSight(ctx, node.x, node.y, field, t, cam.scale);
|
|
9682
|
+
return;
|
|
9683
|
+
}
|
|
9684
|
+
|
|
9685
|
+
// Drawn pixel by pixel wherever there is a buffer to paint into.
|
|
9686
|
+
const sprite = dwarfSprite(node, node.r * node.scale, burn, cam.scale * dpr, t);
|
|
9687
|
+
if (sprite) ctx.drawImage(sprite, node.x - r, node.y - r, r * 2, r * 2);
|
|
9688
|
+
else {
|
|
9689
|
+
ctx.save();
|
|
9690
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI * 2); ctx.closePath();
|
|
9691
|
+
// The ground, struck from the body's own middle: a star is not lit from a corner. It runs
|
|
9692
|
+
// white at the centre the harder it burns and keeps its class's colour outward.
|
|
9693
|
+
const ground = ctx.createRadialGradient(node.x, node.y, r * 0.03, node.x, node.y, r);
|
|
9694
|
+
ground.addColorStop(0, hexMix(node.color, '#ffffff', 0.5 + 0.5 * burn));
|
|
9695
|
+
ground.addColorStop(0.45, hexMix(node.color, '#ffffff', 0.12 + 0.4 * burn));
|
|
9696
|
+
ground.addColorStop(0.85, hexMix(node.color, '#000000', 0.2 - 0.2 * burn));
|
|
9697
|
+
ground.addColorStop(1, hexMix(node.color, '#000000', 0.34 - 0.24 * burn));
|
|
9698
|
+
ctx.fillStyle = ground;
|
|
9699
|
+
ctx.fill();
|
|
9700
|
+
ctx.clip();
|
|
9701
|
+
// The face: the boiling surface a star of this class has, turning slowly, laid over the
|
|
9702
|
+
// whole body. This is most of what is seen, so it is laid down at close to full strength.
|
|
9703
|
+
const face = dwarfTexture(node.color);
|
|
9704
|
+
if (face) {
|
|
9705
|
+
// Wrapped on a sphere, half the strip across the visible face, in columns that narrow
|
|
9706
|
+
// toward the limb: the cells slide in from one edge, cross wide and leave thin, which is
|
|
9707
|
+
// what reads as a body turning rather than a picture sliding behind a hole.
|
|
9708
|
+
const strip = face.width / 2;
|
|
9709
|
+
const turn = ((node.spin * 0.3 + node.seed) / (Math.PI * 2) % 1 + 1) % 1;
|
|
9710
|
+
ctx.globalAlpha = 0.72 + 0.26 * burn;
|
|
9711
|
+
for (let column = 0; column < SKIN_COLUMNS; column += 1) {
|
|
9712
|
+
const x0 = SKIN_EDGES[column];
|
|
9713
|
+
const x1 = SKIN_EDGES[column + 1];
|
|
9714
|
+
ctx.drawImage(face, (turn + column / (SKIN_COLUMNS * 2)) * strip, 0, strip / (SKIN_COLUMNS * 2), face.height,
|
|
9715
|
+
node.x + x0 * r * 1.06, node.y - r * 1.06, Math.max(0.5, (x1 - x0) * r * 1.06), r * 2.12);
|
|
9716
|
+
}
|
|
9717
|
+
ctx.globalAlpha = 1;
|
|
9718
|
+
}
|
|
9719
|
+
// The hottest of it burns through the face, the way an active region does.
|
|
9720
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
9721
|
+
const core = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, r * 0.92);
|
|
9722
|
+
core.addColorStop(0, hexAlpha(hexMix(node.color, '#ffffff', 0.9), 0.2 + 0.45 * burn));
|
|
9723
|
+
core.addColorStop(0.5, hexAlpha(hexMix(node.color, '#ffffff', 0.5), 0.08 + 0.2 * burn));
|
|
9724
|
+
core.addColorStop(1, hexAlpha(node.color, 0));
|
|
9725
|
+
ctx.fillStyle = core;
|
|
9726
|
+
ctx.fillRect(node.x - r, node.y - r, r * 2, r * 2);
|
|
9727
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
9728
|
+
ctx.restore();
|
|
9729
|
+
}
|
|
9730
|
+
|
|
9731
|
+
// The glow: the light a star gives off, clinging to it in a tight bloom the width of a finger,
|
|
9732
|
+
// the same all the way round because nothing else is lighting it. It carries past the body
|
|
9733
|
+
// and stops there, so it belongs to the star rather than hanging off it.
|
|
9734
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
9735
|
+
const air = ctx.createRadialGradient(node.x, node.y, r * 0.99, node.x, node.y, r * 1.42);
|
|
9736
|
+
// It starts at the limb, never over it, and no brighter than the limb itself: scattered
|
|
9737
|
+
// light falling away from the body, with no seam and no ring. A cooling dwarf gives less of
|
|
9738
|
+
// it, in the same proportion its face has dimmed.
|
|
9739
|
+
const cooling = node.cold ? COLD_GLOW : 1;
|
|
9740
|
+
air.addColorStop(0, hexAlpha(hexMix(node.color, '#ffffff', 0.2 + 0.3 * burn), (0.16 + 0.34 * burn) * cooling + 0.2 * arrive));
|
|
9741
|
+
air.addColorStop(0.25, hexAlpha(hexMix(node.color, '#ffffff', 0.1 + 0.2 * burn), (0.07 + 0.16 * burn) * cooling + 0.1 * arrive));
|
|
9742
|
+
air.addColorStop(1, hexAlpha(node.color, 0));
|
|
9743
|
+
ctx.fillStyle = air;
|
|
9744
|
+
ctx.beginPath(); ctx.arc(node.x, node.y, r * 1.34, 0, Math.PI * 2); ctx.fill();
|
|
9745
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
9746
|
+
|
|
9747
|
+
if (node === nostromo.hover && !nostromo.selected) drawSight(ctx, node.x, node.y, r * 1.1, t, cam.scale);
|
|
9748
|
+
};
|
|
9749
|
+
const ordered = [...nostromo.nodes].sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0));
|
|
9750
|
+
for (const node of ordered) if ((node.depth ?? 0) < 0) drawMemory(node);
|
|
9751
|
+
|
|
8119
9752
|
// ---- MOTHER's core: a body, not a disc.
|
|
8120
9753
|
//
|
|
8121
9754
|
// Everything on the surface lives at a latitude and a longitude and is projected before it
|
|
@@ -8123,8 +9756,11 @@ function drawNostromo(t) {
|
|
|
8123
9756
|
// Three cues do the work: features that travel on a real sphere, an edge that darkens into
|
|
8124
9757
|
// shadow, and one fixed light that decides which side is day. The planet turns; the sun
|
|
8125
9758
|
// stays where it is.
|
|
8126
|
-
|
|
8127
|
-
|
|
9759
|
+
// The human's hand turns the star with the body around it: the turn adds to its longitude and
|
|
9760
|
+
// the tip to its lean, in the same order the memories are projected in.
|
|
9761
|
+
const yaw = nostromo.yaw ?? 0;
|
|
9762
|
+
const sinT = Math.sin(CORE_TILT + (nostromo.pitch ?? 0));
|
|
9763
|
+
const cosT = Math.cos(CORE_TILT + (nostromo.pitch ?? 0));
|
|
8128
9764
|
const spin = nostromo.spin ?? 0;
|
|
8129
9765
|
// A point on the surface: where it lands on screen, how much it faces us, what light it takes.
|
|
8130
9766
|
const surface = (lat, lon) => {
|
|
@@ -8172,73 +9808,83 @@ function drawNostromo(t) {
|
|
|
8172
9808
|
ctx.shadowBlur = 0;
|
|
8173
9809
|
ctx.clip();
|
|
8174
9810
|
|
|
8175
|
-
//
|
|
8176
|
-
//
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
8190
|
-
|
|
8191
|
-
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
const
|
|
8195
|
-
|
|
8196
|
-
const
|
|
8197
|
-
|
|
9811
|
+
// Molten rock, painted pixel by pixel wherever there is a buffer to paint into: plates of
|
|
9812
|
+
// crust, cracks running white-hot, lava flowing under them, bursts going off inside.
|
|
9813
|
+
const magma = magmaFace(R, cam.scale * dpr, t, { turn: spin - yaw, beat, alarm, reduced: nostromo.reduced });
|
|
9814
|
+
if (magma) ctx.drawImage(magma, -R, -R, R * 2, R * 2);
|
|
9815
|
+
else {
|
|
9816
|
+
// The skin, wrapped on. Each row of the disc takes the slice of the strip belonging to its
|
|
9817
|
+
// latitude, cut into pieces so longitude runs as a sphere's does and the cells narrow toward
|
|
9818
|
+
// the limb instead of smearing along it. Turning the star is moving the window along.
|
|
9819
|
+
// Someone who asked for stillness gets a star that turns and breathes but does not churn.
|
|
9820
|
+
const flowTime = nostromo.reduced ? 0 : t;
|
|
9821
|
+
const grain = granuleTexture();
|
|
9822
|
+
if (grain) {
|
|
9823
|
+
const width = grain.width / 2;
|
|
9824
|
+
const wrapAt = (turn, alpha, lift) => {
|
|
9825
|
+
ctx.globalAlpha = alpha;
|
|
9826
|
+
for (let row = 0; row < GRAIN_ROWS; row += 1) {
|
|
9827
|
+
const y0 = -R + (row / GRAIN_ROWS) * 2 * R;
|
|
9828
|
+
const y1 = -R + ((row + 1) / GRAIN_ROWS) * 2 * R;
|
|
9829
|
+
const mid = (y0 + y1) * 0.5;
|
|
9830
|
+
const half = Math.sqrt(Math.max(0, R * R - mid * mid));
|
|
9831
|
+
if (half < 0.5) continue;
|
|
9832
|
+
const v0 = (((y0 + R) / (2 * R) + lift) % 1 + 1) % 1 * grain.height;
|
|
9833
|
+
const dv = Math.max(1, ((y1 - y0) / (2 * R)) * grain.height);
|
|
9834
|
+
for (let piece = 0; piece < GRAIN_PIECES; piece += 1) {
|
|
9835
|
+
const xA = Math.sin((piece / GRAIN_PIECES - 0.5) * Math.PI) * half;
|
|
9836
|
+
const xB = Math.sin(((piece + 1) / GRAIN_PIECES - 0.5) * Math.PI) * half;
|
|
9837
|
+
const u = (turn + (piece / GRAIN_PIECES) * 0.5) * width;
|
|
9838
|
+
ctx.drawImage(grain, u, Math.min(v0, grain.height - dv), (width * 0.5) / GRAIN_PIECES, dv, xA, y0, Math.max(0.5, xB - xA), Math.max(1, y1 - y0));
|
|
9839
|
+
}
|
|
8198
9840
|
}
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
9841
|
+
ctx.globalAlpha = 1;
|
|
9842
|
+
};
|
|
9843
|
+
// Advancing the window carries the skin from right to left, so a turn of the hand to the
|
|
9844
|
+
// right carries it back the other way, with the memories.
|
|
9845
|
+
const turn = (((spin - yaw) / (Math.PI * 2)) % 1 + 1) % 1;
|
|
9846
|
+
wrapAt(turn, 0.95, 0);
|
|
9847
|
+
// The same skin again, crawling at its own pace: where the two pull apart the surface
|
|
9848
|
+
// churns, and that is the slowness of lava rather than the flicker of fire.
|
|
9849
|
+
ctx.globalCompositeOperation = 'lighter';
|
|
9850
|
+
wrapAt(((turn * 0.83 + flowTime * 0.0042) % 1 + 1) % 1, 0.32, 0.37);
|
|
9851
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
9852
|
+
}
|
|
8210
9853
|
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
9854
|
+
// Masses of molten matter riding the surface. They are slow, they are large, and each keeps
|
|
9855
|
+
// its own drift, so the face is never the same face twice.
|
|
9856
|
+
for (let i = 0; i < CORE_FLOWS.length; i += 1) {
|
|
9857
|
+
const flow = CORE_FLOWS[i];
|
|
9858
|
+
// The masses ride the same way the skin turns. They used to travel against it, and the two
|
|
9859
|
+
// motions cancelled into a surface that churned in place instead of turning.
|
|
9860
|
+
const p = surface(flow.lat + 0.1 * Math.sin(flowTime * flow.rate * 0.6 + i), flow.lon - spin * 0.96 + yaw + flowTime * flow.drift);
|
|
9861
|
+
if (p.z <= 0.02) continue;
|
|
9862
|
+
const swell = 0.62 + 0.38 * Math.sin(flowTime * flow.rate + i * 1.7);
|
|
9863
|
+
const fade = Math.min(1, p.z * 2.3) * swell;
|
|
9864
|
+
ctx.save();
|
|
9865
|
+
ctx.translate(p.x * R, p.y * R);
|
|
9866
|
+
ctx.rotate(Math.atan2(p.y, p.x));
|
|
9867
|
+
ctx.scale(Math.max(0.05, p.z), 1);
|
|
9868
|
+
const molten = ctx.createRadialGradient(0, 0, 0, 0, 0, R * flow.size);
|
|
9869
|
+
molten.addColorStop(0, `rgba(255, ${flow.hot ? 214 : 96}, ${flow.hot ? 132 : 30}, ${(flow.hot ? 0.46 : 0.2) * fade})`);
|
|
9870
|
+
molten.addColorStop(0.42, `rgba(${flow.hot ? '255, 122, 36' : '176, 34, 10'}, ${(flow.hot ? 0.24 : 0.14) * fade})`);
|
|
9871
|
+
molten.addColorStop(1, 'rgba(120, 16, 8, 0)');
|
|
9872
|
+
ctx.fillStyle = molten;
|
|
9873
|
+
ctx.beginPath(); ctx.arc(0, 0, R * flow.size, 0, Math.PI * 2); ctx.fill();
|
|
9874
|
+
ctx.restore();
|
|
9875
|
+
}
|
|
8231
9876
|
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
9877
|
+
// Limb darkening, hard. Looking at the edge of a star means looking through far more of its
|
|
9878
|
+
// own gas, and on a body this dark the edge goes almost to black. This is the whole of the
|
|
9879
|
+
// illusion: it is what gives the thing its weight.
|
|
9880
|
+
const limb = ctx.createRadialGradient(0, 0, R * 0.34, 0, 0, R);
|
|
9881
|
+
limb.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
|
9882
|
+
limb.addColorStop(0.58, `rgba(96, 8, 4, ${0.3 - 0.05 * beat})`);
|
|
9883
|
+
limb.addColorStop(0.85, `rgba(50, 2, 4, ${0.62 - 0.08 * beat})`);
|
|
9884
|
+
limb.addColorStop(1, `rgba(14, 0, 2, ${0.9 - 0.1 * beat})`);
|
|
9885
|
+
ctx.fillStyle = limb;
|
|
9886
|
+
ctx.fillRect(-R, -R, R * 2, R * 2);
|
|
9887
|
+
}
|
|
8242
9888
|
ctx.restore();
|
|
8243
9889
|
|
|
8244
9890
|
// Prominences: arches of matter torn off the limb, standing up and falling back. Few, large
|
|
@@ -8280,149 +9926,11 @@ function drawNostromo(t) {
|
|
|
8280
9926
|
|
|
8281
9927
|
// ---- Memories: dwarf stars.
|
|
8282
9928
|
//
|
|
8283
|
-
// A dwarf makes its own light, so none of it is in shadow.
|
|
8284
|
-
//
|
|
8285
|
-
//
|
|
8286
|
-
//
|
|
8287
|
-
|
|
8288
|
-
// with its neighbours.
|
|
8289
|
-
for (const node of nostromo.nodes) {
|
|
8290
|
-
const micro = 1 + 0.05 * Math.sin(t * node.rate + node.seed);
|
|
8291
|
-
const r = node.r * node.scale * micro;
|
|
8292
|
-
if (r <= 0) continue;
|
|
8293
|
-
const arrive = Math.min(1.2, node.lit ?? 0);
|
|
8294
|
-
// Two things decide how brightly a dwarf burns. Maturity is what the room has made of it
|
|
8295
|
-
// over time: how often it has been reached for, how lately, how woven into the rest. Feeding
|
|
8296
|
-
// is what it is being given right now, by MOTHER and by its neighbours at once. A young
|
|
8297
|
-
// memory nobody feeds is nearly out; an old one the room leans on runs white.
|
|
8298
|
-
const grown = node.activity ?? 0;
|
|
8299
|
-
const fed = Math.min(1, (node.charge ?? 0) + 0.25 * arrive);
|
|
8300
|
-
const burn = Math.min(1, 0.4 * grown + 0.75 * fed);
|
|
8301
|
-
|
|
8302
|
-
// A collapsed body. This one is not a star and it is not a furnace either: it is a claim the
|
|
8303
|
-
// room established is false, and what it looks like is absence. A round black nothing, the
|
|
8304
|
-
// dust and stars of the field standing around it, and the faint cold light of what passes
|
|
8305
|
-
// close enough to be bent round the rim instead of going straight.
|
|
8306
|
-
//
|
|
8307
|
-
// There is no disc and nothing burns. The whole of it is the dark, and the dark is the point.
|
|
8308
|
-
if (node.memory.kind === COLLAPSED) {
|
|
8309
|
-
const hole = r * 1.35;
|
|
8310
|
-
const field = hole * 2.4;
|
|
8311
|
-
const turn = node.seed + node.spin * 0.12;
|
|
8312
|
-
const lit = 0.45 + 0.55 * burn;
|
|
8313
|
-
|
|
8314
|
-
// The field it sits in: specks of dust and far stars, each on its own slow flicker.
|
|
8315
|
-
ctx.globalCompositeOperation = 'lighter';
|
|
8316
|
-
for (let i = 0; i < VOID_DUST.length; i += 1) {
|
|
8317
|
-
const speck = VOID_DUST[i];
|
|
8318
|
-
const at = hole * 1.06 + (field - hole * 1.06) * speck.out;
|
|
8319
|
-
const angle = speck.angle + turn;
|
|
8320
|
-
const x = node.x + Math.cos(angle) * at;
|
|
8321
|
-
const y = node.y + Math.sin(angle) * at;
|
|
8322
|
-
// Fading outward, so the field thins into the dark rather than stopping.
|
|
8323
|
-
const fade = (1 - speck.out * 0.72) * lit * (0.45 + 0.55 * Math.abs(Math.sin(t * speck.rate + speck.phase)));
|
|
8324
|
-
const size = speck.size * (1 + 0.25 * burn) / cam.scale;
|
|
8325
|
-
const glow = ctx.createRadialGradient(x, y, 0, x, y, size * 3.2);
|
|
8326
|
-
glow.addColorStop(0, `rgba(${speck.colour}, ${fade})`);
|
|
8327
|
-
glow.addColorStop(0.35, `rgba(${speck.colour}, ${fade * 0.3})`);
|
|
8328
|
-
glow.addColorStop(1, `rgba(${speck.colour}, 0)`);
|
|
8329
|
-
ctx.fillStyle = glow;
|
|
8330
|
-
ctx.beginPath(); ctx.arc(x, y, size * 3.2, 0, Math.PI * 2); ctx.fill();
|
|
8331
|
-
}
|
|
8332
|
-
ctx.globalCompositeOperation = 'source-over';
|
|
8333
|
-
|
|
8334
|
-
// The void. Black all the way through, with just enough softness at the rim that it reads
|
|
8335
|
-
// as depth rather than as a sticker.
|
|
8336
|
-
const pit = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, hole * 1.1);
|
|
8337
|
-
pit.addColorStop(0, 'rgba(0, 0, 0, 1)');
|
|
8338
|
-
pit.addColorStop(0.86, 'rgba(0, 0, 0, 1)');
|
|
8339
|
-
pit.addColorStop(0.95, 'rgba(0, 0, 2, .82)');
|
|
8340
|
-
pit.addColorStop(1, 'rgba(0, 0, 4, 0)');
|
|
8341
|
-
ctx.fillStyle = pit;
|
|
8342
|
-
ctx.beginPath(); ctx.arc(node.x, node.y, hole * 1.1, 0, Math.PI * 2); ctx.fill();
|
|
8343
|
-
|
|
8344
|
-
// What passes close enough is bent round instead of going straight: a cold ring of
|
|
8345
|
-
// smeared light gripping the rim, in a few arcs that do not quite close.
|
|
8346
|
-
ctx.globalCompositeOperation = 'lighter';
|
|
8347
|
-
const halo = ctx.createRadialGradient(node.x, node.y, hole * 0.94, node.x, node.y, hole * 1.3);
|
|
8348
|
-
halo.addColorStop(0, 'rgba(150, 190, 255, 0)');
|
|
8349
|
-
halo.addColorStop(0.3, `rgba(168, 204, 255, ${0.26 * lit})`);
|
|
8350
|
-
halo.addColorStop(0.55, `rgba(120, 160, 230, ${0.12 * lit})`);
|
|
8351
|
-
halo.addColorStop(1, 'rgba(90, 130, 210, 0)');
|
|
8352
|
-
ctx.fillStyle = halo;
|
|
8353
|
-
ctx.beginPath(); ctx.arc(node.x, node.y, hole * 1.3, 0, Math.PI * 2); ctx.fill();
|
|
8354
|
-
ctx.lineCap = 'round';
|
|
8355
|
-
for (let arc = 0; arc < 4; arc += 1) {
|
|
8356
|
-
const at = hole * (1.005 + arc * 0.028);
|
|
8357
|
-
const from = turn * (1 + arc * 0.35) + arc * 1.9;
|
|
8358
|
-
const span = 0.9 + 0.5 * Math.sin(t * 0.21 + arc);
|
|
8359
|
-
ctx.strokeStyle = `rgba(${arc % 2 ? '198, 222, 255' : '146, 186, 250'}, ${(0.3 - 0.05 * arc) * lit})`;
|
|
8360
|
-
ctx.lineWidth = (1.4 - 0.25 * arc) / cam.scale;
|
|
8361
|
-
ctx.beginPath(); ctx.arc(node.x, node.y, at, from, from + span); ctx.stroke();
|
|
8362
|
-
}
|
|
8363
|
-
ctx.globalCompositeOperation = 'source-over';
|
|
8364
|
-
|
|
8365
|
-
if (node === nostromo.selected || node === nostromo.hover) {
|
|
8366
|
-
ctx.strokeStyle = hexAlpha(node.color, node === nostromo.selected ? 0.95 : 0.55);
|
|
8367
|
-
ctx.lineWidth = 1.2 / cam.scale;
|
|
8368
|
-
ctx.beginPath(); ctx.arc(node.x, node.y, field + 4 + 2 * Math.sin(t * 4), 0, Math.PI * 2); ctx.stroke();
|
|
8369
|
-
}
|
|
8370
|
-
continue;
|
|
8371
|
-
}
|
|
8372
|
-
|
|
8373
|
-
ctx.save();
|
|
8374
|
-
ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI * 2); ctx.closePath();
|
|
8375
|
-
// The ground, struck from the body's own middle: a star is not lit from a corner. It runs
|
|
8376
|
-
// white at the centre the harder it burns and keeps its class's colour outward.
|
|
8377
|
-
const ground = ctx.createRadialGradient(node.x, node.y, r * 0.03, node.x, node.y, r);
|
|
8378
|
-
ground.addColorStop(0, hexMix(node.color, '#ffffff', 0.5 + 0.5 * burn));
|
|
8379
|
-
ground.addColorStop(0.45, hexMix(node.color, '#ffffff', 0.12 + 0.4 * burn));
|
|
8380
|
-
ground.addColorStop(0.85, hexMix(node.color, '#000000', 0.2 - 0.2 * burn));
|
|
8381
|
-
ground.addColorStop(1, hexMix(node.color, '#000000', 0.34 - 0.24 * burn));
|
|
8382
|
-
ctx.fillStyle = ground;
|
|
8383
|
-
ctx.fill();
|
|
8384
|
-
ctx.clip();
|
|
8385
|
-
// The face: the boiling surface a star of this class has, turning slowly, laid over the
|
|
8386
|
-
// whole body. This is most of what is seen, so it is laid down at close to full strength.
|
|
8387
|
-
const face = dwarfTexture(node.color);
|
|
8388
|
-
if (face) {
|
|
8389
|
-
const strip = face.width / 2;
|
|
8390
|
-
const turn = ((node.spin * 0.1 + node.seed) / (Math.PI * 2) % 1 + 1) % 1;
|
|
8391
|
-
ctx.globalAlpha = 0.72 + 0.26 * burn;
|
|
8392
|
-
ctx.drawImage(face, turn * strip, 0, strip * 0.62, face.height, node.x - r * 1.06, node.y - r * 1.06, r * 2.12, r * 2.12);
|
|
8393
|
-
ctx.globalAlpha = 1;
|
|
8394
|
-
}
|
|
8395
|
-
// The hottest of it burns through the face, the way an active region does.
|
|
8396
|
-
ctx.globalCompositeOperation = 'lighter';
|
|
8397
|
-
const core = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, r * 0.92);
|
|
8398
|
-
core.addColorStop(0, hexAlpha(hexMix(node.color, '#ffffff', 0.9), 0.2 + 0.45 * burn));
|
|
8399
|
-
core.addColorStop(0.5, hexAlpha(hexMix(node.color, '#ffffff', 0.5), 0.08 + 0.2 * burn));
|
|
8400
|
-
core.addColorStop(1, hexAlpha(node.color, 0));
|
|
8401
|
-
ctx.fillStyle = core;
|
|
8402
|
-
ctx.fillRect(node.x - r, node.y - r, r * 2, r * 2);
|
|
8403
|
-
ctx.globalCompositeOperation = 'source-over';
|
|
8404
|
-
ctx.restore();
|
|
8405
|
-
|
|
8406
|
-
// The limb and the bloom, in one pass. On a body that lights itself the edge is the
|
|
8407
|
-
// brightest part of it: the light leaving sideways has the most of its own air to shine
|
|
8408
|
-
// through. It carries a finger's width past the body and stops there, so it belongs to the
|
|
8409
|
-
// star rather than hanging off it.
|
|
8410
|
-
ctx.globalCompositeOperation = 'lighter';
|
|
8411
|
-
const air = ctx.createRadialGradient(node.x, node.y, r * 0.74, node.x, node.y, r * 1.34);
|
|
8412
|
-
air.addColorStop(0, hexAlpha(node.color, 0));
|
|
8413
|
-
air.addColorStop(0.42, hexAlpha(hexMix(node.color, '#ffffff', 0.35 + 0.3 * burn), 0.2 + 0.45 * burn + 0.2 * arrive));
|
|
8414
|
-
air.addColorStop(0.58, hexAlpha(hexMix(node.color, '#ffffff', 0.2 + 0.2 * burn), 0.12 + 0.3 * burn + 0.15 * arrive));
|
|
8415
|
-
air.addColorStop(1, hexAlpha(node.color, 0));
|
|
8416
|
-
ctx.fillStyle = air;
|
|
8417
|
-
ctx.beginPath(); ctx.arc(node.x, node.y, r * 1.34, 0, Math.PI * 2); ctx.fill();
|
|
8418
|
-
ctx.globalCompositeOperation = 'source-over';
|
|
8419
|
-
|
|
8420
|
-
if (node === nostromo.selected || node === nostromo.hover) {
|
|
8421
|
-
ctx.strokeStyle = hexAlpha(node.color, node === nostromo.selected ? 0.95 : 0.55);
|
|
8422
|
-
ctx.lineWidth = 1.2 / cam.scale;
|
|
8423
|
-
ctx.beginPath(); ctx.arc(node.x, node.y, r + 6 + 2 * Math.sin(t * 4), 0, Math.PI * 2); ctx.stroke();
|
|
8424
|
-
}
|
|
8425
|
-
}
|
|
9929
|
+
// A dwarf makes its own light, so none of it is in shadow. Its face boils with granules, it
|
|
9930
|
+
// dims toward the limb the way every star does, and what light escapes clings to it in a tight
|
|
9931
|
+
// bloom the width of a finger. On top of that each breathes at a rate of its own: a
|
|
9932
|
+
// micro-pulsation, small enough that it is never a flash and never in time with its neighbours.
|
|
9933
|
+
for (const node of ordered) if ((node.depth ?? 0) >= 0) drawMemory(node);
|
|
8426
9934
|
// The cold zone, when it is asked for: a thin dusty ring around every memory the archive has
|
|
8427
9935
|
// had its chances with and never once carried. The mark is added rather than taken from the
|
|
8428
9936
|
// rest — these are already the dimmest things out here, and dimming everything else to find
|
|
@@ -8440,6 +9948,44 @@ function drawNostromo(t) {
|
|
|
8440
9948
|
ctx.restore();
|
|
8441
9949
|
}
|
|
8442
9950
|
|
|
9951
|
+
// The veil: everything falls back, and the held dwarf, its own wires and the sight are drawn
|
|
9952
|
+
// again above it.
|
|
9953
|
+
const lock = nostromo.lock;
|
|
9954
|
+
if (lock?.node && lock.fade > 0.01) {
|
|
9955
|
+
const held = lock.node;
|
|
9956
|
+
ctx.save();
|
|
9957
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
9958
|
+
ctx.fillStyle = `rgba(2, 1, 4, ${FOCUS_VEIL * lock.fade})`;
|
|
9959
|
+
ctx.fillRect(0, 0, w, h);
|
|
9960
|
+
ctx.restore();
|
|
9961
|
+
const id = held.memory.id;
|
|
9962
|
+
ctx.lineCap = 'round';
|
|
9963
|
+
for (const link of nostromo.links) {
|
|
9964
|
+
if (link.a !== id && link.b !== id) continue;
|
|
9965
|
+
const a = byId.get(link.a);
|
|
9966
|
+
const b = byId.get(link.b);
|
|
9967
|
+
if (!a || !b) continue;
|
|
9968
|
+
const other = a === held ? b : a;
|
|
9969
|
+
const line = ctx.createLinearGradient(held.x, held.y, other.x, other.y);
|
|
9970
|
+
line.addColorStop(0, hexAlpha(held.color, 0.85 * lock.fade));
|
|
9971
|
+
line.addColorStop(1, hexAlpha(other.color, 0.55 * lock.fade));
|
|
9972
|
+
ctx.strokeStyle = line;
|
|
9973
|
+
ctx.lineWidth = 1.2 / cam.scale;
|
|
9974
|
+
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(link.cx ?? (a.x + b.x) / 2, link.cy ?? (a.y + b.y) / 2, b.x, b.y); ctx.stroke();
|
|
9975
|
+
}
|
|
9976
|
+
ctx.globalAlpha = lock.fade;
|
|
9977
|
+
drawMemory(held);
|
|
9978
|
+
ctx.globalAlpha = 1;
|
|
9979
|
+
if (held === nostromo.selected) {
|
|
9980
|
+
const reach = held.memory.kind === COLLAPSED ? held.r * held.scale * 1.35 * 2.4 : held.r * held.scale * 1.1;
|
|
9981
|
+
drawSight(ctx, held.x, held.y, reach, t, cam.scale, { locked: true, age: t - lock.at, label: lockedLabel(held.memory), still: nostromo.reduced });
|
|
9982
|
+
}
|
|
9983
|
+
}
|
|
9984
|
+
|
|
9985
|
+
// Forgetting, above everything else: the implosions, and the dust their links became.
|
|
9986
|
+
for (const node of nostromo.nodes) if (node.forgetting) drawImplosion(ctx, node, t, cam.scale, nostromo.reduced);
|
|
9987
|
+
if (nostromo.dust?.length || nostromo.fading?.length) drawDust(ctx, t, cam.scale);
|
|
9988
|
+
|
|
8443
9989
|
// CODE000: the safety box. Bars fall from above and lock around the core.
|
|
8444
9990
|
if (nostromo.cage) {
|
|
8445
9991
|
const age = t - nostromo.cage.at;
|
|
@@ -8483,8 +10029,8 @@ function drawNostromo(t) {
|
|
|
8483
10029
|
ctx.fillRect(0, 0, w, h);
|
|
8484
10030
|
if (alarm) { ctx.fillStyle = `rgba(255, 30, 20, ${0.08 * alarm * (0.6 + 0.4 * Math.sin(t * 18))})`; ctx.fillRect(0, 0, w, h); }
|
|
8485
10031
|
|
|
8486
|
-
// The hovered memory, named on screen.
|
|
8487
|
-
if (nostromo.hover && nostromo.hover.scale > 0.5) {
|
|
10032
|
+
// The hovered memory, named on screen; not while another one is held, which is the one being read.
|
|
10033
|
+
if (nostromo.hover && nostromo.hover.scale > 0.5 && !nostromo.selected) {
|
|
8488
10034
|
const node = nostromo.hover;
|
|
8489
10035
|
const p = toScreen(node.x, node.y);
|
|
8490
10036
|
const label = `${kindWord(node.memory.kind)} · ${node.memory.text.length > 72 ? `${node.memory.text.slice(0, 71)}…` : node.memory.text}`;
|
|
@@ -8511,30 +10057,48 @@ function hexMix(hex, other, amount) {
|
|
|
8511
10057
|
return `#${[16, 8, 0].map((shift) => ch(shift).toString(16).padStart(2, '0')).join('')}`;
|
|
8512
10058
|
}
|
|
8513
10059
|
|
|
10060
|
+
// Whether there is a camera to answer the pointer with. The canvas takes events from the moment
|
|
10061
|
+
// it is on the page, and the camera only exists once the map has started: a pointer that moved
|
|
10062
|
+
// over it while the archive was still loading reached for a camera that was not there yet.
|
|
10063
|
+
function nostromoLive() { return Boolean(nostromo.cam && nostromo.size); }
|
|
10064
|
+
|
|
8514
10065
|
// Pointer: what is under it, in world space; the core counts too.
|
|
8515
10066
|
function nostromoAt(event) {
|
|
10067
|
+
if (!nostromoLive()) return null;
|
|
8516
10068
|
const rect = nostromo.canvas.getBoundingClientRect();
|
|
8517
10069
|
const p = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
|
8518
10070
|
const slack = 8 / nostromo.cam.scale;
|
|
8519
10071
|
let best = null;
|
|
10072
|
+
const core = Math.hypot(p.x, p.y) <= CORE_R * 1.15;
|
|
8520
10073
|
for (const node of nostromo.nodes) {
|
|
10074
|
+
if (node.forgetting) continue;
|
|
8521
10075
|
const d = Math.hypot(node.x - p.x, node.y - p.y);
|
|
8522
|
-
if (d
|
|
10076
|
+
if (d > node.r * node.scale + slack) continue;
|
|
10077
|
+
// What MOTHER hides cannot be clicked through her, and of two that overlap, the nearer wins.
|
|
10078
|
+
if (core && (node.depth ?? 0) < 0) continue;
|
|
10079
|
+
if (!best || (node.depth ?? 0) > (best.node.depth ?? 0)) best = { node, d };
|
|
8523
10080
|
}
|
|
8524
10081
|
if (best) return best.node;
|
|
8525
10082
|
return Math.hypot(p.x, p.y) <= CORE_R * 1.15 ? 'core' : null;
|
|
8526
10083
|
}
|
|
8527
|
-
// Drag pans, wheel zooms about the pointer, a still
|
|
8528
|
-
|
|
8529
|
-
|
|
10084
|
+
// Drag turns the archive in the hand, Shift+drag pans, wheel zooms about the pointer, a still
|
|
10085
|
+
// click selects. Let go and it stays where it was left; RECENTER brings back the side view.
|
|
10086
|
+
const drag = { active: false, moved: false, pan: false, x: 0, y: 0 };
|
|
10087
|
+
nostromo.canvas?.addEventListener('mousedown', (event) => { drag.active = true; drag.moved = false; drag.pan = event.shiftKey; drag.x = event.clientX; drag.y = event.clientY; });
|
|
8530
10088
|
nostromo.canvas?.addEventListener('mousemove', (event) => {
|
|
8531
10089
|
if (drag.active) {
|
|
8532
10090
|
const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
|
|
8533
10091
|
if (Math.hypot(dx, dy) > 3) drag.moved = true;
|
|
8534
10092
|
if (drag.moved) {
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
10093
|
+
if (drag.pan && nostromoLive()) {
|
|
10094
|
+
nostromo.cam.manual = true;
|
|
10095
|
+
nostromo.cam.x -= dx / nostromo.cam.scale;
|
|
10096
|
+
nostromo.cam.y -= dy / nostromo.cam.scale;
|
|
10097
|
+
} else if (!drag.pan) {
|
|
10098
|
+
if (nostromo.lock) nostromo.lock.aim = false;
|
|
10099
|
+
nostromo.yaw = (nostromo.yaw ?? 0) + dx * TURN_PER_PIXEL;
|
|
10100
|
+
nostromo.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, (nostromo.pitch ?? 0) + dy * TURN_PER_PIXEL));
|
|
10101
|
+
}
|
|
8538
10102
|
drag.x = event.clientX; drag.y = event.clientY;
|
|
8539
10103
|
nostromo.canvas.classList.add('dragging');
|
|
8540
10104
|
document.querySelector('#nostromo-recenter')?.removeAttribute('hidden');
|
|
@@ -8549,6 +10113,7 @@ window.addEventListener?.('mouseup', () => { drag.active = false; nostromo.canva
|
|
|
8549
10113
|
nostromo.canvas?.addEventListener('mouseleave', () => { nostromo.hover = null; });
|
|
8550
10114
|
nostromo.canvas?.addEventListener('wheel', (event) => {
|
|
8551
10115
|
event.preventDefault();
|
|
10116
|
+
if (!nostromoLive()) return;
|
|
8552
10117
|
const rect = nostromo.canvas.getBoundingClientRect();
|
|
8553
10118
|
const before = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
|
8554
10119
|
const factor = Math.exp(-event.deltaY * 0.0012);
|
|
@@ -8568,7 +10133,7 @@ nostromo.canvas?.addEventListener('click', (event) => {
|
|
|
8568
10133
|
if (!at) { nostromo.selected = null; nostromo.card.hidden = true; return; }
|
|
8569
10134
|
showNostromoCard(at);
|
|
8570
10135
|
});
|
|
8571
|
-
document.querySelector('#nostromo-recenter')?.addEventListener('click', (event) => { nostromo.cam.manual = false; event.currentTarget.setAttribute('hidden', ''); });
|
|
10136
|
+
document.querySelector('#nostromo-recenter')?.addEventListener('click', (event) => { if (nostromo.cam) nostromo.cam.manual = false; nostromo.yaw = 0; nostromo.pitch = 0; event.currentTarget.setAttribute('hidden', ''); });
|
|
8572
10137
|
|
|
8573
10138
|
// MOTHER answering an attempt on her archive, never twice the same way; eight in a row and
|
|
8574
10139
|
// CODE000 comes down: the safety box around her, the archive sealed, a coded word to the crew,
|
|
@@ -8652,7 +10217,7 @@ async function code000(count) {
|
|
|
8652
10217
|
clearTimeout(alarmTimer);
|
|
8653
10218
|
document.querySelector('#nostromo-alert')?.setAttribute('hidden', '');
|
|
8654
10219
|
nostromo.cage = { at: performance.now() / 1000 };
|
|
8655
|
-
nostromo.cam.manual = false;
|
|
10220
|
+
if (nostromo.cam) nostromo.cam.manual = false;
|
|
8656
10221
|
const alert = document.querySelector('#nostromo-alert');
|
|
8657
10222
|
if (alert) {
|
|
8658
10223
|
alert.querySelectorAll('.line').forEach((node, index) => { node.textContent = [t('CODE000 · SPECIAL ORDER 937 IN EFFECT.'), t('THE ARCHIVE IS SEALED. THE CREW HAS BEEN TOLD.'), t('LEAVE MY SHIP, INTRUDER.')][index] ?? ''; });
|
|
@@ -8732,6 +10297,10 @@ function showNostromoCard(node) {
|
|
|
8732
10297
|
document.querySelector('#nostromo-card-span').textContent = memory.fromSequence === memory.throughSequence ? `#${memory.fromSequence}` : `#${memory.fromSequence}–#${memory.throughSequence}${memory.sources?.length ? ` · cites ${memory.sources.map((n) => `#${n}`).join(' ')}` : ''}`;
|
|
8733
10298
|
document.querySelector('#nostromo-card-agent').textContent = `@${memory.agent}${memory.origin === 'noted' ? t(" · on the human's request") : memory.origin === 'flagged' ? t(' · flagged by {who}', { who: memory.detector ?? t('the room') }) : t(' · distilled')}`;
|
|
8734
10299
|
document.querySelector('#nostromo-card-when').textContent = memory.created ? new Date(memory.created).toLocaleString() : '';
|
|
10300
|
+
const zone = document.querySelector('#nostromo-card-zone');
|
|
10301
|
+
zone.textContent = zoneWord(memory.zone ?? 'hold');
|
|
10302
|
+
zone.className = travels(memory) ? 'zone underway' : 'zone archived';
|
|
10303
|
+
zone.title = ZONE_NOTE[memory.zone ?? 'hold'] ?? '';
|
|
8735
10304
|
|
|
8736
10305
|
// The wires it has on the map, strongest first.
|
|
8737
10306
|
const neighbours = neighboursOf(memory.id);
|
|
@@ -8866,8 +10435,7 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
|
|
|
8866
10435
|
const response = await fetch(`/api/memory/${node.memory.id}`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ designation: nostromoDesignation() }) });
|
|
8867
10436
|
const result = await response.json().catch(() => ({}));
|
|
8868
10437
|
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
8869
|
-
node.
|
|
8870
|
-
nostromo.links = nostromo.links.filter((link) => link.a !== node.memory.id && link.b !== node.memory.id);
|
|
10438
|
+
beginImplosion(node, performance.now() / 1000);
|
|
8871
10439
|
nostromo.card.hidden = true;
|
|
8872
10440
|
nostromo.selected = null;
|
|
8873
10441
|
const left = result.stats?.memories ?? Math.max(0, nostromo.nodes.length - 1);
|
|
@@ -8885,38 +10453,107 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
|
|
|
8885
10453
|
|
|
8886
10454
|
/* ---------- First contact: the four-step tour. Once on the first visit, again from ? in MU/TH/UR. ---------- */
|
|
8887
10455
|
|
|
10456
|
+
// First contact: four stops on the room itself. Each one lights the part of the screen it talks
|
|
10457
|
+
// about and leaves the rest in the dark, so a person learns where things are and not only what
|
|
10458
|
+
// they are called. If a stop's part of the screen is not there to light (the bridge is up, the
|
|
10459
|
+
// window is narrow), the card says its piece in the middle instead.
|
|
8888
10460
|
const TOUR_STEPS = [
|
|
8889
|
-
{
|
|
8890
|
-
|
|
8891
|
-
t('
|
|
8892
|
-
t('
|
|
8893
|
-
|
|
8894
|
-
{
|
|
8895
|
-
|
|
8896
|
-
t('
|
|
8897
|
-
t('
|
|
8898
|
-
|
|
8899
|
-
|
|
8900
|
-
|
|
8901
|
-
|
|
8902
|
-
|
|
8903
|
-
|
|
8904
|
-
|
|
8905
|
-
|
|
8906
|
-
|
|
8907
|
-
|
|
8908
|
-
|
|
10461
|
+
{
|
|
10462
|
+
target: '#composer',
|
|
10463
|
+
title: t('Your crew, one box'),
|
|
10464
|
+
text: t('The AI agents already on this computer work here together. Pick one in the row above the box, or write @claude, and ask. Every reply says who answered, in which mode and how many tokens it cost. Nothing leaves this computer on its own.'),
|
|
10465
|
+
},
|
|
10466
|
+
{
|
|
10467
|
+
target: '.mode-chip',
|
|
10468
|
+
title: t('Modes: how far a message may go'),
|
|
10469
|
+
text: t('Every message carries a mode. It starts at #1, and the higher the number, the more it may touch.'),
|
|
10470
|
+
// The five, in their own colours and in order, said the way the mode menu says them.
|
|
10471
|
+
modes: true,
|
|
10472
|
+
},
|
|
10473
|
+
{
|
|
10474
|
+
target: '#mother-button',
|
|
10475
|
+
title: t('MU/TH/UR, the console'),
|
|
10476
|
+
text: t('Sign agents in and set how far each one may go in CONNECTIONS. See what the room remembers in MEMORY, and travel through it as a map in NOSTROMO. When something fails, the diagnosis is here.'),
|
|
10477
|
+
},
|
|
10478
|
+
{
|
|
10479
|
+
target: '#modules-button',
|
|
10480
|
+
title: t('MODULES, optional powers'),
|
|
10481
|
+
text: t('Add what this project needs: Git, images, previews of what you build, a local model, a browser, or a module of your own in a single file. Nothing is installed until you press it.'),
|
|
10482
|
+
note: t('You can take this tour again from ? in MU/TH/UR. Type STOPALL at any time to halt every agent.'),
|
|
10483
|
+
},
|
|
8909
10484
|
];
|
|
8910
|
-
const tour = {
|
|
10485
|
+
const tour = {
|
|
10486
|
+
dialog: document.querySelector('#tour'), spot: document.querySelector('#tour-spot'), card: document.querySelector('#tour-card'),
|
|
10487
|
+
count: document.querySelector('#tour-count'), title: document.querySelector('#tour-title'), text: document.querySelector('#tour-text'), note: document.querySelector('#tour-note'),
|
|
10488
|
+
back: document.querySelector('#tour-back'), next: document.querySelector('#tour-next'), skip: document.querySelector('#tour-skip'), index: 0,
|
|
10489
|
+
};
|
|
10490
|
+
// The part of the screen a stop is about, if it is there to be seen.
|
|
10491
|
+
function tourTarget(step) {
|
|
10492
|
+
const node = step.target ? document.querySelector(step.target) : null;
|
|
10493
|
+
if (!node) return null;
|
|
10494
|
+
const box = node.getBoundingClientRect();
|
|
10495
|
+
return box.width > 0 && box.height > 0 ? box : null;
|
|
10496
|
+
}
|
|
10497
|
+
// Light the target and set the card beside it: under it when there is room, over it when there
|
|
10498
|
+
// is not, never off the edge of the window.
|
|
10499
|
+
function placeTour() {
|
|
10500
|
+
const step = TOUR_STEPS[tour.index];
|
|
10501
|
+
if (!step || !tour.dialog?.open) return;
|
|
10502
|
+
const box = tourTarget(step);
|
|
10503
|
+
const vw = window.innerWidth;
|
|
10504
|
+
const vh = window.innerHeight;
|
|
10505
|
+
const card = tour.card.getBoundingClientRect();
|
|
10506
|
+
const margin = 16;
|
|
10507
|
+
if (!box) {
|
|
10508
|
+
tour.spot.classList.add('none');
|
|
10509
|
+
tour.card.style.left = `${Math.max(margin, (vw - card.width) / 2)}px`;
|
|
10510
|
+
tour.card.style.top = `${Math.max(margin, (vh - card.height) / 2)}px`;
|
|
10511
|
+
return;
|
|
10512
|
+
}
|
|
10513
|
+
const pad = 8;
|
|
10514
|
+
tour.spot.classList.remove('none');
|
|
10515
|
+
Object.assign(tour.spot.style, { left: `${box.left - pad}px`, top: `${box.top - pad}px`, width: `${box.width + pad * 2}px`, height: `${box.height + pad * 2}px` });
|
|
10516
|
+
// Under it, over it, or beside it: the first that leaves the lit part uncovered. Only when none
|
|
10517
|
+
// fits does the card sit over the edge of it, and then from above, where it covers least.
|
|
10518
|
+
const gap = pad + 14;
|
|
10519
|
+
const clampX = (x) => Math.min(vw - card.width - margin, Math.max(margin, x));
|
|
10520
|
+
const clampY = (y) => Math.min(vh - card.height - margin, Math.max(margin, y));
|
|
10521
|
+
const centredX = clampX(box.left + box.width / 2 - card.width / 2);
|
|
10522
|
+
const centredY = clampY(box.top + box.height / 2 - card.height / 2);
|
|
10523
|
+
const places = [
|
|
10524
|
+
{ fits: box.bottom + gap + card.height <= vh - margin, left: centredX, top: box.bottom + gap },
|
|
10525
|
+
{ fits: box.top - gap - card.height >= margin, left: centredX, top: box.top - gap - card.height },
|
|
10526
|
+
{ fits: box.right + gap + card.width <= vw - margin, left: box.right + gap, top: centredY },
|
|
10527
|
+
{ fits: box.left - gap - card.width >= margin, left: box.left - gap - card.width, top: centredY },
|
|
10528
|
+
];
|
|
10529
|
+
const place = places.find((one) => one.fits) ?? { left: centredX, top: Math.max(margin, box.top - gap - card.height) };
|
|
10530
|
+
tour.card.style.left = `${place.left}px`;
|
|
10531
|
+
tour.card.style.top = `${place.top}px`;
|
|
10532
|
+
}
|
|
8911
10533
|
function renderTour() {
|
|
8912
10534
|
const step = TOUR_STEPS[tour.index];
|
|
8913
|
-
tour.
|
|
8914
|
-
tour.
|
|
8915
|
-
|
|
8916
|
-
tour.
|
|
8917
|
-
|
|
8918
|
-
tour.
|
|
8919
|
-
tour.
|
|
10535
|
+
tour.count.textContent = t('{n} of {total}', { n: tour.index + 1, total: TOUR_STEPS.length });
|
|
10536
|
+
tour.title.textContent = step.title;
|
|
10537
|
+
tour.text.textContent = step.text;
|
|
10538
|
+
tour.note.hidden = !step.note;
|
|
10539
|
+
tour.note.textContent = step.note ?? '';
|
|
10540
|
+
tour.list ??= document.querySelector('#tour-modes');
|
|
10541
|
+
tour.list.replaceChildren();
|
|
10542
|
+
tour.list.hidden = !step.modes;
|
|
10543
|
+
if (step.modes) {
|
|
10544
|
+
for (const n of [0, 1, 2, 3, 4]) {
|
|
10545
|
+
const row = el('li', n === 1 ? 'start' : null);
|
|
10546
|
+
const chip = el('span', `tour-mode m${n}`);
|
|
10547
|
+
chip.append(el('b', null, `#${n}`), ` ${MODES[n].label}`);
|
|
10548
|
+
row.append(chip, el('span', 'what', MODES[n].hint));
|
|
10549
|
+
tour.list.append(row);
|
|
10550
|
+
}
|
|
10551
|
+
}
|
|
10552
|
+
tour.skip.textContent = t('Skip');
|
|
10553
|
+
tour.back.textContent = t('Back');
|
|
10554
|
+
tour.back.hidden = tour.index === 0;
|
|
10555
|
+
tour.next.textContent = tour.index === TOUR_STEPS.length - 1 ? t('Start') : t('Next');
|
|
10556
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(placeTour); else placeTour();
|
|
8920
10557
|
}
|
|
8921
10558
|
function endTour() {
|
|
8922
10559
|
try { localStorage.setItem('pulse.tour', 'seen'); } catch { /* no storage */ }
|
|
@@ -8925,22 +10562,183 @@ function endTour() {
|
|
|
8925
10562
|
function startTour() {
|
|
8926
10563
|
if (!tour.dialog || typeof tour.dialog.showModal !== 'function') return;
|
|
8927
10564
|
tour.index = 0;
|
|
8928
|
-
renderTour();
|
|
8929
10565
|
if (!tour.dialog.open) tour.dialog.showModal();
|
|
10566
|
+
renderTour();
|
|
10567
|
+
tour.next.focus({ preventScroll: true });
|
|
8930
10568
|
}
|
|
8931
10569
|
tour.next?.addEventListener('click', () => { if (tour.index >= TOUR_STEPS.length - 1) { endTour(); return; } tour.index += 1; renderTour(); });
|
|
8932
10570
|
tour.back?.addEventListener('click', () => { tour.index = Math.max(0, tour.index - 1); renderTour(); });
|
|
8933
10571
|
tour.skip?.addEventListener('click', endTour);
|
|
8934
10572
|
tour.dialog?.addEventListener('close', () => { try { localStorage.setItem('pulse.tour', 'seen'); } catch { /* no storage */ } });
|
|
10573
|
+
window.addEventListener?.('resize', () => { if (tour.dialog?.open) placeTour(); });
|
|
8935
10574
|
document.querySelector('#tour-button')?.addEventListener('click', () => { document.querySelector('#mother')?.close?.(); startTour(); });
|
|
8936
10575
|
function maybeStartTour() {
|
|
8937
10576
|
if (state.tourArmed) return;
|
|
8938
10577
|
state.tourArmed = true;
|
|
10578
|
+
// What is new comes first; the tour, if this page still owes it, waits until that is closed.
|
|
10579
|
+
void maybeShowWhatsNew().then((shown) => { if (!shown) tourIfOwed(); });
|
|
10580
|
+
}
|
|
10581
|
+
function tourIfOwed() {
|
|
8939
10582
|
let seen = 'seen';
|
|
8940
10583
|
try { seen = localStorage.getItem('pulse.tour'); } catch { seen = 'seen'; }
|
|
8941
10584
|
if (seen !== 'seen') setTimeout(startTour, 900);
|
|
8942
10585
|
}
|
|
8943
10586
|
|
|
10587
|
+
/* ---------- First touch: what a button is for, the first time it is pressed ---------- */
|
|
10588
|
+
|
|
10589
|
+
// Three buttons open whole workspaces and say nothing about it until you are inside one. The
|
|
10590
|
+
// first time each is pressed, the workspace opens as always and a card beside the button says
|
|
10591
|
+
// what it is for. Once per button: after that the button is just a button.
|
|
10592
|
+
const FIRST_TOUCH = {
|
|
10593
|
+
browser: {
|
|
10594
|
+
button: '#browser-button',
|
|
10595
|
+
title: t('The browser'),
|
|
10596
|
+
text: t('See what you are building without leaving the room: the servers answering on this computer, in tabs, at phone, tablet or desktop width. To open the web, switch RIPLEY on in MODULES.'),
|
|
10597
|
+
},
|
|
10598
|
+
tree: {
|
|
10599
|
+
button: '#tree-button',
|
|
10600
|
+
title: t('The project files'),
|
|
10601
|
+
text: t('Your project as a tree. Click a file to read it, search by name, and right-click to copy, move, rename or create files and folders.'),
|
|
10602
|
+
},
|
|
10603
|
+
chats: {
|
|
10604
|
+
button: '#chats-button',
|
|
10605
|
+
panel: '#chats',
|
|
10606
|
+
title: t('Conversations, one memory'),
|
|
10607
|
+
text: t('A project can hold many conversations and a single memory. Start a new one for a new subject: they all feed the same archive, and every agent recalls what was said in the others.'),
|
|
10608
|
+
},
|
|
10609
|
+
};
|
|
10610
|
+
const HINTS_KEY = 'madre.hints';
|
|
10611
|
+
function hintsSeen() {
|
|
10612
|
+
try { return JSON.parse(localStorage.getItem(HINTS_KEY) ?? '{}') ?? {}; } catch { return {}; }
|
|
10613
|
+
}
|
|
10614
|
+
function firstTouch(id) {
|
|
10615
|
+
const hint = FIRST_TOUCH[id];
|
|
10616
|
+
const seen = hintsSeen();
|
|
10617
|
+
if (!hint || seen[id]) return;
|
|
10618
|
+
try { localStorage.setItem(HINTS_KEY, JSON.stringify({ ...seen, [id]: true })); } catch { /* a private window gets the card every time */ }
|
|
10619
|
+
const button = document.querySelector(hint.button);
|
|
10620
|
+
if (!button) return;
|
|
10621
|
+
document.querySelector('.hint-card')?.remove();
|
|
10622
|
+
const card = el('section', 'hint-card');
|
|
10623
|
+
card.setAttribute('role', 'dialog');
|
|
10624
|
+
card.setAttribute('aria-label', hint.title);
|
|
10625
|
+
const ok = el('button', 'hint-ok', t('Got it'));
|
|
10626
|
+
ok.type = 'button';
|
|
10627
|
+
card.append(el('h3', null, hint.title), el('p', null, hint.text), ok);
|
|
10628
|
+
document.body.append(card);
|
|
10629
|
+
// Placed once the workspace has opened, because opening it moves things: the bar narrows when
|
|
10630
|
+
// the browser docks, and the conversations button steps aside for its own panel. Under the
|
|
10631
|
+
// button, with its point aimed at it, while the button is there to point at; beside the panel
|
|
10632
|
+
// it opened, with no point at all, when the button is gone.
|
|
10633
|
+
const place = () => {
|
|
10634
|
+
const box = button.getBoundingClientRect();
|
|
10635
|
+
const width = card.offsetWidth;
|
|
10636
|
+
const panel = hint.panel ? document.querySelector(hint.panel) : null;
|
|
10637
|
+
const room = panel && !panel.hidden ? panel.getBoundingClientRect() : null;
|
|
10638
|
+
if ((!box.width || !box.height) && room?.width) {
|
|
10639
|
+
card.classList.add('beside');
|
|
10640
|
+
card.style.left = `${Math.min(window.innerWidth - width - 12, room.right + 14)}px`;
|
|
10641
|
+
card.style.top = `${Math.max(12, room.top + 12)}px`;
|
|
10642
|
+
return;
|
|
10643
|
+
}
|
|
10644
|
+
const left = Math.min(window.innerWidth - width - 12, Math.max(12, box.left + box.width / 2 < window.innerWidth / 2 ? box.left : box.right - width));
|
|
10645
|
+
card.style.left = `${left}px`;
|
|
10646
|
+
card.style.top = `${box.bottom + 12}px`;
|
|
10647
|
+
card.style.setProperty('--arrow', `${Math.min(width - 20, Math.max(14, box.left + box.width / 2 - left))}px`);
|
|
10648
|
+
};
|
|
10649
|
+
card.style.visibility = 'hidden';
|
|
10650
|
+
const show = () => { place(); card.style.visibility = ''; };
|
|
10651
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(show)); else show();
|
|
10652
|
+
const close = () => {
|
|
10653
|
+
card.remove();
|
|
10654
|
+
document.removeEventListener('pointerdown', outside, true);
|
|
10655
|
+
document.removeEventListener('keydown', escape, true);
|
|
10656
|
+
};
|
|
10657
|
+
const outside = (event) => { if (!card.contains(event.target) && !button.contains(event.target)) close(); };
|
|
10658
|
+
const escape = (event) => { if (event.key === 'Escape') close(); };
|
|
10659
|
+
ok.addEventListener('click', close);
|
|
10660
|
+
setTimeout(() => {
|
|
10661
|
+
document.addEventListener('pointerdown', outside, true);
|
|
10662
|
+
document.addEventListener('keydown', escape, true);
|
|
10663
|
+
}, 0);
|
|
10664
|
+
ok.focus({ preventScroll: true });
|
|
10665
|
+
}
|
|
10666
|
+
for (const id of Object.keys(FIRST_TOUCH)) document.querySelector(FIRST_TOUCH[id].button)?.addEventListener('click', () => firstTouch(id));
|
|
10667
|
+
|
|
10668
|
+
/* ---------- What's new: once per version, after an update. ---------- */
|
|
10669
|
+
|
|
10670
|
+
// The sheet a phone shows after an app updates: what this version brings, a few things said
|
|
10671
|
+
// plainly, each with where it lives, a way to read every change, and one thing nobody is told
|
|
10672
|
+
// how to find. The server decides whether this person should see it (src/whats-new.mjs); the
|
|
10673
|
+
// notes come in both languages and the page picks its own.
|
|
10674
|
+
async function maybeShowWhatsNew() {
|
|
10675
|
+
let news;
|
|
10676
|
+
try { news = await fetch('/api/whats-new').then((response) => response.json()); } catch { return false; }
|
|
10677
|
+
if (!news?.show) return false;
|
|
10678
|
+
return showWhatsNew(news, { after: tourIfOwed });
|
|
10679
|
+
}
|
|
10680
|
+
// Asked for again from the release channel: the same sheet, whether or not it was seen.
|
|
10681
|
+
async function openWhatsNew() {
|
|
10682
|
+
let news;
|
|
10683
|
+
try { news = await fetch('/api/whats-new?any=1').then((response) => response.json()); } catch { return false; }
|
|
10684
|
+
return showWhatsNew(news);
|
|
10685
|
+
}
|
|
10686
|
+
function showWhatsNew(news, { after = null } = {}) {
|
|
10687
|
+
if (!news?.notes || typeof document.createElement('dialog').showModal !== 'function') return false;
|
|
10688
|
+
document.querySelector('dialog.whats-new')?.remove();
|
|
10689
|
+
const notes = pick(news.notes);
|
|
10690
|
+
const dialog = el('dialog', 'whats-new');
|
|
10691
|
+
dialog.setAttribute('aria-label', t("What's new in MADRE {version}", { version: news.version }));
|
|
10692
|
+
const sheet = el('div', 'wn-sheet');
|
|
10693
|
+
const head = el('header', 'wn-head');
|
|
10694
|
+
head.append(el('span', 'wn-badge', `MADRE ${news.version}`), el('h2', null, t("What's new")));
|
|
10695
|
+
if (notes.intro) head.append(el('p', 'wn-intro', notes.intro));
|
|
10696
|
+
sheet.append(head);
|
|
10697
|
+
const list = el('ul', 'wn-list');
|
|
10698
|
+
for (const item of notes.items ?? []) {
|
|
10699
|
+
const row = el('li', 'wn-item');
|
|
10700
|
+
const words = el('div', 'wn-words');
|
|
10701
|
+
words.append(el('h3', null, item.title), el('p', null, item.body));
|
|
10702
|
+
if (item.where) {
|
|
10703
|
+
const where = el('p', 'wn-where');
|
|
10704
|
+
where.append(el('span', 'k', t('Where')), ` ${item.where}`);
|
|
10705
|
+
words.append(where);
|
|
10706
|
+
}
|
|
10707
|
+
row.append(pixelIcon(item.icon, 'pixel-icon wn-icon'), words);
|
|
10708
|
+
list.append(row);
|
|
10709
|
+
}
|
|
10710
|
+
sheet.append(list);
|
|
10711
|
+
if (notes.more) {
|
|
10712
|
+
const more = el('aside', 'wn-more');
|
|
10713
|
+
const words = el('div', 'wn-words');
|
|
10714
|
+
words.append(el('h3', null, notes.more.title), el('p', null, notes.more.body));
|
|
10715
|
+
more.append(pixelIcon(notes.more.icon, 'pixel-icon wn-icon'), words);
|
|
10716
|
+
sheet.append(more);
|
|
10717
|
+
}
|
|
10718
|
+
const foot = el('footer', 'wn-foot');
|
|
10719
|
+
const go = el('button', 'wn-continue', t('Continue'));
|
|
10720
|
+
go.type = 'button';
|
|
10721
|
+
go.addEventListener('click', () => dialog.close());
|
|
10722
|
+
foot.append(go);
|
|
10723
|
+
const changes = news.changes?.[language()] ?? news.changes?.es;
|
|
10724
|
+
if (changes) {
|
|
10725
|
+
const link = el('a', 'wn-changes', t('Read every change on madre.run ↗'));
|
|
10726
|
+
link.href = changes; link.target = '_blank'; link.rel = 'noopener noreferrer';
|
|
10727
|
+
foot.append(link);
|
|
10728
|
+
}
|
|
10729
|
+
sheet.append(foot);
|
|
10730
|
+
dialog.append(sheet);
|
|
10731
|
+
document.body.append(dialog);
|
|
10732
|
+
dialog.addEventListener('close', () => {
|
|
10733
|
+
void fetch('/api/whats-new/seen', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => null);
|
|
10734
|
+
dialog.remove();
|
|
10735
|
+
after?.();
|
|
10736
|
+
});
|
|
10737
|
+
// The button takes the focus without taking the page down to it: the sheet opens at its title.
|
|
10738
|
+
setTimeout(() => { dialog.showModal(); go.focus({ preventScroll: true }); dialog.scrollTop = 0; }, after ? 700 : 0);
|
|
10739
|
+
return true;
|
|
10740
|
+
}
|
|
10741
|
+
|
|
8944
10742
|
/* ---------- Release channel: is there a newer MADRE? A pill in the bar, the command in MU/TH/UR. ---------- */
|
|
8945
10743
|
|
|
8946
10744
|
const updateUI = { section: document.querySelector('#mother-update'), pill: document.querySelector('#update-pill'), info: null, timer: null, announced: null, restarting: false };
|
|
@@ -8971,9 +10769,18 @@ function renderUpdate() {
|
|
|
8971
10769
|
// version the header carries a red mark and nothing else, and what it means is inside.
|
|
8972
10770
|
const body = folding(section, `${t('RELEASE CHANNEL')} · MADRE ${info.current}${info.available ? '' : info.latest ? t(' · UP TO DATE') : info.enabled ? t(' · NPM NOT REACHED YET') : t(' · CHECK OFF')}`, {
|
|
8973
10771
|
key: 'update',
|
|
10772
|
+
icon: 'release',
|
|
8974
10773
|
open: false,
|
|
8975
10774
|
badge: info.available ? { text: info.latest, urgent: true, title: t('MADRE {latest} is on npm · you run {current}', { latest: info.latest, current: info.current }) } : null,
|
|
8976
10775
|
});
|
|
10776
|
+
// What this version brought, again, from right beside its number: the sheet that opened after
|
|
10777
|
+
// the update, for whoever closed it too fast or wants to show someone. Only where it exists.
|
|
10778
|
+
if (info.notes) {
|
|
10779
|
+
const about = el('button', 'fold-link', t('ABOUT THIS VERSION'));
|
|
10780
|
+
about.type = 'button';
|
|
10781
|
+
about.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); void openWhatsNew(); });
|
|
10782
|
+
section.querySelector('.fold > summary h3')?.after(about);
|
|
10783
|
+
}
|
|
8977
10784
|
if (info.available) {
|
|
8978
10785
|
const canRestart = info.install !== 'source';
|
|
8979
10786
|
body.append(el('p', 'note', t('A NEWER MADRE IS ON NPM. THIS COPY RUNS {where}. {how}', {
|
|
@@ -9050,7 +10857,7 @@ function renderMotherSentinel() {
|
|
|
9050
10857
|
section.replaceChildren();
|
|
9051
10858
|
const reports = [...state.reports.values()].sort((a, b) => (a.at < b.at ? 1 : -1));
|
|
9052
10859
|
const unsent = reports.filter((report) => !report.sent?.ok).length;
|
|
9053
|
-
const sentinelBody = folding(section, `${t('SENTINEL')} · ${reports.length ? t('{n} REPORTS · {unsent} NOT SENT', { n: reports.length, unsent }) : t('NOTHING TO REPORT')}`, { key: 'sentinel' });
|
|
10860
|
+
const sentinelBody = folding(section, `${t('SENTINEL')} · ${reports.length ? t('{n} REPORTS · {unsent} NOT SENT', { n: reports.length, unsent }) : t('NOTHING TO REPORT')}`, { key: 'sentinel', icon: 'alert' });
|
|
9054
10861
|
const settings = sentinelUI.settings ?? { autoReport: false, canSend: false, repo: null };
|
|
9055
10862
|
const what = el('p', 'note', t('THE SENTINEL KEEPS FAILURES MU/TH/UR CANNOT EXPLAIN, AND CRASHES, WITH PATHS, NAMES AND KEYS REMOVED. NOTHING LEAVES THIS MACHINE UNLESS YOU SEND IT: BY HAND AS A GITHUB ISSUE YOU READ FIRST, OR AUTOMATICALLY TO THE AUTHOR\'S COLLECTOR IF YOU SWITCH THAT ON.'));
|
|
9056
10863
|
sentinelBody.append(what);
|
|
@@ -9131,25 +10938,54 @@ document.querySelector('#feedback-button')?.addEventListener('click', openFeedba
|
|
|
9131
10938
|
mother.dialog?.addEventListener?.('close', () => { /* keep reports; nothing to reset */ });
|
|
9132
10939
|
void loadSentinel();
|
|
9133
10940
|
|
|
10941
|
+
// The panel's own body, which both destinations cover while they are open.
|
|
10942
|
+
// The release channel is MU/TH/UR's own business, not a header of every screen.
|
|
10943
|
+
const MOTHER_BODY = ['mother-update', 'mother-boot', 'mother-query', 'mother-answer', 'mother-recorded', 'mother-known', 'mother-sentinel'];
|
|
10944
|
+
function showMotherBody(show) {
|
|
10945
|
+
for (const id of MOTHER_BODY) { const node = document.getElementById(id); if (node) node.hidden = !show; }
|
|
10946
|
+
const row = document.querySelector('.fold-all-row');
|
|
10947
|
+
if (row) row.hidden = !show;
|
|
10948
|
+
}
|
|
9134
10949
|
settingsUI.button.addEventListener('click', async () => {
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
settingsUI.
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
}
|
|
9143
|
-
|
|
10950
|
+
const open = !settingsUI.open;
|
|
10951
|
+
closeMemoryPanel();
|
|
10952
|
+
settingsUI.open = open;
|
|
10953
|
+
state.settingsOpen = open;
|
|
10954
|
+
settingsUI.button.setAttribute('aria-pressed', String(open));
|
|
10955
|
+
settingsUI.section.hidden = !open;
|
|
10956
|
+
showMotherBody(!open);
|
|
10957
|
+
if (open) { settingsUI.section.append(el('p', 'mother-answer', t('CHECKING CONNECTIONS…'))); await loadSettings(); }
|
|
10958
|
+
else syncFoldAll();
|
|
10959
|
+
});
|
|
10960
|
+
|
|
10961
|
+
function closeMemoryPanel() {
|
|
10962
|
+
if (!memoryUI.open) return;
|
|
10963
|
+
memoryUI.open = false;
|
|
10964
|
+
memoryUI.section.hidden = true;
|
|
10965
|
+
memoryUI.button?.setAttribute('aria-pressed', 'false');
|
|
10966
|
+
}
|
|
10967
|
+
memoryUI.button?.addEventListener('click', async () => {
|
|
10968
|
+
const open = !memoryUI.open;
|
|
10969
|
+
// Opening MEMORY closes CONNECTIONS the same way the other way round does: one screen at a time.
|
|
10970
|
+
if (settingsUI.open) {
|
|
10971
|
+
settingsUI.open = false;
|
|
10972
|
+
state.settingsOpen = false;
|
|
10973
|
+
settingsUI.button.setAttribute('aria-pressed', 'false');
|
|
10974
|
+
settingsUI.section.hidden = true;
|
|
10975
|
+
}
|
|
10976
|
+
memoryUI.open = open;
|
|
10977
|
+
memoryUI.section.hidden = !open;
|
|
10978
|
+
memoryUI.button.setAttribute('aria-pressed', String(open));
|
|
10979
|
+
showMotherBody(!open);
|
|
10980
|
+
if (open) { memoryUI.section.replaceChildren(el('p', 'mother-answer', t('READING THE ARCHIVE…'))); await loadSettings(); }
|
|
10981
|
+
else syncFoldAll();
|
|
9144
10982
|
});
|
|
9145
10983
|
mother.dialog.addEventListener('close', () => {
|
|
9146
|
-
if (!settingsUI.open) return;
|
|
10984
|
+
if (!settingsUI.open && !memoryUI.open) return;
|
|
9147
10985
|
settingsUI.open = false;
|
|
9148
10986
|
state.settingsOpen = false;
|
|
9149
10987
|
settingsUI.button.setAttribute('aria-pressed', 'false');
|
|
9150
10988
|
settingsUI.section.hidden = true;
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
if (node) node.hidden = false;
|
|
9154
|
-
}
|
|
10989
|
+
closeMemoryPanel();
|
|
10990
|
+
showMotherBody(true);
|
|
9155
10991
|
});
|