jssm 5.147.9 → 5.148.0

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.
@@ -1,5 +1,5 @@
1
1
  import { css, LitElement, html } from 'lit';
2
- import { sm } from 'jssm';
2
+ import { sm, from } from 'jssm';
3
3
 
4
4
  /**
5
5
  * Shared helpers for the dual-prefix (`fsl-` canonical, `jssm-` synonym)
@@ -461,6 +461,94 @@ function build_hook_descriptor(spec, wrapped) {
461
461
  return base;
462
462
  }
463
463
 
464
+ /**
465
+ * Theme registry for the fsl-* suite. A **theme** is a named pair of light/dark
466
+ * palettes; a **mode** (`system` | `light` | `dark`) picks which variant applies,
467
+ * with `system` following the OS `prefers-color-scheme`. The host resolves
468
+ * (theme name × mode) to a palette and writes it as `--fsl-color-*` custom
469
+ * properties, which cascade to every slotted widget.
470
+ *
471
+ * @see resolve_theme_mode
472
+ */
473
+ /**
474
+ * The built-in themes: `Default` (the suite's house palette) and `Solarized`
475
+ * (Ethan Schoonover's palette). Consumers can extend the host's `themes`
476
+ * registry with their own; every entry shows up in the toolbar's theme list.
477
+ */
478
+ const BUILTIN_THEMES = {
479
+ Default: {
480
+ light: {
481
+ surface: '#ffffff', text: '#222222', accent: '#5b9dff', border: '#e5e5e5', muted: '#9aa0a6',
482
+ 'json-key': '#5b3da8', 'json-string': '#2e7d32', 'json-number': '#b8860b', 'json-atom': '#c2185b',
483
+ },
484
+ dark: {
485
+ surface: '#1e1e22', text: '#d6d6d6', accent: '#82aaff', border: '#2a2a2e', muted: '#5a5f66',
486
+ 'json-key': '#82aaff', 'json-string': '#c3e88d', 'json-number': '#f78c6c', 'json-atom': '#c792ea',
487
+ },
488
+ },
489
+ Solarized: {
490
+ light: {
491
+ surface: '#fdf6e3', text: '#657b83', accent: '#268bd2', border: '#eee8d5', muted: '#93a1a1',
492
+ 'json-key': '#6c71c4', 'json-string': '#859900', 'json-number': '#b58900', 'json-atom': '#d33682',
493
+ },
494
+ dark: {
495
+ surface: '#002b36', text: '#839496', accent: '#268bd2', border: '#073642', muted: '#586e75',
496
+ 'json-key': '#6c71c4', 'json-string': '#859900', 'json-number': '#b58900', 'json-atom': '#d33682',
497
+ },
498
+ },
499
+ };
500
+ /**
501
+ * Resolve a `(registry, theme name, variant)` triple to a concrete palette,
502
+ * falling back to the built-in `Default` theme when the name is unknown.
503
+ *
504
+ * @param themes - The registry to look in.
505
+ * @param name - The selected theme name.
506
+ * @param variant - `light` or `dark`.
507
+ * @returns The palette to apply.
508
+ */
509
+ function resolve_palette(themes, name, variant) {
510
+ var _a;
511
+ return ((_a = themes[name]) !== null && _a !== void 0 ? _a : BUILTIN_THEMES['Default'])[variant];
512
+ }
513
+ /**
514
+ * Resolve a theme mode to a concrete `light`/`dark`. `system` consults the OS
515
+ * preference; `light`/`dark` are returned as-is.
516
+ *
517
+ * @param mode - The selected mode.
518
+ * @param prefers_dark - Whether the OS prefers a dark color scheme.
519
+ * @returns The concrete variant to apply.
520
+ *
521
+ * @example
522
+ * resolve_theme_mode('system', true); // 'dark'
523
+ * resolve_theme_mode('light', true); // 'light'
524
+ */
525
+ function resolve_theme_mode(mode, prefers_dark) {
526
+ return mode === 'system' ? (prefers_dark ? 'dark' : 'light') : mode;
527
+ }
528
+ /**
529
+ * Map a palette to its `--fsl-color-*` custom-property entries, ready to set on
530
+ * an element's style.
531
+ *
532
+ * @param palette - The resolved palette.
533
+ * @returns `[propertyName, value]` pairs, e.g. `['--fsl-color-surface', '#fff']`.
534
+ */
535
+ function palette_to_vars(palette) {
536
+ return Object.entries(palette).map(([k, v]) => [`--fsl-color-${k}`, v]);
537
+ }
538
+ function register_palette_properties() {
539
+ if (typeof CSS === 'undefined' || typeof CSS.registerProperty !== 'function') {
540
+ return;
541
+ }
542
+ for (const [prop, value] of palette_to_vars(BUILTIN_THEMES['Default'].light)) {
543
+ try {
544
+ CSS.registerProperty({ name: prop, syntax: '<color>', inherits: true, initialValue: value });
545
+ }
546
+ catch (_a) {
547
+ // already registered by another host or a prior call
548
+ }
549
+ }
550
+ }
551
+
464
552
  /**
465
553
  * Allow-list of event names accepted by `<jssm-on event="...">`. Must stay
466
554
  * in sync with the `JssmEventName` union in `jssm_types.ts` (the library's
@@ -682,6 +770,10 @@ function resolve_fsl_source(host, fsl_attr) {
682
770
  n.remove();
683
771
  }
684
772
  });
773
+ // Drop every element assigned to a named slot — slotted UI (toolbar, panels,
774
+ // action buttons) is projected into the host, not FSL source. Only the
775
+ // default-slot text (the literal FSL) should remain.
776
+ clone.querySelectorAll('[slot]').forEach(n => n.remove());
685
777
  return (clone.textContent || '').trim();
686
778
  })();
687
779
  if (text_content_fsl.length > 0) {
@@ -703,6 +795,36 @@ function resolve_fsl_source(host, fsl_attr) {
703
795
  const only = sources[0];
704
796
  return { fsl: only.fsl, provided_count: 1, error: undefined };
705
797
  }
798
+ /**
799
+ * Resolve `layout="auto"` to a concrete split direction from the viewport
800
+ * shape: side-by-side (`'lr'`) when at least as wide as tall, else stacked
801
+ * (`'tb'`). Pure, so it's testable without a laid-out DOM.
802
+ *
803
+ * @example
804
+ * auto_mode(1200, 800); // => 'lr'
805
+ * auto_mode(600, 900); // => 'tb'
806
+ */
807
+ function auto_mode(width, height) {
808
+ return width >= height ? 'lr' : 'tb';
809
+ }
810
+ /**
811
+ * Clamp a gutter-drag coordinate to a split ratio (percent of the first pane).
812
+ * Pure so it's testable without a laid-out DOM: returns a neutral `50` when the
813
+ * container has no measured size (e.g. jsdom, where `getBoundingClientRect`
814
+ * yields zeros), and otherwise clamps to `[15, 85]` so neither pane collapses.
815
+ *
816
+ * @example
817
+ * split_ratio(30, 0, 100); // => 30
818
+ * split_ratio(5, 0, 100); // => 15 (clamped low)
819
+ * split_ratio(0, 0, 0); // => 50 (no layout)
820
+ */
821
+ function split_ratio(coord, start, size) {
822
+ if (size <= 0) {
823
+ return 50;
824
+ }
825
+ const pct = ((coord - start) / size) * 100;
826
+ return Math.min(85, Math.max(15, pct));
827
+ }
706
828
  /**
707
829
  * Web component that owns a single `Machine<unknown>` constructed from an
708
830
  * FSL source supplied via one of three mutually exclusive channels:
@@ -744,6 +866,50 @@ class FslInstance extends LitElement {
744
866
  * `<script type="text/fsl">` (or non-empty text content) is an error.
745
867
  */
