"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 ;
}
// ---------- icons (stroke 2.2, square caps)
const ic = (d: React.ReactNode, size: number, stroke = 2.2) => (
);
export const IconLeft = ({ size = 22 }: { size?: number }) => ic(, size);
export const IconRight = ({ size = 22 }: { size?: number }) => ic(<>>, size);
export const IconCheck = ({ size = 22 }: { size?: number }) => ic(, size);
export const IconPrinter = ({ size = 22 }: { size?: number }) => ic(<>>, size);
export const IconScan = ({ size = 22 }: { size?: number }) => ic(<>>, size, 2.4);
export const IconSearch = ({ size = 22 }: { size?: number }) => ic(<>>, size);
export const IconX = ({ size = 22 }: { size?: number }) => ic(<>>, size);
export const IconPlus = ({ size = 22 }: { size?: number }) => ic(<>>, size);
export const IconToday = ({ size = 24 }: { size?: number }) => ic(<>>, size);
export const IconWork = ({ size = 24 }: { size?: number }) => ic(, size);
export const IconStock = ({ size = 24 }: { size?: number }) => ic(<>>, size);
export const IconPeople = ({ size = 24 }: { size?: number }) => ic(<>>, size);
export const IconGear = ({ size = 24 }: { size?: number }) => ic(<>>, size);
export const IconBack = ({ size = 24 }: { size?: number }) => ic(, size, 2.6);
const TickMark = ({ size = 44 }: { size?: number }) => (
);
// ---------- top bar
/** The top bar. Its title is the screen's only
. `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.
{back && (
)}
{title}
{right !== undefined && right !== null && (plain
?
{right}
: right)}
);
}
/** 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" ? : icon === "print" ? : icon === "scan" ? : label;
const aria = ariaLabel || (icon ? label : undefined);
if (href) return {inner};
return ;
}
/** A count at the right of the top bar ("142", "7 open"). */
export function MTopCount({ children }: { children: React.ReactNode }) {
return {children};
}
/* Wordmark and facility bar: the staff app home (components/screens/Home.tsx). */
export function MTopBrand({ facility, right }: { facility: string; right?: React.ReactNode }) {
return (
ThreadCount{facility}
{right}
);
}
/** 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 (
{pct !== null && }
);
}
/* 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 (
{children}
);
}
// ---------- toast and scan flash
type ToastApi = { toast: (msg: string) => void; flash: (kind: string, label: string, then: () => void) => void };
const ToastContext = createContext(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("");
const [fl, setFl] = useState<{ kind: string; label: string } | null>(null);
const timer = useRef | 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({ toast, flash });
return (
{children}
{fl && (
{fl.kind}{fl.label}
)}
{msg}
);
}
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 = (
<>
{label}
{sub && {sub}}
{small !== undefined
? {small}
: glyph !== "none" && }
>
);
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 ;
}
if (href && !disabled) return {inner};
return ;
}
/** 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
{children}
;
}
// `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" ? : glyph === "plus" ? : null;
const right = glyphAt === "right";
return (
);
}
// ---------- 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 ;
return ;
}
function MTabsCounted({ active }: { active?: MTab }) {
const w = useWorkCount();
return ;
}
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 (
{cur && }
{icon}
{label}
{!!badge && (
{badge}
)}
);
};
return (
);
}
/** @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 (
{label}
{right !== undefined && {right}}
);
}
/* 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 } | null {
if (typeof window === "undefined") return null;
const cap = (window as unknown as {
Capacitor?: { isNativePlatform?: () => boolean; Plugins?: { Browser?: { open?: (o: { url: string }) => Promise } } };
}).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}
{title}
{sub !== undefined && sub !== "" && sub !== null && {sub}}
{note && {note}}
{right !== undefined && right !== null && {right}}
{chev && ›}
>
);
}
/** 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 : (
);
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 ;
if (href && !disabled) return {body};
if (onClick) return ;
return
{body}
;
}
/* 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("tab");
useEffect(() => { setOpens(whereItOpens()); }, []);
if (opens === "inline") {
return {rowBody(bar, title, sub, right, "Opens here in ThreadCount — the back button brings you back")};
}
return (
{ if (handedToTheBrowser(href)) e.preventDefault(); }}>
{rowBody(bar, title, sub, right, opens === "browser" ? "Opens in your browser" : "Opens in a new tab")}
);
}
/** 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("tab");
useEffect(() => { setOpens(whereItOpens()); }, []);
const st: React.CSSProperties = { color: "inherit", fontWeight: 800, textDecoration: "underline", textUnderlineOffset: 3 };
if (opens === "inline") return {children};
return (
{ if (handedToTheBrowser(href)) e.preventDefault(); }}>
{children}
);
}
/** The ink panel (old screens). */
export function MPanel({ kicker, kickerRight, children, pad = 16 }: { kicker?: string; kickerRight?: React.ReactNode; children: React.ReactNode; pad?: number }) {
return (
{(kicker || kickerRight) && (
{kicker}
{kickerRight}
)}
{children}
);
}
/** 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 ? {label} : ;
}
/** Counted / expected / delta (old counting screen). */
export function MFigures({ counted, expected, unit = "COUNTED" }: { counted: number; expected: number; unit?: string }) {
const d = counted - expected;
return 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 (
{sizes.map((sz, i) => {
const off = !!disabled?.(i);
const on = i === value;
const n = counts?.[i];
return (
);
})}
);
}
/** Chips with optional small counts. `tone="warn"` turns the chosen chip accent (reasons). */
export function MChipRow({ 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 (
{options.map((o) => {
const on = value === o.value;
const off = !!disabled?.(o.value);
return (
);
})}
);
}
/** 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 (
({ 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 (
{n}
);
}
export function MField({ label, children }: { label: string; children: React.ReactNode }) {
return (
);
}
/** 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 (
{a}{bb !== undefined && /{bb}}
);
}
// ---------- redesign components
/** Kicker line: 12px/800 uppercase, muted. */
export function MKick({ children, mono }: { children: React.ReactNode; mono?: boolean }) {
return (
{children}
);
}
/** 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 (
{name}
{meta &&
{meta}
}
{children}
);
}
/** 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 (
);
}
/** One line under the meters ("Manager approval · 1 set left"). */
export function MHeadRow({ label, value }: { label: string; value: string }) {
return (
);
}
/** 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 (
{head}
{sub}
{children}
);
}
/** "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 (
<>
{lines.map((l) => (
))}
>
);
}
/** Three figures in a white box (Today's "Your day"). */
export function MDay({ figs }: { figs: { n: number | string; label: string }[] }) {
return (
{figs.map((f, i) => (
{f.n}{f.label}
))}
);
}
/** Counted / Expected / Gap on the ink counting panel. */
export function MFigRow({ figs }: { figs: { label: string; n: number | string; tone?: "accent" | "ok" }[] }) {
return (
{figs.map((f) => (
{f.label}{f.n}
))}
);
}
/** 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 (
{n}{title}
{sub && {sub}}
›
);
}
/** 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 (
{children}
);
}
/** 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 = (
);
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 (
);
}
/** 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 (