# Fluidd AI Development Guide Fluidd is a Vue 2.7 + TypeScript web interface for Klipper 3D printers that communicates with Moonraker via WebSocket. ## Architecture Overview - **Vue 2.7 + Vuetify 2**: UI framework with Material Design components - **Vuex Store**: 28 namespaced modules mirroring Klipper/Moonraker domains (`printer/`, `files/`, `console/`, `macros/`, `webcams/`, `mmu/`, `spoolman/`, etc.) - **WebSocket Communication**: Real-time JSON-RPC via custom `WebSocketClient` in `src/plugins/socketClient.ts` - **Component Structure**: Class-style components with `vue-property-decorator`; mixins-based architecture with `StateMixin` providing common printer state access ## Key Patterns ### Component Architecture All components use **class-style decorators** — no Options API or Composition API: ```typescript // Standard component @Component({ components: { /* ... */ } }) export default class MyComponent extends Vue { @Prop({ type: String, required: true }) readonly label!: string @VModel({ type: Boolean }) open?: boolean } // Component needing printer state — extend via Mixins() @Component({ components: { /* ... */ } }) export default class PrinterWidget extends Mixins(StateMixin) { get klippyReady (): boolean { return this.$typedGetters['printer/getKlippyReady'] } } ``` **Available mixins** (`src/mixins/`): `StateMixin`, `FilesMixin`, `ServicesMixin`, `BrowserMixin`, `CameraMixin`, `ToolheadMixin`, `AfcMixin`, `MmuMixin` ### State Management - Store modules in `src/store/` — each has `state.ts`, `getters.ts`, `mutations.ts`, `actions.ts`, `types.ts` - Use `$typedState` and `$typedGetters` for type-safe store access (defined in `src/plugins/filters.ts`) - Use `Vue.set()` for reactive dynamic state properties - Module definition: `export const auth = { namespaced, state, getters, actions, mutations } satisfies Module` ### WebSocket Integration - All printer communication through `SocketActions` in `src/api/socketActions.ts` (not direct HTTP) - Pattern: `baseEmit(method, { dispatch, wait, params })` — `method` must be a key of `Moonraker.Methods`; `params` and the result type are inferred from it, never passed as a type argument - `Moonraker.Methods` maps every JSON-RPC method Fluidd calls to `{ params, result }`, built by declaration merging: each `src/typings/moonraker.*.d.ts` adds its entries in a separate `declare namespace Moonraker { interface Methods { … } }` block. Every entry declares both fields — `params: undefined` when the method takes none. Params are typed from the Moonraker docs, not from what Fluidd sends. Helpers: `Moonraker.Method`, `MethodParams`, `MethodResult` - `skipLibCheck` hides errors in `.d.ts` files, so an entry missing `params` or `result` only fails via `CheckedMethods` in `socketClient.ts` — the error does not name the method. A conflicting duplicate method key is **not** caught at all - Params are required in `EmitOptions` when the method has a required param, optional when all are optional, and rejected when it takes none. `RequestOptions` itself carries no `params`. A response reaches the store only through `dispatch` — there is no `commit` option - Generic results (`server.database.*_item`, `server.spoolman.proxy`, `server.extensions.request`) are `unknown` in the map; narrow at the call site with `baseEmit<'method', Narrower>(…)` — `Narrower` must extend the map's result - Dispatched handlers type their payload as `ObjectWithRequest<'method'>`, which types both the result and `__request__.params` (optional params may be `undefined`; default them to Moonraker's documented defaults). A string result is dispatched as `{ result }` - `Moonraker.Notifications` maps each JSON-RPC notification Fluidd handles to its params tuple (`undefined` when Moonraker sends none — it omits `params` entirely), merged per `moonraker.*.d.ts` like `Methods`. `socketClient.ts` dispatches `socket/` with the **first** param only (`notify_status_update` excepted: it is cached and flushed every second, using its `eventtime`); the `socket/` handlers type it as `Moonraker.NotificationPayload<'notify_…'>` (so they follow the map; helpers: `Moonraker.Notification`, `NotificationPayload`, `NotificationAction` — the `socket/` action name, via `TSHelpers.SnakeToCamelCase`), while the module actions they forward to name the concrete type — both spellings are intentional. `CheckedNotificationHandlers` (`src/store/socket/actions.ts`) fails type-check, naming the handler, when an entry has no `socket/` action; `CheckedNotifications` (`socketClient.ts`) checks the entry shape. Notifications outside the map (`notify_sudo_alert`, `notify_button_event`, …) still reach `dispatch` and are dropped as unknown actions - Moonraker agents: the `extensions` module holds connected agents, updated by `notify_agent_event`'s reserved `connected` / `disconnected` events; every agent event, reserved ones included, is then routed to the matching `Globals.MOONRAKER_AGENTS` entry's `eventDispatch`. `extensions/onExtensionsList` also sends each listed agent's `eventDispatch` a synthesized `connected` event, so an agent that connected before Fluidd listed the agents (or before a reconnect) is seen the same way as one connecting live. Symmetrically, `extensions/reset` (socket drop, instance switch) sends each known agent's `eventDispatch` a synthesized `disconnected` before clearing the list. No deduplication is done — a live `connected` can race the list, so agent handlers must be idempotent for repeated `connected` / `disconnected`. An agent reacts to its own `connected` / `disconnected` there — entries carry no separate init or teardown dispatch, only an optional `klippyDispatch` (run when `server/onServerInfo` sees `klippy_state` change, and on `notify_klippy_disconnected`). `Extensions.AgentEvent`'s catch-all `event: string` defeats discriminant narrowing — narrow with type predicates - Payload types used **only** by notifications end in `Event`, named after the notification with the namespace prefix dropped (`notify_job_queue_changed` → `JobQueue.ChangedEvent`, `notify_proc_stat_update` → `ProcStats.UpdateEvent`). A type shared with a method result keeps its `Response` name. `*Event` types are standalone interfaces — declare every field, never `extends`/alias/`Pick` a `Response`, even when the shape matches (`Announcements.DismissedEvent` vs `DismissResponse`). `Authorization.UserEvent` is the one content-named exception, shared by the three user notifications - `notify_user_logged_out` / `notify_user_deleted` are broadcast to every client, but Moonraker only de-authenticates that user's connections — `auth/onUserLoggedOut` and `auth/onUserDeleted` log out only when the `username` is the current user. `access.get_user` answers `{ username: null, … }` for a connection with no user; `auth/setCurrentUser` stores that as `null`. `auth.currentUser` and `auth.users` are `AppUser` (`src/store/auth/types.ts`, no `created_on`): neither the `access.login` response nor `notify_user_created` carries it. `notify_user_created` only fires for users created through the API, which Moonraker restricts to the `moonraker` source, so `setAddUser` sets that `source` itself - Use `wait` parameter for UI loading states: `wait: Waits.onPrintStart` - Wait constants defined in `src/globals.ts` (`Waits` object, ~90 operation types) - Real-time updates handled via store mutations from socket events - Auto-reconnect (`socketClient.ts`) retries forever, staying in `connecting`: exponential backoff from 1s, ±20% jitter, capped at 15s after the jitter (Fluidd is LAN-local with few clients, so retries are cheap and a long cap only delays noticing a printer is back), reset on open. A retry that comes due while the tab is hidden or the browser is offline is held instead of opened (`socket.retry` = `{ held: true }`), and resumes on `visibilitychange` / `online`. A pending retry fires immediately on `online`, when the tab becomes visible, or from `SocketDisconnected`'s Retry now button (`retryNow()`, a no-op without a pending or held retry). `socket.retry` = `{ at }` (epoch ms) is set only for waits of 5s or more (`RETRY_SHOW_AFTER`), and drives the button and the "Unable to connect to Moonraker. Retrying in X seconds..." countdown, so the first quick retries don't flicker them. While held, the button stays up and the message reads "Unable to connect to Moonraker.". `disconnected` is reached only for an empty URL or an `appInit` failure - Liveness uses one timer at a time, re-armed by any inbound frame (a JSON-RPC error counts): 10s of silence sends a `server.info` ping — unauthenticated sockets get no notifications, so only a ping produces traffic — then 10s more of silence replaces the socket. `socket.stalled` is true while that ping is outstanding (set by the ping timer, cleared by `clearSilenceTimer`, so any frame, `handleClose` or `close` resets it); `SocketStalledSnackbar` shows it over the main app only, as the connecting and login screens have their own state. The ping timer is not armed until `onopen`, since `send` throws while `CONNECTING`; the same 10s timer bounds a socket stuck in `CONNECTING`. The two stages are chained, not parallel, so timers that come due together after sleep or a frozen tab still give the ping its 10s. Every socket handler ignores events from a socket that is no longer `this.connection` - `RequestOptions.suppressError` (`boolean | (error: SocketError) => boolean`) skips the global `socket/onSocketError` toast for that request — the emit promise still rejects, callers still need a `catch`. A `code >= 500` is never suppressible (a 503 drives klippy recovery in `socket/onSocketError`), and a predicate that throws logs via consola and falls back to not suppressing. Narrow a caught rejection with `isSocketError` (`src/util/is-socket-error.ts`) — see `history/fetchMissingJobs` suppressing not-found errors from `serverHistoryGetJob`. Moonraker's JSON-RPC layer rewrites a 404 to `-32601` and a 401 to `-32602` (other codes pass through), so test with `isMoonrakerNotFoundError` / `isMoonrakerUnauthorizedError`, never the HTTP code. `is-socket-error.ts` owns `SocketError` and must stay import-free — `socketClient.ts` imports the store types, so pulling it into a spec drags the whole store into the vitest type-check project ### Component Registration - **Auto-imported** (no manual import needed): components in `src/components/common/`, `layout/`, `ui/` — via `unplugin-vue-components` with `VuetifyResolver` - **Manual import** required: widget components, view components - **Lazy-loaded**: `EChart` via `Vue.component('EChart', () => import('./vue-echarts-chunk'))` - Generated types: `components.d.ts` at repo root — **auto-generated, do not edit manually** ## Development Workflow ### Build Toolchain - **Node.js 26** — pinned in `package.json` via `devEngines.runtime` `^26.13.0` with `onFail: download` (engines: `^24 || ^26`). pnpm provisions it; there is no `.node-version` file and Corepack is not used anywhere — the pnpm version itself is pinned the same way, through `devEngines.packageManager`. Setting `packageManager` **as well** is an error in pnpm 12 ("Cannot use both"), so the two fields are mutually exclusive - **Vite 8** — build tool and dev server - **`@pedrolamas/plugin-vue2`** — Vue 2 SFC support for Vite - **`unplugin-vue-components/rolldown`** — auto-imports components from `src/components/common|layout|ui` - **`sass-embedded`** — SCSS preprocessor (variables auto-injected via `@/scss/variables`) - **vitest v5** — unit test runner; `test:unit` is a bare `vitest` call, with the environment, pool and setup files declared in `vitest.config.ts` - **pnpm catalog** (`pnpm-workspace.yaml`) — `dompurify`, `echarts`, `typescript`, `vite` and `vue` are pinned in the catalog and blanket-mapped through `overrides` so every transitive dependency resolves to the same version - **`typescript-native-bridge`** — the `typescript` catalog entry is `npm:typescript-native-bridge@…` (tsgo), not stock `typescript` - Local config imports use explicit `.ts` extensions (`./vite.config.ts`) and `import.meta.dirname` — no `__dirname` - **`commit-and-tag-version`** — release versioning (`pnpm run release`) - **ESLint flat config** (`eslint.config.mjs`) — enforced at dev time via `vite-plugin-checker` with `useFlatConfig: true` - **`vite-plugin-checker`** — runs vue-tsc and ESLint during dev (disabled at build time) - **`skott`** — circular dependency detection (`pnpm run circular-check`) - **ES2020 lib target** (`tsconfig.app.json`) — no ES2021+ built-ins without polyfills - `vite.config.ts`'s `build.rolldownOptions.output.codeSplitting.groups` pins `lodash-es`, `vue`/`vue-router`/`vuex`/`vue-i18n` (as `vue-vendor`), and `vuetify` into named vendor chunks; everything else (monaco, echarts, hls.js, iro, qr-scanner, …) deliberately stays on rolldown's default lazy chunking — do not sweep those into a group ### Essential Commands ```bash pnpm run prepare # Install git hooks (runs automatically on `pnpm i`) pnpm run dev # Start development server (port 8080) pnpm run build # Production build pnpm run type-check # TypeScript validation (vue-tsc) pnpm run lint # ESLint with Vue/TS rules pnpm run test # Vitest unit tests pnpm run circular-check # Check for circular dependencies ``` ### File Organization ```text src/ ├── api/ # WebSocket (custom JSON-RPC) client (`socketActions.ts`) ├── components/ │ ├── common/ # Shared dialogs & status components (auto-imported) │ ├── layout/ # App shell: AppBar, AppDrawer, etc. (auto-imported) │ ├── settings/ # Settings page components │ ├── ui/ # Reusable: AppBtn, AppDialog, AppChart (auto-imported) │ └── widgets/ # 27 feature widget dirs: bedmesh/, camera/, console/, filesystem/, macros/, mmu/, thermals/, toolhead/, etc. ├── directives/ # Custom Vue directives (v-safe-html for DOMPurify) ├── locales/ # i18n YAML files (24 languages) ├── mixins/ # Vue mixins (StateMixin, FilesMixin, etc.) ├── monaco/ # Monarch tokenizers and editor themes ├── plugins/ # Vue plugins (i18n, socketClient, vuetify, filters, colorSet) ├── router/ # Vue Router (hash mode) — no route-level auth guards ├── scss/ # Global styles and Vuetify variable overrides ├── store/ # 28 Vuex modules (printer, files, config, webcams, etc.) ├── types/ # UI-specific TypeScript types ├── typings/ # Global .d.ts declarations (Klipper, Moonraker namespaces) ├── util/ # Helper functions (30+) ├── views/ # Page components (Dashboard, Console, Jobs, etc.) └── workers/ # Web Workers (parseGcode, mjpegStream, sandboxedEval, Monaco language providers) ``` ### Router & Authentication - Hash-based routing (`#/path`) - Views lazy-loaded via dynamic imports: `component: () => import('@/views/X.vue')` - No route-level auth guard — authentication is handled entirely by `App.vue`: `socketReady` → render main app; `socketAuthenticating` → render Login overlay; else → render `SocketDisconnected`. Navigating to `/login` is redirected to `home` (the route no longer exists) - Socket state machine (`src/store/socket/actions.ts`): `initializing → {connecting | disconnected} → identifying → {ready | authenticating}`. `initializing` is the one-shot startup state; the app begins here and leaves it once `$socket.connect()` runs — transitioning to `connecting` (valid URL) or `disconnected` (empty URL). Every transition goes through `socket/onSetStatus`, which validates the edge against `VALID_TRANSITIONS`, commits the new status, and runs side-effects for the destination state. Entering `connecting` clears per-socket identity (`connectionId`, `acceptingNotifications`); `ready → connecting` additionally resets modules holding live data (`charts/resetChartStore`, which clears only the thermal bucket, plus `MODULES_TO_RESET_ON_DROP`). Re-running `appInit` (instance switch) calls `store.dispatch('reset')` which mutates socket state back to `initializing` directly via `setReset`, bypassing the state machine - JWT auth over WebSocket: `runIdentify` sends `server.connection.identify` with the stored user token (refreshed proactively if expired); if both tokens are expired, identify is called without a token (anonymous/trusted identify). On success it awaits the post-auth bootstrap — every Moonraker DB namespace Fluidd owns (skipping any with empty ROOTS), then `server.info`, `server.config`, `machine.proc_stats`, `machine.system_info`, and `server.files.list('config')` — before transitioning to `ready`; on failure → `authenticating`. `server.connection.identify` is one-shot per socket, so the logout→login path (same physical socket, new user via `access.login`) skips the identify call (connectionId already set) and runs only the bootstrap before transitioning to `ready`. The `ready` transition itself has no side-effects — entering `ready` simply unblocks the main app render - `auth/login` stores the fresh tokens then dispatches `socket/onSetStatus` with `identifying` to re-identify the live socket. `auth/logout` (full) transitions the socket to `authenticating`; the socket stays open and `App.vue` renders the Login overlay while `socket.status === 'authenticating'`, so login continues over the existing connection via `access.info` / `access.login`. `notifyUserLoggedOut` (fired when Moonraker invalidates the session) does the same - Token refresh policy (`getAccessToken` in `src/store/socket/actions.ts`): valid access token → use it; expired access token + valid refresh token → call `access.refresh_jwt` and use the new token; refresh rejected as unauthorized (`isMoonrakerUnauthorizedError`) (or both tokens unusable) → clear both from localStorage and identify anonymously; refresh rejected with anything else (transient: socket drop, network) → keep tokens, return `null`, let the next identify cycle retry - Key routes: `/`, `/console`, `/jobs`, `/tune`, `/diagnostics`, `/timelapse`, `/history`, `/system`, `/configure`, `/settings`, `/camera/:cameraId`, `/preview` ### Icons & Theming - MDI icons via `@mdi/js` — mapped in `src/globals.ts` (`Icons` object, ~234 mappings) - Usage: `{{ $globals.Icons.close }}` - Vuetify theme with custom dark/light overrides in `src/scss/variables.scss` - PWA support with service worker in `src/sw.ts` (Workbox, injectManifest strategy) ### Monaco Editor - Setup in `src/components/widgets/filesystem/setupMonaco.ts` (includes worker environment setup) - Monarch tokenizers for `gcode`, `klipper-config`, `moonraker-config`, `log` languages (in `src/monaco/language/*.monarch.ts`) - `src/monaco/language/gcode-rules.ts` is the shared G-code tokenizer factory — `createGcodeRules('standalone')` backs the `gcode` language, `createGcodeRules('embedded')` is spliced into `klipper-config` values - `klipper-config` highlights any option whose name **ends in** `gcode` (`gcode`, `*_gcode`) as a Klipper G-code template — `gcode_id`, `gcode_x_offset` and `gcode_load_sequence` merely start with it and stay plain values - Klipper's Jinja uses **single-brace** delimiters (`{ … }`, not `{{ }}`), so these rules are not reusable for standard Jinja; both hosts must set `ignoreCase: true` at language level because Monarch drops per-rule regex flags - Both editor themes in `setupMonaco.ts` carry `delimiter.jinja`, `keyword.control.jinja`, `variable.jinja` and `number.jinja` token colours - Custom CodeLens providers (links to Klipper/Moonraker docs from config sections) - CodeLens and document symbol providers for `klipper-config` and `moonraker-config`; folding range provider for `klipper-config`, `moonraker-config`, and `gcode` - Language providers run in dedicated Web Workers (`monacoCodeLensWorker`, `monacoDocumentSymbolsWorker`, `monacoFoldingRangesWorker`) ### G-code Preview - Parsed in `src/workers/parseGcode.worker.ts` → `parseGcode.ts` — streamed via `fetch` + `ReadableStream`, reporting progress by file position - Result is a **columnar `MoveStore`** of typed arrays, not `Move` objects (`src/store/gcodePreview/types.ts`): `x`/`y`/`z`/`i`/`j` `Float32Array`, `tool`/`flags`/`role`/`fan` `Uint8Array`, `speed` `Uint16Array`, `filePosition` `Uint32Array`, plus a `length` — every column is indexed by the same move index - `role` is an `ExtrusionRole` (PrusaSlicer's 15-role enum, Brim folded into Skirt), sticky from the last role comment. `src/workers/gcode-extrusion-roles.ts` merges per-slicer tag lists, taken from each slicer's source, into one case-insensitive dictionary — no slicer detection. A spec ensures a tag shared by two slicers maps to the same role; the code assumes it does. `parseLine` returns whole-line comments as a separate `comment` type; slicers never put a role tag after a command - `speed` is the sticky feedrate in **mm/min**, clamped to `0xffff` (`MAX_SPEED`) — a typed array write wraps rather than saturates. The UI shows mm/s - `fan` is the sticky part cooling fan as the raw **`M106 S` value (0–255)**, with Klipper's `M106` semantics: a missing `S` is 255, `P` is ignored, `M107` is 0; `SET_FAN_SPEED` only drives `[fan_generic]`, so it is ignored - The result also carries `roles` (sorted distinct roles of extruding moves) plus `speedRange` and `fanRange` (`ValueRange`: min/max over extruding moves, whole file — keeps the legends stable across layers — plus PrusaSlicer's `count`, exact only up to 2 distinct values) - `x`/`y`/`z` are **forward-filled** absolute positions after each move — there are no `undefined` axes to fall back through - `i`/`j` are arc centre offsets, **truncated after the last `Arc` move** — only read them when that move has the `Arc` flag set - `MoveFlags` is a bit field: `Extruding`, `Retracting`, `Arc`, `Clockwise` — test with `(flags[i] & MoveFlags.Arc) !== 0` - The worker **transfers** the `MoveStore` buffers via `postMessage` transferables rather than copying them; the parser owns nothing afterwards. `defaultMoveStore` (`gcodePreview/state.ts`) is the empty store used on reset - A layer's `move` is an **anchor** — the move whose *endpoint* is the layer start, i.e. the move before the layer's first real move. `buildLayerPaths` both seeds the toolhead from it and iterates from it, drawing it as a zero-length segment - SVG path data is built by `buildLayerPaths` in `src/util/gcode-preview.ts` (extracted from the old `getPaths` getter, which now just delegates). Every emitted `d` **must** start with a moveto — a `d` beginning with `L` or `A` renders nothing - `LayerPaths.extrusions` is keyed by colour group, chosen by `colorBy` (`uiSettings.gcodePreview.colorMode`): `T`, `R`, `S` or `F`, where `rangeBucket` rounds to the nearest of the 11 evenly spaced `rangeColors` stops over `speedRange` / `fanRange`, using PrusaSlicer's range palette. The legends list the stop values like PrusaSlicer's (mm/s, and percent for the fan), or just the values when there are 1 or 2 distinct ones. Colours come from `gcodePreview/getExtrusionColors`; only the current layer is coloured, the other layers stay grey - There is no colour-mode fallback: a file without role tags or extruding moves draws in one colour (`R0` / `S0` / `F0`) with an empty legend - Oversized files are truncated by the parser, which sets `truncated` on the result; `gcodePreview/actions.ts` surfaces it as an `EventBus` warning (`app.general.msg.gcode_preview_truncated`) ### Charts - Chart data lives in a **columnar ring buffer** of `Float64Array`s (`src/util/chart-buffer.ts`), not an array of point objects — a `time` array plus a `Map` of columns, all indexed by the same sample index - The live window is `[offset, offset + count)`, shared by `time` and every column. Read through `chartBufferSource` / `chartBufferColumn` / `chartBufferLastValue` / `chartBufferLastTime`, which apply `offset` for you — never index a column directly - **`revision` is the only reactive change signal.** Vue 2 observes neither typed arrays nor a `Map`, and the arrays are `markRaw`'d so the deep watcher skips them — writes are invisible to Vue. `chartBufferSource` reads `revision`, which is what re-runs a consuming computed; `ThermalChart` also `@Watch`es it, though `initSeries` then early-outs unless the column `Map` or `getChartableSensors` actually changed. Nothing goes through `Vue.set` - Missing values are `NaN`, never `undefined` and never a hole in the index — a sample carrying no value for a column still advances every column - `columns` is a `Map` for own-key semantics: on a plain object a sensor named `constructor` or `toString` passes `in` and its column is never created - The `chartBufferSource` / `smoothChartSource` result **must keep `Object.prototype`** — never `Object.create(null)`. ECharts' `setOption` deep-clones the whole option through zrender's `clone`, which calls `source.hasOwnProperty(key)` and throws on a null-prototype object. That clone also skips a key literally named `__proto__`, so a sensor by that name cannot be charted regardless — not worth defending against - `chartBufferSource` feeds ECharts' `SOURCE_FORMAT_KEYED_COLUMNS` `dataset.source` as subarray views. **`date` must be dimension 0**; ECharts derives the row count from column 0. Results are memoized per `ChartBuffer` by `revision`. The views only avoid a copy on our side — `setOption` deep-clones the whole option, and `clone` copies typed arrays via `Ctor.from` - Incremental `dataset` updates **must** pass `{ replaceMerge: 'dataset' }`. A plain merge runs zrender's `merge`, whose array guard is `Array.isArray` — a `Float64Array` fails it, so `merge` recurses into the column and copies index by index. A source shorter than the one ECharts already holds then keeps stale trailing rows, which duplicate earlier timestamps and make the tooltip report two points per series - With a keyed-columns source a tooltip `param.value` is a **positional array**, not a row object — resolve it via the `encode` / `dimensionNames` helpers in `src/util/chart-tooltip.ts` - `dropExpired` binary-searches `time`, so **samples must be appended in non-decreasing time order**. Appending a backlog behind live samples breaks that, so the thermal history load replaces the buffer wholesale (`setThermalStore`). `machine.proc_stats` re-runs on every reconnect, so `charts/onMoonrakerStats` instead appends only the stats past the buffer's tail (`moonrakerChartSamples`), keeping the accumulated window; the same action handles the live single-stat notification, so there is one ingestion path - `appendChartSamples` is the only append primitive — it takes a batch and bumps `revision` once for the whole thing, so a backlog is one re-render, not one per sample. `setChartEntry` is the single-sample mutation over it; `setChartEntries` takes the batch - `retention` is **seconds everywhere**, never a sample count. `capacityFor` uses it as a first-guess capacity only; samples can arrive faster than 1Hz, and `compact` grows the arrays when they do. `resizeChartBuffer` therefore keeps the whole live window and leaves expiry to `dropExpired` on the next append - History conversion is pure and lives outside the store plumbing: `thermal-history.ts` builds a whole buffer from `server.temperature_store`, writing columns directly and publishing with `commitChartSamples`; `moonraker-history.ts` only maps `machine.proc_stats` to `ChartSample`s - Thermal history is **right-aligned on a 1Hz timeline** ending at `endTime - 1000`, with the lead-in **held at each sensor's oldest non-null reading** rather than left `NaN`. That padding is load-bearing: `ThermalChart`'s x-axis is `max: 'dataMax'` with `min = max - retention * 1000`, so the window is always retention-wide and an unpadded short history renders as a stub in the corner - `server.temperature_store` samples may be `null`; they become `NaN` gaps, never `0` - Thermal column names are `` or `#` — build and parse them with `thermalColumn` / `parseThermalColumn` (`src/store/charts/thermal-columns.ts`); sensor names are runtime data and may themselves contain `#` - Retention: `Globals.CHART_HISTORY_RETENTION` (1200s — thermal, diagnostics) and `Globals.CHART_SYSTEM_RETENTION` (600s — system, MCU, sensor). Only `thermal` carries `retention` on its `setChartEntry` payload — the `ChartBucket` union makes passing one for any other bucket a type error — because only the thermal window is server-configured, overridden by Moonraker's `temperature_store_size` through the `charts/getChartRetention` getter. Diagnostics is fixed at `CHART_HISTORY_RETENTION`, so `DiagnosticsCard`'s x-axis `min` uses the constant too; every other bucket keeps whatever `state.ts` gave its buffer - `charts/resetChartStore` resets **only `thermal` and `ready`**, not the whole module — it is shared by the socket drop and root `resetKlippy`, and a klippy restart does not re-fetch `machine.proc_stats`, so blanking the other buckets would leave them with nothing to refill from - `smoothChartSource` (`src/util/chart-smoothing.ts`) is a trailing moving average over `uiSettings.general.chartSmoothingWindow` seconds (0 = off, the default); `NaN` values stay `NaN` rather than pulling the average down. Only bare thermal columns are smoothed — `ThermalChart`'s `smoothableKeys` filters to `parseThermalColumn(name).sub == null`, so target temperatures, heater power, and fan speed stay exact. `ThermalChart` `@Watch`es `chartRevision` and `chartSmoothingWindow` (the raw inputs), not the smoothed source, so the paused check can skip the recompute entirely ## Integration Points ### Klipper/Moonraker Communication - All printer commands via `SocketActions` methods — both init and live data flow over a single WebSocket - Store updates from WebSocket events (not polling) - File operations through Moonraker's file API (`src/store/files/`) - File uploads/downloads are the sole consumer of `axios` — direct calls in `src/mixins/files.ts` (for upload/download progress, which `fetch` cannot report for uploads), authenticated with a oneshot token fetched via `SocketActions.accessOneshotToken()`. There is no HTTP client plugin, and the rest of the app uses `fetch` or the WebSocket ### Print History - `Globals.JOB_HISTORY_LOAD` (100) bounds the initial `server.history.list` fetch; `history/onHistoryList` derives `state.allLoaded` from the echoed request's `limit` (via `ObjectWithRequest.__request__.params`, not the response), so a short page (or `limit: 0`, used by "Load all") both flip it correctly - A job referenced by a file in the loaded file listing but outside the loaded history window is back-filled individually: `history/fetchMissingJobs` diffs against loaded jobs and `state.unresolvedJobIds`, then calls `SocketActions.serverHistoryGetJob` per missing id, suppressing not-found errors (`suppressError: isMoonrakerNotFoundError`) since a file can reference a job Moonraker has since pruned - Those requests go out in chunks of `Globals.JOB_HISTORY_FETCH_CHUNK`, and each chunk lands as **one** `setUpdateHistoryJobs` commit. `serverHistoryGetJob` deliberately has no `dispatch` — a per-response commit re-runs `getHistory` and `files/getDirectory` and re-renders the file table once per job, which froze large directories. `unresolvedJobIds` holds ids both in flight and confirmed not-found — a success or any other failure removes the id, a not-found leaves it. `onHistoryList` deliberately never clears it: that would re-send in-flight requests and re-ask pruned jobs - Job lookups go through `history/getHistoryByIdMap` (a memoized `Map`), not a linear scan of `getHistory` - CSV export (`JobHistory.handleExportCsv`) always exports the **whole** history — it awaits `serverHistoryList({ limit: 0 })` first when `!allLoaded` (the emit resolves after `history/onHistoryList` has committed) — with filename plus the columns visible in the column picker, ignoring the search box. `getExportValue` returns raw values in base units, with no units in the headers, and a `Date` for the date columns. `Waits.onHistoryExport` is added/removed by hand rather than via the request's `wait`, as it spans the fetch, the build and the download - `toCsv` (`src/util/csv.ts`, import-free) owns every cell representation: RFC 4180 quoting, CRLF rows, `Date` → local `YYYY-MM-DD HH:mm:ss` (strict ISO with `T`/`Z` is not parsed as a date by Excel), arrays joined with `; ` element by element, and a leading `'` on formula-like **strings** (`=`, `+`, `-`, `@`) — numbers are exempt so negatives still sum. Callers pass raw values and prepend a `\uFEFF` BOM to the `Blob` so Excel reads UTF-8 ### Component Communication - Parent-child: Props down, events up - Cross-component: Vuex store or `EventBus` (`src/eventBus.ts`) - Flash messages: `EventBus.$emit(text, { timeout })` — displayed by `FlashMessage` component ### Dynamic Imports - `import.meta.glob()` used in `src/dynamicImports.ts` for lazy-loading: - `I18nLocales` — locale YAML files - `CameraComponents` — camera service Vue components - Views also dynamically imported in `src/router/index.ts` via `() => import('@/views/X.vue')` - `src/plugins/vuetify.ts` holds a private `locales` map of `() => import('vuetify/lib/locale/*')` thunks, keyed by **Fluidd** locale code, so the map doubles as the code translation (`zh-CN` → `zh-Hans`, `zh-HK`/`zh-TW` → `zh-Hant`, `pt-BR` → `pt`); a missing key (`ta`) leaves `$vuetify.lang` on `en`. Locale files and codes are **BCP 47 with regions, hyphenated** (`pt-BR`, `zh-TW`) — what `navigator.languages` sends and `Intl` accepts; `pt_BR` makes `Intl` throw. The Weblate component must use the matching hyphen language-code style. `SupportedLocales` (`src/globals.ts`) is hand-maintained with hardcoded native labels — a locale file without an entry there (`sv`, `th`, `zh-TW`) exists for Weblate but is not selectable. `config/onLocaleChange` drives it via `loadVuetifyLocaleAsync`, which `Vue.set`s the locale into `lang.locales` **before** assigning `lang.current` — the `current` write is what re-renders. These `import()`s do not actually code-split: `vuetify/lib/presets/default` statically imports the `lib/locale` barrel, so every referenced locale lands in the eager `vuetify` chunk regardless of `codeSplitting` groups - `loadLocaleMessagesAsync(locale?)` (`src/plugins/i18n.ts`) defaults to `getStartingLocale()` when called with no argument, guards on `i18n.availableLocales` rather than a separate loaded-list, and on a failed `I18nLocales[locale]()` import logs via consola and keeps the current `i18n.locale` instead of throwing. `getStartingLocale` walks `navigator.languages`, matching each entry's full code first (`pt-BR`, `zh-CN`) then its bare language, before falling back to `VUE_APP_I18N_LOCALE` or `en`. `config/onLocaleChange` holds `Waits.onLoadLanguage` for the duration, which drives the loading spinner on the language `v-select` in `GeneralSettings` ## Testing Conventions - Unit tests in any `src/**/__tests__/*.spec.ts` — not just `src/util/`; e.g. `src/workers/__tests__/parseGcode.spec.ts`. `tsconfig.vitest.json` includes `src/**/__tests__/*` at any depth, plus `src/typings/*.d.ts` so specs can reference the `Klipper`/`Moonraker` namespaces, and `tshelpers.d.ts`, which `src/typings` builds on (`TSHelpers.SnakeToCamelCase`) - **`environment: 'node'` is the default**, with `pool: 'vmThreads'` so each worker builds one environment instead of one per file. A spec needing a DOM opts in with a `@vitest-environment jsdom` docblock — only the three Monarch tokenizer specs (monaco touches `window`), the two `http-endpoint-diagnostics` ones, and the `safe-html`, `sanitize-html` and `mark-text-nodes` ones (DOMPurify and `markTextNodes` need a DOM; without one DOMPurify sanitizes nothing) do. Building jsdom for all 21 files was ~40% of the run; `environmentMatchGlobs` is not an alternative, it was removed in Vitest 3 - **Under `vmThreads`, jsdom's `location` cannot be redefined** — `Object.defineProperty`, `vi.stubGlobal('location', …)` and `vi.spyOn(location, 'protocol', 'get')` all throw `Cannot redefine property`. Set the page URL per file instead, with an `@vitest-environment-options { "url": "https://…" }` docblock; that is why the mixed-content case lives in its own `http-endpoint-diagnostics.mixed-content.spec.ts`. jsdom's default URL is already `http://localhost:3000/`, so http-protocol cases need no stub at all - `navigator` is a Node global (21+), so specs defining `navigator.languages` on the instance (`src/plugins/__tests__/i18n.spec.ts`) work unchanged under the `node` environment - Monarch tokenizer tests co-located in `src/monaco/language/__tests__/` — use shared `tokenize-helper.ts` (`registerLanguage`, `tokenizeLines`, `tokenBuilder`) - Global test functions (`describe`, `it`, `expect`, `vi`, `afterEach`, …) — `globals: true` in vitest config, typed via `/// ` in `env.d.ts`; specs import no vitest symbols - Setup file: `tests/unit/setup.ts` — includes `CSS.escape` and `window.matchMedia` polyfills required by Monaco in jsdom. It runs for every spec, including the `node`-environment ones, so the `matchMedia` half stays behind a `typeof window !== 'undefined'` guard - Time manipulation utility: `timeTravel(date, callback)` in `tests/unit/utils.ts` - Parameterized tests: `it.each([...])` pattern - Test store actions/mutations independently from UI - Stub `fetch` with `vi.stubGlobal('fetch', …)`, always restore with `vi.unstubAllGlobals()` in `afterEach` — see `src/util/__tests__/http-endpoint-diagnostics.spec.ts` and `src/workers/__tests__/parseGcode.spec.ts` (the latter stubs a real `ReadableStream` body) - `Float32Array` values read back are inexact (`0.2` → `0.20000000298…`) — assert with `toBeCloseTo`, or pick exactly-representable fixtures ## Code Style - Source must pass linting with **zero warnings and zero type errors** — run `pnpm run lint` and `pnpm run type-check` before committing - Vue class-style components with `vue-property-decorator` (`@Component`, `@Prop`, `@VModel`, `Mixins()`) - ESLint enforced: `neostandard` + `pluginVue.configs['flat/vue2-recommended']` + `pluginRegexp` + `@vue/eslint-config-typescript` - `.editorconfig` rules: 2 spaces, LF line endings, UTF-8, trim trailing whitespace, max line 100 (code) - camelCase for variables/methods, PascalCase for components - `src/components/ui/` components put a kebab-case class named after the component on their main element (`AppInlineChart` → `app-inline-chart`), with BEM `__element` classes for inner parts (`app-inline-chart__label`). Their scoped styles target these classes, not Vuetify's - Any link opening a new tab (`target="_blank"`) **must** also set `rel="noopener noreferrer"` — applies to ``, ``/`` with `:href`, and any HTML strings rendered via `v-safe-html` (e.g. `src/util/html-helpers.ts`) - Render plain text as plain text: `{{ value }}`, on one line so `pre-wrap` does not reveal template whitespace, and not `v-text` (lint rejects it on components). `v-safe-html` parses its input as HTML, so `` disappears and an unterminated ``). `AppInlineHelp` tooltips, `AppDragOverlay` messages and the `ExtruderStats` strings stay HTML, as their locale strings use `
`, ``, `` and ``. Store raw newlines, never `
` — `server.info` is sent `raw` so Moonraker's warnings arrive that way too — and put real newlines in locale strings rendered as plain text - `AppNamedSelect`, `AppNamedSlider` and `AppNamedTextField` lay out their label and input through `AppNamedContainer` (`src/components/ui/`), which owns the label column (`cols="12" sm="5"`, `text--disabled`, the plain-text fallback). `AppNamedSwitch` does not use it — its label-left, switch-right row has no column split. Each control keeps `inheritAttrs: false` and binds `$attrs` to its input; the container does not, so attributes set on it land on its `v-row`. A control forwards its `label` slot only when given one — `` — so the container's fallback renders from the prop otherwise; use `$scopedSlots`, as a `v-slot` slot is not always in `$slots` - `src/util/html-helpers.ts` (import-free) builds HTML from plain text: `linkExternalUrls` escapes everything except the anchors it generates, which is why `AppWarnings` can stay on `v-safe-html` without losing text. `escapeHtml` escapes `&`, `<`, `>`, `"` and `'`, so its output is safe in text and quoted attribute values - `v-safe-html` (`src/directives/safe-html.ts`) sanitizes to a DOM fragment with `sanitizeHtml` (`src/util/sanitize-html.ts`, which owns the DOMPurify config and its hooks: an `
` (HTML or SVG, matched by `localName`, reading `href` or `xlink:href`) whose `href` names its own `http(s)` host is forced to `target="_blank"` — decided by resolving it with `new URL` against a dummy `http://relative.invalid/` base, not a regex, so backslash, tab and slash-less forms (`\\x.com`, `https:x.com`) the browser treats as external are caught — an author's `target="_blank"` is kept on any ``, and every `_blank` link gets `noopener` and `noreferrer` added to its `rel`, so macro HTML can't skip them. Links without `href` (`ConsoleItem`'s command links), relative, hash, `mailto:` and `tel:` links keep the same tab) and inserts it with `replaceChildren`, never `innerHTML`: the nodes DOMPurify checked are the ones rendered, with no serialize and re-parse for mutation XSS to exploit. Its value is an HTML string or `{ html, search? }` (`SafeHtmlValue`); a `search` term is highlighted by `markTextNodes` (`src/util/mark-text-nodes.ts`): the raw term, case-insensitive, wrapped in `` in text nodes only, skipping `