Skip to content

Latest commit

 

History

History
562 lines (498 loc) · 58.6 KB

AGENTS.md

File metadata and controls

562 lines (498 loc) · 58.6 KB

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:

// 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<AuthState, RootState>

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<M>, MethodResult<M>
  • 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<M> 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/<camelCase(method)> 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<N>, NotificationAction<N> — 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. 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

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

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: <v-icon>{{ $globals.Icons.close }}</v-icon>
  • 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<tool>, R<role>, S<bucket> or F<bucket>, 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 Float64Arrays (src/util/chart-buffer.ts), not an array of point objects — a time array plus a Map<string, Float64Array> 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 @Watches 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 ChartSamples
  • 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 <sensor> or <sensor>#<target|power|speed> — 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 @Watches 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.sets 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 /// <reference types="vitest/globals" /> 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 <a>, <v-btn>/<app-btn> with :href, and any HTML strings rendered via v-safe-html (e.g. src/util/html-helpers.ts)

  • Render plain text as plain text: <span class="text-pre-wrap">{{ value }}</span>, 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 <none> disappears and an unterminated <min loses the rest of the string; keep it for strings that really carry markup. The form controls (AppNamedSwitch/Select/Slider/TextField), StatusLabel and CollapsableCard's subTitle render plain text; markup in a label goes in their label slot instead, which makes the label prop optional (OutputFan uses AppNamedSlider's for the RPM in <small>). AppInlineHelp tooltips, AppDragOverlay messages and the ExtruderStats strings stay HTML, as their locale strings use <br>, <kbd>, <strong> and <span>. Store raw newlines, never <br> — 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 — <template v-if="$scopedSlots.label" #label><slot name="label" /></template> — 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 <a> (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 <a>, 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 <mark> in text nodes only, skipping <style>. An object value is a new object on every render, so the directive compares html and search field by field and skips the update only when both match

  • ConsoleItem renders a response as HTML only when hasRenderableHtml finds markup DOMPurify keeps, so macros can output <img> and <video>; anything else is escaped with escapeHtml and gets command links if it is a response. The check sanitizes the message and looks for a surviving element, behind a /<[a-z]/i pre-check so most lines skip DOMPurify. So <none>, <x>, <temp> or Unknown algorithm <foo> keep the message plain — this matters because Klipper sends a whole help listing as one entry, so one macro description such as M163 [P<factor>] used to cost every line its links. Limits: a placeholder named like a real tag (<b>, <i>, <p>, <s>, <u>) renders as HTML, and in a message with real markup any other <word> is still dropped. Stored messages stay raw, so console filters are unaffected. Search passes { html, search } to v-safe-html, so tags, attributes and entities can't be hit, plain-text and HTML messages match alike, and a match can't span two elements (such as the $ prefix and the command link). itemClick resolves a click on a <mark> to its parent anchor

  • Use consola for logging, not console.log (configured in src/setupConsola.ts — warn in prod, verbose in dev)

  • Type imports: import type { ... } for types only (verbatimModuleSyntax: true)

  • satisfies keyword for store module type checking

  • decimalRound(value, places) (src/util/decimal-round.ts) is the shared rounding helper — use it instead of ad-hoc Math.round(value * 100) / 100

  • Stable v-for keys — key by identity, never by index (:key="`component::${component.name}`"). Separators belong inside the loop with their own key (:key="`component:divider:${component.name}`") rather than a trailing v-if="i < items.length - 1" sibling

  • No double-cast type assertions (as unknown as T) — use a proper TypeScript type guard instead (in, typeof, instanceof, or a custom type predicate):

    // Bad
    const x = value as unknown as { test: () => void }
    x?.test?.()
    
    // Good
    if (value && 'test' in value && typeof value.test === 'function') {
      value.test()
    }

