Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md
gsinghpal efc420b4ce docs(shopfloor): tablet lock-screen redesign spec
Hybrid Industrial Bold + Premium Glassmorphism direction approved
during brainstorming. Adds company branding (logo from
res.company.logo with letter-mark fallback), real-time clock, tighter
3-column tile grid for ~10-15 operator small shops, dual dark/light
mode via compile-time $o-webclient-color-scheme branch, 7-animation
catalogue gated by prefers-reduced-motion.

Backend touch: extend /fp/tablet/tiles payload with company block +
per-tile initials/avatar_gradient/has_photo. Two small helper
functions in tablet_controller. No DB migration.

Frontend touch: new _tablet_lock_tokens.scss (loads first), full
rewrite of tablet_lock.scss, extend XML + JS for clock + company.

Mockup: .superpowers/brainstorm/1983-1779585812/content/lock-final.html
(in-repo since the brainstorm session used --project-dir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:38:11 -04:00

25 KiB
Raw Blame History

Tablet Lock Screen Redesign

Date: 2026-05-24 Status: Design — approved through brainstorming, awaiting plan Affects: fusion_plating_shopfloor (FpTabletLock OWL component + tablet_controller) Scope: Visual + interaction redesign only. PIN gate, unlock RPC, lockout timer, idle warning all unchanged.


1. Problem

The current FpTabletLock tile screen looks like a placeholder. Two operators per row stretch their tiles across 900px max-width; the screen is mostly empty whitespace; there's no branding; the "Tap your name to unlock" prompt is the only header; no animations; no clock/date. Functionally correct but feels unfinished on a wall-mounted shop-floor tablet.

User feedback after live testing (2026-05-24):

"i want company logo and other nice customization, add some animation, reduce the card width so its just enough, there may be many employees, i do not want a lot of scrolling but not cramped at the same time"

Target: tablet that looks like a deliberately-designed shop terminal, fits ~10-15 operators per screen without scrolling, brands the device with the company logo, and has subtle motion that signals "alive."


2. Goals & non-goals

Goals

  1. Brand the screen — pull the company logo from res.company.logo, surface the company name + tagline.
  2. Tighter tile grid — 3 columns max-width 480px, ~140px tile width. Fits 6 tiles per visible row; small shops (10-15 ops) show everything without scroll.
  3. Real-time clock + date — operators glance at the lock screen for the time; big tabular-nums clock front-and-center.
  4. Subtle motion — staggered entrance, hover lift, clocked-in pulse. Doesn't distract; signals freshness.
  5. Dark + light mode parity — single SCSS source, branches at compile time via $o-webclient-color-scheme. No JS-side theme code.
  6. Accessibilityprefers-reduced-motion respected, touch targets ≥ 44px, contrast WCAG AA in both modes.

Non-goals

  • Replacing the PIN gate. The 4-digit PIN flow (FpPinPad component, hash + lockout, /fp/tablet/unlock endpoint) stays identical.
  • Multi-tenant theming. Each company sees its own logo via res.company.logo; we don't build a theme editor for accent colors. The amber accent is a hardcoded brand token in this design.
  • Search box on the lock screen. For ~10-15 operators, scanning the grid is faster than typing. Search returns as a Phase 2 enhancement if a customer scales to 25+ ops.
  • Custom tile sort. Existing rule stays: clocked-in operators first, then alphabetical.
  • Welcome animations / video / mascot. Subtle motion only.

3. Decisions locked during brainstorming

# Decision
D1 Hybrid A+B vibe — Industrial Bold structure (dark gradient bg, bold tabular clock, amber accent) wearing Premium Glassmorphism finish (frosted-glass tiles with backdrop-filter, smooth cubic-bezier hover).
D2 Company logo sourced from res.company.logo (Odoo's standard company logo binary field) via /web/image/res.company/<id>/logo. Letter-mark fallback when no logo is uploaded — built from res.company.name initials.
D3 Company name + tagline below the logo. Name = res.company.name. Tagline = res.company.report_header (existing field, also drives invoice letterheads — natural reuse) with fallback "Shop Floor Terminal" if empty.
D4 3-column tile grid, max-width 480px on the grid container. Tile ~140px wide. Avatar 52px circular with status pulse-dot overlay.
D5 Dark + light mode parity. Same OWL component + same XML; SCSS branches at compile time on $o-webclient-color-scheme. No runtime theme code.
D6 Animation catalogue (full list in §6) — entrance stagger, hover lift, click scale, pulse on clocked-in dot, real-time clock update. prefers-reduced-motion disables all of these.
D7 Sort order unchanged — clocked-in operators first, then alphabetical by name.
D8 No search box for MVP — scoped for the ~10-15-operator small-shop case.

4. Layout

The screen is a full-viewport flex column, centered, with this vertical sequence:

┌──────────────────────────────────────────────────┐
│                                                  │
│              ┌─────────┐                         │  ← logo frame (84×84)
│              │  LOGO   │   (rounded 20, glass)   │     glassmorphic
│              └─────────┘                         │
│            Company Name                          │  ← logo-text (19px, 700)
│        PLATING · ESTD 1985                       │  ← logo-sub (11px upper)
│                                                  │
│                21:09                             │  ← clock (40px, 800, tabular)
│         SATURDAY · MAY 23                        │  ← clock-date (12px upper)
│                                                  │
│         [ 🔒 TAP YOUR NAME ]                     │  ← prompt pill
│                                                  │
│  ┌──────┐  ┌──────┐  ┌──────┐                    │
│  │ GS ● │  │ JM   │  │ CV ● │                    │
│  │Garry │  │Johnny│  │Carlos│                    │  ← 3-column tile grid,
│  │CIN   │  │PIN   │  │CIN   │                    │     max-width 480px
│  └──────┘  └──────┘  └──────┘                    │
│  ┌──────┐  ┌──────┐  ┌──────┐                    │
│  │ LB ● │  │ RB ● │  │ KP ● │                    │
│  │ Lisa │  │ Ravi │  │ Kris │                    │
│  │ CIN  │  │ CIN  │  │ CIN  │                    │
│  └──────┘  └──────┘  └──────┘                    │
│                                                  │
└──────────────────────────────────────────────────┘

Spacing between sections: 22px gap. Logo block top margin: 28px. Outer padding: 28px 20px.

4.1 Logo block

<div class="o_fp_lock_logo_block">
  <div class="o_fp_lock_logo_frame">
    <img t-att-src="logoUrl" t-att-alt="companyName" t-if="logoUrl"/>
    <div t-else="" class="o_fp_lock_logo_placeholder" t-esc="companyInitials"/>
  </div>
  <div class="o_fp_lock_logo_text" t-esc="companyName"/>
  <div class="o_fp_lock_logo_sub" t-esc="companyTagline"/>
</div>
  • logoUrl: /web/image/res.company/<id>/logo — Odoo serves the binary directly. Always 200 if the field is populated (even 1×1 transparent on empty record), so probe the field server-side before emitting the URL.
  • companyInitials: first 1-2 letters of res.company.name (e.g. "EN" for "EN Technologies", "ABC" capped to 2 chars). Computed server-side, sent in the tiles-endpoint payload.
  • companyTagline: from res.company.report_header field; defaults to "Shop Floor Terminal" when empty.

The logo frame is a 84×84 rounded-20 glassmorphic container — same frosted treatment as the tiles. Looks great whether the logo is a sharp PNG, transparent SVG, or the letter-mark fallback.

4.2 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>
  • state.clockText: HH:MM (24h, configurable via intl.DateTimeFormat). Updates every minute via setInterval in tablet_lock.js.
  • state.dateText: WEEKDAY · MMM D uppercase (e.g. "SATURDAY · MAY 23"). Recomputed on date change.
  • Tabular numbers so digits don't jitter when changing.
  • Initial render uses new Date() synchronously so there's no flash of empty content.

4.3 Prompt

A small pill, not a header:

<div class="o_fp_lock_prompt">🔒 Tap your name</div>

Amber-tinted background (matches brand accent), uppercase with 0.18em letter-spacing. Sits between the clock and the tile grid as a visual anchor.

4.4 Tile grid

<div class="o_fp_lock_tiles">
  <t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
    <button class="o_fp_lock_tile"
            t-att-style="'animation-delay: ' + tile.animDelay + 'ms'"
            t-on-click="() => this.onTileClick(tile.user_id)">
      <div t-att-class="tile.is_clocked_in ? 'o_fp_lock_avatar is-clocked' : 'o_fp_lock_avatar'"
           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>
  • Grid: grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 480px.
  • Animation delay computed JS-side per tile (50ms × index, capped at 300ms) so the stagger ripples without dragging.
  • Avatar gradient (per-tile color): server-computed as user.id % len(_AVATAR_GRADIENTS) (8 colors). Deterministic — same operator gets the same color across sessions, so operators learn their own tile color. See §7.3 for the gradient list.
  • has_photo is true when res.users.image_128 is non-empty. Falls back to initials when empty.

5. Color system

All colors live in _tablet_lock_tokens.scss (new file, loaded before tablet_lock.scss). Same pattern as the plant-view tokens shipped earlier.

Light-mode defaults

Token Hex Purpose
$_lock-bg-top #fafafa Gradient top
$_lock-bg-bottom #f0f0f3 Gradient bottom
$_lock-accent rgba(240,165,0,0.12) Top-radial ambient glow
$_lock-accent-2 rgba(99,102,241,0.06) Bottom-radial ambient glow
$_lock-text #1d1f1e Primary text
$_lock-muted #71717a Secondary text
$_lock-prompt #b45309 Prompt text
$_lock-prompt-bg rgba(240,165,0,0.10) Prompt pill bg
$_lock-prompt-border rgba(240,165,0,0.25) Prompt pill border
$_lock-tile-bg rgba(255,255,255,0.7) Tile bg (frosted)
$_lock-tile-border rgba(0,0,0,0.05) Tile border
$_lock-tile-hover-bg rgba(255,255,255,0.95) Tile hover bg
$_lock-tile-hover-border rgba(240,165,0,0.5) Tile hover border
$_lock-tile-hover-shadow 0 12px 24px rgba(240,165,0,0.18) Tile hover shadow
$_lock-frame-bg rgba(255,255,255,0.85) Logo frame bg
$_lock-status-clocked #16a34a Clocked-in green
$_lock-status-pin #d97706 PIN required amber
$_lock-pulse-dot-border #fff Pulse-dot ring

Dark-mode overrides

Token Hex
$_lock-bg-top #1a1d21 (gradient base)
$_lock-bg-bottom #2d3138
$_lock-accent rgba(240,165,0,0.08)
$_lock-accent-2 rgba(99,102,241,0.06)
$_lock-text #f5f5f7
$_lock-muted #adb5bd
$_lock-prompt #f0a500
$_lock-prompt-bg rgba(240,165,0,0.08)
$_lock-prompt-border rgba(240,165,0,0.20)
$_lock-tile-bg rgba(255,255,255,0.06)
$_lock-tile-border rgba(255,255,255,0.08)
$_lock-tile-hover-bg rgba(240,165,0,0.10)
$_lock-tile-hover-border rgba(240,165,0,0.4)
$_lock-tile-hover-shadow 0 12px 24px rgba(240,165,0,0.15), 0 0 0 1px rgba(240,165,0,0.2)
$_lock-frame-bg rgba(255,255,255,0.08)
$_lock-status-clocked #34c759 (brighter — needs to pop on dark)
$_lock-status-pin #ff9f0a
$_lock-pulse-dot-border #2d3138 (so the dot reads as overlapping the dark tile, not floating)

The full-screen background is a stack of two radial gradients (the ambient accent glows) over a linear gradient (the base), per lock-final.html from brainstorm:

background:
    radial-gradient(ellipse at top, $_lock-accent, transparent 50%),
    radial-gradient(ellipse at bottom, $_lock-accent-2, transparent 50%),
    linear-gradient(135deg, $_lock-bg-top 0%, $_lock-bg-bottom 100%);

6. Animation catalogue

All animations use cubic-bezier(0.4, 0, 0.2, 1) for consistency (the "standard easing" curve). Every animation is gated by @media (prefers-reduced-motion: no-preference) — operators who set reduced motion in OS preferences see the same screen with no movement.

# Name What it does Duration Trigger
1 lockLogoEnter Logo block fades down + slides in 500ms onMount
2 lockClockEnter Clock + prompt fade up 500ms (100ms delay) onMount
3 lockTileEnter Each tile fades + slides up + scales from 0.96 400ms (50ms staggered per index, max 6) onMount
4 lockTileHover Lift translateY(-3px) + colored shadow + border glow 250ms hover/focus
5 lockTilePress Quick scale(0.97) 50ms active/click
6 lockPulseDot Green clocked-in dot pulses (ring expands + fades) 2s loop clocked-in state present
7 lockClockTick (no animation — just text content update each minute) setInterval(60000)

Reduced-motion override

@media (prefers-reduced-motion: reduce) {
    .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 {
        animation: none !important;
        transition: none !important;
    }
}

Stagger cap

For very large operator counts the per-tile delay caps at 300ms (6 tiles × 50ms) so the screen doesn't take 3 seconds to settle. Compute animDelay = Math.min(index * 50, 300) JS-side.


7. Backend changes

7.1 Extend /fp/tablet/tiles payload

Currently returns:

{"ok": true, "tiles": [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}

After redesign:

{
  "ok": true,
  "company": {
    "id": 1,
    "name": "EN Technologies",
    "tagline": "Plating & Finishing",
    "logo_url": "/web/image/res.company/1/logo",
    "has_logo": true,
    "initials": "EN"
  },
  "tiles": [
    {
      "user_id": 5,
      "name": "Garry Singh",
      "initials": "GS",
      "avatar_url": "/web/image/res.users/5/avatar_128",
      "has_photo": true,
      "is_clocked_in": true,
      "has_pin": true,
      "avatar_gradient": "linear-gradient(135deg, #ef4444, #dc2626)"
    },
    ...
  ]
}

New fields per tile:

  • initials: server-computed from res.users.name (first letter of first + last word, capped 2 chars).
  • has_photo: true when res.users.image_128 is non-empty (avoids the 1×1 default-image flash).
  • avatar_gradient: deterministic from hash of user.id. Same gradient across sessions so operators recognize "their" tile color.

The company block is one query: env.company.id. Read name, report_header, check logo non-empty.

7.2 _lock_company_payload helper

A small module-level helper in tablet_controller.py:

def _lock_company_payload(env):
    """Returns the company info block for the lock screen."""
    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),
    }


def _initials_from(name):
    """First letter of first + last word, capped at 2 chars uppercase."""
    if not name:
        return '?'
    words = name.strip().split()
    if len(words) == 1:
        return words[0][:2].upper()
    return (words[0][0] + words[-1][0]).upper()

7.3 _avatar_gradient_for helper

_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 _avatar_gradient_for(user_id):
    return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)]

