frontend: thinking-orbs loading indicator and CSS book logo

Replace the gear spinner with the thinking-orbs canvas engine (vendored
framework-free build, driven by js/orb.js in the "solving" state) and the
inline SVG brand icon with a pure CSS book whose cover flips open on page
load and while the brand is hovered (js/logo.js).
This commit is contained in:
Artur Mukhamadiev 2026-09-15 12:00:26 +03:00
parent d8100633f7
commit 3ef50c4448
12 changed files with 891 additions and 41 deletions

View File

@ -15,7 +15,7 @@ The repository also contains the original standalone
| --- | --- | --- |
| `backend/` | FastAPI service: credentials in memory, Confluence/model upstreams, authoritative tool history, container lifecycle, artifact downloads, serves the frontend | [backend/README.md](backend/README.md) |
| `agent/` | Container image: pinned pi SDK bridge, Python supervisor, local tools, artifact exporter | [agent/README.md](agent/README.md) |
| `frontend/` | Static UI (vendored marked + DOMPurify), same-origin mock server for UI development | [frontend/README.md](frontend/README.md) |
| `frontend/` | Static UI (vendored marked, DOMPurify and thinking-orbs engine), same-origin mock server for UI development | [frontend/README.md](frontend/README.md) |
| `tests/integration/` | Integration-stage tests connecting the real components | this file |
| `deploy/`, `scripts/`, `Makefile` | Deployment configuration and entry points | this file |

View File

@ -8,17 +8,21 @@ Minimalist, secure Web UI for Confluence Research, designed to operate against t
frontend/
├── index.html # Main HTML entrypoint (clean white minimalist theme)
├── css/
│ └── style.css # Responsive styling, accessible components, gear animation
│ └── style.css # Responsive styling, accessible components, thinking-orb and logo styles
├── js/
│ ├── app.js # State transitions, keyboard handling, memory credentials, staleness guards
│ ├── api.js # Relative /api/v1/... fetch boundary with UTF-8 byte validation
│ ├── render.js # marked.js + DOMPurify, fail-safe render, bounded sectioning
│ └── history.js # Sources, lazy bounded history serialization, artifacts listing
│ ├── history.js # Sources, lazy bounded history serialization, artifacts listing
│ ├── orb.js # Vanilla canvas driver for the vendored thinking-orbs engine (loading view)
│ └── logo.js # Book logo playback: CSS cover flip on page open and brand hover
├── vendor/ # Pinned vendor libraries & licenses (locally served)
│ ├── marked.min.js
│ ├── marked.LICENSE
│ ├── purify.min.js
│ └── dompurify.LICENSE
│ ├── dompurify.LICENSE
│ ├── thinking-orbs.engine.js # Framework-free engine build of thinking-orbs 0.3.1
│ └── thinking-orbs.LICENSE
├── dev/
│ ├── mock-server.js # Zero-dependency same-origin mock server & scenario runner
│ ├── scenario-toolbar.js # External dev toolbar script (CSP compliant, no inline scripts)

View File

