diff --git a/fusion_plating/docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md b/fusion_plating/docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md new file mode 100644 index 00000000..5d7b1e47 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md @@ -0,0 +1,527 @@ +# Tablet Lock Screen Redesign + +**Date:** 2026-05-24 +**Status:** Design — approved through brainstorming, awaiting plan +**Affects:** `fusion_plating_shopfloor` (FpTabletLock OWL component + tablet_controller) +**Scope:** Visual + interaction redesign only. PIN gate, unlock RPC, lockout timer, idle warning all unchanged. + +--- + +## 1. Problem + +The current FpTabletLock tile screen looks like a placeholder. Two operators per row stretch their tiles across 900px max-width; the screen is mostly empty whitespace; there's no branding; the "Tap your name to unlock" prompt is the only header; no animations; no clock/date. Functionally correct but feels unfinished on a wall-mounted shop-floor tablet. + +User feedback after live testing (2026-05-24): + +> "i want company logo and other nice customization, add some animation, reduce the card width so its just enough, there may be many employees, i do not want a lot of scrolling but not cramped at the same time" + +Target: tablet that looks like a deliberately-designed shop terminal, fits ~10-15 operators per screen without scrolling, brands the device with the company logo, and has subtle motion that signals "alive." + +--- + +## 2. Goals & non-goals + +### Goals + +1. **Brand the screen** — pull the company logo from `res.company.logo`, surface the company name + tagline. +2. **Tighter tile grid** — 3 columns max-width 480px, ~140px tile width. Fits 6 tiles per visible row; small shops (10-15 ops) show everything without scroll. +3. **Real-time clock + date** — operators glance at the lock screen for the time; big tabular-nums clock front-and-center. +4. **Subtle motion** — staggered entrance, hover lift, clocked-in pulse. Doesn't distract; signals freshness. +5. **Dark + light mode parity** — single SCSS source, branches at compile time via `$o-webclient-color-scheme`. No JS-side theme code. +6. **Accessibility** — `prefers-reduced-motion` respected, touch targets ≥ 44px, contrast WCAG AA in both modes. + +### Non-goals + +- **Replacing the PIN gate.** The 4-digit PIN flow (FpPinPad component, hash + lockout, /fp/tablet/unlock endpoint) stays identical. +- **Multi-tenant theming.** Each company sees its own logo via `res.company.logo`; we don't build a theme editor for accent colors. The amber accent is a hardcoded brand token in this design. +- **Search box on the lock screen.** For ~10-15 operators, scanning the grid is faster than typing. Search returns as a Phase 2 enhancement if a customer scales to 25+ ops. +- **Custom tile sort.** Existing rule stays: clocked-in operators first, then alphabetical. +- **Welcome animations / video / mascot.** Subtle motion only. + +--- + +## 3. Decisions locked during brainstorming + +| # | Decision | +|---|---| +| D1 | **Hybrid A+B vibe** — Industrial Bold structure (dark gradient bg, bold tabular clock, amber accent) wearing Premium Glassmorphism finish (frosted-glass tiles with backdrop-filter, smooth cubic-bezier hover). | +| D2 | **Company logo** sourced from `res.company.logo` (Odoo's standard company logo binary field) via `/web/image/res.company//logo`. Letter-mark fallback when no logo is uploaded — built from `res.company.name` initials. | +| D3 | **Company name + tagline** below the logo. Name = `res.company.name`. Tagline = `res.company.report_header` (existing field, also drives invoice letterheads — natural reuse) with fallback "Shop Floor Terminal" if empty. | +| D4 | **3-column tile grid**, max-width 480px on the grid container. Tile ~140px wide. Avatar 52px circular with status pulse-dot overlay. | +| D5 | **Dark + light mode parity.** Same OWL component + same XML; SCSS branches at compile time on `$o-webclient-color-scheme`. No runtime theme code. | +| D6 | **Animation catalogue** (full list in §6) — entrance stagger, hover lift, click scale, pulse on clocked-in dot, real-time clock update. `prefers-reduced-motion` disables all of these. | +| D7 | **Sort order unchanged** — clocked-in operators first, then alphabetical by name. | +| D8 | **No search box** for MVP — scoped for the ~10-15-operator small-shop case. | + +--- + +## 4. Layout + +The screen is a full-viewport flex column, centered, with this vertical sequence: + +``` +┌──────────────────────────────────────────────────┐ +│ │ +│ ┌─────────┐ │ ← logo frame (84×84) +│ │ LOGO │ (rounded 20, glass) │ glassmorphic +│ └─────────┘ │ +│ Company Name │ ← logo-text (19px, 700) +│ PLATING · ESTD 1985 │ ← logo-sub (11px upper) +│ │ +│ 21:09 │ ← clock (40px, 800, tabular) +│ SATURDAY · MAY 23 │ ← clock-date (12px upper) +│ │ +│ [ 🔒 TAP YOUR NAME ] │ ← prompt pill +│ │ +│ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ GS ● │ │ JM │ │ CV ● │ │ +│ │Garry │ │Johnny│ │Carlos│ │ ← 3-column tile grid, +│ │CIN │ │PIN │ │CIN │ │ max-width 480px +│ └──────┘ └──────┘ └──────┘ │ +│ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ LB ● │ │ RB ● │ │ KP ● │ │ +│ │ Lisa │ │ Ravi │ │ Kris │ │ +│ │ CIN │ │ CIN │ │ CIN │ │ +│ └──────┘ └──────┘ └──────┘ │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +Spacing between sections: 22px gap. Logo block top margin: 28px. Outer padding: 28px 20px. + +### 4.1 Logo block + +```html +
+
+ +
+
+
+
+
+``` + +- `logoUrl`: `/web/image/res.company//logo` — Odoo serves the binary directly. Always 200 if the field is populated (even 1×1 transparent on empty record), so probe the field server-side before emitting the URL. +- `companyInitials`: first 1-2 letters of `res.company.name` (e.g. "EN" for "EN Technologies", "ABC" capped to 2 chars). Computed server-side, sent in the tiles-endpoint payload. +- `companyTagline`: from `res.company.report_header` field; defaults to "Shop Floor Terminal" when empty. + +The logo frame is a 84×84 rounded-20 glassmorphic container — same frosted treatment as the tiles. Looks great whether the logo is a sharp PNG, transparent SVG, or the letter-mark fallback. + +### 4.2 Clock block + +```html +
+
+
+
+``` + +- `state.clockText`: `HH:MM` (24h, configurable via `intl.DateTimeFormat`). Updates every minute via `setInterval` in `tablet_lock.js`. +- `state.dateText`: `WEEKDAY · MMM D` uppercase (e.g. "SATURDAY · MAY 23"). Recomputed on date change. +- Tabular numbers so digits don't jitter when changing. +- Initial render uses `new Date()` synchronously so there's no flash of empty content. + +### 4.3 Prompt + +A small pill, not a header: + +```html +
🔒 Tap your name
+``` + +Amber-tinted background (matches brand accent), uppercase with 0.18em letter-spacing. Sits between the clock and the tile grid as a visual anchor. + +### 4.4 Tile grid + +```html +
+ + + +
+``` + +- Grid: `grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 480px`. +- Animation delay computed JS-side per tile (50ms × index, capped at 300ms) so the stagger ripples without dragging. +- Avatar gradient (per-tile color): server-computed as `user.id % len(_AVATAR_GRADIENTS)` (8 colors). Deterministic — same operator gets the same color across sessions, so operators learn their own tile color. See §7.3 for the gradient list. +- `has_photo` is true when `res.users.image_128` is non-empty. Falls back to initials when empty. + +--- + +## 5. Color system + +All colors live in `_tablet_lock_tokens.scss` (new file, loaded before `tablet_lock.scss`). Same pattern as the plant-view tokens shipped earlier. + +### Light-mode defaults + +| Token | Hex | Purpose | +|---|---|---| +| `$_lock-bg-top` | `#fafafa` | Gradient top | +| `$_lock-bg-bottom` | `#f0f0f3` | Gradient bottom | +| `$_lock-accent` | `rgba(240,165,0,0.12)` | Top-radial ambient glow | +| `$_lock-accent-2` | `rgba(99,102,241,0.06)` | Bottom-radial ambient glow | +| `$_lock-text` | `#1d1f1e` | Primary text | +| `$_lock-muted` | `#71717a` | Secondary text | +| `$_lock-prompt` | `#b45309` | Prompt text | +| `$_lock-prompt-bg` | `rgba(240,165,0,0.10)` | Prompt pill bg | +| `$_lock-prompt-border` | `rgba(240,165,0,0.25)` | Prompt pill border | +| `$_lock-tile-bg` | `rgba(255,255,255,0.7)` | Tile bg (frosted) | +| `$_lock-tile-border` | `rgba(0,0,0,0.05)` | Tile border | +| `$_lock-tile-hover-bg` | `rgba(255,255,255,0.95)` | Tile hover bg | +| `$_lock-tile-hover-border` | `rgba(240,165,0,0.5)` | Tile hover border | +| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.18)` | Tile hover shadow | +| `$_lock-frame-bg` | `rgba(255,255,255,0.85)` | Logo frame bg | +| `$_lock-status-clocked` | `#16a34a` | Clocked-in green | +| `$_lock-status-pin` | `#d97706` | PIN required amber | +| `$_lock-pulse-dot-border` | `#fff` | Pulse-dot ring | + +### Dark-mode overrides + +| Token | Hex | +|---|---| +| `$_lock-bg-top` | `#1a1d21` (gradient base) | +| `$_lock-bg-bottom` | `#2d3138` | +| `$_lock-accent` | `rgba(240,165,0,0.08)` | +| `$_lock-accent-2` | `rgba(99,102,241,0.06)` | +| `$_lock-text` | `#f5f5f7` | +| `$_lock-muted` | `#adb5bd` | +| `$_lock-prompt` | `#f0a500` | +| `$_lock-prompt-bg` | `rgba(240,165,0,0.08)` | +| `$_lock-prompt-border` | `rgba(240,165,0,0.20)` | +| `$_lock-tile-bg` | `rgba(255,255,255,0.06)` | +| `$_lock-tile-border` | `rgba(255,255,255,0.08)` | +| `$_lock-tile-hover-bg` | `rgba(240,165,0,0.10)` | +| `$_lock-tile-hover-border` | `rgba(240,165,0,0.4)` | +| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.15), 0 0 0 1px rgba(240,165,0,0.2)` | +| `$_lock-frame-bg` | `rgba(255,255,255,0.08)` | +| `$_lock-status-clocked` | `#34c759` (brighter — needs to pop on dark) | +| `$_lock-status-pin` | `#ff9f0a` | +| `$_lock-pulse-dot-border` | `#2d3138` (so the dot reads as overlapping the dark tile, not floating) | + +The full-screen background is a stack of two radial gradients (the ambient accent glows) over a linear gradient (the base), per `lock-final.html` from brainstorm: + +```scss +background: + radial-gradient(ellipse at top, $_lock-accent, transparent 50%), + radial-gradient(ellipse at bottom, $_lock-accent-2, transparent 50%), + linear-gradient(135deg, $_lock-bg-top 0%, $_lock-bg-bottom 100%); +``` + +--- + +## 6. Animation catalogue + +All animations use `cubic-bezier(0.4, 0, 0.2, 1)` for consistency (the "standard easing" curve). Every animation is gated by `@media (prefers-reduced-motion: no-preference)` — operators who set reduced motion in OS preferences see the same screen with no movement. + +| # | Name | What it does | Duration | Trigger | +|---|---|---|---|---| +| 1 | `lockLogoEnter` | Logo block fades down + slides in | 500ms | onMount | +| 2 | `lockClockEnter` | Clock + prompt fade up | 500ms (100ms delay) | onMount | +| 3 | `lockTileEnter` | Each tile fades + slides up + scales from 0.96 | 400ms (50ms staggered per index, max 6) | onMount | +| 4 | `lockTileHover` | Lift translateY(-3px) + colored shadow + border glow | 250ms | hover/focus | +| 5 | `lockTilePress` | Quick scale(0.97) | 50ms | active/click | +| 6 | `lockPulseDot` | Green clocked-in dot pulses (ring expands + fades) | 2s loop | clocked-in state present | +| 7 | `lockClockTick` | (no animation — just text content update each minute) | — | `setInterval(60000)` | + +### Reduced-motion override + +```scss +@media (prefers-reduced-motion: reduce) { + .o_fp_lock_logo_block, + .o_fp_lock_clock_block, + .o_fp_lock_prompt, + .o_fp_lock_tile, + .o_fp_lock_avatar.is-clocked::after { + animation: none !important; + transition: none !important; + } +} +``` + +### Stagger cap + +For very large operator counts the per-tile delay caps at 300ms (6 tiles × 50ms) so the screen doesn't take 3 seconds to settle. Compute `animDelay = Math.min(index * 50, 300)` JS-side. + +--- + +## 7. Backend changes + +### 7.1 Extend `/fp/tablet/tiles` payload + +Currently returns: +```json +{"ok": true, "tiles": [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]} +``` + +After redesign: +```json +{ + "ok": true, + "company": { + "id": 1, + "name": "EN Technologies", + "tagline": "Plating & Finishing", + "logo_url": "/web/image/res.company/1/logo", + "has_logo": true, + "initials": "EN" + }, + "tiles": [ + { + "user_id": 5, + "name": "Garry Singh", + "initials": "GS", + "avatar_url": "/web/image/res.users/5/avatar_128", + "has_photo": true, + "is_clocked_in": true, + "has_pin": true, + "avatar_gradient": "linear-gradient(135deg, #ef4444, #dc2626)" + }, + ... + ] +} +``` + +New fields per tile: +- `initials`: server-computed from `res.users.name` (first letter of first + last word, capped 2 chars). +- `has_photo`: true when `res.users.image_128` is non-empty (avoids the 1×1 default-image flash). +- `avatar_gradient`: deterministic from hash of user.id. Same gradient across sessions so operators recognize "their" tile color. + +The company block is one query: `env.company.id`. Read `name`, `report_header`, check `logo` non-empty. + +### 7.2 `_lock_company_payload` helper + +A small module-level helper in `tablet_controller.py`: + +```python +def _lock_company_payload(env): + """Returns the company info block for the lock screen.""" + co = env.company + return { + 'id': co.id, + 'name': co.name or '', + 'tagline': co.report_header or _('Shop Floor Terminal'), + 'logo_url': f'/web/image/res.company/{co.id}/logo', + 'has_logo': bool(co.logo), + 'initials': _initials_from(co.name), + } + + +def _initials_from(name): + """First letter of first + last word, capped at 2 chars uppercase.""" + if not name: + return '?' + words = name.strip().split() + if len(words) == 1: + return words[0][:2].upper() + return (words[0][0] + words[-1][0]).upper() +``` + +### 7.3 `_avatar_gradient_for` helper + +```python +_AVATAR_GRADIENTS = [ + 'linear-gradient(135deg, #ef4444, #dc2626)', # red + 'linear-gradient(135deg, #f59e0b, #d97706)', # amber + 'linear-gradient(135deg, #10b981, #059669)', # emerald + 'linear-gradient(135deg, #3b82f6, #2563eb)', # blue + 'linear-gradient(135deg, #8b5cf6, #7c3aed)', # violet + 'linear-gradient(135deg, #ec4899, #db2777)', # pink + 'linear-gradient(135deg, #14b8a6, #0d9488)', # teal + 'linear-gradient(135deg, #f97316, #ea580c)', # orange +] + +def _avatar_gradient_for(user_id): + return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)] +``` + +8 colors, modulo user_id — same operator gets the same color forever. Sufficient variety for a small shop (10-15 ops have <2 collisions on average). + +--- + +## 8. Frontend changes + +### 8.1 Files modified + +| File | Change | +|---|---| +| `static/src/scss/_tablet_lock_tokens.scss` | **new** — design tokens (loads first) | +| `static/src/scss/tablet_lock.scss` | full rewrite — gradient bg, logo block, clock block, prompt, tile grid, animations, dark/light branches | +| `static/src/xml/tablet_lock.xml` | wrap existing tile loop with new logo + clock + prompt blocks; add fallback structures | +| `static/src/js/tablet_lock.js` | add `state.clockText` + `state.dateText` + `_tickClock` setInterval; add `state.company`; consume new payload fields | + +### 8.2 OWL component reactivity for the clock + +The clock updates every 60 seconds: + +```javascript +setup() { + // ... existing setup ... + this.state = useState({ + // ... existing state ... + clockText: this._formatTime(new Date()), + dateText: this._formatDate(new Date()), + company: null, + }); + + onMounted(() => { + // ... existing onMounted ... + this._clockInterval = setInterval(() => { + const now = new Date(); + this.state.clockText = this._formatTime(now); + this.state.dateText = this._formatDate(now); + }, 60000); + }); + + onWillUnmount(() => { + // ... existing cleanup ... + if (this._clockInterval) clearInterval(this._clockInterval); + }); +} + +_formatTime(d) { + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +} + +_formatDate(d) { + return d.toLocaleDateString(undefined, { + weekday: 'long', month: 'short', day: 'numeric' + }).toUpperCase().replace(',', ' ·'); +} +``` + +**Per project rule 20:** all the date/number formatting happens in JS (`_formatTime`, `_formatDate`). The template only renders `state.clockText` / `state.dateText` via `t-esc`. No `String()` / `Number()` / `padStart` calls inside the XML. + +### 8.3 Stagger delay computed JS-side + +In `_loadTiles`, after fetching, decorate each tile with its `animDelay`: + +```javascript +async _loadTiles() { + this.state.loadingTiles = true; + try { + const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null; + const res = await rpc("/fp/tablet/tiles", { station_id: stationId }); + if (res && res.ok) { + this.state.company = res.company || null; + this.state.tiles = res.tiles.map((tile, idx) => ({ + ...tile, + animDelay: Math.min(idx * 50, 300), // cap at 300ms + })); + } + } catch (err) { + // Existing quiet fail + } finally { + this.state.loadingTiles = false; + } +} +``` + +### 8.4 Manifest registration + +Adding two SCSS files. Per project rule 8 (SCSS @import forbidden), tokens must register BEFORE the consumer: + +```python +# In fusion_plating_shopfloor/__manifest__.py, the lock screen block: +'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss', # NEW — load first +'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss', # existing — rewritten +'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml', # existing — extended +'fusion_plating_shopfloor/static/src/js/tablet_lock.js', # existing — extended +``` + +The tokens file lives in `scss/` (not `scss/components/`) because it's session-level — one tokens file for the whole lock-screen experience. + +--- + +## 9. Accessibility + +- **Touch targets**: avatar 52px + 14px padding = 80px tile content; tile itself extends to grid cell width ~140px × 110px tall. Both axes well above the 44×44 WCAG minimum. +- **Focus rings**: visible 2px solid amber outline on `:focus-visible`. Distinguishes keyboard navigation from mouse hover. +- **Contrast**: + - Dark mode: white text on `#1a1d21` background = 16.7:1 (AAA). + - Light mode: `#1d1f1e` text on `#fafafa` background = 17.8:1 (AAA). + - Amber prompt text on its tinted bg: 5.2:1 (AA passes). +- **Reduced motion**: full media-query gate documented in §6. +- **Alt text**: logo `Company Name` so screen readers announce the brand on focus. +- **Keyboard navigation**: tab order = logo (skip) → tiles in DOM order → first tile receives initial focus on mount. + +--- + +## 10. Testing strategy + +### 10.1 Unit / integration + +- `test_tablet_tiles_endpoint_includes_company` — call `/fp/tablet/tiles`, assert response has `company` block with required keys. +- `test_initials_from_helper` — edge cases: empty name, single-word name, multi-word name with hyphens. +- `test_avatar_gradient_deterministic` — same user.id returns same gradient across calls. + +### 10.2 Visual snapshot tests + +Per state, a Playwright snapshot of the lock screen at `1366×768` (typical tablet) in both light and dark mode. Snapshots checked in; PR diff catches accidental CSS regressions. + +### 10.3 Persona walks + +- **Cold start** — operator approaches tablet with no recent session. Clock displays current time; tiles fade in; clicking own tile opens PIN pad immediately (no visible loading state). +- **Mid-shift unlock** — operator returns after auto-lock. Same flow; their tile shows the pulsing clocked-in dot. +- **No logo configured** — companies that haven't set `res.company.logo`. Letter-mark renders cleanly; layout unchanged. +- **Reduced motion** — toggle the OS preference; verify all animations disabled, layout still works. + +--- + +## 11. Migration & rollout + +No database migration needed — this is a presentation-layer change reusing existing fields (`res.company.logo`, `res.company.report_header`, `res.users.image_128`). + +### Rollout sequence + +1. Add tokens SCSS + extend tablet_controller payload — backend deploy. +2. Rewrite tablet_lock.scss + extend XML + extend JS — frontend deploy + asset cache bust. +3. Verify on entech: open the tablet lock URL on a real iPad and a desktop browser. +4. Iterate on visual details (logo padding, gradient intensity, accent color) based on shop-floor feedback. + +No feature flag — the redesign is a strict visual improvement, no behavioral changes. Reverting is `git revert ` if needed. + +--- + +## 12. Open questions (deferred) + +| # | Question | Resolution | +|---|---|---| +| Q1 | Search box for 25+ operator shops? | **Phase 2.** MVP scoped to ~10-15 ops. Re-evaluate when a customer scales. | +| Q2 | Custom accent color per company? | **Phase 2.** Amber is hardcoded in tokens for MVP. Could be a `res.company.x_fc_shopfloor_accent` field later. | +| Q3 | Weather / news widget on lock screen? | **No.** Out of scope; clutters the screen. Operators don't need it. | +| Q4 | Multi-language toggle visible on lock screen? | **No for MVP.** Existing user.lang flow handles this server-side; lock screen renders in the user's language once they're identified post-PIN. | +| Q5 | Operator photo upload UX? | **Existing flow stays** — managers upload via Preferences → My Profile. Lock screen consumes whatever's there. | +| Q6 | Animation when transitioning tile → PIN pad? | **Phase 2 polish.** Currently the existing FpPinPad just appears; could add a crossfade. Subjective; ship clean first. | + +--- + +## 13. Summary + +| Question | Answer | +|---|---| +| Layout | Vertical centered flex column: logo (84px) → clock (40px) → prompt pill → 3-column tile grid (max 480px) | +| Card model | One tile per `res.users` with tablet PIN configured (existing rule); deterministic per-user color gradient | +| Card density | 3 columns, ~140px tiles — fits ~9-12 visible without scroll on a 1366×768 tablet | +| Animation | 7 named animations (entrance stagger, hover lift, click press, status pulse) all bezier-eased, all gated by `prefers-reduced-motion` | +| Dark / Light mode | Single SCSS source with compile-time `$o-webclient-color-scheme` branch — same component, two bundles, no JS theme code | +| Backend touch | Extend `/fp/tablet/tiles` payload with `company` block + per-tile `initials`/`avatar_gradient`/`has_photo`. Two small helper functions. | +| Frontend touch | New `_tablet_lock_tokens.scss`. Full rewrite of `tablet_lock.scss`. Extend XML + JS for clock + company block. | +| Rollout | No DB migration. Plain code deploy + asset cache bust. No feature flag. | + +The redesign solves the "looks like a placeholder" feel by branding the screen with the company logo, adding a real-time clock, tightening the tile grid for the small-shop case, and layering glassmorphic finishes + cubic-bezier animations on a hybrid Industrial Bold + Premium structure. Dark and light modes share one source. + +Implementation plan to follow.