8 colors, modulo user_id — same operator gets the same color forever. Sufficient variety for a small shop (10-15 ops have <2 collisions on average).


8. Frontend changes

8.1 Files modified

File Change
static/src/scss/_tablet_lock_tokens.scss new — design tokens (loads first)
static/src/scss/tablet_lock.scss full rewrite — gradient bg, logo block, clock block, prompt, tile grid, animations, dark/light branches
static/src/xml/tablet_lock.xml wrap existing tile loop with new logo + clock + prompt blocks; add fallback structures
static/src/js/tablet_lock.js add state.clockText + state.dateText + _tickClock setInterval; add state.company; consume new payload fields

8.2 OWL component reactivity for the clock

The clock updates every 60 seconds:

setup() {
    // ... existing setup ...
    this.state = useState({
        // ... existing state ...
        clockText: this._formatTime(new Date()),
        dateText: this._formatDate(new Date()),
        company: null,
    });

    onMounted(() => {
        // ... existing onMounted ...
        this._clockInterval = setInterval(() => {
            const now = new Date();
            this.state.clockText = this._formatTime(now);
            this.state.dateText = this._formatDate(now);
        }, 60000);
    });

    onWillUnmount(() => {
        // ... existing cleanup ...
        if (this._clockInterval) clearInterval(this._clockInterval);
    });
}

