"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) => ( ); 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); 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); // ---------- 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.
{back && ( // −12px pulls the 44px hit area back so the glyph itself lands on the 16px gutter. )}

{title}

{right !== undefined &&
{right}
}
); } /* 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 (
); } /** 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 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
{children}
; } /** 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 = ( <> {label} {sub && {sub}} {glyph !== "none" && } ); 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 {inner}; return ; } /** Two actions sharing the 64px bar, e.g. Scan / Undo 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 }: { 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 ( ); } // ---------- lists export function MSection({ label, right }: { label: string; right?: React.ReactNode }) { return (
{label} {right !== undefined && {right}}
); } /* 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 } | 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"; // 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} {title} {sub !== undefined && sub !== "" && {sub}} {note && {note}} {right !== undefined && {right}} ); } /** 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 : (