ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user