@ -89,6 +89,106 @@ body {
letter-spacing: -0.01em;
}
/* Book logo: pure CSS line art on a 24-unit grid (1em = 24 units).
The front cover swings open around its left edge (js/logo.js toggles
.book-open on page load and while the brand is hovered). */
.book {
--book-line: calc(1.6em / 24);
position: relative;
display: inline-block;
width: 1em;
height: 1em;
font-size: 24px;
flex-shrink: 0;
color: var(--color-text-primary);
perspective: 4em;
perspective-origin: 50% 50%;
}
.book-page,
.book-cover,
.book-edge,
.book-label {
position: absolute;
box-sizing: border-box;
border: var(--book-line) solid currentColor;
}
/* Inner page revealed while the cover is open */
.book-page {
left: calc(4em / 24);
top: calc(2em / 24);
width: calc(16em / 24);
height: calc(17em / 24);
border-radius: calc(3em / 24);
background: var(--color-bg-primary);
}
.book-page::after {
content: "";
position: absolute;
left: calc(3em / 24);
right: calc(3em / 24);
top: calc(3em / 24);
bottom: calc(2em / 24);
background: repeating-linear-gradient(
to bottom,
currentColor 0,
currentColor calc(1.2em / 24),
transparent calc(1.2em / 24),
transparent calc(3.2em / 24)
);
}
/* Page block seen along the bottom */
.book-edge {
left: calc(4em / 24);
top: calc(18em / 24);
width: calc(16em / 24);
height: calc(4em / 24);
border-top: 0;
border-radius: 0 0 calc(3em / 24) calc(3em / 24);
}
/* Front cover, hinged on its left edge */
.book-cover {
left: calc(4em / 24);
top: calc(2em / 24);
width: calc(16em / 24);
height: calc(17em / 24);
border-radius: calc(3em / 24);
background: var(--color-bg-primary);
transform-origin: left center;
transform: rotateY(0deg);
will-change: transform;
}
.book-label {
left: calc(3em / 24);
top: calc(3em / 24);
width: calc(10em / 24);
height: calc(3.5em / 24);
border-width: calc(1.2em / 24);
border-radius: calc(1.2em / 24);
}
.book-open .book-cover {
animation: book-flip 1.2s ease-in-out infinite;
}
@keyframes book-flip {
0% { transform: rotateY(0deg); }
35% { transform: rotateY(-62deg); }
60% { transform: rotateY(-62deg); }
100% { transform: rotateY(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.book-open .book-cover {
animation: none;
}
}
.key-btn {
position: relative;
background: transparent;
@ -282,27 +382,12 @@ body {
margin-bottom: 20px;
}
.gear-spinner {
width: 48px;
height: 48px;
color: var(--color-accent);
stroke-width: 2px;
animation: spin 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.gear-spinner {
animation: none;
}
/* thinking-orbs canvas (js/orb.js); reduced motion renders a static frame */
.thinking-orb {
display: block;
width: 64px;
height: 64px;
margin: 0 auto;
}
.loading-status {

View File

@ -12,11 +12,12 @@
<body>
<!-- Top Navigation Header -->
<header class="app-header">
<div class="app-brand">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
<div id="app-brand" class="app-brand">
<span id="brand-logo" class="book" aria-hidden="true">
<span class="book-page"></span>
<span class="book-edge"></span>
<span class="book-cover"><span class="book-label"></span></span>
</span>
<span>Confluence Research</span>
</div>
<button id="key-btn" class="key-btn" type="button" aria-label="Configure Confluence Credentials" title="Configure Confluence Credentials">
@ -60,10 +61,7 @@
<!-- State 3: Loading View -->
<section id="view-loading" class="view-loading hidden" aria-label="Loading execution view">
<div class="spinner-wrapper">
<svg class="gear-spinner" viewBox="0 0 48 48" width="48" height="48" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="24" cy="24" r="8"></circle>
<path d="M24 4v4m0 32v4M4 24h4m32 0h4m-6.3-13.7l-2.8 2.8m-22.6 22.6l-2.8 2.8m0-28.2l2.8 2.8m22.6 22.6l2.8 2.8M24 10a14 14 0 1 0 0 28 14 14 0 0 0 0-28z"></path>
</svg>
<canvas id="thinking-orb" class="thinking-orb" width="64" height="64" aria-hidden="true"></canvas>
</div>
<p class="loading-status" aria-live="polite">Agent researching Confluence...</p>
<button id="cancel-btn" class="cancel-btn" type="button">Cancel</button>

View File

@ -6,6 +6,8 @@
import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials } from './api.js';
import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js';
import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js';
import { mountThinkingOrb } from './orb.js';
import { initBookLogo } from './logo.js';
// Application memory state
let committedCredentials = null; // { url: string, pat: string } | null
@ -41,6 +43,10 @@ document.addEventListener('DOMContentLoaded', () => {
viewLoading = document.getElementById('view-loading');
viewResult = document.getElementById('view-result');
// Decorative animations (page is a fixed light theme, so the orb ink is pinned to light)
mountThinkingOrb(document.getElementById('thinking-orb'), { state: 'solving', size: 64, theme: 'light' });
initBookLogo(document.getElementById('app-brand'), document.getElementById('brand-logo'));
// Prompt View
promptInput = document.getElementById('prompt-input');
submitBtn = document.getElementById('submit-btn');

72
frontend/js/logo.js Normal file
View File

@ -0,0 +1,72 @@
/**
* Book logo animation control.
*
* The logo is pure CSS (see `.book` in css/style.css); the cover flips while
* the `book-open` class is present. Playback runs briefly when the page opens
* and while the brand is hovered. Stops are deferred to the end of the
* current loop (the `animationiteration` event) so the cover never snaps shut
* mid-flip.
*/
const OPEN_PLAY_MS = 2000;
const LOOP_MS = 1300; // one `book-flip` cycle (1.2s) plus slack, used if the animation clock is unavailable
const OPEN_CLASS = 'book-open';
/**
* @param {HTMLElement} brand Hover target wrapping the logo.
* @param {HTMLElement} book The `.book` element.
*/
export function initBookLogo(brand, book) {
if (!brand || !book) return;
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let stopRequested = false;
let hovering = false;
let openTimer = 0;
let fallbackTimer = 0;
const stopNow = () => {
window.clearTimeout(fallbackTimer);
fallbackTimer = 0;
stopRequested = false;
book.classList.remove(OPEN_CLASS);
};
const play = () => {
window.clearTimeout(fallbackTimer);
fallbackTimer = 0;
stopRequested = false;
book.classList.add(OPEN_CLASS);
};
const msUntilLoopEnd = () => {
const cover = book.querySelector('.book-cover');
const animation = cover && typeof cover.getAnimations === 'function' ? cover.getAnimations()[0] : null;
const timing = animation && animation.effect && typeof animation.effect.getTiming === 'function' ? animation.effect.getTiming() : null;
const duration = timing && typeof timing.duration === 'number' ? timing.duration : 0;
if (!animation || !duration || typeof animation.currentTime !== 'number') return LOOP_MS;
return duration - (animation.currentTime % duration) + 50;
};
const stopAtLoopEnd = () => {
stopRequested = true;
// Iteration events only arrive while the tab is rendering frames; in a
// hidden or throttled tab, fall back to a timer aligned to the loop end.
window.clearTimeout(fallbackTimer);
fallbackTimer = window.setTimeout(stopNow, msUntilLoopEnd());
};
book.addEventListener('animationiteration', () => {
if (stopRequested && !hovering) stopNow();
});
brand.addEventListener('mouseenter', () => {
hovering = true;
window.clearTimeout(openTimer);
play();
});
brand.addEventListener('mouseleave', () => {
hovering = false;
stopAtLoopEnd();
});
play();
openTimer = window.setTimeout(stopAtLoopEnd, OPEN_PLAY_MS);
}

97
frontend/js/orb.js Normal file
View File

@ -0,0 +1,97 @@
/**
* Vanilla driver for the thinking-orbs canvas engine.
*
* The upstream package ships a React component; this module reproduces its
* behaviour on top of the framework-free engine build vendored under
* `vendor/thinking-orbs.engine.js` (same-origin, CSP compliant):
* device-pixel-ratio capped at 2, static frame under reduced motion, and
* automatic pausing while off-screen or in a hidden tab.
*/
import { resolvePreset, MODE_DRAWS } from '../vendor/thinking-orbs.engine.js';
const REDUCED_MOTION_FRAME = 0.6;
/**
* Mounts an animated orb on an existing <canvas>.
* @param {HTMLCanvasElement} canvas
* @param {{ state?: string, size?: 64|20, theme?: 'light'|'dark', speed?: number }} [options]
* @returns {{ destroy: () => void } | null}
*/
export function mountThinkingOrb(canvas, options = {}) {
if (!canvas || typeof canvas.getContext !== 'function') return null;
const state = options.state || 'working';
const size = options.size === 20 ? 20 : 64;
const dark = options.theme === 'dark';
const speedMultiplier = typeof options.speed === 'number' && options.speed > 0 ? options.speed : 1;
const dpr = Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
canvas.width = Math.round(size * dpr);
canvas.height = Math.round(size * dpr);
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const { mode, speed, opts } = resolvePreset(state, size);
const draw = MODE_DRAWS[mode];
const clock = speed * speedMultiplier;
const paintAt = (t) => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, size, size);
draw(ctx, size, t, dark, opts);
};
const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
paintAt(REDUCED_MOTION_FRAME);
return { destroy() {} };
}
let frameId = 0;
let running = false;
let visible = true;
const tick = () => {
paintAt((performance.now() / 1000) * clock);
if (running) frameId = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
frameId = requestAnimationFrame(tick);
};
const stop = () => {
running = false;
cancelAnimationFrame(frameId);
};
paintAt((performance.now() / 1000) * clock);
const observer = typeof IntersectionObserver === 'function'
? new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible && document.visibilityState !== 'hidden') start();
else stop();
})
: null;
if (observer) observer.observe(canvas);
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') stop();
else if (visible) start();
};
document.addEventListener('visibilitychange', onVisibilityChange);
if (!observer) start();
return {
destroy() {
stop();
if (observer) observer.disconnect();
document.removeEventListener('visibilitychange', onVisibilityChange);
}
};
}