_formatTime(d) {
    const hh = String(d.getHours()).padStart(2, '0');
    const mm = String(d.getMinutes()).padStart(2, '0');
    return `${hh}:${mm}`;
}

_formatDate(d) {
    return d.toLocaleDateString(undefined, {
        weekday: 'long', month: 'short', day: 'numeric'
    }).toUpperCase().replace(',', ' ·');
}

Per project rule 20: all the date/number formatting happens in JS (_formatTime, _formatDate). The template only renders state.clockText / state.dateText via t-esc. No String() / Number() / padStart calls inside the XML.

8.3 Stagger delay computed JS-side

In _loadTiles, after fetching, decorate each tile with its animDelay:

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;
            this.state.tiles = res.tiles.map((tile, idx) => ({
                ...tile,
                animDelay: Math.min(idx * 50, 300),  // cap at 300ms
            }));
        }
    } catch (err) {
        // Existing quiet fail
    } finally {
        this.state.loadingTiles = false;
    }
}

8.4 Manifest registration

Adding two SCSS files. Per project rule 8 (SCSS @import forbidden), tokens must register BEFORE the consumer:

# In fusion_plating_shopfloor/__manifest__.py, the lock screen block:
'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss',  # NEW — load first
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss',            # existing — rewritten
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml',              # existing — extended
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',                # existing — extended

