Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified public/apple-touch-icon-180x180.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/favicon.ico
Binary file not shown.
24 changes: 15 additions & 9 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/maskable-icon-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/pwa-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/pwa-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/pwa-64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 47 additions & 33 deletions src/presentation/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,43 +17,51 @@ declare const __APP_VERSION__: string;

export function renderApp(root: HTMLElement): void {
root.innerHTML = `
<header class="masthead">
<img class="masthead__mark" src="${import.meta.env.BASE_URL}favicon.svg" alt="" width="44" height="44" />
<div>
<h1 class="masthead__title">Esku</h1>
<p class="masthead__tagline">Lengua de signos a texto, sin conexión</p>
<!--
Everything needed to actually read signs lives in one viewport-tall column, so that
turning the camera on never means scrolling to reach a control. Secondary panels stay
below it rather than behind a sheet: the diagnostics panel in particular is read *while*
the camera runs, and the sticky action bar keeps the controls reachable down there.
-->
<div class="shell" id="shell">
<header class="masthead">
<img class="masthead__mark" src="${import.meta.env.BASE_URL}favicon.svg" alt="" width="44" height="44" />
<div>
<h1 class="masthead__title">Esku</h1>
<p class="masthead__tagline">Lengua de signos a texto, sin conexión</p>
</div>
</header>

<div class="stage">
<video id="video" class="stage__video" playsinline muted></video>
<canvas id="overlay" class="stage__overlay" aria-hidden="true"></canvas>
<p class="stage__placeholder" id="placeholder">
La cámara se activa al empezar.<br />El vídeo no se graba ni sale del dispositivo.
</p>
<p class="stage__hint" id="hint" hidden></p>

<ul class="parts" id="parts">
${PART_ORDER.map(
(part) => `
<li class="part" data-part="${part}">
<span class="part__dot" style="--part: ${PART_COLOURS[part]}"></span>
${PART_LABELS[part]}
</li>`,
).join('')}
</ul>
</div>
</header>

<div class="stage">
<video id="video" class="stage__video" playsinline muted></video>
<canvas id="overlay" class="stage__overlay" aria-hidden="true"></canvas>
<p class="stage__placeholder" id="placeholder">
La cámara se activa al empezar.<br />El vídeo no se graba ni sale del dispositivo.
</p>
<p class="stage__hint" id="hint" hidden></p>
</div>
<div class="transcript" id="transcript" aria-live="polite"></div>

<ul class="parts" id="parts">
${PART_ORDER.map(
(part) => `
<li class="part" data-part="${part}">
<span class="part__dot" style="--part: ${PART_COLOURS[part]}"></span>
${PART_LABELS[part]}
</li>`,
).join('')}
</ul>

<div class="transcript" id="transcript" aria-live="polite"></div>

<div class="actions">
<button class="button" id="toggle" type="button">Empezar a leer</button>
<button class="button button--quiet" id="undo" type="button">Borrar último</button>
<button class="button button--quiet" id="clear" type="button">Limpiar</button>
<button class="button button--quiet" id="flip" type="button">Cámara trasera</button>
</div>
<p class="status" id="status" role="status"></p>

<p class="status" id="status" role="status"></p>
<div class="actions actions--bar">
<button class="button" id="toggle" type="button">Empezar a leer</button>
<button class="button button--quiet" id="undo" type="button">Borrar último</button>
<button class="button button--quiet" id="clear" type="button">Limpiar</button>
<button class="button button--quiet" id="flip" type="button">Cámara trasera</button>
</div>
</div>

<div id="teach"></div>
<div id="storage"></div>
Expand Down Expand Up @@ -125,6 +133,7 @@ export function renderApp(root: HTMLElement): void {
if (running) {
recognize.stop();
running = false;
root.classList.remove('is-running');
toggle.textContent = 'Empezar a leer';
placeholder.hidden = false;
hint.hidden = true;
Expand Down Expand Up @@ -152,6 +161,9 @@ export function renderApp(root: HTMLElement): void {
diagnostics.update(update.diagnostics);
});
running = true;
// Camera mode: the masthead folds away, the video takes the height the fixed 3/4 ratio
// used to claim regardless of device, and the controls pin to the bottom of the screen.
root.classList.add('is-running');
placeholder.hidden = true;
video.classList.add('is-live');
overlayCanvas.classList.add('is-live');
Expand All @@ -161,6 +173,8 @@ export function renderApp(root: HTMLElement): void {
error instanceof CameraUnavailableError
? 'No hay cámara o se denegó el permiso. Revísalo en los ajustes del navegador.'
: 'No se pudo iniciar el reconocimiento.';
// The camera never opened, so the layout must not be left claiming it did.
root.classList.remove('is-running');
// Without the real cause in the console this is undiagnosable from a bug report.
console.error(error);
} finally {
Expand Down
94 changes: 91 additions & 3 deletions src/presentation/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,56 @@ body {
}

#app {
--pad-top: 20px;
--pad-bottom: calc(40px + env(safe-area-inset-bottom));
/* Height of the pinned action bar in camera mode, reserved so nothing hides beneath it. */
--bar: 72px;

max-width: 560px;
margin: 0 auto;
padding: 20px 16px 40px;
/* Keep the transcript clear of the iOS home indicator when installed. */
padding-bottom: calc(40px + env(safe-area-inset-bottom));
padding: var(--pad-top) 16px var(--pad-bottom);
}

/*
* Camera mode.
*
* With the camera on this stops being a page and becomes a tool: everything needed to read
* signs occupies exactly one viewport, so no control is ever a scroll away. Measured before
* this existed, the primary block alone came to ~813 px on a 390x844 phone — the masthead and
* a hard-coded 3/4 stage between them claimed more than half the screen.
*/
#app.is-running .shell {
display: flex;
flex-direction: column;
gap: var(--gap);
/* dvh, not vh: mobile browsers shrink the viewport as their chrome collapses, and vh keeps
measuring the tall version, pushing the action bar under the address bar. */
min-height: calc(100vh - var(--pad-top) - var(--pad-bottom));
min-height: calc(100dvh - var(--pad-top) - var(--pad-bottom));
}

/* Name and tagline are for someone deciding whether to use the app, not someone using it. */
#app.is-running .masthead {
display: none;
}

/* The one element that should absorb whatever space the device has. */
#app.is-running .stage {
aspect-ratio: auto;
flex: 1;
min-height: 0;
margin-bottom: 0;
}

#app.is-running .transcript,
#app.is-running .status {
margin: 0;
}

/* Room for the pinned bar, which is out of flow and would otherwise cover the last panel. */
#app.is-running {
padding-bottom: calc(var(--pad-bottom) + var(--bar));
}

.masthead {
Expand Down Expand Up @@ -190,6 +235,28 @@ body {
line-height: 1.2;
}

.actions--bar {
margin-top: 0;
}

/*
* Pinned rather than merely sticky: the diagnostics panel is read *while* the camera runs,
* and scrolling down to it must not strand the user without a stop button.
*/
#app.is-running .actions--bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
margin: 0 auto;
max-width: 560px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(12px);
border-top: 1px solid var(--border);
}

.status {
color: var(--text-muted);
font-size: 0.85rem;
Expand Down Expand Up @@ -376,13 +443,34 @@ body {

/* One chip per tracked body part. Colour identifies the part, fill says whether it is
currently being seen — so "it is not working" becomes "it cannot see your face". */
/*
* Over the video rather than beside it. These say what the camera is currently tracking, so
* they belong on the camera — and off it they said nothing while costing a whole row.
*/
.parts {
position: absolute;
left: 10px;
right: 10px;
top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
list-style: none;
margin: 0 0 var(--gap);
margin: 0;
padding: 0;
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}

#app.is-running .parts {
opacity: 1;
}

/* Legible over whatever the camera happens to be pointing at. */
#app.is-running .part {
background: color-mix(in srgb, var(--bg) 72%, transparent);
backdrop-filter: blur(8px);
}

.part {
Expand Down