@perspective-dev/viewer 5.3.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 (100) 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.inline.js +9 -9
  31. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  32. package/dist/esm/perspective-viewer.js +8 -8
  33. package/dist/esm/perspective-viewer.js.map +3 -3
  34. package/dist/wasm/perspective-viewer.d.ts +16 -16
  35. package/dist/wasm/perspective-viewer.js +107 -103
  36. package/dist/wasm/perspective-viewer.wasm +0 -0
  37. package/dist/wasm/perspective-viewer.wasm.d.ts +16 -16
  38. package/package.json +1 -1
  39. package/src/css/column-settings-panel.css +19 -0
  40. package/src/css/column-style.css +82 -0
  41. package/src/css/containers/tabs.css +13 -3
  42. package/src/css/dom/select.css +19 -8
  43. package/src/css/plugin-selector.css +1 -0
  44. package/src/css/plugin-settings-panel.css +72 -0
  45. package/src/css/viewer.css +0 -5
  46. package/src/rust/agent/tools.rs +5 -2
  47. package/src/rust/components/column_settings_sidebar/style_tab.rs +296 -237
  48. package/src/rust/components/containers/control_group.rs +91 -0
  49. package/src/rust/components/containers/mod.rs +1 -0
  50. package/src/rust/components/containers/select.rs +8 -2
  51. package/src/rust/components/datetime_column_style/simple.rs +15 -4
  52. package/src/rust/components/datetime_column_style.rs +50 -10
  53. package/src/rust/components/plugin_tab.rs +125 -87
  54. package/src/rust/components/settings_panel.rs +1 -0
  55. package/src/rust/components/status_bar.rs +0 -1
  56. package/src/rust/components/style_controls/number_string_format/digits_section.rs +22 -10
  57. package/src/rust/components/style_controls/number_string_format/misc_section.rs +6 -2
  58. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -0
  59. package/src/rust/components/style_controls/number_string_format/types.rs +24 -0
  60. package/src/rust/components/style_controls/number_string_format.rs +37 -14
  61. package/src/rust/config/column_config_schema.rs +358 -27
  62. package/src/rust/config/datetime_column_style.rs +4 -21
  63. package/src/rust/config/number_string_format.rs +211 -18
  64. package/src/rust/presentation.rs +17 -0
  65. package/src/rust/queries/plugin_column_styles.rs +2 -2
  66. package/src/rust/renderer/dispatch.rs +34 -8
  67. package/src/rust/renderer/plugin_config.rs +55 -8
  68. package/src/themes/blueprint.css +190 -0
  69. package/src/themes/botanical.css +4 -17
  70. package/src/themes/defaults.css +2 -120
  71. package/src/themes/dracula.css +2 -14
  72. package/src/themes/eggplant.css +189 -0
  73. package/src/themes/gruvbox-dark.css +3 -15
  74. package/src/themes/gruvbox.css +1 -1
  75. package/src/themes/icons.css +1 -2
  76. package/src/themes/intl/de.css +23 -1
  77. package/src/themes/intl/es.css +23 -1
  78. package/src/themes/intl/fr.css +23 -1
  79. package/src/themes/intl/ja.css +23 -1
  80. package/src/themes/intl/pt.css +23 -1
  81. package/src/themes/intl/zh.css +23 -1
  82. package/src/themes/intl.css +26 -4
  83. package/src/themes/ledger.css +195 -0
  84. package/src/themes/monokai.css +2 -14
  85. package/src/themes/nord.css +186 -0
  86. package/src/themes/phosphor.css +2 -14
  87. package/src/themes/pro-dark.css +2 -16
  88. package/src/themes/pro.css +118 -0
  89. package/src/themes/solarized-dark.css +2 -16
  90. package/src/themes/themes.css +10 -5
  91. package/src/themes/vaporwave.css +2 -14
  92. package/src/themes/velvet.css +192 -0
  93. package/src/ts/column-format.ts +52 -2
  94. package/src/svg/dropdown-selector-light.svg +0 -3
  95. /package/dist/wasm/snippets/{perspective-viewer-39c3c3b28e3cbd48 → perspective-viewer-87bd21b215d0631a}/inline0.js +0 -0
  96. /package/dist/wasm/snippets/{perspective-viewer-39c3c3b28e3cbd48 → perspective-viewer-87bd21b215d0631a}/inline1.js +0 -0
  97. /package/dist/wasm/snippets/{perspective-viewer-39c3c3b28e3cbd48 → perspective-viewer-87bd21b215d0631a}/inline2.js +0 -0
  98. /package/dist/wasm/snippets/{perspective-viewer-39c3c3b28e3cbd48 → perspective-viewer-87bd21b215d0631a}/inline3.js +0 -0
  99. /package/dist/wasm/snippets/{perspective-viewer-39c3c3b28e3cbd48 → perspective-viewer-87bd21b215d0631a}/inline4.js +0 -0
  100. /package/dist/wasm/snippets/{perspective-viewer-39c3c3b28e3cbd48 → perspective-viewer-87bd21b215d0631a}/inline5.js +0 -0
