Skip to content

Research: frontend engineering conventions of the sibling work-permit frontend

Question: What frontend conventions does smart-work-permit-frontend encode that a new Vue project in this organisation must match — and which of them would break the offline-first, PWA, Thai-first, account-free shape of the Chemical Safety Assistant if copied by habit?

Sources: primary only — the working tree at /Users/kan/Me/Work/kanverse/projects/work-permit/app/smart-work-permit-frontend/, read read-only. Every finding cites a real file path in that tree, and every src/…, docs/…, .agents/… or bare config path below is relative to that tree, not to this repository.

Findings continue the R- sequence. The highest existing id was R-45; this note starts at R-46. No id here is reused or renumbered.

Coverage gate. This note declares R-46 through R-75, which turn the coverage gate RED until stage 1 accounts for them. That is expected and correct.


Findings

R-46 The stack is fixed and bun-only: Vue 3.5 + TS + Vite 8, PrimeVue 4 unstyled, Tailwind v4 via plugin, Pinia 3, vue-router 5, vue-i18n 11, Zod 4, axios

package.json pins the runtime set a sibling Vue project is expected to match: vue ^3.5.39, vue-router ^5.1.0, pinia ^3.0.4 + pinia-plugin-persistedstate ^4.7.1, vue-i18n ^11.4.8, primevue ^4.5.5 + @primevue/forms ^4.5.5, tailwindcss ^4.3.2 with @tailwindcss/vite (no tailwind.config.js in the tree — Tailwind v4 CSS-first config), tailwindcss-primeui, zod ^4.4.3, axios ^1.18.1, humps (camelCase conversion), js-cookie, dayjs, jsqr ^1.4.0, qrcode, chart.js, quill, html2canvas, thai-address-universal, vue-sanitize-directive, tailwind-merge.

Package manager is bun ("packageManager": "[email protected]"), and AGENTS.md states plainly: "do not invoke npm/yarn/pnpm". Icons come from ~22 @iconify-json/* collections plus @iconify/vue, bundled offline by scripts/generate-icons.mjs against an allow-list.

Source: app/smart-work-permit-frontend/package.json, AGENTS.md §Commands Confidence: high

R-47 Vite config is deliberately small: four plugins, @/ alias, ESLint enforced inside the dev server

vite.config.ts registers exactly Vue(), ESLint() (vite-plugin-eslint2), Tailwindcss() (@tailwindcss/vite), and Components({ dirs: ['src/volt'], dts: true }). Alias @./src with extensions: ['.js','.json','.jsx','.mjs','.ts','.tsx','.vue']; define: { 'process.env': {} }; dev server on 0.0.0.0:8080 with watch.usePolling.

The consequence that bites: lint errors fail dev and build, not just bun run lint (AGENTS.md: "ESLint also runs inside Vite via vite-plugin-eslint2 — lint errors surface during dev/build"). There is no PWA plugin, no vite-plugin-pwa, no manifest plugin, no workbox — see R-63.

Source: app/smart-work-permit-frontend/vite.config.tsConfidence: high

R-48 "Done" is mechanically defined by ./init.sh — six gates, and the evidence must be pasted into the tracker

init.sh runs, with set -e, in this order:

  1. bun install (only when node_modules is missing)
  2. bun run linteslint .
  3. bun run typecheckvue-tsc --noEmit -p tsconfig.app.json
  4. bunx vitest run
  5. node scripts/check-status-contrast.mjs — a WCAG AA contrast gate on every status colour pair
  6. node scripts/check-icons.mjs — every Iconify name referenced in src/ must be on scripts/icons/allowlist.mjs
  7. node scripts/smoke-api.mjs — contract check against a running API; exits 0 (skips) when none is reachable

Gates 5 and 6 are also chained into bun run build ("build": "bun run typecheck && bun run check:contrast && bun run check:icons && vite build"). AGENTS.md §Definition of Done adds: the passing output is recorded in the feature item's evidence field, and new logic ships with at least one vitest check.

Pre-commit is husky → bunx lint-staged, and lint-staged.config.mjs runs eslint --fix plus a full bunx vitest run on any staged *.{js,jsx,ts,tsx,vue} — the whole suite on every commit, not just related tests.

CI is doubled: .gitlab-ci.yml (bun, test:coverage, Vercel deploy per branch/tag) and .github/workflows/deploy.yml (bun 1.3.13, linttest:runbuildwrangler pages deploy to Cloudflare Pages). The GitHub workflow bakes VITE_APP_API_URL in at build time.

Source: app/smart-work-permit-frontend/init.sh, package.json, lint-staged.config.mjs, .husky/pre-commit, .gitlab-ci.yml, .github/workflows/deploy.yml, AGENTS.mdConfidence: high

R-49 Testing is split three ways: vitest+jsdom for units, a Playwright tree fenced out of vitest, and page-level tests excluded entirely

vitest.config.ts spreads viteConfig and sets environment: 'jsdom', globals: true, setupFiles: ['src/tests/setup.ts'], passWithNoTests: true, and excludes src/tests/automate/** (the Playwright tree) and src/pages/**/tests/**. A commented-out projects: [...] block shows an abandoned unit/browser split — do not resurrect it by accident.

playwright.config.ts sets testDir: './src/tests/automate', baseURL: http://localhost:8090, a setup project matching **/fixtures/auth.setup.ts that writes src/tests/automate/.auth/user.json as storageState for the chromium project, workers: 4, retries: 2 on CI, trace: 'on-first-retry', screenshot: 'only-on-failure', and a webServer running bun run dev -- --port 8090 --strictPort on a deliberately non-default port.

Tests do not sit beside the code — they live in one mirrored src/tests/ tree. 87 *.test.ts files, and the directory under src/tests/ mirrors the path under src/: src/tests/utils/ApiError.test.ts, src/tests/composables/useQrScanner.test.ts, src/tests/stores/Notification.test.ts, src/tests/router/AuthGuard.test.ts, src/tests/locales/Locales.test.ts, src/tests/plugins/i18n.plugin.test.ts, src/tests/theme/StatusContrast.test.ts, and page tests flattened one level under src/tests/pages/<module>/<screen>/<Name>.test.ts.

What is tested at which level:

  • utils — the heaviest coverage (~25 files): pure functions, ApiError code mapping, Storage, PermitNormalize, RoleHomeRoute, ScanHistory (against fake-indexeddb ^6.2.5, a devDependency — IndexedDB logic is unit-tested, not only e2e).
  • composablesuseQrScanner, useOfflineQueue, useOnlineStatus, useLocale, usePermission.
  • routerAuthGuard.test.ts calls the exported guardRoute with a fabricated to; one test per route module asserts its records.
  • components and pages — mounted with @vue/test-utils, asserting against data-testid hooks.
  • theme — colour tokens and WCAG contrast, mirroring the check-status-contrast.mjs gate.

One file breaks the pattern — src/components/input/tests/Switch.spec.ts, the tree's only *.spec.ts and its only test sitting beside the code. Treat it as drift, not precedent.

src/tests/automate/ — the Playwright tree — is empty. It contains only an empty .auth/ directory; there is no auth.setup.ts and no spec, so playwright.config.ts is configured infrastructure with nothing to run, and bun run test:playwright is not part of ./init.sh or either CI pipeline. Playwright here is a template, not a practice — do not cite this repo as precedent for how e2e is done.

Source: app/smart-work-permit-frontend/vitest.config.ts, playwright.config.ts, src/tests/ (87 files), package.jsonConfidence: high

R-50 src/ has no views/ and no services/ — it is pages/, resources/, volt/, and a per-page four-folder recursion

