feat(shopfloor): tablet lock-screen redesign — frontend + manifest

LS-T2..T6 of the tablet lock-screen redesign (LS-T1 backend shipped
separately in c6137100).

Files:
  - _tablet_lock_tokens.scss  (new — design tokens, dark/light branches
                               via $o-webclient-color-scheme, registered
                               first in manifest per project rule 8)
  - tablet_lock.scss          (full rewrite — gradient bg, glassmorphic
                               tiles, 4 entrance keyframes, hover lift,
                               click press, clocked-in pulse,
                               prefers-reduced-motion gate)
  - tablet_lock.xml           (extended — logo + clock + prompt blocks
                               wrapping the existing tile loop; tile
                               inner shape updated for avatar gradient,
                               has_photo conditional, is_clocked_in
                               modifier)
  - tablet_lock.js            (extended — state.clockText / dateText /
                               company, setInterval(60s) clock tick,
                               _formatTime / _formatDate / tileStyle /
                               avatarClass helpers per project rule 20)
  - __manifest__.py           (19.0.31.0.0 -> 19.0.32.0.0, registered
                               new tokens SCSS BEFORE tablet_lock.scss)

Verified live on entech:
  - Module upgrade clean, registry loaded in 15.5s
  - 6 stale asset attachments cleared
  - Helpers in tablet_controller.py emit company payload + initials +
    gradients correctly (Garry Singh -> GS, EN Tech -> ET, uid=5 ->
    pink gradient)
  - res.company.logo present (has_logo: True)
  - All animations gated by prefers-reduced-motion per spec §6

CLAUDE.md updated with new Critical Rule 22 about Odoo 19 HTML fields
auto-wrapping plain-string writes — caught during Task 1 testing when
the original 'tagline equality' test failed because res.company.report
_header is an HTML field that wraps writes with <p> tags.

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>
This commit is contained in:
gsinghpal
2026-05-23 21:56:32 -04:00
parent c61371005a
commit 772107d25b
5 changed files with 378 additions and 85 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.31.0.0',
'version': '19.0.32.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -95,6 +95,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'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
# so the $lock-* vars are visible to the consumer (project rule 8).
'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',

View File

@@ -40,6 +40,12 @@ export class FpTabletLock extends Component {
selectedTileUserId: null,
idleSecondsRemaining: null,
loadingTiles: false,
// 2026-05-24 redesign — clock + company branding
// Seeded synchronously so the first render shows real values
// (no flash of empty content).
clockText: this._formatTime(new Date()),
dateText: this._formatDate(new Date()),
company: null,
});
onMounted(async () => {
@@ -52,11 +58,19 @@ export class FpTabletLock extends Component {
.catch(() => {});
}
}, 60000);
// Clock tick — update visible HH:MM and date label every 60s.
// 60s is enough; the displayed precision is minute-level only.
this._clockInterval = setInterval(() => {
const now = new Date();
this.state.clockText = this._formatTime(now);
this.state.dateText = this._formatDate(now);
}, 60000);
});
onWillUnmount(() => {
if (this._tick) clearInterval(this._tick);
if (this._ping) clearInterval(this._ping);
if (this._clockInterval) clearInterval(this._clockInterval);
});
}
@@ -70,7 +84,14 @@ export class FpTabletLock extends Component {
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.tiles = res.tiles;
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
@@ -132,4 +153,36 @@ export class FpTabletLock extends Component {
this.state.idleSecondsRemaining = null;
this._loadTiles();
}
// === 2026-05-24 redesign helpers =====================================
_formatTime(d) {
// 24-hour HH:MM with leading zeros. Per project rule 20 this MUST
// live in JS, not 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. Uses Intl for locale-correct weekday
// + month abbreviations, then upcases for the industrial 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 — the template can't
// call String() inside t-att-style.
return "animation-delay: " + tile.animDelay + "ms";
}
avatarClass(tile) {
return tile.is_clocked_in
? "o_fp_lock_avatar is-clocked"
: "o_fp_lock_avatar";
}
}

