ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
<!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 Staff</title>
<style>
/* The staff app's bundled shell — the same first run as the counter app, with the same
footage. Everything it needs is beside it on the device, which is why the video plays the
instant the app opens rather than crawling over ward wifi on someone's first launch. */
:root {
--ink:#201e1d; --paper:#f3f2f2; --accent:#ec3013; --accent-700:#b8240e;
--n400:#b5b1af; --n300:#d6d3d2; --n700:#57534f;
--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; }
.lede { font-size:15px; line-height:1.6; color:var(--n300); margin-top:18px; max-width:34ch; }
/* The stepped rules, 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:var(--n700); 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 Staff">
<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>Your uniform, tracked</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">Kit · Requests · Orders</div>
<h1>What you&rsquo;ve got.<br>What&rsquo;s on the way.</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="code">I have a code</button>
</div>
</div>
</section>
<section id="offline" hidden>
<h2>No connection.</h2>
<div class="mark"></div>
<p>ThreadCount can&rsquo;t reach your linen room&rsquo;s records, so it can&rsquo;t show you what
you&rsquo;re holding or send a request. Nothing is being saved up in the background.</p>
<p>Check the ward&rsquo;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 app
var SEEN = "tc.staff.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 someone 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 request was awkward is worse than letting it try.
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"); }
});
}
// 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();
$("signin").addEventListener("click", function () { go("/my/signin"); });
// Same screen, opened on the other tab — somebody holding a printed slip should not have to find
// it themselves on their first launch.
$("code").addEventListener("click", function () { go("/my/signin?code=1"); });
$("retry").addEventListener("click", function () { go(window.__pending || "/my"); });
setTimeout(function () {
// Someone who has been here before goes straight to their record; the welcome screen is a
// first-run thing, not a toll gate on every launch.
if (seen()) go("/my");
else { show("welcome"); playWelcome(); }
}, SPLASH_MS);
</script>
</body>
</html>