746
868
  this.fsl = '';
869
+ /**
870
+ * Panel arrangement of the viz + editor panes (see {@link FslLayout}). `''`
871
+ * (default) renders the stacked sections; `'lr'`/`'rl'` lay them side-by-side
872
+ * with a draggable vertical gutter (editor left / right); `'tb'`/`'bt'` stack
873
+ * them with a horizontal gutter; `'editor'`/`'viewer'` show a single pane;
874
+ * `'tabs'` shows one pane at a time behind a tab strip; `'auto'` follows the
875
+ * viewport aspect. Other panels (toolbar, info-panel, …) render below.
876
+ */
877
+ this.layout = '';
878
+ /**
879
+ * Theme **mode**, reflected to the `theme` attribute: `system` (follow the OS
880
+ * `prefers-color-scheme`), `light`, or `dark`. Combined with {@link themeName}
881
+ * it selects a palette from {@link themes}, applied as inline `--fsl-color-*`
882
+ * by {@link _applyTheme}. `<fsl-toolbar>` sets this from its theme pulldown.
883
+ */
884
+ this.theme = 'light';
885
+ /**
886
+ * Selected theme name, reflected to the `theme-name` attribute. A key of
887
+ * {@link themes}; an unknown name falls back to the built-in `Default`.
888
+ */
889
+ this.themeName = 'Default';
890
+ /**
891
+ * The theme registry — named light/dark palette pairs. Defaults to the
892
+ * built-in `Default` + `Solarized`; a consumer can replace or extend it, and
893
+ * every entry appears in the toolbar's theme list.
894
+ */
895
+ this.themes = BUILTIN_THEMES;
896
+ /**
897
+ * Initial extended-state data seeded into the machine at build time. When set
898
+ * (to anything other than `undefined`), the machine is built via `from(fsl,
899
+ * { data })` so `<fsl-data-inspector>` has something to show before any
900
+ * transition; the default keeps the lighter `sm`-tag build path.
901
+ */
902
+ this.data = undefined;
903
+ /** Split ratio (percent of the first pane), updated by the gutter drag. */
904
+ this._split = 50;
905
+ /** Which pane the tabbed layout shows. */
906
+ this._tab = 'viz';
907
+ /** Concrete direction that `layout="auto"` currently resolves to. */
908
+ this._autoMode = 'lr';
909
+ /** Window-resize listener installed while `layout="auto"`, or null. */
910
+ this._autoListener = null;
911
+ /** Slot names of panels currently hidden (driven by `<fsl-toolbar>`). */
912
+ this._hiddenPanels = new Set();
747
913
  /**
748
914
  * The underlying machine instance, constructed at `connectedCallback`.
749
915
  * Exposed raw (not proxied) per the #639/#648 design decision so that
@@ -798,6 +964,41 @@ class FslInstance extends LitElement {
798
964
  * DOM listeners installed by `<jssm-action>` / `data-jssm-action` discovery.
799
965
  */
800
966
  this._action_listeners = [];
