@svgrid/enterprise 2.6.0 → 2.6.2

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 (52) hide show
  1. package/dist/ExpressionEditorHarness.svelte +47 -0
  2. package/dist/ExpressionEditorHarness.svelte.d.ts +15 -0
  3. package/dist/SvAdvancedFilter.svelte +208 -0
  4. package/dist/SvAdvancedFilter.svelte.d.ts +37 -0
  5. package/dist/SvExpressionEditor.svelte +255 -124
  6. package/dist/SvGridEditPanel.svelte +1157 -1012
  7. package/dist/SvGridEditPanel.svelte.d.ts +2 -0
  8. package/dist/advanced-filter/expr-columns-from-grid.d.ts +30 -0
  9. package/dist/advanced-filter/expr-columns-from-grid.js +44 -0
  10. package/dist/advanced-filter-enable.d.ts +5 -0
  11. package/dist/advanced-filter-enable.js +35 -0
  12. package/dist/cdn/svgrid-enterprise.svelte-external.js +7663 -6482
  13. package/dist/designer/assets/GridMenus-Bjr19Tz_.js +7 -0
  14. package/dist/designer/assets/{SvListBox-BVzVfCG3.js → SvListBox-BB8rt4aP.js} +1 -1
  15. package/dist/designer/assets/index-BfzoL904.css +1 -0
  16. package/dist/designer/assets/{index-BF0UH638.js → index-xjfKckuH.js} +139 -139
  17. package/dist/designer/assets/{js-scroller.svelte-tHanKJx0.js → js-scroller.svelte-CGkUUXwV.js} +2 -2
  18. package/dist/designer/assets/src-BT3LjWJL.css +1 -0
  19. package/dist/designer/assets/src-DDhAK0WP.js +2893 -0
  20. package/dist/designer/index.html +6 -6
  21. package/dist/expressions/builder-tree.d.ts +62 -0
  22. package/dist/expressions/builder-tree.js +133 -0
  23. package/dist/expressions/compile.d.ts +14 -0
  24. package/dist/expressions/compile.js +286 -0
  25. package/dist/expressions/expression-columns.d.ts +1 -11
  26. package/dist/expressions/expression-columns.js +40 -11
  27. package/dist/index.d.ts +7 -3
  28. package/dist/index.js +7 -3
  29. package/dist/install.js +2 -0
  30. package/dist/node/studio.js +743 -25
  31. package/dist/schema.d.ts +39 -0
  32. package/dist/schema.js +9 -0
  33. package/dist/sources/rest.js +25 -0
  34. package/dist/sources/supabase.js +49 -3
  35. package/dist/studio/emit-project.js +85 -11
  36. package/dist/studio/index.d.ts +2 -2
  37. package/dist/studio/index.js +5 -2
  38. package/dist/studio/project.d.ts +124 -3
  39. package/dist/studio/project.js +393 -4
  40. package/dist/studio/ui-components.generated.js +213 -0
  41. package/dist/sveltekit/in-memory.js +96 -3
  42. package/dist/sveltekit/query-plan.d.ts +32 -1
  43. package/dist/sveltekit/query-plan.js +88 -1
  44. package/dist/sveltekit/sql.d.ts +20 -0
  45. package/dist/sveltekit/sql.js +26 -0
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +3 -3
  49. package/dist/designer/assets/GridMenus-CW-rnHS9.js +0 -7
  50. package/dist/designer/assets/index-oYok52zd.css +0 -1
  51. package/dist/designer/assets/src-BPJruwB9.js +0 -2873
  52. package/dist/designer/assets/src-D-O2F805.css +0 -1
@@ -12,6 +12,7 @@
12
12
  SvTagsInput,
13
13
  type ExcelFilterOperator,
14
14
  } from '@svgrid/grid'
15
+ import { untrack } from 'svelte'
15
16
  import { evaluatePredicate } from './expressions/evaluate'
16
17
  import {
17
18
  ExpressionParseError,
@@ -29,13 +30,15 @@
29
30
  } from './expressions/expression-columns'
30
31
  import type { PredicateExpr } from './expressions/expression-types'
31
32
 
32
- type Condition = {
33
- column: string
34
- op: ExcelFilterOperator
35
- value: string
36
- valueTo: string
37
- values: string[]
38
- }
33
+ import {
34
+ freshCondition,
35
+ freshGroup,
36
+ fromBuilderTree,
37
+ toBuilderTree,
38
+ MAX_BUILDER_DEPTH,
39
+ type BuilderGroup,
40
+ type BuilderNode,
41
+ } from './expressions/builder-tree'
39
42
 