The tokens file lives in scss/ (not scss/components/) because it's session-level — one tokens file for the whole lock-screen experience.


9. Accessibility

  • Touch targets: avatar 52px + 14px padding = 80px tile content; tile itself extends to grid cell width ~140px × 110px tall. Both axes well above the 44×44 WCAG minimum.
  • Focus rings: visible 2px solid amber outline on :focus-visible. Distinguishes keyboard navigation from mouse hover.
  • Contrast:
    • Dark mode: white text on #1a1d21 background = 16.7:1 (AAA).
    • Light mode: #1d1f1e text on #fafafa background = 17.8:1 (AAA).
    • Amber prompt text on its tinted bg: 5.2:1 (AA passes).
  • Reduced motion: full media-query gate documented in §6.
  • Alt text: logo <img alt="Company Name"> so screen readers announce the brand on focus.
  • Keyboard navigation: tab order = logo (skip) → tiles in DOM order → first tile receives initial focus on mount.

10. Testing strategy

10.1 Unit / integration

  • test_tablet_tiles_endpoint_includes_company — call /fp/tablet/tiles, assert response has company block with required keys.
  • test_initials_from_helper — edge cases: empty name, single-word name, multi-word name with hyphens.
  • test_avatar_gradient_deterministic — same user.id returns same gradient across calls.

