@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
@@ -41,7 +41,9 @@ pub fn get_plugin_config_schema(
41
41
  let view_config_js =
42
42
  wasm_bindgen::JsValue::from_serde_ext(view_config).unwrap_or(wasm_bindgen::JsValue::NULL);
43
43
  let raw = plugin._plugin_config_schema(&view_config_js)?;
44
- serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
44
+ serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
45
+ .map(|schema| schema.canonicalize())
46
+ .map_err(|e| e.into())
45
47
  }
46
48
 
47
49
  /// Queries the active plugin for the per-column [`ColumnConfigSchema`].
@@ -97,5 +99,7 @@ pub fn get_column_config_schema(
97
99
  &stats_js,
98
100
  )?;
99
101
 
100
- serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
102
+ serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
103
+ .map(|schema| schema.canonicalize())
104
+ .map_err(|e| e.into())
101
105
  }
@@ -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({
@@ -153,10 +165,8 @@ impl Renderer {
153
165
  width: f64,
154
166
  height: f64,
155
167
  ) -> ApiResult<Option<js_sys::Function>> {
156
- self.0.presize_pending.set(self.0.presize_pending.get() + 1);
157
- let result = self.presize_inner(None, width, height).await;
158
- self.0.presize_pending.set(self.0.presize_pending.get() - 1);
159
- result
168
+ let _pending = PresizePendingGuard::increment(self);
169
+ self.presize_inner(None, width, height).await
160
170
  }
161
171
 
162
172
  /// Pre-size AND pre-position the plugin to its target layout bo.
@@ -167,10 +177,8 @@ impl Renderer {
167
177
  width: f64,
168
178
  height: f64,
169
179
  ) -> ApiResult<Option<js_sys::Function>> {
170
- self.0.presize_pending.set(self.0.presize_pending.get() + 1);
171
- let result = self.presize_inner(Some((dx, dy)), width, height).await;
172
- self.0.presize_pending.set(self.0.presize_pending.get() - 1);
173
- result
180
+ let _pending = PresizePendingGuard::increment(self);
181
+ self.presize_inner(Some((dx, dy)), width, height).await
174
182
  }
175
183
 
176
184
  async fn presize_inner(
@@ -362,7 +370,14 @@ impl Renderer {
362
370
  .debounce_with(|guard| async move {
363
371
  set_timeout(timer.get_throttle()).await?;
364
372
  if self.0.presize_pending.get() > 0 {
373
+ // Record the drop like the hidden-panel path
374
+ // (`wire_panel_render_sub`) does — a silently
375
+ // discarded update otherwise leaves the plugin's
376
+ // retained frame stale until the NEXT data tick,
377
+ // or forever if none comes. `draw_view` /
378
+ // `activation_render` consume the marker.
365
379
  tracing::debug!("Update skipped, presize pending");
380
+ self.set_data_stale();
366
381
  return Ok(());
367
382
  }
368
383
 
@@ -416,7 +431,7 @@ impl Renderer {
416
431
  let viewer_elem = self.0.borrow().viewer_elem.clone();
417
432
  let slot = self.slot_name();
418
433
  let first_paint = !self.0.has_drawn.get();
419
- let stamped_theme = self.effective_theme();
434
+ let stamped_theme = self.theme();
420
435
  self.stamp_active(&plugin);
421
436
  self.stamp_theme(Some(&plugin));
422
437
  if self.needs_restyle() {
@@ -461,3 +476,26 @@ impl Renderer {
461
476
  self.draw_lock.clone()
462
477
  }
463
478
  }
479
+
480
+ /// RAII increment of [`RendererData::presize_pending`], the counter that
481
+ /// gates [`Renderer::update_lazy`] dispatches. Decrementing on `Drop`
482
+ /// (not on the happy path) makes the release unconditional — a presize
483
+ /// future cancelled mid-await can never strand the counter above zero,
484
+ /// which would silently discard every subsequent debounced update for
485
+ /// the panel's lifetime.
486
+ struct PresizePendingGuard(Renderer);
487
+
488
+ impl PresizePendingGuard {
489
+ fn increment(renderer: &Renderer) -> Self {
490
+ let cell = &renderer.0.presize_pending;
491
+ cell.set(cell.get() + 1);
492
+ Self(renderer.clone())
493
+ }
494
+ }
495
+
496
+ impl Drop for PresizePendingGuard {
497
+ fn drop(&mut self) {
498
+ let cell = &self.0.0.presize_pending;
499
+ cell.set(cell.get() - 1);
500
+ }
501
+ }
@@ -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
@@ -89,7 +133,7 @@ impl Renderer {
89
133
  else {
90
134
  continue;
91
135
  };
92
- let needs_warm = schema.fields.iter().any(|f| {
136
+ let needs_warm = schema.leaf_fields().into_iter().any(|f| {
93
137
  matches!(
94
138
  f,
95
139
  ControlSpec::Number {
@@ -120,7 +164,7 @@ impl Renderer {
120
164
  continue;
121
165
  };
122
166
 
123
- for field in &schema.fields {
167
+ for field in schema.leaf_fields() {
124
168
  let ControlSpec::Number {
125
169
  key,
126
170
  default,
@@ -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())
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())
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,17 +455,24 @@ 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
+
473
+ let leaves = s.leaf_fields();
388
474
  map.retain(|key, value| {
389
- let is_default = s
390
- .fields
475
+ let is_default = leaves
391
476
  .iter()
392
477
  .any(|spec| matches_declared_default(spec, key, value));
393
478
  if is_default {
@@ -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,13 +535,41 @@ 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>,
452
569
  ) {
570
+ let leaves = schema.leaf_fields();
453
571
  map.retain(|key, value| {
454
- !schema
455
- .fields
572
+ !leaves
456
573
  .iter()
457
574
  .any(|spec| matches_declared_default(spec, key, value))
458
575
  });
@@ -479,17 +596,209 @@ 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 strip_default_values_sees_through_groups() {
707
+ let leaves = vec![
708
+ ControlSpec::Bool {
709
+ key: "flag".to_owned(),
710
+ default: false,
711
+ },
712
+ ControlSpec::Number {
713
+ key: "size".to_owned(),
714
+ default: 3.0,
715
+ include: None,
716
+ min: None,
717
+ max: None,
718
+ step: None,
719
+ },
720
+ ];
721
+
722
+ let flat = ColumnConfigSchema {
723
+ fields: leaves.clone(),
724
+ };
725
+
726
+ let grouped = ColumnConfigSchema {
727
+ fields: vec![ControlSpec::Group {
728
+ key: "section".to_owned(),
729
+ fields: leaves,
730
+ }],
731
+ };
732
+
733
+ let src = json!({ "flag": false, "size": 4.0, "foreign": 1 })
734
+ .as_object()
735
+ .unwrap()
736
+ .clone();
737
+
738
+ let mut a = src.clone();
739
+ let mut b = src;
740
+ strip_default_values(&flat, &mut a);
741
+ strip_default_values(&grouped, &mut b);
742
+ assert_eq!(a, b);
743
+ assert_eq!(
744
+ a,
745
+ json!({ "size": 4.0, "foreign": 1 })
746
+ .as_object()
747
+ .unwrap()
748
+ .clone()
749
+ );
750
+ }
751
+
752
+ #[test]
753
+ fn normalize_css_values_canonicalizes_and_reports() {
754
+ let schema = ColumnConfigSchema {
755
+ fields: vec![
756
+ ControlSpec::Color {
757
+ key: "color".to_owned(),
758
+ default: "#000000".to_owned(),
759
+ },
760
+ ControlSpec::Palette {
761
+ key: "palette".to_owned(),
762
+ default: "linear-gradient(to right, #000000)".to_owned(),
763
+ max: None,
764
+ },
765
+ ControlSpec::GradientStops {
766
+ key: "gradient".to_owned(),
767
+ default: "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
768
+ discrete: false,
769
+ },
770
+ ControlSpec::Bool {
771
+ key: "flag".to_owned(),
772
+ default: false,
773
+ },
774
+ ],
775
+ };
776
+
777
+ let mut map = json!({
778
+ "color": "RGB(255,0,0)",
779
+ "palette": "var(--psp-user--palette-warm)",
780
+ "gradient": [{ "color": "#000000", "offset": 0 }],
781
+ "flag": true,
782
+ "foreign": "linear-gradient(red, blue)",
783
+ })
784
+ .as_object()
785
+ .unwrap()
786
+ .clone();
787
+
788
+ let errors = normalize_css_values(&schema, &mut map);
789
+ assert_eq!(errors.len(), 1);
790
+ assert_eq!(errors[0].0, "gradient");
791
+ assert_eq!(
792
+ map,
793
+ json!({
794
+ "color": "#ff0000",
795
+ "palette": "var(--psp-user--palette-warm)",
796
+ "flag": true,
797
+ "foreign": "linear-gradient(red, blue)",
798
+ })
799
+ .as_object()
800
+ .unwrap()
801
+ .clone()
802
+ );
803
+ }
804
+ }