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>
46 KiB
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
Mathis exposed as a JS global. NEVER callString(x),Number(x),padStart(x),toLocaleDateString(), etc. directly insidet-on-click/t-att-*/t-outexpressions. The clock and date formatting MUST happen in JS-side methods; templates justt-escthe prepared strings. - SCSS @import forbidden (rule 8): every SCSS file is registered separately in the manifest. The new
_tablet_lock_tokens.scssMUST appear BEFOREtablet_lock.scssinweb.assets_backendso the$_lock-*tokens are visible to the consumer. - Dark mode at compile time: branch via
@if $o-webclient-color-scheme == dark { ... }with!globaltoken overrides. No JS-side theme code. No.o_dark_modeclass 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(thetiles()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
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:
# -*- 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
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:
# ===== 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:
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)
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:
# fusion_plating_shopfloor/tests/__init__.py
from . import test_tablet_lock_payload
- Step 6: Deploy + run tests — expect PASS
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
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
// =====================================================================
// 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)
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:
// =====================================================================
// 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)
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 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-styles / t-att-classes them directly.
- Step 2: Commit
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
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:
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:
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
onWillUnmount(() => {
if (this._tick) clearInterval(this._tick);
if (this._ping) clearInterval(this._ping);
if (this._clockInterval) clearInterval(this._clockInterval);
});
- Step 5: Replace
_loadTilesto consume the new payload
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
// === 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
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
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:
'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:
'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
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
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
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,initialskeys. -
_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
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 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 (<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_fromused the same way in Task 1's helpers, the test file, and Task 6's verification. _avatar_gradient_forconsistent.- State key names
clockText,dateText,companyconsistent between Task 5'suseState, 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?