344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
247 lines
13 KiB
HTML
247 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>ThreadCount</title>
|
|
<style>
|
|
/* This file ships inside the app, so it stands alone: no web fonts, no network, no framework.
|
|
Archivo is the brand face; the system stack is what actually renders. Everything the welcome
|
|
screen needs is bundled beside it, which is why the video plays the instant the app opens
|
|
instead of crawling over ward wifi on a coordinator's first launch. */
|
|
:root {
|
|
--ink:#201e1d; --paper:#f3f2f2; --accent:#ec3013; --accent-700:#b8240e;
|
|
--n400:#b5b1af; --n300:#d6d3d2;
|
|
--font:"Archivo",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
}
|
|
* { box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
|
|
html, body { height:100%; overflow:hidden; background:var(--ink); }
|
|
body { font-family:var(--font); color:var(--paper); }
|
|
[hidden] { display:none !important; }
|
|
|
|
/* ---------- 1. Splash: the mark draws itself, so the loading indicator IS the logo. */
|
|
#splash { position:fixed; inset:0; background:var(--ink); display:flex; flex-direction:column;
|
|
align-items:flex-start; justify-content:center; padding:0 28px; z-index:3; }
|
|
.bars { display:flex; flex-direction:column; gap:21px; }
|
|
.bar { height:28px; width:0; background:var(--paper); animation:draw 420ms cubic-bezier(.2,.7,.3,1) forwards; }
|
|
.bar.a { background:var(--accent); }
|
|
/* Widths and the 14-step come from tc-icon.svg (60/48/34/22 in a 100 box) scaled 3.5x. */
|
|
.b1 { --w:210px; animation-delay:0ms; }
|
|
.b2 { --w:168px; animation-delay:150ms; }
|
|
.b3 { --w:119px; animation-delay:300ms; }
|
|
.b4 { --w:77px; animation-delay:450ms; }
|
|
@keyframes draw { from { width:0; } to { width:var(--w); } }
|
|
|
|
.rise { opacity:0; transform:translateY(10px); animation:rise 450ms cubic-bezier(.2,.7,.3,1) forwards; }
|
|
@keyframes rise { to { opacity:1; transform:translateY(0); } }
|
|
#splash .wm { margin-top:52px; width:236px; animation-delay:880ms; }
|
|
#splash .wm img { width:100%; display:block; }
|
|
#splash .strap { margin-top:18px; animation-delay:1060ms; }
|
|
#splash .strap hr { border:0; border-top:2px solid rgba(243,242,242,.28); }
|
|
#splash .strap span { display:block; margin-top:14px; font-size:12px; letter-spacing:.14em;
|
|
text-transform:uppercase; color:var(--n400); }
|
|
|
|
/* ---------- 2. Welcome: video, scrim, wordmark, bottom block. */
|
|
#welcome { position:fixed; inset:0; z-index:2; }
|
|
#welcome video, #welcome .poster {
|
|
position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
|
|
}
|
|
/* The background belongs to the poster, not the video. A background on a replaced element is
|
|
painted whether or not there is a frame to draw over it, and the video sits above the poster
|
|
— so an opaque one hides the poster at exactly the moment it is meant to stand in. */
|
|
#welcome .poster { background:var(--ink); }
|
|
/* The scrim is load-bearing: it is what makes the type legible over moving footage. */
|
|
#welcome .scrim { position:absolute; inset:0; background:linear-gradient(180deg,
|
|
rgba(32,30,29,.62) 0%, rgba(32,30,29,.2) 34%, rgba(32,30,29,.93) 100%); }
|
|
#welcome .top { position:absolute; top:calc(24px + env(safe-area-inset-top,0px)); left:24px; }
|
|
#welcome .top img { height:19px; display:block; }
|
|
#welcome .bottom { position:absolute; left:0; right:0;
|
|
bottom:calc(26px + env(safe-area-inset-bottom,0px)); padding:0 24px; }
|
|
.kicker { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:var(--n300); }
|
|
h1 { font-weight:800; font-size:38px; line-height:1.0; letter-spacing:-.03em; color:var(--paper); margin-top:12px; }
|
|
/* The stepped rules return here, in place of a single divider. */
|
|
.steps { margin:22px 0 24px; display:flex; flex-direction:column; gap:14px; }
|
|
.steps i { display:block; height:4px; }
|
|
.steps i:nth-child(1) { width:190px; background:var(--accent); }
|
|
.steps i:nth-child(2) { width:132px; background:rgba(243,242,242,.5); }
|
|
.steps i:nth-child(3) { width:84px; background:rgba(243,242,242,.3); }
|
|
.actions { display:grid; grid-template-columns:1fr 1fr; gap:2px; }
|
|
.btn { height:62px; display:flex; align-items:center; padding:0 16px; border:2px solid transparent;
|
|
font-family:inherit; font-weight:800; font-size:13px; letter-spacing:.08em;
|
|
text-transform:uppercase; text-align:left; cursor:pointer; }
|
|
.btn-primary { background:var(--accent); border-color:var(--accent); color:#fff; }
|
|
.btn-primary:active { background:var(--accent-700); border-color:var(--accent-700); }
|
|
.btn-ghost { background:transparent; border-color:rgba(243,242,242,.85); color:var(--paper); }
|
|
.btn-ghost:active { background:rgba(243,242,242,.15); }
|
|
/* The footage loops for as long as this screen is up, so there has to be a way to stop it
|
|
(WCAG 2.2.2). The chip sits opposite the wordmark and is set like the rest of the type. */
|
|
#welcome .motion { position:absolute; top:calc(12px + env(safe-area-inset-top,0px)); right:16px;
|
|
min-width:92px; height:44px; padding:0 14px; background:transparent;
|
|
border:2px solid rgba(243,242,242,.55); color:var(--paper);
|
|
font-family:inherit; font-weight:800; font-size:11px; letter-spacing:.14em;
|
|
text-transform:uppercase; cursor:pointer; }
|
|
#welcome .motion:active { background:rgba(243,242,242,.15); }
|
|
|
|
/* Someone who has asked the OS for less movement gets the mark drawn rather than drawing, and a
|
|
still welcome screen: the video stays on its poster until they ask for it. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.bar { animation:none; width:var(--w); }
|
|
.rise { animation:none; opacity:1; transform:none; }
|
|
}
|
|
|
|
/* ---------- offline, shown instead of sending anyone to a browser error page */
|
|
#offline { position:fixed; inset:0; z-index:4; background:var(--paper); color:var(--ink);
|
|
padding:calc(32px + env(safe-area-inset-top,0px)) 24px 24px; }
|
|
#offline h2 { font-weight:800; font-size:30px; line-height:1.05; letter-spacing:-.03em; }
|
|
#offline .mark { width:56px; height:4px; background:var(--accent); margin-top:18px; }
|
|
#offline p { font-size:15.5px; line-height:1.65; color:#57534f; margin-top:20px; max-width:44ch; }
|
|
#offline button { margin-top:28px; width:100%; }
|
|
#offline .btn-primary { border-color:var(--accent); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<section id="splash" aria-label="ThreadCount">
|
|
<div class="bars" aria-hidden="true">
|
|
<span class="bar a b1"></span><span class="bar b2"></span><span class="bar b3"></span><span class="bar b4"></span>
|
|
</div>
|
|
<div class="wm rise"><img src="media/wordmark-white.svg" alt="ThreadCount"></div>
|
|
<div class="strap rise"><hr><span>Uniform stock, counted</span></div>
|
|
</section>
|
|
|
|
<section id="welcome" hidden>
|
|
<img class="poster" id="poster" src="media/welcome-poster.jpg" alt="">
|
|
<!-- No autoplay attribute: playback is started from the script, which is what lets it be held
|
|
back for someone who has asked the OS for reduced motion. -->
|
|
<video id="vid" src="media/welcome.mp4" muted loop playsinline preload="auto" tabindex="-1" aria-hidden="true"></video>
|
|
<div class="scrim"></div>
|
|
<div class="top"><img src="media/wordmark-white.svg" alt="ThreadCount"></div>
|
|
<button class="motion" id="motion" type="button" aria-label="Pause background video">Pause</button>
|
|
<div class="bottom">
|
|
<div class="kicker">Stock · Issuing · Counts</div>
|
|
<h1>Know what’s<br>on the shelf.</h1>
|
|
<div class="steps" aria-hidden="true"><i></i><i></i><i></i></div>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" id="signin">Sign in</button>
|
|
<button class="btn btn-ghost" id="signup">Sign up</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="offline" hidden>
|
|
<h2>No connection.</h2>
|
|
<div class="mark"></div>
|
|
<p>ThreadCount can’t reach your linen room’s records. It won’t let you issue or
|
|
count anything without one, and nothing is being saved up in the background.</p>
|
|
<p>Check the ward’s wifi, or step somewhere with signal.</p>
|
|
<button class="btn btn-primary" id="retry">Try again</button>
|
|
</section>
|
|
|
|
<script>
|
|
"use strict";
|
|
var SITE = "https://threadcount.tech";
|
|
var SPLASH_MS = 1500; // the mark finishes at ~1510ms; holding longer just delays the room
|
|
var SEEN = "tc.onboarded";
|
|
var LAST = "tc.lastpath"; // read by error.html, which is bundled beside this file and shares its origin
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
function show(id) { ["splash", "welcome", "offline"].forEach(function (s) { $(s).hidden = s !== id; }); }
|
|
|
|
function seen() { try { return localStorage.getItem(SEEN) === "1"; } catch (e) { return false; } }
|
|
function markSeen() { try { localStorage.setItem(SEEN, "1"); } catch (e) { /* private mode is fine */ } }
|
|
|
|
/* Reaching the site is checked before navigating, so a coordinator with no signal gets the
|
|
offline screen rather than a browser error page they can't do anything with. */
|
|
function reachable() {
|
|
if (navigator.onLine === false) return Promise.resolve(false);
|
|
return new Promise(function (resolve) {
|
|
var done = false;
|
|
var t = setTimeout(function () { if (!done) { done = true; resolve(false); } }, 6000);
|
|
fetch(SITE + "/robots.txt", { method: "GET", cache: "no-store", mode: "no-cors" })
|
|
.then(function () { if (!done) { done = true; clearTimeout(t); resolve(true); } })
|
|
.catch(function () { if (!done) { done = true; clearTimeout(t); resolve(false); } });
|
|
});
|
|
}
|
|
|
|
function go(path) {
|
|
markSeen();
|
|
// The bundled error page is served from this same origin, so this is the one place that can tell
|
|
// it where the app was headed. Without it its "Try again" has nowhere to go but back to itself.
|
|
try { localStorage.setItem(LAST, path); } catch (e) { /* private mode is fine */ }
|
|
// Fail open. A probe can fail for reasons that have nothing to do with the ward's wifi — a
|
|
// proxy that dislikes cross-origin requests, a captive portal, a WebView quirk — and refusing
|
|
// to open the app because one HEAD request was awkward is worse than letting it try.
|
|
// navigator.onLine reporting false is the one signal worth acting on by itself.
|
|
if (navigator.onLine === false) { window.__pending = path; show("offline"); return; }
|
|
reachable().then(function (ok) {
|
|
if (ok || navigator.onLine !== false) location.replace(SITE + path);
|
|
else { window.__pending = path; show("offline"); }
|
|
});
|
|
}
|
|
|
|
$("signin").addEventListener("click", function () { go("/m/login"); });
|
|
$("signup").addEventListener("click", function () { go("/m/signup"); });
|
|
$("retry").addEventListener("click", function () { go(window.__pending || "/m"); });
|
|
|
|
// The video is bundled, so it is ready immediately; the poster only covers the first frame or two.
|
|
var vid = $("vid");
|
|
vid.addEventListener("playing", function () { $("poster").style.display = "none"; });
|
|
|
|
var reduce = false;
|
|
try { reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) { /* old WebView: assume not */ }
|
|
|
|
/* Started when the welcome is actually on screen, not at script load.
|
|
For the first 1.5 seconds #welcome is `hidden`, so the element is display:none, and a WebView
|
|
is entitled to refuse or stall playback for something that isn't in the layout — the promise
|
|
rejects, the poster stays, and it looks like the video is missing. */
|
|
function playWelcome() {
|
|
// Nobody who has asked the OS for less movement gets a loop started for them. The poster is a
|
|
// still from the same footage, and the button turns it on for anyone who does want it.
|
|
if (reduce) return;
|
|
// On the frame after the welcome is laid out, rather than in the same tick as `hidden = false`.
|
|
// Chrome will not start a video it considers off screen, and it does not come back to retry.
|
|
requestAnimationFrame(function () {
|
|
attempt();
|
|
});
|
|
}
|
|
|
|
function attempt() {
|
|
var p = vid.play();
|
|
if (p && p.catch) p.catch(function () {
|
|
// One more try once there is definitely something decoded to show. After that the poster
|
|
// stands in, which is a still from the same footage and reads as intended.
|
|
vid.addEventListener("canplay", retry);
|
|
});
|
|
}
|
|
|
|
function retry() {
|
|
vid.removeEventListener("canplay", retry);
|
|
var p = vid.play();
|
|
if (p && p.catch) p.catch(function () { /* refused: the poster stands in, as designed */ });
|
|
}
|
|
|
|
// The label follows the video's own state rather than the click, so it stays honest when playback
|
|
// is refused, or when the loop is stopped by anything other than this button.
|
|
function motionLabel() {
|
|
var word = vid.paused ? "Play" : "Pause";
|
|
$("motion").textContent = word;
|
|
$("motion").setAttribute("aria-label", word + " background video");
|
|
}
|
|
vid.addEventListener("play", motionLabel);
|
|
vid.addEventListener("pause", motionLabel);
|
|
$("motion").addEventListener("click", function () {
|
|
if (vid.paused) attempt();
|
|
else vid.pause();
|
|
});
|
|
motionLabel();
|
|
|
|
setTimeout(function () {
|
|
// Someone who has been here before goes straight to the app; the welcome screen is a first-run
|
|
// thing, not a toll gate on every launch.
|
|
if (seen()) go("/m");
|
|
else { show("welcome"); playWelcome(); }
|
|
}, SPLASH_MS);
|
|
</script>
|
|
</body>
|
|
</html>
|