Top level of src/ (file counts from the working tree):

src/
  App.vue  main.ts  vite-env.d.ts
  assets/        css/{tailwind,main,primevue,fonts}.css, icons/bundled-icons.gen.json
  components/    shared components, grouped by kind: app/ base/ button/ card/ charts/
                 chip/ display/ flex/ form/ input/ loader/ modal/ nav/ paper/ permit/
                 progress/ selection/ table/ transition/
  composables/   flat, 20 files, all useXxx.ts
  enums/         enums/modules/<domain>/Name.enum.ts
  layouts/       DefaultLayout.vue, BlankLayout.vue
  locales/       en.ts, th.ts  (only two files)
  models/        request/<domain>/, response/<domain>/, modules/
  pages/         one directory per router module
  plugins/       index.ts + Name.plugin.ts
  resources/     HttpRequest.ts, Interceptors.ts, provider/<feature>/, mock/, gateway/
  router/        index.ts + modules/<domain>/index.ts
  stores/        Auth.ts Loading.ts Notification.ts OfflineQueue.ts ScanHistory.ts
  tests/         88 files — the unit suite plus tests/automate/ for Playwright
  types/         ambient .d.ts only (one file)
  utils/         flat, PascalCase.ts
  volt/          PrimeVue PassThrough wrappers, auto-imported, ESLint-ignored

A new project copying this must know: "views" is spelled pages/, "services" is spelled resources/provider/, and a UI-kit layer (volt/) sits between PrimeVue and the app.

Source: app/smart-work-permit-frontend/src/ (directory listing), AGENTS.md §Architecture Confidence: high

R-51 Each router module recurses into pages/<mod>/pages/<screen>/{pages,components,composables,schema,utils} — screen-local code never goes in the shared trees

The shape is consistent and strict. From src/pages/safety-officer/:

pages/safety-officer/
  SafetyOfficer.vue                         # the module's route wrapper
  pages/review-detail/
    pages/SafetyReviewDetailPage.vue        # the screen itself, one level deeper
    components/{AuditTrailSection,JsaSection,SafetyReadingsSection,...}.vue
    composables/{usePermitPhotos,useWorkerCertificates}.ts
    schema/review-detail.schema.ts
  pages/all-permits/
    pages/SafetyAllPermitsPage.vue
    components/BulkApproveModal.vue
    composables/{useAllPermitsFilters,useBulkApprovePermits,usePermitsCsv}.ts
    schema/BulkApprove.schema.ts
  pages/facility-plan/{pages,components,composables,utils}/…   # utils/Homography.ts
  pages/risk-map/utils/LocationPosition.ts

AGENTS.md states the parallel-tree rule directly: "Each shipped module owns three parallel trees: routes (src/router/modules/<mod>/), pages (src/pages/<mod>/), providers (src/resources/provider/<feature>/)." Screen directories are kebab-case; the .vue file inside is PascalCase and carries the module prefix (SafetyReviewDetailPage.vue, InspectorGasLogPage.vue) so the lazy-loaded chunk name is self-identifying.

Only genuinely cross-screen components live in src/components/<kind>/; a section used by one page lives in that page's own components/.

Source: app/smart-work-permit-frontend/src/pages/safety-officer/, src/pages/inspector/, AGENTS.md §Modules Confidence: high

R-52 File-naming is codified per artefact kind, and type names carry I/T/E prefixes

From AGENTS.md §Naming, and confirmed against the tree:

KindConventionReal example
ComponentPascalCase.vuesrc/pages/safety-officer/pages/review-detail/components/JsaSection.vue
ComposableuseName.tssrc/composables/useQrScanner.ts
StoreName.ts, no Store suffix; export is useNameStoresrc/stores/OfflineQueue.tsuseOfflineQueueStore
UtilPascalCase.tssrc/utils/PermitNormalize.ts
ModelName.model.tssrc/models/response/permit/
EnumName.enum.tssrc/enums/modules/permit/PermitStatus.enum.ts
ProviderName.provider.tssrc/resources/provider/permit/Permit.provider.ts
Router moduleName.router.ts or modules/<mod>/index.tssrc/router/modules/Auth.router.ts
SchemaName.schema.tssrc/pages/inspector/pages/gas-log/schema/GasLog.schema.ts

Type prefixes: I<Name> for interfaces, T<Name> for aliases, E<Name>/<Name>Enum for enums.

Caveat on the schema row: the tree is not uniform — GasLog.schema.ts and BulkApprove.schema.ts are PascalCase, while review-detail.schema.ts, area-queue.schema.ts and user-form.schema.ts are kebab-case. The documented convention is PascalCase; the drift is real.

Source: AGENTS.md §Naming, src/pages/**/schema/Confidence: high (the drift is observed, not inferred)

R-53 vue-i18n: two hand-written TypeScript modules, legacy: false, Thai is the default locale, and the catalogue is the last plugin registered

src/plugins/i18n.plugin.ts is the whole configuration:

ts
export const LOCALE_STORAGE_KEY = 'locale'
export const DEFAULT_LOCALE = 'th'
export type TLocale = 'en' | 'th'

const i18n = createI18n({
  legacy: false,
  locale: readStoredLocale(),
  fallbackLocale: DEFAULT_LOCALE,
  messages: { en, th }
})

Facts a new project must copy or consciously reject:

  • Catalogues are .ts, not .json, not .yamlsrc/locales/en.ts and src/locales/th.ts, each a single export default { … } object literal. Two files only, no per-module splitting and no lazy locale loading: both catalogues are in the main bundle at boot. th.ts is 52 KB against en.ts's 32 KB (Thai in UTF-8).
  • Thai is already the default locale AND the fallback locale. DEFAULT_LOCALE = 'th', and fallbackLocale: DEFAULT_LOCALE — an English-only key falls back to Thai, not the other way round. index.html ships <html lang="th"> statically so a pre-hydration view is Thai by default.
  • Locale is chosen and persisted in localStorage under the key locale, read at module evaluation by readStoredLocale() (which validates the stored value against 'en' | 'th' and falls back to 'th'), never in a Pinia store and never in a cookie. No navigator.language sniffing at all.
  • document.documentElement.lang is kept in sync in two places — once at boot in i18n.plugin.ts, and again in useLocale().setLocale(), both guarded with typeof document !== 'undefined' for Vitest.
  • Registration order in src/plugins/index.ts is registerIcons() (side effect) → routerpiniaSanitizeregisterPrimeVue()i18n. i18n is last.

Source: app/smart-work-permit-frontend/src/plugins/i18n.plugin.ts, src/plugins/index.ts, src/composables/useLocale.ts, src/locales/{en,th}.ts, index.htmlConfidence: high

R-54 Keys are nested and namespaced by domain, always read through useI18n() in <script setup>$t in a template appears nowhere in the repo

Top-level namespaces in src/locales/en.ts, in file order: documentTitle, pagination, gasLog, profileMenu, profile, common, auth, error, permit, safety, inspector. So the shape is <module-or-cross-cutting>.<screen-or-group>.<key> — e.g. common.toast.success, common.role.safetyOfficer, auth.login.welcomeToast, error.gasOutOfRange, gasLog.validation.required, documentTitle.inspectorScan.

Two namespaces are load-bearing beyond copy:

  • documentTitle.* — the router's afterEach sets document.title from route.meta.titleKey, localised. Route titles are i18n keys, not literals.
  • error.* — one key per backend errorCode (error.gasOutOfRange, error.entrantsStillInside, error.certExpired, error.fireWatchNotElapsed, …). AGENTS.md is explicit: "Map codes → localized EN/TH messages client-side; never render a backend-supplied sentence."

