@perspective-dev/viewer 5.2.0 → 5.3.1

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.
Files changed (179) hide show
  1. package/dist/cdn/perspective-viewer.js +8 -8
  2. package/dist/cdn/perspective-viewer.js.map +3 -3
  3. package/dist/css/blueprint.css +1 -0
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -0
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/de.css +1 -1
  11. package/dist/css/intl/es.css +1 -1
  12. package/dist/css/intl/fr.css +1 -1
  13. package/dist/css/intl/ja.css +1 -1
  14. package/dist/css/intl/pt.css +1 -1
  15. package/dist/css/intl/zh.css +1 -1
  16. package/dist/css/intl.css +1 -1
  17. package/dist/css/ledger.css +1 -0
  18. package/dist/css/monokai.css +1 -1
  19. package/dist/css/nord.css +1 -0
  20. package/dist/css/phosphor.css +1 -1
  21. package/dist/css/pro-dark.css +1 -1
  22. package/dist/css/pro.css +1 -1
  23. package/dist/css/solarized-dark.css +1 -1
  24. package/dist/css/solarized.css +1 -1
  25. package/dist/css/themes.css +1 -1
  26. package/dist/css/vaporwave.css +1 -1
  27. package/dist/css/velvet.css +1 -0
  28. package/dist/docs/perspective-docs.json +1 -1
  29. package/dist/esm/column-format.d.ts +16 -3
  30. package/dist/esm/perspective-viewer.d.ts +1 -0
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +8 -8
  34. package/dist/esm/perspective-viewer.js.map +3 -3
  35. package/dist/esm/plugin.d.ts +10 -0
  36. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  37. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  41. package/dist/wasm/perspective-viewer.d.ts +43 -36
  42. package/dist/wasm/perspective-viewer.js +198 -136
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  45. package/package.json +1 -1
  46. package/src/css/column-settings-panel.css +28 -5
  47. package/src/css/column-style.css +291 -31
  48. package/src/css/containers/tabs.css +13 -3
  49. package/src/css/dom/select.css +21 -9
  50. package/src/css/form/code-editor.css +4 -4
  51. package/src/css/plugin-selector.css +1 -0
  52. package/src/css/plugin-settings-panel.css +72 -0
  53. package/src/css/viewer.css +0 -5
  54. package/src/rust/agent/tools.rs +53 -19
  55. package/src/rust/components/column_selector/active_column.rs +2 -2
  56. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  57. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  58. package/src/rust/components/column_selector/config_selector.rs +8 -0
  59. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  60. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  61. package/src/rust/components/column_selector.rs +48 -4
  62. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  63. package/src/rust/components/column_settings_sidebar/style_tab.rs +330 -234
  64. package/src/rust/components/column_settings_sidebar.rs +88 -69
  65. package/src/rust/components/containers/control_group.rs +91 -0
  66. package/src/rust/components/containers/mod.rs +1 -0
  67. package/src/rust/components/containers/select.rs +8 -2
  68. package/src/rust/components/datetime_column_style/simple.rs +15 -4
  69. package/src/rust/components/datetime_column_style.rs +50 -10
  70. package/src/rust/components/editable_header.rs +85 -144
  71. package/src/rust/components/expression_editor.rs +9 -16
  72. package/src/rust/components/form/code_editor.rs +16 -6
  73. package/src/rust/components/form/debug.rs +1 -0
  74. package/src/rust/components/form/mod.rs +3 -1
  75. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  76. package/src/rust/components/form/named_value_picker.rs +86 -0
  77. package/src/rust/components/form/palette_selector.rs +440 -0
  78. package/src/rust/components/main_panel/msg.rs +2 -2
  79. package/src/rust/components/main_panel/reconcile.rs +4 -4
  80. package/src/rust/components/plugin_tab.rs +126 -112
  81. package/src/rust/components/settings_panel.rs +26 -20
  82. package/src/rust/components/status_bar.rs +0 -1
  83. package/src/rust/components/style_controls/number_string_format/digits_section.rs +22 -10
  84. package/src/rust/components/style_controls/number_string_format/misc_section.rs +6 -2
  85. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -0
  86. package/src/rust/components/style_controls/number_string_format/types.rs +24 -0
  87. package/src/rust/components/style_controls/number_string_format.rs +37 -14
  88. package/src/rust/components/viewer/msg.rs +46 -7
  89. package/src/rust/components/viewer/panels.rs +11 -9
  90. package/src/rust/components/viewer/render.rs +8 -3
  91. package/src/rust/components/viewer/settings.rs +163 -23
  92. package/src/rust/components/viewer/snapshots.rs +46 -21
  93. package/src/rust/components/viewer/wiring.rs +14 -25
  94. package/src/rust/components/viewer.rs +12 -8
  95. package/src/rust/config/column_config_schema.rs +435 -45
  96. package/src/rust/config/css_scale.rs +882 -0
  97. package/src/rust/config/datetime_column_style.rs +4 -21
  98. package/src/rust/config/mod.rs +2 -0
  99. package/src/rust/config/number_string_format.rs +211 -18
  100. package/src/rust/config/options.rs +10 -0
  101. package/src/rust/config/viewer_config.rs +49 -55
  102. package/src/rust/config/workspace_config.rs +132 -5
  103. package/src/rust/custom_elements/viewer.rs +214 -155
  104. package/src/rust/custom_events.rs +14 -1
  105. package/src/rust/js/regular_layout.rs +3 -3
  106. package/src/rust/lib.rs +2 -0
  107. package/src/rust/presentation/column_locator.rs +19 -5
  108. package/src/rust/presentation.rs +209 -36
  109. package/src/rust/queries/column_locator.rs +74 -44
  110. package/src/rust/queries/get_viewer_config.rs +0 -3
  111. package/src/rust/queries/mod.rs +2 -0
  112. package/src/rust/queries/palette_set.rs +82 -0
  113. package/src/rust/queries/plugin_column_styles.rs +6 -2
  114. package/src/rust/renderer/dispatch.rs +50 -12
  115. package/src/rust/renderer/plugin_config.rs +362 -53
  116. package/src/rust/renderer.rs +15 -42
  117. package/src/rust/session/column_defaults_update.rs +15 -8
  118. package/src/rust/session/metadata.rs +5 -1
  119. package/src/rust/session/props.rs +1 -0
  120. package/src/rust/session.rs +9 -6
  121. package/src/rust/tasks/create_panel.rs +44 -57
  122. package/src/rust/tasks/edit_expression.rs +19 -23
  123. package/src/rust/tasks/edit_window.rs +8 -10
  124. package/src/rust/tasks/eject.rs +4 -1
  125. package/src/rust/tasks/pipeline.rs +2 -2
  126. package/src/rust/tasks/presize_panels.rs +44 -5
  127. package/src/rust/tasks/reset_all.rs +4 -4
  128. package/src/rust/tasks/restore_and_render.rs +17 -44
  129. package/src/rust/tasks/restore_panel.rs +11 -13
  130. package/src/rust/tasks/set_edit_mode.rs +3 -1
  131. package/src/rust/tasks/sync_update_panels.rs +11 -1
  132. package/src/rust/tasks/update_theme.rs +23 -18
  133. package/src/rust/utils/css_vars.rs +26 -0
  134. package/src/rust/utils/mod.rs +2 -0
  135. package/src/rust/utils/spawn.rs +1 -1
  136. package/src/rust/workspace.rs +435 -272
  137. package/src/svg/lock.svg +7 -0
  138. package/src/themes/blueprint.css +190 -0
  139. package/src/themes/botanical.css +14 -17
  140. package/src/themes/defaults.css +4 -110
  141. package/src/themes/dracula.css +19 -14
  142. package/src/themes/eggplant.css +189 -0
  143. package/src/themes/gruvbox-dark.css +23 -15
  144. package/src/themes/gruvbox.css +11 -1
  145. package/src/themes/icons.css +2 -2
  146. package/src/themes/intl/de.css +23 -1
  147. package/src/themes/intl/es.css +23 -1
  148. package/src/themes/intl/fr.css +23 -1
  149. package/src/themes/intl/ja.css +23 -1
  150. package/src/themes/intl/pt.css +23 -1
  151. package/src/themes/intl/zh.css +23 -1
  152. package/src/themes/intl.css +26 -4
  153. package/src/themes/ledger.css +195 -0
  154. package/src/themes/monokai.css +24 -14
  155. package/src/themes/nord.css +186 -0
  156. package/src/themes/phosphor.css +7 -14
  157. package/src/themes/pro-dark.css +12 -16
  158. package/src/themes/pro.css +118 -0
  159. package/src/themes/solarized-dark.css +9 -16
  160. package/src/themes/solarized.css +7 -0
  161. package/src/themes/themes.css +10 -5
  162. package/src/themes/vaporwave.css +22 -14
  163. package/src/themes/velvet.css +192 -0
  164. package/src/ts/column-format.ts +52 -2
  165. package/src/ts/perspective-viewer.ts +1 -0
  166. package/src/ts/plugin.ts +14 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  168. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  169. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  170. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  171. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  172. package/src/rust/components/form/color_range_selector.rs +0 -137
  173. package/src/svg/dropdown-selector-light.svg +0 -3
  174. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline0.js +0 -0
  175. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline1.js +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline2.js +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline3.js +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline4.js +0 -0
  179. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline5.js +0 -0
