@jossuealcala/madre 1.0.2 → 1.0.4

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.
Files changed (77) hide show
  1. package/CHANGELOG.en.md +76 -1
  2. package/CHANGELOG.md +76 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1635 -159
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +395 -26
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +105 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +350 -31
  33. package/public/styles.css +844 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +8 -8
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server-sentinel.mjs +7 -3
  76. package/src/server.mjs +617 -48
  77. package/src/setup.mjs +1 -1
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ // «Your computer» (2026-10-16), the same block in a project (Modules and Settings › Local AI) and in
4
+ // Home (Settings › Local AI). Each page hands in its own row builders, so it reads like the rest.
5
+ import { t } from './i18n.js';
6
+
7
+ // What this computer can do with a local AI, measured on it (src/modules/local-models.mjs): which
8
+ // sizes run and train here, the one to use every day, and which features are worth turning on.
9
+ const LOCAL_FEATURE_NAMES = { search: 'Search by meaning', archivist: 'Archivist', agent: '@madre in the project', coordinator: 'The Coordinator with local AI', train: 'Train your own AI' };
10
+ export function hardwareLine(hw) {
11
+ if (!hw) return t('Not measured.');
12
+ if (hw.accelerator === 'apple') return t('{chip} · {gb} GB of shared memory', { chip: hw.chip ?? t('Apple chip'), gb: hw.memoryGb });
13
+ if (hw.accelerator === 'nvidia') return t('{gpu} · {vram} GB of video memory · {gb} GB of memory', { gpu: hw.gpus.map((gpu) => gpu.name).join(' + '), vram: hw.acceleratorGb, gb: hw.memoryGb });
14
+ return t('No compatible graphics card found · {gb} GB of memory', { gb: hw.memoryGb });
15
+ }
16
+ export function drawHardware(host, fit, { el, sxBlock, sxRow, sxChip }) {
17
+ const block = sxBlock(host, t('Your computer'), t('What it can do with a local AI, measured on it. Each model has a minimum to run it and another to train it.'));
18
+ sxRow(block, t('Hardware'), hardwareLine(fit?.hardware));
19
+ if (!fit?.models) return block;
20
+ const daily = fit.recommended?.run;
21
+ sxRow(block, t('Model for every day'), daily ? t('{model} · Ollama downloads it as {tag}', { model: daily.label, tag: daily.ollama }) : t('None runs well here: the project keeps using its providers.'));
22
+ for (const feature of fit.features ?? []) {
23
+ const tier = fit.models.find((model) => model.id === feature.needs);
24
+ const hint = feature.ok
25
+ ? (feature.slow ? t('Yes, slowly: it runs on the processor.') : null)
26
+ : feature.id === 'train'
27
+ ? (fit.accelerated ? t('Training starts at {gb} GB.', { gb: tier.trainGb }) : t('Training needs an Apple chip or an NVIDIA card.'))
28
+ : t('Needs at least {model}, from {gb} GB.', { model: tier.label, gb: tier.runGb });
29
+ sxRow(block, t(LOCAL_FEATURE_NAMES[feature.id]), hint, sxChip(feature.ok ? (feature.slow ? t('Slow') : t('Yes')) : t('No'), feature.ok ? (feature.slow ? 'warn' : 'on') : ''));
30
+ }
31
+ const more = el('details', 'sx-more');
32
+ more.append(el('summary', null, t('Every size, to run and to train')));
33
+ const wrap = el('div', 'sx-table');
34
+ const table = el('table', 'sx-models');
35
+ const head = el('tr');
36
+ for (const text of [t('Model'), t('To run'), t('To train'), t('Here')]) head.append(el('th', null, text));
37
+ table.append(head);
38
+ const verdict = (value) => (value === 'yes' ? t('runs') : value === 'slow' ? t('runs slowly') : null);
39
+ for (const model of fit.models) {
40
+ const row = el('tr', model.id === daily?.id ? 'pick' : null);
41
+ const here = [verdict(model.run), model.train === 'yes' ? t('trains') : null].filter(Boolean).join(' · ') || t('does not fit');
42
+ row.append(el('td', null, model.label), el('td', null, t('{gb} GB', { gb: model.runGb })), el('td', null, t('{gb} GB', { gb: model.trainGb })), el('td', model.run === 'no' ? 'no' : null, here));
43
+ table.append(row);
44
+ }
45
+ wrap.append(table);
46
+ more.append(wrap, el('p', 'sx-hint', t('Minimums for the whole computer, with the system and the browser open: a 4-bit model in Ollama to run it, a LoRA with mlx-lm to train it. On a Mac with an Apple chip the memory is shared; with an NVIDIA card what counts is the card\'s own.')));
47
+ block.append(more);
48
+ return block;
49
+ }
50
+
package/public/index.html CHANGED
@@ -43,6 +43,9 @@
43
43
  <button id="stop-all" class="stop-all" type="button" title="STOP ALL · halt every plan and every agent turn" hidden>
