Skip to content

Migration/vue3 - #57

Merged
Spiritreader merged 66 commits into
masterfrom
migration/vue3
Jul 14, 2026
Merged

Migration/vue3#57
Spiritreader merged 66 commits into
masterfrom
migration/vue3

Conversation

@Spiritreader

Copy link
Copy Markdown
Owner

No description provided.

bmaeofu and others added 30 commits July 13, 2026 16:32
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 tasks across the 12 spec stages. Adds the TextDataTable v-model contract
change to the spec's breaking-change inventory, found while mapping Vuetify
APIs to files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each task file carries the global constraints, the Vuetify conversion table
(port tasks only), and its own steps: 65-353 lines instead of the master
plan's 1474.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Import package-lock.json via 'pnpm import' to preserve resolved versions.

Nuxt 2's webpack build requires a flat node_modules, which pnpm's strict
symlinked layout does not provide. pnpm 11 ignores shamefully-hoist in .npmrc:
the setting now lives in pnpm-workspace.yaml. Both are present; the workspace
file is the one that takes effect. Without it the build fails with
"Cannot find module 'vue'". Removed at cutover, when Nuxt is dropped.

Docker build not verified: Docker is not installed in this environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Registry check showed the newest Vuetify is 4.1.4, not 3.x. Vuetify 4 is still
a Vue 3 library (peer: vue ^3.5.0), so "newest Vuetify compatible with Vue 3"
is v4.

- Adds a second conversion table for the v3->v4 delta: grid props become
  utility classes, v-select's #item slot becomes #internalItem, VContainer
  max-widths shrink, elevation drops to 0-5.
- Vuetify 4 is Material Design 3, so the app will NOT look pixel-identical.
  Verification restated: same components, same places, same hierarchy, same
  interactions. Typography, elevation, casing and margins may differ and must
  not be "fixed" back.
- defaultTheme must be 'dark' explicitly; v4 defaults to system preference.
- vue-router pinned to 4.6.x: unplugin-vue-router peers on ^4.6.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e.yaml, not .npmrc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- package.json lint script invoked npm, which is not on PATH and violates the
  pnpm-only constraint.
- Document in pnpm-workspace.yaml that .npmrc's copy of the hoist setting is
  inert on pnpm 11, so a partial revert cannot silently look correct.
- Plan: Task 1's Files header omitted pnpm-workspace.yaml.
- Plan: Jenkinsfile is legacy and out of scope; CI is GitHub Actions only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
server/app.js exports the Express app; server/index.js boots it standalone
and will serve the SPA from dist/ after cutover. Nuxt still consumes the same
app via api/api.js, so behaviour is unchanged.

Mongo URL moves to MONGO_URL (same default). Drops body-parser for Express's
built-in parsers, replaces deprecated Mongoose callbacks with promises, and
returns 400 rather than 500 for missing request fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The upstream MongoDB server was upgraded, and mongoose 6 is three majors
behind with an outdated driver. Mongoose 9 requires Node >= 20.19, so the
Docker base image moves from node:20.1.0-alpine to node:24-alpine.

Express 5 rejects the bare '*' path (path-to-regexp v8), so the SPA fallback
becomes '/*splat'. Drops body-parser, which Express has built in.

Also fixes two defects found during Task 2:
- mongoose.connect() had no .catch(), so an unreachable database killed the
  process under Node 24. server/index.js becomes the production process at
  cutover, where that is unacceptable.
- Queries hung indefinitely against a down database instead of failing;
  serverSelectionTimeoutMS makes them return 500 in ~5s.

Unknown /api paths now return a JSON 404 instead of falling through to the
SPA fallback and returning index.html with a 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
… stack

Queries issued while disconnected are buffered by Mongoose and never reach
server selection, so serverSelectionTimeoutMS did not govern them and
GET /api/clients took 10.2s to fail. bufferTimeoutMS bounds that path.

Verified: 500 in 5.2s (was 10.2s), 400 on empty POST, JSON 404 on unknown
path, process still alive with Mongo unreachable.

Plan synced: Task 2's code blocks showed Express 4 patterns and an
unguarded mongoose.connect(); Task 13's Dockerfile pinned node:20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Express 5 leaves req.body undefined, not {}, when no parseable body is sent.
Both POST handlers destructured it directly, so a bodyless request threw a
TypeError and Express's default handler answered with a 500 HTML page
containing a stack trace and absolute filesystem paths. The original Express 4
code returned 500 JSON. This is the production API surface after cutover.

Guards req.body in both handlers and adds a JSON error handler, so malformed
JSON returns 400 JSON instead of a 400 HTML page with a stack trace.

Verified on an isolated port:
  POST /api/clients      (no body)        400 {"error":"Name and Addresses are required"}
  POST /api/clients/delete (no body)      400 {"error":"_id is required"}
  POST /api/clients      (malformed json) 400 {"error":"malformed request"}
  POST /api/clients      ({})             400  (unchanged)
  GET  /api/nonexistent                   404  (unchanged)