40
43
  type Props = {
41
44
  columns: ReadonlyArray<ExprColumn>
@@ -54,55 +57,50 @@
54
57
  }: Props = $props()
55
58
 
56
59
  // --- Builder <-> predicate conversion ------------------------------------
60
+ // The conversion itself lives in expressions/builder-tree.ts, so the round
61
+ // trip can be tested without mounting this component.
57
62
 
58
- function toConditions(expr: PredicateExpr): { combinator: 'and' | 'or'; conditions: Condition[] } | null {
59
- const parts =
60
- expr.kind === 'and' || expr.kind === 'or' ? expr.parts : [expr]
61
- const combinator = expr.kind === 'or' ? 'or' : 'and'
62
- const conditions: Condition[] = []
63
- for (const p of parts) {
64
- if (p.kind !== 'cmp') return null // scalarCmp / nested -> text mode only
65
- conditions.push({
66
- column: p.column,
67
- op: p.op,
68
- value: p.value != null && !Array.isArray(p.value) ? String(p.value) : '',
69
- valueTo: p.valueTo != null ? String(p.valueTo) : '',
70
- values: Array.isArray(p.value) ? p.value.map(String) : [],
71
- })
72
- }
73
- return { combinator, conditions }
74
- }
75
-
76
- function fromConditions(combinator: 'and' | 'or', conditions: Condition[]): PredicateExpr {
77
- const parts: PredicateExpr[] = conditions.map((c) => {
78
- const base = { kind: 'cmp' as const, column: c.column, op: c.op }
79
- if (isValueless(c.op)) return base
80
- if (isSetOperator(c.op)) return { ...base, value: c.values }
81
- if (isRangeOperator(c.op)) return { ...base, value: c.value, valueTo: c.valueTo }
82
- return { ...base, value: c.value }
83
- })
84
- if (parts.length === 0) return { kind: 'const', value: true }
85
- if (parts.length === 1) return parts[0]
86
- return { kind: combinator, parts }
87
- }
88
-
89
- const initial = toConditions(value)
90
- let combinator = $state<'and' | 'or'>(initial?.combinator ?? 'and')
91
- let conditions = $state<Condition[]>(
92
- initial?.conditions ?? [freshCondition()],
93
- )
94
- // If the incoming value is not representable as a flat condition list, start
95
- // in text mode so nothing is lost.
63
+ const initial = toBuilderTree(value, columns)
64
+ let root = $state<BuilderGroup>(initial ?? freshGroup(columns))
65
+ // If the incoming value cannot be shown as a tree, start in text mode so
66
+ // nothing is lost.
96
67
  if (!initial && mode === 'builder') mode = 'text'
97
68
 
98
69
  let text = $state(stringifyPredicate(value, columns))
99
70
  let textError = $state<string | null>(null)
100
71
 
101
- function freshCondition(): Condition {
102
- const first = columns[0]
103
- const ops = operatorsForType(first?.type)
104
- return { column: first?.id ?? '', op: ops[0]?.value ?? 'equals', value: '', valueTo: '', values: [] }
105
- }
72
+ /**
73
+ * The last expression WE produced, serialized. Everything below is seeded
74
+ * once at init, so without this the editor would ignore a `value` assigned
75
+ * from outside - loading a saved filter or clicking a preset would update the
76
+ * grid while the editor kept showing the old conditions.
77
+ *
78
+ * Compared by VALUE, not by reference. Reference comparison looks right and
79
+ * is cheaper, but `value` is $bindable: the object that comes back in is not
80
+ * the object we assigned, so the check never matched and the effect re-seeded
81
+ * the tree after every one of our own edits. Flat lists survived that because
82
+ * they round-trip exactly; a nested group does not, since a group holding one
83
+ * condition serializes to the bare condition, so adding a group appeared to
84
+ * do nothing at all.
85
+ */
86
+ let lastEmittedJson = $state<string>(JSON.stringify(value))
87
+
88
+ $effect(() => {
89
+ const incoming = value
90
+ if (JSON.stringify(incoming) === untrack(() => lastEmittedJson)) return
91
+ untrack(() => {
92
+ const next = toBuilderTree(incoming, columns)
93
+ if (next) {
94
+ root = next
95
+ } else if (mode === 'builder') {
96
+ // Not representable as a tree; text mode keeps it intact.
97
+ mode = 'text'
98
+ }
99
+ text = stringifyPredicate(incoming, columns)
100
+ textError = null
101
+ lastEmittedJson = JSON.stringify(incoming)
102
+ })
103
+ })
106
104
 
