diff --git a/src/app/globals.css b/src/app/globals.css
index 98fefef..2d9bcee 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -302,16 +302,34 @@ select:-webkit-autofill:focus {
to { transform: rotate(360deg); }
}
-/* Mobile Water Log — 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 water-blink {
+/* The water success screen used `water-draw`/`water-ring`; the Time
+ * Tracking Clocked Out screen used inline `strokeDashoffset` transitions
+ * + a single `field-draw`. Cycle 11 collapses everything into shared
+ * `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; }
}
-/* Mobile Water Log — PIN-entry error shake. Three diminishing
- * oscillations for a "no, that wasn't right" feel. */
-@keyframes water-shake {
+/* Mobile Field — PIN-entry error shake. Three diminishing oscillations
+ * for a "no, that wasn't right" feel. The shake is applied directly
+ * 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); }
20% { transform: translateX(-10px); }
40% { transform: translateX(10px); }
@@ -319,19 +337,6 @@ select:-webkit-autofill:focus {
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. */
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
diff --git a/src/components/field/FieldPinScreen.tsx b/src/components/field/FieldPinScreen.tsx
new file mode 100644
index 0000000..06dffb9
--- /dev/null
+++ b/src/components/field/FieldPinScreen.tsx
@@ -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 . 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;
+ /** 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 React.JSX.Element> = {
+ droplet: Droplet,
+ clock: Clock,
+};
+
+function BrandMark({ icon, primary = "#14532D" }: { icon: FieldPinIcon; primary?: string }) {
+ const Icon = ICONS[icon];
+ return (
+
-
- {/* ── Digit boxes ────────────────────────────────────── */}
- {/* The hidden input is the source of truth and gives us
- * native iOS numeric keyboard + autofill. The visible
- * boxes mirror its value. */}
-
-
- {/* ── Unlock button ─────────────────────────────────── */}
-
-
- {/* Footer hint */}
-
- {t.pin.forgot}
-
-
-
-
- {/* 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 && (
-
- )}
-
+
);
}
-export default MobileWaterPinScreen;
\ No newline at end of file
+export default MobileWaterPinScreen;
diff --git a/src/components/water/mobile/SuccessScreen.tsx b/src/components/water/mobile/SuccessScreen.tsx
index 17700e5..5a146eb 100644
--- a/src/components/water/mobile/SuccessScreen.tsx
+++ b/src/components/water/mobile/SuccessScreen.tsx
@@ -14,8 +14,10 @@
* - "Back to Headgates" (white secondary with thin border)
* - Subtle thank-you micro-copy at the bottom.
*
- * The animations use the global `water-ring` + `water-draw` keyframes
- * from `src/app/globals.css`.
+ * The animations use the shared `field-ring` + `field-draw` keyframes
+ * (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 { Grid, Refresh } from "./icons";
@@ -119,7 +121,7 @@ export function MobileWaterSuccessScreen({
strokeDashoffset="264"
style={{
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",
}}
/>
@@ -135,7 +137,7 @@ export function MobileWaterSuccessScreen({
strokeDashoffset="62"
style={{
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",
}}
/>
diff --git a/src/components/water/mobile/icons.tsx b/src/components/water/mobile/icons.tsx
index e965c50..56ee642 100644
--- a/src/components/water/mobile/icons.tsx
+++ b/src/components/water/mobile/icons.tsx
@@ -7,10 +7,17 @@
* mobile bundle. All icons use 1.75px strokes (close to SF's
* "medium" weight at this size) and rounded line caps for the soft,
* 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";
type IconProps = SVGProps & { 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) {
return {