Found by review of Tasks 2/2b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds vite.config.ts, the Vuetify 4 instance (stock dark theme, primary
#9E9E9E and secondary #FF8F00), file-based routing via unplugin-vue-router,
and the native-fetch http wrapper replacing @nuxt/http.

No baseURL is configured: relative /api paths resolve against the origin in
prod and are proxied to Express in dev. The Vite dev proxy was verified to
reach the standalone Express server on :10009.

Resolved versions:
  vue 3.5.39, vue-router 4.6.4, vuetify 4.1.4, @mdi/font 7.4.47
  vite 8.1.4, @vitejs/plugin-vue 6.0.7, vite-plugin-vuetify 2.1.3,
  unplugin-vue-router 0.19.2, typescript 7.0.2, vue-tsc 3.3.7,
  @types/node 26.1.1

Two deviations from the plan, both forced by the installed versions:

1. VTimePicker is no longer in Vuetify labs. In Vuetify 4 it graduated to
   the stable `vuetify/components` entry point, so `vuetify/labs/VTimePicker`
   does not resolve and broke the dev server. As a stable component it is
   picked up by vite-plugin-vuetify's autoImport, so the explicit
   registration is dropped. Verified: <v-time-picker /> builds and
   auto-imports with no registration.

2. tsconfig.node.json is not created. The plan lists it but supplies no
   content and no project reference to it; the given tsconfig.json already
   includes vite.config.ts, so a second file would be dead.

KNOWN BREAKAGE: the Nuxt app no longer boots. Installing vue@3 as a direct
dependency makes root node_modules/vue resolve to 3.5.39 under the
shamefullyHoist flat layout Nuxt 2 requires, and vue-server-renderer@2.7.15
hard-asserts a matching vue. `vuetify` collides the same way (nuxt.config.js
imports vuetify/es5/util/colors, which Vuetify 4 does not ship). The plan
assumed Vue 2 and Vue 3 could coexist as "separate packages"; they are the
same package name and there is only one node_modules/vue. Side-by-side
coexistence needs a design decision -- see the task report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
Nuxt 2 and Vue 3 cannot coexist in one node_modules: 'vue' is one package name
at two versions, and shamefullyHoist (which Nuxt 2 itself requires) forces the
collision. Vuetify 2 and 4 collide the same way. The plan's claim that they
were "separate packages in the tree" was wrong, and Task 3 killed the Nuxt app.

Side-by-side comparison is the entire verification strategy for Tasks 4-12, so
the old app now runs from ../avior-nuxt-reference, a git worktree pinned to
baff6fe with its own node_modules. Verified: compiles with zero errors, boots.
The dead dev:nuxt / build:nuxt / start:nuxt scripts are removed.

Also:
- typescript pinned to ^5. vue-tsc peers on ">=5.0.0", which TypeScript 7
  satisfies while being wholly incompatible: pnpm typecheck died with
  ERR_PACKAGE_PATH_NOT_EXPORTED. Now 5.9.3 and typecheck passes.
- VTimePicker is stable in Vuetify 4, not labs. Plan corrected; this removes
  the risk flagged as Task 12's likeliest blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Subagents have no browser, so they could not see the pages they port. Tasks
4-12 are UI ports whose acceptance criterion is "same components, same places,
same interactions" — unverifiable by compiling alone.

inspect-page.mjs loads a page in headless chromium and writes console.txt,
structure.txt and screenshot.png. structure.txt is a SEMANTIC dump (headings,
nav items, tabs, buttons, input labels, table columns, rendered Vuetify
components), not a DOM diff: Vuetify 2 and 4 emit different class names for the
same component, so comparing markup would be all noise.

Commits baselines for all five pages, captured from the real Nuxt app in the
reference worktree. Each port task diffs its output against these.

KNOWN GAP, documented in the plan: Mongo and the daemons are unreachable from
this machine, so both apps render their empty state. The baselines cover the
shell, nav and static controls — NOT populated tables or client cards. Data-
dependent behaviour still needs verifying on the real network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
App.vue replaces layouts/default.vue; a [...path] catch-all route replaces
layouts/error.vue.

Vuetify 4 computes layout geometry itself, so the app/fixed/clipped/
clipped-left props are gone from v-app-bar and v-navigation-drawer. The
app-bar button that toggled 'clipped' has been removed rather than left
wired to nothing. Its logo image is retained as a plain <img>, so the app
bar looks unchanged -- only the dead button is gone. Say so if you would
rather the logo went with it.

Two deviations from the task brief, both driven by what Vuetify 4 actually
does:

- v-footer KEEPS `app`. Unlike v-app-bar/v-navigation-drawer (which are
  layout items unconditionally), VFooter is opt-in: without `app` it is a
  plain flex child with `flex: 1 0 auto` and stretches to fill the page --
  measured at 320px tall instead of 40px. `app absolute` reproduces the old
  `app :absolute="!fixed"` with fixed hard-coded false.
- v-icon color "grey lighten-1" -> "grey-lighten-1". The Vuetify 2 space
  form emits a broken class in Vuetify 4.

Other changes: v-list-item-action/-content unwrapped into #prepend + a
direct v-list-item-title; :mini-variant -> :rail; <nuxt /> -> <router-view />;
process.env.commitSha -> import.meta.env.VITE_COMMIT_SHA (still falls back
to "dev"). The <style> block carries over verbatim.
The MONGO_URL default (10.11.194.75, inherited from the committed
api/config.json) was stale. The database is at 192.168.178.75:27017 and is
reachable: it connects in 0.2s and returns five real clients. This also closes
the last unverified piece of Task 2b — Mongoose 9 does talk to the upgraded
MongoDB server.

Baselines recaptured against live data (index 33 -> 57 elements, globalconfig
32 -> 69), so client-registry-driven UI is now genuinely covered by the port
tasks' verification rather than compared empty-to-empty.

Also corrects two things Task 4 found the hard way, both now in the conversion
table:
- VFooter KEEPS its `app` prop in Vuetify 4, unlike v-app-bar and
  v-navigation-drawer. Dropping it makes the footer a flex child that stretches
  to 320px and shoves content up. It emits NO warning — only the screenshot
  caught it. The plan told agents to remove it; that was wrong.
- color="grey lighten-1" must become "grey-lighten-1"; the v2 space-separated
  form silently emits a broken class in v4.

Daemons remain unreachable: 192.168.178.61 pings in 1ms but TCP 10000-10002
refuse connections, so daemon-dependent UI still renders its offline state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two regressions the structure dump missed and the screenshot caught.

The drawer. layouts/default.vue says `drawer: false`, and the port reproduced
that faithfully — but Vuetify 2's `v-navigation-drawer app` auto-opened on
desktop regardless of the initial v-model, so the REAL app ships with the
drawer open. Vuetify 4 honours v-model literally, so a faithful port produced
a closed drawer. Reproducing the observable behaviour means `drawer: true`.

The `exact` prop was dropped in the port. Without it, vue-router prefix-matches,
so `to="/"` is active on every route and Overview stays highlighted everywhere.

Also closes the hole in inspect-page.mjs that hid the first bug: a closed
Vuetify drawer is translated off-screen, not hidden, and keeps its dimensions —
so the visibility check counted its items as rendered. It now rejects elements
outside the viewport bounds.

Verified: drawer open with 5 items, Overview highlighted, nav identical to the
baseline, console clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First page on the new stack. Proves the browser -> Express -> Mongoose path.
Nuxt's fetch() hook becomes refresh() called from mounted(); $http becomes the
native-fetch wrapper. Drops dead commented-out localhost:3000 fetch calls.

SimpleList's v-list-item-group was already commented out and its model never
declared, so no selection state is ported. Adds persistent-counter to the client
name field: Vuetify 2 always showed the counter, Vuetify 4 only shows it on
focus, and the reference app shows it permanently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
The previous baselines were captured while the environment was half-broken and
were actively misleading — settings.txt recorded an EMPTY client list, so a
correct port showing 5 clients would have looked like a regression.

Three separate causes, all now fixed:
- The daemons were unreachable (the client machine's network adapter was on
  Windows' Public profile). Now reachable; VDR-U/-1/-2 answer 200.
- The reference app's own MONGO_URL was the stale 10.11.194.75.
- The reference Nuxt ran on a random port, so under SSR @nuxt/http fell back to
  localhost:3000, could not reach itself, and its fetch() timed out. Pinned to
  3300 with a matching baseURL. This is precisely the SSR base-URL bug the SPA
  migration eliminates — reproduced live.

Baselines now contain real data: settings lists all 5 clients with addresses,
index/jobs/config/globalconfig carry live daemon state.

Task 5's port verified against the corrected baseline: list items byte-identical
(5 clients, same addresses, same order), inputs identical, 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rop Docker

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Largest and most carefully tuned component in the project (879 lines).
Behaviour preserved exactly. v-tooltip's activator slot API changed
(on/attrs -> props); v-skeleton-loader, v-virtual-scroll and v-btn-toggle
props checked individually against Vuetify 4.

Daemon log calls remain absolute URLs straight from the browser, not proxied.

Three changes go beyond a mechanical rewrite; all three fix real breakage:

- VCard's `progress` slot is `loader` in Vuetify 4, and its LoaderSlot renders
  the slot content unconditionally (passing `isActive`) where Vuetify 2 rendered
  it only while `loading` was truthy. Left as-is the progress bars would show on
  idle cards, and an offline client -- which has no Encoder object at all --
  would throw on `client.Encoder.OfSlices`. The content is now gated on
  `isLoading`, the exact expression Vuetify 2 passed to `loading`.

- src/api/http.ts `get()` did an unconditional JSON.parse. Nuxt's $http used
  destr(): JSON when the body parses as JSON, the raw string otherwise. The
  daemons serve everything as text/plain and /logs/{main,err,processed,skipped}
  are plain log text, so every log fetch would have thrown. get() now follows
  destr's rule.

- The style block declared lang="scss" but contains no SCSS syntax. The only
  sass in node_modules is a stale 1.32 transitive leftover that Vite 8 cannot
  drive, so it is plain CSS now: identical output, no new dependency.

Pre-existing quirks preserved, NOT fixed:
- getSkippedLog() sets showProcessedLog = false twice and never clears
  showErrorLog, so opening Skipped Log leaves Error Log open.
- Line 37's `v-else-if="isActive"` references the method without calling it, so
  it is always truthy. Preserved verbatim.
- `:v-show="!determineIndeterminate()"` on both progress bars binds a prop
  literally named "v-show" and has never done anything. Preserved verbatim.
- bufferValue returns undefined when no process is active.
Nuxt's fetch() hook becomes refresh() from mounted(); $fetchState.pending and
$fetchState.error become plain `loading` and `fetchError` data properties.
$http.$get becomes get() from @/api/http, this.$set becomes direct assignment,
and the promise.any polyfill import is dropped in favour of the native
Promise.any (the dependency stays in package.json until Task 14).

Fixes a latent bug: the client registry was fetched from the relative path
'api/clients' rather than '/api/clients', which only worked because this page is
mounted at the root and would have broken on any nested route.

Address-resolution logic remains duplicated here; it is extracted into a
composable in the TypeScript pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
…raps

The ten parallel Modules ports all made the SAME four mistakes, because they
all validated against the same incomplete table. vue-tsc catches none of them:
an unknown attribute on a Vue component falls through to $attrs with no type
error, so every agent reported 'typechecks: true' in good faith.

Verified against the shipped Vuetify 4.1.4 typings:
- VRow STILL has dense/align/justify/no-gutters. My table said they were removed.
- 'outlined' is NOT a VTextField prop, only a variant value. Bare 'outlined'
  silently renders the default filled variant.
- color='red darken-3' (v2 space syntax) is not a valid v4 colour; falls back.
- v-slider: ticks='always' moved to show-ticks; tick-labels does not exist.
- v-slider #thumb-label slot arg renamed value -> modelValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ported in parallel by ten agents, then fixed as a batch.

A consistency reviewer reading all ten together caught four errors that every
agent made identically, because they all validated against the same incomplete
conversion table — and that vue-tsc cannot catch, since unknown attributes on a
Vue component fall through to $attrs with no type error:

- bare `outlined` on v-text-field: not a prop in Vuetify 4, only a variant
  value. Silently rendered the default FILLED variant. Now variant="outlined".
- color="red darken-3": the v2 space-separated helper is not a valid v4 colour;
  components silently fell back to their default. Now "red-darken-3".
- v-slider ticks="always": the "always" value moved to a separate show-ticks
  prop; ticks now takes number[] | Record<number,string>.
- v-slider tick-labels: does not exist in v4 at all. Labels now come from
  `ticks` as a Record — added a computed to build it from the existing array.

Left deliberately alone: the #thumb-label slot arg rename (value ->
modelValue). Neither slider sets thumb-label, so the slot never renders in
either version. Dead code stays dead.

Also reverts a justify="start" -> utility-class change in index.vue: VRow in
4.1.4 still has dense/align/justify/no-gutters, contrary to what the table said.

Scripts untouched: all ten remain Options API JavaScript, props and the
`newdata` event byte-identical, so config.vue and Module.vue port against the
original contract.

NOT visually verified — these render inside config.vue, which is Task 10.
Typecheck and build pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found four more Vuetify 2 boolean shorthand props that vanish silently in
Vuetify 4 — same failure mode as the batch before: unknown attributes fall
through to $attrs, so vue-tsc passes and the component renders wrong.

- v-btn bare `text` -> variant="text". VBtn's variant defaults to 'elevated'
  independently of `text`, so Cancel/Delete rendered as filled elevated buttons
  instead of flat text buttons.
- v-btn bare `large` -> size="large". No boolean `large` prop exists.
- v-alert `dismissible` -> `closable`, and bare `text` -> variant="text".
  In v4 `text` on VAlert is a STRING CONTENT prop, so a bare `text` does not
  even mean what it looks like.
- v-alert transition="fade-transition" dropped: not a valid VAlert prop.
- v-subheader -> v-list-subheader (CacheConfig).
- Dialog activator slot: {on, attrs} -> {props}.

Verified `flat` is NOT affected: it is still a real prop on VCard, VBtn,
VToolbar and VAppBar in 4.1.4. Left alone.

REVERTED one change the porter made: it converted xs="6" to cols="6" on
Property.vue's v-col, believing xs was a lost breakpoint. It was not. Vuetify
2's VCol breakpoints were ['sm','md','lg','xl'] — there has never been an `xs`
prop, in either version. The attribute is dead code in both, so converting it
would ADD a 50% span at narrow widths that the page never had. Behaviour
preserved; the dead attribute stays dead. Same call as Client.vue.

Module.vue gets no Settings imports: its only reference to AudioSettings is
inside an HTML comment, and the real extension point is a <slot>. config.vue
instantiates the Settings components as slotted children, so the imports belong
there (Task 10).

NOT visually verified — these render inside config.vue (Task 10). Typecheck and
build pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng="scss"

globalconfig.vue was the heaviest Vuetify conversion in the project.

- v-layout / v-flex (Vuetify 1-era grid, gone since v3) -> v-row / v-col.
  Geometry measured at 1400/800/500px rather than mapped blindly: md keeps all
  5 fields on one row (4+2+2+2+2=12), sm wraps 6/3/3 then 6/6, xs wraps 12 then
  6/6. The two time fields only had md2 in v2 (auto-width below md).
- v-tabs-items -> v-window, v-tab-item -> v-window-item, v-tabs-slider deleted.
- v-menu / v-overlay activator slots: {on, attrs} -> {props}.
- TextDataTable's `value` prop -> `modelValue` (Vue 3's v-model contract) and
  its headers from {text,value} to {title,key}.