967
+ /** Tracks the OS color scheme; null when `matchMedia` is unavailable. */
968
+ this._mql = null;
969
+ /** Re-apply on an OS color-scheme change — only relevant while in `system` mode. */
970
+ this._on_os_theme_change = () => { if (this.theme === 'system') {
971
+ this._applyTheme();
972
+ } };
973
+ /**
974
+ * Gutter pointer-down: begin a drag. Move/up are attached to the document so
975
+ * the drag survives the pointer leaving the thin gutter (no pointer-capture,
976
+ * which keeps the handlers testable in jsdom).
977
+ */
978
+ this._onGutterDown = (e) => {
979
+ e.preventDefault();
980
+ document.addEventListener('pointermove', this._onGutterMove);
981
+ document.addEventListener('pointerup', this._onGutterUp);
982
+ };
983
+ /** Document pointer-move during a drag: recompute the split ratio. */
984
+ this._onGutterMove = (e) => {
985
+ const rect = this.renderRoot.querySelector('.workbench').getBoundingClientRect();
986
+ const mode = this._effectiveMode();
987
+ this._split = (mode === 'tb' || mode === 'bt')
988
+ ? split_ratio(e.clientY, rect.top, rect.height)
989
+ : split_ratio(e.clientX, rect.left, rect.width);
990
+ this.requestUpdate();
991
+ };
992
+ /** Document pointer-up: end the drag and detach the document listeners. */
993
+ this._onGutterUp = () => {
994
+ document.removeEventListener('pointermove', this._onGutterMove);
995
+ document.removeEventListener('pointerup', this._onGutterUp);
996
+ };
997
+ /** Gutter double-click: reset the split to 50/50. */
998
+ this._onGutterReset = () => {
999
+ this._split = 50;
1000
+ this.requestUpdate();
1001
+ };
801
1002
  }