10.2 Visual snapshot tests

Per state, a Playwright snapshot of the lock screen at 1366×768 (typical tablet) in both light and dark mode. Snapshots checked in; PR diff catches accidental CSS regressions.

10.3 Persona walks

  • Cold start — operator approaches tablet with no recent session. Clock displays current time; tiles fade in; clicking own tile opens PIN pad immediately (no visible loading state).
  • Mid-shift unlock — operator returns after auto-lock. Same flow; their tile shows the pulsing clocked-in dot.
  • No logo configured — companies that haven't set res.company.logo. Letter-mark renders cleanly; layout unchanged.
  • Reduced motion — toggle the OS preference; verify all animations disabled, layout still works.

11. Migration & rollout

No database migration needed — this is a presentation-layer change reusing existing fields (res.company.logo, res.company.report_header, res.users.image_128).

Rollout sequence

  1. Add tokens SCSS + extend tablet_controller payload — backend deploy.
  2. Rewrite tablet_lock.scss + extend XML + extend JS — frontend deploy + asset cache bust.
  3. Verify on entech: open the tablet lock URL on a real iPad and a desktop browser.
  4. Iterate on visual details (logo padding, gradient intensity, accent color) based on shop-floor feedback.

No feature flag — the redesign is a strict visual improvement, no behavioral changes. Reverting is git revert <commit> if needed.


12. Open questions (deferred)

# Question Resolution
Q1 Search box for 25+ operator shops? Phase 2. MVP scoped to ~10-15 ops. Re-evaluate when a customer scales.
Q2 Custom accent color per company? Phase 2. Amber is hardcoded in tokens for MVP. Could be a res.company.x_fc_shopfloor_accent field later.
Q3 Weather / news widget on lock screen? No. Out of scope; clutters the screen. Operators don't need it.
Q4 Multi-language toggle visible on lock screen? No for MVP. Existing user.lang flow handles this server-side; lock screen renders in the user's language once they're identified post-PIN.
Q5 Operator photo upload UX? Existing flow stays — managers upload via Preferences → My Profile. Lock screen consumes whatever's there.
Q6 Animation when transitioning tile → PIN pad? Phase 2 polish. Currently the existing FpPinPad just appears; could add a crossfade. Subjective; ship clean first.

13. Summary

Question Answer
Layout Vertical centered flex column: logo (84px) → clock (40px) → prompt pill → 3-column tile grid (max 480px)
Card model One tile per res.users with tablet PIN configured (existing rule); deterministic per-user color gradient
Card density 3 columns, ~140px tiles — fits ~9-12 visible without scroll on a 1366×768 tablet
Animation 7 named animations (entrance stagger, hover lift, click press, status pulse) all bezier-eased, all gated by prefers-reduced-motion
Dark / Light mode Single SCSS source with compile-time $o-webclient-color-scheme branch — same component, two bundles, no JS theme code
Backend touch Extend /fp/tablet/tiles payload with company block + per-tile initials/avatar_gradient/has_photo. Two small helper functions.
Frontend touch New _tablet_lock_tokens.scss. Full rewrite of tablet_lock.scss. Extend XML + JS for clock + company block.
Rollout No DB migration. Plain code deploy + asset cache bust. No feature flag.

The redesign solves the "looks like a placeholder" feel by branding the screen with the company logo, adding a real-time clock, tightening the tile grid for the small-shop case, and layering glassmorphic finishes + cubic-bezier animations on a hybrid Industrial Bold + Premium structure. Dark and light modes share one source.

Implementation plan to follow.