@7365admin1/layer-common 3.2.7 → 3.2.8-staging.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/assets/css/primitives.css +1113 -0
  2. package/assets/css/screens.css +1097 -0
  3. package/assets/css/shell.css +258 -0
  4. package/assets/css/tokens.css +236 -0
  5. package/components/AcceptDialog.vue +17 -18
  6. package/components/AccessCard/AvailableStats.vue +24 -16
  7. package/components/AccessCardAddForm.vue +42 -42
  8. package/components/AccessCardAssignToUnitForm.vue +13 -21
  9. package/components/AccessCardDeleteDialog.vue +41 -39
  10. package/components/AccessCardDetailsDialog.vue +125 -79
  11. package/components/AccessCardPreviewDialog.vue +58 -84
  12. package/components/AccessCardQrTagging.vue +60 -63
  13. package/components/AccessCardReplaceForm.vue +27 -23
  14. package/components/AccessManagement.vue +86 -85
  15. package/components/AddEqupmentForm.vue +18 -22
  16. package/components/AddPassKeyToVisitor.vue +95 -73
  17. package/components/AppButton.vue +62 -0
  18. package/components/AppCard.vue +20 -0
  19. package/components/AppDateField.vue +49 -0
  20. package/components/AppField.vue +76 -0
  21. package/components/AppKpiTile.vue +65 -0
  22. package/components/AppSegmented.vue +38 -0
  23. package/components/AppSelect.vue +247 -0
  24. package/components/AreaChecklistHistoryLogs.vue +51 -44
  25. package/components/AreaChecklistHistoryMain.vue +14 -7
  26. package/components/AreaFormDialog.vue +71 -59
  27. package/components/AreaMain.vue +73 -107
  28. package/components/AttachmentsViewer.vue +84 -22
  29. package/components/AttendanceCheckInOutDialog.vue +85 -88
  30. package/components/AttendanceDetailsDialog.vue +57 -63
  31. package/components/AttendanceMain.vue +4 -3
  32. package/components/AttendanceMapSearchDialog.vue +42 -44
  33. package/components/AttendanceSettingsDialog.vue +79 -63
  34. package/components/Avatar/Main.vue +54 -2
  35. package/components/BillingMain.vue +26 -30
  36. package/components/BtnUploadFile.vue +53 -11
  37. package/components/BuildingForm.vue +34 -27
  38. package/components/BuildingManagement/buildings.vue +39 -39
  39. package/components/BuildingManagement/units.vue +26 -42
  40. package/components/BuildingUnitFormAdd.vue +20 -23
  41. package/components/BuildingUnitFormEdit.vue +8 -4
  42. package/components/BulletinBoardForm.vue +19 -24
  43. package/components/BulletinBoardManagement.vue +49 -57
  44. package/components/BulletinBoardView.vue +9 -6
  45. package/components/BulletinExpirationChip.vue +13 -4
  46. package/components/Button/Close.vue +2 -2
  47. package/components/CameraForm.vue +21 -22
  48. package/components/CameraMain.vue +50 -49
  49. package/components/Card/DeleteConfirmation.vue +24 -22
  50. package/components/Card/MemberInfoSummary.vue +28 -3
  51. package/components/Card/Toggle.vue +15 -3
  52. package/components/CardToolbar.vue +37 -0
  53. package/components/Carousel.vue +56 -36
  54. package/components/Chat/Bubbles.vue +43 -14
  55. package/components/Chat/Information.vue +49 -32
  56. package/components/Chat/ListCard.vue +38 -9
  57. package/components/Chat/Message.vue +41 -15
  58. package/components/Chat/Navigation.vue +27 -15
  59. package/components/Chat/SkeletonLoader.vue +9 -2
  60. package/components/ChecklistItemRow.vue +25 -1
  61. package/components/CleaningScheduleMain.vue +44 -49
  62. package/components/ClientDetailForm.vue +81 -56
  63. package/components/ClientMain.vue +92 -82
  64. package/components/ConfirmDialog.vue +14 -8
  65. package/components/Container/Standard.vue +9 -7
  66. package/components/CreateSubsciptionPlan.vue +148 -89
  67. package/components/DashboardEmptyState.vue +48 -3
  68. package/components/DashboardMain.vue +924 -676
  69. package/components/DashboardPanel.vue +84 -21
  70. package/components/DeliveryCompany.vue +34 -20
  71. package/components/Dialog/DeleteConfirmation.vue +20 -21
  72. package/components/Dialog/ReplaceAutofillPrompt.vue +19 -26
  73. package/components/Dialog/ReusablePrompt.vue +30 -29
  74. package/components/Dialog/UpdateMoreAction.vue +62 -59
  75. package/components/DocumentForm.vue +14 -21
  76. package/components/DocumentManagement.vue +162 -108
  77. package/components/DocumentViewer.vue +29 -12
  78. package/components/DrawImage.vue +29 -21
  79. package/components/EntryPass/QrTemplatePreview.vue +27 -16
  80. package/components/EntryPassInformation.vue +40 -10
  81. package/components/EntryPassMain.vue +49 -27
  82. package/components/EquipmentItemMain.vue +84 -97
  83. package/components/EquipmentManagementMain.vue +81 -103
  84. package/components/Facility/BookingSetup.vue +43 -52
  85. package/components/Feedback/Form.vue +26 -12
  86. package/components/FeedbackMain.vue +35 -49
  87. package/components/FileInput.vue +75 -58
  88. package/components/FileInputWithList.vue +59 -12
  89. package/components/FormDialog.vue +60 -21
  90. package/components/HidAccessLogDashboard.vue +512 -490
  91. package/components/HidCameraCaptureDialog.vue +216 -0
  92. package/components/HidEnabledGate.vue +67 -9
  93. package/components/HidIntercomManagement.vue +1322 -653
  94. package/components/HidQrCodeConfiguration.vue +1030 -258
  95. package/components/HidReaderForm.vue +142 -59
  96. package/components/HidReaderManagement.vue +211 -323
  97. package/components/HidServiceSettingsPanel.vue +44 -13
  98. package/components/HidUserEnrollment.vue +1185 -412
  99. package/components/HygieneUpdateMoreAction.vue +44 -44
  100. package/components/IncidentReport/Authorities.vue +30 -18
  101. package/components/IncidentReport/IncidentInformation.vue +10 -10
  102. package/components/IncidentReport/IncidentInformationDownload.vue +3 -4
  103. package/components/IncidentReport/affectedEntities.vue +41 -20
  104. package/components/Input/Date.vue +3 -0
  105. package/components/Input/DatePicker.vue +1 -2
  106. package/components/Input/DateTimePicker.vue +1 -1
  107. package/components/Input/File.vue +55 -10
  108. package/components/Input/FileV2.vue +39 -6
  109. package/components/Input/InputPhoneNumberV2.vue +2 -1
  110. package/components/Input/ListGroupSelection.vue +56 -8
  111. package/components/Input/NRICNumber.vue +1 -0
  112. package/components/Input/NewDate.vue +1 -0
  113. package/components/Input/Number.vue +1 -0
  114. package/components/Input/Password.vue +1 -0
  115. package/components/Input/PhoneNumber.vue +21 -1
  116. package/components/Input/VehicleNumber.vue +1 -0
  117. package/components/InputLabel.vue +2 -2
  118. package/components/InvitationClientForm.vue +21 -19
  119. package/components/InvitationForm.vue +15 -21
  120. package/components/InvitationMain.vue +46 -61
  121. package/components/Layout/Header.vue +263 -36
  122. package/components/Layout/NavigationDrawer.vue +300 -30
  123. package/components/ListItem.vue +33 -3
  124. package/components/LocalPagination.vue +27 -3
  125. package/components/ManageChecklistMain.vue +136 -86
  126. package/components/MemberInformation.vue +32 -16
  127. package/components/MemberMain.vue +113 -93
  128. package/components/MyAttendanceMain.vue +3 -5
  129. package/components/NavigationItem.vue +83 -3
  130. package/components/Nfc/NFCPatrolReportMain.vue +47 -33
  131. package/components/Nfc/NFCPatrolRouteForm.vue +18 -20
  132. package/components/Nfc/NFCPatrolRouteMain.vue +116 -103
  133. package/components/Nfc/NFCPatrolSettings.vue +14 -4
  134. package/components/Nfc/NFCTagForm.vue +22 -8
  135. package/components/Nfc/NFCTagMain.vue +41 -43
  136. package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
  137. package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
  138. package/components/Nfc/PatrolReport/MonthlyReportTable.vue +5 -3
  139. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +26 -6
  140. package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
  141. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +41 -9
  142. package/components/Nfc/PatrolReport/RemarksDialog.vue +39 -9
  143. package/components/Nfc/PatrolReport/ReportEmptyState.vue +29 -9
  144. package/components/Nfc/PatrolReport/ReportFilters.vue +19 -10
  145. package/components/Nfc/PatrolReport/ReportLocationHeader.vue +32 -6
  146. package/components/Nfc/PatrolReport/SummaryReportTable.vue +28 -6
  147. package/components/NotificationSettings.vue +373 -0
  148. package/components/NumberSettingField.vue +9 -2
  149. package/components/OnlineFormConfigurationForm.vue +725 -237
  150. package/components/OnlineFormFill.vue +213 -106
  151. package/components/OnlineFormRenderPreview.vue +25 -30
  152. package/components/OnlineFormsConfiguration.vue +113 -109
  153. package/components/OrgViewDialog.vue +34 -16
  154. package/components/OvernightParkingAvailability.vue +49 -26
  155. package/components/OvernightParkingManagement.vue +78 -18
  156. package/components/PageHeader.vue +43 -0
  157. package/components/PassInformation.vue +109 -53
  158. package/components/PasswordConfirmation.vue +44 -26
  159. package/components/PeopleForm.vue +41 -20
  160. package/components/PhotoUpload.vue +110 -48
  161. package/components/PlaceholderComponent.vue +35 -19
  162. package/components/PlanActionsMenu.vue +12 -2
  163. package/components/PlanStatusBadge.vue +7 -8
  164. package/components/PlateNumberDisplay.vue +9 -6
  165. package/components/QrTemplate/PrintDialog.vue +66 -43
  166. package/components/RolePermissionFormCreate.vue +104 -79
  167. package/components/RolePermissionFormPreviewUpdate.vue +111 -109
  168. package/components/RolePermissionMain.vue +183 -81
  169. package/components/ScanVisitorQRCode.vue +99 -35
  170. package/components/ScheduleAreaMain.vue +34 -59
  171. package/components/ScheduleTaskForm.vue +61 -42
  172. package/components/ScheduleTaskMain.vue +56 -69
  173. package/components/SearchVehicleNumberUser.vue +39 -37
  174. package/components/ServiceProviderFormCreate.vue +29 -20
  175. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  176. package/components/ServiceProviderMain.vue +601 -432
  177. package/components/Signature.vue +40 -34
  178. package/components/SignaturePad.vue +5 -29
  179. package/components/SiteSettings.vue +41 -11
  180. package/components/SiteSettingsWorkOrder.vue +61 -30
  181. package/components/SlideCardGroup.vue +35 -11
  182. package/components/Snackbar.vue +36 -3
  183. package/components/SpecificAttr.vue +18 -8
  184. package/components/StatusChip.vue +156 -0
  185. package/components/StockCard.vue +17 -12
  186. package/components/SubscriptionPlanTable.vue +207 -140
  187. package/components/SwitchContext.vue +201 -9
  188. package/components/TableHygiene.vue +36 -46
  189. package/components/TableList.vue +10 -10
  190. package/components/TableListSecondary.vue +105 -54
  191. package/components/TableMain.vue +251 -78
  192. package/components/TableV3.vue +61 -70
  193. package/components/TiptapEditor.vue +33 -40
  194. package/components/TiptapEditorQr.vue +5 -2
  195. package/components/Tooltip/Info.vue +69 -24
  196. package/components/UnitMain.vue +40 -60
  197. package/components/UnitPersonCard.vue +42 -13
  198. package/components/VehicleAddSelection.vue +48 -32
  199. package/components/VehicleForm.vue +112 -129
  200. package/components/VehicleManagement.vue +26 -12
  201. package/components/VehicleUpdateMoreAction.vue +59 -46
  202. package/components/VisitorDataFromScannedQRCodeForm.vue +90 -65
  203. package/components/VisitorForm.vue +774 -210
  204. package/components/VisitorFormSelection.vue +40 -37
  205. package/components/VisitorManagement.vue +239 -82
  206. package/components/VisitorPassKeyQRScanner.vue +65 -18
  207. package/components/VisitorSearchResultSummary.vue +60 -16
  208. package/components/VisitorSocketPopUp.vue +22 -14
  209. package/components/VisitorsReportPreview.vue +44 -29
  210. package/components/WorkOrder/Create.vue +26 -17
  211. package/components/WorkOrder/ListView.vue +38 -7
  212. package/components/WorkOrder/Main.vue +62 -59
  213. package/composables/useFeedback.ts +3 -3
  214. package/composables/useHidAmico.ts +269 -0
  215. package/composables/useHidNavigation.ts +2 -9
  216. package/composables/useMember.ts +3 -3
  217. package/composables/useNFCPatrolDailyReport.ts +13 -0
  218. package/composables/useNFCPatrolMonthlyReport.ts +22 -56
  219. package/composables/useNFCPatrolReport.ts +13 -0
  220. package/composables/useNotificationPreference.ts +87 -0
  221. package/composables/useOnlineFormPages.ts +2 -0
  222. package/composables/useRole.ts +22 -0
  223. package/composables/useServiceProvider.ts +3 -3
  224. package/composables/useServiceProviderInvitation.ts +145 -0
  225. package/composables/useSipWebPhone.ts +336 -0
  226. package/composables/useSubscriptionPlan.ts +62 -0
  227. package/composables/useThemePreference.ts +63 -0
  228. package/composables/useVisitor.ts +6 -1
  229. package/composables/useWebUsb.ts +127 -37
  230. package/composables/useWorkOrder.ts +17 -3
  231. package/error.vue +30 -6
  232. package/nuxt.config.ts +36 -0
  233. package/package.json +25 -4
  234. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
  235. package/pages/[org]/[site]/access-mgmt/{identity-mapping → hid-cards}/index.vue +2 -2
  236. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  237. package/pages/notification-settings.vue +19 -0
  238. package/pages/payment-method-linked.vue +34 -10
  239. package/pages/require-customer.vue +48 -10
  240. package/pages/require-organization-membership.vue +32 -8
  241. package/pages/unauthorized.vue +29 -4
  242. package/plugins/secure-member.client.ts +21 -56
  243. package/plugins/vuetify.ts +69 -2
  244. package/types/nfc-patrol-report.ts +3 -2
  245. package/types/site.d.ts +71 -0
  246. package/types/subscription.ts +18 -11
  247. package/utils/breadcrumb.test.ts +108 -0
  248. package/utils/breadcrumb.ts +82 -0
  249. package/utils/dashboard.test.ts +118 -0
  250. package/utils/dashboard.ts +53 -0
  251. package/utils/data.test.ts +95 -0
  252. package/utils/data.ts +40 -2
  253. package/utils/hid-discover.test.ts +102 -0
  254. package/utils/list-page.test.ts +60 -0
  255. package/utils/list-page.ts +38 -0
  256. package/utils/materialSymbols.ts +363 -0
  257. package/utils/nav-sections.test.ts +190 -0
  258. package/utils/nav-sections.ts +151 -0
  259. package/utils/nfc-patrol-report.test.ts +130 -0
  260. package/utils/nfc-patrol-report.ts +101 -0
  261. package/utils/role.test.ts +69 -0
  262. package/utils/role.ts +10 -0
  263. package/utils/route-name.test.ts +85 -0
  264. package/utils/route-name.ts +24 -0
  265. package/utils/status.test.ts +110 -0
  266. package/utils/status.ts +135 -0
  267. package/utils/table-card.test.ts +84 -0
  268. package/utils/theme-aa-ledger.ts +148 -0
  269. package/utils/theme.test.ts +485 -7
  270. package/utils/theme.ts +295 -110
  271. package/.changeset/README.md +0 -8
  272. package/.changeset/config.json +0 -11
  273. package/.editorconfig +0 -12
  274. package/.github/workflows/main.yml +0 -17
  275. package/.github/workflows/publish-staging.yml +0 -47
  276. package/.github/workflows/publish.yml +0 -39
  277. package/PUBLISHING.md +0 -269
  278. package/components/HidIdentityMapping.vue +0 -975
  279. package/components/ScheduleTaskAreaFormDialog.vue +0 -144
  280. package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -109
  281. package/components/ScheduleTastTicketMain.vue +0 -182
  282. package/components/TableWithButton.vue +0 -94
  283. package/test/visitor-socket.test.mjs +0 -36
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
2
3
  import { test } from "node:test";
