feat(field): cycle 11 — visual cohesion for time-tracking + water-log
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s

- Lift the PIN screen out of water-specific code into a shared
  src/components/field/FieldPinScreen.tsx used by both apps.
  Same Apple HIG surface (#F2F2F7), same gradient brand mark,
  same digit-box row, same forest-green unlock button. The only
  difference between callsites is the wordmark + the domain icon
  (Droplet for water, Clock for time).

- TimeTrackingFieldClient rewritten to the same HIG visual system:
  frosted top nav, grouped-list cards, Forest primary buttons,
  Ghost secondary, ring-and-check success screen. Behavior
  (reducer/handlers/lang toggle) unchanged.

- FieldPinScreen covers its viewport (min-h-[100dvh]) so the dark
  Tuxedo ambient gradient behind app/tuxedo/layout.tsx no longer
  shows through on /tuxedo/time-clock.

- Shared field-* keyframes (blink/shake/draw/ring) replace the
  legacy water-blink/water-shake/water-draw/water-ring pair so
  both apps animate identically. SuccessScreen updated to use the
  new field-* names.

- Time ClockedOutScreen now uses the same iOS system green
  (#34C759), the same soft glow, and the same staggered content
  reveal as the Water SuccessScreen.

- No new lint errors or warnings (335 problems = baseline). tsc
  clean.
This commit is contained in:
Nora
2026-07-03 20:47:58 -06:00
parent 16ad0955f2
commit eb0da05037
7 changed files with 1464 additions and 731 deletions
+25 -20
View File
@@ -302,16 +302,34 @@ select:-webkit-autofill:focus {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Mobile Water Log — PIN caret blink. The `to { opacity: 0 }` + /* The water success screen used `water-draw`/`water-ring`; the Time
* `steps(2, start)` combo gives a hard on/off blink like UISegmentedControl * Tracking Clocked Out screen used inline `strokeDashoffset` transitions
* / UITextField, not a gentle fade. */ * + a single `field-draw`. Cycle 11 collapses everything into shared
@keyframes water-blink { * `field-*` keyframes so a worker who clocks out sees the same
* ring-and-check reveal choreography as a worker who submits a water
* reading. */
@keyframes field-draw {
from { stroke-dashoffset: var(--draw-from, 100); }
to { stroke-dashoffset: 0; }
}
@keyframes field-ring {
from { stroke-dashoffset: var(--ring-from, 360); }
to { stroke-dashoffset: 0; }
}
/* Mobile Field — PIN caret blink. The `to { opacity: 0 }` +
* `steps(2, start)` combo gives a hard on/off blink like
* UISegmentedControl / UITextField, not a gentle fade. */
@keyframes field-blink {
to { opacity: 0; } to { opacity: 0; }
} }
/* Mobile Water Log — PIN-entry error shake. Three diminishing /* Mobile Field — PIN-entry error shake. Three diminishing oscillations
* oscillations for a "no, that wasn't right" feel. */ * for a "no, that wasn't right" feel. The shake is applied directly
@keyframes water-shake { * to the digit-box row in `FieldPinScreen.tsx`, not to an empty
* overlay, so the user actually sees the row jerk back and forth. */
@keyframes field-shake {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translateX(0); }
20% { transform: translateX(-10px); } 20% { transform: translateX(-10px); }
40% { transform: translateX(10px); } 40% { transform: translateX(10px); }
@@ -319,19 +337,6 @@ select:-webkit-autofill:focus {
80% { transform: translateX(7px); } 80% { transform: translateX(7px); }
} }
/* Mobile Water Log — checkmark stroke draw for the success screen.
* The element's `stroke-dasharray` is set inline so it works for any
* length; the keyframe just controls the timing. */
@keyframes water-draw {
from { stroke-dashoffset: var(--draw-from, 100); }
to { stroke-dashoffset: 0; }
}
/* Mobile Water Log — circular progress for the success ring. */
@keyframes water-ring {
from { stroke-dashoffset: var(--ring-from, 360); }
to { stroke-dashoffset: 0; }
}
/* Reduce motion: respect the user's OS-level preference. */ /* Reduce motion: respect the user's OS-level preference. */
/* The motion pass deliberately keeps the visual design intact (colors, type, layout) /* The motion pass deliberately keeps the visual design intact (colors, type, layout)
+367
View File
@@ -0,0 +1,367 @@
"use client";
/**
* FieldPinScreen — shared Apple HIG PIN entry, used by both the
* Water Log field app and the Time Tracking field app.
*
* Visual system (cycle 11 — visual cohesion):
* - Surface: `#F2F2F7` Apple system grey (matches iOS Keyboard,
* secondarySystemBackground). Same surface for both apps so a
* worker stepping from `/water` into `/tuxedo/time-clock` (or
* vice versa) feels they switched modes, not apps.
* - Brand mark: a 22px-rounded square with a subtle green→white
* gradient, holding the brand icon. The icon is the only thing
* that differs between apps — Droplet for water, Clock for
* time. The square + gradient + ring shadow + tracked wordmark
* strap are the recognizable signature.
* - Four large digit boxes (68×58) that mirror the value of a
* hidden <input>. The hidden input is the source of truth so
* iOS's QuickType / password-manager suggestions fire
* correctly. Boxes never echo a digit back; they show a filled
* dot, with a blinking caret in the active slot.
* - Full-width forest-green primary with an inner highlight; on
* idle: low-contrast grey placeholder.
* - Inline error replaces the caption. Failure = 0.4s shake of
* the digit-box row (`field-shake` keyframe, mirrors
* `water-shake` semantically — three diminishing oscillations
* so it reads as "no, that wasn't right").
*
* Props are intentionally a small surface (length, onSubmit, app
* identity, translations, brand color) so both call sites stay
* thin. The `shakeEventName` lets the parent fire the same shake
* when the verify-result returned from the server is "PIN wrong".
*/
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import {
Droplet,
Clock,
Spinner,
type IconProps,
} from "@/components/field/icons";
// ── Domain types ──────────────────────────────────────────────────────────
export type FieldPinIcon = "droplet" | "clock";
export type FieldPinTranslations = {
/** Headline above the digit boxes. */
title: string;
/** Caption below the headline (and the in-line error slot). */
caption: string;
/** Unlock button label at idle. */
unlock: string;
/** Unlock button label while verifying. */
verifying: string;
/** Footer hint (e.g. "Forgot your PIN? Ask the office"). */
forgot: string;
/** Fallback error when `onSubmit` throws. */
genericError: string;
/** a11y label for the hidden PIN input. */
a11yPin: string;
/** a11y label for the digit-box tap target. */
a11yPinEntry: string;
};
export type FieldPinBrand = {
/** Wordmark strap (already tracked-uppercase in the rendered DOM). */
wordmark: string;
/** Brand color used for the primary button, active digit ring, etc. */
primary?: string;
/** Domain icon shown inside the brand mark. */
icon: FieldPinIcon;
};
type Props = {
/** Called when the user submits a `length`-digit PIN. May throw. */
onSubmit: (pin: string) => Promise<void>;
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
length?: 4 | 5 | 6 | 7 | 8;
brand: FieldPinBrand;
translations: FieldPinTranslations;
/**
* If a parent has a separate "PIN wrong" result path (e.g. server
* returned `{ ok: false }` instead of throwing), it can dispatch
* a DOM CustomEvent of this name to trigger the same shake + reset
* the input gets on a thrown submit error.
*/
shakeEventName?: string;
};
// ── Brand mark ────────────────────────────────────────────────────────────
const ICONS: Record<FieldPinIcon, (p: IconProps) => React.JSX.Element> = {
droplet: Droplet,
clock: Clock,
};
function BrandMark({ icon, primary = "#14532D" }: { icon: FieldPinIcon; primary?: string }) {
const Icon = ICONS[icon];
return (
<div
className="mb-7 flex justify-center"
aria-hidden
>
<div
className="flex h-20 w-20 items-center justify-center rounded-[22px]"
style={{
background:
"linear-gradient(160deg, #ffffff 0%, #f0f7f2 60%, #e1efe5 100%)",
boxShadow:
"0 1px 0 rgba(255,255,255,0.9) inset, 0 6px 16px rgba(20,83,45,0.18), 0 1px 3px rgba(0,0,0,0.06)",
}}
>
<Icon size={32} className="" style={{ color: primary }} />
</div>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────
export function FieldPinScreen({
onSubmit,
length = 4,
brand,
translations: t,
shakeEventName = "field-pin-shake",
}: Props) {
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [shake, setShake] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const timer = window.setTimeout(
() => inputRef.current?.focus(),
80,
);
return () => window.clearTimeout(timer);
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value.replace(/\D/g, "").slice(0, length);
setPin(next);
if (error) setError(null);
},
[error, length],
);
const resetForFailure = useCallback(() => {
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
inputRef.current?.focus();
}, []);
const submit = useCallback(
async (e?: FormEvent) => {
e?.preventDefault();
if (pin.length !== length || submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(pin);
// Don't reset here — the parent will navigate away.
} catch (err) {
setError(err instanceof Error ? err.message : t.genericError);
resetForFailure();
} finally {
setSubmitting(false);
}
},
[pin, length, submitting, onSubmit, t.genericError, resetForFailure],
);
// External shake trigger (parent fires this when verify returns false).
useEffect(() => {
function onShake() {
resetForFailure();
}
window.addEventListener(shakeEventName, onShake);
return () => window.removeEventListener(shakeEventName, onShake);
}, [resetForFailure, shakeEventName]);
const ready = pin.length === length && !submitting;
const primary = brand.primary ?? "#14532D";
return (
// `min-h-[100dvh]` covers the viewport regardless of parent layout
// — the parent wraps us in a fixed-inset-0 container on the
// Time Tracking route, but we don't depend on that to fill.
// `100dvh` excludes iOS Safari's collapsing chrome; the legacy
// `100vh` fallback keeps Android/older browsers full-bleed too.
<div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]">
{/* ── Branded header ───────────────────────────────────────── */}
<header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
}}
>
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
{(() => {
const HeaderIcon = ICONS[brand.icon];
return <HeaderIcon size={18} style={{ color: primary }} />;
})()}
<span
className="text-[12px] font-bold tracking-[0.22em]"
style={{
color: primary,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{brand.wordmark}
</span>
</div>
</header>
{/* ── Centered content ─────────────────────────────────────── */}
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-[max(env(safe-area-inset-bottom),1.25rem)]">
<div className="w-full max-w-sm">
<BrandMark icon={brand.icon} primary={primary} />
<h1
className="mb-2 text-center text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{t.title}
</h1>
<p
className={[
"mb-9 min-h-[20px] text-center text-[15px]",
error ? "text-[#FF3B30]" : "text-[rgba(60,60,67,0.6)]",
].join(" ")}
role={error ? "alert" : undefined}
aria-live={error ? "assertive" : undefined}
>
{error ?? t.caption}
</p>
{/* ── Digit boxes ────────────────────────────────────── */}
<form onSubmit={submit}>
<input
ref={inputRef}
type="password"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={length}
value={pin}
onChange={handleChange}
className="sr-only"
aria-label={t.a11yPin}
disabled={submitting}
/>
<button
type="button"
onClick={() => inputRef.current?.focus()}
aria-label={t.a11yPinEntry}
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
style={
shake
? {
animation:
"field-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97)",
}
: undefined
}
>
{Array.from({ length }).map((_, i) => {
const filled = i < pin.length;
const active = i === pin.length;
return (
<div
key={i}
className="relative flex h-[68px] w-[58px] items-center justify-center rounded-[14px] bg-white text-[34px] font-bold leading-none text-[#1d1d1f]"
style={{
boxShadow: active
? `0 0 0 2px ${primary}, 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(20,83,45,0.18)`
: "0 0 0 1px rgba(60,60,67,0.18), 0 1px 2px rgba(0,0,0,0.04)",
transition: "box-shadow 180ms cubic-bezier(0.32,0.72,0,1)",
}}
>
{filled ? (
<span className="relative" aria-hidden>
<span
className="block h-[14px] w-[14px] rounded-full"
style={{ background: "#1d1d1f" }}
/>
</span>
) : null}
{active && !filled && (
<span
aria-hidden
className="block h-[28px] w-[2px] rounded-full"
style={{
background: primary,
animation: "field-blink 1.05s steps(2, start) infinite",
}}
/>
)}
</div>
);
})}
</button>
<button type="submit" className="sr-only" tabIndex={-1}>
{t.unlock}
</button>
</form>
{/* ── Unlock button ─────────────────────────────────── */}
<button
type="button"
onClick={() => submit()}
disabled={!ready}
className={[
"flex h-[58px] w-full items-center justify-center gap-2 rounded-[14px] text-[17px] font-semibold",
"transition-[transform,background-color,opacity] duration-200",
"active:scale-[0.985]",
ready
? "text-white shadow-[0_6px_18px_rgba(20,83,45,0.28),inset_0_1px_0_rgba(255,255,255,0.16)]"
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
].join(" ")}
style={ready ? { background: primary } : undefined}
>
{submitting ? (
<>
<Spinner
size={20}
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
{t.verifying}
</>
) : (
<>{t.unlock}</>
)}
</button>
{/* Footer hint */}
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
{t.forgot}
</p>
</div>
</main>
</div>
);
}
export default FieldPinScreen;
+16
View File
@@ -0,0 +1,16 @@
/**
* Field-domain icons (re-exports the SF-Symbol-style primitives from
* the water icon set, plus the shared `IconProps` type). This module
* is the canonical import path for shared field app icons so that
* `FieldPinScreen` and any future shared field components don't have
* to reach into `@/components/water/mobile/icons`.
*
* The actual icon definitions still live in
* `@/components/water/mobile/icons` because they're shared by both
* the water and time-tracking field apps, and moving them would
* touch too many callers in one go. When the next field app shows
* up, it should import from this barrel.
*/
export type { IconProps } from "@/components/water/mobile/icons";
export { Droplet, Clock, Spinner } from "@/components/water/mobile/icons";
File diff suppressed because it is too large Load Diff
+30 -279
View File
@@ -1,297 +1,48 @@
"use client"; "use client";
/** /**
* MobileWaterPinScreen — PIN entry screen. * MobileWaterPinScreen — water-side wrapper around the shared
* `FieldPinScreen`. Same brand color, water-specific icon and
* wordmark, and translations lifted from the existing Lang context.
* *
* Visual structure (Apple HIG): * Cycle 11 (visual cohesion) lifted the actual rendering code into
* - Minimal nav bar with "TUXEDO WATER LOG" branding (system * `src/components/field/FieldPinScreen.tsx` so Time Tracking can use
* small-caps via tracking). * the same component with a clock icon + a different wordmark.
* - Centered droplet + lock icon pair as a brand anchor. * Behavior is byte-identical to the previous implementation; only
* - Headline ("Enter PIN") + supporting caption. * the rendering primitive changed.
* - Four large digit boxes (64pt square) that mirror the value of
* a hidden <input>. Tapping the row focuses the hidden input —
* the native numeric keyboard rises from the bottom.
* - Full-width primary "Unlock" button (HIG success blue).
* - Inline error pill replaces the caption on failure, with a
* gentle shake.
*
* The hidden input is the source of truth so iOS's QuickType /
* password-manager suggestions work correctly — we never rebuild
* the digit boxes from a synthetic onChange.
*/ */
import {
useCallback, import { FieldPinScreen } from "@/components/field/FieldPinScreen";
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import { Droplet, Lock, Spinner } from "./icons";
import { useLang } from "@/lib/water-log/lang-context"; import { useLang } from "@/lib/water-log/lang-context";
type Props = { type Props = {
/** Called when the user submits a 4-8 digit PIN. */
onSubmit: (pin: string) => Promise<void>; onSubmit: (pin: string) => Promise<void>;
/** PIN length, matches `PIN_MIN`/`PIN_MAX` from water-log-pin. */
length?: 4 | 5 | 6 | 7 | 8; length?: 4 | 5 | 6 | 7 | 8;
}; };
export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) { export function MobileWaterPinScreen({ onSubmit, length = 4 }: Props) {
const { t } = useLang(); const { t } = useLang();
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [shake, setShake] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus the hidden input when the screen mounts so the
// keyboard rises immediately. iOS sometimes needs a tiny delay
// for the focus to take after the page transition.
useEffect(() => {
const t = window.setTimeout(() => inputRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
// Strip non-digits, clamp to `length`.
const next = e.target.value.replace(/\D/g, "").slice(0, length);
setPin(next);
if (error) setError(null);
},
[error, length],
);
const submit = useCallback(
async (e?: FormEvent) => {
e?.preventDefault();
if (pin.length !== length || submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(pin);
// Don't reset here — the parent will navigate away.
} catch (err) {
// Network / unexpected error
setError(
err instanceof Error ? err.message : t.pin.genericError,
);
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
} finally {
setSubmitting(false);
}
},
[pin, length, submitting, onSubmit, t.pin.genericError],
);
const onFailureShake = useCallback(() => {
setShake(true);
window.setTimeout(() => setShake(false), 420);
setPin("");
inputRef.current?.focus();
}, []);
// Expose the shake trigger via a custom event so the parent can
// fire it after a failed verify (parent owns the verify call).
useEffect(() => {
function onShake() {
onFailureShake();
}
window.addEventListener("water-pin-shake", onShake);
return () => window.removeEventListener("water-pin-shake", onShake);
}, [onFailureShake]);
return ( return (
<div className="flex h-full flex-col bg-[#F2F2F7]"> <FieldPinScreen
{/* ── Branded header ───────────────────────────────────────── */} onSubmit={onSubmit}
<header length={length}
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]" brand={{
style={{ wordmark: t.brand.waterLog,
background: icon: "droplet",
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)", primary: "#14532D",
backdropFilter: "blur(10px)", }}
WebkitBackdropFilter: "blur(10px)", translations={{
}} title: t.pin.title,
> caption: t.pin.caption,
<div className="mx-auto flex max-w-md items-center justify-center gap-2"> unlock: t.pin.unlock,
<Droplet size={18} className="text-[#14532d]" /> verifying: t.pin.verifying,
<span forgot: t.pin.forgot,
className="text-[12px] font-bold tracking-[0.22em] text-[#14532d]" genericError: t.pin.genericError,
style={{ a11yPin: t.pin.a11yPin,
fontFamily: a11yPinEntry: t.pin.a11yPinEntry,
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif", }}
}} shakeEventName="water-pin-shake"
> />
{t.brand.waterLog}
</span>
</div>
</header>
{/* ── Centered content ─────────────────────────────────────── */}
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-[max(env(safe-area-inset-bottom),1.25rem)]">
<div className="w-full max-w-sm">
{/* Brand mark */}
<div className="mb-7 flex justify-center">
<div
className="flex h-20 w-20 items-center justify-center rounded-[22px]"
style={{
background:
"linear-gradient(160deg, #ffffff 0%, #f0f7f2 60%, #e1efe5 100%)",
boxShadow:
"0 1px 0 rgba(255,255,255,0.9) inset, 0 6px 16px rgba(20,83,45,0.18), 0 1px 3px rgba(0,0,0,0.06)",
}}
>
<Lock size={32} className="text-[#14532d]" />
</div>
</div>
<h1
className="mb-2 text-center text-[28px] font-bold leading-[1.15] tracking-tight text-[#1d1d1f]"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif",
}}
>
{t.pin.title}
</h1>
{/* Caption OR inline error */}
<p
className={[
"mb-9 min-h-[20px] text-center text-[15px]",
error ? "text-[#FF3B30]" : "text-[rgba(60,60,67,0.6)]",
].join(" ")}
role={error ? "alert" : undefined}
aria-live={error ? "assertive" : undefined}
>
{error ?? t.pin.caption}
</p>
{/* ── Digit boxes ────────────────────────────────────── */}
{/* The hidden input is the source of truth and gives us
* native iOS numeric keyboard + autofill. The visible
* boxes mirror its value. */}
<form onSubmit={submit}>
<input
ref={inputRef}
type="password"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={length}
value={pin}
onChange={handleChange}
// Keep the hidden input in the layout flow so its
// keyboard rises correctly. Visually hidden via the
// standard "sr-only" technique.
className="sr-only"
aria-label={t.pin.a11yPin}
disabled={submitting}
/>
<button
type="button"
onClick={() => inputRef.current?.focus()}
aria-label={t.pin.a11yPinEntry}
className="mx-auto mb-8 flex w-full justify-center gap-3 outline-none"
>
{Array.from({ length }).map((_, i) => {
const filled = i < pin.length;
const active = i === pin.length;
return (
<div
key={i}
className="relative flex h-[68px] w-[58px] items-center justify-center rounded-[14px] bg-white text-[34px] font-bold leading-none text-[#1d1d1f]"
style={{
boxShadow: active
? "0 0 0 2px #14532d, 0 1px 2px rgba(0,0,0,0.04), 0 4px 14px rgba(20,83,45,0.18)"
: "0 0 0 1px rgba(60,60,67,0.18), 0 1px 2px rgba(0,0,0,0.04)",
transition: "box-shadow 180ms cubic-bezier(0.32,0.72,0,1)",
}}
>
{filled ? (
<span className="relative">
{/* Solid dot — never reveals the digit back */}
<span
aria-hidden
className="block h-[14px] w-[14px] rounded-full bg-[#1d1d1f]"
/>
</span>
) : null}
{/* Caret blink on the active box (iOS-style). */}
{active && !filled && (
<span
aria-hidden
className="block h-[28px] w-[2px] rounded-full bg-[#14532d]"
style={{
animation: "water-blink 1.05s steps(2, start) infinite",
}}
/>
)}
</div>
);
})}
</button>
{/* Hidden submit so Enter from the keyboard triggers Unlock */}
<button type="submit" className="sr-only" tabIndex={-1}>
{t.pin.unlock}
</button>
</form>
{/* ── Unlock button ─────────────────────────────────── */}
<button
type="button"
onClick={() => submit()}
disabled={pin.length !== length || submitting}
className={[
"flex h-[58px] w-full items-center justify-center gap-2 rounded-[14px] text-[17px] font-semibold",
"transition-[transform,background-color,opacity] duration-200",
"active:scale-[0.985]",
pin.length === length && !submitting
? "bg-[#14532d] text-white shadow-[0_6px_18px_rgba(20,83,45,0.28),inset_0_1px_0_rgba(255,255,255,0.16)]"
: "bg-[rgba(120,120,128,0.16)] text-[rgba(60,60,67,0.4)]",
].join(" ")}
style={{
// Pressable feedback — springy scale.
transform: shake ? "translateX(0)" : undefined,
}}
>
{submitting ? (
<>
<Spinner
size={20}
className="text-white"
style={{ animation: "spin 0.8s linear infinite" }}
/>
{t.pin.verifying}
</>
) : (
<>{t.pin.unlock}</>
)}
</button>
{/* Footer hint */}
<p className="mt-7 text-center text-[12px] text-[rgba(60,60,67,0.45)]">
{t.pin.forgot}
</p>
</div>
</main>
{/* Apply the shake via inline animation when `shake` is true.
* The `water-shake` keyframe lives in `src/app/globals.css`
* alongside the rest of the Water Log animation set. */}
{shake && (
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-1/2 z-50 mx-auto max-w-sm"
style={{
animation: "water-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97)",
}}
/>
)}
</div>
); );
} }
@@ -14,8 +14,10 @@
* - "Back to Headgates" (white secondary with thin border) * - "Back to Headgates" (white secondary with thin border)
* - Subtle thank-you micro-copy at the bottom. * - Subtle thank-you micro-copy at the bottom.
* *
* The animations use the global `water-ring` + `water-draw` keyframes * The animations use the shared `field-ring` + `field-draw` keyframes
* from `src/app/globals.css`. * (declared in `src/app/globals.css`). Cycle 11 collapsed the legacy
* `water-*` names so this screen and the Time Tracking clocked-out
* screen share the same reveal choreography.
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Grid, Refresh } from "./icons"; import { Grid, Refresh } from "./icons";
@@ -119,7 +121,7 @@ export function MobileWaterSuccessScreen({
strokeDashoffset="264" strokeDashoffset="264"
style={{ style={{
animation: animation:
"water-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards", "field-ring 420ms cubic-bezier(0.32, 0.72, 0, 1) forwards",
["--ring-from" as string]: "264", ["--ring-from" as string]: "264",
}} }}
/> />
@@ -135,7 +137,7 @@ export function MobileWaterSuccessScreen({
strokeDashoffset="62" strokeDashoffset="62"
style={{ style={{
animation: animation:
"water-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards", "field-draw 320ms cubic-bezier(0.32, 0.72, 0, 1) 280ms forwards",
["--draw-from" as string]: "62", ["--draw-from" as string]: "62",
}} }}
/> />
+7
View File
@@ -7,10 +7,17 @@
* mobile bundle. All icons use 1.75px strokes (close to SF's * mobile bundle. All icons use 1.75px strokes (close to SF's
* "medium" weight at this size) and rounded line caps for the soft, * "medium" weight at this size) and rounded line caps for the soft,
* pen-friendly feel HIG calls for. * pen-friendly feel HIG calls for.
*
* Note: the `IconProps` type is re-exported from
* `@/components/field/icons` (cycle 11). The type itself is defined
* here for backward compat with older call sites.
*/ */
import type { SVGProps } from "react"; import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement> & { size?: number }; type IconProps = SVGProps<SVGSVGElement> & { size?: number };
// Exported for FieldPinScreen etc. — same SF-Symbol-style primitives
// are reused across both the water and the time-tracking field apps.
export type { IconProps };
function base({ size = 22, strokeWidth = 1.75, ...rest }: IconProps) { function base({ size = 22, strokeWidth = 1.75, ...rest }: IconProps) {
return { return {