Git & Contribution Policy

  • Conventional commits required: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, types, i18n
  • Commit subject max 50 characters — hard-enforced by .husky/commit-msg hook
  • Signed-off-by line required on all commits (use git commit -s): Signed-off-by: Your Name <your@email>
  • PR titles must follow conventional commits — CI-enforced via amannn/action-semantic-pull-request (scope optional)
  • PR branches must be off a branch other than develop or master
  • Clean develop preferred: squash and rebase feature branches prior to merge
  • CHANGELOG visibility: only feat, fix, perf, refactor appear in CHANGELOG.md (configured in .versionrc.json)
  • CI setup: .github/actions/setup-pnpm-node is a composite action wrapping pnpm/setup (install: false, cache: true), which installs both pnpm and Node from devEngines — it replaced pnpm/action-setup + actions/setup-node. ci.yml's bundle-baseline job uses it too, which is why its sparse-checkout lists .github/actions alongside tools: cone mode checks out root files (so package.json and pnpm-lock.yaml are there) but no other directory
  • CI pipeline order: pnpm i --frozen-lockfile → lint --no-fix → type-check → test:unit → circular-check → build. The three checks after lint (type-check, test:unit, circular-check) each carry if: ${{ !cancelled() }}, so a lint failure no longer hides them — one run reports all four. build and the artifact upload deliberately do not, so they still skip once anything above has failed
  • Reusable workflows: the build and both publish paths live in _build.yml, _publish-docker.yml and _publish-web.yml (workflow_call), called by ci.yml (PRs + develop/master pushes) and release.yml (v* tags, which ci.yml no longer triggers on). A calling job's permissions: is a ceiling on the called workflow's token, so every calling job needs its own explicit block — a top-level permissions: {} alone starves it. Secret values can't cross workflow_call via with:; _publish-web.yml takes them through on.workflow_call.secrets
  • PR bundle-size report: tools/bundle-size.mjs (zero-dependency) emits a gzip-size manifest per build and diffs the PR against its merge-base, posted as a sticky comment by pr-comment.yml. That second workflow exists because the report has to build PR code, so it can't hold a write token — it's workflow_run-triggered, reads only an artifact, and takes the PR number from pr-number.txt since workflow_run.pull_requests[] is empty for fork PRs. workflows: ['CI'] must match ci.yml's name: exactly, and a workflow_run trigger only fires once the file exists on the default branch
  • Bundle-size baselines are cached by bundle-size-<hash of tools/bundle-size.mjs>-<sha> — the tool's own hash is in the key because the manifest format is not versioned, so without it a format change silently poisons every cached baseline (they're keyed by a base SHA that never changes) and compare publishes a table of NaN B on a green check. compare also rejects non-finite entries for the same reason. The compare step sets shell: bash for pipefail; the implicit default is bash -e without it, so a failure would be masked by tee

Common Gotchas

  • Vue 2.7 limitations: no Composition API in production builds
  • WebSocket reconnection handled automatically by socketClient.ts
  • File uploads use FormData with progress tracking in store
  • Dynamic imports for code splitting (see vue-echarts-chunk.ts, src/dynamicImports.ts)
  • SCSS deprecation warnings silenced: import, global-builtin, slash-div, if-function
  • @/scss/variables auto-injected into all SCSS/Sass files via Vite config
  • Strict Vuex mode enabled only in dev (strict: import.meta.env.DEV)
  • SVG files auto-optimized on commit — pre-commit hook runs SVGO on staged .svg, .vue, and src/globals.ts files
  • VUE_ env prefix required — only env vars prefixed VUE_ are exposed to app code via import.meta.env (Vite envPrefix)
  • import.meta.env.VERSION and import.meta.env.HASH (short git hash) are injected at build time
  • server/config.json is the runtime config source (deployed as dist/config.json) — contains theme presets, endpoints, hosted flag
  • npm and npx refuse to run inside this repo — devEngines.packageManager names pnpm with onFail: download, which npm reads as a hard EBADDEVENGINES failure (no flag overrides it, engine-strict included). Use pnpm dlx in place of npx, and install pnpm itself from outside the clone
  • Translations managed via Weblate — do not directly edit non-English locale files in src/locales/

Dev Container

  • VSCode Dev Container (.devcontainer/) bundles a docker-klipper-simulavr container — real Klipper/Moonraker simulation on port 7125, Fluidd on port 8080
  • Base image is mcr.microsoft.com/devcontainers/javascript-node:26, Debian-based. It must stay glibc — typescript-native-bridge ships a Go c-shared NAPI bridge with glibc-only native packages (no -musl build), so on Alpine it fails to load and segfaults even with gcompat
  • The image ships Node.js, pnpm, git, zsh, Oh My Zsh and the node user (uid 1000) that devcontainer.json's remoteUser names, so there is no Dockerfile — docker-compose.yml uses the image directly. The image defaults to root, and remoteUser only covers VS Code and lifecycle commands, so the service also sets user: node and working_dir; a root pnpm i from docker compose exec would leave files node cannot write. A fresh node_modules named volume is root-owned, so postCreateCommand starts with sudo chown node:node node_modules (idempotent, not recursive). pnpm's store lands inside that volume (node_modules/.pnpm-store), so hard links work and it survives rebuilds
  • The image's pnpm switches to devEngines.packageManager inside the project. A bare node is the image's own version, which can lag the pin; anything run through pnpm or node_modules/.bin uses devEngines.runtime
  • postCreateCommand runs pnpm i --frozen-lockfile automatically (which in turn runs prepare)

