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>
25 KiB
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
- Brand the screen — pull the company logo from
res.company.logo, surface the company name + tagline. - 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.
- Real-time clock + date — operators glance at the lock screen for the time; big tabular-nums clock front-and-center.
- Subtle motion — staggered entrance, hover lift, clocked-in pulse. Doesn't distract; signals freshness.
- Dark + light mode parity — single SCSS source, branches at compile time via
$o-webclient-color-scheme. No JS-side theme code. - Accessibility —
prefers-reduced-motionrespected, 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 ofres.company.name(e.g. "EN" for "EN Technologies", "ABC" capped to 2 chars). Computed server-side, sent in the tiles-endpoint payload.companyTagline: fromres.company.report_headerfield; 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 viaintl.DateTimeFormat). Updates every minute viasetIntervalintablet_lock.js.state.dateText:WEEKDAY · MMM Duppercase (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_photois true whenres.users.image_128is 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 fromres.users.name(first letter of first + last word, capped 2 chars).has_photo: true whenres.users.image_128is 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
#1a1d21background = 16.7:1 (AAA). - Light mode:
#1d1f1etext on#fafafabackground = 17.8:1 (AAA). - Amber prompt text on its tinted bg: 5.2:1 (AA passes).
- Dark mode: white text on
- 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 hascompanyblock 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
- Add tokens SCSS + extend tablet_controller payload — backend deploy.
- Rewrite tablet_lock.scss + extend XML + extend JS — frontend deploy + asset cache bust.
- Verify on entech: open the tablet lock URL on a real iPad and a desktop browser.
- 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.