Files
threadcount-community/components/m.tsx
T
ThreadCount 0910bc32c1 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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 07:57:54 +10:00

934 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* The phone app's shared furniture, built once from the approved counter redesign.
Every screen is a top bar, an accent rule, a scrolling body and (usually) one primary action bar;
the five tab roots add the tab bar. Sizes are the mockup's px straight: the app runs at device width.
Square corners, 2px ink borders, 44px minimum targets, IBM Plex Mono for codes, sizes and counts. */
import Link from "next/link";
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useWorkCount } from "@/lib/workcount";
export const INK = "var(--color-text)";
export const GROUND = "var(--color-bg)";
export const ACCENT = "var(--color-accent)";
export const ON_DARK = "var(--color-neutral-400)"; // meta text on ink — 500/600 fail contrast there
/** Codes, sizes, counts and money. */
export const MONO = "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace";
export const OK = "#1e7a4f";
export const AC3 = "var(--color-accent-300)";
export const AC7 = "var(--color-accent-700)";
export const MUTED = "var(--color-neutral-600)";
export const DIVIDER = "var(--color-divider)";
const WHITE = "#ffffff";
const OFF_GREY = "#b5b1af";
const DARK_RULE = "#3a3735";
const DARK_EDGE = "#57534f";
/* Rules inline styles cannot express: first-section margin, focus rings, the viewfinder laser.
React hoists and de-duplicates a <style> carrying href + precedence, so every component can
render it and the page gets one copy. */
const M_CSS = `
.tcx-sec{margin:22px 0 4px}
.tcx-sec:first-child{margin-top:4px}
.tcx-app a:focus-visible,.tcx-app button:focus-visible,.tcx-app input:focus-visible,.tcx-app [tabindex]:focus-visible{outline:3px solid var(--color-accent);outline-offset:-3px}
@keyframes tcx-vfsweep{from{top:12%}to{top:88%}}
.tcx-vf-laser{animation:tcx-vfsweep 2.2s ease-in-out infinite alternate;top:12%}
@media (prefers-reduced-motion:reduce){.tcx-vf-laser{animation:none;top:50%}}
`;
export function MStyles() {
return <style href="tcx-m-furniture" precedence="medium">{M_CSS}</style>;
}
// ---------- icons (stroke 2.2, square caps)
const ic = (d: React.ReactNode, size: number, stroke = 2.2) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true">{d}</svg>
);
export const IconLeft = ({ size = 22 }: { size?: number }) => ic(<path d="M15 18 9 12l6-6" />, size);
export const IconRight = ({ size = 22 }: { size?: number }) => ic(<><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>, size);
export const IconCheck = ({ size = 22 }: { size?: number }) => ic(<path d="m4 12 5 5L20 6" />, size);
export const IconPrinter = ({ size = 22 }: { size?: number }) => ic(<><path d="M6 9V3h12v6" /><path d="M6 18H3v-6h18v6h-3" /><path d="M6 14h12v7H6z" /></>, size);
export const IconScan = ({ size = 22 }: { size?: number }) => ic(<><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /><path d="M7 8v8M10 8v8M13 8v8M17 8v8" /></>, size, 2.4);
export const IconSearch = ({ size = 22 }: { size?: number }) => ic(<><circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5 21 21" /></>, size);
export const IconX = ({ size = 22 }: { size?: number }) => ic(<><path d="M5 5 19 19" /><path d="M19 5 5 19" /></>, size);
export const IconPlus = ({ size = 22 }: { size?: number }) => ic(<><path d="M12 5v14" /><path d="M5 12h14" /></>, size);
export const IconToday = ({ size = 24 }: { size?: number }) => ic(<><rect x="3" y="4" width="18" height="17" /><path d="M3 9h18M8 2v4M16 2v4M7 13h4M7 17h7" /></>, size);
export const IconWork = ({ size = 24 }: { size?: number }) => ic(<path d="M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11" />, size);
export const IconStock = ({ size = 24 }: { size?: number }) => ic(<><path d="M3 3v18M21 3v18M3 9h18M3 15h18M3 21h18" /><rect x="6" y="5" width="4" height="4" /><rect x="12" y="11" width="5" height="4" /></>, size);
export const IconPeople = ({ size = 24 }: { size?: number }) => ic(<><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4.4 3.6-7 8-7s8 2.6 8 7" /></>, size);
export const IconGear = ({ size = 24 }: { size?: number }) => ic(<><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" /></>, size);
export const IconBack = ({ size = 24 }: { size?: number }) => ic(<path d="M15 5l-7 7 7 7" />, size, 2.6);
const TickMark = ({ size = 44 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth={3.2} aria-hidden="true"><path d="M4 12.5l5 5L20 6.5" /></svg>
);
// ---------- top bar
/** The top bar. Its title is the screen's only <h1>. `right` is an MTopAction, an MTopCount, or a
* short string (drawn small and muted, as before). */
export function MTop({ title, right, back, onBack, dark = true }: { title: string; right?: React.ReactNode; back?: boolean; onBack?: () => void; dark?: boolean }) {
const router = useRouter();
const plain = typeof right === "string" || typeof right === "number";
return (
// The bar runs under the status bar so the ink reaches the top of the screen, but a hairline
// keeps the phone's own clock and battery from reading as part of ThreadCount's header.
<header className="tcx-topbar" style={{ height: 56, flex: "0 0 56px", background: dark ? INK : GROUND, color: dark ? GROUND : INK, display: "flex", alignItems: "center", gap: 4, paddingLeft: back ? 0 : 16, paddingRight: 6, backgroundImage: dark ? "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)" : undefined, backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px" }}>
<MStyles />
{back && (
<button onClick={() => (onBack ? onBack() : router.back())} aria-label="Back"
style={{ minWidth: 48, height: 48, border: 0, background: "none", color: "inherit", display: "grid", placeItems: "center", cursor: "pointer", flex: "0 0 48px", padding: 0 }}>
<IconBack />
</button>
)}
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.07em", textTransform: "uppercase", margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</h1>
{right !== undefined && right !== null && (plain
? <div style={{ fontSize: 12, color: dark ? ON_DARK : MUTED, margin: "0 10px", whiteSpace: "nowrap" }}>{right}</div>
: right)}
</header>
);
}
/** A top-bar action (Settings gear, Finish): 48px, 13px/800 uppercase, ground colour. */
export function MTopAction({ label, onClick, href, icon, ariaLabel }: { label?: string; onClick?: () => void; href?: string; icon?: "gear" | "print" | "scan"; ariaLabel?: string }) {
const st: React.CSSProperties = { minWidth: 48, height: 48, border: 0, background: "none", color: GROUND, display: "grid", placeItems: "center", fontFamily: "var(--font-heading)", fontSize: 13, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase", padding: "0 10px", cursor: "pointer", textDecoration: "none" };
const inner = icon === "gear" ? <IconGear /> : icon === "print" ? <IconPrinter size={24} /> : icon === "scan" ? <IconScan size={24} /> : label;
const aria = ariaLabel || (icon ? label : undefined);
if (href) return <Link href={href} aria-label={aria} style={st}>{inner}</Link>;
return <button type="button" onClick={onClick} aria-label={aria} style={st}>{inner}</button>;
}
/** A count at the right of the top bar ("142", "7 open"). */
export function MTopCount({ children }: { children: React.ReactNode }) {
return <span style={{ fontFamily: MONO, fontWeight: 500, fontSize: 13, color: GROUND, padding: "0 10px", whiteSpace: "nowrap" }}>{children}</span>;
}
/* Wordmark and facility bar: the staff app home (components/screens/Home.tsx). */
export function MTopBrand({ facility, right }: { facility: string; right?: React.ReactNode }) {
return (
<header
className="tcx-topbar"
style={{
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex",
alignItems: "center", gap: 10, paddingLeft: 16, paddingRight: 16,
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
backgroundPosition: "0 env(safe-area-inset-top, 0px)",
backgroundRepeat: "no-repeat",
backgroundSize: "100% 1px",
}}
>
<span aria-hidden="true" style={{ width: 16, height: 16, background: "#fff", flex: "0 0 16px" }} />
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em", flex: 1 }}>ThreadCount</span>
<span style={{ fontSize: 12, color: "var(--color-neutral-400)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: right ? "32%" : "48%" }}>{facility}</span>
{right}
</header>
);
}
/** The 4px accent rule under the top bar. Given `of`, it doubles as the count progress bar. */
export function MRule({ n, of }: { n?: number; of?: number }) {
const pct = of && of > 0 ? Math.max(0, Math.min(1, (n || 0) / of)) : null;
return (
<div style={{ height: 4, flex: "0 0 4px", position: "relative", background: pct === null ? ACCENT : DARK_EDGE }}>
{pct !== null && <div style={{ position: "absolute", inset: "0 auto 0 0", width: `${pct * 100}%`, background: ACCENT, transition: "width 200ms linear" }} />}
</div>
);
}
/* Android 15 draws the app edge to edge, so a bar docked at the foot of the column has the gesture
handle sitting on its bottom edge. The inset goes inside the bar. It reads through a custom
property that MBody and MSplit set to zero, because the same bars are also used away from the foot
of the window, where the inset would open a band of dead colour mid-screen. */
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
export const NOT_DOCKED = { "--tcx-safe-bottom": "0px" } as React.CSSProperties;
/** The scrolling body. `pad` gives the mockup's 16px; `dark` is the ink ground of the Scan tab. */
export function MBody({ children, pad = false, dark = false, className }: { children?: React.ReactNode; pad?: boolean; dark?: boolean; className?: string }) {
return (
<div className={className} style={{
...NOT_DOCKED, flex: 1, overflowY: "auto", WebkitOverflowScrolling: "touch", scrollbarWidth: "thin", position: "relative",
background: dark ? INK : GROUND, color: dark ? GROUND : INK, padding: pad ? 16 : 0,
...(dark ? { "--tcx-sec-rule": "var(--color-bg)", "--tcx-sec-sub": OFF_GREY } as React.CSSProperties : null),
}}>
<MStyles />
{children}
</div>
);
}
// ---------- toast and scan flash
type ToastApi = { toast: (msg: string) => void; flash: (kind: string, label: string, then: () => void) => void };
const ToastContext = createContext<ToastApi | null>(null);
/** Mounts the toast (2200ms, over the column) and the full-screen scan confirmation (650ms). Renders
* no wrapper element: screens stay direct children of .tcx-app. */
export function MToastProvider({ children }: { children: React.ReactNode }) {
const [msg, setMsg] = useState<string>("");
const [fl, setFl] = useState<{ kind: string; label: string } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const toast = useCallback((m: string) => {
setMsg(m);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setMsg(""), 2200);
}, []);
const flash = useCallback((kind: string, label: string, then: () => void) => {
setFl({ kind, label });
setTimeout(() => { setFl(null); then(); }, 650);
}, []);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const api = useRef<ToastApi>({ toast, flash });
return (
<ToastContext.Provider value={api.current}>
{children}
{fl && (
<div className="tcx-scanui" role="status" aria-live="assertive" style={{ position: "fixed", inset: 0, zIndex: 99, background: "rgba(32,30,29,.92)", color: WHITE, display: "grid", placeItems: "center", textAlign: "center", padding: 24 }}>
<div>
<div style={{ width: 72, height: 72, background: ACCENT, display: "grid", placeItems: "center", margin: "0 auto 14px" }}><TickMark /></div>
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase", color: "#d6d3d2" }}>{fl.kind}</span>
<b style={{ display: "block", fontSize: 22, fontWeight: 900 }}>{fl.label}</b>
</div>
</div>
)}
<div role="status" aria-live="polite" className="tcx-scanui" style={msg ? {
position: "fixed", left: 12, right: 12, bottom: "calc(84px + env(safe-area-inset-bottom, 0px))", zIndex: 100,
background: INK, color: GROUND, padding: "14px 16px", fontWeight: 700, fontSize: 15, borderLeft: "6px solid " + ACCENT,
boxShadow: "0 10px 30px rgba(0,0,0,.25)", maxWidth: 536, margin: "0 auto", width: "auto",
} : { position: "fixed", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>{msg}</div>
</ToastContext.Provider>
);
}
const noopToast = () => {};
/** Show a one-line toast. Outside MToastProvider (the staff app) it does nothing. */
export function useToast(): (msg: string) => void {
return useContext(ToastContext)?.toast ?? noopToast;
}
/** Full-screen "Staff badge / Priya Nair" confirmation, then `then()` after 650ms. */
export function useScanFlash(): (kind: string, label: string, then: () => void) => void {
const c = useContext(ToastContext);
return c?.flash ?? ((_k: string, _l: string, then: () => void) => then());
}
// ---------- action bars
/** Full-bleed 64px primary action: label at the left, small mono text or a glyph at the right.
* A disabled bar with `offReason` stays tappable and says why in a toast. */
export function MBar({ label, onClick, href, glyph = "arrow", disabled, tone = "accent", sub, small, offReason }: {
label: string; onClick?: () => void; href?: string; glyph?: "arrow" | "check" | "printer" | "scan" | "none"; disabled?: boolean; tone?: "accent" | "ink"; sub?: string;
small?: string; offReason?: string;
}) {
const toast = useToast();
const G = glyph === "check" ? IconCheck : glyph === "printer" ? IconPrinter : glyph === "scan" ? IconScan : IconRight;
const bg = disabled ? OFF_GREY : tone === "ink" ? INK : ACCENT;
const inner = (
<>
<span style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>{label}</span>
{sub && <span style={{ fontSize: 12, fontWeight: 600, opacity: 0.9, textTransform: "none", letterSpacing: 0 }}>{sub}</span>}
</span>
{small !== undefined
? <small style={{ fontFamily: MONO, fontWeight: 500, fontSize: 13, letterSpacing: 0, textTransform: "none", opacity: 0.9, whiteSpace: "nowrap" }}>{small}</small>
: glyph !== "none" && <G />}
</>
);
const st: React.CSSProperties = {
height: `calc(64px + ${SAFE_BOTTOM})`, flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, width: "100%", background: bg, color: WHITE, border: 0, borderRadius: 0,
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer",
textDecoration: "none", textAlign: "left",
};
if (disabled && offReason) {
return <button type="button" aria-disabled="true" onClick={() => toast(offReason)} style={st} className="tcx-bar">{inner}</button>;
}
if (href && !disabled) return <Link href={href} style={st} className="tcx-bar">{inner}</Link>;
return <button type="button" onClick={onClick} disabled={disabled} style={st} className="tcx-bar">{inner}</button>;
}
/** Two actions sharing the 64px bar, e.g. Undo (1fr, ink) / Scan (2fr) on the counting screen. */
export function MSplit({ children }: { children: React.ReactNode }) {
return <div style={{ ...NOT_DOCKED, display: "flex", flex: "0 0 64px", height: 64 }}>{children}</div>;
}
// `flex` is only meaningful inside an MSplit; on its own the bar is a fixed 64px like MBar.
export function MAction({ label, onClick, disabled, flex, tone = "accent", glyph, glyphAt = "left", small }: {
label: string; onClick?: () => void; disabled?: boolean; flex?: number; tone?: "accent" | "grey" | "ink"; glyph?: "scan" | "plus" | "none";
/** "right" puts the glyph at the far end, as the Scan half of the counting split. */
glyphAt?: "left" | "right"; small?: string;
}) {
const bg = disabled && tone !== "grey" ? OFF_GREY : tone === "accent" ? ACCENT : tone === "ink" ? INK : "var(--color-neutral-200)";
const fg = tone === "grey" ? INK : WHITE;
const g = glyph === "scan" ? <IconScan size={26} /> : glyph === "plus" ? <IconPlus /> : null;
const right = glyphAt === "right";
return (
<button type="button" onClick={onClick} disabled={disabled} className="tcx-bar" style={{ flex: flex ?? `0 0 calc(64px + ${SAFE_BOTTOM})`, height: `calc(64px + ${SAFE_BOTTOM})`, background: bg, color: fg, border: 0, display: "flex", alignItems: "center", justifyContent: right || small ? "space-between" : glyph ? "flex-start" : "center", gap: 10, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled && tone === "grey" ? 0.45 : 1, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
{!right && g}
<span>{label}</span>
{small !== undefined && <small style={{ fontFamily: MONO, fontWeight: 500, fontSize: 13, letterSpacing: 0, textTransform: "none" }}>{small}</small>}
{right && g}
</button>
);
}
// ---------- tab bar
export type MTab = "today" | "work" | "scan" | "stock" | "people";
const TAB_ROOTS: [MTab, string[]][] = [
["work", ["/m/work", "/m/request", "/m/receive", "/m/round"]],
["scan", ["/m/scan"]],
["stock", ["/m/stock", "/m/line", "/m/count", "/m/reorder", "/m/variance", "/m/catalogue"]],
["people", ["/m/people", "/m/person"]],
];
function tabOf(path: string): MTab | undefined {
if (path === "/m" || path === "/m/") return "today";
for (const [t, roots] of TAB_ROOTS) if (roots.some((r) => path === r || path.startsWith(r + "/"))) return t;
return undefined;
}
/** The five-tab bar, drawn only on Today, Work, Scan, Stock and People. */
export function MTabs({ active, workBadge }: { active?: MTab; workBadge?: number }) {
if (workBadge === undefined) return <MTabsCounted active={active} />;
return <MTabsView active={active} workBadge={workBadge} />;
}
function MTabsCounted({ active }: { active?: MTab }) {
const w = useWorkCount();
return <MTabsView active={active} workBadge={w.total} />;
}
function MTabsView({ active, workBadge }: { active?: MTab; workBadge: number }) {
const path = usePathname() || "";
const on = active ?? tabOf(path);
const tab = (t: MTab, href: string, label: string, icon: React.ReactNode, badge?: number) => {
const cur = on === t;
return (
<Link key={t} href={href} aria-current={cur ? "page" : undefined} aria-label={badge ? `${label}, ${badge} open` : undefined}
style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3, fontSize: 12, fontWeight: 700, color: cur ? INK : MUTED, textDecoration: "none", minHeight: 66 }}>
{cur && <span aria-hidden="true" style={{ position: "absolute", top: -2, left: "22%", right: "22%", height: 4, background: ACCENT }} />}
{icon}
<span>{label}</span>
{!!badge && (
<span aria-hidden="true" style={{ position: "absolute", top: 7, left: "calc(50% + 6px)", background: ACCENT, color: WHITE, fontFamily: MONO, fontSize: 11, fontWeight: 800, minWidth: 18, height: 18, padding: "0 5px", display: "grid", placeItems: "center" }}>{badge}</span>
)}
</Link>
);
};
return (
<nav aria-label="Main" style={{ position: "relative", zIndex: 2, flex: "0 0 auto", height: "calc(66px + env(safe-area-inset-bottom, 0px))", paddingBottom: "env(safe-area-inset-bottom, 0px)", background: WHITE, borderTop: "2px solid " + INK, display: "grid", gridTemplateColumns: "repeat(5, 1fr)", alignItems: "stretch" }}>
{tab("today", "/m", "Today", <IconToday />)}
{tab("work", "/m/work", "Work", <IconWork />, workBadge)}
<Link href="/m/scan" aria-current={on === "scan" ? "page" : undefined}
style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3, fontSize: 12, textDecoration: "none", color: WHITE, minHeight: 66 }}>
<span style={{ width: 62, height: 62, background: ACCENT, display: "grid", placeItems: "center", marginTop: -22, border: "3px solid " + WHITE, boxShadow: "0 0 0 2px " + INK, boxSizing: "border-box" }}><IconScan size={30} /></span>
<b style={{ color: INK, fontWeight: 800 }}>Scan</b>
</Link>
{tab("stock", "/m/stock", "Stock", <IconStock />)}
{tab("people", "/m/people", "People", <IconPeople />)}
</nav>
);
}
/** @deprecated The old four-tab bar. Now the five-tab bar; removed at integration. */
export const MNav = MTabs;
// ---------- lists
/** Section heading: 12px/800 uppercase with a 2px rule, count or note at the right in mono. */
export function MSection({ label, right, flush }: { label: string; right?: React.ReactNode; /** No top margin (straight under an MHead). */ flush?: boolean }) {
return (
<div className="tcx-sec" style={{ ...(flush ? { marginTop: 0 } : null), display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8, paddingBottom: 6, borderBottom: "2px solid var(--tcx-sec-rule, " + INK + ")", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: "inherit" }}>
<span>{label}</span>
{right !== undefined && <span style={{ color: "var(--tcx-sec-sub, " + MUTED + ")", fontFamily: MONO, fontWeight: 500, letterSpacing: 0, textTransform: "none" }}>{right}</span>}
</div>
);
}
/* Can a tap on a link to the marketing site actually get out of here? In a browser, yes. Inside the
* Android shell only a browser plugin can do it, and asking the plugin registry is the only honest
* way to find out. */
function browserPlugin(): { open?: (o: { url: string }) => Promise<unknown> } | null {
if (typeof window === "undefined") return null;
const cap = (window as unknown as {
Capacitor?: { isNativePlatform?: () => boolean; Plugins?: { Browser?: { open?: (o: { url: string }) => Promise<unknown> } } };
}).Capacitor;
if (!cap?.isNativePlatform?.()) return null;
return cap.Plugins?.Browser ?? null;
}
type Opens = "tab" | "browser" | "inline";
function whereItOpens(): Opens {
if (typeof window === "undefined") return "tab";
const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
if (!cap?.isNativePlatform?.()) return "tab";
return browserPlugin()?.open ? "browser" : "inline";
}
function handedToTheBrowser(url: string): boolean {
const browser = browserPlugin();
if (!browser?.open) return false;
browser.open({ url }).catch(() => { window.location.href = url; });
return true;
}
export type Mark = "ink" | "accent" | "mute" | "ok" | "none";
const markColour = (m: Mark) => (m === "accent" ? ACCENT : m === "mute" ? DIVIDER : m === "ok" ? OK : INK);
function rowBody(bar: React.ReactNode, title: React.ReactNode, sub: React.ReactNode, right: React.ReactNode, note?: string, chev?: boolean) {
return (
<>
{bar}
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 15, fontWeight: 700 }}>{title}</span>
{sub !== undefined && sub !== "" && sub !== null && <span style={{ display: "block", fontSize: 13, color: MUTED, marginTop: 1 }}>{sub}</span>}
{note && <span style={{ display: "block", fontSize: 12, fontWeight: 600, color: "var(--color-neutral-700)", marginTop: 4 }}>{note}</span>}
</span>
{right !== undefined && right !== null && <span style={{ flex: "0 0 auto", textAlign: "right", fontFamily: MONO, fontSize: 14, fontWeight: 600, whiteSpace: "nowrap" }}>{right}</span>}
{chev && <span aria-hidden="true" style={{ color: MUTED, fontSize: 18, flex: "0 0 auto" }}></span>}
</>
);
}
/** A list row (`dense`: 48px, for short check lists). `mark` is the 4px status stripe at the left; `active` is the selected row (white,
* accent edge); `attention` lifts the row to white; `chev` says it opens something. */
export function MRow({ title, sub, right, mark = "none", attention, active, chev, onClick, href, external, disabled, dense }: {
title: React.ReactNode; sub?: React.ReactNode; right?: React.ReactNode; mark?: Mark; attention?: boolean; active?: boolean; chev?: boolean; dense?: boolean;
onClick?: () => void; href?: string; external?: boolean; disabled?: boolean;
}) {
const bar = mark === "none" ? null : (
<span aria-hidden="true" style={{ width: 4, alignSelf: "stretch", minHeight: 34, flex: "0 0 4px", background: markColour(mark), visibility: active ? "hidden" : undefined }} />
);
const body = rowBody(bar, title, sub, right, undefined, chev);
const st: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: dense ? 48 : 62, padding: dense ? "6px 0" : "9px 0",
background: active || attention ? WHITE : "none", boxShadow: active ? "inset 4px 0 0 " + ACCENT : undefined,
border: "none", borderBottom: "1px solid " + DIVIDER,
color: "inherit", textAlign: "left", textDecoration: "none", font: "inherit", cursor: onClick || href ? "pointer" : "default", opacity: disabled ? 0.5 : 1,
};
if (href && external && !disabled) return <MExternal href={href} st={st} bar={bar} title={title} sub={sub} right={right} />;
if (href && !disabled) return <Link href={href} style={st}>{body}</Link>;
if (onClick) return <button type="button" onClick={onClick} disabled={disabled} aria-pressed={active === undefined ? undefined : active} style={st}>{body}</button>;
return <div style={st}>{body}</div>;
}
/* A row pointing at the marketing site (privacy, terms, deleting an account). In a browser a new
* tab; in the shell, Chrome through the Browser plugin, or the page itself with a note on how to
* come back when there is no plugin. Settled after mount. */
function MExternal({ href, st, bar, title, sub, right }: {
href: string; st: React.CSSProperties; bar: React.ReactNode; title: React.ReactNode; sub: React.ReactNode; right: React.ReactNode;
}) {
const [opens, setOpens] = useState<Opens>("tab");
useEffect(() => { setOpens(whereItOpens()); }, []);
if (opens === "inline") {
return <a href={href} style={st}>{rowBody(bar, title, sub, right, "Opens here in ThreadCount — the back button brings you back")}</a>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" style={st}
onClick={(e) => { if (handedToTheBrowser(href)) e.preventDefault(); }}>
{rowBody(bar, title, sub, right, opens === "browser" ? "Opens in your browser" : "Opens in a new tab")}
</a>
);
}
/** A link to the marketing site inside a sentence, with the same three behaviours as the row above. */
export function MExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
const [opens, setOpens] = useState<Opens>("tab");
useEffect(() => { setOpens(whereItOpens()); }, []);
const st: React.CSSProperties = { color: "inherit", fontWeight: 800, textDecoration: "underline", textUnderlineOffset: 3 };
if (opens === "inline") return <a href={href} style={st}>{children}</a>;
return (
<a href={href} target="_blank" rel="noopener noreferrer" style={st}
onClick={(e) => { if (handedToTheBrowser(href)) e.preventDefault(); }}>
{children}
</a>
);
}
/** The ink panel (old screens). */
export function MPanel({ kicker, kickerRight, children, pad = 16 }: { kicker?: string; kickerRight?: React.ReactNode; children: React.ReactNode; pad?: number }) {
return (
<section style={{ background: INK, color: GROUND, padding: pad }}>
{(kicker || kickerRight) && (
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10 }}>
<span style={{ flex: 1, fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY }}>{kicker}</span>
{kickerRight}
</div>
)}
{children}
</section>
);
}
/** A link inside an ink panel (old screens). */
export function MInkLink({ label, onClick, href }: { label: string; onClick?: () => void; href?: string }) {
const st: React.CSSProperties = { background: "none", border: 0, padding: 0, color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", borderBottom: "2px solid " + ACCENT, paddingBottom: 2, cursor: "pointer", textDecoration: "none" };
return href ? <Link href={href} style={st}>{label}</Link> : <button type="button" onClick={onClick} style={st}>{label}</button>;
}
/** Counted / expected / delta (old counting screen). */
export function MFigures({ counted, expected, unit = "COUNTED" }: { counted: number; expected: number; unit?: string }) {
const d = counted - expected;
return <MFigRow figs={[{ label: unit, n: counted }, { label: "Expected", n: expected }, { label: "Gap", n: d === 0 ? 0 : d > 0 ? `+${d}` : `${-d}`, tone: d === 0 ? "ok" : "accent" }]} />;
}
// ---------- odds and ends
/** Nothing to show: a bold line and at most one short line under it. */
export function MEmpty({ title, sub, action }: { title: string; sub?: string; action?: React.ReactNode }) {
return (
<div style={{ padding: "28px 0", textAlign: "left" }}>
<div style={{ fontSize: 17, fontWeight: 700 }}>{title}</div>
{sub && <div style={{ fontSize: 14, color: MUTED, marginTop: 2 }}>{sub}</div>}
{action && <div style={{ marginTop: 14 }}>{action}</div>}
</div>
);
}
export function MNote({ children, tone = "ink" }: { children: React.ReactNode; tone?: "ink" | "warn" }) {
return (
<div style={{ margin: 16, padding: 16, background: tone === "ink" ? INK : "#fff", color: tone === "ink" ? GROUND : INK, borderLeft: tone === "warn" ? "4px solid " + ACCENT : undefined, fontSize: 13.5, lineHeight: 1.6 }}>
{children}
</div>
);
}
/** Something went wrong, said plainly, full-bleed under the rule. */
export function MError({ msg, onDismiss }: { msg: string; onDismiss?: () => void }) {
if (!msg) return null;
return (
<div role="alert" style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "12px 16px", background: ACCENT, color: "#fff", fontSize: 14, lineHeight: 1.45, fontWeight: 700 }}>
<span style={{ flex: 1 }}>{msg}</span>
{onDismiss && <button type="button" onClick={onDismiss} aria-label="Dismiss" style={{ width: 44, height: 44, margin: "-12px -12px -12px 0", border: 0, background: "none", color: "#fff", padding: 0, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "0 0 44px" }}><IconX size={18} /></button>}
</div>
);
}
const chipStyle = (on: boolean, off: boolean, warn = false): React.CSSProperties => ({
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + (off ? DIVIDER : on && warn ? ACCENT : INK),
background: on ? (warn ? ACCENT : INK) : "transparent", color: off ? OFF_GREY : on ? (warn ? WHITE : GROUND) : INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
cursor: off ? "not-allowed" : "pointer", borderRadius: 0,
});
const chipSmall = (on: boolean): React.CSSProperties => ({ fontFamily: MONO, fontSize: 11, fontWeight: 500, color: on ? OFF_GREY : MUTED });
/** Size chips; the selected one inverts. `counts` adds how many are on the shelf. */
export function MChips({ sizes, value, onPick, disabled, counts }: { sizes: string[]; value: number; onPick: (i: number) => void; disabled?: (i: number) => boolean; counts?: number[] }) {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
{sizes.map((sz, i) => {
const off = !!disabled?.(i);
const on = i === value;
const n = counts?.[i];
return (
<button type="button" key={i} onClick={() => onPick(i)} disabled={off} aria-pressed={on} aria-label={n === undefined ? undefined : `${sz}, ${n} on the shelf`} style={chipStyle(on, off)}>
{sz}{n !== undefined && <small style={chipSmall(on)}>{n}</small>}
</button>
);
})}
</div>
);
}
/** Chips with optional small counts. `tone="warn"` turns the chosen chip accent (reasons). */
export function MChipRow<V extends string>({ options, value, onPick, tone = "ink", label, disabled, grid }: {
options: { value: V; label: string; n?: number }[]; value: V | null; onPick: (v: V) => void;
tone?: "ink" | "warn"; label: string; disabled?: (v: V) => boolean;
/** Equal columns instead of wrapping (the four condition chips). */
grid?: number;
}) {
return (
<div role="group" aria-label={label} style={grid ? { display: "grid", gridTemplateColumns: `repeat(${grid}, 1fr)`, gap: 6, marginTop: 8 } : { display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
{options.map((o) => {
const on = value === o.value;
const off = !!disabled?.(o.value);
return (
<button type="button" key={o.value} onClick={() => onPick(o.value)} disabled={off} aria-pressed={on}
aria-label={o.n === undefined ? undefined : `${o.label}, ${o.n} on the shelf`}
style={{ ...chipStyle(on, off, tone === "warn"), ...(grid ? { minWidth: 0, padding: "0 4px", fontSize: 13 } : null) }}>
{o.label}{o.n !== undefined && <small style={chipSmall(on)}>{o.n}</small>}
</button>
);
})}
</div>
);
}
/** The fixed reasons for a flagged line or a count gap. Tapping the chosen chip clears it. */
export function MReasonChips({ reasons, value, onPick, label }: { reasons: readonly string[]; value: string | null; onPick: (r: string | null) => void; label: string }) {
return (
<MChipRow tone="warn" label={label} value={value} options={reasons.map((r) => ({ value: r, label: r }))}
onPick={(r) => onPick(r === value ? null : r)} />
);
}
/** n + with 44px buttons. `label` names what is counted for screen readers. */
export function MStepper({ n, onChange, min = 0, max = 999, label = "" }: { n: number; onChange: (v: number) => void; min?: number; max?: number; label?: string }) {
const b = (off: boolean): React.CSSProperties => ({ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 700, lineHeight: 1, cursor: off ? "not-allowed" : "pointer", opacity: off ? 0.35 : 1, padding: 0, fontFamily: "inherit" });
const what = label ? ` ${label}` : "";
return (
<div style={{ display: "flex", alignItems: "stretch", height: 44, flex: "none" }}>
<button type="button" style={b(n <= min)} onClick={() => onChange(Math.max(min, n - 1))} disabled={n <= min} aria-label={`One fewer${what}`}></button>
<b aria-live="polite" aria-label={label ? `${n} ${label}` : undefined} style={{ minWidth: 44, background: INK, color: GROUND, display: "grid", placeItems: "center", fontFamily: MONO, fontSize: 16, padding: "0 6px" }}>{n}</b>
<button type="button" style={b(n >= max)} onClick={() => onChange(Math.min(max, n + 1))} disabled={n >= max} aria-label={`One more${what}`}>+</button>
</div>
);
}
export function MField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label style={{ display: "block", padding: "12px 0" }}>
<span style={{ display: "block", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MUTED, marginBottom: 6 }}>{label}</span>
{children}
</label>
);
}
/** 52px field, 2px ink border, white. Add `paddingLeft: 44` when an icon sits in it. */
export const inputStyle: React.CSSProperties = {
width: "100%", minHeight: 52, padding: "0 14px", border: "2px solid " + INK, background: "#fff", color: INK,
fontSize: 16, fontFamily: "inherit", borderRadius: 0, boxSizing: "border-box", // 16px keeps Android from zooming on focus
};
/** Numerals that line up in a column (old screens). */
export function MNum({ a, b: bb, tone }: { a: number | string; b?: number | string; tone?: "accent" | "mute" }) {
return (
<span style={{ fontFamily: MONO, fontWeight: 600, fontSize: 15, fontVariantNumeric: "tabular-nums", color: tone === "accent" ? AC7 : tone === "mute" ? "var(--color-neutral-500)" : INK }}>
{a}{bb !== undefined && <span style={{ color: "var(--color-neutral-700)" }}>/{bb}</span>}
</span>
);
}
// ---------- redesign components
/** Kicker line: 12px/800 uppercase, muted. */
export function MKick({ children, mono }: { children: React.ReactNode; mono?: boolean }) {
return (
<div style={mono
? { fontFamily: MONO, fontSize: 12, fontWeight: 500, color: MUTED }
: { fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: "var(--tcx-kick, " + MUTED + ")" }}>
{children}
</div>
);
}
/** The ink person header. Bleeds to the body edges (use inside MBody pad). */
export function MHead({ name, meta, children }: { name: string; meta: string; children?: React.ReactNode }) {
return (
<section style={{ background: INK, color: GROUND, margin: "-16px -16px 14px", padding: "14px 16px 16px" }}>
<h2 style={{ fontSize: 24, fontWeight: 900, letterSpacing: "-0.01em", lineHeight: 1.1, margin: 0 }}>{name}</h2>
{meta && <div style={{ fontSize: 13, color: "#d6d3d2", marginTop: 3 }}>{meta}</div>}
{children}
</section>
);
}
/** Held-against-the-cap meters: held in ground, what is being added in accent after it. */
export function MMeterPair({ items }: { items: { label: string; held: number; adding: number; cap: number }[] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginTop: 14 }}>
{items.map((m) => {
const total = m.held + m.adding;
const cap = Math.max(1, m.cap);
const heldPct = Math.min(100, (m.held / cap) * 100);
const addPct = Math.max(0, Math.min(100 - heldPct, (m.adding / cap) * 100));
return (
<div key={m.label} role="meter" aria-valuenow={total} aria-valuemin={0} aria-valuemax={m.cap} aria-label={`${m.label} ${total} of ${m.cap}`}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: "#d6d3d2" }}>
<span>{m.label}</span>
<b style={{ fontFamily: MONO, fontSize: 14, letterSpacing: 0, color: total > m.cap ? AC3 : GROUND }}>{total}/{m.cap}</b>
</div>
<div style={{ height: 8, background: DARK_RULE, marginTop: 5, position: "relative", overflow: "hidden" }}>
<i style={{ position: "absolute", inset: "0 auto 0 0", width: `${heldPct}%`, background: GROUND }} />
<em style={{ position: "absolute", top: 0, bottom: 0, left: `${heldPct}%`, width: `${addPct}%`, background: ACCENT }} />
</div>
</div>
);
})}
</div>
);
}
/** One line under the meters ("Manager approval · 1 set left"). */
export function MHeadRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 13, borderTop: "1px solid " + DARK_RULE, paddingTop: 10, color: "#d6d3d2" }}>
<span>{label}</span>
<b style={{ color: GROUND, fontFamily: MONO }}>{value}</b>
</div>
);
}
/** Segmented control. */
export function MSeg<K extends string>({ value, options, onPick, label }: { value: K; options: { key: K; label: string; n?: number }[]; onPick: (k: K) => void; label: string }) {
return (
<div role="tablist" aria-label={label} style={{ display: "flex", border: "2px solid " + INK, marginBottom: 6 }}>
{options.map((o, i) => {
const on = o.key === value;
return (
<button type="button" key={o.key} role="tab" aria-selected={on} onClick={() => onPick(o.key)}
style={{ flex: 1, minHeight: 44, border: 0, borderRight: i === options.length - 1 ? 0 : "2px solid " + INK, background: on ? INK : "transparent", color: on ? GROUND : INK, fontFamily: "inherit", fontSize: 13, fontWeight: 800, letterSpacing: "0.03em", textTransform: "uppercase", padding: "0 4px", whiteSpace: "nowrap", cursor: "pointer" }}>
{o.label}{o.n !== undefined && <span style={{ fontFamily: MONO, fontWeight: 500, marginLeft: 4 }}>{o.n}</span>}
</button>
);
})}
</div>
);
}
/** A garment in their kit: size button, on-shelf count, and a big plus. The size chips go in children. */
export function MKitCard({ title, size, onShelf, onSize, sizeOpen, onAdd, addDisabled, addLabel, children }: {
title: string; size: string | null; onShelf: number | null; onSize: () => void; sizeOpen: boolean;
onAdd: () => void; addDisabled: boolean; addLabel: string; children?: React.ReactNode;
}) {
return (
<>
<div style={{ display: "flex", alignItems: "center", gap: 12, border: "2px solid " + INK, background: WHITE, padding: "10px 10px 10px 14px", marginTop: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: 800 }}>{title}</span>
<div style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 4, fontSize: 13, color: MUTED, flexWrap: "wrap" }}>
<button type="button" onClick={onSize} aria-expanded={sizeOpen}
style={{ border: 0, background: "var(--color-surface, #eae9e9)", color: INK, fontFamily: "inherit", fontWeight: 800, fontSize: 13, minHeight: 44, padding: "0 10px", display: "inline-flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
{size === null ? "Pick a size" : `Size ${size}`}<span aria-hidden="true"></span>
</button>
{onShelf !== null && <span style={{ fontFamily: MONO }}>{onShelf} on shelf</span>}
</div>
</div>
<button type="button" onClick={onAdd} disabled={addDisabled} aria-label={addLabel}
style={{ width: 56, height: 56, border: 0, background: addDisabled ? DIVIDER : INK, color: GROUND, fontSize: 30, fontWeight: 600, lineHeight: 1, flex: "none", cursor: addDisabled ? "not-allowed" : "pointer", fontFamily: "inherit" }}>+</button>
</div>
{sizeOpen && children && <div style={{ border: "2px solid " + INK, borderTop: 0, padding: "4px 12px 12px", background: WHITE }}>{children}</div>}
</>
);
}
/** A basket line. `flag` draws the accent stripe and says why the line needs a reason. */
export function MLine({ title, size, flag, right, children }: { title: string; size?: string; flag?: string; right?: React.ReactNode; children?: React.ReactNode }) {
return (
<div style={{ borderBottom: "1px solid " + DIVIDER, padding: "10px 0", ...(flag ? { background: `linear-gradient(90deg, ${ACCENT} 0 4px, transparent 4px)`, paddingLeft: 12 } : null) }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<b style={{ fontWeight: 700 }}>{title}</b>{size !== undefined && <> <span style={{ fontFamily: MONO }}>{size}</span></>}
{flag && <div style={{ fontSize: 13, fontWeight: 800, color: AC7, marginTop: 2 }}>{flag}</div>}
</div>
{right}
</div>
{children}
</div>
);
}
/** Bottom sheet. Backdrop tap and Escape close it; focus moves in and returns on close. */
export function MSheet({ open, onClose, labelId, children, bar }: { open: boolean; onClose: () => void; labelId: string; children: React.ReactNode; bar?: React.ReactNode }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const back = document.activeElement as HTMLElement | null;
ref.current?.focus();
const key = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", key);
return () => { document.removeEventListener("keydown", key); back?.focus?.(); };
}, [open, onClose]);
if (!open) return null;
return (
<div className="tcx-scanui" onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(32,30,29,.5)", display: "flex", alignItems: "flex-end", justifyContent: "center" }}>
<div ref={ref} role="dialog" aria-modal="true" aria-labelledby={labelId} tabIndex={-1} onClick={(e) => e.stopPropagation()}
style={{ ...NOT_DOCKED, background: GROUND, color: INK, width: "100%", maxWidth: 560, borderTop: "4px solid " + ACCENT, padding: "16px 16px 0", outline: "none" }}>
{children}
{bar && <div style={{ margin: "16px -16px 0", paddingBottom: "env(safe-area-inset-bottom, 0px)", background: GROUND }}>{bar}</div>}
</div>
</div>
);
}
/** Finish screen head. The top bar "Done" is the h1; this is an h2. */
export function MDone({ head, sub, children }: { head: string; sub: string; children?: React.ReactNode }) {
return (
<div>
<div aria-hidden="true" style={{ width: 84, height: 84, background: ACCENT, display: "grid", placeItems: "center", margin: "18px 0 16px" }}><TickMark /></div>
<h2 style={{ fontSize: 30, fontWeight: 900, lineHeight: 1.05, letterSpacing: "-0.015em", margin: 0 }}>{head}</h2>
<div style={{ color: MUTED, marginTop: 6 }}>{sub}</div>
{children}
</div>
);
}
/** "Shelf now" on a Done screen: what is left of each variant that moved. */
export function MShelfNow({ lines }: { lines: { key: string; name: string; onHand: number; par: number; onOrder: string }[] }) {
if (!lines.length) return null;
return (
<>
<MSection label="Shelf now" />
{lines.map((l) => (
<MRow key={l.key} mark={l.onHand < l.par ? "accent" : "ok"} title={l.name}
sub={l.onHand < l.par ? `${l.par - l.onHand} below par${l.onOrder ? ` · ${l.onOrder}` : ""}` : "At par"}
right={`${l.onHand}/${l.par}`} />
))}
</>
);
}
/** Three figures in a white box (Today's "Your day"). */
export function MDay({ figs }: { figs: { n: number | string; label: string }[] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.max(1, figs.length)}, 1fr)`, border: "2px solid " + INK, background: WHITE, marginTop: 8 }}>
{figs.map((f, i) => (
<div key={f.label} style={{ padding: "10px 12px", borderRight: i === figs.length - 1 ? 0 : "1px solid " + DIVIDER }}>
<b style={{ display: "block", fontSize: 30, fontWeight: 900, fontVariantNumeric: "tabular-nums", lineHeight: 1.1 }}>{f.n}</b>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: MUTED }}>{f.label}</span>
</div>
))}
</div>
);
}
/** Counted / Expected / Gap on the ink counting panel. */
export function MFigRow({ figs }: { figs: { label: string; n: number | string; tone?: "accent" | "ok" }[] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.max(1, figs.length)}, 1fr)`, marginTop: 12, borderTop: "1px solid " + DARK_RULE, paddingTop: 10 }}>
{figs.map((f) => (
<div key={f.label} style={{ display: "grid" }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY }}>{f.label}</span>
<b style={{ fontSize: 34, fontWeight: 900, fontVariantNumeric: "tabular-nums", lineHeight: 1.1, color: f.tone === "accent" ? AC3 : f.tone === "ok" ? "#9fd8b8" : undefined }}>{f.n}</b>
</div>
))}
</div>
);
}
/** A to-do row on Today: the number block, what to do, and where it goes. */
export function MTodo({ n, accent, title, sub, href }: { n: string | number; accent?: boolean; title: string; sub: string; href: string }) {
return (
<Link href={href} style={{ display: "flex", gap: 12, alignItems: "center", width: "100%", minHeight: 68, borderBottom: "1px solid " + DIVIDER, padding: "10px 0", textDecoration: "none", color: "inherit" }}>
<span style={{ minWidth: 52, height: 52, padding: "0 6px", display: "grid", placeItems: "center", fontFamily: MONO, fontSize: 22, fontWeight: 600, background: accent ? ACCENT : INK, color: accent ? WHITE : GROUND, flex: "none", boxSizing: "border-box" }}>{n}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: 800 }}>{title}</span>
{sub && <span style={{ display: "block", fontSize: 13, color: MUTED }}>{sub}</span>}
</span>
<span aria-hidden="true" style={{ color: MUTED, fontSize: 18 }}></span>
</Link>
);
}
/** A small status label. */
export function MPill({ tone = "ink", children, mono }: { tone?: "ink" | "accent" | "ok" | "mute"; children: React.ReactNode; mono?: boolean }) {
const [bg, fg] = tone === "accent" ? [ACCENT, WHITE] : tone === "ok" ? [OK, WHITE] : tone === "mute" ? [DIVIDER, INK] : [INK, GROUND];
return (
<span style={{ display: "inline-flex", alignItems: "center", minHeight: 26, padding: "0 8px", fontSize: 12, fontWeight: 800, letterSpacing: mono ? 0 : "0.05em", textTransform: mono ? "none" : "uppercase", fontFamily: mono ? MONO : "var(--font-body)", background: bg, color: fg, whiteSpace: "nowrap" }}>{children}</span>
);
}
/** A setting you switch on or off. `dark` is the Hands-free switch on the ink counting panel. */
export function MSwitchRow({ title, sub, on, onToggle, disabled, tone = "ink", dark }: { title: string; sub?: string; on: boolean; onToggle: () => void; disabled?: boolean; tone?: "ink" | "accent"; dark?: boolean }) {
const onBg = tone === "accent" || dark ? ACCENT : INK;
const tog = (
<span aria-hidden="true" style={{ width: 52, height: 30, background: on ? onBg : dark ? DARK_EDGE : DIVIDER, position: "relative", flex: "none" }}>
<span style={{ position: "absolute", top: 3, left: on ? 25 : 3, width: 24, height: 24, background: WHITE, transition: "left .15s" }} />
</span>
);
const st: React.CSSProperties = dark
? { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginTop: 12, border: 0, background: "#2d2b2b", width: "100%", minHeight: 48, padding: "0 12px", color: GROUND, fontFamily: "inherit", fontWeight: 800, fontSize: 13, letterSpacing: "0.05em", textTransform: "uppercase", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, textAlign: "left" }
: { display: "flex", alignItems: "center", gap: 12, minHeight: 60, border: 0, borderBottom: "1px solid " + DIVIDER, width: "100%", background: "none", padding: 0, color: "inherit", fontFamily: "inherit", fontSize: 15, textAlign: "left", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1 };
return (
<button type="button" role="switch" aria-checked={on} onClick={disabled ? undefined : onToggle} aria-disabled={disabled || undefined} style={st}>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: dark ? 800 : 700 }}>{title}</span>
{sub && <span style={{ display: "block", fontSize: 13, color: MUTED, fontWeight: 400, textTransform: "none", letterSpacing: 0 }}>{sub}</span>}
</span>
{tog}
</button>
);
}
/** A pick-list row: the tick box is the button. */
export function MPickRow({ done, onToggle, title, sub, right, children, tickLabel }: { done: boolean; onToggle: () => void; title: string; sub?: string; right?: React.ReactNode; children?: React.ReactNode; tickLabel?: string }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, borderBottom: "1px solid " + DIVIDER, width: "100%", padding: "8px 0" }}>
<button type="button" onClick={onToggle} aria-pressed={done} aria-label={tickLabel || `Picked ${title}`}
style={{ padding: 7, margin: -7, border: 0, background: "none", cursor: "pointer", flex: "none" }}>
<span style={{ width: 30, height: 30, border: "2px solid " + INK, display: "grid", placeItems: "center", background: done ? INK : WHITE, color: GROUND, boxSizing: "border-box" }}>
{done && <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3.2} aria-hidden="true"><path d="M4 12.5l5 5L20 6.5" /></svg>}
</span>
</button>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: 700 }}>{title}</span>
{sub && <span style={{ display: "block", fontSize: 13, color: MUTED }}>{sub}</span>}
</span>
{right !== undefined && <span style={{ fontFamily: MONO, fontSize: 14, fontWeight: 600, whiteSpace: "nowrap" }}>{right}</span>}
{children}
</div>
);
}
/** A card with a strip of three action chips (Pickups). */
export function MCard({ title, sub, pill, actions }: { title: string; sub: string; pill?: React.ReactNode; actions: React.ReactNode }) {
return (
<div style={{ border: "2px solid " + INK, background: WHITE, padding: "12px 14px", marginTop: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "flex-start" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 800 }}>{title}</div>
{sub && <div style={{ fontSize: 13, color: MUTED }}>{sub}</div>}
</div>
{pill}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, marginTop: 10 }}>{actions}</div>
</div>
);
}
/** A chip for MCard's action strip (a button, or a link such as tel:). */
export function MCardChip({ label, onClick, href, on, disabled }: { label: string; onClick?: () => void; href?: string; on?: boolean; disabled?: boolean }) {
const st: React.CSSProperties = { ...chipStyle(!!on, !!disabled), minWidth: 0, fontSize: 13, padding: "0 6px", textDecoration: "none" };
if (href && !disabled) return <a href={href} style={st}>{label}</a>;
return <button type="button" onClick={onClick} disabled={disabled} aria-pressed={on === undefined ? undefined : on} style={st}>{label}</button>;
}
/** Big button: 52px (44 when small), 2px ink, optional icon at the left. */
export function MButton({ label, onClick, href, icon, tone = "line", disabled, small }: { label: string; onClick?: () => void; href?: string; icon?: "scan" | "print" | "plus"; tone?: "line" | "ink"; disabled?: boolean; small?: boolean }) {
const ink = tone === "ink";
const st: React.CSSProperties = {
minHeight: small ? 44 : 52, width: "100%", border: "2px solid " + (disabled ? DIVIDER : INK), background: ink ? (disabled ? DIVIDER : INK) : "transparent",
color: disabled ? OFF_GREY : ink ? GROUND : INK, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase",
display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "0 14px", cursor: disabled ? "not-allowed" : "pointer", textDecoration: "none", boxSizing: "border-box", marginTop: 10,
};
const g = icon === "scan" ? <IconScan /> : icon === "print" ? <IconPrinter /> : icon === "plus" ? <IconPlus /> : null;
if (href && !disabled) return <Link href={href} style={st}>{g}{label}</Link>;
return <button type="button" onClick={onClick} disabled={disabled} style={st}>{g}{label}</button>;
}
/** Search field: leading icon, trailing accent scan button. Never focuses itself on load. */
export function MSearch({ value, onChange, placeholder, label, scanHref, onScan, scanLabel = "Scan a badge" }: { value: string; onChange: (v: string) => void; placeholder: string; label: string; scanHref?: string; onScan?: () => void; scanLabel?: string }) {
const id = useId();
const scanSt: React.CSSProperties = { position: "absolute", right: 4, top: 4, width: 44, height: 44, background: ACCENT, border: 0, display: "grid", placeItems: "center", color: WHITE, cursor: "pointer" };
return (
<div style={{ position: "relative" }}>
<label htmlFor={id} style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>{label}</label>
<span aria-hidden="true" style={{ position: "absolute", left: 12, top: 15, color: MUTED, display: "flex" }}><IconSearch /></span>
<input id={id} type="search" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} autoComplete="off"
style={{ ...inputStyle, paddingLeft: 44, paddingRight: scanHref || onScan ? 56 : 14 }} />
{scanHref ? <Link href={scanHref} aria-label={scanLabel} style={scanSt}><IconScan /></Link>
: onScan ? <button type="button" onClick={onScan} aria-label={scanLabel === "Scan a badge" ? "Scan" : scanLabel} style={scanSt}><IconScan /></button> : null}
</div>
);
}
/** Signature box. The canvas is sized to its CSS box at the device pixel ratio. */
export function MSignature({ name, onReady, onChange }: { name: string; onReady: (api: { clear: () => void; dataUrl: () => string | null }) => void; onChange: (signed: boolean) => void }) {
const ref = useRef<HTMLCanvasElement | null>(null);
const dirty = useRef(false);
const change = useRef(onChange); change.current = onChange;
useEffect(() => {
const el = ref.current; if (!el) return;
const dpr = window.devicePixelRatio || 1;
const r = el.getBoundingClientRect();
el.width = Math.max(1, Math.round(r.width * dpr));
el.height = Math.max(1, Math.round(r.height * dpr));
const ctx = el.getContext("2d")!;
ctx.scale(dpr, dpr);
ctx.lineWidth = 2.6; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#201e1d";
let draw = false;
const pos = (ev: PointerEvent) => { const b = el.getBoundingClientRect(); return [ev.clientX - b.left, ev.clientY - b.top] as const; };
const down = (ev: PointerEvent) => {
ev.preventDefault(); draw = true; el.setPointerCapture(ev.pointerId);
const [x, y] = pos(ev); ctx.beginPath(); ctx.moveTo(x, y);
if (!dirty.current) { dirty.current = true; change.current(true); }
};
const move = (ev: PointerEvent) => { if (!draw) return; const [x, y] = pos(ev); ctx.lineTo(x, y); ctx.stroke(); };
const up = () => { draw = false; };
el.addEventListener("pointerdown", down); el.addEventListener("pointermove", move); el.addEventListener("pointerup", up); el.addEventListener("pointercancel", up);
onReady({
clear: () => { ctx.clearRect(0, 0, el.width, el.height); if (dirty.current) { dirty.current = false; change.current(false); } },
dataUrl: () => (dirty.current ? el.toDataURL("image/png") : null),
});
return () => { el.removeEventListener("pointerdown", down); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{ position: "relative", border: "2px solid " + INK, background: WHITE, height: 150, marginTop: 8, touchAction: "none" }}>
<canvas ref={ref} role="img" aria-label={`Signature box for ${name}`} style={{ display: "block", width: "100%", height: "100%", touchAction: "none" }} />
<div aria-hidden="true" style={{ position: "absolute", left: 14, right: 14, bottom: 12, borderTop: "1px solid " + DIVIDER, paddingTop: 6, fontSize: 12, color: MUTED, pointerEvents: "none", display: "flex", justifyContent: "space-between", gap: 8 }}>
<span>{name}</span><span>Sign above the line</span>
</div>
</div>
);
}