- append-icon -> append-inner-icon (v4 renders append-icon OUTSIDE the box).
- v-list-group open state moved to v-model:opened on v-list; v4 has no
  per-group v-model.
- v-data-table needs return-object + item-value: v4 selection returns keys,
  not objects.
- VTimePicker is stable in v4 and auto-imported. It was flagged as this task's
  likeliest blocker; it was a non-event.

Verified live: all four field tables render real daemon data (name_exclude 20
rows); a full add/edit/delete round trip on log_exclude succeeded and daemon
state was restored. The time picker opens, advances hour->minute, and writes
back. Zero app console errors or warnings.

Also fixes a landmine that would have 500'd config.vue: every ported component
declared <style lang="scss"> while containing PLAIN CSS. The only sass in the
tree is a stale 1.32 inherited from the Nuxt/Vuetify 2 era, which Vite 8 cannot
drive (sass.initAsyncCompiler is not a function). All 14 blocks were checked —
not one uses nesting, variables, or @use — so the declaration is dropped rather
than a sass compiler installed. No new dependency.

Fixes the latent "api/clients" -> "/api/clients" bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First v-data-table conversion, and the one that hid the worst trap in the
migration.

- headers {text,value} -> {title,key}. Slot names item.Name / item.actions do
  survive unchanged — confirmed by rendering, not assumed.
