@kupcad/ui 0.0.0-stage → 0.1.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 (45) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +281 -2
  3. package/package.json +65 -3
  4. package/src/ConsolePanel.svelte +139 -0
  5. package/src/ExportMenu.svelte +36 -0
  6. package/src/ParamField.svelte +390 -0
  7. package/src/ParamsPanel.svelte +216 -0
  8. package/src/PreviewPanel.svelte +372 -0
  9. package/src/ShortcutsDialog.svelte +170 -0
  10. package/src/Splitter.svelte +148 -0
  11. package/src/Viewer.svelte +175 -0
  12. package/src/ViewerPane.svelte +745 -0
  13. package/src/base64.js +16 -0
  14. package/src/console.js +78 -0
  15. package/src/controls.css +183 -0
  16. package/src/index.js +40 -0
  17. package/src/measures.js +52 -0
  18. package/src/params.js +348 -0
  19. package/src/range.js +19 -0
  20. package/src/recent.js +44 -0
  21. package/src/run-status.js +78 -0
  22. package/src/settings.js +82 -0
  23. package/src/shortcuts.js +59 -0
  24. package/src/theme.js +131 -0
  25. package/src/tokens.css +70 -0
  26. package/src/values.svelte.js +13 -0
  27. package/src/view-settings.js +81 -0
  28. package/src/viewer/appearance.js +90 -0
  29. package/src/viewer/camera.js +100 -0
  30. package/src/viewer/dispose.js +30 -0
  31. package/src/viewer/environment.js +145 -0
  32. package/src/viewer/faces.js +311 -0
  33. package/src/viewer/hover-mark.js +125 -0
  34. package/src/viewer/index.js +7 -0
  35. package/src/viewer/model.js +293 -0
  36. package/src/viewer/motion.js +96 -0
  37. package/src/viewer/section.js +166 -0
  38. package/src/viewer/testing.js +90 -0
  39. package/src/viewer/theme.js +32 -0
  40. package/src/viewer/types.d.ts +136 -0
  41. package/src/viewer/view-cube.js +314 -0
  42. package/src/viewer/viewer.js +1027 -0
  43. package/src/viewer/wheel.js +55 -0
  44. package/src/viewport-socket.js +64 -0
  45. package/src/vscode.css +49 -0
