Appearance
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:
bun install(only whennode_modulesis missing)bun run lint—eslint .bun run typecheck—vue-tsc --noEmit -p tsconfig.app.jsonbunx vitest runnode scripts/check-status-contrast.mjs— a WCAG AA contrast gate on every status colour pairnode scripts/check-icons.mjs— every Iconify name referenced insrc/must be onscripts/icons/allowlist.mjsnode 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, lint → test:run → build → wrangler 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,
ApiErrorcode mapping,Storage,PermitNormalize,RoleHomeRoute,ScanHistory(againstfake-indexeddb ^6.2.5, a devDependency — IndexedDB logic is unit-tested, not only e2e). - composables —
useQrScanner,useOfflineQueue,useOnlineStatus,useLocale,usePermission. - router —
AuthGuard.test.tscalls the exportedguardRoutewith a fabricatedto; one test per route module asserts its records. - components and pages — mounted with
@vue/test-utils, asserting againstdata-testidhooks. - theme — colour tokens and WCAG contrast, mirroring the
check-status-contrast.mjsgate.
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-ignoredA 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.tsAGENTS.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:
| Kind | Convention | Real example |
|---|---|---|
| Component | PascalCase.vue | src/pages/safety-officer/pages/review-detail/components/JsaSection.vue |
| Composable | useName.ts | src/composables/useQrScanner.ts |
| Store | Name.ts, no Store suffix; export is useNameStore | src/stores/OfflineQueue.ts → useOfflineQueueStore |
| Util | PascalCase.ts | src/utils/PermitNormalize.ts |
| Model | Name.model.ts | src/models/response/permit/ |
| Enum | Name.enum.ts | src/enums/modules/permit/PermitStatus.enum.ts |
| Provider | Name.provider.ts | src/resources/provider/permit/Permit.provider.ts |
| Router module | Name.router.ts or modules/<mod>/index.ts | src/router/modules/Auth.router.ts |
| Schema | Name.schema.ts | src/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.yaml—src/locales/en.tsandsrc/locales/th.ts, each a singleexport 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.tsis 52 KB againsten.ts's 32 KB (Thai in UTF-8). - Thai is already the default locale AND the fallback locale.
DEFAULT_LOCALE = 'th', andfallbackLocale: DEFAULT_LOCALE— an English-only key falls back to Thai, not the other way round.index.htmlships<html lang="th">statically so a pre-hydration view is Thai by default. - Locale is chosen and persisted in
localStorageunder the keylocale, read at module evaluation byreadStoredLocale()(which validates the stored value against'en' | 'th'and falls back to'th'), never in a Pinia store and never in a cookie. Nonavigator.languagesniffing at all. document.documentElement.langis kept in sync in two places — once at boot ini18n.plugin.ts, and again inuseLocale().setLocale(), both guarded withtypeof document !== 'undefined'for Vitest.- Registration order in
src/plugins/index.tsisregisterIcons()(side effect) →router→pinia→Sanitize→registerPrimeVue()→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'safterEachsetsdocument.titlefromroute.meta.titleKey, localised. Route titles are i18n keys, not literals.error.*— one key per backenderrorCode(error.gasOutOfRange,error.entrantsStillInside,error.certExpired,error.fireWatchNotElapsed, …).AGENTS.mdis 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:
enandthexpose the same key set (expect(enKeys).toEqual(thKeys)on sorted arrays);enhas no empty string values;thhas 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 filename —
defineStore('Auth', …),defineStore('OfflineQueue', …),defineStore('ScanHistory', …)— while the exported function isuseAuthStore/useOfflineQueueStore/useScanHistoryStore, and the file has noStoresuffix. - The setup function has an explicit return-type interface declared above it (
IAuthStore,IUseOfflineQueue,IUseScanHistory), typed withRef<…>/ComputedRef<…>. ESLint'sexplicit-function-return-typemakes this mandatory, not stylistic. - Each file also does
export default useXxxStorealongside the named export.
Persistence is an explicit allowlist, using persistedstate v4's pick (not paths), as an array of per-key configs — src/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 memoisedlet dbPromiseso the database is opened once; openDb()handlingonupgradeneededidempotently (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 sameclientIdis 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:
- 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. - The store does not use
useOnlineStatus()— that composable tears listeners down inonUnmounted, and a store setup has no component instance, so it registers its ownwindow.addEventListener('online'/'offline')and disposes them inonScopeDispose.
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.mdcalls 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), andmeta: { auth: true, layout: 'default', permission: 'scan' }; childrenwhosepathis 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
nameis the PascalCase component name —InspectorGasLogPage,NotPermittedPage,ProfileDetailPage— and a comment on/profilenotes the name is load-bearing because other componentspush({ name })to it. meta.titleKeyis an i18n key, never a string.afterEachsetsdocument.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.permissionis aTPermissionModuleor 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' }whenmeta.authand the token is empty,{ name: 'NotPermittedPage' }when a required module is missing, elsetrue. Registered asrouter.beforeEach(guardRoute). The export exists "so it is unit-testable in isolation — build a faketo… no router/DOM mount required." router.onErrorretries chunk-load failures twice, counting insessionStorageunderchunk-retry:${to.fullPath}and thenwindow.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 insrc/.- 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 insidecomputed/filteralso carry return types and typed parameters —typedefwitharrowParameter: trueandexplicit-function-return-typewithallowTypedFunctionExpressions: falseforce 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 inlineimport { type X }/import type { X }(consistent-type-imports,fixStyle: 'inline-type-imports'). data-testidattributes are placed on anything a test or screenshot asserts against (data-testid="overdue-banner",data-testid="queued-offline-message").AGENTS.mdrequiresdefineProps/defineEmitsto be type-based (no runtime object form),v-bindshorthand, andv-onhandlers written inline with the event —@click="loadHistory()", never@click="loadHistory".vue/max-lenis 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 insrc/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:
AGENTS.mdforbids re-enabling PrimeVue's theme. All component styling lives insrc/volt/*.vuePassThrough wrappers written in Tailwind v4 classes — 24 wrappers today (Button,DangerButton,SecondaryButton,ContrastButton,InputText,InputNumber,Select,MultiSelect,DataTable,DatePicker,Dialog,Toast,Password, …) plus a sharedsrc/volt/utils.ts.- Auto-import covers
src/volt/only.Components({ dirs: ['src/volt'], dts: true })— the generatedcomponents.d.tsat repo root lists exactly those 24 plusRouterLink/RouterView. So<Button>,<InputNumber>and<Select>appear in templates with no import statement, while everything undersrc/components/**is imported explicitly (import LabelField from '@/components/input/LabelField.vue'). New Volt components are scaffolded withvolt add <ComponentName>; adding an explicit import is wrong. 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 centralschemas/directory. Shared Zod primitives live insrc/utils/Schema.ts(schema.id,schema.enum,schema.date,schema.media,schema.richText,schema.numericField). - The schema is a factory taking
t, typed asexport 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-
undefinedcoercion is done withz.preprocess(…), and the file also exportsuseXxxInitialValues()and anIXxxFormValuesinterface alongside the schema. - Field layout goes through the shared
src/components/input/LabelField.vue, which takes the$formslot object plusnameand renders the error — pages do not read$form.<field>.errorby hand. - Submit goes through
FormSubmitEvent;src/utils/HandleSubmit.tsandsrc/models/Form.model.tswrap 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 GasLogProviderEvery 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:
onResponseunwraps the{ message: 'success', data }envelope — but returns the whole body minusmessagewhen there are sibling fields (count/page/limit/totalPageon lists,overdueon the gas log), because unwrapping todata"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.
humpsis still a dependency;AGENTS.mdstill claims interceptors do the conversion — the doc is stale against the code. onResponseErrorlogs 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/… says | Reality |
|---|---|
naming-conventions.md: Providers are <Name>Provider.ts, router modules <DomainName>.route.ts | Files 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: string | Five 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/Auth | Request |
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:
| Prefix | Usage |
|---|---|
Base* | layout/structural primitives — BasePage, BaseTop, BaseContainer, BaseTab |
App* | app chrome — AppDrawer, AppDrawerMenu, AppNav, AppIcon |
*Button | semantic 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 thedark:variant. - Mobile-first, with the breakpoints written down: none/0px mobile default,
sm:640,md:768,lg:1024. Grids always startgrid-cols-1and expand (grid grid-cols-1 md:grid-cols-2is right;grid grid-cols-2is called out as wrong). Flex rows useflex-col sm:flex-roworflex-wrap; action bars wrap; padding isp-4 md:p-6, never a fixed large value. - Touch targets ≥ 44px, minimum
h-9, preferringh-10/py-2.5, and never two tappable elements closer than 8px.AGENTS.mdrestates this as a hard minimum "44×44px on every interactive element", designed for "plant floor, gloved hands" at 375–430px first. Real code usesh-11andmin-h-11. - Overflow:
truncate/break-words/overflow-hiddenon long content; "horizontal scroll is acceptable only insideDataTable— never at the page level." Tables hide non-critical columns withhidden md:table-cell; dialogs set:style="{ width: 'min(95vw, 480px)' }". Never add scoped CSSin components that use volt wrappers, andptViewMergefromsrc/volt/utils.tsmust 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) }) }scrollToFirstErrorlives insrc/utils/HandleSubmit.tsand is unit-tested (src/tests/utils/HandleSubmit.test.ts). handleLoading(callback, options?, errorCallback?)(src/utils/HandleLoading.ts) adds and removes global loading viauseLoadingStore, catches the error and raises the toast — "No need to wrap in try/catch." Anoptions.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/onDeletecontain onlyhandleLoading+ emit + close, and pick the action with a ternary, never anif/elsethat duplicateshandleLoading. LabelFieldrenders anInputTextfrom its default slot automatically; inner selection/date components need nonameattribute because the resolver validates the reactiveinitial-values.- Child forms are submitted by the parent through
defineExpose: the child holdsuseTemplateRef<any>('formRef'), exposessubmit()callingformRef.value?.submit(), and the parent callschildRef.value?.submit()to trigger validation. - Form error state is typed by
IFormStateinsrc/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 viarouter.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 andPOST /sync/batch. None of that is built —vite-plugin-pwais 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
onMountedand rendering a'loading' | 'result' | 'error'state machine over a network round trip (InspectorGasLogPage.vue); Interceptors.ts's 401 →window.location.href = /auth/loginhard redirect, which turns any network hiccup into a navigation;HttpRequest'stimeout: 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/holdsfavicon.svg,logo.svg,robots.txt,_redirects,vite.svgand image/font directories — nomanifest.webmanifest, no icon set sized for installability.index.htmlhas no<link rel="manifest">and no theme-color meta; it deliberately carries<meta content="noindex, nofollow" name="robots" />androbots.txtDisallow: /.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 }withstatus: 'idle' | 'requesting' | 'scanning' | 'denied' | 'error' | 'stopped'.- It renders nothing. The caller binds
videoRefto its own<video>. start()callsnavigator.mediaDevices.getUserMedia({ video: true })and setsstatus = '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 usesnew BarcodeDetector({ formats: ['qr_code'] })anddetector.detect(video). Otherwise (iOS Safari) it falls back to drawing each frame to an offscreen canvas and callingjsQR(imageData.data, width, height). Both loops drive onrequestAnimationFrameand are cancelled through a sharedcancelLoop()/releaseStream()/stopRequestedteardown, withonUnmounted(stop)as a backstop.BarcodeDetectoris typed locally (IBarcodeDetector,IBarcodeDetectorConstructor) — no@typespackage. export function decodeQrPayload (raw: string): TQrDecodeOutcomeis 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:
getUserMediaandBarcodeDetectorneed a secure context — HTTPS orlocalhost. Testingbun run devfrom a phone against your machine's LAN IP fails camera permission and "the scanner looks broken for reasons unrelated to the code."- 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:
src/stores/Auth.ts—useAuthStoreholdingIUser(id, name, email,role: TUserRole, image) andIToken { accessToken, expireIn }, persisted two ways:userto localStorage underauth,userTokenbase64-encoded into ajs-cookiecookieuser_access_tokenvia a customIStorage(src/utils/Storage.ts).src/router/index.ts—guardRouteredirects toLoginPageonmeta.authwith an empty token, and toNotPermittedPageon a failedmeta.permissioncheck. Routemeta.auth: trueandmeta.permissionappear on essentially every non-error route.src/resources/Interceptors.ts— a 401 anywhere logs out and hard-navigates to/auth/login;HttpRequestsetswithCredentials: truefor a better-auth session cookie (there is noAuthorizationheader at all).- Role-based routing and nav —
src/utils/RoleHomeRoute.ts(getRoleHomeRoute()),src/utils/Permission.ts+src/composables/usePermission.ts(TPermissionModule), andsrc/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." - Pages and copy —
src/pages/auth/**(login, reset-password, plus aVITE_TRIAL_LOGINdemo-login path),ProfileDetailPage,NotPermittedPage, theauth.*andprofileMenu.*i18n namespaces, and the Playwrightauth.setup.tsfixture that writesstorageState.
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.tshardcodes Thai day/month names inprimeVueConfig.localerather than deriving them from the active locale — the DatePicker stays Thai in the EN UI.src/utils/Schema.tshardcodes Thai validation messages (กรุณาเลือก${label},'ไฟล์ต้องเป็นรูปภาพหรือ PDF'). The per-screencreate…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.
- Does
vite-plugin-pwacoexist with the chunk-retryonErrorhandler, or does one have to go? Both react to a failed dynamic import, and no repo in the workspace has run them together. - 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. - 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. - Is
buna hard organisational constraint or this repo's choice?AGENTS.mdforbids npm/yarn/pnpm, CI installs bun explicitly on both GitLab and GitHub, but no cross-repo policy document was read. - Should the consuming project keep
@primevue/formsat all? Its value here is the$form/zodResolverbinding 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.
| Upstream | Landed in | Evidence | Note |
|---|