- align: "right" -> "end".
- item-key -> item-value="ID" + return-object (the parent needs job objects,
  and v4 selection returns keys, not objects).
- disable-pagination, disable-filtering and unselectable DO NOT EXIST in
  Vuetify 4. Dropping disable-pagination alone silently renders only 10 of 34
  rows — a truncated job list with no warning and no error. :items-per-page="-1"
  is required. This is the single most dangerous find so far: it looks like
  missing data, not like a bug.
- @item-selected / @toggle-select-all are gone. Vuetify 4 does shift-range and
  select-all natively, so the manual keydown/bulkSelect machinery is replaced
  by a watcher.
- <v-list flat> dropped: no such prop in v4.

Verified live against 70 real jobs on daemon :10001. All 34 rows render for
VDR-U; add, edit, reassign and delete all exercised against the real daemon and
state restored. Zero app console errors or warnings.

De-duplication (flagged in the plan as able to regress silently) is NOT in the
daemon — two identical POSTs create two jobs. The dedup is in the SELECTION set:
select row 3, then row 1, then shift-click row 5 yields 5 selected, not 6.
Verified identical in the port; Vuetify 4 keeps selection in a Set.

Preserved deliberately, not fixed: the selectedJobs.length bug that stops the
Delete button showing a count, and reassignJobs()'s idx % 5 batching, which is
nonsense (idx is a client-ID string) but is what the original does.

Fixes the latent "api/clients" -> "/api/clients" bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bmaeofu and others added 29 commits July 13, 2026 21:31
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two real regressions, both mine — the conversion table missed them.

1. THE FONT WAS NEVER INSTALLED. Vuetify's CSS says
   `font-family: var(--v-font-body, "Roboto", sans-serif)` — it EXPECTS Roboto but
   does not ship it. Under Nuxt, @nuxtjs/vuetify injected it from Google Fonts
   automatically. Dropping that module dropped the font with it, and we only ever
   installed @mdi/font for icons, so the entire app silently fell back to a generic
   system sans-serif. Now self-hosted via @fontsource/roboto (no CDN dependency).

