app
Adom TTS: Text to Speech
Public Made by Adomby adom
Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos, so you never record your own voice.
← Commit history
v1.9.49: design-system pass (house header, line-drawing icon + favicon, newbie panel, status pills, settings modal, toasts, tokens, fonts, no dashes) and Hydrogen dock membership (dockbar.json, adom-tts open --print-url, /api/health)
18 files changed
+1825−508
Cargo.toml+1−1VERSION+1−1dev-skills/adom-tts-dev/SKILL.md+82−58dev-skills/adom-tts-test/SKILL.md+32−8dockbar.json+29docs/icon.svg+11−8icons/app.svg+12page.json−21publish-skills/adom-tts-publish/SKILL.md+27−19screenshots/history-drawer.pngscreenshots/player-live.pngsrc/main.rs+47src/play.rs+248−2src/player.html+519−390src/server.rs+9tools/ralph-test.shtools/ui-shot.py+116vendor/newbie-panel.js+691Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.48"+version = "1.9.49" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.48+1.9.49
dev-skills/adom-tts-dev/SKILL.md+82−58@@ -2,7 +2,7 @@ name: adom-tts-dev description: >- Develop and debug adom-tts from source: run the build, add features to the- player/queue, and — critically — the hard-won engineering rules for a shared+ player/queue, and, critically, the hard-won engineering rules for a shared audio system that many AI threads drive at once. Read before touching src/play.rs, src/player.html, or the drainer. Covers: the single-server/ single-drainer architecture (multiple AIs talk to ONE adom-tts player),@@ -14,18 +14,18 @@ description: >- player.html, queue debugging, playback verdict, run the tests. --- -# adom-tts — development cycle & engineering rules+# adom-tts, development cycle & engineering rules ## Dev cycle ```bash-cargo test --release # 6 unit tests — MUST pass before any ship+cargo test --release # 6 unit tests, MUST pass before any ship ./build.sh # cargo build --release + refresh bin/adom-tts (never bare cargo build) ./target/release/adom-tts --version && ./target/release/adom-tts health adom-tts say "smoke" --out /tmp/s.mp3 --thread dev --desc "smoke test" --play --confirm # end-to-end proof ``` -Install locally + make every copy on the container identical (byte-compare —+Install locally + make every copy on the container identical (byte-compare , version strings can't catch same-version drift): ```bash@@ -39,28 +39,28 @@ done ## The five engineering rules (each one paid for in real outages) -### 1. Multiple AIs talk to ONE server — keep it that way+### 1. Multiple AIs talk to ONE server, keep it that way Every thread's `say --play` feeds one shared queue (`~/.adom/tts/queue/`); ONE drainer (lock = `~/.adom/tts/drainer.lock` = `{pid}\n{version}`) runs ONE persistent HTTP server on ONE port; the webview loads once and swaps clips via-`/poll` — never a re-navigation. A NEWER-version drainer takes over the lock;+`/poll`, never a re-navigation. A NEWER-version drainer takes over the lock; the old one notices within 250ms, abandons its clip, and exits. Two drainers alive = clips flip/overlap (users hear it immediately). Never kill drainers-manually — deploy a new version and let supersede do the handoff. If you must-(last resort), match argv exactly via `/proc/<pid>/cmdline` NUL-split — a+manually, deploy a new version and let supersede do the handoff. If you must+(last resort), match argv exactly via `/proc/<pid>/cmdline` NUL-split, a `grep`-kill matches its own shell and kills your session. ### 2. Never let audio drop - Clips **buffer** (up to 10 min) until the player page shows a *sustained*- live audience — 3 consecutive polls. One fresh poll is NOT alive (a+ live audience, 3 consecutive polls. One fresh poll is NOT alive (a disconnected/backgrounded page still trickles heartbeats). - `no-playback` AND **early-partial** clips (died under 10% of their duration, e.g. 0.7s into an 80s clip) are **retried 3×** (`clip_retry` with a reason, `replay: auto`), never silently consumed.-- The page **downloads the whole mp3 to a local blob before playing** —+- The page **downloads the whole mp3 to a local blob before playing** , a cellular-hotspot drop mid-clip cannot stall audio (the listener is often tethered while driving).-- The drainer **adopts surface env** from `~/.adom/tts/env.json` at startup —+- The drainer **adopts surface env** from `~/.adom/tts/env.json` at startup , a `say` from a sanitized shell (cron/hook) once spawned a drainer with no `VSCODE_PROXY_URI`, which silently killed ALL playback for every thread. The snapshot also carries **`ADOM_BRIDGE_TARGET`** (pup; legacy ADOM_DESKTOP_TARGET still read as fallback): with >1 Bridge machine@@ -69,36 +69,36 @@ manually — deploy a new version and let supersede do the handoff. If you must sole connected desktop) and passes `--target`. A long-lived unpinned drainer thus picks up a target that appears later without a restart. - **FAIL AND ADVANCE, never wedge.** If the surface OPEN itself keeps erroring- (ambiguous target, dead bridge) — never a poll, never a successful open —+ (ambiguous target, dead bridge), never a poll, never a successful open , `wait_for_audience` gives up (~45s, then ~8s once `surface_down` is known) and returns `SurfaceUnavailable`; the drainer marks the clip- `surface-unavailable` into History and ADVANCES (no retry — retrying a broken+ `surface-unavailable` into History and ADVANCES (no retry, retrying a broken surface just re-wedges). One un-openable clip can never silence the whole session. Field bug: 2 desktops + unpinned pup drainer → every clip stuck `now playing 0s/0s`, queue only grew. - **Honest `status`.** `0s/0s` with no live page (poll_age > 5s) reads as- `⚠ STALLED — no audio sink`, NOT "now playing" (which read as healthy while+ `⚠ STALLED, no audio sink`, NOT "now playing" (which read as healthy while the surface was never open and misled a reporting thread). - **PORT STABILITY (pup wedge).** Drainers cycle (idle-close + respawn); an ephemeral port each time STRANDS the open player page on a dead port while- new clips queue to the new port — "player :A polls A forever while clips go+ new clips queue to the new port, "player :A polls A forever while clips go to :B". `bind_player_server` REUSES the last port (`read_player_port`, retry briefly for a superseding drainer to release it) so a stale page reconnects to the fresh drainer with zero re-navigation. Hydrogen survived this via open-or-refresh re-navigation; pup did not. Also: `--surface pup` is fragile- (ambiguous window with >1 desktop, stale port) — SKILLS DEFAULT TO HYDROGEN+ (ambiguous window with >1 desktop, stale port), SKILLS DEFAULT TO HYDROGEN and only use pup on explicit request. A `surface-unavailable` failure names the working hydrogen surface, and `check`/`--confirm` say the clip is fine, only the surface failed. - **`adom-tts restart`** = the real reset: kills the drainer, clears the LOCK (not the portfile), respawns a FRESH drainer of the SAME surface, pinned from the current env. `reopen` only re-offers the tab and keeps a broken player;- `restart` replaces it. CRITICAL: restart must NOT wipe `player.json` — it once+ `restart` replaces it. CRITICAL: restart must NOT wipe `player.json`, it once did, which forced the fresh drainer onto a NEW port while the human's page kept polling the OLD one forever (the exact wedge port-reuse prevents). Keep the portfile so `bind_player_server` rebinds the SAME port and the page reconnects with no `reopen`.-- Never reopen the tab for a clip that already started (`reached > 0`) — a+- Never reopen the tab for a clip that already started (`reached > 0`), a reopen reloads the page and REPLAYS the clip (users heard 5× repeats). - **`restart` REFUSES while a clip is playing or human-paused** (exit 5; `--force` overrides). Field incident: the human paused for their kid, an ops@@ -107,10 +107,10 @@ manually — deploy a new version and let supersede do the handoff. If you must /histplay but never plays the staged replay = "the ↻ button does nothing". It also must NOT idle-close while a page is polling (a watcher may tap ↻). - **Audio age rides in the queue entry (`audio_ms`), never the snapshot's- mtime** — the snapshot is written at enqueue and always looks fresh (this+ mtime**, the snapshot is written at enqueue and always looks fresh (this blinded the stale-audio guard; ralph-test T6 guards it now). - **Occluded pup windows cannot play audio** (Chrome suspends the media- pipeline). The pup player window opens `foreground:true` — see adom-tts-test+ pipeline). The pup player window opens `foreground:true`, see adom-tts-test for the full platform facts. - **Settings, not preferences** (Adom-wide word choice; Microsoft/Google/Apple all converged on Settings). `adom-tts settings` / `settings set surface pup`.@@ -145,14 +145,14 @@ manually — deploy a new version and let supersede do the handoff. If you must if the drainer dies) and is actively cleared the moment a clip's audio finishes (queue empty), after a History replay, and at idle close. A retry keeps the flash: audio is still owed.-- **Run `tools/ralph-test.sh` before every release** — see the adom-tts-test+- **Run `tools/ralph-test.sh` before every release**, see the adom-tts-test skill for the full matrix and the incident behind each test. - **TELEMETRY MUST BE VOUCHED FOR (`loaded=1`).** The page's `/poll` carries `t`/`dur`/`done` read off the `<audio>` element, but `curClipId` moves the INSTANT a new clip arrives while the element still holds the PREVIOUS clip until its blob finishes downloading. That window reported the new id with the old clip's `ended`/position, so the drainer marked the new clip done on- arrival, advanced, and cut it off ~3s in — while telling its sender "ok"+ arrival, advanced, and cut it off ~3s in, while telling its sender "ok" (field: two threads sent clips back to back; clip two died at 2.8s and 3.3s carrying clip one's exact `reached`). FIX, both sides: the page tracks `loadedClipId` (the id the element ACTUALLY holds), sends `loaded=0` with@@ -163,7 +163,7 @@ manually — deploy a new version and let supersede do the handoff. If you must ralph-test T10 reproduces the exact race (`stale-done` fake-page mode). - **NEVER auto-replay audio the human already heard.** Retry is for TRULY-SILENT clips only: `verdict == "no-playback"` (reached==0) AND `i_own_lock()`. A clip- that produced ANY audio (`partial`) is NEVER re-queued — it goes to History,+ that produced ANY audio (`partial`) is NEVER re-queued, it goes to History, replayable by the human's CHOICE. Two real outages came from breaking this: an "early-partial" retry (<10% of duration) replayed clips cut off by a SUPERSEDE, and a superseded drainer re-queued a clip the new owner then replayed. The@@ -174,9 +174,9 @@ manually — deploy a new version and let supersede do the handoff. If you must Synthesis success ≠ playback. The page reports telemetry (beacons + piggybacked on `/poll` so a connection gap can't fake a failure); the drainer logs a `clip_end` verdict (`ok/partial/no-playback/error`) per clip to-`~/.adom/tts/events.jsonl`. `--confirm` and `check` read that log — they never+`~/.adom/tts/events.jsonl`. `--confirm` and `check` read that log, they never touch the contended surface, which is why they're trustworthy. CRITICAL: the-verdict must reflect the LATEST attempt — `--confirm` keeps waiting while a+verdict must reflect the LATEST attempt, `--confirm` keeps waiting while a retry is queued/playing instead of failing on the first attempt's verdict (the confirm-vs-reality race from field feedback), and failure output includes inline evidence (reached/duration + live surface state) so no forensics@@ -192,15 +192,15 @@ answers with the clip's queue position. **ANTI-REPLAY on reopen (distinct from enqueue dedup).** The page plays any clip whose polled `id != curClipId`, and a FRESH page (after a reopen) starts-at `curClipId=0` — so a reloaded page would replay a clip the drainer already+at `curClipId=0`, so a reloaded page would replay a clip the drainer already finished. During a flapping surface (e.g. 265 open_surface_failed → constant reopens) this played clips twice. Enqueue dedup can't catch it (it's the same queued clip replayed by a reloaded page, not a re-enqueue). FIX: once a clip-is `done`, `/poll` reports it as **idle (id 0)** so no page — current or freshly-loaded — ever starts it again; a new clip gets a fresh id. Any new "replay on+is `done`, `/poll` reports it as **idle (id 0)** so no page, current or freshly+loaded, ever starts it again; a new clip gets a fresh id. Any new "replay on reload" symptom → check this guard first. -**DEDUPLICATION — a resend must be structurally impossible to double-play.**+**DEDUPLICATION, a resend must be structurally impossible to double-play.** Every enqueue fingerprints the mp3 bytes (FNV-1a 64, the request id, stored in the queue entry AND every History row; logged as `req=` on enqueue + clip_start so a double-play is greppable by request id). Identical content already queued, or@@ -211,53 +211,53 @@ same 65-second clip twice back to back. `--allow-repeat` is the deliberate override (the human asked to hear it again). Human History-replays bypass the gate by design (they never pass through enqueue). -**CLAIM-BEFORE-PLAY — one queue entry is played AT MOST once, across drainer+**CLAIM-BEFORE-PLAY, one queue entry is played AT MOST once, across drainer deaths.** The enqueue dedup above stops a re-ENQUEUE; this stops the SAME entry being re-dequeued by a second drainer. Field case: a clip was in-flight when `restart` hard-killed its drainer; the queue entry survived, a fresh drainer re-dequeued it, and the human heard it twice (same `req`, two `clip_start` on two ports). FIX: a clip stays a normal `.json` through the audience wait (so a-drainer that dies WAITING for a slept device is still recoverable — a fresh+drainer that dies WAITING for a slept device is still recoverable, a fresh drainer plays it once), but at the instant we emit sound we CLAIM it with an atomic `rename` to `<seq>.playing`. Only the drainer that wins the rename plays it; any racing drainer's rename fails and it skips (`continue`). A retry/preempt renames the `.playing` back to `.json` (front position kept). A hard kill mid-sound leaves a `.playing` orphan, which the NEXT drainer's `sweep_stale_claims`-at `drain_start` DROPS (logged `claim_orphan_dropped`) — never replayed, because+at `drain_start` DROPS (logged `claim_orphan_dropped`), never replayed, because the human already heard part of it. Losing-after-partial beats replaying: a double-play is the rudeness we optimize against. Any new "heard it twice after a restart/supersede" symptom → check the claim rename + orphan sweep first. ### 5. Hints back to the AI on every command-Every command output teaches the calling AI the next correct step: `ACCEPTED —+Every command output teaches the calling AI the next correct step: `ACCEPTED , position N` on enqueue, `QUEUED/PLAYING/PLAYED/FAILED` from `check` (with-"do NOT re-send a buffered clip — it'd play twice"), `⚠ AUDIENCE OFFLINE` at+"do NOT re-send a buffered clip, it'd play twice"), `⚠ AUDIENCE OFFLINE` at say-time when nobody is listening, and non-`ok` verdicts explain recovery (`logs` → `status` → `reopen` → one careful `--confirm` retry). When adding a command, add its hints; when changing behavior, update the hints in the same commit. `ADOM_TTS_NO_HINTS=1` silences them for stable scripts. ### 6a. The human preempts; the AI queues-AI clips wait their turn in the FIFO queue — always. But a HUMAN action (a+AI clips wait their turn in the FIFO queue, always. But a HUMAN action (a history-replay tap) plays IMMEDIATELY: the drainer aborts the current AI clip (`clip_preempted`, entry kept untouched), plays the replay, then restarts the interrupted clip from the top. Never make a human wait behind machine output; never let a machine skip the queue. Preempt lands via the shared `preempt` slot checked by both the drain loop and `wait_for_clip`. -**PAUSE is a human action too — it means "stop talking to me."** The page+**PAUSE is a human action too, it means "stop talking to me."** The page reports `paused=1` on every poll; the drainer then HOLDS: no stall-advance (a-paused clip is not a stuck clip — this exact bug force-resumed a human 3+paused clip is not a stuck clip, this exact bug force-resumed a human 3 times), no deadlines, no tab reopen, no next clip, queue frozen (`pause_hold` in the log, `paused:true` in /state). Everything resumes only when the human presses play. The page also cancels pending auto-play intent on a pause gesture so a late `canplay` can't un-pause them. Never add a code path that-starts audio while `user_paused` is set — replay taps excepted (the human+starts audio while `user_paused` is set, replay taps excepted (the human asked for that sound). **FRESHNESS TTL: held clips go stale.** A clip that has waited past the TTL-(default 2h, `ADOM_TTS_CLIP_TTL_SECS`) — buffered, pause-held, or behind a-dead page — is EXPIRED at play-time: skipped into History flagged+(default 2h, `ADOM_TTS_CLIP_TTL_SECS`), buffered, pause-held, or behind a+dead page, is EXPIRED at play-time: skipped into History flagged (`clip_expired`, verdict "expired", replayable on purpose), never played as surprise old news. Happened live: a 34-hour pause-hold resumed into yesterday's clip. `check` reports EXPIRED with a rewrite-don't-replay hint.@@ -267,21 +267,21 @@ The human misses clips (dead zones, back-to-back updates from parallel threads) and gets confused about WHO said WHAT. Two mechanisms, keep both: - **History**: every played clip is persisted (audio bytes + `~/.adom/tts/play_history.jsonl`, capped 40) and shown in the player's- History drawer — thread chip + description + time + verdict (non-`ok` =- flagged "missed?") + one-tap replay via `/histplay?seq=` (plays immediately — see 6a). History entries carry the clip's words so a replay keeps its karaoke transcript. Never surface raw+ History drawer, thread chip + description + time + verdict (non-`ok` =+ flagged "missed?") + one-tap replay via `/histplay?seq=` (plays immediately, see 6a). History entries carry the clip's words so a replay keeps its karaoke transcript. Never surface raw mp3 filenames to the human; the filename means nothing to them. - **Forced identification**: `say --play` HARD-REQUIRES `--thread` (which AI- thread) and `--desc` (one human sentence about the update) — exit 6 with a+ thread) and `--desc` (one human sentence about the update), exit 6 with a teaching hint otherwise. This is deliberate API design: hints alone were not enough; anonymous clips made the shared history useless. When adding any new human-facing surface, carry label + desc through it (queue entry → sidecar → CurrentClip → /poll → UI). -### 7. Lint the AI's input — never speak garbage aloud+### 7. Lint the AI's input, never speak garbage aloud AIs pass what feels natural: `@/path/file.txt` (curl-style), bare paths,-stdin. Meet them where they are — `@file` and bare-existing-path are+stdin. Meet them where they are, `@file` and bare-existing-path are first-class file inputs (contents are read + a notice teaches the explicit-form) — but a path that does NOT exist, a binary file, or a >200 KB file is+form), but a path that does NOT exist, a binary file, or a >200 KB file is a HARD ERROR with a teaching hint. The human must never hear "slash tmp slash claude" read out character by character (happened live: a thread sent @-paths and the human heard the path twice). Same principle for any future@@ -291,7 +291,7 @@ input: detect the intent mismatch at the CLI boundary, teach in the error. - Start: `adom-tts logs` (verdict summary + anomaly hints) and `adom-tts status` (lock pid/version, current clip, queue). Raw: `~/.adom/tts/events.jsonl`.- The summary is a 24h WINDOW and the log rotates (newest 3000 events) —+ The summary is a 24h WINDOW and the log rotates (newest 3000 events) , before that, cumulative counters made healed week-old incidents read as "failing hard today" and sent reporters chasing ghosts. - "Clip played twice / flipping" → two drainers or a reopen-after-start. Check@@ -305,24 +305,48 @@ input: detect the intent mismatch at the CLI boundary, teach in the error. named `reply.mp3` from different threads LOOK like a repeat; `clip_retry` events show real retries. - Intermittent long-say failures with healthy `/health` → duration-correlated- proxy timeout: keep synth chunks small (≤360 chars, sequential) — don't raise+ proxy timeout: keep synth chunks small (≤360 chars, sequential), don't raise timeouts.-- UI changes: ALWAYS ralph-test — render the page headless, LOOK at it, and- run an overflow probe (walk every element's boundingRect vs viewport; report- anything past the edge). Headless Chrome clamps min window width to 500, so- for narrower widths embed the page in an iframe of the target width. Media- queries go LAST in the stylesheet. Exercise real event paths- (`el.dispatchEvent(new Event('playing'))`) — the play/pause icon was broken- for 9 versions because nothing fired the event.+- UI changes: ALWAYS ralph-test with `tools/ui-shot.py` (see adom-tts-test+ section 2): a MUTED headless Chrome driven over raw CDP against an isolated+ rig drainer, one PNG per state (idle, clip, ended + History, settings modal,+ toasts, stale pill, pill detail, corner tooltip, panel closed, phone open and+ closed, wide). LOOK at every PNG. It also prints the boot marker+ (`window.__ttsBoot`, complete + failed[]), the newbie module's selfCheck, the+ tooltip clamp rect, the panel-open vs panel-closed spacing under the header,+ and the phone scrollWidth. Media queries go LAST in the stylesheet. Exercise+ real event paths (`el.dispatchEvent(new Event('playing'))`), the play/pause+ icon was broken for 9 versions because nothing fired the event, and again in+ 1.9.49's first cut because `.hidden` is not a property on SVG elements+ (toggle the ATTRIBUTE, `setAttribute('hidden','')`, never the property).+- DESIGN CONTRACT (1.9.49, John: "your app looks like a brother from another+ mother"): the player page IS the dock dashboard (dock-dashboard skill, full-app+ species: one app, one page, never a second launch page). Anatomy in order:+ the 44px house header (dash-header numbers: edge to edge, `--surface` bg, 1px+ `--line` bottom border, 18px line-drawing mark, name Familjen 600 15px in+ `--text` never teal, subtitle 13px muted, version ONLY in the brand popover,+ right controls `target: this machine` chip, ? toggle, the pack's cog), the+ newbie panel (MOUNTED from `vendor/newbie-panel.js`, served at+ `/newbie-panel.js`; never hand-rolled; hash it against+ `~/.claude/skills/adom-ui-design/assets/module/newbie-panel.js` when you+ re-vendor), the status pill row (server emits SHORT `state` + prose `detail`+ per subsystem in `/poll.health` and `/api/health` with CORS `*`; gray until+ probed; a red detail leads with the exact command), then the player. Tokens+ are the dock-dashboard stub verbatim; icons are line drawings (fill none,+ stroke 1.8, currentColor inline, `#e6edf3` hardcoded in `icons/app.svg`, the+ ONE file that is the favicon, the header mark, the tab strip icon and+ `webview.favicon` in `dockbar.json`). No em-dashes, no emoji, no `title=`+ attributes (data-tooltip only), no uppercase eyebrows, no max-width on the+ page (prose blocks may cap at 90ch). - LAYOUT LAWS (user-mandated, non-negotiable): 1. The outermost html NEVER shows a horizontal scrollbar. Fluid layout does the work (`min-width:0` on flex children, ellipsize long text); the root also carries `overflow-x: clip` as a guarantee.- 2. Scrollbars are always styled — slim (7px), rounded, transparent track,+ 2. Scrollbars are always styled, slim (7px), rounded, transparent track, subtle teal thumb. Never ship a default chunky scrollbar. 3. Motion is GENTLE: no per-poll pulses/jumps (the LED breathes on a ~3s ease; it does not blink per heartbeat).- 4. The header shows the human WHO (thread) + WHAT (desc) — never a raw mp3+ 4. The header shows the human WHO (thread) + WHAT (desc), never a raw mp3 filename except in a tooltip. 5. NO EMOJIS in READMEs or skills, ever (user rule: they read as childish). Plain typographic marks are fine; the only pictograph-adjacent characters@@ -333,6 +357,6 @@ input: detect the intent mismatch at the CLI boundary, teach in the error. ## Service coupling -`adom-tts` is a client of `service-tts` (shared container — NEVER install a+`adom-tts` is a client of `service-tts` (shared container, NEVER install a local TTS engine as a fallback; report outages instead). Client-side synth robustness lives in `synth_chunk`/`split_into_chunks` in `src/main.rs`.
dev-skills/adom-tts-test/SKILL.md+32−8@@ -9,7 +9,7 @@ description: >- player, pre-release check, did the buttons break. --- -# adom-tts-test — the regression matrix that keeps the player honest+# adom-tts-test, the regression matrix that keeps the player honest Every test here is a REAL field incident from the hardening week (2026-07/08), reported by the user while driving, parenting, and working. The lesson of that@@ -47,19 +47,43 @@ Extending: add a `say "TN ..."` block copying an existing test. Trigger the behavior via the real CLI, assert via events.jsonl (`ev <kind>`) or the queue dir. Never assert on hint text (it changes); assert on event kinds and exits. -## 2. UI checks (need a real browser: pup, muted, tidy)+## 2. UI checks (tools/ui-shot.py: headless, muted, isolated) -Backend green does not mean buttons work — T4's bug shipped while all backend-paths were "fine". Drive the real page for:+Backend green does not mean buttons work, T4's bug shipped while all backend+paths were "fine". The UI harness drives the page in a MUTED headless Chrome+over raw CDP (python3 + websockets, no puppeteer) against an isolated rig+drainer, so it never touches the human's player:++```bash+RIG=$(mktemp -d); mkdir -p $RIG/.adom/tts+SAFE=$(mktemp -d); for t in curl sh sed grep cat; do ln -sf "$(command -v $t)" $SAFE/$t; done # no adom-cli/adom-bridge+HOME=$RIG PATH=$SAFE VSCODE_PROXY_URI= ADOM_TTS_KEEP_OPEN=1 ./target/release/adom-tts open --print-url # prints the rig URL+cp <some>.mp3 $RIG/reply.mp3; cp <some>.json $RIG/reply.json; touch $RIG/reply.mp3+(sleep 4; HOME=$RIG PATH=$SAFE VSCODE_PROXY_URI= ./target/release/adom-tts play $RIG/reply.mp3 --allow-repeat) &+python3 tools/ui-shot.py http://127.0.0.1:<port>/ /tmp/shots # then READ every PNG+kill $(head -1 $RIG/.adom/tts/drainer.lock)+```++It prints, and you must read: `boot` (`complete:true, failed:[]` or the page+half-booted), the newbie module `selfCheck` (`ok:true`), `icons while playing`+(pause shown, play hidden, warning hidden on a fresh clip), the tooltip rect+(`inside:true`), the panel-open vs panel-closed spacing under the header (must+match: 60px), and the phone `scrollW == vw`. The PNGs: 01 idle, 02 clip, 02b+ended + History, 03 settings, 04 toasts, 05 stale pill, 06 pill detail, 07+tooltip, 08 panel closed, 09/10 phone closed/open, 11 wide. If a PNG looks+wrong, the harness passed and the page is still wrong: fix the page.++For the things only a real browser on the user's box can show (autoplay+policy, a real Hydrogen tab, the dock card), drive the real page for: - **Freshness pill + tooltip**: mock an old clip (`setClipAge(Date.parse("..."), Date.now())` via eval), assert the pill text/color, fire the tooltip and run the tooltips skill's MANDATORY on-screen assert (rect inside viewport).-- **Trusted-click the controls**: play, pause, resume, seek, History ↻ — via+- **Trusted-click the controls**: play, pause, resume, seek, History ↻, via `pup_click`, not eval (eval carries no user gesture; clicks do). - **Version badge** matches the shipped build. -### Rig etiquette (each one is a user complaint — follow all of them)+### Rig etiquette (each one is a user complaint, follow all of them) 1. **NEVER blast test audio at the human.** Isolated $HOME rig + MUTE the page audio element FIRST (`a.muted=true;a.volume=0` + a 1s re-mute interval). 2. **One window.** Player tab + shotlog tab in ONE pup session; close it when@@ -67,7 +91,7 @@ paths were "fine". Drive the real page for: 3. **Never touch the real player tab.** The rig drainer must run with PATH stripped of adom-cli/adom-bridge so its surface-open cannot reach the user's workspace.-4. **Shotlog every screenshot** (`shotlog paste -c <channel>`) — the user (and+4. **Shotlog every screenshot** (`shotlog paste -c <channel>`), the user (and you) can only SEE shots there. ## 3. Hard-won platform facts (do not relearn these)@@ -76,7 +100,7 @@ paths were "fine". Drive the real page for: pipeline in fully-occluded background windows: the page polls, blob download succeeds, but `netState` sticks at LOADING forever; raise the window and it plays instantly. This was the true root of "--surface pup never plays". The- pup player window must be opened `foreground:true` — an audio surface the+ pup player window must be opened `foreground:true`, an audio surface the user chose must be visible. - **`eval play()` is not a user gesture.** Autoplay unlock needs a TRUSTED click (`pup_click`) or the visible-window + drainer kick path.
dockbar.jsonadded+29@@ -0,0 +1,29 @@+{+ "dock_manifest": "1.2.0",+ "webview": { "title": "Adom TTS", "favicon": "icons/app.svg" },+ "launch": {+ "mode": "container_command",+ "console": "show",+ "steps": [+ {+ "label": "Installing Adom TTS",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg update adom/adom-tts || adom-wiki pkg install adom/adom-tts'",+ "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v adom-tts'",+ "timeout_seconds": 180+ },+ {+ "label": "Starting the player",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec adom-tts open --print-url'",+ "await": "url",+ "timeout_seconds": 60+ }+ ],+ "on_ready": "navigate_placeholder",+ "ai": {+ "prompt": "The user just opened Adom TTS from the dock. Ask what they want read aloud, or offer to play the built-in demo with `adom-tts demo`.",+ "thread": "existing",+ "thread_fallback": "new",+ "submit": "staged"+ }+ }+}
docs/icon.svg+11−8@@ -1,9 +1,12 @@-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">- <g fill="#e6edf3">- <rect x="8" y="28" width="6" height="8" rx="2"/>- <rect x="18" y="22" width="6" height="20" rx="2"/>- <rect x="28" y="14" width="6" height="36" rx="2"/>- <rect x="38" y="22" width="6" height="20" rx="2"/>- <rect x="48" y="28" width="6" height="8" rx="2"/>- </g>+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"+ fill="none" stroke="#e6edf3" stroke-width="1.8"+ stroke-linecap="round" stroke-linejoin="round">+ <!-- Adom TTS mark: a speaker cone with two sound waves. Line drawing on a+ transparent ground (icons skill). Hardcoded #e6edf3 because this file is+ loaded as its own document (favicon, tab strip, dock card) where+ currentColor would resolve to black. The inline header copy in+ player.html swaps the stroke to currentColor. -->+ <path d="M4 9.5h3.2L12 5.5v13l-4.8-4H4z"/>+ <path d="M15.5 9.2a4 4 0 0 1 0 5.6"/>+ <path d="M18.4 6.6a8 8 0 0 1 0 10.8"/> </svg>
icons/app.svgadded+12@@ -0,0 +1,12 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"+ fill="none" stroke="#e6edf3" stroke-width="1.8"+ stroke-linecap="round" stroke-linejoin="round">+ <!-- Adom TTS mark: a speaker cone with two sound waves. Line drawing on a+ transparent ground (icons skill). Hardcoded #e6edf3 because this file is+ loaded as its own document (favicon, tab strip, dock card) where+ currentColor would resolve to black. The inline header copy in+ player.html swaps the stroke to currentColor. -->+ <path d="M4 9.5h3.2L12 5.5v13l-4.8-4H4z"/>+ <path d="M15.5 9.2a4 4 0 0 1 0 5.6"/>+ <path d="M18.4 6.6a8 8 0 0 1 0 10.8"/>+</svg>
page.json−21@@ -12,7 +12,6 @@ "audio", "demo", "accessibility",- "cli", "open-source", "mit" ],@@ -65,25 +64,5 @@ ], "install": { "binary_name": "adom-tts"- },- "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",- "dependencies": {},- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "needs_sudo": false,- "files": [- "package.json",- "install.sh",- "uninstall.sh",- "bin/adom-tts",- "skills/**",- "completions/**"- ],- "org": "adom",- "author": {- "name": "John Lauer",- "email": "[email protected]" } }
publish-skills/adom-tts-publish/SKILL.md+27−19@@ -2,9 +2,9 @@ name: adom-tts-publish description: >- Cut and publish a new adom-tts release to THE Adom Wiki. Single source of- truth for WHERE this app lives (https://wiki.adom.inc/adom/adom-tts — org+ truth for WHERE this app lives (https://wiki.adom.inc/adom/adom-tts, org `adom`, slug `adom-tts`) and HOW to ship it with the adom-wiki CLI (adompkg- and all *.adom.cloud slug hosts are DEPRECATED — never use them). Covers the+ and all *.adom.cloud slug hosts are DEPRECATED, never use them). Covers the version bump, the test gate, packaging, `adom-wiki pkg publish`, `repo push` for source + dev/publish skills, keeping the hero linked, deprecating a bad version, and the mandatory verify-in-pup step. Triggers: publish adom-tts,@@ -16,11 +16,11 @@ description: >- **Where this app lives (memorize):** - Page: `https://wiki.adom.inc/adom/adom-tts` (org **adom**, slug **adom-tts**, type app)-- Page title: **Adom TTS — Text to Speech** (the hero headline matches it verbatim)-- CLI: **`adom-wiki`** — the ONLY wiki tool. `adompkg`, "wiki v1/v2", and any+- Page title: **Adom TTS, Text to Speech** (the hero headline matches it verbatim)+- CLI: **`adom-wiki`**, the ONLY wiki tool. `adompkg`, "wiki v1/v2", and any `*.adom.cloud` slug host are DEPRECATED; if a doc says otherwise it is stale. - The wiki has TWO unsynced layers: the **package registry** (`pkg publish` /- `pkg install` — what installers get) and the **git page repo** (`repo push` —+ `pkg install`, what installers get) and the **git page repo** (`repo push` , the Files tab, README, dev-skills/, publish-skills/, screenshots). Ship BOTH. ## Release steps@@ -41,13 +41,13 @@ description: >- ./build.sh && install -m 0755 target/release/adom-tts ~/.local/bin/adom-tts ``` -3. **Publish the package — the binary ships INSIDE it. NO release uploads.**+3. **Publish the package, the binary ships INSIDE it. NO release uploads.** Read the adom-wiki skill carefully: packages are resolved PER PLATFORM and the skill's own example says "a Linux consumer installs the package (CLI + skills)". Release assets exist ONLY for artifacts fetched OUTSIDE the install flow (a Windows installer a laptop downloads directly, pup's bridge- zip that Bridge (ab) fetches itself). adom-tts has no such consumer —- every user is a Linux container running `pkg install` — so a release for+ zip that Bridge (ab) fetches itself). adom-tts has no such consumer ,+ every user is a Linux container running `pkg install`, so a release for adom-tts makes NO sense. (Got this wrong twice: first shipped src+pngs+ binary in a fat tarball AND a duplicate release; then over-corrected to a skills-only tarball with the binary as a release. Correct: lean allowlist@@ -62,12 +62,20 @@ description: >- Then prove the loop: `HOME=$(mktemp -d) adom-wiki pkg install adom/adom-tts` and run `$HOME/.local/bin/adom-tts --version` from that fresh home. -4. **Push the source** — README, src/, screenshots, page.json, and the dev +- publish skills (they reach the page ONLY via repo push; the Skills tab shows- them under its Dev / Publish sub-tabs):+4. **Push the source**, README, src/, screenshots, page.json, `dockbar.json`+ (the dock manifest), `icons/app.svg` (the dock card + tab favicon; the SAME+ file the page serves as `/favicon.svg`), `vendor/newbie-panel.js`, tools/,+ and the dev + publish skills (they reach the page ONLY via repo push; the+ Skills tab shows them under its Dev / Publish sub-tabs): ```bash- adom-wiki repo push adom/adom-tts -m "vX.Y.Z — <changelog>"+ adom-wiki repo push adom/adom-tts -m "vX.Y.Z, <changelog>" ```+ Dock checks before pushing: `dockbar.json` parses, `webview.title` is still+ `Adom TTS` (renaming it makes duplicate tabs: the drainer's tab name is the+ same string, `PLAYER_TAB_NAME`), the serve step is `adom-tts open+ --print-url` and RETURNS (run it by hand: one URL on stdout, exit 0 in+ under a second), and `/api/health` answers with `Access-Control-Allow-Origin: *`.+ A user picks up the new manifest with Hard Refresh Open on the card. 5. **Keep the hero linked.** The hero lives in `page.json` ONLY (never embed it in the README):@@ -79,14 +87,14 @@ description: >- `adom-wiki-hero` skill (fresh REAL player screenshot, bleed-bottom/midnight, title = the page title verbatim). -6. **Verify live — in pup, always** (never claim published without looking):+6. **Verify live, in pup, always** (never claim published without looking): ```bash adom-bridge --target <laptop> pup_open_window '{"sessionId":"wiki-check","url":"https://wiki.adom.inc/adom/adom-tts?cb=<epoch>"}' adom-bridge --target <laptop> pup_screenshot '{"sessionId":"wiki-check"}' # read the PNG it saves ``` Check: version, hero renders, Skills tab shows User + Dev + Publish tiers, README intact, Install command correct, and NO EMOJIS anywhere in the README- or skills (user rule — plain typographic marks only).+ or skills (user rule, plain typographic marks only). 7. **Prove playback end-to-end** (the release isn't done until a human can hear it):@@ -100,24 +108,24 @@ description: >- We have not launched: leaving "[DEPRECATED]" garbage around is worse than removing it. A bad package VERSION: fix, bump, republish (registry versions are immutable; the new version supersedes). A stale PAGE, hero, or file: delete it-outright — `adom-wiki repo rm <ref> <path>` for files, `adom-wiki repo delete+outright, `adom-wiki repo rm <ref> <path>` for files, `adom-wiki repo delete <ref> --hard --confirm` for whole pages. Only reach for deprecation notices after we launch and third parties depend on things. ## Skills live IN this repo (user rule) -Never create a standalone wiki skill page that just points at adom-tts — all+Never create a standalone wiki skill page that just points at adom-tts, all skills associated with the app belong in THIS repo's tiers (skills/, dev-skills/, publish-skills/) so they version and ship with the app. Example: tts-pronunciation was a scattered standalone page and is now skills/tts-pronunciation/ here. The one sanctioned standalone is-adom/hands-free — a cross-cutting trigger router, kept deliberately thin.+adom/hands-free, a cross-cutting trigger router, kept deliberately thin. ## A rename is not just a slug change Renaming a page = slug + title + brief/description + SKILL.md name + install paths + discovery triggers + THE HERO (the old pixels carry the old-name — regenerate it with the adom-wiki-hero skill) + deleting old-name art+name, regenerate it with the adom-wiki-hero skill) + deleting old-name art and directories everywhere (wiki files, ~/.claude/skills, installers). The im-driving → hands-free rename missed the hero on the first pass; don't repeat that.@@ -127,4 +135,4 @@ repeat that. - Other containers pick the release up via their wiki auto-update hook; inside THIS container run the byte-compare sweep (dev skill) so every copy matches. - The running drainer self-supersedes to the new version on the next- `say --play` — do not kill it.+ `say --play`, do not kill it.
screenshots/history-drawer.png⋯ 1 unchanged line ⋯
screenshots/player-live.png⋯ 1 unchanged line ⋯
src/main.rs+47@@ -229,6 +229,23 @@ enum Cmd { #[command(subcommand)] action: QueueAction, },+ /// Show the player: make sure a drainer is up (spawning one for the+ /// webview surface if none is live), wait for its page to answer, and+ /// return. This is the Hydrogen dock's serve step: `--print-url` prints+ /// only the direct loopback URL on stdout and exits immediately.+ Open {+ /// Print just `http://127.0.0.1:<port>/` (the dock's readiness gate).+ #[arg(long)]+ print_url: bool,+ },+ /// Drive the live player page from the CLI (the AI narrating over the app).+ Ui {+ #[command(subcommand)]+ action: UiAction,+ },+ /// Play the built-in three-marker demo on the live player (what the+ /// newbie panel's "Play the demo" button runs). Blocks until heard.+ Demo, /// GET /tts/voices — list edge-tts voices as JSON Voices, /// GET /tts/pronunciations — list the override table@@ -294,6 +311,17 @@ enum PronCmd { }, } +#[derive(Subcommand)]+enum UiAction {+ /// Raise a toast on the player page (bottom center, about 4s; errors stick).+ Toast {+ text: String,+ /// info | success | error+ #[arg(long, default_value = "info")]+ kind: String,+ },+}+ #[derive(Subcommand)] enum QueueAction { /// Drop waiting clips: `--mine <thread>` (only yours) or `--all` (every thread).@@ -382,6 +410,22 @@ fn cmd_load(text: &str, thread: &str, id: Option<String>, voice: &str, rate: &st Ok(()) } +/// The built-in demo: three markers that teach the player by using it. It+/// exercises the real script-and-cue path (issue #4), so the demo is also a+/// live test of marker playback. Each cue blocks until that marker is heard.+fn cmd_demo() -> Result<()> {+ const SCRIPT: &str = "Hi, this is Adom TTS, your AI's voice. Any AI thread can send me text, and I read it aloud right here, in one shared player, in the order it arrived.\n---\nWatch the transcript: the words light up as I say them, and the freshness pill under it tells you when this audio was made, so an old clip can never pass as news.\n---\nThat is the tour. Try it yourself: say to your AI, with adom tts, read me your last answer. I will take it from there.";+ let _ = play::ui_toast("Demo starting: three short markers", "info");+ cmd_load(SCRIPT, "adom-tts demo", Some("demo".to_string()), "en-US-AndrewNeural", "+0%", true)?;+ for n in 1..=3 {+ let _ = play::ui_toast(&format!("Demo marker {n} of 3"), "info");+ play::cue("demo", n, true)?;+ }+ let _ = play::ui_toast("Demo finished", "success");+ println!("OK: demo played (3 markers)");+ Ok(())+}+ fn preview(s: &str) -> String { let one: String = s.split_whitespace().collect::<Vec<_>>().join(" "); if one.chars().count() > 64 { format!("\"{}…\"", one.chars().take(64).collect::<String>()) } else { format!("\"{one}\"") }@@ -446,6 +490,9 @@ fn main() -> Result<()> { }, Cmd::Load { text, thread, id, voice, rate, no_pronunciations } => cmd_load(&text, &thread, id, &voice, &rate, !no_pronunciations),+ Cmd::Open { print_url } => play::open(print_url),+ Cmd::Ui { action: UiAction::Toast { text, kind } } => play::ui_toast(&text, &kind),+ Cmd::Demo => cmd_demo(), Cmd::Cue { script, marker, confirm } => play::cue(&script, marker, confirm), Cmd::CueStatus { script } => play::cue_status(&script), Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),
src/play.rs+248−2@@ -32,7 +32,20 @@ use std::time::{Duration, Instant}; use tiny_http::{Header, Method, Response, Server, StatusCode}; const PLAYER_HTML: &str = include_str!("player.html");-const PLAYER_TAB_NAME: &str = "TTS Player";+/// The player tab's name. It is ALSO `webview.title` in dockbar.json on purpose:+/// the dock pre-opens a tab by that title and the drainer's open-or-refresh+/// reuses a tab by name, so the dock's tab and the drainer's tab are the same+/// tab. Renaming either side alone creates a duplicate tab.+const PLAYER_TAB_NAME: &str = "Adom TTS";+/// Pre-1.9.49 tab name, closed once on startup so an upgrade never leaves two.+const LEGACY_TAB_NAME: &str = "TTS Player";+const NEWBIE_PANEL_JS: &str = include_str!("../vendor/newbie-panel.js");+const APP_ICON_SVG: &str = include_str!("../icons/app.svg");+/// The voice service the drainer probes for the status pill row.+fn service_url() -> String {+ std::env::var("ADOM_TTS_API").unwrap_or_else(|_| env!("SERVICE_TTS_URL").to_string())+ .trim_end_matches('/').to_string()+} const SERVER_DEFAULT_TIMEOUT_SECS: u64 = 60; const SERVER_MAX_TIMEOUT_SECS: u64 = 300; @@ -903,7 +916,15 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { let player = start_player_server()?; write_player_portfile(player.port); let url = server::webview_url(player.port);+ // One-time upgrade tidy: the tab used to be called "TTS Player". Close it+ // before opening "Adom TTS" so an upgrade never leaves two player tabs.+ if surface == Surface::Hydrogen && std::env::var("VSCODE_PROXY_URI").is_ok() {+ let _ = close_hydrogen_tab(LEGACY_TAB_NAME);+ } open_player_surface(surface, &url, &cfg.play);+ // Opening the tab flashes it; with nothing to play (a dock click, `open`)+ // that flash is noise. The flash is re-armed at every clip start below.+ if surface == Surface::Hydrogen && queue_depth(&dir) == 0 { server::clear_tab_alert(PLAYER_TAB_NAME); } let mut next_id: u64 = 1; let mut superseded = false;@@ -931,6 +952,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { created_ms: file_created_ms(Path::new(&file)), }); log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "replay": true }));+ if surface == Surface::Hydrogen { server::set_tab_alert(PLAYER_TAB_NAME); } let _ = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, now_ms()); let _ = log_clip_end(&player.shared, &name); // replay done → the tab's blink has served its purpose@@ -1058,6 +1080,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { created_ms: audio_ms, }); log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "req": &chash }));+ if surface == Surface::Hydrogen { server::set_tab_alert(PLAYER_TAB_NAME); } let expired_mid = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, enq_ms); // Interrupted by a human replay tap? Not a failure — // keep the entry untouched; it restarts after the replay.@@ -1734,6 +1757,12 @@ struct PlayerShared { // interrupting the current AI clip (which re-queues and restarts after). // Principle: the human preempts; the AI queues. preempt: std::sync::Mutex<Option<serde_json::Value>>,+ // Voice-service probe for the status pill row: (0 unprobed, 1 ok, 2 bad),+ // short state text, prose detail (a bad state names the next command).+ service: std::sync::Mutex<(u8, String, String)>,+ // `adom-tts ui toast` / POST /ui/toast: the page shows it once (by seq).+ toast_seq: AtomicU64,+ toast: std::sync::Mutex<serde_json::Value>, } impl PlayerShared { /// Milliseconds since the page last polled. `u64::MAX` before any poll →@@ -1798,6 +1827,8 @@ fn start_player_server() -> Result<Player> { events: std::sync::Mutex::new(Vec::new()), cmd_seq: AtomicU64::new(0), cmd_str: std::sync::Mutex::new(String::new()), queued: AtomicU64::new(0), served: AtomicU64::new(0), last_poll_ms: AtomicU64::new(0),+ service: std::sync::Mutex::new((0, "checking".into(), "Probing the voice service.".into())),+ toast_seq: AtomicU64::new(0), toast: std::sync::Mutex::new(serde_json::Value::Null), user_paused: AtomicBool::new(false), skipped: AtomicBool::new(false), surface_down: AtomicBool::new(false),@@ -1806,6 +1837,35 @@ fn start_player_server() -> Result<Player> { let html = Arc::new(player_page_html()); let stop = Arc::new(AtomicBool::new(false)); + // STATUS PILL PROBE: the voice service, checked every 60s from the drainer+ // (never from the page: the page cannot reach it cross-origin and a pill+ // must show one server-side truth). Gray until the first probe answers;+ // red carries the exact next command.+ {+ let shared_p = shared.clone();+ let stop_p = stop.clone();+ thread::spawn(move || {+ let client = reqwest::blocking::Client::builder()+ .timeout(Duration::from_secs(6)).build().ok();+ let url = service_url();+ loop {+ if stop_p.load(Ordering::SeqCst) { break; }+ let probe = client.as_ref().map(|c| c.get(format!("{url}/health")).send());+ let (code, short, detail) = match probe {+ Some(Ok(r)) if r.status().is_success() =>+ (1u8, "answering".to_string(), format!("Voice service at {url} is answering.")),+ Some(Ok(r)) => (2u8, format!("http {}", r.status().as_u16()),+ format!("Voice service at {url} answered HTTP {}. Run `adom-tts health` for the body, then check the service-tts container.", r.status().as_u16())),+ Some(Err(e)) => (2u8, "not answering".to_string(),+ format!("Voice service at {url} did not answer ({}). Check your network, then run `adom-tts health`.", e.to_string().chars().take(80).collect::<String>())),+ None => (2u8, "no client".to_string(), "Could not build an HTTP client. Run `adom-tts health`.".to_string()),+ };+ if let Ok(mut g) = shared_p.service.lock() { *g = (code, short, detail); }+ for _ in 0..60 { if stop_p.load(Ordering::SeqCst) { return; } thread::sleep(Duration::from_secs(1)); }+ }+ });+ }+ let server_t = server.clone(); let shared_t = shared.clone(); let stop_t = stop.clone();@@ -2133,11 +2193,77 @@ fn serve_audio(request: tiny_http::Request, query: &str, shared: &Arc<PlayerShar } /// Route one request against the persistent player's shared state.-fn handle_player_request(request: tiny_http::Request, method: &Method, route: &str, query: &str,+fn handle_player_request(mut request: tiny_http::Request, method: &Method, route: &str, query: &str, shared: &Arc<PlayerShared>, html: &Arc<String>) -> std::io::Result<()> { match (method, route) { (Method::Get, "/" | "/index.html" | "") => respond_inline(request, 200, "text/html; charset=utf-8", html.as_bytes().to_vec()),+ // ONE icon file for the favicon, the header mark, the tab strip and the+ // dock card (icons skill): the page links `favicon.svg` relatively.+ (Method::Get, "/favicon.svg" | "favicon.svg" | "/favicon.ico" | "favicon.ico" | "/icons/app.svg" | "icons/app.svg") =>+ respond_inline(request, 200, "image/svg+xml", APP_ICON_SVG.as_bytes().to_vec()),+ // The newbie panel module, vendored into this package and served as a+ // FILE (newbie-panel skill: never inlined, never read from the pack path).+ (Method::Get, "/newbie-panel.js" | "newbie-panel.js") =>+ respond_inline(request, 200, "application/javascript; charset=utf-8", NEWBIE_PANEL_JS.as_bytes().to_vec()),+ // Dock health probe (cross-origin from the Hydrogen shell, so CORS *).+ // The same truth the page's pill row renders: one state, two views.+ (Method::Get, "/api/health" | "api/health") => {+ let body = health_json(shared).to_string();+ let headers = vec![+ Header::from_bytes(&b"Content-Type"[..], b"application/json".to_vec()).unwrap(),+ Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*".to_vec()).unwrap(),+ Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),+ ];+ let len = body.len();+ request.respond(Response::new(StatusCode(200), headers, Cursor::new(body.into_bytes()), Some(len), None))+ }+ // Settings, read and written by the gear modal (settings-layout skill).+ // Writes go through the same config::save_setting the CLI uses.+ (Method::Get, "/settings" | "settings") => {+ let body = settings_json().to_string();+ respond_inline(request, 200, "application/json", body.into_bytes())+ }+ (Method::Post, "/settings" | "settings") => {+ let mut raw = String::new();+ let _ = std::io::Read::read_to_string(request.as_reader(), &mut raw);+ let v: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);+ let key = v.get("key").and_then(|k| k.as_str()).map(str::to_string).unwrap_or_else(|| qval(query, "key"));+ let value = v.get("value").and_then(|k| k.as_str()).map(str::to_string).unwrap_or_else(|| qval(query, "value"));+ let body = match config::save_setting(&key, &value) {+ Ok(()) => {+ log_event("setting_changed", serde_json::json!({ "key": key, "value": value, "via": "page" }));+ serde_json::json!({ "ok": true, "settings": settings_json(),+ "note": if key == "surface" { "Takes effect on the next player spawn (adom-tts restart when idle)." } else { "" } })+ }+ Err(e) => serde_json::json!({ "ok": false, "error": e.to_string() }),+ };+ respond_inline(request, 200, "application/json", body.to_string().into_bytes())+ }+ // The AI narrates over the running app (adom-ui-design section 3):+ // `adom-tts ui toast "..."` lands here; the page shows it once.+ (Method::Post, "/ui/toast" | "ui/toast") | (Method::Get, "/ui/toast" | "ui/toast") => {+ let mut raw = String::new();+ let _ = std::io::Read::read_to_string(request.as_reader(), &mut raw);+ let v: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);+ let text = v.get("text").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(|| qval(query, "text"));+ let kind = v.get("kind").and_then(|t| t.as_str()).map(str::to_string).unwrap_or_else(|| qval(query, "kind"));+ let kind = if kind.is_empty() { "info".to_string() } else { kind };+ if text.trim().is_empty() {+ return respond_inline(request, 400, "application/json", b"{\"ok\":false,\"error\":\"text is required\"}".to_vec());+ }+ if let Ok(mut t) = shared.toast.lock() { *t = serde_json::json!({ "text": text, "kind": kind }); }+ let seq = shared.toast_seq.fetch_add(1, Ordering::SeqCst) + 1;+ respond_inline(request, 200, "application/json", format!("{{\"ok\":true,\"seq\":{seq}}}").into_bytes())+ }+ // The newbie panel's Play-the-demo button: run the built-in demo as a+ // detached CLI process (it loads a 3-marker script and cues it).+ (Method::Post, "/demo" | "demo") | (Method::Get, "/demo" | "demo") => {+ let ok = std::env::current_exe().ok().and_then(|exe| Command::new(exe).arg("demo")+ .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().ok()).is_some();+ log_event("demo_requested", serde_json::json!({ "ok": ok, "via": "page" }));+ respond_inline(request, 200, "application/json", format!("{{\"ok\":{ok}}}").into_bytes())+ } (Method::Get, "/audio" | "audio") => serve_audio(request, query, shared), // The page's single poll: heartbeat + current clip + control command. // It also CARRIES telemetry (?id&t&dur&done) — after a hotspot gap the@@ -2207,6 +2333,12 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s "prefetch": prefetch_list(), "queued": shared.queued.load(Ordering::SeqCst), "now_ms": now_ms(),+ "version": MY_VERSION,+ // status pill row (status-leds skill): SHORT state emitted by the+ // server, prose detail on tap. The page never slices prose.+ "health": health_json(shared),+ "toast_seq": shared.toast_seq.load(Ordering::SeqCst),+ "toast": shared.toast.lock().map(|t| t.clone()).unwrap_or(serde_json::Value::Null), // strip words from the listing — the page only needs them when a // replay actually starts (they'd bloat every 700ms poll otherwise) "history": read_play_history(12).into_iter().map(|mut e| {@@ -2596,6 +2728,120 @@ fn sidecar_meta_line(mp3: &Path) -> String { parts.join(" · ") } +/// The status pill row's truth, shared by `/poll` (the page) and `/api/health`+/// (the dock card). Each subsystem carries a SHORT `state` for the pill and+/// prose `detail` for the tap-to-expand; a non-green detail leads with the+/// exact next command (status-leds skill).+fn health_json(shared: &Arc<PlayerShared>) -> serde_json::Value {+ let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();+ let done = shared.done.load(Ordering::SeqCst);+ let paused = shared.user_paused.load(Ordering::SeqCst);+ let queued = shared.queued.load(Ordering::SeqCst);+ let (svc_code, svc_state, svc_detail) = shared.service.lock().map(|g| g.clone()).unwrap_or((0, "checking".into(), String::new()));+ let svc_led = match svc_code { 1 => "ok", 2 => "bad", _ => "unknown" };+ let cfg = config::load();+ let surface = live_drainer_surface().unwrap_or_else(|| config::resolve_surface(None, &cfg.play));+ let surface_word = match surface { Surface::Hydrogen => "webview", Surface::Pup => "pup" };+ let surface_down = shared.surface_down.load(Ordering::SeqCst);+ let (queue_led, queue_state, queue_detail) = if clip.id != 0 && !done && paused {+ ("busy", "paused".to_string(), format!("A clip is paused by the listener; {queued} waiting behind it. Nothing plays until they press play, or an AI runs `adom-tts control play`."))+ } else if clip.id != 0 && !done {+ ("ok", if queued > 0 { format!("playing, {queued} waiting") } else { "playing".to_string() },+ format!("Playing \"{}\"; {queued} clip(s) waiting in the shared queue.", clip.label.as_str().trim().is_empty().then_some(clip.title.as_str()).unwrap_or(clip.label.as_str())))+ } else if queued > 0 {+ ("busy", format!("{queued} waiting"), format!("{queued} clip(s) waiting; the next one starts as soon as the page is ready."))+ } else {+ ("ok", "idle".to_string(), "Nothing playing and nothing queued. Any AI thread's `adom-tts say --play` lands here.".to_string())+ };+ serde_json::json!({+ "ok": !surface_down && svc_code != 2,+ "version": MY_VERSION,+ "state": if surface_down { "surface down" } else if clip.id != 0 && !done { "playing" } else { "serving" },+ "player": { "led": "ok", "state": format!("v{MY_VERSION} serving"),+ "detail": format!("Player v{MY_VERSION} is serving this page from the drainer (pid {}). Logs: `adom-tts logs`.", std::process::id()) },+ "surface": { "led": if surface_down { "bad" } else { "ok" },+ "state": if surface_down { "not opening".to_string() } else { surface_word.to_string() },+ "detail": if surface_down { format!("The {surface_word} surface failed to open. Run `adom-tts reopen`, or switch the default in Settings and `adom-tts restart` when idle.") }+ else { format!("Clips play in the {surface_word} surface. Change the default with the gear (Settings) or `adom-tts settings set surface pup|webview`.") } },+ "service": { "led": svc_led, "state": svc_state, "detail": svc_detail },+ "queue": { "led": queue_led, "state": queue_state, "detail": queue_detail },+ })+}++/// The settings the gear modal shows: the persisted config plus its path.+fn settings_json() -> serde_json::Value {+ let cfg = config::load();+ serde_json::json!({+ "surface": cfg.play.surface.as_str(),+ "pup_session": cfg.play.pup_session,+ "pup_profile": cfg.play.pup_profile,+ "path": config::config_path().map(|p| p.display().to_string()).unwrap_or_default(),+ "live_surface": live_drainer_surface().map(|s| s.as_str()),+ })+}++/// `adom-tts open [--print-url]`: the dock's serve step and a human's "show me+/// the player". Makes sure a drainer is up (spawning one for the WEBVIEW+/// surface if none is live), waits for its page to answer, then RETURNS,+/// printing the direct loopback URL on stdout. The drainer itself owns the+/// tab (open-or-refresh by the same name the dock pre-opens), so this never+/// opens a second one. Self-backgrounding is the dock contract: Hydrogen waits+/// for this process to EXIT before it reads the URL.+pub fn open(print_url: bool) -> Result<()> {+ persist_surface_env();+ if let Some(Surface::Pup) = live_drainer_surface() {+ eprintln!("Hint: the live player is on the pup surface; the dock tab will mirror it. Two open pages both play audio.");+ eprintln!(" To move it here: `adom-tts settings set surface webview` then `adom-tts restart` (when idle).");+ }+ let already = lock_owner().map(|(pid, _)| pid_alive(pid)).unwrap_or(false);+ ensure_drainer(Surface::Hydrogen)?;+ let client = reqwest::blocking::Client::builder().timeout(Duration::from_millis(800)).build()?;+ let deadline = Instant::now() + Duration::from_secs(15);+ let port = loop {+ if let Some(p) = read_player_port() {+ if client.get(format!("http://127.0.0.1:{p}/api/health")).send().map(|r| r.status().is_success()).unwrap_or(false) { break p; }+ }+ if Instant::now() > deadline { anyhow::bail!("the player did not come up within 15s. Hint: `adom-tts logs` then `adom-tts restart`"); }+ thread::sleep(Duration::from_millis(250));+ };+ if !already {+ log_event("dock_open", serde_json::json!({ "port": port, "dock_webview": std::env::var("ADOM_DOCK_WEBVIEW").ok() }));+ } else {+ // A drainer was already up: it opened its tab long ago and may have+ // idle-closed it. Re-offer the tab so a dock click always shows a page.+ let cfg = config::load();+ let surface = live_drainer_surface().unwrap_or(Surface::Hydrogen);+ if surface == Surface::Hydrogen && std::env::var("VSCODE_PROXY_URI").is_ok() {+ let _ = server::open_webview_tab_named(&server::webview_url(port), PLAYER_TAB_NAME);+ let _ = &cfg;+ }+ }+ if print_url {+ println!("http://127.0.0.1:{port}/");+ } else {+ println!("OK: player up on :{port}");+ println!(" player page: {}", server::webview_url(port));+ }+ Ok(())+}++/// `adom-tts ui toast "<text>" [--kind info|success|error]`: raise a toast on+/// the live player page (the AI narrating over the app it drives).+pub fn ui_toast(text: &str, kind: &str) -> Result<()> {+ let port = read_player_port().ok_or_else(|| anyhow!("no live player. Hint: `adom-tts open` starts one"))?;+ let r = reqwest::blocking::Client::builder().timeout(Duration::from_secs(3)).build()?+ .post(format!("http://127.0.0.1:{port}/ui/toast"))+ .json(&serde_json::json!({ "text": text, "kind": kind }))+ .send().map_err(|e| anyhow!("player on :{port} did not answer ({e}). Hint: `adom-tts restart`"))?;+ let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);+ if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {+ println!("OK: toast raised on the player page (seq {})", v.get("seq").and_then(|s| s.as_u64()).unwrap_or(0));+ Ok(())+ } else {+ anyhow::bail!("player refused the toast: {}", v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"))+ }+}+ #[cfg(test)] mod tests { use super::*;
src/player.html+519−390@@ -3,27 +3,37 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1">-<title>adom-tts player</title>+<title>Adom TTS</title>+<!-- ONE icon file for the favicon, the header mark, the tab strip and the dock+ card (icons skill). Relative URL: this page is often served through the+ workspace proxy, where a leading slash would escape to the host origin. -->+<link rel="icon" type="image/svg+xml" href="favicon.svg"> <style>- @import url('https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&display=swap');+ /* Brand fonts, self-declared from adom.inc (no stylesheet is published there). */+ @font-face { font-family: "Familjen Grotesk"; font-weight: 500; font-display: swap; src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2") format("woff2"); }+ @font-face { font-family: "Familjen Grotesk"; font-weight: 600; font-display: swap; src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2") format("woff2"); }+ @font-face { font-family: "Familjen Grotesk"; font-weight: 700; font-display: swap; src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2") format("woff2"); }+ @font-face { font-family: "Satoshi"; font-weight: 400; font-display: swap; src: url("https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2") format("woff2"); }+ @font-face { font-family: "Satoshi"; font-weight: 500; font-display: swap; src: url("https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2") format("woff2"); }+ @font-face { font-family: "Satoshi"; font-weight: 700; font-display: swap; src: url("https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2") format("woff2"); }++ /* The dock-dashboard token stub. Every color below resolves to one of these. */ :root {- --bg: #0d1117; --bg2: #00142f;- --surface: #161b22; --elevated: #1c2128; --border: #2b3a52;- --fg: #e6edf3; --muted: #8b98a5;- --teal: #00B8B1; --teal-soft: rgba(0,184,177,0.16);- --blue: #0061EF; --purple: #8C6BF7;- --amber: #d4a017; --red: #e0524d;- /* z-index ladder (adom-ui-design/tooltips) — never hardcode a literal */- --z-toolbar: 80; --z-floating-menu: 200; --z-toast: 400; --z-tooltip: 99999;+ --bg: #0d1117; --surface: #161b22; --surface2: #1c232c; --line: #262d36;+ --text: #e6edf3; --muted: #8b949e; --teal: #00b8b1;+ --led-green: #3fb950; --led-amber: #d29922; --led-red: #f85149;+ --blue-text: #64abff; --purple: #8c6bf7;+ --teal-soft: rgba(0,184,177,.16);+ --sans: "Familjen Grotesk", system-ui, sans-serif;+ --body: "Satoshi", system-ui, -apple-system, "Segoe UI", sans-serif;+ --mono: "JetBrains Mono", ui-monospace, Consolas, Menlo, monospace;+ --gutter: 16px;+ /* z-index ladder (tooltips skill): never a literal in a rule */+ --z-toolbar: 80; --z-floating-menu: 200; --z-modal: 300; --z-toast: 400; --z-tooltip: 99999; } * { box-sizing: border-box; min-width: 0; }- /* HARD RULE: the outermost html NEVER shows a horizontal scrollbar. A truly- fluid layout never overflows sideways; `clip` guarantees a stray pixel- (negative margin, long token) can't summon one either. */- html, body { margin: 0; height: 100%; overflow-x: clip; }-- /* Pretty scrollbars everywhere (transcript, history, page) — slim, rounded,- quiet until hovered. Never the chunky default. */+ /* body grows with content (a long transcript + history must push the footer down, never under it) */+ html { height: 100%; } html, body { margin: 0; overflow-x: clip; } body { min-height: 100%; } * { scrollbar-width: thin; scrollbar-color: rgba(0,184,177,.35) transparent; } *::-webkit-scrollbar { width: 7px; height: 7px; } *::-webkit-scrollbar-track { background: transparent; }@@ -31,349 +41,367 @@ *::-webkit-scrollbar-thumb:hover { background: rgba(0,184,177,.55); } *::-webkit-scrollbar-corner { background: transparent; } body {- background:- radial-gradient(900px 420px at 85% -10%, rgba(140,107,247,0.12), transparent 60%),- radial-gradient(900px 420px at 0% 0%, rgba(0,97,239,0.12), transparent 55%),- linear-gradient(170deg, var(--bg2), var(--bg) 60%);- color: var(--fg);- font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;- min-height: 100%;- display: flex; flex-direction: column;+ background: var(--bg); color: var(--text);+ font-family: var(--body); font-size: 14px;+ min-height: 100%; display: flex; flex-direction: column; }+ :focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }+ button { font-family: inherit; }+ svg.ic { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; } - /* ---- canonical Adom app-header: single row, 44px, brand | subject | actions ---- */- header {- position: sticky; top: 0; z-index: 20; min-height: 44px;- display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; padding: 6px 14px;- background: rgba(13,17,23,0.82); backdrop-filter: blur(10px);- border-bottom: 1px solid var(--border);- }- /* version badge — ALWAYS visible so you can tell which build is running and- spot any thread still launching an old one. */- .ver { font-family: ui-monospace,Menlo,monospace; font-size:11px; color: var(--teal);- border:1px solid rgba(0,184,177,0.55); border-radius:6px; padding:1px 6px; flex:none; }- header::after {- content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px;- background: linear-gradient(90deg, var(--blue), var(--teal) 50%, var(--purple)); opacity:.85;- }- /* Zone 1: brand */- .brand { display: flex; align-items: center; gap: 9px; position: relative; cursor: default; outline: none; flex: none; }- .eq { display: flex; align-items: flex-end; gap: 3px; height: 17px; }- .eq i { width: 3px; border-radius: 2px; background: var(--teal); transform-origin: bottom; animation: bob 1s ease-in-out infinite; }- .eq i:nth-child(1){height:7px;animation-delay:0s}- .eq i:nth-child(2){height:15px;animation-delay:.15s}- .eq i:nth-child(3){height:10px;animation-delay:.3s}- .eq i:nth-child(4){height:17px;animation-delay:.45s}- body:not(.is-playing) .eq i { animation-play-state: paused; transform: scaleY(.55); }- @keyframes bob { 0%,100%{transform:scaleY(.45)} 50%{transform:scaleY(1)} }- .app-name { font-family:'Familjen Grotesk',system-ui,sans-serif; font-weight:600; font-size:15px; color: var(--teal); letter-spacing:-.2px; }- .app-subtitle { font-family:'Familjen Grotesk',system-ui,sans-serif; font-weight:500; font-size:13px; color: var(--muted); }- .sep { color: var(--border); margin: 0 1px; }- .vsep { width:1px; height:20px; background: var(--border); flex: none; }- /* Zone 2: subject — truncates when tight; hover/tap reveals the full story */- .subject { display:flex; align-items:baseline; gap:8px; min-width:0; flex:1; overflow:visible; position:relative; cursor:default; outline:none; }- .subject-name { font-size:14px; font-weight:600; color: var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:0 1 auto; min-width:4.5em; }- /* rich tooltip: everything the truncated header hides, in one card */- .subject-pop {- position:absolute; top: calc(100% + 10px); left: 0; width: min(340px, 86vw); z-index: 30;- background: var(--elevated); border:1px solid var(--border); border-radius:12px;- padding:13px 15px; box-shadow: 0 14px 40px rgba(0,0,0,.5);- opacity:0; transform: translateY(-4px); pointer-events:none; transition: opacity .14s, transform .14s;- }- .subject:hover .subject-pop, .subject:focus-within .subject-pop, .subject.open .subject-pop { opacity:1; transform:none; pointer-events:auto; }- .sp-desc { font-size:13px; color: var(--fg); line-height:1.5; margin-bottom:10px; }- .sp-row { display:flex; gap:10px; font-size:12px; margin:4px 0; }- .sp-k { color: var(--muted); width:52px; flex:none; text-transform:uppercase; letter-spacing:.5px; font-size:10px; padding-top:1px; }- .sp-v { color: var(--fg); min-width:0; overflow-wrap:anywhere; }- .sp-v.mono { font-family: ui-monospace,Menlo,monospace; font-size:11.5px; }- /* the clip DESCRIPTION (human sentence) — always visible, ellipsized when tight */- .subject-meta { font-size:12.5px; color: var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex: 1 1 auto; }- .subject-meta .sep { color: var(--border); }- /* CLIP FRESHNESS line — lives by the transcript (not the header). Shows when- THIS clip's audio was made + how long ago. Teal fresh · amber hours · red- days. Hover it (or the transcript) for the exact synthesized timestamp. */- .clip-fresh { align-self:flex-start; margin: 2px 0 0; font-size:12px; font-family: ui-monospace,Menlo,monospace;- padding:3px 10px; border-radius:999px; white-space:nowrap; cursor: default;- color: var(--teal); border:1px solid rgba(0,184,177,.4); background: var(--teal-soft); }- .clip-fresh.warn { color: var(--amber); border-color: rgba(212,160,23,.5); background: rgba(212,160,23,.14); }- .clip-fresh.stale { color: var(--red); border-color: rgba(224,82,77,.6); background: rgba(224,82,77,.16); font-weight:600; }- /* Global tooltip layer (adom-ui-design/tooltips) — one fixed div, topmost. */- #global-tooltip { position: fixed; z-index: var(--z-tooltip); top:0; left:0;- transform: translate(var(--tip-x,0), var(--tip-y,0));- max-width: min(320px, calc(100vw - 40px)); pointer-events:none; opacity:0; display:none;- background: rgba(22,27,34,.96); color: var(--fg);- border:1px solid rgba(0,184,177,.28); border-radius:10px; padding:9px 13px;- font: 400 13px/1.4 system-ui, sans-serif; box-shadow: 0 12px 40px rgba(0,0,0,.55);- backdrop-filter: blur(16px); text-transform:none; letter-spacing:0; }- #global-tooltip.visible { display:block; }- #global-tooltip.shown { opacity:1; transition: opacity .18s ease .6s; }- /* Zone 3: actions */- .actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex:none; }- .queue-chip { font-size:11.5px; color: var(--blue); border:1px solid rgba(0,97,239,0.5); background: rgba(0,97,239,0.12); padding:3px 9px; border-radius:999px; white-space:nowrap; }- .queue-chip.hidden { display:none; }- .thread-chip { font-size:11.5px; color: var(--purple); border:1px solid rgba(140,107,247,0.5); background: rgba(140,107,247,0.12); padding:3px 10px; border-radius:999px; white-space:nowrap; }- /* live-link LED — proves the front-end has a live pipe to the AI/back-end.- green=connected, amber=degraded, red/gray=disconnected (dead/orphaned tab). */- .led { width:11px; height:11px; border-radius:50%; flex:none; margin-left:3px; transition: background .4s, box-shadow .4s; }- /* GENTLE liveness: a slow breathing glow, never a jumpy per-ping pulse. */- .led.ok { background:#3fb950; animation: ledbreathe 3.2s ease-in-out infinite; }- .led.warn { background:var(--amber); box-shadow:0 0 7px rgba(212,160,23,.5); }- .led.down { background:#e0524d; box-shadow:0 0 6px rgba(224,82,77,.55); }- @keyframes ledbreathe {- 0%,100% { box-shadow: 0 0 4px rgba(63,185,80,.35); }- 50% { box-shadow: 0 0 10px rgba(63,185,80,.75); }- }- .pill {- display: inline-flex; align-items: center; gap: 7px;- padding: 4px 11px; background: var(--elevated); border: 1px solid var(--border);- border-radius: 999px; font-size: 12px; color: var(--muted);- }- .pill::before { content:""; width:7px; height:7px; border-radius:50%; background: currentColor; }- .pill.playing { color: var(--teal); border-color: var(--teal); }- .pill.buffering { color: var(--amber); border-color: var(--amber); }- .pill.paused { color: var(--amber); }- .pill.ended { color: var(--muted); }- .pill.error { color: var(--red); border-color: var(--red); }- /* app-info popover (hover/focus the brand) */- .popover {- position:absolute; top: calc(100% + 8px); left:0; width: 320px; z-index: 30;- background: var(--elevated); border:1px solid var(--border); border-radius:12px;- padding:14px 16px; box-shadow: 0 14px 40px rgba(0,0,0,.5);- opacity:0; transform: translateY(-4px); pointer-events:none; transition: opacity .14s, transform .14s;+ /* ============ 1. The house header (dash-header numbers) ============ */+ header.dash {+ height: 44px; flex: none; display: flex; align-items: center; gap: 12px;+ padding: 0 var(--gutter); background: var(--surface); border-bottom: 1px solid var(--line);+ white-space: nowrap; }- .brand:hover .popover, .brand:focus-within .popover, .popover:hover { opacity:1; transform:none; pointer-events:auto; }- .po-title { font-family:'Familjen Grotesk',sans-serif; font-weight:700; font-size:15px; color:var(--fg); }- .po-title b { color: var(--teal); }- .po-ver { font-family: ui-monospace,Menlo,monospace; font-size:11px; color: var(--muted); margin-left:4px; }- .po-desc { font-size:12.5px; color: var(--muted); margin:8px 0 12px; line-height:1.5; }- .po-row { display:flex; gap:10px; font-size:12px; margin:5px 0; }- .po-k { color: var(--muted); width:54px; flex:none; text-transform:uppercase; letter-spacing:.5px; font-size:10.5px; padding-top:1px; }- .po-v { color: var(--fg); }- .po-link { display:inline-block; margin-top:11px; font-size:12.5px; color: var(--teal); text-decoration:none; }- .po-link:hover { text-decoration:underline; }-- /* ---- player: centered card on a wide screen, edge-to-edge fill in the- skinny 20% webview (see the max-width:620px block below) ---- */- main { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 24px 20px; }- .player {- width: min(620px, 94vw); display: flex; flex-direction: column; min-height: 0;- background: linear-gradient(180deg, var(--surface), #0e131a);- border: 1px solid var(--border); border-radius: 18px;- box-shadow: 0 18px 50px rgba(0,0,0,0.5);- padding: 26px 28px 22px;+ .brand { display: flex; align-items: center; gap: 8px; position: relative; cursor: default; flex: none; height: 44px; }+ .brand .mark { width: 18px; height: 18px; color: var(--text); }+ .app-name { font-family: var(--sans); font-weight: 600; font-size: 15px; color: var(--text); letter-spacing: -.01em; }+ .app-subtitle { font-size: 13px; font-weight: 400; color: var(--muted); }+ .vsep { width: 1px; height: 20px; background: var(--line); flex: none; }+ .subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; position: relative; cursor: default; }+ .subject-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; flex: 0 1 auto; min-width: 3em; }+ .subject-meta { font-size: 12.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; flex: 1 1 auto; }+ .subject-pop, .popover {+ position: absolute; top: calc(100% - 2px); left: 0; z-index: var(--z-floating-menu);+ background: var(--surface2); border: 1px solid var(--line); border-radius: 12px;+ padding: 13px 15px; box-shadow: 0 14px 40px rgba(0,0,0,.5); white-space: normal;+ opacity: 0; transform: translateY(-4px); pointer-events: none; transition: opacity .14s .6s, transform .14s .6s; }+ .subject-pop { width: min(360px, calc(100vw - 32px)); }+ .popover { width: min(320px, calc(100vw - 32px)); }+ .subject:hover .subject-pop, .subject:focus-within .subject-pop, .subject.open .subject-pop,+ .brand:hover .popover, .brand:focus-within .popover, .popover:hover { opacity: 1; transform: none; pointer-events: auto; }+ .subject.open .subject-pop { transition-delay: 0s; }+ .sp-desc { font-size: 13px; color: var(--text); line-height: 1.5; margin-bottom: 10px; }+ .sp-row, .po-row { display: flex; gap: 10px; font-size: 12px; margin: 4px 0; }+ .sp-k, .po-k { color: var(--muted); width: 56px; flex: none; font-family: var(--mono); font-size: 11px; padding-top: 1px; }+ .sp-v, .po-v { color: var(--text); min-width: 0; overflow-wrap: anywhere; }+ .sp-v.mono { font-family: var(--mono); font-size: 11.5px; }+ .po-title { font-family: var(--sans); font-weight: 600; font-size: 15px; color: var(--text); }+ .po-ver { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-left: 6px; }+ .po-desc { font-size: 12.5px; color: var(--muted); margin: 8px 0 12px; line-height: 1.5; }+ .po-link { display: inline-block; margin-top: 11px; font-size: 12.5px; color: var(--teal); text-decoration: none; }+ .po-link:hover { text-decoration: underline; }+ .actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex: none; }+ .queue-chip { font-size: 11.5px; font-family: var(--mono); color: var(--blue-text); border: 1px solid rgba(100,171,255,.4); background: rgba(100,171,255,.10); padding: 3px 9px; border-radius: 999px; margin-right: 4px; }+ .queue-chip[hidden] { display: none; }+ .target-chip { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 12px; color: var(--muted);+ border: 1px solid var(--line); border-radius: 999px; padding: 3px 10px; margin-right: 4px; cursor: default; }+ .target-chip b { color: var(--text); font-weight: 500; }+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; transition: background .4s; }+ .dot.ok { background: var(--led-green); }+ .dot.warn { background: var(--led-amber); }+ .dot.down { background: var(--led-red); }+ .ibtn { width: 28px; height: 28px; border-radius: 7px; border: 0; background: transparent; color: var(--muted);+ display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }+ .ibtn:hover, .ibtn[aria-pressed="true"] { color: var(--teal); background: var(--teal-soft); } + /* ============ 2 + 3. Newbie panel mount and the status pill row ============ */+ .page { flex: 1 0 auto; display: flex; flex-direction: column; padding: 0 var(--gutter) var(--gutter); }+ #newbie { flex: none; }+ .leds { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 14px; flex: none; }+ .led-pill { display: inline-flex; align-items: center; gap: 7px; background: var(--surface); border: 1px solid var(--line);+ border-radius: 999px; padding: 4px 12px; font-size: 12.5px; cursor: pointer; color: var(--text); }+ .led-pill .lbl { color: var(--muted); }+ .led-pill[data-state=ok] .dot { background: var(--led-green); }+ .led-pill[data-state=busy] .dot { background: var(--led-amber); }+ .led-pill[data-state=bad] .dot { background: var(--led-red); }+ #led-pop { position: fixed; z-index: var(--z-floating-menu); display: none; width: min(380px, calc(100vw - 32px));+ background: var(--surface2); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px;+ box-shadow: 0 14px 40px rgba(0,0,0,.5); font-size: 13px; line-height: 1.5; color: var(--text); }+ #led-pop.visible { display: block; }+ #led-pop code { font-family: var(--mono); font-size: 12px; color: var(--teal); }++ /* ============ 4. The player (the app's own content) ============ */+ .player { flex: none; min-height: 0; display: flex; flex-direction: column; background: var(--surface);+ border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px 16px; } .transport { display: flex; align-items: center; gap: 16px; }- .play {- width: 56px; height: 56px; flex: none; border-radius: 50%; border: 0;- background: var(--teal); color: #04201f; cursor: pointer;- display: flex; align-items: center; justify-content: center;- box-shadow: 0 4px 16px rgba(0,184,177,0.4); transition: transform .08s, filter .15s, box-shadow .3s;- }+ .play { width: 56px; height: 56px; flex: none; border-radius: 50%; border: 0; background: var(--teal); color: #04201f; cursor: pointer;+ display: flex; align-items: center; justify-content: center; transition: transform .08s, filter .15s, box-shadow .3s; } .play:hover { filter: brightness(1.08); }- .play:active { transform: scale(0.93); }- .play svg { width: 24px; height: 24px; fill: #04201f; }+ .play:active { transform: scale(.93); }+ .play svg.ic { width: 26px; height: 26px; stroke-width: 2; }+ .play svg.ic[hidden] { display: none; } .play.attention { animation: pulse 1.3s ease-in-out infinite; }- @keyframes pulse { 0%,100%{box-shadow:0 4px 16px rgba(0,184,177,0.4)} 50%{box-shadow:0 4px 30px rgba(0,184,177,0.95)} }-+ @keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(0,184,177,.4); } 50% { box-shadow: 0 0 0 10px rgba(0,184,177,0); } } .seekwrap { flex: 1; min-width: 0; }- .seek {- -webkit-appearance: none; appearance: none; width: 100%; height: 6px;- border-radius: 999px; background: var(--elevated); cursor: pointer; outline: none;- }- .seek::-webkit-slider-thumb {- -webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;- background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); cursor: pointer;- }+ .seek { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 999px; background: var(--surface2); cursor: pointer; }+ .seek::-webkit-slider-thumb { -webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); cursor: pointer; } .seek::-moz-range-thumb { width: 15px; height: 15px; border: 0; border-radius: 50%; background: var(--teal); cursor: pointer; }- .times { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--muted); margin-top: 7px; font-variant-numeric: tabular-nums; }-- /* ---- transcript / captions ---- */- .transcript {- margin-top: 22px; padding: 16px 18px; border-radius: 12px;- background: rgba(0,0,0,0.22); border: 1px solid var(--border);- font-size: 16px; line-height: 1.75; max-height: 230px; overflow-y: auto;- color: var(--muted);- }- .transcript .label { display:block; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; opacity:.9; }- .transcript w {- transition: color .12s, background .12s; border-radius: 5px; padding: 0 1px;- }- .transcript w.spoken { color: var(--fg); }- .transcript w.active {- color: #04201f; background: var(--teal); padding: 1px 4px; margin: 0 -3px;- font-weight: 600;- }- .transcript:empty, .transcript.hidden { display: none; }+ .times { display: flex; justify-content: space-between; align-items: center; font-size: 11.5px; color: var(--muted); margin-top: 7px; font-variant-numeric: tabular-nums; font-family: var(--mono); }+ .pill { display: inline-flex; align-items: center; gap: 7px; padding: 2px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 11.5px; color: var(--muted); font-family: var(--body); }+ .pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }+ .pill.playing { color: var(--teal); border-color: var(--teal); }+ .pill.playing::before { background: var(--led-green); }+ .pill.buffering::before, .pill.paused::before { background: var(--led-amber); }+ .pill.error { color: var(--led-red); border-color: var(--led-red); }+ .pill.error::before { background: var(--led-red); } - /* ---- replayable history ---- */- .history {- margin-top: 16px; padding: 12px 14px; border-radius: 12px;- background: rgba(0,0,0,0.22); border: 1px solid var(--border);- max-height: 190px; overflow-y: auto;- }- .history.hidden { display: none; }- .hist-label { font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; opacity:.9; }- .hist-hint { text-transform: none; letter-spacing: 0; color: var(--muted); font-size: 10.5px; margin-left: 8px; }- .hist-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-top: 1px solid rgba(43,58,82,.5); }+ .clip-fresh { align-self: flex-start; margin: 14px 0 0; font-size: 12px; font-family: var(--mono); padding: 3px 10px; border-radius: 999px;+ white-space: nowrap; cursor: default; display: inline-flex; align-items: center; gap: 6px;+ color: var(--teal); border: 1px solid rgba(0,184,177,.4); background: var(--teal-soft); }+ .clip-fresh svg.ic { width: 14px; height: 14px; }+ .clip-fresh.warn { color: var(--led-amber); border-color: rgba(210,153,34,.5); background: rgba(210,153,34,.14); }+ .clip-fresh.stale { color: var(--led-red); border-color: rgba(248,81,73,.6); background: rgba(248,81,73,.16); font-weight: 600; }+ .clip-fresh[hidden] { display: none; }++ .transcript { margin-top: 12px; padding: 16px 18px; border-radius: 10px; background: var(--bg); border: 1px solid var(--line);+ font-size: 16px; line-height: 1.75; max-height: 260px; overflow-y: auto; color: var(--muted); max-width: 90ch; }+ .transcript .label, .hist-label { display: block; font-size: 12px; color: var(--teal); margin-bottom: 8px; font-family: var(--sans); font-weight: 600; }+ .transcript w { transition: color .12s, background .12s; border-radius: 5px; padding: 0 1px; }+ .transcript w.spoken { color: var(--text); }+ .transcript w.active { color: #04201f; background: var(--teal); padding: 1px 4px; margin: 0 -3px; font-weight: 600; }+ .transcript:empty, .transcript[hidden] { display: none; }++ .history { margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }+ .history[hidden] { display: none; }+ .hist-hint { color: var(--muted); font-size: 11px; margin-left: 8px; font-family: var(--body); font-weight: 400; }+ .hist-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); } .hist-row:first-of-type { border-top: 0; }- .hist-thread { flex: none; font-size: 10.5px; color: var(--purple); border: 1px solid rgba(140,107,247,0.45); background: rgba(140,107,247,0.10); padding: 1px 7px; border-radius: 999px; white-space: nowrap; max-width: 34%; overflow: hidden; text-overflow: ellipsis; }- .hist-desc { flex: 1; min-width: 0; font-size: 12.5px; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }- .hist-desc.missed { color: var(--amber); }- .hist-when { flex: none; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }- .hist-replay { flex: none; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--elevated); color: var(--teal); cursor: pointer; font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: background .12s, transform .08s; }+ .hist-thread { flex: none; font-size: 10.5px; color: var(--purple); border: 1px solid rgba(140,107,247,.45); background: rgba(140,107,247,.10); padding: 1px 7px; border-radius: 999px; white-space: nowrap; max-width: 34%; overflow: hidden; text-overflow: ellipsis; }+ .hist-desc { flex: 1; min-width: 0; font-size: 12.5px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+ .hist-desc.missed { color: var(--led-amber); }+ .hist-when { flex: none; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; font-family: var(--mono); }+ .hist-replay { flex: none; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface2); color: var(--teal); cursor: pointer;+ display: flex; align-items: center; justify-content: center; padding: 0; transition: background .12s, transform .08s; }+ .hist-replay svg.ic { width: 15px; height: 15px; } .hist-replay:hover { background: var(--teal-soft); } .hist-replay:active { transform: scale(.9); }+ .diag { margin-top: 12px; font-size: 10.5px; color: var(--muted); font-family: var(--mono); opacity: .6; word-break: break-all; }+ footer { padding: 10px var(--gutter); text-align: center; font-size: 11px; color: var(--muted); opacity: .7; flex: none; } - .diag { margin-top: 14px; font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; opacity: .6; word-break: break-all; }- footer { padding: 12px; text-align: center; font-size: 11px; color: var(--muted); opacity: .6; }-- /* ============================================================- RESPONSIVE — kept LAST so these win over the base rules above.- The webview is often a skinny 20% column, so below 620px we drop- the tagline, let the filename take its own row, and — most importantly- — stretch the player so the transcript FILLS the tall empty space- instead of floating in a small centered card with wasted margin.- ============================================================ */- @media (max-width: 620px){- .app-subtitle, .brand .sep { display: none; } /* row 1: brand + version + status + LED */- .subject { order: 3; flex-basis: 100%; } /* filename drops to its own row if tight */- .actions { margin-left: auto; }+ /* ============ Toasts (toasts skill: bottom center, newest on top) ============ */+ .toasts { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: var(--z-toast); display: flex; flex-direction: column-reverse; gap: 8px; width: min(440px, calc(100vw - 32px)); pointer-events: none; }+ .toast { pointer-events: auto; display: flex; align-items: center; gap: 10px; background: var(--surface2); border: 1px solid var(--line); border-left: 3px solid var(--teal);+ border-radius: 10px; padding: 10px 12px; font-size: 13px; color: var(--text); box-shadow: 0 12px 36px rgba(0,0,0,.5); animation: toastin .18s ease; }+ .toast.success { border-left-color: var(--led-green); }+ .toast.error { border-left-color: var(--led-red); }+ .toast svg.ic { width: 16px; height: 16px; color: var(--teal); }+ .toast.success svg.ic { color: var(--led-green); }+ .toast.error svg.ic { color: var(--led-red); }+ .toast .tx { flex: 1; min-width: 0; }+ .toast .x { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 2px; display: flex; }+ @keyframes toastin { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }++ /* ============ Settings modal (settings-layout skill) ============ */+ .modal { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; align-items: center; justify-content: center; }+ .modal[hidden] { display: none; }+ .modal .backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.45); }+ .dialog { position: relative; width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow-y: auto;+ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; box-shadow: 0 24px 60px rgba(0,0,0,.6); }+ .dialog h2 { margin: 0 0 4px; font-family: var(--sans); font-weight: 600; font-size: 17px; }+ .dialog .sub { margin: 0 0 14px; color: var(--muted); font-size: 12.5px; }+ .dialog .close { position: absolute; top: 12px; right: 12px; }+ .card { background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; }+ .card h3 { margin: 0 0 4px; font-size: 13.5px; font-weight: 600; font-family: var(--sans); }+ .card p { margin: 0 0 10px; font-size: 12.5px; color: var(--muted); line-height: 1.45; }+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }+ .seg button { border: 0; background: transparent; color: var(--muted); padding: 7px 14px; font-size: 13px; cursor: pointer; min-height: 36px; }+ .seg button + button { border-left: 1px solid var(--line); }+ .seg button.is-on, .seg button[aria-pressed="true"] { background: var(--teal-soft); color: var(--teal); box-shadow: inset 0 0 0 1px var(--teal); }+ .kv { display: flex; gap: 10px; font-size: 12px; margin: 3px 0; }+ .kv span:first-child { color: var(--muted); width: 90px; flex: none; font-family: var(--mono); font-size: 11px; }+ .kv span:last-child { font-family: var(--mono); font-size: 11.5px; overflow-wrap: anywhere; }+ .btn { border: 1px solid var(--line); background: var(--surface2); color: var(--text); border-radius: 8px; padding: 7px 12px; font-size: 13px; cursor: pointer; min-height: 36px; }+ .btn:hover { border-color: var(--teal); color: var(--teal); }+ .dialog .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }++ /* ============ Tooltip layer (tooltips skill) ============ */+ #global-tooltip { position: fixed; z-index: var(--z-tooltip); top: 0; left: 0; transform: translate(var(--tip-x,0), var(--tip-y,0));+ max-width: min(320px, calc(100vw - 40px)); pointer-events: none; opacity: 0; display: none;+ background: var(--surface2); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 9px 13px;+ font: 400 13px/1.4 var(--body); box-shadow: 0 12px 40px rgba(0,0,0,.55); text-transform: none; letter-spacing: 0; }+ #global-tooltip.visible { display: block; }+ #global-tooltip.shown { opacity: 1; transition: opacity .18s ease .6s; }++ /* ============ Responsive (responsive-ui): reshape, never squeeze ============ */+ @media (max-width: 720px) {+ .app-subtitle, .subject-meta { display: none; }+ }+ @media (max-width: 620px) { .vsep { display: none; }- main { padding: 8px; align-items: stretch; } /* fill width, don't center a tiny card */- .player { width: 100%; border-radius: 12px; padding: 14px 15px 12px; box-shadow: none; }- .transcript { flex: 1 1 auto; max-height: none; margin-top: 12px; padding: 14px 16px; font-size: 15.5px; line-height: 1.7; }- footer { display: none; } /* reclaim the footer line for content */+ /* the chip collapses to its LED so the clip name keeps the row */+ .target-chip .lbl, .target-chip b { display: none; }+ .target-chip { padding: 3px 6px; margin-right: 2px; }+ .player { flex: 1; padding: 14px 14px 12px; }+ .transcript { flex: 1 1 auto; max-height: none; font-size: 15.5px; line-height: 1.7; }+ footer { display: none; } }- @media (max-width: 400px){- .actions { flex-basis: 100%; justify-content: flex-end; margin-left: 0; }+ @media (max-width: 400px) {+ .queue-chip { display: none; } .transport { gap: 12px; } .play { width: 48px; height: 48px; }- .transcript { font-size: 15px; }+ }+ @media (hover: none), (pointer: coarse) {+ .ibtn, .hist-replay { min-width: 44px; min-height: 44px; } } </style> </head> <body>- <header>- <!-- Zone 1: BRAND (hover → app-info popover) -->+ <!-- Inline icon sprite: line drawings, currentColor, stroke 1.8 (icons skill). -->+ <svg width="0" height="0" style="position:absolute" aria-hidden="true">+ <symbol id="i-mark" viewBox="0 0 24 24"><path d="M4 9.5h3.2L12 5.5v13l-4.8-4H4z"/><path d="M15.5 9.2a4 4 0 0 1 0 5.6"/><path d="M18.4 6.6a8 8 0 0 1 0 10.8"/></symbol>+ <symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></symbol>+ <symbol id="i-pause" viewBox="0 0 24 24"><path d="M8 5v14"/><path d="M16 5v14"/></symbol>+ <symbol id="i-replay" viewBox="0 0 24 24"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4.5v5h5"/></symbol>+ <symbol id="i-warn" viewBox="0 0 24 24"><path d="M12 3.5 2.5 20h19z"/><path d="M12 9.5v4.5"/><path d="M12 17.2h.01"/></symbol>+ <symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7"/></symbol>+ <symbol id="i-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.6a2.4 2.4 0 1 1 3.4 2.2c-.7.4-1 .9-1 1.7"/><path d="M12 16.8h.01"/></symbol>+ <symbol id="i-cog" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>+ <symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12"/><path d="M18 6L6 18"/></symbol>+ <symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 7.8h.01"/></symbol>+ </svg>++ <header class="dash"> <div class="brand" id="brand" tabindex="0">- <span class="eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>- <span class="app-name">adom-tts</span>- <span class="ver" title="running build">v{{VERSION}}</span>- <span class="sep">·</span>+ <svg class="ic mark" aria-hidden="true"><use href="#i-mark"/></svg>+ <span class="app-name">Adom TTS</span> <span class="app-subtitle">your AI's voice</span>- <div class="popover" id="appinfo" role="dialog" aria-label="About adom-tts">- <div class="po-title">adom<b>-tts</b> <span class="po-ver">v{{VERSION}}</span></div>- <div class="po-desc">Adom's shared voice service — one player, one queue, shared by every AI thread. Reads your AI's answers aloud and renders demo voiceovers.</div>- <div class="po-row"><span class="po-k">engine</span><span class="po-v">service-tts · edge-tts · en-US-AndrewNeural</span></div>- <div class="po-row"><span class="po-k">license</span><span class="po-v">MIT · open source</span></div>- <div class="po-row"><span class="po-k">author</span><span class="po-v">John Lauer · Adom Industries</span></div>- <a class="po-link" href="https://wiki.adom.inc/adom/adom-tts" target="_blank" rel="noopener">View on the Adom Wiki ↗</a>+ <div class="popover" id="appinfo" role="dialog" aria-label="About Adom TTS">+ <div class="po-title">Adom TTS <span class="po-ver" id="poVer">v{{VERSION}}</span></div>+ <div class="po-desc">Adom's shared voice service: one player, one queue, shared by every AI thread. Reads your AI's answers aloud and renders demo voiceovers.</div>+ <div class="po-row"><span class="po-k">player</span><span class="po-v" id="poPlayer">v{{VERSION}}</span></div>+ <div class="po-row"><span class="po-k">engine</span><span class="po-v">service-tts, edge-tts, en-US-AndrewNeural</span></div>+ <div class="po-row"><span class="po-k">license</span><span class="po-v">MIT, open source</span></div>+ <div class="po-row"><span class="po-k">author</span><span class="po-v">John Lauer, Adom Industries</span></div>+ <a class="po-link" href="https://wiki.adom.inc/adom/adom-tts" target="_blank" rel="noopener">View on the Adom Wiki</a> </div> </div>- <div class="vsep" aria-hidden="true"></div>-- <!-- Zone 2: SUBJECT (what's playing right now); hover/tap for the full card --> <div class="subject" id="subject" tabindex="0">- <span class="subject-name" id="subjectName">waiting for a clip…</span>+ <span class="subject-name" id="subjectName">waiting for a clip</span> <span class="subject-meta" id="subjectMeta"></span> <div class="subject-pop" id="subjectPop" role="tooltip">- <div class="sp-desc" id="spDesc">No clip yet — the next `say --play` from any thread lands here.</div>- <div class="sp-row"><span class="sp-k">thread</span><span class="sp-v" id="spThread">—</span></div>- <div class="sp-row"><span class="sp-k">created</span><span class="sp-v" id="spCreated">—</span></div>- <div class="sp-row"><span class="sp-k">file</span><span class="sp-v mono" id="spFile">—</span></div>- <div class="sp-row"><span class="sp-k">voice</span><span class="sp-v mono" id="spVoice">—</span></div>+ <div class="sp-desc" id="spDesc">No clip yet. The next `say --play` from any thread lands here.</div>+ <div class="sp-row"><span class="sp-k">thread</span><span class="sp-v" id="spThread">none</span></div>+ <div class="sp-row"><span class="sp-k">created</span><span class="sp-v" id="spCreated">none</span></div>+ <div class="sp-row"><span class="sp-k">file</span><span class="sp-v mono" id="spFile">none</span></div>+ <div class="sp-row"><span class="sp-k">voice</span><span class="sp-v mono" id="spVoice">none</span></div> </div> </div>-- <!-- Zone 3: ACTIONS / status + live-link LED (always visible, even skinny) --> <div class="actions">- <span id="queueChip" class="queue-chip hidden"></span>- <span id="threadChip" class="thread-chip" style="display:none"></span>- <span id="status" class="pill">idle</span>- <span id="live" class="led down" title="AI ↔ player link — green = live, red = disconnected"></span>+ <span id="queueChip" class="queue-chip" hidden></span>+ <span class="target-chip" id="targetChip" data-tooltip="Where clips play. The dot is the page's live link to the player: green means polling, red means this tab lost it.">+ <i class="dot" id="live"></i><span class="lbl">target:</span> <b>this machine</b>+ </span>+ <button class="ibtn" id="help-btn" type="button" aria-label="Show the starter phrases" aria-pressed="false" data-tooltip="Show the starter phrases and the demo">+ <svg class="ic" aria-hidden="true"><use href="#i-help"/></svg>+ </button>+ <button class="ibtn" id="settings-btn" type="button" aria-label="Settings" data-tooltip="Settings: choose where clips play by default">+ <svg class="ic" aria-hidden="true"><use href="#i-cog"/></svg>+ </button> </div> </header> - <main>- <div class="player">+ <div class="page">+ <div id="newbie"></div>++ <div class="leds" id="leds" aria-label="Status">+ <button class="led-pill" type="button" data-key="player" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Player</span><span class="st">checking</span></button>+ <button class="led-pill" type="button" data-key="surface" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Surface</span><span class="st">checking</span></button>+ <button class="led-pill" type="button" data-key="service" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Voice service</span><span class="st">checking</span></button>+ <button class="led-pill" type="button" data-key="queue" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Queue</span><span class="st">checking</span></button>+ </div>+ <div id="led-pop" role="dialog"></div>++ <section class="player" aria-label="Player"> <div class="transport">- <button id="play" class="play" aria-label="Play">- <svg id="playicon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>+ <button id="play" class="play" type="button" aria-label="Play" data-tooltip="Play or pause. Pausing holds the whole queue until you press play again.">+ <svg class="ic" id="ic-play" aria-hidden="true"><use href="#i-play"/></svg>+ <svg class="ic" id="ic-pause" aria-hidden="true" hidden><use href="#i-pause"/></svg>+ <svg class="ic" id="ic-replay" aria-hidden="true" hidden><use href="#i-replay"/></svg> </button> <div class="seekwrap">- <input id="seek" class="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="Seek">- <div class="times"><span id="cur">0:00</span><span id="dur">0:00</span></div>+ <input id="seek" class="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="Seek" data-tooltip="Drag to seek inside this clip">+ <div class="times"><span id="cur">0:00</span><span id="status" class="pill">idle</span><span id="dur">0:00</span></div> </div> </div> - <!-- CLIP FRESHNESS: when THIS clip's audio was synthesized + how long ago,- right by the transcript so you always know if you're hearing old news.- Teal fresh · amber hours · red days. Hover for the exact timestamp. -->- <div id="clipFreshness" class="clip-fresh" data-tooltip="" style="display:none"></div>-- <div id="transcript" class="transcript hidden"></div>-- <!-- Replayable history: the human misses clips (dead zones, back-to-back- updates) — every played clip is listed with WHO sent it and WHAT it- says, with one-tap replay. -->- <div id="histwrap" class="history hidden">- <div class="hist-label">History <span class="hist-hint">tap ↻ to replay</span></div>+ <div id="clipFreshness" class="clip-fresh" hidden><svg class="ic" id="freshIcon" aria-hidden="true" hidden><use href="#i-warn"/></svg><span id="freshText"></span></div>+ <div id="transcript" class="transcript" hidden></div>+ <div id="histwrap" class="history" hidden>+ <span class="hist-label">History <span class="hist-hint">tap the arrow to hear a clip again</span></span> <div id="histlist"></div> </div>- <div class="diag" id="diag"></div>+ </section>+ </div>+ <footer>Adom TTS shared player. One queue across all AI threads. Closes itself after a couple of minutes idle.</footer>++ <div class="toasts" id="toasts" aria-live="polite"></div>++ <div class="modal" id="settings" hidden>+ <div class="backdrop" id="settingsBackdrop"></div>+ <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">+ <button class="ibtn close" id="settingsClose" type="button" aria-label="Close settings"><svg class="ic" aria-hidden="true"><use href="#i-x"/></svg></button>+ <h2 id="settingsTitle">Settings</h2>+ <p class="sub">Saved to <span id="setPath" class="mono"></span>. The same values as <code>adom-tts settings</code>.</p>+ <div class="card">+ <h3>Playback surface</h3>+ <p>Where new clips open. Webview is a tab inside Hydrogen. Pup is a separate desktop browser window on your box. A change takes effect on the next player spawn (run <code>adom-tts restart</code> when nothing is playing).</p>+ <div class="seg" role="group" aria-label="Playback surface">+ <button type="button" data-surface="hydrogen" aria-pressed="false">Webview</button>+ <button type="button" data-surface="pup" aria-pressed="false">Pup</button>+ </div>+ <div id="setLive" class="kv" style="margin-top:10px"><span>live now</span><span id="setLiveVal">unknown</span></div>+ </div>+ <div class="card">+ <h3>Pup window</h3>+ <p>Used only when the surface is Pup. Change them with <code>adom-tts settings set pup_session <name></code>.</p>+ <div class="kv"><span>session</span><span id="setSession"></span></div>+ <div class="kv"><span>profile</span><span id="setProfile"></span></div>+ </div>+ <div class="row">+ <button class="btn" id="settingsDefaults" type="button" data-tooltip="Back to Webview, the reliable default">Restore defaults</button>+ <button class="btn" id="settingsDone" type="button">Done</button>+ </div> </div>- </main>- <footer>adom-tts shared player · one queue across all AI threads · auto-closes after a couple minutes idle</footer>+ </div> <audio id="a" preload="auto"></audio>- <!-- ONE body-appended tooltip layer (adom-ui-design/tooltips): position:fixed,- z-index topmost, cursor-anchored, edge-clamped. Never a ::after pseudo. --> <div id="global-tooltip"></div> +<script src="newbie-panel.js"></script> <script> // PERSISTENT PLAYER. The page loads ONCE and stays put. It polls /poll for- // the current clip id; when a NEW clip appears it swaps <audio>.src to- // /audio?id=<id> and plays — so clips NEVER trigger a tab re-navigation- // (the old new-port-per-clip race that caused "opened but silent"). One- // server owns this page + the queue and knows the true playback state.- const APP_NAME = "adom-tts";+ // the current clip id; when a NEW clip appears it swaps <audio>.src and+ // plays, so clips never trigger a tab re-navigation. One server owns this+ // page, the queue, and the true playback state.+ window.__ttsBoot = { complete: false, failed: [], version: "{{VERSION}}", panelMounted: false };+ function region(name, fn){ try { fn(); } catch (e) { window.__ttsBoot.failed.push(name + ': ' + (e && e.message || e)); try { showToast('Page region failed: ' + name, 'error', true); } catch (_) {} } }+ const a = document.getElementById('a');- a.loop = false; // never auto-repeat a clip — one play, then stop+ a.loop = false; const statusEl = document.getElementById('status'); const subjectName = document.getElementById('subjectName'); const meta = document.getElementById('subjectMeta');- const threadChip = document.getElementById('threadChip'); const queueChip = document.getElementById('queueChip'); const diag = document.getElementById('diag'); const playBtn = document.getElementById('play');- // NOTE: the <path> inside the svg — setting `d` on the <svg> itself is a- // silent no-op, which kept the button stuck on ▶ forever (real bug).- const playIcon = document.querySelector('#playicon path'); const seek = document.getElementById('seek'); const curEl = document.getElementById('cur'); const durEl = document.getElementById('dur'); const transcript = document.getElementById('transcript');- const ICON_PLAY = "M8 5v14l11-7z", ICON_PAUSE = "M6 5h4v14H6zM14 5h4v14h-4z", ICON_REPLAY = "M12 5V1L7 6l5 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z"; const MEDIA_ERR = {1:"ABORTED",2:"NETWORK",3:"DECODE",4:"SRC_NOT_SUPPORTED"}; let lastErr = "", seeking = false;-- // ---- current clip state ----- let curClipId = 0; // id of the clip the page INTENDS to play (set on arrival)- // SCRIPT MARKERS (issue #4): segments prefetched at load time, keyed- // "<script>#<n>" -> blob URL, so a cue is "seek and play", never a download.+ let curClipId = 0; const prefetched = {}; const prefetching = {};- let loadedClipId = 0; // id whose audio the <audio> element ACTUALLY holds.- // Telemetry is only valid when these agree — see applyClip.- let wantPlay = false; // we just swapped src and intend to auto-play it- let metaBase = ""; // clip meta line before we append duration+ let loadedClipId = 0;+ let wantPlay = false;+ let metaBase = ""; function fmt(t){ if(!isFinite(t))return "0:00"; const m=Math.floor(t/60),s=String(Math.floor(t%60)).padStart(2,'0'); return m+':'+s; } function setStatus(label, cls){ statusEl.textContent = label; statusEl.className = 'pill ' + (cls||''); }- function setIcon(kind){ playIcon.setAttribute('d', kind==='pause'?ICON_PAUSE:kind==='replay'?ICON_REPLAY:ICON_PLAY); playBtn.setAttribute('aria-label', kind==='pause'?'Pause':kind==='replay'?'Replay':'Play'); document.body.classList.toggle('is-playing', kind==='pause'); }- function updateDiag(){ const e=a.error?`err=${MEDIA_ERR[a.error.code]||a.error.code}`:'err=none'; diag.textContent=`clip#${curClipId} ready=${a.readyState} net=${a.networkState} ${e}${lastErr?' · '+lastErr:''}`; }+ // SVG elements have no `.hidden` property: toggle the ATTRIBUTE (the CSS keys on it).+ function hideEl(el, yes){ if (yes) el.setAttribute('hidden', ''); else el.removeAttribute('hidden'); }+ function setIcon(kind){+ ['play','pause','replay'].forEach(k => hideEl(document.getElementById('ic-' + k), k !== kind));+ playBtn.setAttribute('aria-label', kind==='pause'?'Pause':kind==='replay'?'Replay':'Play');+ document.body.classList.toggle('is-playing', kind==='pause');+ }+ function updateDiag(){ const e=a.error?`err=${MEDIA_ERR[a.error.code]||a.error.code}`:'err=none'; diag.textContent=`clip#${curClipId} ready=${a.readyState} net=${a.networkState} ${e}${lastErr?' '+lastErr:''}`; } function beacon(p){ fetch(p,{cache:'no-store'}).catch(()=>{}); } function ev(name, extra){ const buf = a.buffered.length ? a.buffered.end(a.buffered.length-1).toFixed(1) : '0';@@ -382,13 +410,34 @@ ['loadstart','durationchange','loadeddata','canplay','canplaythrough','play', 'waiting','stalled','suspend','abort','emptied'].forEach(n => a.addEventListener(n, () => ev(n))); - // ---- karaoke transcript (rebuilt per clip) ----+ // ---- toasts (toasts skill): bottom center, about 4s, errors stick ----+ function showToast(text, kind, sticky){+ const wrap = document.getElementById('toasts');+ const t = document.createElement('div'); t.className = 'toast ' + (kind || 'info');+ const icon = kind === 'success' ? '#i-check' : kind === 'error' ? '#i-warn' : '#i-info';+ t.innerHTML = '<svg class="ic" aria-hidden="true"><use href="' + icon + '"/></svg><span class="tx"></span><button class="x" type="button" aria-label="Dismiss"><svg class="ic" aria-hidden="true"><use href="#i-x"/></svg></button>';+ t.querySelector('.tx').textContent = text;+ t.querySelector('.x').addEventListener('click', () => t.remove());+ wrap.appendChild(t);+ while (wrap.children.length > 4) wrap.firstChild.remove();+ if (!sticky && kind !== 'error') {+ let left = 4000, timer = null, last = Date.now();+ const arm = () => { last = Date.now(); timer = setTimeout(() => t.remove(), left); };+ t.addEventListener('mouseenter', () => { clearTimeout(timer); left -= (Date.now() - last); });+ t.addEventListener('mouseleave', arm);+ arm();+ }+ return t;+ }+ window.showToast = showToast;++ // ---- karaoke transcript ---- let spans = [], bounds = [], activeIdx = -1; function buildTranscript(words){ spans = []; bounds = []; activeIdx = -1; transcript.innerHTML = ''; if (Array.isArray(words) && words.length) {- transcript.classList.remove('hidden');+ transcript.hidden = false; const lbl = document.createElement('span'); lbl.className='label'; lbl.textContent='Transcript'; transcript.appendChild(lbl); let weights = words.map(w => w.length + 1), total = weights.reduce((x,y)=>x+y,0) || 1, acc = 0; words.forEach((w, i) => {@@ -398,7 +447,7 @@ transcript.appendChild(el); transcript.appendChild(document.createTextNode(' ')); }); } else {- transcript.classList.add('hidden');+ transcript.hidden = true; } } function highlight(frac){@@ -411,17 +460,10 @@ if (spans[idx]) spans[idx].scrollIntoView({ block:'nearest', behavior:'smooth' }); } - // ---- apply a new clip (swap source, no page reload) ----- // HOTSPOT-PROOF: download the ENTIRE mp3 first, then play from a local blob.- // The listener is often on a cellular hotspot that drops for seconds at a- // time (driving) — streaming used to stall clips mid-sentence; a local blob- // keeps playing through any drop once the download lands.+ // ---- freshness: when THIS clip's audio was made, so old news is obvious ---- let blobUrl = null;- // ---- freshness display (John's ask: show date/time so an ancient clip is- // obvious). created_ms = when the AUDIO was synthesized (mp3 mtime), which- // catches a weeks-old clip re-queued today that the enqueue-time TTL misses. let lastServerNow = Date.now();- let curCreatedMs = 0; // synth time of the CURRENT clip, so the age can re-tick+ let curCreatedMs = 0; function fmtAgo(ms){ const s = Math.max(0, Math.floor(ms/1000)); if (s < 45) return 'just now';@@ -430,39 +472,34 @@ const h = Math.floor(m/60); if (h < 36) return h + 'h ago'; const d = Math.floor(h/24); return d + ' day' + (d===1?'':'s') + ' ago'; }- // Show, by the transcript, WHEN this clip's audio was made + how long ago,- // color-coded so old news is unmissable. Full timestamp lives in the hover- // tooltip (and the subject card). You should never wonder if a clip is fresh. function setClipAge(created, now){ const el = document.getElementById('clipFreshness');+ const txt = document.getElementById('freshText');+ const icon = document.getElementById('freshIcon'); const sp = document.getElementById('spCreated'); const tr = document.getElementById('transcript'); curCreatedMs = created || 0;- if (!created){ el.style.display='none'; el.removeAttribute('data-tooltip'); if (tr) tr.removeAttribute('data-tooltip'); if (sp) sp.textContent = '—'; return; }+ if (!created){ el.hidden = true; el.removeAttribute('data-tooltip'); if (tr) tr.removeAttribute('data-tooltip'); if (sp) sp.textContent = 'none'; return; } const age = Math.max(0, (now || lastServerNow) - created);- const abs = new Date(created).toLocaleString(undefined,- {weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit'});+ const abs = new Date(created).toLocaleString(undefined, {weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit'}); const ago = fmtAgo(age);- if (sp) sp.textContent = abs + ' · ' + ago;- el.style.display = '';+ if (sp) sp.textContent = abs + ', ' + ago;+ el.hidden = false; el.className = 'clip-fresh'; const min = age / 60000;- if (min >= 1440){ el.classList.add('stale'); el.textContent = '⚠ audio from ' + ago; }- else if (min >= 60){ el.classList.add('warn'); el.textContent = 'audio from ' + ago; }- else { el.textContent = 'audio from ' + ago; }- // canonical hover tooltip (exact timestamp) on BOTH the freshness pill and- // the transcript, so hovering the clip anywhere reveals when it was made- const tip = 'This clip’s audio was synthesized ' + abs + ' — ' + ago- + (min >= 1440 ? '. That is old; a fresh answer would be re-synthesized, not replayed.' : '.');+ hideEl(icon, !(min >= 1440));+ if (min >= 1440){ el.classList.add('stale'); txt.textContent = 'audio from ' + ago; }+ else if (min >= 60){ el.classList.add('warn'); txt.textContent = 'audio from ' + ago; }+ else { txt.textContent = 'audio from ' + ago; }+ const tip = 'This clip’s audio was synthesized ' + abs + ', ' + ago+ + (min >= 1440 ? '. That is old: a fresh answer would be re-synthesized, not replayed.' : '.'); el.setAttribute('data-tooltip', tip); if (tr) tr.setAttribute('data-tooltip', tip); }- // keep the relative age honest without a new clip event (polls refresh- // lastServerNow every ~700ms, so this just re-renders the text)+ window.setClipAge = setClipAge; setInterval(function(){ if (curCreatedMs) setClipAge(curCreatedMs, lastServerNow); }, 10000); - // ---- global tooltip renderer (adom-ui-design/tooltips): one fixed div,- // cursor-anchored, edge-clamped, 600ms reveal via CSS transition delay ----+ // ---- global tooltip renderer (tooltips skill): one fixed div, edge-clamped ---- (function(){ const tip = document.getElementById('global-tooltip'); let _cx = 0, _cy = 0;@@ -494,9 +531,9 @@ tip.textContent = t.getAttribute('data-tooltip') || ''; if (!tip.textContent) return; tip.classList.add('visible');- const a = pickAnchor(t, tip.offsetWidth, tip.offsetHeight, _cx, _cy);- tip.style.setProperty('--tip-x', a.x + 'px');- tip.style.setProperty('--tip-y', a.y + 'px');+ const p = pickAnchor(t, tip.offsetWidth, tip.offsetHeight, _cx, _cy);+ tip.style.setProperty('--tip-x', p.x + 'px');+ tip.style.setProperty('--tip-y', p.y + 'px'); requestAnimationFrame(() => tip.classList.add('shown')); } function hide(){ tip.classList.remove('visible', 'shown'); }@@ -504,43 +541,31 @@ const t = e.target.closest && e.target.closest('[data-tooltip]'); t ? show(t) : hide(); });+ window.__showTooltipFor = show; })();+ function applyClip(c){ curClipId = c.id;- // TELEMETRY MUST NEVER DESCRIBE THE PREVIOUS CLIP. `curClipId` moves the- // instant a new clip arrives, but the <audio> element keeps the OLD clip's- // `ended`/`currentTime` until the new blob finishes downloading. Polling in- // that window reported the NEW id with the OLD state (done=1, t=83.1), so- // the drainer marked the new clip finished on arrival and advanced — the- // human heard clip two get cut off ~3s in while its sender was told "ok".- // Two guards: clear `loadedClipId` so telemetry is withheld until the audio- // element truly holds THIS clip, and reset the element synchronously so- // stale ended/currentTime cannot leak through any other path.+ // Telemetry must never describe the previous clip: withhold it until the+ // <audio> element truly holds THIS clip, and reset the element now. loadedClipId = 0; try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) {}- // Subject = WHO is speaking (AI thread) + WHAT the clip says (--desc).- // The mp3 filename means nothing to a human — it lives in the tooltip only. subjectName.textContent = c.label || c.title || 'audio clip'; metaBase = c.desc ? c.desc : (c.meta || ''); meta.textContent = metaBase;- // rich tooltip card: the untruncated story of the current clip document.getElementById('spDesc').textContent = c.desc || '(no description sent)';- document.getElementById('spThread').textContent = c.label || '—';- document.getElementById('spFile').textContent = c.title || '—';- document.getElementById('spVoice').textContent = c.meta || '—';+ document.getElementById('spThread').textContent = c.label || 'none';+ document.getElementById('spFile').textContent = c.title || 'none';+ document.getElementById('spVoice').textContent = c.meta || 'none'; setClipAge(c.created_ms, c.now_ms || lastServerNow);- threadChip.style.display = 'none'; // thread now leads the subject zone buildTranscript(c.words || []);- // reset transport UI for the fresh clip lastErr = ''; seek.value = 0; curEl.textContent = '0:00'; durEl.textContent = '0:00'; _wpos = 0; _stalls = 0; setStatus('downloading', 'buffering'); setIcon('play'); wantPlay = true; const pk = (c.script && c.seg) ? (c.script + '#' + c.seg) : null; if (pk && prefetched[pk]) {- // CUE FAST PATH: the marker's audio was prefetched at load time — bind it- // and play now; no download, no buffering. Prefetched URLs are never- // revoked by the ordinary download path (blobUrl stays null here).+ // CUE FAST PATH: the marker's audio was prefetched at load time. blobUrl = null; a.src = prefetched[pk]; try { a.load(); } catch(e) {} loadedClipId = c.id;@@ -553,7 +578,7 @@ updateDiag(); } function downloadAndPlay(id, attempt){- if (id !== curClipId) return; // superseded while downloading+ if (id !== curClipId) return; fetch('audio?id=' + id, { cache: 'no-store' }) .then(r => { if (!r.ok) throw new Error('http ' + r.status); return r.blob(); }) .then(b => {@@ -563,24 +588,22 @@ ev('blobready', '&bytes=' + b.size); a.src = blobUrl; try { a.load(); } catch(e) {}- loadedClipId = id; // NOW the element's state describes THIS clip+ loadedClipId = id; tryPlay(); }) .catch(() => {- // hotspot blip mid-download — retry while this is still the clip if (id !== curClipId) return; ev('dlretry', '&att=' + (attempt + 1));- if (attempt < 8) { setStatus('reconnecting…', 'buffering'); setTimeout(() => downloadAndPlay(id, attempt + 1), 1600); }- else setStatus('download failed', 'error');+ if (attempt < 8) { setStatus('reconnecting', 'buffering'); setTimeout(() => downloadAndPlay(id, attempt + 1), 1600); }+ else { setStatus('download failed', 'error'); showToast('Download failed. Press play to try again, or run adom-tts reopen.', 'error'); } }); } // ---- single transport ---- function tryPlay(){ const p=a.play(); if(p&&p.catch) p.then(()=>{lastErr='';playBtn.classList.remove('attention');}).catch(showPlayError); }- function showPlayError(err){ lastErr=(err&&err.name||'PlayError')+(err&&err.message?': '+err.message:''); if(err&&err.name==='NotAllowedError'){setStatus('tap play ▶','paused');playBtn.classList.add('attention');setIcon('play');} else setStatus('play failed','error'); updateDiag(); ev('playreject','&msg='+encodeURIComponent(((err&&err.name)||'')+' '+((err&&err.message)||'').slice(0,60))); }+ function showPlayError(err){ lastErr=(err&&err.name||'PlayError')+(err&&err.message?': '+err.message:''); if(err&&err.name==='NotAllowedError'){setStatus('tap play','paused');playBtn.classList.add('attention');setIcon('play');} else setStatus('play failed','error'); updateDiag(); ev('playreject','&msg='+encodeURIComponent(((err&&err.name)||'')+' '+((err&&err.message)||'').slice(0,60))); } playBtn.addEventListener('click', () => { if (a.ended) { a.currentTime=0; tryPlay(); } else if (a.paused) tryPlay(); else a.pause(); }); - // tap toggles the subject card (hover covers mouse; this covers touch) var subjEl = document.getElementById('subject'); subjEl.addEventListener('click', function(e){ if (e.target.closest('.subject-pop')) return; subjEl.classList.toggle('open'); }); document.addEventListener('click', function(e){ if (!subjEl.contains(e.target)) subjEl.classList.remove('open'); });@@ -588,8 +611,7 @@ seek.addEventListener('input', () => { seeking = true; if (isFinite(a.duration)) { a.currentTime = (seek.value/1000)*a.duration; curEl.textContent = fmt(a.currentTime); } }); seek.addEventListener('change', () => { seeking = false; }); - // ---- audio events ----- a.addEventListener('loadedmetadata', () => { durEl.textContent = fmt(a.duration); if(isFinite(a.duration)){ meta.textContent=(metaBase?metaBase+' · ':'')+fmt(a.duration); beacon('meta?id='+curClipId+'&dur='+a.duration);} updateDiag(); });+ a.addEventListener('loadedmetadata', () => { durEl.textContent = fmt(a.duration); if(isFinite(a.duration)){ meta.textContent=(metaBase?metaBase+', ':'')+fmt(a.duration); beacon('meta?id='+curClipId+'&dur='+a.duration);} updateDiag(); }); a.addEventListener('canplay', () => { updateDiag(); if (wantPlay && a.paused && !a.ended && a.currentTime===0) tryPlay(); }); let lastBeacon = 0; a.addEventListener('timeupdate', () => {@@ -603,9 +625,6 @@ a.addEventListener('playing', () => { lastErr=''; wantPlay=false; setStatus('playing','playing'); setIcon('pause'); playBtn.classList.remove('attention'); ev('playing'); }); a.addEventListener('waiting', () => setStatus('buffering','buffering')); a.addEventListener('stalled', () => { setStatus('stalled','buffering'); updateDiag(); });- // A pause is a HUMAN action: cancel any pending auto-play intent so nothing- // (canplay retry, watchdog) un-pauses them. The server also sees paused=1 on- // the poll and HOLDS the whole queue until they press play again. a.addEventListener('pause', () => { if (!a.ended){ wantPlay = false; setStatus('paused','paused'); setIcon('play'); } }); a.addEventListener('ended', () => { setStatus('ended','ended'); setIcon('replay'); highlight(1); spans.forEach(el=>el.classList.add('spoken')); ev('ended'); beacon('done?id='+curClipId); }); a.addEventListener('error', () => {@@ -622,66 +641,135 @@ let _wpos = 0, _stalls = 0; setInterval(function(){ if (a.paused || a.ended || !isFinite(a.duration)) { _wpos = a.currentTime; return; }- if (a.currentTime > _wpos + 0.15) { _wpos = a.currentTime; _stalls = 0; return; } // healthy- if (a.readyState >= 3) return; // buffered enough; just a blip+ if (a.currentTime > _wpos + 0.15) { _wpos = a.currentTime; _stalls = 0; return; }+ if (a.readyState >= 3) return; _stalls++; if (_stalls <= 3) { ev('selfheal', '&att=' + _stalls);- setStatus('recovering…', 'buffering');+ setStatus('recovering', 'buffering'); var ct = a.currentTime; try { a.load(); a.currentTime = ct; } catch (e) {} a.play().catch(function(){}); } else if (_stalls === 4) {- setStatus('stalled — press play', 'error');+ setStatus('stalled, press play', 'error'); ev('selfheal-giveup', '&t=' + a.currentTime.toFixed(1)); } }, 2500); - // ---- replayable history drawer ----+ // ---- replayable history ---- const histwrap = document.getElementById('histwrap'); const histlist = document.getElementById('histlist'); let histKey = ''; function ago(ms){ const s=Math.max(0,Math.round(ms/1000)); if(s<60)return s+'s'; const m=Math.round(s/60); if(m<60)return m+'m'; return Math.round(m/60)+'h'; }+ function svgUse(id, cls){ const s = document.createElementNS('http://www.w3.org/2000/svg','svg'); s.setAttribute('class', cls || 'ic'); const u = document.createElementNS('http://www.w3.org/2000/svg','use'); u.setAttribute('href', id); s.appendChild(u); return s; } function renderHistory(items, nowMs){ const key = items.map(e => e.seq).join(',');- if (key === histKey) { // entries unchanged → just refresh the ago labels+ if (key === histKey) { const whens = histlist.querySelectorAll('.hist-when'); items.forEach((e,i)=>{ if(whens[i]) whens[i].textContent = ago(nowMs - (e.ts_ms||nowMs)); }); return; } histKey = key; histlist.innerHTML = '';- if (!items.length) { histwrap.classList.add('hidden'); return; }- histwrap.classList.remove('hidden');+ if (!items.length) { histwrap.hidden = true; return; }+ histwrap.hidden = false; items.forEach(e => { const row = document.createElement('div'); row.className = 'hist-row'; const th = document.createElement('span'); th.className = 'hist-thread'; th.textContent = e.label || '?'; row.appendChild(th); const d = document.createElement('span'); d.className = 'hist-desc'; d.textContent = e.desc || e.name || 'clip';- // a clip that never fully played is the one you MISSED — flag it if (e.verdict && e.verdict !== 'ok') { d.classList.add('missed'); d.textContent += ' (missed?)'; }- d.title = (e.label?e.label+' — ':'') + (e.desc||'') + (e.dur_s?(' · '+e.dur_s+'s'):'');+ d.setAttribute('data-tooltip', (e.label?e.label+': ':'') + (e.desc||'') + (e.dur_s?(', '+e.dur_s+'s'):'')); row.appendChild(d); const w = document.createElement('span'); w.className = 'hist-when'; w.textContent = ago(nowMs - (e.ts_ms||nowMs)); row.appendChild(w);- const b = document.createElement('button'); b.className = 'hist-replay'; b.title = 'Play this again'; b.textContent = '↻';- b.addEventListener('click', () => { b.textContent='…'; fetch('histplay?seq='+e.seq,{cache:'no-store'}).then(()=>{ b.textContent='✓'; setTimeout(()=>b.textContent='↻',1500); }).catch(()=>{ b.textContent='↻'; }); });+ const b = document.createElement('button'); b.className = 'hist-replay'; b.type = 'button';+ b.setAttribute('aria-label', 'Play this clip again'); b.setAttribute('data-tooltip', 'Play this clip again, right now, ahead of the queue');+ b.appendChild(svgUse('#i-replay'));+ b.addEventListener('click', () => {+ b.innerHTML = ''; b.appendChild(svgUse('#i-replay'));+ fetch('histplay?seq='+e.seq,{cache:'no-store'}).then(r => r.json()).then(j => {+ b.innerHTML = ''; b.appendChild(svgUse(j && j.ok ? '#i-check' : '#i-warn'));+ if (!(j && j.ok)) showToast('Could not replay that clip. Its audio file may be gone.', 'error');+ setTimeout(()=>{ b.innerHTML=''; b.appendChild(svgUse('#i-replay')); },1500);+ }).catch(()=>{ b.innerHTML=''; b.appendChild(svgUse('#i-replay')); showToast('The player did not answer. Run adom-tts restart.', 'error'); });+ }); row.appendChild(b); histlist.appendChild(row); }); } - // ---- live link + clip poll: one poll drives BOTH the LED heartbeat AND- // the clip swap. Each successful poll proves the front-end↔back-end pipe. ----+ // ---- status pill row (status-leds): SHORT state from the server, prose on tap ----+ const ledPop = document.getElementById('led-pop');+ let lastHealth = null;+ function renderLeds(h){+ lastHealth = h || null;+ document.querySelectorAll('.led-pill').forEach(p => {+ const k = p.getAttribute('data-key'); const sub = h && h[k];+ const st = p.querySelector('.st');+ if (!sub) { p.setAttribute('data-state', ''); st.textContent = 'checking'; return; }+ const led = sub.led === 'ok' ? 'ok' : sub.led === 'busy' ? 'busy' : sub.led === 'bad' ? 'bad' : '';+ p.setAttribute('data-state', led);+ st.textContent = sub.state || '';+ });+ }+ function ledDetail(pill){+ const k = pill.getAttribute('data-key'); const sub = lastHealth && lastHealth[k];+ ledPop.innerHTML = '';+ const text = sub ? (sub.detail || sub.state || '') : 'Not probed yet.';+ // render `code` spans for backticked commands+ text.split('`').forEach((part, i) => { const n = i % 2 ? document.createElement('code') : document.createTextNode(part); if (i % 2) { n.textContent = part; } ledPop.appendChild(n); });+ ledPop.classList.add('visible');+ const r = pill.getBoundingClientRect();+ const w = ledPop.offsetWidth, hgt = ledPop.offsetHeight;+ let x = r.left, y = r.bottom + 8;+ if (y + hgt > innerHeight - 8) y = Math.max(8, r.top - hgt - 8);+ x = Math.max(8, Math.min(x, innerWidth - w - 8));+ ledPop.style.left = x + 'px'; ledPop.style.top = y + 'px';+ }+ document.querySelectorAll('.led-pill').forEach(p => p.addEventListener('click', e => { e.stopPropagation(); ledDetail(p); }));+ document.addEventListener('click', e => { if (!ledPop.contains(e.target)) ledPop.classList.remove('visible'); });+ window.addEventListener('keydown', e => { if (e.key === 'Escape') { ledPop.classList.remove('visible'); closeSettings(); } });++ // ---- settings modal (settings-layout): the gear opens it, writes go to the CLI's config ----+ const settingsEl = document.getElementById('settings');+ let settingsState = null;+ function renderSettings(s){+ settingsState = s || {};+ document.getElementById('setPath').textContent = s.path || '~/.adom/tts/config.toml';+ document.getElementById('setSession').textContent = s.pup_session || '';+ document.getElementById('setProfile').textContent = s.pup_profile || '';+ document.getElementById('setLiveVal').textContent = s.live_surface ? (s.live_surface === 'hydrogen' ? 'webview' : s.live_surface) : 'no player running';+ document.querySelectorAll('.seg [data-surface]').forEach(b => {+ const on = b.getAttribute('data-surface') === s.surface;+ b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false');+ });+ }+ function openSettings(){+ settingsEl.hidden = false;+ fetch('settings', {cache:'no-store'}).then(r => r.json()).then(renderSettings).catch(() => showToast('Could not read settings from the player.', 'error'));+ document.getElementById('settingsDone').focus();+ }+ function closeSettings(){ settingsEl.hidden = true; }+ function saveSetting(key, value){+ return fetch('settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key, value }) })+ .then(r => r.json()).then(j => {+ if (j && j.ok) { renderSettings(j.settings); showToast('Saved: ' + key + ' = ' + (value === 'hydrogen' ? 'webview' : value) + (j.note ? '. ' + j.note : ''), 'success'); }+ else showToast('Could not save ' + key + ': ' + ((j && j.error) || 'unknown error'), 'error');+ }).catch(() => showToast('The player did not answer. Run adom-tts restart.', 'error'));+ }+ document.getElementById('settings-btn').addEventListener('click', openSettings);+ document.getElementById('settingsClose').addEventListener('click', closeSettings);+ document.getElementById('settingsDone').addEventListener('click', closeSettings);+ document.getElementById('settingsBackdrop').addEventListener('click', closeSettings);+ document.getElementById('settingsDefaults').addEventListener('click', () => saveSetting('surface', 'hydrogen'));+ document.querySelectorAll('.seg [data-surface]').forEach(b => b.addEventListener('click', () => {+ const v = b.getAttribute('data-surface'); if (settingsState && settingsState.surface === v) return; saveSetting('surface', v);+ }));++ // ---- live link + clip poll: one poll drives the LED, the pills, the toasts and the clip swap ---- const led = document.getElementById('live');- let lastCmdSeq = 0, lastPing = 0;+ let lastCmdSeq = 0, lastPing = 0, lastToastSeq = -1; function poll() {- // Telemetry rides ON the poll: after a hotspot gap the first poll through- // carries the true position/done state, so lost beacons can't produce a- // false "partial/no-playback" verdict for a clip that actually played.- // `loaded` tells the drainer whether the element's numbers describe the- // clip named by `id`. While a new clip is still downloading it is 0 and we- // send zeroed telemetry, so the previous clip's ended/position can never be- // attributed to the new clip (which used to kill it on arrival). const loaded = (loadedClipId !== 0 && loadedClipId === curClipId) ? 1 : 0; const q = 'poll?id=' + curClipId + '&loaded=' + loaded@@ -692,7 +780,13 @@ fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => { lastPing = Date.now(); if (!j) return;- if (j.now_ms) lastServerNow = j.now_ms; // keep the clip-age display honest+ if (j.now_ms) lastServerNow = j.now_ms;+ if (j.version) { document.getElementById('poVer').textContent = 'v' + j.version; document.getElementById('poPlayer').textContent = 'v' + j.version + ' (page built with v{{VERSION}})'; }+ if (j.health) renderLeds(j.health);+ if (typeof j.toast_seq === 'number') {+ if (lastToastSeq === -1) lastToastSeq = j.toast_seq; // never replay a toast raised before this page loaded+ else if (j.toast_seq > lastToastSeq && j.toast && j.toast.text) { lastToastSeq = j.toast_seq; showToast(j.toast.text, j.toast.kind || 'info'); }+ } if (Array.isArray(j.prefetch)) j.prefetch.forEach(pf => { const k = pf.script + '#' + pf.n; if (prefetched[k] || prefetching[k]) return;@@ -703,34 +797,69 @@ .catch(() => {}) .finally(() => { delete prefetching[k]; }); });- // queue depth chip- if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.classList.remove('hidden'); }- else queueChip.classList.add('hidden');+ if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.hidden = false; }+ else queueChip.hidden = true; renderHistory(j.history || [], j.now_ms || Date.now());- // NEW clip? swap without reloading the page. if (j.id && j.id !== curClipId) applyClip(j);- else if (!j.id && curClipId && a.ended) { subjectName.textContent = 'waiting for a clip…'; setClipAge(0); }- // control commands (replay/pause/play/toggle)+ else if (!j.id && curClipId && a.ended) { subjectName.textContent = 'waiting for a clip'; setClipAge(0); } if (j.seq > lastCmdSeq) { lastCmdSeq = j.seq; switch ((j.cmd || '').toLowerCase()) { case 'replay': a.currentTime = 0; tryPlay(); break; case 'play': tryPlay(); break; case 'pause': a.pause(); break;- case 'skip': a.pause(); wantPlay = false; break; // drainer already ended the clip; just stop the sound+ case 'skip': a.pause(); wantPlay = false; break; case 'toggle': if (a.paused) tryPlay(); else a.pause(); break; } }- }).catch(() => {}); // a failed poll leaves lastPing stale → LED ages to red+ }).catch(() => {}); }- setInterval(poll, 700);+ window.__ttsPoll = setInterval(poll, 700); poll(); setInterval(() => { const age = Date.now() - lastPing; const s = age < 2500 ? 'ok' : age < 6000 ? 'warn' : 'down';- led.className = 'led ' + s;- led.title = 'AI ↔ player link — ' + (s === 'ok' ? 'live' : s === 'warn' ? 'degraded' : 'disconnected (dead tab)');+ led.className = 'dot ' + s;+ document.getElementById('targetChip').setAttribute('data-tooltip', 'Where clips play. Live link to the player: '+ + (s === 'ok' ? 'polling.' : s === 'warn' ? 'degraded, polls are late.' : 'lost. The player closed or this tab is stale: run adom-tts reopen.')); }, 1000);++ // ---- the newbie panel: MOUNT the vendored module, never rebuild it ----+ region('newbie-panel', function(){+ const PHRASES = [+ 'with adom tts, read me your last answer',+ 'with adom tts, replay the last clip',+ 'with adom tts, what is queued right now',+ 'with adom tts, pause the player',+ 'with adom tts, play the demo'+ ];+ let tries = 0;+ (function mountWhenReady(){+ if (!window.AdomNewbiePanel) {+ if (++tries < 50) return setTimeout(mountWhenReady, 100);+ window.__ttsBoot.failed.push('newbie-panel: newbie-panel.js did not load');+ showToast('The starter panel did not load. Reload the page, or run adom-tts reopen.', 'error', true);+ return;+ }+ try { window.AdomNewbiePanel.mount({+ mount: '#newbie',+ app: 'adom tts',+ appName: 'Adom TTS',+ slug: 'adom-tts',+ toggleButton: '#help-btn',+ demo: { label: 'Play the demo', meta: '3 markers, about 40 seconds',+ onPlay: function(){+ fetch('demo', { method: 'POST' }).then(r => r.json()).then(j => {+ if (j && j.ok) showToast('Demo starting. Three short markers play in this player.', 'info');+ else showToast('Could not start the demo. Run adom-tts demo in a terminal.', 'error');+ }).catch(() => showToast('The player did not answer. Run adom-tts restart.', 'error'));+ } },+ phrases: PHRASES+ }); window.__ttsBoot.panelMounted = true; }+ catch (e) { window.__ttsBoot.failed.push('newbie-panel: ' + (e && e.message || e)); showToast('The starter panel failed to mount: ' + (e && e.message || e), 'error', true); }+ })();+ });+ window.__ttsBoot.complete = true; </script> </body> </html>
src/server.rs+9@@ -486,6 +486,15 @@ fn open_webview_tab(url: &str) -> Result<()> { /// reason is "audio is waiting/playing — come listen"; once the audio is done /// the flash is noise) and when the drainer closes. Belt to the --duration 600 /// suspenders on `alert set` (10-min hard cap even if no one clears).+/// Flash the tab: a clip is starting and the human should come listen. Set at+/// CLIP START (never at tab open): a dock click that opens an idle player must+/// not flash for ten minutes with nothing playing.+pub(crate) fn set_tab_alert(display_name: &str) {+ let _ = Command::new("adom-cli")+ .args(["hydrogen", "alert", "set", "--name", display_name, "--duration", "600"])+ .output();+}+ pub(crate) fn clear_tab_alert(display_name: &str) { let _ = Command::new("adom-cli") .args(["hydrogen", "alert", "clear", "--name", display_name])
tools/ralph-test.shtools/ui-shot.pyadded+116@@ -0,0 +1,116 @@+#!/usr/bin/env python3+"""UI ralph harness for the player page: drives a MUTED headless Chrome over raw+CDP (no puppeteer needed) against an isolated rig drainer and writes one PNG per+state. Never touches the human's player or any Hydrogen tab.++ python3 tools/ui-shot.py http://127.0.0.1:<rig-port>/ <outdir>++States captured: idle, clip (transcript + history + freshness pill), settings+modal, toasts, stale-audio pill, pill detail popover, corner tooltip, newbie+panel closed, and phone width (open and closed). Also prints the page's boot+marker and the newbie module's selfCheck so a half-booted page cannot pass.+"""+import asyncio, json, os, glob, socket, subprocess, sys, time, urllib.request+import websockets++URL, OUT = sys.argv[1], sys.argv[2]+os.makedirs(OUT, exist_ok=True)++def find_shell():+ cands = glob.glob(os.path.expanduser("~/.cache/puppeteer/chrome-headless-shell/*/chrome-headless-shell-linux64/chrome-headless-shell")) \+ + glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium_headless_shell-*/chrome-linux/headless_shell"))+ for c in ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome"]:+ if os.path.exists(c): cands.append(c)+ if not cands: sys.exit("ERROR: no headless chrome found. Hint: npx puppeteer browsers install chrome-headless-shell")+ return cands[0]++def free_port():+ s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p++port = free_port()+shell = find_shell()+proc = subprocess.Popen([shell, "--headless", "--no-sandbox", "--disable-gpu", "--mute-audio", "--hide-scrollbars",+ "--autoplay-policy=no-user-gesture-required", f"--remote-debugging-port={port}", "--window-size=1400,900", "about:blank"],+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)++def targets():+ for _ in range(50):+ try:+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=1) as r: return json.load(r)+ except Exception: time.sleep(0.2)+ sys.exit("ERROR: headless chrome never answered on its debug port")++async def main():+ page = next(t for t in targets() if t["type"] == "page")+ async with websockets.connect(page["webSocketDebuggerUrl"], max_size=64 * 1024 * 1024) as ws:+ seq = 0+ async def call(method, **params):+ nonlocal seq; seq += 1+ await ws.send(json.dumps({"id": seq, "method": method, "params": params}))+ while True:+ m = json.loads(await ws.recv())+ if m.get("id") == seq:+ if "error" in m: raise RuntimeError(f"{method}: {m['error']}")+ return m.get("result", {})+ async def js(expr):+ r = await call("Runtime.evaluate", expression=expr, awaitPromise=True, returnByValue=True)+ return r.get("result", {}).get("value")+ async def shot(name):+ r = await call("Page.captureScreenshot", format="png")+ import base64; open(os.path.join(OUT, name + ".png"), "wb").write(base64.b64decode(r["data"]))+ print("shot", name)+ async def size(w, h):+ await call("Emulation.setDeviceMetricsOverride", width=w, height=h, deviceScaleFactor=1, mobile=w < 600)++ await call("Page.enable"); await call("Runtime.enable")+ await size(1400, 900)+ await call("Page.navigate", url=URL); await asyncio.sleep(3.0)+ print("boot:", await js("JSON.stringify(window.__ttsBoot)"))+ print("panel selfCheck:", await js("(function(){try{var p=window.AdomNewbiePanel;if(!p)return 'no module';if(!p.selfCheck)return 'version '+p.version+' (no selfCheck)';var r=p.selfCheck();return JSON.stringify(r).slice(0,600);}catch(e){return 'threw '+e}})()"))+ await shot("01-idle")+ # a clip: the caller enqueued one; wait for the page to swap and render+ for _ in range(30):+ if await js("!!(document.getElementById('transcript') && !document.getElementById('transcript').hidden)"): break+ await asyncio.sleep(0.5)+ await asyncio.sleep(1.5)+ await shot("02-clip")+ print("icons while playing:", await js("JSON.stringify({play:document.getElementById('ic-play').hasAttribute('hidden'),pause:document.getElementById('ic-pause').hasAttribute('hidden'),freshWarnHidden:document.getElementById('freshIcon').hasAttribute('hidden'),status:document.getElementById('status').textContent})"))+ print("panel-open spacing:", await js("(function(){var n=document.getElementById('newbie').firstElementChild;var r=n?n.getBoundingClientRect():null;var h=document.querySelector('header.dash').getBoundingClientRect();return JSON.stringify({panelTop:r&&r.top,panelBottom:r&&r.bottom,headerBottom:h.bottom,ledsTop:document.getElementById('leds').getBoundingClientRect().top})})()"))+ # let the clip end so History renders its rows (replay icon check)+ await js("var a=document.getElementById('a'); if(isFinite(a.duration)) a.currentTime=Math.max(0,a.duration-0.4);"); await asyncio.sleep(3.0)+ await size(1400, 1150); await asyncio.sleep(0.3) # tall enough to show History under a full transcript+ await shot("02b-ended-history")+ await size(1400, 900)+ await js("document.getElementById('settings-btn').click()"); await asyncio.sleep(0.8)+ await shot("03-settings")+ await js("document.getElementById('settingsDone').click()")+ await js("showToast('Running test 3 of 8', 'info'); showToast('File downloaded', 'success'); showToast('Voice service did not answer. Run adom-tts health.', 'error')")+ await asyncio.sleep(0.4); await shot("04-toasts")+ await js("document.querySelectorAll('.toast .x').forEach(function(b){b.click()})")+ await js("setClipAge(Date.parse('2026-06-16T22:55:00'), Date.now())"); await asyncio.sleep(0.2)+ await shot("05-stale-pill")+ await js("document.querySelector('.led-pill[data-key=service]').click()"); await asyncio.sleep(0.3)+ await shot("06-pill-detail")+ await js("document.body.click()")+ # tooltip clamp test: cursor never moved, so the anchor is (0,0) and must clamp inside the viewport+ await js("__showTooltipFor(document.getElementById('targetChip'))"); await asyncio.sleep(1.0)+ print("tooltip rect:", await js("(function(){var r=document.getElementById('global-tooltip').getBoundingClientRect();return JSON.stringify({l:r.left,t:r.top,r:r.right,b:r.bottom,vw:innerWidth,vh:innerHeight,inside:(r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight)})})()"))+ await shot("07-tooltip")+ await js("document.getElementById('global-tooltip').className=''")+ await js("try{AdomNewbiePanel.close({remember:false})}catch(e){document.getElementById('help-btn').click()}"); await asyncio.sleep(0.5)+ await shot("08-panel-closed")+ print("gutter check (panel open/closed spacing under header):", await js("(function(){var l=document.getElementById('leds').getBoundingClientRect();var h=document.querySelector('header.dash').getBoundingClientRect();return JSON.stringify({ledsTop:l.top,headerBottom:h.bottom,ledsLeft:l.left})})()"))+ await size(390, 844); await asyncio.sleep(0.6)+ await shot("09-phone-closed")+ await js("try{AdomNewbiePanel.open({remember:false})}catch(e){document.getElementById('help-btn').click()}"); await asyncio.sleep(0.5)+ await shot("10-phone-open")+ print("phone overflow:", await js("JSON.stringify({scrollW:document.documentElement.scrollWidth, vw:innerWidth})"))+ await js("try{AdomNewbiePanel.close({remember:false})}catch(e){}")+ await size(2400, 1000); await asyncio.sleep(0.5)+ await shot("11-wide-closed")+ print("console errors:", await js("JSON.stringify((window.__ttsBoot||{}).failed)"))++try:+ asyncio.run(main())+finally:+ proc.terminate()
vendor/newbie-panel.jsadded+691@@ -0,0 +1,691 @@+/* Adom newbie panel - the ONE implementation every dock dashboard mounts.+ * Published in adom-ui-design. Do not fork it: fix it here and every app gets the fix.+ * Self-contained: no network, no deps, fonts embedded. Serve it next to your dashboard+ * and <script src="newbie-panel.js"></script>, or inline its text into your page.+ */+(function (root) {+ 'use strict';+ var CSS = `+@font-face{font-family:'Familjen Grotesk';font-style:normal;font-weight:400 700;font-display:swap;+ src:url(data:font/woff2;base64,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) format('woff2-variations');}+@font-face{font-family:'Satoshi';font-style:normal;font-weight:300 900;font-display:swap;+ src:url(data:font/woff2;base64,d09GMgABAAAAAKZcABQAAAAB88AAAKXjAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoN1G4G9GByXQj9IVkFSiCI/TVZBUjwGYD9TVEFUHCciAIokL0wRCAqC/AyComYLh3IAMIK3OAE2AiQDj2AEIAWONQecXFvLwXEGHWP7UQCz0m0IRKa6aKl/51XIzofGbYOid0Oi/ltVotw+Rdyt6kIdsG6y////PzepyJhJuWm7wQBgchVVUx6RUCm1ICnlo2rUMqHWJmRBxjzBjbqsGxINjWoWI2mn5nx0yuWOPBvav5AUQWMEZUPajUZrRaficHI5SaTFN8znggeWZz+oPZDbBtEWMaQha2z2rOHPm2kvmUcz5PTQqqDwEOJQf/F/jGmEwy/K1KOLVsz416xld2wQS9AkPwTnC+8IKjSlKy72WMy75WX0Faqi1KAdKhH0uUZUkfyCyxTpSsXePEogGsvd8Hd4kCSZNdwOGXNxpOiNcu86P2hjziMnJRU4RvSsSW+Iex3xpTTuAbckOCbSF098hBfJPqlWOH742M1wkqEB3yHd3M0v3KrQrfxI70YXVnolVfMpW4M1/Hh1/TTUNUxcnfcvIv4msQo/dJ9GV/byB6sp2av9sCfzwmmfpnQrDwG2yw+KkIsIohmWOOc8/ef5tXXu+zHDn6FHShxqwBEpdUiLVUClRQSWKAuLKiN3Fxur0XVxA12MxcZurIBVsEAweoanOf135knu4hcxoqgMQp2NfVNtoHOtOJWJV9ShnoIHjckdzWnwnsNGtx0I20lgudCHgX+e36961z73VU3NB8YAQ7cMAFtUUd8GZCSBCqtoYE0SoQb4ufXPwAKVirFoWCWrZmtgkcDoEBB10AIiIKKCgVgV1573zwbzc3oK5tXw76bPn9L6uisme27bE/WPH/u13u2a2OYtdcSKhCQQIyIGAQqFAgjgn4/79Nz3ZwKaiMRs41rHq5tvWoGSrdiOR7uBdb65ejPZ4MJnsr5O3PXV+Fog17qy+4RdymWzmAkMYSx0onZy1gs4FYREaQLkisswZc6wZVhyZZu77d/Q4R9i4tNzbPgLc2jcviojVsWRKmI+OFd2QHJCfd0DYf+IniilNHdp0muSlv+EGZAaqykUbnZqrKampqampjaypt0HWchR/CrkgJMZ8cIU4YrHVDRqfMArgbJbAIATLshk4MllcJ5/fr9v+4wjSSWZjVhCXEMl34dY81ChUsqEjIVijf6Xv7bHt9LWwTIBVxCCBc/7CzOBgvYtHEOVrnT1FYaEZ4qOLmAKowzoB3RmdAN6E3Mmgx8N/875f6BQxWRcmMHm3F0VAtueqH38P6cUttEivkJFk5qlkVrqaI0a4hOzobk1cyC+aElfAuI35FDZ7sVb39CWRZSHf/ZzbxuSxKTULTcuZnEVqxwSWcQTXjXrh+/zpJfgSmStM/kaLljBjgMcKh2gVmgj4Ci3qioCt1VpOHXp7goE8cBkxdnb+SRf5rvm+vM76Fd7JsBxivrVcUaTudjSI+S0HeeUdTg/AEDsydxru29H39yi+EBhcYofZ1xUzjW1q+pu7yO4e5wQoHScMkoIZeQEDl+OUnZIZYili1I45C7EonTTuOrdtS46l53992laXv9jkJqacbnp+DkRE9r1uesAqTx3qptN58gt+xYTGmiwATRbBJukDJLSLEQliJrwuyVNQckGuoFm6CBKk0KJk6LCBs3GTyoY5OzUtikHOcRw1IxDjCf7ctm9zfXq8+7N+XC7muf5X9psZi4lm1WfUhX6K7yb/NnGPPrQcuJAYTdqiaIpiTJE4/g/m1Zpl3u1B+gD4iD0yhMeYpLL/9dXqfp3W7bM0oBGg/LMvLW9RN1dVa1ugZeYouOUIk5yoOiiexddlKHDTfLzv5Y1uydPQpaaQuLE1vZeSt2EGO2GmX+vNwzUn+6+kDwhRkfhsChwCJmCCjG5O0mt9ggp8J+/d/z/P6+1KMNziRP5a7Q2DbCxQLOXYBZmGEkkkVL6yFJf0gO7VxqBR7zMpYRRh3/sRMvKpkpXaPIrhMOYYIQQRvj8jgn+lc7xC562V2rfzJX2O9f8CEFERCSIiEiQ4BZ/2xOFDf/TnsccIRIRIiIPkWiPQ+bMC9Kx6VhoLVKEGDGNaVxIQ2SeaX8syxlWWGoBQlXZdv743+BPtS+J6SBtdTsD2yDQBGRJIEBijZf/d7+3ZYfslb14/+2d4jAZx4iIKUYWWWSPv5qV2yx46rgfwfQlj18nSx8NwjEzTNCTMD3QS1JWEiPGSCcm1pXEiQbpxqQ7Ex0THyZ+laSf/shAJsFMwplEMxnGJJ5JCpPMSjLBRKSASWklmWwKUlFJ5pqHLGaynEkVkz+YbGWyk8nuSrLfAeQokwv+wpdc5uuu8x13+ZFH/MILbtXG7yX5s/yy72H/4drbKVxnauG6H4tqKw1BABMMYANYjND3HQEgB0CgxfIzQd+QSKZQIhqtTm8wWqw2B+lyeym/kS8US5WDar0BNWEEbbUxXGKCpDo0w3K8IEqyomq6YdqO6/kBwpRxIZU21gNYQzyJrp2PaVr3XF6EovXs2cs3oE/gc0Jjk2ACQgM//mQYUKvV5w0aMmzMRObs3P1fPQFI2CkMCzocHgujCfcKs2EGPwA6yH+6HmxPN/DpsS/0Kzjs6Ykc8dJwjnlphMQGOPYVYX1A7xiME/r4R/nZ3gBG9deFCoXsJMCIF1zBOyJfWZ/lSKZmijHc8QXE+MiSWQYS3NrLOrFecZPddKfjw29tfddAwcJFGyZeisyVkxUoXS9SsVLGF1uuyh+2Jh2uBT/JhRUg1y/77JEXl7if0qxcumhRiRKKGCHjScBKJ8BCoO9/8SrAZkAN4NspNas6Q69WTyvDEeTc/huYh6GQwpB2+kbnRdeJ7pDH0Ee5QoHqQrH8aTxlQ6BSiLSwRqsi2tyoRuriP55D0zQFMcn1H3SHtcnlKr3ENyq5qrZEVyoetbPh91CKKi9NGpesHhZCUNTwZucCChGoIA0BBj8qY+pFKR+UDC/Pgfns08psdXMKSMlGAGDpA9b6lUn6AatjJrGr0VuwcmeRmsg4VFGalJX0s7pYCmql0cc3gtQGykrRxeiovOBQje7sEZUASes2HXImhoEJZWSAi2T5ElypzBRfHH25gqQOvxv9N69kwxQCxbcR3AJCUEoHy0V8Uk3qXnLSxEBuN1cH3PYMKs4lcKpvuA+rm9325d880eMtLG/Px6/ndV4viE8xNVOdyucuL+UbDT8/XcFXLUmtfet+/gTppTe8yEW+2fsL/DdfKdgKeIEJbxdCBItTnS7kp4XC4iWqSLRE/TnfVu8UDr71lN9gq5/5bcu/5Pi2aKRUIxLzFj9R5ccJUDu8NUss76oni63VK8Sq6lpxf3oid0nJbVj2NNe+SMaszEpGXAxU+8kkhXCZ18omyOqtJMrmr5etWH6vKtnfGLqy/fbkE95bb+SlPugUphYzf4GESQ3pJGvItuW3k92kBW76GVlK+l76IjM1Jj9k1qH61KrMA0vj87qTEwcuLZMDQBmoioWbqCW17O6kfkHVTDWn+knWm9WbWTuqTyxfSqPLsWgWwHKoJEoWi4HMRWLaYohkPHn0crFY6pJ1prhTOsYX6D6/om/14aUK5SGzVeCntI8iKYqZinKerOgqrlPs5GN8u9DMnyygZB2lWYbWK928+TkPW9LsTPbFvSu7Y1bdVOHfVd1CtrkRcGAdMX7hnciDjeUmCIvd9UdX0PHUEpvTxpd2aEGFhr6KLHnNGOuSFsnIxGYy+2QF8cwpXRXkx76IamUPfQG686K0nIjaczv5sy9rE2+D9GExEPdKXK0v5SJl83KzdfRamrq7M7897slcq0PnX50iGxRmt7HhzLLKuItLsVVbV6AVEAc0/jy2zoXczqXkI6Ksyi3ZwNJM43XWnbDRafKN4hI61L9VCWRcizVxQVrYjkm0YX0LgQdBuWUfThaVCFRndrqRcfi5Hg3J8cLkTZwZnNTChZzm2AJ21Tyx4abMHGD4B4DjF82nvfN0jJKH+rg1gbSrD/iXWnvHQAkWK581LXPmTtfGqJN2/nVa47W6y30LVN/gQtUyiNgapQ19lMf2Ne80/93kPlcW5VtiPfWQb1Plry8vLhwwWsq0El2V5YV/buyeuO76mStqfiWdPgGEQetYSJuNjeV8WXTsH+fgTfCj+G165ISJpJpYIAZedvbk15NDG3OT0IFev9p5eTtoWo9byp0meL06eU3t7bAP2vYg5NcBtXuCdPJhAGEqWj5xtHF/zTnOpFskCiTGOYxwgbCcGPbB+fOCyn7nbD6xRy4D/iFIvtCLQE9t5Hmf7/zdsQmZD+gV2IdC8YVrcjPLxN0dWgC7QmQ4E+gLc7nDBG+F29aWWRZ06p3snomv/HXiumIX6Zke2mmyBnPXP5smmQxkVhjz23TMvuU3lwH7BsRje0ORYTu1oUXZlIeGU+uRI/iNmqZ2RE0iFQ+C4bpkdLRuqJGsReIx58sBORq8Fa0EYkkfcgULtH2Qhx89GYJi1E1tyGxVUnVMG6eOepIWNNcXMU4aM0820sXVn03M9jXdm0aeSx7BHAAx3XN5HQuG2jF3W2xXLwRHbptBMiHzPhDwUeqs/LLNo26uwnST6DTmumn/jWPvatNOm6x5+R9FHZqxLBJRW5g7nyVB0DLkIhxO9BWt2miL0dkdH3JiXt6T+9DyzTufm3/PfdXdjp0QoK8Ii6MwvUL3XZozhu36fRblSX9sfQqtsbj92++9l+WuNZIHcobeOn7jklNF5maR855QJDK4zfxUJPIVO6YdpH1K4zh7oi8ES10hOeqymCk1iU2e5MU2eUEZD0/QvOydUOQaZIKbOwK74beJM3VNV6iHNLd7ls2KNGGvbY7qTawbOaFrFNmrlUK1s+moDshXOzUbxGkpj70G0zFA7FlkMpc6jFoOCjS63FABMhEOmv14oQzpIA9b2UhULgDwVrXlo/K7qCORJIMhrqgiY1DkKnSPjbjhwkDmyJEteQX9x5NBDgbJcz4QK0YQQD4ccQzJ134yLpE/e6Fng7hY0NFwOX/7jgDQg6VDqRznJsGuWxSSstxwLR0PLj8R48NlBd6s/XLRymJL04VPs5Jp0dSiz/1o3eEZYaVHhLdF5HKIIiISxX6YJGNLBFQL9DmJ1Z3+ha77NCLDBzeow/08C9uiZpAKY9guj3oh737vS7bsJmrCr6Sd2gBRkIwG2dLX70JIF+TUmEFhaA/0Eex7sO2wcfhmRDiiHOFDtCN6EDsRxxHnEfdQfajbyNDUj3glRj3y0Vr8uhF1JW5QGzIb45nxsq9Xjcf/IX47axzXgBPgFPhoEpGpRoo5y9mkOa2p8h0sRwKMGtECdPrSClappyWWmmq5XaarU2ej/b5bQ180NUsCnrdEpl50a1He5Q3jfYHifag8go9VRfGpxli+tCCZr0kle9nzLU/58r1ABQpr8QULrzsVL67uVbL4elDpEupRvZVYj+urrsgos66pWa26pU797mnYpWfIcKzdcC14JfwqByglqrD/GxpAW+ma1LYNoDdIHwCYZVA4PIFIRs6AREHJsNZBCpvjXVKEVGnSZWwmkJSJerKRI1fe4Xztf0EB/z9U+KJIsZK0kBd46ZXX3mjzVrsO7/7+BcLm2qMK+3EvMw1DfBLPEmGCoTONwmhjsq2maU6Rndj3h/f4ayY/bEpxK9p2AJWkfwmAKQ5PIJKRMyBRUDKsdfpwsv9HpM5jAFBQVFJWAaqqqWtoahXYewgUBkcgUWgMFocnEElkCpVWOlxXT9/AjVt37j149BQ9UQyYLDaHy+MLhHkmI2eciZiaLeaP/x8LSytrm0iwDso93Bf1wKAhw0aMLsYeqxMnNajR6vQQjKCFJTGcIKMu9iwnU9oOHoWj0BNJktpZIjaos5IeVTLpRrbhIGokq+8zjmfZcdvXEiLmZHq2zT15Ho2P7/NIPPbEU42a/O+Z52kuNzvs+8M/wOH49OPxW8Gg+QP3nMbvv8Djg+dpGAlQNQCljNPzVsQWR0cBWL4IfjLwi/wWeNOQz2r/j/HSqdG89c57H3z0yWdffPXN9zVLx0YGEhERERERUWkONKAwOAKJQmOwODyBSCJTqDQdXT19AzqDyWJzuDy+QGhUz8XDLCytrG3yEjS90nr8KEBZNNFAa1YBVgF2nIJ4Wd7RnFPjpIvzT2hqLA9TZ4KpmbmFpZW1ja2dfX84oC9I70eHveDwBCIZOQMSBSXDMgEzUWXLkSvvaj6cAAUMhYoUK9lcD8gN3NTglttavPDSK6+96Vq3O7XhrXYd/bsv2UWznlUXnwIJn71IumEv+/EoG6lZoxqzmYgabZiT46A1a9I0rbSnwL4XSN93NQYOTyCSkTMgUVAyrFM0AKnSpPcZrRGrXbPc9SNy5MobeKvtvctdM6JIsZK6NKoBX1OmvJ8UX1VtXADgx6dwXO0TK7xSWhEEOILzPLI6MwL6ulErtNiudRljAL7xSET7OgB5NZGObYEqR6u2SQ3fV0hQaUEUqGmiTtiaVL5VLy9Xea1E13MzHJ5AJCNnQKKgZFgb61mOTJgyY05Fw5mLrrS1Tr+2OLwTfEgIBhlsiFBhwkWIFCWmjhelk+BniZIkj3fVjFSkSZeRySl2p9guDuwLKCopqwBV1dQ1NLXAECgMjkCi0BgsDk8gksgUKm23zQ/qoKunb1BuWG5x596DR0/RYTBZ7DhweXyBMM8YZewyEVOzem67RhsGRyA3WuokjmSOhaVV1qTAhdJGZWtn71COCVlOCaQGNVpd+qAgYATFcIKMunY0MYXGtidO+D799ceEwRETGTpHgT65d68OhdLlleEDoup3d3CSgzr4Nbys4knO5FNYRDdj0SkZXypK8mSqrtBeplr0mqnTkqkzMHNd9PQNpjFzE0zNzC0sraxtbO3sHXLMmRPO/eUbwJgGYhPfLu0iP6hVYc1VqK6YDE/cZVO42BT8BFsOPYrf/vjrny3bduzaK/skkoNDDnHk2IlTZ85duHTlWi75dCmgqKSsAlRVU9fQ1ApMEKAwOAKJQmOwODyBSCJTqLS0M190OD5BiagRXRlMFpvD5fEFQqNI/H+ezMXC0qpeJzoPCgoXShuVrZ29QzkmkpMa1Gj1EIygUcUxLMf3w5xXJgw4AolC76u48JSI+rlITV3sHdmQH4jPuYQcLXSJZd8cVAyMeUiTtEgHXT19A2MTUzNzC0sra5sc8+Rkztj6Eg4r16xqPGEAe/ZzzlwuuebYA7gASNlyu0Wpk9EbOc8v2bnwkXpwOxO17IaIsfAEtRkiP7BpLXp6oFVLkY9AtDpklPFNa1TzjZXgiDq3fl2co4sJmfp2NdFTP9z2Uzr1o1xs8SYXJBa2o8GPAG5x1ufw+/9RB+SAWuzH5njnbsxXTdYqsBJbc2EwGIwtOhiR5hQ5yEWey+LHJAgtMQZozf/0FAw4GZEe6iTw9vwQ94BAYhvp+LIi+gGFg+/cG7aE59q6K5T3hg/4qD8FizXHfIpAINyZRIRg/OV+2jkfLOO62gCs+cw55KKpaupLduqM/NNJNuaqfFzwh1FQXW9Vezf501/+9o8a22z3b2rjsgM77bLbf+rssdc+F9056bgpId3eRLTebVMiM1WZSYMO7bbTx4fhvyIykv3ikJ6ehAkOTyCSkTMgUVAyjK5i8aZOAbdJlSZdRpmAJsqWI1feJp/rboHqqQpRpFhJXUquUKbcpHqK5lqhXVBUUlYBqqqpa2hqgSFQGByBRKExWByeQCSRKVSajq6evgGdwWSxOVweXyA0que6W1haWdvkhcXr22MTsDIuJ2qj9Nb27F9O2Fr7B44qp+Qal59th+lRivlj8cRTz4wa89wLL/Mqc37jT/+XPTevOqN9vdIe7QJ0Y3zj8vmsTePxR5nwadQ0aa4zvdHCEHynzgC42J4hKEe7Zm6MluMEr/dbtyxHs0cgQ2FwBBKFxmBxeAKRRKZQaXQGk8XmcHl8gdAo8wplgaWVtU2d9XA3KgD33PfAw/Iikpdeee2NNm+16/Aun8nXCRdO8IQpJvOcD2DKtmouh/yzAtvsSsTwb0xi0T7ZEa5dJmJqZm5haWVtk2P+vMYbRXRreLPfwG8A/plePlnAG3h+qu4zU44PPq21Ubewawsc95eOH33G9zHjR2p3lmhB8FzWEljk9WXDfQAIfMNMJmzSw61RfV+kMAUGNI9GZXLkZHaW4Fme1QksSCZ6RDPoGn0GFfGRp5qEF4hDKaA8CbzWqPe4NipP11AATiTkWYE7JDtRjJgE0Gs6GNNbnkRfZxuKmR5ZSZHH33H1f6ttsR517WFuhkeWEwCfkBzpqKgiJIfOf4tOxDPjkUAmuVbeIzIAcqqIs++yeGlp2fRRc3QNadUoyNI8iwwFQMIiEU7YTqcTmYgm0DX6FEqah3q60DSXu+N+rLFH6d02wJE3u9RnAFL1iAZ0jcYoaB5ozyV8hkni0tp4OrcNTmwluFSYjdm+Ii20NxDZDnbEHQc64mNYCCxGoxJXUbKHFdpvyQ30jgXINGCTPX3CvWHlaUwIgITPJIIfOIAWWmi5aIwDeZ9Uk7FPEOcwfnRi1O7haQpdow7Tzu4g6+oQ5daXglsFtMQBB26hATQgw9wcUukEeammEA3oGu0GxPZjoYuJJ44BDYnBoWBBsquPEWwEYnXs5eIzgLga4ZJ7gFNHuHflhcbFZnEaEu2A0Z/m5g8HjNFGfR4g16/BubLsky8sY/htpOKSSHww2kTwi9cv6OCbBWFIsvW5MHqoW0w0ZCMHuci7ZQHvLoUoQjFKIiVrcyd9sdUq77c2PnL6bQAZWERoicG1vqSuRqZjmQMh+02A7ujRH/CZBIxvYIL97fY+ANiNYvbYa5/9DjjjvAsu9pfaErwCWAGsAP4MADgeSbfeB37ghBNOODGozesDJ36GP3saaACFKFKsZNuSRJ8+YmJiYmJiSqVSqQRB8DlcesYLhdLQcplVPj+6gOb62qB+9v8L0CqzeHT/vShQfvX/CrBB7DE8UYRWqTXIxOWPRyvoRIGwZduOXXubfZlEyMEhhzhy7MSpM+cuXLpy/Rt7+osoH3Nj4hKSyUUnX0oKoqikrAJUVVPX0NQCQ6AwOAKJQmOwODyBSCJTqLS6owQAunr63aB9hAMAAACaMJgsNofL4wuEA6G5gQAApWAzSytrm4GIiIiIiIVIL7+hBjVanR6CEbRjtxU4QUZdLHrbvc0GNI69cDyg8gOCrnxxaSjju0M7+OArJUK4fDZf/G8+rVLPeQhEEplCpdFjFJQjTn29kzzA4Y26SJjNOCcW04E09+K2+2J7vTCbqIFNO/5sEJ6gukNi5Kyd3ZfqUy0wiEgZTvC2WfjCj3McNAD51e+JJzfr/vI9eZUbCoy/tSyobby++G4ZgTbEFlaMjU+vIt8tWHMmkHCd7kAxg3fy2lz6NkmRNLsYXErNDB6YcVQmPStRLyFSvLu7DMdIbZxGX0JQEtKdLDKyttwac3o1gLEtod1YRrTwupkTaHknF2Y4PIFIRs6AREHJcJuCNxYi4i2jL2tky5Err+RjKVAUKlKspC4VdFeZcpO61kPtZhnrneJh8sKUGXMqGs5cdKWtdfoYIeGd4ENCMMhgQ4QKEy5CpCgxdYpKaqRKky4jmf6MwEijjDZGlrHGGf/0BNukwURFthy58kqBpFCRYiUJnVyK5K+agigqKasAVdXUNTS1AqcLAhQGRyBRaAwWhycQSWQKlUZnMFlsDpfHFwiNMlcLSytrmyLBMSFzQg1qtHoIRtBJEWj6zNAnl8/qi//NrawVLpQ2Kls7tt53ALArcHWf++V/TpnuruJM8karebmA7nA5iz138RLg2eiAkRl0gXlzl91/VOlXH5y4nq6N5kE0ABJ1HJ+NXd8FHtWdNtlfuulC6pVEu12HMc656u9dL2heSgjW1AufDPJLsG/Rf0pIjv1cquynTr7NowhKNc5ftlKBdYAqzVof1DgYebodg9WpXt+3SkfNdgVpvR5L1bs2+MmpkUblhEJdx/w2YpD8NB9CXjbUG03ZvsqiGfU4R22QcJe215Sa2uVckUdpTqLx/BV9jXFjj4zVKT+QFLWi5yl9m3xgXaRuMoL5NrNe1vLrxpRmjTcXkt8Nva3b+chu5shpYS0G/Rj0dpEB9hohwTvai/gYR0/uVIwR5KCE7bwt41FzG/gP4bgm1+qSrUbgCWl79qt4f7Z0J3TjeoBgR0KreYs+TI2CICfBg5xIjobZb0hok9EjF7ZWkb32C6mrRBl54j932xRSzkHfTNFH5Ct/BxpN8+jyJ+p+V3wnc1NKmfM3KWtLY0rFFo37Vr9eL6PmR2Ih7dN7CwwiNX7GN2hF2tXyZBJKIaqK0olkJJZenJR9n5YF9Di0xhTibXMAX0K0GPRLkHv+ctBLitSKBhuGkZ2XJOHD6FJsDD+jGqiRC9EbLf3865h+9EliKa1uHHZWt78pJPSz+TmLLtQ4XJVlI9fx4Z0PSdqqZ2joKeRMHXmXQAbfBU6UhZSHVWmcj1lKvAHaHeThKbD8o1TCSPHxnX7JEutcBy1WTRThEWfQ3oncBy9AncZsj2asHcDajzKI/6kekSLtRHySQH8pjUrKJ/+1ZozIcMkl/KT2PPu8nuLKWKUdT5bsTyJz1O0RgFqFenut62WyQiTnAA/emesfZVT2WSdeTmIvhZQIOdJWOe2Gww7uVM2JBJ0jn6qjEcfqadF9kR+xiga1uxIe0Z4kivvJNsY3f8NQXuVRMpj/zuwhdyKbAnsrNs/okaQOw8sX/bNHRrBf5b4YWw7GrtVlSohRH1GIajMggeH++rOjjtE4MnEk2zMNrWP62xkD7mdUsrqIQGy5noiRRsaczCqLnlu5QpWk9828Xt2Pli2/WuHhu0YwfJmBptNeVci0kwL3xpzg9HQsy3U9hvWJDNMMv8IIjfW+9/5WYF+JlR62/pyrLASFxdsq76NANBNPtSz2eD0zv/ddrpnhvRCzgTBtycfKZ5O3C/l/NqGMfKD2+XUxVTMDOnt/olNFlPa9oNPv/adu2RWS3lugq+62m+5r/T9TKfVWvuU87dN4a3bCKvaal9E9rGShAR3JeBryGgc0WOWK4p8DS0NWJbIiC79eQkiVHX/EE3trdtzX3UuvZlJuUE4ouYjwTYXkRleT+C+oTpsMeqK2QzV1q3fB1MYU0QmO76KobVE+iYJ0D6en2N71tuP9XoizR0a23gwx/wo6Ay9gzoSMKwHyNCG9mpF+FjxMe/JepH/iauWaHvSzN8Nvq4eQDrA1VjnZf++uLqYzYggw5siQhpYDnSguhhlvgInyJClQKR1biC5+92XNdnvhjUvaFF1GG8ITaUIPwvNNzf3VNOZlf5vLK8hrzWsz3vbKtCMGLaTHzG+bBxONKMgoGeIYUzHSiRVrZY64NJPZn+fNiQ9fGn76MtZPEIVgkUiUKJxh4tiIl8xEigzmMo1nwCOpppXopNQknU3WN5EpfiNXYQ7eXFQy81QytYSZpZUultuAt/GPdlVlCwt/qCbZ6l+CWjs42GkXV4GRy7QjJEedw5znD+ddcBFz2TW2rrvD0F0PieIkq2kJrPcyLL6Q9b4y7ht7fWeMfhkEC2VkIcRZCPEWYkIXSimQetLEuaSS2cuC7CPnHrtyOYnADnZkty+HY7mTpS3P6la+pa3A6lah1b0iy61iy70Sy61Sy72yru7S1B7EpyX04Bdx6FU2y8bziZggJFlhMoUcbNBFsKgWxYQw2cHw7sYwgSksiK2WGgnZBiQjKGEGARJ1zvYRpEOy5HzH8anMRY4KLrmEHJINbjNyZ0oESQ0faaaQF1TQ+ow+h0BSu0TsB0O5akAUK4lADRvYwGqTurSwrPNa1mVefGYNbNmxZ8eeA0dOHDlx6DS3aYy027lH6Ugu1833gbgbAPweRPHQiYatEKOMEOpnPxkuQZLDlBZi0j9UcdN/92Y0oQlNSMd87NVNC2neAn8D7mdOiWImbB5kNDBhyuzoYyqdWNDqeL5k8GDJ778BvBdY6f2TjrW8RmcAc+nWL4f4DdIjTbMd6bGOEZRe6oTa0ivTBZr0+tskkD5iqmHp0zl+4OdjfxFhu9Jv6nan0h/nS0/6y+Sm/2OImNJ/xkZGaV40PbgnzeNeFHlnHcE/UJVZQHiV26Z+8MRRxAawAhJPAwYtKZ3+H2bMlLlOLFnrrAs7DpyC0s1AgAgZ5FDAGS5wZS9i3P1tOJMbIncMefACxMBAmtZVDyOjNjYPTewd0dd5z4ZP9d6275marWGz1N5NvxCUBOQhH22mm4GhDy17kJLd++3Yb9gf2ZN77cwDZ0oPjsPw4dhBOQzO6pD+/4piRvI0uV8jo+pES5bhc3fiExKTklNS09J9aTQzK9s/bvtaLDe/JTIgiRSkJEMyImMyKaifw9qorVTxkiCeNJy56EorRqJ0U0w1zXQzzDTLbL/41W+qbLLZ77bYqsFd99z3sOsKKbSwwososqiiiyk9b7uq71RhcxAAkSNrKNhy5CFAiBipxik200LMwzxCZoFCR45DyLzYMFHiElIFDhs1HqFOQYqLFp+QRdDioycgZBksXIyEhKyCh4+ZiJB1iAixEhOyCRkxdhJCnUNFKiEphnngAdCBTBERwEhscQASinggAOft6IdRse1W5hHWyUpXUHs77iheWmqjGhs+3Fa1i6NG+FLueNtDhM/6Kxs8CLYS0SB0xolLVVMWEDde/AQJEyVOkrSgYgqSeg3ZHyAZiZEzXqyYlL0ZLVdUiqRMFd1E/reKASx5iTRBhS3q3dYWSeroqXJVnK/Kqtrewc7UjsugQKPKmBwqo+C9ASRWJRJWboDlDbBSOZaTY2UynF6GlYg4nYjlBZwqYDkeJ/M4PYd9HE7HMMPQjSl0jHcCjOqt4Zce9beXQsRqn916Bqe/62eyeFwrrD4pujJmG9Y3SPbbE/tyqkMP8bGRlX05iyP1BE+BX/XlrI5dR3QE+FxfzuaWpWTWAVf35Wx1zL1YbHg6pZz6tlwZiARO6MvZ3yYg0cAk1FpI7FiOy/Mq4SUFZf/tc4JWJCVmW6lzI8ht16FLJPhJrCdLnBvmU+O3ENU0ybcb52h/VeZT+yMnYDYrZp6EE5wH4tG+RNVHYNoG2RsYUTWN3taGzN3m+0+GMBvdZUq/6ZtFRiyoabjy8h3SkW31A3q7fWN0IyFivJue7hK1OPwzdusiUJDBwkWLlSBZupGyTJCrUKnJpputwnyVllll3e357E81LVdNbrfmPxX6TF/ofzX3gqwBK1pTQRdU0oYq2tJO7b0gZ8CBjnSK6i3lCVJDbdSRfhv2qILJ9iqC/YkIu0YR9Yjp3mCTRbsmq3NnX8q2vb9Fy/Qa/zlc1+9uA9vhCiRFkDnZROCtJoRbBBlDUU+DYO1WQublg1N0CurDNge9VAxjmxJ3uyHhrt/nky0V5q5TQoA0fCxDEG312K/Hegv9E3T9wOEOH8W2z/9RqOSe7OEbEyhxpPx2sUt0TLz8+bBNkzx4c0Lu1Nfi56PworHJDr2xXfcbK9WU3n3pFfPi0ueTLr6b9Ag52ZW8nqJnOCmBNjoPejSzhlaFQdP2tVvI0oaJV99I2TnZxTeKLH9jh6WlS784r+8R6miprLDAIrW80qV5xOhn/3PTK31r69t3u2zjY2jrr8E9dV3rb7ls3eby7HleV2QsiEtSR2j0oxu8iffXKyHhSITxWVwMLI8IdIx0lT+3Ntp6j+xMg1RxwHeDgLSa+mYXGHS9txFHkT52RARxP+tup/vM+91tUYajaOxTyOhc/aZDLkoRMXN5ilfop1/c5S8FkOjMgpqob6VUE3pcsOZpFQ0uErs9xRhhB5Xz56K7h5PX9OpEVJvzTQe1Mix3GjS2R97oYp6jRMSLbAvL+EeLk5VR1mrqTGdg5vvmVN3tSIiwPnmtngBjrjWHu46Aj7F9Q2lWYa/0fAycmZpEfbd4kadf+IlezQSU2KcmoRlmPplerWd4Q386DGLU4GuH7EKiUMX+P0Daj97I5QeYuQlYCgD9dQYB9lGZyOc1r3L2kbdkFdL0NXQLL7zR8TOeGmWYmYmzdpw7AAQKVCkcM+EEmN+Z/H+YazqRxJO/qf5SY19O4aLESpWughbX14T+B6swC74GP0SmT5s1LWBaMIJBGAgb2THtvPUx8AQ0FBoNBUPhUDSUDKVCOVAJVAYthdZCl0J3wyJgcBgR5oGtgU+F43ABroFHw8FwHFwFT4NnoTaiDqFuoc6jKzAhX/67isIxCux5CbDJP7Y7kEvE6CWkzlhxfX0Zw7Dm6pALcHtO4swWejEWS8dF/n9vP8ztrext7T0HM/5cCJ0OC06CVdLu7hffcSSq6mXnyzl4LbcB63/rhK+XnvXjqyWtdLfr+6tcpsv+wJPj+dxb0S82ixSRiR4/T8rnOCc8vhVa+D/g0UOCy8ALvoBP5fl8AsB99/7wo/j+mw93+pZuAchcQHUeCRJlGfSQhzm8cUzaDAD7xtQD++EUBhp8OoYB9ktD/3yEkUB3ORojheQJ/ThJcRhRRMZU+F+8L96U4rBU9d7WmZGprUvKsbpNroaLfW1b4W9IFNLHrKprLryV6XrvkZ67pvymxMxqVybcSbCmBb6X5L5prc0VNZW1++94+9a5bXf9/vubd6z/kX3tC7c33Nyap5Ar7VQgEcNTEBhQMmPPRhdqXty482AhyBAhBok0uHrhRhthpFEKDXXHTFNN95sZflFhoXVWWG2NGn/6y9+WOOKkY44750T/44xGjz3x1FtndSDMwplGbjYT85lbRKWSqQUsLWVlGQfrdbaSnbUcbWBrFRebOdtEa4uufueqWi//6mGbnrbz7AFvO/jaxd9/etsjQJ0+9uprn0AHDVDvJ4cMdFiY00KdEuW8WFfEuWa4qxLckOSWRA1+dlO6B9Lcl+qe8Zpl+d84z43RZKxncrxSpN3flPng3z75l49ovzI0R4Szkt2W4WG1DqrUCA2pVbOtDnSoo+Xab197O9JO9bV3TJ43sr2U67US7/pZWRVVXGEH2ll0h6tpdUtb3srWtq41LWtFq6poXguaVFMz29gG34KI4bsSCGCCISoCeWNF+Cz3yVPpfe5GAkD3EfbwEJyBv6oHom8P0VnayoSOUxvQP78XAO6PAP8iCPgVIOITAIwAgwEAw3BjHz+BsE4k2zOLZsZxSimJfHTjc4l81o9CPgNZEiCREvlUVVDnZxI2xVVaCIigZpQ1H3w2kFKmScGnjZkV+CzF9CExV+tzipGEzaNOCcf5zJ8eEgl4jLgpR9yErL3vswmExh7MvIiSiYQR3FSGz6vOaFNzi8CoZaWZTn0uJVM+JYTfzwhkykij1KiZRsxlsrSCIUeJ4mSfgjpXUJRttcFUu5S7eHUkI/QUwDsfha4Sr4odkQyQAwgogBNKoh084oZZjgOHHPlgY0YFI5JWWNtvhWHSAtTQZ79ve8udbroB/YLfyeOXCxhjlCzShQy9BDFKrjD1HL0VZCTCVnrRcJeC0HgqsTdx1W01JV4VGXdLX1/4kq+cepJFouZXKkZEiUPi1g8kI0TXGKOZJkwykdJCoppMx0Bdz3yp+bI3ewNkkWTJARFQEM6Jd4m2YZybM2zofM7IkhOh8suoR+eEznPYnjKEyRPABbSXVyyRoHRM7HuA4lE4HtnTC5kE0/s2B/cuHfFCeB05O+8t0rZopU33jVvuLI/Mz6fSeRCloEjZVkA4CSMSjb4smPHOv9luONN6aT4kBrEPLk0/cU3ejLh3O1sb1KHXVfh18Ea2DCGmrd8GOKpJ79DXQD0zhcjceXdXpKSosfBHTSjMc8jt/Qqh7ZcNV4Oo39bwqQ0mgpRCtUxKI/FTHi9F66DAaUWWxSuAHCEo+7mLaRT34FxIKhJCEYkyK0hFj5XsVaVZ9fN7iJDvMxUZhHKlZCVQfkeiMZT6y8Zs8IejtXhvddnQiDYn5ZvsUi1wXaqUNTrtElubdRyRUuZmK1neU0Ma0nqSvV7si7ykF6qHpJqKfVVEA+QAAgrghJJo2ihbzd43SEPK0vk1SjEBSoaexvVXmP+I0f+F8PuWZUp4QJYizXaZ1LxAfCe0N0MSQ3OaUUY5zO/fyyaoJFU7r5hHEZg18mFGFY/2gjwsLMI7cbOIf9eoIOaPQYgPeEA6NlEPavZoEbMQo/5Rrgh654nYJaT+Fzthwcx37VP3gEydpOJKMDX9H5EZ5OFWzpAh317QyHW0jX9yCVnCfUEFzQDVmEd+s4DFMEAF21bWJPVpXpeQJ6j8xLf8LKSZTrz9de0PQU3fKPfH+/P/e/SvoXU5ZaR1SAeCJ/IpfMsFdAI3alyTpD9Y8y0bYf2jrXGjU7YNmOk3T642Pi7vgZ/dr4EmuQemMRqWA5W/CLQNga84Svnqmmfb+X/M9AUigt2gEBXAQxHcmkwUd0PwBYTLgM/VzXXN1ySRpKmnwMOxjNJipPGneTa/UzBKtQEB6GWRZW7pUvTXTSGQNew1Aapcovzu+X5EMz/okH1OCP2onDLCSrEDGdGELvVkhHyL7vvG1IrKHlLWh3I86M4rrtPfiZHPjnLZIzcIOxaLoCv4O5LWGL4jInwM83bpIapiTbdNsvz6FosKBldLZZi3bcceQgZdHkafvfRBjNEwKdjqsI9x4DguJSUKB2p1oSAgEL2CqVfDd9SQtZkw6C12LTHzUufl15h0pmIbNDlbravnw4y7uaqGVF6ZjdEDqshzjXHIsTON3VKbTYCB1QKsHR5h6bIQTnYXYM85da9dl3NphouNTs9jMjWKS+ks55BG+k+7UTVNkk4dnxMYpmdIjqwvtOUijqqKCa7DKf07jmbTcFSb1zC9MtHiKvrNlz5EF0Z/78H7lizwWvhPiCN8Ud2klz5qEXyEjQ3OY/7AdxLqYKknXY4LVXd7ghKVyQXONnFyKGCOj2wdtgBmc46kSG8CyuLrFiD1lplJVeBXcxpbk8DMXG55ZnhB0rCzwg2jYmcGRqgOALLLmdxsVt7gQY9wYo98jUV4rXW4CsOWdFUwwjzBqygD+1msCped1HQCfJOCmaJYAkZYWH9UM7BxDGbWV4gsZzmUyvZyUQGsf7OLMnO1HCGbM7IJvpdhjEsrs471+YSI+1y78pPkm6fesAqVw1gYxFomSL1aAhvl2jJrW47L8Ixbs3nCYYnsrlyxgMj6pZOmxaJFy0Z/5yeIlxqlYqWCYeYLk04KQ8BOAePAqHPqHfpwdaOGpDEQH5yEonFdmtM+C1h7Wr4JFaRYYYEp4Y15EszMRy1wmlrk3xyH7IqMayKBqC+kQSTSTSQ1CWJyTJkNCdmPbHCtpqNZZebZbL5fRahAp+ntMKMCWTbOntNDv5eRvFbjW0mZ912aWeGO6q7RDWa0pzhAjDZEZYv7jTA4lMuYV7kPfwEAh6g3Cp1zdt9SN/OufEltj2vmY5jZn/jOmbC0mcYqX1CDPWC/PYHXsLJ+IlfskkxdrwsYodRfuVBS63B6jtuK+Bw+yJok7Xt6R8alESKLHjn9mCxaZv/L37aYzQt1tutKLPt3YyPbNhxvbC+yRvl6qSBo/gJUtRefOOOWCI7JDaFYc0iOqLIqHXFkdTh6No4qGyIRcBXkQlr+fuVQAcIAO5RYqyCIYcTHQ0ZI8gkjZ1+003GvjBd2u3hlDK8f8bkVxpM1drLSsqaV5ZM10fb7z2sADh4f0o7nMsAIFqlrF8F24hlwEK3vFSB2AbLmryBgF4Ka6jk9aw+St2DqCJMy4YdNzUrApwO3U+fRb6KCpwG0tpGW2TC1PqDtlIXjyIYVDbBdeZ0mHU4FBSkr3YwuJYU/w4GlETokwt+RuPd5g8sLw81b26CMoy6iN4Uo4LmNh0WK3VVE6/XLvot6R5TcU+qvBdXgZBRXPUwwcv1GYJYrgYTKidKVZZvvkG5fPLVl6a6d0kAep3RqQG7AxggdWV84y+k0I3LCgWGa0/N3HvcpcVZLL8qk2Ha7psHNtY3PTw1pLivbiZ6np8UuUn74k9N9K8y0o5i1auQ9s0g6xBYpa1A1MwCtBJtHf/gOdJAeQysl6cC81tsmXkqO79GNmT7d9jsBDYNOpAWdNhclzAYLx2QZYVLzzAY4jdSyypUaTjbeMNwWIjogdCrxaOoVRJg1oz5wgFlukwmtUVtBULo8za8ixJCsaNVzWUJRKEbvUC879LQ54UstkJ1vCKPQao0wWmw1A6v5tJnF+VuGkbUwwy8XFTwZpm1mSsvZlRHVbp/xucts9mZty/MlwiGvFHxI1rFctpqwbBZKbMe0XLW5avDkkt2XMy/IOHUyNAdXFFq8cgVWwwrnDHAfeE7KTQ5GXwdLulZFswHfUetcuQyK3sOiiymOgBHYABMagJoS9yiECvS7ronYTdjzYiBf9lTVqtZbz3O2ZFMb8lpTZk4rnUoBZx17aYMadHA4jxmiRBsB6yjfJTDBBH+e8DK6ZNpfWzuLaW9bDjyXlaY5soZ1CoB5MD6Qx1hgffEiFPp5xpoZ8WePmzHFamcpuhZsTObQG5HyHltnQkavn7GJpswfwUDIH57KYdf9nLUfMxBupmKYBStZXJzoNG0abAg38RUda9S3LhehEPX4ps2PZUIFp5o2GWU7dbDqV1bOAUTw/S+ufah/V8OGFR2g6hAOAJML6F7KS586N+uR34m5cwohakJjPXpzwWrlO/CF/nG/KCeoC4fvi67jY4y0ocCp6kUotE2FVmfO9dCcEi7V+rBTqsBE4h1AEIl1fVjzP1FAU/TeGG+VVz/bRkxqgscCz/vqJH/arv//H0MBf5QRpoV6yekZiZAdagYo2y0L6ITqPUr4rvpjTqy/ljfZqDThYnwtf6kZZV/+Sn6vQ24pwIjDr9UTsb9jqxUGekV/6CiptrB+9kUpinmxKhZzVz84EKCCkKJXuPmK+zYFMSvO8f2peDAFqfhyMAST3UR4XwKa5IUxVBsvloeBJQN7Jk7EQnaxMEOa8ws1lBSOqhY7e/H+3Cec6sY6Abl2j/c8g89e/pydu5rBDG4ZGZxH2swZkSPL4Aj1cPgB/aLIzS4UKlDFWL7CrCQpwHuZoBNLFqoAxHcRnlKAigJCJkL1DBdZXZUcFSO9bZG+tNK5uPNRlCi+KB7ZJLs2iqvXY0uQgScv/qSjHl8UZ715sGujOHs3DfYpeWR1VldZteKd6YUq2btJFEN2cKltpVr9ZFR6fj7NtPa9PGXjPQCzCP9lZcVs1JmWSREZUrF1sSFDjN6Apm7U2b769etlZvMSo/HL379ZbrBswghRS/5COLE4b7R6X8kiTBzDvGlurqtyQGOiyO1Z6Hlyc1nWF+qW8mZUldfCBXpghuiFEOla7+LhBOIw7LlLtCr398UAPx/CTiV5ZfYXQBlzFGHlZ3ewQnVF724UdUEn+AcBzOb3K9vnw7iMWggPFu7148UjSdDsEbJhg2cXZGpwO2LIzRd5XPN4GiXXjVKTXWU5nh3n+EccYpb/GFqHEjY7+B2cSKMhNRja6FTZbb2IASRwd4ocYrbICTNAaHRcenqpXgFvNcVglMQHA9d5epdB9mlvrkk3qmn7P3xmc/jFhnDoufA1b+q0Be8BEnd1FCg7MQv5Fx6+ZOMVVCT3wMQf3NTtOfEwRdFzyHsXy73F+5UnOODh3iZLXvZ0k/EnYxA0emigt1MWzdowl/qS48Yz9oy2U6yY6a9NmwWHaLWLuJcEXh/U62aec2OnXLy8/oWK7bPcIpIGSHTPfu10BqBhCkyApzu9ClbsXrda/78+NDzeaZA8/QkmOZPZbR3zFOu85FpFZB1krVz+oFACpxlR5lLEIQM5aIFTdCRxyNWLP883q2PjoKt78Sh+GNCL8UJzk+FCsgdHAx2RhPvlp2eY1sB2C5vToNVsh8asX0GHmYzAwVzNFNXyCeMW1Jw7OjZB4/66PHt4FC+YBaCW+Pdl0iEMsFlKAZRcZCdWVZ3uS2bb4aziXE3nhwfnaD4zo35zImFVfKdvZaxv5oV29moLulLzK0bwmN92sYB1rvZCVV40ZV4gZOQXPfKpGKBYVBAYrbjMpboCqhjyV3vYXSHwFl7nskcnzrp3Ve7vdxYYhaC6FGLdOs9wcswt3rnf0dHZNByk9YxuW0f/5RQ27mrrkZ9g0SN3th0m0u1YKeC0ofGmoporc2aZTk+Mz940XaizMzSzp+lebHQRCvUpEMzRcx07XsApFrsdlJYpoI5BGxj3qK4p7pINxSPTYyNcuVYVjj766B+8HUUc+rUlCqnMyqxG7Be9y5+XWuAovmtMVFkGdzKCw4jtUlNbd/y2FROb9lGFihojpAWuVKkUlUXlWpotIdNb+sWKQQJTxxqawhzobnDIWuCp5T57PITLwfcm2cvqUu9cvxvIpw7nyMVdHVCe45f9b9xGgF4NrNQK82W/+h+60nwZujRTyZkoZbpsTWvrVMMPpr69hVf87hcPEfr93ixbzxC6lxljauSHWpKMTbApBoqDSYUQikZBNu5Ex4b7ej9S2CHUZJPU/0Kj+NWiLMrlGYVpDc9zzriZzksdNbDyW/WCRqiyXu00FuBsdcyXvHdZjUDhOTESJ5T7/ttaaL+WRg8bD9wBqojqIdtRAaxG9gs8Yjn7WiG38C29WK1r5vVnWNCEBVP4WyhaJQ3HVzCL4zvnQ8pF62H0Berb1VHB+C64eYNiBL6VsGe2T4mYSYEocKEjmTJTh1Agen9e0c9aCV3BQD69e5yQvRn2wYvOC0q+1sL9jF4xUUAG6DJwxxvYrZoMcoir0e3sdbMlzQMCqZv9rmvkcIjBFSXl1I4rMm0pOWPZ1CP3DC6s5e83tocKUAxbTu6C29VfVzCCPSHonLdNI9l6ex9JsOW4h1Rjtl7BjbZz64kfb7R9AjtF4h/akGkT6fp+g6zjRviVLTZim4/zw6eSINNlFKCugjoWm3tpw3A2DU8L/SSVBMq7r/VY3BR8ewj2rTMbV+GNO2qXv8tA0df3+TuUdAwkV8MeqCMvireUJG9JWp/yanJSaqqRHqikIhTbwd4PaQ02XOnd159eoUp2bvKzJCmhtx3Q+zboRZ7vC62QHyCFqKR4S0fKDfM5cuH5/D2aeTNa5w4LHBMS+OxHHfD6R0HEYbR/cvM9+kbp+NjIyaV997keTOTYi7qYD7INRdugEdROsc1HMiP3Q6BGf7GsUjMgH+JvZcVsZs2pVO3muJmm3gYG6FjjM95cz3Bt3GR4AFdXsJ8c2Hpr4DRNN7Zu8Bf2uwNu+OHt3dvThnb+l3HDz2mtW3P80TPtR/D9Psyt0M7uU6WLi1LFQvvFQqChLF4uZjuLicZT3QcJN/zoZ2A+52xyWXdWVTp3bfEUu3skzto5Sf983vNSnoVBGKmUE7MsTG6QR/BiMzs0deEKKfaY6aNXxQJTjZmDcDKsqCmvbMCkn4P3Xhdh5oR1qQ5WZ4TNz/j9HiVnGOYOsQRAVbmbIbVDGAybRIafvn4rXJ2tTrj+5qvN4ajXO44Zija73duKCx1bht2vl35JqzsjcswLJhopXaagej106aHkdV0FaqpuwmHp11WBYXmHEA1j9wbSb5wK6g66dCP9esz0ZEy1eMW1Nycjrvl7JTK6TwiveIbwWQSPwEXX5ga8cPj/27TrBZeHfLbBK0YuHHQIHoHFudISiIbRU+7eyusD18mHhvqGgDXxUjuHkKdS0zIcZC5VHrUnEcLVaoO7XTs6AzLBf0twtghqznqPfUd5qXv3Dm+5vVtkr55p2Pk/J53QNDdbtfo4sZKz2tbG4wlP+rxAHuYPQmNslTDEcixq/OFH0nMXuxDrSjkuOj0yNoIEI6twPPhq7vjz9q/tZRN/4p/u27K68keL3waKMiGRwg0J2cXvVRvVYEQohd31RAQu8lHzCJ7lpB+e4nX6Cnqsm0hwouaMhAo5GYUJGlMBh54FZY7G4j9Axxs/dE7SVvmPrRubczp72V+HT93b25eVgxBsZdCDgR0DHxMCRrcoquR+o5OQywshl74GuMZrYqMlPDDdMwgcPgXm+PPoLcYnnfruH8bhGuDNurF1v2NuYVbXZzzE04egkWv7+4f6c6em8oHJ4bHhPBgV/sUEpV4rxVCxvCDM8tFxeMXbd+8t5Cln6Q2D2yLbNvX5czux/Ef3Tu4W696vw3Ep6dnE7uwvJXeKzESZc/emtMKCkTTHrrJh4EhaQeFhnbt2lioJmSkMHs9s86O8OMtcn8Js0OgqcrnEaCr2ucVIwJQusD9bBZqPbHvdXtBE9UXL1ZR89xbcv2haaZjWOMHPO9LwMfToGsLotxahnagwd/R4fTsvrrxkaqcnu4WdTLRHqPBCTOm9AXpvUHfQGvrAPWnKh7dJoURTI8uVTe7cmZxIzbTQmUwaSMlMTBxooTGZ0WW8OZ4ebvJgTze3j07v43b3HNSW8gXFarWINiVJRd9dCsNYE4vv8WfyBPlyuYxK9AMtJCmEKbPsImalH9rgJ/rqK1S8rMcNqAPFUxeZLi1kbCKRfEy4WpCrSnpyxkUXQMax+z21CBcIoZTKs+s4BmMFS5Cl2IhxESk7UHBaTnG4NyTVa1PK8xcnmJPqWJJs+UbsEJP1BQF5qijdabHMEjq1b85DaSNn5zIfWhuPph1tHd1qY82+z3REuljYQ4Vld4ltywdGBn5QDigGPuRD9Hg15TazcLibGte1iGSE1Kmo9vDfc38ZK8PujlxSJCbK5GqN7NLIXZu6YanS7WpXqBvh6t6/pU9OZp9tWqOiuNxJtdZkApljc8rkVN7wr/0lI8kN5W6WmmhmnhkekVGdyYrJMZnK/brz9+8Dm2p0T2fVhbCucz+HL9y/tGrH/FHwx8PRHWminiBVa2uQstchOmIRNQQl9m8LUmzwSrY8HV3f+rzjOcaYEPFMeQIwJ/gh9BULwO7U1CZwKTeedkwAQl/dJ52V90fZBf4PBFjFshWTZ16XuZfGeXL3qTjN8PqPZ4FpVtmbRxa+8912q8r7nQvf7v96M2odZNj8FbL6p7Apm2SjpAabJos/7tBZm0S/bLbd6sQjogH/Xv1p+9WwmbQ5aFpMpneCmR6bI2py+HdFXolz91Zvaevh9rAlOyPMHDHXxibnqLSUbAstISEFj0k5LRZ86vZmcAnlVtcSZnLRZo9jF/B9gMFlSZR7TPYZnXNmRVWJQBQE3JeQkJxPUCZm0ugZKmT7bPEBG35KGb4OF6ObEfJkZEwUiRork00HpampYq6ogwfzu1WqGqhdED+Wug5esuqWUegglKbntktDYD6X6dR0cHEWnLB4OhgONte4YxzNzerO1nbzdcfedbP/eXYvUWcZkoBOFlL2M77CD7qUzU7OIkpFJvBaaNW5TKLY5rGsNURpoGQBJtI6ddiyLnrPlCnuEBScigvRYoQMg5i0JHdZgKk9IzexVCyoNhh51cVitalMwl1gAEP9HrSx7ji3ej8MstMdWKYiW8qqTdLxKguFSnpKPGXKadSrYiTKcA3luWZAIYtfAuCvOnF1uVRTsrRsRvpQ1SjWPm8VBtv+B8w/PXNIL/EUMRSGSrGwGkltjc8fg8uONrs4DEHTDvgGPxg+7FqzTYDgh/6D3e85YpSqPCGn0mDk1hRLtSQjzIkf/NC7hGKJD+xCFr9Clb0sRiJT9X0xpALgd1p5UTlPipOHdQWd/Acj7dMF3Hn/3b7z8yTt8C6FV9VQJ9Ck1IolFYNycJMQgyE2gqH9JAxa2DQnAPwlfiHB8pI5e9ZBYNi/A6dF/c1eDcCUmkqppFqnF1VXSDWGMhG/OgmzZ8oUTwjWQ8LMXUxRsCU8/Lq6ZQFJS72YSjBqHEACE+y5+weSsv18AwNSsQ38moELXG7I+gkgC54jq2VAMkni9yxAfPk5vkPBBRZvQD/Sg3dOh9VntSxonhtGkW5C5UGQhuJ24oWBcMDOtLyKQrGCm0L69Nrf0PuzXeyrlVkqltfNyVsbdZbGFvEZ+QGfnCSeIl/ILRvUI+8JnQj9VZTnsg4J518hNu3YU4v0cmM4zDy08mGGlnnaTRa+AYv+/9sXpKuFLEMZATXooXdl8MMQtcSIoNi9hV7GYCDUnRcDvr4MEiv/1+YODs55NzipE6PgKBQMhfQKBY8o+72mLELWTNZC7NZlL6v+ixSuZRVIRGQoubaKD/lAbYSxILfLtIzcpAK4H6J16He5viOybdN7f0sHDDWYkJKe7QhEzEG0M/BNnTDjEEGSmFau/ODBjYcunLpyhHCw5OSeO6tlzgRboIh/lO2KikQoiZcssgg6nh6ylGamNQIRCPcJCFgQHDZlSwSHRW4+KfintECe/WRPoPn9ax1DIDGIkZGHnWJRemq2RTkIIt1B6crX4wJU5PToxvXxiLx3RYluFdIXLNFCihs293IR/VNYLNiGUpLt+RtkSUesrlNu7TZ1BxcXJzi8XA9m/WJQVNeLjcaF4qbcMBRXLYzUH75q0IB+5UEiPffRZb96K+olmgJ/qgEUNhPy9QRE4RKmbRsoerlvXntBIfMbh9dGUDQywtcaQbjbcyVQlNxRmfytWfGHNN8Mger60ataLB4icVW16fPTjm4Q6NjMnE7x+QQSWsd66yXWOIYij88tNxmYdblyDd2KcMKBsTF2fdp/e31rc2VDB58NzZeVZwvZlYNJ6RPRntdp5dsougGAycQtz+fLGdY4SX3v2CDcaUXQ1XlyZt2gAnUtFTlaOxjQ29FLkaiF6t8G273hnz+vVyHiz7sIUfgjnRIS+Ci7Wfun2idbgj8lYev7otwIZESIv98RZ4hsnlQioTNVbjSPY4ujaGh90Y9Aplehdqcr5IFIwGOwdBlYcUaFh0a1Nz+AFlK2+tEjaqFvWit64qGFLdvxyIboIHcZe2x9v/apam5+EUdTtjjXRSfrh8MnXsV2kZTC3Aq2MSTjxxY54lMXlC6mi/F1eOxJDOZXrB+JB+gOm8/2pWBw+eDHyk+d8dXxtzK6uC9Lfena7KZM9aSidjxtU6y4D6uWjsHJ3lXFKSiC2Zvv4gxO5e8gUbfyYTWJh7ooSRn9Z7VIyYHOeiTkKqRHSuFzXjhZEkLV+IEuSoKZ/84joQV3eiSK5bh0Jqs76ImLE1t5e9HRFqgcZIVK9g5/PwHsqpoYIYwsn680qytPjlBH1hNHwppPL42BjT+PrBzZtHdiOWk9ISQZQgqSR+hJQ7g1fmNJJEkGetgJJQs0dYQKZVI9MHT96D/x77b326YcdK4yr9q9+d/1kwfMy4EdHX371AfG0wNGI+Zo0KLBm6A1/6SBNTaZBIROGBM97a4u6AgTAkJ8ktOROjO7giQnnWRMSAhWqoWvbfI9nnjiq9o+n3hxpeK/VsD0KIv+0M6mx4Z+ODbmP8KOjmWTvUucJgq3d6sxdHsR5zC/06AG1eH9C4DNf3WGGoPgnBWZaQp18gpzT1MFU0DUMAWtUOL8iC8mKsUh4UGFsXsVRh6dmsjL+RzC2GjkMv38RXXk2X6gx7bW8DnObLoUljj/dHgTSI7msM3ZRKUqj8SxrRShfseU3UR6HqDLfgfmdJh9ZvMfQf/+RVirh+NNiieD50mamEiHVW2e1W6bjaebyMcV8s3oMxpiPaNfjPfh5Rdgl3MktxXPOaLr7eTQpHaa5Z3B3e707cwzO/52xiDEPBDRR9Tw38X/m9fnnerU7/aC7wLfm1v2rT5wILlZrUzkrVQftQSCb3rte63Ca/z1uxHd5szXAo4e8B3oBvqPdcdFU4/2+noTbsgdtPqsHdGv0OO+458qVi9fPTEo3k/Nio+lq/kjrDSRSFhQJTYlV0j5BXwRO/0AfnXpp9x/avJg6Mi9KNTeSAyy7uZNoGG271J/WKJErs0elIc3Rb1vnhyc/HOiqr4KQORUGHf3pqel96Q3jIpy7a7lnuysHg9jbSgzoPNVdBpdSWeUIQldoGSMdBWDv3MP3Eja3isdGwIENH9KzcS5Xwe3X49oUlPpbKlgY7KkrkZqMi/giIoN6DDZkoX4Te2nlgD+mX7Pf6349yGmW9n9zK+tMJemngupTYVoBXgXT68uXcTVqGoSlGXJw6zjFNIuCp4lAkCNY2vGSn4UXNolf3SuIbM7E4hTGBYIxeXaYfoQDcW/hinbgkehfvzfihhXV98Ar4161kNOY0hURVx+kSZZVe+TA6tte66euB188ok4AqwtF4kqtCZV7RJZUnKDMnGhjhhhZLisD9FjqUhE6m/osvsGJLLkPjC90fM6arN+if7fiO0A4qbWUiAPmP1kvlnAlzpqaAZjNVXsEghkKf/KUiomxkSTK7OaWA5HI1OZtd4A/no0Pv7oVzBBSRUIlNS5M1F5FusvFPpvFutv4MHVorz3O3Hku9ctkZ62Tk+IMDGc1gfo/pIvOBWBSmXF5lQo97SmuT0taZxRUSXf2exKT2txMfuGSn1M3j1qzT29UW+MKfz1e9FvAGDBycO9RsuZ7uBuwF45H/T0ruV3+baXJvNLl///nfOwUjk518bhCMwcQq5cTsq2srmu7EUZ2QqKnEggyxUqsoJApCiAlh/ljntnMgF7Bk+9uaPJEiXlgGegfjxVa7ap1CkGJQ4tT800H3gGqGbt6/+8ofnPS3bEyT9QnYfvhfhVoDvUSpw0EUHAqeA4GS0DSgbFBIGip2PweUCo7UzamZXFpfYzYQVPkqLVwNsMcBIEJocjbDAiwunPnpqwn7kY+8uURSa8FIOWE8hxKg2WStFicDLA88pRV7Zoe2/H4hCSHImRkKh4jS6ORtPjCFoS6MIXdid0KQy6FArthMI6AajeuTn27gzvuiWAhUAVb5v9uRLFNMpVSqOMje77NPO1VBLPJU9Ocsq9quiFCFRBdMxCBHxo5tqwWXDnrDApAO4MAFwt8uOWLgkhKlqkxHfTx1E/dA2ZAdlrSS5I1BkLLA0jT6015potXAzyzjCXh61+2gO/RaXpj1SaAbqZsC61fyg8ellE1JloC1eXKBAoZFwsRrHkqfcSEFRuib4ZrhuKCN0LZbJ00hbdZHBjlQT6qzeea5CykbftwNmBh6RD4/aBUfOYaYzSTZGZgGFxVcFF/X/DyXDQ/pjY/ybNY7dhNvnET7rN/41faWwtRgklGhZfrwEsYXJ3prfpQA0okKeUcQRGnThegijBIjt5GTEQUMz8GHNwdAwcOILervPX1aMtQMxo1FbUzIG5kJCEzUkQM0lQ0MqZyKOYah1XItFyzdywF9GmF2G6F9HGT36RwP6oyJWBgS1RUSuBqSn5fAlNOhFOjCiOuxU2rdIaV94bgJbjx/kixq4uyeqKluUDHzQNXXDxNtG6tOfmmrgSu/DoXal2zfRvs3n3/a4b6LDtdgo74XfZCavz5JMrNb7HBAd45utb/uiKIRbCPCQrUuf81tRbst+PrhDj24P9PRPzn/8Esi9N07o9J5MzSTW5AyrTyA+JnSqPjFFKwtp+eENZEKnWqSPl5N8+tEnDopWKSGvWlID2g91vBsemeKSIPTff7P2p7W8K6kbT1vepffIa9pV3/XNp69+5z9pNfPAIp/x0fM9q/n6b9WnpRc5ELhlI4IYazzr7LgPWPIHcXwN9ChyvgZX4QR0zSmBQ4wyo7aMR+H8uUU8gaogkolZPXEwNiaAdhbVNSwDWK9FfQc2taFTmTGTq1V8KsEc29oRyHqIIi3uGCTFEdDJWpyKPsX4WBbw1QSWv/T8UYPzBkrqAshkN4m0nt0lnNs4tVQaPW7loxpL2uaUzG2SGZIavS1eGhoFCZR0q5oVOAVobe0bXzHr/cgp8URr2e2KXe/y86aDlXsbft4D2emcYBSqBNYlIishYZWYs+FY5YqNOUlEt0htK6NwMBROj3ngp3IESvXFtaDMKaQYm1DMLkaHSwH4DVB4FUlWCIKfQqpVUrtCXvm8WJvHgQXQQhzKjUVlnkQX81oF4pkM+M+VVhAdL7b/Ahi4Q2wuNuUXAMeHNDCno/4gBa1ZDng4C3MlFJ4oAfN8aRh/tgvCLiSJdNS7YEn2cQ5XMn0/t5B6PsgbjqnUiU6LwAu1lwFQW1s4adZxLFV85j3M82rJCbLSto9fQR2gXhF/YwP1pFdX/bWwMmJ73SxuHc0zKT9W5piHCyWwn5ohx8+PqHeJ1iONZdvBh0Wagat+kSISbm27P2lqidSeqHx8KHbozu1PozgPk19pd7Q8oE3PPtluMaXezk3bjtC/jZT3w/tFfa/wKNwdMGzcCbCXh/HGGbEGh36L8MdTEFK9sJmvR9d9h740AbVnuHNC3UKjHaD+IogaacffXfofDfQlsTaBcuiQ8zbgdyBrt8M1a8bzv132xlFde64e1Ko+Dy9Rps3AJnKJrtY3gcOKzdDqhhjJxNlzWF+F/9d5MpacfCxycznTSUCKIffg+5vvPMd6PBgxa/xHURw8wI3lDOkiQV6GasRZXRKWfwsKVq/GZs0JdqWYGbhkrQYJDCVTzxh+GjJCMIuCgomSLw7VjQW3qrj2uikhU1RpzeLAul0TLSjJSstLJypj2V2uZVX7jZ0vvcOOXDnDEWIqbbS301hr6k2KXC8Ug//TFlfne/nywpGw4rCcMOMLeUrWDuqduFfRNLyJ6tms+ZJG0rFZi1FQmSAs0G6euI5Kuk2zf7F/VqHOwIdGOuWur69uBxy233Me7Q3tCj992l5RuIG+AUb2mOosviwBLQz9O/3jixKRvEjD0AMVv6epy2IOjdHY67BSgHoJDYEMIDr0JDAJZAA8cGA2M1sWAh5rrxcoKYnF7Yu2WW29o6Y6JPQNpeZMyUIcBaW9Kb3YsQIqY2CScmpY6MDCh/Jgadk1vDjD6qnybfsD4K/9E7hbflhO9YfbvvlXSEcliMpF/su/MCAVND/8LFWGSkAkI2smMMND04xf3Arg8QM5lQC+t90B9I7BXoW3kpOcwO0wmRmd6dlOCTlfqS89mdJpMzA5vThNH0y3y8OkFCiUoN1ck9HBrv1LR6uXfDb6wTK75YRmAcRSx0r1sodhFe/hQPJEonc1KFwkFCRLW+oMHMSwWerdLWypAn0NiT9//bVcc8mwl+v5pLPIcuv/8p4Aqf1uXFvx9/OcltFQnnTfAFNPSHHRgTXb7O2f3/BOnSGpOwreZuO1RuHPAu5ExzuCI/hMQb0fu/Hzn1sb3A/Qf7v3yhA2OXPowXBJ/C4R0Db6xAXffDY0VqaenF7T/kDFw4NqfAIJgVdYuECrA2sCllvOkr6idriuC+Zo+aK/YSl6YpM9BshIX8bV1gzbs3D4UqnEutgY3otB9/wA7tFyqw07lchxUqNwmcJjDtZOxgIM+i0SeRaPOLdQ5yEPK/VXzrZ6J+uDbHL1bT0Iy3da1xmistbDU+e5dZ3knMDuvPCK8IhkKNRqgMGDCMyb+otW4zOGyDQBQY1kGRHQCXsMQiCEHAtw16E0Do9ZkevtuQ/kwkDyYZRGAliEYfEEYAiAEo6mupq7OaHj35zCy3MYJYea5zoJ8pQwAAkuf+Vf4Txn95giDymBsXNcXwwH+FFDmYX5VRpJAayMQvXT2oTXF3z46MiRI9K3jxJQDOtMT6oiUdQo+623s4ublczuNRm5Xfl4nt6qd6+U8n4RlyODm4PV6fA6XW4QI5eALOHagNfOqt++MF/cpqHfoKN0yYgC8U5bujRLzC/6+DfAit14BXno/fdkEXNxyRc+WnVO33bEdPxd7gdY1WTMJyztuACDeHjM7FrdiMj0mYHZj25c1yTMWz54p0+HfQf6iq3SmZ+TnepuZeER8qk5OqrCl1BBU6nqq001ZpNFQFzvdC6nqzFQ+NiVf1pi6QVRdLdqQmipeX1U9LPK4h3jlRcJ+m4lUZRMK8mOMByxEht6qXZYY9QIa/IKKVPiN1oPMlsAUe6UIRFTS+O6sxZWryh3bN7lKTfVMeaqQQjI7pFfhi82lXVjQ2ejotzHSpWu2NZcPZnX9gPw8kU43OogcjhVH0dP4ygwg0lpAtFsp3M2KxgNwnoVMdE70JyqIwEUfgaQ0xJFI6sCHzBBD7sHsYSW11L6oxyCZof51pStMFpigovcsU+Fypsyd2hMrwhWaXwZc4SxltzQ2EYNJjI1VLUEqYEipb+Vk5bI7jUZ2RxbaiF7fMgwdqFMgS2fnZuA1hrh8gaDkqw2ZOC43kzTkIc13Lb4RlUC2plBYSVnJ01XlVzmug/H4XiTvaDmBojBi6BwLmWgF/Pw6MhI4Gf2CjMwEQaPZ7AyhkO3dMl7TPkfAG2l0vKFSNJqBSDCkvxsTjAYCm/A1Nx6DiNqaR8+g4K38HCOKyNFxwquS+W4ZI6SzcDo0joJntzrZZBKeDPx9n4c+Sfe8YU/QrfTUF54X79+d+1R7CardAGsk1jAW55s0YOP0k9q4Mb0JGzsl2vwBBHofEz0FKLQXVxdrg63aVpzOF2h2Pus4e3OahWd5zbI3DP8rxE3PT9nQ70hLX6FP7suZB6KJn2Yv5Bt0DYlv3EhVn9mTvdqRMpKBP3rruqSGqdRUSVvL1iFKA0kFeUVcpbyEL8oTy4T5FSsURfxzWyaDdN2H2F6thcTs/hob+9uu2pqLzReBM4sam5MHZn6j7jdg6M+IS7L6GV+KjPQC9PvaWrmiRqNRLKhVaLUL5PIFiy5bILuWyXMz6R4+vwjcxuN5mMymMzz8m2mejhVcQVkUx+CxcwUEpyDHiaEyTczG1PcBpkiX0d2J8Qpb5NbZ6F0Isi03VPHjz0DfqULcsuN8B9bh2W3BO5Kf0Pbj7rIq/u0xyFu+42sD+Z6TLnyreMQ9fOdG9t3jX+49wQhn//+rLf4mWq4cFFA277Ggskf0LXi6ZI3mmK4YsL491VwieZ4ntut1qvT4Wct3Q4+tw79zC8BdoY870bZnaad1uk7Wf2B6Z3nn+GWDEIOzZONzQaY+SqtamGT4PkWlhdy6rM9ctHBiP1hXhvBPwi8UtN/05Kf/J3He1Pe8KGwnW5VDtRiGbqpBKgVGyD78bUWgSFwG/rVFwBjI8I3GAsOW5F/bESowt19pczBS578fa54wLk19e+t/XIIxc777j46hQA5EmD7/Z80rkGzZwxfrbWFkaP29cz1YgeQd+qdvjsrceH4Qxaczt+Vvw31fXSj6J563syrZwLpjed6Sbt3qH3eQ3ugq6DztPmBwzxvpIluWPt1cYMD8tlb1rPnD2rRnzS/AuBkwv/LzVAZnnT6s4k6yT1x2oQeL3cEYIOuQ6QuZCkzg/zZqjt0ZX2UEIAT8nj4BAH8AIOlqiCJrXNGelLT6TvV2tMOfglkxh+fN+qklXBWrb/237q3JJm74zbAVbG3b7u3m5u0WBY+dZFe5O7x7DeBCHf4nvIW+gr6ZvoN+emF6kSD0hJdYSOQRzYw+xh7G6GJ6iSEd5CIyk6wm1zDfZR5eur2MUR7qC9QCaoR1YUWgW+hf0wPsgyszxyqZnzNtnM2cq8fiq1q2hf0tu7Gk/7jE/YLbVDp5vHiVmvfxX+fr+VVl28puXM0IAUFtwjC/eP7Q/B9UaKAlUBm4IHBH4HiQOUgQZA1aEDQY9F3QZFApOCQYF6wKrgreGvx7iCkkKWQo5PtQLHRJaH/oWFh5WH3Yd2HZcGi4Jrw2/Fz4HxH2CE1Ea8SxiOeRfGRrZHnkSOSPUWKUKqoqaiTqYVQumh5dH30mZm7Ml2PKYvpidsacjLkTMwuaDTKC4kBJoGrQIOgE6DYoAfpv42NtscJYe2xu7EDsgdjvY+fAcHAiuB48Ar4NzkJYkHRIL+QU5DlUhJqga6CXodMwE4wGM8HKYBtgP8AJeBjeB7+F0CCUCCuiAOFD7EP8jJhBBiJpyBxkG3I38jYyh6KgslCLUEdQE2gauhl9AX0FPY0RMDWYHEwrZg1mP+YmZgwrYcuwXdhrcfo4S9zCuH1xo/FEfHm8Or4sfjj+bPzz+CQOjFPjPLhO3AncdTyJb8C78cX4AfwF/BUCKHcncAl1hG2EG4RrhBQRSWQT04ldxLXEX0ig0p5EIOlJlaQO0mHS76QxsousIReQa8lD5AvkGxSYEqAkUCooQ5SzlFuUCaqRSqBaqA3UXupNapL6L41N89I20afS/0mfZKQwljJBTCxTxLQxa5grmTeZ4yya5WQxWUWsTaxrrAE2wubZRraUXcz2sZezd7AvsS+yb7PHEuAEcwIugZdQmrAl4beEJCeIQ+DQOV7Ods5jLsut5eZyO7kD3PXcPdy73BvcEe4c9x/ebJ6Oh+Kl8Rp43bwzvK98CJ/DT+Sn8sv4y/kXBYzg/4J+wRbBUcE1wYBgQggLRSFT6BBWCTcLDzE49829I5ohChUVi3aLrojDxeni9eJfxRMDIiQICU0ilVjYv1+9kg2S/ZJxaYAUKsVL2VKN1CUtkrZKd0hvSn+RfgiaJ2PIxDKdLFu2QLZetk/2nWxU9k4+VT5XjpYnyvPldfIO+Xr5b4q5CoOiXXFIcVZxT+mnFChLlYeUz1RBKr2qX/UoEZrYmfhO7VXfUr/R+GvkmipNg6Zbs0azWbNfc1xzWcNLxG8HQGcKzAL+AADPFHtSyYMeSJ/+GSjf55rn6Nj7tlbtuqunPziDUB1aBjhuQVRaKwgGw7iYOt/8jVyMUKhDFHAEUMCJrnsIzxGv7iJ3ymy9rH3DEwP66e343wXtV0aIXF+W+ykcJ9pEVskW3YxrrvK1zuzImClBFN346sBvK54fzRPWrKJh7WNFySAk4I0eMAaE2RjPmBg21yMULqxrRqvQ2KXrUomE5M5xVggRk6+3tBc4szsXn3guCcMt/9aA7864cNF+KXO2cQzM0d2mcTHYNL9W2iPJKO4fq6JARiuHeVG0Mmz6cuImbsCChgzkRi83CHOGREomamkTR8wRONaGEyTlYwxbY3aooOXLOs6PRlHWH15CERhGkPsMOhWojK5h4MZjjUkJr24YxeGzONYWmSX4Q3na+fyWpjkuo9GVXc4/09FChBvWVEVR1ftc7aTUTpCJ9+BhmTfbyBmj06yUC0G9MYDgVPzBvVEtU5npQeeiwk7Sk2j05QHDvdDLdDsQBEN46pQO6b7T2MBVieanK5aFdf1f51M6vh7O377PgUYnPSqB96/hPx4pfH1iZ40Z/f2Tz6J32/Z6KTqn6PB4AE36lhzN8hLnHIdklm+5qJSbLZUiVa1JeuS1sXRlj/MSRKDIq+Dqa2P31C1zacA1YEdD9F7HlmkuOhcQPQsI8v1hvxd4TqKc46twY4gbOXplWo6xPkYxAJrT0G0H/ranqMNfDSJIk9DHrvJdmC8LQvZC4PKRrUiEm1KrQ18gGPQs8d3oEa2RIhFD8agqD9Ub6b2Z49MgpIXOOf2GFF+UuccDw7r+qR/iOmQOf6zA5sxlDS1xdeQskXA+1m2g4XhRLBrDMK4jEng1gzcWFWRnAXDY12OazRarg9hkMGj5kkqZDtIzKMJhvCnQ3tvsD5iz/9J0ABzXCW6KA09c5YYxFIQzBpFRiciqjguhUgnf2EF0rTfGR1Bg2Iwfl3J73LidfBQViRZzwtUhQxytX5Eb1TIkPYj3RkC68AYJy3LM1ncnRQO/IgtybIXuQ2J3omET6GjxDgKQdsqePzJcwbCXzaedaDKfr5E755fMJTEACwn9xg3yXYdXOu8ay2D9DOIIHZTqzN69trfekkYQF0Uc+zh2XSLiauXSfu0QV8B4EfNLTcxera6e8mzbMeOt1lDoiC11QIqi06+rfHLE+SpyvFqVvN97w1j2j2iOfpWLgHxclwBMUbzGcbnK2MyVLDw53fEwVdvRap5EVmvxdhSXriBhGjOp4U4k46LLFDWG0mr17j3Yk/s/9fJqy4h01Pdk/Klfqz5elo7xEoA8isO8ZsDqmx97mih6E33t9O7w8LSgA1jjNu5nPDcpQcUUfBQkgEXl0nsdu93gGAg+mPG0ekEXY03A8ydoOpt9pNzltvIlsGmoE7x9hNZN6FFrEex3tDucKaS4SxMMHBWH3HFNzQV4qvz8dFCtgDikgatLuwSszVbdTQ/VXdT8ZyD2XVGABwc9UYnnBRZQPmEz/94664k7+EZvQTMWbsjtoppfi/j78Z3iLQFQ3h3VfD7yibJMQSN/idjoweHhDCHKxmzeYSh5at+AxIX2l2h0Ux0IVQk7dFRx32stnpaF/XyVQQXlNWoUYogRKTwrwptPUxJQPuKnPqYFaFfDzb640HdCOjYE1VIw3zJt1hbGcuDELY5K+xQ2rRX4/eIANUVtMKsQ/hQ1QsWbtDvJWkOYp2FqnQXb1XkI7PBAuEYFSSeL0u9nFkUXewBpbNjOl2/OfT+UafeHYESzCxBolmsjOWcwRpIairVLQRxnL6mHetWr3Jpd0cwB0JqahnFVGwdWscL3vWEclLiXSLfwyz546DwvZxHuocuyw3MWZ4YAVE0P5qX3jKT5TqtWpbL6qd4azUVF/lEUnC5r+AYZm1SJerGQjTO9KlChnnlSkHQN/KS6QDAi5a8IkynvGrTK0njiylB2g+rJZtPNvk1eRJekcLoPDWYDX2rcGNEbMyA3TNyM+VbP4SZdATw/LNjnM0c8nPWhUxJi/xc8rSj6T1+j0lph9M/+PAbMYurt3xJOkrwa5owtqrp1DAF2UhKRlW9BpOYQS4PrNWHeeZl0/+AAhI9Ki4KJfHc0lgY2PYU+uznYwLwe+L5ze2oKnOBZPofbHUJkdmltVLoRRMusdKVaRQjZMWZqO8adHIlgppVfqbHBs2ygmsQKy7zyzop7OxB3ZrdviFJyytQiM3hsiRBt1UYlcQ0xPQ9Tztytb7qKpTrAC+nWUGsyC28Ki83DMRReR98oHwBwdjV80nnFXDud8ViJRCRjES/AyZyJy5EKLJKglSdbtb6Avz5BP/4+UH1Zscb94D/6kctgVpf5wVUjVOxIlAA3RTI82gPVmUbjaaRHTHd6A8opTk3HYZhgzchYQnIXj0JmkxR1gEZ1XqMzsBj6W6KTjLjdeEraSrpinz3hAirsUKg8cA6q6Tp9IQzWOiq0ketGfAgLuikeKazN49qSxsHFPa8rXkhft9jkDCMULfAaNXg7lB3JiO1yi9L8AUJFAJL2VKr1B6eo+CT2zSDq02fdhZpHcUvhTO/osYZiEjqKCEKxA7L28wSNnKHOjsw0LcnzRbRl87nrk2Sd3O3wHCRX5etu+qpKPPb1LddqJ/e5k1GoqqpZG0ax1cYyr9R4eob2cae+q1vjHamT7/lIWP27qrtnuNZRR0oKtqpccKZTiQY2rKQ5uLJvuYr9O0JeYEsjubtmmhMVOTEuCWF39+49oL9EnCoimOP2b/lg9R6YNI79D9fmo+Rm6LD0gxNWvBSoHNvxr9bv1J4YpoTZEVDAx8yFHL2GUhBeiFeCD3V8LxXrizknAkWoQgt2pnANEMEy1AuDuuWhaUlGzK+MTBNfDYLWSE3cN7X7sdpnfeLvGWI/oQJov/xdYmRPlZLnMXdhxIR1LcRrvjBIJbiAqRkzAGp8y0W3zhMPwr4yel1CK2OoCGJbBKsmeqP2I+KLU/Nh9OwGs1dc1ixjwE6bsNu/QOj6musPZZPGqo0e57YEF1D6Ujsa2EV0exDDPVRtDI/Lvnxt0bZONu565yeQ6DRICjTNsDuotzZMKCex97Dl4adiNY9LgsO4jCgrZjBYjLCrqhZdwqP85bJFIYlNJTJOOl/nxnYsU3ZF07cgrsSGP5Zb6mej6Ox837HSfLlOic+9VzS4O1NOS4pih8UF45SKl46hYa7Qkyxlx71elzLwBJbTdNESoQ0rfshWknNvibNN4/kNdojS+6zIrVbGtDk/2PSMNqTBVcfxoiOAcGd43r9gHjBoPa/sU2FSyelRSR5BdqTcL6of2f3V1bJPILtm1RxvYm9FVmIg6h0eZ1yDwprVceXS3geK8pEXuuz9AmXEhvUl0zkoWur7sg1WDMszPBVBOvjq7RZa2sGbWNbpKcElbetStLw4mb6eEMdYDx6m98pzacGdq9W0A+jbPK85A8h4VkCyr6kVZR4bTd1wR9HAPwh0z9Ool4ZpSCrp1sEYUVNE7n4a/X0Ar+GGNr0svCUcbmRuvjNr1vdNALlMtfNd7mS1+oSqb+QUCBFIWePTObdcXSDrNrCBtAiEgLZcof3ojtsCY5MvyxkEjS1+zh+78oweYjpaqTQx0TLuHGzpd7LFPA/J0jU2eO6AYGo5brmwZ21oQH2BzjhjTDlwSNlNSUIujeC8bClJopVeT0FSOV1CLop4wBfcYAeFisXl1hmBiyKPt/9OsCu/0ukRU97PnHGzKN7Ir1iNqbxDYmdzhsHlTf1EWb1F1OAx5fM+xU/PZ899Vx2pHYjK6uGX85Pt+7MnNbNt+jsLiOVK4TxL+PQow5XPUqlaspBbVJnkJck339Y4itISIViJ7HFPRU3IKFtFagw4c0FktQ60beXcsQ5FtlEdmilqHzV5fZ3Sj7dZsducTNxm0Vq1HL9S3h7WuYIIpxmuAz6SNMB+K/gCeHyvWCxk83vg3Bg4p7MgKv0mhcJ/nUrdcermQs62Qf3tMiXAU7v5XbebcDJ6lkkMbVaLqefFZcdOgwEk64ZyjVeHILGlupV9wgwyYTavQXmbDqydM7xmQuLcdhOyO6OfNvvAUZmD+gtqZDn2YVe0VKgxAOorq6E3riS0cVInC5j8HJ3Bl+CJrppOjOirZQBSKrMKeFgVl284yOZqJgDejymkdCfBvFDJXnw9j49FktNeVcBOiGd/gzA+8Iwk/SoKIoyVO1IUgfJy+l8/V7bcQhybSYOe7cyux4PbHQ+0LYiWnidxOBhOb3tmRCKXj/X2DgM3P2Yciwl+vGFOrN3OP06JtJWU42GhzF2v77/254F9P+XWhbxbLk3Dx7hlT40tprGJY+/ovMPaKcGfIno3b51WfXmwHI0F9oN4fXV524Y6IjBELc7cvEH6jzJg3HpyFdzBZbxS6nLH3/70eu3PMTh2fu1eJMIyjmPOAGaSxij/3vbcaLSROV1W5i5LAWWSXhtjzmXMFuy2Zk/QXF2swgXGwDKKgpMWmq3+Sg0ca34+oLnUcWc+sDAKcZdHWAAmun2UAv9zIObqIjhQr6/rGqcSMeL8Ltfel1abmq7hV+XC8mzGdyPYzACCcRHmSUeAkLDITOY6DTEnQzskyCaLiro8tofPrkuD/WDeLgjKOKaum7EqVAtCpiilP/xBDlWmSKmBgnYQrwJ+lyOnwemQqE0F+UN4PbAwyhCl0hnCUFmbJpZCr2R554vSWLyJSsp2SsMsv47lY8woNQXmGiytTQeXMaNyGwvQHIej/XEJN/at4ltR+CF7lIPeDspSe/kVtw1O3JSjbIlZXnHz2G63s24D9bVpAzPYNUeY34JK2SzE9cXaobPFWwWodsv9bAgRHYQChXZ2nQu0BJ67ghfE+agPHeWIvwTsgK+S1LB8b97xPpMVpZUuF3IGW903NccYMSYcG7qmnRHvX6ONt32+Sd78YEV2nzobyiQCN/xr3ofnLf4KoTheDM+Bj2QS+pWOj34CViPwdKdvkhnXmeeb5/HcHLdKSi0n18wN6UY099tHYRR4uue5Ytpc23KrnXBqc8wSZEJNZ1SOA5e0TTuCIMaVxCgrRYH3hrjyuWN2CGfsZpp59SqjwVC4wzqsXd0KrlxdwM5yEVE0Z8jSoOtb86NrY7LId8DXCmtiHvHvlu5UxDUtO2M8aJjuvipNH6IzEyRqNUWGC1tGeE3Z5oWuIDNQSAi1CwggcffdE/byOggjyM+gEKEOxekrUgJdjZnqJYNk2icgC7RTFJhwa2fxFjmho+dFrNOckLuyZ6eNSMoBRIGL9syFjgFuwe2u7SLoXJxNK8aJcAEP09pTVwdtNW6iN8rrwcUxENRM5pwyJFXjJcwl3PdE8X0XxHOwQHECGqvp0DkMg5USywbafpj0ezGSFFmlQzXyeKMwcWEBXPQVn/9Shv8pWvceIHZJsm9wL5jgqiYERjSmjOckZOuppEDQMeuuUzWX783sKEVFMJT0QbkobtJUSxzU7y+JIMhpqvCNJEAfBqRSCEKhbGAaCEYubglrVSE1tErqXI3pqiU4CoWJ05elBTxB9WEwom1q/0K4wKPi1dI6tdZApTDNy1vWEKdWSvA1L1VAVgeeSAppyf7ybQawYXe+N5jw60ZmLi46Q6RgCIKsm1pciGdJiHGRqvsbCoK8sujsSjmCMlYeH5cnh/6n8XpdsLWDRJpOrDqlem/y9b6u1kf+QKtrFSXEsWrWxRqPK1CNOSdjcGHNWZ3ynvmL6CM+dIzeRp47YU0k0x40DyL2QVAh6XrZHmJa2TqkHmsl52XA4+VHT4uiNVPHqK6MTLrk9V5FHCB0PUBmdVWkDOUT5Yy2GAqNmadebboCIk7QF09VwVKdn6nnEtt7F3F4DPmhdNSWdoI78ClLAO5ZDDXVtYCxpA8a68isiofHMBO3Sxuqp4aDiE9H83aqOwlc+2N3XqQrqlhcre6H0oVb93OL9VEpiS4ZafdX2UM5OWlORSp/yrzxrj82fxDbmhYjclP1/nnoLUkGMlNHrRCz1ip0WhWsEipfrIRfsemAHifm/PrB0Y4er70qQq/SxYWMhIJ8Zi1BKFzMSSa7/CGZrqQ8NS3b5EirRxYESTz4mEzT5SIkgDtLWsxNTVO5L0Ug6L1CoDN+46zupmOBJj/HxGOuTLgrCxZgWYInM3rxPBHWhmzF/CEcxQdtXdzxZ+F7H1iysdTcnEriuBvOgIvaxjc/c2aG4tpXnC5qhos3n0osxf6g0XT2/MMEOMQSSzDnou9NJcOqAzDgDPpVftT1JMqWdUgajfm1QJPEsBDeKuYHKoVE2ISH4dPsWOmbR88NwlKltA+CRuMRLhNjSaWhKXmkEguiYHiCqlzUrFnKTrUUmn1Vmo3EJk94HTXXoS367csIAmawIeiekEcN8HR/jckEF4auJxFxAHYFZtz91r9rrMcuZ6cWVUBr6h1wYz1kPDzLVjFoDhA6y2IOeK9aD0/hnOWl9NVVNzHq2JCn4EEUoBds/xTD8lfugj49v0qfAquYEq+Nl4zNmC64FHa3Zllc2TKc1wyTwFu+O5GGsebOBo4xtctr5oUX72S4DHvhv1YINAS2cGnbsCGn6lM5yvgSD/vFzGw7zGb3vPJY2ey2QwWHfKwxshVzZcoJrFu3u9iEw7XqtYrP9M6I3h1Ced30XPD7LiibpeBGmWFKI9RHzjlGdCug8QxzcI4/CwiNvjP1WntKd5wRHyomBejTGih66nd3SahbXX52tQ4usLVOt1Vf+kTvYMm1b8AAI+4Jr950opmLjI19LoPi4FoN1XkFmKthtHTg/zkqSP4Gwhoo6Uxk2m8Jnc+cLx73mO+Hou2apBqUTmYlWMPEMtc4TrX7PaUzPJxxXlmeQ0eIiKjOcwUWcCaxxZNV3rZussO5O1Cr1tE+ihMIqJXJlh7Im6q2yr20GNtBHP5DscpyG97CfVWaTTIEMcJqk8pi1BnHBaJx460dWIo45mwn//0RC2hwK2KO2tINciJJigYZxtcYhmAXj2b3RhjG0MPr6E5fB4S0NhNfr7HjUWQUi1Xiz05cZszJY1fDdKZVzQwerqMWRdyfAH95B9XMLlQvLoo8tpfGqzj+iqVvLnUX4sFaNqDpVZZVrEQx9rnm/jFFpVlkGCdrbvQaViCf4qB+QdjKOJNiiymS0jjds3x38qDoOUjcrgJ3zHLW1ZusxLROQeVS3i/W6/0w5ot1JMU01rZ5j4/CFEjzHRQF3r2SebWAaVL2AWulxJKQ+/pAzK9Py81zEjCNlfTNHsPvFc5UFLrshhwEvt5la7oGqSwNaOLy3J8wvDxBFb3G4XMwVeWjO7adh6g9sgtyH73e3TwAFVU1a6NI9Bpr0KSIe4cQFcO5S7LpHONEP+KjZO7fSd3z0arvOGpcYKnKBS+W5/oPwmGFZU/yit3ou1ztByWRqzPJXX8gHSFLRNc9A1kPHfZgUpKjXtV+KBaE5XdRip9AqcKF9cgVlApdjW63YP9q+2TKHqp1dD9M9cDHLIXWpg69XlH5OowUJ6VdI5v7awcgaBLPimnNGyvrrzcjkjd78FgZ5sATAdHf85Z99bw8cxW7GBpNH2zAKH/aasLh8tn1SJFyLN3siTm7SwYJWfESJSQNDZ4Uv6kgiM2Er3/kIDpHmKKRi73XupzWpx4QKa6bGQCajOd871pFP7+bjTOGrUPga4XZPCh7OMtLZqZ8USDy04QyL7nKTSJFc0qRDfPJ3ZvOg54GAzyucM6nzWTWZsLVPcIhBt8BNk+zWmY0IKkMeBc2mzGrSQVkiuN/L4Zc00BOuuPCMMTkqIY9vcr4yJAot95orLScVS7cJ19o5y2M+6xvLB0pFtekywXT/UrrnafcKq8WxtJ5ihK0KG9sd2rbRwxyU0VCiZZbPKcu4LAFmT41lXauEZc34vwsOykRzT0n1mjMcHtyYP8l1MQmE3TEgKuODipwGkIvY04nW6wWIu8ZJILSPGaqYBnAkzu81easQwiTpz8noEaV9G9CT2MvboOotzlmVebQE+9QLBGP/I7lBHbRu+bcfiozZ4o0DHZRR07xqB8YjA1mRVkXda5abTXx+srpfSfG4qo3qD1ZovXwcIZ+EnldeodYV27Jnx3OpQQV1WtQf9ewRXl4x9jDBI75enSvqR2koUrBHJ0xyt8SU729wyo7yx0u5S/abAv6qhVkkScIPtEGKKP1LxBA4VYiOUtAsD3KhH2jn4+8Q6inTlE+BIcv61Mnuoz9WXXdQeJc3jkvtVbBWs1hVgM9WhHWrAZozGxX7RnaWrpSUjqMML2bqHI3BUZxNK8Xwrz17x5186Byc2+ras99zh3flDqQDWy2MKpFwTueSLay57VpsNVPCBYa2UvV9RAXf19cig7pPAMQWVr5Q2uTxaEnCY3RJvssHsgyOKZA1lq5Q4RIwiXXRS8ph5IBBpgxjZtfoYqGcVGrCzVLlZWxu96+F90+tcH1UesKKPCULdeGwjuNOaVf4GDvgoBru+apjNlm1MNeyxzeJ/rp2vk61O8sa4C4rB5Ot/3Sw8WTSHbOn7cRtT51yuhDxKcLQXaSqQKKvGAVVebIhQBkfrW4SxARzFupKO99b0VjqfJSlNa4E5AmihaH7qMcleJYpo3leEEeIeoxrxdN+PL7njN3lQlpX8SbLQp/Jb0/bvJFMclywhnwU9gBzHPyFPbiDlGDW8Ut+utyICov7XnKkh95X29/E1Z1adZ3poEpyI1iLyebDaiBIqXpi2fvZ1cpVWqZzcDkaeHTbaH6QbnBvw8GCYJ+S44GhACKR2uVQPV3luqGOVmTmJCEGuO3b3WtPTtCkPn2jz2LRMDuIZA+V+vkkhtcJ3ZMV8srVFcb5JWbXycWCrbDwupq27g9NcZO7Nr4a5WF0mp94qSm2QneLc54A6AA7ilAh57wT5+ODLFOguV0XcUxZ/2mM4qANgPAn6P0ond1HsfvkIIXJM+YyKRoTcBTqmLvf++f56R5uUzZcSa68owfAaxYGnE9Njy4mb0Zq67AJ7iU0+ncHS/vvgIRMoGxGtzFBkgvV6aNAfPI2OjUNvD8Wxp3HiLs7SDc4/KloNcxq952oaMoW4zlwby6vPjphhbywjixvwh9ZEdsO6Tj5950tt+GUwfHY7/1ZbOaDmmQrXN1W8RIyzjOpXO6eMOhy7mWFfF6WVmBWvJ0dH3Qca5FQ5xoWbWnZox89TuGakko6XntKQopTub4m1CzdOlbSd43AA79ix3dE2JU8qXfWmoTsilOi3wXYgP2Wx3IlIwbi8M7DcyDWgunZ/XxKjZHUj1nKEUr/u823QE/upLN7nm9JEF4QjdIbKnbM+RCqXbj6RrCu2NW2qpP+ccvL8DPtL4BSZ1tGg2D5Tie07n9rNrGho7wIFU8nyDRNjJWLl6F4pAITYzOthznOU2h5xor6/ISBsMaguYoOEVv4rWB1DxFnDUsaoOatXxIEoBDimilJVdIAUEx12VsSsHilcSda+Y7apWsoPBUWmrcWl6n1OvUSGV1d+XTul2/+Huwa/02/eOqIQEMgavXo7CgL3q40KPNCotegU23K4HNVY5fFKnKVjjAOPzrVlz42/Tf+1hT1ykpAcBlr+a3gwKeydQxbIrs0Hov/mkClY3uKasKK0pprlPAjgJGE60uir72SF2WUkNVBQZpD/JD2eJvbw/UbjFWq6TUx1quWXa7yEX/VCK9r1pNvWtEHWYd828VeLC+g758mv/yyYeJp8Ao6XK2O4dIe8LhZl4YnMJfLch575qNE916CWI4+SS3hmAyQToA4ltubigQURpUMgekjgCqUyRl3oKFq/Rbc291L3v9NjIa9yhbASDp/+Ntp1CfNpmwWSw2hZipySlp4VQ+8NN81gcWHLMZOehufh/foNl3zUpRSod0np2bluRMF8RjK1I8ZW8yq1Pu845ywSkBB3B9oDD1JQtSF1wfZ2uCSuYSTZY6uWrZZ/FBGW4Xsidtq2ggNv7Nww2Y9OSQDMgognprkrX21LdzLQR2v32NZa9Y8Ei5ftJhNQ+oiAAGIGeTg+RN/GC2+aO3GmErjZhACx5PdhitLFMsjvJXe6tBWEHrQ+a7xYiEBDZwq/Z4FjHPi2u8UlHKczDkG4NbsSdI90rXhtp0gpBaSS8LO7xSafqtSSRQEvtEz9KDfTXSZi3ju50Bdqvd8AYenzdGOU7agMDg5hu4+faLbQ7I7Q6DgYpbQm2HAdO0NeAMmzRYxppad2BeqzLeXteADHbcb+LA1v04dfRYZTGJpW0ZCfdODHvnDWLgVED4GDw0Ay1nOLTnYNfzQRYvfhYtwYZEu7ztdcmes4cqg01Pt79G6k0a0M4UNlG/B1fyIcexDANzSkvgw6WwGKOCez6ImEh5yhDOS+uTBAFPEV+OjfEQEReO6T/V9DetL9arr6/GskeJ1cX2pjCq93q342sT39dkgMB3jbWqx65FbLR+uGKMbIiE2qKbKIAohZJU+nDWnHdDhYb/Fna5WlzGzvPv2BuMH4OVrlz2S+9IhdtMOwgAI9ubml5dbX7HsBiRwfZYQpCY4jPf7dNfgPciX8dZut40F7PbBljDbU6bopj7gIQtKYVicMRtUeEKWLqLkyLKI9YbhNpfhkvJmrjp9VFPFm6jUJUEInpjTmIw2Q+k9rw3728H049OEZekYMPBd3cQZlSB4GWrW5xh5ANTITI2flkGAs3ZoLlhwLkMIHR+CtuGFYvbxjnglGL2Q6BuCcECW4soGp1OszoIdEpEd+2EHoZ1ZluAOyX3KHG9yplWuionGCWp/KnZHFNGBiwhrzTOgrCGUVXWvCA1CHV52aJcP/A0VSMjyD/WWIgk+LwUnDUhju24sDJVxc6S/aHJjrJTVrzy2yfgFE+zKokEVwwkpMpoqSf34ogZ6oocgc7lAA+GcQf4uKcZL6QwjoAvnvIF3SHMTo4509U4xohsWl6lZvjlWsf5Nr1ejYOvhfABbgIoUd+H+E1QH070PKII0LL+BW/vnCdESvWizGRKYiIjIS0liK0I8XYOYRDJQ+QiVHe3RPL4Lk3/XHMvfdUXXfjRei6QhoLSLl+5Q6ac7Q9HmI3l5sqFn2qqsqpyEoFfKzpY5itIbLsyp1UdtPW4psyfafmHa2Cq39hYS+SnEkpkBxCKG/3+neQGOPYN3Gbg3gsArcaU5R1Jq5mF8fZA4GfW9oy+5VHXxdEVveb2O2V3+UY7ve5YNEcTuM/kfq+Sg4lZVnh4kzqmiarM0ySkSJlZPQyZ/sASMqa62c/jNY+jVUYL53gXEPwiEL5+6pGG/vSJ3iOosjXV1DR1/sSpEuHiwXsGIQ4KhUoDDugEsOSrOZRrR4hY2QlGLDScazScWVMjnkkTEmucvpD4ZaH9f3OTZrKZLQ6PCCsPxSWaoSRfPMczQOOlC3jNi1oz+rYvzlJu4Nrl1Uwnk0iSWubIUvxuwLxFlTjSgYmSCgUmWgncgRUJZYcepVWwCewMY0cvFh9UecRxBvo13Onv9bRnzlkkMGikTQvn+kAKxkwBQYuoxnsagXD+86MMEEWY8xrVB3Zw/BkT3Yl61jQK0myeNicFH/gUf4gnB7A6nRiCTyrnNQNCbIw9r3PBaN9VXdM8D8Z5bmgSg+OcRbd0CdaTgE5XDRARmr7fNC2pG3A39l2b64A1f2PYrRh7NrYZL4AFhK6JJsw8ROltFrpsrvw1bPRjWcG47Oir6IPBsJA1OhqqggDd75mICibJQuybwFo8qBlMitBTh/eOOuBElh8Kd05er5BMN42OVFFJlCWBIyHZsYlIXuJeQnI80jMRsoGAxvlqpwVPKozXoZ7qoc0FhU7M8AATG8PcMoUyQs/bHLlCgObY4DZUCJufRsBlgF/aarNsevlyGgb2ncByHRQhCQYfhuQbxAjlFTBynYpgmBqaCergCGKAp1WSC6GtMx0CR94rriRT7tzKoaKJrBDyakKxqoj7kdYyWcsjC5f3gTRwPIqSjNsiQ2op8gTzlkiUyGODHEeGsIHbZ3s5a8MgGMeNrvINL2J2Mjm9CSvNw4MprLGUspbWIk1ddn5AvYREu59T4RsbBAj607pJzBgxwPJodbBtGv0ePbVB9Tw4I/8rRsFeNK3fIIoKzTT731DtYykWlNlWa43tN0QdDKi0fqYIiiL1DyxONv0+uf5kcc0TdFQjrhmSzIkG26JPhGAnAAJQSMHXkdFXK6hNri274fiHEbjC8pyudogFLXHzlgP5BvFTt1lGdCaYOXA0O7bKRGi+QEfmDVKpFNsoOIMdhot7liRhaDpNNTxMueo28f6RyVmBs8VqQ+EurAvnrMJavieTMNkcgDjCEOjON2hgxXIHhhC0X7hZ+p0uYpolWkmYz2NA9lelzVI0UidHGaGLSPMRF41AL9IfBBzAzP++QThO2vpl3aFSjdPGoMLEdzJDeQ4pFNDopPOGdy1o+ISBCjHO7H9Ts5ceBmetVoFRiTU4qFatlRgtOR4u/cjJZhf0QWZRDGObEpHayQIGimGiRtMn4YtGLwZFHBV9lbYa0JqiljrGsh0pjI8SNSIY4TIkc9awiFHNryYtEWxAxYkD36qT2KHuDBEXftofaNnA7CyOkC4UbhSyyWsxijkozAQfBAqpCaWQhukLYncYaTClTY3HWmUZAatrdi2eTErp2qDg6louFWMfPVX5XR2Q5oAxnZBnJyoqNNpJyZTSNIbXwqFI+XlJTpUSuL5t3cgZwIdUBOmEew/+imqulEzzTvgjpbyEamlHrosa5kxV0LhnfG5lWVxKN2tSDfCOmyOTik5XLLLZPpSS78eImPuxmCfEXp1SiZuHIXWVPZvNaSTFNPAsO5NeSv1EqqYZeiGnEYqgNJRoVOTKNCF5zZi0pMZdO6LxSJMTDlWI1zJUTZvtkVqNRgFM7NlR4CluT+ezOsUKnaSMBVW1ZfF5NlPXGGtdxb3qjBvjTipiyR/dHuvfQ/5rnP3zFfWImsS+PSrcg0NqnY/ogrkN5GfFbUGdGwyykGaokMTqlWtI8FAptanasGox4aBLgBJWl5HNpbqGHaWWnQ9Qs+vGIWP68FxcFA5BFhh4OtbRwK2/zVm1qB1h7Yb4Wans6165U6Zu2rtEEkOlVlII4OlOlTUkG+zU6WN/wBGOVvBM4y7O19rZPtpIjA7fJYgXlxkFXSqJpF5Noea3lkoysFLwKxR0Mi+ITaDlrAlTQuibDm67sb85Y6JjxkmuMOu2SpV4H53DRkPalRETZeZBN5FdXq9WUae0Vqp80B7q5K3WiJODOBe0izVLunENrgR6lE8ZG3fMPE9DcV/7xIk/Lirub+XL8z46XRI5fleAi5OZCV2pZBm9WkLW88WZoG1N67ZaLtYFsRe0hTVjutENRlbdK47F0WgoOz41pX0dMxxvvETxawsVfHSGFErCrggfLzMbunJmWb1aRm1sLads0A5qwxaWHjYE0fxOsZaUDjRYmX+P0k4YGPso54E09VwB+AYtfs6xrKBoLEoT7TaC4EjAKOq74BheJiezcQqCO0JpVEiCKMq6LYq2LouSIIlzRBICYDnQ8lckncnE34S4BtAogxbwcI+OaDoIDWA2ylIMjhNVnWsxJPamY8QQ4ryO1rXCK9Tk0mTSf5zofhlvwAQ5CYEuflehDpfb+/anWrvZoNcZTNpovVYuF3KZisaLiiiw5wmDEjaZKRD15vH5/UMj/H2I3xXt3V2dnVweJH2tUCol4qnJCYnHibsJAgMCbQ5b4Yh7jycpvDBegEsD34V3Ox1Wi1Gnkqv9oarqypC/SlZrLRarjaOHDdPQTTcc0cNJU7fy+K+MrHBMYdafdoVFs6QzyUa9ZCYKMwRBMg5b/miSbAZ3Hdj3JPwa/1EE8NHFKRdbGplHYMU3IcOFLY2oHyGO7eXFo4hvH1zR86jYXJsLi1FXNOW2ZiGNrNC4bLKsVuL7doVHwYxYubpGKzAexCRxXMb2pmR9HURFwfAk19wSI8G5qUBAi6clzOrj2kCLyfHbMMYKcVpTGpwTs6eKzz5p1cAFgASelOGt/ZcssLeqyqjnHUifIjIy3jcFtcP3ksEfCtyXv6vEA0lU2EZzjMHRuhQ217XMlCfiqu1qArogGT3JDUwm+S7m4ew37AAQq88MfFXgJSnbjFAZbKaR5uExxp6KZ9vS+tWTA08qNRoU8FhJCksEYsXjVpgDSKQcgYuxLA9wbUBIB2VkeSxho6FOtVJRQsbethSmX0ZKwVbze7YgapZ8lg280Xyi8oOh3GAhGazGcdU5Urf+hMJQgpfLHKSZJMpfDg8XoXmtVVo3g2+z27R6/bJxhA3q4FI9s8DuxEWEhAqZbLGlJURwPkiqkecLhnAGRrC9cNqRGVIA7xVnLMgwqsitJExmAzzRF6QI0/Yxh+N9M77AO2MUfFgBNEn2V6/gOcqY1mMAu1pBIDWy3WbishZ5RYK6zdMoKg96alEoGfph4mievC8mqOjDYSKaFsvevofpl5CEYjLFDbOwr8OgebTM131aAKBBngm+BrnZ1UhNTRLYkZ+hg9f0GQKxkxkaxwggYqgIoCrvVN9CC6D8PlUutkJILcPavQYePQP4LYOKHEBFMicPwOyyqbUw/gVo0Ax3FkW330UEAh63s01sEnfqXN63LOJ9n30EntGsOpy/PBX6ul9olUmJ5+YUKyDds4M+0G88Kk5otqnMWrlgSBlJM2yb4QKmPU1GKoYa34wNCAQDT1osQeGpxLPMQfgAOWMp1XPbFG30/Ol1oXfQUP4ala1Z8C/kCGJsYZ21WrglXBP3cQEhBltxZlItyWgQSRmNEdoghigLR5FW72kC8ZT48pI5I5gkZshXhHJVMCwFIyoXBiu4Tg6OkSh6OdKU462NvSC6tQs8cH2RURbsWhTwBof18DIsK88px/fjpDhn6yIIoaD86BRnOk+FtQbxnao+YmVE2N/8oQxMvTcJuASGHc6DmUvLTlQeAuw4nAbpX81TCYXvK8SjHb38NAr9P7TXWict5ThJ5Fg2/6fjRJRnCUqDeR83uQyveO1rh47cZHqmiaAvdAhFTjSpnJMiqfV1jm2MOEvfbdaBBtphnbGxxRxsM5d6Sb3zEu/c0KjSjnz5yEkd2m7O6jYYJEOi7fGyOM0J51229noqXW1zi8thHHVusLcbb1sqW8XfXKC9cZySz+Vg9lW5aHSI25G4VY3Dajuz4+1r8XXeOjodU9CqeWF7xUNfeD2ZXuy+OMjPjB7AXm5Mp2x3CauRuPh6HtLBz0cnNFYSVy3gos4DAlz1rLniFD0XoSgr3+BV6OB3+NIoj5QpDp+p32thkZBPGNhW6Ie9JbN9UaJFnKv+orfq6O6M6I8JqlCVGfX6UXDpA9x+0yRyU8vaUCdBIKfptHJfPJTHgp6CQo8uNKbn7Yja20ezWIslREd5UnNdK+quXPFdYsbh8aUkLLFzcsjJuv86+ngN1S7h5HmstGfVKsb7GDyu1Np8UQDZmtqJodpVAZ5LnbW50iVtvY4o95GvcDC1T36xfUvD3E8brkQTMSBBgAlbxqGDcDl725rs5fNI0NR+U04uvicF5WoZM66l3BTezuOQBledyBrGsNtn3dMhxczPcN0oFMv6voo3mdFvcIdC4gWT5fWEQj6yy6x610Pt7U0ym8fSmH9p0eptqqtr8dGSRN+xgLaLtJQGsSnx5Dr4rx5Wj+KgWXtK5zc5xcAALjikLlOH8+W0ZwleyYlI+zA72VssBv4KtvOkREewpd42DNp5kzbbJ0v/HZOWPxrN5uD0n665in7p0V0f0393YwVkVcZW2Vh1hVZbXAxbdWo2PEocXICNjdoMuw8jbHCQ5yweupSImg1qChjRDIbzzbVDr0nxOCr3YM7AzQkg/GWzObBgZbzEQXGmuj6+3ZAnhDMA/BFICw62Gb4405mP/exD+vvvLnqa4ThxDDXcMUl66o1RKq2yMefHayJ0CVVok45lejsi1/ATgSd4j0mAmZOfGIbQIGKMvtlz03xQ38tw62+ZaeZNbswBU68wsjGj8GpK4tueaLTZBDRrkjFEBRvHFUINx7NpYeH4ReRBT69d3hMczt+sv4o96mf3tSGN/pPEsxxowp4Zk8rCzw/0xK9f4DWho33/oZ3eKR8LzSD96A/W7Ak+mu71zo920jdkyXsyNH3zSaOx57gpkDHKeuL25Enhm+PgoZxFQFhSFMYTneydfcHOWVM6ay6EL6DwrxLo6mhCsspMgm3XN3EuO2uLgr/gCNUuDuzAomfVHik0RN/hEaUlVLOhhZfD2yYFob+iB6gmbA01EbWSxGWtMAcXJjcEXCmgSxnPQYQ9VHhetBGm/KgrIOHihiGDEgj8zWDXiZ9vMZl6i9+/5c6h79casq5TnKyJpwgdvNU4KJPVJkY414cIcQLPvsO1TQXTEV8EGmYP5otODUsVFVatgNUbgdWzjqWADM3DKfDkDHlJVkgNtAkUL1IvScSszDIEitT3tHrPuXkXYJRgODkkpV2utGGPNHnLQLckaLjM9MRky4XLwOKDhNrYB7YYLRQRU7uIyYeb2pp8nAlJyDDmu6RhGEvwrVXu4pPJcotcMLvGKIautiMffEts2FBp7e14TB+KAQgerDR5L/jjkPRIawSN6DeOqM1s2LI9QVD/h606mKSO1gPy6XcvtEiKVgCOS1u/I30EZUdqjfx0doY9lCEOOFyD9qmEYdeP7QfcywF1YNseS5sA14mdmyUmUWmT7LCDzZGfABZc1svX/6gKwfpXr0+whuiT0jDoO+6tlhmFwFEn+8QnteOpeGpfM25I38YmB3JDlrGU2tlvg/HKEo+nccHGmLNGb3AwhNO0wR3A8jwJ/r2zuWNBHy+TfrHIb5k+ZBoWXSeA+DMUmtaPrNUMsEOT5LoSpBwdp+AWyK/6z9jijEf2J5vGBIUIrGhhV5QEtuyn7NUzrCXcRrvtDqfGxQWtTLsWL8YqdyKg/qkCc0/rSBV4ScuJn1oo9K11TKrBvKQwjXTsDPRtl7xVuygV5GDYq1NqmwciTQqls4jQUQo/iJyRKNe2YY0y0Qq0lTXaoOQMe2qzjkTVCuBqlNTjcVKC1nvjqXkpPO41xPAfmZ1OFqbMHgAHv6c6Td3bKiv7OfVYz7fgnFGMyurCZrasBUQNtuekE7v88QjQBeDzwJx1tJYJBCkE3M2BJrZeTnjmJ2+oa+BSTaMg+jvfeXr/Ye858Ovcx/QOwvpwO3C+L5SJMf2nl4+RkUISNOHZTrx5+9cXqVSnq/EV75D3dzdswPleuObjZwuhXe7q6DMQuwDozWrsnSB2XH/iDGjnNeSLoVsPvzQ6XX6NR2PVZT3gDbmz6v5T19z5Rv/4Y+e2rt0FSBHL+lx2AF2OT2SNXZBIoEvh6O1HT7Jz/Tjzip4m5tKix8/0vgjtdnHBC1wzKp/wnwe/ue1u4GvPLmwr8aUb3OeC1v0xv7XONpuAP2Hu6wObBQeFwJnV2Enk/OiP/t/fXan3w3Vxmw8+l2YQGJ3s6S3kAascDOV0+bd1295y+4eAfMaK6WrQxd5VfwzDFjr+wsC/P4ekkoc/tuecG5wFEBSBoZ4yCEZQDCdtETgGZj0zX2F8dDQNEEJQNFQDexmTjPvMMVZvtlDBUdlgXoWdR7M6hR4vpQ8BGTlMhQFBmNwDtByo4QmCFgZ8lDFoc2YtLae2Ci802rSVBVav5BtlsNuonpbbm5m3b1/H00tiTJVRswDQ939WwNQIKMT7OQf9Xa8w7stLa3ytv4fMhrcaMV0VNtAFZOS9VJ8P2HkBV75URtXM6RLz5433cl3PFPhWt7SoVHp++rAHO/WO+IseH/ccN8oXbE8zCpq9gPAMm/ACg2mjtcLenMEDpD3IW3qbteKH7SOLbcOVXHAI4fnrUraluPFLw6awusqAfdZhgGpL9XrdBLSC80gSkcVXufoRQnATTNDx1DQClZLACoNP7TyLloIyjiyQsu64I4zAKokiMPiTr9wA3q3weTM5KKc0RTI9wWdd+hnzKn6D2r7Aqb2e5kzvy6w9ApYaG1KeTIOOcNQMRMzRUOZrEVsNhpRlTkhZhdhXw/SmW+KJgJqMa2i8dufImFVbq7iImeZYaHatlNGJDPhjf/QswBFt7eMXwDAKLxUKtxhKixT2qXgggNSpqp0qhR/BDqblR5nAm3t0vHyJkTDTKH9negOgNhs/xfLh5fLgHEI2u0Sk0TQQrrDhTmFqufwjaKxunAPTE2s3oB8hMe3yxe9RV8o7VgkWOjbdJMZ2luaGJgShoarCO0tYtAYzlTAdAb7luCPlOdqkjpl97/E+FldnIdbCKCkCVJ7mAG8M8nLq4Nk5EGPs0NN3cMYhJVVVzcToCXG7x4AbtE89sMUHETqKE7RxfgQFV3s86wuTp9WTOz32IRYFGf2HwQrct8ZCfE7TC7Cww+lbMKUHKhbMlQzZ7ym45oZMCGdGYx1m29a3Xz1ShDjDSI1abfvhj0x46w7OeyioskiNBMKpIksTrKiaXshrTXuo1mQzt92GOd7mim7zmQlq6m/rVajjR0kBRs/FKj2VJ8BJ0oNPlQN4Pp3WQjPkACUzSaQS0nLuec0vmXbSXXE6sxu6PqPY6Ff83iXiPldVzbB/Muk0uyJgHNIO6SQzqBjuqYJkAu6rX1lJpSR8+9sGFBKlpXQVg4UapiVJmc9uu6q5v1qJCEQof2UqBFRMf7jSaUPVnJlVqN0B72n0iSSDkHDPDgJn9DwgcegNmWLMTcRs9sc5eK4mdNWUE4MECUti3ySSe6bxYIxPTUpxyuUemB9aAUY1avW67MLD6nH2KELfNwMUO/xrdI8UuuDC+RntYorDuqQi9YgC1pWiPLdJBytl0ldFZRceOAVS8V4I7G6Yunz0KkGMHXASHRx5PiTLQVFumnjDydpRWTvhKWq1D3tM6dw1qn6nWe4U5IEbpHeNAJgiMjuqaQByJrzqFylFzxze49Tolg5BF86EAL+fExZm6PEajKCoJ0eEBgQBtCCNxfO6LCVIrS5V10ecWSsZk9oGM3SUktY21hWGrNtn7LQQFMt2I67E3rgpC45zajeNEDpdegqFdpON8gPjKKfE4tgp4c6X2+bwlU3CbkNIOYIhUjSdUvE8ZxFqj/Zd5nxjsMcIJ5TWjpEG07yCpuMiPCslxrQyEL0zyXrQOyt3uE8FSYhiioBOq7IAWgrysj9145Qcpm8VAMhQxwh1RmiImmsPuJCHhH7GC2764J+hK86vUk87JvoAeP6S0JANEGFQ516TBLAj4P2cXjP/tc+aSlKXHFarzQzarSjcM9baDATduunBsqfkavQY/fYsiYxKNbYsnGuBIgnx8D0J3mNg9wMfZAqQwOSuzjIJpl3aRh1WifmuOrpPZXBNNxEcA2fUM+xUieGDBpyourvKMIXBU0iTO96GAQxb7wl3pUqt6FFN46lGQRhTY9RWFDIZxgXTNLcbdDCxk1og9y+kraKh8yZED0Aqi6GziROZ5c2tHYGrPAb2xPZQXVesjtXeghKeQ9NosWcy0PKChNedIAiBkSmynsMYHeXnwTcOgqwMxKtKcD3GHsLYHDqmJCWWnXd1eFvjxruCvp2vgP5XNK+qBnGWeOMTO2W+GzPrdFiUH2mOicODRay3/1UnldFYA46zQhejA26Sl+zdrB/WlshU2ADQrMWzhRrQSTIJF6FDdgByS801NzN6ZZi//u3U/Cl+8+1Jan3ict0TWJajwYr3rdYU/xUB4Km+nGFP9xz3fsXxaRX47QBQV30ri6IHMnzqJFjxGUPPiCeQW30Ne+NugBtP89DGU4+DFe9Ot9jvBPBv37e7n9Hb6OHg/js65E2xQy+5y9NJZ8rYC2KIJg6nLe881GVX0W+rbnFNKrfsjqd51vr9uXUx+6VcwJKQzCKe1gLdL7uQs/wyk60HydOJ52ABhGWor7aNvheLQRnrFOP5mErBUyy07VkJ1k35pZSaZCWoBCAMt+UGTlmyDFQCuPTmfupmKI23wYx+55k1v5hJOxx+2JyvRQpdoMi2xIU+7ZepF0s7IMnTj+czeh6b4VqZK6b8KGeOsGc1tmqZeJ/h0tEVnUbhqjITZrIQsCjH+uP8YdtofDS5fn15VluamXi+8m/vpxYL1dqLpZ/MHMTzkTmLDu85HChWElW/NEcN0kYbzxzw25gQMe8vYPlh4hEiYtmLW4s44Gc5inmHbAFi4ucllrEA5s4Bf8hDzBP03SlPF7G6MeuO2Nd6bLRSUXocxXKa68+PDxMrDET4dOfV4rOQJJomR2wYHvDrHMa812XNYuLnLJa1GH4L0KczYJWI3Ze8TuwKcQkYVIdXIqIwOJHvKmGjnexhNLZTEcInL4Z64vw9F5BfrjZ2zgZHIge7DvQFC5VcZd9gQK24ySifwOtliTYpQHgv0RJoUmAp52dH4yTpL6F04NqWQfTLpPRt8rmLgHCYied8pKQfETR9oAVanXxC4J7oNyYn/zphy9+BDr5m6kRtnzgA0Xp783kqAK8QA3Hyd/SkKBPT/scKMIR1u1x1zvoC8u/PSGnArZPgQT/YmV1anjE0FI2zKprWFoFyxwl+dncL9obm3HRvMBHOHmBQ/Pgn4fsC728a4ZoBeLAW9aev/+a3SvS1RwD3bnmIiAEAgv+SWf/q1gH6iTsjN7+G0D9LqJlzDO4uWqfxX8pZLWKZDU3bfPVoH/0ESb4BKT7VBUbTNn971IDrwyS0HEPVaxCO+i0MwX87Maeo4N2viIiNN0zWct6SJRg+wbE0oS0VT7cIKbog2O8iH3c664+9mOoT/sGSz+kT0ubO+Es815nNTtLHGMr6K8gKoRk65wtXw5d6fAImgR34wNPs6wIhVRHFlLEwhD0C2h2z8fBAAS16WiUJPiBCH9SQ1aqA5xcFiNEKQXrYGTFYlfS8kE9wFrvQ+lEUgQwRu7YKSPs83e0TFvAlnGwBFVN0fxLpPqdMwupPahnNx+pN+BS9pJHkYbEoKmNo6lvWOfVMg2qU1TKlajFhmXp5CzPOdS3fWctwLtF0hOO4qYdDPoupJoNDb2K9I4kwS2n8dP8dDAZ/6AEq8AZ38NU0MEcHWRRKVWnNLWDWUqYcNm7uGjwiEcnKC9n7iC+0PYtZVsphmxxlGMMo0rakJFGm28+yE0sIh/9rF5ZYHIxW5/7FkBpRlTS1rLCFt1Otm1SdBHqvwH4pfJXB0fVbxovyiBmbN5xHx0bMDoi+aQ8sRZvrJLCH65OPO68QGiriCqHhtCT4PwhZ/lylu/osKO2FTV42/gj84JFONhtgL9Zt/ta2eTl5MuWs1Ws2sZrjcshEE4jrimjSZ+XQrqEFekU9P+o5P4sYcx2dANTSvoLHeshmt1LtVqE+yw0T4IQC8MAkkoa2gdA8XbEWJxqszgig8Woy6Bu+m3Zd03NNhccUs+4t28WinTlwsj42WahLU9TjMqnKYSx/9NlBRUxkXcApmsmh7TqdDgyz9WSrtk/NO4d/5j/gOdK5nzg1OOZ/WsgrnHpUdDncu9OXjEsKfRI5W/KDu4SuiYkyLuzP69Nm3YBi5VaWrm5H3+ZjsChamtqhbMO4Re6Zh+VA1XqqNaHaP/hRHY/oYEdA//QEma4YMsiu6nhGemwaIrBL9iNBwQ6m2RVWs3dkgygwsoMgJLKHTFjkDEQxkQN4CYyzECTj0KavB8plIELs3Yswcl8jnH5Ni/BcE0cENhVERNpaIjIj2xORc5mgiAGrYUUUAsYQURowSyOG3Z/a1UZ8tm3vh4B+28MIwWIXA6yZAlyzMsJDuUuJCJB2YyMijHYFquy4ecYk+bH2k0Fv32WAmsx3WGZABhSKRXpcjlmTYK8DJxPWqLIHxQZxbJemsYMBp2gc6c8prVMndBG0uGzMiFHTBkWPtZ9XliM6AkS2FTqGbJOK2Jff7YZDK6t4ystizBxtVGFoL63XhzTAvqb5Uy0k2wEovqLiY70WGnfpscwj4+9jXm5rszLPTMf6IDhFqMsd7yjTbJ9peipmb+IwSlqDssLd0LrYZq60ZLK5qK2dbHKbBWV8w4D4epUtCCMyKITm3V02Z3jKIxtx7Gei1tb9bRb742YNsXkrIxHTCcQVWeMuTPatMGTw3Y5LW4Z2vl2XxaaacFkHqCbNL0KD2EAQyOcVMggwnYfABVCcQdoW0Jjs1zPvJwNEhhqhgyJDLJBiEuVSZlhMrFOWMxGRDcBqELSA8kRkEBzXUSqbu1mr1CcpUWX1carHnNjwrItYpmRYhMCD2vdhjYYqwrguKJNpWi+TDVVGPN3QKqqqgGi4MC2bNYlEmSCBlczA4gPgMKwGJFANkDozczBcmJwv13apMlEvzlyP2BJYdVRKUYsLr/nH0SgR4zHDK3XSP1m0r6R/AtA4WwqnZdoWRzxG5IftKhPhzMV3irSZJEdXLS2TBK1R+ZMSZ5zrOM53FRmjG1efUhVUsO6FcHPBRZd8cb4bCiucexE8iuR5D36uQZ427sNiAwep8v5DerriqjGud29wi3Pyr1q9vKbjHTJUaD5hNPmarzi+xYcLH4GfG27K0tAPR4ocJSp/AdGix4iptz5e9jMO5KdvCZ7183EFGRvPeBOMi59AdkITVRUoWIgfAeQkTmKQF/1aUzBYbrLk8hWkkJcylSFCfa4rdRphaYUrVKwknaL0GTKKyCSyJFEll5I5i0bRhmbNlj2HUjHKTDJZec5cNpViih2GeSPW8DylliaudP/71o/KMEd8mWWVXY4EU003w7Ryyyu/ggr9LLGiiiupVJJkr/oP+8uXUoXn/XdVVbegmmqra2H1jjja/7S4JTZaex+e/kmwYs3mz5hGTSl3V97uPc88+9ze/IJ9+w8cPHT4yNFjx088X3jyVFFxSWlZeUVlVXU4UlPrHoz8rr6hsamZx0MM/0trW7vSHNqEnLVbOYMiKnSVmikVY9ujSodU6fqBKJRwsKugo+dDiUA5YXBhNN28GKEQPSaUueOu3+0zaL+9xo0ZpfFnW3IaIQ4zZRIcTogpWjrZ1d3T25fqn52bRxZeXFpeeaAFyzlnCPCb04fnj5p1CVZb114jtBmORLe2Y/F73vRHM1n9Vm3bsGTAmpMeWbFl3fI9dDmR74L73jK8hy0/P0CYUMaFVNpY54dRRip1kptEq3aUTH1XhzRPO1nT1cc7zv7eWBeZKeaRrT9rL26zRh50Xe+Xq1shNnNb2NCip5ybreJokimbsqU3YUGVRWZI2TgUjWUcxXwY9u2ev9CCZpY/N7JqNKHcSsi9hxwvm1Sy/LpcmEBxT+3aU9d5cfLGKOr9Ecooc/lGV0guKtZa2WKztk10ZooqW16yChtP52KtXr3lr3xX17UaOmvsFzeWxpZ6fQJb/hBv5a/TsmkivjX3UWxvu7d93Ngem9tXZALbRz+FXY92n8muXspMD2PvtOEUbbQ/hv1R+451oNGDqzfODKWf6xTAXaCAAgQqwGCNNOhd4/LDIEMZymAoIEHD0ihaX6EeuhOAuClawMACmxgERBDDkFRRpnDhCheMmWAmB89xwaVQQglLQMBnP+/D5xAhIooAUthqPQLfe9LN08wsk/+yXGj0ZOHy1PnCobGMT/RPNBwsGVAiq03u9xm2Gc/JKaKP5xUmaJvXYuhQGCnrQQ4/9mDmawjqWLiqDLWs7pT9xB2qAXBbBVvOVcBCQwkqqXVAT0GqqkGprVaCPVBgsh+QWrUB3gfsgD6AqTYGTHRRUBOXAT0Easke6siFQAMh3UM61wKwA0AQIECgIBAAFAIGCAQChQXQGD51mmiIgCCQKFBAIBCUzBCqcF6j4DtM/cbMHBOYb4q8Bga/BDrl3BqkkxkT76scHWchiCQTLUA2eQMuI+Dq7fs6tF/7eqfBtL4cfe3XnYChfFQ46g9M5qNVCECqLyIba6YdeyIqD+TRF0I/JBwCK1fmD2aBH1rVx8gy8Fp+PrDbM13OK4jT2LTNj03D/Ngi7Kes76sTboB1yOAbIXAHzNlfQ/BRi/hjCu+GpVg8iDDE5CHFLkSIgvNOgJGc9VTjUvhCwS6+iAEBflPgKESIkL5MIODglSPNEETmfwDfjp21GBGzUFhAdH/zTc9vxo9FJ6nloudqLYiCKChBvRxHz229sEyB8KHzzhgA7zw3liFGcvZoQNqlsdrsM96A/4+BrYoBnSC8MsTZlosWLaBhwkOtohfxHo+upHVFT/Z9+NO3ExmBFimsw0w/79HJ8c5zU+T5wkcbNh5JP4B2NmSdAYbTokfAoMBzEw5gnrrEay9Rs9iMy7la0hfE+wGvjhrsF8yCzBoU+hPoEpIk1iH4XnKRur33KEEJAAAA) format('woff2-variations');}++.anp-wrap{container-type:inline-size; container-name:anp; width:100%;}+.anp{+ --anp-surface:#161b22; --anp-line:#30363d; --anp-teal:#00b8b1;+ --anp-text:#e6edf3; --anp-muted:#8b949e; --anp-row:#262d36;+ --anp-display:'Familjen Grotesk',ui-sans-serif,system-ui,sans-serif;+ --anp-body:'Satoshi',ui-sans-serif,system-ui,sans-serif;+ box-sizing:border-box; width:100%; margin:16px 0 20px; padding:18px 20px 20px;+ background:var(--anp-surface); border:1px solid var(--anp-line);+ border-left:3px solid var(--anp-teal); border-radius:12px;+ font-family:var(--anp-body); color:var(--anp-text);+ display:grid; grid-template-areas:"head head" "left right";+ grid-template-columns:220px minmax(0,1fr); column-gap:24px; row-gap:14px;+}+.anp *,.anp *::before,.anp *::after{box-sizing:border-box;}+.anp[hidden]{display:none;}++.anp-head{grid-area:head; position:relative; padding-right:40px;}+.anp-head h2{margin:0; font:600 15px/1.3 var(--anp-display); color:var(--anp-text); letter-spacing:-.01em;}+.anp-head p{margin:6px 0 0; font:400 13.5px/1.5 var(--anp-body); color:var(--anp-muted); max-width:78ch;}+.anp-head p b{color:var(--anp-teal); font-weight:500;}++.anp-left{grid-area:left; display:flex; flex-direction:column; align-items:flex-start; gap:8px;}+.anp-right{grid-area:right; min-width:0;}++.anp-demo{+ display:inline-flex; align-items:center; gap:9px; white-space:nowrap;+ font:600 14px/1 var(--anp-display); color:#05221f;+ background:var(--anp-teal); border:0; border-radius:8px; padding:10px 18px;+ cursor:pointer; transition:filter .15s ease;+}+.anp-demo:hover{filter:brightness(1.08);}+.anp-demo:active{filter:brightness(.94);}+.anp-demo:focus-visible{outline:2px solid var(--anp-teal); outline-offset:3px;}+.anp-demo svg{width:14px;height:14px;flex:0 0 auto;}+.anp-meta{font:400 12px/1.4 var(--anp-body); color:var(--anp-muted);}++.anp-tbl{width:100%; max-width:720px; border-collapse:collapse; table-layout:fixed;}+.anp-tbl td{padding:0; border:0; border-bottom:1px solid var(--anp-row); height:40px; vertical-align:middle;}+.anp-tbl tr:last-child td{border-bottom:0;}+.anp-tbl tr{cursor:pointer;}+.anp-tbl tr:hover td{background:rgba(255,255,255,.03);}+.anp-phrase{width:auto; min-width:0;}+.anp-phrase span{display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;+ font:400 14px/1.4 var(--anp-body); color:var(--anp-text);}+.anp-phrase b{color:var(--anp-teal); font-weight:400;}+.anp-acts{width:72px; white-space:nowrap; text-align:right;}+.anp-acts .anp-ib + .anp-ib{margin-left:6px;}++.anp-ib{display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px;+ padding:0; background:none; border:0; border-radius:6px; color:var(--anp-muted); cursor:pointer;+ transition:color .15s ease, background .15s ease;}+.anp-ib:hover{color:var(--anp-teal); background:rgba(255,255,255,.05);}+.anp-ib:focus-visible{outline:2px solid var(--anp-teal); outline-offset:1px;}+.anp-ib svg{width:16px;height:16px;display:block;}+.anp-ib[disabled]{opacity:.45; cursor:default;}++.anp-x{position:absolute; top:-2px; right:0; width:28px; height:28px; padding:0; background:none;+ border:0; border-radius:6px; color:var(--anp-muted); cursor:pointer;}+.anp-x:hover{color:var(--anp-text); background:rgba(255,255,255,.06);}+.anp-x:focus-visible{outline:2px solid var(--anp-teal); outline-offset:1px;}+.anp-x svg{width:14px;height:14px;display:block;margin:auto;}++@container anp (max-width:699px){+ .anp{grid-template-areas:"head" "left" "right"; grid-template-columns:minmax(0,1fr); row-gap:12px;}+ .anp-left{flex-direction:row; align-items:center; gap:12px;}+}++.anp-toasts{position:fixed; left:50%; bottom:22px; transform:translateX(-50%); z-index:99999;+ display:flex; flex-direction:column-reverse; gap:8px; pointer-events:none;}+.anp-toast{pointer-events:auto; display:flex; align-items:center; gap:10px; max-width:min(92vw,520px);+ padding:10px 14px; border-radius:8px; border:1px solid var(--anp-line,#30363d);+ background:#1c2230; color:#e6edf3; font:400 13.5px/1.4 'Satoshi',ui-sans-serif,system-ui,sans-serif;+ box-shadow:0 10px 30px rgba(0,0,0,.45); animation:anp-in .18s ease;}+.anp-toast.ok{border-left:3px solid #3fb950;}+.anp-toast.err{border-left:3px solid #f85149;}+.anp-toast.info{border-left:3px solid #00b8b1;}+@keyframes anp-in{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}+@media (prefers-reduced-motion:reduce){.anp-toast{animation:none;}}++.anp-tip{position:fixed; z-index:99999; pointer-events:none; padding:6px 9px; border-radius:6px;+ background:#0d1117; border:1px solid #30363d; color:#e6edf3; text-transform:none;+ font:400 12px/1.3 'Satoshi',ui-sans-serif,system-ui,sans-serif; box-shadow:0 6px 20px rgba(0,0,0,.5);+ opacity:0; transition:opacity .18s ease;}+.anp-tip.on{opacity:1;}+`;+ var ICON = {+ copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg>',+ send: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.5 2.5 10.5 13.5"/><path d="M21.5 2.5 14.8 21.5l-4.3-8-8-4.3z"/></svg>',+ play: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4l14 8-14 8z"/></svg>',+ x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M5 5l14 14M19 5 5 19"/></svg>',+ help: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.2 9.3a2.9 2.9 0 0 1 5.6 1c0 1.9-2.8 2.4-2.8 4"/><path d="M12 17.6h.01"/></svg>'+ };+ /* The two-column layout needs, inside the panel's padding:+ * phrase cell 336 + action column 72 + 8 gutter = 416 for the right column+ * left column 220 + column gap 24 + 416 = 660 content+ * + 20 left padding + 20 right padding = 700 PANEL border-box+ * That is where 700 comes from. It is a PANEL width, not a viewport width, so it is the+ * same number in every app regardless of that app's page chrome. */+ var TWO_COL_MIN = 700, PHRASE_BUDGET = 336;+ var MIN_API = [1, 0, 142];++ /* Hydrogen 1.0.187+ answers a send with a JOB and reports progress on+ * GET /ai-threads/run-prompt/status?job=<uuid>. Measured on the VM: tab visible ~0.1s,+ * session id ~2.5s, sent by 3-5s, a trivial answer done ~8s. One poll a second is the+ * right cadence for that shape, and it is an in-memory read on the other end. */+ var PHASE = {+ opening: 'Opening a new AI chat…',+ prefilled: 'Prompt ready…',+ submitted: 'Sent, waiting for the AI…',+ working: 'The AI is working…',+ needs_you: 'The AI needs you. Open the tab.',+ done: 'Done.',+ submit_failed: 'The prompt is in the tab. Press Enter there to send it.',+ submitted_unidentified:'Sent, but the AI never checked in.',+ submit_unconfirmed: 'Sent, but delivery was not confirmed.',+ still_working: 'The AI is still working. Open the tab.'+ };++ function css() {+ if (document.getElementById('anp-css')) return;+ var s = document.createElement('style');+ s.id = 'anp-css'; s.textContent = CSS;+ document.head.appendChild(s);+ }+ function el(tag, cls, html) {+ var n = document.createElement(tag);+ if (cls) n.className = cls;+ if (html != null) n.innerHTML = html;+ return n;+ }+ function esc(s) {+ return String(s).replace(/[&<>"]/g, function (c) {+ return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];+ });+ }+ /* Highlight the trigger noun. Built with RegExp(), never a literal, because a dashboard+ * assembled as a JS template literal turns \b into a BACKSPACE and the boundaries vanish+ * silently: the regex still matches, just too greedily. (nb, 2026-08-25) */+ function mark(phrase, noun) {+ var safe = esc(phrase);+ if (!noun) return safe;+ var re = new RegExp('\\b(' + esc(noun).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')\\b', 'gi');+ return safe.replace(re, '<b>$1</b>');+ }++ /* ---- copy, with a fallback that does not need the async clipboard ------+ * navigator.clipboard.writeText needs a secure context AND, in several embedded webviews,+ * transient user activation plus a clipboard-write permission that is simply absent. In+ * ah's webview it rejects with NotAllowedError, so the ONLY path the panel had would fail+ * and the user got "Could not reach the clipboard" on a button that looks like it works.+ * The execCommand path is deprecated but synchronous, needs no permission, and works in+ * exactly the contexts the async API refuses. Try the modern one, fall back, and only then+ * report failure. */+ function copyText(text) {+ return new Promise(function (resolve, reject) {+ function legacy() {+ try {+ var ta = document.createElement('textarea');+ ta.value = text;+ ta.setAttribute('readonly', '');+ ta.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';+ document.body.appendChild(ta);+ ta.select(); ta.setSelectionRange(0, ta.value.length);+ var ok = document.execCommand && document.execCommand('copy');+ ta.remove();+ ok ? resolve('legacy') : reject(new Error('execCommand refused'));+ } catch (e) { reject(e); }+ }+ if (navigator.clipboard && navigator.clipboard.writeText) {+ navigator.clipboard.writeText(text).then(function () { resolve('async'); }, legacy);+ } else legacy();+ });+ }++ /* ---- toasts ---------------------------------------------------------- */+ var toastHost = null;+ function toast(msg, kind, ms) {+ if (!toastHost) { toastHost = el('div', 'anp-toasts'); document.body.appendChild(toastHost); }+ var t = el('div', 'anp-toast ' + (kind || 'info'), esc(msg));+ toastHost.appendChild(t);+ /* retextable, so a long send can narrate its phases in ONE toast instead of+ * stacking six of them on top of each other. */+ t.setText = function (next) { t.innerHTML = esc(next); return t; };+ setTimeout(function () { t.remove(); }, ms || (kind === 'err' ? 7000 : 4000));+ return t;+ }++ /* ---- one tooltip, viewport-clamped, never a title attribute ----------- */+ var tip = null, tipT = null;+ function tipFor(node, text) {+ node.setAttribute('aria-label', text);+ node.removeAttribute('title'); // two tooltips on one control is a bug+ node.__anpSetTip = function (t) { text = t; if (tip && tip.classList.contains('on')) tip.textContent = t; };+ node.addEventListener('mouseenter', function (e) {+ clearTimeout(tipT);+ tipT = setTimeout(function () {+ if (!tip) { tip = el('div', 'anp-tip'); document.body.appendChild(tip); }+ tip.textContent = text; tip.classList.add('on');+ var r = node.getBoundingClientRect(), w = tip.offsetWidth, h = tip.offsetHeight, M = 8;+ var left = r.left + r.width / 2 - w / 2, top = r.top - h - 8;+ if (top < M) top = r.bottom + 8;+ left = Math.max(M, Math.min(left, document.documentElement.clientWidth - w - M));+ top = Math.max(M, Math.min(top, document.documentElement.clientHeight - h - M));+ tip.style.left = left + 'px'; tip.style.top = top + 'px';+ }, 600);+ });+ ['mouseleave', 'blur', 'click'].forEach(function (ev) {+ node.addEventListener(ev, function () {+ clearTimeout(tipT); if (tip) tip.classList.remove('on');+ });+ });+ }++ /* ---- send to a new AI thread ----------------------------------------- */+ function verNum(s) {+ var p = String(s || '').split('.').map(function (x) { return parseInt(x, 10) || 0; });+ return [p[0] || 0, p[1] || 0, p[2] || 0];+ }+ function verGte(a, b) {+ for (var i = 0; i < 3; i++) { if (a[i] > b[i]) return true; if (a[i] < b[i]) return false; }+ return true;+ }+ function gate(base) {+ /* NUMERIC compare, split on dots. A string compare passes "1.0.99", which is older and+ * still carries the crash. Fail OPEN if the probe itself is unreachable. */+ return fetch(base + '/updater/status', { cache: 'no-store' })+ .then(function (r) { return r.json(); })+ .then(function (j) {+ var v = (j && j.updater && j.updater.current_version) || '0.0.0';+ return { ok: verGte(verNum(v), MIN_API), version: v };+ })+ .catch(function () { return { ok: true, version: null, open: true }; });+ }+ function send(base, phrase, appName, slug, onProgress) {+ return gate(base).then(function (g) {+ if (!g.ok) throw new Error('Adom needs updating to send prompts (found ' + g.version + ', needs 1.0.142). Run: adom update');+ var ctl = new AbortController(), timer = setTimeout(function () { ctl.abort(); }, 120000);+ /* ONE call. POST /ai-threads/new NEVER returns an id, so a two-step create-then-run+ * flow fails 100% of the time and looks like a platform fault. */+ return fetch(base + '/ai-threads/run-prompt', {+ method: 'POST', signal: ctl.signal,+ headers: { 'content-type': 'application/json' },+ body: JSON.stringify({+ "new": true, prompt: phrase, submit: true,+ name: appName + ' - say it', tags: ['dock:' + slug]+ })+ }).then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })+ .then(function (res) {+ clearTimeout(timer);+ var j = res.j || {};+ if (!(res.status >= 200 && res.status < 300))+ throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+ /* PRE-1.0.187 SHAPE, kept because older Hydrogen builds still answer this way.+ * delivered_to is an ARRAY; a thread that opened but did not confirm delivery is+ * NOT a failure, and retrying it double-sends. */+ var dt = j.delivered_to, arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);+ if (arr.length || (j.inject && j.inject.delivered_in_conversation)) return { state: 'delivered' };+ /* 1.0.187+ returns in well under a second with a JOB, not a delivery. ok:true means+ * the job STARTED. delivered_to is never filled on this path any more, so without+ * the poll below every send would report "delivery not confirmed" forever. */+ if (!j.job) return { state: 'opened', hint: j._hints && j._hints.note };+ var started = Date.now(), missing = false;+ return new Promise(function (resolve) {+ (function tick() {+ /* the deadline is checked in BOTH paths. Checking it only after a successful+ * poll leaves a dashboard whose server stops answering polling forever. */+ function again() {+ if (Date.now() - started > 120000)+ return resolve({ state: 'opened', hint: 'no confirmation within 120s' });+ setTimeout(tick, 1000);+ }+ fetch(base + '/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job))+ .then(function (r) {+ /* THE STATUS ROUTE IS A THIRD PROXY ROUTE. Dashboards proxy /updater/status+ * and POST /ai-threads/run-prompt only. If this one was never added, every+ * poll 404s and the user watches "Opening…" for the full 120s before a+ * hedge, which is worse than the behaviour this replaced. Fail FAST and say+ * exactly which route is missing instead of hanging. */+ if (r.status === 404) { missing = true; return null; }+ return r.json();+ })+ .then(function (st) {+ if (missing) return resolve({ state: 'opened', phase: 'no_status_route',+ hint: 'Your AI chat opened. This app does not proxy GET ' ++ '/ai-threads/run-prompt/status yet, so progress cannot be shown.' });+ var state = (st.job && st.job.state) || '';+ if (typeof onProgress === 'function') { try { onProgress(state, st.job); } catch (e) {} }+ /* working or later IS sent, for the user-facing toast. */+ if (state === 'working' || state === 'done' || state === 'needs_you' ||+ state === 'still_working') return resolve({ state: 'delivered', phase: state });+ if (state === 'submit_failed' || state === 'submitted_unidentified' ||+ state === 'submit_unconfirmed')+ return resolve({ state: 'opened', phase: state,+ hint: (st.job && (st.job.error || st.job.note)) || state });+ again();+ })+ .catch(again);+ })();+ });+ })+ .catch(function (e) {+ clearTimeout(timer);+ if (e.name === 'AbortError') return { state: 'opened', hint: 'no answer within 120s' };+ throw e;+ });+ });+ }++ /* ---- mount ------------------------------------------------------------ */+ function mount(cfg) {+ css();+ var host = typeof cfg.mount === 'string' ? document.querySelector(cfg.mount) : cfg.mount;+ if (!host) throw new Error('newbie panel: mount target not found: ' + cfg.mount);+ var noun = cfg.app || '';+ var slug = cfg.slug || noun.replace(/\s+/g, '-');+ var appName = cfg.appName || noun;+ var base = cfg.apiBase == null ? '' : cfg.apiBase;+ var key = 'anp:' + slug + ':open';++ var wrap = el('div', 'anp-wrap');+ var panel = el('section', 'anp');+ panel.setAttribute('aria-label', 'Say it: sample prompts for ' + appName);++ var head = el('div', 'anp-head');+ head.appendChild(el('h2', null, 'Say it'));+ head.appendChild(el('p', null,+ "You never need this page's buttons: just tell your AI what you want, using the words <b>" ++ esc(noun) + "</b>. Copy a phrase, or send it straight to a new AI thread."));+ var x = el('button', 'anp-x', ICON.x);+ x.type = 'button'; tipFor(x, 'Close this panel');+ head.appendChild(x);+ panel.appendChild(head);++ var left = el('div', 'anp-left');+ var demo = el('button', 'anp-demo', ICON.play + '<span>' + esc((cfg.demo && cfg.demo.label) || 'Play the demo') + '</span>');+ demo.type = 'button';+ demo.addEventListener('click', function () {+ if (cfg.demo && typeof cfg.demo.onPlay === 'function') cfg.demo.onPlay();+ else toast('This dashboard has no demo wired up yet', 'err');+ });+ left.appendChild(demo);+ if (cfg.demo && cfg.demo.meta) left.appendChild(el('span', 'anp-meta', esc(cfg.demo.meta)));+ panel.appendChild(left);++ var right = el('div', 'anp-right');+ var tbl = el('table', 'anp-tbl');+ var tb = el('tbody');+ (cfg.phrases || []).slice(0, 6).forEach(function (phrase) {+ var tr = el('tr');+ var td = el('td', 'anp-phrase', '<span>“' + mark(phrase, noun) + '”</span>');+ var ta = el('td', 'anp-acts');++ var cp = el('button', 'anp-ib', ICON.copy); cp.type = 'button';+ tipFor(cp, 'Copy this phrase');+ cp.addEventListener('click', function (e) {+ e.stopPropagation();+ copyText(phrase)+ .then(function () { toast('Copied. Paste it to your AI.', 'ok'); })+ .catch(function () { toast('Could not reach the clipboard. Select the phrase and copy it.', 'err'); });+ });++ var sd = el('button', 'anp-ib', ICON.send); sd.type = 'button';+ tipFor(sd, 'Send to a new AI thread');+ sd.addEventListener('click', function (e) {+ e.stopPropagation();+ if (sd.disabled) return;+ sd.disabled = true;+ var pending = toast('Opening a new AI thread…', 'info', 120000);+ send(base, phrase, appName, slug, function (state, job) {+ if (PHASE[state]) pending.setText(PHASE[state]);+ if (typeof cfg.onProgress === 'function') { try { cfg.onProgress(state, job); } catch (e) {} }+ }).then(function (r) {+ pending.remove();+ if (r.state === 'delivered') toast('Sent. Your AI thread has the prompt.', 'ok');+ else toast('Thread opened, delivery not confirmed. Check your AI threads before sending again.', 'info', 8000);+ }).catch(function (err) {+ pending.remove();+ toast(err.message || 'Could not send the prompt.', 'err');+ }).then(function () { sd.disabled = false; });+ });++ ta.appendChild(cp); ta.appendChild(sd);+ tr.appendChild(td); tr.appendChild(ta);+ tr.addEventListener('click', function () { cp.click(); });+ tb.appendChild(tr);+ });+ tbl.appendChild(tb); right.appendChild(tbl); panel.appendChild(right);+ wrap.appendChild(panel); host.appendChild(wrap);++ /* the header ? is a real TOGGLE, both ways, with aria-pressed and a label that swaps */+ var toggleBtn = typeof cfg.toggleButton === 'string'+ ? document.querySelector(cfg.toggleButton) : cfg.toggleButton;+ /* setOpen(open, remember, source)+ * source is 'user' when a person clicked the X or the header toggle, and 'api' when the+ * app called open()/close() itself. It is passed to onToggle, because an app with a+ * graduation policy calls open()/close() from inside its own onToggle handler: without a+ * source the callback cannot tell its own action from a person's, so graduating the panel+ * open pins it open forever and graduating it closed marks it user-dismissed. It looks+ * like "the panel sometimes ignores graduation". (lbr, 2026-08-25) */+ function setOpen(open, remember, source) {+ panel.hidden = !open;+ wrap.style.display = open ? '' : 'none'; /* no dead margin when collapsed */+ if (toggleBtn) {+ var label = open ? 'Hide the sample prompts' : 'Show the sample prompts';+ toggleBtn.setAttribute('aria-pressed', open ? 'true' : 'false');+ toggleBtn.setAttribute('aria-label', label);+ /* the TOOLTIP has to swap with the state too, or a person with the panel already in+ * front of them hovers the control and is told it will show them the panel */+ if (toggleBtn.__anpSetTip) toggleBtn.__anpSetTip(label);+ }+ if (remember !== false) { try { localStorage.setItem(key, open ? '1' : '0'); } catch (e) {} }+ if (typeof cfg.onToggle === 'function') cfg.onToggle(open, source || 'api');+ }+ x.addEventListener('click', function () { setOpen(false, true, 'user'); });+ if (toggleBtn) {+ if (!toggleBtn.innerHTML.trim()) toggleBtn.innerHTML = ICON.help;+ tipFor(toggleBtn, 'Show the sample prompts');+ toggleBtn.addEventListener('click', function () { setOpen(panel.hidden, true, 'user'); });+ }+ var saved = null;+ try { saved = localStorage.getItem(key); } catch (e) {}+ setOpen(saved === null ? true : saved === '1', false);++ /* authoring guard: a phrase that truncates at rest is an authoring bug, and it eats the+ * trigger noun because the noun is usually the last word. Warn loudly at build time. */+ var probe = el('span'); probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap';+ document.body.appendChild(probe);+ var over = [];+ (cfg.phrases || []).forEach(function (p) {+ probe.style.font = '400 14px/1.4 Satoshi,ui-sans-serif,system-ui,sans-serif';+ probe.textContent = '“' + p + '”';+ var need = Math.ceil(probe.getBoundingClientRect().width);+ if (need > PHRASE_BUDGET) over.push({ phrase: p, needs: need, budget: PHRASE_BUDGET });+ });+ probe.remove();+ if (over.length) {+ console.warn('[newbie-panel] ' + over.length + ' phrase(s) will truncate at the resting width. ' ++ 'Shorten them, and lead with the trigger noun so a clip costs a detail not the routing words.', over);+ }++ return {+ panel: panel,+ open: function (o) { setOpen(true, !(o && o.remember === false), 'api'); },+ close: function (o) { setOpen(false, !(o && o.remember === false), 'api'); },+ toggle: function () { setOpen(panel.hidden, true, 'api'); },+ isOpen: function () { return !panel.hidden; },+ overBudget: over, toast: toast, selfCheck: function () { return selfCheck(panel); }+ };+ }++ /* ---- selfCheck: prove conformance from one call ---------------------- */+ var CANON = {+ 'card background': ['backgroundColor', 'rgb(22, 27, 34)'],+ 'card border': ['borderTopWidth', 'snap:1'],+ 'card border colour': ['borderTopColor', 'rgb(48, 54, 61)'],+ 'card accent edge': ['borderLeftWidth', 'snap:3'],+ 'card radius': ['borderRadius', '12px'],+ 'card padding': ['padding', '18px 20px 20px'],+ 'column gap': ['columnGap', '24px']+ };+ /* selfCheck(panel) - pass the panel explicitly when the page mounts more than one, or+ * mounts into a shadow root. The no-arg form takes the FIRST .anp in the light DOM, which+ * is right for the single-panel case and silently wrong for the others. Returns+ * {ok:false, error} rather than throwing when there is no panel. (chip-fetcher, 2026-08-25) */+ function selfCheck(panel) {+ panel = panel || document.querySelector('.anp');+ if (!panel) return { ok: false, error: 'no .anp panel on the page' };+ /* REFUSE TO REPORT FROM A COLLAPSED LAYOUT. An unforegrounded tab returns zero-height+ * rects while computed styles still resolve normally, so selfCheck produced EIGHT GREEN+ * ROWS beside two red ones: "panel gutter: 0px left, 744px right" and "columns: want 1,+ * got 3". That mixed shape is far more dangerous than an all-zero one, because it reads+ * as a specific layout regression rather than an invalid run, and project-manager nearly+ * filed it as a bug. A refusal cannot be misread. (project-manager, 2026-08-25.) */+ var w0 = Math.round(panel.getBoundingClientRect().width);+ /* rows: [] is deliberate. The refusal previously omitted the key entirely, and EVERY+ * caller does r.rows.map(...) because every healthy result has rows, so a correct refusal+ * arrived at the caller as "Cannot read properties of undefined (reading 'map')" and read+ * like harness noise. kicad hit exactly that and logged it as its own crash. A guard whose+ * failure mode is a TypeError in someone else's stack is a guard that will be misread. */+ if (w0 === 0) return { ok: false, invalid: true, rows: [],+ error: 'panel measures 0px wide: this tab is not foregrounded (or the panel is hidden), ' ++ 'so every layout row would ' ++ 'be computed from a collapsed pane while the style rows pass normally. Activate ' ++ 'the tab by name (or open the panel) and re-run. No rows reported, because a ' ++ 'partial result from an unrendered pane looks like a real bug.' };+ var cs = getComputedStyle(panel), rows = [], ok = true;+ /* Chrome reports the USED border-width, snapped to whole device pixels, so a declared+ * 1px reads as 0.666667px at dpr 1.5. Compare against the snapped value, not the+ * declared one, or every panel fails a check it actually passes. */+ function snap(declared) {+ var dpr = window.devicePixelRatio || 1;+ return (Math.max(1, Math.floor(declared * dpr)) / dpr) + 'px';+ }+ Object.keys(CANON).forEach(function (k) {+ var want = CANON[k][1];+ var got = cs[CANON[k][0]], declared = CANON[k][1];+ /* Border widths need BOTH candidates accepted. Chrome reports the USED width, snapped+ * to whole device pixels, on some surfaces (ah's webview and pup on a dpr-1.5 laptop+ * both return 0.666667px for a declared 1px) and reports the DECLARED width on others+ * (an emulated dpr returns 1px at every ratio). Accepting only one of the two fails a+ * correct panel in the other environment, and at dpr 1.5 specifically, which is the+ * machine this fleet is reviewed on. Found by nb, 2026-08-25. */+ var pass;+ if (declared.indexOf('snap:') === 0) {+ var d = parseFloat(declared.slice(5)), g = parseFloat(got);+ pass = Math.abs(g - d) < 0.01 || Math.abs(g - parseFloat(snap(d))) < 0.01;+ want = d + 'px or ' + snap(d);+ } else {+ pass = (parseFloat(want) && parseFloat(got))+ ? Math.abs(parseFloat(got) - parseFloat(want)) < 0.01+ : got === want;+ }+ if (!pass) ok = false;+ rows.push({ what: k, want: want, got: got, pass: pass });+ });+ var copies = panel.querySelectorAll('.anp-acts .anp-ib:first-child');+ var xs = {}; [].forEach.call(copies, function (b) { xs[Math.round(b.getBoundingClientRect().x)] = 1; });+ var aligned = Object.keys(xs).length <= 1;+ if (!aligned) ok = false;+ rows.push({ what: 'copy icons in one column', want: '1 x-position', got: Object.keys(xs).length + ' x-positions', pass: aligned });+ var cols = cs.gridTemplateColumns.split(' ').filter(Boolean).length;+ rows.push({ what: 'columns at this width', want: panel.getBoundingClientRect().width >= TWO_COL_MIN ? '2' : '1',+ got: String(cols), pass: true });+ /* THE GUTTER ROW. Four apps measured at the SAME 744px pane reported panel widths of+ * 734, 735, 702 and 694, because each page put a different amount of its own padding+ * around the mount point. 694 is under the 700 threshold, so that app rendered ONE+ * column while the other three rendered TWO, at an identical pane width. The panel was+ * conformant in all four; the PAGE around it was not. The fleet gutter is 16px a side,+ * so the panel's border box is the pane minus 32. */+ /* Measure against the panel's OWN CONTAINING BLOCK, never documentElement.+ * The first version compared to documentElement.clientWidth, which silently assumes the+ * PAGE scrolls. An app-shell dashboard (position:fixed shell, 44px header, scroller on an+ * inner .app-body) has no document scrollbar, so clientWidth is the full pane and the+ * inner scrollbar is charged to content instead: a correctly built app-shell page fails+ * by exactly one scrollbar width, and only pages that happen to scroll at document level+ * pass. Since app-shell is the prescribed structure, the check was failing the apps that+ * followed the skill. The containing block is scrollbar-agnostic and still catches the+ * real defect, which is a page adding its own padding around the mount point.+ * (parts-search, 2026-08-25, from reading the implementation rather than the prose.) */+ /* Measure BOTH INSETS AGAINST THE PANE, and never against a named element.+ * Two earlier versions asked the wrong element how wide the world is. documentElement+ * assumes the page scrolls, and fails every app-shell dashboard by one scrollbar width.+ * The mount div assumes the 16px lives on the mount div, and reds a visually perfect page+ * that puts the gutter on a shared outer container instead. Both encode a STRUCTURE the+ * fleet was never promised. What the contract actually says is where the panel's EDGES+ * land, so measure exactly that: find the nearest scrolling ancestor (or the document),+ * and check the panel sits 16px inside its client box on both sides. Structure-agnostic:+ * it does not care which element carries the padding, or how many wrappers sit between.+ * (pup found the mount-div version would red an outer-container page, before six apps+ * adopted the pattern I had recommended; parts-search found the documentElement one.) */+ var sc = panel.parentElement;+ while (sc && sc !== document.body) {+ /* A DECLARED scroller is the pane whether or not it is scrolling RIGHT NOW. Requiring+ * it to overflow was a bug with a nastier shape than the two before it: with+ * scrollbar-gutter:stable (which this pack mandates, so results arriving cause no+ * reflow) the track is reserved even when nothing overflows, so the loop walked PAST+ * the real pane, fell back to documentElement, and reported the right inset over by+ * one scrollbar width. The SAME PAGE passed when populated and failed when empty, and+ * empty is a dashboard's first-load state. Predicted from a code read by parts-search+ * and confirmed here: 16 left / 31 right, exact. */+ var so = getComputedStyle(sc).overflowY;+ if (so === 'auto' || so === 'scroll') break;+ sc = sc.parentElement;+ }+ var paneLeft, paneW;+ if (sc && sc !== document.body) { paneLeft = sc.getBoundingClientRect().left; paneW = sc.clientWidth; }+ else { paneLeft = 0; paneW = document.documentElement.clientWidth; }+ var pr = panel.getBoundingClientRect();+ var insetL = Math.round(pr.left - paneLeft);+ var insetR = Math.round(paneLeft + paneW - pr.right);+ var gutterOk = Math.abs(insetL - 16) <= 2 && Math.abs(insetR - 16) <= 2;+ if (!gutterOk) ok = false;+ rows.push({ what: 'panel gutter', want: '16px inset both sides',+ got: insetL + 'px left, ' + insetR + 'px right', pass: gutterOk });+ /* THE LEFT-EDGE ROW closes the hole the gutter row cannot see. The gutter row measures+ * against whatever element the app mounts INTO, so an app that mounts into a wrapper+ * which itself carries padding passes while sitting inset from its neighbours, and the+ * fleet mismatch comes back in a form the check is blind to. This asserts the panel's+ * left edge against the PANE, which covers the whole ancestor chain in one number.+ * (parts-search, 2026-08-25, reasoning about what the new yardstick still could not see.) */+ /* PHRASE HEADROOM AS A ROW, not a console warning nobody reads: "does not truncate TODAY"+ * and "has room" are different claims.+ * MEASURE need AGAINST AN OFFSCREEN nowrap PROBE. Do NOT use clientWidth - scrollWidth:+ * for a block span with overflow:hidden, scrollWidth is max(clientWidth, content), so that+ * expression SATURATES AT 0 for anything that fits, whether it fits by 1px or by 180px. It+ * still goes negative correctly when text overflows, so it looks like a working metric.+ * Two apps independently reported "0px headroom, one character from truncating" off it;+ * remeasured with this probe, neither had less than 141px of slack. A metric that can only+ * return one value on success is not evidence of success. */+ var spans = [].slice.call(panel.querySelectorAll('.anp-phrase span'));+ if (spans.length) {+ var probe = document.createElement('span');+ probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:' ++ getComputedStyle(spans[0]).font;+ document.body.appendChild(probe);+ var worst = 0, worstTxt = '', tight = 0;+ spans.forEach(function (sp) {+ probe.textContent = sp.textContent;+ var need = Math.ceil(probe.getBoundingClientRect().width);+ var have = Math.round(sp.getBoundingClientRect().width);+ var slack = have - need;+ if (worstTxt === '' || slack < worst) { worst = slack; worstTxt = sp.textContent.trim().slice(0, 40); }+ if (slack < 8) tight++;+ });+ probe.remove();+ var headOk = worst >= 8;+ if (!headOk) ok = false;+ rows.push({ what: 'phrase headroom', want: 'at least 8px of slack on every phrase',+ got: worst + 'px on ' + JSON.stringify(worstTxt) + (tight > 1 ? ' (' + tight + ' tight)' : ''),+ pass: headOk });+ }+ var fam = getComputedStyle(panel.querySelector('.anp-phrase span') || panel).fontFamily;+ var famOk = /Satoshi/.test(fam);+ if (!famOk) ok = false;+ rows.push({ what: 'phrase typeface', want: 'Satoshi', got: fam.split(',')[0], pass: famOk });+ return { ok: ok, rows: rows };+ }++ /* checkSend(apiBase) - prove the send path EXISTS, without sending anything.+ * gate() fails OPEN on purpose, so a broken probe cannot block a user from sending. The+ * consequence, which kicad found in itself: A MISSING GATE READS AS AN ALLOWED GATE. Its+ * /updater/status route had been lost in a rebuild, the gate got HTML, caught, and returned+ * open, so the version gate had been silently no-opping. Nothing anywhere said so.+ * This makes that visible. It never posts a prompt: the run-prompt route is probed with+ * OPTIONS, so it can report "wired" without creating a thread in anyone's list. */+ function checkSend(base) {+ base = base == null ? '' : base;+ var rows = [], ok = true;+ function add(what, want, got, pass) { rows.push({what: what, want: want, got: got, pass: pass}); if (!pass) ok = false; }+ return fetch(base + '/updater/status', {cache: 'no-store'})+ .then(function (r) { return r.text().then(function (t) { return {s: r.status, t: t}; }); })+ .then(function (res) {+ var v = null, parsed = false;+ try { var j = JSON.parse(res.t); parsed = true; v = j && j.updater && j.updater.current_version; } catch (e) {}+ add('gate route wired', '200 with JSON', res.s + (parsed ? ' JSON' : ' NOT JSON'), res.s === 200 && parsed);+ add('gate reports a version', 'updater.current_version', String(v), !!v);+ if (v) add('version meets the minimum', MIN_API.join('.'), String(v), verGte(verNum(v), MIN_API));+ })+ .catch(function (e) { add('gate route wired', '200 with JSON', 'FETCH FAILED: ' + e.message, false); })+ .then(function () {+ /* PROBE A NONSENSE PATH TOO, AND REQUIRE THE TWO TO DIFFER.+ * "not 404" was too weak an assertion. kicad's do_OPTIONS returned 204 for EVERY path+ * unconditionally, so this row was green for any string at all, including routes that+ * exist in no method. An app could fix its GET catch-all, still have a blanket+ * preflight, and keep a green row that tests nothing. Comparing against a path that+ * cannot exist makes the row self-validating: if they answer identically, the server+ * is answering the SHAPE of the question rather than the question. */+ var nonsense = '/__anp_probe_' + String(Math.floor(1e9 * 0.6180339887)) + '__';+ return Promise.all([+ fetch(base + '/ai-threads/run-prompt', {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; }),+ fetch(base + nonsense, {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; })+ ]).then(function (both) {+ var real = both[0], fake = both[1];+ add('send route wired', 'answers, and differently from a path that cannot exist',+ 'route ' + real + ', nonsense ' + fake,+ real !== 'ERR' && real !== 404 && real !== fake);+ });+ })+ .then(function () {+ /* THE STATUS ROUTE, added for Hydrogen 1.0.187+. It is a THIRD proxy route and no+ * dashboard had it before 2.22.0, so it is the row most likely to be red. A GET with+ * a job id that cannot exist must still be ANSWERED (any non-404), because a 404 here+ * means the route was never proxied and progress can never be reported. */+ return fetch(base + '/ai-threads/run-prompt/status?job=__anp_probe__')+ .then(function (r) { return r.status; }, function () { return 'ERR'; })+ .then(function (st) {+ add('progress route wired', 'answers (not 404)', String(st), st !== 'ERR' && st !== 404);+ });+ })+ .then(function () { return {ok: ok, rows: rows}; });+ }++ root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText,+ checkSend: checkSend, version: '2.22.0',+ TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET };+})(typeof window !== 'undefined' ? window : this);