"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 (
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 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
;
}
// `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 : (
);
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 ;
if (href && !disabled) return {body};
if (onClick) return ;
return
{body}
;
}
/* 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("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 — "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("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 — 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 (
{(kicker || kickerRight) && (
);
}
/** Something went wrong, said plainly and with a way out. */
export function MError({ msg, onDismiss }: { msg: string; onDismiss?: () => void }) {
if (!msg) return null;
return (
{msg}
{/* 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 && }
);
}
export function MField({ label, children }: { label: string; children: React.ReactNode }) {
return (
);
}
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 (
{/* 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 && /{bb}}
);
}