# PIN Kiosk Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship a polished, opt-in PIN kiosk (photo-tile → PIN → optional selfie → clock) matching the NFC kiosk's premium dark/glass/brand-gradient style, gated by the existing `enable_kiosk` setting. **Architecture:** Rework the existing `controllers/clock_kiosk.py` (4 routes + 1 new), rebuild `views/kiosk_templates.xml`, rewrite `static/src/js/fusion_clock_kiosk.js` as an Odoo-19 Interaction with a small state machine, and add a new `static/src/scss/pin_kiosk.scss` that mirrors `nfc_kiosk.scss` (scoped to `#pin_kiosk_root`, brand hue in `--pk-h`). Reuse the master photo gate, `hr.employee.public` avatars, and the company kiosk location. **Tech Stack:** Odoo 19 HTTP controllers (`type='jsonrpc'` / `type='http'`), `@web/public/interaction` Interaction, SCSS (frontend bundle), `HttpCase`/`TransactionCase` tests. **Reference (read first):** spec `fusion_clock/docs/superpowers/specs/2026-05-31-pin-kiosk-design.md`; mirror sources `static/src/scss/nfc_kiosk.scss` and `static/src/js/fusion_clock_nfc_kiosk.js` (hue extraction lines ~60-117, photo capture); repo `CLAUDE.md` + `fusion_clock/CLAUDE.md` (Interaction rule, scoped-SCSS rule). **Test command** (substitute `odoo-modsdev-app` if that's your dev container): ```bash docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \ -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 ``` **Commit discipline (shared tree):** stage explicit paths, verify `git diff --cached --name-only`, `git commit --only -- `, never `git add -A`, no `.pyc`/`.DS_Store`. Push **origin + gitea** at the end. Append `Co-Authored-By: Claude Opus 4.8 ` to messages. **File structure:** - `controllers/clock_kiosk.py` — rework `kiosk_search` (+avatar/has_pin), `kiosk_verify_pin` (→ needs_setup), new `kiosk_set_pin`, rework `kiosk_clock` (kiosk location + photo). - `static/src/scss/pin_kiosk.scss` (new) — kiosk styling, scoped to `#pin_kiosk_root`. - `views/kiosk_templates.xml` — rebuilt root + chrome + `#pin_state_container`. - `static/src/js/fusion_clock_kiosk.js` — Interaction state machine. - `models/res_config_settings.py`, `views/res_config_settings_views.xml`, `data/ir_config_parameter_data.xml` — drop `kiosk_pin_required`. - `models/res_company.py` — relabel kiosk-location field string. - `views/clock_menus.xml` — PIN kiosk app icon. - `__manifest__.py` — register scss + version bump. - `tests/test_clock_kiosk.py` (new). --- ## Task 1: Backend — employee list (+avatar/has_pin), verify_pin (needs_setup), set_pin **Files:** - Modify: `controllers/clock_kiosk.py` - Create: `tests/test_clock_kiosk.py` - Modify: `tests/__init__.py` - [ ] **Step 1: Register the test module** — add to `fusion_clock/tests/__init__.py`: ```python from . import test_clock_kiosk ``` - [ ] **Step 2: Write the failing tests** — create `fusion_clock/tests/test_clock_kiosk.py`: ```python # -*- coding: utf-8 -*- import json from odoo.tests.common import HttpCase, tagged @tagged('-at_install', 'post_install', 'fusion_clock') class TestPinKioskIdentity(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.enable_kiosk', 'True') cls.location = cls.env['fusion.clock.location'].create({ 'name': 'PIN Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, }) cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id cls.env['res.users'].create({ 'name': 'PIN Kiosk Op', 'login': 'pin-kiosk-op', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.withpin = cls.env['hr.employee'].create({ 'name': 'Pat WithPin', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234', }) cls.nopin = cls.env['hr.employee'].create({ 'name': 'Nora NoPin', 'x_fclk_enable_clock': True, }) def _call(self, route, params): self.authenticate('pin-kiosk-op', 'kioskpass123') resp = self.url_open(route, data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': params, }), headers={'Content-Type': 'application/json'}) return resp.json().get('result', {}) def test_search_returns_avatar_and_has_pin(self): res = self._call('/fusion_clock/kiosk/search', {'query': ''}) rows = {e['name']: e for e in res['employees']} self.assertIn('Pat WithPin', rows) self.assertTrue(rows['Pat WithPin']['has_pin']) self.assertFalse(rows['Nora NoPin']['has_pin']) self.assertIn('/web/image/hr.employee.public/', rows['Pat WithPin']['avatar_url']) def test_verify_pin_correct(self): res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '1234'}) self.assertTrue(res.get('success')) def test_verify_pin_incorrect(self): res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '9999'}) self.assertEqual(res.get('error'), 'invalid_pin') def test_verify_pin_needs_setup(self): res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.nopin.id, 'pin': ''}) self.assertTrue(res.get('needs_setup')) def test_set_pin_success_then_required(self): res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '4321'}) self.assertTrue(res.get('success')) self.assertEqual(self.nopin.x_fclk_kiosk_pin, '4321') # already set → reject res2 = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '0000'}) self.assertEqual(res2.get('error'), 'already_set') def test_set_pin_rejects_bad_format(self): res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.withpin.id, 'pin': '12'}) self.assertEqual(res.get('error'), 'bad_pin') ``` - [ ] **Step 3: Run the tests, verify they FAIL** — run the test command. Expected: FAIL (`search` lacks `has_pin`/`avatar_url`; `verify_pin` has no `needs_setup`; `set_pin` route 404). - [ ] **Step 4: Implement** — in `controllers/clock_kiosk.py`, replace `kiosk_search` and `kiosk_verify_pin` and add `kiosk_set_pin`: ```python @http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST']) def kiosk_search(self, query='', **kw): """Employees for the kiosk grid. Also used by the NFC kiosk's employee_search — keep the return shape additive.""" if not _is_kiosk_operator(request.env.user): return {'error': 'Access denied.'} employees = request.env['hr.employee'].sudo().search([ ('x_fclk_enable_clock', '=', True), ('name', 'ilike', query), ], limit=200, order='name') rows = [] for emp in employees: unique = emp.write_date.strftime('%Y%m%d%H%M%S') if emp.write_date else '' rows.append({ 'id': emp.id, 'name': emp.name, 'department': emp.department_id.name or '', 'is_checked_in': emp.attendance_state == 'checked_in', 'card_uid': emp.x_fclk_nfc_card_uid or '', 'has_pin': bool(emp.x_fclk_kiosk_pin), 'avatar_url': '/web/image/hr.employee.public/%s/avatar_128?unique=%s' % (emp.id, unique), }) return {'employees': rows} @http.route('/fusion_clock/kiosk/verify_pin', type='jsonrpc', auth='user', methods=['POST']) def kiosk_verify_pin(self, employee_id=0, pin='', **kw): if not _is_kiosk_operator(request.env.user): return {'error': 'Access denied.'} employee = request.env['hr.employee'].sudo().browse(int(employee_id)) if not employee.exists(): return {'error': 'not_found'} if not employee.x_fclk_kiosk_pin: return {'needs_setup': True, 'employee_name': employee.name} if employee.x_fclk_kiosk_pin != pin: return {'error': 'invalid_pin'} return {'success': True, 'employee_name': employee.name, 'is_checked_in': employee.attendance_state == 'checked_in'} @http.route('/fusion_clock/kiosk/set_pin', type='jsonrpc', auth='user', methods=['POST']) def kiosk_set_pin(self, employee_id=0, pin='', **kw): """First-use PIN creation. Rejects if the employee already has one.""" if not _is_kiosk_operator(request.env.user): return {'error': 'Access denied.'} employee = request.env['hr.employee'].sudo().browse(int(employee_id)) if not employee.exists() or not employee.x_fclk_enable_clock: return {'error': 'not_found'} if employee.x_fclk_kiosk_pin: return {'error': 'already_set'} pin = (pin or '').strip() if not (pin.isdigit() and 4 <= len(pin) <= 6): return {'error': 'bad_pin'} employee.write({'x_fclk_kiosk_pin': pin}) return {'success': True, 'employee_name': employee.name} ``` - [ ] **Step 5: Run the tests, verify they PASS** — run the test command. Expected: `TestPinKioskIdentity` passes. - [ ] **Step 6: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py fusion_clock/tests/__init__.py git diff --cached --name-only git commit --only -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py fusion_clock/tests/__init__.py \ -m "feat(fusion_clock): PIN kiosk identity endpoints (grid list, verify, first-use set_pin)" ``` --- ## Task 2: Backend — clock with kiosk location + photo gating **Files:** - Modify: `controllers/clock_kiosk.py` (rework `kiosk_clock`) - Modify: `tests/test_clock_kiosk.py` - [ ] **Step 1: Write the failing tests** — append to `test_clock_kiosk.py`: ```python @tagged('-at_install', 'post_install', 'fusion_clock') class TestPinKioskClock(HttpCase): PNG = ('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC' 'AAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=') @classmethod def setUpClass(cls): super().setUpClass() cls.ICP = cls.env['ir.config_parameter'].sudo() cls.ICP.set_param('fusion_clock.enable_kiosk', 'True') cls.location = cls.env['fusion.clock.location'].create({ 'name': 'PIN Plant 2', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, }) cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id cls.env['res.users'].create({ 'name': 'PIN Op2', 'login': 'pin-op2', 'password': 'kioskpass123', 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], }) cls.emp = cls.env['hr.employee'].create({ 'name': 'Quinn Clock', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234', }) def _clock(self, photo_b64=''): self.authenticate('pin-op2', 'kioskpass123') resp = self.url_open('/fusion_clock/kiosk/clock', data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': {'employee_id': self.emp.id, 'photo_b64': photo_b64}, }), headers={'Content-Type': 'application/json'}) return resp.json().get('result', {}) def _latest(self): return self.env['hr.attendance'].search( [('employee_id', '=', self.emp.id)], order='check_in desc', limit=1) def test_clock_in_uses_kiosk_location(self): res = self._clock() self.assertTrue(res.get('success')) self.assertEqual(res.get('action'), 'clock_in') att = self._latest() self.assertEqual(att.x_fclk_clock_source, 'kiosk') self.assertEqual(att.x_fclk_location_id, self.location) def test_photo_stored_only_when_master_on(self): self.ICP.set_param('fusion_clock.enable_photo_verification', 'False') self._clock(self.PNG) self.assertFalse(self._latest().x_fclk_check_in_photo) # new employee for an ON run (avoid debounce/clocked-in state) emp2 = self.env['hr.employee'].create({ 'name': 'Quinn Two', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'}) self.ICP.set_param('fusion_clock.enable_photo_verification', 'True') self.authenticate('pin-op2', 'kioskpass123') self.url_open('/fusion_clock/kiosk/clock', data=json.dumps({ 'jsonrpc': '2.0', 'method': 'call', 'params': {'employee_id': emp2.id, 'photo_b64': self.PNG}}), headers={'Content-Type': 'application/json'}) att2 = self.env['hr.attendance'].search([('employee_id', '=', emp2.id)], limit=1) self.assertTrue(att2.x_fclk_check_in_photo) def test_no_location_configured(self): self.env.company.x_fclk_nfc_kiosk_location_id = False res = self._clock() self.assertEqual(res.get('error'), 'no_location_configured') ``` - [ ] **Step 2: Run the tests, verify they FAIL** — run the test command. Expected: FAIL (current `kiosk_clock` uses `_verify_location` GPS, takes no `photo_b64`, no `no_location_configured`). - [ ] **Step 3: Implement** — in `controllers/clock_kiosk.py`, replace the whole `kiosk_clock` method with: ```python @http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST']) def kiosk_clock(self, employee_id=0, photo_b64='', **kw): """Clock the employee in/out from the shared kiosk. Fixed wall device: uses the company kiosk location, no per-clock GPS geofence.""" if not _is_kiosk_operator(request.env.user): return {'error': 'Access denied.'} employee = request.env['hr.employee'].sudo().browse(int(employee_id)) if not employee.exists() or not employee.x_fclk_enable_clock: return {'error': 'not_found'} ICP = request.env['ir.config_parameter'].sudo() company = request.env.company.sudo() location = company.x_fclk_nfc_kiosk_location_id if not location: return {'error': 'no_location_configured'} from .clock_api import FusionClockAPI from .clock_nfc_kiosk import _strip_data_url_prefix api = FusionClockAPI() photo_enabled = ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True' photo_bytes = _strip_data_url_prefix(photo_b64) if (photo_enabled and photo_b64) else b'' is_checked_in = employee.attendance_state == 'checked_in' now = fields.Datetime.now() today = get_local_today(request.env, employee) day_plan = employee._get_fclk_day_plan(today) is_scheduled_off = not day_plan.get('scheduled') geo_info = {'latitude': 0, 'longitude': 0, 'browser': 'kiosk', 'ip_address': request.httprequest.remote_addr or ''} try: attendance = employee.sudo()._attendance_action_change(geo_info) if not is_checked_in: attendance.sudo().write({ 'x_fclk_location_id': location.id, 'x_fclk_in_distance': 0.0, 'x_fclk_clock_source': 'kiosk', 'x_fclk_check_in_photo': photo_bytes if photo_bytes else False, }) api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}", attendance=attendance, location=location, latitude=0, longitude=0, distance=0, source='kiosk') if is_scheduled_off: api._log_activity(employee, 'unscheduled_shift', f"Kiosk clock-in on an unscheduled day at {location.name}", attendance=attendance, location=location, latitude=0, longitude=0, distance=0, source='kiosk') else: scheduled_in, _ = api._get_scheduled_times(employee, today) api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) return {'success': True, 'action': 'clock_in', 'employee_name': employee.name, 'message': f'{employee.name} clocked in at {location.name}', 'worked_hours': 0.0} else: attendance.sudo().write({ 'x_fclk_out_distance': 0.0, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, }) api._apply_break_deduction(attendance, employee) if not is_scheduled_off: _, scheduled_out = api._get_scheduled_times(employee, today) api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) api._log_activity(employee, 'clock_out', f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h", attendance=attendance, location=location, latitude=0, longitude=0, distance=0, source='kiosk') return {'success': True, 'action': 'clock_out', 'employee_name': employee.name, 'message': f'{employee.name} clocked out from {location.name}', 'net_hours': round(attendance.x_fclk_net_hours or 0, 2)} except Exception as e: _logger.error("Fusion Clock PIN kiosk error: %s", str(e)) return {'error': str(e)} ``` Confirm `_strip_data_url_prefix` exists in `controllers/clock_nfc_kiosk.py` (it does — used by the NFC tap). Confirm `kiosk_page` already imports `fields` and `get_local_today` at module top (it does). - [ ] **Step 4: Run the tests, verify they PASS** — run the test command. Expected: `TestPinKioskClock` passes. - [ ] **Step 5: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py git diff --cached --name-only git commit --only -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py \ -m "feat(fusion_clock): PIN kiosk clock — kiosk location + master-gated selfie" ``` --- ## Task 3: Settings cleanup, company relabel, app icon **Files:** - Modify: `models/res_config_settings.py`, `views/res_config_settings_views.xml`, `data/ir_config_parameter_data.xml`, `models/res_company.py`, `views/clock_menus.xml` - [ ] **Step 1: Drop `kiosk_pin_required`** (PIN always required now): - `models/res_config_settings.py`: delete the `fclk_kiosk_pin_required = fields.Boolean(...)` field block AND its line in `_FCLK_BOOL_PARAMS` (`('fclk_kiosk_pin_required', 'fusion_clock.kiosk_pin_required', True),`). - `views/res_config_settings_views.xml`: delete the `` and its surrounding ``/row. - `data/ir_config_parameter_data.xml`: delete the `config_kiosk_pin_required` record. - [ ] **Step 2: Relabel the kiosk location** — in `models/res_company.py`, change the field string/help (it now serves NFC + PIN): ```python x_fclk_nfc_kiosk_location_id = fields.Many2one( 'fusion.clock.location', string='Kiosk Location', help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.", ) ``` - [ ] **Step 3: Add the PIN Kiosk app icon** — in `views/clock_menus.xml`, after the NFC kiosk app block, add: ```xml Fusion Clock PIN Kiosk /fusion_clock/kiosk self ``` - [ ] **Step 4: Apply + verify** — run: ```bash docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20 ``` Expected: no ParseError / no `Invalid field` for `fclk_kiosk_pin_required`. - [ ] **Step 5: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add -- fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/models/res_company.py fusion_clock/views/clock_menus.xml git diff --cached --name-only git commit --only -- fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/models/res_company.py fusion_clock/views/clock_menus.xml \ -m "feat(fusion_clock): drop kiosk_pin_required, relabel kiosk location, add PIN kiosk app icon" ``` --- ## Task 4: SCSS — `pin_kiosk.scss` (mirror the NFC kiosk) **Files:** - Create: `static/src/scss/pin_kiosk.scss` - Modify: `__manifest__.py` (register in `web.assets_frontend`) - [ ] **Step 1: Create `static/src/scss/pin_kiosk.scss`** — mirror `nfc_kiosk.scss` exactly for the shared chrome, **but** scope every rule under `:has(#pin_kiosk_root)` / `.pin-kiosk`, rename the hue var to `--pk-h`, and replace the NFC idle/icon section with the **grid + tiles**. Full file: ```scss // PIN Clock Kiosk — premium glass + animated mesh, always-dark. // Mirrors nfc_kiosk.scss; scoped under :has(#pin_kiosk_root) so it never leaks. // Brand hue --pk-h is set by JS from the company logo's dominant color. :root { --pk-h: 168; --pk-bg: #0b0d10; --pk-text: #ffffff; --pk-text-muted: #9ba3ad; --pk-success: #18a957; --pk-error: #d9374e; } html:has(#pin_kiosk_root) { overflow: hidden; height: 100%; body { overflow: hidden; height: 100%; margin: 0; padding: 0; background: var(--pk-bg) !important; color: var(--pk-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .o_main_navbar, header, footer, .o_header_standard, .o_footer { display: none !important; } } .pin-kiosk { position: fixed; inset: 0; width: 100vw; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding: 1.25rem 2rem 2rem; box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; overflow: hidden; background: var(--pk-bg); &::before { content: ""; position: absolute; inset: -15%; background: radial-gradient(circle at 20% 30%, hsla(var(--pk-h), 75%, 40%, 0.55) 0%, transparent 45%), radial-gradient(circle at 80% 20%, hsla(calc(var(--pk-h) + 40), 65%, 35%, 0.50) 0%, transparent 50%), radial-gradient(circle at 70% 75%, hsla(calc(var(--pk-h) - 25), 70%, 35%, 0.45) 0%, transparent 55%), radial-gradient(circle at 15% 85%, hsla(calc(var(--pk-h) + 80), 60%, 30%, 0.40) 0%, transparent 50%); filter: blur(60px) saturate(140%); animation: pk-mesh 28s ease-in-out infinite alternate; z-index: 0; } &::after { content: ""; position: absolute; inset: 0; background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.45) 100%); z-index: 1; pointer-events: none; } > * { position: relative; z-index: 2; } } @keyframes pk-mesh { 0% { transform: translate(0,0) rotate(0) scale(1); } 50% { transform: translate(3%,-2%) rotate(2deg) scale(1.05); } 100% { transform: translate(-3%,3%) rotate(-1deg) scale(0.98); } } // Header chrome .pin-kiosk__logo { max-height: 56px; max-width: 240px; object-fit: contain; background: rgba(255,255,255,0.95); padding: 0.55rem 1rem; border-radius: 0.9rem; border: 2px solid hsla(var(--pk-h), 85%, 72%, 0.95); box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 0 26px hsla(var(--pk-h), 90%, 60%, 0.5); } .pin-kiosk__clock { margin-top: 0.5rem; font-size: 2.1rem; font-weight: 300; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; text-shadow: 0 2px 12px rgba(0,0,0,0.4); .ampm { font-size: 0.9rem; font-weight: 500; color: var(--pk-text-muted); margin-left: 0.3rem; } } .pin-kiosk__date { font-size: 0.8rem; color: var(--pk-text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.1rem; } // Search .pin-kiosk__search { margin: 1rem 0 0.85rem; width: min(440px, 92%); background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 999px; padding: 0.7rem 1.2rem; color: var(--pk-text); font-size: 1rem; outline: none; &::placeholder { color: var(--pk-text-muted); } &:focus { border-color: hsl(var(--pk-h), 80%, 55%); } } // Tile grid .pin-kiosk__grid { flex: 1; min-height: 0; overflow-y: auto; width: 100%; max-width: 1100px; display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.85rem; align-content: start; padding-bottom: 1rem; } .pin-kiosk__tile { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.85rem 0.4rem; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); border-radius: 1rem; box-shadow: 0 8px 24px rgba(0,0,0,0.25); cursor: pointer; transition: transform 120ms ease, background 150ms ease; &:hover, &:active { background: rgba(255,255,255,0.1); transform: translateY(-2px); } } .pin-kiosk__tile-av { width: 60px; height: 60px; border-radius: 50%; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; color: #fff; border: 2px solid rgba(255,255,255,0.25); box-shadow: 0 6px 16px rgba(0,0,0,0.35); } .pin-kiosk__tile-nm { font-size: 0.8rem; text-align: center; line-height: 1.15; color: #e7ebf0; max-width: 100px; } // Bottom chrome .pin-kiosk__location { position: absolute; bottom: 1.5rem; left: 1.5rem; font-size: 0.85rem; color: var(--pk-text-muted); background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); padding: 0.5rem 1rem; border-radius: 999px; } .pin-kiosk__settings { position: absolute; bottom: 1.5rem; right: 1.5rem; width: 2.75rem; height: 2.75rem; border-radius: 50%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); color: var(--pk-text-muted); display: flex; align-items: center; justify-content: center; font-size: 1.2rem; cursor: pointer; } // Glass overlay (PIN pad / setup / result), centered .pin-kiosk__overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.55); backdrop-filter: blur(6px); padding: 2rem; animation: pk-fade 200ms ease-out; } @keyframes pk-fade { from { opacity: 0; } to { opacity: 1; } } %pk-glass { background: rgba(255,255,255,0.06); backdrop-filter: blur(24px) saturate(160%); border: 1px solid rgba(255,255,255,0.12); box-shadow: 0 20px 60px rgba(0,0,0,0.5); border-radius: 1.5rem; } .pin-kiosk__panel { @extend %pk-glass; padding: 1.75rem 2rem; width: min(360px, 90%); display: flex; flex-direction: column; align-items: center; gap: 0.75rem; } .pin-kiosk__av { width: 64px; height: 64px; border-radius: 50%; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: 700; color: #fff; background-color: hsl(var(--pk-h), 60%, 45%); border: 2px solid rgba(255,255,255,0.25); } .pin-kiosk__name { font-size: 1.25rem; font-weight: 600; } .pin-kiosk__sub { font-size: 0.85rem; color: var(--pk-text-muted); margin-top: -0.3rem; } .pin-kiosk__dots { display: flex; gap: 0.85rem; margin: 0.5rem 0; } .pin-kiosk__dot { width: 0.85rem; height: 0.85rem; border-radius: 50%; border: 2px solid hsla(var(--pk-h),80%,70%,0.8); &.on { background: hsl(var(--pk-h),80%,65%); border-color: hsl(var(--pk-h),80%,65%); } } .pin-kiosk__pad { display: grid; grid-template-columns: repeat(3, 4rem); gap: 0.6rem; } .pin-kiosk__key { height: 3.25rem; border-radius: 0.85rem; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); color: var(--pk-text); font-size: 1.4rem; font-weight: 300; cursor: pointer; display: flex; align-items: center; justify-content: center; &:active { transform: scale(0.95); background: rgba(255,255,255,0.14); } &.ok { background: hsl(var(--pk-h),80%,45%); border-color: transparent; } } .pin-kiosk__cancel { margin-top: 0.3rem; color: var(--pk-text-muted); font-size: 0.85rem; cursor: pointer; background: none; border: none; } .pin-kiosk__err { min-height: 1.1rem; color: var(--pk-error); font-size: 0.9rem; } .pin-kiosk__panel.shake { animation: pk-shake 350ms ease-in-out; } @keyframes pk-shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-10px)} 40%{transform:translateX(10px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(6px)} } // Result card .pin-kiosk__result { @extend %pk-glass; padding: 2.25rem 3rem; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; text-align: center; width: min(420px, 90%); border-color: rgba(24,169,87,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 80px rgba(24,169,87,0.35); &--error { border-color: rgba(217,55,78,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 60px rgba(217,55,78,0.3); } } .pin-kiosk__check { width: 74px; height: 74px; border-radius: 50%; background: rgba(24,169,87,0.18); border: 2px solid rgba(24,169,87,0.6); display: flex; align-items: center; justify-content: center; font-size: 2rem; color: #34d399; } .pin-kiosk__result .name { font-size: 1.6rem; font-weight: 600; } .pin-kiosk__result .action { font-size: 1.2rem; color: #34d399; font-weight: 500; } .pin-kiosk__result .meta { font-size: 0.9rem; color: var(--pk-text-muted); } // Photo capture (reuse the NFC oval-guide pattern) .pin-kiosk__photo { @extend %pk-glass; padding: 1.5rem; width: min(540px,86%); text-align: center; .stage { position: relative; aspect-ratio: 3/4; height: 56vh; max-height: 480px; margin: 0 auto; border-radius: 1rem; overflow: hidden; background: #000; } video, img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } video { transform: scaleX(-1); } .guide { position: absolute; top: 47%; left: 50%; width: 64%; aspect-ratio: 3/4; transform: translate(-50%,-50%); border: 3px dashed rgba(255,255,255,0.92); border-radius: 50%; box-shadow: 0 0 0 9999px rgba(0,0,0,0.5); } .countdown { position: absolute; top: 47%; left: 50%; transform: translate(-50%,-50%); font-size: 5rem; font-weight: 200; color: #fff; text-shadow: 0 2px 24px rgba(0,0,0,0.85); } } @media (prefers-reduced-motion: reduce) { .pin-kiosk::before, .pin-kiosk__panel.shake, .pin-kiosk__result { animation: none; } } ``` - [ ] **Step 2: Register in the manifest** — in `__manifest__.py` `web.assets_frontend`, add after `nfc_kiosk.scss`: ```python 'fusion_clock/static/src/scss/pin_kiosk.scss', ``` - [ ] **Step 3: Force-compile to verify the SCSS is valid** — run: ```bash docker exec odoo-dev-app odoo shell -d fusion-dev --no-http 2>/dev/null <<'PY' env['ir.qweb']._get_asset_bundle('web.assets_frontend').css() print('FRONTEND BUNDLE OK') PY ``` Expected: `FRONTEND BUNDLE OK`, no Sass error. (If `min()`/mixed-unit or `@extend` errors appear, fix before moving on.) - [ ] **Step 4: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add -- fusion_clock/static/src/scss/pin_kiosk.scss fusion_clock/__manifest__.py git diff --cached --name-only git commit --only -- fusion_clock/static/src/scss/pin_kiosk.scss fusion_clock/__manifest__.py \ -m "feat(fusion_clock): PIN kiosk SCSS (glass + brand-gradient, scoped)" ``` --- ## Task 5: Template — rebuild `views/kiosk_templates.xml` **Files:** - Modify: `views/kiosk_templates.xml` - Modify: `controllers/clock_kiosk.py` (`kiosk_page` context) - [ ] **Step 1: Update `kiosk_page` context** — in `controllers/clock_kiosk.py`, replace the `values = {...}` in `kiosk_page` with: ```python company = request.env.company.sudo() location = company.x_fclk_nfc_kiosk_location_id values = { 'page_name': 'kiosk', 'company_name': company.name, 'company_logo_url': '/web/image/res.company/%s/logo' % company.id if company.logo else '', 'location_name': location.name if location else 'No location configured', 'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True', 'photo_required': ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True', } ``` - [ ] **Step 2: Replace `views/kiosk_templates.xml`** with: ```xml ``` - [ ] **Step 3: Apply + verify the template loads** — run: ```bash docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -15 ``` Expected: no ParseError on `fusion_clock.kiosk_page`. - [ ] **Step 4: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add -- fusion_clock/views/kiosk_templates.xml fusion_clock/controllers/clock_kiosk.py git diff --cached --name-only git commit --only -- fusion_clock/views/kiosk_templates.xml fusion_clock/controllers/clock_kiosk.py \ -m "feat(fusion_clock): PIN kiosk template (logo, clock, search, grid, state container)" ``` --- ## Task 6: JS — rewrite `fusion_clock_kiosk.js` as an Interaction **Files:** - Modify: `static/src/js/fusion_clock_kiosk.js` - [ ] **Step 1: Replace the file** with an Odoo-19 Interaction. Full implementation: ```javascript /** @odoo-module **/ import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; export class PinKiosk extends Interaction { static selector = "#pin_kiosk_root"; setup() { this.root = this.el; this.grid = this.el.querySelector("#pin_kiosk_grid"); this.searchEl = this.el.querySelector("#pin_kiosk_search"); this.stage = this.el.querySelector("#pin_state_container"); this.photoRequired = this.el.dataset.photo === "1"; this.soundsOn = this.el.dataset.sounds === "1"; this.employees = []; this.filtered = []; } async willStart() { const res = await rpc("/fusion_clock/kiosk/search", { query: "" }); this.employees = res.employees || []; this.filtered = this.employees; } start() { this.initBrandHue(); this.startClock(); this.renderGrid(); this.searchEl.addEventListener("input", () => this.onSearch()); } // ---- brand hue (mirrors fusion_clock_nfc_kiosk.js) ---- rgbToHue(r, g, b) { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; if (d === 0) return null; let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h = Math.round(h * 60); if (h < 0) h += 360; return h; } extractHue(img) { try { const w = img.naturalWidth, h = img.naturalHeight; if (!w || !h) return null; const c = document.createElement("canvas"); c.width = w; c.height = h; const ctx = c.getContext("2d"); ctx.drawImage(img, 0, 0); const data = ctx.getImageData(0, 0, w, h).data; let rs = 0, gs = 0, bs = 0, n = 0; for (let i = 0; i < data.length; i += 4) { const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3]; if (a < 128) continue; if (Math.max(r,g,b) - Math.min(r,g,b) < 25) continue; rs += r; gs += g; bs += b; n++; } if (n < 20) return null; return this.rgbToHue(Math.round(rs/n), Math.round(gs/n), Math.round(bs/n)); } catch (e) { return null; } } initBrandHue() { const img = this.el.querySelector("#pin_kiosk_logo"); if (!img) return; const apply = () => { const hue = this.extractHue(img); if (hue != null) document.documentElement.style.setProperty("--pk-h", String(hue)); }; if (img.complete) apply(); else img.addEventListener("load", apply); } // ---- clock ---- startClock() { const tick = () => { const d = new Date(); let h = d.getHours(); const m = String(d.getMinutes()).padStart(2, "0"); const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; this.el.querySelector("#pin_kiosk_clock").innerHTML = `${h}:${m}${ap}`; this.el.querySelector("#pin_kiosk_date").textContent = d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); }; tick(); this._clockTimer = setInterval(tick, 1000); } // ---- grid ---- initials(name) { return (name||"").split(" ").filter(Boolean).slice(0,2).map(p=>p[0].toUpperCase()).join(""); } onSearch() { const q = this.searchEl.value.trim().toLowerCase(); this.filtered = q ? this.employees.filter(e => e.name.toLowerCase().includes(q)) : this.employees; this.renderGrid(); } renderGrid() { this.grid.innerHTML = ""; for (const emp of this.filtered) { const tile = document.createElement("div"); tile.className = "pin-kiosk__tile"; const av = document.createElement("div"); av.className = "pin-kiosk__tile-av"; if (emp.avatar_url) av.style.backgroundImage = `url(${emp.avatar_url})`; av.textContent = emp.avatar_url ? "" : this.initials(emp.name); const nm = document.createElement("div"); nm.className = "pin-kiosk__tile-nm"; nm.textContent = emp.name; tile.append(av, nm); tile.addEventListener("click", () => this.onTile(emp)); this.grid.appendChild(tile); } } // ---- PIN / setup overlay ---- onTile(emp) { this.current = emp; this.pinBuf = ""; this.attempts = 0; if (emp.has_pin) this.showPin(emp, "Enter your PIN", false); else this.showPin(emp, "Create a PIN", true); // first-use } showPin(emp, sub, isSetup, confirming) { this.stage.innerHTML = ""; const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; const panel = document.createElement("div"); panel.className = "pin-kiosk__panel"; panel.innerHTML = `
${emp.avatar_url ? "" : this.initials(emp.name)}
${emp.name}
${confirming ? "Re-enter to confirm" : sub}
`; if (emp.avatar_url) panel.querySelector(".pin-kiosk__av").style.backgroundImage = `url(${emp.avatar_url})`; const pad = panel.querySelector(".pin-kiosk__pad"); const keys = ["1","2","3","4","5","6","7","8","9","⌫","0","✓"]; for (const k of keys) { const b = document.createElement("button"); b.className = "pin-kiosk__key" + (k === "✓" ? " ok" : ""); b.textContent = k; b.addEventListener("click", () => this.onKey(k, emp, isSetup, confirming)); pad.appendChild(b); } panel.querySelector(".pin-kiosk__cancel").addEventListener("click", () => this.reset()); ov.appendChild(panel); this.stage.appendChild(ov); this._panel = panel; this.renderDots(); } renderDots() { const dots = this._panel.querySelector(".pin-kiosk__dots"); dots.innerHTML = ""; const len = Math.max(4, this.pinBuf.length); for (let i = 0; i < len; i++) { const d = document.createElement("span"); d.className = "pin-kiosk__dot" + (i < this.pinBuf.length ? " on" : ""); dots.appendChild(d); } } err(msg) { const e = this._panel.querySelector(".pin-kiosk__err"); e.textContent = msg; this._panel.classList.add("shake"); setTimeout(() => this._panel.classList.remove("shake"), 360); } onKey(k, emp, isSetup, confirming) { if (k === "⌫") { this.pinBuf = this.pinBuf.slice(0, -1); this.renderDots(); return; } if (k === "✓") { this.submitPin(emp, isSetup, confirming); return; } if (this.pinBuf.length < 6) { this.pinBuf += k; this.renderDots(); } if (this.pinBuf.length >= 4 && !isSetup) { /* allow ✓; no auto-submit */ } } async submitPin(emp, isSetup, confirming) { const pin = this.pinBuf; if (pin.length < 4) return this.err("PIN must be at least 4 digits"); if (isSetup && !confirming) { // first entry of new PIN → confirm this._newPin = pin; this.pinBuf = ""; return this.showPin(emp, "Create a PIN", true, true); } if (isSetup && confirming) { if (pin !== this._newPin) { this.pinBuf = ""; this.renderDots(); return this.err("PINs didn't match"); } const r = await rpc("/fusion_clock/kiosk/set_pin", { employee_id: emp.id, pin }); if (r.error) return this.err("Couldn't save PIN"); return this.afterPin(emp); } const v = await rpc("/fusion_clock/kiosk/verify_pin", { employee_id: emp.id, pin }); if (v.success) return this.afterPin(emp); this.attempts++; this.pinBuf = ""; this.renderDots(); if (this.attempts >= 3) return this.reset(); this.err("Wrong PIN — try again"); } // ---- photo (optional) then clock ---- async afterPin(emp) { let photo = ""; if (this.photoRequired) { try { photo = await this.capturePhoto(emp); } catch (e) { photo = ""; } } const r = await rpc("/fusion_clock/kiosk/clock", { employee_id: emp.id, photo_b64: photo }); this.showResult(emp, r); } showResult(emp, r) { this.stage.innerHTML = ""; const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; const card = document.createElement("div"); if (r && r.success) { card.className = "pin-kiosk__result"; const act = r.action === "clock_out" ? "Clocked Out" : "Clocked In"; card.innerHTML = `
${emp.name}
${act}
${r.message || ""}
`; if (this.soundsOn) this.beep(); } else { card.className = "pin-kiosk__result pin-kiosk__result--error"; card.innerHTML = `
!
${emp.name}
Couldn't clock
${(r && r.error) || "Try again"}
`; } ov.appendChild(card); this.stage.appendChild(ov); setTimeout(() => this.reset(), 3000); } beep() { try { const a = new (window.AudioContext || window.webkitAudioContext)(); const o = a.createOscillator(); o.frequency.value = 880; o.connect(a.destination); o.start(); o.stop(a.currentTime + 0.12); } catch (e) {} } // ---- camera capture (mirrors the NFC kiosk; oval guide + 3s countdown) ---- capturePhoto(emp) { return new Promise(async (resolve, reject) => { let stream; try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" } }); } catch (e) { return reject(e); } this.stage.innerHTML = ""; const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; const panel = document.createElement("div"); panel.className = "pin-kiosk__photo"; panel.innerHTML = `

