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>
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
# 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. **Accessibility** — `prefers-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
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```html
|
||||
<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
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```scss
|
||||
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
|
||||
|
||||
```scss
|
||||
@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:
|
||||
```json
|
||||
{"ok": true, "tiles": [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}
|
||||
```
|
||||
|
||||
After redesign:
|
||||
```json
|
||||
{
|
||||
"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`:
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
_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:
|
||||
|
||||
```javascript
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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:
|
||||
|
||||
```python
|
||||
# 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.
|
||||
Reference in New Issue
Block a user