40 files call useI18n(); zero files use $t( anywhere. The convention is const { t } = useI18n() in <script setup> and t('…') bound into the template.

Source: src/locales/en.ts (namespace grep), src/pages/inspector/pages/gas-log/pages/InspectorGasLogPage.vue:298, repo-wide grep for $t(Confidence: high

R-55 There is no vue-i18n pluralisation in this codebase at all — plurals are written as literal "(s)" and interpolation is single-brace named only

Grepping src/locales/en.ts for vue-i18n's pipe-separated plural form ('zero | one | many') returns nothing. Where a count varies, the catalogue writes the English plural inline:

readingsFailed: '{count} safety reading check(s) failed'
jsaStepCount: '{count} step(s)'
bulkApproveConfirmDescription: 'You are about to approve {count} permit(s). …'

Interpolation is vue-i18n named interpolation with single braces{count}, {label}, {date}, {name}, {index}, and multi-slot strings like 'The export contains {exported} of {total} matching permits…' and '{worker} — {direction}'. No list interpolation, no linked messages (@:key), no $tc.

This is a deliberate-looking accommodation of Thai, which has no plural morphology — but it degrades the English catalogue. A Thai-first project can adopt the same rule cleanly; if it ever needs real English plurals it must introduce pluralisation, not extend "(s)".

Source: src/locales/en.ts lines 202, 388, 486 and the pipe grep returning empty Confidence: high

R-56 The two catalogues are held in lockstep by a unit test, not by a type

src/tests/locales/Locales.test.ts recursively flattens both default exports to dotted key paths and asserts three things:

  1. en and th expose the same key set (expect(enKeys).toEqual(thKeys) on sorted arrays);
  2. en has no empty string values;
  3. th has no empty string values.

There is no defineI18n-style message-schema type and no build-time key checking — the guarantee is one vitest file, which runs in ./init.sh and (via lint-staged) on every commit. A new project should copy this test before it copies anything else about the i18n layer: it is the only thing preventing a half-translated Thai catalogue.

Source: app/smart-work-permit-frontend/src/tests/locales/Locales.test.tsConfidence: high

R-57 Fonts are self-hosted IBM Plex — including IBM Plex Sans Thai — explicitly because a plant-floor role must work offline

src/assets/css/fonts.css carries the reasoning in a header comment: the faces were "previously loaded from fonts.googleapis.com / fonts.gstatic.com — the Inspector role must work offline on a plant floor, so a third-party CDN request at load time is not acceptable." Binaries are woff2 only under public/assets/fonts/, weights 400/500/600/700, with font-display: swap.

Three families ship: ibm-plex-sans, ibm-plex-sans-thai (with separate -thai subset files per weight, e.g. ibm-plex-sans-thai-400-thai.woff2), and ibm-plex-mono.

Icons follow the same rule: scripts/generate-icons.mjs bundles Iconify data offline into src/assets/icons/bundled-icons.gen.json, registered through @iconify/vue/offline, and scripts/check-icons.mjs fails the build if src/ references an icon absent from scripts/icons/allowlist.mjs.

This is the one convention here that transfers to an offline-first project unchanged, and it is directly reusable — the Thai subset problem is already solved.

Source: src/assets/css/fonts.css, index.html (SHL-006 comment), src/plugins/Icon.plugin.ts, scripts/check-icons.mjs, scripts/icons/allowlist.mjsConfidence: high

R-58 Pinia: setup syntax only, store id is the PascalCase filename, return type is a declared I… interface, and persistence uses an explicit pick allowlist per key

src/plugins/Pinia.plugin.ts is four lines: createPinia() + pinia.use(piniaPluginPersistedstate). Nothing global is configured — every store declares its own persistence.

The store contract, from src/stores/Auth.ts, OfflineQueue.ts and ScanHistory.ts:

  • Setup syntax only. AGENTS.md: "Setup-store pattern only — defineStore('name', () => { … })." No options stores exist in the tree.
  • The store id is the PascalCase filenamedefineStore('Auth', …), defineStore('OfflineQueue', …), defineStore('ScanHistory', …) — while the exported function is useAuthStore / useOfflineQueueStore / useScanHistoryStore, and the file has no Store suffix.
  • The setup function has an explicit return-type interface declared above it (IAuthStore, IUseOfflineQueue, IUseScanHistory), typed with Ref<…> / ComputedRef<…>. ESLint's explicit-function-return-type makes this mandatory, not stylistic.
  • Each file also does export default useXxxStore alongside the named export.

Persistence is an explicit allowlist, using persistedstate v4's pick (not paths), as an array of per-key configssrc/stores/Auth.ts:

ts
}, {
  persist: [
    { key: 'auth',      pick: ['user'] },
    { key: 'userToken', pick: ['userToken'], storage: accessTokenStorage }
  ]
})

Two separate storage keys with different backends: user goes to localStorage under auth; userToken goes through a custom IStorage (src/utils/Storage.ts) that base64-encodes the state and writes it to a js-cookie cookie user_access_token, with expiry derived from the token's expireIn. OfflineQueue and ScanHistory declare no persist at all — their durable state is IndexedDB (R-59).

One store per cross-cutting concern, not per domain entity. Five stores exist: Auth (identity + token), Loading, Notification, OfflineQueue, ScanHistory. Permit data, gas logs and users have providers and page-local composables, not stores.

Source: src/plugins/Pinia.plugin.ts, src/stores/Auth.ts, src/stores/OfflineQueue.ts, src/stores/ScanHistory.ts, src/utils/Storage.ts, AGENTS.md §State (Pinia) Confidence: high

R-59 The reusable offline pattern: raw IndexedDB in a src/utils/<Name>.ts module is the source of truth, and the Pinia store is a reactive snapshot over it — never a substitute

This is the single most transferable piece of engineering in the repo. src/utils/OfflineQueue.ts and src/utils/ScanHistory.ts each own one IndexedDB database with no wrapper dependency — no idb, no dexie, no localforage. The shape:

  • module-level const DB_NAME, DB_VERSION, STORE_NAME; a memoised let dbPromise so the database is opened once;
  • openDb() handling onupgradeneeded idempotently (if (!db.objectStoreNames.contains(...)), createObjectStore(STORE_NAME, { keyPath: 'clientId' }));
  • a generic runRequest<T>(mode, executor) helper wrapping one transaction in a Promise;
  • exported async functions only (getAllOfflineQueueEntries, enqueueOfflineAction, updateOfflineQueueEntryStatus) — the IDB API never leaks to callers;
  • crypto.randomUUID() for client-generated ids, and enqueue is dedupe-by-id: an existing entry with the same clientId is returned unchanged rather than duplicated.

The store docblock states the division of labour verbatim: "IndexedDB (src/utils/OfflineQueue.ts) is the source of truth; queue is a reactive snapshot refreshed after every mutation, never a substitute for it." The store is a store rather than a composable specifically because several screens must observe one queue ("finding 14" in the docblock).

Two hard-won details worth copying outright:

  1. The database name is treated as an address, not a label. A long comment forbids renaming 'smart-work-permit-offline-queue' even though the product was renamed, because renaming orphans the store and silently loses unsynced field data. Migration means open-old → copy → drop.
  2. The store does not use useOnlineStatus() — that composable tears listeners down in onUnmounted, and a store setup has no component instance, so it registers its own window.addEventListener('online'/'offline') and disposes them in onScopeDispose.

fake-indexeddb is a devDependency, so these modules are unit-testable in jsdom.

