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 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 12:12:58 +10:00
commit cbf533d90b
275 changed files with 43340 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
"use client";
/* The phone app's shared furniture, built once from the handoff's "structure common to every screen".
Every screen is a top bar, an accent rule, a scrolling body and (usually) one primary action bar.
Sizes here are the handoff's dp figures used straight as px — the app runs at device width. */
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
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
// ---------- icons (Lucide shapes, stroke 2.2, square caps)
const ic = (d: React.ReactNode, size: number) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} 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 7V3h4" /><path d="M17 3h4v4" /><path d="M21 17v4h-4" /><path d="M7 21H3v-4" /><path d="M7 8v8M11 8v8M15 8v8" /></>, size);
export const IconSearch = ({ size = 22 }: { size?: number }) => ic(<><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>, 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);
// ---------- top bar
export function MTop({ title, right, back, onBack, dark = true }: { title: string; right?: React.ReactNode; back?: boolean; onBack?: () => void; dark?: boolean }) {
const router = useRouter();
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", paddingLeft: 16, paddingRight: 16, 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" }}>
{back && (
// 12px pulls the 44px hit area back so the glyph itself lands on the 16px gutter.
<button onClick={() => (onBack ? onBack() : router.back())} aria-label="Back"
style={{ width: 44, height: 44, marginLeft: -12, marginRight: 0, border: 0, background: "none", color: "inherit", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "0 0 44px" }}>
<IconLeft />
</button>
)}
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase", margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</h1>
{right !== undefined && <div style={{ fontSize: 12, color: dark ? ON_DARK : "var(--color-neutral-600)", marginLeft: 12, whiteSpace: "nowrap" }}>{right}</div>}
</header>
);
}
/* The home screen's bar: wordmark and facility rather than a screen title.
*
* It exists as a component for one reason — it used to be hand-rolled inline on the home page,
* which meant it missed `tcx-topbar` and therefore the safe-area padding. The wordmark and the
* facility name were drawn underneath the phone's status bar, colliding with the clock and the
* battery, while every MTop screen sat correctly below it. Sharing the class here is what stops
* that drifting apart again. */
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,
// Same hairline as MTop, pinned to the bottom of the status bar area.
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", background: pct === null ? ACCENT : "var(--color-divider)" }}>
{pct !== null && <div style={{ height: "100%", width: `${pct * 100}%`, background: ACCENT, transition: "width 140ms linear" }} />}
</div>
);
}
/* Android 15 draws the app edge to edge whether it asks or not, so a bar docked at the foot of the
column has the gesture handle — or the three-button bar — sitting on its bottom edge. The inset
goes inside the bar, the way .tcx-topbar takes the status bar at the other end: the accent still
runs to the bottom of the screen, but "Commit count" and its tick stay above the system's own
furniture rather than being drawn under it with their lower half untappable.
It reads through a custom property, and MBody and MSplit set that property to zero, because the
same bars are also used away from the foot of the window — the "Done" bar mid-list on a person's
record, Scan / Undo above the lines on the counting screen. There the inset would open a band of
dead colour in the middle of the screen. Inheritance does the sorting: anything that scrolls or
shares a split is by definition not the thing the gesture bar is sitting on.
A screen that parks a bar above its own tab bar has the same problem and no ancestor to say so,
which is why NOT_DOCKED is exported for that wrapper to carry: the nav underneath already takes
the inset, and a bar that takes it as well leaves a strip of accent nothing sits on, halfway up
the screen, with the tap target ending above it. */
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
export const NOT_DOCKED = { "--tcx-safe-bottom": "0px" } as React.CSSProperties;
export function MBody({ children, pad = false }: { children?: React.ReactNode; pad?: boolean }) {
return <div style={{ ...NOT_DOCKED, flex: 1, overflowY: "auto", WebkitOverflowScrolling: "touch", background: GROUND, padding: pad ? 16 : 0 }}>{children}</div>;
}
/** Full-bleed 64px primary action. Label flush left at 20px, glyph at the right edge — brand rule. */
export function MBar({ label, onClick, href, glyph = "arrow", disabled, tone = "accent", sub }: {
label: string; onClick?: () => void; href?: string; glyph?: "arrow" | "check" | "printer" | "scan" | "none"; disabled?: boolean; tone?: "accent" | "ink"; sub?: string;
}) {
const G = glyph === "check" ? IconCheck : glyph === "printer" ? IconPrinter : glyph === "scan" ? IconScan : IconRight;
const bg = 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: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>{label}</span>
{sub && <span style={{ fontSize: 11, fontWeight: 600, opacity: 0.85, textTransform: "none", letterSpacing: 0 }}>{sub}</span>}
</span>
{glyph !== "none" && <G />}
</>
);
const st: React.CSSProperties = {
height: `calc(64px + ${SAFE_BOTTOM})`, flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, width: "100%", background: bg, color: "#fff", border: 0, borderRadius: 0,
display: "flex", alignItems: "center", gap: 12, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, textDecoration: "none", textAlign: "left",
};
if (href && !disabled) return <Link href={href} style={st} className="tcx-bar">{inner}</Link>;
return <button onClick={onClick} disabled={disabled} style={st} className="tcx-bar">{inner}</button>;
}
/** Two actions sharing the 64px bar, e.g. Scan / Undo 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 }: { label: string; onClick?: () => void; disabled?: boolean; flex?: number; tone?: "accent" | "grey" | "ink"; glyph?: "scan" | "plus" | "none" }) {
const bg = tone === "accent" ? ACCENT : tone === "ink" ? INK : "var(--color-neutral-200)";
const fg = tone === "grey" ? INK : "#fff";
return (
<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: glyph ? "flex-start" : "center", gap: 10, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
{glyph === "scan" && <IconScan />}
{glyph === "plus" && <IconPlus />}
{label}
</button>
);
}
// ---------- lists
export function MSection({ label, right }: { label: string; right?: React.ReactNode }) {
return (
<div style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{label}</span>
{right !== undefined && <span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{right}</span>}
</div>
);
}
/* Can a tap on a link to the marketing site actually get out of here?
*
* In any browser, yes — it has real tabs. Inside the Android shell only a browser plugin can do it,
* and asking the plugin registry is the only honest way to find out: nothing is imported, so this
* stays out of the web bundle, and a shell built without one simply says no. Neither shipped app
* has one today, so today the answer on a phone is no, and the rows below have to behave and read
* accordingly rather than promising a trip to Chrome that never happens. */
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"; // the server can't know; see MExternal
const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
if (!cap?.isNativePlatform?.()) return "tab";
return browserPlugin()?.open ? "browser" : "inline";
}
/** Hands a URL to the phone's own browser, and says whether it got there. */
function handedToTheBrowser(url: string): boolean {
const browser = browserPlugin();
if (!browser?.open) return false;
// If the plugin refuses, load it where the tap would have gone anyway rather than leaving the row
// looking broken — the hardware back button gets a counter out of that.
browser.open({ url }).catch(() => { window.location.href = url; });
return true;
}
export type Mark = "ink" | "accent" | "mute" | "none";
/* One row's contents, shared so the external row can add a line about where the tap goes without a
second copy of the markup drifting away from this one. */
function rowBody(bar: React.ReactNode, title: React.ReactNode, sub: React.ReactNode, right: React.ReactNode, note?: string) {
return (
<>
{bar}
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 16, fontWeight: 600, letterSpacing: "-0.01em", overflow: "hidden", textOverflow: "ellipsis" }}>{title}</span>
{sub !== undefined && sub !== "" && <span style={{ display: "block", fontSize: 13, color: "var(--color-neutral-600)", marginTop: 2 }}>{sub}</span>}
{note && <span style={{ display: "block", fontSize: 12, fontWeight: 600, color: "var(--color-neutral-700)", marginTop: 4 }}>{note}</span>}
</span>
{right !== undefined && <span style={{ flex: "0 0 auto", textAlign: "right" }}>{right}</span>}
</>
);
}
/** A list row. `mark` is the 4×34 status bar at the left; `attention` lifts the row to white. */
export function MRow({ title, sub, right, mark = "none", attention, onClick, href, external, disabled }: {
title: React.ReactNode; sub?: React.ReactNode; right?: React.ReactNode; mark?: Mark; attention?: boolean; onClick?: () => void; href?: string; external?: boolean; disabled?: boolean;
}) {
const bar = mark === "none" ? null : (
<span aria-hidden="true" style={{ width: 4, height: 34, flex: "0 0 4px", background: mark === "accent" ? ACCENT : mark === "mute" ? "var(--color-neutral-400)" : INK }} />
);
const body = rowBody(bar, title, sub, right);
const st: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: 62, padding: "12px 16px",
background: attention ? "#fff" : GROUND, borderBottom: "1px solid var(--color-divider)", border: "none",
borderBottomWidth: 1, borderBottomStyle: "solid", borderBottomColor: "var(--color-divider)",
color: INK, 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 onClick={onClick} disabled={disabled} style={st}>{body}</button>;
return <div style={st}>{body}</div>;
}
/* A row pointing at the marketing site — the privacy policy, the terms, how to delete an account.
*
* On the web it is a plain anchor in a new tab, never a Link: those are the site's pages, not a
* counter screen. Inside the Android shell target="_blank" does nothing of the sort. The site and
* the app are the same host, that host is the one host server.allowNavigation lets the WebView
* load, and the WebView is given no second window, so the page lands over the top of the counter
* with the site's own nav and no tab bar to leave by. A browser plugin is the only route to Chrome
* from in there, and neither shipped app has one in its capacitor.plugins.json.
*
* So one check settles both what the row does and what it says. Where a tap can leave, it leaves
* and says where it is going. Where it cannot, the row stops claiming it can: it still opens — the
* deletion instructions have to be reachable from inside the app, and a dead row is worse — but it
* says the page opens here and how to come back, which is the whole difference between a page
* somebody chose and a page somebody is stuck on. Both are settled after mount, not during render:
* the server has no idea which shell this is, and the shell's globals only exist once it has
* booted, so the first paint is the web row and the phone corrects it. */
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 — "I agree to the Terms of use" — with the same
* three behaviours as the row above: a new tab in a browser, Chrome through the Browser plugin
* in the shell, and the page itself (back button returns) in a shell without one. */
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 — the active line on a count, the person on an issue. */
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: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>{kicker}</span>
{kickerRight}
</div>
)}
{children}
</section>
);
}
/** A link inside an ink panel — underlined in accent, per the Hands-free treatment. */
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 onClick={onClick} style={st}>{label}</button>;
}
/** Counted / expected / delta — the three-part figure row inside the ink panel. */
export function MFigures({ counted, expected, unit = "COUNTED" }: { counted: number; expected: number; unit?: string }) {
const d = counted - expected;
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: 20, marginTop: 14 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: ON_DARK }}>{unit}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 64, lineHeight: 0.9, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums" }}>{counted}</div>
</div>
<div style={{ paddingBottom: 4 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: ON_DARK }}>Expected</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 32, lineHeight: 1, letterSpacing: "-0.02em", color: "var(--color-neutral-300)", fontVariantNumeric: "tabular-nums" }}>{expected}</div>
</div>
<div style={{ flex: 1, textAlign: "right", paddingBottom: 6, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", color: d === 0 ? "var(--color-neutral-300)" : "var(--color-accent-300)", fontVariantNumeric: "tabular-nums" }}>
{d === 0 ? "Match" : d > 0 ? `+${d}` : `${-d}`}
</div>
</div>
);
}
// ---------- bottom nav
const NAV: [string, string][] = [["/m", "Home"], ["/m/count", "Count"], ["/m/stock", "Stock"], ["/m/search", "Search"]];
export function MNav() {
const path = usePathname();
const on = (href: string) => (href === "/m" ? path === "/m" : path.startsWith(href));
return (
<nav style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", flex: "0 0 auto", borderTop: "2px solid " + INK, background: GROUND }}>
{NAV.map(([href, label]) => (
<Link key={href} href={href} aria-current={on(href) ? "page" : undefined}
style={{ minHeight: 52, display: "flex", alignItems: "center", justifyContent: "center", padding: "14px 4px calc(14px + env(safe-area-inset-bottom, 0px))", fontSize: 10, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none", background: on(href) ? INK : GROUND, color: on(href) ? GROUND : "var(--color-neutral-600)" }}>
{label}
</Link>
))}
</nav>
);
}
// ---------- odds and ends
export function MEmpty({ title, sub, action }: { title: string; sub?: string; action?: React.ReactNode }) {
return (
<div style={{ padding: "48px 24px", textAlign: "center" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em" }}>{title}</div>
{sub && <p style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 8, lineHeight: 1.55 }}>{sub}</p>}
{action && <div style={{ marginTop: 18, display: "flex", justifyContent: "center" }}>{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 and with a way out. */
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: 13.5, lineHeight: 1.5, fontWeight: 600 }}>
<span style={{ flex: 1 }}>{msg}</span>
{/* Negative margins give the × a 44px hit area without moving the glyph or growing the
banner: an 18px icon with no padding is a target you miss with gloves on. */}
{onDismiss && <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>
);
}
/** 52px size chips; the selected one inverts. */
export function MChips({ sizes, value, onPick, disabled }: { sizes: string[]; value: number; onPick: (i: number) => void; disabled?: (i: number) => boolean }) {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
{sizes.map((sz, i) => {
const off = disabled?.(i);
const on = i === value;
return (
<button key={i} onClick={() => onPick(i)} disabled={off} aria-pressed={on}
style={{ minWidth: 52, height: 52, padding: "0 10px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? GROUND : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, cursor: off ? "not-allowed" : "pointer", opacity: off ? 0.35 : 1 }}>
{sz}
</button>
);
})}
</div>
);
}
export function MStepper({ n, onChange, min = 0, max = 999 }: { n: number; onChange: (v: number) => void; min?: number; max?: number }) {
const b: React.CSSProperties = { width: 44, height: 44, border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18, lineHeight: 1, cursor: "pointer" };
return (
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
<button style={b} onClick={() => onChange(Math.max(min, n - 1))} disabled={n <= min} aria-label="One fewer"></button>
<span style={{ width: 46, height: 44, display: "flex", alignItems: "center", justifyContent: "center", background: INK, color: GROUND, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, fontVariantNumeric: "tabular-nums" }}>{n}</span>
<button style={b} onClick={() => onChange(Math.min(max, n + 1))} disabled={n >= max} aria-label="One more">+</button>
</div>
);
}
export function MField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label style={{ display: "block", padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<span style={{ display: "block", fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 6 }}>{label}</span>
{children}
</label>
);
}
export const inputStyle: React.CSSProperties = {
width: "100%", minHeight: 48, padding: "10px 12px", border: "2px solid " + INK, background: "#fff",
fontSize: 16, fontWeight: 600, borderRadius: 0, // 16px keeps iOS/Android from zooming the field on focus
};
/** Numerals that line up in a column: counted/expected, on hand, par. */
export function MNum({ a, b: bb, tone }: { a: number | string; b?: number | string; tone?: "accent" | "mute" }) {
return (
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em", fontVariantNumeric: "tabular-nums", color: tone === "accent" ? "var(--color-accent-700)" : tone === "mute" ? "var(--color-neutral-500)" : INK }}>
{/* The second figure is data — what you are counting towards — so it takes a shade that can
be read on paper and on white: neutral-400 is 1.9:1 there and effectively invisible. */}
{a}{bb !== undefined && <span style={{ color: "var(--color-neutral-700)" }}>/{bb}</span>}
</span>
);
}