@filamind-app/core 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,40 +1,68 @@
1
+ <div align="center">
2
+
1
3
  # @filamind-app/core
2
4
 
3
- The shared foundation of the FilaMind suite **framework-agnostic TypeScript** consumed by FilaMind 3d
4
- (web), FilaMind screen (touch), and FilaMind flow. Phase 0.
5
+ The shared foundation of the FilaMind suite - one framework-agnostic TypeScript library
6
+ that talks to the printer, holds its state honestly, and looks the same everywhere.
7
+
8
+ **Built by Egyptian makers, for world makers. Happy printing.** 🇪🇬
9
+
10
+ A small-team hobby project, built and tested on real printers. The code is all here to read.
11
+
12
+ [![Support on Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I119XEIV)
5
13
 
6
14
  [![CI](https://github.com/filamind-app/filamind-core/actions/workflows/ci.yml/badge.svg)](https://github.com/filamind-app/filamind-core/actions/workflows/ci.yml)
7
15
  [![npm](https://img.shields.io/npm/v/@filamind-app/core?color=111111&label=npm)](https://www.npmjs.com/package/@filamind-app/core)
8
16
  [![License: GPLv3](https://img.shields.io/badge/License-GPLv3-111111.svg)](LICENSE)
9
17
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-111111?logo=typescript&logoColor=white)](https://www.typescriptlang.org)
18
+ [![Klipper](https://img.shields.io/badge/Klipper-compatible-111111)](https://www.klipper3d.org)
19
+ [![Moonraker](https://img.shields.io/badge/Moonraker-API-111111)](https://moonraker.readthedocs.io)
20
+
21
+ [Install](#install) · [What's inside](#whats-inside) · [Quick start](#quick-start) · [Tested on real printers](#tested-on-real-printers) · [Languages](#languages) · [Docs](#documentation) · [Support](#support)
10
22
 
11
- ## What's here
12
- | Module | Purpose |
23
+ </div>
24
+
25
+ `@filamind-app/core` is the shared core of the FilaMind suite. It is the one place that knows how
26
+ to open a Moonraker connection, keep printer state honest, remember a user's settings, theme the UI,
27
+ and translate it - so the three FilaMind surfaces (**3d** on the web, **screen** on a touch panel,
28
+ and the **flow** touch app) all behave the same way without copying that logic three times. It is
29
+ pure TypeScript with no UI framework inside: the reactive layer is a plain observable, and each
30
+ surface adapts it to Vue, signals, or whatever it uses. One core, many faces.
31
+
32
+ ## Install
33
+
34
+ It is published to npm as `@filamind-app/core`:
35
+
36
+ ```bash
37
+ npm install @filamind-app/core
38
+ ```
39
+
40
+ The package is ESM-only and ships its own type declarations. It has **zero runtime dependencies** -
41
+ nothing is pulled in behind it. Inside the FilaMind suite it is consumed through the workspace during
42
+ development, so changes to the core are picked up by 3d, screen, and flow without a publish step.
43
+
44
+ ## What's inside
45
+
46
+ | Piece | What it gives you |
13
47
  | --- | --- |
14
- | `provenance.ts` | `Stamped<T>` "never-lie" values (`value · ts · source · staleAfter`) + `isStale` / `freshness` |
15
- | `state/observable.ts` | a tiny framework-agnostic observable (consumers bind it to Pinia/signals) |
16
- | `state/printer.ts` | `PrinterState` merge-patch from `notify_status_update`, **1 s coalescing + `motion_report` fast-path** |
17
- | `moonraker/connector.ts` | the backend-agnostic `Connector` seam (Moonraker first; remote/others later) |
18
- | `moonraker/client.ts` | `MoonrakerClient` reconnecting JSON-RPC WS (**injectable socket**, backoff **+ jitter + max-attempts**), id-correlated, notify fan-out, re-subscribe on reconnect, **REST file channel** (`upload`/`download`) |
19
- | `moonraker/discovery.ts` | **zero-config endpoint discovery** `resolveMoonrakerUrl()` races candidate endpoints (same-origin proxy / direct `:7125` / localhost) first to open wins; runtime `override` short-circuits. Socket-injectable |
20
- | `moonraker/subscriptions.ts` | the **versioned** canonical subscription contract `NARROW_STATUS` / `FULL_CONTROL` tiers + `tier()` |
21
- | `moonraker/rpc-types.ts` | **RPC type safety** `RpcError` (keeps JSON-RPC `code`/`data`) · `MoonrakerMethods` typed-`call` result map · `NotifyEvent` + `parseNotifyEvent()` discriminated notify union |
22
- | `printer/klippy.ts` | the Klippy lifecycle (`ready`/`startup`/`shutdown`/`error`/`disconnected`) tracked apart from the socket so a FIRMWARE_RESTART re-seeds instead of showing stale-as-live |
23
- | `printer/prompt-parser.ts` | parses Klipper's `// action:prompt_*` protocol into structured modal dialogs (`PromptParser`) |
24
- | `session/session.ts` | **`FilaMindSession`** the orchestrator: staged init (identify capabilities query/seed subscribe), routes `notify_*`, owns the Klippy-aware `live`/stale gate |
25
- | `safety/write-arbiter.ts` | fail-closed **`WriteArbiter`** the single chokepoint for every mutation; enforces safe-mode + a caller guard |
26
- | `identity/machine.ts` | `deriveMachineId` → stable `fm-<hash>` from `machine.system_info` (the roaming-settings key) |
27
- | `backup/restore-points.ts` | reversible, retention-bounded **`RestorePoints`** (back-up-first substrate for config/flash/migrate) |
28
- | `observability/logger.ts` | pluggable ring-buffer `Logger` (the diagnostics-bundle source; replaces silent `catch {}`) |
29
- | `registry/widget-registry.ts` | the cross-surface widget/plugin registry + `aggregateSubscriptions` |
30
- | `remote/commands.ts` · `remote/command-sender.ts` | the cross-surface command bus — **UI-only** commands (navigate / message / locate) over a Moonraker **agent** connection. `CommandSender` identifies as an agent, re-identifies on reconnect, is single-flight, and sanitizes control/bidi text; it is never a mutation path (those still go through `WriteArbiter`) |
31
- | `theme/tokens.ts` | the theme palettes as `--fm-*` design tokens — 3 signature Pharaonic themes (Tutankhamun · Horus · Anubis) + neutral **light** / **dark** |
32
- | `i18n/locale-meta.ts` | the 19 shipped locales `{ code, name, rtl, dir }` + RTL list + CLDR `pluralCategory` (`Intl.PluralRules`) |
33
- | `i18n/translator.ts` | framework-agnostic `translate` / `Translator` + the backend message-code contract (`resolveMessage`) |
34
- | `i18n/locales/*.json` | drop-in catalogs (en + ar shipped as proof, incl. the full Arabic plural set) |
35
- | `settings/settings.ts` | unified user settings (theme · locale · density · motif · reduced-motion): `SettingsStore` (patch/hydrate/reset/export/import), **`migrate()`** (versioned, enum-coercing — `import()` trusts no foreign JSON), `applySettings` (re-theme + RTL dir), persistence seam (Moonraker-DB / localStorage) |
36
-
37
- ## Wiring (the consumer entry point)
48
+ | **Moonraker client** | A reconnecting JSON-RPC WebSocket plus a REST file channel, behind a backend-agnostic connector seam. Backs off with jitter, re-subscribes on reconnect, and times out stuck requests. |
49
+ | **Zero-config discovery** | Finds the printer's WebSocket for you by racing the likely endpoints; the first to open wins, and an explicit override always wins first. |
50
+ | **FilaMindSession** | The orchestrator. It connects, identifies, queries, subscribes, and routes every update into one place - and re-seeds itself after a firmware restart so the UI never shows stale data as live. |
51
+ | **Printer state** | One reactive, merge-patched model of the printer, coalesced so the UI isn't thrashed, with a fast path so live motion stays smooth. |
52
+ | **Provenance** | "Never-lie" stamped values that carry where they came from and when, so a widget can dim or strike anything that is stale or unknown. |
53
+ | **WriteArbiter** | A single fail-closed chokepoint every mutation passes through: if state isn't trustworthy, or safe-mode is on, the write is refused. |
54
+ | **Settings store** | One unified, persisted user-settings model (theme, language, density, motifs) that roams across every surface on the same printer, with strict versioned migration. |
55
+ | **Theme tokens** | The FilaMind palette as `--fm-*` CSS variables - three signature Pharaonic themes plus a neutral light and dark - so one switch restyles everything. |
56
+ | **i18n** | Metadata for 19 locales (including right-to-left), correct plural rules, and a tiny framework-agnostic translator that 3d, screen, and flow all share. |
57
+ | **Widget registry & dashboard** | Features register themselves without touching the core, declare which surfaces they target, and resolve into one adaptive dashboard per screen size. |
58
+ | **Remote control bus** | A UI-only command bus so one FilaMind surface can steer another (navigate, message, locate). It can never move the printer - those still go through the WriteArbiter. |
59
+ | **Backup, identity, logging** | Reversible restore points, a stable per-machine id, and a ring-buffer logger that feeds the diagnostics bundle. |
60
+
61
+ The full public surface is exported from a single entry point; the details of how each piece works
62
+ live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
63
+
64
+ ## Quick start
65
+
38
66
  ```ts
39
67
  import { MoonrakerClient, FilaMindSession, FULL_CONTROL } from '@filamind-app/core'
40
68
 
@@ -45,33 +73,100 @@ const session = new FilaMindSession(connector, {
45
73
  })
46
74
  await session.start()
47
75
 
48
- // reactive, framework-agnostic bind these to Pinia/signals/etc.
49
- session.live.subscribe((v) => {/* dim the UI when not trustworthy-live */})
50
- session.printer.objects.subscribe((o) => {/* render telemetry */})
51
- session.prompt.subscribe((ev) => {/* show a Klipper modal */})
76
+ // reactive and framework-agnostic - bind these to Pinia, signals, or anything:
77
+ session.live.subscribe((live) => {/* dim the UI when the data isn't trustworthy-live */})
78
+ session.printer.objects.subscribe((objects) => {/* render telemetry */})
79
+ session.prompt.subscribe((dialog) => {/* show a Klipper modal */})
80
+ ```
81
+
82
+ Don't know the printer's address? Let the core find it:
83
+
84
+ ```ts
85
+ import { resolveMoonrakerUrl, MoonrakerClient } from '@filamind-app/core'
86
+
87
+ const url = await resolveMoonrakerUrl() // races the likely endpoints, first to open wins
88
+ const connector = new MoonrakerClient({ url })
52
89
  ```
53
90
 
54
- ## Scripts
91
+ There is a worked example for settings, theming, and adding a widget in
92
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#using-the-core).
93
+
94
+ ## Tested on real printers
95
+
96
+ The core is exercised by the FilaMind suite on two machines that disagree on almost everything that
97
+ matters to a control surface. The first is a Sovol SV08: an STM32F103 mainboard, TMC2209 drivers
98
+ over UART, a USB toolhead, and a BTT CB1 host. The second is a Voron-class CoreXY: an STM32H723
99
+ mainboard, six TMC5160 drivers on a shared software-SPI bus, a CAN toolhead, and a Raspberry Pi 4.
100
+ The pieces here that hurt to get wrong - the reconnect and re-seed logic, the Klippy-aware live gate,
101
+ the fail-closed write path - are the ones those two printers stress, and the library ships **101
102
+ pure-logic tests** that pin that behaviour down without needing a printer attached.
103
+
104
+ ## How it's built
105
+
106
+ Pure TypeScript in `strict` mode, no UI framework imported anywhere in the core. Reactivity is a
107
+ small hand-written observable that each surface adapts to its own framework. Everything that touches
108
+ the network - the WebSocket, the discovery probes - is injectable, so the whole library is unit-tested
109
+ without a real socket. It builds to a single ESM bundle with type declarations via
110
+ [tsup](https://tsup.egoist.dev), and a CI guard keeps the published code free of any reference to
111
+ other projects. The deeper design is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
112
+
113
+ ## Languages
114
+
115
+ The core carries the suite's shared internationalization foundation: metadata for **19 locales**,
116
+ the correct CLDR plural rules for each, and a tiny translator that 3d, screen, and flow all use so
117
+ they translate identically. The catalogs themselves are drop-in JSON, one folder per locale - adding
118
+ a language needs no code change. English and Arabic (right-to-left, with the full Arabic plural set)
119
+ ship in the package as a working proof; the surfaces carry the rest.
120
+
121
+ | | | | |
122
+ | --- | --- | --- | --- |
123
+ | English | Español | 简体中文 (Simplified Chinese) | 日本語 (Japanese) |
124
+ | العربية (Arabic, RTL) | Français | 繁體中文 (Traditional Chinese) | 한국어 (Korean) |
125
+ | Deutsch (German) | Русский (Russian) | Português (Brasil) | Tiếng Việt (Vietnamese) |
126
+ | Italiano | Nederlands (Dutch) | Polski (Polish) | Bahasa Indonesia |
127
+ | Türkçe (Turkish) | Українська (Ukrainian) | हिन्दी (Hindi) | |
128
+
129
+ ## Develop
130
+
55
131
  ```bash
56
132
  npm install
57
- npm run type-check # tsc --noEmit
58
- npm test # vitest (76 pure-logic tests)
59
- npm run build # tsup dist/ (ESM + .d.ts)
133
+ npm run type-check # tsc --noEmit (strict)
134
+ npm test # vitest run (101 tests, 13 files)
135
+ npm run build # tsup > dist/ (ESM + .d.ts)
60
136
  ```
61
137
 
62
- ## Notes
63
- - Consumed by the suite apps (FilaMind 3d / screen / flow) via the workspace in development.
64
- - Framework-agnostic on purpose: no Vue/React import in core. The reactive layer is a plain `Observable`;
65
- Vue/Pinia adapts it on the consumer side.
66
- - Versions are pinned to current real latest-stable; bump deliberately at first real install.
67
- - GPL-3.0-or-later. FilaMind's own — no third-party project named (R1).
138
+ `npm run dev` rebuilds on change. CI runs the type check, the tests, the build, and the
139
+ no-external-references guard on every push and pull request. Releases are cut by bumping the
140
+ version and pushing a matching `vX.Y.Z` tag; the workflow builds, publishes to npm (with provenance,
141
+ when a token is configured), and creates a GitHub Release from the changelog. The full contributor
142
+ and release flow is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#building-testing-and-releasing).
143
+
144
+ ## Documentation
145
+
146
+ | Document | What's inside |
147
+ | -------- | ------------- |
148
+ | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design, key modules, data flow, build/release internals, and conventions |
149
+ | [CHANGELOG.md](CHANGELOG.md) | Release history |
150
+ | [SECURITY.md](SECURITY.md) | How to report a vulnerability privately |
151
+
152
+ ## Support
153
+
154
+ `@filamind-app/core` is free and open source, built and maintained in spare time. If the suite it
155
+ powers saved you a tuning session, or you just want to see it grow, a coffee helps keep the work
156
+ going. Code, data, and ideas are just as welcome.
157
+
158
+ <div align="center">
159
+
160
+ [![Support on Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I119XEIV)
161
+
162
+ </div>
68
163
 
69
164
  ## Credits
70
165
 
71
- Built and maintained by the DeltaFabs team:
166
+ Built and maintained by the DeltaFabs team. **Built by Egyptian makers, for world makers.**
72
167
 
73
- - abdelmonem awad - <eg2@live.com>
74
- - Ahmed bebars - <Ahmedbebars1@gmail.com>
168
+ - Abdelmonem Awad - <eg2@live.com>
169
+ - Ahmed Bebars - <Ahmedbebars1@gmail.com>
75
170
  - Kareem Salama - <Golden.kiko@gmail.com>
76
171
 
77
172
  ## License
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  type Source = 'live' | 'cache' | 'optimistic' | 'unknown';
2
2
  interface Stamped<T> {
3
3
  value: T;
4
- /** when this value arrived (ms epoch, client clock Moonraker has no server timestamp) */
4
+ /** when this value arrived (ms epoch, client clock - Moonraker has no server timestamp) */
5
5
  ts: number;
6
6
  source: Source;
7
7
  /** ms after `ts` when the value should be treated as stale (omit = never goes stale on its own) */
@@ -169,7 +169,7 @@ type NotifyEvent = {
169
169
  /** Parse a raw (method, params) notify into a typed event, or null for an unhandled method. */
170
170
  declare function parseNotifyEvent(method: string, params: unknown): NotifyEvent | null;
171
171
 
172
- /** Minimal WebSocket surface the client uses the DOM WebSocket satisfies it; tests fake it. */
172
+ /** Minimal WebSocket surface the client uses - the DOM WebSocket satisfies it; tests fake it. */
173
173
  interface WebSocketLike {
174
174
  send(data: string): void;
175
175
  close(): void;
@@ -185,7 +185,7 @@ interface MoonrakerClientOptions {
185
185
  url: string;
186
186
  requestTimeoutMs?: number;
187
187
  maxBackoffMs?: number;
188
- /** stop reconnecting after this many consecutive attempts terminal 'closed' (default: unlimited) */
188
+ /** stop reconnecting after this many consecutive attempts > terminal 'closed' (default: unlimited) */
189
189
  maxReconnectAttempts?: number;
190
190
  wsFactory?: (url: string) => WebSocketLike;
191
191
  logger?: Logger;
@@ -223,13 +223,13 @@ declare class MoonrakerClient implements Connector {
223
223
  private onMessage;
224
224
  }
225
225
 
226
- /** A minimal view of `location` the DOM Location satisfies it; tests pass a plain object. */
226
+ /** A minimal view of `location` - the DOM Location satisfies it; tests pass a plain object. */
227
227
  interface LocationLike {
228
228
  protocol?: string;
229
229
  hostname?: string;
230
230
  }
231
231
  interface DiscoveryOptions {
232
- /** Explicit runtime override (e.g. from settings). If set, it wins immediately no probing. */
232
+ /** Explicit runtime override (e.g. from settings). If set, it wins immediately - no probing. */
233
233
  override?: string;
234
234
  /** Candidate ws(s):// URLs to race. If omitted, they are derived from `location`. */
235
235
  candidates?: string[];
@@ -262,7 +262,7 @@ declare const FULL_CONTROL: SubscriptionMap;
262
262
  type SubscriptionTier = 'narrow' | 'full';
263
263
  declare function tier(t: SubscriptionTier): SubscriptionMap;
264
264
  /** Union of subscription maps (`null` = "all fields" wins). The single source for the
265
- * null-wins merge rule consumers compose a baseline tier with widget-declared needs. */
265
+ * null-wins merge rule - consumers compose a baseline tier with widget-declared needs. */
266
266
  declare function mergeSubscriptions(...maps: SubscriptionMap[]): SubscriptionMap;
267
267
 
268
268
  type KlippyState = 'ready' | 'startup' | 'shutdown' | 'error' | 'disconnected';
@@ -344,7 +344,7 @@ interface SessionOptions {
344
344
  subscriptions?: SubscriptionMap;
345
345
  identify?: IdentifyInfo;
346
346
  logger?: Logger;
347
- /** Receive cross-surface agent events (notify_agent_event) e.g. remote-control commands from
347
+ /** Receive cross-surface agent events (notify_agent_event) - e.g. remote-control commands from
348
348
  * another FilaMind surface. Discrete (fired per event, never replayed), so no stale re-fire. */
349
349
  onAgentEvent?: (ev: AgentEvent) => void;
350
350
  }
@@ -366,7 +366,7 @@ declare class FilaMindSession {
366
366
  setSubscriptions(subs: SubscriptionMap): void;
367
367
  start(): Promise<void>;
368
368
  stop(): void;
369
- /** Staged init runs on first connect AND on reconnect AND on notify_klippy_ready.
369
+ /** Staged init - runs on first connect AND on reconnect AND on notify_klippy_ready.
370
370
  * Guarded so overlapping triggers (reconnect + klippy_ready) can't interleave two seeds. */
371
371
  private bootstrap;
372
372
  private onUpdate;
@@ -396,7 +396,7 @@ declare class WriteArbiter {
396
396
  run<T>(action: string, fn: () => Promise<T>): Promise<T>;
397
397
  }
398
398
 
399
- /** FNV-1a 32-bit hex deterministic, dependency-free. */
399
+ /** FNV-1a 32-bit hex - deterministic, dependency-free. */
400
400
  declare function fnv1a(s: string): string;
401
401
  /** Derive a stable `fm-<hash>` machine id from Moonraker. Falls back to `fm-unknown`. */
402
402
  declare function deriveMachineId(connector: Connector): Promise<string>;
@@ -438,7 +438,7 @@ interface WidgetDefinition {
438
438
  title: string;
439
439
  icon?: string;
440
440
  description?: string;
441
- /** lazy component loader the host framework (Vue) resolves it (kept as `unknown` here) */
441
+ /** lazy component loader - the host framework (Vue) resolves it (kept as `unknown` here) */
442
442
  component: () => Promise<unknown>;
443
443
  defaultSize?: {
444
444
  w: number;
@@ -454,9 +454,45 @@ declare function getWidget(id: string): WidgetDefinition | undefined;
454
454
  declare function getWidgets(target?: SurfaceTarget): WidgetDefinition[];
455
455
  /** The union of the given widgets' declared subscriptions (subscribe once). */
456
456
  declare function aggregateSubscriptions(ids: string[]): SubscriptionMap;
457
- /** Test/host helper clear the registry. */
457
+ /** Test/host helper - clear the registry. */
458
458
  declare function _resetRegistry(): void;
459
459
 
460
+ type SurfaceHint = 'dense-desktop' | 'thumb-phone' | 'big-touch';
461
+ /** Derive the layout hint from the surface + viewport width (px). The touch screen is always
462
+ * big-touch; the 3d web UI is dense on wide viewports and a single thumb column when narrow. */
463
+ declare function surfaceHint(target: SurfaceTarget, viewportWidth: number): SurfaceHint;
464
+ /** One slot in the shared dashboard definition. */
465
+ interface DashboardSlot {
466
+ widgetId: string;
467
+ /** ascending display order; slots without it sort after ordered ones */
468
+ order?: number;
469
+ /** hide this widget on these hints (e.g. a dense-only widget hidden on thumb-phone) */
470
+ hideOn?: SurfaceHint[];
471
+ }
472
+ /** The single serializable dashboard definition (persisted in UserSettings, machineUUID-keyed). */
473
+ interface DashboardLayout {
474
+ version: number;
475
+ slots: DashboardSlot[];
476
+ }
477
+ declare const DASHBOARD_LAYOUT_VERSION = 1;
478
+ interface ResolvedDashboard {
479
+ hint: SurfaceHint;
480
+ columns: number;
481
+ /** the widgets to render, in order, already filtered for this hint */
482
+ widgets: WidgetDefinition[];
483
+ }
484
+ /**
485
+ * Adapt one layout definition + the available widgets to a surface hint.
486
+ *
487
+ * Slots are honoured first (ordered, minus those hidden on this hint); any registered widget the
488
+ * layout doesn't mention is appended (so newly added widgets still appear). A widget a slot hides
489
+ * on this hint stays hidden even though it is available. With no layout, all available widgets
490
+ * render in registry order.
491
+ */
492
+ declare function resolveDashboard(layout: DashboardLayout | undefined, available: WidgetDefinition[], hint: SurfaceHint): ResolvedDashboard;
493
+ /** Coerce an arbitrary blob into a valid DashboardLayout, or undefined. Used by settings migrate. */
494
+ declare function coerceDashboardLayout(raw: unknown): DashboardLayout | undefined;
495
+
460
496
  interface ThemeTokens {
461
497
  bg: string;
462
498
  surface: string;
@@ -526,14 +562,14 @@ interface BackendMessage {
526
562
  declare function resolveMessage(catalog: Catalog, m: BackendMessage, locale?: string): string;
527
563
 
528
564
  interface CommandSenderOptions {
529
- /** Stable agent name (pinned a drifting name registers phantom duplicates on reconnect). */
565
+ /** Stable agent name (pinned - a drifting name registers phantom duplicates on reconnect). */
530
566
  client_name: string;
531
567
  version: string;
532
568
  /** Required: Moonraker documents `url` as mandatory for identify; omitting it can make a strict
533
569
  * host reject the agent identify, silently stranding the bus. */
534
570
  url: string;
535
571
  logger?: Logger;
536
- /** Fires whenever readiness flips (connected+identified) lets a UI gate its send affordances. */
572
+ /** Fires whenever readiness flips (connected+identified) - lets a UI gate its send affordances. */
537
573
  onReadyChange?: (ready: boolean) => void;
538
574
  }
539
575
  declare class CommandSender {
@@ -546,14 +582,14 @@ declare class CommandSender {
546
582
  private retryDelayMs;
547
583
  private readonly log;
548
584
  constructor(connector: Connector, opts: CommandSenderOptions);
549
- /** true only when the bus is connected AND identified gate remote-control affordances on this. */
585
+ /** true only when the bus is connected AND identified - gate remote-control affordances on this. */
550
586
  get ready(): boolean;
551
587
  /** Open the agent connection and identify once. Call alongside the app's main session start. */
552
588
  start(): Promise<void>;
553
589
  /** Close the agent connection (call on app teardown so the socket doesn't linger). */
554
590
  stop(): void;
555
591
  /** Broadcast a UI-only command to the other FilaMind surfaces. Best-effort: a command issued
556
- * while the bus is down is dropped (logged), never queued/replayed a stale "navigate" fired
592
+ * while the bus is down is dropped (logged), never queued/replayed - a stale "navigate" fired
557
593
  * minutes later would yank a screen unexpectedly. */
558
594
  send(cmd: RemoteCommand): Promise<void>;
559
595
  navigate(view: RemoteView): Promise<void>;
@@ -582,12 +618,14 @@ interface UserSettings {
582
618
  motifDensity: 'off' | 'subtle' | 'full';
583
619
  /** reduce animation/illustration for low-power or accessibility */
584
620
  reducedMotion: boolean;
621
+ /** the single per-surface dashboard definition (resolved per surface + viewport at render) */
622
+ dashboardLayout?: DashboardLayout;
585
623
  }
586
624
  declare const DEFAULT_SETTINGS: UserSettings;
587
- /** Coerce an arbitrary (old / foreign / partial) blob into valid UserSettings drops unknown keys,
625
+ /** Coerce an arbitrary (old / foreign / partial) blob into valid UserSettings - drops unknown keys,
588
626
  * validates every enum, stamps the current version. Used by hydrate() + import(). */
589
627
  declare function migrate(raw: unknown): UserSettings;
590
- /** App-provided persistence the Moonraker-DB impl is keyed by machineUUID; tests use memory. */
628
+ /** App-provided persistence - the Moonraker-DB impl is keyed by machineUUID; tests use memory. */
591
629
  interface SettingsPersistence {
592
630
  load(): Promise<Partial<UserSettings>>;
593
631
  save(settings: UserSettings): Promise<void>;
@@ -595,7 +633,7 @@ interface SettingsPersistence {
595
633
  declare function memoryPersistence(): SettingsPersistence;
596
634
  declare function localStoragePersistence(storageKey?: string): SettingsPersistence;
597
635
  /** Settings persisted in the printer's Moonraker database, so they roam across every FilaMind
598
- * surface on that printer (3d, screen, …) the F10 remote-config substrate. */
636
+ * surface on that printer (3d, screen, …) - the F10 remote-config substrate. */
599
637
  declare function moonrakerDbPersistence(connector: Connector): SettingsPersistence;
600
638
  /** Roam a (locally-persisted) SettingsStore across surfaces via a shared remote store: pull the
601
639
  * shared settings whenever the connection goes live (the shared copy wins on connect), and
@@ -623,4 +661,4 @@ declare function applySettings(s: UserSettings, el?: {
623
661
  dir: 'ltr' | 'rtl';
624
662
  };
625
663
 
626
- export { type AgentAllow, type AgentEvent, type BackendMessage, type Catalog, CommandSender, type CommandSenderOptions, type ConnectionState, type Connector, type ConnectorCallbacks, DEFAULT_LOCALE, DEFAULT_SETTINGS, DEFAULT_THEME, type DiscoveryOptions, FILAMIND_COMMAND_EVENT, FULL_CONTROL, FilaMindSession, type IdentifyInfo, type KlippyState, LOCALES, type Listener, type LocaleMeta, type LocationLike, type LogEntry, type LogLevel, Logger, MoonrakerClient, type MoonrakerClientOptions, type MoonrakerMethods, NARROW_STATUS, NULL_LOGGER, type NotifyEvent, Observable, type Params, type PluralCategory, type PrinterInfo, type PrinterObjects, PrinterState, type PromptButton, type PromptDialog, type PromptEvent, PromptParser, type QueryResult, REMOTE_MESSAGE_LEVELS, REMOTE_VIEWS, RTL_LOCALES, type RemoteCommand, type RemoteMessageLevel, type RemoteView, type RestorePoint, RestorePoints, type RestoreStore, RpcError, SETTINGS_VERSION, SUBSCRIPTION_CONTRACT_VERSION, type ServerInfo, type SessionOptions, type SettingsPersistence, SettingsStore, type Source, type Stamped, type SubscriptionMap, type SubscriptionTier, type SurfaceTarget, type ThemeName, type ThemeTokens, Translator, UNKNOWN, type UserSettings, type WebSocketLike, type WidgetDefinition, WriteArbiter, type WriteGuard, WriteRefused, _resetRegistry, aggregateSubscriptions, applySettings, applyTheme, deepMerge, deriveCandidates, deriveKlippyState, deriveMachineId, fnv1a, freshness, getWidget, getWidgets, handleAgentCommand, isKlippyLive, isRtl, isStale, localStoragePersistence, localeMeta, memoryPersistence, memoryRestoreStore, mergeSubscriptions, migrate, moonrakerDbPersistence, parseAgentEvent, parseCommand, parseNotifyEvent, pluralCategory, registerWidget, resolveMessage, resolveMoonrakerUrl, roamSettings, stamp, themeToCssVars, themes, tier, translate };
664
+ export { type AgentAllow, type AgentEvent, type BackendMessage, type Catalog, CommandSender, type CommandSenderOptions, type ConnectionState, type Connector, type ConnectorCallbacks, DASHBOARD_LAYOUT_VERSION, DEFAULT_LOCALE, DEFAULT_SETTINGS, DEFAULT_THEME, type DashboardLayout, type DashboardSlot, type DiscoveryOptions, FILAMIND_COMMAND_EVENT, FULL_CONTROL, FilaMindSession, type IdentifyInfo, type KlippyState, LOCALES, type Listener, type LocaleMeta, type LocationLike, type LogEntry, type LogLevel, Logger, MoonrakerClient, type MoonrakerClientOptions, type MoonrakerMethods, NARROW_STATUS, NULL_LOGGER, type NotifyEvent, Observable, type Params, type PluralCategory, type PrinterInfo, type PrinterObjects, PrinterState, type PromptButton, type PromptDialog, type PromptEvent, PromptParser, type QueryResult, REMOTE_MESSAGE_LEVELS, REMOTE_VIEWS, RTL_LOCALES, type RemoteCommand, type RemoteMessageLevel, type RemoteView, type ResolvedDashboard, type RestorePoint, RestorePoints, type RestoreStore, RpcError, SETTINGS_VERSION, SUBSCRIPTION_CONTRACT_VERSION, type ServerInfo, type SessionOptions, type SettingsPersistence, SettingsStore, type Source, type Stamped, type SubscriptionMap, type SubscriptionTier, type SurfaceHint, type SurfaceTarget, type ThemeName, type ThemeTokens, Translator, UNKNOWN, type UserSettings, type WebSocketLike, type WidgetDefinition, WriteArbiter, type WriteGuard, WriteRefused, _resetRegistry, aggregateSubscriptions, applySettings, applyTheme, coerceDashboardLayout, deepMerge, deriveCandidates, deriveKlippyState, deriveMachineId, fnv1a, freshness, getWidget, getWidgets, handleAgentCommand, isKlippyLive, isRtl, isStale, localStoragePersistence, localeMeta, memoryPersistence, memoryRestoreStore, mergeSubscriptions, migrate, moonrakerDbPersistence, parseAgentEvent, parseCommand, parseNotifyEvent, pluralCategory, registerWidget, resolveDashboard, resolveMessage, resolveMoonrakerUrl, roamSettings, stamp, surfaceHint, themeToCssVars, themes, tier, translate };
package/dist/index.js CHANGED
@@ -291,7 +291,8 @@ var MoonrakerClient = class {
291
291
  if (!p) return;
292
292
  clearTimeout(p.timer);
293
293
  this.pending.delete(msg.id);
294
- if (msg.error) p.reject(new RpcError(msg.error.message ?? "rpc error", msg.error.code, msg.error.data));
294
+ if (msg.error)
295
+ p.reject(new RpcError(msg.error.message ?? "rpc error", msg.error.code, msg.error.data));
295
296
  else p.resolve(msg.result);
296
297
  return;
297
298
  }
@@ -617,18 +618,18 @@ var FilaMindSession = class {
617
618
  this.connector.close();
618
619
  this.setLive(false);
619
620
  }
620
- /** Staged init runs on first connect AND on reconnect AND on notify_klippy_ready.
621
+ /** Staged init - runs on first connect AND on reconnect AND on notify_klippy_ready.
621
622
  * Guarded so overlapping triggers (reconnect + klippy_ready) can't interleave two seeds. */
622
623
  async bootstrap() {
623
624
  if (this.bootstrapping) return;
624
625
  this.bootstrapping = true;
625
626
  try {
626
627
  if (this.opts.identify) {
627
- await this.connector.call("server.connection.identify", { ...this.opts.identify }).catch(
628
- (e) => this.log.warn("identify failed", String(e))
629
- );
628
+ await this.connector.call("server.connection.identify", { ...this.opts.identify }).catch((e) => this.log.warn("identify failed", String(e)));
630
629
  }
631
- const info = await this.connector.call("server.info");
630
+ const info = await this.connector.call(
631
+ "server.info"
632
+ );
632
633
  this.capabilities.set(info?.components ?? []);
633
634
  const ks = deriveKlippyState(info?.klippy_state);
634
635
  this.klippy.set(ks);
@@ -754,7 +755,10 @@ function memoryRestoreStore() {
754
755
  byMachine.set(m, arr);
755
756
  },
756
757
  remove: async (m, id) => {
757
- byMachine.set(m, (byMachine.get(m) ?? []).filter((p) => p.id !== id));
758
+ byMachine.set(
759
+ m,
760
+ (byMachine.get(m) ?? []).filter((p) => p.id !== id)
761
+ );
758
762
  }
759
763
  };
760
764
  }
@@ -834,9 +838,63 @@ function _resetRegistry() {
834
838
  registry.clear();
835
839
  }
836
840
 
841
+ // src/registry/dashboard-resolver.ts
842
+ var COLUMNS = {
843
+ "dense-desktop": 3,
844
+ "thumb-phone": 1,
845
+ "big-touch": 2
846
+ };
847
+ function surfaceHint(target, viewportWidth) {
848
+ if (target === "screen") return "big-touch";
849
+ return viewportWidth < 640 ? "thumb-phone" : "dense-desktop";
850
+ }
851
+ var DASHBOARD_LAYOUT_VERSION = 1;
852
+ var HINTS = ["dense-desktop", "thumb-phone", "big-touch"];
853
+ function resolveDashboard(layout, available, hint) {
854
+ const byId = new Map(available.map((w) => [w.id, w]));
855
+ const slots = layout?.slots ?? [];
856
+ const hiddenHere = new Set(
857
+ slots.filter((s) => (s.hideOn ?? []).includes(hint)).map((s) => s.widgetId)
858
+ );
859
+ const used = /* @__PURE__ */ new Set();
860
+ const widgets = [];
861
+ for (const slot of [...slots].filter((s) => !(s.hideOn ?? []).includes(hint)).sort(
862
+ (a, b) => (a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY)
863
+ )) {
864
+ const w = byId.get(slot.widgetId);
865
+ if (w && !used.has(w.id)) {
866
+ widgets.push(w);
867
+ used.add(w.id);
868
+ }
869
+ }
870
+ for (const w of available) {
871
+ if (!used.has(w.id) && !hiddenHere.has(w.id)) widgets.push(w);
872
+ }
873
+ return { hint, columns: COLUMNS[hint], widgets };
874
+ }
875
+ function coerceDashboardLayout(raw) {
876
+ if (!raw || typeof raw !== "object") return void 0;
877
+ const r = raw;
878
+ if (!Array.isArray(r.slots)) return void 0;
879
+ const slots = [];
880
+ for (const item of r.slots) {
881
+ if (!item || typeof item !== "object") continue;
882
+ const s = item;
883
+ if (typeof s.widgetId !== "string" || !s.widgetId) continue;
884
+ const slot = { widgetId: s.widgetId };
885
+ if (typeof s.order === "number" && Number.isFinite(s.order)) slot.order = s.order;
886
+ if (Array.isArray(s.hideOn)) {
887
+ const hideOn = s.hideOn.filter((h) => HINTS.includes(h));
888
+ if (hideOn.length) slot.hideOn = hideOn;
889
+ }
890
+ slots.push(slot);
891
+ }
892
+ return { version: DASHBOARD_LAYOUT_VERSION, slots };
893
+ }
894
+
837
895
  // src/theme/tokens.ts
838
896
  var themes = {
839
- // flagship default obsidian + gold + lapis + turquoise
897
+ // flagship default - obsidian + gold + lapis + turquoise
840
898
  tutankhamun: {
841
899
  bg: "#0E0F12",
842
900
  surface: "#17171C",
@@ -884,7 +942,7 @@ var themes = {
884
942
  warning: "#D98E2B",
885
943
  danger: "#8C1F1A"
886
944
  },
887
- // neutral light a conventional bright UI for users who prefer it
945
+ // neutral light - a conventional bright UI for users who prefer it
888
946
  light: {
889
947
  bg: "#F7F8FA",
890
948
  surface: "#FFFFFF",
@@ -900,7 +958,7 @@ var themes = {
900
958
  warning: "#B45309",
901
959
  danger: "#DC2626"
902
960
  },
903
- // neutral dark a conventional dark UI, distinct from the warm Pharaonic palettes
961
+ // neutral dark - a conventional dark UI, distinct from the warm Pharaonic palettes
904
962
  dark: {
905
963
  bg: "#0F1115",
906
964
  surface: "#171A21",
@@ -1016,7 +1074,7 @@ var CommandSender = class {
1016
1074
  this.opts = opts;
1017
1075
  this.log = opts.logger ?? NULL_LOGGER;
1018
1076
  connector.setCallbacks({
1019
- // Moonraker forgets the agent identity on disconnect re-identify on every genuine reconnect.
1077
+ // Moonraker forgets the agent identity on disconnect > re-identify on every genuine reconnect.
1020
1078
  onReconnected: () => {
1021
1079
  this.setIdentified(false);
1022
1080
  void this.ensureIdentified().catch(() => this.scheduleRetry());
@@ -1036,7 +1094,7 @@ var CommandSender = class {
1036
1094
  retryTimer;
1037
1095
  retryDelayMs = 500;
1038
1096
  log;
1039
- /** true only when the bus is connected AND identified gate remote-control affordances on this. */
1097
+ /** true only when the bus is connected AND identified - gate remote-control affordances on this. */
1040
1098
  get ready() {
1041
1099
  return this._ready;
1042
1100
  }
@@ -1052,7 +1110,7 @@ var CommandSender = class {
1052
1110
  this.setIdentified(false);
1053
1111
  }
1054
1112
  /** Broadcast a UI-only command to the other FilaMind surfaces. Best-effort: a command issued
1055
- * while the bus is down is dropped (logged), never queued/replayed a stale "navigate" fired
1113
+ * while the bus is down is dropped (logged), never queued/replayed - a stale "navigate" fired
1056
1114
  * minutes later would yank a screen unexpectedly. */
1057
1115
  async send(cmd) {
1058
1116
  if (this.connector.state !== "ready") {
@@ -1061,7 +1119,10 @@ var CommandSender = class {
1061
1119
  }
1062
1120
  try {
1063
1121
  await this.ensureIdentified();
1064
- await this.connector.call("connection.send_event", { event: FILAMIND_COMMAND_EVENT, data: cmd });
1122
+ await this.connector.call("connection.send_event", {
1123
+ event: FILAMIND_COMMAND_EVENT,
1124
+ data: cmd
1125
+ });
1065
1126
  } catch (e) {
1066
1127
  this.log.warn("command send failed", String(e));
1067
1128
  }
@@ -1144,7 +1205,7 @@ var DENSITIES = /* @__PURE__ */ new Set(["comfortable", "compact"]);
1144
1205
  var MOTIFS = /* @__PURE__ */ new Set(["off", "subtle", "full"]);
1145
1206
  function migrate(raw) {
1146
1207
  const r = raw && typeof raw === "object" ? raw : {};
1147
- return {
1208
+ const out = {
1148
1209
  version: SETTINGS_VERSION,
1149
1210
  theme: typeof r.theme === "string" && r.theme in themes ? r.theme : DEFAULT_SETTINGS.theme,
1150
1211
  locale: typeof r.locale === "string" && LOCALES.some((l) => l.code === r.locale) ? r.locale : DEFAULT_SETTINGS.locale,
@@ -1152,6 +1213,9 @@ function migrate(raw) {
1152
1213
  motifDensity: typeof r.motifDensity === "string" && MOTIFS.has(r.motifDensity) ? r.motifDensity : DEFAULT_SETTINGS.motifDensity,
1153
1214
  reducedMotion: typeof r.reducedMotion === "boolean" ? r.reducedMotion : DEFAULT_SETTINGS.reducedMotion
1154
1215
  };
1216
+ const layout = coerceDashboardLayout(r.dashboardLayout);
1217
+ if (layout) out.dashboardLayout = layout;
1218
+ return out;
1155
1219
  }
1156
1220
  function memoryPersistence() {
1157
1221
  let store;
@@ -1266,6 +1330,7 @@ function applySettings(s, el) {
1266
1330
  }
1267
1331
  export {
1268
1332
  CommandSender,
1333
+ DASHBOARD_LAYOUT_VERSION,
1269
1334
  DEFAULT_LOCALE,
1270
1335
  DEFAULT_SETTINGS,
1271
1336
  DEFAULT_THEME,
@@ -1296,6 +1361,7 @@ export {
1296
1361
  aggregateSubscriptions,
1297
1362
  applySettings,
1298
1363
  applyTheme,
1364
+ coerceDashboardLayout,
1299
1365
  deepMerge,
1300
1366
  deriveCandidates,
1301
1367
  deriveKlippyState,
@@ -1320,10 +1386,12 @@ export {
1320
1386
  parseNotifyEvent,
1321
1387
  pluralCategory,
1322
1388
  registerWidget,
1389
+ resolveDashboard,
1323
1390
  resolveMessage,
1324
1391
  resolveMoonrakerUrl,
1325
1392
  roamSettings,
1326
1393
  stamp,
1394
+ surfaceHint,
1327
1395
  themeToCssVars,
1328
1396
  themes,
1329
1397
  tier,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@filamind-app/core",
3
- "version": "0.1.3",
4
- "description": "Shared foundation for the FilaMind suite Moonraker client, reactive printer state, provenance-stamped values, design tokens, and the widget/plugin registry. Framework-agnostic TypeScript.",
3
+ "version": "0.1.6",
4
+ "description": "Shared foundation for the FilaMind suite - Moonraker client, reactive printer state, provenance-stamped values, design tokens, and the widget/plugin registry. Framework-agnostic TypeScript.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
7
7
  "type": "git",
@@ -33,11 +33,14 @@
33
33
  "dev": "tsup src/index.ts --format esm --dts --watch",
34
34
  "type-check": "tsc --noEmit",
35
35
  "test": "vitest run",
36
- "prepublishOnly": "npm run build"
36
+ "prepublishOnly": "npm run build",
37
+ "format": "prettier --write \"src/**/*.ts\" \"*.{ts,js,json}\"",
38
+ "format:check": "prettier --check \"src/**/*.ts\" \"*.{ts,js,json}\""
37
39
  },
38
40
  "devDependencies": {
41
+ "prettier": "3.9.6",
39
42
  "tsup": "^8.5.1",
40
43
  "typescript": "~6.0.3",
41
- "vitest": "^4.1.9"
44
+ "vitest": "^4.1.10"
42
45
  }
43
46
  }