Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s

Water log mobile field experience (PIN → Headgates → Log → Success):

* Brand rename: "Tuxedo Ditch Co." → "Tuxedo Corn" everywhere it
  was a stale reference (page metadata + admin Today tab caption).
  Constants already say Tuxedo Corn; this just aligns the stragglers.

* Tab bar (Headgates | Time): pill pair → real UISegmentedControl.
  MiniSegmented lives next to TabBar in MobileWaterApp.tsx and uses
  the same ResizeObserver-measure + spring-timing pattern as the
  existing SegmentedControl — feels like one widget at two sizes
  (this one at 32pt, the unit selector at 44pt).

* Liquid-glass chrome (recipe applied 5×, mirrored on the bottom
  submit bar): linear-gradient background + blur(24px) +
  saturate(180%) + inset top highlight + inset bottom hairline.
  TabBar, HeadgateList top nav, LogForm top nav, LogForm sticky
  submit bar, FieldPinScreen + SuccessScreen brand strips.

* ThresholdMeter (new component, mobile/ThresholdMeter.tsx): the
  screen's signature. A live "blip" indicator under the 44pt
  measurement input that ties the form to its real domain (a
  worker reading a real gauge) instead of being a decorative
  gauge trophy. Track + optional colored zones (red·green·red)
  + thumb that slides on Apple's spring curve + color-coded
  zone label. Mirrors Apple's Volume Limit / Screen Time sliders.

* i18n: 9 new zone labels per language (zoneBelowNormal,
  zoneAboveAlert, zoneInRange, zoneBelowAlert, zoneAboveMin,
  zoneReading, zoneTypeToLog, meterNormalRange + ES twins).
  Legacy normalRange/alertAbove/alertBelow signatures widened
  to accept pre-formatted strings so integer units render clean.

* Updated water-log-i18n unit test for the wider signatures.
  All 27 tests pass; tsc + eslint clean.
