Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md
gsinghpal 7a0bd67fc0 docs(shopfloor): implementation plan for tablet lock-screen redesign
6 tasks covering the visual + interaction redesign:

  Task 1 — Backend: 3 module-level helpers in tablet_controller.py
           (_initials_from, _avatar_gradient_for, _lock_company_payload)
           + extended /fp/tablet/tiles payload + 3 test classes (TDD)
  Task 2 — Create _tablet_lock_tokens.scss design tokens (light + dark
           branches via $o-webclient-color-scheme)
  Task 3 — Full rewrite of tablet_lock.scss (gradient bg, glassmorphic
           tiles, 4 entrance keyframes, hover lift, click press,
           clocked-in pulse, prefers-reduced-motion gate)
  Task 4 — Extend tablet_lock.xml with logo + clock + prompt blocks
           wrapping the existing tile loop
  Task 5 — Extend tablet_lock.js with state.clockText / state.dateText /
           state.company + setInterval clock tick + _formatTime /
           _formatDate / tileStyle / avatarClass helpers (all per
           project rule 20 — coercion lives in JS, not in templates)
  Task 6 — Register the new tokens SCSS in manifest BEFORE
           tablet_lock.scss (per rule 8), bump version 19.0.32.0.0,
           deploy + verify

Each task has TDD-style steps with full code blocks. Self-review
confirms 1-to-1 coverage of every spec section + correct deferral of
every §12 Phase 2 item.

Plan: 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) <noreply@anthropic.com>
2026-05-23 21:44:24 -04:00

1108 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) <noreply@anthropic.com>`
- **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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## Task 4 — XML template extension
**Files:**
- Modify: `fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` (replace contents)
- [ ] **Step 1: Replace the file**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.TabletLock">
<t t-if="isLocked">
<div class="o_fp_tablet_lock">
<!-- ============== LOGO BLOCK ============== -->
<div class="o_fp_lock_logo_block" t-if="state.company">
<div class="o_fp_lock_logo_frame">
<img t-if="state.company.has_logo"
t-att-src="state.company.logo_url"
t-att-alt="state.company.name"/>
<div t-else=""
class="o_fp_lock_logo_placeholder"
t-esc="state.company.initials"/>
</div>
<div class="o_fp_lock_logo_text" t-esc="state.company.name"/>
<div class="o_fp_lock_logo_sub" t-esc="state.company.tagline"/>
</div>
<!-- ============== CLOCK BLOCK ============== -->
<div class="o_fp_lock_clock_block">
<div class="o_fp_lock_clock" t-esc="state.clockText"/>
<div class="o_fp_lock_clock_date" t-esc="state.dateText"/>
</div>
<!-- ============== PROMPT PILL ============== -->
<div t-if="!state.selectedTileUserId" class="o_fp_lock_prompt">
<i class="fa fa-lock"/> Tap your name
</div>
<!-- ============== TILES OR PIN PAD ============== -->
<div t-if="state.loadingTiles" class="o_fp_lock_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<div t-elif="!state.selectedTileUserId" class="o_fp_lock_tiles">
<t t-if="!state.tiles.length">
<div class="o_fp_lock_empty">
No operators configured.
</div>
</t>
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
<button class="o_fp_lock_tile"
t-att-style="tileStyle(tile)"
t-on-click="() => this.onTileClick(tile.user_id)">
<div t-att-class="avatarClass(tile)"
t-att-style="'background: ' + tile.avatar_gradient">
<img t-if="tile.has_photo"
t-att-src="tile.avatar_url"
t-att-alt="tile.name"/>
<span t-else="" t-esc="tile.initials"/>
</div>
<div class="o_fp_lock_name" t-esc="tile.name"/>
<div t-if="tile.is_clocked_in"
class="o_fp_lock_status status-clocked">
Clocked in
</div>
<div t-elif="!tile.has_pin"
class="o_fp_lock_status status-pin">
PIN required
</div>
</button>
</t>
</div>
<div t-else="" class="o_fp_lock_pinwrap">
<FpPinPad onSubmit.bind="unlock"
title="_selectedTileName()"
subtitle="'Enter your 4-digit PIN'"
onCancel.bind="onPinCancel"/>
</div>
</div>
</t>
<t t-else="">
<t t-slot="default"/>
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
secondsRemaining="state.idleSecondsRemaining"/>
</t>
</t>
</templates>
```
**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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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 `<img>` 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 16 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 (`<img alt="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?