44
44
  <svg class="bar-glyph" viewBox="0 0 20 20" aria-hidden="true"><rect class="ic-f" x="4" y="4" width="12" height="12" rx="3.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>STOP ALL
45
45
  </button>
46
+ <button id="code-button" class="mother-button code-button" type="button" title="Code · your files, the editor and the preview beside the conversation" aria-pressed="false">
47
+ <svg class="bar-glyph" viewBox="0 0 20 20" aria-hidden="true"><path d="M7.2 5.4 2.8 10l4.4 4.6M12.8 5.4l4.4 4.6-4.4 4.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path class="code-slash" d="M11.3 5.6 8.7 14.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" pathLength="10"/></svg>CODE
48
+ </button>
46
49
  <button id="modules-button" class="mother-button" type="button" title="Modules · optional integrations for this project" aria-haspopup="dialog" aria-pressed="false">
47
50
  <span class="mother-glyph modules-glyph" aria-hidden="true"></span><svg class="bar-glyph" viewBox="0 0 20 20" aria-hidden="true"><path class="ic-f" d="M2.75 8.45a2.2 2.2 0 0 1 2.2-2.2h2a1.9 1.9 0 1 1 2.6 0h2a2.2 2.2 0 0 1 2.2 2.2v2a1.9 1.9 0 1 1 0 2.6v2a2.2 2.2 0 0 1-2.2 2.2h-2a1.9 1.9 0 1 0-2.6 0h-2a2.2 2.2 0 0 1-2.2-2.2z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>MODULES<span id="modules-count" class="modules-count" hidden></span>
48
51
  </button>
@@ -254,8 +257,9 @@
254
257
  <span class="nostromo-actions">
255
258
  <button id="nostromo-cold" class="mother-close cold" type="button" hidden aria-pressed="false" title="The memories nothing has ever reached for, that share a subject with nothing, and that have had their chances">UNUSED · 0</button>
256
259
  <button id="nostromo-ask" class="mother-close ask" type="button" hidden aria-pressed="false" title="The questions this archive cannot answer yet, written from what it already holds">ASK · 0</button>
260
+ <button id="nostromo-neural" class="mother-close neural-key" type="button" aria-pressed="false">Neural</button>
257
261
  <button id="nostromo-recenter" class="mother-close" type="button" hidden>RECENTER</button>
258
- <button id="nostromo-close" class="mother-close" type="button">LEAVE ×</button>
262
+ <span class="surface-modes"><button id="nostromo-close" class="surface-mode close" type="button" aria-label="Leave the Memory map" title="Leave the Memory map">×</button></span>
259
263
  </span>
260
264
  </header>
261
265
  <canvas id="nostromo-canvas" class="nostromo-canvas" tabindex="-1" aria-label="Memories orbiting the core"></canvas>
@@ -24,7 +24,8 @@
24
24
  // for less motion gets one still frame redrawn when something changes. No line joins two universes
25
25
  // until a link exists (phase 3c).
26
26
 
27
- import { COLLAPSED, FOCUS_VEIL, MEMORY_COLORS, PITCH_LIMIT, TURN_PER_PIXEL, alongCurve, collapsedChip, createNostromo, currentAlong, dwarfChip, hexAlpha, hexMix, magmaMap, texturesReady } from './nostromo-engine.js';
27
+ import { COLLAPSED, FOCUS_VEIL, MEMORY_COLORS, NEURAL_AXON, NEURAL_CROSSFADE, NEURAL_IGNITION, PITCH_LIMIT, TURN_PER_PIXEL, alongCurve, collapsedChip, createNostromo, currentAlong, drawNeuralBulb, dwarfChip, hexAlpha, hexMix, magmaMap, neuralGround, neuronGlow, texturesReady } from './nostromo-engine.js';
28
+ import { createStrain, inviteNeural, neuralKey, paintNeuralKey } from './neural.js';
28
29
 
29
30
  const KINDS = Object.keys(MEMORY_COLORS);
30
31
  const TILT = 0.42; // how flat the orbits read: the plane seen a little from above