View File

@@ -0,0 +1,75 @@
// =====================================================================
// 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.70);
$_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.50);
$_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.40) !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.30), 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;

View File

@@ -1,96 +1,227 @@
// =============================================================================
// =====================================================================
// FpTabletLock — lock screen with tile grid + PIN pad overlay
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_lock-bg-hex: #f3f4f6;
$_lock-card-hex: #ffffff;
$_lock-border-hex: #d8dadd;
$_lock-ink-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_lock-bg-hex: #1a1d21 !global;
$_lock-card-hex: #22262d !global;
$_lock-border-hex: #424245 !global;
$_lock-ink-hex: #f5f5f7 !global;
}
// 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;
background: $_lock-bg-hex;
color: $_lock-ink-hex;
color: $lock-text;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
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%);
}
.o_fp_tablet_lock_header {
h1 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
// === 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_tablet_lock_loading, .o_fp_tablet_lock_empty {
margin: 2rem auto;
color: var(--text-secondary, #666);
.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_tablet_lock_tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
max-width: 900px;
width: 100%;
.o_fp_lock_logo_text {
font-size: 19px; font-weight: 700; letter-spacing: -0.01em;
color: $lock-text;
}
.o_fp_tablet_lock_tile {
background: $_lock-card-hex;
border: 2px solid $_lock-border-hex;
border-radius: 12px;
padding: 1rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: border-color 0.1s ease, transform 0.05s ease;
&:hover { border-color: #0071e3; }
&:active { transform: scale(0.98); }
.o_fp_lock_logo_sub {
font-size: 11px;
text-transform: uppercase; letter-spacing: 0.15em;
margin-top: 4px;
color: $lock-muted;
}
.o_fp_tablet_lock_tile_avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.o_fp_tablet_lock_tile_name {
font-weight: 600;
// === 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_tablet_lock_tile_clocked {
color: #34c759;
font-size: 0.75rem;
.o_fp_lock_clock {
font-size: 40px; font-weight: 800;
letter-spacing: -0.03em; line-height: 1;
color: $lock-text;
}
.o_fp_tablet_lock_tile_nopin {
color: #ff9f0a;
font-size: 0.75rem;
.o_fp_lock_clock_date {
font-size: 12px;
text-transform: uppercase; letter-spacing: 0.12em;
margin-top: 4px;
color: $lock-muted;
}
.o_fp_tablet_lock_pinwrap {
margin-top: 2rem;
// === 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;
font-family: 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;
}
}

View File

@@ -4,35 +4,66 @@
<t t-name="fusion_plating_shopfloor.TabletLock">
<t t-if="isLocked">
<div class="o_fp_tablet_lock">
<div class="o_fp_tablet_lock_header">
<h1><i class="fa fa-lock"/> Tap your name to unlock</h1>
<!-- ============== 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>
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
<!-- ============== 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_tablet_lock_tiles">
<div t-elif="!state.selectedTileUserId" class="o_fp_lock_tiles">
<t t-if="!state.tiles.length">
<div class="o_fp_tablet_lock_empty">
<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_tablet_lock_tile"
<button class="o_fp_lock_tile"
t-att-style="tileStyle(tile)"
t-on-click="() => this.onTileClick(tile.user_id)">
<img class="o_fp_tablet_lock_tile_avatar"
t-att-src="tile.avatar_url"
t-att-alt="tile.name"/>
<div class="o_fp_tablet_lock_tile_name" t-esc="tile.name"/>
<span t-if="tile.is_clocked_in" class="o_fp_tablet_lock_tile_clocked">
● Clocked in
</span>
<span t-if="!tile.has_pin" class="o_fp_tablet_lock_tile_nopin">
<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
</span>
</div>
</button>
</t>
</div>
<div t-else="" class="o_fp_tablet_lock_pinwrap">
<div t-else="" class="o_fp_lock_pinwrap">
<FpPinPad onSubmit.bind="unlock"
title="_selectedTileName()"
subtitle="'Enter your 4-digit PIN'"