diff --git a/openspec/changes/archive/2026-10-01-carbon-ui-redesign/.openspec.yaml b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/.openspec.yaml new file mode 100644 index 0000000..480ad4d --- /dev/null +++ b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-01 diff --git a/openspec/changes/archive/2026-10-01-carbon-ui-redesign/design.md b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/design.md new file mode 100644 index 0000000..5b71ffe --- /dev/null +++ b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/design.md @@ -0,0 +1,60 @@ +# Design + +## Context + +Pages are Vue 2 + Bootstrap 4 via CDN with swal/toastr dialogs; behavior and +endpoints are frozen for this change (see proposal.md). Carbon web components +v2.50.0 are available as bundled per-component `.min.js` artifacts on IBM's CDN +(verified: button, text-input, dropdown, notification [inline-notification + +toast], progress-bar, tag, checkbox, textarea, tile, ui-shell, modal, loading); +`@carbon/styles` CSS (jsdelivr) provides tokens + Carbon-styled native +controls; IBM Plex Sans comes from Google Fonts. + +## Goals / Non-Goals + +**Goals:** Carbon look across landing + both tools with zero build step; +Vue 2 kept as the thin state layer; server untouched. + +**Non-Goals:** Framework rewrite; UX/flow changes; self-hosting assets; +replacing vue-croppa. + +## Decisions + +1. **CDN delivery, version-pinned (v2.50.0)** — same pattern as the existing + Vue/axios CDNs; zero tooling. Self-hosting is the fallback if CDN + availability becomes a field problem. +2. **Hybrid controls** — true web components where Vue interacts lightly + (cds-button click, cds-tag, cds-inline-notification, cds-toast, + cds-progress-bar, cds-tile); Carbon-styled native controls where v-model + matters (text inputs, selects, checkboxes, textarea use + `cds--text-input`/`cds--select-input`/`cds--checkbox` classes from + @carbon/styles CSS). Avoids value/event adapter friction with shadow-DOM + inputs. +3. **Notification models replace dialogs** — wizard: one inline-notification + slot (`notif` state, auto-dismiss on success); cropper: a fixed + `cds-toast` viewport fed by a `toasts` array with timed removal. Native + `confirm()` stays for dequeue (behavior frozen). +4. **`Vue.config.ignoredElements = [/^cds-/]`** plus a + `customElements.whenDefined('cds-button').then($forceUpdate)` so + pre-upgrade property bindings (disabled, progress value) re-apply after + upgrade. +5. **style.css rewritten on Carbon tokens** (layout helpers kept: grid, gaps, + details grid, editor grid, croppa pad, footer). + +## Risks / Trade-offs + +- [Shadow-DOM inputs would fight Vue v-model] → native controls for fields; + web components only for click/display surfaces. +- [IBM CDN availability] → pinned version; self-hosting is a drop-in swap of + the script/link URLs. +- [Lit property set before upgrade] → forceUpdate after whenDefined; disabled + states verified in the visual harness. + +## Migration Plan + +Rewrite pages/styles → visual harness screenshots (landing, wizard, cropper at +390/768/1280) → unit tests (server untouched) → docker smoke → walkthrough. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-01-carbon-ui-redesign/proposal.md b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/proposal.md new file mode 100644 index 0000000..071944d --- /dev/null +++ b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/proposal.md @@ -0,0 +1,42 @@ +# Proposal: carbon-ui-redesign + +## Why + +The tools pages are styled with ad-hoc Bootstrap 4 overrides and ad-hoc dialogs +(swal/toastr). Adopting the Carbon Design System gives the two tools a single +professional design language (IBM Plex typography, Carbon components, Carbon +tokens) consistent with the wider YOUreka ecosystem. + +## What Changes + +- **BREAKING (visual only)**: All three surfaces — landing, `/fingerprint` + wizard, `/cropper` — are rebuilt on Carbon Design System web components + (`@carbon/web-components` v2.50.0 via the IBM CDN, version-pinned) with IBM + Plex Sans typography and Carbon tokens for layout/colors. +- Bootstrap 4, swal, toastr and jQuery are removed from the pages; errors and + confirmations surface as Carbon inline notifications and toasts. +- Form fields use Carbon-styled native controls (text input, select, checkbox, + textarea) so Vue state bindings are unchanged; buttons, tags, notifications, + toasts, progress bar and tiles are Carbon web components. +- **Behavior is frozen**: flows, endpoints, validation and copy stay exactly as + they are. The vue-croppa crop pad is kept (no Carbon equivalent), framed in a + Carbon tile/panel. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `tools-app`: adds a requirement that the tools UI is built on Carbon Design + System web components with Carbon notifications. + +## Impact + +- Pages and `style.css` rewritten; `app.js` files adapted (notification/toast + models replace swal/toastr calls; `Vue.config.ignoredElements` for cds-*). +- No server-side changes; no new npm dependencies; component JS + Plex font + + Carbon CSS delivered via CDN (IBM CDN for components, jsdelivr for + @carbon/styles, Google Fonts for Plex). diff --git a/openspec/changes/archive/2026-10-01-carbon-ui-redesign/specs/tools-app/spec.md b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/specs/tools-app/spec.md new file mode 100644 index 0000000..1b12c6d --- /dev/null +++ b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/specs/tools-app/spec.md @@ -0,0 +1,16 @@ +# Spec Delta: tools-app + +## ADDED Requirements + +### Requirement: Carbon Design System UI +The tools pages SHALL be built on the Carbon Design System: Carbon web +components (version-pinned via CDN) for buttons, notifications, toasts, tags, +progress and tiles; Carbon-styled native form controls; IBM Plex Sans +typography and Carbon color tokens for layout. Errors, confirmations and +successes SHALL surface as Carbon notifications rather than ad-hoc alert +dialogs. + +#### Scenario: Pages render with Carbon components +- **WHEN** any tools page is opened +- **THEN** its interactive controls are Carbon components (or Carbon-styled + native controls) and its notifications render as Carbon notifications diff --git a/openspec/changes/archive/2026-10-01-carbon-ui-redesign/tasks.md b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/tasks.md new file mode 100644 index 0000000..8ffff67 --- /dev/null +++ b/openspec/changes/archive/2026-10-01-carbon-ui-redesign/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +- [x] 1. Retheme `style.css` on Carbon tokens (Plex, dark header, layers, + sharp corners); drop Bootstrap/swal/toastr/jQuery from all pages; load + pinned Carbon CDN artifacts + Plex + @carbon/styles CSS. +- [x] 2. Rebuild the landing page (Carbon header + cds-tile tool cards). +- [x] 3. Rebuild the fingerprint wizard with Carbon components (inputs, + dropdown, cds-progress-bar scan progress, cds-tag badges, + cds-inline-notification replacing swal); verify at 390/768/1280 via the + visual harness. +- [x] 4. Rebuild the cropper page with Carbon components (cds-toast replacing + toastr, cds-checkbox reasons, cds-tag); verify at 390/768/1280. +- [x] 5. Run unit tests + docker smoke; sync/archive the change and commit. diff --git a/openspec/specs/tools-app/spec.md b/openspec/specs/tools-app/spec.md index 17980ff..83f1d63 100644 --- a/openspec/specs/tools-app/spec.md +++ b/openspec/specs/tools-app/spec.md @@ -132,4 +132,17 @@ environment variables and SHALL NOT be stored in source. #### Scenario: Cropper credentials from the environment - **WHEN** the cropper credentials are supplied as environment variables - **THEN** the cropper routes use them without any change to the hardcoded - URL/port configuration \ No newline at end of file + URL/port configuration + +### Requirement: Carbon Design System UI +The tools pages SHALL be built on the Carbon Design System: Carbon web +components (version-pinned via CDN) for buttons, notifications, toasts, tags, +progress and tiles; Carbon-styled native form controls; IBM Plex Sans +typography and Carbon color tokens for layout. Errors, confirmations and +successes SHALL surface as Carbon notifications rather than ad-hoc alert +dialogs. + +#### Scenario: Pages render with Carbon components +- **WHEN** any tools page is opened +- **THEN** its interactive controls are Carbon components (or Carbon-styled + native controls) and its notifications render as Carbon notifications diff --git a/tools/public/assets/cropper/app.js b/tools/public/assets/cropper/app.js index 0f125b3..f33e22d 100644 --- a/tools/public/assets/cropper/app.js +++ b/tools/public/assets/cropper/app.js @@ -1,32 +1,13 @@ /* Image Cropper — review, crop, clean and save identity photos. Identity comes from the YOUreka session; the only operator credential is - the save-time OTP (required by a2loft). */ -/* global Vue, axios, toastr, Croppa */ -Vue.use(Croppa); + the save-time OTP (required by a2loft). Notifications are Carbon toasts. */ +/* global Vue, axios, Croppa, customElements */ +Vue.config.ignoredElements = [/^cds-/]; + (function () { 'use strict'; - var REVIEW_OPTIONS = [ - 'IMAGE: Not in the midline of the photo frame (aligned to the left or right)', - 'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)', - 'IMAGE: Blurry ', - 'IMAGE: Improper lighting ', - 'IMAGE: Not coloured.', - 'IMAGE: Scanned photo', - 'IMAGE: Has no adequate space to allow for proper cropping ', - 'IMAGE: Not taken on a recommended background.', - 'IMAGE: Photo is a selfie', - 'DRESSING: Unacceptable dress. ', - 'HEAD: Skewed (leaning away from the midline - vertically or horizontally)', - 'EYES: Not looking into the camera (looking downward, upward, or sideward)', - 'EYES: Not properly opened', - 'EARS: Not visible ', - 'EARS: Both ears are not equally visible (face not aligned central)', - 'FACIAL EXPRESSION: NOT neutral (subject has a facial expression)', - 'GLASSES: Subject is wearing glasses ', - 'JUST WRING: Goes against almost all acceptable passport criteria.', - 'Others not specified.', - ]; + var toastSeq = 0; var app = new Vue({ el: '#app', @@ -43,12 +24,31 @@ Vue.use(Croppa); cleanedImage: null, requiresCleaning: '', otp: '', - reviewOptions: REVIEW_OPTIONS.map(function (name, index) { - return { index: index + 1, name: name }; - }), + reviewOptions: [ + 'IMAGE: Not in the midline of the photo frame (aligned to the left or right)', + 'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)', + 'IMAGE: Blurry ', + 'IMAGE: Improper lighting ', + 'IMAGE: Not coloured.', + 'IMAGE: Scanned photo', + 'IMAGE: Has no adequate space to allow for proper cropping ', + 'IMAGE: Not taken on a recommended background.', + 'IMAGE: Photo is a selfie', + 'DRESSING: Unacceptable dress. ', + 'HEAD: Skewed (leaning away from the midline - vertically or horizontally)', + 'EYES: Not looking into the camera (looking downward, upward, or sideward)', + 'EYES: Not properly opened', + 'EARS: Not visible ', + 'EARS: Both ears are not equally visible (face not aligned central)', + 'FACIAL EXPRESSION: NOT neutral (subject has a facial expression)', + 'GLASSES: Subject is wearing glasses ', + 'JUST WRING: Goes against almost all acceptable passport criteria.', + 'Others not specified.', + ].map(function (name, index) { return { index: index + 1, name: name }; }), selectedReviewOptions: [], otherReviewOption: '', busy: { load: false, clean: false, review: false, save: false }, + toasts: [], myCroppa: {}, }, @@ -78,11 +78,31 @@ Vue.use(Croppa); return; } self.loading = false; - toastr.error('Could not load your session. Please refresh the page.', 'Oops!'); + self.toast('error', 'Session error', 'Could not load your session. Please refresh the page.'); + }); + Promise.all( + ['cds-button', 'cds-toast', 'cds-toast-item', 'cds-tag'].map(function (n) { + return customElements.whenDefined(n).catch(function () {}); + }) + ).then(function () { + self.$forceUpdate(); }); }, methods: { + toast: function (kind, title, caption) { + var self = this; + var id = ++toastSeq; + this.toasts.push({ id: id, kind: kind, title: title, caption: caption || '' }); + setTimeout(function () { + self.removeToast(id); + }, 6000); + }, + + removeToast: function (id) { + this.toasts = this.toasts.filter(function (t) { return t.id !== id; }); + }, + reset: function () { this.message = { message: { caption: null, file_url: null, domain: '' } }; this.agent = null; @@ -110,7 +130,7 @@ Vue.use(Croppa); ); if (!data.ok) { this.reset(); // nothing (more) to review — back to the load state - toastr.error(data.message, 'Oops!'); + this.toast('info', 'No picture', data.message || 'Nothing to load.'); return; } this.message = data.data; @@ -119,7 +139,7 @@ Vue.use(Croppa); ); if (!image.data.ok) { this.reset(); - toastr.error("Couldn't load the attached image. Please try again.", 'Oops!'); + this.toast('error', 'Load failed', "Couldn't load the attached image. Please try again."); return; } this.message.message.file_url = image.data.data; @@ -133,14 +153,8 @@ Vue.use(Croppa); this.agent = review.data.ok ? review.data.data : null; } } catch ({ message }) { - toastr.error(message, 'Operation Failed'); + this.toast('error', 'Operation Failed', message); } finally { - this.reviewed = false; - this.accepted = false; - this.selectedReviewOptions = []; - this.otherReviewOption = ''; - this.requiresCleaning = ''; - this.otp = ''; this.busy.load = false; } }, @@ -168,7 +182,7 @@ Vue.use(Croppa); fr.onload = function () { self.cleanedImage = fr.result; }; fr.readAsDataURL(blob); } catch ({ message }) { - toastr.error(message, 'Image Cleanup Failed'); + this.toast('error', 'Image Cleanup Failed', message); } finally { this.busy.clean = false; } @@ -178,7 +192,7 @@ Vue.use(Croppa); var self = this; if (!this.canReview) return; if (!this.accepted && this.selectedReviewOptions.length === 0 && !this.otherReviewOption) { - toastr.error('Please indicate why this image is not good enough.', 'Operation Failed'); + this.toast('error', 'Operation Failed', 'Please indicate why this image is not good enough.'); return; } this.busy.review = true; @@ -206,15 +220,15 @@ Vue.use(Croppa); ...data, }); if (!result.ok) { - toastr.error(result.message, 'Operation Failed'); + this.toast('error', 'Operation Failed', result.message || 'Review failed.'); return; } if (reviewMessage) { - toastr.success('Profile owner has been notified of the review.', 'Review Sent'); + this.toast('success', 'Review Sent', 'Profile owner has been notified of the review.'); } this.reset(); } catch ({ message }) { - toastr.error(message, 'Operation Failed'); + this.toast('error', 'Operation Failed', message); } finally { this.busy.review = false; } @@ -227,21 +241,19 @@ Vue.use(Croppa); try { var { data } = await axios.post('/cropper/dequeue', { message: this.message }); if (!data.ok) { - toastr.error(data.message, 'Not Removed'); + this.toast('error', 'Not Removed', data.message || 'Removal failed.'); return; } - toastr.success(data.message, 'Removed!'); + this.toast('success', 'Removed!', data.message || ''); this.reset(); } catch ({ message }) { - toastr.error(message, 'Not Removed'); + this.toast('error', 'Not Removed', message); } finally { this.busy.review = false; } }, saveChanges: async function () { - var self = this; - if (!this.canSave) return; this.busy.save = true; try { var { data } = await axios.post( @@ -254,13 +266,13 @@ Vue.use(Croppa); } ); if (!data.ok) { - toastr.error(data.message, 'Not Saved'); + this.toast('error', 'Not Saved', data.message || 'Save failed.'); return; } - toastr.success('Picture saved for ' + this.message.message.caption, 'Saved'); + this.toast('success', 'Saved', 'Picture saved for ' + this.message.message.caption); this.reset(); } catch ({ message }) { - toastr.error(message, 'Not Saved'); + this.toast('error', 'Not Saved', message); } finally { this.busy.save = false; } diff --git a/tools/public/assets/cropper/index.html b/tools/public/assets/cropper/index.html index aef7f52..9c30128 100644 --- a/tools/public/assets/cropper/index.html +++ b/tools/public/assets/cropper/index.html @@ -4,13 +4,19 @@ Image Cropper · YOUreka Tools - + + + + + + - - + + + @@ -43,20 +49,28 @@ + +
+ + + +
+
1