2. EVERY TYPOGRAPHY CLASS IN THE APP WAS DEAD. Vuetify 4 (Material Design 3)
   DELETED the old scale — .display-1, .headline, .title, .body-1, .body-2,
   .text-h4, .text-h5, .text-overline do not exist in dist/vuetify.css, and they
   are not aliased. So `class="display-1 text-h4"` on Client.vue's hostname
   resolved to nothing and rendered at plain body size, which is why the card
   header did not look like a header.

   My conversion table told agents not to INTRODUCE the MD3 names. It never told
   them to MAP the old ones. Mapped, verified against the classes Vuetify 4
   actually ships:
     display-1 / text-h4  -> text-headline-large
     headline  / text-h5  -> text-headline-small
     title                -> text-title-large
     body-1               -> text-body-large
     body-2               -> text-body-medium
     text-overline        -> text-label-medium

   Swept for the other Vuetify 2 utility conventions that MD3 also killed:
   no `red--text` colour helpers and no elevation above 5 exist in the codebase,
   so typography was the whole problem.

Also (uncommitted until now): vite.config.ts gains `host: true` and
`allowedHosts: true` so the dev server is reachable by hostname over the LAN/VPN.
Dev server only; production is served by Express.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fallout from the typography fix, and a genuine Vuetify 4 behaviour change.

Vuetify 2's .v-icon had a FIXED default size of 24px. Vuetify 3/4 size icons in
EM units (--v-icon-size-multiplier), so a default icon scales with its parent's
font-size. The six status icons live inside the hostname's heading div, so once
that div correctly became a 32px headline, the icons inflated with it.

They were only the right size before because the heading class was broken.

