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

46 KiB
Raw Blame History

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

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 _loadTiles to 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, 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

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?