@perspective-dev/viewer 5.2.0 → 5.3.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.
Files changed (133) hide show
  1. package/dist/cdn/perspective-viewer.js +2 -2
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/monokai.css +1 -1
  9. package/dist/css/phosphor.css +1 -1
  10. package/dist/css/pro-dark.css +1 -1
  11. package/dist/css/pro.css +1 -1
  12. package/dist/css/solarized-dark.css +1 -1
  13. package/dist/css/solarized.css +1 -1
  14. package/dist/css/themes.css +1 -1
  15. package/dist/css/vaporwave.css +1 -1
  16. package/dist/docs/perspective-docs.json +1 -1
  17. package/dist/esm/perspective-viewer.d.ts +1 -0
  18. package/dist/esm/perspective-viewer.inline.js +9 -9
  19. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  20. package/dist/esm/perspective-viewer.js +4 -4
  21. package/dist/esm/perspective-viewer.js.map +2 -2
  22. package/dist/esm/plugin.d.ts +10 -0
  23. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  24. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  25. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  26. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  27. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  28. package/dist/wasm/perspective-viewer.d.ts +43 -36
  29. package/dist/wasm/perspective-viewer.js +194 -136
  30. package/dist/wasm/perspective-viewer.wasm +0 -0
  31. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  32. package/package.json +1 -1
  33. package/src/css/column-settings-panel.css +9 -5
  34. package/src/css/column-style.css +209 -31
  35. package/src/css/dom/select.css +2 -1
  36. package/src/css/form/code-editor.css +4 -4
  37. package/src/rust/agent/tools.rs +48 -17
  38. package/src/rust/components/column_selector/active_column.rs +2 -2
  39. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  40. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  41. package/src/rust/components/column_selector/config_selector.rs +8 -0
  42. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  43. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  44. package/src/rust/components/column_selector.rs +48 -4
  45. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  46. package/src/rust/components/column_settings_sidebar/style_tab.rs +262 -225
  47. package/src/rust/components/column_settings_sidebar.rs +88 -69
  48. package/src/rust/components/editable_header.rs +85 -144
  49. package/src/rust/components/expression_editor.rs +9 -16
  50. package/src/rust/components/form/code_editor.rs +16 -6
  51. package/src/rust/components/form/debug.rs +1 -0
  52. package/src/rust/components/form/mod.rs +3 -1
  53. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  54. package/src/rust/components/form/named_value_picker.rs +86 -0
  55. package/src/rust/components/form/palette_selector.rs +440 -0
  56. package/src/rust/components/main_panel/msg.rs +2 -2
  57. package/src/rust/components/main_panel/reconcile.rs +4 -4
  58. package/src/rust/components/plugin_tab.rs +4 -28
  59. package/src/rust/components/settings_panel.rs +25 -20
  60. package/src/rust/components/viewer/msg.rs +46 -7
  61. package/src/rust/components/viewer/panels.rs +11 -9
  62. package/src/rust/components/viewer/render.rs +8 -3
  63. package/src/rust/components/viewer/settings.rs +163 -23
  64. package/src/rust/components/viewer/snapshots.rs +46 -21
  65. package/src/rust/components/viewer/wiring.rs +14 -25
  66. package/src/rust/components/viewer.rs +12 -8
  67. package/src/rust/config/column_config_schema.rs +100 -41
  68. package/src/rust/config/css_scale.rs +882 -0
  69. package/src/rust/config/mod.rs +2 -0
  70. package/src/rust/config/options.rs +10 -0
  71. package/src/rust/config/viewer_config.rs +49 -55
  72. package/src/rust/config/workspace_config.rs +132 -5
  73. package/src/rust/custom_elements/viewer.rs +214 -155
  74. package/src/rust/custom_events.rs +14 -1
  75. package/src/rust/js/regular_layout.rs +3 -3
  76. package/src/rust/lib.rs +2 -0
  77. package/src/rust/presentation/column_locator.rs +19 -5
  78. package/src/rust/presentation.rs +192 -36
  79. package/src/rust/queries/column_locator.rs +74 -44
  80. package/src/rust/queries/get_viewer_config.rs +0 -3
  81. package/src/rust/queries/mod.rs +2 -0
  82. package/src/rust/queries/palette_set.rs +82 -0
  83. package/src/rust/queries/plugin_column_styles.rs +6 -2
  84. package/src/rust/renderer/dispatch.rs +16 -4
  85. package/src/rust/renderer/plugin_config.rs +309 -47
  86. package/src/rust/renderer.rs +15 -42
  87. package/src/rust/session/column_defaults_update.rs +15 -8
  88. package/src/rust/session/metadata.rs +5 -1
  89. package/src/rust/session/props.rs +1 -0
  90. package/src/rust/session.rs +9 -6
  91. package/src/rust/tasks/create_panel.rs +44 -57
  92. package/src/rust/tasks/edit_expression.rs +19 -23
  93. package/src/rust/tasks/edit_window.rs +8 -10
  94. package/src/rust/tasks/eject.rs +4 -1
  95. package/src/rust/tasks/pipeline.rs +2 -2
  96. package/src/rust/tasks/presize_panels.rs +44 -5
  97. package/src/rust/tasks/reset_all.rs +4 -4
  98. package/src/rust/tasks/restore_and_render.rs +17 -44
  99. package/src/rust/tasks/restore_panel.rs +11 -13
  100. package/src/rust/tasks/set_edit_mode.rs +3 -1
  101. package/src/rust/tasks/sync_update_panels.rs +11 -1
  102. package/src/rust/tasks/update_theme.rs +23 -18
  103. package/src/rust/utils/css_vars.rs +26 -0
  104. package/src/rust/utils/mod.rs +2 -0
  105. package/src/rust/utils/spawn.rs +1 -1
  106. package/src/rust/workspace.rs +435 -272
  107. package/src/svg/lock.svg +7 -0
  108. package/src/themes/botanical.css +10 -0
  109. package/src/themes/defaults.css +12 -0
  110. package/src/themes/dracula.css +17 -0
  111. package/src/themes/gruvbox-dark.css +20 -0
  112. package/src/themes/gruvbox.css +10 -0
  113. package/src/themes/icons.css +1 -0
  114. package/src/themes/monokai.css +22 -0
  115. package/src/themes/phosphor.css +5 -0
  116. package/src/themes/pro-dark.css +10 -0
  117. package/src/themes/solarized-dark.css +7 -0
  118. package/src/themes/solarized.css +7 -0
  119. package/src/themes/vaporwave.css +20 -0
  120. package/src/ts/perspective-viewer.ts +1 -0
  121. package/src/ts/plugin.ts +14 -0
  122. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  123. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  124. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  125. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  126. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  127. package/src/rust/components/form/color_range_selector.rs +0 -137
  128. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline0.js +0 -0
  129. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline1.js +0 -0
  130. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline2.js +0 -0
  131. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline3.js +0 -0
  132. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline4.js +0 -0
  133. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline5.js +0 -0