@@ -255,13 +255,106 @@ pub struct CustomNumberFormatConfig {
255
255
  pub sign_display: Option<SignDisplay>,
256
256
  }
257
257
 
258
+ /// The active plugin's `NumberFormat` default overlaid on the built-in
259
+ /// `number_format` values.
260
+ #[derive(Clone, Debug, PartialEq)]
261
+ pub struct NumberFormatDefaults {
262
+ pub is_float: bool,
263
+ pub style: NumberFormatStyle,
264
+ pub notation: Notation,
265
+ pub minimum_integer_digits: f64,
266
+ pub fraction: (f64, f64),
267
+ pub significant: (f64, f64),
268
+ pub rounding_priority: RoundingPriority,
269
+ pub rounding_mode: RoundingMode,
270
+ pub trailing_zero_display: TrailingZeroDisplay,
271
+ pub use_grouping: UseGrouping,
272
+ pub sign_display: SignDisplay,
273
+ }
274
+
275
+ impl NumberFormatDefaults {
276
+ pub fn builtin(is_float: bool) -> Self {
277
+ Self {
278
+ is_float,
279
+ style: NumberFormatStyle::default(),
280
+ notation: Notation::default(),
281
+ minimum_integer_digits: 1.,
282
+ fraction: if is_float { (2., 2.) } else { (0., 0.) },
283
+ significant: (1., 21.),
284
+ rounding_priority: RoundingPriority::default(),
285
+ rounding_mode: RoundingMode::default(),
286
+ trailing_zero_display: TrailingZeroDisplay::default(),
287
+ use_grouping: UseGrouping::default(),
288
+ sign_display: SignDisplay::default(),
289
+ }
290
+ }
291
+
292
+ /// Overlay a plugin-declared partial default onto [`Self::builtin`].
293
+ pub fn resolve(spec_default: Option<&CustomNumberFormatConfig>, is_float: bool) -> Self {
294
+ let mut out = Self::builtin(is_float);
295
+ let Some(d) = spec_default else {
296
+ return out;
297
+ };
298
+
299
+ if let Some(style) = &d._style {
300
+ out.style = style.clone();
301
+ }
302
+
303
+ if let Some(notation) = &d._notation {
304
+ out.notation = notation.clone();
305
+ }
306
+
307
+ if let Some(v) = d.minimum_integer_digits {
308
+ out.minimum_integer_digits = v;
309
+ }
310
+
311
+ if let Some(v) = d.minimum_fraction_digits {
312
+ out.fraction.0 = v;
313
+ }
314
+
315
+ if let Some(v) = d.maximum_fraction_digits {
316
+ out.fraction.1 = v;
317
+ }
318
+
319
+ if let Some(v) = d.minimum_significant_digits {
320
+ out.significant.0 = v;
321
+ }
322
+
323
+ if let Some(v) = d.maximum_significant_digits {
324
+ out.significant.1 = v;
325
+ }
326
+
327
+ if let Some(v) = d.rounding_priority {
328
+ out.rounding_priority = v;
329
+ }
330
+
331
+ if let Some(v) = d.rounding_mode {
332
+ out.rounding_mode = v;
333
+ }
334
+
335
+ if let Some(v) = d.trailing_zero_display {
336
+ out.trailing_zero_display = v;
337
+ }
338
+
339
+ if let Some(v) = d.use_grouping {
340
+ out.use_grouping = v;
341
+ }
342
+
343
+ if let Some(v) = d.sign_display {
344
+ out.sign_display = v;
345
+ }
346
+
347
+ out
348
+ }
349
+ }
350
+
258
351
  impl CustomNumberFormatConfig {
259
- pub fn filter_default(self, is_float: bool) -> Self {
260
- let (frac_min, frac_max) = if is_float { (2., 2.) } else { (0., 0.) };
352
+ pub fn filter_default(self, defaults: &NumberFormatDefaults) -> Self {
353
+ let (frac_min, frac_max) = defaults.fraction;
261
354
  let rounding_increment = self.rounding_increment;
262
355
  let use_grouping = self
263
356
  .use_grouping
264
- .filter(|val| *val != UseGrouping::default());
357
+ .filter(|val| *val != defaults.use_grouping);
265
358
 
266
359
  let mut minimum_fraction_digits =
267
360
  self.minimum_fraction_digits.filter(|val| *val != frac_min);
@@ -269,7 +362,7 @@ impl CustomNumberFormatConfig {
269
362
  let mut maximum_fraction_digits =
270
363
  self.maximum_fraction_digits.filter(|val| *val != frac_max);
271
364
 
272
- let mut show_frac = is_float
365
+ let mut show_frac = defaults.is_float
273
366
  && (minimum_fraction_digits.is_some()
274
367
  || maximum_fraction_digits.is_some()
275
368
  || use_grouping.is_some()
@@ -277,7 +370,7 @@ impl CustomNumberFormatConfig {
277
370
  self._style,
278
371
  Some(NumberFormatStyle::Percent | NumberFormatStyle::Unit(_))
279
372
  ))
280
- || !is_float && matches!(self._style, Some(NumberFormatStyle::Currency(_)));
373
+ || !defaults.is_float && matches!(self._style, Some(NumberFormatStyle::Currency(_)));
281
374
 
282
375
  // Rounding increment does not work unless `minimum_fraction_digits`
283
376
  // and `maximum_fraction_digits` are set to 0.
@@ -287,39 +380,139 @@ impl CustomNumberFormatConfig {
287
380
  maximum_fraction_digits = Some(0.);
288
381
  }
289
382
 
290
- let minimum_significant_digits = self.minimum_significant_digits.filter(|val| *val != 1.);
291
- let maximum_significant_digits = self.maximum_significant_digits.filter(|val| *val != 21.);
383
+ let minimum_significant_digits = self
384
+ .minimum_significant_digits
385
+ .filter(|val| *val != defaults.significant.0);
386
+
387
+ let maximum_significant_digits = self
388
+ .maximum_significant_digits
389
+ .filter(|val| *val != defaults.significant.1);
390
+
292
391
  let show_sig = minimum_significant_digits.is_some() || maximum_significant_digits.is_some();
293
392
  Self {
294
- _style: self
295
- ._style
296
- .filter(|style| !matches!(style, NumberFormatStyle::Decimal)),
297
- minimum_integer_digits: self.minimum_integer_digits.filter(|val| *val != 1.),
393
+ _style: self._style.filter(|style| *style != defaults.style),
394
+ minimum_integer_digits: self
395
+ .minimum_integer_digits
396
+ .filter(|val| *val != defaults.minimum_integer_digits),
298
397
  minimum_fraction_digits: show_frac
299
398
  .then_some(minimum_fraction_digits.unwrap_or(frac_min)),
300
399
  maximum_fraction_digits: show_frac
301
400
  .then_some(maximum_fraction_digits.unwrap_or(frac_max)),
302
401
  minimum_significant_digits: show_sig
303
- .then_some(minimum_significant_digits.unwrap_or(1.)),
402
+ .then_some(minimum_significant_digits.unwrap_or(defaults.significant.0)),
304
403
  maximum_significant_digits: show_sig
305
- .then_some(minimum_significant_digits.unwrap_or(21.)),
404
+ .then_some(maximum_significant_digits.unwrap_or(defaults.significant.1)),
306
405
  rounding_priority: self
307
406
  .rounding_priority
308
- .filter(|val| *val != RoundingPriority::default()),
407
+ .filter(|val| *val != defaults.rounding_priority),
309
408
  rounding_increment,
310
409
  rounding_mode: self
311
410
  .rounding_mode
312
- .filter(|val| *val != RoundingMode::default()),
411
+ .filter(|val| *val != defaults.rounding_mode),
313
412
  trailing_zero_display: self
314
413
  .trailing_zero_display
315
- .filter(|val| *val != TrailingZeroDisplay::default()),
414
+ .filter(|val| *val != defaults.trailing_zero_display),
316
415
  _notation: self
317
416
  ._notation
318
- .filter(|notation| !matches!(notation, Notation::Standard)),
417
+ .filter(|notation| *notation != defaults.notation),
319
418
  use_grouping,
320
419
  sign_display: self
321
420
  .sign_display
322
- .filter(|val| *val != SignDisplay::default()),
421
+ .filter(|val| *val != defaults.sign_display),
422
+ }
423
+ }
424
+ }
425
+
426
+ #[cfg(test)]
427
+ mod tests {
428
+ use super::*;
429
+
430
+ fn sig(min: f64, max: f64) -> CustomNumberFormatConfig {
431
+ CustomNumberFormatConfig {
432
+ minimum_significant_digits: Some(min),
433
+ maximum_significant_digits: Some(max),
434
+ ..Default::default()
323
435
  }
324
436
  }
437
+
438
+ #[test]
439
+ fn resolve_overlays_partial_default_on_builtin() {
440
+ let spec = CustomNumberFormatConfig {
441
+ _notation: Some(Notation::Compact(CompactDisplay::Short)),
442
+ minimum_fraction_digits: Some(0.),
443
+ maximum_fraction_digits: Some(1.),
444
+ ..Default::default()
445
+ };
446
+
447
+ let defaults = NumberFormatDefaults::resolve(Some(&spec), true);
448
+ assert_eq!(defaults.notation, Notation::Compact(CompactDisplay::Short));
449
+ assert_eq!(defaults.fraction, (0., 1.));
450
+ assert_eq!(defaults.style, NumberFormatStyle::Decimal);
451
+ assert_eq!(defaults.significant, (1., 21.));
452
+ assert_eq!(
453
+ NumberFormatDefaults::resolve(None, true),
454
+ NumberFormatDefaults::builtin(true)
455
+ );
456
+ }
457
+
458
+ #[test]
459
+ fn filter_preserves_max_significant_digits() {
460
+ let filtered = sig(1., 5.).filter_default(&NumberFormatDefaults::builtin(true));
461
+ assert_eq!(filtered.minimum_significant_digits, Some(1.));
462
+ assert_eq!(filtered.maximum_significant_digits, Some(5.));
463
+
464
+ let filtered = sig(3., 21.).filter_default(&NumberFormatDefaults::builtin(true));
465
+ assert_eq!(filtered.minimum_significant_digits, Some(3.));
466
+ assert_eq!(filtered.maximum_significant_digits, Some(21.));
467
+ }
468
+
469
+ #[test]
470
+ fn filter_elides_values_equal_to_resolved_defaults() {
471
+ let defaults = NumberFormatDefaults {
472
+ fraction: (0., 1.),
473
+ notation: Notation::Compact(CompactDisplay::Short),
474
+ ..NumberFormatDefaults::builtin(true)
475
+ };
476
+
477
+ let config = CustomNumberFormatConfig {
478
+ _notation: Some(Notation::Compact(CompactDisplay::Short)),
479
+ minimum_fraction_digits: Some(0.),
480
+ maximum_fraction_digits: Some(1.),
481
+ ..Default::default()
482
+ };
483
+
484
+ assert_eq!(
485
+ config.filter_default(&defaults),
486
+ CustomNumberFormatConfig::default()
487
+ );
488
+ }
489
+
490
+ #[test]
491
+ fn filter_serializes_builtin_values_under_override() {
492
+ let defaults = NumberFormatDefaults {
493
+ notation: Notation::Compact(CompactDisplay::Short),
494
+ ..NumberFormatDefaults::builtin(true)
495
+ };
496
+
497
+ let config = CustomNumberFormatConfig {
498
+ _notation: Some(Notation::Standard),
499
+ ..Default::default()
500
+ };
501
+
502
+ let filtered = config.filter_default(&defaults);
503
+ assert_eq!(filtered._notation, Some(Notation::Standard));
504
+ }
505
+
506
+ #[test]
507
+ fn filter_bakes_fraction_digits_for_percent_style() {
508
+ let config = CustomNumberFormatConfig {
509
+ _style: Some(NumberFormatStyle::Percent),
510
+ ..Default::default()
511
+ };
512
+
513
+ let filtered = config.filter_default(&NumberFormatDefaults::builtin(true));
514
+ assert_eq!(filtered._style, Some(NumberFormatStyle::Percent));
515
+ assert_eq!(filtered.minimum_fraction_digits, Some(2.));
516
+ assert_eq!(filtered.maximum_fraction_digits, Some(2.));
517
+ }
325
518
  }
@@ -104,6 +104,8 @@ pub struct PresentationHandle {
104
104
  open_column_settings: RefCell<OpenColumnSettings>,
105
105
  is_workspace: RefCell<Option<bool>>,
106
106
 
107
+ collapsed_control_groups: RefCell<HashSet<String>>,
108
+
107
109
  /// Drag/drop in-progress state. Empty (`NoDrag`) when no user drag is
108
110
  /// active. Mutated by `notify_drag_*` / `notify_drop`; read by component
109
111
  /// CSS-class derivations (`is_dragover`, `get_drag_column`).
@@ -191,6 +193,7 @@ impl Presentation {
191
193
  on_is_workspace_changed: Default::default(),
192
194
  is_settings_open: Default::default(),
193
195
  open_column_settings: Default::default(),
196
+ collapsed_control_groups: Default::default(),
194
197
  theme_config_updated: PubSub::default(),
195
198
  on_eject: PubSub::default(),
196
199
  statusbar_pointer_event: PubSub::default(),
@@ -357,6 +360,20 @@ impl Presentation {
357
360
  self.open_column_settings.borrow().deref().clone()
358
361
  }
359
362
 
363
+ pub fn is_control_group_collapsed(&self, key: &str) -> bool {
364
+ self.collapsed_control_groups.borrow().contains(key)
365
+ }
366
+
367
+ pub fn set_control_group_collapsed(&self, key: &str, collapsed: bool) {
368
+ if collapsed {
369
+ self.collapsed_control_groups
370
+ .borrow_mut()
371
+ .insert(key.to_owned());
372
+ } else {
373
+ self.collapsed_control_groups.borrow_mut().remove(key);
374
+ }
375
+ }
376
+
360
377
  async fn init(self) -> ApiResult<()> {
361
378
  self.set_theme_attribute(self.get_selected_theme_name().await.as_deref())
362
379
  }
@@ -42,7 +42,7 @@ pub fn get_plugin_config_schema(
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
44
  serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
45
- .map(|schema| schema.canonicalize_defaults())
45
+ .map(|schema| schema.canonicalize())
46
46
  .map_err(|e| e.into())
47
47
  }
48
48
 
@@ -100,6 +100,6 @@ pub fn get_column_config_schema(
100
100
  )?;
101
101
 
102
102
  serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
103
- .map(|schema| schema.canonicalize_defaults())
103
+ .map(|schema| schema.canonicalize())
104
104
  .map_err(|e| e.into())
105
105
  }
@@ -165,10 +165,8 @@ impl Renderer {
165
165
  width: f64,
166
166
  height: f64,
167
167
  ) -> ApiResult<Option<js_sys::Function>> {
168
- self.0.presize_pending.set(self.0.presize_pending.get() + 1);
169
- let result = self.presize_inner(None, width, height).await;
170
- self.0.presize_pending.set(self.0.presize_pending.get() - 1);
171
- result
168
+ let _pending = PresizePendingGuard::increment(self);
169
+ self.presize_inner(None, width, height).await
172
170
  }
173
171
 
174
172
  /// Pre-size AND pre-position the plugin to its target layout bo.
@@ -179,10 +177,8 @@ impl Renderer {
179
177
  width: f64,
180
178
  height: f64,
181
179
  ) -> ApiResult<Option<js_sys::Function>> {
182
- self.0.presize_pending.set(self.0.presize_pending.get() + 1);
183
- let result = self.presize_inner(Some((dx, dy)), width, height).await;
184
- self.0.presize_pending.set(self.0.presize_pending.get() - 1);
185
- result
180
+ let _pending = PresizePendingGuard::increment(self);
181
+ self.presize_inner(Some((dx, dy)), width, height).await
186
182
  }
187
183
 
188
184
  async fn presize_inner(
@@ -374,7 +370,14 @@ impl Renderer {
374
370
  .debounce_with(|guard| async move {
375
371
  set_timeout(timer.get_throttle()).await?;
376
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.
377
379
  tracing::debug!("Update skipped, presize pending");
380
+ self.set_data_stale();
378
381
  return Ok(());
379
382
  }
380
383
 
@@ -473,3 +476,26 @@ impl Renderer {
473
476
  self.draw_lock.clone()
474
477
  }
475
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
+ }
@@ -133,7 +133,7 @@ impl Renderer {
133
133
  else {
134
134
  continue;
135
135
  };
136
- let needs_warm = schema.fields.iter().any(|f| {
136
+ let needs_warm = schema.leaf_fields().into_iter().any(|f| {
137
137
  matches!(
138
138
  f,
139
139
  ControlSpec::Number {
@@ -164,7 +164,7 @@ impl Renderer {
164
164
  continue;
165
165
  };
166
166
 
167
- for field in &schema.fields {
167
+ for field in schema.leaf_fields() {
168
168
  let ControlSpec::Number {
169
169
  key,
170
170
  default,
@@ -380,7 +380,7 @@ impl Renderer {
380
380
  let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
381
381
  let raw = plugin._plugin_config_schema(&view_config_js)?;
382
382
  serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
383
- .map(|schema| schema.canonicalize_defaults())
383
+ .map(|schema| schema.canonicalize())
384
384
  .map_err(|e| e.into())
385
385
  }
386
386
 
@@ -429,7 +429,7 @@ impl Renderer {
429
429
  )?;
430
430
 
431
431
  serde_wasm_bindgen::from_value::<ColumnConfigSchema>(raw)
432
- .map(|schema| schema.canonicalize_defaults())
432
+ .map(|schema| schema.canonicalize())
433
433
  .map_err(|e| e.into())
434
434
  }
435
435
 
@@ -470,9 +470,9 @@ impl Renderer {
470
470
  ))));
471
471
  }
472
472
 
473
+ let leaves = s.leaf_fields();
473
474
  map.retain(|key, value| {
474
- let is_default = s
475
- .fields
475
+ let is_default = leaves
476
476
  .iter()
477
477
  .any(|spec| matches_declared_default(spec, key, value));
478
478
  if is_default {
@@ -567,9 +567,9 @@ fn strip_default_values(
567
567
  schema: &ColumnConfigSchema,
568
568
  map: &mut serde_json::Map<String, serde_json::Value>,
569
569
  ) {
570
+ let leaves = schema.leaf_fields();
570
571
  map.retain(|key, value| {
571
- !schema
572
- .fields
572
+ !leaves
573
573
  .iter()
574
574
  .any(|spec| matches_declared_default(spec, key, value))
575
575
  });
@@ -702,6 +702,53 @@ mod tests {
702
702
  ));
703
703
  }
704
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
+
705
752
  #[test]
706
753
  fn normalize_css_values_canonicalizes_and_reports() {
707
754
  let schema = ColumnConfigSchema {