ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:16:36 +10:00
commit 057da00fd2
406 changed files with 47822 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
<!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&rsquo;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&rsquo;s hosted service. A room running the Community edition on
its own server enters that server&rsquo;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&rsquo;t reach your linen room&rsquo;s records. It won&rsquo;t let you issue or
count anything without one, and 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";
/* 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 servers 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>