# Tablet Lock Screen Redesign — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Redesign the FpTabletLock OWL component's tile screen with company branding, real-time clock, glassmorphic tiles, dual dark/light mode, and a 7-animation catalogue per the spec at `docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md`. **Architecture:** No DB migration. Extend the existing `/fp/tablet/tiles` endpoint payload with a `company` block + per-tile metadata (initials, gradient, has_photo). Rewrite `tablet_lock.scss` with compile-time dark/light branches via `$o-webclient-color-scheme`. New `_tablet_lock_tokens.scss` partial loads first per project rule 8. Extend XML and JS with logo, clock, and prompt blocks; existing PIN gate / lockout / unlock RPC unchanged. **Tech Stack:** Odoo 19 (Python 3.11, OWL 2), JSONRPC, PostgreSQL, native odoo on entech (LXC 111 on pve-worker5). SCSS bundle pipeline auto-compiles for both light and dark. --- ## Critical patterns to follow Already documented in `K:/Github/Odoo-Modules/fusion_plating/CLAUDE.md` — re-stating so the implementer doesn't context-switch: - **OWL template scope (rule 20):** only `Math` is exposed as a JS global. NEVER call `String(x)`, `Number(x)`, `padStart(x)`, `toLocaleDateString()`, etc. directly inside `t-on-click` / `t-att-*` / `t-out` expressions. The clock and date formatting MUST happen in JS-side methods; templates just `t-esc` the prepared strings. - **SCSS @import forbidden (rule 8):** every SCSS file is registered separately in the manifest. The new `_tablet_lock_tokens.scss` MUST appear BEFORE `tablet_lock.scss` in `web.assets_backend` so the `$_lock-*` tokens are visible to the consumer. - **Dark mode at compile time:** branch via `@if $o-webclient-color-scheme == dark { ... }` with `!global` token overrides. No JS-side theme code. No `.o_dark_mode` class selectors. No `@media (prefers-color-scheme)`. - **End every git commit with:** `Co-Authored-By: Claude Opus 4.7 (1M context) ` - **Existing PIN flow stays:** FpPinPad component, unlock RPC, lockout, idle warning are out of scope. Don't touch them. --- ## File structure map ### Backend | File | Change | Why | |---|---|---| | `fusion_plating_shopfloor/controllers/tablet_controller.py` | modify | Add 3 module-level helpers (`_initials_from`, `_avatar_gradient_for`, `_lock_company_payload`) and extend the `/fp/tablet/tiles` route to emit company block + per-tile metadata | | `fusion_plating_shopfloor/tests/test_tablet_lock_payload.py` | **create** | Cover the new payload shape + helper edge cases | | `fusion_plating_shopfloor/__manifest__.py` | modify | Bump version to `19.0.32.0.0`, register the new tokens SCSS BEFORE the consumer scss | ### Frontend | File | Change | Why | |---|---|---| | `fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss` | **create** | Design tokens (light defaults + dark overrides) per spec §5; loads first | | `fusion_plating_shopfloor/static/src/scss/tablet_lock.scss` | full rewrite | New visual structure + 7 animations + reduced-motion gate + dark/light parity | | `fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` | modify | Wrap existing tile loop with new logo + clock + prompt blocks; tile inner shape updated | | `fusion_plating_shopfloor/static/src/js/tablet_lock.js` | modify | Add `state.clockText`, `state.dateText`, `state.company`, `_tickClock` interval, `_formatTime` / `_formatDate` helpers, `animDelay` decoration in `_loadTiles` | --- ## Task 1 — Backend: helpers + extended tiles payload **Files:** - Modify: `fusion_plating_shopfloor/controllers/tablet_controller.py` (the `tiles()` route around line 144, see Step 1) - Create: `fusion_plating_shopfloor/tests/test_tablet_lock_payload.py` - [ ] **Step 1: Locate the existing route + endpoint helpers** ```bash grep -n "^def \|@http.route.*tiles\|def tiles" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py | head ``` Confirm the existing `tiles()` route lives between lines ~140 and ~194. The new module-level helpers go above the controller class. - [ ] **Step 2: Write the failing test first** Create `fusion_plating_shopfloor/tests/test_tablet_lock_payload.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase from odoo.addons.fusion_plating_shopfloor.controllers.tablet_controller import ( _initials_from, _avatar_gradient_for, _AVATAR_GRADIENTS, ) class TestInitialsFrom(TransactionCase): def test_empty_returns_question_mark(self): self.assertEqual(_initials_from(''), '?') self.assertEqual(_initials_from(None), '?') def test_single_word_returns_first_two_chars_upper(self): self.assertEqual(_initials_from('Garry'), 'GA') self.assertEqual(_initials_from('ab'), 'AB') self.assertEqual(_initials_from('z'), 'Z') def test_multi_word_returns_first_and_last_initial(self): self.assertEqual(_initials_from('Garry Singh'), 'GS') self.assertEqual(_initials_from('Johnny Matloub'), 'JM') self.assertEqual(_initials_from('Mary Anne Smith'), 'MS') self.assertEqual(_initials_from('EN Technologies'), 'ET') def test_extra_whitespace_handled(self): self.assertEqual(_initials_from(' Garry Singh '), 'GS') self.assertEqual(_initials_from('Garry\tSingh'), 'GS') class TestAvatarGradientFor(TransactionCase): def test_deterministic_per_user_id(self): # Same id → same gradient across calls self.assertEqual( _avatar_gradient_for(5), _avatar_gradient_for(5), ) def test_modulo_distribution(self): # Wrapping wraps cleanly: id 0 and id len(gradients) get the same colour n = len(_AVATAR_GRADIENTS) self.assertEqual(_avatar_gradient_for(0), _avatar_gradient_for(n)) self.assertEqual(_avatar_gradient_for(3), _avatar_gradient_for(n + 3)) def test_returns_a_known_gradient(self): # Every output is one of the documented gradients for uid in range(50): self.assertIn(_avatar_gradient_for(uid), _AVATAR_GRADIENTS) class TestTilesPayload(TransactionCase): """End-to-end test of the /fp/tablet/tiles endpoint payload shape. Doesn't drive HTTP — calls the controller method directly via a light request stand-in. """ def test_payload_has_company_block(self): from odoo.addons.fusion_plating_shopfloor.controllers.tablet_controller import ( _lock_company_payload, ) payload = _lock_company_payload(self.env) self.assertIn('id', payload) self.assertIn('name', payload) self.assertIn('tagline', payload) self.assertIn('logo_url', payload) self.assertIn('has_logo', payload) self.assertIn('initials', payload) self.assertTrue(payload['logo_url'].startswith('/web/image/res.company/')) self.assertEqual(payload['id'], self.env.company.id) def test_tagline_default_when_empty_report_header(self): from odoo.addons.fusion_plating_shopfloor.controllers.tablet_controller import ( _lock_company_payload, ) # Force report_header empty for this test self.env.company.report_header = False payload = _lock_company_payload(self.env) # When empty, falls back to the i18n-friendly default. self.assertTrue(payload['tagline']) self.assertNotEqual(payload['tagline'], False) ``` - [ ] **Step 3: Run tests — expect ImportError** ```bash ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-enable --stop-after-init --log-level=test -u fusion_plating_shopfloor --test-tags /fusion_plating_shopfloor:TestInitialsFrom,/fusion_plating_shopfloor:TestAvatarGradientFor,/fusion_plating_shopfloor:TestTilesPayload' 2>&1 | tail -30" ``` Expected: failures with `ImportError: cannot import name '_initials_from' from ...`. - [ ] **Step 4: Add the helpers + extend the route** At the top of `fusion_plating_shopfloor/controllers/tablet_controller.py`, after the existing imports (around line 10), add: ```python # ===== 2026-05-24 lock-screen redesign helpers ========================= _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 _initials_from(name): """First letter of first + last word, capped at 2 chars uppercase. Single-word names return their first two chars. Empty returns '?'. """ if not name: return '?' words = name.strip().split() if not words: return '?' if len(words) == 1: return words[0][:2].upper() return (words[0][0] + words[-1][0]).upper() def _avatar_gradient_for(user_id): """Deterministic gradient per user id. Same operator gets the same color across sessions so they learn their tile.""" return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)] def _lock_company_payload(env): """Returns the company info block for the lock screen. Reuses res.company.report_header as the tagline (the same field used by invoice letterheads) with a sensible fallback when empty. """ 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), } ``` Then in the `tiles()` route body, replace the per-user dict to include the new fields, and add the company block to the return: ```python tiles = [] for u, u_sudo in zip(users_sorted, users_sudo): tiles.append({ 'user_id': u.id, 'name': u.name, 'initials': _initials_from(u.name), 'avatar_url': f'/web/image/res.users/{u.id}/avatar_128', 'has_photo': bool(u_sudo.image_128), 'avatar_gradient': _avatar_gradient_for(u.id), 'is_clocked_in': u.id in clocked_ids, 'has_pin': bool(u_sudo.x_fc_tablet_pin_hash), }) # Clocked-in first, then alphabetical within bucket tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name'])) return { 'ok': True, 'company': _lock_company_payload(request.env), 'tiles': tiles, } ``` - [ ] **Step 5: Add the test file to the tests package init (if explicit)** ```bash ls K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/tests/__init__.py 2>/dev/null && cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/tests/__init__.py ``` If the file exists and explicitly imports each test module, add `from . import test_tablet_lock_payload`. If it uses a glob or doesn't exist, create it with that single import: ```python # fusion_plating_shopfloor/tests/__init__.py from . import test_tablet_lock_payload ``` - [ ] **Step 6: Deploy + run tests — expect PASS** ```bash cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_shopfloor/controllers/tablet_controller.py'" cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_lock_payload.py" | ssh pve-worker5 "pct exec 111 -- bash -c 'mkdir -p /mnt/extra-addons/custom/fusion_plating_shopfloor/tests && cat > /mnt/extra-addons/custom/fusion_plating_shopfloor/tests/test_tablet_lock_payload.py'" ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-enable --stop-after-init --log-level=test -u fusion_plating_shopfloor --test-tags /fusion_plating_shopfloor:TestInitialsFrom,/fusion_plating_shopfloor:TestAvatarGradientFor,/fusion_plating_shopfloor:TestTilesPayload' 2>&1 | tail -25" ``` Expected: all tests pass. - [ ] **Step 7: Commit** ```bash cd K:/Github/Odoo-Modules/fusion_plating && git add fusion_plating_shopfloor/controllers/tablet_controller.py fusion_plating_shopfloor/tests/ && git commit -m "feat(shopfloor): extend /fp/tablet/tiles payload with company block Adds 3 module-level helpers in tablet_controller.py: _initials_from(name) — first/last initials for letter-mark fallback _avatar_gradient_for(uid) — deterministic per-user color gradient _lock_company_payload(env) — company name + tagline + logo URL Endpoint /fp/tablet/tiles now returns: {ok, company:{id,name,tagline,logo_url,has_logo,initials}, tiles:[{user_id, name, initials, avatar_url, has_photo, avatar_gradient, is_clocked_in, has_pin}, ...]} 8 avatar gradients keyed by user.id modulo — operators learn their own color. has_photo is sudo-read of res.users.image_128 (avoids the 1×1 default-image flash on the frontend). Tagline reuses res.company.report_header (the invoice-letterhead field) so no new model field; defaults to 'Shop Floor Terminal'. Tested with 3 test classes covering initials edge cases, gradient determinism, and payload shape. Part of the 2026-05-24 tablet lock-screen redesign (spec at docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md). Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ## Task 2 — SCSS design tokens **Files:** - Create: `fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss` - [ ] **Step 1: Create the tokens file** ```scss // ===================================================================== // Tablet lock-screen design tokens (2026-05-24 redesign). // MUST load before tablet_lock.scss. Per project rule 8 (SCSS @import // forbidden in Odoo 19 custom code), the manifest registers each SCSS // file separately; ordering IS the variable scope. // ===================================================================== $o-webclient-color-scheme: bright !default; // === Light-mode defaults === $_lock-bg-top-hex: #fafafa; $_lock-bg-bottom-hex: #f0f0f3; $_lock-accent-1-rgba: rgba(240,165,0,0.12); $_lock-accent-2-rgba: rgba(99,102,241,0.06); $_lock-text-hex: #1d1f1e; $_lock-muted-hex: #71717a; $_lock-prompt-hex: #b45309; $_lock-prompt-bg-rgba: rgba(240,165,0,0.10); $_lock-prompt-border-rgba: rgba(240,165,0,0.25); $_lock-tile-bg-rgba: rgba(255,255,255,0.7); $_lock-tile-border-rgba: rgba(0,0,0,0.05); $_lock-tile-hover-bg-rgba: rgba(255,255,255,0.95); $_lock-tile-hover-border-rgba: rgba(240,165,0,0.5); $_lock-tile-hover-shadow: (0 12px 24px rgba(240,165,0,0.18)); $_lock-frame-bg-rgba: rgba(255,255,255,0.85); $_lock-frame-border-rgba: rgba(0,0,0,0.05); $_lock-frame-shadow: (0 8px 24px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8)); $_lock-status-clocked-hex: #16a34a; $_lock-status-pin-hex: #d97706; $_lock-pulse-dot-border-hex: #ffffff; @if $o-webclient-color-scheme == dark { $_lock-bg-top-hex: #1a1d21 !global; $_lock-bg-bottom-hex: #2d3138 !global; $_lock-accent-1-rgba: rgba(240,165,0,0.08) !global; $_lock-accent-2-rgba: rgba(99,102,241,0.06) !global; $_lock-text-hex: #f5f5f7 !global; $_lock-muted-hex: #adb5bd !global; $_lock-prompt-hex: #f0a500 !global; $_lock-prompt-bg-rgba: rgba(240,165,0,0.08) !global; $_lock-prompt-border-rgba: rgba(240,165,0,0.20) !global; $_lock-tile-bg-rgba: rgba(255,255,255,0.06) !global; $_lock-tile-border-rgba: rgba(255,255,255,0.08) !global; $_lock-tile-hover-bg-rgba: rgba(240,165,0,0.10) !global; $_lock-tile-hover-border-rgba: rgba(240,165,0,0.4) !global; $_lock-tile-hover-shadow: (0 12px 24px rgba(240,165,0,0.15), 0 0 0 1px rgba(240,165,0,0.2)) !global; $_lock-frame-bg-rgba: rgba(255,255,255,0.08) !global; $_lock-frame-border-rgba: rgba(255,255,255,0.10) !global; $_lock-frame-shadow: (0 8px 24px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08)) !global; $_lock-status-clocked-hex: #34c759 !global; $_lock-status-pin-hex: #ff9f0a !global; $_lock-pulse-dot-border-hex: #2d3138 !global; } // === CSS-custom-property wrappers so future themes can override === $lock-bg-top: var(--fp-lock-bg-top, $_lock-bg-top-hex); $lock-bg-bottom: var(--fp-lock-bg-bottom, $_lock-bg-bottom-hex); $lock-accent-1: $_lock-accent-1-rgba; $lock-accent-2: $_lock-accent-2-rgba; $lock-text: var(--fp-lock-text, $_lock-text-hex); $lock-muted: var(--fp-lock-muted, $_lock-muted-hex); $lock-prompt: var(--fp-lock-prompt, $_lock-prompt-hex); $lock-prompt-bg: $_lock-prompt-bg-rgba; $lock-prompt-border: $_lock-prompt-border-rgba; $lock-tile-bg: $_lock-tile-bg-rgba; $lock-tile-border: $_lock-tile-border-rgba; $lock-tile-hover-bg: $_lock-tile-hover-bg-rgba; $lock-tile-hover-border: $_lock-tile-hover-border-rgba; $lock-tile-hover-shadow: $_lock-tile-hover-shadow; $lock-frame-bg: $_lock-frame-bg-rgba; $lock-frame-border: $_lock-frame-border-rgba; $lock-frame-shadow: $_lock-frame-shadow; $lock-status-clocked: var(--fp-lock-status-clocked, $_lock-status-clocked-hex); $lock-status-pin: var(--fp-lock-status-pin, $_lock-status-pin-hex); $lock-pulse-dot-border: $_lock-pulse-dot-border-hex; ``` - [ ] **Step 2: Commit (tokens land in one commit, registered in manifest in Task 6)** ```bash git add fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss && git commit -m "feat(shopfloor): tablet lock-screen design tokens Light defaults + dark overrides via \$o-webclient-color-scheme compile-time branch (project rule for Odoo 19 dark mode — no class selectors / media queries). Wrapped in CSS custom properties for future per-tenant theming. Per project rule 8 this file MUST load before tablet_lock.scss in the manifest's web.assets_backend list. Manifest update lands with the SCSS rewrite in a later commit. Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ## Task 3 — SCSS full rewrite **Files:** - Modify: `fusion_plating_shopfloor/static/src/scss/tablet_lock.scss` (replace entirely) - [ ] **Step 1: Replace the file** The new file is ~210 lines. Replace contents entirely: ```scss // ===================================================================== // FpTabletLock — lock screen with tile grid + PIN pad overlay // 2026-05-24 redesign: hybrid Industrial Bold + Premium Glassmorphism // Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md // Depends on _tablet_lock_tokens.scss being loaded first. // ===================================================================== .o_fp_tablet_lock { position: fixed; inset: 0; color: $lock-text; display: flex; flex-direction: column; align-items: center; padding: 28px 20px; gap: 22px; z-index: 9000; overflow-y: auto; background: radial-gradient(ellipse at top, $lock-accent-1, transparent 50%), radial-gradient(ellipse at bottom, $lock-accent-2, transparent 50%), linear-gradient(135deg, $lock-bg-top 0%, $lock-bg-bottom 100%); } // === Logo block ===================================================== .o_fp_lock_logo_block { text-align: center; animation: lockLogoEnter 0.5s ease-out; } .o_fp_lock_logo_frame { display: inline-flex; align-items: center; justify-content: center; width: 84px; height: 84px; border-radius: 20px; margin-bottom: 12px; padding: 14px; box-sizing: border-box; background: $lock-frame-bg; border: 1px solid $lock-frame-border; box-shadow: $lock-frame-shadow; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); img { max-width: 100%; max-height: 100%; object-fit: contain; } } .o_fp_lock_logo_placeholder { width: 100%; height: 100%; border-radius: 14px; background: linear-gradient(135deg, #f0a500, #ff6b00); display: inline-flex; align-items: center; justify-content: center; font-size: 32px; font-weight: 900; color: #1a1d21; } .o_fp_lock_logo_text { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; color: $lock-text; } .o_fp_lock_logo_sub { font-size: 11px; text-transform: uppercase; letter-spacing: 0.15em; margin-top: 4px; color: $lock-muted; } // === Clock block ==================================================== .o_fp_lock_clock_block { text-align: center; font-variant-numeric: tabular-nums; animation: lockClockEnter 0.5s ease-out 0.1s both; } .o_fp_lock_clock { font-size: 40px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; color: $lock-text; } .o_fp_lock_clock_date { font-size: 12px; text-transform: uppercase; letter-spacing: 0.12em; margin-top: 4px; color: $lock-muted; } // === Prompt pill ==================================================== .o_fp_lock_prompt { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.18em; color: $lock-prompt; display: inline-flex; align-items: center; gap: 8px; padding: 6px 16px; border-radius: 999px; background: $lock-prompt-bg; border: 1px solid $lock-prompt-border; animation: lockClockEnter 0.5s ease-out 0.2s both; } // === Tile grid ====================================================== .o_fp_lock_tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; width: 100%; max-width: 480px; } .o_fp_lock_tile { background: $lock-tile-bg; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid $lock-tile-border; border-radius: 14px; padding: 14px 8px 12px; text-align: center; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); cursor: pointer; animation: lockTileEnter 0.4s cubic-bezier(0.4, 0, 0.2, 1) both; box-shadow: 0 1px 3px rgba(0,0,0,0.04); color: inherit; &:hover, &:focus-visible { background: $lock-tile-hover-bg; border-color: $lock-tile-hover-border; transform: translateY(-3px); box-shadow: $lock-tile-hover-shadow; outline: none; } &:focus-visible { outline: 2px solid $lock-tile-hover-border; outline-offset: 2px; } &:active { transform: scale(0.97); transition: transform 0.05s; } } .o_fp_lock_avatar { width: 52px; height: 52px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 21px; font-weight: 700; color: #fff; margin-bottom: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); position: relative; overflow: hidden; img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } &.is-clocked::after { content: ''; position: absolute; width: 12px; height: 12px; border-radius: 50%; background: $lock-status-clocked; bottom: 0; right: 0; border: 2px solid $lock-pulse-dot-border; animation: lockPulseDot 2s ease-in-out infinite; } } .o_fp_lock_name { font-size: 12px; font-weight: 600; line-height: 1.3; color: $lock-text; } .o_fp_lock_status { font-size: 10px; margin-top: 4px; font-weight: 500; &.status-clocked { color: $lock-status-clocked; } &.status-pin { color: $lock-status-pin; } } // === Empty / loading states ========================================= .o_fp_lock_loading, .o_fp_lock_empty { margin: 2rem auto; color: $lock-muted; font-size: 14px; } // === PIN pad wrap =================================================== .o_fp_lock_pinwrap { margin-top: 8px; animation: lockClockEnter 0.3s ease-out; } // === Animations ===================================================== @keyframes lockLogoEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } @keyframes lockClockEnter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @keyframes lockTileEnter { from { opacity: 0; transform: translateY(16px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes lockPulseDot { 0%, 100% { box-shadow: 0 0 0 0 rgba(52,199,89,0.6); } 50% { box-shadow: 0 0 0 6px rgba(52,199,89,0); } } // === Reduced-motion override (accessibility) ======================== @media (prefers-reduced-motion: reduce) { .o_fp_tablet_lock, .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, .o_fp_lock_pinwrap { animation: none !important; transition: none !important; } } ``` - [ ] **Step 2: Commit (will compile after manifest update in Task 6)** ```bash git add fusion_plating_shopfloor/static/src/scss/tablet_lock.scss && git commit -m "feat(shopfloor): rewrite tablet_lock.scss for the 2026-05-24 redesign Full rewrite per spec §4–§6: - Full-viewport gradient bg (two radial ambient glows over linear) - Glassmorphic logo frame (84px rounded-20, backdrop-blur) - 40px tabular-nums clock + uppercase tracked date - Amber prompt pill - 3-column tile grid (max 480px) with frosted-glass tiles - 52px circular avatars with status pulse-dot overlay - 4 entrance keyframes + hover lift + click press + clocked-in pulse - prefers-reduced-motion override disables all animation + transition Dark / light parity via the new _tablet_lock_tokens.scss compile-time branch (project rule). No JS-side theme code. Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ## Task 4 — XML template extension **Files:** - Modify: `fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` (replace contents) - [ ] **Step 1: Replace the file** ```xml
Tap your name
Loading…
No operators configured.
``` **Template scope check (project rule 20):** No `String()` / `Number()` / `parseInt()` calls inside `t-on-click`, `t-att-*`, or `t-out`. The two computed values — `tileStyle(tile)` for the animation-delay inline style and `avatarClass(tile)` for the clocked-in modifier — are getter methods on the component (defined in Task 5). Both return strings, so the template just `t-att-style`s / `t-att-class`es them directly. - [ ] **Step 2: Commit** ```bash git add fusion_plating_shopfloor/static/src/xml/tablet_lock.xml && git commit -m "feat(shopfloor): extend tablet_lock.xml with logo, clock, prompt blocks Wraps the existing tile loop with three new blocks per spec §4: - .o_fp_lock_logo_block — company logo + name + tagline - .o_fp_lock_clock_block — HH:MM clock + date - .o_fp_lock_prompt — amber pill 'Tap your name' (hidden while PIN pad is open via !state.selectedTileUserId guard) Tile inner structure updated: - Avatar background uses the per-tile gradient string from server - Falls back to initials when has_photo is False - is_clocked_in adds the .is-clocked modifier (drives the SCSS pulse-dot overlay) All formatting (clock text, date, animation delay, modifier classes) is computed in JS-side getter methods per project rule 20 — templates just render strings via t-esc / t-att-*. Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ## Task 5 — JS extensions: clock + company state + helpers **Files:** - Modify: `fusion_plating_shopfloor/static/src/js/tablet_lock.js` - [ ] **Step 1: Read the current file to confirm structure** ```bash grep -n "useState\|setup()\|onMounted\|onWillUnmount\|_loadTiles" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js | head ``` Confirm `setup()` has the `useState({...})` block; `onMounted` exists; `_loadTiles` is around line 67. - [ ] **Step 2: Extend `setup()` — add clock and company state** In `setup()`, change the `useState` block to include three new keys: ```javascript this.state = useState({ tiles: [], selectedTileUserId: null, idleSecondsRemaining: null, loadingTiles: false, // 2026-05-24 redesign — clock + company branding clockText: this._formatTime(new Date()), dateText: this._formatDate(new Date()), company: null, }); ``` Note `clockText` / `dateText` are seeded synchronously so there's no flash of empty content on first render. - [ ] **Step 3: Extend `onMounted` — start the clock tick interval** In the existing `onMounted(async () => { ... })` block, add a tick interval AFTER the existing setup: ```javascript onMounted(async () => { await this._loadTiles(); this._tick = setInterval(() => this._checkIdle(), 1000); // Heartbeat ping every 60s — for forensic visibility this._ping = setInterval(() => { if (this.techStore.currentTechId) { rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId }) .catch(() => {}); } }, 60000); // Clock tick — update visible HH:MM and date label every 60s. // 60s is enough; minute boundaries align naturally. this._clockInterval = setInterval(() => { const now = new Date(); this.state.clockText = this._formatTime(now); this.state.dateText = this._formatDate(now); }, 60000); }); ``` - [ ] **Step 4: Extend `onWillUnmount` — clear the new interval** ```javascript onWillUnmount(() => { if (this._tick) clearInterval(this._tick); if (this._ping) clearInterval(this._ping); if (this._clockInterval) clearInterval(this._clockInterval); }); ``` - [ ] **Step 5: Replace `_loadTiles` to consume the new payload** ```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; // Decorate each tile with an animation-delay (50ms staggered, // capped at 300ms so the screen doesn't take 3s to settle on // shops with 20+ operators). this.state.tiles = (res.tiles || []).map((tile, idx) => ({ ...tile, animDelay: Math.min(idx * 50, 300), })); } } catch (err) { // Quiet fail — tile grid stays empty; user gets prompted } finally { this.state.loadingTiles = false; } } ``` - [ ] **Step 6: Add the formatting + getter methods at the bottom of the class** ```javascript // === 2026-05-24 redesign helpers ==================================== _formatTime(d) { // 24-hour HH:MM with leading zeros. Per project rule 20 this MUST // live in JS, not in the template (padStart isn't in OWL scope). const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); return hh + ':' + mm; } _formatDate(d) { // 'SATURDAY · MAY 23' style label. Uses Intl for locale-correct // weekday + month abbreviations, then upcases for visual tracking. const weekday = d.toLocaleDateString(undefined, { weekday: 'long' }); const month = d.toLocaleDateString(undefined, { month: 'short' }); const day = d.getDate(); return (weekday + ' · ' + month + ' ' + day).toUpperCase(); } tileStyle(tile) { // Inline animation-delay so each tile's entrance staggers. // Returned as a string per project rule 20 — templates can't call // String() inside t-att-* expressions. return 'animation-delay: ' + tile.animDelay + 'ms'; } avatarClass(tile) { return tile.is_clocked_in ? 'o_fp_lock_avatar is-clocked' : 'o_fp_lock_avatar'; } ``` - [ ] **Step 7: Commit** ```bash git add fusion_plating_shopfloor/static/src/js/tablet_lock.js && git commit -m "feat(shopfloor): tablet_lock.js — clock + company + tile-decoration Adds three new state keys (clockText, dateText, company) seeded synchronously in setup() so the first render shows the real time and company info — no flash of empty content. onMounted starts a 60s setInterval that re-formats time + date. onWillUnmount clears it. Minute-boundary precision is enough — the clock isn't a seconds display. _loadTiles now reads res.company from the extended endpoint, and decorates each tile with a staggered animDelay (Math.min(idx * 50, 300)) so the entrance ripple settles in ~300ms even with 20+ tiles. Four new helpers — _formatTime, _formatDate, tileStyle(tile), avatarClass(tile) — all live in JS per project rule 20 (templates only get Math as a global; String/Number/padStart fail with 'v2 is not a function' at click time). Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ## Task 6 — Manifest registration + deploy + verify **Files:** - Modify: `fusion_plating_shopfloor/__manifest__.py` - [ ] **Step 1: Find the lock-screen SCSS block in the manifest** ```bash grep -n "tablet_lock\|tablet_lock.scss" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/__manifest__.py ``` Confirm the existing line is `'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss'` (around line 98 from earlier readings). - [ ] **Step 2: Insert the tokens file BEFORE `tablet_lock.scss`** Replace: ```python 'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss', 'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml', 'fusion_plating_shopfloor/static/src/js/components/idle_warning.js', 'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss', ``` With: ```python 'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss', 'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml', 'fusion_plating_shopfloor/static/src/js/components/idle_warning.js', # 2026-05-24 lock-screen redesign — tokens MUST precede tablet_lock.scss 'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss', 'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss', ``` - [ ] **Step 3: Bump the manifest version** Change `'version': '19.0.31.0.0'` → `'version': '19.0.32.0.0'`. - [ ] **Step 4: Deploy all changed files to entech** ```bash cd K:/Github/Odoo-Modules/fusion_plating && for f in \ fusion_plating_shopfloor/__manifest__.py \ fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss \ fusion_plating_shopfloor/static/src/scss/tablet_lock.scss \ fusion_plating_shopfloor/static/src/xml/tablet_lock.xml \ fusion_plating_shopfloor/static/src/js/tablet_lock.js \ fusion_plating_shopfloor/controllers/tablet_controller.py ; do cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/$f'" done && echo "All files copied." ``` - [ ] **Step 5: Upgrade the module + bust the asset cache** ```bash ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_shopfloor --stop-after-init\" 2>&1 | grep -iE \"(ERROR|loaded fusion_plating_shopfloor|Registry loaded)\" | tail -8 && systemctl start odoo && systemctl is-active odoo'" ssh pve-worker5 'pct exec 111 -- su - postgres -c "psql -d admin -c \"DELETE FROM ir_attachment WHERE url LIKE '"'"'/web/assets/%'"'"';\""' ``` Expected: "Module fusion_plating_shopfloor loaded ... active". A small number of asset attachments deleted (3-10). - [ ] **Step 6: Verify the endpoint returns the new payload shape** ```bash echo "from odoo.addons.fusion_plating_shopfloor.controllers.tablet_controller import _lock_company_payload, _initials_from, _avatar_gradient_for co = _lock_company_payload(env) print('=== Company payload ===') print(co) print() print('=== Sample initials ===') print('Garry Singh:', _initials_from('Garry Singh')) print('EN Technologies:', _initials_from('EN Technologies')) print() print('=== Sample gradient ===') print('uid=5:', _avatar_gradient_for(5))" | ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http' 2>&1" | grep -E "^(Company|uid|Garry|EN|===|{)" | head -20 ``` Expected: - Company payload prints with `id`, `name`, `tagline`, `logo_url`, `has_logo`, `initials` keys. - `_initials_from('Garry Singh')` → `'GS'`. - `_avatar_gradient_for(5)` → one of the 8 documented gradients. - [ ] **Step 7: Smoke-test in a browser** Open `https://enplating.com` in a browser. The Plating app should land on the Shop Floor plant kanban. Log out (or use Hand Off) to trigger the lock screen. Verify: - Company logo / letter-mark appears centered top - Company name + tagline below it - Real-time clock + date - Amber "Tap your name" pill - Tiles laid out 3 per row with frosted-glass styling - Hover on a tile lifts it + glows amber - Clocked-in tiles show a pulsing green dot on the avatar - Hard-refresh once (Ctrl+Shift+R) to clear browser cache - [ ] **Step 8: Commit the manifest + final tag** ```bash cd K:/Github/Odoo-Modules/fusion_plating && git add fusion_plating_shopfloor/__manifest__.py && git commit -m "chore(shopfloor): register _tablet_lock_tokens.scss + bump to 19.0.32.0.0 Final commit for the 2026-05-24 tablet lock-screen redesign. Registers the new tokens partial in the manifest's web.assets_backend list BEFORE tablet_lock.scss — per project rule 8, SCSS file order in the manifest IS the variable scope (no @import allowed in Odoo 19 custom SCSS). Verified end-to-end on entech: - Module loaded clean on -u - Endpoint /fp/tablet/tiles returns the new company block + per- tile initials/gradient/has_photo fields - 3 helper unit tests pass - Lock screen renders with company branding, clock, glassmorphic tiles, animations in both dark and light bundles - Asset cache cleared so the new bundles compile fresh Closes the 6-task plan in docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md Co-Authored-By: Claude Opus 4.7 (1M context) " ``` --- ## Self-review **1. Spec coverage** — Cross-checking spec sections against the 6 tasks: | Spec section | Implementing task | |---|---| | §3 D1 — Hybrid A+B vibe | Task 3 (SCSS sets gradient bg + glassmorphic tiles + bezier hover) | | §3 D2 — Company logo from res.company.logo + letter-mark fallback | Task 1 (`_lock_company_payload` + `_initials_from`) + Task 4 (XML conditionally renders `` or placeholder) | | §3 D3 — Tagline from res.company.report_header | Task 1 (`tagline = co.report_header or 'Shop Floor Terminal'`) | | §3 D4 — 3-column tile grid max 480px | Task 3 (`.o_fp_lock_tiles { grid-template-columns: repeat(3, 1fr); max-width: 480px }`) | | §3 D5 — Dark + light parity via compile-time branch | Task 2 (tokens with `@if $o-webclient-color-scheme == dark` overrides) | | §3 D6 — 7-animation catalogue + reduced-motion gate | Task 3 (4 `@keyframes` + hover/active transitions + `prefers-reduced-motion: reduce` override) | | §3 D7 — Clocked-in first, then alphabetical | Existing behaviour preserved (Task 1 doesn't change the sort) | | §3 D8 — No search box | Out of scope (correctly not in any task) | | §4 Layout | Tasks 3 + 4 implement the vertical structure | | §5 Color tokens | Task 2 | | §6 Animation catalogue + reduced-motion gate | Task 3 | | §7.1 Extended payload | Task 1 | | §7.2 `_lock_company_payload` helper | Task 1 | | §7.3 `_avatar_gradient_for` helper | Task 1 (`_AVATAR_GRADIENTS` + the function) | | §8.1 Files modified table | Tasks 1–6 each handle one or two files | | §8.2 Clock interval setup/teardown | Task 5 (Step 3 + Step 4) | | §8.3 animDelay computation | Task 5 (Step 5) | | §8.4 Manifest registration order | Task 6 | | §9 Accessibility (touch target, focus ring, contrast, alt text, reduced motion) | Task 3 (focus-visible outline + `prefers-reduced-motion`) + Task 4 (`state.company.name`) — note touch-target sizing is met by avatar 52px + tile padding (~140×110 px). | | §10 Testing strategy | Task 1 includes 3 unit-test classes covering initials, gradient determinism, and payload shape | | §11 Migration & rollout | Tasks 4-6 deploy via the existing `-u` pattern; no DB migration | | §12 Open questions | Correctly deferred — no task implements search, multi-tenant theming, weather widget, etc. | All spec sections map to at least one task. No gaps. **2. Placeholder scan** — Searched for `TBD`, `TODO`, `implement later`, `add appropriate`, `similar to Task`. None found. Every code block contains the actual content. **3. Type consistency**: - Function name `_initials_from` used the same way in Task 1's helpers, the test file, and Task 6's verification. - `_avatar_gradient_for` consistent. - State key names `clockText`, `dateText`, `company` consistent between Task 5's `useState`, the XML in Task 4, and the verification in Task 6. - CSS class name `o_fp_lock_*` prefix consistent across Tasks 3 and 4 (Task 3 defines, Task 4 references). - Endpoint payload field names (`avatar_gradient`, `has_photo`, `initials`, `is_clocked_in`, `has_pin`) consistent between Task 1's Python emitter and Task 4's XML consumer. No issues found. --- ## Execution Handoff Plan complete and saved to `docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md`. Two execution options: **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration **2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints Which approach?