Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
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:
@@ -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;
|
||||
Reference in New Issue
Block a user