802
1003
  /**
803
1004
  * Raw machine accessor. Returns the owned {@link Machine} instance.
@@ -826,6 +1027,36 @@ class FslInstance extends LitElement {
826
1027
  this.requestUpdate();
827
1028
  return result;
828
1029
  }
1030
+ /**
1031
+ * Convenience wrapper for `machine.transition(state, data)` — moves directly
1032
+ * to a state along a legal (non-forced) edge. Reflects the new state and
1033
+ * requests an update, exactly as {@link FslInstance.do} does for actions.
1034
+ *
1035
+ * @param state - The destination state.
1036
+ * @param data - Optional data payload.
1037
+ * @returns `true` if the transition succeeded (a legal edge existed).
1038
+ */
1039
+ transition(state, data) {
1040
+ const result = this.machine.transition(state, data);
1041
+ this._paint_state_reflection();
1042
+ this.requestUpdate();
1043
+ return result;
1044
+ }
1045
+ /**
1046
+ * Convenience wrapper for `machine.force_transition(state, data)` — moves to a
1047
+ * state along any edge, including forced-only ones. Reflects the new state and
1048
+ * requests an update.
1049
+ *
1050
+ * @param state - The destination state.
1051
+ * @param data - Optional data payload.
1052
+ * @returns `true` if the forced transition succeeded (any edge existed).
1053
+ */
1054
+ force_transition(state, data) {
1055
+ const result = this.machine.force_transition(state, data);
1056
+ this._paint_state_reflection();
1057
+ this.requestUpdate();
1058
+ return result;
1059
+ }
829
1060
  /**
830
1061
  * Convenience wrapper for `machine.state()`. Returns the current
831
1062
  * state's name.
@@ -833,6 +1064,101 @@ class FslInstance extends LitElement {
833
1064
  state() {
834
1065
  return String(this.machine.state());
835
1066
  }
1067
+ /** Whether the OS currently prefers a dark color scheme. */
1068
+ _prefers_dark() {
1069
+ return typeof window.matchMedia === 'function'
1070
+ && window.matchMedia('(prefers-color-scheme: dark)').matches;
1071
+ }
1072
+ /**
1073
+ * Resolve `theme` (mode) × `themeName` to a palette and apply it: write the
1074
+ * `--fsl-color-*` tokens inline (overriding the CSS fallback and cascading to
1075
+ * every slotted widget), reflect the chosen variant to `resolved-theme`, and
1076
+ * drive each slotted editor's light/dark CodeMirror theme.
1077
+ */
1078
+ _applyTheme() {
1079
+ const variant = resolve_theme_mode(this.theme, this._prefers_dark());
1080
+ this.setAttribute('resolved-theme', variant);
1081
+ for (const [prop, value] of palette_to_vars(resolve_palette(this.themes, this.themeName, variant))) {
1082
+ this.style.setProperty(prop, value);
1083
+ }
1084
+ for (const editor of this.querySelectorAll('[slot="editor"]')) {
1085
+ editor.theme = variant;
1086
+ }
1087
+ }
1088
+ /** Resolve the active layout to a concrete mode (`'auto'` → a direction). */
1089
+ _effectiveMode() {
1090
+ return this.layout === 'auto' ? this._autoMode : this.layout;
1091
+ }
1092
+ /** Tab strip for the `tabs` layout. */
1093
+ _renderTabbar() {
1094
+ return html `
1095
+ <div class="tabbar" role="tablist" aria-label="Pane">
1096
+ <button type="button" role="tab" aria-selected=${this._tab === 'viz'} @click=${() => this._setTab('viz')}>Graph</button>
1097
+ <button type="button" role="tab" aria-selected=${this._tab === 'editor'} @click=${() => this._setTab('editor')}>Code</button>
1098
+ </div>`;
1099
+ }
1100
+ /** Switch the visible pane in the `tabs` layout. */
1101
+ _setTab(tab) {
1102
+ this._tab = tab;
1103
+ this.requestUpdate();
1104
+ }
1105
+ /**
1106
+ * Whether the panel slotted under `slot` is currently hidden.
1107
+ *
1108
+ * @param slot - A panel slot name (e.g. `"viz"`, `"editor"`, `"history"`).
1109
+ * @returns `true` when the panel is hidden.
1110
+ */
1111
+ isPanelHidden(slot) {
1112
+ return this._hiddenPanels.has(slot);
1113
+ }
1114
+ /**
1115
+ * Show or hide the panel slotted under `slot`. Hiding `viz` or `editor`
1116
+ * collapses that workbench pane (the other fills); hiding an aux panel
1117
+ * removes its section. `<fsl-toolbar>` drives this from its panel toggles.
1118
+ *
1119
+ * @param slot - A panel slot name (e.g. `"viz"`, `"editor"`, `"history"`).
1120
+ * @param hidden - `true` to hide, `false` to show.
1121
+ */
1122
+ setPanelHidden(slot, hidden) {
1123
+ if (hidden) {
1124
+ this._hiddenPanels.add(slot);
1125
+ }
1126
+ else {
1127
+ this._hiddenPanels.delete(slot);
1128
+ }
1129
+ this.requestUpdate();
1130
+ }
1131
+ /**
1132
+ * Toggle the visibility of the panel slotted under `slot`.
1133
+ *
1134
+ * @param slot - A panel slot name (e.g. `"viz"`, `"editor"`, `"history"`).
1135
+ */
1136
+ togglePanel(slot) {
1137
+ this.setPanelHidden(slot, !this._hiddenPanels.has(slot));
1138
+ }
1139
+ /**
1140
+ * Install or remove the window-resize listener that resolves `layout="auto"`
1141
+ * to a concrete direction. Called from {@link updated}; uses the viewport
1142
+ * aspect so it needs no layout measurement (works in jsdom).
1143
+ */
1144
+ _syncAutoListener() {
1145
+ if (this.layout === 'auto' && this._autoListener === null) {
1146
+ const recompute = () => {
1147
+ const next = auto_mode(window.innerWidth, window.innerHeight);
1148
+ if (next !== this._autoMode) {
1149
+ this._autoMode = next;
1150
+ this.requestUpdate();
1151
+ }
1152
+ };
1153
+ this._autoListener = recompute;
1154
+ window.addEventListener('resize', recompute);
1155
+ recompute();
1156
+ }
1157
+ else if (this.layout !== 'auto' && this._autoListener !== null) {
1158
+ window.removeEventListener('resize', this._autoListener);
1159
+ this._autoListener = null;
1160
+ }
1161
+ }
836
1162
  /**
837
1163
  * Lifecycle hook. Resolves the FSL source, constructs the machine,
838
1164
  * paints initial reflection, then defers shadow-DOM rendering to Lit.
@@ -854,7 +1180,7 @@ class FslInstance extends LitElement {
854
1180
  // Step 2: construct the machine.
855
1181
  // (The resolver guarantees `fsl` is a non-empty string when error is undefined.)
856
1182
  const fsl_source = resolved.fsl;
857
- this._machine = sm `${fsl_source}`;
1183
+ this._machine = this._build_machine(fsl_source);
858
1184
  // Step 3: paint initial host attributes + CSS custom properties.
859
1185
  this._paint_state_reflection();
860
1186
  // Step 4: shadow DOM render is automatic via Lit; requesting an update
@@ -872,6 +1198,15 @@ class FslInstance extends LitElement {
872
1198
  this._unsubs.push(...install_bindings(this, this._machine));
873
1199
  // #640: <jssm-action> DOM event → machine action wiring.
874
1200
  this._discover_jssm_actions();
1201
+ // Theme: register the palette tokens as animatable colors (once, globally, so
1202
+ // switches can ease), follow the OS while in `system` mode, then apply the
1203
+ // resolved palette.
1204
+ register_palette_properties();
1205
+ if (typeof window.matchMedia === 'function') {
1206
+ this._mql = window.matchMedia('(prefers-color-scheme: dark)');
1207
+ this._mql.addEventListener('change', this._on_os_theme_change);
1208
+ }
1209
+ this._applyTheme();
875
1210
  }
876
1211
  /**
877
1212
  * Discover direct-child `<jssm-on>` elements and install their
@@ -980,9 +1315,75 @@ class FslInstance extends LitElement {
980
1315
  *
981
1316
  * @param changed - Lit's changed-property map (forwarded to super).
982
1317
  */
