822c0b7c0b
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
335 lines
20 KiB
HTML
335 lines
20 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); }
|
||
|
||
/* ---------- which server: the hosted service, or a room's own (Community edition) */
|
||
.srvline { display:block; margin-top:14px; background:none; border:0; padding:6px 0; text-align:left;
|
||
color:rgba(243,242,242,.7); font-family:inherit; font-size:11px; font-weight:800;
|
||
letter-spacing:.12em; text-transform:uppercase; cursor:pointer; }
|
||
#serverpick { position:fixed; inset:0; z-index:5; background:var(--paper); color:var(--ink); overflow:auto;
|
||
padding:calc(32px + env(safe-area-inset-top,0px)) 24px 24px; }
|
||
#serverpick h2 { font-weight:800; font-size:30px; line-height:1.05; letter-spacing:-.03em; }
|
||
#serverpick .mark { width:56px; height:4px; background:var(--accent); margin-top:18px; }
|
||
#serverpick p { font-size:15px; line-height:1.6; color:#57534f; margin-top:16px; max-width:44ch; }
|
||
#serverpick .srvopt { display:block; width:100%; height:auto; line-height:1.25; margin-top:10px; text-align:left; color:var(--ink);
|
||
border:2px solid #cfcbc9; background:#fff; padding:14px 16px; }
|
||
#serverpick .srvopt[aria-pressed="true"] { border-color:var(--ink); }
|
||
#serverpick .srvopt small { display:block; font-weight:500; font-size:12px; letter-spacing:0; text-transform:none; color:#57534f; margin-top:2px; }
|
||
#serverpick label { display:block; margin-top:18px; font-size:11px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; color:#57534f; }
|
||
#serverpick input { display:block; width:100%; margin-top:8px; min-height:52px; padding:0 14px; border:2px solid #cfcbc9;
|
||
border-radius:0; font:inherit; font-size:16px; background:#fff; color:var(--ink); }
|
||
#serverpick .srvmsg { min-height:1.6em; font-weight:700; color:var(--ink); }
|
||
#serverpick .btn { width:100%; margin-top:14px; }
|
||
#serverpick .btn-primary { border-color:var(--accent); }
|
||
#serverpick .btn-ghost { color:var(--ink); border-color:#cfcbc9; }
|
||
</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>
|
||
<button class="srvline" id="srvline" type="button">Server: threadcount.tech · Change</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="serverpick" hidden>
|
||
<h2>Which server?</h2>
|
||
<div class="mark"></div>
|
||
<p>Most rooms use ThreadCount’s hosted service. A room running the Community edition on
|
||
its own server enters that server’s address here, once, on each phone.</p>
|
||
<button class="btn srvopt" id="srvhosted" type="button" aria-pressed="true">ThreadCount hosted<small>threadcount.tech</small></button>
|
||
<button class="btn srvopt" id="srvown" type="button" aria-pressed="false">Self-hosted<small>your own server, over https</small></button>
|
||
<div id="srvform" hidden>
|
||
<label for="srvhost">Server address</label>
|
||
<input id="srvhost" type="url" inputmode="url" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="uniforms.example.health">
|
||
<button class="btn btn-primary" id="srvsave" type="button">Check and use this server</button>
|
||
</div>
|
||
<p class="srvmsg" id="srvmsg" role="status"></p>
|
||
<button class="btn btn-ghost" id="srvback" type="button">Back</button>
|
||
</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";
|
||
/* Which server. The hosted service unless this phone was pointed at a self-hosted one (the
|
||
Community edition runs on a room's own server): saved here, on the app's own origin, and told to
|
||
the shell through a navigation MainActivity intercepts, so the native side can register the
|
||
scanner bridge for it too. Only an https origin with no path is ever accepted. */
|
||
var DEFAULT_SITE = "https://threadcount.tech";
|
||
var SERVER_KEY = "tc.server";
|
||
var SITE = DEFAULT_SITE;
|
||
try {
|
||
var savedSite = localStorage.getItem(SERVER_KEY);
|
||
if (savedSite && /^https:\/\/[a-z0-9.-]+(:[0-9]+)?$/i.test(savedSite)) SITE = savedSite;
|
||
} catch (e) { /* private mode is fine */ }
|
||
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", "serverpick"].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 + "/api/app-info", { 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.
|
||
/* The server chooser. The address is saved on this origin and handed to the shell by a navigation
|
||
MainActivity intercepts (never in a browser, where it would really navigate). The server is
|
||
checked first: it has to answer /api/app-info as a ThreadCount server, over https. */
|
||
function hostOf(site) { return site.replace(/^https:\/\//, ""); }
|
||
function isNative() { try { return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform()); } catch (e) { return false; } }
|
||
function paintServer() {
|
||
var own = SITE !== DEFAULT_SITE;
|
||
$("srvline").textContent = "Server: " + hostOf(SITE) + " · Change";
|
||
$("srvhosted").setAttribute("aria-pressed", own ? "false" : "true");
|
||
$("srvown").setAttribute("aria-pressed", own ? "true" : "false");
|
||
$("srvform").hidden = !own;
|
||
if (own) $("srvhost").value = hostOf(SITE);
|
||
}
|
||
function tellShell(origin) { if (isNative()) location.href = "https://localhost/__server?origin=" + encodeURIComponent(origin || ""); }
|
||
function saveServer(site) {
|
||
try { if (site === DEFAULT_SITE) localStorage.removeItem(SERVER_KEY); else localStorage.setItem(SERVER_KEY, site); } catch (e) { /* private mode is fine */ }
|
||
SITE = site;
|
||
tellShell(site === DEFAULT_SITE ? "" : site);
|
||
paintServer();
|
||
}
|
||
$("srvline").addEventListener("click", function () { $("srvmsg").textContent = ""; paintServer(); show("serverpick"); });
|
||
$("srvhosted").addEventListener("click", function () { saveServer(DEFAULT_SITE); $("srvmsg").textContent = "Using threadcount.tech."; });
|
||
$("srvown").addEventListener("click", function () { $("srvform").hidden = false; $("srvown").setAttribute("aria-pressed", "true"); $("srvhosted").setAttribute("aria-pressed", "false"); $("srvhost").focus(); });
|
||
$("srvback").addEventListener("click", function () { show("welcome"); });
|
||
$("srvsave").addEventListener("click", function () {
|
||
var raw = ($("srvhost").value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{2,5})?$/.test(raw)) { $("srvmsg").textContent = "Enter the server’s address, like uniforms.example.health"; return; }
|
||
var origin = "https://" + raw;
|
||
$("srvmsg").textContent = "Checking " + raw + "…";
|
||
var done = false;
|
||
var t = setTimeout(function () { if (!done) { done = true; $("srvmsg").textContent = "No answer from " + raw + ". Is it reachable from this phone, over https?"; } }, 8000);
|
||
fetch(origin + "/api/app-info", { cache: "no-store" }).then(function (r) { return r.json(); }).then(function (j) {
|
||
if (done) return; done = true; clearTimeout(t);
|
||
if (!j || j.product !== "threadcount") { $("srvmsg").textContent = raw + " answered, but it is not a ThreadCount server."; return; }
|
||
saveServer(origin);
|
||
$("srvmsg").textContent = "Using " + raw + (j.edition ? " (" + j.edition + " edition)." : ".");
|
||
}).catch(function () { if (done) return; done = true; clearTimeout(t); $("srvmsg").textContent = "Could not reach " + raw + " over https."; });
|
||
});
|
||
paintServer();
|
||
|
||
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>
|