3
4
 
4
- import { DARK_THEME, LIGHT_THEME } from "./theme.ts";
5
+ import { DARK_THEME, LIGHT_THEME, PALETTE, type TPalette } from "./theme.ts";
6
+ import { DARK_PAIRS, measure } from "./theme-aa-ledger.ts";
5
7
 
6
8
  /**
7
9
  * The dark theme was shipped once with `primary` chosen for the one place it
@@ -180,22 +182,498 @@ test("primary-button carries a white label in both themes", () => {
180
182
  assert.ok(contrast(WHITE, light["primary-button"]) >= 4.5);
181
183
  });
182
184
 
185
+ /**
186
+ * `primary` is still a fill in a v-btn, a v-chip, a v-alert and a tab bar. The
187
+ * comment above `primary` used to claim Vuetify derives black for `on-primary`;
188
+ * it derives WHITE, and white on this blue is 2.68:1. Nothing measured it, so
189
+ * it shipped. This is that measurement.
190
+ */
191
+ test("a filled primary surface carries a readable label in both themes", () => {
192
+ assert.equal(DARK_THEME.colors["on-primary"], dark.background);
193
+ assert.ok(contrast(dark["on-primary"], dark.primary) >= 4.5);
194
+ assert.ok(contrast(WHITE, dark.primary) < 4.5, "white here is the defect");
195
+
196
+ // Light derives its own and it passes; measure it rather than assume.
197
+ assert.ok(contrast(WHITE, light.primary) >= 4.5);
198
+ });
199
+
183
200
  /**
184
201
  * Both themes correct the same two Vuetify defaults, and a value that drifts
185
202
  * apart between them is a bug nobody sees until they flip the switch.
186
203
  */