Swept the rest: no other v-icon sits inside a text-* container. settings.vue's
text-title-large row wraps only an <h2>, and Client.vue's text-headline-small is
on a v-progress-circular (whose size is set explicitly by prop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er implies flat

All 30 icon buttons in the app rendered as filled coloured discs instead of bare
tinted icons.

In Vuetify 2, the `icon` prop implied flat/transparent styling and `color` tinted
the ICON. In Vuetify 4, `icon` only sets the round shape; `variant` controls the
fill and DEFAULTS TO "elevated" — so `<v-btn icon color="blue">` became a filled
blue disc with a white glyph. The pause/resume/shutdown controls, every log
button, every add/delete button, and the app-bar toggle were all affected.

This is NOT a cosmetic Material Design 3 difference, which is how it was
originally triaged and left in place. It is a semantic change to what the `icon`
prop means, and it inverted foreground and background. Added variant="text" to
all 30.

Verified against the reference app: transparent buttons with coloured icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 44px

Vuetify 2's bare `large` on an icon button rendered 44px. Vuetify 4's
size="large" renders 56px. Those 12px inflated the flex row the hostname sits
in, which doubled the empty space under it — the "margins are way too large"
the user saw.

Measured against the reference app, before and after:

                          Vuetify 2   was      now
  icon button (large)        44px      56px     44px
  card header height         52px      64px     52px
  dead space under hostname  12px      24px     12px

Now identical to the original. `large` was mapped to size="large" on the
assumption the names meant the same thing; they do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gress arc

Three more Vuetify 4 behaviour changes, all found by looking at the running app.

1. THE GLOBAL CSS RESET IS GONE. Vuetify 2 shipped `* { margin: 0; padding: 0 }`
   and gave paragraphs a 16px bottom margin. Vuetify 4 removed it — the first
   breaking change in their upgrade guide — so every <p>, <h*> and <ul> in the app
   picked up the browser's UA defaults it was never written for. Stacked
   paragraphs each gained ~14px of space above them.

   Measured against the reference app:
                        Vuetify 2   was      now
     p margin-top          0px      14px     0px
     p margin-bottom      16px      14px    16px

   Restored in src/styles/reset.css. It MUST live in Vuetify's own
   `vuetify-core.reset` sublayer: a bare top-level `@layer reset` is a DIFFERENT
   layer, orders after all of Vuetify's, and therefore BEATS utility classes like
   `ma-0` instead of losing to them. Getting that wrong silently broke every
   ma-0 in the app — caught by measuring, not by reading.

2. VSystemBar IS A LAYOUT COMPONENT IN VUETIFY 4. It calls useLayoutItem, so it
   hoisted itself out of the job card and pinned to the top of the page at full
   width. In Vuetify 2 it was an ordinary inline element unless given `app`/`fixed`,
   and no prop restores that (`absolute` would overlay the card's own content).
   Replaced with a plain v-sheet strip: same colour, same 32px height, same
   content, but it stays inside the card where it belongs.

3. VProgressCircular's ARC ORIGIN MOVED. Vuetify 2 started the arc at 3 o'clock, so
   the original passes rotate="-90" to bring it to the top. Vuetify 4 already applies
   `rotate(calc(-90deg + Xdeg))` internally, so its default of 0 starts at the top —
   and the inherited -90 rotated it a further quarter turn to 9 o'clock. Prop dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ow metrics

All measured against the reference app rather than eyeballed.

BUTTON / TAB TYPOGRAPHY. Material Design 3 dropped the uppercase default and
re-specced button text entirely:

                    Vuetify 2   Vuetify 4   now
  text-transform    uppercase   none        uppercase
  letter-spacing    1.25px      0.11px      1.25px
  font-weight       500         400         500
  font-size         14px        16px        14px

These rules are UNLAYERED on purpose. Vuetify sets button typography inside its own
cascade layers, and layer order beats specificity — a rule inside `vuetify-overrides`
lost even at doubled specificity. Only unlayered wins.

STRIPED PROGRESS BAR. Both versions paint the same 135deg gradient, but Vuetify 4
scales the pattern to the bar's height instead of using a fixed size:
background-size went from `40px 40px` to `8px`. The encoder bar is 8px tall, so the
stripes came out 8px wide — invisible. Pinned back to 40px.

ICON SIZES. Vuetify 4 derives an icon-button's glyph size from the button size, so
size="44" produced a 48px icon where Vuetify 2 drew 28px. Pinned the three control
icons to 28px; the button box was already correct at 44px.

LOG BUTTONS. Vuetify 4's default icon button is 28px wide against Vuetify 2's 36px,
so the four log icons nearly touched. Pinned to 36x36.

LOG ROWS. The original wrapped each line in <v-list-item-content class="pa-0 pr-2">.
Vuetify 4 deleted that element and the padding reset went with it, so v-list-item's
own padding inflated every row above the 20px item-height the virtual scroller
assumes. Padding restored on the list item itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntent

These were bad ports, not Material Design 3 differences. Fixed with Vuetify 4's own
mechanisms rather than by bolting Vuetify 2's CSS back on.

INPUT VARIANT. Vuetify 4 defaults inputs to variant="filled" — the grey box. This app
is an underlined-input design (Vuetify 2's default, which every field here was written
against), which is why the edit dialogs, the search bar and the toolbar stripe all
looked wrong and grey. Set once via createVuetify's `defaults` — the idiomatic Vuetify 4
mechanism. An explicit variant="outlined"/"solo" on a field still wins.

MODULE CARD HEADER. Vuetify 4's v-card-title is `display: block` with nowrap/ellipsis,
where Vuetify 2's was a flex row — so v-spacer did nothing and the Enabled switch
dropped onto its own line. Now `d-flex align-center`, with the switch given hide-details
and density="compact". The original's `pt-0` also zeroed the top padding: harmless in
Vuetify 2, where the switch's own margins filled the gap, but in Vuetify 4 it glued the
title to the top edge of the card. Default padding restored.

SETTINGS LABEL. Vuetify 4 has no `text-overline`. MD3's nearest label style is
text-label-medium, but it is neither uppercase nor tracked — so the label rendered
sentence-case and flat. Added Vuetify 4's own `text-uppercase` utility.

CLIENT SELECT SHOWED "[object Object]". selectedClient was initialised to {}. Vuetify 2
rendered that as blank; Vuetify 4 falls back to String(value) for a model it cannot
resolve against `items`. null is the correct "nothing selected" model in Vuetify 4.

SIMPLELIST ADD ROW. The text field was nested inside <v-list-item-title>, which in
Vuetify 4 is overflow:hidden / nowrap with a fixed line-height — so the input could not
stretch and its caret sat below the text. It belongs in the list item's content slot,
which is what the deleted v-list-item-content used to provide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified by cropping the actual bar from both apps, not by reading CSS.

1. NO DEFAULT COLOUR. The stripes are white at 25% alpha. Vuetify 2's
   VProgressLinear defaulted to color="primary" (this app's grey), so they showed.
   Vuetify 4 has no default and falls back to currentColor — white. White stripes
   on a white bar. Client.vue now passes color="primary" explicitly.

2. PATTERN SCALED TO THE BAR HEIGHT. background-size went from a fixed `40px 40px`
   to `8px`, i.e. the bar's own height. Pinned back to 40px.

3. NO-REPEAT. background-repeat went from `repeat` to `no-repeat`, so even at the
   right size only ONE tile ever painted, at the left edge. Restored.

Any one of these alone hides the stripes completely, which is why the earlier
"the class is applied and the gradient is there" checks were misleading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dding

Vuetify 4's v-toolbar paints its own surface colour, so inside a card it rendered as a
lighter grey stripe behind the search bar. Vuetify 2's flat toolbar inherited the card's
background. color="transparent" restores that.

Its content padding also differs, leaving the search input flush against the card's left
edge. class="px-2" restores the gutter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ehind them

Handoff for a fresh session. Records what is still broken (tab animation,
resolutions row spacing, audio-format delete placement, globalconfig toolbar
buttons), how to reproduce and measure, and a table of every issue fixed so far
with its root cause.

The recurring pattern, worth internalising: almost every one was a Vuetify 2
default that Vuetify 4 changed SILENTLY. vue-tsc catches none of them, because
unknown attributes on a Vue component fall through to $attrs with no type error.
Reading Vuetify's CSS is also unreliable — `vuetify/styles` resolves to
lib/styles/main.css, not dist/vuetify.css. Measure computed styles in both apps
and diff; do not reason from source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g back

Vuetify 4's v-toolbar paints the `surface-light` theme token, which the dark
theme sets to #424242 — a washed-out grey band across the card. Overriding it to
transparent went too far the other way and the stripe vanished into the card.

Vuetify 2 used #272727 on a #1E1E1E card: surface, one step up. Vuetify 4's card
surface is #212121, so the same step lands on #2A2A2A. That is a dedicated theme
token rather than a retuned `surface-light`, because VSelect, VSlider, VAlert and
VTimePicker all share that one and globalconfig's time-picker clock face would go
nearly black.

v4's .v-toolbar__content also has no padding of its own (v2's was `4px 16px`), so
the search field sat flush against the card's left edge and the buttons against
the right. px-4 restores it. Measured against the reference app: search inset 16px,
buttons inset 24px, both now matching, and the two share a vertical centre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
… did

No transition in the app ran on the user's machine — tabs, dialogs, expands, all
of it. The cause was not a broken port: Windows' Accessibility > Visual effects >
"Animation effects" is off, so Chrome reports prefers-reduced-motion: reduce and
Vuetify 4 dutifully turns everything off. Vuetify 2 ignored the preference
entirely, so the old app animated anyway. Confirmed by reading
SPI_GETCLIENTAREAANIMATION, the flag Chrome actually maps to the media query.

Vuetify gates motion in two independent layers and both had to go:

  CSS (build/force-motion.ts, a PostCSS pass). Not only `reduce { duration: 0s }`
  — several transitions are only DECLARED inside a no-preference query (the
  floating field label, the outlined field's notch, the expansion-panel title), so
  under reduce they never exist and no override can restore them. The pass drops
  the reduce blocks and unwraps the no-preference ones. Doing it at build time
  rather than listing selectors by hand means Vuetify upgrades cannot silently
  reintroduce a gated rule. All 15 of Vuetify's queries are bare single-feature
  ones, so the rewrite is unambiguous; a compound query is left alone and warned about.

  JS (src/force-motion.ts). Every transition component's `disabled` prop defaults
  to PREFERS_REDUCED_MOTION(), which switches Vue's <Transition> off outright so it
  never emits the classes the CSS would style. It must be imported before Vuetify:
  the prop default is evaluated once, at module load. The patch is a Proxy, not an
  object spread — MediaQueryList exposes `matches` as a prototype getter and carries
  addEventListener, which Vuetify's display composable needs.

Verified in the built app with the browser forced to `reduce`: the window slide
samples 41 distinct frames and the dialog 15, identical to no-preference. Before,
reduce gave 4 and 1. Zero prefers-reduced-motion queries remain in the built CSS.

This deliberately overrides an accessibility preference app-wide, at the user's
request, to restore Vuetify 2 parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
Vuetify 4 rebuilt the grid on flexbox `gap`. Vuetify 2's .v-row carried a -12px
margin that cancelled the enclosing v-container's 12px padding, so the container
contributed no vertical height. v4's row has no negative margin (gutters come from
`gap`, and .v-col has no padding at all now), so the container's 16px top and bottom
padding started counting and every resolution row grew by 32px.

py-0 on the container restores what v2 did in effect. The col's own py-2 still
provides the separation between rows. Measured against the reference: row pitch is
72px in both apps again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
Both lists shrink-wrapped to ~260px instead of taking half the card each, which is
why the delete buttons sat against the item text rather than at the column's right
edge.

Vuetify 2's .col-md-6 carried `flex: 0 0 50%` on the class itself, so it sized
correctly inside any flex parent and the original got away with dropping the cols
straight into a `d-flex` container with no row. Vuetify 4 computes a column's
flex-basis from custom properties declared on .v-row (--v-col-size, --v-col-gap-x);
with no row they are undefined, the calc is invalid, and the column falls back to
shrink-wrapping its content.

Swept the whole app for the same shape — every tab of every page, since v-window-item
mounts lazily — and this was the only v-col in the codebase without a v-row parent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
Vuetify 4 pads a field's input asymmetrically (16px top, 0 bottom) to reserve room
for a floating label, but a solo field's label does not float — it stays vertically
centred. The label therefore rendered 8px above the input's text box and the caret
dropped below it.

Vuetify 2's solo label already behaved as a placeholder (it vanished on input rather
than floating up), so a real placeholder is the faithful equivalent, and it puts the
caret on the text. Measured: input padding is symmetric again and the prepend icon
and the input now share a centre line exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
…ton gaps

Sliders: MD3 re-specced them much heavier than Vuetify 2 (track 2px -> 4px, thumb
12px -> 20px, overall 32px -> 58px). Restored via v4's own trackSize/thumbSize/density
props as global defaults, so no CSS override is needed.

Dead space under inputs: Vuetify 4 permanently reserves ~26px beneath every field for
a validation message, whether or not one can appear. hideDetails: 'auto' collapses it
and expands only when a rule actually fails, so the fields that do have :rules (client
name, priority) still report their errors.

Module card contrast: the cards are #242424 and sat on the card surface #212121 — a few
values apart, so they read as one flat slab. The modules pane now uses the theme's
background token (#121212), the darker step that gives them an edge.

Button gaps: the original never declared one between Import and Export — it got 4px from
the whitespace text node between two inline-flex buttons. Vue 3's compiler condenses
whitespace-only nodes between elements away, so they ended up touching. Declared
explicitly on config and globalconfig rather than leaning on an HTML space again.
Swept every page and tab for adjacent button pairs whose gap changed; /jobs (20->16px)
and / (8->32px) also shifted and are still open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
Scratch Playwright probes used to diff computed styles against the reference app.
Committed by accident in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
…e schedule alert

Two causes for the panel, both the same Vuetify 4 grid rewrite:

  The switch reserved a 26px validation slot it can never use (50px -> 78px).
  VSwitch now defaults to hideDetails: 'auto', like the other inputs.

  Vuetify 4 puts a 24px margin BETWEEN consecutive rows (.v-row + .v-row) where
  Vuetify 2 gave them -12px. The original's explicit pt-4 was compensating for that
  negative margin; here it stacked on top of a gap that already existed. And as in
  Property.vue, the row's negative margin used to cancel the container's vertical
  padding, so py-0 restores what v2 did in effect.

Panel is 216px now against the reference's 204px; the remainder is v4's own row gap.

The schedule alert is a deliberate design change, not parity - the original was a solid
red-lighten-2 slab and the user asked for something less shouty. Tonal tints the surface
rather than filling it, with an accent stripe. Typed warning, not error: nothing failed,
the change just needs a client restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
The recurring root cause across nearly all of these is one thing: Vuetify 4 rebuilt
the grid on flexbox gap. Vuetify 2's .v-row carried a -12px margin that cancelled its
container's padding and its cols carried the gutters; v4's row has no negative margin,
its cols have no padding, and it adds a 24px margin BETWEEN rows. Any markup written
against v2 that leaned on the negative margin gains height, and any v-col without a
v-row parent collapses entirely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
Negative parallelism throughout the comments — "the same OBJECT, not a copy",
"null, not {}", "placeholder, not label", "surface, one step up, not four". Each says
what a thing isn't before saying what it is, which makes the reader hold two ideas to
receive one. Rewritten as direct statements; where the contrast carried real information
(the original used a label, the port used variant="text"), it is now spelled out as the
history it actually is.

Also drops "wired to" / "not wiring it up" in favour of saying what the code does.

No behaviour change; comments only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
…ert margin

Log rows (Client.vue): the original kept the list item's default px-4 padding and put
`pa-0 pr-2` on the inner v-list-item-content. Vuetify 4 deleted that wrapper, so the
port moved pa-0 onto the list item itself — which zeroed the horizontal padding that was
giving the log text its left margin, and the vertical padding it added on top of my-2 is
what made the rows sit too far apart. py-0 keeps the sides and drops the height.

Encoder tag field: the same bug as the audio formats — a v-col inside a bare `d-flex`
div with no v-row, so it shrink-wrapped to the width of its own text. The earlier sweep
missed it because EncoderConfig only mounts once a config is selected, so it was never
in the DOM to be found.

Encoder selects overlapped: they were held apart by the validation-message slot that
hideDetails: 'auto' now collapses. That slot was dead space everywhere else, so the gap
is declared explicitly here instead.

Jobs client rows: Vuetify 2 derived a list item's height from what was inside its
v-list-item-content, so title + subtitle automatically got the taller two-line box. v4
needs telling — lines="two". The home icon's pt-3 existed because v2's v-list-item-icon
was top-aligned; v4's #prepend is already centred, so it just dropped the icon 12px.

Checkbox: v2 tinted a ticked box with the theme colour, v4 leaves it at currentColor —
a hard white square in the dark theme.

Alert: v2's v-alert shipped a 16px bottom margin, so the original only ever set the
sides. v4's has none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
The "Enable Job Caching" box has to be flush with the "Cache Config" subheader above it
and the Redis URL field below. Two things pushed it in:

  The row's px-3 used to be cancelled by .v-row's -12px margin, which Vuetify 4 dropped,
  so those 12px started counting.

  Vuetify 4's checkbox reserves an 8px ripple gutter inside the control, so its glyph no
  longer starts at the component's own left edge. ml-n2 takes that back.

Measured: glyph, subheader and field now share a left edge (376px), as all three do in
the reference (368px). Same ml-n2 applied to the "Pause service if encoding fails" box,
which had the 8px offset against the fields above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
py-0 -> py-1 (4px top and bottom) on the virtual-scroll rows in all five logs. Zeroing
it was the right call for the horizontal margins but left the rows tighter than the user
wants to read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
…bled

Vuetify 2 sized a list item from what was inside its v-list-item-content, so a title
plus a subtitle automatically got the taller two-line box. Vuetify 4 deleted that
wrapper and has to be told with lines="two". Without it the job rows in JobDataTable and
the client rows on /settings packed the name and its path into a single-line box (48px
against the reference's 62px). Swept the app: every list item with both a title and a
subtitle now sets it. The five log rows in Client.vue are left alone — they are subtitle
only and deliberately single-line.

Disabled buttons were the worse bug. Vuetify 2 neutralised them; Vuetify 4 keeps their
colour, so a disabled button renders at full saturation and reads as ENABLED:

                    Vuetify 2                 Vuetify 4
  background        rgba(255,255,255,.12)     the button's own colour
  text              rgba(255,255,255,.3)      the button's own on-colour

/settings' Submit sat there in solid blue with white-on-blue text, and the data table's
delete button glowed red while it was unclickable. Restored in vuetify2-compat.css with
the literal Vuetify 2 dark values, since the theme is dark-only.

Also reverts the log rows to py-0 — that padding request was about the job rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
…pilling the card

All three are the same root cause: Vuetify 2's .v-col carried 12px of padding, and this
component's layout was built around it. Vuetify 4's col has none (gutters come from the
row's `gap`), so anything that relied on that padding moved.

  The Tag field sat flush against the edge while every other block in the component
  stayed indented 12px (ml-3 on the heading, mx-3 on the alert, px-3 on the fields
  below). px-3 on the row puts it back. Measured: heading, tag field and directory field
  now share a left edge, as all three do in the reference.

  The buttons' mt-3 was tuned against that same 12px. A fixed offset cannot land them on
  the field's centre line once the padding is gone, so the column centres them instead
  (d-flex align-center). Also drops the cols/md/lg/xl props that were sitting on the
  v-btn, where they have never meant anything.

The select spilling past the card's bottom border is the hideDetails: 'auto' trade-off
again: with no config selected it is the card's last child, and the message slot v2
reserved underneath happened to keep it clear of the edge. pb-4 declares that gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
…lour; top gap

The over-tall input boxes (Maximum Errors, Threshold, Maximum Age, Maximum Size, Sample
Count) were NOT a leftover validation slot, which is what they looked like and what I
wrongly dismissed twice. The row is a flex container, so it defaults to align-items:
stretch, and the help button's mt-3/mb-2 margins make it taller than the 56px field — so
the field was stretched to the button's height, leaving the value stranded at the top of
a box with dead space beneath.

Vuetify 2 hid this: its field reserved a message slot and was therefore TALLER than the
button, so the button was the one being stretched and nobody noticed. Collapsing that
slot with hideDetails: 'auto' exposed the mismatch. Centring the row removes the stretch,
and the button no longer needs vertical margins to fake its position. All five fields now
measure 56px, exactly as in the reference.

Module pane: #121212 made it the same colour as the page background, so it stopped being
a layer. A dedicated module-pane token (#1A1A1A) sits between the page and the cards
(#242424), so the cards keep an edge and the pane still reads as its own surface.

Top gap: Vuetify 2's client select carried a message slot that pushed the tab bar down.
With it collapsed, the card rode 22px higher and the tabs crowded the select. mb-8 on the
row declares the gap the layout actually wants. Tab bar is at 184px, reference 182px.

Encoder alert: its own 16px padding sat on top of a 12px margin, so its icon began at
28px while the heading and Tag field are at 12px. It paints no background (variant=text),
so only where its content starts reads — px-3 with no margin lands it on the same line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
Card titles: Vuetify 4 halved a card title's vertical padding (16px -> 8px), so every
card header sat cramped against its own top edge — /settings' "Add Client" and "Client
List", and every other card in the app. Restored as a VCardTitle default class rather
than a CSS rule, so the components that deliberately zero it (Module.vue's header,
Client.vue's four log headers) keep winning: their pb-0 is a utility like py-4 and beats
it on source order, where an unlayered CSS override would have outranked them. Verified:
the log headers still measure `16px 16px 0px`.

Slider bottom margin: the tick labels (low/med/high, include/neutral/exclude) hang below
the track, and the only thing keeping them off the card's bottom edge was the
validation-message slot. hideDetails: 'auto' collapsed it and they went flush against
the card. mb-4 on the two tick-label sliders; the ones with an append text field do not
need it, the field supplies the height. Slider-to-card-bottom is 32px, reference 34px.

Module SETTINGS gap: v-container's padding went from 12px in Vuetify 2 to 16px in v4, so
the space between the Priority field and the SETTINGS label grew with it. pb-3/pt-3
bring it back to the reference's 12px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
The app is signed off as usable. Rewrites STILL BROKEN down to the two minor measured
items nobody has raised since, and adds the section that actually matters for future
work: the two root causes behind nearly every issue in this migration — Vuetify 4
rebuilding the grid on flexbox gap, and hideDetails: 'auto' removing accidental spacing
along with the dead space.

Also records the blind spot in the v-col sweep: v-window-item mounts lazily and some
components only render after a selection, so a bare .v-col can be missing from the DOM at
scan time. That is how the encoder Tag field survived a sweep that reported the app clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyoqQ4kmb2CrxtK8yRJ3N2
@Spiritreader
Spiritreader merged commit daac417 into master Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants