Skip to content

Commit 488f1bf

Browse files
authored
feat: give the camera its own layout so no control needs a scroll (#49)
1 parent 540dbaa commit 488f1bf

9 files changed

Lines changed: 153 additions & 45 deletions

File tree

-69 Bytes
Loading

public/favicon.ico

1 Byte
Binary file not shown.

public/favicon.svg

Lines changed: 15 additions & 9 deletions
Loading

public/maskable-icon-512x512.png

-1.06 KB
Loading

public/pwa-192x192.png

-118 Bytes
Loading

public/pwa-512x512.png

-547 Bytes
Loading

public/pwa-64x64.png

0 Bytes
Loading

src/presentation/App.ts

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,43 +17,51 @@ declare const __APP_VERSION__: string;
1717

1818
export function renderApp(root: HTMLElement): void {
1919
root.innerHTML = `
20-
<header class="masthead">
21-
<img class="masthead__mark" src="${import.meta.env.BASE_URL}favicon.svg" alt="" width="44" height="44" />
22-
<div>
23-
<h1 class="masthead__title">Esku</h1>
24-
<p class="masthead__tagline">Lengua de signos a texto, sin conexión</p>
20+
<!--
21+
Everything needed to actually read signs lives in one viewport-tall column, so that
22+
turning the camera on never means scrolling to reach a control. Secondary panels stay
23+
below it rather than behind a sheet: the diagnostics panel in particular is read *while*
24+
the camera runs, and the sticky action bar keeps the controls reachable down there.
25+
-->
26+
<div class="shell" id="shell">
27+
<header class="masthead">
28+
<img class="masthead__mark" src="${import.meta.env.BASE_URL}favicon.svg" alt="" width="44" height="44" />
29+
<div>
30+
<h1 class="masthead__title">Esku</h1>
31+
<p class="masthead__tagline">Lengua de signos a texto, sin conexión</p>
32+
</div>
33+
</header>
34+
35+
<div class="stage">
36+
<video id="video" class="stage__video" playsinline muted></video>
37+
<canvas id="overlay" class="stage__overlay" aria-hidden="true"></canvas>
38+
<p class="stage__placeholder" id="placeholder">
39+
La cámara se activa al empezar.<br />El vídeo no se graba ni sale del dispositivo.
40+
</p>
41+
<p class="stage__hint" id="hint" hidden></p>
42+
43+
<ul class="parts" id="parts">
44+
${PART_ORDER.map(
45+
(part) => `
46+
<li class="part" data-part="${part}">
47+
<span class="part__dot" style="--part: ${PART_COLOURS[part]}"></span>
48+
${PART_LABELS[part]}
49+
</li>`,
50+
).join('')}
51+
</ul>
2552
</div>
26-
</header>
2753
28-
<div class="stage">
29-
<video id="video" class="stage__video" playsinline muted></video>
30-
<canvas id="overlay" class="stage__overlay" aria-hidden="true"></canvas>
31-
<p class="stage__placeholder" id="placeholder">
32-
La cámara se activa al empezar.<br />El vídeo no se graba ni sale del dispositivo.
33-
</p>
34-
<p class="stage__hint" id="hint" hidden></p>
35-
</div>
54+
<div class="transcript" id="transcript" aria-live="polite"></div>
3655
37-
<ul class="parts" id="parts">
38-
${PART_ORDER.map(
39-
(part) => `
40-
<li class="part" data-part="${part}">
41-
<span class="part__dot" style="--part: ${PART_COLOURS[part]}"></span>
42-
${PART_LABELS[part]}
43-
</li>`,
44-
).join('')}
45-
</ul>
46-
47-
<div class="transcript" id="transcript" aria-live="polite"></div>
48-
49-
<div class="actions">
50-
<button class="button" id="toggle" type="button">Empezar a leer</button>
51-
<button class="button button--quiet" id="undo" type="button">Borrar último</button>
52-
<button class="button button--quiet" id="clear" type="button">Limpiar</button>
53-
<button class="button button--quiet" id="flip" type="button">Cámara trasera</button>
54-
</div>
56+
<p class="status" id="status" role="status"></p>
5557
56-
<p class="status" id="status" role="status"></p>
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>
63+
</div>
64+
</div>
5765
5866
<div id="teach"></div>
5967
<div id="storage"></div>
@@ -125,6 +133,7 @@ export function renderApp(root: HTMLElement): void {
125133
if (running) {
126134
recognize.stop();
127135
running = false;
136+
root.classList.remove('is-running');
128137
toggle.textContent = 'Empezar a leer';
129138
placeholder.hidden = false;
130139
hint.hidden = true;
@@ -152,6 +161,9 @@ export function renderApp(root: HTMLElement): void {
152161
diagnostics.update(update.diagnostics);
153162
});
154163
running = true;
164+
// Camera mode: the masthead folds away, the video takes the height the fixed 3/4 ratio
165+
// used to claim regardless of device, and the controls pin to the bottom of the screen.
166+
root.classList.add('is-running');
155167
placeholder.hidden = true;
156168
video.classList.add('is-live');
157169
overlayCanvas.classList.add('is-live');
@@ -161,6 +173,8 @@ export function renderApp(root: HTMLElement): void {
161173
error instanceof CameraUnavailableError
162174
? 'No hay cámara o se denegó el permiso. Revísalo en los ajustes del navegador.'
163175
: 'No se pudo iniciar el reconocimiento.';
176+
// The camera never opened, so the layout must not be left claiming it did.
177+
root.classList.remove('is-running');
164178
// Without the real cause in the console this is undiagnosable from a bug report.
165179
console.error(error);
166180
} finally {

src/presentation/styles/global.css

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,56 @@ body {
6565
}
6666

6767
#app {
68+
--pad-top: 20px;
69+
--pad-bottom: calc(40px + env(safe-area-inset-bottom));
70+
/* Height of the pinned action bar in camera mode, reserved so nothing hides beneath it. */
71+
--bar: 72px;
72+
6873
max-width: 560px;
6974
margin: 0 auto;
70-
padding: 20px 16px 40px;
7175
/* Keep the transcript clear of the iOS home indicator when installed. */
72-
padding-bottom: calc(40px + env(safe-area-inset-bottom));
76+
padding: var(--pad-top) 16px var(--pad-bottom);
77+
}
78+
79+
/*
80+
* Camera mode.
81+
*
82+
* With the camera on this stops being a page and becomes a tool: everything needed to read
83+
* signs occupies exactly one viewport, so no control is ever a scroll away. Measured before
84+
* this existed, the primary block alone came to ~813 px on a 390x844 phone — the masthead and
85+
* a hard-coded 3/4 stage between them claimed more than half the screen.
86+
*/
87+
#app.is-running .shell {
88+
display: flex;
89+
flex-direction: column;
90+
gap: var(--gap);
91+
/* dvh, not vh: mobile browsers shrink the viewport as their chrome collapses, and vh keeps
92+
measuring the tall version, pushing the action bar under the address bar. */
93+
min-height: calc(100vh - var(--pad-top) - var(--pad-bottom));
94+
min-height: calc(100dvh - var(--pad-top) - var(--pad-bottom));
95+
}
96+
97+
/* Name and tagline are for someone deciding whether to use the app, not someone using it. */
98+
#app.is-running .masthead {
99+
display: none;
100+
}
101+
102+
/* The one element that should absorb whatever space the device has. */
103+
#app.is-running .stage {
104+
aspect-ratio: auto;
105+
flex: 1;
106+
min-height: 0;
107+
margin-bottom: 0;
108+
}
109+
110+
#app.is-running .transcript,
111+
#app.is-running .status {
112+
margin: 0;
113+
}
114+
115+
/* Room for the pinned bar, which is out of flow and would otherwise cover the last panel. */
116+
#app.is-running {
117+
padding-bottom: calc(var(--pad-bottom) + var(--bar));
73118
}
74119

75120
.masthead {
@@ -190,6 +235,28 @@ body {
190235
line-height: 1.2;
191236
}
192237

238+
.actions--bar {
239+
margin-top: 0;
240+
}
241+
242+
/*
243+
* Pinned rather than merely sticky: the diagnostics panel is read *while* the camera runs,
244+
* and scrolling down to it must not strand the user without a stop button.
245+
*/
246+
#app.is-running .actions--bar {
247+
position: fixed;
248+
left: 0;
249+
right: 0;
250+
bottom: 0;
251+
z-index: 20;
252+
margin: 0 auto;
253+
max-width: 560px;
254+
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
255+
background: color-mix(in srgb, var(--bg) 92%, transparent);
256+
backdrop-filter: blur(12px);
257+
border-top: 1px solid var(--border);
258+
}
259+
193260
.status {
194261
color: var(--text-muted);
195262
font-size: 0.85rem;
@@ -376,13 +443,34 @@ body {
376443

377444
/* One chip per tracked body part. Colour identifies the part, fill says whether it is
378445
currently being seen — so "it is not working" becomes "it cannot see your face". */
446+
/*
447+
* Over the video rather than beside it. These say what the camera is currently tracking, so
448+
* they belong on the camera — and off it they said nothing while costing a whole row.
449+
*/
379450
.parts {
451+
position: absolute;
452+
left: 10px;
453+
right: 10px;
454+
top: 10px;
380455
display: flex;
381456
flex-wrap: wrap;
382457
gap: 8px;
383458
list-style: none;
384-
margin: 0 0 var(--gap);
459+
margin: 0;
385460
padding: 0;
461+
opacity: 0;
462+
transition: opacity 0.25s ease;
463+
pointer-events: none;
464+
}
465+
466+
#app.is-running .parts {
467+
opacity: 1;
468+
}
469+
470+
/* Legible over whatever the camera happens to be pointing at. */
471+
#app.is-running .part {
472+
background: color-mix(in srgb, var(--bg) 72%, transparent);
473+
backdrop-filter: blur(8px);
386474
}
387475

388476
.part {

0 commit comments

Comments
 (0)