107
105
  const columnOptions = $derived(columns.map((c) => ({ value: c.id, label: c.name ?? c.id })))
108
106
 
@@ -114,7 +112,8 @@
114
112
  // --- Emit ----------------------------------------------------------------
115
113
 
116
114
  function emitBuilder() {
117
- const expr = fromConditions(combinator, conditions)
115
+ const expr = fromBuilderTree(root)
116
+ lastEmittedJson = JSON.stringify(expr)
118
117
  value = expr
119
118
  text = stringifyPredicate(expr, columns)
120
119
  textError = null
@@ -131,6 +130,7 @@
131
130
  const problems = validateExpression(expr, columns)
132
131
  textError = problems.length ? problems.join('; ') : null
133
132
  if (!problems.length) {
133
+ lastEmittedJson = JSON.stringify(expr)
134
134
  value = expr
135
135
  onChange?.(expr)
136
136
  }
@@ -139,22 +139,60 @@
139
139
  }
140
140
  }
141
141
 
142
- function addCondition() {
143
- conditions = [...conditions, freshCondition()]
142
+ // Mutations take the parent group and an index rather than a path, because
143
+ // the recursive markup already holds the parent it is rendering.
144
+
145
+ function addCondition(group: BuilderGroup) {
146
+ group.children.push(freshCondition(columns))
144
147
  emitBuilder()
145
148
  }
146
- function removeCondition(i: number) {
147
- conditions = conditions.filter((_, idx) => idx !== i)
148
- if (conditions.length === 0) conditions = [freshCondition()]
149
+
150
+ function addGroup(group: BuilderGroup) {
151
+ // A nested group defaults to the OPPOSITE combinator. Nesting an "all"
152
+ // inside an "all" is a no-op the user would then have to correct, so the
153
+ // default is the one that makes the new group mean something.
154
+ group.children.push(
155
+ freshGroup(columns, group.combinator === 'and' ? 'or' : 'and'),
156
+ )
149
157
  emitBuilder()
150
158
  }
151
- function setColumn(i: number, columnId: string) {
159
+
160
+ function removeChild(group: BuilderGroup, i: number) {
161
+ group.children.splice(i, 1)
162
+ // The root must always offer somewhere to type. A nested group is allowed
163
+ // to empty out, because removing its last child is how you delete it.
164
+ if (group === root && group.children.length === 0) {
165
+ group.children.push(freshCondition(columns))
166
+ }
167
+ emitBuilder()
168
+ }
169
+
170
+ function setColumn(cond: Extract<BuilderNode, { kind: 'cond' }>, columnId: string) {
152
171
  const ops = opsFor(columnId)
153
- conditions[i].column = columnId
154
- if (!ops.some((o) => o.value === conditions[i].op)) conditions[i].op = ops[0]?.value ?? 'equals'
172
+ cond.column = columnId
173
+ if (!ops.some((o) => o.value === cond.op)) cond.op = ops[0]?.value ?? 'equals'
155
174
  emitBuilder()
156
175
  }
157
176
 
177
+ /**
178
+ * Whether the text currently in the box could be shown in the builder.
179
+ *
180
+ * Drives the Builder tab's disabled state. Letting the user click a tab that
181
+ * then refuses with a red error is worse than showing up front that this
182
+ * particular expression has to be edited as text - the refusal is a property
183
+ * of the expression, not a mistake the user made.
184
+ */
185
+ const builderAvailable = $derived.by(() => {
186
+ if (mode === 'builder') return true
187
+ try {
188
+ return toBuilderTree(parsePredicate(text, columns), columns) != null
189
+ } catch {
190
+ // Half-typed text is not a reason to lock the tab; switchMode reports
191
+ // the parse error properly if they do click.
192
+ return true
193
+ }
194
+ })
195
+
158
196
  function switchMode(next: 'builder' | 'text') {
159
197
  if (next === mode) return
160
198
  if (next === 'text') {
@@ -164,13 +202,13 @@
164
202
  // Re-seed the builder from the parsed text, if representable.
165
203
  try {
166
204
  const expr = parsePredicate(text, columns)
167
- const c = toConditions(expr)
168
- if (!c) {
169
- textError = 'This expression uses maths or grouping - stay in text mode'
205
+ const tree = toBuilderTree(expr, columns)
206
+ if (!tree) {
207
+ textError = 'This expression uses column maths - stay in text mode'
170
208
  return
171
209
  }
172
- combinator = c.combinator
173
- conditions = c.conditions
210
+ root = tree
211
+ lastEmittedJson = JSON.stringify(expr)
174
212
  value = expr
175
213
  } catch {
176
214
  textError = 'Fix the expression before switching'
@@ -213,6 +251,10 @@
213
251
  role="tab"
214
252
  aria-selected={mode === 'builder'}
215
253
  class:sx-active={mode === 'builder'}
254
+ disabled={!builderAvailable}
255
+ title={builderAvailable
256
+ ? undefined
257
+ : 'The builder shows a flat list of conditions, so it cannot represent column maths, aggregates or nested groups. Edit this one as text.'}
216
258
  onclick={() => switchMode('builder')}>Builder</button>
217
259
  <button
218
260
  type="button"
@@ -233,65 +275,7 @@
233
275
  </div>
234
276
 
235
277
  {#if mode === 'builder'}
236
- <div class="sx-combinator">
237
- Match
238
- <SvDropDownList
239
- size="sm"
240
- options={[{ value: 'and', label: 'all' }, { value: 'or', label: 'any' }]}
241
- value={combinator}
242
- onChange={(v) => { combinator = v as 'and' | 'or'; emitBuilder() }}
243
- ariaLabel="Combine conditions" />
244
- of the following:
245
- </div>
246
- <div class="sx-rows">
247
- {#each conditions as cond, i (i)}
248
- <div class="sx-row">
249
- <SvDropDownList
250
- size="sm"
251
- options={columnOptions}
252
- value={cond.column}
253
- onChange={(v) => setColumn(i, String(v))}
254
- ariaLabel="Column" />
255
- <SvDropDownList
256
- size="sm"
257
- options={opsFor(cond.column)}
258
- value={cond.op}
259
- onChange={(v) => { conditions[i].op = v as ExcelFilterOperator; emitBuilder() }}
260
- ariaLabel="Operator" />
261
- {#if isValueless(cond.op)}
262
- <span class="sx-noval">-</span>
263
- {:else if isSetOperator(cond.op)}
264
- <SvTagsInput
265
- value={cond.values}
266
- onChange={(vals) => { conditions[i].values = vals; emitBuilder() }}
267
- placeholder="Add value…"
268
- ariaLabel="Values" />
269
- {:else if isRangeOperator(cond.op)}
270
- <SvTextInput
271
- size="sm"
272
- value={cond.value}
273
- onChange={(v) => { conditions[i].value = v; emitBuilder() }}
274
- placeholder="From"
275
- ariaLabel="From" />
276
- <SvTextInput
277
- size="sm"
278
- value={cond.valueTo}
279
- onChange={(v) => { conditions[i].valueTo = v; emitBuilder() }}
280
- placeholder="To"
281
- ariaLabel="To" />
282
- {:else}
283
- <SvTextInput
284
- size="sm"
285
- value={cond.value}
286
- onChange={(v) => { conditions[i].value = v; emitBuilder() }}
287
- placeholder="Value"
288
- ariaLabel="Value" />
289
- {/if}
290
- <button type="button" class="sx-del" onclick={() => removeCondition(i)} aria-label="Remove condition">✕</button>
291
- </div>
292
- {/each}
293
- </div>
294
- <button type="button" class="sx-add" onclick={addCondition}>+ Add condition</button>
278
+ {@render groupBody(root, 0)}
295
279
  {:else}
296
280
  <SvTextArea
297
281
  value={text}
@@ -307,6 +291,122 @@
307
291
  {/if}
308
292
  </div>
309
293
 
294
+ <!--
295
+ One condition row: column, operator, and whichever value control the operator
296
+ needs. `cond` is the live state object, so writing to it mutates the tree in
297
+ place and `emitBuilder` re-reads the whole thing.
298
+ -->
299
+ {#snippet conditionRow(cond: Extract<BuilderNode, { kind: 'cond' }>, parent: BuilderGroup, i: number)}
300
+ <div class="sx-row">
301
+ <SvDropDownList
302
+ size="sm"
303
+ options={columnOptions}
304
+ value={cond.column}
305
+ onChange={(v) => setColumn(cond, String(v))}
306
+ ariaLabel="Column" />
307
+ <SvDropDownList
308
+ size="sm"
309
+ options={opsFor(cond.column)}
310
+ value={cond.op}
311
+ onChange={(v) => { cond.op = v as ExcelFilterOperator; emitBuilder() }}
312
+ ariaLabel="Operator" />
313
+ {#if isValueless(cond.op)}
314
+ <span class="sx-noval">-</span>
315
+ {:else if isSetOperator(cond.op)}
316
+ <SvTagsInput
317
+ value={cond.values}
318
+ onChange={(vals) => { cond.values = vals; emitBuilder() }}
319
+ placeholder="Add value…"
320
+ ariaLabel="Values" />
321
+ {:else if isRangeOperator(cond.op)}
322
+ <SvTextInput
323
+ size="sm"
324
+ value={cond.value}
325
+ onChange={(v) => { cond.value = v; emitBuilder() }}
326
+ placeholder="From"
327
+ ariaLabel="From" />
328
+ <SvTextInput
329
+ size="sm"
330
+ value={cond.valueTo}
331
+ onChange={(v) => { cond.valueTo = v; emitBuilder() }}
332
+ placeholder="To"
333
+ ariaLabel="To" />
334
+ {:else}
335
+ <SvTextInput
336
+ size="sm"
337
+ value={cond.value}
338
+ onChange={(v) => { cond.value = v; emitBuilder() }}
339
+ placeholder="Value"
340
+ ariaLabel="Value" />
341
+ {/if}
342
+ <button
343
+ type="button"
344
+ class="sx-del"
345
+ onclick={() => removeChild(parent, i)}
346
+ aria-label="Remove condition">✕</button>
347
+ </div>
348
+ {/snippet}
349
+
350
+ <!--
351
+ A group and everything under it. Recursive: a snippet may render itself, which
352
+ is what lets one definition cover arbitrary nesting.
353
+
354
+ `depth` only drives presentation and whether another level may be added.
355
+ toBuilderTree enforces the same limit on the way in, so an expression too deep
356
+ to render never reaches here in the first place.
357
+ -->
358
+ {#snippet groupBody(group: BuilderGroup, depth: number)}
359
+ <div class="sx-combinator">
360
+ {#if group.negated}
361
+ <span class="sx-not">NOT</span>
362
+ {/if}
363
+ Match
364
+ <SvDropDownList
365
+ size="sm"
366
+ options={[{ value: 'and', label: 'all' }, { value: 'or', label: 'any' }]}
367
+ value={group.combinator}
368
+ onChange={(v) => { group.combinator = v as 'and' | 'or'; emitBuilder() }}
369
+ ariaLabel="Combine conditions" />
370
+ of the following:
371
+ </div>
372
+ <div class="sx-rows">
373
+ {#each group.children as child, i (i)}
374
+ {#if child.kind === 'cond'}
375
+ {@render conditionRow(child, group, i)}
376
+ {:else}
377
+ <div class="sx-group">
378
+ <div class="sx-group-head">
379
+ <button
380
+ type="button"
381
+ class="sx-neg"
382
+ class:sx-neg-on={child.negated}
383
+ aria-pressed={child.negated}
384
+ title="Negate this group"
385
+ onclick={() => { child.negated = !child.negated; emitBuilder() }}>NOT</button>
386
+ <span class="sx-group-spacer"></span>
387
+ <button
388
+ type="button"
389
+ class="sx-del"
390
+ onclick={() => removeChild(group, i)}
391
+ aria-label="Remove group">✕</button>
392
+ </div>
393
+ {@render groupBody(child, depth + 1)}
394
+ </div>
395
+ {/if}
396
+ {/each}
397
+ </div>
398
+ <div class="sx-actions">
399
+ <button type="button" class="sx-add" onclick={() => addCondition(group)}>
400
+ + Add condition
401
+ </button>
402
+ {#if depth < MAX_BUILDER_DEPTH - 1}
403
+ <button type="button" class="sx-add" onclick={() => addGroup(group)}>
404
+ + Add group
405
+ </button>
406
+ {/if}
407
+ </div>
408
+ {/snippet}
409
+
310
410
  <style>
311
411
  /* Theme-agnostic surfaces (see SvGridAlerts): currentColor + inherit. */
312
412
  .sx-editor {
@@ -320,6 +420,9 @@
320
420
  .sx-modes button { border: 0; background: transparent; color: inherit; opacity: 0.65; padding: 4px 12px; font-size: 12px; font-weight: 500; border-radius: 6px; cursor: pointer; transition: background 0.13s ease, opacity 0.13s ease; }
321
421
  .sx-modes button:hover { opacity: 1; }
322
422
  .sx-modes button.sx-active { background: var(--sg-accent, #4f46e5); color: #fff; opacity: 1; }
423
+ /* Not representable as a flat condition list - the tooltip says why. */
424
+ .sx-modes button:disabled { opacity: 0.35; cursor: not-allowed; }
425
+ .sx-modes button:disabled:hover { opacity: 0.35; }
323
426
  .sx-match {
324
427
  font-size: 11.5px; font-weight: 600; color: var(--sg-accent, #4f46e5);
325
428
  background: color-mix(in srgb, var(--sg-accent, #4f46e5) 14%, transparent);
@@ -328,14 +431,42 @@
328
431
  .sx-combinator { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: 0.7; }
329
432
  .sx-rows { display: flex; flex-direction: column; gap: 7px; }
330
433
  .sx-row {
331
- display: grid; grid-template-columns: minmax(90px, 1fr) minmax(90px, 1fr) 2fr auto; gap: 7px; align-items: center;
434
+ /* minmax(0, 2fr), not 2fr: a grid track sizes to its content by default, so
435
+ the value input refused to shrink and pushed the row out of any narrow
436
+ panel (the form builder's rules pane, a docked alerts panel). */
437
+ display: grid; grid-template-columns: minmax(90px, 1fr) minmax(90px, 1fr) minmax(0, 2fr) auto; gap: 7px; align-items: center;
332
438
  border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 9px; padding: 8px;
333
439
  }
334
440
  .sx-noval { text-align: center; font-size: 13px; opacity: 0.5; }
335
441
  .sx-del { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; background: transparent; color: inherit; opacity: 0.55; cursor: pointer; font-size: 13px; border-radius: 7px; transition: background 0.13s ease, color 0.13s ease, opacity 0.13s ease; }
336
442
  .sx-del:hover { color: var(--sg-danger, #dc2626); opacity: 1; background: color-mix(in srgb, var(--sg-danger, #dc2626) 12%, transparent); }
443
+ .sx-actions { display: flex; gap: 7px; flex-wrap: wrap; }
337
444
  .sx-add { align-self: flex-start; border: 1px dashed color-mix(in srgb, var(--sg-accent, #4f46e5) 45%, currentColor); background: transparent; color: var(--sg-accent, #4f46e5); border-radius: 8px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.13s ease; }
338
445
  .sx-add:hover { background: color-mix(in srgb, var(--sg-accent, #4f46e5) 10%, transparent); }
446
+ /* A nested group. The left rule is what makes the nesting readable at a
447
+ glance - without it the rows of a subgroup look like siblings of the rows
448
+ above them, which is exactly the ambiguity the group exists to remove. */
449
+ .sx-group {
450
+ display: flex; flex-direction: column; gap: 8px;
451
+ border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
452
+ border-left: 3px solid color-mix(in srgb, var(--sg-accent, #4f46e5) 55%, transparent);
453
+ border-radius: 9px; padding: 9px; margin-left: 2px;
454
+ background: color-mix(in srgb, currentColor 3%, transparent);
455
+ }
456
+ .sx-group-head { display: flex; align-items: center; gap: 6px; }
457
+ .sx-group-spacer { flex: 1; }
458
+ .sx-neg {
459
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
460
+ background: transparent; color: inherit; opacity: 0.6;
461
+ border-radius: 6px; padding: 2px 8px; font-size: 10.5px; font-weight: 700;
462
+ letter-spacing: 0.04em; cursor: pointer; transition: background 0.13s ease, opacity 0.13s ease;
463
+ }
464
+ .sx-neg:hover { opacity: 1; }
465
+ .sx-neg-on {
466
+ background: var(--sg-danger, #dc2626); border-color: var(--sg-danger, #dc2626);
467
+ color: #fff; opacity: 1;
468
+ }
469
+ .sx-not { font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; color: var(--sg-danger, #dc2626); }
339
470
  .sx-error { margin: 0; font-size: 12px; color: var(--sg-danger, #dc2626); }
340
471
  .sx-hint { margin: 0; font-size: 12px; opacity: 0.55; }
341
472
  </style>