Load a picture

- Working on {{ message.message.caption }} · {{ message.message.domain }} + Working on {{ message.message.caption || '(no caption)' }} · {{ message.message.domain }}
- - - + + Load by upload ID + Clear
@@ -69,88 +83,88 @@
-
Phone
{{ message.message.caption }}
+
Phone
{{ message.message.caption || '—' }}
Domain
{{ message.message.domain }}
Agent
{{ agent.name }} {{ agent.phone }}
Process
-
{{ message.message.process.variables.name }} {{ message.message.process.variables.environment }}
+
{{ message.message.process.variables.name }} {{ message.message.process.variables.environment }}
- +
- -
- +

-
Accept picture
-
Reject picture
+ Accept picture + Reject picture
- - + +
- - + + Change review
- +
-
- - +
+ +
- - + +
- - - + + Dequeue only + Change review
@@ -171,11 +185,6 @@
- - - - -
diff --git a/tools/public/assets/fp/app.js b/tools/public/assets/fp/app.js index 52ea78b..305a662 100644 --- a/tools/public/assets/fp/app.js +++ b/tools/public/assets/fp/app.js @@ -1,6 +1,8 @@ /* Fingerprint Agent — enrollment wizard. Captures run through the local fpagent binary; artifacts are saved via the app's a2loft proxy. */ -/* global Vue, axios, swal, io */ +/* global Vue, axios, io, customElements */ +Vue.config.ignoredElements = [/^cds-/]; + (function () { 'use strict'; @@ -25,7 +27,8 @@ staff: null, busy: { lookup: false, save: false }, refreshing: false, - pageError: '', + notif: { show: false, kind: 'info', title: '', subtitle: '' }, + notifTimer: null, fingers: { 'right-thumb': newFinger(), 'right-index-finger': newFinger(), @@ -78,9 +81,26 @@ }) .catch(function () { /* loadSession redirects on 401 */ }); setInterval(function () { self.tick = Date.now(); }, 1000); + // web components upgrade after first render — re-apply reactive bindings + Promise.all( + ['cds-button', 'cds-tag', 'cds-progress-bar', 'cds-inline-notification'].map(function (n) { + return customElements.whenDefined(n).catch(function () {}); + }) + ).then(function () { + self.$forceUpdate(); + }); }, methods: { + showNotif: function (kind, title, subtitle, sticky) { + var self = this; + this.notif = { show: true, kind: kind, title: title, subtitle: subtitle || '' }; + if (this.notifTimer) clearTimeout(this.notifTimer); + if (!sticky) { + this.notifTimer = setTimeout(function () { self.notif.show = false; }, 6000); + } + }, + loadSession: function () { var self = this; return axios.get('/auth/session').then(function (r) { @@ -93,7 +113,7 @@ return; } self.loading = false; - self.pageError = 'Could not load your session. Please refresh the page.'; + self.showNotif('error', 'Session error', 'Could not load your session. Please refresh the page.', true); }); }, @@ -108,12 +128,13 @@ self.domain = self.session.domains[0].code; } if (!self.session.domains.length) { - swal('No permitted domains', 'You are not permitted to capture fingerprints for any domain.', 'info'); + self.showNotif('warning', 'No permitted domains', + 'You are not permitted to capture fingerprints for any domain.', true); } }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not refresh domain permissions.'; - swal('Refresh failed', msg, 'error'); + self.showNotif('error', 'Refresh failed', msg); }).finally(function () { self.refreshing = false; }); @@ -174,12 +195,13 @@ f.status = 'failed'; f.stage = 0; } - swal( + self.showNotif( + wasCancelling ? 'info' : 'error', wasCancelling ? 'Capture cancelled' : 'Capture failed', wasCancelling - ? (restored ? 'The previous capture for the ' + self.fingerLabels[finger].toLowerCase() + ' was kept.' : 'No changes were saved for the ' + self.fingerLabels[finger].toLowerCase() + '.') - : (data && data.message) || 'The capture did not complete.', - wasCancelling ? 'info' : 'error' + ? (restored ? 'The previous capture for the ' + self.fingerLabels[finger].toLowerCase() + ' was kept.' + : 'No changes were saved for the ' + self.fingerLabels[finger].toLowerCase() + '.') + : ((data && data.message) || 'The capture did not complete.') ); }); }, @@ -220,13 +242,13 @@ { params: { otp: this.otpLookup } } ).then(function (r) { if (!r.data.ok) { - swal('Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')', 'error'); + self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')'); return; } self.staff = r.data.data; }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not reach the identity service.'; - swal('Lookup failed', msg, 'error'); + self.showNotif('error', 'Lookup failed', msg); }).finally(function () { self.busy.lookup = false; }); @@ -245,14 +267,15 @@ { params: { otp: this.otpSave } } ).then(function (r) { if (r.data.ok) { - swal('Saved', 'Fingerprints saved for ' + self.staff.name + ' (reference ' + self.staff.reference + ').', 'success'); + self.showNotif('success', 'Enrollment saved', + 'Fingerprints saved for ' + self.staff.name + ' (reference ' + self.staff.reference + ').'); self.newEnrollment(); } else { - swal('Not saved', 'Operation failed. ' + (r.data.message || ''), 'error'); + self.showNotif('error', 'Not saved', 'Operation failed. ' + (r.data.message || '')); } }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not reach the identity service.'; - swal('Save failed', msg, 'error'); + self.showNotif('error', 'Save failed', msg); }).finally(function () { self.busy.save = false; }); @@ -269,18 +292,14 @@ this.prevCapture = null; }, - fingerBadge: function (finger) { + fingerTag: function (finger) { switch (this.fingers[finger].status) { - case 'scanning': return { cls: 'badge-brand', text: 'Scanning' }; - case 'done': return { cls: 'badge-ok', text: 'Captured' }; - case 'failed': return { cls: 'badge-err', text: 'Failed' }; - default: return { cls: 'badge-idle', text: 'Pending' }; + case 'scanning': return { type: 'blue', text: 'Scanning' }; + case 'done': return { type: 'green', text: 'Captured' }; + case 'failed': return { type: 'red', text: 'Failed' }; + default: return { type: 'gray', text: 'Pending' }; } }, - - progressPct: function (stage) { - return Math.round((Math.min(stage, MAX_STAGE) / MAX_STAGE) * 100) + '%'; - }, }, }); })(); diff --git a/tools/public/assets/fp/index.html b/tools/public/assets/fp/index.html index 4dd7736..1ea4a8c 100644 --- a/tools/public/assets/fp/index.html +++ b/tools/public/assets/fp/index.html @@ -4,10 +4,15 @@ Fingerprint Agent · YOUreka Tools - - + + - + + + + + +
@@ -20,9 +25,8 @@ YOUreka Tools
-
{{ pageError }}
+ + - +
1 @@ -55,36 +61,36 @@
- -
- - + +
- - + +
- - +
-

- You are not permitted to capture fingerprints for any domain. Contact your domain administrator if you believe this is a mistake. -

+ +
- +
2 @@ -105,29 +111,29 @@ v-bind:class="{ capturing: capturing === finger, done: fingers[finger].status === 'done' }">

{{ label }}

- {{ fingerBadge(finger).text }} + {{ fingerTag(finger).text }}
-
-
-
-

Scan {{ Math.min(fingers[finger].stage, maxStage) }} of {{ maxStage }} — place the {{ label.toLowerCase() }} on the scanner.

+ + +

Place the {{ label.toLowerCase() }} on the scanner.

Enrollment ID {{ fingers[finger].id }}

The last attempt failed. You can try again.

- - - + + Cancel
- +
3 @@ -135,14 +141,14 @@ Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})
-
- - +
+ +
- +
@@ -162,7 +168,6 @@ - diff --git a/tools/public/assets/style.css b/tools/public/assets/style.css index 700ac6a..a47f347 100644 --- a/tools/public/assets/style.css +++ b/tools/public/assets/style.css @@ -1,202 +1,181 @@ -/* YOUreka Tools — shared styling (Bootstrap 4 base + this overlay) */ -:root { - --ink: #1a2340; - --muted: #64748b; - --faint: #94a3b8; - --line: #e4e8f1; - --bg: #f4f6fb; - --surface: #ffffff; - --brand: #3b4fd8; - --brand-dark: #2f3eb0; - --brand-soft: #eef1fe; - --ok: #15803d; - --ok-soft: #e8f7ee; - --warn: #b45309; - --warn-soft: #fdf3e3; - --err: #b91c1c; - --err-soft: #fdeaea; - --radius: 14px; - --shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 8px 24px -12px rgba(16, 24, 40, 0.12); -} +/* YOUreka Tools — Carbon Design System styling. + Depends on: @carbon/styles CSS (tokens + Carbon-styled native controls), + IBM Plex Sans, and @carbon/web-components v2.50.0 (IBM CDN). */ + +/* ── base ───────────────────────────────────────────────────────────────── */ +[v-cloak] { display: none; } +a { text-decoration: none; } -html, body { height: 100%; } body.app { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: - radial-gradient(1200px 400px at 80% -10%, rgba(59, 79, 216, 0.08), transparent 60%), - var(--bg); - color: var(--ink); - font-size: 14.5px; + font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--cds-background, #f4f4f4); + color: var(--cds-text-primary, #161616); + font-size: 14px; min-height: 100vh; display: flex; flex-direction: column; } -[v-cloak] { display: none; } -a { text-decoration: none; } -/* ── navbar ─────────────────────────────────────────────── */ -.nav { - background: var(--surface); - border-bottom: 1px solid var(--line); - padding: 0.65rem 0; +/* prevent FOUC on web components */ +cds-button:not(:defined), cds-tag:not(:defined), cds-tile:not(:defined), +cds-inline-notification:not(:defined), cds-toast:not(:defined), +cds-toast-item:not(:defined), cds-progress-bar:not(:defined) { + visibility: hidden; } -.nav .container { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } -.brand { display: inline-flex; align-items: center; gap: 0.6rem; font-weight: 700; color: var(--ink); text-decoration: none; } -.brand:hover { text-decoration: none; color: var(--ink); } + +/* ── header (Carbon ui-shell look) ──────────────────────────────────────── */ +.nav { + background: #161616; + height: 48px; + padding: 0; +} +.nav .container { display: flex; align-items: center; justify-content: space-between; gap: 1rem; height: 100%; max-width: none; padding: 0 1rem; } +.brand { display: inline-flex; align-items: center; gap: 0.6rem; font-weight: 600; color: #f4f4f4; font-size: 14px; } +.brand:hover { color: #f4f4f4; } +.brand small { color: #c6c6c6; font-weight: 400; } .brand-mark { - width: 32px; height: 32px; border-radius: 9px; - background: linear-gradient(135deg, var(--brand), var(--brand-dark)); - color: #fff; display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; border-radius: 4px; + background: #0f62fe; color: #fff; + display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; } -.brand-mark svg { width: 18px; height: 18px; } -.brand small { color: var(--muted); font-weight: 500; } -.nav-right { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } -.user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--muted); } +.brand-mark svg { width: 16px; height: 16px; } +.nav-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } +.nav-right .linklike { color: #c6c6c6; font-size: 13px; padding: 0.4rem 0.6rem; } +.nav-right .linklike:hover { color: #f4f4f4; background: #353535; } +.user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: #c6c6c6; font-size: 13px; } .user-chip .avatar { width: 26px; height: 26px; border-radius: 50%; - background: var(--brand-soft); color: var(--brand); + background: #0f62fe; color: #fff; display: inline-flex; align-items: center; justify-content: center; - font-size: 12px; font-weight: 700; + font-size: 11px; font-weight: 700; } -/* ── layout ─────────────────────────────────────────────── */ -main.container { flex: 1 0 auto; } -.narrow { max-width: 560px; margin-left: auto; margin-right: auto; } +/* ── layout ─────────────────────────────────────────────────────────────── */ +main.container { flex: 1 0 auto; max-width: none; padding: 0 1rem; width: 100%; } +main.container > * { max-width: 1136px; margin-left: auto; margin-right: auto; } +main.container > .narrow { max-width: 560px; } +.narrow { max-width: 560px; } .center-v { display: flex; flex-direction: column; justify-content: center; min-height: 70vh; } -.page-head { margin: 2.2rem 0 1.4rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } -.page-head h1 { font-size: 1.45rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; } -.page-head .sub { color: var(--muted); margin: 0.25rem 0 0; } +.page-head { margin: 2rem auto 1.25rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.page-head h1 { font-size: 1.5rem; font-weight: 600; margin: 0; letter-spacing: 0; } +.page-head .sub { color: var(--cds-text-secondary, #525252); margin: 0.25rem 0 0; font-size: 14px; } -/* ── cards ──────────────────────────────────────────────── */ -.card, .tool-card { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); +/* ── panels (Carbon layer) ──────────────────────────────────────────────── */ +.card, .panel, .tool-card, .message-card { + background: var(--cds-layer, #f4f4f4); + border: 1px solid var(--cds-border-subtle, #c6c6c6); + border-radius: 2px; } -.panel { padding: 1.35rem 1.5rem; margin-bottom: 1.25rem; } -.panel-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; } +.panel { padding: 1.25rem 1.5rem 1.5rem; margin-bottom: 1rem; } +.panel-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; flex-wrap: wrap; } .step-chip { - width: 24px; height: 24px; border-radius: 7px; flex: 0 0 auto; - background: var(--brand-soft); color: var(--brand); - font-size: 12.5px; font-weight: 700; + width: 22px; height: 22px; border-radius: 2px; flex: 0 0 auto; + background: var(--cds-layer-selected-inverse, #161616); color: #f4f4f4; + font-size: 12px; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; } -.panel-head h2 { font-size: 1.02rem; font-weight: 700; margin: 0; } -.panel-head .hint { color: var(--faint); font-size: 12.5px; margin-left: auto; } +.panel-head h2 { font-size: 1rem; font-weight: 600; margin: 0; } +.panel-head .hint { color: var(--cds-text-helper, #6f6f6f); font-size: 12px; } -/* landing tool cards */ -.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.25rem; } -.tool-card { display: flex; flex-direction: column; padding: 1.5rem; color: inherit; text-decoration: none; transition: border-color .15s ease, transform .15s ease; } -.tool-card:hover { text-decoration: none; color: inherit; border-color: var(--brand); transform: translateY(-2px); } +/* ── hero + landing ─────────────────────────────────────────────────────── */ +.hero { padding: 2.5rem 0 1.75rem; } +.hero h1 { font-size: 1.75rem; font-weight: 600; margin: 0 0 0.35rem; } +.hero p { color: var(--cds-text-secondary, #525252); max-width: 560px; margin: 0; font-size: 14px; } +.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; max-width: 960px; } +.tool-grid cds-tile { height: 100%; } +.tool-card { display: flex; flex-direction: column; padding: 1.25rem; } +.tool-card h3 { font-size: 1rem; font-weight: 600; margin: 0.75rem 0 0.3rem; } +.tool-card p { color: var(--cds-text-secondary, #525252); margin: 0 0 1rem; font-size: 14px; } .tool-icon { - width: 44px; height: 44px; border-radius: 12px; margin-bottom: 0.9rem; + width: 40px; height: 40px; border-radius: 4px; margin: 0; display: inline-flex; align-items: center; justify-content: center; - background: var(--brand-soft); color: var(--brand); + background: var(--cds-layer-selected-inverse, #161616); color: #fff; } -.tool-icon svg { width: 22px; height: 22px; } -.tool-card h3 { font-size: 1.05rem; font-weight: 700; margin: 0 0 0.3rem; } -.tool-card p { color: var(--muted); margin: 0 0 1rem; } -.tool-card .go { margin-top: auto; color: var(--brand); font-weight: 600; font-size: 13.5px; display: inline-flex; align-items: center; gap: 0.3rem; } +.tool-icon svg { width: 20px; height: 20px; } +.tool-card .go { margin-top: auto; color: var(--cds-link, #0f62fe); font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 0.3rem; } +.tool-card .go svg { width: 14px; height: 14px; } -/* hero */ -.hero { padding: 3.2rem 0 2.2rem; } -.hero h1 { font-size: 1.9rem; font-weight: 800; letter-spacing: -0.02em; margin: 0 0 0.4rem; } -.hero p { color: var(--muted); max-width: 560px; margin: 0; } - -/* ── forms ──────────────────────────────────────────────── */ -label { font-weight: 600; font-size: 13px; color: var(--ink); margin-bottom: 0.3rem; } +/* ── forms (Carbon-styled native controls) ──────────────────────────────── */ +label { font-weight: 400; font-size: 12px; color: var(--cds-text-secondary, #525252); margin-bottom: 0.25rem; display: block; } .form-control { - border-color: var(--line); border-radius: 10px; height: 42px; - box-shadow: none; font-size: 14.5px; color: var(--ink); + border: none; border-bottom: 1px solid var(--cds-border-strong, #8d8d8d); + border-radius: 0; height: 40px; box-shadow: none; + font-size: 14px; color: var(--cds-text-primary, #161616); + background: var(--cds-field, #f4f4f4); padding: 0 0.75rem; width: 100%; + appearance: auto; } -.form-control:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(59, 79, 216, 0.12); } -select.form-control { appearance: auto; } +select.form-control { padding-right: 2rem; } +.form-control:focus { outline: 2px solid var(--cds-focus, #0f62fe); outline-offset: -2px; border-bottom-color: transparent; background: var(--cds-field, #f4f4f4); } +textarea.form-control { height: auto; padding: 0.6rem 0.75rem; } -/* ── buttons ────────────────────────────────────────────── */ -.btn { border-radius: 10px; font-weight: 600; font-size: 14px; padding: 0.55rem 1.1rem; border: 1px solid transparent; text-decoration: none; } -a.btn:hover, a.btn:focus { text-decoration: none; } -.btn:focus { box-shadow: 0 0 0 3px rgba(59, 79, 216, 0.18); } -.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; } -.btn-primary:hover, .btn-primary:focus { background: var(--brand-dark); border-color: var(--brand-dark); color: #fff; } -.btn-ghost { background: transparent; border-color: var(--line); color: var(--ink); } -.btn-ghost:hover { background: var(--bg); border-color: var(--faint); color: var(--ink); } -.btn-danger-ghost { background: transparent; border-color: #f3c6c6; color: var(--err); } -.btn-danger-ghost:hover { background: var(--err-soft); border-color: var(--err); color: var(--err); } -.btn[disabled], .btn.disabled { opacity: 0.55; pointer-events: none; } +/* ── buttons (cds-button handles tool pages; these serve server pages) ──── */ +.btn { + border-radius: 0; font-weight: 400; font-size: 14px; + padding: 0.65rem 0.95rem; border: 1px solid transparent; + text-decoration: none; display: inline-block; cursor: pointer; + font-family: inherit; line-height: 1.2; text-align: center; +} +.btn-primary { background: var(--cds-button-primary, #0f62fe); border-color: var(--cds-button-primary, #0f62fe); color: #fff; } +.btn-primary:hover, .btn-primary:focus { background: #0353e9; border-color: #0353e9; color: #fff; } +.btn-ghost { background: transparent; border-color: var(--cds-border-strong, #8d8d8d); color: var(--cds-text-primary, #161616); } +.btn-ghost:hover { background: var(--cds-layer-hover, #e8e8e8); color: var(--cds-text-primary, #161616); } +.btn-danger { background: var(--cds-button-danger-primary, #da1e28); border-color: var(--cds-button-danger-primary, #da1e28); color: #fff; } +.btn-danger-ghost { background: transparent; border: none; color: var(--cds-button-danger-primary, #da1e28); } +.btn[disabled], .btn.disabled { opacity: 0.5; pointer-events: none; } .btn-block { display: flex; width: 100%; align-items: center; justify-content: center; } -.linklike { background: none; border: none; padding: 0; color: var(--brand); font-weight: 600; cursor: pointer; font-size: 13px; } -.linklike[disabled] { color: var(--faint); cursor: default; } +.linklike { background: none; border: none; padding: 0; color: var(--cds-link, #0f62fe); font-weight: 400; cursor: pointer; font-size: 13px; font-family: inherit; } +.linklike[disabled] { color: var(--cds-text-disabled, #c6c6c6); cursor: default; } -/* ── badges & status ────────────────────────────────────── */ -.badge-soft { - display: inline-flex; align-items: center; gap: 0.35rem; - border-radius: 999px; padding: 0.2rem 0.65rem; - font-size: 12px; font-weight: 600; line-height: 1.4; -} -.badge-soft .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } -.badge-ok { background: var(--ok-soft); color: var(--ok); } -.badge-warn { background: var(--warn-soft); color: var(--warn); } -.badge-err { background: var(--err-soft); color: var(--err); } -.badge-idle { background: #eef2f7; color: var(--muted); } -.badge-brand { background: var(--brand-soft); color: var(--brand); } -.badge-live .dot { animation: pulse 1.6s ease-in-out infinite; } -@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } +/* cds-button inside flex rows */ +cds-button { min-width: 0; } +cds-button::part(button) { font-size: 14px; } -/* ── details grid ───────────────────────────────────────── */ -.details { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0.9rem 1.4rem; margin: 0; } -.details > div { min-width: 0; } -.details dt { font-size: 12px; font-weight: 600; color: var(--faint); text-transform: uppercase; letter-spacing: 0.04em; } -.details dd { margin: 0.15rem 0 0; font-weight: 600; overflow-wrap: anywhere; } - -/* ── finger capture panels ──────────────────────────────── */ -.finger-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem; } -@media (max-width: 720px) { .finger-grid { grid-template-columns: 1fr; } } -.finger { border: 1px solid var(--line); border-radius: 12px; padding: 1.1rem 1.2rem; } -.finger.capturing { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(59, 79, 216, 0.10); } -.finger.done { border-color: #bfe3cd; background: #fbfefc; } -.finger-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; } -.finger-head h3 { font-size: 0.95rem; font-weight: 700; margin: 0; } -.finger .meta { color: var(--faint); font-size: 12.5px; margin-top: 0.6rem; overflow-wrap: anywhere; } - -.progress { height: 8px; border-radius: 999px; background: #edf0f7; overflow: hidden; } -.progress-bar { background: var(--brand); border-radius: 999px; transition: width .25s ease; } -.progress-bar.striped { - background-image: linear-gradient(45deg, rgba(255,255,255,.25) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 75%, transparent 75%, transparent); - background-size: 1rem 1rem; - animation: stripes 0.9s linear infinite; -} -@keyframes stripes { from { background-position: 1rem 0; } to { background-position: 0 0; } } - -/* ── alerts ─────────────────────────────────────────────── */ -.alert-soft { border-radius: 10px; border: 1px solid var(--err-soft); background: var(--err-soft); color: var(--err); font-size: 13.5px; padding: 0.6rem 0.9rem; } - -/* ── editor grid (cropper) ──────────────────────────────── */ -.editor-grid { display: grid; grid-template-columns: 1fr; gap: 1.25rem; } -@media (min-width: 900px) { - .editor-grid { grid-template-columns: 300px 1fr; align-items: start; } -} -.panel-head { flex-wrap: wrap; } -.panel-head .hint { flex-basis: 100%; margin-left: 0; } -@media (min-width: 720px) { - .panel-head .hint { flex-basis: auto; margin-left: auto; } -} - -/* ── footer ─────────────────────────────────────────────── */ -footer.foot { flex-shrink: 0; padding: 1.4rem 0 1.8rem; color: var(--faint); font-size: 12.5px; } -footer.foot .container { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } -footer.foot a { color: var(--muted); } - -/* ── message card (auth errors) ─────────────────────────── */ -.message-card { padding: 2.2rem; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; } -.message-card h1 { font-size: 1.2rem; font-weight: 700; margin: 0; } -.message-icon { width: 52px; height: 52px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; } -.message-icon.error { background: var(--err-soft); color: var(--err); } -.message-card .btn { margin-top: 0.5rem; min-width: 180px; } - -.muted { color: var(--muted); } -.faint { color: var(--faint); font-size: 12.5px; } +/* ── badges → cds-tag; helper text ──────────────────────────────────────── */ +.faint { color: var(--cds-text-helper, #6f6f6f); font-size: 12px; } +.muted { color: var(--cds-text-secondary, #525252); } .mt-1 { margin-top: 0.75rem; } .mt-2 { margin-top: 1.25rem; } .row-gap { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; } +.flex-fill { flex: 1 1 auto; } + +/* ── details grid ───────────────────────────────────────────────────────── */ +.details { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0.9rem 1.4rem; margin: 0 0 0.25rem; } +.details > div { min-width: 0; } +.details dt { font-size: 12px; font-weight: 400; color: var(--cds-text-helper, #6f6f6f); } +.details dd { margin: 0.15rem 0 0; font-weight: 400; font-size: 14px; overflow-wrap: anywhere; } + +/* ── wizard finger panels ───────────────────────────────────────────────── */ +.finger-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 720px) { .finger-grid { grid-template-columns: 1fr; } } +.finger { background: var(--cds-layer, #f4f4f4); border: 1px solid var(--cds-border-subtle, #c6c6c6); padding: 1rem 1.1rem 1.1rem; } +.finger.capturing { border-color: var(--cds-border-interactive, #0f62fe); box-shadow: inset 0 0 0 1px var(--cds-border-interactive, #0f62fe); } +.finger.done { background: var(--cds-layer, #f4f4f4); border-color: var(--cds-border-subtle, #c6c6c6); } +.finger-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; gap: 0.5rem; } +.finger-head h3 { font-size: 0.95rem; font-weight: 600; margin: 0; } +.finger .meta { color: var(--cds-text-helper, #6f6f6f); font-size: 12.5px; margin-top: 0.6rem; overflow-wrap: anywhere; } + +/* ── cropper editor ─────────────────────────────────────────────────────── */ +.editor-grid { display: grid; grid-template-columns: 1fr; gap: 1.25rem; } +@media (min-width: 900px) { .editor-grid { grid-template-columns: 300px 1fr; align-items: start; } } +.croppa-pad { + border: 1px solid var(--cds-border-strong, #8d8d8d); + width: 252px; height: 270px; overflow: hidden; background: var(--cds-field, #f4f4f4); +} +.croppa-pad img { width: 250px; display: block; } +.review-options .form-check { margin-bottom: 0.35rem; } + +/* ── notifications viewport (cropper toasts) ────────────────────────────── */ +.toast-viewport { position: fixed; top: 3.5rem; right: 1rem; z-index: 1100; width: min(360px, calc(100vw - 2rem)); } + +/* ── footer ─────────────────────────────────────────────────────────────── */ +footer.foot { flex-shrink: 0; padding: 1.1rem 0 1.3rem; color: var(--cds-text-helper, #6f6f6f); font-size: 12px; border-top: 1px solid var(--cds-border-subtle, #c6c6c6); } +footer.foot .container { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +footer.foot a { color: var(--cds-text-secondary, #525252); } + +/* ── message card (server error page) ───────────────────────────────────── */ +.message-card { padding: 2.25rem 2rem; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; } +.message-card h1 { font-size: 1.25rem; font-weight: 600; margin: 0; } +.message-icon { width: 52px; height: 52px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: var(--cds-support-error, #da1e28); color: #fff; margin-bottom: 0.4rem; } +.message-icon.error svg { width: 24px; height: 24px; } +.message-card .btn { margin-top: 0.6rem; min-width: 200px; } diff --git a/tools/public/index.html b/tools/public/index.html index 5189a51..3eedcd0 100644 --- a/tools/public/index.html +++ b/tools/public/index.html @@ -4,9 +4,14 @@ YOUreka Tools - + + - + + +