@@ -49,7 +49,7 @@ impl Renderer {
49
49
  let active_plugin = self.active_plugin();
50
50
  if let Some(plugin) = plugin.or(active_plugin.as_ref()) {
51
51
  let theme_elem = plugin.unchecked_ref::<HtmlElement>();
52
- match self.effective_theme() {
52
+ match self.theme() {
53
53
  Some(theme)
54
54
  if theme_elem.get_attribute("theme").as_deref() != Some(theme.as_str()) =>
55
55
  {
@@ -73,7 +73,7 @@ impl Renderer {
73
73
  let _pin = self.pin_context(&guard, ctx.clone());
74
74
  let plugin = self.ensure_plugin_selected()?;
75
75
  let meta = self.metadata();
76
- let stamped_theme = self.effective_theme();
76
+ let stamped_theme = self.theme();
77
77
  self.stamp_theme(Some(&plugin));
78
78
  self.stamp_active(&plugin);
79
79
  plugin.restyle();
@@ -134,7 +134,19 @@ impl Renderer {
134
134
 
135
135
  pub async fn resize(&self) -> ApiResult<()> {
136
136
  self.0.geometry_cmd.set(Some(GeometryCmd::Measure));
137
- self.geometry_task().await.map(|_| ())
137
+
138
+ // This caller queued a `Measure`, but the parked run it resolves
139
+ // through executes the LATEST `geometry_cmd` — which a concurrent
140
+ // presize sweep may have swapped to a `Presize` whose sweep
141
+ // coalesced (and so received `None`). Any present closure returned
142
+ // here is therefore held by no one else: invoke it, or the
143
+ // transport's present-hold leaks and the plot freezes on its
144
+ // last-blitted frame.
145
+ if let Some(present) = self.geometry_task().await? {
146
+ present.call0(&JsValue::NULL)?;
147
+ }
148
+
149
+ Ok(())
138
150
  }
139
151
 
140
152
  /// Public one-shot resize to explicit dimensions (`viewer.resize({
@@ -416,7 +428,7 @@ impl Renderer {
416
428
  let viewer_elem = self.0.borrow().viewer_elem.clone();
417
429
  let slot = self.slot_name();
418
430
  let first_paint = !self.0.has_drawn.get();
419
- let stamped_theme = self.effective_theme();
431
+ let stamped_theme = self.theme();
420
432
  self.stamp_active(&plugin);
421
433
  self.stamp_theme(Some(&plugin));
422
434
  if self.needs_restyle() {
@@ -18,7 +18,7 @@ use std::collections::HashMap;
18
18
 
19
19
  use futures::future::join_all;
20
20
  use perspective_client::config::ViewConfig;
21
- use perspective_js::utils::{ApiResult, JsValueSerdeExt};
21
+ use perspective_js::utils::{ApiError, ApiResult, JsValueSerdeExt};
22
22
  use serde_json::Value;
23
23
  use wasm_bindgen::prelude::*;
24
24
 
@@ -63,6 +63,50 @@ impl Renderer {
63
63
  .unwrap_or_default()
64
64
  }
65
65
 
66
+ fn resolve_css_refs_in(&self, column: &str, entry: &mut serde_json::Map<String, Value>) {
67
+ let host = self.borrow().viewer_elem.clone();
68
+ let lookup = |name: &str| crate::utils::read_custom_property(&host, name);
69
+ for (key, name) in resolve_css_refs(entry, &lookup) {
70
+ tracing::warn!(
71
+ "Dropping `columns_config[\"{column}\"].{key}`: `var({name})` is undefined or not \
72
+ a valid value of its kind"
73
+ );
74
+ }
75
+ }
76
+
77
+ /// Every CSS literal stored in the active plugin's per-column bucket
78
+ /// with its schema kind, sorted by `(column, key)`.
79
+ pub fn css_literals_in_use(
80
+ &self,
81
+ view_config: &ViewConfig,
82
+ session: &Session,
83
+ ) -> Vec<CssLiteralUse> {
84
+ let mut out = vec![];
85
+ for (column, entry) in self.all_columns_configs() {
86
+ let Ok(schema) =
87
+ self.query_column_config_schema(view_config, session, &column, Some(&entry))
88
+ else {
89
+ continue;
90
+ };
91
+
92
+ for (key, value) in &entry {
93
+ if let (Some(kind), Some(literal)) = (schema.css_kind_of(key), value.as_str())
94
+ && parse_var_ref(literal).is_none()
95
+ {
96
+ out.push(CssLiteralUse {
97
+ column: column.clone(),
98
+ key: key.clone(),
99
+ kind,
100
+ literal: literal.to_owned(),
101
+ });
102
+ }
103
+ }
104
+ }
105
+
106
+ out.sort_by(|a, b| (&a.column, &a.key).cmp(&(&b.column, &b.key)));
107
+ out
108
+ }
109
+
66
110
  /// Restore-prep snapshot: like [`Self::all_columns_configs`], but
67
111
  /// for each column also materializes any `ControlSpec::Number`
68
112
  /// fields the schema declares with `include: true` that aren't
@@ -173,15 +217,15 @@ impl Renderer {
173
217
  .cloned()
174
218
  }
175
219
 
176
- /// Wholesale update the active plugin's per-column config map
220
+ /// Wholesale update the active plugin's per-column config map.
177
221
  pub fn update_columns_configs(
178
222
  &self,
179
223
  view_config: &ViewConfig,
180
224
  session: &Session,
181
225
  update: ColumnConfigUpdate,
182
- ) -> bool {
226
+ ) -> ApiResult<bool> {
183
227
  let Some(n) = self.active_plugin_name() else {
184
- return false;
228
+ return Ok(false);
185
229
  };
186
230
 
187
231
  match update {
@@ -190,24 +234,31 @@ impl Renderer {
190
234
  let bucket = st.plugin_states.entry(n).or_default();
191
235
  let was_nonempty = !bucket.columns.is_empty();
192
236
  bucket.columns.clear();
193
- was_nonempty
237
+ Ok(was_nonempty)
194
238
  },
195
- OptionalUpdate::Missing => false,
239
+ OptionalUpdate::Missing => Ok(false),
196
240
  OptionalUpdate::Update(map) => {
197
- let stripped: Vec<(String, serde_json::Map<String, serde_json::Value>)> = map
198
- .into_iter()
199
- .map(|(col, mut cfg)| {
200
- if let Ok(schema) =
201
- self.query_column_config_schema(view_config, session, &col, Some(&cfg))
202
- {
203
- let active = schema.active_keys();
204
- cfg.retain(|k, _| active.contains(k));
205
- strip_default_values(&schema, &mut cfg);
241
+ let mut stripped: Vec<(String, serde_json::Map<String, serde_json::Value>)> =
242
+ Vec::with_capacity(map.len());
243
+ for (col, mut cfg) in map {
244
+ if let Ok(schema) =
245
+ self.query_column_config_schema(view_config, session, &col, Some(&cfg))
246
+ {
247
+ let active = schema.active_keys();
248
+ cfg.retain(|k, _| active.contains(k));
249
+ let errors = normalize_css_values(&schema, &mut cfg);
250
+ if let Some((key, error)) = errors.first() {
251
+ return Err(ApiError::from(JsValue::from_str(&format!(
252
+ "Invalid `columns_config[\"{col}\"].{key}`: {error}"
253
+ ))));
206
254
  }
207
255
 
208
- (col, cfg)
209
- })
210
- .collect();
256
+ self.resolve_css_refs_in(&col, &mut cfg);
257
+ strip_default_values(&schema, &mut cfg);
258
+ }
259
+
260
+ stripped.push((col, cfg));
261
+ }
211
262
 
212
263
  let mut st = self.borrow_mut();
213
264
  let bucket = st.plugin_states.entry(n).or_default();
@@ -226,7 +277,7 @@ impl Renderer {
226
277
  }
227
278
  }
228
279
 
229
- changed
280
+ Ok(changed)
230
281
  },
231
282
  }
232
283
  }
@@ -253,22 +304,45 @@ impl Renderer {
253
304
  &column_name,
254
305
  current_value.as_ref(),
255
306
  ) {
307
+ for (key, error) in normalize_css_values(&schema, &mut update.value) {
308
+ tracing::error!("Dropping `{column_name}`.`{key}`: {error}");
309
+ }
310
+
311
+ self.resolve_css_refs_in(&column_name, &mut update.value);
256
312
  strip_default_values(&schema, &mut update.value);
257
313
  }
258
314
 
315
+ let next = {
316
+ let mut st = self.borrow_mut();
317
+ let bucket = st.plugin_states.entry(n.clone()).or_default();
318
+ let entry = bucket.columns.entry(column_name.clone()).or_default();
319
+ for k in &update.keys {
320
+ entry.remove(k);
321
+ }
322
+ for (k, v) in update.value {
323
+ if update.keys.contains(&k) {
324
+ entry.insert(k, v);
325
+ }
326
+ }
327
+
328
+ entry.clone()
329
+ };
330
+
331
+ let active = self
332
+ .query_column_config_schema(view_config, session, &column_name, Some(&next))
333
+ .ok()
334
+ .map(|schema| schema.active_keys());
335
+
259
336
  let mut st = self.borrow_mut();
260
337
  let bucket = st.plugin_states.entry(n).or_default();
261
- let entry = bucket.columns.entry(column_name.clone()).or_default();
262
- for k in &update.keys {
263
- entry.remove(k);
264
- }
265
- for (k, v) in update.value {
266
- if update.keys.contains(&k) {
267
- entry.insert(k, v);
338
+ if let Some(entry) = bucket.columns.get_mut(&column_name) {
339
+ if let Some(active) = &active {
340
+ entry.retain(|k, _| active.contains(k));
341
+ }
342
+
343
+ if entry.is_empty() {
344
+ bucket.columns.remove(&column_name);
268
345
  }
269
- }
270
- if entry.is_empty() {
271
- bucket.columns.remove(&column_name);
272
346
  }
273
347
  }
274
348
 
@@ -305,7 +379,9 @@ impl Renderer {
305
379
  let plugin = self.ensure_plugin_selected()?;
306
380
  let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
307
381
  let raw = plugin._plugin_config_schema(&view_config_js)?;
308
- serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
382
+ serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
383
+ .map(|schema| schema.canonicalize_defaults())
384
+ .map_err(|e| e.into())
309
385
  }
310
386
 
311
387
  /// Per-column counterpart of [`query_plugin_config_schema`]. Used by
@@ -352,7 +428,9 @@ impl Renderer {
352
428
  &stats_js,
353
429
  )?;
354
430
 
355
- serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
431
+ serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
432
+ .map(|schema| schema.canonicalize_defaults())
433
+ .map_err(|e| e.into())
356
434
  }
357
435
 
358
436
  /// Wholesale update the active plugin's plugin-level config map.
@@ -365,9 +443,9 @@ impl Renderer {
365
443
  &self,
366
444
  view_config: &ViewConfig,
367
445
  update: PluginConfigUpdate,
368
- ) -> bool {
446
+ ) -> ApiResult<bool> {
369
447
  let Some(n) = self.active_plugin_name() else {
370
- return false;
448
+ return Ok(false);
371
449
  };
372
450
 
373
451
  let schema = self.query_plugin_config_schema(view_config).ok();
@@ -377,14 +455,21 @@ impl Renderer {
377
455
  OptionalUpdate::SetDefault => {
378
456
  let changed = !bucket.plugin.is_empty();
379
457
  bucket.plugin.clear();
380
- changed
458
+ Ok(changed)
381
459
  },
382
- OptionalUpdate::Missing => false,
460
+ OptionalUpdate::Missing => Ok(false),
383
461
  OptionalUpdate::Update(mut map) => {
384
462
  let mut changed = false;
385
463
  if let Some(s) = &schema {
386
464
  let active = s.active_keys();
387
465
  map.retain(|k, _| active.contains(k));
466
+ let errors = normalize_css_values(s, &mut map);
467
+ if let Some((key, error)) = errors.first() {
468
+ return Err(ApiError::from(JsValue::from_str(&format!(
469
+ "Invalid `plugin_config.{key}`: {error}"
470
+ ))));
471
+ }
472
+
388
473
  map.retain(|key, value| {
389
474
  let is_default = s
390
475
  .fields
@@ -408,7 +493,7 @@ impl Renderer {
408
493
  }
409
494
  }
410
495
 
411
- changed
496
+ Ok(changed)
412
497
  },
413
498
  }
414
499
  }
@@ -424,6 +509,10 @@ impl Renderer {
424
509
  };
425
510
 
426
511
  if let Ok(schema) = self.query_plugin_config_schema(view_config) {
512
+ for (key, error) in normalize_css_values(&schema, &mut update.value) {
513
+ tracing::error!("Dropping `plugin_config`.`{key}`: {error}");
514
+ }
515
+
427
516
  strip_default_values(&schema, &mut update.value);
428
517
  }
429
518
 
@@ -446,6 +535,34 @@ impl Renderer {
446
535
  }
447
536
  }
448
537
 
538
+ fn normalize_css_values(
539
+ schema: &ColumnConfigSchema,
540
+ map: &mut serde_json::Map<String, serde_json::Value>,
541
+ ) -> Vec<(String, String)> {
542
+ let mut errors = vec![];
543
+ for (key, value) in map.iter_mut() {
544
+ let Some(kind) = schema.css_kind_of(key) else {
545
+ continue;
546
+ };
547
+
548
+ let canonical = match value.as_str() {
549
+ Some(src) => kind.canonicalize(src),
550
+ None => Err(format!("expected a CSS string, got `{value}`")),
551
+ };
552
+
553
+ match canonical {
554
+ Ok(canonical) => *value = Value::String(canonical),
555
+ Err(error) => errors.push((key.clone(), error)),
556
+ }
557
+ }
558
+
559
+ for (key, _) in &errors {
560
+ map.remove(key);
561
+ }
562
+
563
+ errors
564
+ }
565
+
449
566
  fn strip_default_values(
450
567
  schema: &ColumnConfigSchema,
451
568
  map: &mut serde_json::Map<String, serde_json::Value>,
@@ -479,17 +596,162 @@ fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> boo
479
596
  } if k == key => value.as_str() == Some(default.as_str()),
480
597
  ControlSpec::Color {
481
598
  key: k, default, ..
482
- } if k == key => value.as_str() == Some(default.as_str()),
483
- ControlSpec::ColorRange {
484
- key_pos,
485
- default_pos,
486
- ..
487
- } if key_pos == key => value.as_str() == Some(default_pos.as_str()),
488
- ControlSpec::ColorRange {
489
- key_neg,
490
- default_neg,
491
- ..
492
- } if key_neg == key => value.as_str() == Some(default_neg.as_str()),
599
+ } if k == key => css_matches(CssKind::Color, value, default),
600
+ ControlSpec::Palette {
601
+ key: k, default, ..
602
+ } if k == key => css_matches(CssKind::Palette, value, default),
603
+ ControlSpec::GradientStops {
604
+ key: k, default, ..
605
+ } if k == key => css_matches(CssKind::Gradient, value, default),
493
606
  _ => false,
494
607
  }
495
608
  }
609
+
610
+ fn css_matches(kind: CssKind, value: &Value, default: &str) -> bool {
611
+ value
612
+ .as_str()
613
+ .and_then(|src| kind.canonicalize(src).ok())
614
+ .is_some_and(|canonical| canonical == default)
615
+ }
616
+
617
+ #[cfg(test)]
618
+ mod tests {
619
+ use serde_json::json;
620
+
621
+ use super::*;
622
+
623
+ #[test]
624
+ fn palette_default_matches_canonically() {
625
+ let spec = ControlSpec::Palette {
626
+ key: "palette".to_owned(),
627
+ default: "linear-gradient(to right, #0366d6, #ff7f0e)".to_owned(),
628
+ max: None,
629
+ };
630
+
631
+ assert!(matches_declared_default(
632
+ &spec,
633
+ "palette",
634
+ &json!("linear-gradient(to right, #0366d6, #ff7f0e)")
635
+ ));
636
+
637
+ assert!(matches_declared_default(
638
+ &spec,
639
+ "palette",
640
+ &json!("linear-gradient(90deg, RGB(3,102,214), #FF7F0E)")
641
+ ));
642
+
643
+ assert!(!matches_declared_default(
644
+ &spec,
645
+ "palette",
646
+ &json!("linear-gradient(to right, #ff7f0e, #0366d6)")
647
+ ));
648
+
649
+ assert!(!matches_declared_default(
650
+ &spec,
651
+ "palette",
652
+ &json!("var(--psp-user--palette-1)")
653
+ ));
654
+
655
+ assert!(!matches_declared_default(
656
+ &spec,
657
+ "palette",
658
+ &json!(["#0366d6"])
659
+ ));
660
+ assert!(!matches_declared_default(
661
+ &spec,
662
+ "other",
663
+ &json!("linear-gradient(to right, #0366d6, #ff7f0e)")
664
+ ));
665
+ }
666
+
667
+ #[test]
668
+ fn gradient_default_matches_canonical_values() {
669
+ let spec = ControlSpec::GradientStops {
670
+ key: "gradient".to_owned(),
671
+ default: "linear-gradient(to right, #0366d6 0%, #ff7f0e 33.3%)".to_owned(),
672
+ discrete: false,
673
+ };
674
+
675
+ assert!(matches_declared_default(
676
+ &spec,
677
+ "gradient",
678
+ &json!("linear-gradient(#0366d6, #ff7f0e 33.3%)")
679
+ ));
680
+
681
+ assert!(!matches_declared_default(
682
+ &spec,
683
+ "gradient",
684
+ &json!("linear-gradient(to right, #0366d6 0%, #ff7f0e 33.4%)")
685
+ ));
686
+
687
+ let color = ControlSpec::Color {
688
+ key: "color".to_owned(),
689
+ default: "#ff7f0e".to_owned(),
690
+ };
691
+
692
+ assert!(matches_declared_default(&color, "color", &json!("#FF7F0E")));
693
+ assert!(matches_declared_default(
694
+ &color,
695
+ "color",
696
+ &json!("rgb(255, 127, 14)")
697
+ ));
698
+ assert!(!matches_declared_default(
699
+ &color,
700
+ "color",
701
+ &json!("var(--psp-user--color-1)")
702
+ ));
703
+ }
704
+
705
+ #[test]
706
+ fn normalize_css_values_canonicalizes_and_reports() {
707
+ let schema = ColumnConfigSchema {
708
+ fields: vec![
709
+ ControlSpec::Color {
710
+ key: "color".to_owned(),
711
+ default: "#000000".to_owned(),
712
+ },
713
+ ControlSpec::Palette {
714
+ key: "palette".to_owned(),
715
+ default: "linear-gradient(to right, #000000)".to_owned(),
716
+ max: None,
717
+ },
718
+ ControlSpec::GradientStops {
719
+ key: "gradient".to_owned(),
720
+ default: "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
721
+ discrete: false,
722
+ },
723
+ ControlSpec::Bool {
724
+ key: "flag".to_owned(),
725
+ default: false,
726
+ },
727
+ ],
728
+ };
729
+
730
+ let mut map = json!({
731
+ "color": "RGB(255,0,0)",
732
+ "palette": "var(--psp-user--palette-warm)",
733
+ "gradient": [{ "color": "#000000", "offset": 0 }],
734
+ "flag": true,
735
+ "foreign": "linear-gradient(red, blue)",
736
+ })
737
+ .as_object()
738
+ .unwrap()
739
+ .clone();
740
+
741
+ let errors = normalize_css_values(&schema, &mut map);
742
+ assert_eq!(errors.len(), 1);
743
+ assert_eq!(errors[0].0, "gradient");
744
+ assert_eq!(
745
+ map,
746
+ json!({
747
+ "color": "#ff0000",
748
+ "palette": "var(--psp-user--palette-warm)",
749
+ "flag": true,
750
+ "foreign": "linear-gradient(red, blue)",
751
+ })
752
+ .as_object()
753
+ .unwrap()
754
+ .clone()
755
+ );
756
+ }
757
+ }
@@ -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();