View File

@ -7,10 +7,10 @@
"": {
"name": "frontend",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"dompurify": "^3.4.15",
"marked": "^18.0.13"
"dompurify": "3.4.15",
"marked": "18.0.13",
"thinking-orbs": "0.3.1"
}
},
"node_modules/@types/trusted-types": {
@ -40,6 +40,15 @@
"engines": {
"node": ">= 20"
}
},
"node_modules/thinking-orbs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.3.1.tgz",
"integrity": "sha512-3BG1aeB1RUTxItCml/BBuIz5JRM4kZqGuyx+vouv0fXTtcR9ZNoKjWGneHPx94y74GxgArwJZ1qbJR5dt54kSw==",
"license": "MIT",
"peerDependencies": {
"react": ">=18.0.0"
}
}
}
}

View File

@ -12,6 +12,7 @@
},
"dependencies": {
"dompurify": "3.4.15",
"marked": "18.0.13"
"marked": "18.0.13",
"thinking-orbs": "0.3.1"
}
}

View File

@ -216,7 +216,7 @@ async function runTests() {
});
// Test 5: Research Query Submission & Loading View
await step('Query Submission & Loading Gear Spinner', async () => {
await step('Query Submission & Loading Thinking Orb', async () => {
// Set prompt
await cdp.eval('document.getElementById("prompt-input").value = "How do I deploy service X?"');
await cdp.eval('document.getElementById("submit-btn").click()');
@ -228,8 +228,8 @@ async function runTests() {
const statusText = await cdp.eval('document.querySelector(".loading-status").textContent');
assert.equal(statusText, 'Agent researching Confluence...');
const gearPresent = await cdp.eval('!!document.querySelector(".gear-spinner")');
assert.equal(gearPresent, true, 'Gear spinner glyph must be present');
const orbPresent = await cdp.eval('!!document.querySelector("canvas.thinking-orb")');
assert.equal(orbPresent, true, 'Thinking orb canvas must be present');
// Wait for query completion and result view
await new Promise((r) => setTimeout(r, 600));

21
frontend/vendor/thinking-orbs.LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jakub Antalik
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

557
frontend/vendor/thinking-orbs.engine.js vendored Normal file
View File

@ -0,0 +1,557 @@
function U(n, s, t) {
return n + (s - n) * t;
}
function nt(n) {
return n - Math.floor(n);
}
function G(n, s) {
const t = Math.floor(n), r = Math.floor(s);
let a = n - t, o = s - r;
a = a * a * (3 - 2 * a), o = o * o * (3 - 2 * o);
const c = E(t, r), M = E(t + 1, r), h = E(t, r + 1), m = E(t + 1, r + 1);
return c + (M - c) * a + (h - c) * o + (c - M - h + m) * a * o;
}
function E(n, s) {
const t = Math.sin(n * 12.9898 + s * 78.233) * 43758.5453;
return t - Math.floor(t);
}
function J(n, s) {
const t = Math.PI * (3 - Math.sqrt(5)), r = 1 - 2 * (n + 0.5) / s, a = Math.sqrt(1 - r * r), o = n * t;
return [a * Math.cos(o), r, a * Math.sin(o)];
}
function et(n, s) {
return Math.atan2(Math.sin(n - s), Math.cos(n - s));
}
function _(n, s, t, r, a) {
const o = Math.sin(s), c = Math.cos(s), M = Math.sin(n), h = Math.cos(n);
return (m, D, p) => {
const e = m * h + p * M, l = -m * M + p * h, R = D * c - l * o, w = D * o + l * c;
return [t + e * a, r - R * a, w];
};
}
function rt(n, s, t, r = 0.3) {
for (const a of s) {
const o = a.a ?? 1, c = Math.min(1, Math.max(0, a.white)), M = Math.round((t ? 1 - c : c) * 255);
n.fillStyle = `rgba(${M},${M},${M},${o})`, n.beginPath(), n.arc(a.x, a.y, a.r, 0, Math.PI * 2), n.fill();
}
}
function it(n, s, t) {
for (const r of s) {
const a = r.a ?? 1, o = Math.min(1, Math.max(0, r.white)), c = Math.round((t ? 1 - o : o) * 255);
n.strokeStyle = `rgba(${c},${c},${c},${a})`, n.lineWidth = r.w, n.beginPath(), n.moveTo(r.x1, r.y1), n.lineTo(r.x2, r.y2), n.stroke();
}
}
function L(n, s, t = 0.3) {
const r = [];
for (const a of n)
(a.a ?? 1) < 0.02 || (a.r = Math.max(t, a.r), r.push(a));
return r.sort((a, o) => a.z - o.z), { dots: r, lines: s.filter((a) => (a.a ?? 1) >= 0.02) };
}
function ht(n, s, t) {
s.lines.length && it(n, s.lines, t), rt(n, s.dots, t);
}
function $(n, s) {
return (n / 300) ** s;
}
const Mt = (n, s, t) => {
const r = n / 2, a = n / 2, o = n / 2 * 0.76, c = _(s * 0.4, 0.3, r, a, 1), M = $(n, t.rsPow ?? 0.6), h = [], m = t.ghostN ?? 150;
for (let e = 0; e < m; e++) {
const l = J(e, m), [R, w, i] = c(l[0] * o, l[1] * o, l[2] * o), u = (i / o + 1) / 2;
h.push({ x: R, y: w, z: i, r: 0.8 * M, white: 0.78, a: 0.1 + 0.22 * u });
}
const D = t.strandN ?? 52, p = t.turns ?? 3;
for (let e = 0; e < 3; e++) {
const l = e / 3 * 2 * Math.PI;
for (let R = 0; R < D; R++) {
const w = (nt(R / D + s * 0.045) * 2 - 1) * 0.96, i = Math.sqrt(Math.max(0, 1 - w * w)), u = Math.min(1, (1 - Math.abs(w)) / 0.1), y = w * Math.PI * p + l, b = 1 + 0.075 * Math.sin(w * Math.PI * p * 2 + l * 2 + s * 0.8), f = i * o * b, [P, x, g] = c(Math.cos(y) * f, w * o * b, Math.sin(y) * f), d = (g / o + 1) / 2;
h.push({
x: P,
y: x,
z: g,
r: ((t.rBase ?? 1.2) + (t.rDepth ?? 1.8) * d) * M,
white: 0.55 - 0.45 * d,
a: u * (0.45 + 0.55 * d)
});
}
}
return L(h, [], t.rMin);
};
function lt(n, s, t, r) {
const a = 2 * s * t + r, o = n % a, c = new Array(s).fill(0);
let M = -1;
if (o < 2 * s * t) {
const h = Math.floor(o / t), m = (o - h * t) / t, p = 1 - (1 - Math.min(1, m / 0.7)) ** 3;
if (h < s) {
for (let e = 0; e < h; e++) c[e] = 1;
c[h] = p, M = h;
} else {
const e = 2 * s - 1 - h;
for (let l = 0; l < e; l++) c[l] = 1;
c[e] = 1 - p, M = e;
}
}
return { amount: c, active: M };
}
function pt(n, s, t) {
let [r, a, o] = n, c = !1;
for (let M = 0; M < s.length; M++) {
if (t.amount[M] <= 0) continue;
const h = s[M], m = h.axis === 0 ? r : h.axis === 1 ? a : o;
if (m < h.lo || m >= h.hi) continue;
M === t.active && (c = !0);
const D = h.ang * t.amount[M], p = Math.cos(D), e = Math.sin(D);
if (h.axis === 0) {
const l = a * p - o * e;
o = a * e + o * p, a = l;
} else if (h.axis === 1) {
const l = r * p + o * e;
o = -r * e + o * p, r = l;
} else {
const l = r * p - a * e;
a = r * e + a * p, r = l;
}
}
return [r, a, o, c];
}
function ut(n) {
const s = [];
for (let t = 0; t < n; t++) {
const r = Math.min(2, Math.floor(E(t, 2.3) * 3)), a = -1 + 0.5 * Math.min(3, Math.floor(E(t, 5.9) * 4)), o = E(t, 7.7) < 0.5 ? 1 : -1;
s.push({ axis: r, lo: a, hi: a + 0.5, ang: o * Math.PI / 2 });
}
return s;
}
const ft = (n, s, t) => {
const a = n / 2, o = n / 2, c = n / 2 * 0.82, M = 0.4 + 0.06 * Math.sin(s * 0.35), h = _(s * 0.5, M, a, o, c), m = s * (0.5 + (1.7 - 0.5) * (t.scanMul ?? 1)), D = $(n, t.rsPow ?? 0.6), p = t.dimBase ?? 1, e = [], l = t.latRings ?? 17, R = t.lonDensity ?? 44;
for (let w = 0; w <= l; w++) {
const i = -Math.PI / 2 + w / l * Math.PI, u = Math.cos(i), y = Math.sin(i), b = Math.max(1, Math.round(Math.abs(u) * R));
for (let f = 0; f < b; f++) {
const P = f / b * 2 * Math.PI, [x, g, d] = h(u * Math.cos(P), y, u * Math.sin(P)), v = (d + 1) / 2, k = et(P + s * 0.5, m), N = Math.exp(-(k * k) / 0.18) * Math.max(0, d);
e.push({
x,
y: g,
z: d,
r: ((t.rBase ?? 0.6) + (t.rDepth ?? 1.7) * v + (t.rBoost ?? 1) * N) * D,
white: (t.inkFar ?? 0.62) - (t.inkSpan ?? 0.54) * v,
// dimBase < 1 fades un-scanned dots so the meridian reads clearly
a: p + (1 - p) * Math.min(1, N)
});
}
}
return L(e, [], t.rMin);
}, dt = (n, s, t) => {
const r = n / 2, a = n / 2, o = n / 2 * 0.82, c = _(s * 0.55, 0.35 + 0.1 * Math.sin(s * 0.9), r, a, o), M = $(n, t.rsPow ?? 0.6), h = t.moveCount ?? 14, m = ut(h), D = lt(s, h, 0.42, 1.2), p = [], e = t.latRings ?? 15, l = t.lonDensity ?? 40;
for (let R = 0; R <= e; R++) {
const w = -Math.PI / 2 + R / e * Math.PI, i = Math.cos(w), u = Math.sin(w), y = Math.max(1, Math.round(Math.abs(i) * l));
for (let b = 0; b < y; b++) {
const f = b / y * 2 * Math.PI, [P, x, g, d] = pt([i * Math.cos(f), u, i * Math.sin(f)], m, D), [v, k, N] = c(P, x, g), z = (N + 1) / 2;
p.push({
x: v,
y: k,
z: N,
r: ((t.rBase ?? 0.6) + (t.rDepth ?? 1.7) * z + (d ? t.rActive ?? 0.3 : 0)) * M,
white: (t.inkFar ?? 0.62) - (t.inkSpan ?? 0.54) * z - (d ? 0.14 : 0)
});
}
}
return L(p, [], t.rMin);
}, bt = (n, s, t) => {
const r = n / 2, a = n / 2, o = n / 2 * 0.874, c = _(s * 0.18, 0.38, r, a, 1), M = $(n, t.rsPow ?? 0.6), h = [], m = t.rings ?? 15, D = t.lonDensity ?? 40;
for (let p = 0; p <= m; p++) {
const e = -Math.PI / 2 + p / m * Math.PI, l = Math.cos(e), R = Math.sin(e), w = 0.62 * Math.sin(s * 2.1 - p * 0.52) + 0.38 * Math.sin(s * 1.27 + p * 0.83), i = o * (0.88 + 0.105 * w), u = Math.max(1, Math.round(Math.abs(l) * D));
for (let y = 0; y < u; y++) {
const b = y / u * 2 * Math.PI, [f, P, x] = c(l * Math.cos(b) * i, R * i, l * Math.sin(b) * i), g = (x / o + 1) / 2, d = Math.max(0, w);
h.push({
x: f,
y: P,
z: x,
r: ((t.rBase ?? 0.6) + (t.rDepth ?? 1.7) * g) * (1 + 0.4 * d) * M,
white: 0.66 - 0.56 * g - 0.1 * d
});
}
}
return L(h, [], t.rMin);
};
function xt(n) {
return n * n * (3 - 2 * n);
}
function st(n) {
const s = n.length, t = [];
let r = 0;
for (let a = 0; a < s; a++) {
const o = n[a], c = n[(a + 1) % s], M = Math.hypot(c[0] - o[0], c[1] - o[1]);
t.push(M), r += M;
}
return (a) => {
let o = a * r, c = 0;
for (; o > t[c] && c < s - 1; )
o -= t[c], c++;
const M = n[c], h = n[(c + 1) % s], m = t[c] ? Math.min(1, o / t[c]) : 0;
return [M[0] + (h[0] - M[0]) * m, M[1] + (h[1] - M[1]) * m];
};
}
const yt = (n) => {
const s = -Math.PI / 2 + n * 2 * Math.PI;
return [Math.cos(s) * 0.24, Math.sin(s) * 0.24];
}, gt = st([
[0, -0.26],
[0.24, 0.16],
[-0.24, 0.16]
]), mt = st([
[0, -0.2],
[0.2, -0.2],
[0.2, 0.2],
[-0.2, 0.2],
[-0.2, -0.2]
]), H = [yt, gt, mt];
function wt(n) {
return Math.max(6, Math.round(34 * n));
}
const V = 1.4, ot = 0.9, Q = V + ot, Pt = (n, s, t) => {
const r = H.length, a = s % (Q * r), o = Math.floor(a / Q), c = a - o * Q, M = c > V ? xt((c - V) / ot) : 0, h = t.spread ?? 1, m = H[o], D = H[(o + 1) % r], p = 160, e = [];
for (let x = 0; x < p; x++) {
const g = x / p, d = m(g), v = D(g);
e.push([(d[0] + (v[0] - d[0]) * M) * h, (d[1] + (v[1] - d[1]) * M) * h]);
}
const l = [];
let R = 0;
for (let x = 0; x < p; x++) {
const g = e[x], d = e[(x + 1) % p], v = Math.hypot(d[0] - g[0], d[1] - g[1]);
l.push(v), R += v;
}
const w = wt(t.iconD ?? 1), i = (t.rDot ?? 0.021) * 1.35 * h, u = 1 + 0.02 * Math.sin(c * 3.1), y = [], b = n / 2;
let f = 0, P = 0;
for (let x = 0; x < w; x++) {
const g = x / w * R;
for (; P + l[f] < g && f < p - 1; )
P += l[f], f++;
const d = e[f], v = e[(f + 1) % p], k = l[f] ? Math.min(1, (g - P) / l[f]) : 0, N = (d[0] + (v[0] - d[0]) * k) * u, z = (d[1] + (v[1] - d[1]) * k) * u;
y.push({
x: b + N * n,
y: b + z * n,
z: 0,
r: Math.max(0.35, i * n),
white: 0.1
});
}
return L(y, [], t.rMin);
}, Rt = (n, s, t) => {
const r = n / 2, a = n / 2, o = n / 2 * 0.82, c = _(s * 0.12, 0.3, r, a, 1), M = $(n, t.rsPow ?? 0.6), h = [], m = t.orbitN ?? 12, D = t.ghostN ?? 40, p = t.particles ?? 3;
for (let e = 0; e < m; e++) {
const l = E(e, 1.7), R = E(e, 5.2), w = E(e, 8.9), i = o * (0.45 + 0.52 * l), u = l * 2 * Math.PI, y = Math.acos(2 * R - 1), b = Math.sin(y) * Math.cos(u), f = Math.cos(y), P = Math.sin(y) * Math.sin(u);
let x = -f, g = b;
const d = 0, v = Math.max(1e-6, Math.sqrt(x * x + g * g));
x /= v, g /= v;
const k = f * d - P * g, N = P * x - b * d, z = b * g - f * x, O = (0.25 + 0.55 * w) * (w > 0.5 ? 1 : -1);
for (let B = 0; B < D; B++) {
const I = B / D * 2 * Math.PI, [S, A, T] = c(
(x * Math.cos(I) + k * Math.sin(I)) * i,
(g * Math.cos(I) + N * Math.sin(I)) * i,
(d * Math.cos(I) + z * Math.sin(I)) * i
), C = (T / i + 1) / 2;
h.push({
x: S,
y: A,
z: T,
r: (t.ghostR ?? 0.9) * M,
white: 0.72,
a: (t.ghostA ?? 0.5) * (0.4 + 0.6 * C)
});
}
for (let B = 0; B < p; B++) {
const I = s * O + B / p * 2 * Math.PI + R * 6, [S, A, T] = c(
(x * Math.cos(I) + k * Math.sin(I)) * i,
(g * Math.cos(I) + N * Math.sin(I)) * i,
(d * Math.cos(I) + z * Math.sin(I)) * i
), C = (T / i + 1) / 2;
h.push({
x: S,
y: A,
z: T,
r: ((t.partR ?? 1.2) + (t.partRDepth ?? 1.6) * C) * M,
white: 0.3 - 0.22 * C
});
}
}
return L(h, [], t.rMin);
}, Z = (n, s, t) => {
const r = n / 2, a = n / 2, o = n / 2 * 0.78, c = t.spin ?? 1, M = 0.3, h = _(s * 0.1 * c, M, r, a, 1), m = $(n, t.rsPow ?? 0.6), D = [], p = t.ghostN ?? 150;
for (let z = 0; z < p; z++) {
const O = J(z, p), [B, I, S] = h(O[0] * o, O[1] * o, O[2] * o), A = (S / o + 1) / 2;
D.push({ x: B, y: I, z: S, r: 0.8 * m, white: 0.78, a: 0.1 + 0.22 * A });
}
const e = s * 0.24 * c, l = t.faceOn ? -M : 0.55 + 0.3 * Math.sin(s * 0.18) * c, R = Math.cos(e), w = 0, i = Math.sin(e), u = -i * Math.sin(l), y = Math.cos(l), b = R * Math.sin(l), f = w * b - i * y, P = i * u - R * b, x = R * y - w * u, g = 0.23 * (t.wobMul ?? 1), d = t.faceOn ? o / (1 + 0.85 * g) : o, v = t.lanes ?? 5, k = t.segs ?? 88, N = Math.max(1, Math.round(v * (t.bandMul ?? 1)));
for (let z = 0; z < N; z++) {
const O = (z - (N - 1) / 2) * 0.075, B = Math.abs(z - (N - 1) / 2) / Math.max(1, (N - 1) / 2);
for (let I = 0; I < k; I++) {
const S = I / k * 2 * Math.PI, A = (0.16 * Math.sin(S * 3 - s * 1.7 + z * 0.22) + 0.07 * Math.sin(S * 5 + s * 1.1)) * (t.wobMul ?? 1), T = t.faceOn ? 1 + A : 1, C = t.faceOn ? O : O + A, q = R * Math.cos(S) + u * Math.sin(S) + f * C, F = w * Math.cos(S) + y * Math.sin(S) + P * C, j = i * Math.cos(S) + b * Math.sin(S) + x * C, W = Math.sqrt(q * q + F * F + j * j), Y = d * T, [ct, at, X] = h(q / W * Y, F / W * Y, j / W * Y), K = (X / o + 1) / 2;
D.push({
x: ct,
y: at,
z: X,
r: ((t.rBase ?? 1.1) + (t.rDepth ?? 1.7) * K) * (1 - 0.25 * B) * m,
white: 0.52 - 0.44 * K + 0.18 * B,
a: 0.4 + 0.6 * K
});
}
}
return L(D, [], t.rMin);
}, Dt = (n, s, t) => {
const r = n / 2, a = n / 2, o = n / 2 * 0.8 * (t.spread ?? 1), c = _(s * 0.12, 0.32, r, a, o), M = $(n, t.rsPow ?? 0.6), h = t.nodeN ?? 30, m = t.thr ?? 0.72, D = t.nodeR ?? 1.4, p = t.nodeRDepth ?? 1.8, e = [];
for (let i = 0; i < h; i++) {
const u = J(i, h), y = u[0] + 0.3 * (G(i * 0.31 + 9, s * 0.24) - 0.5) * 2, b = u[1] + 0.3 * (G(i * 0.53 + 27, s * 0.21) - 0.5) * 2, f = u[2] + 0.3 * (G(i * 0.77 + 55, s * 0.27) - 0.5) * 2, P = Math.sqrt(y * y + b * b + f * f);
e.push([y / P, b / P, f / P]);
}
const l = [], R = [];
for (let i = 0; i < h; i++)
for (let u = i + 1; u < h; u++) {
const y = e[i][0] - e[u][0], b = e[i][1] - e[u][1], f = e[i][2] - e[u][2], P = Math.sqrt(y * y + b * b + f * f);
if (P >= m) continue;
const [x, g, d] = c(e[i][0], e[i][1], e[i][2]), [v, k, N] = c(e[u][0], e[u][1], e[u][2]), z = ((d + N) / 2 + 1) / 2;
l.push({
x1: x,
y1: g,
x2: v,
y2: k,
white: 0.42,
a: (1 - P / m) * (0.3 + 0.55 * z),
w: Math.max(0.6, (t.lineW ?? 0.8) * M)
});
}
for (let i = 0; i < h; i++) {
const [u, y, b] = c(e[i][0], e[i][1], e[i][2]), f = (b + 1) / 2, P = 1 + 0.25 * Math.sin(s * 1.4 + i * 2.7);
R.push({
x: u,
y,
z: b,
r: (D + p * f) * P * M,
white: 0.55 - 0.45 * f
});
}
const w = t.signals ?? 5;
for (let i = 0; i < w; i++) {
const u = Math.floor(s * 0.55 + i * 7.31), y = Math.floor(E(u, i * 3.1 + 1.7) * h), b = Math.floor(E(u, i * 5.7 + 4.2) * h);
if (y === b) continue;
const f = nt(s * 0.55 + i * 7.31), P = U(e[y][0], e[b][0], f), x = U(e[y][1], e[b][1], f), g = U(e[y][2], e[b][2], f), d = Math.max(1e-6, Math.sqrt(P * P + x * x + g * g)), [v, k, N] = c(P / d, x / d, g / d), z = (N + 1) / 2;
R.push({
x: v,
y: k,
z: N,
r: (D * 1.5 + p * z) * M,
white: 0.05,
a: 0.5 + 0.5 * z
});
}
return L(R, l, t.rMin);
}, vt = {
orbits: Rt,
globe: ft,
rubik: dt,
wave: bt,
web: Dt,
braid: Mt,
ribbon: Z,
// ring shares ribbon's geometry — the `faceOn` profile flag switches it
ring: Z,
morph: Pt
}, Ct = Object.fromEntries(
Object.entries(vt).map(([n, s]) => [
n,
(t, r, a, o, c) => ht(t, s(r, a, c), o)
])
), zt = [
["latRings", "lonDensity"],
["rings", "lonDensity"],
["lanes", "segs"]
], Nt = ["orbitN", "ghostN", "nodeN", "strandN", "signals"], It = ["iconD"], kt = [
"rBase",
"rDepth",
"rActive",
"rDot",
"ghostR",
"partR",
"partRDepth",
"nodeR",
"nodeRDepth"
];
function St(n, s) {
const t = { ...n }, r = /* @__PURE__ */ new Set(), a = Math.sqrt(s);
for (const [o, c] of zt) {
const M = t[o], h = t[c];
M != null && h != null && !r.has(o) && !r.has(c) && (t[o] = Math.max(2, Math.round(M * a)), t[c] = Math.max(2, Math.round(h * a)), r.add(o), r.add(c));
}
for (const o of Nt) {
const c = t[o];
c != null && c !== 0 && !r.has(o) && (t[o] = Math.max(1, Math.round(c * s)));
}
for (const o of It) {
const c = t[o];
c != null && (t[o] = Math.max(0.02, c * s));
}
return t;
}
function Bt(n, s) {
const t = { ...n };
for (const r of kt) {
const a = t[r];
a != null && (t[r] = a * s);
}
return t.rSizeMul = (t.rSizeMul ?? 1) * s, t;
}
const Et = {
globe: {
latRings: 17,
lonDensity: 44,
rBase: 0.6,
rDepth: 1.7,
rBoost: 1,
inkFar: 0.62,
inkSpan: 0.54,
rsPow: 0.6,
rMin: 0.3
},
orbits: {
orbitN: 12,
ghostN: 40,
ghostR: 0.9,
ghostA: 0.5,
particles: 3,
partR: 1.2,
partRDepth: 1.6,
rsPow: 0.6,
rMin: 0.3
},
rubik: {
latRings: 15,
lonDensity: 40,
moveCount: 14,
rBase: 0.6,
rDepth: 1.7,
rActive: 0.3,
inkFar: 0.62,
inkSpan: 0.54,
rsPow: 0.6,
rMin: 0.3
},
wave: {
rings: 15,
lonDensity: 40,
rBase: 0.6,
rDepth: 1.7,
rsPow: 0.6,
rMin: 0.3
},
web: {
nodeN: 30,
thr: 0.72,
signals: 5,
nodeR: 1.4,
nodeRDepth: 1.8,
lineW: 0.8,
rsPow: 0.6,
rMin: 0.3
},
braid: {
strandN: 52,
turns: 3,
ghostN: 150,
rBase: 1.2,
rDepth: 1.8,
rsPow: 0.6,
rMin: 0.3
},
ribbon: {
lanes: 5,
segs: 88,
ghostN: 150,
rBase: 1.1,
rDepth: 1.7,
rsPow: 0.6,
rMin: 0.3
},
// ring shares ribbon's painter; faceOn cancels the camera tilt and moves
// the undulation onto the radius, and there is no ghost sphere behind it
ring: {
lanes: 5,
segs: 88,
ghostN: 0,
faceOn: 1,
rBase: 1.1,
rDepth: 1.7,
rsPow: 0.6,
rMin: 0.3
},
morph: {
rDot: 0.021,
iconD: 1,
rMin: 0.25
}
}, Ot = {
working: "orbits",
searching: "globe",
solving: "rubik",
listening: "wave",
connecting: "web",
weaving: "braid",
composing: "ribbon",
breathing: "ring",
shaping: "morph"
}, At = {
orbits: {
64: { speed: 1.885, count: 1, size: 1 },
20: { speed: 3.9, count: 0.238, size: 2.4 }
},
globe: {
64: { speed: 2.015, count: 0.42, size: 1.15, extra: { scanMul: 4.08, dimBase: 0.45 } },
20: { speed: 2.665, count: 0.105, size: 1.75, extra: { scanMul: 4.335, dimBase: 0.45 } }
},
rubik: {
64: { speed: 1.82, count: 0.35, size: 1.05 },
20: { speed: 1.95, count: 0.088, size: 1.9 }
},
wave: {
64: { speed: 4.388, count: 0.341, size: 1 },
20: { speed: 3.998, count: 0.105, size: 1.6 }
},
web: {
64: { speed: 3.315, count: 1.35, size: 0.95 },
20: { speed: 6.63, count: 0.25, size: 1.52 }
},
braid: {
64: { speed: 1.625, count: 0.5, size: 1 },
20: { speed: 2.75, count: 0.1125, size: 1.36 }
},
ribbon: {
64: { speed: 2.34, count: 0.25, size: 0.85, extra: { spin: 0, bandMul: 3.9, wobMul: 1 } },
20: { speed: 3.12, count: 0.051, size: 1.073, extra: { spin: 0, bandMul: 4.94, wobMul: 1 } }
},
ring: {
64: { speed: 3.24, count: 0.25, size: 0.956, extra: { spin: 0, bandMul: 3.627, wobMul: 0.368 } },
20: { speed: 3.78, count: 0.028, size: 1.622, extra: { spin: 0, bandMul: 3.968, wobMul: 0.565 } }
},
morph: {
64: { speed: 2.405, count: 0.702, size: 0.395, extra: { spread: 1.45 } },
20: { speed: 2.08, count: 0.53, size: 1.011, extra: { spread: 1.45 } }
}
}, tt = /* @__PURE__ */ new Map();
function Lt(n, s) {
const t = `${n}-${s}`, r = tt.get(t);
if (r) return r;
const a = Ot[n], o = At[a][s];
let c = { ...Et[a] };
o.count !== 1 && (c = St(c, o.count)), o.size !== 1 && (c = Bt(c, o.size)), o.extra && (c = { ...c, ...o.extra });
const M = { mode: a, speed: o.speed, opts: c };
return tt.set(t, M), M;
}
export {
Ct as MODE_DRAWS,
vt as MODE_FRAMES,
Ot as STATE_TO_MODE,
L as finalizeFrame,
_ as makeProj,
rt as paint,
ht as paintFrame,
it as paintLines,
$ as radiusScale,
Lt as resolvePreset
};