This commit is contained in:
Tyler
2026-07-06 10:32:36 -06:00
parent 5188960bd2
commit 5dc9a4604f
10 changed files with 577 additions and 96 deletions
+156 -42
View File
@@ -36,7 +36,14 @@
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
* - `logoutTimeTracking` → clears the time-tracking cookie on logout
*/
import { useCallback, useEffect, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
verifyWaterPin,
submitWaterEntry,
@@ -353,11 +360,18 @@ export function MobileWaterApp() {
);
}
// ── TabBar (Cycle 4) ───────────────────────────────────────────
// Sticky two-tab top nav for the Tuxedo worker experience.
// Stays visible across Headgates → Log → Success AND on the Time
// tab. Extracted as a presentational component (no server actions,
// no effects) so it's pure and easy to iterate on visually.
// ── TabBar (Cycle 4 → Apple HIG pass) ──────────────────────────
// Sticky top nav for the Tuxedo worker experience. Stays visible
// across Headgates → Log → Success AND on the Time tab.
//
// Two HIG principles in play:
// 1. Use UISegmentedControl for the mode switch (not two pills).
// The control is a true iOS segmented control: gray track,
// sliding white pill, spring timing. The same widget appears
// as the unit selector inside the log form, just bigger.
// 2. Use a "liquid glass" chrome on the bar itself: gradient
// background, strong saturate(180%), inner top highlight, and
// a hairline at the bottom edge — not a flat colored band.
function TabBar({
activeTab,
onChange,
@@ -372,41 +386,35 @@ function TabBar({
const t = useLang().t;
return (
<div
className="sticky top-0 z-30 flex items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-white/85 px-3 py-2 backdrop-blur"
role="tablist"
aria-label="Worker navigation"
className="sticky top-0 z-30 flex items-center justify-between px-3"
style={{
paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))",
// "Liquid glass": top catches more light than the bottom,
// so the gradient runs brighter at the top and slightly
// more transparent below. Saturation boost makes any
// background read with a hint of brand warmth as the
// worker scrolls headgates past the bar.
background:
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)",
// Inner highlight + hairline (one box-shadow with two
// insets reads cheaper than separate pseudo-elements).
boxShadow:
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
}}
>
<div className="flex items-center gap-2">
<button
type="button"
role="tab"
aria-selected={activeTab === "headgates"}
onClick={() => onChange("headgates")}
className={
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
(activeTab === "headgates"
? "bg-[#1a4d2e] text-white shadow-sm"
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.headgates}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "time"}
onClick={() => onChange("time")}
className={
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
(activeTab === "time"
? "bg-[#1a4d2e] text-white shadow-sm"
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
}
>
{t.tab.time}
</button>
</div>
<div className="flex items-center gap-2">
<MiniSegmented
ariaLabel="Worker navigation"
value={activeTab}
onChange={onChange}
options={[
{ value: "headgates", label: t.tab.headgates },
{ value: "time", label: t.tab.time },
]}
/>
<div className="flex items-center gap-1.5 pr-0.5">
{irrigatorName && (
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
{irrigatorName}
@@ -415,8 +423,8 @@ function TabBar({
<button
type="button"
onClick={onLogout}
className="rounded-full px-2.5 py-1 text-[12px] font-semibold text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]"
aria-label="Log out"
aria-label={t.tab.logout}
className="rounded-full px-2.5 py-1 text-[13px] font-semibold text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
>
{t.tab.logout}
</button>
@@ -425,4 +433,110 @@ function TabBar({
);
}
// ── MiniSegmented (nav-bar-sized UISegmentedControl) ──────────────
// Sliding-pill variant of SegmentedControl tuned for the 32pt nav
// bar context. Reuses the same measurement + spring-timing approach
// as the larger control in `SegmentedControl.tsx` so the two feel
// like one widget at different sizes.
function MiniSegmented<V extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: ReadonlyArray<{ value: V; label: ReactNode }>;
value: V;
onChange: (next: V) => void;
ariaLabel: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
// Sliding indicator rect. The indicator uses
// `transform: translateX(...)` with Apple's spring curve so the
// transition reads as "physical," not "fade."
const [rect, setRect] = useState<{ x: number; w: number } | null>(null);
const measure = useCallback(() => {
const root = containerRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(
`[data-seg-key="${value}"]`,
);
if (!btn) return;
const rootRect = root.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
// `x` is measured from the container's content edge (after the
// 2px padding) so the indicator rides just inside the track.
setRect({ x: btnRect.left - rootRect.left - 2, w: btnRect.width });
}, [value]);
useLayoutEffect(() => {
measure();
}, [measure]);
// Re-measure whenever the option set changes (e.g. labels
// translate via the Lang context and the active segment width
// shifts) so the indicator stays in place.
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) return;
const ro = new ResizeObserver(measure);
ro.observe(root);
return () => ro.disconnect();
}, [measure]);
return (
<div
ref={containerRef}
role="tablist"
aria-label={ariaLabel}
className="relative inline-flex h-8 items-stretch rounded-[9px] p-[2px]"
style={{ background: "rgba(120,120,128,0.20)" }}
>
{rect && (
<div
aria-hidden
className="absolute rounded-[7px] bg-white"
style={{
top: 2,
bottom: 2,
left: 2,
width: rect.w,
transform: `translateX(${rect.x}px)`,
boxShadow:
"0 3px 8px rgba(0,0,0,0.06), 0 3px 1px rgba(0,0,0,0.04), inset 0 -0.5px 0 0 rgba(0,0,0,0.04), inset 0 0.5px 0 0 rgba(255,255,255,1)",
transition:
"transform 320ms cubic-bezier(0.32, 0.72, 0, 1), width 320ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
/>
)}
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={selected}
data-seg-key={opt.value}
onClick={() => onChange(opt.value)}
className={[
"relative z-10 select-none px-3 text-[13px] font-semibold",
"flex items-center justify-center",
"min-w-[78px] active:opacity-80",
selected ? "text-[#1d1d1f]" : "text-[rgba(60,60,67,0.65)]",
].join(" ")}
style={{
height: 28,
transition:
"color 220ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<span className="truncate">{opt.label}</span>
</button>
);
})}
</div>
);
}
export default MobileWaterApp;