@@ -329,7 +330,7 @@ function farMemories(data) {
329
330
 
330
331
  // One universe painted once: its scene placed, settled and drawn into a sprite by the engine. A
331
332
  // room that is asleep, sealed, outdated or unreachable is MOTHER alone: cold, or caged.
332
- function paintUniverse(one, dpr, shown = null) {
333
+ function paintUniverse(one, dpr, shown = null, neural = false) {
333
334
  const side = Math.min(SPRITE_MAX, Math.round(Math.max(SIDE_MIN, shown ?? one.reach * 2) * dpr));
334
335
  const css = side / dpr;
335
336
  const canvas = canvasOf(side, side);
@@ -345,6 +346,8 @@ function paintUniverse(one, dpr, shown = null) {
345
346
  }
346
347
  scene.cam = { x: 0, y: 0, scale: (css / 2 - 4) / BODY_REACH, manual: true };
347
348
  if (data?.sealed) scene.cage = { at: 0, closed: true };
349
+ // Neural mode (public/neural.js): the universe drawn as a cluster of neurons round its bulb.
350
+ scene.neural = neural;
348
351
  engine.projectNostromo();
349
352
  engine.drawNostromo(0.7);
350
353
  if (!one.room.open) {
@@ -408,7 +411,7 @@ function paintStars(seed, count, biggest, dpr) {
408
411
  return { canvas, side };
409
412
  }
410
413
 
411
- export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bff66', accentInk = '#0a1405', bishop = null, words, actions }) {
414
+ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bff66', accentInk = '#0a1405', bishop = null, words, actions, neural: neuralStart = false }) {
412
415
  const reduced = reducedMotion();
413
416
  const layer = document.createElement('section');
414
417
  layer.className = 'mv';
@@ -454,12 +457,23 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
454
457
  recenter.className = 'mother-close mv-recenter';
455
458
  recenter.textContent = words.recenter ?? 'RECENTER';
456
459
  recenter.hidden = true;
460
+ // Leaving: the room's close, a sunk track and the cross that turns (styles.css, .x-close), as
461
+ // every window closes; what it does is in its name.
462
+ const backTrack = document.createElement('span');
463
+ backTrack.className = 'surface-modes';
457
464
  const back = document.createElement('button');
458
465
  back.type = 'button';
459
- back.className = 'mother-close mv-back';
460
- back.append(words.back);
461
- back.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>'); // trusted: our own SVG
462
- keys.append(recenter, back);
466
+ back.className = 'surface-mode close x-close x-only mv-back';
467
+ back.setAttribute('aria-label', words.back);
468
+ back.title = words.back;
469
+ back.insertAdjacentHTML('beforeend', '<span class="x-icon"><svg viewBox="0 0 20 20" focusable="false" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>'); // trusted: our own SVG
470
+ backTrack.append(back);
471
+ // Neural mode (public/neural.js): the same space drawn as neurons, chosen here or in a project.
472
+ const neuralButton = document.createElement('button');
473
+ neuralButton.type = 'button';
474
+ neuralButton.className = 'mother-close neural-key';
475
+ neuralKey(neuralButton);
476
+ keys.append(neuralButton, recenter, backTrack);
463
477
  head.append(title, sub, legend, keys);
464
478
  const card = document.createElement('aside');
465
479
  card.className = 'mv-card';
@@ -488,6 +502,9 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
488
502
  let raf = 0;
489
503
  let last = performance.now();
490
504
  let clock = 0;
505
+ let neural = Boolean(neuralStart); // drawn as neurons (setNeuralMode)
506
+ let neuralSince = null; // when it came on, for the light running out from the centre
507
+ let fade = null; // the space as it was, fading over the new drawing
491
508
  let queued = false;
492
509
  let resizeTimer = 0;
493
510
  const drag = { active: false, moved: false, turning: false, link: null, x: 0, y: 0, x0: 0, y0: 0 };
@@ -497,6 +514,36 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
497
514
  let orb = null;
498
515
  const gaze = { x: 0, y: 0, open: 0, at: 0, jumpAt: 0, jx: 0, jy: 0, blinkAt: -1, nextBlink: 3 + Math.random() * 4, twice: false };
499
516
  let pointer = null;
517
+ // Neural mode, switched from its key or because a project switched it: the space as it was fades
518
+ // over the new drawing, the light runs out from the centre, every universe is drawn again its new
519
+ // way (a sleeping one, painted once, is painted again), and the choice is kept (actions.neural).
520
+ paintNeuralKey(neuralButton, neural);
521
+ function setNeuralMode(on) {
522
+ if (neural === on) return;
523
+ if (!reduced && canvas.width && canvas.height) {
524
+ const held = document.createElement('canvas');
525
+ held.width = canvas.width;
526
+ held.height = canvas.height;
527
+ held.getContext('2d').drawImage(canvas, 0, 0);
528
+ fade = { canvas: held, at: clock };
529
+ }
530
+ neural = on;
531
+ neuralSince = on && !reduced ? clock : null;
532
+ paintNeuralKey(neuralButton, on);
533
+ layer.querySelector('.neural-invite')?.remove();
534
+ const now = performance.now() / 1000;
535
+ for (const one of universes) {
536
+ if (!one.sprite?.scene) continue;
537
+ if (one.room.open && !reducedMotion()) one.sprite.engine.setNeural(on, now);
538
+ else one.dirty = true;
539
+ }
540
+ live?.engine.setNeural(on, now);
541
+ actions.neural?.(on);
542
+ redraw();
543
+ }
544
+ neuralButton.addEventListener('click', () => setNeuralMode(!neural));
545
+ // When this machine does not give the space its frames, the key invites to try the mode.
546
+ const strain = createStrain({ onStrain: () => { if (!neural) inviteNeural({ key: neuralButton, host: layer, map: 'projects', onTry: () => setNeuralMode(true) }); } });
500
547
  const motes = Array.from({ length: ORB_MOTES }, (_, i) => { const rnd = random(hash(`mote${i}`)); return { phase: rnd() * Math.PI * 2, reach: 1.3 + rnd() * 0.65, speed: 0.12 + rnd() * 0.2, tilt: (rnd() - 0.5) * 0.5, size: 0.35 + rnd() * 0.6 }; });
501
548
 
502
549
  function layout(all) {
@@ -595,7 +642,7 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
595
642
  const next = [focus, ...universes].find((one) => one && one.dirty);
596
643
  if (!next) return false;
597
644
  if (!next.data) { if (!next.loading?.data) void load(next); return false; }
598
- next.sprite = paintUniverse(next, view.dpr, next.shown ?? null);
645
+ next.sprite = paintUniverse(next, view.dpr, next.shown ?? null, neural);
599
646
  next.dirty = false;
600
647
  return true;
601
648
  }
@@ -650,6 +697,7 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
650
697
  }
651
698
  engine.placeNostromo();
652
699
  scene.words = words;
700
+ scene.neural = neural;
653
701
  if (data.sealed) scene.cage = { at: 0, closed: true };
654
702
  // The pointer reaches it through its own canvas, which is nowhere on the page: it answers
655
703
  // from the origin, and the pointer is moved into its frame before it is asked.
@@ -713,7 +761,9 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
713
761
  }
714
762
 
715
763
  function frame(now) {
764
+ const started = performance.now();
716
765
  advance(now);
766
+ strain.frame(performance.now() - started, now);
717
767
  raf = reduced && !camera.to ? 0 : requestAnimationFrame(frame);
718
768
  }
719
769
  // One frame's worth of everything, without asking for the next: what frame() does, and what
@@ -766,10 +816,13 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
766
816
  ctx.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
767
817
  ctx.globalCompositeOperation = 'source-over';
768
818
  ctx.globalAlpha = 1;
769
- if (nebula) ctx.drawImage(nebula, 0, 0, view.w, view.h);
819
+ const beat = reduced ? 0.4 : 0.5 + 0.5 * Math.sin(clock * 5.46);
820
+ // Neural mode: no nebula and no stars, the dark the neurons stand on.
821
+ if (neural) neuralGround(ctx, view.w, view.h, beat);
822
+ else if (nebula) ctx.drawImage(nebula, 0, 0, view.w, view.h);
770
823
  // Stars: the near ones move with the camera, the far ones barely. The tiles repeat under any
771
824
  // movement, so the space has no edge.
772
- stars.forEach((tile, depth) => {
825
+ if (!neural) stars.forEach((tile, depth) => {
773
826
  const speed = [0.04, 0.1, 0.22][depth] * camera.zoom;
774
827
  const ox = (((-camera.x * speed - clock * (reduced ? 0 : 1.5 * (depth + 1))) % tile.side) + tile.side) % tile.side;
775
828
  const oy = (((-camera.y * speed) % tile.side) + tile.side) % tile.side;
@@ -783,14 +836,13 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
783
836
  const span = Math.min(view.w, view.h * 1.7);
784
837
  ctx.lineWidth = 1;
785
838
  for (const orbit of [INNER, OUTER]) {
786
- ctx.strokeStyle = 'rgba(242, 241, 238, .11)';
839
+ ctx.strokeStyle = neural ? `rgba(${NEURAL_AXON}, .1)` : 'rgba(242, 241, 238, .11)';
787
840
  ctx.setLineDash(orbit === OUTER ? [3, 7] : []);
788
841
  ctx.beginPath();
789
842
  ctx.ellipse(centre.x, centre.y, span * orbit * camera.zoom, span * orbit * TILT * camera.zoom, 0, 0, Math.PI * 2);
790
843
  ctx.stroke();
791
844
  }
792
845
  ctx.setLineDash([]);
793
- const beat = reduced ? 0.4 : 0.5 + 0.5 * Math.sin(clock * 5.46);
794
846
  drawVeins(ctx);
795
847
  drawCentre(ctx, beat);
796
848
 
@@ -807,6 +859,12 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
807
859
  if (veil > 0.004) { ctx.fillStyle = `rgba(2, 1, 4, ${veil})`; ctx.fillRect(0, 0, view.w, view.h); }
808
860
  const top = order.find((entry) => entry.one === lifted);
809
861
  if (top) drawUniverse(ctx, top.one, top.at, beat);
862
+ // Changing the drawing: the space as it was fades over the new one.
863
+ if (fade) {
864
+ const k = (clock - fade.at) / NEURAL_CROSSFADE;
865
+ if (k >= 1 || k < 0) fade = null;
866
+ else { ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1 - k * (2 - k); ctx.drawImage(fade.canvas, 0, 0); ctx.restore(); }
867
+ }
810
868
  }
811
869
 
812
870
  // One universe on screen: MOTHER's warmth, the live scene or its own living canvas, its rings
@@ -830,7 +888,7 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
830
888
  const away = at.light * at.light * (0.55 + 0.45 * Math.min(1, camera.zoom));
831
889
  const reach = r * (2.3 + 0.2 * beat);
832
890
  const halo = ctx.createRadialGradient(p.x, p.y, r * 0.6, p.x, p.y, reach);
833
- halo.addColorStop(0, awake ? `rgba(226, 40, 22, ${(0.16 + 0.05 * beat) * away})` : `rgba(120, 150, 200, ${0.08 * away})`);
891
+ halo.addColorStop(0, neural ? (awake ? `rgba(${NEURAL_AXON}, ${(0.12 + 0.04 * beat) * away})` : `rgba(120, 150, 200, ${0.05 * away})`) : awake ? `rgba(226, 40, 22, ${(0.16 + 0.05 * beat) * away})` : `rgba(120, 150, 200, ${0.08 * away})`);
834
892
  halo.addColorStop(1, 'rgba(0, 0, 0, 0)');
835
893
  ctx.fillStyle = halo;
836
894
  ctx.beginPath(); ctx.arc(p.x, p.y, reach, 0, Math.PI * 2); ctx.fill();
@@ -844,8 +902,8 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
844
902
  } else {
845
903
  // Not read yet: an ember where it will be.
846
904
  const ember = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
847
- ember.addColorStop(0, awake ? '#ff7a3a' : '#56637a');
848
- ember.addColorStop(1, awake ? 'rgba(168, 23, 12, 0)' : 'rgba(27, 33, 48, 0)');
905
+ ember.addColorStop(0, neural ? (awake ? '#9fdcff' : '#56637a') : awake ? '#ff7a3a' : '#56637a');
906
+ ember.addColorStop(1, neural ? 'rgba(20, 60, 110, 0)' : awake ? 'rgba(168, 23, 12, 0)' : 'rgba(27, 33, 48, 0)');
849
907
  ctx.fillStyle = ember;
850
908
  ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
851
909
  }
@@ -899,85 +957,91 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
899
957
  const centre = toScreen(0, 0);
900
958
  const p = { x: centre.x, y: centre.y + float * Math.max(0.6, camera.zoom) };
901
959
  const r = 30 * clamp(camera.zoom, 0.75, 1.8);
902
- core.screen = { x: p.x, y: p.y, r: r * 1.3 };
903
- // Where it looks: at the person (the pointer) first; at the universe being carried, or the
904
- // chosen one, when the pointer is away; wandering slowly when nothing is there. A living eye
905
- // never holds still: small jumps every second or so.
906
- const seen = linking?.from ?? (focus && !focus.centre ? focus : null);
907
- let target = pointer ?? seen?.screen ?? null;
908
- if (!target && !reduced) target = { x: p.x + Math.cos(clock * 0.21) * 160, y: p.y + Math.sin(clock * 0.34) * 90 };
909
- if (!reduced && clock > gaze.jumpAt) { gaze.jumpAt = clock + 0.6 + Math.random() * 1.2; gaze.jx = (Math.random() - 0.5) * 0.07; gaze.jy = (Math.random() - 0.5) * 0.05; }
910
- const toward = target ? { x: target.x - p.x, y: target.y - p.y } : { x: 0, y: 0 };
911
- const far = Math.hypot(toward.x, toward.y) + 180;
912
- const step = reduced ? 1 : clamp((clock - gaze.at) * 6, 0, 1);
913
- gaze.at = clock;
914
- gaze.x += (clamp(toward.x / far + gaze.jx, -1, 1) - gaze.x) * step;
915
- gaze.y += (clamp(toward.y / far + gaze.jy, -1, 1) - gaze.y) * step;
916
- gaze.open += ((linking?.over === core ? 1 : hover === core ? 0.45 : 0) - gaze.open) * step;
917
- // A blink now and then, sometimes two; the lids close fast and open a little slower.
918
- if (!reduced && awake && clock > gaze.nextBlink) {
919
- gaze.blinkAt = clock;
920
- gaze.twice = !gaze.twice && Math.random() < 0.25;
921
- gaze.nextBlink = clock + (gaze.twice ? 0.32 : 4 + Math.random() * 6);
922
- }
923
- const since = clock - gaze.blinkAt;
924
- const lid = gaze.blinkAt < 0 || since > 0.26 ? 0 : since < 0.09 ? since / 0.09 : 1 - (since - 0.09) / 0.17;
925
- if (clock - orb.paintedAt >= 1 / ORB_FPS || orb.paintedAt < 0 || lid > 0) {
926
- orb.paint(reduced ? 0 : clock, { x: gaze.x, y: gaze.y, open: gaze.open, lid: clamp(lid, 0, 1) * 0.94, asleep: !awake });
927
- orb.paintedAt = clock;
928
- }
929
- const life = awake ? 1 : 0.5;
930
- // The atmosphere: violet into blue, breathing slowly.
931
- ctx.globalCompositeOperation = 'lighter';
932
- const air = ctx.createRadialGradient(p.x, p.y, r * 0.8, p.x, p.y, r * (2.7 + 0.12 * beat));
933
- air.addColorStop(0, `rgba(122, 58, 238, ${0.3 * life})`);
934
- air.addColorStop(0.45, `rgba(72, 112, 255, ${0.12 * life})`);
935
- air.addColorStop(1, 'rgba(40, 20, 120, 0)');
936
- ctx.fillStyle = air;
937
- ctx.beginPath(); ctx.arc(p.x, p.y, r * (2.7 + 0.12 * beat), 0, Math.PI * 2); ctx.fill();
938
- // The wisps, turning two ways at once, as gas does.
939
- const span = r * orb.reach * 2;
940
- for (const [speed, alpha] of [[0.05, 0.8], [-0.033, 0.5]]) {
941
- ctx.save();
942
- ctx.translate(p.x, p.y);
943
- ctx.rotate(reduced ? 0 : clock * speed);
944
- ctx.globalAlpha = alpha * life;
945
- ctx.drawImage(orb.wisps, -span / 2, -span / 2, span, span);
946
- ctx.restore();
947
- }
948
- // The motes behind it, then the sphere, then the motes in front.
949
- const drawMotes = (front) => {
950
- for (const mote of motes) {
951
- const a = mote.phase + (reduced ? 0 : clock * mote.speed);
952
- const ahead = Math.sin(a) > 0;
953
- if (ahead !== front) continue;
954
- const mx = p.x + Math.cos(a) * r * mote.reach;
955
- const my = p.y + Math.sin(a) * r * mote.reach * 0.3 + mote.tilt * r * Math.cos(a);
956
- const size = mote.size * (ahead ? 1 : 0.7) * clamp(camera.zoom, 0.7, 1.4);
957
- const glow = ctx.createRadialGradient(mx, my, 0, mx, my, size * 3);
958
- glow.addColorStop(0, `rgba(214, 206, 255, ${(ahead ? 0.6 : 0.25) * life})`);
959
- glow.addColorStop(1, 'rgba(120, 140, 255, 0)');
960
- ctx.fillStyle = glow;
961
- ctx.beginPath(); ctx.arc(mx, my, size * 3, 0, Math.PI * 2); ctx.fill();
960
+ core.screen = { x: p.x, y: p.y, r: r * (neural ? 1.8 : 1.3) };
961
+ // Neural mode: the centre of the brain, its bulb, in the Coordinator's violet; no eye, no gas.
962
+ if (neural) {
963
+ const ignite = neuralSince === null ? 1 : clamp((clock - neuralSince) / NEURAL_IGNITION, 0, 1);
964
+ drawNeuralBulb(ctx, p.x, p.y, r * 1.6, clock, { beat, light: awake ? 1 : 0.6, ignite, tone: 'bishop', still: reduced });
965
+ } else {
966
+ // Where it looks: at the person (the pointer) first; at the universe being carried, or the
967
+ // chosen one, when the pointer is away; wandering slowly when nothing is there. A living eye
968
+ // never holds still: small jumps every second or so.
969
+ const seen = linking?.from ?? (focus && !focus.centre ? focus : null);
970
+ let target = pointer ?? seen?.screen ?? null;
971
+ if (!target && !reduced) target = { x: p.x + Math.cos(clock * 0.21) * 160, y: p.y + Math.sin(clock * 0.34) * 90 };
972
+ if (!reduced && clock > gaze.jumpAt) { gaze.jumpAt = clock + 0.6 + Math.random() * 1.2; gaze.jx = (Math.random() - 0.5) * 0.07; gaze.jy = (Math.random() - 0.5) * 0.05; }
973
+ const toward = target ? { x: target.x - p.x, y: target.y - p.y } : { x: 0, y: 0 };
974
+ const far = Math.hypot(toward.x, toward.y) + 180;
975
+ const step = reduced ? 1 : clamp((clock - gaze.at) * 6, 0, 1);
976
+ gaze.at = clock;
977
+ gaze.x += (clamp(toward.x / far + gaze.jx, -1, 1) - gaze.x) * step;
978
+ gaze.y += (clamp(toward.y / far + gaze.jy, -1, 1) - gaze.y) * step;
979
+ gaze.open += ((linking?.over === core ? 1 : hover === core ? 0.45 : 0) - gaze.open) * step;
980
+ // A blink now and then, sometimes two; the lids close fast and open a little slower.
981
+ if (!reduced && awake && clock > gaze.nextBlink) {
982
+ gaze.blinkAt = clock;
983
+ gaze.twice = !gaze.twice && Math.random() < 0.25;
984
+ gaze.nextBlink = clock + (gaze.twice ? 0.32 : 4 + Math.random() * 6);
962
985
  }
963
- };
964
- drawMotes(false);
965
- ctx.globalCompositeOperation = 'source-over';
966
- ctx.globalAlpha = awake ? 1 : 0.8;
967
- ctx.drawImage(orb.canvas, p.x - r, p.y - r, r * 2, r * 2);
968
- ctx.globalAlpha = 1;
969
- // Its own thin atmosphere, brighter on the side the light comes from.
970
- ctx.globalCompositeOperation = 'lighter';
971
- const limb = ctx.createRadialGradient(p.x - r * 0.1, p.y - r * 0.12, r * 0.9, p.x, p.y, r * 1.14);
972
- limb.addColorStop(0, 'rgba(120, 150, 255, 0)');
973
- limb.addColorStop(0.55, `rgba(130, 160, 255, ${0.28 * life})`);
974
- limb.addColorStop(1, 'rgba(90, 60, 220, 0)');
975
- ctx.fillStyle = limb;
976
- ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.14, 0, Math.PI * 2); ctx.fill();
977
- ctx.globalCompositeOperation = 'source-over';
978
- ctx.globalCompositeOperation = 'lighter';
979
- drawMotes(true);
980
- ctx.globalCompositeOperation = 'source-over';
986
+ const since = clock - gaze.blinkAt;
987
+ const lid = gaze.blinkAt < 0 || since > 0.26 ? 0 : since < 0.09 ? since / 0.09 : 1 - (since - 0.09) / 0.17;
988
+ if (clock - orb.paintedAt >= 1 / ORB_FPS || orb.paintedAt < 0 || lid > 0) {
989
+ orb.paint(reduced ? 0 : clock, { x: gaze.x, y: gaze.y, open: gaze.open, lid: clamp(lid, 0, 1) * 0.94, asleep: !awake });
990
+ orb.paintedAt = clock;
991
+ }
992
+ const life = awake ? 1 : 0.5;
993
+ // The atmosphere: violet into blue, breathing slowly.
994
+ ctx.globalCompositeOperation = 'lighter';
995
+ const air = ctx.createRadialGradient(p.x, p.y, r * 0.8, p.x, p.y, r * (2.7 + 0.12 * beat));
996
+ air.addColorStop(0, `rgba(122, 58, 238, ${0.3 * life})`);
997
+ air.addColorStop(0.45, `rgba(72, 112, 255, ${0.12 * life})`);
998
+ air.addColorStop(1, 'rgba(40, 20, 120, 0)');
999
+ ctx.fillStyle = air;
1000
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * (2.7 + 0.12 * beat), 0, Math.PI * 2); ctx.fill();
1001
+ // The wisps, turning two ways at once, as gas does.
1002
+ const span = r * orb.reach * 2;
1003
+ for (const [speed, alpha] of [[0.05, 0.8], [-0.033, 0.5]]) {
1004
+ ctx.save();
1005
+ ctx.translate(p.x, p.y);
1006
+ ctx.rotate(reduced ? 0 : clock * speed);
1007
+ ctx.globalAlpha = alpha * life;
1008
+ ctx.drawImage(orb.wisps, -span / 2, -span / 2, span, span);
1009
+ ctx.restore();
1010
+ }
1011
+ // The motes behind it, then the sphere, then the motes in front.
1012
+ const drawMotes = (front) => {
1013
+ for (const mote of motes) {
1014
+ const a = mote.phase + (reduced ? 0 : clock * mote.speed);
1015
+ const ahead = Math.sin(a) > 0;
1016
+ if (ahead !== front) continue;
1017
+ const mx = p.x + Math.cos(a) * r * mote.reach;
1018
+ const my = p.y + Math.sin(a) * r * mote.reach * 0.3 + mote.tilt * r * Math.cos(a);
1019
+ const size = mote.size * (ahead ? 1 : 0.7) * clamp(camera.zoom, 0.7, 1.4);
1020
+ const glow = ctx.createRadialGradient(mx, my, 0, mx, my, size * 3);
1021
+ glow.addColorStop(0, `rgba(214, 206, 255, ${(ahead ? 0.6 : 0.25) * life})`);
1022
+ glow.addColorStop(1, 'rgba(120, 140, 255, 0)');
1023
+ ctx.fillStyle = glow;
1024
+ ctx.beginPath(); ctx.arc(mx, my, size * 3, 0, Math.PI * 2); ctx.fill();
1025
+ }
1026
+ };
1027
+ drawMotes(false);
1028
+ ctx.globalCompositeOperation = 'source-over';
1029
+ ctx.globalAlpha = awake ? 1 : 0.8;
1030
+ ctx.drawImage(orb.canvas, p.x - r, p.y - r, r * 2, r * 2);
1031
+ ctx.globalAlpha = 1;
1032
+ // Its own thin atmosphere, brighter on the side the light comes from.
1033
+ ctx.globalCompositeOperation = 'lighter';
1034
+ const limb = ctx.createRadialGradient(p.x - r * 0.1, p.y - r * 0.12, r * 0.9, p.x, p.y, r * 1.14);
1035
+ limb.addColorStop(0, 'rgba(120, 150, 255, 0)');
1036
+ limb.addColorStop(0.55, `rgba(130, 160, 255, ${0.28 * life})`);
1037
+ limb.addColorStop(1, 'rgba(90, 60, 220, 0)');
1038
+ ctx.fillStyle = limb;
1039
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.14, 0, Math.PI * 2); ctx.fill();
1040
+ ctx.globalCompositeOperation = 'source-over';
1041
+ ctx.globalCompositeOperation = 'lighter';
1042
+ drawMotes(true);
1043
+ ctx.globalCompositeOperation = 'source-over';
1044
+ }
981
1045
  // A ring in the accent while a universe is carried onto it, and while it waits for the person;
982
1046
  // a white one while it is pointed at or chosen.
983
1047
  const target2 = linking?.over === core;
@@ -985,14 +1049,15 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
985
1049
  ctx.strokeStyle = accent;
986
1050
  ctx.globalAlpha = target2 ? 0.95 : reduced ? 0.6 : 0.45 + 0.35 * Math.sin(clock * 2.4);
987
1051
  ctx.lineWidth = 2;
988
- ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2); ctx.stroke();
1052
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * (neural ? 2.2 : 1.5), 0, Math.PI * 2); ctx.stroke();
989
1053
  ctx.globalAlpha = 1;