1318
+ /**
1319
+ * Lit lifecycle. After the initial connect, a change to the `fsl` property
1320
+ * (e.g. written back by a bound `<fsl-editor>`, #1387) rebuilds the machine.
1321
+ * The first update is skipped via `hasUpdated` — `connectedCallback` already
1322
+ * built the initial machine.
1323
+ */
1324
+ willUpdate(changed) {
1325
+ if (this.hasUpdated && changed.has('fsl')) {
1326
+ this._rebuild_machine();
1327
+ }
1328
+ }
1329
+ /**
1330
+ * Rebuild the owned machine from the current `fsl` property and re-install
1331
+ * every machine-scoped subscription against the new machine (#1387).
1332
+ *
1333
+ * Semantics:
1334
+ * - **Keep-last-good:** if the new FSL is empty or fails to parse/compile,
1335
+ * the current machine is left untouched (a bound editor's lint surfaces
1336
+ * the error); we never blank or throw on mid-edit invalid source.
1337
+ * - **Reset-to-start:** a successful rebuild is a fresh machine at its
1338
+ * start state — the structure changed, so preserving position is unsafe.
1339
+ * - **Re-subscribe:** mechanism-4 re-emission, `<fsl-hook>`, `<fsl-on>`,
1340
+ * and `<fsl-bind>` projections are torn down from the old machine and
1341
+ * re-installed on the new one. DOM action listeners (`<fsl-action>`)
1342
+ * persist untouched — they read `this.machine` live.
1343
+ */
1344
+ /**
1345
+ * Build a machine from FSL source, seeding {@link data} when it is set.
1346
+ *
1347
+ * @param fsl_source - The FSL string to compile.
1348
+ * @returns The compiled machine.
1349
+ */
1350
+ _build_machine(fsl_source) {
1351
+ return (this.data === undefined
1352
+ ? sm `${fsl_source}`
1353
+ : from(fsl_source, { data: this.data }));
1354
+ }
1355
+ _rebuild_machine() {
1356
+ if (typeof this.fsl !== 'string' || this.fsl.trim().length === 0) {
1357
+ return;
1358
+ }
1359
+ let next;
1360
+ try {
1361
+ next = this._build_machine(this.fsl);
1362
+ }
1363
+ catch (_a) {
1364
+ return; // keep-last-good
1365
+ }
1366
+ // Tear down the OLD machine's subscriptions (shared with disconnect).
1367
+ this._unbind_machine_subscriptions();
1368
+ // Swap to the new machine and re-bind everything machine-scoped.
1369
+ this._machine = next;
1370
+ this._paint_state_reflection();
1371
+ this._install_event_reemission();
1372
+ this._install_declarative_hooks();
1373
+ this._install_jssm_on_children();
1374
+ this._unsubs.push(...install_bindings(this, next));
1375
+ this.requestUpdate();
1376
+ // Notify bound children (e.g. <fsl-viz>) that the machine object was
1377
+ // replaced, so they can re-subscribe and re-render against the new one.
1378
+ this.dispatchEvent(new CustomEvent('fsl-machine-rebuilt', { bubbles: true, composed: true }));
1379
+ }
983
1380
  updated(changed) {
984
1381
  super.updated(changed);
985
1382
  this._flush_pending_dom_events();
1383
+ this._syncAutoListener();
1384
+ if (['theme', 'themeName', 'themes'].some(p => changed.has(p))) {
1385
+ this._applyTheme();
1386
+ }
986
1387
  }
987
1388
  /**
988
1389
  * Dispatch and clear the queue of pending DOM events. The queue is
@@ -1008,21 +1409,20 @@ class FslInstance extends LitElement {
1008
1409
  }
1009
1410
  }
1010
1411
  /**
1011
- * Lifecycle hook. Cleans up everything the WC installed at connect: hook
1012
- * registrations from `<jssm-hook>`, event subscriptions from `<jssm-on>`,
1013
- * mechanism-4 re-emission subscriptions, and DOM listeners from
1014
- * `<jssm-action>` / `data-jssm-action`.
1412
+ * Release every machine-scoped subscription installed against the current
1413
+ * machine: #639 mechanism-4 re-emission, #641 `<fsl-hook>` registrations,
1414
+ * #643 `<fsl-on>` subscriptions, and #645 `<fsl-bind>` projections. Shared by
1415
+ * {@link disconnectedCallback} (full teardown) and the live-rebuild path
1416
+ * ({@link _rebuild_machine}, #1387), which re-installs against the new machine.
1417
+ * DOM action listeners (`<fsl-action>`) are NOT touched here — they read
1418
+ * `this.machine` live and survive a rebuild.
1015
1419
  */