@@ -0,0 +1,390 @@
1
+ <!--
2
+ @component
3
+ One parameter, in rows: its title (its name where it has none) with a revert while the panel
4
+ overrides it, its description, its control as its rules say (params.js's controlOf) with its
5
+ unit, and why its value was refused: by the panel's own check, or by the run (`refused`, the
6
+ frame's error naming it, and `refusedAt`, where in its value), the control there marked. A
7
+ tuple's places and a Map's fields are controls side by side, each labelled on top; a list is
8
+ its elements, each a control (or a block of them) with a remove, and an add, as its
9
+ `min_items` and `max_items` allow.
10
+ -->
11
+ <script>
12
+ import {
13
+ canAdd,
14
+ canRemove,
15
+ controlOf,
16
+ newElement,
17
+ partAt,
18
+ partsOf,
19
+ sameValue,
20
+ stepOf,
21
+ validate,
22
+ withPart,
23
+ } from './params.js';
24
+ import { fillOf } from './range.js';
25
+
26
+ /** @import { ParamSpec, PathStep, Rule } from '@kupcad/protocol/types' */
27
+ /** @import { Value } from './params.js' */
28
+
29
+ /**
30
+ * @typedef {object} Props
31
+ * @property {ParamSpec} spec
32
+ * @property {Value | null} value what the control shows
33
+ * @property {boolean} overridden the panel sets it, in place of the script's value
34
+ * @property {string | null} refused why the run refused its value
35
+ * @property {PathStep[]} [refusedAt] where in its value the run refused it (all of it: empty)
36
+ * @property {(value: Value) => void} onset a value the script takes was given
37
+ * @property {() => void} onrevert
38
+ */
39
+
40
+ /** @type {Props} */
41
+ let { spec, value, overridden, refused, refusedAt = [], onset, onrevert } = $props();
42
+
43
+ const id = $props.id();
44
+ const errorId = `${id}-error`;
45
+ const descriptionId = `${id}-description`;
46
+ const labelId = `${id}-label`;
47
+ const control = $derived(controlOf(spec));
48
+ /** A list's elements, as it shows them (none while it has no value). */
49
+ const items = $derived(control === 'list' && Array.isArray(value) ? value : []);
50
+ /** A list's element's rule. */
51
+ const element = $derived(/** @type {Rule} */ (spec.items ?? spec));
52
+ /** Whether a list's elements are blocks of controls: tuples, or Maps. */
53
+ const blocks = $derived(
54
+ control === 'list' && (element.type === 'array' || element.type === 'map'),
55
+ );
56
+ /** What the control is labelled by: its title, else its name. */
57
+ const label = $derived(spec.title || spec.name);
58
+ /**
59
+ * Why the value last given isn't one the script takes, where in it, and the value shown then:
60
+ * another (a revert, the next run's) takes it away.
61
+ */
62
+ let refusal = $state(
63
+ /** @type {{ why: string, path: PathStep[], shown: Value | null } | null} */ (null),
64
+ );
65
+ // By what it holds: the array a state keeps is a copy, never the one shown
66
+ const invalid = $derived(refusal && sameValue(refusal.shown, value) ? refusal : null);
67
+ const message = $derived(invalid?.why ?? refused);
68
+ /** Where in the value the message is about: the panel's refusal's place, else the run's. */
69
+ const wrongAt = $derived(invalid ? invalid.path : refused !== null ? refusedAt : null);
70
+
71
+ /**
72
+ * What ties the control at `path` to the description and the message, and marks it while the
73
+ * message is about it.
74
+ * @param {PathStep[]} path
75
+ */
76
+ function described(path) {
77
+ const wrong = wrongAt !== null && sameValue(wrongAt, path);
78
+ return {
79
+ 'aria-invalid': wrong ? true : undefined,
80
+ 'aria-describedby':
81
+ [spec.description && descriptionId, message !== null && errorId]
82
+ .filter(Boolean)
83
+ .join(' ') || undefined,
84
+ };
85
+ }
86
+
87
+ /** @param {Value} input the whole value */
88
+ function commit(input) {
89
+ const result = validate(spec, input);
90
+ if ('error' in result) {
91
+ refusal = { why: result.error, path: result.path, shown: value };
92
+ return;
93
+ }
94
+ refusal = null;
95
+ onset(result.value);
96
+ }
97
+
98
+ function revert() {
99
+ refusal = null;
100
+ onrevert();
101
+ }
102
+
103
+ /** @param {Event} event */
104
+ const inputOf = (event) => /** @type {HTMLInputElement} */ (event.currentTarget).value;
105
+
106
+ /**
107
+ * Fills a slider's track to its thumb as it is dragged: its value is given only once let go.
108
+ * @param {Event} event
109
+ */
110
+ function dragged(event) {
111
+ const slider = /** @type {HTMLInputElement} */ (event.currentTarget);
112
+ slider.style.setProperty('--kc-range-fill', fillOf(slider.value, slider.min, slider.max));
113
+ }
114
+ </script>
115
+
116
+ <!--
117
+ The control of a scalar of `rule` (the parameter, or a part of its value at `path`), showing
118
+ `shown`; `named` ties it to its label (an id) or names it (an aria-label).
119
+ -->
120
+ {#snippet scalar(
121
+ /** @type {Rule} */ rule,
122
+ /** @type {Value | null} */ shown,
123
+ /** @type {PathStep[]} */ path,
124
+ /** @type {{ id?: string, 'aria-label'?: string }} */ named,
125
+ )}
126
+ {@const kind = controlOf(rule)}
127
+ {@const chosen = (rule.in ?? []).findIndex((choice) => String(choice) === String(shown))}
128
+ {@const give = (/** @type {Value} */ input) => commit(withPart(value, path, input))}
129
+ {#if kind === 'slider'}
130
+ <input
131
+ class="kc-range"
132
+ type="range"
133
+ min={rule.min}
134
+ max={rule.max}
135
+ step={stepOf(rule)}
136
+ value={shown ?? ''}
137
+ style:--kc-range-fill={fillOf(shown, rule.min, rule.max)}
138
+ aria-label="{named['aria-label'] ?? label} slider"
139
+ oninput={dragged}
140
+ onchange={(event) => give(inputOf(event))}
141
+ {...described(path)}
142
+ />
143
+ {/if}
144
+ {#if kind === 'slider' || kind === 'number'}
145
+ <input
146
+ {...named}
147
+ class="kc-control"
148
+ type="number"
149
+ min={rule.min}
150
+ max={rule.max}
151
+ step={stepOf(rule)}
152
+ value={shown ?? ''}
153
+ onchange={(event) => give(inputOf(event))}
154
+ {...described(path)}
155
+ />
156
+ {:else if kind === 'select'}
157
+ <select
158
+ {...named}
159
+ class="kc-control"
160
+ onchange={(event) =>
161
+ give(/** @type {Value[]} */ (rule.in)[Number(event.currentTarget.value)])}
162
+ {...described(path)}
163
+ >
164
+ <!-- No choice yet (it has no default, or the script computes it, and no run took one) -->
165
+ {#if chosen === -1}<option value="" selected disabled></option>{/if}
166
+ {#each rule.in ?? [] as choice, index (index)}
167
+ <option value={index} selected={index === chosen}>{choice}</option>
168
+ {/each}
169
+ </select>
170
+ {:else if kind === 'toggle'}
171
+ <input
172
+ {...named}
173
+ class="kc-check"
174
+ type="checkbox"
175
+ checked={shown === true}
176
+ onchange={(event) => give(event.currentTarget.checked)}
177
+ {...described(path)}
178
+ />
179
+ {:else}
180
+ <input
181
+ {...named}
182
+ class="kc-control"
183
+ type="text"
184
+ value={shown ?? ''}
185
+ onchange={(event) => give(inputOf(event))}
186
+ {...described(path)}
187
+ />
188
+ {/if}
189
+ {#if rule.unit}<span class="unit">{rule.unit}</span>{/if}
190
+ {/snippet}
191
+
192
+ <!-- A tuple's places, or a Map's fields, of the value at `path`: side by side, labelled on top -->
193
+ {#snippet parts(
194
+ /** @type {Rule} */ rule,
195
+ /** @type {Value | null} */ shown,
196
+ /** @type {PathStep[]} */ path,
197
+ )}
198
+ <div class="parts">
199
+ {#each partsOf(rule) as part (part.step)}
200
+ {@const at = [...path, part.step]}
201
+ <!-- Of a separator the field's own ids don't have: a field may be named `label` -->
202
+ {@const partId = `${id}/${at.join('/')}`}
203
+ <div class="part">
204
+ <label for={partId}>{part.label}</label>
205
+ <div class="control">
206
+ {@render scalar(part.rule, partAt(shown, [part.step]), at, { id: partId })}
207
+ </div>
208
+ </div>
209
+ {/each}
210
+ </div>
211
+ {/snippet}
212
+
213
+ <div class="field" class:overridden>
214
+ <div class="head">
215
+ <!-- The name the script reads it by, where the title hides it -->
216
+ <label
217
+ id={labelId}
218
+ for={control === 'list' || control === 'tuple' || control === 'record' ? undefined : id}
219
+ title={spec.title ? spec.name : undefined}>{label}</label
220
+ >
221
+ {#if overridden}
222
+ <button type="button" class="revert" aria-label="Revert {label}" onclick={revert}> ↺ </button>
223
+ {/if}
224
+ </div>
225
+ {#if spec.description}
226
+ <p class="description" id={descriptionId}>{spec.description}</p>
227
+ {/if}
228
+ {#if control === 'list'}
229
+ <ol class="list" aria-labelledby={labelId}>
230
+ {#each items as item, index (index)}
231
+ <li class={blocks ? 'block' : 'control'}>
232
+ {#if blocks}
233
+ <div class="block-head">
234
+ <span class="index">[{index}]</span>
235
+ {@render remove(index)}
236
+ </div>
237
+ <div role="group" aria-label="{label} [{index}]">
238
+ {@render parts(element, item, [index])}
239
+ </div>
240
+ {:else}
241
+ {@render scalar(element, item, [index], { 'aria-label': `${label} [${index}]` })}
242
+ {@render remove(index)}
243
+ {/if}
244
+ </li>
245
+ {/each}
246
+ </ol>
247
+ <button
248
+ type="button"
249
+ class="add"
250
+ aria-label="Add to {label}"
251
+ disabled={!canAdd(spec, items.length)}
252
+ onclick={() => commit([...items, newElement(spec, items)])}>+ Add</button
253
+ >
254
+ {:else if control === 'tuple' || control === 'record'}
255
+ <div role="group" aria-labelledby={labelId}>
256
+ {@render parts(spec, value, [])}
257
+ </div>
258
+ {:else}
259
+ <div class="control">
260
+ {@render scalar(spec, value, [], { id })}
261
+ </div>
262
+ {/if}
263
+ {#if message !== null}
264
+ <p class="error" id={errorId} role="alert">{message}</p>
265
+ {/if}
266
+ </div>
267
+
268
+ {#snippet remove(/** @type {number} */ index)}
269
+ <button
270
+ type="button"
271
+ class="remove"
272
+ aria-label="Remove {label} [{index}]"
273
+ disabled={!canRemove(spec, items.length)}
274
+ onclick={() => commit(items.filter((_, at) => at !== index))}>−</button
275
+ >
276
+ {/snippet}
277
+
278
+ <style>
279
+ .field {
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 4px;
283
+ padding: 6px 0;
284
+ }
285
+ /* A line between one parameter and the next */
286
+ .field + .field {
287
+ border-top: 1px solid var(--kc-border);
288
+ }
289
+ .head {
290
+ display: flex;
291
+ align-items: center;
292
+ justify-content: space-between;
293
+ gap: 4px;
294
+ }
295
+ label {
296
+ color: var(--kc-muted);
297
+ overflow-wrap: anywhere;
298
+ }
299
+ .description {
300
+ margin: 0;
301
+ color: var(--kc-muted);
302
+ font-size: 0.92em;
303
+ }
304
+ .overridden label {
305
+ color: var(--kc-fg);
306
+ font-weight: 600;
307
+ }
308
+ .control {
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 4px;
312
+ min-width: 0;
313
+ }
314
+ /* As wide as the row leaves it: its own width (a browser's 129px) would widen the panel */
315
+ input[type='range'] {
316
+ flex: 1;
317
+ width: 0;
318
+ min-width: 4em;
319
+ }
320
+ .kc-control {
321
+ min-width: 0;
322
+ width: 5em;
323
+ padding: 2px 4px;
324
+ }
325
+ input[type='text'] {
326
+ flex: 1;
327
+ }
328
+ .unit {
329
+ color: var(--kc-muted);
330
+ }
331
+ .revert,
332
+ .remove,
333
+ .add {
334
+ font: inherit;
335
+ color: var(--kc-accent-text);
336
+ background: none;
337
+ border: none;
338
+ cursor: pointer;
339
+ padding: 0 2px;
340
+ }
341
+ .list {
342
+ display: flex;
343
+ flex-direction: column;
344
+ gap: 4px;
345
+ margin: 0;
346
+ padding: 0;
347
+ list-style: none;
348
+ }
349
+ .add {
350
+ align-self: flex-start;
351
+ }
352
+ .remove:disabled,
353
+ .add:disabled {
354
+ color: var(--kc-muted);
355
+ cursor: default;
356
+ }
357
+ /* A tuple's places, a Map's fields: side by side, wrapping where the panel is narrow */
358
+ .parts {
359
+ display: flex;
360
+ flex-wrap: wrap;
361
+ gap: 4px 8px;
362
+ }
363
+ .part {
364
+ display: flex;
365
+ flex-direction: column;
366
+ gap: 2px;
367
+ min-width: 0;
368
+ }
369
+ /* An element of a list of tuples or Maps */
370
+ .block {
371
+ display: flex;
372
+ flex-direction: column;
373
+ gap: 4px;
374
+ padding: 4px 6px;
375
+ border: 1px solid var(--kc-border);
376
+ border-radius: 3px;
377
+ }
378
+ .block-head {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: space-between;
382
+ }
383
+ .index {
384
+ color: var(--kc-muted);
385
+ }
386
+ .error {
387
+ margin: 0;
388
+ color: var(--kc-error);
389
+ }
390
+ </style>
@@ -0,0 +1,216 @@
1
+ <!--
2
+ @component
3
+ The parameters' controls, from the schema a frame (or `kupcad doc`) gives: grouped by their
4
+ `group:`, each showing the value set in the panel, else the one the run took. `overrides` is
5
+ what the panel sets (bindable), each change (and revert) given to `onchange`, which a host
6
+ sends to the core; a value refused by the run is shown on its control. A bar at the top, held
7
+ there as the panel scrolls, says how many values are set; while any is, its Reset all reverts
8
+ them all at once, the panel's refusals with them. The button keeps its place when there is
9
+ nothing to reset, so nothing under the bar moves as a first value is set or the last reverted.
10
+ A value set back to the parameter's own (the one it showed before the panel set it: the run's,
11
+ the script's or a host's preset's) is a revert, not a value set.
12
+ -->
13
+ <script>
14
+ import ParamField from './ParamField.svelte';
15
+ import {
16
+ groupsOf,
17
+ isOverridden,
18
+ sameValue,
19
+ shownValue,
20
+ withOverride,
21
+ withoutOverride,
22
+ } from './params.js';
23
+
24
+ /** @import { Failure, ParamSpec, ParamValue, ParamValues, UiSchema } from '@kupcad/protocol/types' */
25
+
26
+ /**
27
+ * @typedef {object} Props
28
+ * @property {UiSchema} schema
29
+ * @property {ParamValues} [values] the values the run took
30
+ * @property {Failure | null} [error] the frame's error: the parameter it refused is marked,
31
+ * and the place in its value
32
+ * @property {ParamValues} [overrides] the values the panel sets; bindable
33
+ * @property {(overrides: ParamValues) => void} [onchange]
34
+ */
35
+
36
+ /** @type {Props} */
37
+ let { schema, values = {}, error = null, overrides = $bindable({}), onchange } = $props();
38
+
39
+ const groups = $derived(groupsOf(schema.parameters));
40
+ const set = $derived(Object.keys(overrides).length);
41
+ /** How many times all were reset: the fields are made again, and their refusals go. */
42
+ let resets = $state(0);
43
+
44
+ /**
45
+ * Each parameter's own value, which setting it to again reverts it: the one it showed before
46
+ * the panel first set it. Taken again from a later run, not from `stale`, the run of the
47
+ * panel's value just reverted, until the next comes.
48
+ * @type {Map<string, { value: ParamValue | null, stale: ParamValues | null }>}
49
+ */
50
+ const own = new Map();
51
+
52
+ /** @param {ParamValues} next */
53
+ function change(next) {
54
+ overrides = next;
55
+ onchange?.(next);
56
+ }
57
+
58
+ /**
59
+ * The panel sets `spec` to `value`; to its own value, it sets it no more.
60
+ * @param {ParamSpec} spec
61
+ * @param {ParamValue} value
62
+ */
63
+ function setParam(spec, value) {
64
+ if (!isOverridden(overrides, spec.name) && own.get(spec.name)?.stale !== values) {
65
+ own.set(spec.name, { value: shownValue(spec, overrides, values), stale: null });
66
+ }
67
+ if (sameValue(value, own.get(spec.name)?.value)) revert(spec.name);
68
+ else change(withOverride(overrides, spec.name, value));
69
+ }
70
+
71
+ /** @param {string} name */
72
+ function revert(name) {
73
+ const kept = own.get(name);
74
+ if (kept) own.set(name, { ...kept, stale: values });
75
+ change(withoutOverride(overrides, name));
76
+ }
77
+
78
+ function resetAll() {
79
+ for (const [name, kept] of own) {
80
+ if (isOverridden(overrides, name)) own.set(name, { ...kept, stale: values });
81
+ }
82
+ resets += 1;
83
+ change({});
84
+ }
85
+ </script>
86
+
87
+ <form class="kc-params" onsubmit={(event) => event.preventDefault()}>
88
+ <div class="bar">
89
+ <span class="set">{set === 0 ? "The script's values" : `${set} changed`}</span>
90
+ <!-- Unseen, not gone, while nothing is set: its room kept, out of reach of the keys -->
91
+ <button
92
+ type="button"
93
+ class="kc-control reset"
94
+ title="Every parameter back to the script's value"
95
+ style:visibility={set > 0 ? null : 'hidden'}
96
+ onclick={resetAll}
97
+ >
98
+ Reset all
99
+ </button>
100
+ </div>
101
+ {#if schema.meta.title}<h2>{schema.meta.title}</h2>{/if}
102
+ {#if schema.meta.description}<p class="description">{schema.meta.description}</p>{/if}
103
+ {#snippet fields(/** @type {import('@kupcad/protocol/types').ParamSpec[]} */ parameters)}
104
+ <!-- Made again on a reset, not the groups around them: a folded one stays so -->
105
+ {#key resets}
106
+ {#each parameters as spec (spec.name)}
107
+ <ParamField
108
+ {spec}
109
+ value={shownValue(spec, overrides, values)}
110
+ overridden={isOverridden(overrides, spec.name)}
111
+ refused={error?.param === spec.name ? error.message : null}
112
+ refusedAt={error?.param === spec.name ? error.path : []}
113
+ onset={(value) => setParam(spec, value)}
114
+ onrevert={() => revert(spec.name)}
115
+ />
116
+ {/each}
117
+ {/key}
118
+ {/snippet}
119
+ {#each groups as { group, parameters } (group)}
120
+ {#if group}
121
+ <!-- A group under its title, folded away with it -->
122
+ <details class="group" open>
123
+ <summary>{group}</summary>
124
+ <div class="fields" role="group" aria-label={group}>{@render fields(parameters)}</div>
125
+ </details>
126
+ {:else}
127
+ <div class="fields">{@render fields(parameters)}</div>
128
+ {/if}
129
+ {:else}
130
+ <p class="empty">This script has no parameters.</p>
131
+ {/each}
132
+ </form>
133
+
134
+ <style>
135
+ .kc-params {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: var(--kc-gap);
139
+ font-family: var(--kc-font);
140
+ font-size: var(--kc-font-size);
141
+ color: var(--kc-fg);
142
+ background: var(--kc-bg);
143
+ }
144
+ h2 {
145
+ margin: 0;
146
+ font-size: 1.1em;
147
+ }
148
+ .description,
149
+ .empty {
150
+ margin: 0;
151
+ color: var(--kc-muted);
152
+ }
153
+ /* Always there, a line under it, held at the top of the column as it scrolls: its own space
154
+ above it, so what scrolls under it shows nowhere above it (a host's column gives none) */
155
+ .bar {
156
+ position: sticky;
157
+ top: 0;
158
+ z-index: 1;
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: space-between;
162
+ gap: var(--kc-gap);
163
+ padding: var(--kc-gap) 0 6px;
164
+ background: var(--kc-bg);
165
+ border-bottom: 1px solid var(--kc-border);
166
+ }
167
+ .set {
168
+ min-width: 0;
169
+ overflow: hidden;
170
+ text-overflow: ellipsis;
171
+ white-space: nowrap;
172
+ color: var(--kc-muted);
173
+ }
174
+ .reset {
175
+ flex: none;
176
+ }
177
+ /* Parameters, a line between each (ParamField) */
178
+ .fields {
179
+ display: flex;
180
+ flex-direction: column;
181
+ min-width: 0;
182
+ padding: 0 var(--kc-gap);
183
+ }
184
+ /* A group stands apart from its controls: a section with a header of its own */
185
+ .group {
186
+ border: 1px solid var(--kc-border);
187
+ border-radius: var(--kc-radius);
188
+ overflow: hidden;
189
+ }
190
+ summary {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 6px;
194
+ padding: 5px var(--kc-gap);
195
+ font-size: 0.85em;
196
+ font-weight: 600;
197
+ letter-spacing: 0.04em;
198
+ text-transform: uppercase;
199
+ color: var(--kc-section-fg);
200
+ background: var(--kc-section-bg);
201
+ cursor: pointer;
202
+ list-style: none;
203
+ }
204
+ summary::-webkit-details-marker {
205
+ display: none;
206
+ }
207
+ summary::before {
208
+ content: '▾';
209
+ }
210
+ .group:not([open]) summary::before {
211
+ content: '▸';
212
+ }
213
+ .group[open] summary {
214
+ border-bottom: 1px solid var(--kc-border);
215
+ }
216
+ </style>