cute-sim / index.html
dk5426's picture
landing page
05e9fa3 verified
Raw History Blame Contribute Delete
24.2 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CUTE Robot Simulator</title>
<style>
:root {
--bg: #f6f7fb; --card: #ffffff; --text: #1d2330; --muted: #5d6676; --line: #e3e6ee;
--accent: #3b5bdb; --accent-text: #ffffff; --ok: #2f9e44; --off: #868e96; --key-bg: #eef1f7;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #12151c; --card: #1a1f29; --text: #e6e9ef; --muted: #9aa3b2; --line: #2a3140;
--accent: #5c7cfa; --accent-text: #ffffff; --ok: #51cf66; --off: #6c757d; --key-bg: #242b38;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
main { max-width: 860px; margin: 0 auto; padding: 28px 16px 56px; }
h1 { font-size: 1.9rem; margin: 0 0 4px; }
h2 { font-size: 1.15rem; margin: 0 0 10px; }
p { margin: 0 0 10px; }
.sub { color: var(--muted); margin-bottom: 22px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; }
.launch { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; }
.btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent); color: var(--accent-text);
font-weight: 600; text-decoration: none; border: 0; font-size: 1rem; }
.btn[aria-disabled="true"] { background: var(--off); pointer-events: none; }
.status { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--off); }
.dot.on { background: var(--ok); }
ol, ul { margin: 0 0 6px; padding-left: 22px; }
li { margin-bottom: 6px; }
table { width: 100%; border-collapse: collapse; font-size: 0.96rem; }
td { padding: 7px 6px; border-top: 1px solid var(--line); vertical-align: top; }
td:first-child { white-space: nowrap; width: 38%; }
th { text-align: left; padding: 7px 6px; font-size: 0.9rem; color: var(--muted); }
.guide td:first-child { white-space: normal; width: 34%; }
kbd { display: inline-block; min-width: 1.6em; padding: 1px 6px; border-radius: 5px; background: var(--key-bg);
border: 1px solid var(--line); font: 0.85rem ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 680px) { .grid { grid-template-columns: 1fr; } td:first-child { white-space: normal; } }
.note { color: var(--muted); font-size: 0.92rem; }
h3 { font-size: 1rem; margin: 14px 0 6px; }
.toc { line-height: 1.9; font-size: 0.95rem; }
.toc a { color: var(--accent); text-decoration: none; white-space: nowrap; }
code { font: 0.85rem ui-monospace, SFMono-Regular, Menlo, monospace; }
.scroll { overflow-x: auto; }
table.dr td:first-child { width: 26%; white-space: normal; }
table.dr { font-size: 0.86rem; }
.upgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 18px; margin-top: 10px; }
.upgrid figure { margin: 0; }
.upgrid img { width: 100%; max-width: 280px; aspect-ratio: 1 / 1; border-radius: 8px; display: block; background: var(--key-bg); }
.upgrid figcaption { font-size: 0.9rem; color: var(--muted); margin-top: 4px; }
.upgrid b { color: var(--text); }
.soon { font-size: 0.72rem; font-weight: 600; color: #fff; background: #d35400; border-radius: 4px; padding: 1px 6px; margin-left: 4px; white-space: nowrap; }
</style>
</head>
<body>
<main>
<h1>CUTE Robot Simulator</h1>
<p class="sub">Drive a simulation of CUTE, a small bipedal robot, in your browser. It runs the same control policies
we deploy on the real robot, on a physics model of the real hardware.</p>
<section class="card">
<div class="launch">
<a id="launch" class="btn" href="#" target="_blank" rel="noopener" aria-disabled="true">Launch sim</a>
<span class="status"><span id="dot" class="dot"></span><span id="state">Checking…</span></span>
</div>
<p class="note" style="margin-top:10px">You'll need the password you were given. The sim opens in a new tab.</p>
</section>
<!-- guide:begin (generated by build_guide.py; edit host/guide.py instead) -->
<nav class="card toc">
<b>Guide:</b> <a href="#g0">Start</a> · <a href="#g1">Drive</a> · <a href="#g2">Behaviours</a> · <a href="#g3">Panels</a> · <a href="#g4">Status</a> · <a href="#g5">Help</a> · <a href="#upcoming">Upcoming motions</a>
</nav>
<section class="card guide" id="g0">
<h2>What this is</h2>
<p><strong>CUTE</strong> is a small two-legged robot (about 0.8 m tall, 16.8 kg, 14 motors: 5 per leg and 4 in the neck). This is a
physics simulation of the real robot's test-rig plant, driven by the same kind of neural-network controllers that run
on the hardware. None of what you see here has run on the real robot yet except the walker.</p>
<p><strong>Four policies, one supervisor</strong></p>
<ul>
<li><strong>STAND</strong> - the standing policy and the hub. It stands still and follows head and torso commands; the gestures
(nod, shake, bow ...) are command timelines played into it. This is where you start.</li>
<li><strong>Walker</strong> - walks forward and turns on command.</li>
<li><strong>Foot tap</strong> and <strong>happy dance</strong> - each plays one clip from STAND and ends standing.</li>
</ul>
<p>You never switch between them yourself. You ask for something and the <strong>supervisor</strong> makes the switch: walker &lt;-&gt; STAND
and STAND -&gt; clip -&gt; STAND, never walker &lt;-&gt; clip. Every switch is <strong>hard</strong>: on one control tick, when both feet carry
load, with no blending. A refused request shows its reason as <em>last call</em> in the status panel.</p>
<h3>Your session</h3>
<ul>
<li>Your own robot; at most <strong>4</strong> at once; freed after <strong>10 minutes without any input</strong>.</li>
</ul>
<h3>Startup</h3>
<p>A new robot (and every <em>Reset</em>) comes up in STAND through the walker's start sequence, about 8.5 s:
<strong>HOLD</strong> (2 s approach to the hold pose), <strong>GO</strong> at 3.5 s, <strong>BLEND</strong> 5 s, <strong>ACTIVE</strong>.</p>
</section>
<section class="card guide" id="g1">
<h2>Walking</h2>
<p>Push a walk command and the walker takes over at once (one tick, in double support). When the command is back to zero,
STAND takes back by itself once the walker stands still, about 1.5 s later.
Limits: <strong>forward 0 to 0.15 m/s</strong>, <strong>turn +-0.4 rad/s</strong>. Backward is refused (this
walker falls on it) and sideways is clamped to 0 (the walker's own ranges are vx -0.15 to 0.3).</p>
<p><strong>Keys</strong> (click once on the 3D view first):</p>
<table>
<thead>
<tr>
<th>key</th>
<th>action</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>I / K</strong></td>
<td>walk faster / slower, +0.025 / -0.025 m/s</td>
</tr>
<tr>
<td><strong>J / L</strong></td>
<td>turn left / right, +-0.1 rad/s</td>
</tr>
<tr>
<td><strong>Space</strong></td>
<td>stop</td>
</tr>
<tr>
<td><strong>Shift + I / K / J / L</strong></td>
<td>head up / down / left / right, 0.1 rad a press</td>
</tr>
<tr>
<td><strong>C</strong></td>
<td>centre the head</td>
</tr>
<tr>
<td><strong>U / O</strong></td>
<td>squat lower / raise, 10 mm a press</td>
</tr>
<tr>
<td><strong>Shift + U / O</strong></td>
<td>bow more / less, 0.05 rad a press</td>
</tr>
<tr>
<td><strong>1 - 8</strong></td>
<td>nod, shake, scan, bow, squat, greeting, bob, perk</td>
</tr>
<tr>
<td><strong>9 / 0</strong></td>
<td>foot tap / happy dance</td>
</tr>
<tr>
<td><strong>N</strong></td>
<td>neutral stance</td>
</tr>
<tr>
<td><strong>B</strong></td>
<td>say the selected sound</td>
</tr>
<tr>
<td><strong>M</strong></td>
<td>MOTION STOP</td>
</tr>
<tr>
<td><strong>G / R / X / P / F / H</strong></td>
<td>GO, Reset, E-STOP, pause, camera follow, this guide</td>
</tr>
</tbody>
</table>
<p><strong>Gamepad</strong> (PS4-style, on your computer, Chrome or Edge; press any button once so the browser sees it):
left stick: walk and turn | right stick: head | L2 squat, R2 bow | L1 + right stick: torso twist / lean | cross nod, circle shake, square scan, triangle greeting | d-pad up bow, down squat, left bob, right perk | R1 foot tap, SHARE happy dance | L3 neutral stance, R3 centre head | OPTIONS motion stop.
If the pad goes silent for 0.5 s while the walk stick is held, the walk command is zeroed.</p>
</section>
<section class="card guide" id="g2">
<h2>Head and torso</h2>
<p><em>Head and torso (STAND)</em> panel, the right stick and the triggers, or the Shift keys. The <strong>head</strong> also acts while the robot walks: the walking policy runs the legs and the head command
drives the neck beside it (yaw within 30 deg, at most 2.5 rad/s), and it carries over when STAND
takes back. The <strong>torso</strong> needs STAND: it is refused while walking and returns to neutral before the walker takes over.
Both are refused while a clip plays.
Commands are clamped and rate-limited to what the policies were tested with, and one head command moves an angle by at
most 0.4 rad.</p>
<h3>Gestures and clips</h3>
<ul>
<li><strong>Gestures</strong> (yes, no, scan, perk, roll, sweep, admire_down, bow, squat, greeting, bob): timelines of head and torso commands played into STAND once. No policy switch.</li>
<li><strong>Foot tap</strong> and <strong>happy dance</strong>: their own policies. STAND must have been in control for 1 s; then STAND -&gt; clip -&gt;
STAND, both switches hard.</li>
<li><strong>A clip or gesture cannot be interrupted.</strong> Requests during one are refused with <em>busy</em>; <em>stop</em> leaves it playing.
Only <strong>MOTION STOP</strong> ends one early: the walk command is zeroed and the joints are held from the next double support
(Reset to continue).</li>
</ul>
<h3>Switching, underneath</h3>
<p>A switch waits for <strong>double support</strong> (both feet over 60 N for 3 ticks, forced after
3 s), then happens on that tick. The incoming policy is given the last three actions and the last
target that was sent, and its command starts where the robot is: STAND's head command at the measured neck angles, its
torso command at zero, a clip at its first frame.</p>
</section>
<section class="card guide" id="g3">
<h2>People</h2>
<p>Scripted people can walk around the robot: 13 short scenarios (someone walks past, comes up
and stops, crouches, a child, two people at once ...). They are drawn figures on a script; they cannot touch the robot.</p>
<ul>
<li>Wait for <strong>ACTIVE</strong>, pick a <strong>scenario</strong> and press <strong>Start</strong>. The view moves back so you see the people arrive.</li>
<li><strong>behaviour layer <code>none</code></strong>: you drive, with the keys or the gamepad, and the people still react to the robot.</li>
<li><strong><code>fast</code></strong>: a small hand-written layer looks at the person, turns and walks up to them, stops at about 1.1 m and
greets, all through the same requests your buttons send. <strong><code>fast+director</code></strong> and <strong><code>director</code></strong> add a slow
decision-maker that acts on a picture <em>slow caller delay</em> seconds old; <strong><code>follower</code></strong> is a simple baseline.</li>
<li><strong>perception</strong> sets how well the robot sees (field of view, noise, dropouts). The panel shows what it currently
sees of each person and the layer's last requests. A scenario ends by itself or with <strong>Stop</strong>.</li>
</ul>
<h3>Floor / plant</h3>
<p><em>Harness cell presets</em> (each rebuilds the floor and resets):</p>
<ul>
<li><strong>v254 (rig nominal: v2, 54 ms)</strong></li>
<li><strong>c54 (soft foot v6c10s, 54 ms)</strong></li>
<li><strong>osc (c54 + IMU roll 2.5 deg @ 0.62 Hz)</strong></li>
<li><strong>c74 (v2, 74 ms)</strong></li>
<li><strong>plant_v2.sh (v2, 54 ms + 52 ms hip-roll torque lag)</strong></li>
</ul>
<p><em>Floor</em> (pick, then <strong>Build floor</strong>):</p>
<ul>
<li><strong>flat v2 (rig nominal plant)</strong></li>
<li><strong>soft box-sole foot cell (v6c10s)</strong></li>
<li><strong>bump15 field, v2 feet</strong></li>
<li><strong>bump15 field, v6c10s feet</strong></li>
<li><strong>generated small bumps</strong></li>
<li>
<p><strong>slope</strong></p>
</li>
<li>
<p><em>feet for generated floors</em>: v2 sphere soles or the soft v6c10s box sole.</p>
</li>
<li><em>generated small bumps</em>: shape (domes, low boxes, boxes + lying capsules (teammate bump15 style), dense domes (random terrain)); <strong>bump height</strong> 2-20 mm, <strong>size / spacing</strong> 3-15 cm, <strong>density</strong> 1-40 per m^2,
<strong>layout seed</strong> (Reseed layout = next seed). A 0.3 m disc around the start stays clear.</li>
<li><em>slope</em>: -5 to +5 deg (+ = uphill ahead); flat start plateau, the ramp begins 0.35 m ahead.</li>
<li><em>friction</em>: <strong>scene default</strong> (v2 mu 1.0, v6c10s soles 0.8) or the <strong>mu slider</strong> 0.3-1.3 (applies immediately).</li>
<li><em>hip-roll 52 ms load-gated torque lag</em> and <em>robot torque law r x clip(u, cap)</em>: plant options, applied with
<strong>Apply plant options</strong> (rebuild + reset).</li>
</ul>
<h3>Domain randomization</h3>
<p>Each item has <strong>on</strong> and range slider(s). Turning an item on draws a value inside its range right away;
<strong>Resample</strong> redraws every enabled item; <strong>Reset + resample</strong> does it through a reset; <strong>All off</strong> returns to nominal.
Items marked +- use the slider's upper value as a symmetric bound. The drawn value is shown under each item.</p>
<table>
<thead>
<tr>
<th>item</th>
<th>ranges</th>
<th>what it simulates</th>
</tr>
</thead>
<tbody>
<tr>
<td>loop latency (obs age + lag + act)</td>
<td>ms 20-110 ms (default range 26-74)</td>
<td>plant: all controllers</td>
</tr>
<tr>
<td>kp scale</td>
<td>x 0.5-1.5 (default range 0.9-1.05)</td>
<td>plant: all controllers</td>
</tr>
<tr>
<td>kd scale</td>
<td>x 0.5-1.5 (default range 0.8-1.05)</td>
<td>plant: all controllers</td>
</tr>
<tr>
<td>RS03 firmware cap</td>
<td>units 20-60 (default range 33-60)</td>
<td>plant: all controllers</td>
</tr>
<tr>
<td>joint friction scale (BAM law, per joint)</td>
<td>x 0.3-2 (default range 0.8-1.3)</td>
<td>plant: all controllers</td>
</tr>
<tr>
<td>mass scale + payload</td>
<td>scale 0.9-1.1 (default range 0.97-1.03); payload 0-1 kg (default range 0-0.5)</td>
<td>plant</td>
</tr>
<tr>
<td>base CoM offset (B frame)</td>
<td>x fwd -15-15 mm (default range -8-8); y left -15-15 mm (default range -5-5); z up -15-15 mm (default range -5-5)</td>
<td>plant</td>
</tr>
<tr>
<td>encoder bias (+-, per joint)</td>
<td>legs 0-5 deg (default range 0-1); hip roll 0-5 deg (default range 0-2.5)</td>
<td>sensor: every controller's q (walker obs, idle/neck obs + estimator) AND the motor PD frame</td>
</tr>
<tr>
<td>IMU mount error (+-)</td>
<td>roll 0-10 deg (default range 0-6); pitch 0-10 deg (default range 0-5)</td>
<td>sensor: walker IMU (Mahony, obs) + idle/neck estimator IMU</td>
</tr>
<tr>
<td>IMU roll oscillation</td>
<td>amp 0-6 deg (default range 0-4); freq 0.1-2 Hz (default range 0.4-1)</td>
<td>sensor: walker IMU + idle/neck obs attitude/ang-vel (harness --imu-roll-osc)</td>
</tr>
<tr>
<td>extra gyro noise (std) + bias (+-)</td>
<td>std 0-0.1 rad/s (default range 0.01-0.02); bias 0-0.05 rad/s (default range 0-0.01)</td>
<td>sensor: walker + estimator IMU (on top of the harness --imu-noise 1.0 model)</td>
</tr>
<tr>
<td>sole z stiffness</td>
<td>K 5-80 kN/m (default range 20-45)</td>
<td>plant (scenes with the sole slide joint)</td>
</tr>
<tr>
<td>floor friction</td>
<td>mu 0.3-1.5 (default range 0.6-1.3)</td>
<td>plant (floor/bump geoms + sole geoms)</td>
</tr>
<tr>
<td>random pushes (heading frame)</td>
<td>interval 1-15 s (default range 4-8); dvx +- 0-0.4 m/s (default range 0-0.15); dvy +- 0-0.4 m/s (default range 0-0.1)</td>
<td>plant (base velocity kicks)</td>
</tr>
</tbody>
</table>
<h3>External force</h3>
<p>Push the robot: <strong>application point</strong> (pelvis or any body), <strong>azimuth</strong> 0-360 deg (0 = forward, 90 = left, robot heading
frame), <strong>elevation</strong> -90 to 90 deg (+ = up), <strong>mode</strong> <em>force</em> (<strong>magnitude</strong> 0-200 N for <strong>duration</strong> 0.02-2 s) or
<em>impulse</em> (a sudden velocity change <strong>delta-v</strong> 0-1 m/s of the whole robot's mass), then <strong>Apply</strong>. A red (force) or
orange (impulse) arrow shows it. 40 N for 0.2 s sideways is a gentle push; 1.5 m/s sideways knocks it over.</p>
<h3>Sound</h3>
<p>Sounds for what <em>your</em> robot does (other visitors never hear them):</p>
<ul>
<li><strong>walking</strong>: a melody <strong>loops the whole time the walker walks</strong>; it fades out when you stop (command back to zero
and the robot stands) or when the walker loses control (switch, fall, reset);</li>
<li><strong>the walker or idle takes control</strong> (walker ready, back to idle after walking): "hi, hello";</li>
<li><strong>a turn starts</strong>: "woohoo"; <strong>speed change</strong> (vx up / down): a little blip;</li>
<li><strong>neck clips</strong>, when a clip starts: yes "yeah-yeah", no "hmm", perk "yippee", scan "hoo-hoo", roll a giggle,
sweep "yay", admire_down "wow";</li>
<li><strong>standing idle a while</strong>: now and then a sound or a gesture, chosen by the <em>mood</em> (calm: every 20-40 s, mostly
nothing; playful: every 6-12 s; quiet: never); <strong>Say sound</strong> plays any of the robot's sounds on demand;</li>
<li><strong>e-stop</strong>: "uh-oh" (the melody stops at once); <strong>end of your session</strong>: "bye-bye".</li>
</ul>
<p>Sound panel: <strong>Sound on</strong> (default) / off, <strong>volume</strong>, <strong>Play melody</strong>, a <strong>Sound test</strong> dropdown (two longer clips) and
<strong>Stop sounds</strong>. A cue repeats at most every 2 s (the blip every 0.15 s) and at most two cues play at once; the walking
melody is separate and never blocks them.</p>
<h3>View and camera</h3>
<ul>
<li><strong>Rotate</strong>: left-drag. <strong>Pan</strong>: right-drag (or shift + drag). <strong>Zoom</strong>: scroll / pinch.</li>
<li><strong>W/A/S/D/Q/E and the arrow keys move the camera</strong> (that is why the robot uses I/J/K/L/U/O).</li>
<li><strong>Follow</strong> (F, on by default) keeps the robot centred by moving the world under it. <em>trail</em> draws the path;
<em>show collision geoms</em> shows the sole contact spheres.</li>
</ul>
</section>
<section class="card guide" id="g4">
<h2>Reading the status panel</h2>
<ul>
<li><strong>Stage</strong>: HOLD, BLEND, ACTIVE, FELL / ESTOP / FROZEN.</li>
<li><strong>in control</strong>: walk, stand, foot_tap or happy_dance. <strong>behaviour / state / mood</strong>: what the runtime is doing.
<strong>supervisor</strong>: the switch it is carrying out. <strong>last call</strong>: the latest request and its answer, <em>ok</em> or
<em>REFUSED (reason)</em>.</li>
<li><strong>cmd set / walker sees / achieved</strong>: the walk command, after the walker's smoothing, and the robot's velocity.</li>
<li><strong>tilt</strong> (fall above 25 deg; a commanded bow shows here too), <strong>feet R/L</strong> in newtons.</li>
<li>The last lines are the event log (hard switches, timelines, refusals, falls).</li>
</ul>
</section>
<section class="card guide" id="g5">
<h2>Troubleshooting</h2>
<ul>
<li><strong>The robot fell</strong>: tilt over 25 deg or a motor at its torque limit. Press <strong>Reset (R)</strong>.</li>
<li><strong>A request did nothing</strong>: <em>last call</em> says why (busy with a clip, walking, startup not finished, fallen ...).</li>
<li><strong>Keys do nothing</strong>: click once on the 3D view; W/A/S/D and the arrows move the camera, not the robot.</li>
<li><strong>Gamepad not seen</strong>: press a button on it, use Chrome or Edge, check the <em>Gamepad</em> panel.</li>
<li><strong>Turning on the soft-sole floor</strong> can trip the walker's torque limit; that is the walker, not the switch.</li>
</ul>
<h3>Limitations</h3>
<ul>
<li>A simulation of the test-rig model. STAND, the gestures, foot tap and happy dance have not run on the real robot.</li>
<li>Forward and turning only; no backward or sideways walking.</li>
<li>Please free your robot (close the tab) when done.</li>
</ul>
</section>
<!-- guide:end -->
<section class="card" id="upcoming">
<h2>Upcoming motions</h2>
<p class="note">Motions in development, rendered from their reference clips on the same robot model. Not in the sim yet. (Bow, squat, greeting, foot taps and the happy dance moved into the sim on 2026-10-09.)</p>
<div id="upgrid" class="upgrid"><p class="note">Loading…</p></div>
</section>
</main>
<script>
// status.json is rewritten by the operator's `cutesim on` / `cutesim off`; never contains the password.
async function refresh() {
const btn = document.getElementById('launch'), dot = document.getElementById('dot'), state = document.getElementById('state');
try {
const r = await fetch('status.json?t=' + Date.now(), { cache: 'no-store' });
const s = await r.json();
if (s.online && /^https:\/\/[a-z0-9-]+\.trycloudflare\.com\/?$/.test(s.url)) {
btn.href = s.url; btn.removeAttribute('aria-disabled'); dot.classList.add('on');
state.textContent = 'Online' + (s.updated ? ' since ' + s.updated : '');
} else {
btn.href = '#'; btn.setAttribute('aria-disabled', 'true'); dot.classList.remove('on');
state.textContent = 'Offline right now';
}
} catch (e) { state.textContent = 'Status unavailable'; }
}
refresh(); setInterval(refresh, 30000);
</script>
<script>
// Upcoming motions: upcoming/manifest.yaml (list of title / desc / file), synced by cute_upcoming_motions/sync_upcoming.sh.
function parseManifest(txt) {
const out = []; let cur = null, key = null;
const unq = v => { v = v.trim();
if (v.startsWith("'")) return v.slice(1, v.endsWith("'") ? -1 : undefined).replace(/''/g, "'");
if (v.startsWith('"')) return v.slice(1, v.endsWith('"') ? -1 : undefined).replace(/\\"/g, '"');
return v; };
for (const raw of txt.split(/\r?\n/)) {
const line = raw.replace(/\s+$/, '');
if (!line.trim() || line.trim().startsWith('#') || /^motions:/.test(line)) continue;
let m = line.match(/^\s*-\s+(\w+):\s*(.*)$/);
if (m) { cur = {}; out.push(cur); key = m[1]; cur[key] = m[2]; continue; }
m = line.match(/^\s+(\w+):\s*(.*)$/);
if (m && cur) { key = m[1]; cur[key] = m[2]; continue; }
if (cur && key) cur[key] += ' ' + line.trim(); // folded continuation line
}
return out.map(e => Object.fromEntries(Object.entries(e).map(([k, v]) => [k, unq(v)])));
}
async function loadUpcoming() {
const grid = document.getElementById('upgrid');
try {
const r = await fetch('upcoming/manifest.yaml?t=' + Date.now(), { cache: 'no-store' });
const items = parseManifest(await r.text()).filter(e => e.file && /^[\w.-]+\.(gif|webp|png)$/i.test(e.file));
grid.textContent = '';
for (const e of items) {
const f = document.createElement('figure');
const img = document.createElement('img'); img.loading = 'lazy'; img.src = 'upcoming/' + e.file; img.alt = e.title || e.file;
const c = document.createElement('figcaption');
const t = document.createElement('b'); t.textContent = e.title || '';
const s = document.createElement('span'); s.className = 'soon'; s.textContent = 'coming soon';
c.append(t, s, document.createElement('br'), document.createTextNode(e.desc || ''));
f.append(img, c); grid.append(f);
}
if (!items.length) grid.innerHTML = '<p class="note">Nothing listed right now.</p>';
} catch (e) { grid.innerHTML = '<p class="note">Could not load the list.</p>'; }
}
loadUpcoming();
</script>
</body>
</html>