${emp.name}

`; ov.appendChild(panel); this.stage.appendChild(ov); const video = panel.querySelector("video"); video.srcObject = stream; const cd = panel.querySelector(".countdown"); let n = 3; cd.textContent = n; const timer = setInterval(() => { n--; if (n > 0) { cd.textContent = n; return; } clearInterval(timer); const c = document.createElement("canvas"); c.width = video.videoWidth; c.height = video.videoHeight; c.getContext("2d").drawImage(video, 0, 0); stream.getTracks().forEach(t => t.stop()); resolve(c.toDataURL("image/jpeg", 0.8)); }, 1000); }); } reset() { this.stage.innerHTML = ""; this.pinBuf = ""; this.current = null; this._newPin = null; this.searchEl.value = ""; this.filtered = this.employees; this.renderGrid(); // refresh checked-in state in the background rpc("/fusion_clock/kiosk/search", { query: "" }).then(res => { this.employees = res.employees || []; }); } destroy() { if (this._clockTimer) clearInterval(this._clockTimer); } } registry.category("public.interactions").add("fusion_clock.pin_kiosk", PinKiosk); ``` - [ ] **Step 2: Syntax-check** — run: ```bash docker exec odoo-dev-app node --check /mnt/extra-addons/custom/fusion_clock/static/src/js/fusion_clock_kiosk.js 2>&1 | tail -3 || echo "(node unavailable — rely on browser load in Task 7)" ``` Expected: no syntax error. - [ ] **Step 3: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add -- fusion_clock/static/src/js/fusion_clock_kiosk.js git diff --cached --name-only git commit --only -- fusion_clock/static/src/js/fusion_clock_kiosk.js \ -m "feat(fusion_clock): PIN kiosk Interaction (grid, PIN/setup, photo, clock)" ``` --- ## Task 7: Version bump, full upgrade + tests, manual smoke, deploy **Files:** - Modify: `__manifest__.py` (version), `fusion_clock/CLAUDE.md` - [ ] **Step 1: Bump version** — `__manifest__.py` version → `19.0.4.0.0` (new feature). - [ ] **Step 2: Update module docs** — in `fusion_clock/CLAUDE.md`: in the kiosk section note the classic kiosk is now the polished PIN kiosk (photo-tile → PIN → optional selfie, brand-gradient, app icon, opt-in via `enable_kiosk`); remove `fusion_clock.kiosk_pin_required` from the §11 settings-keys list. - [ ] **Step 3: Full upgrade + run the suite** ```bash docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \ -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 ``` Expected: upgrade succeeds; `test_clock_kiosk` passes; existing tests still pass; `0 failed, 0 error`. - [ ] **Step 4: Manual browser smoke (local)** — http://localhost:8082: as a manager, set `enable_kiosk` ON + a Kiosk Location, open `/fusion_clock/kiosk`. Confirm: logo pill + brand gradient + live clock; the photo-tile grid; search filters; tapping a tile opens the PIN pad; a no-PIN employee gets the create+confirm flow; correct PIN → (selfie if Photo Verification ON) → success card → auto-return; wrong PIN shakes. Toggle Photo Verification and confirm the selfie step appears/disappears. - [ ] **Step 5: Commit** ```bash cd /Users/gurpreet/Github/Odoo-Modules git add -- fusion_clock/__manifest__.py fusion_clock/CLAUDE.md git diff --cached --name-only git commit --only -- fusion_clock/__manifest__.py fusion_clock/CLAUDE.md \ -m "chore(fusion_clock): bump 19.0.4.0.0 (PIN kiosk) + docs" ``` - [ ] **Step 6: Push both remotes + deploy entech** ```bash cd /Users/gurpreet/Github/Odoo-Modules git log origin/main..HEAD --oneline git push origin main && git push gitea main ``` Then deploy the whole `fusion_clock` dir to entech (tar excluding `.superpowers`/`__pycache__`/`*.pyc`/`.DS_Store` → scp pve-worker5 → pct push 111 → extract → chown odoo:odoo → upgrade as `odoo` user with `--http-port=0 --gevent-port=0`). Verify web/login → 200, version `19.0.4.0.0`, and (read-only) the `/fusion_clock/kiosk` page renders for the operator. Hard-refresh the tablet. (entech can keep using the NFC kiosk; the PIN kiosk is opt-in via `enable_kiosk` + the PIN Kiosk app icon.) --- ## Self-Review (completed inline) - **Spec coverage:** §3.1 flow → Tasks 5/6; §3.2 style → Task 4; §3.3 backend (search/verify/set_pin/clock) → Tasks 1–2; §3.4 JS Interaction → Task 6; §3.5 template → Task 5; §3.6 settings/menu/location/PIN → Task 3 (+ company relabel); photo master-gate → Task 2; tests → Tasks 1–2 + Task 7; deploy → Task 7. - **Placeholder scan:** none — complete code for backend/tests/config/template/JS/SCSS; commands have expected output. - **Type/name consistency:** routes `/fusion_clock/kiosk/{search,verify_pin,set_pin,clock}` match between controller (Tasks 1–2), JS `rpc(...)` calls (Task 6), and tests. Return keys (`employees[].{has_pin,avatar_url}`, `needs_setup`, `invalid_pin`, `already_set`, `bad_pin`, `no_location_configured`, `success/action/message/net_hours`) consistent across controller, JS, tests. DOM ids (`#pin_kiosk_root`, `#pin_kiosk_grid`, `#pin_kiosk_search`, `#pin_state_container`, `#pin_kiosk_logo`, `#pin_kiosk_clock`, `#pin_kiosk_date`) match between template (Task 5), JS (Task 6), and SCSS scoping (Task 4). `--pk-h` used in SCSS + set in JS. `x_fclk_nfc_kiosk_location_id` used consistently as the kiosk location. - **Scope:** single feature; one plan.