990
1054
  } else if (core === hover || core === focus) {
991
1055
  ctx.strokeStyle = core === focus ? 'rgba(255, 255, 255, .85)' : 'rgba(255, 255, 255, .4)';
992
1056
  ctx.lineWidth = core === focus ? 1.4 : 1;
993
- ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2); ctx.stroke();
1057
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * (neural ? 2.2 : 1.5), 0, Math.PI * 2); ctx.stroke();
994
1058
  }
995
- const below = p.y + r * 1.62 + 8;
1059
+ // The bulb's stem reaches lower than the orb did: its name stands under the stem.
1060
+ const below = p.y + r * (neural ? 3.25 : 1.62) + 8;
996
1061
  ctx.textAlign = 'center';
997
1062
  ctx.textBaseline = 'top';
998
1063
  ctx.lineJoin = 'round';
@@ -1033,6 +1098,32 @@ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bf
1033
1098
  const from = back ? pb : pa, to = back ? pa : pb;
1034
1099
  // MOTHER's red leaves the room that serves and the accent arrives where it is read; both
1035
1100
  // ways, red at both ends with the accent between them.
1101
+ // Neural mode: an axon between two clusters, in one cold line, and what crosses it a light
1102
+ // with the stretch it has just run lit behind it.
1103
+ if (neural) {
1104
+ ctx.globalCompositeOperation = 'lighter';
1105
+ ctx.strokeStyle = `rgba(${NEURAL_AXON}, ${0.18 + 0.22 * lately})`;
1106
+ ctx.lineWidth = 0.9 + 0.6 * lately;
1107
+ ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.quadraticCurveTo(cx, cy, pb.x, pb.y); ctx.stroke();
1108
+ vein.pulses = vein.pulses.filter((pulse) => clock - pulse.born < VEIN_LIFE);
1109
+ for (const pulse of vein.pulses) {
1110
+ const u = Math.min(1, (clock - pulse.born) / VEIN_LIFE);
1111
+ const along = (k) => alongCurve(pa.x, pa.y, cx, cy, pb.x, pb.y, pulse.back ? 1 - k : k);
1112
+ let behind = along(u);
1113
+ const head = behind;
1114
+ for (let step = 1; step <= 6; step += 1) {
1115
+ const back = along(Math.max(0, u - step * 0.03));
1116
+ ctx.strokeStyle = hexAlpha(accent, 0.5 - step * 0.07);
1117
+ ctx.lineWidth = 2 - step * 0.22;
1118
+ ctx.beginPath(); ctx.moveTo(behind.x, behind.y); ctx.lineTo(back.x, back.y); ctx.stroke();
1119
+ behind = back;
1120
+ }
1121
+ const size = (6 + 4 * Math.sin(u * Math.PI)) * Math.max(0.7, Math.min(1.4, camera.zoom));
1122
+ ctx.drawImage(neuronGlow(accent), head.x - size, head.y - size, size * 2, size * 2);
1123
+ }
1124
+ ctx.globalCompositeOperation = 'source-over';
1125
+ continue;
1126
+ }
1036
1127
  const red = '#e22816';
1037
1128
  const arriving = enlace.direction === 'both' ? red : accent;
1038
1129
  const head = reduced ? 0.5 : ((clock * (0.06 + 0.2 * recent) + vein.seed) % 1 + 1) % 1;