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.

Files changed (5) hide show
  1. main.js +20 -27
  2. style.css +14 -10
  3. tests/unit/views.test.js +2 -2
  4. views/learn.js +3 -5
  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
- app.append(header, viewHost, tabbar);
 
 
 
 
 
 
 
 
133
  app.hidden = false;
134
- show("compose");
 
 
 
 
 
 
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
- /* ─── View host + tabbar ──────────────────────────────────── */
 
 
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: 12px;
200
- border-radius: 6px;
201
  background: var(--accent);
202
  }
203
- .pattern .dot { width: 12px; }
204
- .pattern .dash { width: 30px; background: var(--accent-2); }
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(96px, 1fr)); gap: 8px; }
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: 110px; transform: translateX(-50%) translateY(20px);
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, digit and 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 + 12);
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 (·) and long (—) beats. Tap any tile to feel it."),
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 1 tap"]),
58
- el("span.legend-item", {}, [morsePattern("-"), " dash quick double-tap"]),
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
- mic = ctx.makeMic({
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
  }