Spaces:
Running
Running
RemiFabre commited on
Commit ·
d96c9a7
1
Parent(s): 02264c2
ui: single scrolling page, always-on Listen, leaner Learn
Browse files- Compose, then an always-present + auto-listening Listen panel, then the Learn
chart on one scrollable page (no tab bar) — send from the robot and watch it
decode live, scroll to look things up without losing the live message.
- Listen auto-starts the mic (falls back to a tap-to-start if the browser
blocks it before a gesture).
- Learn: removed punctuation (too advanced); fixed the number tiles colliding
(smaller chips, wider cells, wrap); no em-dashes in UI text.
- main.js +20 -27
- style.css +14 -10
- tests/unit/views.test.js +2 -2
- views/learn.js +3 -5
- views/listen.js +28 -11
main.js
CHANGED
|
@@ -90,48 +90,40 @@ async function bootEmbed() {
|
|
| 90 |
toast,
|
| 91 |
};
|
| 92 |
|
| 93 |
-
// ─── Views
|
|
|
|
|
|
|
| 94 |
const views = {
|
| 95 |
compose: createCompose(ctx),
|
| 96 |
listen: createListen(ctx),
|
| 97 |
learn: createLearn(ctx),
|
| 98 |
};
|
| 99 |
-
const order = ["compose", "listen", "learn"];
|
| 100 |
-
const labels = { compose: "Compose", listen: "Listen", learn: "Learn" };
|
| 101 |
-
const icons = { compose: "✏️", listen: "🎧", learn: "📖" };
|
| 102 |
|
| 103 |
const app = document.getElementById("app");
|
| 104 |
clear(app);
|
| 105 |
|
| 106 |
-
const viewHost = el("main.view-host");
|
| 107 |
-
let current = null;
|
| 108 |
-
function show(name) {
|
| 109 |
-
if (current === name) return;
|
| 110 |
-
if (current && views[current].onHide) views[current].onHide();
|
| 111 |
-
current = name;
|
| 112 |
-
clear(viewHost);
|
| 113 |
-
viewHost.append(views[name].node);
|
| 114 |
-
views[name].onShow?.();
|
| 115 |
-
[...tabbar.children].forEach((b) =>
|
| 116 |
-
b.classList.toggle("active", b.dataset.view === name));
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
const tabbar = el("nav.tabbar");
|
| 120 |
-
order.forEach((name) => {
|
| 121 |
-
tabbar.append(el("button.tab", {
|
| 122 |
-
"data-view": name,
|
| 123 |
-
onclick: () => show(name),
|
| 124 |
-
}, [el("span.tab-ico", {}, icons[name]), el("span.tab-label", {}, labels[name])]));
|
| 125 |
-
});
|
| 126 |
-
|
| 127 |
const header = el("header.appbar", {}, [
|
| 128 |
el("div.brand", {}, [el("span.brand-logo", {}, "📡"), el("span", {}, "Morse Code")]),
|
| 129 |
el("button.icon-btn", { title: "Settings", onclick: openSettings }, "⚙️"),
|
| 130 |
]);
|
| 131 |
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
app.hidden = false;
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
// ─── Settings modal ─────────────────────────────────────────────
|
| 137 |
function openSettings() {
|
|
@@ -151,6 +143,7 @@ async function bootEmbed() {
|
|
| 151 |
handle.onLeave?.(() => {
|
| 152 |
try { tapper?.stop(); } catch { /* */ }
|
| 153 |
try { synth.stop(); } catch { /* */ }
|
|
|
|
| 154 |
if (reachy) return safelyReturnToPose(reachy);
|
| 155 |
});
|
| 156 |
|
|
|
|
| 90 |
toast,
|
| 91 |
};
|
| 92 |
|
| 93 |
+
// ─── Views (single scrolling page: Compose, then always-on Listen,
|
| 94 |
+
// then the Learn chart — scroll down to look anything up while a
|
| 95 |
+
// live message keeps decoding) ────────────────────────────────────
|
| 96 |
const views = {
|
| 97 |
compose: createCompose(ctx),
|
| 98 |
listen: createListen(ctx),
|
| 99 |
learn: createLearn(ctx),
|
| 100 |
};
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
const app = document.getElementById("app");
|
| 103 |
clear(app);
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
const header = el("header.appbar", {}, [
|
| 106 |
el("div.brand", {}, [el("span.brand-logo", {}, "📡"), el("span", {}, "Morse Code")]),
|
| 107 |
el("button.icon-btn", { title: "Settings", onclick: openSettings }, "⚙️"),
|
| 108 |
]);
|
| 109 |
|
| 110 |
+
const scroller = el("main.scroller", {}, [
|
| 111 |
+
views.compose.node,
|
| 112 |
+
el("hr.divider"),
|
| 113 |
+
views.listen.node,
|
| 114 |
+
el("hr.divider"),
|
| 115 |
+
views.learn.node,
|
| 116 |
+
]);
|
| 117 |
+
|
| 118 |
+
app.append(header, scroller);
|
| 119 |
app.hidden = false;
|
| 120 |
+
views.compose.onShow?.();
|
| 121 |
+
views.listen.onShow?.();
|
| 122 |
+
views.learn.onShow?.();
|
| 123 |
+
// The Listen panel is always present and listening — auto-start the mic so
|
| 124 |
+
// you can send from the robot and watch it decode live. Needs a user
|
| 125 |
+
// gesture in some browsers; on failure the panel shows a tap-to-start button.
|
| 126 |
+
views.listen.autoStart?.();
|
| 127 |
|
| 128 |
// ─── Settings modal ─────────────────────────────────────────────
|
| 129 |
function openSettings() {
|
|
|
|
| 143 |
handle.onLeave?.(() => {
|
| 144 |
try { tapper?.stop(); } catch { /* */ }
|
| 145 |
try { synth.stop(); } catch { /* */ }
|
| 146 |
+
try { views.listen.onHide?.(); } catch { /* */ }
|
| 147 |
if (reachy) return safelyReturnToPose(reachy);
|
| 148 |
});
|
| 149 |
|
style.css
CHANGED
|
@@ -81,9 +81,11 @@ html, body {
|
|
| 81 |
}
|
| 82 |
.icon-btn:active { transform: scale(0.95); }
|
| 83 |
|
| 84 |
-
/* ───
|
|
|
|
|
|
|
| 85 |
.view-host { flex: 1; padding: 16px 16px 96px; }
|
| 86 |
-
.view { display: flex; flex-direction: column; gap: 16px; }
|
| 87 |
.view h2 { margin: 4px 0 0; font-size: 24px; letter-spacing: -0.02em; }
|
| 88 |
.view h3 { margin: 6px 0; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
| 89 |
.muted { color: var(--muted); }
|
|
@@ -193,15 +195,15 @@ html, body {
|
|
| 193 |
border: 1px solid var(--line);
|
| 194 |
border-radius: var(--radius);
|
| 195 |
}
|
| 196 |
-
.pattern { display: inline-flex; align-items: center; gap: 4px; }
|
| 197 |
.pattern .dot, .pattern .dash {
|
| 198 |
display: inline-block;
|
| 199 |
-
height:
|
| 200 |
-
border-radius:
|
| 201 |
background: var(--accent);
|
| 202 |
}
|
| 203 |
-
.pattern .dot { width:
|
| 204 |
-
.pattern .dash { width:
|
| 205 |
.wordsep { color: var(--muted); font-weight: 800; padding: 0 2px; }
|
| 206 |
|
| 207 |
/* ─── Listen ──────────────────────────────────────────────── */
|
|
@@ -235,11 +237,13 @@ html, body {
|
|
| 235 |
|
| 236 |
/* ─── Learn chart ─────────────────────────────────────────── */
|
| 237 |
.chart-section { display: flex; flex-direction: column; gap: 8px; }
|
| 238 |
-
.chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(
|
| 239 |
.chart-cell {
|
| 240 |
appearance: none;
|
| 241 |
-
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
| 242 |
padding: 12px 8px;
|
|
|
|
|
|
|
| 243 |
background: var(--panel);
|
| 244 |
border: 1px solid var(--line);
|
| 245 |
border-radius: 14px;
|
|
@@ -275,7 +279,7 @@ html, body {
|
|
| 275 |
|
| 276 |
/* ─── Toast ───────────────────────────────────────────────── */
|
| 277 |
.toast {
|
| 278 |
-
position: fixed; left: 50%; bottom:
|
| 279 |
background: var(--panel-2); color: var(--text);
|
| 280 |
border: 1px solid var(--line);
|
| 281 |
padding: 12px 18px; border-radius: 12px;
|
|
|
|
| 81 |
}
|
| 82 |
.icon-btn:active { transform: scale(0.95); }
|
| 83 |
|
| 84 |
+
/* ─── Scrolling single page ───────────────────────────────── */
|
| 85 |
+
.scroller { flex: 1; padding: 16px 16px 48px; display: flex; flex-direction: column; gap: 22px; }
|
| 86 |
+
.divider { width: 100%; border: 0; border-top: 1px solid var(--line); margin: 2px 0; }
|
| 87 |
.view-host { flex: 1; padding: 16px 16px 96px; }
|
| 88 |
+
.view { display: flex; flex-direction: column; gap: 16px; scroll-margin-top: 72px; }
|
| 89 |
.view h2 { margin: 4px 0 0; font-size: 24px; letter-spacing: -0.02em; }
|
| 90 |
.view h3 { margin: 6px 0; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
| 91 |
.muted { color: var(--muted); }
|
|
|
|
| 195 |
border: 1px solid var(--line);
|
| 196 |
border-radius: var(--radius);
|
| 197 |
}
|
| 198 |
+
.pattern { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 4px; max-width: 100%; }
|
| 199 |
.pattern .dot, .pattern .dash {
|
| 200 |
display: inline-block;
|
| 201 |
+
height: 10px;
|
| 202 |
+
border-radius: 5px;
|
| 203 |
background: var(--accent);
|
| 204 |
}
|
| 205 |
+
.pattern .dot { width: 10px; }
|
| 206 |
+
.pattern .dash { width: 22px; background: var(--accent-2); }
|
| 207 |
.wordsep { color: var(--muted); font-weight: 800; padding: 0 2px; }
|
| 208 |
|
| 209 |
/* ─── Listen ──────────────────────────────────────────────── */
|
|
|
|
| 237 |
|
| 238 |
/* ─── Learn chart ─────────────────────────────────────────── */
|
| 239 |
.chart-section { display: flex; flex-direction: column; gap: 8px; }
|
| 240 |
+
.chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 8px; }
|
| 241 |
.chart-cell {
|
| 242 |
appearance: none;
|
| 243 |
+
display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 8px;
|
| 244 |
padding: 12px 8px;
|
| 245 |
+
min-height: 78px;
|
| 246 |
+
overflow: hidden;
|
| 247 |
background: var(--panel);
|
| 248 |
border: 1px solid var(--line);
|
| 249 |
border-radius: 14px;
|
|
|
|
| 279 |
|
| 280 |
/* ─── Toast ───────────────────────────────────────────────── */
|
| 281 |
.toast {
|
| 282 |
+
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(20px);
|
| 283 |
background: var(--panel-2); color: var(--text);
|
| 284 |
border: 1px solid var(--line);
|
| 285 |
padding: 12px 18px; border-radius: 12px;
|
tests/unit/views.test.js
CHANGED
|
@@ -62,11 +62,11 @@ describe("Compose view", () => {
|
|
| 62 |
});
|
| 63 |
|
| 64 |
describe("Learn view", () => {
|
| 65 |
-
it("renders a tile per letter
|
| 66 |
const ctx = makeCtx();
|
| 67 |
const { node } = createLearn(ctx);
|
| 68 |
const cells = node.querySelectorAll(".chart-cell");
|
| 69 |
-
expect(cells.length).toBe(26 + 10
|
| 70 |
cells[0].click(); // 'A'
|
| 71 |
expect(ctx._synth.play).toHaveBeenCalled();
|
| 72 |
});
|
|
|
|
| 62 |
});
|
| 63 |
|
| 64 |
describe("Learn view", () => {
|
| 65 |
+
it("renders a tile per letter and digit (no punctuation), and plays on tap", () => {
|
| 66 |
const ctx = makeCtx();
|
| 67 |
const { node } = createLearn(ctx);
|
| 68 |
const cells = node.querySelectorAll(".chart-cell");
|
| 69 |
+
expect(cells.length).toBe(26 + 10);
|
| 70 |
cells[0].click(); // 'A'
|
| 71 |
expect(ctx._synth.play).toHaveBeenCalled();
|
| 72 |
});
|
views/learn.js
CHANGED
|
@@ -8,7 +8,6 @@ import { tokensToOnsets } from "../lib/wire.js";
|
|
| 8 |
|
| 9 |
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
| 10 |
const DIGITS = "0123456789".split("");
|
| 11 |
-
const PUNCT = [".", ",", "?", "/", "@", "=", "+", "-", "!", "'", "(", ")"];
|
| 12 |
|
| 13 |
export function createLearn(ctx) {
|
| 14 |
const root = el("section.view#view-learn");
|
|
@@ -48,14 +47,13 @@ export function createLearn(ctx) {
|
|
| 48 |
|
| 49 |
root.append(
|
| 50 |
el("h2", {}, "Learn Morse"),
|
| 51 |
-
el("p.muted", {}, "A letter is just a rhythm of short
|
| 52 |
emitterNote,
|
| 53 |
grid("Letters", LETTERS),
|
| 54 |
grid("Numbers", DIGITS),
|
| 55 |
-
grid("Punctuation", PUNCT),
|
| 56 |
el("div.legend", {}, [
|
| 57 |
-
el("span.legend-item", {}, [morsePattern("."), " dot
|
| 58 |
-
el("span.legend-item", {}, [morsePattern("-"), " dash
|
| 59 |
]),
|
| 60 |
);
|
| 61 |
|
|
|
|
| 8 |
|
| 9 |
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
| 10 |
const DIGITS = "0123456789".split("");
|
|
|
|
| 11 |
|
| 12 |
export function createLearn(ctx) {
|
| 13 |
const root = el("section.view#view-learn");
|
|
|
|
| 47 |
|
| 48 |
root.append(
|
| 49 |
el("h2", {}, "Learn Morse"),
|
| 50 |
+
el("p.muted", {}, "A letter is just a rhythm of short and long beats. Tap any tile to feel it."),
|
| 51 |
emitterNote,
|
| 52 |
grid("Letters", LETTERS),
|
| 53 |
grid("Numbers", DIGITS),
|
|
|
|
| 54 |
el("div.legend", {}, [
|
| 55 |
+
el("span.legend-item", {}, [morsePattern("."), " dot: one tap"]),
|
| 56 |
+
el("span.legend-item", {}, [morsePattern("-"), " dash: quick double tap"]),
|
| 57 |
]),
|
| 58 |
);
|
| 59 |
|
views/listen.js
CHANGED
|
@@ -13,7 +13,7 @@ export function createListen(ctx) {
|
|
| 13 |
const canvas = el("canvas.scope");
|
| 14 |
const scope = new Scope(canvas);
|
| 15 |
|
| 16 |
-
const liveText = el("div.decoded-text", {}, "
|
| 17 |
const liveMorse = el("div.decoded-morse muted");
|
| 18 |
const transcript = el("div.transcript");
|
| 19 |
const startBtn = el("button.btn.primary.big", { onclick: toggle }, "🎙 Start listening");
|
|
@@ -27,25 +27,41 @@ export function createListen(ctx) {
|
|
| 27 |
|
| 28 |
function timing() { return ctx.timing(); }
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
async function toggle() {
|
| 31 |
if (mic) return stopMic();
|
| 32 |
try {
|
| 33 |
-
|
| 34 |
-
onOnset: (t) => { onOnset(t); },
|
| 35 |
-
onLevel: (lvl) => scope.pushLevel(lvl),
|
| 36 |
-
});
|
| 37 |
-
await mic.start();
|
| 38 |
-
scope.start();
|
| 39 |
-
startBtn.textContent = "◼ Stop listening";
|
| 40 |
-
startBtn.classList.add("listening");
|
| 41 |
-
hint.textContent = "Listening… every detected hit drops a red marker.";
|
| 42 |
-
idleTimer = setInterval(checkIdle, 250);
|
| 43 |
} catch (e) {
|
| 44 |
ctx.toast(`Mic error: ${e?.message || e}`);
|
| 45 |
mic = null;
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
async function stopMic() {
|
| 50 |
if (idleTimer) { clearInterval(idleTimer); idleTimer = null; }
|
| 51 |
scope.stop();
|
|
@@ -112,5 +128,6 @@ export function createListen(ctx) {
|
|
| 112 |
node: root,
|
| 113 |
onShow: () => {},
|
| 114 |
onHide: () => { if (mic) stopMic(); },
|
|
|
|
| 115 |
};
|
| 116 |
}
|
|
|
|
| 13 |
const canvas = el("canvas.scope");
|
| 14 |
const scope = new Scope(canvas);
|
| 15 |
|
| 16 |
+
const liveText = el("div.decoded-text", {}, "…");
|
| 17 |
const liveMorse = el("div.decoded-morse muted");
|
| 18 |
const transcript = el("div.transcript");
|
| 19 |
const startBtn = el("button.btn.primary.big", { onclick: toggle }, "🎙 Start listening");
|
|
|
|
| 27 |
|
| 28 |
function timing() { return ctx.timing(); }
|
| 29 |
|
| 30 |
+
async function startMic() {
|
| 31 |
+
if (mic) return;
|
| 32 |
+
mic = ctx.makeMic({
|
| 33 |
+
onOnset: (t) => { onOnset(t); },
|
| 34 |
+
onLevel: (lvl) => scope.pushLevel(lvl),
|
| 35 |
+
});
|
| 36 |
+
await mic.start();
|
| 37 |
+
scope.start();
|
| 38 |
+
startBtn.textContent = "◼ Stop listening";
|
| 39 |
+
startBtn.classList.add("listening");
|
| 40 |
+
hint.textContent = "Listening. Every detected hit drops a red marker.";
|
| 41 |
+
idleTimer = setInterval(checkIdle, 250);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
async function toggle() {
|
| 45 |
if (mic) return stopMic();
|
| 46 |
try {
|
| 47 |
+
await startMic();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
} catch (e) {
|
| 49 |
ctx.toast(`Mic error: ${e?.message || e}`);
|
| 50 |
mic = null;
|
| 51 |
}
|
| 52 |
}
|
| 53 |
|
| 54 |
+
// Auto-start on load so the panel is always listening. Browsers may block
|
| 55 |
+
// mic/AudioContext until a user gesture; if so, leave the button for a tap.
|
| 56 |
+
async function autoStart() {
|
| 57 |
+
try {
|
| 58 |
+
await startMic();
|
| 59 |
+
} catch {
|
| 60 |
+
mic = null;
|
| 61 |
+
hint.textContent = "Tap “Start listening” to enable the microphone.";
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
async function stopMic() {
|
| 66 |
if (idleTimer) { clearInterval(idleTimer); idleTimer = null; }
|
| 67 |
scope.stop();
|
|
|
|
| 128 |
node: root,
|
| 129 |
onShow: () => {},
|
| 130 |
onHide: () => { if (mic) stopMic(); },
|
| 131 |
+
autoStart,
|
| 132 |
};
|
| 133 |
}
|