Skip to content

Commit a62d3cc

Browse files
authored
feat(ui): plegar la portada y dejar la barra de acciones en una fila (#56)
1 parent 3c9a9c3 commit a62d3cc

10 files changed

Lines changed: 547 additions & 89 deletions

File tree

src/presentation/App.ts

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,38 +49,64 @@ export function renderApp(root: HTMLElement): void {
4949
</li>`,
5050
).join('')}
5151
</ul>
52+
53+
<!-- On the camera, not in the action bar: it is a control about the camera, the same
54+
argument that put the body-part chips here. Off, it would be a button that does
55+
nothing visible. -->
56+
<button class="stage__flip" id="flip" type="button" aria-label="Cambiar a cámara trasera">
57+
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor"
58+
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
59+
<path d="M4 9h3l1.6-2.2h6.8L17 9h3v10H4z" />
60+
<path d="M9.6 14.2a2.6 2.6 0 0 0 4.9.9M14.4 13a2.6 2.6 0 0 0-4.9-.9" />
61+
<path d="M9.4 10.6v1.5h1.5M14.6 15.6v-1.5h-1.5" />
62+
</svg>
63+
</button>
5264
</div>
5365
5466
<div class="transcript" id="transcript" aria-live="polite"></div>
5567
5668
<p class="status" id="status" role="status"></p>
5769
58-
<div class="actions actions--bar">
59-
<button class="button" id="toggle" type="button">Empezar a leer</button>
60-
<button class="button button--quiet" id="undo" type="button">Borrar último</button>
61-
<button class="button button--quiet" id="clear" type="button">Limpiar</button>
62-
<button class="button button--quiet" id="flip" type="button">Cámara trasera</button>
70+
<!-- One row: the four buttons this replaces wrapped to two on a 390 px phone and to
71+
three at 320 px, and two of them acted on a transcript that did not exist yet. -->
72+
<div class="actions actions--bar" id="actions">
73+
<button class="button button--grow" id="toggle" type="button">Empezar a leer</button>
74+
<div class="actions__edit" id="edit" hidden>
75+
<button class="button button--quiet" id="undo" type="button">Deshacer</button>
76+
<button class="button button--quiet" id="clear" type="button">Limpiar</button>
77+
</div>
6378
</div>
6479
</div>
6580
6681
<div id="teach"></div>
6782
<div id="storage"></div>
6883
<div id="diagnostics"></div>
6984
70-
<section class="card">
71-
<h2 class="card__title">Qué reconoce, y con qué fiabilidad</h2>
72-
<p class="card__body">
73-
<strong>Vocabulario LSE:</strong> 238 signos de ámbito sanitario, entrenados sobre
74-
SWL-LSE. Acierta el signo exacto en torno a <strong>2 de cada 3 veces</strong>, y está
75-
entre sus tres primeras opciones en <strong>8 de cada 10</strong>. Es un modelo real,
76-
no infalible: revisa el texto antes de darlo por bueno.
77-
</p>
78-
<p class="card__body" style="margin-top: 10px">
79-
<strong>Alfabeto dactilológico:</strong> para deletrear cualquier palabra fuera de ese
80-
vocabulario. Todavía no distingue <strong>${UNSUPPORTED_LETTERS.join(', ')}</strong>:
81-
unas se trazan con movimiento y otras dependen de la orientación de la palma.
82-
</p>
83-
</section>
85+
<!--
86+
The reliability figure lives in the summary, not behind it. Folding this card away
87+
saves 363 px, but what made it worth having was saying out loud that the model is
88+
fallible — so that sentence has to survive the fold.
89+
-->
90+
<details class="card">
91+
<summary class="card__summary">
92+
<h2 class="card__title">Qué reconoce</h2>
93+
<span class="card__note">238 signos LSE y el alfabeto — acierta 2 de cada 3 veces</span>
94+
</summary>
95+
96+
<div class="card__content">
97+
<p class="card__body">
98+
<strong>Vocabulario LSE:</strong> 238 signos de ámbito sanitario, entrenados sobre
99+
SWL-LSE. Acierta el signo exacto en torno a <strong>2 de cada 3 veces</strong>, y está
100+
entre sus tres primeras opciones en <strong>8 de cada 10</strong>. Es un modelo real,
101+
no infalible: revisa el texto antes de darlo por bueno.
102+
</p>
103+
<p class="card__body" style="margin-top: 10px">
104+
<strong>Alfabeto dactilológico:</strong> para deletrear cualquier palabra fuera de ese
105+
vocabulario. Todavía no distingue <strong>${UNSUPPORTED_LETTERS.join(', ')}</strong>:
106+
unas se trazan con movimiento y otras dependen de la orientación de la palma.
107+
</p>
108+
</div>
109+
</details>
84110
85111
<p class="footnote">
86112
v${__APP_VERSION__} · Vocabulario LSE sobre
@@ -95,6 +121,15 @@ export function renderApp(root: HTMLElement): void {
95121
const transcriptEl = must<HTMLElement>(root, '#transcript');
96122
const toggle = must<HTMLButtonElement>(root, '#toggle');
97123
const status = must<HTMLElement>(root, '#status');
124+
const edit = must<HTMLElement>(root, '#edit');
125+
126+
// In camera mode the bar is fixed, so the page reserves its height at the bottom. Measured
127+
// rather than hard-coded: it grows a second row on a narrow phone once the transcript has
128+
// text, and a stale constant would leave the last panel hidden underneath it.
129+
const actions = must<HTMLElement>(root, '#actions');
130+
new ResizeObserver(() => {
131+
root.style.setProperty('--bar', `${actions.offsetHeight}px`);
132+
}).observe(actions);
98133

99134
const parts = must<HTMLElement>(root, '#parts');
100135
const container = new Container(video);
@@ -114,6 +149,9 @@ export function renderApp(root: HTMLElement): void {
114149

115150
const render = (text: string, candidates: readonly { gloss: { text: string } }[]) => {
116151
transcriptEl.textContent = text;
152+
// Undo and clear exist only once there is something to undo or clear. Every path that
153+
// changes the transcript comes through here, so this is the single place that decides.
154+
edit.hidden = text.length === 0;
117155
const top = candidates[0]?.gloss.text;
118156
hint.hidden = !top;
119157
if (top) hint.textContent = top.toUpperCase();
@@ -204,7 +242,11 @@ export function renderApp(root: HTMLElement): void {
204242
const mirrored = next === 'user';
205243
video.classList.toggle('is-flipped', !mirrored);
206244
overlayCanvas.classList.toggle('is-flipped', !mirrored);
207-
flip.textContent = mirrored ? 'Cámara trasera' : 'Cámara frontal';
245+
// The icon says "switch", so the name has to say which way — it is the only label.
246+
flip.setAttribute(
247+
'aria-label',
248+
mirrored ? 'Cambiar a cámara trasera' : 'Cambiar a cámara frontal',
249+
);
208250
status.textContent = mirrored
209251
? 'Cámara frontal: para signar tú.'
210252
: 'Cámara trasera: para leer a quien tienes delante.';

src/presentation/components/DiagnosticsPanel.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ const VETO_LABELS: Record<WindowVeto, string> = {
3434
* reproducing.
3535
*/
3636
export class DiagnosticsPanel {
37-
private open = false;
3837
private latest: RecognitionDiagnostics = EMPTY_DIAGNOSTICS;
3938

4039
constructor(private readonly root: HTMLElement) {
@@ -43,33 +42,32 @@ export class DiagnosticsPanel {
4342

4443
update(diagnostics: RecognitionDiagnostics): void {
4544
this.latest = diagnostics;
46-
if (this.open) this.paint();
45+
// Painting a shut panel is work nobody sees, at the frame rate of the camera.
46+
if (this.details().open) this.paint();
4747
}
4848

4949
private render(): void {
5050
this.root.innerHTML = `
51-
<section class="card">
52-
<h2 class="card__title">Diagnóstico</h2>
53-
<p class="card__body">
54-
Para entender por qué no aparece una palabra. No hace falta para usar la app.
55-
</p>
56-
<div class="actions">
57-
<button class="button button--quiet" id="diag-toggle" type="button">Ver diagnóstico</button>
58-
</div>
59-
<dl class="diagnostics" id="diag-body" hidden></dl>
60-
</section>
51+
<details class="card">
52+
<summary class="card__summary">
53+
<h2 class="card__title">Diagnóstico</h2>
54+
<span class="card__note">Por qué no aparece una palabra</span>
55+
</summary>
56+
<dl class="diagnostics card__content" id="diag-body"></dl>
57+
</details>
6158
`;
6259

63-
this.root.querySelector<HTMLButtonElement>('#diag-toggle')!.addEventListener('click', () => {
64-
this.open = !this.open;
65-
this.body().hidden = !this.open;
66-
this.root.querySelector<HTMLButtonElement>('#diag-toggle')!.textContent = this.open
67-
? 'Ocultar diagnóstico'
68-
: 'Ver diagnóstico';
69-
if (this.open) this.paint();
60+
// Opening it must show the latest reading, not wait for the next frame — the panel is
61+
// also opened with the camera off, when no further update is ever coming.
62+
this.details().addEventListener('toggle', () => {
63+
if (this.details().open) this.paint();
7064
});
7165
}
7266

67+
private details(): HTMLDetailsElement {
68+
return this.root.querySelector<HTMLDetailsElement>('details')!;
69+
}
70+
7371
private body(): HTMLElement {
7472
return this.root.querySelector<HTMLElement>('#diag-body')!;
7573
}

src/presentation/components/StoragePanel.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,33 @@ export class StoragePanel {
2828

2929
private render(): void {
3030
this.root.innerHTML = `
31-
<section class="card">
32-
<h2 class="card__title">Espacio en el dispositivo</h2>
33-
<p class="card__body">
34-
El motor de reconocimiento son unos <strong>19 MB de descarga</strong>, que ocupan
35-
<strong>30 MB en el dispositivo</strong> una vez descomprimidos. Se guarda la primera
36-
vez que lo usas para que después funcione sin conexión. Puedes descargarlo ahora o
37-
liberarlo cuando quieras: se volverá a bajar la próxima vez que enciendas la cámara.
38-
</p>
39-
40-
<p class="storage" id="storage-figure">—</p>
41-
42-
<div class="actions">
43-
<button class="button button--quiet" id="preload" type="button">Descargar ahora</button>
44-
<button class="button button--quiet" id="clear" type="button">Liberar espacio</button>
31+
<details class="card">
32+
<summary class="card__summary">
33+
<h2 class="card__title">Espacio en el dispositivo</h2>
34+
<span class="card__note">19 MB de descarga, 30 MB guardados</span>
35+
</summary>
36+
37+
<div class="card__content">
38+
<p class="card__body">
39+
El motor de reconocimiento son unos <strong>19 MB de descarga</strong>, que ocupan
40+
<strong>30 MB en el dispositivo</strong> una vez descomprimidos. Se guarda la primera
41+
vez que lo usas para que después funcione sin conexión. Puedes descargarlo ahora o
42+
liberarlo cuando quieras: se volverá a bajar la próxima vez que enciendas la cámara.
43+
</p>
44+
45+
<p class="storage" id="storage-figure">—</p>
46+
47+
<div class="actions">
48+
<button class="button button--quiet" id="preload" type="button">Descargar ahora</button>
49+
<button class="button button--quiet" id="clear" type="button">Liberar espacio</button>
50+
</div>
51+
52+
<p class="status" id="storage-status" role="status"></p>
53+
<p class="card__body card__body--tight">
54+
Los signos que le hayas enseñado no se borran con esto: son tuyos y se guardan aparte.
55+
</p>
4556
</div>
46-
47-
<p class="status" id="storage-status" role="status"></p>
48-
<p class="card__body card__body--tight">
49-
Los signos que le hayas enseñado no se borran con esto: son tuyos y se guardan aparte.
50-
</p>
51-
</section>
57+
</details>
5258
`;
5359

5460
this.button('preload').addEventListener('click', () => void this.preload());

src/presentation/components/TeachSignPanel.ts

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,36 +35,41 @@ export class TeachSignPanel {
3535
}
3636

3737
private render(): void {
38+
// Teaching and the resulting list are one subject, and an empty list did not deserve a
39+
// card of its own on a page that already ran three screens long.
3840
this.root.innerHTML = `
39-
<section class="card">
40-
<h2 class="card__title">Enseñar un signo</h2>
41-
<p class="card__body">
42-
Graba el mismo signo ${MIN_PROTOTYPES_PER_SIGN} veces y escribe la palabra que debe
43-
aparecer. Funciona con cualquier signo y cualquier lengua de signos, y se queda en
44-
este dispositivo.
45-
</p>
46-
47-
<label class="field">
48-
<span class="field__label">Palabra que se escribirá</span>
49-
<input id="sign-text" class="field__input" type="text" maxlength="40"
50-
placeholder="ibuprofeno" autocomplete="off" />
51-
</label>
52-
53-
<div class="takes" id="takes"></div>
54-
55-
<div class="actions">
56-
<button class="button" id="record" type="button">Grabar una toma</button>
57-
<button class="button button--quiet" id="save" type="button" disabled>Guardar</button>
58-
<button class="button button--quiet" id="reset" type="button">Descartar tomas</button>
41+
<details class="card">
42+
<summary class="card__summary">
43+
<h2 class="card__title">Enseñar un signo</h2>
44+
<span class="card__note">Cualquier signo, cualquier lengua, sólo aquí</span>
45+
</summary>
46+
47+
<div class="card__content">
48+
<p class="card__body">
49+
Graba el mismo signo ${MIN_PROTOTYPES_PER_SIGN} veces y escribe la palabra que debe
50+
aparecer.
51+
</p>
52+
53+
<label class="field">
54+
<span class="field__label">Palabra que se escribirá</span>
55+
<input id="sign-text" class="field__input" type="text" maxlength="40"
56+
placeholder="ibuprofeno" autocomplete="off" />
57+
</label>
58+
59+
<div class="takes" id="takes"></div>
60+
61+
<div class="actions">
62+
<button class="button" id="record" type="button">Grabar una toma</button>
63+
<button class="button button--quiet" id="save" type="button" disabled>Guardar</button>
64+
<button class="button button--quiet" id="reset" type="button">Descartar tomas</button>
65+
</div>
66+
67+
<p class="status" id="teach-status" role="status"></p>
68+
69+
<h3 class="card__subtitle">Tus signos</h3>
70+
<ul class="signs" id="signs"></ul>
5971
</div>
60-
61-
<p class="status" id="teach-status" role="status"></p>
62-
</section>
63-
64-
<section class="card">
65-
<h2 class="card__title">Tus signos</h2>
66-
<ul class="signs" id="signs"></ul>
67-
</section>
72+
</details>
6873
`;
6974

7075
this.button('record').addEventListener('click', () => void this.recordTake());
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
EMPTY_DIAGNOSTICS,
3+
type RecognitionDiagnostics,
4+
} from '@domain/recognition/value-objects/RecognitionDiagnostics';
5+
import { DiagnosticsPanel } from '@presentation/components/DiagnosticsPanel';
6+
import { beforeEach, describe, expect, it } from 'vitest';
7+
8+
const reading: RecognitionDiagnostics = {
9+
...EMPTY_DIAGNOSTICS,
10+
framesSeen: 42,
11+
windowsClosed: 3,
12+
windowsTooShort: 1,
13+
};
14+
15+
/** The toggle event is queued, not synchronous — opening is observable on the next tick. */
16+
const openAndSettle = async (details: HTMLDetailsElement): Promise<void> => {
17+
details.open = true;
18+
await new Promise((resolve) => setTimeout(resolve, 0));
19+
};
20+
21+
describe('DiagnosticsPanel', () => {
22+
let root: HTMLElement;
23+
let panel: DiagnosticsPanel;
24+
25+
beforeEach(() => {
26+
root = document.createElement('div');
27+
panel = new DiagnosticsPanel(root);
28+
});
29+
30+
it('starts shut, so it costs one line on a page read by someone not debugging', () => {
31+
expect(root.querySelector<HTMLDetailsElement>('details')?.open).toBe(false);
32+
expect(root.querySelector('#diag-body')?.children).toHaveLength(0);
33+
});
34+
35+
it('shows the last reading taken while it was shut, not an empty panel', async () => {
36+
panel.update(reading);
37+
await openAndSettle(root.querySelector<HTMLDetailsElement>('details')!);
38+
39+
expect(root.querySelector('#diag-body')?.textContent).toContain('42');
40+
expect(root.querySelector('#diag-body')?.textContent).toContain('3 (1 descartadas por cortas)');
41+
});
42+
43+
it('keeps updating while open, since it is read with the camera running', async () => {
44+
await openAndSettle(root.querySelector<HTMLDetailsElement>('details')!);
45+
panel.update({ ...reading, framesSeen: 99 });
46+
47+
expect(root.querySelector('#diag-body')?.textContent).toContain('99');
48+
});
49+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { StoragePanel, type StoragePanelPorts } from '@presentation/components/StoragePanel';
2+
import { describe, expect, it } from 'vitest';
3+
4+
const ports: StoragePanelPorts = {
5+
isSupported: () => true,
6+
report: async () => ({ cachedBytes: 0, entries: 0, hasRuntime: false }),
7+
clear: async () => true,
8+
preload: async () => {},
9+
};
10+
11+
describe('StoragePanel', () => {
12+
it('folds away, since choosing to pre-download is a once-ever decision', () => {
13+
const root = document.createElement('div');
14+
new StoragePanel(root, ports);
15+
16+
const card = root.querySelector<HTMLDetailsElement>('details.card');
17+
expect(card?.open).toBe(false);
18+
expect(card?.querySelector('#preload')).not.toBeNull();
19+
expect(card?.querySelector('#clear')).not.toBeNull();
20+
});
21+
22+
it('keeps the size in the summary, where it is read without opening anything', () => {
23+
const root = document.createElement('div');
24+
new StoragePanel(root, ports);
25+
26+
expect(root.querySelector('summary')?.textContent).toContain('19 MB');
27+
});
28+
});

0 commit comments

Comments
 (0)