Source: src/utils/OfflineQueue.ts, src/utils/ScanHistory.ts, src/stores/OfflineQueue.ts, src/stores/ScanHistory.ts, src/composables/useOnlineStatus.tsConfidence: high

R-60 Routing: one default-exported RouteRecordRaw per module with a const prefix, every component lazy-loaded, route names = the component's PascalCase name, and meta carries auth / permission / layout / titleKey

src/router/index.ts assembles a flat top-level routes: RouteRecordRaw[] from literal records (HomePage, /not-permitted, /not-available, /profile, /not-found, /:pathMatch(.*)*) plus three imported module objects: AuthRouter, SafetyOfficerRouter, InspectorRouter.

Each module file (src/router/modules/inspector/index.ts) is a single export default { … } as RouteRecordRaw with:

  • a const prefix = '/inspector' at the top (AGENTS.md calls this the module's route prefix and requires the Modules table to be updated when it changes);
  • a section-root record with redirect, a wrapper component (@/pages/inspector/Inspector.vue), and meta: { auth: true, layout: 'default', permission: 'scan' };
  • children whose path is a bare kebab-case segment ('entrant-register', 'gas-log').

Conventions that transfer:

  • Every component is lazy — component: (): ComponentOptions => import('…'), with the explicit return type ESLint demands. There is no eager import in the router.
  • Route name is the PascalCase component nameInspectorGasLogPage, NotPermittedPage, ProfileDetailPage — and a comment on /profile notes the name is load-bearing because other components push({ name }) to it.
  • meta.titleKey is an i18n key, never a string. afterEach sets document.title = titleKey ? \e-safework | ${i18n.global.t(titleKey)}` : 'e-safework'`. A hardcoded Thai literal here was a bug that survived into the EN UI.
  • meta.permission is a TPermissionModule or an array, AND-gated. Child routes re-declare the parent's module alongside their own (['scan', 'gas_log']) so a future role holding one without the other is still handled.
  • One guard, exported for unit testing. export function guardRoute (to) returns { name: 'LoginPage' } when meta.auth and the token is empty, { name: 'NotPermittedPage' } when a required module is missing, else true. Registered as router.beforeEach(guardRoute). The export exists "so it is unit-testable in isolation — build a fake to … no router/DOM mount required."
  • router.onError retries chunk-load failures twice, counting in sessionStorage under chunk-retry:${to.fullPath} and then window.location.assign(to.fullPath). This is post-deploy stale-chunk recovery for a long-lived SPA tab — worth copying, and it interacts directly with any future service worker.

Source: src/router/index.ts, src/router/modules/inspector/index.ts, src/router/modules/safety-officer/index.ts, AGENTS.md §Router Confidence: high

R-61 SFCs: template then script setup lang="ts", no <style> block in practice, every ref given an explicit Ref<T> / ComputedRef<T> annotation

ESLint fixes block order to template, script, style (AGENTS.md §TypeScript & lint rules). Reading src/pages/inspector/pages/gas-log/pages/InspectorGasLogPage.vue end to end, the observed conventions are:

  • <script setup lang="ts"> only; no Options API anywhere in src/.
  • Every reactive binding carries an explicit type annotation, not just an inferred one: const loadState: Ref<TLoadState> = ref('loading'), const entries: ComputedRef<IGasLogEntry[]> = computed((): IGasLogEntry[] => …). Arrow callbacks inside computed/filter also carry return types and typed parameters — typedef with arrowParameter: true and explicit-function-return-type with allowTypedFunctionExpressions: false force this.
  • Local types are declared at the top of the script block (type TLoadState = 'loading' | 'result' | 'error', interface IQueuedGasLogPayload extends ICreateGasLogPayload), often with a comment explaining why.
  • Import order in practice: vue → vue-i18n → vue-router → PrimeVue/@primevue/forms@/components@/utils@/stores@/composables@/enums@/plugins@/resources@/models → relative sibling (../schema/…, ../utils/…). Type-only imports use inline import { type X } / import type { X } (consistent-type-imports, fixStyle: 'inline-type-imports').
  • data-testid attributes are placed on anything a test or screenshot asserts against (data-testid="overdue-banner", data-testid="queued-offline-message").
  • AGENTS.md requires defineProps/defineEmits to be type-based (no runtime object form), v-bind shorthand, and v-on handlers written inline with the event — @click="loadHistory()", never @click="loadHistory". vue/max-len is 150.
  • Style is Tailwind utility classes in the template. There is no <style> block, no scoped CSS, and no CSS modules on this page; global CSS lives only in src/assets/css/.

Source: src/pages/inspector/pages/gas-log/pages/InspectorGasLogPage.vue, eslint.config.js, AGENTS.mdConfidence: high

R-62 PrimeVue runs unstyled: true behind a hand-maintained src/volt/ PassThrough layer that unplugin-vue-components auto-imports — app code imports Tailwind-styled wrappers, never PrimeVue directly

src/plugins/primevue.plugin.ts:

ts
export const primeVueConfig: PrimeVueConfiguration = {
  unstyled: true,
  locale: { dayNames: ['อาทิตย์', …], monthNames: ['มกราคม', …], today: 'วันนี้', firstDayOfWeek: 1, … },
  pt: { directives: { tooltip: { root: { class: '… Tailwind …' } } } }
}

registerPrimeVue(app) registers the tooltip directive, PrimeVue with that config, ToastService, and a project registerToastService(app) wrapper (src/plugins/toast.ts).

Three consequences a new project inherits:

  1. AGENTS.md forbids re-enabling PrimeVue's theme. All component styling lives in src/volt/*.vue PassThrough wrappers written in Tailwind v4 classes — 24 wrappers today (Button, DangerButton, SecondaryButton, ContrastButton, InputText, InputNumber, Select, MultiSelect, DataTable, DatePicker, Dialog, Toast, Password, …) plus a shared src/volt/utils.ts.
  2. Auto-import covers src/volt/ only. Components({ dirs: ['src/volt'], dts: true }) — the generated components.d.ts at repo root lists exactly those 24 plus RouterLink/RouterView. So <Button>, <InputNumber> and <Select> appear in templates with no import statement, while everything under src/components/** is imported explicitly (import LabelField from '@/components/input/LabelField.vue'). New Volt components are scaffolded with volt add <ComponentName>; adding an explicit import is wrong.
  3. src/volt/** is ESLint-ignored, so vendored wrapper code does not have to satisfy the project's strict rules — and does not get checked either.

The PrimeVue locale block is hardcoded Thai, not driven by vue-i18n. Thai day and month names are literals in primeVueConfig, so switching the UI to English leaves the DatePicker in Thai. That is a live bug, and a Thai-first project should notice it is only accidentally correct.

Tailwind is v4 CSS-first: @tailwindcss/vite plus src/assets/css/tailwind.css; there is no tailwind.config.js in the tree. Colour tokens are used as CSS variables in class names — text-(--p-red), border-(--p-red-2), bg-(--p-primary-50) — alongside a surface-* scale. tailwindcss-primeui and tailwind-merge are dependencies.

Source: src/plugins/primevue.plugin.ts, components.d.ts, vite.config.ts, src/volt/, src/assets/css/tailwind.css, AGENTS.md §Volt auto-import Confidence: high

R-63 Forms: @primevue/forms <Form> + zodResolver, one Name.schema.ts per screen exporting a create…Schema(t) factory so validation messages are localised

AGENTS.md states the rule as "@primevue/forms + zodResolver — never raw safeParse". Six screens follow it identically:

ts
import { Form, type FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { createGasLogSchema, useGasLogInitialValues, type TGasLogFormValues } from '../schema/GasLog.schema'

const resolver = zodResolver(createGasLogSchema(t))
const formData: Ref<TGasLogFormValues> = ref(useGasLogInitialValues())
html
<Form v-slot="$form" :initial-values="formData" :resolver="resolver" @submit="onSubmit($event)">
  <LabelField :form="$form" :label="t('inspector.gasLog.fieldLel')" name="lel" tag="div" required>
    <InputNumber v-model="formData.lel" :min="0" class="h-11 w-full text-base" name="lel" fluid />
  </LabelField>
</Form>

The conventions worth naming:

  • Schemas live beside the screen in src/pages/<mod>/pages/<screen>/schema/<Name>.schema.ts, never in a central schemas/ directory. Shared Zod primitives live in src/utils/Schema.ts (schema.id, schema.enum, schema.date, schema.media, schema.richText, schema.numericField).
  • The schema is a factory taking t, typed as export type TSchemaTranslate = (key: string, named: Record<string, unknown>) => string, so every Zod message is an i18n key: z.number({ error: t('gasLog.validation.required', { label }) }). A comment records why: "They were hardcoded Thai literals, which stayed Thai in the EN UI." A constant schema with no messages to localise is exported as a plain object instead (zodResolver(BulkApproveSchema)).
  • Empty-to-undefined coercion is done with z.preprocess(…), and the file also exports useXxxInitialValues() and an IXxxFormValues interface alongside the schema.
  • Field layout goes through the shared src/components/input/LabelField.vue, which takes the $form slot object plus name and renders the error — pages do not read $form.<field>.error by hand.
  • Submit goes through FormSubmitEvent; src/utils/HandleSubmit.ts and src/models/Form.model.ts wrap the shared parts.

Trap: src/utils/Schema.ts is lending-era leftover and its messages are hardcoded Thai literals (กรุณาเลือก${label}, 'ไฟล์ต้องเป็นรูปภาพหรือ PDF') — the exact bug the per-screen factories were written to fix. Copying Schema.ts copies the bug.

Source: src/pages/inspector/pages/gas-log/schema/GasLog.schema.ts, src/pages/inspector/pages/gas-log/pages/InspectorGasLogPage.vue, src/pages/safety-officer/pages/all-permits/components/BulkApproveModal.vue, src/utils/Schema.ts, AGENTS.mdConfidence: high

R-64 API access: a hand-written HttpRequest axios class that providers extend, /api/v1 in the baseURL, and a hand-maintained error-code enum — there is no generated client

src/resources/HttpRequest.ts is a class with a private axiosInstance created in the constructor: baseURL = ${VITE_APP_API_URL}${API_PREFIX} where export const API_PREFIX = '/api/v1', timeout: 120000, withCredentials: true, Content-Type: application/json. It exposes get / download / post / put / patch / delete and installs the two interceptors.

Providers extend it — inheritance, not composition:

ts
export interface IGasLogProvider {
  list (permitId: string): Promise<IGasLogHistory>
  create (permitId: string, payload: ICreateGasLogPayload): Promise<IGasLogEntry>
}

class GasLogProvider extends HttpRequest implements IGasLogProvider {
  private urlPrefix: string = '/permits'

}
export default GasLogProvider

Every provider declares a public I<Name>Provider interface, sets a private urlPrefix naming only the resource (the version prefix belongs to the baseURL), and is the default export. Callers instantiate with the typed interface and a Service suffix — AGENTS.md gives the exact right/wrong pair: const PermitService: IPermitProvider = new PermitProvider(), not const provider = new PermitProvider().

Request/response types are separate model files: src/models/request/<domain>/<Name>Req.model.ts and src/models/response/<domain>/<Name>Res.model.ts.

src/resources/Interceptors.ts:

  • onResponse unwraps the { message: 'success', data } envelope — but returns the whole body minus message when there are sibling fields (count/page/limit/totalPage on lists, overdue on the gas log), because unwrapping to data "would silently drop the sibling, which is usually the field the screen exists to show". XLSX responses pass through untouched.
  • humps camelCase conversion was deliberately removed. A header comment explains: the backend is camelCase both ways, and camelising would rewrite free-form user key/value objects. humps is still a dependency; AGENTS.md still claims interceptors do the conversion — the doc is stale against the code.
  • onResponseError logs out on 401 and hard-navigates to /auth/login, except when the failing URL contains /auth/ or the user is already on an auth page (a failed sign-in is also a 401). It resolves the Pinia store inside that branch so the interceptor is usable outside a mounted app.
  • Errors are rejected as the backend's { code, message, errorCode? } payload.

Error handling is code-driven, never message-driven. src/utils/ApiError.ts exports API_ERROR_CODES as a runtime array (GAS_OUT_OF_RANGE, CERT_EXPIRED, ENTRANTS_STILL_INSIDE, FIRE_WATCH_NOT_ELAPSED, INVALID_QR_TOKEN, RATE_LIMITED, UNAUTHENTICATED, …), a TApiErrorCode derived from it, an isApiErrorPayload type guard, and mapApiErrorCodeToI18nKey(code). It is exported as an array specifically "so tests can iterate the full vocabulary and a future addition fails loudly instead of silently falling back".

No generated client exists. docs/api/openapi.json (152 KB, generated, never hand-edited) is treated as the authority to read, with docs/api/CONTRACT.md beside it and scripts/smoke-api.mjs checking a live API — but no codegen step produces TypeScript from it. The providers and models are written by hand and kept honest by the smoke check.

A mock gateway (src/resources/mock/MockAdapter, MockRoutes, fixtures/*) is installed onto the axios instance only when VITE_APP_USE_MOCK=true.

Source: src/resources/HttpRequest.ts, src/resources/Interceptors.ts, src/resources/provider/gas-log/GasLog.provider.ts, src/utils/ApiError.ts, .env.example, docs/api/CONTRACT.md, AGENTS.md §HTTP layer Confidence: high

R-70 The declared canonical convention set is a 33-file skill at .agents/skills/project-conventions/ — and it is partly a stale template that contradicts both AGENTS.md and the code

AGENTS.md opens by ranking it above itself for the "how": "read the project skill index at {.agents, .claude}/skills/project-conventions/SKILL.md … The skill is the canonical convention set for this codebase (one H2 topic per reference file)." (.claude/skills is a symlink to .agents/skills.) SKILL.md carries YAML frontmatter with name and a description beginning "MUST be used for any non-trivial code change", then an index of 33 files under reference/, grouped Overview & Stack / Style & Typing / Architecture / Utilities / UI / Tooling / Quick Reference. Sibling skills in the same directory: primevue, zod, vitest, vue-best-practices, vue-pinia-best-practices, vue-router-best-practices, vue-debug-guides, tailwind-css-patterns, playwright-best-practices, frontend-design, harness-creator, two Figma skills, setup-ai.

There is no "traps" file. The nearest equivalents are AGENTS.md §"TypeScript & lint rules that bite" (already captured in R-61) and the inline KEEP THIS NAME / "wayfinder ticket" comments in source, which is where this team actually records the hard-won rules.

The reference files are a template carried from the earlier lending-domain project and were never fully re-derived. Concrete contradictions a reader must not trust:

reference/… saysReality
naming-conventions.md: Providers are <Name>Provider.ts, router modules <DomainName>.route.tsFiles are Name.provider.ts and Name.router.ts / modules/<mod>/index.ts (R-52)
router-conventions.md: "Always add meta.title (Thai string used for document.title)"The code uses meta.titleKey, an i18n key — the Thai literal was the bug that was fixed (R-60)
form-patterns.md: helpers are schema.IdSchema(label) / schema.enumSchema(enumObj, label)src/utils/Schema.ts exports schema.id and schema.enum
stores-pinia.md / state-management-pinia.md: "Existing stores: useAuthStore(), useLoadingStore()"; Auth holds a merchant and userToken: stringFive stores exist; Auth holds IToken { accessToken, expireIn } and no merchant (R-58)
directory-structure-conventions.md: components/badge/, navigation-drawer/, autocomplete-api/; router/navigation.ts; `models/AuthRequest
directory-structure-conventions.md / component-patterns.md: each domain owns tests/playwright/ and tests/unit/Tests live in the mirrored src/tests/ tree, and vitest.config.ts excludes src/pages/**/tests/** (R-49)
tests.md: "Test utils in test-utils.ts"No test-utils.ts exists; the setup file is src/tests/setup.ts
composables.md: useInit, useDetail, usePayload, useDebounce, useDayjs under src/composables/useDayjs is in src/utils/Dayjs.ts, debounce in src/utils/Debounce.ts; useInit/useDetail/usePayload do not exist as shared composables

The lesson for the consuming project is a process one, and it is the most important thing in this note after R-65: a convention set that is not verified against the code rots into a trap, because an agent told it is canonical will follow it over the code. Anything the new project writes as "canonical conventions" needs the same treatment Locales.test.ts gives the catalogues (R-56) — a check, or it will drift. Where this note reports a convention, the code was read; where only the skill states something, it is flagged as such below.

Source: .agents/skills/project-conventions/SKILL.md and reference/*.md (33 files), .claude/skills../.agents/skills, checked against src/Confidence: high

R-71 Props and emits are typed with a declared IProps / IEmits interface passed to defineProps<> / defineEmits<> — never the runtime object form

reference/component-patterns.md gives the exact required shape, and AGENTS.md backs it with the ESLint rule ("defineProps/defineEmits must be type-based"):

vue
<script setup lang="ts">
import type { IExample } from '@/models/modules/Example.model'

interface IProps {
  item: IExample
  loading?: boolean
}
const props = defineProps<IProps>()

interface IEmits {
  submit: [value: IExample]
  cancel: []
}
const emit = defineEmits<IEmits>()
</script>

Emits use the tuple-payload form (submit: [value: IExample], cancel: []), not the call-signature form. Optionality is expressed on the interface (loading?: boolean), which means defaults come from withDefaults or are handled in the body — there is no runtime default:. reference/component-patterns.md and directory-structure-conventions.md both close with the same absolute: "All components use <script setup lang="ts">. No Options API. No defineComponent."

Source: .agents/skills/project-conventions/reference/component-patterns.md, reference/directory-structure-conventions.md, AGENTS.mdConfidence: high — stated by the skill and consistent with every SFC read

R-72 Components are grouped by intent with mandated prefixes, and confirm/delete dialogs must compose the shared DeleteModal / ConfirmModal — never an inline confirmation

reference/naming-conventions.md fixes five component prefixes:

PrefixUsage
Base*layout/structural primitives — BasePage, BaseTop, BaseContainer, BaseTab
App*app chrome — AppDrawer, AppDrawerMenu, AppNav, AppIcon
*Buttonsemantic action buttons by intent — CreateButton, EditButton, DeleteButton, ConfirmButton, CancelButton, BackButton, DownloadButton, FilterButton
Form*form action bars — FormAction, FormActionFilter
Display*read-only display components

Buttons are named for intent, not appearance — this extends into src/volt/, which ships Button, SecondaryButton, DangerButton and ContrastButton as separate wrappers rather than a severity prop.

reference/component-patterns.md §Delete Actions is a hard rule: "Use @/components/modal/DeleteModal.vue for all delete confirmations. Never inline a delete confirmation inside another modal or page — always compose DeleteModal alongside the parent modal/view." Open with deleteVisible.value = true; on confirm, close both the DeleteModal and the parent, then emit update. src/components/modal/ holds exactly BaseModal.vue, ConfirmModal.vue, DeleteModal.vue — verified in the tree.

Actual src/components/ groups (the skill's list is stale — see R-70): app, base, button, card, charts, chip, display, flex, form, input, loader, modal, nav, paper, permit, progress, selection, table, transition.

Source: .agents/skills/project-conventions/reference/{naming-conventions,component-patterns}.md, src/components/, src/volt/Confidence: high

R-73 Styling: mobile-first is a written rule with named breakpoints and a 44px touch target, scoped CSS is banned in any component using a Volt wrapper, and page-level horizontal scroll is forbidden

reference/styling-rules.md is the most operationally useful reference file, and unlike most of the set it matches the code. The rules:

  • Tailwind v4, no tailwind.config.js — configured through @tailwindcss/vite. Use PrimeVue design tokens as Tailwind classes (text-primary, bg-surface-0, border-surface-200); dark mode via the dark: variant.
  • Mobile-first, with the breakpoints written down: none/0px mobile default, sm: 640, md: 768, lg: 1024. Grids always start grid-cols-1 and expand (grid grid-cols-1 md:grid-cols-2 is right; grid grid-cols-2 is called out as wrong). Flex rows use flex-col sm:flex-row or flex-wrap; action bars wrap; padding is p-4 md:p-6, never a fixed large value.
  • Touch targets ≥ 44px, minimum h-9, preferring h-10/py-2.5, and never two tappable elements closer than 8px. AGENTS.md restates this as a hard minimum "44×44px on every interactive element", designed for "plant floor, gloved hands" at 375–430px first. Real code uses h-11 and min-h-11.
  • Overflow: truncate / break-words / overflow-hidden on long content; "horizontal scroll is acceptable only inside DataTablenever at the page level." Tables hide non-critical columns with hidden md:table-cell; dialogs set :style="{ width: 'min(95vw, 480px)' }".
  • Never add scoped CSS in components that use volt wrappers, and ptViewMerge from src/volt/utils.ts must be used to merge PT props in every Volt component.
  • Three CSS files and no more: tailwind.css (base import), primevue.css (token overrides), main.css (global app styles).

Source: .agents/skills/project-conventions/reference/styling-rules.md, AGENTS.md §Layout switching, src/volt/utils.ts, src/assets/css/Confidence: high

R-74 Form handling has three more mandated pieces the code alone does not show: scrollToFirstError on an invalid submit, handleLoading wrapping every API call, and one named useCreate/useUpdate/useDelete function per action

reference/form-patterns.md opens with a MANDATORY banner: "ALL forms MUST use @primevue/forms <Form> with zodResolver. Direct z.schema.safeParse() calls or handleSubmit(formRef) for form field validation are forbidden." Beyond what R-63 recorded from the code, the reference adds:

  • The submit handler shape is fixed:
    ts
    function onSubmit (event: FormSubmitEvent): void {
      if (!event.valid) { scrollToFirstError(event.errors); return }
      handleLoading(async (): Promise<void> => { await provider.createFeature(event.values as TFormValues) })
    }
    scrollToFirstError lives in src/utils/HandleSubmit.ts and is unit-tested (src/tests/utils/HandleSubmit.test.ts).
  • handleLoading(callback, options?, errorCallback?) (src/utils/HandleLoading.ts) adds and removes global loading via useLoadingStore, catches the error and raises the toast — "No need to wrap in try/catch." An options.loadingUnit?: Ref<boolean> switches it to a local flag instead of the global store.
  • API action separation in form modals: each of CREATE / UPDATE / DELETE gets its own named async function (useCreate, useUpdate, useDelete) containing only the API call. onSubmit/onDelete contain only handleLoading + emit + close, and pick the action with a ternary, never an if/else that duplicates handleLoading.
  • LabelField renders an InputText from its default slot automatically; inner selection/date components need no name attribute because the resolver validates the reactive initial-values.
  • Child forms are submitted by the parent through defineExpose: the child holds useTemplateRef<any>('formRef'), exposes submit() calling formRef.value?.submit(), and the parent calls childRef.value?.submit() to trigger validation.
  • Form error state is typed by IFormState in src/models/Form.model.ts ({ [field]: { invalid?, error?: { message? }, errors?: [...] } }).

Caveat: every code sample in this file writes validation messages and labels as hardcoded Thai literals ('กรุณากรอกชื่อ', label="ชื่อ"). That is the pattern the per-screen create…Schema(t) factories were introduced to replace (R-63, R-69). Follow the structure, not the strings.

Source: .agents/skills/project-conventions/reference/form-patterns.md, src/utils/HandleSubmit.ts, src/utils/HandleLoading.ts, src/models/Form.model.tsConfidence: high

R-75 Composables return only refs, computeds and pure helpers; usePagination syncs to the URL; module-level singletons are preferred over Pinia for pure UI state — and the Buddhist-era date format is commented out "for UAT"

reference/composables.md: "All composables are in src/composables/ and follow the useX naming pattern. Return only refs/computed and pure helpers." Notable members, verified in the tree:

  • usePagination() returns { pagination, search, sortBy, sortOrder } and "syncs to URL query params automatically via router.replace" — list state lives in the URL, not a store.
  • useAppDrawer() returns { isOpen, open, close, toggle } and is explicitly a "module-level singleton (no Pinia needed)". Together with R-58 this gives the rule for when something becomes a store: shared durable or multi-screen domain state does; pure UI toggles do not.
  • useCopy(){ isCopied, copy }; useTabItems(computed(() => [...])){ tab, tabItems }; useDebounce(fn, ms).

Dates are Thai-locale-shaped but currently disabled. src/utils/Dayjs.ts exposes useDayjs() returning an augmented dayjs with formatDate, formatTime, formatDateTime, formatDateRequest, formatAge, formatAgeYear, formatDurationThai. Every formatter calls .tz() (so Asia/Bangkok is applied via the plugin), formatDateRequest is ISO 8601 for the wire, and formatDurationThai/formatAge return Thai unit words (นาที, ปี, เดือน, วัน).

But the Buddhist-era format the skill documents is commented out in the code:

ts
const formatDate = (input?: string | Date): string => {
  // return input ? dayjs(input).format('DD/MM/BB') : '-'
  return input ? dayjs(input).tz().format('DD/MM/YYYY') : '-' // use 'YYYY' for UAT
}

reference/composables.md still advertises formatDate(date) → 'DD/MM/BBBB' (Buddhist era). A Thai-first project must decide Buddhist vs Gregorian era deliberately; this repo has a temporary UAT override sitting where that decision should be, and the composable also lives in src/utils/, not src/composables/, contrary to the skill.

Source: .agents/skills/project-conventions/reference/composables.md, src/utils/Dayjs.ts, src/composables/{usePagination,useAppDrawer,useCopy,useTabItems}.tsConfidence: high


Conflicts with the Chemical Safety Assistant's shape

Each of these is a place where copying this repo's habit would break the consuming project.

R-65 CONFLICT (most important) — this frontend assumes a live API everywhere; its "offline" is a write queue, not an offline read path, and the deploy runbook states plainly "Inspectors need network"

The offline machinery here (R-59) is genuinely good, but it solves the opposite problem. From the store's own docblock: "This store never caches reads — GET responses are the caller's concern; this API only ever enqueues and replays writes." Only two mutating actions queue (entrant scan, gas log). Every read — permit lookup, QR resolution, certificate status, audit log — is an axios call that fails without network.

deploy/RUNBOOK.md §7 is unambiguous:

Offline — NOT deployed. … a service worker, runtimeCaching, an IndexedDB queue and POST /sync/batch. None of that is builtvite-plugin-pwa is not installed in this repo. The doc describes an intended feature, not the shipped app. Inspectors need network.

And it goes further, laying down a rule that is directly opposed to what the Chemical Safety Assistant needs: "never cache /api/ responses — a cached GET /permits/qr/:token would show an inspector a stale permit status in the field."

That rule is correct for a permit system, where the authoritative fact is a mutable server-side status. It is wrong for this project, where the Corpus is a curated, versioned body of SDS content and a Safety-Critical Content read must never touch the network at all. The architectural verdict this frontend embodies — server is the source of truth, the client caches nothing it reads — is the exact inversion of an offline-first Corpus in IndexedDB.

Concretely, the following patterns must not be carried over:

  • pages calling a provider in onMounted and rendering a 'loading' | 'result' | 'error' state machine over a network round trip (InspectorGasLogPage.vue);
  • Interceptors.ts's 401 → window.location.href = /auth/login hard redirect, which turns any network hiccup into a navigation;
  • HttpRequest's timeout: 120000 — a two-minute hang is survivable for an office review screen and unacceptable for a Handler at the point of exposure.

What does transfer, unchanged: the raw-IndexedDB-module-as-source-of-truth pattern with a Pinia snapshot over it (R-59), the "never rename the IndexedDB database" rule, and the self-hosted font/icon bundling (R-57).

Source: deploy/RUNBOOK.md §7, src/stores/OfflineQueue.ts (docblock), src/resources/HttpRequest.ts, src/resources/Interceptors.ts, src/pages/inspector/pages/gas-log/pages/InspectorGasLogPage.vueConfidence: high

R-66 CONFLICT — there is no PWA tooling of any kind in this repo, and it was deferred on purpose

A repo-wide grep (excluding node_modules, dist, .git) for service worker, serviceWorker, workbox, vite-plugin-pwa, precache, beforeinstallprompt, manifest.json and standalone returns no hits in source, config or public/. The only matches are prose: deploy/RUNBOOK.md §7, one line in progress.md, and the bundled Playwright skill docs.

  • public/ holds favicon.svg, logo.svg, robots.txt, _redirects, vite.svg and image/font directories — no manifest.webmanifest, no icon set sized for installability.
  • index.html has no <link rel="manifest"> and no theme-color meta; it deliberately carries <meta content="noindex, nofollow" name="robots" /> and robots.txt Disallow: /.
  • progress.md: "PWA / service worker deliberately deferred — the task doc says confirm with the product owner before adding it because it changes build tooling."

So the consuming project gets zero reusable PWA work from here and must add vite-plugin-pwa/Workbox, a manifest, a maskable icon set and a precache strategy from scratch. Note the interaction with R-60: router.onError's chunk-retry-then-location.assign recovery is written for a network-fetched chunk. Under a precaching service worker that logic must be revisited or it will fight the SW's update cycle.

Source: repo-wide grep; public/, index.html, deploy/RUNBOOK.md §7, progress.md:108Confidence: high

R-67 Camera + QR is built and is directly reusable: src/composables/useQrScanner.ts, BarcodeDetector first with a jsqr fallback, and it returns a pure decodeQrPayload for unit testing

jsqr ^1.4.0 is a runtime dependency and qrcode ^1.5.4 generates codes. The whole camera pipeline is one composable, src/composables/useQrScanner.ts (7.3 KB), shared by two screens. Its shape:

  • useQrScanner({ onDecode }) returns { status, isNativeDetectorSupported, errorMessage, lastResult, videoRef, start, stop } with status: 'idle' | 'requesting' | 'scanning' | 'denied' | 'error' | 'stopped'.
  • It renders nothing. The caller binds videoRef to its own <video>.
  • start() calls navigator.mediaDevices.getUserMedia({ video: true }) and sets status = 'denied' on rejection rather than throwing — the denied state is a first-class UI state.
  • Two decoders behind one interface. If 'BarcodeDetector' in window, it uses new BarcodeDetector({ formats: ['qr_code'] }) and detector.detect(video). Otherwise (iOS Safari) it falls back to drawing each frame to an offscreen canvas and calling jsQR(imageData.data, width, height). Both loops drive on requestAnimationFrame and are cancelled through a shared cancelLoop()/releaseStream()/stopRequested teardown, with onUnmounted(stop) as a backstop. BarcodeDetector is typed locally (IBarcodeDetector, IBarcodeDetectorConstructor) — no @types package.
  • export function decodeQrPayload (raw: string): TQrDecodeOutcome is a pure function, separated out precisely so the decode contract is unit-testable with no camera and no DOM. It returns a discriminated union { ok: true, raw, payload } | { ok: false, raw, reason: 'malformed' }. Today's validation is only "non-empty after trimming", and a module docblock flags the payload format as unconfirmed with the backend.

Two operational traps recorded in README.md and deploy/RUNBOOK.md §6 that apply verbatim to a QR-label Identification route:

  1. getUserMedia and BarcodeDetector need a secure context — HTTPS or localhost. Testing bun run dev from a phone against your machine's LAN IP fails camera permission and "the scanner looks broken for reasons unrelated to the code."
  2. Camera cannot be verified in CI (progress.md): the decode pipeline is unit-tested against a fixture image; the camera path is manual-test only. The runbook's phone checklist explicitly includes verifying that the manual-entry fallback works and that the jsqr fallback actually engages on iOS Safari.

Source: src/composables/useQrScanner.ts, README.md §"Camera access (QR scanning)", deploy/RUNBOOK.md §6, progress.md:106-107, package.jsonConfidence: high

R-68 CONFLICT — auth is woven through the router, the HTTP layer, the layout and the nav; omitting accounts means deleting five specific things, not just skipping a login page

The Chemical Safety Assistant has Anonymous Use as its only mode. This repo's account machinery reaches into:

  1. src/stores/Auth.tsuseAuthStore holding IUser (id, name, email, role: TUserRole, image) and IToken { accessToken, expireIn }, persisted two ways: user to localStorage under auth, userToken base64-encoded into a js-cookie cookie user_access_token via a custom IStorage (src/utils/Storage.ts).
  2. src/router/index.tsguardRoute redirects to LoginPage on meta.auth with an empty token, and to NotPermittedPage on a failed meta.permission check. Route meta.auth: true and meta.permission appear on essentially every non-error route.
  3. src/resources/Interceptors.ts — a 401 anywhere logs out and hard-navigates to /auth/login; HttpRequest sets withCredentials: true for a better-auth session cookie (there is no Authorization header at all).
  4. Role-based routing and navsrc/utils/RoleHomeRoute.ts (getRoleHomeRoute()), src/utils/Permission.ts + src/composables/usePermission.ts (TPermissionModule), and src/composables/useNavItems.ts, which derives visible nav from the account role. AGENTS.md: "Nav visibility is derived from the account role — never a user-facing toggle."
  5. Pages and copysrc/pages/auth/** (login, reset-password, plus a VITE_TRIAL_LOGIN demo-login path), ProfileDetailPage, NotPermittedPage, the auth.* and profileMenu.* i18n namespaces, and the Playwright auth.setup.ts fixture that writes storageState.

Every one of these is a place a reflexive copy would reintroduce accounts. The parts that are worth keeping without auth are meta.layout, meta.titleKey, the lazy-import convention and the chunk-retry onError — the guard itself becomes an empty function or is dropped.

Note also that the whole app is built as an authenticated internal tool: index.html carries noindex, nofollow and public/robots.txt is Disallow: /, with a comment reasoning "every page is behind a login". An installable, anonymous PWA has the opposite indexing and entry-point requirements.

Source: src/stores/Auth.ts, src/utils/Storage.ts, src/router/index.ts, src/resources/Interceptors.ts, src/utils/RoleHomeRoute.ts, src/utils/Permission.ts, src/composables/{usePermission,useNavItems}.ts, src/pages/auth/, index.html, public/robots.txt, playwright.config.tsConfidence: high

R-69 Thai-first: the plumbing is genuinely Thai-default, but two Thai strings are hardcoded outside the catalogue — and the EN/TH split here is UI-language, not identity-vs-copy

What is already right and should be copied: DEFAULT_LOCALE = 'th', fallbackLocale: 'th', <html lang="th">, the persisted-locale composable, the self-hosted IBM Plex Sans Thai subset, firstDayOfWeek: 1, timestamps stored/transported in UTC and localised to Asia/Bangkok (AGENTS.md §i18n, src/utils/Dayjs.ts), and the EN/TH key-parity test (R-56).

Two leaks a new project must not inherit:

  • src/plugins/primevue.plugin.ts hardcodes Thai day/month names in primeVueConfig.locale rather than deriving them from the active locale — the DatePicker stays Thai in the EN UI.
  • src/utils/Schema.ts hardcodes Thai validation messages (กรุณาเลือก${label}, 'ไฟล์ต้องเป็นรูปภาพหรือ PDF'). The per-screen create…Schema(t) factories (R-63) exist because this was a real bug; the shared util was never fixed.

The shape difference matters more than either leak. Here, en and th are two complete translations of the same UI, switchable by a pill in the top nav, and locale parity is enforced as an invariant. The Chemical Safety Assistant is Thai-first with English chemical identity retained — product name, supplier and CAS Number stay in their source language inside Thai copy, and a Curated Translation is a Corpus artifact reviewed at Curation, not a message-catalogue entry. Nothing in this repo models a string that is deliberately untranslated, and nothing models per-record translated content. vue-i18n catalogues are the right home for Conversational Content and chrome; they are the wrong home for Safety-Critical Content, which must carry its Source Span. Copying the catalogue mechanism is right; copying "every user-facing string goes through t()" as an unqualified rule is not.

Source: src/plugins/i18n.plugin.ts, src/plugins/primevue.plugin.ts, src/utils/Schema.ts, src/assets/css/fonts.css, AGENTS.md §i18n Confidence: high — the leaks are read from the files; the shape difference is an inference from CONTEXT.md, and would be settled by an ADR on where Curated Translation lives

Open questions

Things this note could not settle. These deliberately carry no R- id.

  1. Does vite-plugin-pwa coexist with the chunk-retry onError handler, or does one have to go? Both react to a failed dynamic import, and no repo in the workspace has run them together.
  2. Is the raw-IndexedDB style (no idb/dexie) still the right call at Corpus scale? It was chosen here for a two-store write queue of tens of rows. A Corpus of SDS documents with indexed search over Source Spans is a different workload, and the "no wrapper dependency" rule was never tested against it.
  3. Where do Curated Translation and Source Span live relative to vue-i18n? R-69 argues they are Corpus data and not catalogue entries, but nothing in the sibling repo settles it.
  4. Is bun a hard organisational constraint or this repo's choice? AGENTS.md forbids npm/yarn/pnpm, CI installs bun explicitly on both GitLab and GitHub, but no cross-repo policy document was read.
  5. Should the consuming project keep @primevue/forms at all? Its value here is the $form/zodResolver binding for large permit forms; an app whose only inputs are a search box and a scanner may not earn the dependency.

Coverage

Stage 0 is the head of the pipeline — it has no upstream artifact to account for. This table is intentionally empty; the first real Coverage table is written by stage 1, which must account for every R-nn above.

UpstreamLanded inEvidenceNote