Docker Images (production)

  • Dockerfile builds on nginx:alpine-slim (fluidd) / nginxinc/nginx-unprivileged:alpine-slim (fluidd-unprivileged), set via a BASE_IMAGE build arg per matrix entry in .github/workflows/_publish-docker.yml
  • The listen port is a PORT build arg re-exported as ENV PORT (default 80), substituted into server/nginx/default.conf.template by nginx's own envsubst entrypoint at container start — not baked in at build time. CI used to sed the template's port in build.yml (now ci.yml); that step is gone now that PORT flows through as a build arg, and the port can also be overridden at docker run time
  • HEALTHCHECK (wget -q -O /dev/null "http://127.0.0.1:${PORT}/healthz", every 10s) — the base image must keep wget; /healthz is a plain 200 ok in default.conf.template, excluded from the access log

Documentation Site

  • Zensical (Material for MkDocs successor) — static site generator in docs/
  • Config: docs/zensical.toml — nav, theme, extensions, plugins (glightbox, llmstxt, social, minify, redirects), social links
  • [project.markdown_extensions] REPLACES Zensical's DEFAULT_MARKDOWN_EXTENSIONS, it does not merge with them (config.get("markdown_extensions", DEFAULTS) in zensical/config.py). Anything omitted from that table is off, including toc.permalink — dropping an entry silently removes heading anchors sitewide. Keep it in sync with zensical/bootstrap/zensical.toml in the installed package
  • pymdownx.tabbed carries combine_header_slug + slugify, so a content tab is linkable as #<enclosing-heading>-<tab-label> (e.g. #thumbnails-orcaslicer). Replacing a heading with a tab changes its anchor — preserve the old one with an anchor redirect in [project.plugins.redirects.redirect_maps] ("page.md#old" = "page.md#new", Zensical 0.0.61+), which lands in site/redirect.json
  • llmstxt emits llms.txt / llms-full.txt plus a per-page index.md (backing the content.action.copy button) — it only includes pages matched by its sections, so a new top-level page must be added there too
  • social generates Open Graph cards; our palette sets no primary, so cards_layout_options passes background_color and color (the square logo needs the dark background). It fetches fonts from Google Fonts at build time
  • privacy self-hosts external assets under site/assets/external/, restricted by an assets_include allow-list (Google Fonts, glightbox). Don't switch it to exclusions: the theme bundle references unused lazy libraries (pyodide, ace, mermaid, plyr, vimeo), and plyr's pull in Google ad SDKs that fail the --strict build. Unlisted URLs, like the Weblate badge, stay external
  • optimize is deliberately not enabled — it shells out to pngquant, which would become a requirement for every local zensical serve, and TOML config has no !ENV to enable it in CI only
  • Internal links are relative .md paths (printing.md#thumbnails, ../configuration.md), never site-absolute — Zensical's invalid_links / invalid_link_anchors validation cannot resolve /features/printing, so absolute links are silently unchecked. Image paths stay site-absolute (/assets/images/…)
  • Content: docs/docs/ — Markdown files with YAML frontmatter
  • Overrides: docs/overrides/ — custom Jinja2 templates (header, htmltitle)
  • Custom CSS: docs/docs/stylesheets/extra.css — Fluidd brand colors
  • Glossary: docs/includes/glossary.md — abbreviation tooltips auto-appended to all pages
  • Lint: markdownlint --config docs/.markdownlint.json docs/docs/ — in CI it runs as pnpm dlx markdownlint-cli@…, because npx cannot run inside this repo (EBADDEVENGINES), which is why docs.yml sets up pnpm at all
  • Install: cd docs && python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
  • Build: cd docs && zensical build --clean --strict (CI uses --strict; warnings, including broken links/anchors, fail the build)
  • Serve: cd docs && zensical serve or pnpm run serve:docs (localhost:8000)
  • Deploy: GitHub Actions (.github/workflows/docs.yml) — builds and lints (codespell + markdownlint) on PRs and on pushes to master and develop; uploads the Pages artifact via actions/upload-pages-artifact on both branches, but only master runs the deploy job (actions/deploy-pages)

Documentation Structure

docs/
├── docs/                  # Markdown content
│   ├── index.md           # Homepage
│   ├── getting-started.md # Installation (KIAUH, Docker, Manual, fluidd.xyz, FluiddPI)
│   ├── configuration.md   # Fluidd Config, Klipper, Moonraker, Multiple Printers
│   ├── customize.md       # Layout, themes, hiding components
│   ├── features/
│   │   ├── index.md           # Features overview (section landing page)
│   │   ├── authorization.md
│   │   ├── cameras.md
│   │   ├── console.md
│   │   ├── diagnostics.md
│   │   ├── file-editor.md     # Monaco editor features, syntax, CodeLens, folding
│   │   ├── file-manager.md    # File browser, upload, search, previews, drag-and-drop
│   │   ├── job-queue.md       # Sequential printing queue
│   │   ├── keyboard-shortcuts.md  # Global, editor, console keyboard shortcuts
│   │   ├── localization.md
│   │   ├── macros.md
│   │   ├── multi-material.md  # Multiple extruders + Spoolman
│   │   ├── multiple-printers.md
│   │   ├── printing.md        # G-code viewer, thumbnails, bed mesh, print history
│   │   ├── slicer-uploads.md
│   │   ├── system-and-notifications.md  # System info + notifications
│   │   ├── thermals.md        # Chart, presets, sensors
│   │   ├── third-party-integrations.md  # Kalico, Happy Hare, AFC, Beacon, Obico, OctoEverywhere, etc.
│   │   ├── timelapse.md
│   │   └── updates.md
│   ├── development.md     # Dev container, local dev, localization
│   ├── faq.md             # Organized by topic (Setup, Cameras, System, Printing)
│   └── sponsors.md
├── includes/
│   └── glossary.md        # Abbreviation definitions (auto-appended)
├── overrides/             # Jinja2 template overrides
├── zensical.toml          # Site configuration
└── .markdownlint.json     # Lint rules (MD013/MD025/MD046 disabled, MD033 allows `div` for card grids)

Documentation Conventions

  • Frontmatter: title (required), icon (top-level pages only, Lucide icons)
  • Images: /assets/images/ path, stored in docs/docs/assets/images/
  • Code blocks must always have a language tag: ini for Klipper/Moonraker config, bash for shell commands, json for JSON, text when no language applies
  • Zensical uses Python-Markdown which requires 4-space indentation per nesting level for all block-level elements nested in lists (sub-lists, paragraphs, code blocks, blockquotes) — no tabs
  • Tables must use aligned pipe style (columns padded to equal width)
  • Links: use {.md-button} attribute for standalone action links
  • Keys: use ++key++ syntax (pymdownx.keys extension) instead of <kbd>
  • Terminology: G-code (not gcode/Gcode), Wi-Fi (not WiFi), GitHub (not Github), Node.js (not NodeJS), SD card (not SDCard), em dash (—) not hyphen (-) for parenthetical dashes
  • Klipper macro names: format as inline code (e.g., PAUSE, SET_PAUSE_AT_LAYER, _CLIENT_VARIABLE)
  • Klipper/Moonraker section names and config variable names: format as inline code (e.g., [virtual_sdcard], enable_object_processing) — exception: leave unformatted when used as markdown headings
  • Glossary terms (AFC, API, CNC, CORS, JWT, MCU, MMU, MPC, PID, etc.) get automatic tooltips via docs/includes/glossary.md
  • When introducing acronyms in docs, check if they exist in the glossary — if not, assess whether they should be added (domain-specific or non-obvious acronyms: yes; universally known ones like USB, HTTP, CPU: no)
  • Before committing docs changes, always run:
    • markdownlint --config docs/.markdownlint.json docs/docs/ — must be clean
    • codespell docs/docs/ — must be clean

Communication Style

  • Be extremely concise in responses
  • Sacrifice grammar for brevity
  • Focus on essential info only