@@ -142,19 +142,14 @@ pub struct RendererData {
142
142
  /// coexist there.
143
143
  slot_name: RefCell<Option<String>>,
144
144
 
145
- /// This panel's selected theme name (per-panel theming), or `None` to
146
- /// inherit the element-level (active) theme.
145
+ /// This panel's theme name — CONCRETE, resolved once at creation from
146
+ /// the config's `theme`, else the host's, else the registry default.
147
+ /// There is no "unthemed panel" that resolves against live element
148
+ /// state at draw time: a registry re-ordering must never repaint a
149
+ /// panel, so nothing but an explicit write may change this. `None` only
150
+ /// while no themes exist at all.
147
151
  theme: RefCell<Option<String>>,
148
152
 
149
- /// The registry default theme name (first registered), cached here so
150
- /// LOCKED draw paths can resolve this panel's EFFECTIVE theme
151
- /// synchronously (`Presentation::get_default_theme_name` is async — it
152
- /// awaits theme discovery). Seeded by every content-load path before its
153
- /// first locked draw (`restore_and_render`, `load()`, the
154
- /// resize-observer's deferred first render) and kept fresh by the root's
155
- /// `UpdatePresentation` default-theme fan-out and `resetThemes`.
156
- default_theme: RefCell<Option<String>>,
157
-
158
153
  /// Whether the active plugin has completed a draw. An EXPLICIT flag —
159
154
  /// not inferred from DOM connectedness — because plugin elements may be
160
155
  /// mounted eagerly (at panel creation / draw start, before the view
@@ -276,7 +271,6 @@ impl Renderer {
276
271
  on_render_limits_changed: Default::default(),
277
272
  slot_name: Default::default(),
278
273
  theme: Default::default(),
279
- default_theme: Default::default(),
280
274
  has_drawn: Cell::new(false),
281
275
  captured_theme: Default::default(),
282
276
  cached_context: Default::default(),
@@ -299,45 +293,24 @@ impl Renderer {
299
293
  self.0.slot_name.borrow().clone()
300
294
  }
301
295
 
302
- /// Set this panel's theme name (`None` = inherit the element-level theme).
296
+ /// Set this panel's theme name. Callers must pass a CONCRETE name —
297
+ /// resolve "the default" through `Presentation` before calling, never by
298
+ /// leaving this empty.
303
299
  pub fn set_theme(&self, name: Option<String>) {
304
300
  *self.0.theme.borrow_mut() = name;
305
301
  }
306
302
 
307
- /// This panel's selected theme name, if any (per-panel theming).
303
+ /// This panel's theme name.
308
304
  pub fn theme(&self) -> Option<String> {
309
305
  self.0.theme.borrow().clone()
310
306
  }
311
307
 
312
- /// [`Self::set_theme`] plus a synchronous [`Self::stamp_theme`] when the
313
- /// effective theme is resolvable now (a named theme needs no registry; a
314
- /// reset stamps only from a warm default cache — a cold one would stamp
315
- /// attribute-removal). The shared "stamp-with-commit" head of every
316
- /// per-panel theme mutation site.
308
+ /// [`Self::set_theme`] plus a synchronous [`Self::stamp_theme`] — the
309
+ /// shared "stamp-with-commit" head of every per-panel theme mutation
310
+ /// site.
317
311
  pub fn set_theme_stamped(&self, theme: Option<String>) {
318
- let stamp = theme.is_some() || self.default_theme().is_some();
319
312
  self.set_theme(theme);
320
- if stamp {
321
- self.stamp_theme(None);
322
- }
323
- }
324
-
325
- /// Set the cached registry default theme name (see the field docs on
326
- /// [`RendererData`]).
327
- pub fn set_default_theme(&self, name: Option<String>) {
328
- *self.0.default_theme.borrow_mut() = name;
329
- }
330
-
331
- /// The cached registry default theme name.
332
- pub fn default_theme(&self) -> Option<String> {
333
- self.0.default_theme.borrow().clone()
334
- }
335
-
336
- /// This panel's EFFECTIVE theme: its own ([`Self::theme`]), else the
337
- /// cached registry default ([`Self::default_theme`]) — the value
338
- /// [`Self::stamp_theme`] stamps.
339
- pub fn effective_theme(&self) -> Option<String> {
340
- self.theme().or_else(|| self.default_theme())
313
+ self.stamp_theme(None);
341
314
  }
342
315
 
343
316
  /// Whether the active plugin's captured `--psp-*` CSS is STALE — the
@@ -352,7 +325,7 @@ impl Renderer {
352
325
  /// paint captures fresh by construction ("stamp before draw").
353
326
  pub fn needs_restyle(&self) -> bool {
354
327
  match &*self.0.captured_theme.borrow() {
355
- Some(captured) => *captured != self.effective_theme(),
328
+ Some(captured) => *captured != self.theme(),
356
329
  None => false,
357
330
  }
358
331
  }
@@ -20,11 +20,16 @@ use crate::config::PluginStaticConfig;
20
20
 
21
21
  #[extend::ext]
22
22
  pub impl ViewConfigUpdate {
23
- /// Coerce this update's `group_rollup_mode` to one the plugin accepts
24
- /// (`PluginStaticConfig::group_rollup_modes`, feature-filtered). An
25
- /// absent mode counts as `Rollup` for the acceptance check, so a plugin
26
- /// that only renders `flat` (Treemap / Sunburst) gets it stamped even
27
- /// when the update never mentions rollup at all.
23
+ /// Coerce this update's `group_rollup_mode` / `split_rollup_mode` to one
24
+ /// the plugin accepts (`PluginStaticConfig::*_rollup_modes`,
25
+ /// feature-filtered). An absent mode counts as `current`'s committed mode
26
+ /// for the acceptance check — `ViewConfig::apply_update` KEEPS the
27
+ /// committed mode when the update's field is `None`, so acceptance must
28
+ /// be judged against the mode that will actually be in effect. A
29
+ /// hardcoded-default fallback here let a plugin swap silently retain a
30
+ /// mode the new plugin excludes (e.g. Datagrid's `split_rollup_mode:
31
+ /// "rollup"` surviving a `restore({plugin: "Y Bar"})` onto a flat-only
32
+ /// chart).
28
33
  ///
29
34
  /// Split out of [`Self::set_update_column_defaults`] so same-plugin
30
35
  /// restores can enforce the mode WITHOUT the column-defaulting below —
@@ -33,6 +38,7 @@ pub impl ViewConfigUpdate {
33
38
  fn set_update_rollup_defaults(
34
39
  &mut self,
35
40
  metadata: &SessionMetadata,
41
+ current: &ViewConfig,
36
42
  config_static: &PluginStaticConfig,
37
43
  ) {
38
44
  let rollup_features = metadata
@@ -44,7 +50,7 @@ pub impl ViewConfigUpdate {
44
50
  if !group_rollups.contains(
45
51
  self.group_rollup_mode
46
52
  .as_ref()
47
- .unwrap_or(&GroupRollupMode::Rollup),
53
+ .unwrap_or(&current.group_rollup_mode),
48
54
  ) {
49
55
  self.group_rollup_mode = group_rollups.first().cloned();
50
56
  tracing::debug!(
@@ -62,7 +68,7 @@ pub impl ViewConfigUpdate {
62
68
  if !split_rollups.contains(
63
69
  self.split_rollup_mode
64
70
  .as_ref()
65
- .unwrap_or(&SplitRollupMode::Flat),
71
+ .unwrap_or(&current.split_rollup_mode),
66
72
  ) {
67
73
  self.split_rollup_mode = split_rollups.first().cloned();
68
74
  tracing::debug!(
@@ -81,10 +87,11 @@ pub impl ViewConfigUpdate {
81
87
  fn set_update_column_defaults(
82
88
  &mut self,
83
89
  metadata: &SessionMetadata,
90
+ current: &ViewConfig,
84
91
  columns: &[Option<String>],
85
92
  config_static: &PluginStaticConfig,
86
93
  ) {
87
- self.set_update_rollup_defaults(metadata, config_static);
94
+ self.set_update_rollup_defaults(metadata, current, config_static);
88
95
 
89
96
  if let (None, Some(min_cols)) = (&self.columns, config_static.min_config_columns) {
90
97
  let names_len = config_static.config_column_names.len();
@@ -187,7 +187,7 @@ impl SessionMetadata {
187
187
  }
188
188
 
189
189
  pub fn get_expression_columns(&self) -> impl Iterator<Item = &'_ String> {
190
- try {
190
+ let mut columns = try {
191
191
  self.as_ref()?
192
192
  .expr_meta
193
193
  .as_ref()?
@@ -197,6 +197,10 @@ impl SessionMetadata {
197
197
  }
198
198
  .into_iter()
199
199
  .flatten()
200
+ .collect::<Vec<_>>();
201
+
202
+ columns.sort_unstable();
203
+ columns.into_iter()
200
204
  }
201
205
 
202
206
  /// Returns the full original expression `String` for an expression alias.
@@ -145,6 +145,7 @@ impl SessionProps {
145
145
  ) {
146
146
  config_update.set_update_column_defaults(
147
147
  &self.metadata,
148
+ &self.config,
148
149
  &self.all_columns().into_iter().map(Some).collect::<Vec<_>>(),
149
150
  config_static,
150
151
  )
@@ -719,9 +719,11 @@ impl Session {
719
719
  config_static: &PluginStaticConfig,
720
720
  ) {
721
721
  use self::column_defaults_update::*;
722
+ let config = self.get_view_config();
722
723
  config_update.set_update_column_defaults(
723
724
  &self.metadata(),
724
- &self.get_view_config().columns,
725
+ &config,
726
+ &config.columns,
725
727
  config_static,
726
728
  )
727
729
  }
@@ -737,7 +739,11 @@ impl Session {
737
739
  config_static: &PluginStaticConfig,
738
740
  ) {
739
741
  use self::column_defaults_update::*;
740
- config_update.set_update_rollup_defaults(&self.metadata(), config_static)
742
+ config_update.set_update_rollup_defaults(
743
+ &self.metadata(),
744
+ &self.get_view_config(),
745
+ config_static,
746
+ )
741
747
  }
742
748
 
743
749
  /// Apply a `ViewConfigUpdate` to the live config — the ONLY view-config
@@ -889,7 +895,7 @@ impl Session {
889
895
  .pending_dispatches
890
896
  .set(self.0.pending_dispatches.get() + 1);
891
897
  let session = self.clone();
892
- ApiFuture::spawn(async move {
898
+ ApiFuture::spawn_named("config-update-dispatch", async move {
893
899
  let result = fut.await;
894
900
  let remaining = session.0.pending_dispatches.get() - 1;
895
901
  session.0.pending_dispatches.set(remaining);
@@ -1165,9 +1171,6 @@ impl Session {
1165
1171
  let old = self.borrow_mut().view_sub.take();
1166
1172
  ApiFuture::spawn(old.delete());
1167
1173
  self.borrow_mut().view_sub = Some(sub);
1168
- // REBUILD: a `View` was genuinely constructed and bound — the one
1169
- // place `view_created` may fire, and the one place the `FreshView`
1170
- // full-draw witness is minted from a rebuild.
1171
1174
  self.view_created.emit(());
1172
1175
  self.commit_reconciled.emit(());
1173
1176
  match self.get_view() {
@@ -20,7 +20,7 @@ use crate::renderer::*;
20
20
  use crate::session::{ResetOptions, Session};
21
21
  use crate::tasks::*;
22
22
  use crate::utils::*;
23
- use crate::workspace::{Panel, PanelId, Workspace};
23
+ use crate::workspace::{Panel, PanelId, PanelPhase, Workspace};
24
24
  use crate::*;
25
25
 
26
26
  /// Build the full set of subscriptions a [`Panel`] owns for its lifetime: its
@@ -70,29 +70,19 @@ fn wire_panel_render_sub(session: &Session, renderer: &Renderer) -> Subscription
70
70
  }
71
71
 
72
72
  /// Create a new independent panel (own `Session` + `Renderer` + id) and
73
- /// restore `update` into it. Shared by `addPanel`, `restore`'s
74
- /// create-if-missing upsert, and `restorePanel`. `id` is the panel's id —
75
- /// provided when restoring into a specific named slot, or `None` to
76
- /// generate a fresh one. `theme` is stripped (element-level, not
77
- /// per-panel) and `client` — or the element's default client when `None` —
78
- /// is bound so the config's `table` resolves against it.
79
- ///
80
- /// A `table` is OPTIONAL here: an update with none creates a deferred
81
- /// panel that a later `load()` binds, which is the pre-existing
82
- /// `restore`-then-`load` contract and the same state the reserved
83
- /// `load()` path produces. The routes where a table-less panel WOULD be
84
- /// permanently blank require one in their own argument types instead —
85
- /// `addPanel`'s [`ViewerConfigInitial`], `WorkspaceConfigUpdate`'s
86
- /// `panels` entries, and the agent's `add_panel` — so the guarantee sits
87
- /// at the boundary that owns it rather than here, which would sweep
88
- /// `restore`'s patch in with them.
73
+ /// restore `config` into it. Shared by `addPanel` and `restore`'s
74
+ /// create-if-missing upsert. `id` is the panel's id — provided when
75
+ /// restoring into a specific named slot, or `None` to generate a fresh
76
+ /// one. `theme` is stripped (element-level, not per-panel) and `client` —
77
+ /// or the element's default client when `None` — is bound so the config's
78
+ /// `table` resolves against it.
89
79
  pub(crate) async fn create_panel(
90
80
  elem: &HtmlElement,
91
81
  presentation: &Presentation,
92
82
  workspace: &Workspace,
93
83
  notify: &Callback<()>,
94
84
  id: Option<PanelId>,
95
- update: ViewerConfigUpdate,
85
+ config: ViewerConfigInitial,
96
86
  client: Option<perspective_client::Client>,
97
87
  ) -> ApiResult<PanelId> {
98
88
  let (id, session, renderer, update) = create_panel_model(
@@ -100,36 +90,25 @@ pub(crate) async fn create_panel(
100
90
  presentation,
101
91
  workspace,
102
92
  id,
103
- update,
93
+ config.into(),
104
94
  client,
105
- Placement::Placed,
95
+ Placement::Staged,
106
96
  );
107
97
 
108
98
  stamp_global_overlay(workspace, &id, &session);
109
- // STAGE the panel (model-first): `MainPanel` renders its plugin slot
110
- // into a hidden pre-sized wrapper — not a layout cell — so the restore
111
- // below completes its first draw invisibly at (near-)final size. The
112
- // promote below then re-renders, and `MainPanel::reconcile` inserts an
113
- // ALREADY-DRAWN panel: the one layout transition reveals content,
114
- // never a blank frame.
115
- workspace.stage_panel(&id);
116
99
  notify.emit(());
117
100
 
118
- // The staging deadline: a slow restore promotes EARLY (placed, still
119
- // loading — the pre-staging progressive reveal) rather than leaving
120
- // the layout without feedback. Races the completion promote below on
121
- // the staged flag; only the winner re-renders.
122
- {
101
+ ApiFuture::spawn({
123
102
  clone!(workspace, id, notify);
124
- ApiFuture::spawn(async move {
103
+ async move {
125
104
  set_timeout(STAGING_DEADLINE_MS).await?;
126
- if workspace.clear_staged(&id) {
105
+ if workspace.promote(&id) {
127
106
  notify.emit(());
128
107
  }
129
108
 
130
109
  Ok(())
131
- });
132
- }
110
+ }
111
+ });
133
112
 
134
113
  // A fresh panel is never the active one, so it needs no `root` for the
135
114
  // (active-only) settings-sidebar sequencing.
@@ -146,7 +125,7 @@ pub(crate) async fn create_panel(
146
125
 
147
126
  // Promote on completion AND error — a failed restore's error state
148
127
  // must become visible too.
149
- if workspace.clear_staged(&id) {
128
+ if workspace.promote(&id) {
150
129
  notify.emit(());
151
130
  }
152
131
 
@@ -156,10 +135,17 @@ pub(crate) async fn create_panel(
156
135
 
157
136
  /// Where [`create_panel_model`] registers the new panel model.
158
137
  pub(crate) enum Placement {
159
- /// Into the placed panel set ([`Workspace::insert_panel`]) — a live,
160
- /// visible panel from the start.
138
+ /// Into the placed panel set at [`PanelPhase::Placed`] — a live, visible
139
+ /// panel from the start (`restoreWorkspace` mounts panels directly at
140
+ /// their saved layout positions).
161
141
  Placed,
162
142
 
143
+ /// Into the placed panel set at [`PanelPhase::Staging`] — withheld from
144
+ /// the layout while its first draw completes in the hidden staging
145
+ /// wrapper, then promoted ([`Workspace::promote`]) by restore completion
146
+ /// or the [`STAGING_DEADLINE_MS`] deadline, whichever wins.
147
+ Staged,
148
+
163
149
  /// Into the reservation slot ([`Workspace::reserve_panel`]) — a pending
164
150
  /// `load()`'s first panel, held out of the placed set until it is
165
151
  /// claimed ([`place_reserved`]) or discarded.
@@ -180,32 +166,23 @@ pub(crate) fn create_panel_model(
180
166
  client: Option<perspective_client::Client>,
181
167
  placement: Placement,
182
168
  ) -> (PanelId, Session, Renderer, ViewerConfigUpdate) {
183
- // Authored-theme boot: the FIRST panel of an empty element with no explicit
184
- // theme adopts the element's `theme` attribute (a one-time initial
185
- // selection; the attribute is otherwise an active-panel mirror). Captured
186
- // before `insert_panel` makes the workspace non-empty.
187
- let boot_theme = (workspace.is_empty() && matches!(update.theme, OptionalUpdate::Missing))
188
- .then(|| elem.get_attribute("theme"))
189
- .flatten();
190
-
191
169
  let session = Session::new();
192
170
  let renderer = Renderer::new(elem);
193
171
  let id = id.unwrap_or_else(|| workspace.generate_id());
194
172
  renderer.set_slot_name(id.as_str());
195
- renderer.set_default_theme(presentation.default_theme_name_sync());
196
173
  let subs = wire_panel_subs(elem, presentation, &session, &renderer);
197
174
  let panel = Panel::new(id.clone(), session.clone(), renderer.clone(), subs);
198
175
  match placement {
199
- Placement::Placed => workspace.insert_panel(panel),
176
+ Placement::Placed => workspace.insert_panel(panel, PanelPhase::Placed),
177
+ Placement::Staged => workspace.insert_panel(panel, PanelPhase::Staging),
200
178
  Placement::Reserved => workspace.reserve_panel(panel),
201
179
  }
202
180
 
203
181
  update.settings = OptionalUpdate::Missing;
204
- if let OptionalUpdate::Update(theme) = &update.theme {
205
- renderer.set_theme(Some(theme.clone()));
206
- } else if let Some(theme) = boot_theme {
207
- renderer.set_theme(Some(theme));
208
- }
182
+ renderer.set_theme(match &update.theme {
183
+ OptionalUpdate::Update(theme) => Some(theme.clone()),
184
+ _ => presentation.active_theme_name_sync(),
185
+ });
209
186
 
210
187
  update.theme = OptionalUpdate::Missing;
211
188
  if let Some(client) = client.or_else(|| workspace.default_client()) {
@@ -221,8 +198,18 @@ pub(crate) fn create_panel_model(
221
198
  (id, session, renderer, update)
222
199
  }
223
200
 
224
- pub(crate) fn place_reserved(workspace: &Workspace, notify: &Callback<()>) -> Option<Panel> {
225
- let panel = workspace.claim_reserved()?;
201
+ /// Claim the pending `load()` reservation into the placed set
202
+ /// ([`Workspace::claim_reserved`]), stamping the global-filter overlay and
203
+ /// notifying the root. `has_table` is whether the claimant carried a
204
+ /// `table`, recorded atomically with the claim — pass `true` when `load()`
205
+ /// itself places its reservation (a `Table` payload, or error surfacing),
206
+ /// which is not a restore claim and must never arm the epilogue's eviction.
207
+ pub(crate) fn place_reserved(
208
+ workspace: &Workspace,
209
+ notify: &Callback<()>,
210
+ has_table: bool,
211
+ ) -> Option<Panel> {
212
+ let panel = workspace.claim_reserved(has_table)?;
226
213
  stamp_global_overlay(workspace, &panel.id, &panel.session);
227
214
  notify.emit(());
228
215
  Some(panel)
@@ -230,7 +217,7 @@ pub(crate) fn place_reserved(workspace: &Workspace, notify: &Callback<()>) -> Op
230
217
 
231
218
  /// Tear down a [`Panel`] already removed from the [`Workspace`]: dispose its
232
219
  /// renderer (slot-scoped plugin + light-DOM cleanup) and eject its table.
233
- /// Shared by the root's `ClosePanel` handler and whole-element `restore`'s
220
+ /// Shared by the root's `ClosePanel` handler and `restoreWorkspace`'s
234
221
  /// batch replacement of the pre-existing panel set.
235
222
  pub(crate) fn eject_panel(panel: Panel) -> ApiFuture<()> {
236
223
  let was_errored = panel.session.is_errored();
@@ -18,14 +18,22 @@ use perspective_client::clone;
18
18
  use perspective_client::config::{Expression, ViewConfigUpdate};
19
19
 
20
20
  use super::apply_and_render;
21
- use crate::presentation::{ColumnLocator, ColumnSettingsTab, OpenColumnSettings, Presentation};
21
+ use crate::presentation::{
22
+ ColumnSettingsTab, ColumnSettingsTarget, OpenColumnSettings, Presentation,
23
+ };
22
24
  use crate::renderer::Renderer;
23
25
  use crate::session::Session;
24
26
  use crate::*;
25
27
 
26
- /// Replace the expression at `old_name` with `new_expr` and re-render. After
27
- /// the render completes, opens the column-settings drawer on the renamed
28
- /// expression.
28
+ fn reopen(presentation: &Presentation, name: String) {
29
+ presentation.set_open_column_settings(Some(OpenColumnSettings {
30
+ target: Some(ColumnSettingsTarget::Column(name)),
31
+ tab: Some(ColumnSettingsTab::Attributes),
32
+ }));
33
+ }
34
+
35
+ /// Replace the expression at `old_name` with `new_expr` and re-render,
36
+ /// moving the column-settings drawer onto the renamed expression.
29
37
  pub fn update_expr(
30
38
  session: &Session,
31
39
  renderer: &Renderer,
@@ -39,25 +47,21 @@ pub fn update_expr(
39
47
  .to_props()
40
48
  .create_replace_expression_update(&old_name, &new_expr);
41
49
 
42
- apply_and_render(&session, &renderer, update)?.await?;
43
- presentation.set_open_column_settings(Some(OpenColumnSettings {
44
- locator: Some(ColumnLocator::Expression(new_expr.name.to_string())),
45
- tab: Some(ColumnSettingsTab::Attributes),
46
- }));
47
-
50
+ let run = apply_and_render(&session, &renderer, update)?;
51
+ reopen(&presentation, new_expr.name.to_string());
52
+ run.await?;
48
53
  Ok(())
49
54
  });
50
55
  }
51
56
 
52
- /// Insert `expr` into the session's expressions and re-render. After the
53
- /// render completes, opens the column-settings drawer on the new expression.
57
+ /// Insert `expr` into the session's expressions and re-render, moving the
58
+ /// column-settings drawer onto the new expression.
54
59
  pub fn save_expr(
55
60
  session: &Session,
56
61
  renderer: &Renderer,
57
62
  presentation: &Presentation,
58
63
  expr: Expression,
59
64
  ) -> ApiResult<()> {
60
- let presentation = presentation.clone();
61
65
  let expr_name: String = expr.name.clone().into();
62
66
  let task = {
63
67
  let mut serde_exprs = session.get_view_config().expressions.clone();
@@ -68,16 +72,8 @@ pub fn save_expr(
68
72
  })
69
73
  }?;
70
74
 
71
- ApiFuture::spawn(async move {
72
- task.await?;
73
- presentation.set_open_column_settings(Some(OpenColumnSettings {
74
- locator: Some(ColumnLocator::Expression(expr_name)),
75
- tab: Some(ColumnSettingsTab::Attributes),
76
- }));
77
-
78
- Ok(())
79
- });
80
-
75
+ reopen(presentation, expr_name);
76
+ ApiFuture::spawn(task);
81
77
  Ok(())
82
78
  }
83
79
 
@@ -14,14 +14,16 @@ use perspective_client::clone;
14
14
  use perspective_client::config::{Filter, ViewConfigUpdate, WindowSpec};
15
15
 
16
16
  use super::apply_and_render;
17
- use crate::presentation::{ColumnLocator, ColumnSettingsTab, OpenColumnSettings, Presentation};
17
+ use crate::presentation::{
18
+ ColumnSettingsTab, ColumnSettingsTarget, OpenColumnSettings, Presentation,
19
+ };
18
20
  use crate::renderer::Renderer;
19
21
  use crate::session::Session;
20
22
  use crate::*;
21
23
 
22
24
  fn reopen(presentation: &Presentation, name: String) {
23
25
  presentation.set_open_column_settings(Some(OpenColumnSettings {
24
- locator: Some(ColumnLocator::Window(name)),
26
+ target: Some(ColumnSettingsTarget::Column(name)),
25
27
  tab: Some(ColumnSettingsTab::Window),
26
28
  }));
27
29
  }
@@ -36,7 +38,6 @@ pub fn save_window(
36
38
  name: String,
37
39
  spec: WindowSpec,
38
40
  ) -> ApiResult<()> {
39
- let presentation = presentation.clone();
40
41
  let task = {
41
42
  let mut windows = session.get_view_config().windows.clone();
42
43
  windows.insert(name.clone(), spec);
@@ -46,12 +47,8 @@ pub fn save_window(
46
47
  })
47
48
  }?;
48
49
 
49
- ApiFuture::spawn(async move {
50
- task.await?;
51
- reopen(&presentation, name);
52
- Ok(())
53
- });
54
-
50
+ reopen(presentation, name);
51
+ ApiFuture::spawn(task);
55
52
  Ok(())
56
53
  }
57
54
 
@@ -134,8 +131,9 @@ pub fn update_window(
134
131
  update
135
132
  };
136
133
 
137
- apply_and_render(&session, &renderer, update)?.await?;
134
+ let run = apply_and_render(&session, &renderer, update)?;
138
135
  reopen(&presentation, new_name);
136
+ run.await?;
139
137
  Ok(())
140
138
  });
141
139
  }
@@ -23,11 +23,14 @@ use crate::workspace::Workspace;
23
23
  /// Tear down the entire viewer: dispose EVERY panel's engines (emit
24
24
  /// `table_unloaded`, destroy the renderer under its own draw lock, eject the
25
25
  /// session), then drop the Yew root once. Fans out over all panels — not just
26
- /// the seed — so panels added via `addPanel` or a whole-element `restore` don't
26
+ /// the seed — so panels added via `addPanel` or `restoreWorkspace` don't
27
27
  /// leak their `View`/`Table` + plugin when the element is deleted or ejected.
28
28
  pub fn delete_all<T: Component>(workspace: &Workspace, root: &Root<T>) -> ApiFuture<()> {
29
29
  clone!(workspace, root);
30
30
  ApiFuture::new(async move {
31
+ // A staged-but-unapplied `restoreWorkspace` layout tree must not
32
+ // outlive the panels it names.
33
+ workspace.take_pending_layout();
31
34
  let panels = workspace
32
35
  .take_reserved()
33
36
  .into_iter()
@@ -285,12 +285,12 @@ pub(crate) async fn locked_run(
285
285
 
286
286
  let view_config_snapshot = session.get_view_config().clone();
287
287
  let plugin_config_changed =
288
- renderer.update_plugin_config(&view_config_snapshot, spec.plugin_config);
288
+ renderer.update_plugin_config(&view_config_snapshot, spec.plugin_config)?;
289
289
  let columns_config_changed = renderer.update_columns_configs(
290
290
  &view_config_snapshot,
291
291
  &session,
292
292
  spec.columns_config,
293
- );
293
+ )?;
294
294
 
295
295
  let changed = plugin_config_changed || columns_config_changed;
296
296
  if changed || plugin_swapped {
@@ -131,11 +131,14 @@ pub async fn presize_visible_panels_grown(
131
131
  presize_visible_panels_scaled(workspace, elem, width_ratio, height_ratio).await
132
132
  }
133
133
 
134
- /// Pre-size each visible plugin to its SHRUNK post-open cell *before* the
135
- /// settings pane mounts (P2), using the open-state geometry deltas cached at
136
- /// the last close (`(layout_area.w − mpc.w, main_column.h − mpc.h)` — the pane
137
- /// and divider width and the docked status-bar height, both stable across a
138
- /// close/open cycle since the pane width persists in the override).
134
+ /// Pre-size each visible plugin to its cell SHRUNK by `(delta_w, delta_h)` —
135
+ /// GROWN when a delta is negative — *before* the layout change commits. Used
136
+ /// by the settings-pane open (P2, deltas `(layout_area.w − mpc.w,
137
+ /// main_column.h − mpc.h)` cached at the last close — the pane and divider
138
+ /// width and the docked status-bar height, both stable across a close/open
139
+ /// cycle since the pane width persists in the override) and by the
140
+ /// column-settings pin toggle and docked-drawer mount/unmount (signed delta
141
+ /// measured live at toggle time).
139
142
  pub async fn presize_visible_panels_open(
140
143
  workspace: &Workspace,
141
144
  elem: &web_sys::HtmlElement,
@@ -199,6 +202,7 @@ async fn presize_visible_panels_scaled(
199
202
  return StagedPresents::default();
200
203
  }
201
204
 
205
+ let mpc = shadow_rect(elem, "#main_panel_container");
202
206
  let mut last_chrome: Option<(f64, f64)> = None;
203
207
  let targets = workspace
204
208
  .panels()
@@ -222,6 +226,23 @@ async fn presize_visible_panels_scaled(
222
226
 
223
227
  last_chrome = chrome.or(last_chrome);
224
228
  let (cw, ch) = last_chrome.unwrap_or(super::CHROME_FALLBACK);
229
+
230
+ // Per axis, chrome can never exceed the container's
231
+ // non-plugin remainder: with no sibling panel on that axis,
232
+ // `mpc − box` IS the exact chrome, while the frame-margin
233
+ // measurement overcounts (margins that overlap container
234
+ // padding) — the source of a ~2px target miss that pushed
235
+ // every commit past the plugin transport's ±0.5px resize
236
+ // dedupe, costing an extra corrective render. With siblings
237
+ // the remainder is large and the measured chrome wins.
238
+ let (cw, ch) = match &mpc {
239
+ Some(mpc) => (
240
+ cw.min((mpc.width() - plugin_box.width()).max(0.0)),
241
+ ch.min((mpc.height() - plugin_box.height()).max(0.0)),
242
+ ),
243
+ None => (cw, ch),
244
+ };
245
+
225
246
  let w = ((plugin_box.width() + cw) * width_ratio - cw).max(0.0);
226
247
  let h = ((plugin_box.height() + ch) * height_ratio - ch).max(0.0);
227
248
  Some((panel, w, h))
@@ -300,6 +321,24 @@ pub fn measure_settings_open_deltas(elem: &web_sys::HtmlElement) -> Option<(f64,
300
321
  ))
301
322
  }
302
323
 
324
+ /// The signed `#main_panel_container` width delta a column-settings pin
325
+ /// toggle will cause: the docked `#modal_panel`'s flex box spans its drawer
326
+ /// child plus the overlapping divider (`drawer.right − divider.left`, the
327
+ /// same span in both modes since `.pinned` reparents nothing) — PINNING
328
+ /// takes it from the main column (positive, shrink), UNPINNING releases it
329
+ /// back (negative, grow). `None` when the drawer isn't mounted.
330
+ pub fn measure_column_settings_pin_delta(
331
+ elem: &web_sys::HtmlElement,
332
+ is_pinned: bool,
333
+ ) -> Option<f64> {
334
+ let drawer = shadow_rect(elem, "#modal_panel > .split-panel-child:first-child")?;
335
+ let divider_left = shadow_rect(elem, "#modal_panel > .split-panel-divider")
336
+ .map(|r| r.left())
337
+ .unwrap_or_else(|| drawer.left());
338
+ let width = (drawer.right() - divider_left).max(0.0);
339
+ Some(if is_pinned { -width } else { width })
340
+ }
341
+
303
342
  /// The frame chrome a plugin's grid cell has that the plugin itself doesn't
304
343
  /// occupy, as `(width, height)` px: margin + border + titlebar. Measured live
305
344
  /// from a `regular-layout-frame` and its plugin — robust to theme/CSS changes —