187
- test("disabled text clears 4.5:1 on the surface each theme puts it on", () => {
188
- assert.equal(LIGHT_THEME.variables["disabled-opacity"], 0.55);
189
- assert.equal(DARK_THEME.variables["disabled-opacity"], 0.55);
190
- assert.ok(contrast(over("#000000", WHITE, 0.55), WHITE) >= 4.5);
204
+ /**
205
+ * Both opacities are applied to `on-surface`, which is now the design's ink
206
+ * rather than pure black - so an opacity that was right for black is not right
207
+ * for this. These hold the corrected values in place, measured against the ink
208
+ * they are actually applied to.
209
+ */
210
+ test("disabled text is no paler than it was before the palette changed", () => {
211
+ assert.equal(LIGHT_THEME.variables["disabled-opacity"], 0.62);
212
+ assert.equal(DARK_THEME.variables["disabled-opacity"], 0.62);
213
+
214
+ // The bar is the previous rendering, not 4.5: Vuetify multiplies its own
215
+ // field alpha on top of this, which is Phase 3's problem to unpick.
216
+ const wasLight = contrast(over("#000000", WHITE, 0.55), WHITE);
217
+ const nowLight = contrast(over(PALETTE.light.text, WHITE, 0.62), WHITE);
191
218
  assert.ok(
192
- contrast(over(WHITE, dark.background, 0.55), dark.background) >= 4.5
219
+ nowLight >= wasLight,
220
+ `disabled light went backwards: ${wasLight.toFixed(2)} -> ${nowLight.toFixed(2)}`
221
+ );
222
+
223
+ const nowDark = contrast(
224
+ over(PALETTE.dark.text, PALETTE.dark.card, 0.62),
225
+ PALETTE.dark.card
193
226
  );
227
+ assert.ok(nowDark >= 4.5, `disabled dark: ${nowDark.toFixed(2)}`);
228
+ });
229
+
230
+ /**
231
+ * THE REGRESSION THIS CAUGHT. `text-medium-emphasis` is `on-surface` at this
232
+ * opacity, and it is roughly two hundred lines of secondary text across eleven
233
+ * applications. At Vuetify's 0.6 the new ink lands at 4.41:1 on the page.
234
+ */
235
+ test("medium-emphasis text clears AA on every surface, in both themes", () => {
236
+ for (const [name, p] of [
237
+ ["light", PALETTE.light],
238
+ ["dark", PALETTE.dark],
239
+ ] as const) {
240
+ const alpha = (name === "light" ? LIGHT_THEME : DARK_THEME).variables[
241
+ "medium-emphasis-opacity"
242
+ ];
243
+
244
+ for (const bg of [p.card, p.bg, p.sidebar]) {
245
+ const ratio = contrast(over(p.text, bg, alpha), bg);
246
+ assert.ok(
247
+ ratio >= 4.5,
248
+ `${name} medium emphasis on ${bg}: ${ratio.toFixed(2)} at alpha ${alpha}`
249
+ );
250
+ }
251
+ }
194
252
  });
195
253
 
196
254
  test("the dark divider clears the 3:1 a boundary wants", () => {
197
- const border = "#62666a"; // white at 0.35 over the dark page
255
+ // Derived, not hardcoded: this used to be a literal "#62666a" with a comment
256
+ // saying it was white at 0.35 over the dark page, and it stopped being that
257
+ // the moment the page colour moved.
258
+ const border = over(WHITE, dark.background, 0.35);
198
259
 
199
260
  assert.equal(DARK_THEME.variables["border-opacity"], 0.35);
200
261
  assert.ok(contrast(border, dark.background) >= 3);
201
262
  });
263
+
264
+ /* ==================================================================== */
265
+ /* THE REDESIGN PALETTE. */
266
+ /* */
267
+ /* The owner's decision on 2026-08-14 is 100% DESIGN FIDELITY over WCAG */
268
+ /* AA, reversing 2026-08-13's "minimal darkening". So the two themes */
269
+ /* are now held to two different standards, on purpose: */
270
+ /* */
271
+ /* DARK - exact as handed over AND fully AA. Asserted below. */
272
+ /* LIGHT - exact as handed over, 16 of 22 read pairs BELOW AA. Not */
273
+ /* asserted as passing; every ratio is PINNED to its measured */
274
+ /* value in `utils/theme-aa-ledger.ts` instead, so the cost is */
275
+ /* recorded rather than hidden and any drift - lighter OR */
276
+ /* darker - still fails the build and has to be declared. */
277
+ /* */
278
+ /* A later phase that "tidies" a light colour to make a contrast test */
279
+ /* green is exactly what the ledger test is here to catch. */
280
+ /* ==================================================================== */
281
+
282
+ const L = PALETTE.light;
283
+ const D = PALETTE.dark;
284
+
285
+ /** The soft chip backgrounds, as the design states them - literally. */
286
+ const SOFT = {
287
+ light: {
288
+ ok: ["#0f8a62", 0.1],
289
+ warn: ["#be8c19", 0.13],
290
+ err: ["#cf4b4b", 0.1],
291
+ info: ["#3b6fd4", 0.1],
292
+ },
293
+ dark: {
294
+ ok: ["#41c795", 0.12],
295
+ warn: ["#dcaf4e", 0.13],
296
+ err: ["#e57373", 0.12],
297
+ info: ["#7da6ec", 0.12],
298
+ },
299
+ } as const;
300
+
301
+ /** `--thead` is a 2% ink wash over the card, not a colour of its own. */
302
+ const thead = (p: TPalette) =>
303
+ over(p === L ? "#14161a" : WHITE, p.card, 0.02);
304
+
305
+ const soft = (p: TPalette, key: "ok" | "warn" | "err" | "info", on: string) => {
306
+ const [hex, alpha] = SOFT[p === L ? "light" : "dark"][key];
307
+ return over(hex, on, alpha);
308
+ };
309
+
310
+ const accentSoft = (p: TPalette, on: string) => over(p.accent, on, 0.12);
311
+
312
+ /*
313
+ * DARK ONLY. The light theme is measured by the ledger test further down, not
314
+ * asserted here - see the section note above.
315
+ */
316
+ for (const [name, p] of [["dark", D]] as const) {
317
+ /**
318
+ * `--muted` is the table header of every table in the product, the
319
+ * breadcrumb, and every KPI sub-line. The dark theme's `#868c98` clears AA
320
+ * on all four of its surfaces as designed; the light theme's `#8b8f98` does
321
+ * not, at 2.99-3.24:1, and that is the ledger's headline entry.
322
+ */
323
+ test(`${name}: muted text is readable on every surface it is used on`, () => {
324
+ for (const [where, bg] of [
325
+ ["a card", p.card],
326
+ ["the page", p.bg],
327
+ ["a table header", thead(p)],
328
+ ["the sidebar", p.sidebar],
329
+ ] as const) {
330
+ assert.ok(
331
+ contrast(p.muted, bg) >= 4.5,
332
+ `${name} muted on ${where}: ${contrast(p.muted, bg).toFixed(2)}`
333
+ );
334
+ }
335
+ });
336
+
337
+ test(`${name}: primary and secondary text clear AA on every surface`, () => {
338
+ for (const fg of [p.text, p.text2]) {
339
+ for (const bg of [p.card, p.bg, p.sidebar]) {
340
+ assert.ok(
341
+ contrast(fg, bg) >= 4.5,
342
+ `${name} ${fg} on ${bg}: ${contrast(fg, bg).toFixed(2)}`
343
+ );
344
+ }
345
+ }
346
+ });
347
+
348
+ /**
349
+ * A status chip is its own soft background, so the label is NOT read on the
350
+ * card - measuring it against white flatters it by about half a point.
351
+ */
352
+ test(`${name}: every status chip's label is readable on its own chip`, () => {
353
+ for (const [key, fg] of [
354
+ ["ok", p.ok],
355
+ ["warn", p.warn],
356
+ ["err", p.err],
357
+ ["info", p.info],
358
+ ] as const) {
359
+ const bg = soft(p, key, p.card);
360
+ assert.ok(
361
+ contrast(fg, bg) >= 4.5,
362
+ `${name} ${key} chip: ${contrast(fg, bg).toFixed(2)}`
363
+ );
364
+ }
365
+ });
366
+
367
+ /** The same four also appear as bare text: status dots, chart labels, the
368
+ * red Logout row in the profile dropdown. */
369
+ test(`${name}: the status colours are readable as plain text on a card`, () => {
370
+ for (const fg of [p.ok, p.warn, p.err, p.info]) {
371
+ assert.ok(
372
+ contrast(fg, p.card) >= 4.5,
373
+ `${name} ${fg} on a card: ${contrast(fg, p.card).toFixed(2)}`
374
+ );
375
+ }
376
+ });
377
+
378
+ /**
379
+ * The active sidebar item is `--accent-text` on `--accent-soft`, and
380
+ * `--accent-soft` is transparent - so it takes the colour of whatever is
381
+ * behind it. Behind it is the SIDEBAR, which is not the card. Solving this
382
+ * against the card gives 4.50:1 and ships a 4.35:1 sidebar.
383
+ */
384
+ test(`${name}: accent text is readable on accent-soft over BOTH surfaces`, () => {
385
+ for (const [where, under] of [
386
+ ["the sidebar", p.sidebar],
387
+ ["a card", p.card],
388
+ ] as const) {
389
+ const bg = accentSoft(p, under);
390
+ assert.ok(
391
+ contrast(p.accentText, bg) >= 4.5,
392
+ `${name} accent-text on accent-soft over ${where}: ${contrast(
393
+ p.accentText,
394
+ bg
395
+ ).toFixed(2)}`
396
+ );
397
+ }
398
+
399
+ assert.ok(contrast(p.accentText, p.card) >= 4.5, "as a plain link");
400
+ });
401
+
402
+ /**
403
+ * THE REASON `--accent-strong` EXISTS. If someone deletes it and points the
404
+ * primary button back at `--accent`, the second assertion is what fails, and
405
+ * the message says why rather than just reporting a number.
406
+ */
407
+ test(`${name}: a white label sits on accent-strong, never on accent`, () => {
408
+ assert.ok(
409
+ contrast(WHITE, p.accentStrong) >= 4.5,
410
+ `white on accent-strong: ${contrast(WHITE, p.accentStrong).toFixed(2)}`
411
+ );
412
+ assert.ok(
413
+ contrast(WHITE, p.accent) < 4.5,
414
+ "white on --accent passes now? then --accent-strong is redundant - " +
415
+ "re-measure before deleting it"
416
+ );
417
+ // The fill still has to be findable against the card it sits on.
418
+ assert.ok(
419
+ contrast(p.accentStrong, p.card) >= 3,
420
+ `accent-strong against a card: ${contrast(p.accentStrong, p.card).toFixed(2)}`
421
+ );
422
+ });
423
+
424
+ /** Non-text uses of the accent only owe 3:1, and they keep the design value. */
425
+ test(`${name}: the accent clears 3:1 where it is a line rather than a word`, () => {
426
+ assert.ok(contrast(p.accent, p.card) >= 3, "chart line / tab underline");
427
+ assert.ok(contrast(WHITE, p.accent) >= 3, "the toggle knob on its on state");
428
+ });
429
+
430
+ /**
431
+ * Vuetify DERIVES the label on a filled colour and its derivation picks
432
+ * white, which on the dark palette is 2.04:1 to 2.99:1. Every one is set
433
+ * explicitly in `theme.ts`; this is what proves they were all set.
434
+ */
435
+ test(`${name}: every filled semantic colour carries a readable label`, () => {
436
+ const t = p === L ? LIGHT_THEME : DARK_THEME;
437
+
438
+ for (const key of ["primary", "success", "warning", "error", "info"]) {
439
+ const on = (t.colors as Record<string, string>)[`on-${key}`];
440
+ const fill = (t.colors as Record<string, string>)[key];
441
+
442
+ assert.ok(on, `on-${key} is not set - Vuetify would guess, and it guesses white`);
443
+ assert.ok(
444
+ contrast(on, fill) >= 4.5,
445
+ `${name} on-${key}: ${contrast(on, fill).toFixed(2)}`
446
+ );
447
+ }
448
+
449
+ assert.ok(
450
+ contrast(t.colors["on-primary-button"], t.colors["primary-button"]) >= 4.5
451
+ );
452
+ });
453
+ }
454
+
455
+ /**
456
+ * "KEEP THE LOOK" IS HALF THE INSTRUCTION, AND IT IS THE HALF A CONTRAST TEST
457
+ * CANNOT CATCH ON ITS OWN - darkening everything until it passes would satisfy
458
+ * every test above and would not be the design. These are the values that were
459
+ * NOT the problem, pinned to what the handoff says.
460
+ */
461
+ test("the design's own values are unchanged wherever they already passed", () => {
462
+ assert.equal(L.accent, "#5b8def", "the brand accent is the design's, exactly");
463
+ assert.equal(D.accent, "#5b8def");
464
+
465
+ assert.equal(L.bg, "#f6f6f2");
466
+ assert.equal(L.sidebar, "#fbfbf8");
467
+ assert.equal(L.card, "#ffffff");
468
+ assert.equal(L.border, "#e8e8e1");
469
+ assert.equal(L.text, "#191b1f");
470
+ assert.equal(L.text2, "#41454d");
471
+
472
+ // The dark theme passed AA as designed and therefore moves nowhere at all.
473
+ assert.deepEqual(
474
+ [D.bg, D.sidebar, D.card, D.border, D.text, D.text2, D.muted],
475
+ ["#131418", "#17181d", "#1c1e24", "#2a2d35", "#eef0f3", "#c6cad2", "#868c98"],
476
+ "the dark palette needed no darkening - it should still be the handoff's"
477
+ );
478
+ assert.deepEqual(
479
+ [D.ok, D.warn, D.err, D.info, D.accentText],
480
+ ["#41c795", "#dcaf4e", "#e57373", "#7da6ec", "#78a2f4"]
481
+ );
482
+ });
483
+
484
+ /**
485
+ * THE SIX THAT WERE DARKENED ON 2026-08-13 ARE BACK ON THE HANDOFF, EXACTLY.
486
+ * Hex equality, not a tolerance: "design-exact" has no tolerance.
487
+ */
488
+ test("the light palette is the handoff's, value for value", () => {
489
+ assert.equal(L.muted, "#8b8f98");
490
+ assert.equal(L.ok, "#0e7c58");
491
+ assert.equal(L.warn, "#8e6611");
492
+ assert.equal(L.err, "#bc4444");
493
+ assert.equal(L.info, "#3b6fd4");
494
+ // `color-mix(in oklab, #5b8def 75%, #191b1f)`, resolved in a browser.
495
+ assert.equal(L.accentText, "#4a6eb6");
496
+ });
497
+
498
+ /**
499
+ * THE LEDGER. The light theme's known sub-AA pairs, each pinned to the ratio it
500
+ * actually measures. This is the "recorded, not hidden" half of the owner's
501
+ * decision: nobody can quietly darken a value to make a test green, and nobody
502
+ * can lighten one further without this failing and making them write it down.
503
+ *
504
+ * The numbers come from `utils/theme-aa-ledger.ts`, which is also runnable:
505
+ * node --experimental-strip-types utils/theme-aa-ledger.ts
506
+ */
507
+ test("the light theme's sub-AA pairs measure exactly what the ledger says", () => {
508
+ const EXPECTED: Record<string, number> = {
509
+ "muted on a card (KPI sub-lines, field labels)": 3.24,
510
+ "muted on the page background": 2.99,
511
+ "muted on the sidebar": 3.13,
512
+ "muted on a disabled control's hover fill": 2.71,
513
+ "ok label on its own chip": 4.57,
514
+ "warn label on its own chip": 4.56,
515
+ "err label on its own chip": 4.55,
516
+ "info label on its own chip": 4.2,
517
+ "ok as plain text on a card": 5.19,
518
+ "warn as plain text on a card": 5.17,
519
+ "err as plain text on a card (the Logout row)": 5.19,
520
+ "info as plain text on a card": 4.76,
521
+ "accent-text as a link on a card": 5,
522
+ "accent-text on accent-soft over the sidebar (active nav item)": 4.29,
523
+ "accent-text on accent-soft over a card (active tab, avatar initials)": 4.42,
524
+ "white on the filled success colour": 5.19,
525
+ "white on the filled warning colour": 5.17,
526
+ "white on the filled error colour": 5.19,
527
+ "white on the filled info colour": 4.76,
528
+ "white on primary-button (accent-strong) - NOT reverted": 4.57,
529
+ "text on a card": 17.24,
530
+ "text2 on a card": 9.62,
531
+ };
532
+
533
+ const measured = measure();
534
+ assert.equal(
535
+ measured.length,
536
+ Object.keys(EXPECTED).length,
537
+ "a pair was added to or removed from the ledger without updating this test"
538
+ );
539
+
540
+ for (const row of measured) {
541
+ assert.equal(
542
+ row.ratio,
543
+ EXPECTED[row.what],
544
+ `${row.what}: ledger says ${EXPECTED[row.what]}:1, measured ${row.ratio}:1`
545
+ );
546
+ }
547
+
548
+ const failing = measured.filter((r) => !r.passes).length;
549
+ assert.equal(
550
+ failing,
551
+ 7,
552
+ `${failing} light pairs are below AA; the recorded, accepted count is 7. ` +
553
+ "If this moved, the owner's design-fidelity decision needs re-stating."
554
+ );
555
+ });
556
+
557
+ /** The dark theme carries no such debt, and that is worth asserting out loud. */
558
+ test("the dark theme has no sub-AA pairs at all", () => {
559
+ const bad = DARK_PAIRS().filter((r) => !r.passes);
560
+ assert.deepEqual(bad, [], "dark mode was exact AND compliant - keep it that way");
561
+ });
562
+
563
+ /* ------------------------------------------------------------------ */
564
+ /* The two contrast fixes QA drove, asserted at the token level AND at */
565
+ /* the stylesheet that wires them. */
566
+ /* ------------------------------------------------------------------ */
567
+
568
+ /**
569
+ * A native `<select>`'s option list is painted by the browser. With no
570
+ * background of its own the option keeps the theme's ink over whatever the
571
+ * browser paints - white on Windows/Chrome - which is 1.14:1 in dark mode.
572
+ * The rule gives it `--card`/`--text`, the same pair the themed popover uses.
573
+ */
574
+ test("a native option's own fill carries the theme ink in both themes", () => {
575
+ for (const [name, p] of [["light", PALETTE.light], ["dark", PALETTE.dark]] as const) {
576
+ assert.ok(
577
+ contrast(p.text, p.card) >= 4.5,
578
+ `${name}: option ink on the card fill is ${contrast(p.text, p.card).toFixed(2)}:1`
579
+ );
580
+ }
581
+
582
+ // What the defect measured, so the number stays on the record.
583
+ assert.equal(Number(contrast(PALETTE.dark.text, "#ffffff").toFixed(2)), 1.14);
584
+ });
585
+
586
+ /**
587
+ * The table header. `--muted` measured 3.10:1 on the header band in light -
588
+ * below AA and reported by two apps' QA. `--text2` clears AA in both themes
589
+ * while staying clearly below the primary ink the data rows use, so the
590
+ * header still reads as secondary to the data.
591
+ */
592
+ test("the table header clears AA and still reads below the data", () => {
593
+ for (const [name, p, wash] of [
594
+ ["light", PALETTE.light, "#14161a"],
595
+ ["dark", PALETTE.dark, "#ffffff"],
596
+ ] as const) {
597
+ const band = over(wash, p.card, 0.02);
598
+ const header = contrast(p.text2, band);
599
+ const data = contrast(p.text, band);
600
+
601
+ assert.ok(header >= 4.5, `${name}: header is ${header.toFixed(2)}:1, below AA`);
602
+ assert.ok(
603
+ header < data,
604
+ `${name}: the header (${header.toFixed(2)}:1) must stay below the data ` +
605
+ `(${data.toFixed(2)}:1) or it stops reading as secondary`
606
+ );
607
+ assert.ok(
608
+ contrast(p.muted, band) < header,
609
+ `${name}: --text2 must be an improvement on the --muted it replaced`
610
+ );
611
+ }
612
+ });
613
+
614
+ /**
615
+ * The ratios above are the tokens'. They say nothing about whether the CSS
616
+ * still ASKS for them - a deleted rule would leave every number here passing.
617
+ * These two assertions are the wiring. Neither can measure a real computed
618
+ * style: that needs a browser, and the measured evidence for it is in the PR.
619
+ */
620
+ test("the stylesheets still ask for the tokens the two fixes chose", () => {
621
+ const css = (f: string) =>
622
+ readFileSync(new URL(`../assets/css/${f}`, import.meta.url), "utf8")
623
+ .replace(/\/\*[\s\S]*?\*\//g, "");
624
+
625
+ const primitives = css("primitives.css");
626
+ const optionRule = primitives.slice(primitives.indexOf("select option {"));
627
+ assert.ok(
628
+ primitives.includes("select option {"),
629
+ "the native <option> fill rule is gone - dark mode goes back to 1.14:1"
630
+ );
631
+ const optionBody = optionRule.slice(0, optionRule.indexOf("}"));
632
+ assert.ok(
633
+ optionBody.includes("background-color: var(--card)"),
634
+ "the option lost its own fill, which is the whole defect"
635
+ );
636
+ assert.ok(optionBody.includes("color: var(--text)"), "the option lost its ink");
637
+
638
+ const screens = css("screens.css");
639
+ const header = screens.slice(screens.indexOf(".table-card thead th {"));
640
+ const headerBody = header.slice(0, header.indexOf("}"));
641
+ assert.ok(
642
+ headerBody.includes("color: var(--text2) !important"),
643
+ "the table header is back on a token that does not clear AA in light"
644
+ );
645
+ });
646
+
647
+ /**
648
+ * THE BRIDGE. Every design token in `assets/css/tokens.css` is an alias onto a
649
+ * `--v-theme-*`, so a token whose Vuetify colour does not exist resolves to
650
+ * nothing and paints transparent - silently, in eleven applications. This is
651
+ * the list that CSS file reads.
652
+ */
653
+ test("every design token has a Vuetify colour behind it, in both themes", () => {
654
+ const required = [
655
+ "background", "surface", "sidebar", "border",
656
+ "text-primary", "text2", "muted",
657
+ "success", "warning", "error", "info",
658
+ "accent", "accent-strong", "accent-text",
659
+ "primary", "primary-button", "brand-surface",
660
+ ];
661
+
662
+ for (const [name, t] of [["light", LIGHT_THEME], ["dark", DARK_THEME]] as const) {
663
+ for (const key of required) {
664
+ const value = (t.colors as Record<string, string>)[key];
665
+ assert.match(
666
+ value ?? "",
667
+ /^#[0-9a-f]{6}$/i,
668
+ `${name} is missing a usable colour for "${key}" - tokens.css aliases it`
669
+ );
670
+ }
671
+ }
672
+
673
+ assert.deepEqual(
674
+ Object.keys(LIGHT_THEME.colors),
675
+ Object.keys(DARK_THEME.colors),
676
+ "a colour wired into one theme and forgotten in the other is how this " +
677
+ "file broke before"
678
+ );
679
+ });