# 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.