openvibers/openvibe.shared Commits
4.196.0: your Actor in the Dock, redrawn; a tone is a lean and a glow (#425)
Changes
@@@ -1,5 +1,39 @@# Changelog++## 4.196.0 — 2026-11-10++++**Your Actor in the Dock, redrawn: one row and one line to type in. And a tone is a lean and a glow, never a bar.** From++the owner's notes of 2026-11-10.++++- **The Dock's Actor block (`dock-actor.js`).** The owner, on the Agents tab on a phone: "improve the design and layout++ so it's more intuitive, minimalist, elegant". It was a row with two outlined pills ("Resume", "Open"), a grey box++ that said "Paused" a second time beside a green dot, the model and the cap in a sentence, a field with a "Send" pill++ and a "Read recent history" pill. Now:++ - **Who, in one row.** The face wears a ring in the colour of its state (green and breathing while it works, amber++ while it waits for you, dimmed when paused), then the name and one quiet line: the state, and today's spend against++ the cap ("Paused · $0.00 of $1.00 today"; the model is that figure's tooltip). The row is the link to the Actor's++ page, so "Open" is gone.++ - **One action, the one that fits.** "Resume" in the accent when the Actor is paused; a quiet pause button when it++ runs. What happened is said in one line under the field ("Resumed.", "Sent. Your Actor reads it at its next step.",++ or why it did not go through), in place of notes piling up at the bottom.++ - **What it does, only when there is something to say.** The step it is on (a soft pulse), what it is looking at, the++ steps before it and what comes next ("Next: … , in 40 min"), as a short line of dots. A paused Actor shows none of++ it, and the state words Actor sends as a step ("Paused", "Resting until the next goal is due.") are no longer++ drawn as one.++ - **What it asks you** sits in the warning tone (a lean and a glow), with its own buttons: the first in the accent,++ "Not now" as plain words, and "1 more on your Actor's page" as a link.++ - **One field to tell it something**, with send inside the field; it lights up when there are words in it.++ - **Built once.** A refresh (every 15 seconds) used to rebuild the whole block and wiped the line being typed; now it++ repaints the row, the steps and the ask only when the answer changed, and never touches the field.++ - "Recent history" is a quiet text button; the button that opens the list closes it, one line per entry.++- **The agent view (`dock-agent.js`): no black box for a session that left no picture.** A session that ended with no++ still drew a 16:9 black stage saying "Nothing to show", which pushed the runs off a phone's screen. The stage is++ left out; the line under it still says what it was and how long it worked.++- **The kit's tone (`kit.css`).** The owner, on a notice with a red bar down its side: "instead of just like this red++ and crappy border … blur maybe effect or shadow, glow". Notices and `.ovk-tone[data-tone=good|alert|warning]` take a++ tinted hairline, a gradient that leans from the corner, a soft glow under them and a round icon chip; no side stripe++ anywhere.++## 4.195.0 — 2026-11-10**The You tab gets a row of its own, and the menu grows out of the button that was pressed.** From the owner's notes of@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.195.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.196.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.195.0",++ "version": "4.196.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -1,21 +1,25 @@// ═══════════════════════════════════════════════════════════════--// OpenVibe — the Dock's Actor block (Actor v3 plan, stage A3; design 2026-11-09)++// OpenVibe — the Dock's Actor block (Actor v3 plan, stage A3; redrawn 2026-11-10)//--// Your Actor at the top of the Dock's Actor tab, on every OpenVibe site. dock.js loads this file the first time that++// Your Actor at the top of the Dock's Agents tab, on every OpenVibe site. dock.js loads this file the first time that// tab opens:// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }//// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`--// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token. It shows:--// - the Actor's face, name and state (Working, Waiting for you, Resting, Paused), Pause/Resume, and its model;--// - Now: the step it is on in plain words, what it is looking at, the last three steps and what comes next, and the--// cost so far today;--// - the first thing it is asking you, with its own buttons;--// - one line to tell it something.--// A person with no Actor sees nothing here (the tab's timeline and list stay as they are). Everything people or the--// Actor wrote is set as text, never markup. No spinner: a running step shows its words with a soft pulse.++// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token.++//++// The look (the owner, 2026-11-10: "more intuitive, minimalist, elegant"): one row and one line to type in.++// - Who: the face with a ring in the colour of its state, the name, and one quiet line (the state, today's spend++// against the cap). The row is the way to the Actor's page; there is no separate "Open".++// - One action beside it, the one that fits: "Resume" in the accent when it is paused, a quiet pause when it runs.++// - What it does, only when there is something to say: the step it is on (a soft pulse), what it looks at, the++// steps before it, and what comes next, as a short line of dots. A paused Actor shows none of this: the row says it.++// - What it asks you, with its own buttons, in the warning tone (a lean and a glow, never a bar).++// - One field to tell it something, with send inside the field.++// A person with no Actor sees nothing here. Everything people or the Actor wrote is set as text, never markup. The++// block is built once: a refresh repaints what changed and never touches the line being typed. Every control is 44 px.// ═══════════════════════════════════════════════════════════════(function (root) {'use strict';@@@ -23,6 +27,16 @@const POLL_MS = 15000;const STATE = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' };++ // The lines Actor sends as "the current step" when there is none: the state already says them.++ const NOT_A_STEP = /^(paused|resting\b.*)\.?$/i;++ const ICON = {++ play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 5.6v12.8a.9.9 0 0 0 1.37.77l10.3-6.4a.9.9 0 0 0 0-1.54l-10.3-6.4a.9.9 0 0 0-1.37.77z" fill="currentColor"/></svg>',++ pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/><rect x="13.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/></svg>',++ go: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6.5 15 12l-5.5 5.5"/></svg>',++ send: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5.5M6 11l6-6 6 6"/></svg>',++ ask: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.2 9.3a2.9 2.9 0 1 1 4.4 2.5c-.9.6-1.6 1.2-1.6 2.3"/><path d="M12 17.6v.1"/></svg>',++ past: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.6-5.9"/><path d="M4 5v3.6h3.6"/><path d="M12 8.2V12l2.6 1.7"/></svg>',++ };function styles(doc) {const d = doc || document;@@@ -30,58 +44,133 @@const st = d.createElement('style');st.id = 'ovdac-styles';st.textContent = `-- .ovdac { display: none; flex: none; padding: 10px 10px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }-- .ovdac.on { display: grid; gap: 8px; }-- .ovdac-head { display: flex; align-items: center; gap: 10px; min-width: 0; }-- .ovdac-face { flex: none; width: 34px; height: 34px; border-radius: 50%; overflow: hidden; display: grid; place-items: center;-- background: var(--accent, #3b82f6); color: #fff; font-weight: 800; font-size: 14px; }-- .ovdac-face img { width: 100%; height: 100%; object-fit: cover; }-- .ovdac-who { min-width: 0; flex: 1; }-- .ovdac-name { display: block; font-weight: 800; font-size: 14px; color: var(--text-primary, #ececf1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-- .ovdac-state { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-secondary, #b6b6c0); }-- .ovdac-state .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted, #8b8b96); }-- .ovdac-state.is-working .dot { background: var(--success, #22c55e); animation: ovdac-pulse 1.8s ease-in-out infinite; }-- .ovdac-state.is-waiting .dot { background: var(--warning, #f5a524); }-- .ovdac-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 44px; padding: 0 14px; border-radius: 999px;++ .ovdac { display: none; flex: none; padding: 12px 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08));++ --ovdac-ok: var(--success, #22c55e); --ovdac-warn: var(--warning, #f5a524); --ovdac-ink: var(--text-primary, #ececf1); }++ .ovdac.on { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }++ .ovdac [hidden] { display: none !important; }++ .ovdac svg { width: 18px; height: 18px; flex: none; }++ .ovdac .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++++ .ovdac-head { display: flex; align-items: center; gap: 8px; min-width: 0; }++ .ovdac-id { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 2px 8px 2px 4px; margin-left: -4px; border-radius: 14px;++ color: inherit; text-decoration: none; transition: background .15s ease; }++ .ovdac-id:hover { background: color-mix(in srgb, var(--ovdac-ink) 5%, transparent); }++ .ovdac-id:focus-visible, .ovdac-go:focus-visible, .ovdac-btn:focus-visible, .ovdac-link:focus-visible, .ovdac-send:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px; }++ .ovdac-face { position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;++ background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font-weight: 800; font-size: 16px; transition: filter .2s ease, opacity .2s ease; }++ .ovdac-face img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }++ .ovdac-face::after { content: ''; position: absolute; inset: -4px; border-radius: 50%; border: 2px solid transparent; transition: border-color .2s ease; }++ .ovdac[data-state="working"] .ovdac-face::after { border-color: var(--ovdac-ok); animation: ovdac-ring 2.4s ease-in-out infinite; }++ .ovdac[data-state="waiting"] .ovdac-face::after { border-color: var(--ovdac-warn); }++ .ovdac[data-state="paused"] .ovdac-face { filter: saturate(.5); opacity: .78; }++ .ovdac-who { min-width: 0; display: grid; gap: 1px; }++ .ovdac-name { display: flex; align-items: center; gap: 1px; min-width: 0; font-weight: 750; font-size: 15px; line-height: 1.25; color: var(--ovdac-ink); }++ .ovdac-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .ovdac-name svg { width: 16px; height: 16px; color: var(--text-muted, #8b8b96); transition: transform .18s ease, color .18s ease; }++ .ovdac-id:hover .ovdac-name svg { transform: translateX(2px); color: var(--text-secondary, #b6b6c0); }++ .ovdac-line { display: flex; gap: 6px; min-width: 0; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); white-space: nowrap; }++ .ovdac-state { flex: none; font-weight: 650; color: var(--text-secondary, #b6b6c0); }++ .ovdac[data-state="working"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-ok) 72%, var(--ovdac-ink)); }++ .ovdac[data-state="waiting"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-warn) 76%, var(--ovdac-ink)); }++ .ovdac-spend { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }++ .ovdac-spend::before { content: '·'; margin-right: 6px; }++++ .ovdac-go { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; height: 44px; padding: 0; border-radius: 999px;border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-secondary, #b6b6c0);-- font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; text-decoration: none; white-space: nowrap; }-- .ovdac-btn:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }++ font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, color .15s ease, transform .12s ease, box-shadow .2s ease; }++ .ovdac-go:hover { color: var(--ovdac-ink); background: color-mix(in srgb, var(--ovdac-ink) 7%, transparent); }++ .ovdac-go.is-main { padding: 0 18px 0 13px; border-color: transparent; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff);++ box-shadow: 0 8px 20px -10px var(--accent, #3b82f6); }++ .ovdac-go.is-main:hover { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); filter: brightness(1.08); }++ .ovdac-go:active, .ovdac-btn:active, .ovdac-send:active { transform: scale(.96); }++ .ovdac .is-busy { opacity: .6; pointer-events: none; animation: ovdac-pulse 1.1s ease-in-out infinite; }++++ .ovdac-rail { list-style: none; margin: 0; padding: 2px 4px 2px 6px; display: grid; gap: 9px; }++ .ovdac-rail li { position: relative; padding-left: 22px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }++ .ovdac-rail li::before { content: ''; position: absolute; left: 1px; top: .36em; width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--text-muted, #8b8b96); }++ .ovdac-rail li:not(:last-child)::after { content: ''; position: absolute; left: 5px; top: calc(.36em + 13px); bottom: -8px; width: 1px;++ background: color-mix(in srgb, var(--ovdac-ink) 14%, transparent); }++ .ovdac-rail .is-live { font-size: 13.5px; font-weight: 650; color: var(--ovdac-ink); }++ .ovdac-rail .is-live::before { border-color: var(--ovdac-ok); background: var(--ovdac-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovdac-ok) 20%, transparent); animation: ovdac-pulse 1.8s ease-in-out infinite; }++ .ovdac-rail .is-next { color: var(--text-muted, #8b8b96); }++ .ovdac-rail .is-next::before { border-style: dashed; }++ .ovdac-rail b { font-weight: 650; }++ .ovdac-look { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--text-muted, #8b8b96); }++++ .ovdac-ask { display: grid; gap: 10px; padding: 12px 14px 10px; border-radius: 16px;++ background: linear-gradient(135deg, color-mix(in srgb, var(--ovdac-warn) 15%, transparent), color-mix(in srgb, var(--ovdac-warn) 5%, transparent) 62%);++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovdac-warn) 22%, transparent), 0 12px 28px -20px var(--ovdac-warn); }++ .ovdac-ask-k { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; color: color-mix(in srgb, var(--ovdac-warn) 74%, var(--ovdac-ink)); }++ .ovdac-ask-k i { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--ovdac-warn) 24%, transparent); }++ .ovdac-ask-k svg { width: 16px; height: 16px; }++ .ovdac-ask p { margin: 0; font-size: 14px; line-height: 1.45; color: var(--ovdac-ink); overflow-wrap: anywhere; }++ .ovdac-ask .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }++ .ovdac-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 999px;++ border: 1px solid color-mix(in srgb, var(--ovdac-ink) 18%, transparent); background: transparent; color: var(--ovdac-ink);++ font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, transform .12s ease; }++ .ovdac-btn:hover { background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); }.ovdac-btn.is-yes { background: var(--accent-strong, var(--accent, #2563eb)); border-color: transparent; color: var(--on-accent-strong, #fff); }-- .ovdac-btn:disabled { opacity: .5; cursor: default; }-- .ovdac-now { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #ececf1) 4%, transparent); }-- .ovdac-step { display: flex; gap: 8px; align-items: flex-start; font-weight: 700; font-size: 13px; color: var(--text-primary, #ececf1); line-height: 1.35; }-- .ovdac-step .dot { flex: none; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: var(--success, #22c55e); animation: ovdac-pulse 1.8s ease-in-out infinite; }-- .ovdac-look, .ovdac-next, .ovdac-cost { font-size: 12px; color: var(--text-muted, #8b8b96); overflow-wrap: anywhere; }-- .ovdac-done { margin: 0; padding-left: 18px; display: grid; gap: 2px; font-size: 12px; color: var(--text-secondary, #b6b6c0); }-- .ovdac-ask { display: grid; gap: 6px; padding: 10px 12px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--warning, #f5a524) 45%, transparent);-- background: color-mix(in srgb, var(--warning, #f5a524) 10%, transparent); }-- .ovdac-ask b { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: color-mix(in srgb, var(--warning, #f5a524) 70%, var(--text-primary, #ececf1)); }-- .ovdac-ask p { margin: 0; font-size: 13px; color: var(--text-primary, #ececf1); }-- .ovdac-ask .row { display: flex; flex-wrap: wrap; gap: 6px; }-- .ovdac-tell { display: flex; gap: 6px; }-- .ovdac-tell input { flex: 1; min-width: 0; min-height: 44px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14));-- background: var(--bg-input, transparent); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; }-- .ovdac-note { font-size: 12px; color: var(--text-muted, #8b8b96); }-- @keyframes ovdac-pulse { 50% { opacity: .4; } }++ .ovdac-btn.is-yes:hover { background: var(--accent-strong, var(--accent, #2563eb)); filter: brightness(1.08); }++ .ovdac-btn.is-quiet { padding: 0 10px; border-color: transparent; color: var(--text-secondary, #b6b6c0); font-weight: 650; }++ .ovdac-more { display: inline-flex; align-items: center; min-height: 44px; margin: -8px 0 -8px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); text-decoration: underline; text-underline-offset: 3px; }++++ .ovdac-tell { position: relative; display: flex; }++ .ovdac-tell input { flex: 1; min-width: 0; height: 52px; box-sizing: border-box; padding: 0 58px 0 18px; border-radius: 26px;++ border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-input, color-mix(in srgb, var(--ovdac-ink) 4%, transparent));++ color: var(--ovdac-ink); font: inherit; font-size: 14.5px; outline: none; transition: border-color .15s ease, box-shadow .15s ease; }++ .ovdac-tell input::placeholder { color: var(--text-muted, #8b8b96); }++ .ovdac-tell input:focus { border-color: color-mix(in srgb, var(--accent, #3b82f6) 62%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }++ .ovdac-send { position: absolute; right: 4px; top: 4px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;++ background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); color: var(--text-muted, #8b8b96); cursor: pointer;++ transition: background .18s ease, color .18s ease, transform .12s ease, box-shadow .2s ease; }++ .ovdac-tell.has-text .ovdac-send { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 16px -8px var(--accent, #3b82f6); }++++ .ovdac-foot { display: flex; align-items: center; gap: 10px; min-width: 0; margin-top: -6px; }++ .ovdac-link { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 8px; margin-left: -8px; border: 0; border-radius: 10px;++ background: none; color: var(--text-muted, #8b8b96); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; transition: color .15s ease; }++ .ovdac-link:hover, .ovdac-link[aria-expanded="true"] { color: var(--ovdac-ink); }++ .ovdac-link svg { width: 15px; height: 15px; }++ .ovdac-note { flex: 1; min-width: 0; text-align: right; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); overflow-wrap: anywhere; }++ .ovdac-note.is-ok { color: color-mix(in srgb, var(--ovdac-ok) 70%, var(--ovdac-ink)); }++ .ovdac-note.is-bad { color: color-mix(in srgb, var(--danger, #ef4444) 74%, var(--ovdac-ink)); }++ .ovdac-hist { list-style: none; margin: -4px 0 4px; padding: 0; display: grid; gap: 6px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }++ .ovdac-hist b { margin-right: 6px; font-weight: 650; color: var(--text-muted, #8b8b96); }++++ @keyframes ovdac-pulse { 50% { opacity: .45; } }++ @keyframes ovdac-ring { 50% { opacity: .35; transform: scale(1.06); } }++ @media (pointer: coarse) { .ovdac-tell input { font-size: 16px; } }++ html[data-ov-motion=off] .ovdac *, html[data-ov-motion=off] .ovdac *::before, html[data-ov-motion=off] .ovdac *::after { animation: none !important; transition: none !important; }`;(d.head || d.documentElement).appendChild(st);}const money = (n) => { const v = Number(n) || 0; return v < 0.01 && v > 0 ? `$${v.toFixed(3)}` : `$${v.toFixed(2)}`; };++ /** "in 40 min", "in 3 h", "tomorrow": when the next goal is due; '' when unknown or already due. */++ function due(at, now = Date.now()) {++ const t = Date.parse(at || '');++ if (!Number.isFinite(t)) return '';++ const min = Math.round((t - now) / 60000);++ if (min < 1) return '';++ if (min < 60) return `in ${min} min`;++ if (min < 24 * 60) return `in ${Math.round(min / 60)} h`;++ return min < 48 * 60 ? 'tomorrow' : `in ${Math.round(min / 1440)} days`;++ }function mount(container, opts = {}) {const d = container.ownerDocument || document;styles(d);const o = { ...opts };const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };-- let gone = false, timer = 0, busy = false;++ const icon = (name) => { const t = d.createElement('template'); t.innerHTML = ICON[name]; return t.content.firstChild; };++ const tip = (n, text) => { if (root.OVTip) n.setAttribute('data-ov-tip', text); else n.title = text; };++ let gone = false, timer = 0, busy = false, shown = false, last = '', actor = null, noteTimer = 0;container.replaceChildren();container.classList.add('ovdac');// Network's frame relay on a site; an explicit base reaches Actor itself (a test).const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);-- // "Open" is a navigation, never a fetch: Actor's own page, wherever the API is.++ // The way to the Actor's page is a navigation, never a fetch: Actor's own site, wherever the API is.const openBase = direct || 'https://openvibe.actor';async function api(path, init = {}) {@@@ -95,111 +184,175 @@}function stateOf(a) {-- const asks = Array.isArray(a.asks) ? a.asks : [];-- if (asks.length) return 'waiting';++ if (Array.isArray(a.asks) && a.asks.length) return 'waiting';return STATE[a.status] ? a.status : 'paused';}-- function render(a) {-- container.replaceChildren();-- if (!a || a.enabled === false && !a.id) { container.classList.remove('on'); return; }-- container.classList.add('on');-- const state = stateOf(a);-- // Head: face, name and state; Pause or Resume; the model; open the Actor's page.-- const head = h('div', 'ovdac-head');-- const face = h('span', 'ovdac-face');++ // ── The parts, built once ────────────────────────────────────────────────────────────────────────────────++ const head = h('div', 'ovdac-head');++ const id = h('a', 'ovdac-id'); id.href = openBase + '/you'; id.target = '_blank'; id.rel = 'noopener';++ const face = h('span', 'ovdac-face');++ const who = h('span', 'ovdac-who');++ const name = h('span', 'ovdac-name'); const nameText = h('span'); name.append(nameText, icon('go'));++ const line = h('span', 'ovdac-line'); const stateText = h('span', 'ovdac-state'); const spend = h('span', 'ovdac-spend'); line.append(stateText, spend);++ who.append(name, line); id.append(face, who);++ const go = h('button', 'ovdac-go'); go.type = 'button';++ go.addEventListener('click', () => {++ if (!actor) return;++ const paused = actor.status === 'paused';++ act(paused ? '/resume' : '/pause', {}, go, paused ? 'Resumed.' : 'Paused.');++ });++ head.append(id, go);++++ const rail = h('ul', 'ovdac-rail'); rail.hidden = true;++ const askBox = h('div', 'ovdac-ask'); askBox.hidden = true;++++ const form = h('form', 'ovdac-tell');++ const input = h('input'); input.type = 'text'; input.maxLength = 2000; input.autocomplete = 'off'; input.enterKeyHint = 'send';++ input.placeholder = 'Tell your Actor something'; input.setAttribute('aria-label', 'Tell your Actor something');++ const send = h('button', 'ovdac-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.append(icon('send'));++ input.addEventListener('input', () => form.classList.toggle('has-text', !!input.value.trim()));++ form.append(input, send);++ form.addEventListener('submit', (e) => {++ e.preventDefault();++ const text = input.value.trim();++ if (!text) { input.focus(); return; }++ const paused = actor && actor.status === 'paused';++ act('/tell', { text }, send, paused ? 'Sent. Your Actor reads it once you resume it.' : 'Sent. Your Actor reads it at its next step.')++ .then((ok) => { if (ok) { input.value = ''; form.classList.remove('has-text'); } });++ });++++ const foot = h('div', 'ovdac-foot');++ const past = h('button', 'ovdac-link'); past.type = 'button'; past.setAttribute('aria-expanded', 'false'); past.append(icon('past'), d.createTextNode('Recent history'));++ const note = h('div', 'ovdac-note'); note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite');++ const hist = h('ul', 'ovdac-hist'); hist.hidden = true;++ foot.append(past, note);++ // The button that opens the list closes it.++ past.addEventListener('click', async () => {++ if (!hist.hidden) { hist.hidden = true; past.setAttribute('aria-expanded', 'false'); return; }++ past.disabled = true; past.classList.add('is-busy');++ const one = (text) => { hist.replaceChildren(h('li', '', text)); };++ try {++ const t = typeof o.token === 'function' ? await o.token() : o.token;++ if (!t) throw new Error('Sign in to read history.');++ const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10';++ const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });++ const result = await response.json().catch(() => ({}));++ if (response.status === 403) one('Your agent history setting is off. Turn it on in My settings to allow this read.');++ else if (!response.ok) one('Recent history is unavailable.');++ else {++ const items = (result.items || []).slice(0, 10);++ if (!items.length) one('No recent history.');++ else hist.replaceChildren(...items.map((x) => { const li = h('li'); li.append(h('b', '', String(x.type || '')), d.createTextNode(String(x.title || ''))); return li; }));++ }++ } catch { one('Recent history is unavailable.'); }++ finally { past.disabled = false; past.classList.remove('is-busy'); }++ hist.hidden = false; past.setAttribute('aria-expanded', 'true');++ });++++ /** One line under the field: what just happened. A good word goes by itself after a moment; a problem stays. */++ function say(text, tone) {++ clearTimeout(noteTimer);++ note.textContent = text || '';++ note.className = 'ovdac-note' + (tone ? ` is-${tone}` : '');++ if (text && tone === 'ok') noteTimer = setTimeout(() => { note.textContent = ''; note.className = 'ovdac-note'; }, 6000);++ }++++ // ── A refresh repaints the row, the steps and the ask; the field and the history stay as they are ────────++ function paint(a) {++ if (!a || (a.enabled === false && !a.id)) {++ actor = null; shown = false; last = '';++ container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state');++ return;++ }++ actor = a;++ if (!shown) { container.replaceChildren(head, rail, askBox, form, foot, hist); container.classList.add('on'); shown = true; }++ const sig = JSON.stringify([a.name, a.status, a.avatar, a.brain, a.caps, a.now, a.asks]);++ if (sig === last) return;++ last = sig;++ const state = stateOf(a), now = a.now || {};++ container.setAttribute('data-state', state);++++ // Who, and the one line about it.++ const label = String(a.name || 'Your Actor');const pic = a.avatar && typeof a.avatar.url === 'string' && /^https:\/\//.test(a.avatar.url) ? a.avatar.url : '';-- if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';-- const who = h('span', 'ovdac-who');-- who.append(h('span', 'ovdac-name', a.name || 'Your Actor'));-- const st = h('span', `ovdac-state is-${state}`); st.append(h('span', 'dot'), d.createTextNode(STATE[state]));-- who.append(st);-- const pause = h('button', 'ovdac-btn', a.status === 'paused' ? 'Resume' : 'Pause'); pause.type = 'button';-- pause.addEventListener('click', () => act(a.status === 'paused' ? '/resume' : '/pause', {}, pause));-- const open = h('a', 'ovdac-btn', 'Open'); open.href = openBase + '/you'; open.target = '_blank'; open.rel = 'noopener';-- head.append(face, who, pause, open);-- container.append(head);-- // Now: the step, what it looks at, the last three, next, today's cost.-- const now = a.now || {};-- const box = h('div', 'ovdac-now');-- if (now.current_step) {-- const step = h('div', 'ovdac-step'); step.append(h('span', 'dot'), d.createTextNode(String(now.current_step)));-- box.append(step);-- if (now.looking_at) box.append(h('div', 'ovdac-look', `Looking at: ${now.looking_at.title || now.looking_at}`));-- } else {-- box.append(h('div', 'ovdac-step', state === 'paused' ? 'Paused. It does nothing until you resume it.' : 'Resting.'));++ if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.replaceChildren(img); } else face.replaceChildren(d.createTextNode(label.trim().charAt(0).toUpperCase() || 'A'));++ nameText.textContent = label;++ id.setAttribute('aria-label', `${label}, ${STATE[state]}. Open your Actor's page`);++ stateText.textContent = STATE[state];++ const cap = a.caps && Number(a.caps.usd) > 0 ? ` of ${money(a.caps.usd)}` : '';++ spend.textContent = `${money(now.cost_today)}${cap} today`;++ tip(spend, `Model: ${a.brain && a.brain !== 'auto' ? a.brain : 'Auto'}`);++++ // The one action that fits.++ const paused = a.status === 'paused';++ go.className = 'ovdac-go' + (paused ? ' is-main' : '');++ go.replaceChildren(icon(paused ? 'play' : 'pause'), h('span', paused ? '' : 'sr', paused ? 'Resume' : 'Pause'));++ tip(go, paused ? 'Your Actor does nothing until you resume it' : 'Pause your Actor');++++ // What it does: the step it is on, the steps before it, what comes next. Nothing when there is nothing to say.++ const asks = Array.isArray(a.asks) ? a.asks : [];++ const ask = asks[0];++ const step = String(now.current_step || '').trim();++ const live = a.status === 'working' && step && !NOT_A_STEP.test(step) && !(ask && step === String(ask.text || '').trim()) ? step : '';++ const items = [];++ if (live) {++ const li = h('li', 'is-live'); li.append(d.createTextNode(live));++ const looking = now.looking_at && (now.looking_at.title || now.looking_at);++ if (looking) li.append(h('span', 'ovdac-look', `Looking at ${String(looking)}`));++ items.push(li);}-- const last = Array.isArray(now.last_three_steps) ? now.last_three_steps.slice(0, 3) : [];-- if (last.length) { const ol = h('ol', 'ovdac-done'); last.forEach((x) => ol.append(h('li', '', String(x.text || x)))); box.append(ol); }-- if (now.next) box.append(h('div', 'ovdac-next', `Next: ${now.next.text || now.next}`));-- const model = a.brain && a.brain !== 'auto' ? a.brain : 'Auto';-- box.append(h('div', 'ovdac-cost', `Model: ${model} · today ${money(now.cost_today)}${a.caps && a.caps.usd ? ` of your ${money(a.caps.usd)} cap` : ''}`));-- container.append(box);++ const before = (Array.isArray(now.last_three_steps) ? now.last_three_steps : []).map((x) => String((x && x.text) || x || '').trim()).filter((t) => t && t !== live).slice(0, 3);++ if (!paused) before.forEach((t) => items.push(h('li', '', t)));++ if (!paused && now.next && (now.next.text || typeof now.next === 'string')) {++ const li = h('li', 'is-next'); const when = due(now.next.at);++ li.append(h('b', '', 'Next: '), d.createTextNode(String(now.next.text || now.next) + (when ? `, ${when}` : '')));++ items.push(li);++ }++ rail.replaceChildren(...items); rail.hidden = !items.length;++// The first thing it asks, with its own buttons.-- const ask = (Array.isArray(a.asks) ? a.asks : [])[0];if (ask) {-- const card = h('div', 'ovdac-ask');-- card.append(h('b', '', 'It asks you'), h('p', '', String(ask.text || '')));++ const k = h('div', 'ovdac-ask-k'); const chip = h('i'); chip.append(icon('ask')); k.append(chip, d.createTextNode(`${label} asks`));const row = h('div', 'row');const options = Array.isArray(ask.options) && ask.options.length ? ask.options : [{ id: 'yes', label: 'Yes' }];options.slice(0, 3).forEach((op, i) => {const b = h('button', `ovdac-btn${i === 0 ? ' is-yes' : ''}`, String(op.label || op)); b.type = 'button';-- b.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/answer`, { option: op.id || op }, b));++ b.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/answer`, { option: op.id || op }, b, 'Answered.'));row.append(b);});-- const no = h('button', 'ovdac-btn', 'Not now'); no.type = 'button';++ const no = h('button', 'ovdac-btn is-quiet', 'Not now'); no.type = 'button';no.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/decline`, {}, no));row.append(no);-- card.append(row);-- if (a.asks.length > 1) card.append(h('div', 'ovdac-note', `${a.asks.length - 1} more on your Actor's page`));-- container.append(card);-- }-- // Tell it something.-- const form = h('form', 'ovdac-tell');-- const input = h('input'); input.type = 'text'; input.maxLength = 2000; input.placeholder = 'Tell your Actor something'; input.setAttribute('aria-label', 'Tell your Actor something');-- const send = h('button', 'ovdac-btn', 'Send'); send.type = 'submit';-- form.append(input, send);-- form.addEventListener('submit', (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; act('/tell', { text }, send).then((ok) => { if (ok) input.value = ''; }); });-- container.append(form);-- const history = h('div', 'ovdac-history');-- const read = h('button', 'ovdac-btn', 'Read recent history'); read.type = 'button';-- const rows = h('div', 'ovdac-note'); rows.hidden = true;-- read.addEventListener('click', async () => {-- read.disabled = true;-- try {-- const t = typeof o.token === 'function' ? await o.token() : o.token;-- if (!t) throw new Error('Sign in to read history.');-- const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10';-- const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });-- const result = await response.json().catch(() => ({}));-- if (response.status === 403) { rows.textContent = 'Your agent history setting is off. Turn it on in My settings to allow this read.'; }-- else if (!response.ok) { rows.textContent = 'Recent history is unavailable.'; }-- else { rows.textContent = (result.items || []).map((x) => `${x.type}: ${x.title}`).join(' · ') || 'No recent history.'; }-- rows.hidden = false;-- } catch { rows.textContent = 'Recent history is unavailable.'; rows.hidden = false; }-- finally { read.disabled = false; }-- });-- history.append(read, rows);-- container.append(history);++ askBox.replaceChildren(k, h('p', '', String(ask.text || '')), row);++ if (asks.length > 1) { const more = h('a', 'ovdac-more', `${asks.length - 1} more on your Actor's page`); more.href = openBase + '/you'; more.target = '_blank'; more.rel = 'noopener'; askBox.append(more); }++ } else askBox.replaceChildren();++ askBox.hidden = !ask;}-- async function act(path, body, btn) {++ async function act(path, body, btn, done) {if (busy) return false;-- busy = true; if (btn) btn.disabled = true;-- try { await api(path, { method: 'POST', json: true, body }); await refresh(); return true; }-- catch (err) { const n = h('div', 'ovdac-note', `That did not go through: ${err.message}`); container.append(n); return false; }-- finally { busy = false; if (btn) btn.disabled = false; }++ busy = true; if (btn) { btn.disabled = true; btn.classList.add('is-busy'); }++ say('');++ try {++ await api(path, { method: 'POST', json: true, body });++ last = '';++ await refresh();++ if (done) say(done, 'ok');++ return true;++ } catch (err) { say(`That did not go through: ${err.message}`, 'bad'); return false; }++ finally { busy = false; if (btn) { btn.disabled = false; btn.classList.remove('is-busy'); } }}async function refresh() {if (gone) return;if (o.active && !o.active()) return;-- try { render(await api('')); }-- catch (err) { if (err.status === 401 || err.status === 403 || err.status === 404) render(null); }++ try { paint(await api('')); }++ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 404) paint(null); }}refresh();timer = setInterval(refresh, POLL_MS);-- return { refresh, destroy() { gone = true; clearInterval(timer); container.replaceChildren(); container.classList.remove('on'); } };++ return { refresh, destroy() { gone = true; clearInterval(timer); clearTimeout(noteTimer); container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state'); } };}root.OpenVibeDockActor = { mount };@@@ -49,6 +49,7 @@.ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }.ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }.ovda-still { object-fit: cover; opacity: .92; }++ .ovda-stage[hidden] { display: none; }.ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }.ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }.ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }@@@ -154,8 +155,11 @@} else if (!embed) {if (shown) stage.innerHTML = '';if (agent.last_frame_url) still(agent.last_frame_url);-- else if (!stage.querySelector('img')) stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));++ else if (!ended && !stage.querySelector('img')) stage.append(h('div', 'ovda-wait', 'Waiting for the desk'));}++ // A session that ended and left no picture has no stage: a black box saying "Nothing to show" told nobody++ // anything (the owner, 2026-11-10). The line under it still says what it was and how long it worked.++ stage.hidden = ended && !embed && !agent.last_frame_url && !stage.querySelector('img');// The line: what it is doing, one row.line.innerHTML = '';@@@ -113,7 +113,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': '41d5083aeeb8', 'dock-runs.js': 'b734e5e7ba11', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '21c2d71b5e8e', 'dock-agent.js': 'de6612c136a8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };++ const DOCK_REV = { 'dock-vibe.js': '41d5083aeeb8', 'dock-runs.js': 'b734e5e7ba11', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'a88ef9799001', 'dock-agent.js': '2875da28ff94', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -305,14 +305,18 @@ div.ovk-set-row { cursor: default; }.ovk-empty > .ovk-i { width: 28px; height: 28px; }.ovk-empty.ovk-empty p { margin: 0; max-width: 46ch; }.ovk-empty .ovk-empty-title { color: var(--ovk-text); font-weight: 750; }--.ovk-notice { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .92rem; }--.ovk-notice > .ovk-i { margin-top: 2px; }--.ovk-notice--ok { border-color: color-mix(in srgb, var(--ovk-ok) 45%, transparent); }--.ovk-notice--ok > .ovk-i { color: var(--ovk-ok-text); }--.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }--.ovk-notice--error > .ovk-i { color: var(--ovk-bad-text); }--.ovk-notice--warn > .ovk-i { color: var(--ovk-warn-text); }--.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }++/* A tone is a lean and a glow, never a coloured bar (4.196.0; the owner, 2026-11-10: "instead of just like this red and++ crappy border … shadow, glow … something nice. Also add some icons"). A notice leans toward its tone from the side its++ icon is on, carries a hairline tinted with it and a soft glow in it; its icon sits in a round chip of the tone. The same++ three properties are a class of their own (.ovk-tone, with data-tone) for a card, a toast or a row that must say++ "good", "careful" or "wrong" without a stripe. */++.ovk-notice, .ovk-tone { --ovk-tone: var(--ovk-accent); --ovk-tone-text: var(--ovk-accent); border: 1px solid color-mix(in srgb, var(--ovk-tone) 32%, var(--ovk-line)); background: linear-gradient(100deg, color-mix(in srgb, var(--ovk-tone) 13%, var(--ovk-panel)) 0, var(--ovk-panel) 58%); box-shadow: 0 12px 28px -20px var(--ovk-tone); }++.ovk-notice--ok, .ovk-tone[data-tone="good"] { --ovk-tone: var(--ovk-ok); --ovk-tone-text: var(--ovk-ok-text); }++.ovk-notice--error, .ovk-tone[data-tone="alert"] { --ovk-tone: var(--ovk-bad); --ovk-tone-text: var(--ovk-bad-text); }++.ovk-notice--warn, .ovk-tone[data-tone="warning"] { --ovk-tone: var(--ovk-warn); --ovk-tone-text: var(--ovk-warn-text); }++.ovk-notice { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 12px; padding: 10px 14px 10px 10px; border-radius: calc(var(--ov-radius-1) + 4px); color: var(--ovk-text); font-size: .92rem; }++.ovk-notice > .ovk-i { flex: none; box-sizing: border-box; width: 26px; height: 26px; margin: -2px 0; padding: 5px; border-radius: 50%; background: color-mix(in srgb, var(--ovk-tone) 20%, transparent); color: var(--ovk-tone-text); }++.ovk-notice > span { align-self: center; min-width: 0; }.ovk-snip { position: relative; }.ovk-snip > .ovk-icon-btn { position: absolute; top: 6px; right: 6px; z-index: 1; width: 34px; height: 34px; background: var(--ovk-panel); border-color: var(--ovk-line); }.ovk-snip > pre { max-height: 50vh; margin: 0 0 10px; padding: 12px 48px 12px 12px; overflow: auto; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font-size: .84rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }@@@ -566,7 +566,7 @@const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '810a6665e79c', 'dock.js': '1e2c189c1b1e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };++ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '810a6665e79c', 'dock.js': 'de8b3805e36c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.195.0",++ "version": "4.196.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.195.0",++ "version": "4.196.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.195.0",++ "version": "4.196.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -1,10 +1,12 @@'use strict';// The Dock's Actor block (dock-actor.js, Actor v3 stage A3), in a real headless Chrome, against a local stand-in for// OpenVibe.Actor's /api/v1/me/actor:--// • a working Actor shows its name, "Working", the step in plain words, what it looks at, the last three steps, Next,--// and the model with today's cost against the cap;++// • a working Actor shows its name, its state, the step in plain words, what it looks at, the steps before it, Next,++// and today's cost against the cap (the model is the cost's tooltip); the row is the link to the Actor's page;// • its first ask shows with its own buttons: the first answers it (POST …/answer {option}), "Not now" declines it;--// • "Tell your Actor something" posts the words; Pause posts /pause;++// • "Tell your Actor something" posts the words; Pause posts /pause; a paused Actor shows "Resume", no steps, and++// never "Paused" twice; Resume posts /resume;++// • a refresh never touches the line being typed, and an unchanged answer repaints nothing;// • every call carries the person's token as a bearer (a token given as a function is resolved at the call) and no// cookies; what the Actor wrote is text, never markup;// • a person with no Actor (404) sees nothing; every control is 44 px; nothing runs off a 320 px screen.@@@ -50,7 +52,8 @@ const server = http.createServer((req, res) => {calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: body ? JSON.parse(body) : null });if (mode === 'none') { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"detail":"no actor"}'); }res.writeHead(200, { 'content-type': 'application/json' });-- return res.end(JSON.stringify(req.method === 'GET' ? ACTOR : { ok: true }));++ const paused = { ...ACTOR, enabled: false, status: 'paused', asks: [], now: { current_step: 'Paused', looking_at: null, last_three_steps: [{ text: 'Opened the job board' }], next: { text: 'Write a cover letter' }, cost_today: 0 } };++ return res.end(JSON.stringify(req.method === 'GET' ? (mode === 'paused' ? paused : ACTOR) : { ok: true }));}res.writeHead(404); res.end();});@@@ -80,24 +83,34 @@ const wait = async (pg, expr, ms = 4000) => {const t = (s) => (document.querySelector(s) || {}).textContent || '';const box = document.getElementById('box');const small = [...box.querySelectorAll('button, a, input')].filter((n) => n.getBoundingClientRect().height < 44).map((n) => n.textContent || n.placeholder);-- return { name: t('.ovdac-name'), state: t('.ovdac-state'), step: t('.ovdac-step'), look: t('.ovdac-look'), done: [...box.querySelectorAll('.ovdac-done li')].map((n) => n.textContent),-- next: t('.ovdac-next'), cost: t('.ovdac-cost'), ask: t('.ovdac-ask p'), more: t('.ovdac-ask .ovdac-note'), imgs: box.querySelectorAll('img').length, pwned: !!window.pwned,-- buttons: [...box.querySelectorAll('.ovdac-ask button')].map((n) => n.textContent), small, scroll: document.documentElement.scrollWidth };++ const live = box.querySelector('.ovdac-rail .is-live');++ const spend = box.querySelector('.ovdac-spend');++ return { name: t('.ovdac-name'), state: t('.ovdac-state'), step: live ? live.firstChild.textContent : '', look: t('.ovdac-look'),++ done: [...box.querySelectorAll('.ovdac-rail li:not(.is-live):not(.is-next)')].map((n) => n.textContent),++ next: t('.ovdac-rail .is-next'), cost: t('.ovdac-spend'), model: spend ? (spend.getAttribute('data-ov-tip') || spend.title) : '', ask: t('.ovdac-ask p'), more: t('.ovdac-more'),++ page: (box.querySelector('.ovdac-id') || {}).href || '', kicker: t('.ovdac-ask-k'), ring: box.getAttribute('data-state'),++ imgs: box.querySelectorAll('img').length, pwned: !!window.pwned,++ buttons: [...box.querySelectorAll('.ovdac-ask button')].map((n) => n.textContent), small, scroll: document.documentElement.scrollWidth,++ wide: [...box.querySelectorAll('*')].filter((n) => n.getBoundingClientRect().right > document.documentElement.clientWidth + 0.01).map((n) => n.tagName.toLowerCase() + '.' + String(n.className.baseVal || n.className) + ' ' + n.getBoundingClientRect().right.toFixed(1)).slice(0, 4) };})()`);assert.strictEqual(seen.name, 'Juniper');assert.strictEqual(seen.state, 'Waiting for you', 'an ask makes it wait for you');assert.strictEqual(seen.step, 'Reading the third job post on the list');-- assert.strictEqual(seen.look, 'Looking at: Remote support jobs, posted today');++ assert.strictEqual(seen.look, 'Looking at Remote support jobs, posted today');++ assert.strictEqual(seen.ring, 'waiting', 'the face wears the state');++ assert.ok(seen.page.endsWith('/you'), 'the row is the way to the Actor\'s page');++ assert.strictEqual(seen.kicker, 'Juniper asks');assert.deepStrictEqual(seen.done, ['Opened the job board', 'Searched remote support', 'Saved two posts']);assert.strictEqual(seen.next, 'Next: Write a cover letter for the best one');-- assert.strictEqual(seen.cost, 'Model: DeepSeek V4 · today $0.42 of your $3.00 cap');++ assert.strictEqual(seen.cost, '$0.42 of $3.00 today');++ assert.strictEqual(seen.model, 'Model: DeepSeek V4');assert.ok(seen.ask.includes('<img src=x'), 'the ask is text, never markup');assert.strictEqual(seen.imgs, 0, 'no element came from the Actor\'s words');assert.strictEqual(seen.pwned, false);assert.deepStrictEqual(seen.buttons, ['Apply', 'Skip it', 'Not now']);assert.strictEqual(seen.more, '1 more on your Actor\'s page');assert.deepStrictEqual(seen.small, [], `${width}px: every control is 44 px`);-- assert.ok(seen.scroll <= width, `${width}px: no sideways scroll (${seen.scroll})`);++ assert.ok(seen.scroll <= width, `${width}px: no sideways scroll (${seen.scroll}; ${seen.wide.join(', ')})`);await pg.close();}@@@ -133,8 +146,36 @@ const wait = async (pg, expr, ms = 4000) => {const tell = await post(`(() => { const i = document.querySelector('.ovdac-tell input'); i.value = 'Only remote jobs, please'; document.querySelector('.ovdac-tell button').click(); })()`, '/api/v1/me/actor/tell');assert.deepStrictEqual(tell.body, { text: 'Only remote jobs, please' });await post(`[...document.querySelectorAll('.ovdac-head button')].find((b) => b.textContent === 'Pause').click()`, '/api/v1/me/actor/pause');++++ // The line being typed survives a refresh, and an unchanged answer repaints nothing.++ await new Promise((ok) => setTimeout(ok, 400));++ const kept = await pg.evaluate(`(async () => {++ const i = document.querySelector('.ovdac-tell input'); i.value = 'half a thought'; i.focus();++ const row = document.querySelector('.ovdac-rail li');++ await window.m.refresh();++ const j = document.querySelector('.ovdac-tell input');++ return { same: i === j, value: j.value, focused: document.activeElement === j, sameRow: row === document.querySelector('.ovdac-rail li') };++ })()`);++ assert.deepStrictEqual(kept, { same: true, value: 'half a thought', focused: true, sameRow: true });await pg.close();++ // A paused Actor: "Resume" is the one action, there are no steps, and "Paused" is said once.++ mode = 'paused';++ const pz = await h.openPage(browser, context, { width: 390, height: 800 });++ await pz.goto(`${base}/`, { maxMs: 5000 });++ assert.ok(await wait(pz, `!!document.querySelector('#box.on .ovdac-go')`), 'the paused block shows');++ const paused = await pz.evaluate(`(() => { const b = document.getElementById('box'); return { state: b.querySelector('.ovdac-state').textContent, go: b.querySelector('.ovdac-go').textContent,++ main: b.querySelector('.ovdac-go').classList.contains('is-main'), rail: b.querySelector('.ovdac-rail').hidden, ask: b.querySelector('.ovdac-ask').hidden,++ said: (b.textContent.match(/Paused/g) || []).length }; })()`);++ assert.deepStrictEqual(paused, { state: 'Paused', go: 'Resume', main: true, rail: true, ask: true, said: 1 });++ calls.length = 0;++ await pz.evaluate(`document.querySelector('.ovdac-go').click()`);++ const end = Date.now() + 3000;++ while (!calls.some((c) => c.method === 'POST') && Date.now() < end) await new Promise((ok) => setTimeout(ok, 50));++ assert.strictEqual((calls.find((c) => c.method === 'POST') || {}).url, '/api/v1/me/actor/resume');++ assert.ok(await wait(pz, `/Resumed/.test(document.querySelector('.ovdac-note').textContent)`), 'it says what happened');++ await pz.close();++// No Actor: nothing shows.mode = 'none';const none = await h.openPage(browser, context, { width: 390, height: 800 });@@@ -143,7 +184,7 @@ const wait = async (pg, expr, ms = 4000) => {const empty = await none.evaluate(`(() => { const b = document.getElementById('box'); return { on: b.classList.contains('on'), kids: b.children.length, shown: getComputedStyle(b).display }; })()`);assert.deepStrictEqual(empty, { on: false, kids: 0, shown: 'none' }, 'a person with no Actor sees nothing');await none.close();-- console.log('dock-actor-chrome: the Actor block shows the step, asks with their buttons, tells and pauses with the bearer, text only, 44 px, nothing without an Actor ok');++ console.log('dock-actor-chrome: the Actor block shows the step, asks with their buttons, tells, pauses and resumes with the bearer, keeps the typed line, text only, 44 px, nothing without an Actor ok');} finally {await browser.close();server.close();@@@ -337,3 +337,17 @@ console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, tiassert.ok(h.includes('aria-label="Create"') && h.includes('One line & all the detail.'));assert.ok(!kit.pageHead({ title: 'Home' }).includes('<details'));}++++// A tone is a lean and a glow, never a coloured bar (4.196.0): a notice and anything with .ovk-tone take a tinted++// hairline, a soft lean toward the tone and a glow in it; the notice's icon sits in a round chip. No side stripe anywhere++// in the kit.++{++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');++ assert.match(css, /\.ovk-notice, \.ovk-tone \{ --ovk-tone: var\(--ovk-accent\);[^}]*border: 1px solid color-mix\(in srgb, var\(--ovk-tone\) 32%, var\(--ovk-line\)\);[^}]*background: linear-gradient\([^;]*var\(--ovk-tone\) 13%[^;]*\);[^}]*box-shadow: 0 12px 28px -20px var\(--ovk-tone\); \}/, 'the tone: a hairline, a lean, a glow');++ for (const [cls, tone, v] of [['ok', 'good', 'ok'], ['error', 'alert', 'bad'], ['warn', 'warning', 'warn']]) {++ assert.ok(css.includes(`.ovk-notice--${cls}, .ovk-tone[data-tone="${tone}"] { --ovk-tone: var(--ovk-${v}); --ovk-tone-text: var(--ovk-${v}-text); }`), `${cls} and data-tone="${tone}" are the same tone`);++ }++ assert.match(css, /\.ovk-notice > \.ovk-i \{[^}]*border-radius: 50%;[^}]*background: color-mix\(in srgb, var\(--ovk-tone\) 20%, transparent\); color: var\(--ovk-tone-text\); \}/, 'the icon in a round chip of the tone');++ assert.ok(!/border-(left|right|inline-start):\s*[2-9]px solid/.test(css), 'no side stripe marks a tone anywhere in the kit');++ assert.match(kit.notice({ kind: 'error', text: 'x' }), /^<p class="ovk-notice ovk-notice--error" role="alert"><svg/, 'an error is still an alert with its icon first');++}
@@ -1,5 +1,39 @@
@@ -1,5 +1,39 @@
# Changelog
# Changelog
+## 4.196.0 — 2026-11-10
+
+**Your Actor in the Dock, redrawn: one row and one line to type in. And a tone is a lean and a glow, never a bar.** From
+the owner's notes of 2026-11-10.
+
+- **The Dock's Actor block (`dock-actor.js`).** The owner, on the Agents tab on a phone: "improve the design and layout
+ so it's more intuitive, minimalist, elegant". It was a row with two outlined pills ("Resume", "Open"), a grey box
+ that said "Paused" a second time beside a green dot, the model and the cap in a sentence, a field with a "Send" pill
+ and a "Read recent history" pill. Now:
+ - **Who, in one row.** The face wears a ring in the colour of its state (green and breathing while it works, amber
+ while it waits for you, dimmed when paused), then the name and one quiet line: the state, and today's spend against
+ the cap ("Paused · $0.00 of $1.00 today"; the model is that figure's tooltip). The row is the link to the Actor's
+ page, so "Open" is gone.
+ - **One action, the one that fits.** "Resume" in the accent when the Actor is paused; a quiet pause button when it
+ runs. What happened is said in one line under the field ("Resumed.", "Sent. Your Actor reads it at its next step.",
+ or why it did not go through), in place of notes piling up at the bottom.
+ - **What it does, only when there is something to say.** The step it is on (a soft pulse), what it is looking at, the
+ steps before it and what comes next ("Next: … , in 40 min"), as a short line of dots. A paused Actor shows none of
+ it, and the state words Actor sends as a step ("Paused", "Resting until the next goal is due.") are no longer
+ drawn as one.
+ - **What it asks you** sits in the warning tone (a lean and a glow), with its own buttons: the first in the accent,
+ "Not now" as plain words, and "1 more on your Actor's page" as a link.
+ - **One field to tell it something**, with send inside the field; it lights up when there are words in it.
+ - **Built once.** A refresh (every 15 seconds) used to rebuild the whole block and wiped the line being typed; now it
+ repaints the row, the steps and the ask only when the answer changed, and never touches the field.
+ - "Recent history" is a quiet text button; the button that opens the list closes it, one line per entry.
+- **The agent view (`dock-agent.js`): no black box for a session that left no picture.** A session that ended with no
+ still drew a 16:9 black stage saying "Nothing to show", which pushed the runs off a phone's screen. The stage is
+ left out; the line under it still says what it was and how long it worked.
+- **The kit's tone (`kit.css`).** The owner, on a notice with a red bar down its side: "instead of just like this red
+ and crappy border … blur maybe effect or shadow, glow". Notices and `.ovk-tone[data-tone=good|alert|warning]` take a
+ tinted hairline, a gradient that leans from the corner, a soft glow under them and a round icon chip; no side stripe
+ anywhere.
+
## 4.195.0 — 2026-11-10
## 4.195.0 — 2026-11-10
**The You tab gets a row of its own, and the menu grows out of the button that was pressed.** From the owner's notes of
**The You tab gets a row of its own, and the menu grows out of the button that was pressed.** From the owner's notes of
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.195.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.196.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.195.0",
+ "version": "4.196.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -1,21 +1,25 @@
@@ -1,21 +1,25 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// OpenVibe — the Dock's Actor block (Actor v3 plan, stage A3; design 2026-11-09)
+// OpenVibe — the Dock's Actor block (Actor v3 plan, stage A3; redrawn 2026-11-10)
//
//
-// Your Actor at the top of the Dock's Actor tab, on every OpenVibe site. dock.js loads this file the first time that
+// Your Actor at the top of the Dock's Agents tab, on every OpenVibe site. dock.js loads this file the first time that
// tab opens:
// tab opens:
// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }
// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }
//
//
// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,
// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,
// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay
// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay
// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`
// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`
-// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token. It shows:
-// - the Actor's face, name and state (Working, Waiting for you, Resting, Paused), Pause/Resume, and its model;
-// - Now: the step it is on in plain words, what it is looking at, the last three steps and what comes next, and the
-// cost so far today;
-// - the first thing it is asking you, with its own buttons;
-// - one line to tell it something.
-// A person with no Actor sees nothing here (the tab's timeline and list stay as they are). Everything people or the
-// Actor wrote is set as text, never markup. No spinner: a running step shows its words with a soft pulse.
+// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token.
+//
+// The look (the owner, 2026-11-10: "more intuitive, minimalist, elegant"): one row and one line to type in.
+// - Who: the face with a ring in the colour of its state, the name, and one quiet line (the state, today's spend
+// against the cap). The row is the way to the Actor's page; there is no separate "Open".
+// - One action beside it, the one that fits: "Resume" in the accent when it is paused, a quiet pause when it runs.
+// - What it does, only when there is something to say: the step it is on (a soft pulse), what it looks at, the
+// steps before it, and what comes next, as a short line of dots. A paused Actor shows none of this: the row says it.
+// - What it asks you, with its own buttons, in the warning tone (a lean and a glow, never a bar).
+// - One field to tell it something, with send inside the field.
+// A person with no Actor sees nothing here. Everything people or the Actor wrote is set as text, never markup. The
+// block is built once: a refresh repaints what changed and never touches the line being typed. Every control is 44 px.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function (root) {
(function (root) {
'use strict';
'use strict';
@@ -23,6 +27,16 @@
@@ -23,6 +27,16 @@
const POLL_MS = 15000;
const POLL_MS = 15000;
const STATE = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' };
const STATE = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' };
+ // The lines Actor sends as "the current step" when there is none: the state already says them.
+ const NOT_A_STEP = /^(paused|resting\b.*)\.?$/i;
+ const ICON = {
+ play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 5.6v12.8a.9.9 0 0 0 1.37.77l10.3-6.4a.9.9 0 0 0 0-1.54l-10.3-6.4a.9.9 0 0 0-1.37.77z" fill="currentColor"/></svg>',
+ pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/><rect x="13.5" y="5" width="4" height="14" rx="1.3" fill="currentColor"/></svg>',
+ go: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6.5 15 12l-5.5 5.5"/></svg>',
+ send: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5.5M6 11l6-6 6 6"/></svg>',
+ ask: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.2 9.3a2.9 2.9 0 1 1 4.4 2.5c-.9.6-1.6 1.2-1.6 2.3"/><path d="M12 17.6v.1"/></svg>',
+ past: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.6-5.9"/><path d="M4 5v3.6h3.6"/><path d="M12 8.2V12l2.6 1.7"/></svg>',
+ };
function styles(doc) {
function styles(doc) {
const d = doc || document;
const d = doc || document;
@@ -30,58 +44,133 @@
@@ -30,58 +44,133 @@
const st = d.createElement('style');
const st = d.createElement('style');
st.id = 'ovdac-styles';
st.id = 'ovdac-styles';
st.textContent = `
st.textContent = `
- .ovdac { display: none; flex: none; padding: 10px 10px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
- .ovdac.on { display: grid; gap: 8px; }
- .ovdac-head { display: flex; align-items: center; gap: 10px; min-width: 0; }
- .ovdac-face { flex: none; width: 34px; height: 34px; border-radius: 50%; overflow: hidden; display: grid; place-items: center;
- background: var(--accent, #3b82f6); color: #fff; font-weight: 800; font-size: 14px; }
- .ovdac-face img { width: 100%; height: 100%; object-fit: cover; }
- .ovdac-who { min-width: 0; flex: 1; }
- .ovdac-name { display: block; font-weight: 800; font-size: 14px; color: var(--text-primary, #ececf1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .ovdac-state { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-secondary, #b6b6c0); }
- .ovdac-state .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted, #8b8b96); }
- .ovdac-state.is-working .dot { background: var(--success, #22c55e); animation: ovdac-pulse 1.8s ease-in-out infinite; }
- .ovdac-state.is-waiting .dot { background: var(--warning, #f5a524); }
- .ovdac-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 44px; padding: 0 14px; border-radius: 999px;
+ .ovdac { display: none; flex: none; padding: 12px 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08));
+ --ovdac-ok: var(--success, #22c55e); --ovdac-warn: var(--warning, #f5a524); --ovdac-ink: var(--text-primary, #ececf1); }
+ .ovdac.on { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
+ .ovdac [hidden] { display: none !important; }
+ .ovdac svg { width: 18px; height: 18px; flex: none; }
+ .ovdac .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+ .ovdac-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
+ .ovdac-id { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 2px 8px 2px 4px; margin-left: -4px; border-radius: 14px;
+ color: inherit; text-decoration: none; transition: background .15s ease; }
+ .ovdac-id:hover { background: color-mix(in srgb, var(--ovdac-ink) 5%, transparent); }
+ .ovdac-id:focus-visible, .ovdac-go:focus-visible, .ovdac-btn:focus-visible, .ovdac-link:focus-visible, .ovdac-send:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px; }
+ .ovdac-face { position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center;
+ background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font-weight: 800; font-size: 16px; transition: filter .2s ease, opacity .2s ease; }
+ .ovdac-face img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
+ .ovdac-face::after { content: ''; position: absolute; inset: -4px; border-radius: 50%; border: 2px solid transparent; transition: border-color .2s ease; }
+ .ovdac[data-state="working"] .ovdac-face::after { border-color: var(--ovdac-ok); animation: ovdac-ring 2.4s ease-in-out infinite; }
+ .ovdac[data-state="waiting"] .ovdac-face::after { border-color: var(--ovdac-warn); }
+ .ovdac[data-state="paused"] .ovdac-face { filter: saturate(.5); opacity: .78; }
+ .ovdac-who { min-width: 0; display: grid; gap: 1px; }
+ .ovdac-name { display: flex; align-items: center; gap: 1px; min-width: 0; font-weight: 750; font-size: 15px; line-height: 1.25; color: var(--ovdac-ink); }
+ .ovdac-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ovdac-name svg { width: 16px; height: 16px; color: var(--text-muted, #8b8b96); transition: transform .18s ease, color .18s ease; }
+ .ovdac-id:hover .ovdac-name svg { transform: translateX(2px); color: var(--text-secondary, #b6b6c0); }
+ .ovdac-line { display: flex; gap: 6px; min-width: 0; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); white-space: nowrap; }
+ .ovdac-state { flex: none; font-weight: 650; color: var(--text-secondary, #b6b6c0); }
+ .ovdac[data-state="working"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-ok) 72%, var(--ovdac-ink)); }
+ .ovdac[data-state="waiting"] .ovdac-state { color: color-mix(in srgb, var(--ovdac-warn) 76%, var(--ovdac-ink)); }
+ .ovdac-spend { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
+ .ovdac-spend::before { content: '·'; margin-right: 6px; }
+
+ .ovdac-go { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; height: 44px; padding: 0; border-radius: 999px;
border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-secondary, #b6b6c0);
border: 1px solid var(--border, rgba(255,255,255,.14)); background: transparent; color: var(--text-secondary, #b6b6c0);
- font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; text-decoration: none; white-space: nowrap; }
- .ovdac-btn:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
+ font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, color .15s ease, transform .12s ease, box-shadow .2s ease; }
+ .ovdac-go:hover { color: var(--ovdac-ink); background: color-mix(in srgb, var(--ovdac-ink) 7%, transparent); }
+ .ovdac-go.is-main { padding: 0 18px 0 13px; border-color: transparent; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff);
+ box-shadow: 0 8px 20px -10px var(--accent, #3b82f6); }
+ .ovdac-go.is-main:hover { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); filter: brightness(1.08); }
+ .ovdac-go:active, .ovdac-btn:active, .ovdac-send:active { transform: scale(.96); }
+ .ovdac .is-busy { opacity: .6; pointer-events: none; animation: ovdac-pulse 1.1s ease-in-out infinite; }
+
+ .ovdac-rail { list-style: none; margin: 0; padding: 2px 4px 2px 6px; display: grid; gap: 9px; }
+ .ovdac-rail li { position: relative; padding-left: 22px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
+ .ovdac-rail li::before { content: ''; position: absolute; left: 1px; top: .36em; width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--text-muted, #8b8b96); }
+ .ovdac-rail li:not(:last-child)::after { content: ''; position: absolute; left: 5px; top: calc(.36em + 13px); bottom: -8px; width: 1px;
+ background: color-mix(in srgb, var(--ovdac-ink) 14%, transparent); }
+ .ovdac-rail .is-live { font-size: 13.5px; font-weight: 650; color: var(--ovdac-ink); }
+ .ovdac-rail .is-live::before { border-color: var(--ovdac-ok); background: var(--ovdac-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovdac-ok) 20%, transparent); animation: ovdac-pulse 1.8s ease-in-out infinite; }
+ .ovdac-rail .is-next { color: var(--text-muted, #8b8b96); }
+ .ovdac-rail .is-next::before { border-style: dashed; }
+ .ovdac-rail b { font-weight: 650; }
+ .ovdac-look { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--text-muted, #8b8b96); }
+
+ .ovdac-ask { display: grid; gap: 10px; padding: 12px 14px 10px; border-radius: 16px;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--ovdac-warn) 15%, transparent), color-mix(in srgb, var(--ovdac-warn) 5%, transparent) 62%);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovdac-warn) 22%, transparent), 0 12px 28px -20px var(--ovdac-warn); }
+ .ovdac-ask-k { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; color: color-mix(in srgb, var(--ovdac-warn) 74%, var(--ovdac-ink)); }
+ .ovdac-ask-k i { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: color-mix(in srgb, var(--ovdac-warn) 24%, transparent); }
+ .ovdac-ask-k svg { width: 16px; height: 16px; }
+ .ovdac-ask p { margin: 0; font-size: 14px; line-height: 1.45; color: var(--ovdac-ink); overflow-wrap: anywhere; }
+ .ovdac-ask .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+ .ovdac-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--ovdac-ink) 18%, transparent); background: transparent; color: var(--ovdac-ink);
+ font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s ease, transform .12s ease; }
+ .ovdac-btn:hover { background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); }
.ovdac-btn.is-yes { background: var(--accent-strong, var(--accent, #2563eb)); border-color: transparent; color: var(--on-accent-strong, #fff); }
.ovdac-btn.is-yes { background: var(--accent-strong, var(--accent, #2563eb)); border-color: transparent; color: var(--on-accent-strong, #fff); }
- .ovdac-btn:disabled { opacity: .5; cursor: default; }
- .ovdac-now { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #ececf1) 4%, transparent); }
- .ovdac-step { display: flex; gap: 8px; align-items: flex-start; font-weight: 700; font-size: 13px; color: var(--text-primary, #ececf1); line-height: 1.35; }
- .ovdac-step .dot { flex: none; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: var(--success, #22c55e); animation: ovdac-pulse 1.8s ease-in-out infinite; }
- .ovdac-look, .ovdac-next, .ovdac-cost { font-size: 12px; color: var(--text-muted, #8b8b96); overflow-wrap: anywhere; }
- .ovdac-done { margin: 0; padding-left: 18px; display: grid; gap: 2px; font-size: 12px; color: var(--text-secondary, #b6b6c0); }
- .ovdac-ask { display: grid; gap: 6px; padding: 10px 12px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--warning, #f5a524) 45%, transparent);
- background: color-mix(in srgb, var(--warning, #f5a524) 10%, transparent); }
- .ovdac-ask b { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: color-mix(in srgb, var(--warning, #f5a524) 70%, var(--text-primary, #ececf1)); }
- .ovdac-ask p { margin: 0; font-size: 13px; color: var(--text-primary, #ececf1); }
- .ovdac-ask .row { display: flex; flex-wrap: wrap; gap: 6px; }
- .ovdac-tell { display: flex; gap: 6px; }
- .ovdac-tell input { flex: 1; min-width: 0; min-height: 44px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14));
- background: var(--bg-input, transparent); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; }
- .ovdac-note { font-size: 12px; color: var(--text-muted, #8b8b96); }
- @keyframes ovdac-pulse { 50% { opacity: .4; } }
+ .ovdac-btn.is-yes:hover { background: var(--accent-strong, var(--accent, #2563eb)); filter: brightness(1.08); }
+ .ovdac-btn.is-quiet { padding: 0 10px; border-color: transparent; color: var(--text-secondary, #b6b6c0); font-weight: 650; }
+ .ovdac-more { display: inline-flex; align-items: center; min-height: 44px; margin: -8px 0 -8px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); text-decoration: underline; text-underline-offset: 3px; }
+
+ .ovdac-tell { position: relative; display: flex; }
+ .ovdac-tell input { flex: 1; min-width: 0; height: 52px; box-sizing: border-box; padding: 0 58px 0 18px; border-radius: 26px;
+ border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-input, color-mix(in srgb, var(--ovdac-ink) 4%, transparent));
+ color: var(--ovdac-ink); font: inherit; font-size: 14.5px; outline: none; transition: border-color .15s ease, box-shadow .15s ease; }
+ .ovdac-tell input::placeholder { color: var(--text-muted, #8b8b96); }
+ .ovdac-tell input:focus { border-color: color-mix(in srgb, var(--accent, #3b82f6) 62%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
+ .ovdac-send { position: absolute; right: 4px; top: 4px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%;
+ background: color-mix(in srgb, var(--ovdac-ink) 8%, transparent); color: var(--text-muted, #8b8b96); cursor: pointer;
+ transition: background .18s ease, color .18s ease, transform .12s ease, box-shadow .2s ease; }
+ .ovdac-tell.has-text .ovdac-send { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); box-shadow: 0 6px 16px -8px var(--accent, #3b82f6); }
+
+ .ovdac-foot { display: flex; align-items: center; gap: 10px; min-width: 0; margin-top: -6px; }
+ .ovdac-link { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 8px; margin-left: -8px; border: 0; border-radius: 10px;
+ background: none; color: var(--text-muted, #8b8b96); font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; transition: color .15s ease; }
+ .ovdac-link:hover, .ovdac-link[aria-expanded="true"] { color: var(--ovdac-ink); }
+ .ovdac-link svg { width: 15px; height: 15px; }
+ .ovdac-note { flex: 1; min-width: 0; text-align: right; font-size: 12.5px; line-height: 1.35; color: var(--text-muted, #8b8b96); overflow-wrap: anywhere; }
+ .ovdac-note.is-ok { color: color-mix(in srgb, var(--ovdac-ok) 70%, var(--ovdac-ink)); }
+ .ovdac-note.is-bad { color: color-mix(in srgb, var(--danger, #ef4444) 74%, var(--ovdac-ink)); }
+ .ovdac-hist { list-style: none; margin: -4px 0 4px; padding: 0; display: grid; gap: 6px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); overflow-wrap: anywhere; }
+ .ovdac-hist b { margin-right: 6px; font-weight: 650; color: var(--text-muted, #8b8b96); }
+
+ @keyframes ovdac-pulse { 50% { opacity: .45; } }
+ @keyframes ovdac-ring { 50% { opacity: .35; transform: scale(1.06); } }
+ @media (pointer: coarse) { .ovdac-tell input { font-size: 16px; } }
+ html[data-ov-motion=off] .ovdac *, html[data-ov-motion=off] .ovdac *::before, html[data-ov-motion=off] .ovdac *::after { animation: none !important; transition: none !important; }
`;
`;
(d.head || d.documentElement).appendChild(st);
(d.head || d.documentElement).appendChild(st);
}
}
const money = (n) => { const v = Number(n) || 0; return v < 0.01 && v > 0 ? `$${v.toFixed(3)}` : `$${v.toFixed(2)}`; };
const money = (n) => { const v = Number(n) || 0; return v < 0.01 && v > 0 ? `$${v.toFixed(3)}` : `$${v.toFixed(2)}`; };
+ /** "in 40 min", "in 3 h", "tomorrow": when the next goal is due; '' when unknown or already due. */
+ function due(at, now = Date.now()) {
+ const t = Date.parse(at || '');
+ if (!Number.isFinite(t)) return '';
+ const min = Math.round((t - now) / 60000);
+ if (min < 1) return '';
+ if (min < 60) return `in ${min} min`;
+ if (min < 24 * 60) return `in ${Math.round(min / 60)} h`;
+ return min < 48 * 60 ? 'tomorrow' : `in ${Math.round(min / 1440)} days`;
+ }
function mount(container, opts = {}) {
function mount(container, opts = {}) {
const d = container.ownerDocument || document;
const d = container.ownerDocument || document;
styles(d);
styles(d);
const o = { ...opts };
const o = { ...opts };
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
- let gone = false, timer = 0, busy = false;
+ const icon = (name) => { const t = d.createElement('template'); t.innerHTML = ICON[name]; return t.content.firstChild; };
+ const tip = (n, text) => { if (root.OVTip) n.setAttribute('data-ov-tip', text); else n.title = text; };
+ let gone = false, timer = 0, busy = false, shown = false, last = '', actor = null, noteTimer = 0;
container.replaceChildren();
container.replaceChildren();
container.classList.add('ovdac');
container.classList.add('ovdac');
// Network's frame relay on a site; an explicit base reaches Actor itself (a test).
// Network's frame relay on a site; an explicit base reaches Actor itself (a test).
const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);
const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);
- // "Open" is a navigation, never a fetch: Actor's own page, wherever the API is.
+ // The way to the Actor's page is a navigation, never a fetch: Actor's own site, wherever the API is.
const openBase = direct || 'https://openvibe.actor';
const openBase = direct || 'https://openvibe.actor';
async function api(path, init = {}) {
async function api(path, init = {}) {
@@ -95,111 +184,175 @@
@@ -95,111 +184,175 @@
}
}
function stateOf(a) {
function stateOf(a) {
- const asks = Array.isArray(a.asks) ? a.asks : [];
- if (asks.length) return 'waiting';
+ if (Array.isArray(a.asks) && a.asks.length) return 'waiting';
return STATE[a.status] ? a.status : 'paused';
return STATE[a.status] ? a.status : 'paused';
}
}
- function render(a) {
- container.replaceChildren();
- if (!a || a.enabled === false && !a.id) { container.classList.remove('on'); return; }
- container.classList.add('on');
- const state = stateOf(a);
- // Head: face, name and state; Pause or Resume; the model; open the Actor's page.
- const head = h('div', 'ovdac-head');
- const face = h('span', 'ovdac-face');
+ // ── The parts, built once ────────────────────────────────────────────────────────────────────────────────
+ const head = h('div', 'ovdac-head');
+ const id = h('a', 'ovdac-id'); id.href = openBase + '/you'; id.target = '_blank'; id.rel = 'noopener';
+ const face = h('span', 'ovdac-face');
+ const who = h('span', 'ovdac-who');
+ const name = h('span', 'ovdac-name'); const nameText = h('span'); name.append(nameText, icon('go'));
+ const line = h('span', 'ovdac-line'); const stateText = h('span', 'ovdac-state'); const spend = h('span', 'ovdac-spend'); line.append(stateText, spend);
+ who.append(name, line); id.append(face, who);
+ const go = h('button', 'ovdac-go'); go.type = 'button';
+ go.addEventListener('click', () => {
+ if (!actor) return;
+ const paused = actor.status === 'paused';
+ act(paused ? '/resume' : '/pause', {}, go, paused ? 'Resumed.' : 'Paused.');
+ });
+ head.append(id, go);
+
+ const rail = h('ul', 'ovdac-rail'); rail.hidden = true;
+ const askBox = h('div', 'ovdac-ask'); askBox.hidden = true;
+
+ const form = h('form', 'ovdac-tell');
+ const input = h('input'); input.type = 'text'; input.maxLength = 2000; input.autocomplete = 'off'; input.enterKeyHint = 'send';
+ input.placeholder = 'Tell your Actor something'; input.setAttribute('aria-label', 'Tell your Actor something');
+ const send = h('button', 'ovdac-send'); send.type = 'submit'; send.setAttribute('aria-label', 'Send'); send.append(icon('send'));
+ input.addEventListener('input', () => form.classList.toggle('has-text', !!input.value.trim()));
+ form.append(input, send);
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const text = input.value.trim();
+ if (!text) { input.focus(); return; }
+ const paused = actor && actor.status === 'paused';
+ act('/tell', { text }, send, paused ? 'Sent. Your Actor reads it once you resume it.' : 'Sent. Your Actor reads it at its next step.')
+ .then((ok) => { if (ok) { input.value = ''; form.classList.remove('has-text'); } });
+ });
+
+ const foot = h('div', 'ovdac-foot');
+ const past = h('button', 'ovdac-link'); past.type = 'button'; past.setAttribute('aria-expanded', 'false'); past.append(icon('past'), d.createTextNode('Recent history'));
+ const note = h('div', 'ovdac-note'); note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite');
+ const hist = h('ul', 'ovdac-hist'); hist.hidden = true;
+ foot.append(past, note);
+ // The button that opens the list closes it.
+ past.addEventListener('click', async () => {
+ if (!hist.hidden) { hist.hidden = true; past.setAttribute('aria-expanded', 'false'); return; }
+ past.disabled = true; past.classList.add('is-busy');
+ const one = (text) => { hist.replaceChildren(h('li', '', text)); };
+ try {
+ const t = typeof o.token === 'function' ? await o.token() : o.token;
+ if (!t) throw new Error('Sign in to read history.');
+ const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10';
+ const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });
+ const result = await response.json().catch(() => ({}));
+ if (response.status === 403) one('Your agent history setting is off. Turn it on in My settings to allow this read.');
+ else if (!response.ok) one('Recent history is unavailable.');
+ else {
+ const items = (result.items || []).slice(0, 10);
+ if (!items.length) one('No recent history.');
+ else hist.replaceChildren(...items.map((x) => { const li = h('li'); li.append(h('b', '', String(x.type || '')), d.createTextNode(String(x.title || ''))); return li; }));
+ }
+ } catch { one('Recent history is unavailable.'); }
+ finally { past.disabled = false; past.classList.remove('is-busy'); }
+ hist.hidden = false; past.setAttribute('aria-expanded', 'true');
+ });
+
+ /** One line under the field: what just happened. A good word goes by itself after a moment; a problem stays. */
+ function say(text, tone) {
+ clearTimeout(noteTimer);
+ note.textContent = text || '';
+ note.className = 'ovdac-note' + (tone ? ` is-${tone}` : '');
+ if (text && tone === 'ok') noteTimer = setTimeout(() => { note.textContent = ''; note.className = 'ovdac-note'; }, 6000);
+ }
+
+ // ── A refresh repaints the row, the steps and the ask; the field and the history stay as they are ────────
+ function paint(a) {
+ if (!a || (a.enabled === false && !a.id)) {
+ actor = null; shown = false; last = '';
+ container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state');
+ return;
+ }
+ actor = a;
+ if (!shown) { container.replaceChildren(head, rail, askBox, form, foot, hist); container.classList.add('on'); shown = true; }
+ const sig = JSON.stringify([a.name, a.status, a.avatar, a.brain, a.caps, a.now, a.asks]);
+ if (sig === last) return;
+ last = sig;
+ const state = stateOf(a), now = a.now || {};
+ container.setAttribute('data-state', state);
+
+ // Who, and the one line about it.
+ const label = String(a.name || 'Your Actor');
const pic = a.avatar && typeof a.avatar.url === 'string' && /^https:\/\//.test(a.avatar.url) ? a.avatar.url : '';
const pic = a.avatar && typeof a.avatar.url === 'string' && /^https:\/\//.test(a.avatar.url) ? a.avatar.url : '';
- if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';
- const who = h('span', 'ovdac-who');
- who.append(h('span', 'ovdac-name', a.name || 'Your Actor'));
- const st = h('span', `ovdac-state is-${state}`); st.append(h('span', 'dot'), d.createTextNode(STATE[state]));
- who.append(st);
- const pause = h('button', 'ovdac-btn', a.status === 'paused' ? 'Resume' : 'Pause'); pause.type = 'button';
- pause.addEventListener('click', () => act(a.status === 'paused' ? '/resume' : '/pause', {}, pause));
- const open = h('a', 'ovdac-btn', 'Open'); open.href = openBase + '/you'; open.target = '_blank'; open.rel = 'noopener';
- head.append(face, who, pause, open);
- container.append(head);
- // Now: the step, what it looks at, the last three, next, today's cost.
- const now = a.now || {};
- const box = h('div', 'ovdac-now');
- if (now.current_step) {
- const step = h('div', 'ovdac-step'); step.append(h('span', 'dot'), d.createTextNode(String(now.current_step)));
- box.append(step);
- if (now.looking_at) box.append(h('div', 'ovdac-look', `Looking at: ${now.looking_at.title || now.looking_at}`));
- } else {
- box.append(h('div', 'ovdac-step', state === 'paused' ? 'Paused. It does nothing until you resume it.' : 'Resting.'));
+ if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.replaceChildren(img); } else face.replaceChildren(d.createTextNode(label.trim().charAt(0).toUpperCase() || 'A'));
+ nameText.textContent = label;
+ id.setAttribute('aria-label', `${label}, ${STATE[state]}. Open your Actor's page`);
+ stateText.textContent = STATE[state];
+ const cap = a.caps && Number(a.caps.usd) > 0 ? ` of ${money(a.caps.usd)}` : '';
+ spend.textContent = `${money(now.cost_today)}${cap} today`;
+ tip(spend, `Model: ${a.brain && a.brain !== 'auto' ? a.brain : 'Auto'}`);
+
+ // The one action that fits.
+ const paused = a.status === 'paused';
+ go.className = 'ovdac-go' + (paused ? ' is-main' : '');
+ go.replaceChildren(icon(paused ? 'play' : 'pause'), h('span', paused ? '' : 'sr', paused ? 'Resume' : 'Pause'));
+ tip(go, paused ? 'Your Actor does nothing until you resume it' : 'Pause your Actor');
+
+ // What it does: the step it is on, the steps before it, what comes next. Nothing when there is nothing to say.
+ const asks = Array.isArray(a.asks) ? a.asks : [];
+ const ask = asks[0];
+ const step = String(now.current_step || '').trim();
+ const live = a.status === 'working' && step && !NOT_A_STEP.test(step) && !(ask && step === String(ask.text || '').trim()) ? step : '';
+ const items = [];
+ if (live) {
+ const li = h('li', 'is-live'); li.append(d.createTextNode(live));
+ const looking = now.looking_at && (now.looking_at.title || now.looking_at);
+ if (looking) li.append(h('span', 'ovdac-look', `Looking at ${String(looking)}`));
+ items.push(li);
}
}
- const last = Array.isArray(now.last_three_steps) ? now.last_three_steps.slice(0, 3) : [];
- if (last.length) { const ol = h('ol', 'ovdac-done'); last.forEach((x) => ol.append(h('li', '', String(x.text || x)))); box.append(ol); }
- if (now.next) box.append(h('div', 'ovdac-next', `Next: ${now.next.text || now.next}`));
- const model = a.brain && a.brain !== 'auto' ? a.brain : 'Auto';
- box.append(h('div', 'ovdac-cost', `Model: ${model} · today ${money(now.cost_today)}${a.caps && a.caps.usd ? ` of your ${money(a.caps.usd)} cap` : ''}`));
- container.append(box);
+ const before = (Array.isArray(now.last_three_steps) ? now.last_three_steps : []).map((x) => String((x && x.text) || x || '').trim()).filter((t) => t && t !== live).slice(0, 3);
+ if (!paused) before.forEach((t) => items.push(h('li', '', t)));
+ if (!paused && now.next && (now.next.text || typeof now.next === 'string')) {
+ const li = h('li', 'is-next'); const when = due(now.next.at);
+ li.append(h('b', '', 'Next: '), d.createTextNode(String(now.next.text || now.next) + (when ? `, ${when}` : '')));
+ items.push(li);
+ }
+ rail.replaceChildren(...items); rail.hidden = !items.length;
+
// The first thing it asks, with its own buttons.
// The first thing it asks, with its own buttons.
- const ask = (Array.isArray(a.asks) ? a.asks : [])[0];
if (ask) {
if (ask) {
- const card = h('div', 'ovdac-ask');
- card.append(h('b', '', 'It asks you'), h('p', '', String(ask.text || '')));
+ const k = h('div', 'ovdac-ask-k'); const chip = h('i'); chip.append(icon('ask')); k.append(chip, d.createTextNode(`${label} asks`));
const row = h('div', 'row');
const row = h('div', 'row');
const options = Array.isArray(ask.options) && ask.options.length ? ask.options : [{ id: 'yes', label: 'Yes' }];
const options = Array.isArray(ask.options) && ask.options.length ? ask.options : [{ id: 'yes', label: 'Yes' }];
options.slice(0, 3).forEach((op, i) => {
options.slice(0, 3).forEach((op, i) => {
const b = h('button', `ovdac-btn${i === 0 ? ' is-yes' : ''}`, String(op.label || op)); b.type = 'button';
const b = h('button', `ovdac-btn${i === 0 ? ' is-yes' : ''}`, String(op.label || op)); b.type = 'button';
- b.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/answer`, { option: op.id || op }, b));
+ b.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/answer`, { option: op.id || op }, b, 'Answered.'));
row.append(b);
row.append(b);
});
});
- const no = h('button', 'ovdac-btn', 'Not now'); no.type = 'button';
+ const no = h('button', 'ovdac-btn is-quiet', 'Not now'); no.type = 'button';
no.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/decline`, {}, no));
no.addEventListener('click', () => act(`/asks/${encodeURIComponent(ask.id)}/decline`, {}, no));
row.append(no);
row.append(no);
- card.append(row);
- if (a.asks.length > 1) card.append(h('div', 'ovdac-note', `${a.asks.length - 1} more on your Actor's page`));
- container.append(card);
- }
- // Tell it something.
- const form = h('form', 'ovdac-tell');
- const input = h('input'); input.type = 'text'; input.maxLength = 2000; input.placeholder = 'Tell your Actor something'; input.setAttribute('aria-label', 'Tell your Actor something');
- const send = h('button', 'ovdac-btn', 'Send'); send.type = 'submit';
- form.append(input, send);
- form.addEventListener('submit', (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; act('/tell', { text }, send).then((ok) => { if (ok) input.value = ''; }); });
- container.append(form);
- const history = h('div', 'ovdac-history');
- const read = h('button', 'ovdac-btn', 'Read recent history'); read.type = 'button';
- const rows = h('div', 'ovdac-note'); rows.hidden = true;
- read.addEventListener('click', async () => {
- read.disabled = true;
- try {
- const t = typeof o.token === 'function' ? await o.token() : o.token;
- if (!t) throw new Error('Sign in to read history.');
- const url = String(o.networkBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/v1/history/agents/recent?limit=10';
- const response = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });
- const result = await response.json().catch(() => ({}));
- if (response.status === 403) { rows.textContent = 'Your agent history setting is off. Turn it on in My settings to allow this read.'; }
- else if (!response.ok) { rows.textContent = 'Recent history is unavailable.'; }
- else { rows.textContent = (result.items || []).map((x) => `${x.type}: ${x.title}`).join(' · ') || 'No recent history.'; }
- rows.hidden = false;
- } catch { rows.textContent = 'Recent history is unavailable.'; rows.hidden = false; }
- finally { read.disabled = false; }
- });
- history.append(read, rows);
- container.append(history);
+ askBox.replaceChildren(k, h('p', '', String(ask.text || '')), row);
+ if (asks.length > 1) { const more = h('a', 'ovdac-more', `${asks.length - 1} more on your Actor's page`); more.href = openBase + '/you'; more.target = '_blank'; more.rel = 'noopener'; askBox.append(more); }
+ } else askBox.replaceChildren();
+ askBox.hidden = !ask;
}
}
- async function act(path, body, btn) {
+ async function act(path, body, btn, done) {
if (busy) return false;
if (busy) return false;
- busy = true; if (btn) btn.disabled = true;
- try { await api(path, { method: 'POST', json: true, body }); await refresh(); return true; }
- catch (err) { const n = h('div', 'ovdac-note', `That did not go through: ${err.message}`); container.append(n); return false; }
- finally { busy = false; if (btn) btn.disabled = false; }
+ busy = true; if (btn) { btn.disabled = true; btn.classList.add('is-busy'); }
+ say('');
+ try {
+ await api(path, { method: 'POST', json: true, body });
+ last = '';
+ await refresh();
+ if (done) say(done, 'ok');
+ return true;
+ } catch (err) { say(`That did not go through: ${err.message}`, 'bad'); return false; }
+ finally { busy = false; if (btn) { btn.disabled = false; btn.classList.remove('is-busy'); } }
}
}
async function refresh() {
async function refresh() {
if (gone) return;
if (gone) return;
if (o.active && !o.active()) return;
if (o.active && !o.active()) return;
- try { render(await api('')); }
- catch (err) { if (err.status === 401 || err.status === 403 || err.status === 404) render(null); }
+ try { paint(await api('')); }
+ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 404) paint(null); }
}
}
refresh();
refresh();
timer = setInterval(refresh, POLL_MS);
timer = setInterval(refresh, POLL_MS);
- return { refresh, destroy() { gone = true; clearInterval(timer); container.replaceChildren(); container.classList.remove('on'); } };
+ return { refresh, destroy() { gone = true; clearInterval(timer); clearTimeout(noteTimer); container.replaceChildren(); container.classList.remove('on'); container.removeAttribute('data-state'); } };
}
}
root.OpenVibeDockActor = { mount };
root.OpenVibeDockActor = { mount };
@@ -49,6 +49,7 @@
@@ -49,6 +49,7 @@
.ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }
.ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }
.ovda-still { object-fit: cover; opacity: .92; }
.ovda-still { object-fit: cover; opacity: .92; }
+ .ovda-stage[hidden] { display: none; }
.ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }
.ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }
.ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
.ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
.ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -154,8 +155,11 @@
@@ -154,8 +155,11 @@
} else if (!embed) {
} else if (!embed) {
if (shown) stage.innerHTML = '';
if (shown) stage.innerHTML = '';
if (agent.last_frame_url) still(agent.last_frame_url);
if (agent.last_frame_url) still(agent.last_frame_url);
- else if (!stage.querySelector('img')) stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));
+ else if (!ended && !stage.querySelector('img')) stage.append(h('div', 'ovda-wait', 'Waiting for the desk'));
}
}
+ // A session that ended and left no picture has no stage: a black box saying "Nothing to show" told nobody
+ // anything (the owner, 2026-11-10). The line under it still says what it was and how long it worked.
+ stage.hidden = ended && !embed && !agent.last_frame_url && !stage.querySelector('img');
// The line: what it is doing, one row.
// The line: what it is doing, one row.
line.innerHTML = '';
line.innerHTML = '';
@@ -113,7 +113,7 @@
@@ -113,7 +113,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '41d5083aeeb8', 'dock-runs.js': 'b734e5e7ba11', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '21c2d71b5e8e', 'dock-agent.js': 'de6612c136a8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
+ const DOCK_REV = { 'dock-vibe.js': '41d5083aeeb8', 'dock-runs.js': 'b734e5e7ba11', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'a88ef9799001', 'dock-agent.js': '2875da28ff94', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -305,14 +305,18 @@ div.ovk-set-row { cursor: default; }
@@ -305,14 +305,18 @@ div.ovk-set-row { cursor: default; }
.ovk-empty > .ovk-i { width: 28px; height: 28px; }
.ovk-empty > .ovk-i { width: 28px; height: 28px; }
.ovk-empty.ovk-empty p { margin: 0; max-width: 46ch; }
.ovk-empty.ovk-empty p { margin: 0; max-width: 46ch; }
.ovk-empty .ovk-empty-title { color: var(--ovk-text); font-weight: 750; }
.ovk-empty .ovk-empty-title { color: var(--ovk-text); font-weight: 750; }
-.ovk-notice { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 12px; padding: 10px 14px; border-radius: calc(var(--ov-radius-1) + 2px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); font-size: .92rem; }
-.ovk-notice > .ovk-i { margin-top: 2px; }
-.ovk-notice--ok { border-color: color-mix(in srgb, var(--ovk-ok) 45%, transparent); }
-.ovk-notice--ok > .ovk-i { color: var(--ovk-ok-text); }
-.ovk-notice--error { border-color: color-mix(in srgb, var(--ovk-bad) 50%, transparent); }
-.ovk-notice--error > .ovk-i { color: var(--ovk-bad-text); }
-.ovk-notice--warn > .ovk-i { color: var(--ovk-warn-text); }
-.ovk-notice--info > .ovk-i { color: var(--ovk-accent); }
+/* A tone is a lean and a glow, never a coloured bar (4.196.0; the owner, 2026-11-10: "instead of just like this red and
+ crappy border … shadow, glow … something nice. Also add some icons"). A notice leans toward its tone from the side its
+ icon is on, carries a hairline tinted with it and a soft glow in it; its icon sits in a round chip of the tone. The same
+ three properties are a class of their own (.ovk-tone, with data-tone) for a card, a toast or a row that must say
+ "good", "careful" or "wrong" without a stripe. */
+.ovk-notice, .ovk-tone { --ovk-tone: var(--ovk-accent); --ovk-tone-text: var(--ovk-accent); border: 1px solid color-mix(in srgb, var(--ovk-tone) 32%, var(--ovk-line)); background: linear-gradient(100deg, color-mix(in srgb, var(--ovk-tone) 13%, var(--ovk-panel)) 0, var(--ovk-panel) 58%); box-shadow: 0 12px 28px -20px var(--ovk-tone); }
+.ovk-notice--ok, .ovk-tone[data-tone="good"] { --ovk-tone: var(--ovk-ok); --ovk-tone-text: var(--ovk-ok-text); }
+.ovk-notice--error, .ovk-tone[data-tone="alert"] { --ovk-tone: var(--ovk-bad); --ovk-tone-text: var(--ovk-bad-text); }
+.ovk-notice--warn, .ovk-tone[data-tone="warning"] { --ovk-tone: var(--ovk-warn); --ovk-tone-text: var(--ovk-warn-text); }
+.ovk-notice { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 12px; padding: 10px 14px 10px 10px; border-radius: calc(var(--ov-radius-1) + 4px); color: var(--ovk-text); font-size: .92rem; }
+.ovk-notice > .ovk-i { flex: none; box-sizing: border-box; width: 26px; height: 26px; margin: -2px 0; padding: 5px; border-radius: 50%; background: color-mix(in srgb, var(--ovk-tone) 20%, transparent); color: var(--ovk-tone-text); }
+.ovk-notice > span { align-self: center; min-width: 0; }
.ovk-snip { position: relative; }
.ovk-snip { position: relative; }
.ovk-snip > .ovk-icon-btn { position: absolute; top: 6px; right: 6px; z-index: 1; width: 34px; height: 34px; background: var(--ovk-panel); border-color: var(--ovk-line); }
.ovk-snip > .ovk-icon-btn { position: absolute; top: 6px; right: 6px; z-index: 1; width: 34px; height: 34px; background: var(--ovk-panel); border-color: var(--ovk-line); }
.ovk-snip > pre { max-height: 50vh; margin: 0 0 10px; padding: 12px 48px 12px 12px; overflow: auto; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font-size: .84rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.ovk-snip > pre { max-height: 50vh; margin: 0 0 10px; padding: 12px 48px 12px 12px; overflow: auto; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font-size: .84rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -566,7 +566,7 @@
@@ -566,7 +566,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '810a6665e79c', 'dock.js': '1e2c189c1b1e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };
+ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '810a6665e79c', 'dock.js': 'de8b3805e36c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.195.0",
+ "version": "4.196.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.195.0",
+ "version": "4.196.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.195.0",
+ "version": "4.196.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -1,10 +1,12 @@
@@ -1,10 +1,12 @@
'use strict';
'use strict';
// The Dock's Actor block (dock-actor.js, Actor v3 stage A3), in a real headless Chrome, against a local stand-in for
// The Dock's Actor block (dock-actor.js, Actor v3 stage A3), in a real headless Chrome, against a local stand-in for
// OpenVibe.Actor's /api/v1/me/actor:
// OpenVibe.Actor's /api/v1/me/actor:
-// • a working Actor shows its name, "Working", the step in plain words, what it looks at, the last three steps, Next,
-// and the model with today's cost against the cap;
+// • a working Actor shows its name, its state, the step in plain words, what it looks at, the steps before it, Next,
+// and today's cost against the cap (the model is the cost's tooltip); the row is the link to the Actor's page;
// • its first ask shows with its own buttons: the first answers it (POST …/answer {option}), "Not now" declines it;
// • its first ask shows with its own buttons: the first answers it (POST …/answer {option}), "Not now" declines it;
-// • "Tell your Actor something" posts the words; Pause posts /pause;
+// • "Tell your Actor something" posts the words; Pause posts /pause; a paused Actor shows "Resume", no steps, and
+// never "Paused" twice; Resume posts /resume;
+// • a refresh never touches the line being typed, and an unchanged answer repaints nothing;
// • every call carries the person's token as a bearer (a token given as a function is resolved at the call) and no
// • every call carries the person's token as a bearer (a token given as a function is resolved at the call) and no
// cookies; what the Actor wrote is text, never markup;
// cookies; what the Actor wrote is text, never markup;
// • a person with no Actor (404) sees nothing; every control is 44 px; nothing runs off a 320 px screen.
// • a person with no Actor (404) sees nothing; every control is 44 px; nothing runs off a 320 px screen.
@@ -50,7 +52,8 @@ const server = http.createServer((req, res) => {
@@ -50,7 +52,8 @@ const server = http.createServer((req, res) => {
calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: body ? JSON.parse(body) : null });
calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: body ? JSON.parse(body) : null });
if (mode === 'none') { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"detail":"no actor"}'); }
if (mode === 'none') { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"detail":"no actor"}'); }
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { 'content-type': 'application/json' });
- return res.end(JSON.stringify(req.method === 'GET' ? ACTOR : { ok: true }));
+ const paused = { ...ACTOR, enabled: false, status: 'paused', asks: [], now: { current_step: 'Paused', looking_at: null, last_three_steps: [{ text: 'Opened the job board' }], next: { text: 'Write a cover letter' }, cost_today: 0 } };
+ return res.end(JSON.stringify(req.method === 'GET' ? (mode === 'paused' ? paused : ACTOR) : { ok: true }));
}
}
res.writeHead(404); res.end();
res.writeHead(404); res.end();
});
});
@@ -80,24 +83,34 @@ const wait = async (pg, expr, ms = 4000) => {
@@ -80,24 +83,34 @@ const wait = async (pg, expr, ms = 4000) => {
const t = (s) => (document.querySelector(s) || {}).textContent || '';
const t = (s) => (document.querySelector(s) || {}).textContent || '';
const box = document.getElementById('box');
const box = document.getElementById('box');
const small = [...box.querySelectorAll('button, a, input')].filter((n) => n.getBoundingClientRect().height < 44).map((n) => n.textContent || n.placeholder);
const small = [...box.querySelectorAll('button, a, input')].filter((n) => n.getBoundingClientRect().height < 44).map((n) => n.textContent || n.placeholder);
- return { name: t('.ovdac-name'), state: t('.ovdac-state'), step: t('.ovdac-step'), look: t('.ovdac-look'), done: [...box.querySelectorAll('.ovdac-done li')].map((n) => n.textContent),
- next: t('.ovdac-next'), cost: t('.ovdac-cost'), ask: t('.ovdac-ask p'), more: t('.ovdac-ask .ovdac-note'), imgs: box.querySelectorAll('img').length, pwned: !!window.pwned,
- buttons: [...box.querySelectorAll('.ovdac-ask button')].map((n) => n.textContent), small, scroll: document.documentElement.scrollWidth };
+ const live = box.querySelector('.ovdac-rail .is-live');
+ const spend = box.querySelector('.ovdac-spend');
+ return { name: t('.ovdac-name'), state: t('.ovdac-state'), step: live ? live.firstChild.textContent : '', look: t('.ovdac-look'),
+ done: [...box.querySelectorAll('.ovdac-rail li:not(.is-live):not(.is-next)')].map((n) => n.textContent),
+ next: t('.ovdac-rail .is-next'), cost: t('.ovdac-spend'), model: spend ? (spend.getAttribute('data-ov-tip') || spend.title) : '', ask: t('.ovdac-ask p'), more: t('.ovdac-more'),
+ page: (box.querySelector('.ovdac-id') || {}).href || '', kicker: t('.ovdac-ask-k'), ring: box.getAttribute('data-state'),
+ imgs: box.querySelectorAll('img').length, pwned: !!window.pwned,
+ buttons: [...box.querySelectorAll('.ovdac-ask button')].map((n) => n.textContent), small, scroll: document.documentElement.scrollWidth,
+ wide: [...box.querySelectorAll('*')].filter((n) => n.getBoundingClientRect().right > document.documentElement.clientWidth + 0.01).map((n) => n.tagName.toLowerCase() + '.' + String(n.className.baseVal || n.className) + ' ' + n.getBoundingClientRect().right.toFixed(1)).slice(0, 4) };
})()`);
})()`);
assert.strictEqual(seen.name, 'Juniper');
assert.strictEqual(seen.name, 'Juniper');
assert.strictEqual(seen.state, 'Waiting for you', 'an ask makes it wait for you');
assert.strictEqual(seen.state, 'Waiting for you', 'an ask makes it wait for you');
assert.strictEqual(seen.step, 'Reading the third job post on the list');
assert.strictEqual(seen.step, 'Reading the third job post on the list');
- assert.strictEqual(seen.look, 'Looking at: Remote support jobs, posted today');
+ assert.strictEqual(seen.look, 'Looking at Remote support jobs, posted today');
+ assert.strictEqual(seen.ring, 'waiting', 'the face wears the state');
+ assert.ok(seen.page.endsWith('/you'), 'the row is the way to the Actor\'s page');
+ assert.strictEqual(seen.kicker, 'Juniper asks');
assert.deepStrictEqual(seen.done, ['Opened the job board', 'Searched remote support', 'Saved two posts']);
assert.deepStrictEqual(seen.done, ['Opened the job board', 'Searched remote support', 'Saved two posts']);
assert.strictEqual(seen.next, 'Next: Write a cover letter for the best one');
assert.strictEqual(seen.next, 'Next: Write a cover letter for the best one');
- assert.strictEqual(seen.cost, 'Model: DeepSeek V4 · today $0.42 of your $3.00 cap');
+ assert.strictEqual(seen.cost, '$0.42 of $3.00 today');
+ assert.strictEqual(seen.model, 'Model: DeepSeek V4');
assert.ok(seen.ask.includes('<img src=x'), 'the ask is text, never markup');
assert.ok(seen.ask.includes('<img src=x'), 'the ask is text, never markup');
assert.strictEqual(seen.imgs, 0, 'no element came from the Actor\'s words');
assert.strictEqual(seen.imgs, 0, 'no element came from the Actor\'s words');
assert.strictEqual(seen.pwned, false);
assert.strictEqual(seen.pwned, false);
assert.deepStrictEqual(seen.buttons, ['Apply', 'Skip it', 'Not now']);
assert.deepStrictEqual(seen.buttons, ['Apply', 'Skip it', 'Not now']);
assert.strictEqual(seen.more, '1 more on your Actor\'s page');
assert.strictEqual(seen.more, '1 more on your Actor\'s page');
assert.deepStrictEqual(seen.small, [], `${width}px: every control is 44 px`);
assert.deepStrictEqual(seen.small, [], `${width}px: every control is 44 px`);
- assert.ok(seen.scroll <= width, `${width}px: no sideways scroll (${seen.scroll})`);
+ assert.ok(seen.scroll <= width, `${width}px: no sideways scroll (${seen.scroll}; ${seen.wide.join(', ')})`);
await pg.close();
await pg.close();
}
}
@@ -133,8 +146,36 @@ const wait = async (pg, expr, ms = 4000) => {
@@ -133,8 +146,36 @@ const wait = async (pg, expr, ms = 4000) => {
const tell = await post(`(() => { const i = document.querySelector('.ovdac-tell input'); i.value = 'Only remote jobs, please'; document.querySelector('.ovdac-tell button').click(); })()`, '/api/v1/me/actor/tell');
const tell = await post(`(() => { const i = document.querySelector('.ovdac-tell input'); i.value = 'Only remote jobs, please'; document.querySelector('.ovdac-tell button').click(); })()`, '/api/v1/me/actor/tell');
assert.deepStrictEqual(tell.body, { text: 'Only remote jobs, please' });
assert.deepStrictEqual(tell.body, { text: 'Only remote jobs, please' });
await post(`[...document.querySelectorAll('.ovdac-head button')].find((b) => b.textContent === 'Pause').click()`, '/api/v1/me/actor/pause');
await post(`[...document.querySelectorAll('.ovdac-head button')].find((b) => b.textContent === 'Pause').click()`, '/api/v1/me/actor/pause');
+
+ // The line being typed survives a refresh, and an unchanged answer repaints nothing.
+ await new Promise((ok) => setTimeout(ok, 400));
+ const kept = await pg.evaluate(`(async () => {
+ const i = document.querySelector('.ovdac-tell input'); i.value = 'half a thought'; i.focus();
+ const row = document.querySelector('.ovdac-rail li');
+ await window.m.refresh();
+ const j = document.querySelector('.ovdac-tell input');
+ return { same: i === j, value: j.value, focused: document.activeElement === j, sameRow: row === document.querySelector('.ovdac-rail li') };
+ })()`);
+ assert.deepStrictEqual(kept, { same: true, value: 'half a thought', focused: true, sameRow: true });
await pg.close();
await pg.close();
+ // A paused Actor: "Resume" is the one action, there are no steps, and "Paused" is said once.
+ mode = 'paused';
+ const pz = await h.openPage(browser, context, { width: 390, height: 800 });
+ await pz.goto(`${base}/`, { maxMs: 5000 });
+ assert.ok(await wait(pz, `!!document.querySelector('#box.on .ovdac-go')`), 'the paused block shows');
+ const paused = await pz.evaluate(`(() => { const b = document.getElementById('box'); return { state: b.querySelector('.ovdac-state').textContent, go: b.querySelector('.ovdac-go').textContent,
+ main: b.querySelector('.ovdac-go').classList.contains('is-main'), rail: b.querySelector('.ovdac-rail').hidden, ask: b.querySelector('.ovdac-ask').hidden,
+ said: (b.textContent.match(/Paused/g) || []).length }; })()`);
+ assert.deepStrictEqual(paused, { state: 'Paused', go: 'Resume', main: true, rail: true, ask: true, said: 1 });
+ calls.length = 0;
+ await pz.evaluate(`document.querySelector('.ovdac-go').click()`);
+ const end = Date.now() + 3000;
+ while (!calls.some((c) => c.method === 'POST') && Date.now() < end) await new Promise((ok) => setTimeout(ok, 50));
+ assert.strictEqual((calls.find((c) => c.method === 'POST') || {}).url, '/api/v1/me/actor/resume');
+ assert.ok(await wait(pz, `/Resumed/.test(document.querySelector('.ovdac-note').textContent)`), 'it says what happened');
+ await pz.close();
+
// No Actor: nothing shows.
// No Actor: nothing shows.
mode = 'none';
mode = 'none';
const none = await h.openPage(browser, context, { width: 390, height: 800 });
const none = await h.openPage(browser, context, { width: 390, height: 800 });
@@ -143,7 +184,7 @@ const wait = async (pg, expr, ms = 4000) => {
@@ -143,7 +184,7 @@ const wait = async (pg, expr, ms = 4000) => {
const empty = await none.evaluate(`(() => { const b = document.getElementById('box'); return { on: b.classList.contains('on'), kids: b.children.length, shown: getComputedStyle(b).display }; })()`);
const empty = await none.evaluate(`(() => { const b = document.getElementById('box'); return { on: b.classList.contains('on'), kids: b.children.length, shown: getComputedStyle(b).display }; })()`);
assert.deepStrictEqual(empty, { on: false, kids: 0, shown: 'none' }, 'a person with no Actor sees nothing');
assert.deepStrictEqual(empty, { on: false, kids: 0, shown: 'none' }, 'a person with no Actor sees nothing');
await none.close();
await none.close();
- console.log('dock-actor-chrome: the Actor block shows the step, asks with their buttons, tells and pauses with the bearer, text only, 44 px, nothing without an Actor ok');
+ console.log('dock-actor-chrome: the Actor block shows the step, asks with their buttons, tells, pauses and resumes with the bearer, keeps the typed line, text only, 44 px, nothing without an Actor ok');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
@@ -337,3 +337,17 @@ console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, ti
@@ -337,3 +337,17 @@ console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, ti
assert.ok(h.includes('aria-label="Create"') && h.includes('One line & all the detail.'));
assert.ok(h.includes('aria-label="Create"') && h.includes('One line & all the detail.'));
assert.ok(!kit.pageHead({ title: 'Home' }).includes('<details'));
assert.ok(!kit.pageHead({ title: 'Home' }).includes('<details'));
}
}
+
+// A tone is a lean and a glow, never a coloured bar (4.196.0): a notice and anything with .ovk-tone take a tinted
+// hairline, a soft lean toward the tone and a glow in it; the notice's icon sits in a round chip. No side stripe anywhere
+// in the kit.
+{
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');
+ assert.match(css, /\.ovk-notice, \.ovk-tone \{ --ovk-tone: var\(--ovk-accent\);[^}]*border: 1px solid color-mix\(in srgb, var\(--ovk-tone\) 32%, var\(--ovk-line\)\);[^}]*background: linear-gradient\([^;]*var\(--ovk-tone\) 13%[^;]*\);[^}]*box-shadow: 0 12px 28px -20px var\(--ovk-tone\); \}/, 'the tone: a hairline, a lean, a glow');
+ for (const [cls, tone, v] of [['ok', 'good', 'ok'], ['error', 'alert', 'bad'], ['warn', 'warning', 'warn']]) {
+ assert.ok(css.includes(`.ovk-notice--${cls}, .ovk-tone[data-tone="${tone}"] { --ovk-tone: var(--ovk-${v}); --ovk-tone-text: var(--ovk-${v}-text); }`), `${cls} and data-tone="${tone}" are the same tone`);
+ }
+ assert.match(css, /\.ovk-notice > \.ovk-i \{[^}]*border-radius: 50%;[^}]*background: color-mix\(in srgb, var\(--ovk-tone\) 20%, transparent\); color: var\(--ovk-tone-text\); \}/, 'the icon in a round chip of the tone');
+ assert.ok(!/border-(left|right|inline-start):\s*[2-9]px solid/.test(css), 'no side stripe marks a tone anywhere in the kit');
+ assert.match(kit.notice({ kind: 'error', text: 'x' }), /^<p class="ovk-notice ovk-notice--error" role="alert"><svg/, 'an error is still an alert with its icon first');
+}