From 930af97f057147aa539451c6d3de34cfc67a3093 Mon Sep 17 00:00:00 2001
From: "Foluso ADEBISI (DevBox Aux)"
Date: Thu, 1 Oct 2026 02:03:07 +0100
Subject: [PATCH] feat(ui): rebuild all tools pages on Carbon Design System web
components
- @carbon/web-components v2.50.0 via the IBM CDN (version-pinned);
@carbon/styles CSS + IBM Plex Sans for tokens/typography
- landing: Carbon header + cds-tile tool cards
- fingerprint wizard: Carbon-styled form controls, cds-progress-bar scan
progress, cds-tag badges, cds-inline-notification replacing swal
- cropper: cds-toast replacing toastr, cds-checkbox review reasons,
cds-tag process badge; vue-croppa pad kept in the Carbon layout
- Bootstrap 4, swal, toastr and jQuery removed from all pages
- behavior frozen: same flows, endpoints, validation and copy
---
.../.openspec.yaml | 2 +
.../2026-10-01-carbon-ui-redesign/design.md | 60 ++++
.../2026-10-01-carbon-ui-redesign/proposal.md | 42 +++
.../specs/tools-app/spec.md | 16 +
.../2026-10-01-carbon-ui-redesign/tasks.md | 13 +
openspec/specs/tools-app/spec.md | 15 +-
tools/public/assets/cropper/app.js | 112 ++++---
tools/public/assets/cropper/index.html | 89 ++---
tools/public/assets/fp/app.js | 65 ++--
tools/public/assets/fp/index.html | 87 ++---
tools/public/assets/style.css | 311 ++++++++----------
tools/public/index.html | 53 +--
12 files changed, 522 insertions(+), 343 deletions(-)
create mode 100644 openspec/changes/archive/2026-10-01-carbon-ui-redesign/.openspec.yaml
create mode 100644 openspec/changes/archive/2026-10-01-carbon-ui-redesign/design.md
create mode 100644 openspec/changes/archive/2026-10-01-carbon-ui-redesign/proposal.md
create mode 100644 openspec/changes/archive/2026-10-01-carbon-ui-redesign/specs/tools-app/spec.md
create mode 100644 openspec/changes/archive/2026-10-01-carbon-ui-redesign/tasks.md
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
-
+
-
-
-
+
+
-
-
-
+
+ Dequeue only
+ Change review
@@ -171,11 +185,6 @@
-
-
-
-
-
@@ -20,9 +25,8 @@
YOUreka
Tools
-
- {{ agent.connected ? 'Agent connected' : 'Agent offline' }}
-
+ {{ agent.connected ? 'Agent connected' : 'Agent offline' }}
{{ initials }}
{{ session.firstName || session.phone }}
@@ -43,9 +47,11 @@
- {{ 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 @@
-