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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user