1016
- disconnectedCallback() {
1017
- super.disconnectedCallback();
1018
- // #639 mechanism 4: release host-level re-emission subscriptions and drop
1019
- // any events that were queued but not yet flushed.
1420
+ _unbind_machine_subscriptions() {
1020
1421
  for (const off of this._reemit_unsubscribes) {
1021
1422
  off();
1022
1423
  }
1023
1424
  this._reemit_unsubscribes = [];
1024
1425
  this._pending_dom_events = [];
1025
- // #641: remove installed hooks.
1026
1426
  if (this._machine !== undefined) {
1027
1427
  const machine = this._machine;
1028
1428
  for (const desc of this._installed_hooks) {
@@ -1030,7 +1430,6 @@ class FslInstance extends LitElement {
1030
1430
  }
1031
1431
  }
1032
1432
  this._installed_hooks = [];
1033
- // #643: release every subscription installed from a <jssm-on> child.
1034
1433
  for (const off of this._on_unsubscribes) {
1035
1434
  try {
1036
1435
  off();
@@ -1038,16 +1437,38 @@ class FslInstance extends LitElement {
1038
1437
  catch ( /* swallow — cleanup must not throw past us */_a) { /* swallow — cleanup must not throw past us */ }
1039
1438
  }
1040
1439
  this._on_unsubscribes = [];
1041
- // #645: tear down every live binding.
1042
1440
  for (const off of this._unsubs) {
1043
1441
  off();
1044
1442
  }
1045
1443
  this._unsubs = [];
1444
+ }
1445
+ /**
1446
+ * Lifecycle hook. Cleans up everything the WC installed at connect: hook
1447
+ * registrations from `<jssm-hook>`, event subscriptions from `<jssm-on>`,
1448
+ * mechanism-4 re-emission subscriptions, and DOM listeners from
1449
+ * `<jssm-action>` / `data-jssm-action`.
1450
+ */
1451
+ disconnectedCallback() {
1452
+ super.disconnectedCallback();
1453
+ // Release every machine-scoped subscription (#639 re-emission, #641 hooks,
1454
+ // #643 <fsl-on>, #645 <fsl-bind>). Shared with the live-rebuild path (#1387).
1455
+ this._unbind_machine_subscriptions();
1046
1456
  // #640: remove DOM listeners installed via <jssm-action> / data-jssm-action.
1047
1457
  for (const entry of this._action_listeners) {
1048
1458
  entry.target.removeEventListener(entry.event, entry.handler);
1049
1459
  }
1050
1460
  this._action_listeners = [];
1461
+ // Detach any in-flight gutter-drag document listeners.
1462
+ this._onGutterUp();
1463
+ if (this._autoListener !== null) {
1464
+ window.removeEventListener('resize', this._autoListener);
1465
+ this._autoListener = null;
1466
+ }
1467
+ // Stop following the OS color scheme.
1468
+ if (this._mql !== null) {
1469
+ this._mql.removeEventListener('change', this._on_os_theme_change);
1470
+ this._mql = null;
1471
+ }
1051
1472
  }
1052
1473
  /**
1053
1474
  * Wire DOM events to machine actions, using the two declarative forms from
@@ -1157,58 +1578,99 @@ class FslInstance extends LitElement {
1157
1578
  const state_slot_name = this._machine === undefined
1158
1579
  ? 'state-unknown'
1159
1580
  : `state-${String(this._machine.state())}`;
1581
+ const header = html `
1582
+ <header>
1583
+ <slot name="title"><span class="placeholder">fsl-instance</span></slot>
1584
+ </header>`;
1585
+ const viz = html `<slot name="viz"><span class="placeholder">no viz configured</span></slot>`;
1586
+ const editor = html `<slot name="editor"></slot>`;
1587
+ const toolbar = html `<section class="toolbar"><slot name="toolbar"></slot></section>`;
1588
+ if (this.layout !== '') {
1589
+ const mode = this._effectiveMode();
1590
+ return html `
1591
+ <div class="container is-split">
1592
+ ${header}
1593
+ ${toolbar}
1594
+ <div class="middle">
1595
+ <section class="dock actions-dock${this._hiddenPanels.has('actions') ? '' : ' open'}" part="actions-dock">
1596
+ <slot name="actions"></slot>
1597
+ </section>
1598
+ <div class="workbench${this._hiddenPanels.has('viz') ? ' hide-viz' : ''}${this._hiddenPanels.has('editor') ? ' hide-editor' : ''}"
1599
+ data-mode=${mode} style="--fsl-split:${this._split}%">
1600
+ ${mode === 'tabs' ? this._renderTabbar() : ''}
1601
+ <section class="pane viz" ?hidden=${mode === 'tabs' && this._tab !== 'viz'}>${viz}</section>
1602
+ <div class="gutter"
1603
+ @pointerdown=${this._onGutterDown}
1604
+ @dblclick=${this._onGutterReset}></div>
1605
+ <section class="pane editor" ?hidden=${mode === 'tabs' && this._tab !== 'editor'}>${editor}</section>
1606
+ </div>
1607
+ <section class="dock data-dock${this._hiddenPanels.has('data-inspector') ? '' : ' open'}" part="data-dock">
1608
+ <slot name="data-inspector"></slot>
1609
+ </section>
1610
+ </div>
1611
+ ${this._renderAuxPanels(true)}
1612
+ <section class="state-section"><slot name=${state_slot_name}></slot></section>
1613
+ <footer><slot name="footer"></slot></footer>
1614
+ </div>
1615
+ `;
1616
+ }
1160
1617
  return html `
1161
1618
  <div class="container">
1162
- <header>
1163
- <slot name="title"><span class="placeholder">fsl-instance</span></slot>
1164
- </header>
1165
- <section class="viz">
1166
- <slot name="viz"><span class="placeholder">no viz configured</span></slot>
1167
- </section>
1168
- <section class="editor">
1169
- <slot name="editor"></slot>
1170
- </section>
1171
- <section class="toolbar">
1172
- <slot name="toolbar"></slot>
1173
- </section>
1174
- <section class="actions">
1175
- <slot name="actions"></slot>
1176
- </section>
1177
- <section class="info-panel">
1178
- <slot name="info-panel"></slot>
1179
- </section>
1180
- <section class="history">
1181
- <slot name="history"></slot>
1182
- </section>
1183
- <section class="data-inspector">
1184
- <slot name="data-inspector"></slot>
1185
- </section>
1186
- <section class="hook-log">
1187
- <slot name="hook-log"></slot>
1188
- </section>
1189
- <section class="effective-properties">
1190
- <slot name="effective-properties"></slot>
1191
- </section>
1192
- <section class="simulation">
1193
- <slot name="simulation"></slot>
1194
- </section>
1195
- <section class="export">
1196
- <slot name="export"></slot>
1197
- </section>
1198
- <section class="state-section">
1199
- <slot name=${state_slot_name}></slot>
1200
- </section>
1201
- <footer>
1202
- <slot name="footer"></slot>
1203
- </footer>
1619
+ ${header}
1620
+ ${toolbar}
1621
+ <section class="viz" ?hidden=${this._hiddenPanels.has('viz')}>${viz}</section>
1622
+ <section class="editor" ?hidden=${this._hiddenPanels.has('editor')}>${editor}</section>
1623
+ ${this._renderAuxPanels(false)}
1624
+ <section class="state-section"><slot name=${state_slot_name}></slot></section>
1625
+ <footer><slot name="footer"></slot></footer>
1204
1626
  </div>
1205
1627
  `;
1206
1628
  }
1629
+ /** The stacked middle panels, shared by both layouts. The toolbar slot is
1630
+ * rendered at the top of {@link render}. In split mode the `actions` and
1631
+ * `data-inspector` panels are lifted out into easing side docks, so
1632
+ * `docked` is true there and they are skipped here to avoid duplicating
1633
+ * their slots. The state-section + footer stay in {@link render} so the
1634
+ * dynamic state-slot name binds at the top level.
1635
+ *
1636
+ * @param docked - True when actions + data-inspector are rendered as side
1637
+ * docks (split layouts); they are then omitted from this stack. */
1638
+ _renderAuxPanels(docked) {
1639
+ const h = (slot) => this._hiddenPanels.has(slot);
1640
+ return html `
1641
+ ${docked ? '' : html `<section class="actions" ?hidden=${h('actions')}><slot name="actions"></slot></section>`}
1642
+ <section class="info-panel" ?hidden=${h('info-panel')}><slot name="info-panel"></slot></section>
1643
+ <section class="history" ?hidden=${h('history')}><slot name="history"></slot></section>
1644
+ ${docked ? '' : html `<section class="data-inspector" ?hidden=${h('data-inspector')}><slot name="data-inspector"></slot></section>`}
1645
+ <section class="hook-log" ?hidden=${h('hook-log')}><slot name="hook-log"></slot></section>
1646
+ <section class="effective-properties" ?hidden=${h('effective-properties')}><slot name="effective-properties"></slot></section>
1647
+ <section class="simulation" ?hidden=${h('simulation')}><slot name="simulation"></slot></section>
1648
+ <section class="export" ?hidden=${h('export')}><slot name="export"></slot></section>
1649
+ `;
1650
+ }
1207
1651
  }
1208
1652
  FslInstance.styles = css `
1209
1653
  :host {
1210
1654
  display: block;
1655
+ /* Ease every palette token on a theme switch. The tokens are registered as
1656
+ animatable <color>s in JS (register_palette_properties) — @property in a
1657
+ shadow stylesheet does not register globally. Because the host transitions
1658
+ its own --fsl-color-*, every widget's var(--fsl-color-*) re-resolves to the
1659
+ animating value each frame, so the whole suite cross-fades. */
1660
+ transition:
1661
+ --fsl-color-surface 0.28s ease, --fsl-color-text 0.28s ease, --fsl-color-accent 0.28s ease,
1662
+ --fsl-color-border 0.28s ease, --fsl-color-muted 0.28s ease, --fsl-color-json-key 0.28s ease,
1663
+ --fsl-color-json-string 0.28s ease, --fsl-color-json-number 0.28s ease, --fsl-color-json-atom 0.28s ease;
1664
+ /* Pre-JS fallback palette (the Default theme's light variant). At runtime
1665
+ the host writes the resolved theme's --fsl-color-* tokens as inline
1666
+ style (see _applyTheme), which override these and cascade to every
1667
+ slotted widget. A consumer's own --fsl-color-* still wins over both. */
1668
+ --fsl-color-surface: #ffffff; --fsl-color-text: #222222; --fsl-color-accent: #5b9dff;
1669
+ --fsl-color-border: #e5e5e5; --fsl-color-muted: #9aa0a6;
1670
+ --fsl-color-json-key: #5b3da8; --fsl-color-json-string: #2e7d32;
1671
+ --fsl-color-json-number: #b8860b; --fsl-color-json-atom: #c2185b;
1211
1672
  }
1673
+ @media (prefers-reduced-motion: reduce) { :host { transition: none; } }
1212
1674
  .container {
1213
1675
  width: 100%;
1214
1676
  height: 100%;
@@ -1217,6 +1679,72 @@ FslInstance.styles = css `
1217
1679
  opacity: 0.6;
1218
1680
  font-style: italic;
1219
1681
  }
1682
+
1683
+ /* layout modes: lr/rl (row) · tb/bt (column) · editor/viewer (single) · tabs. */
1684
+ .container.is-split { display: flex; flex-direction: column; }
1685
+ /* the middle band: actions dock | workbench | data dock */
1686
+ .middle { display: flex; flex: 1 1 auto; min-height: 0; }
1687
+ .workbench { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; }
1688
+ /* side docks ease their width like the help drawer; closed = 0, open = a
1689
+ fixed basis. overflow:hidden clips the content during the slide. */
1690
+ .dock {
1691
+ flex: 0 0 0; min-width: 0; overflow: hidden; box-sizing: border-box;
1692
+ background: var(--fsl-color-surface, #fff);
1693
+ transition: flex-basis 0.28s ease;
1694
+ }
1695
+ .dock.open { flex-basis: var(--fsl-dock-width, 17em); }
1696
+ .actions-dock.open { border-right: 1px solid var(--fsl-color-border, #e5e5e5); }
1697
+ .data-dock.open { border-left: 1px solid var(--fsl-color-border, #e5e5e5); }
1698
+ /* a docked panel fills the band's height; the data inspector then scrolls
1699
+ to the dock instead of its own 16em cap. */
1700
+ .dock ::slotted(*) { height: 100%; box-sizing: border-box; }
1701
+ .data-dock { --fsl-data-inspector-max-height: 100%; }
1702
+ @media (prefers-reduced-motion: reduce) { .dock { transition: none; } }
1703
+ .workbench[data-mode="tb"], .workbench[data-mode="bt"], .workbench[data-mode="tabs"] { flex-direction: column; }
1704
+ .workbench .pane { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
1705
+ .workbench .pane ::slotted(*) { flex: 1; min-width: 0; min-height: 0; }
1706
+
1707
+ /* pane order (which pane comes first along the main axis) */
1708
+ .workbench[data-mode="lr"] .editor { order: 0; } .workbench[data-mode="lr"] .gutter { order: 1; } .workbench[data-mode="lr"] .viz { order: 2; }
1709
+ .workbench[data-mode="rl"] .viz { order: 0; } .workbench[data-mode="rl"] .gutter { order: 1; } .workbench[data-mode="rl"] .editor { order: 2; }
1710
+ .workbench[data-mode="tb"] .editor { order: 0; } .workbench[data-mode="tb"] .gutter { order: 1; } .workbench[data-mode="tb"] .viz { order: 2; }
1711
+ .workbench[data-mode="bt"] .viz { order: 0; } .workbench[data-mode="bt"] .gutter { order: 1; } .workbench[data-mode="bt"] .editor { order: 2; }
1712
+
1713
+ /* the first pane carries the split ratio; the other fills */
1714
+ .workbench[data-mode="lr"] .editor, .workbench[data-mode="tb"] .editor,
1715
+ .workbench[data-mode="rl"] .viz, .workbench[data-mode="bt"] .viz { flex: 0 0 var(--fsl-split, 50%); }
1716
+ .workbench[data-mode="lr"] .viz, .workbench[data-mode="tb"] .viz,
1717
+ .workbench[data-mode="rl"] .editor, .workbench[data-mode="bt"] .editor { flex: 1 1 0; }
1718
+
1719
+ /* gutter */
1720
+ .gutter {
1721
+ flex: 0 0 6px; align-self: stretch; cursor: col-resize;
1722
+ background: rgba(0, 0, 0, 0.18); touch-action: none;
1723
+ }
1724
+ .workbench[data-mode="tb"] .gutter, .workbench[data-mode="bt"] .gutter { cursor: row-resize; }
1725
+
1726
+ /* single-pane modes hide the gutter and the other pane */
1727
+ .workbench[data-mode="editor"] .gutter, .workbench[data-mode="editor"] .viz { display: none; }
1728
+ .workbench[data-mode="viewer"] .gutter, .workbench[data-mode="viewer"] .editor { display: none; }
1729
+ .workbench[data-mode="editor"] .editor, .workbench[data-mode="viewer"] .viz { flex: 1 1 0; }
1730
+
1731
+ /* panel-hidden workbench panes (driven by <fsl-toolbar> toggles): drop the
1732
+ hidden pane + the gutter and let the surviving pane fill. */
1733
+ .workbench.hide-viz .viz, .workbench.hide-viz .gutter,
1734
+ .workbench.hide-editor .editor, .workbench.hide-editor .gutter { display: none; }
1735
+ .workbench.hide-viz .editor, .workbench.hide-editor .viz { flex: 1 1 0; }
1736
+
1737
+ /* tabbed: a tab strip + one pane at a time */
1738
+ .workbench[data-mode="tabs"] .gutter { display: none; }
1739
+ .workbench[data-mode="tabs"] .pane { flex: 1 1 0; }
1740
+ .workbench[data-mode="tabs"] .pane[hidden] { display: none; }
1741
+ .tabbar { display: flex; gap: 3px; padding: 5px 6px 0; flex: 0 0 auto; border-bottom: 1px solid rgba(127,127,127,0.25); }
1742
+ .tabbar button {
1743
+ font: inherit; font-size: 12px; padding: 5px 14px; cursor: pointer; color: inherit;
1744
+ background: rgba(127,127,127,0.12); border: 1px solid rgba(127,127,127,0.25);
1745
+ border-bottom: none; border-radius: 6px 6px 0 0;
1746
+ }
1747
+ .tabbar button[aria-selected="true"] { background: var(--_fsl-surface, #fff); font-weight: 600; }
1220
1748
  `;
1221
1749
  /**
1222
1750
  * Library event names this WC re-emits as DOM `CustomEvent`s, fulfilling
@@ -1241,6 +1769,11 @@ FslInstance.REEMITTED_EVENTS = [
1241
1769
  */
1242
1770
  FslInstance.properties = {
1243
1771
  fsl: { type: String, reflect: false },
1772
+ layout: { type: String, reflect: true },
1773
+ theme: { type: String, reflect: true },
1774
+ themeName: { type: String, attribute: 'theme-name', reflect: true },
1775
+ themes: { type: Object, reflect: false },
1776
+ data: { type: Object, reflect: false },
1244
1777
  };
1245
1778
 
1246
- export { FslInstance, JSSM_ON_EVENT_NAMES, compile_inline_body, jssm_handler_registry, parse_jssm_on_element, resolve_fsl_source, resolve_named_handler };
1779
+ export { FslInstance, JSSM_ON_EVENT_NAMES, auto_mode, compile_inline_body, jssm_handler_registry, parse_jssm_on_element, resolve_fsl_source, resolve_named_handler, split_ratio };