"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 (
); } /** 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 &&