@kitnai/chat 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,16 @@ declare module 'solid-js' {
16
16
  sandbox?: string;
17
17
  'iframe-title'?: string;
18
18
  ref?: (el: HTMLElement) => void;
19
+ // toolbar composition flags (all boolean attrs)
20
+ expandable?: boolean | string;
21
+ 'open-in-tab'?: boolean | string;
22
+ 'no-nav'?: boolean | string;
23
+ 'no-reload'?: boolean | string;
24
+ 'no-home'?: boolean | string;
25
+ 'no-path-field'?: boolean | string;
26
+ 'no-tabs'?: boolean | string;
27
+ standalone?: boolean | string;
28
+ 'readonly-path'?: boolean | string;
19
29
  };
20
30
  }
21
31
  }
@@ -289,3 +299,207 @@ export const WithEvents: Story = {
289
299
  );
290
300
  },
291
301
  };
302
+
303
+ const CONFIGURABLE_TOOLBAR_SNIPPET = `<!-- All toolbar affordances are individually opt-in/out via attributes. -->
304
+
305
+ <!-- The five default-shown items each have a "no-*" flag to hide them: -->
306
+ <kc-artifact
307
+ src="…"
308
+ no-nav
309
+ no-reload
310
+ no-home
311
+ ></kc-artifact>
312
+
313
+ <!-- Two new buttons are OPT-IN (hidden by default): -->
314
+ <kc-artifact
315
+ src="…"
316
+ expandable
317
+ open-in-tab
318
+ ></kc-artifact>
319
+
320
+ <!-- Standalone chrome: rounded corners + border (default is square/borderless in-panel): -->
321
+ <kc-artifact src="…" standalone></kc-artifact>
322
+
323
+ <!-- Read-only path: visible, nav-tracking, non-editable: -->
324
+ <kc-artifact src="…" readonly-path></kc-artifact>`;
325
+
326
+ /**
327
+ * Every toolbar affordance is individually configurable.
328
+ *
329
+ * **Default-shown (`no-*` flags hide):** back/forward (`no-nav`), reload (`no-reload`),
330
+ * home (`no-home`), address field (`no-path-field`), Preview|Code toggle (`no-tabs`).
331
+ *
332
+ * **Opt-in (hidden by default; positive attr enables):** expand-to-fill (`expandable`),
333
+ * open-in-new-tab (`open-in-tab`).
334
+ *
335
+ * **Chrome:** `standalone` adds rounded corners + border (default = square/borderless
336
+ * for in-panel use). `readonly-path` makes the address field visible but non-editable.
337
+ *
338
+ * When ALL affordances are hidden the toolbar bar is omitted entirely (zero height).
339
+ */
340
+ export const ConfigurableToolbar: Story = {
341
+ name: 'Configurable toolbar',
342
+ render: (args: {
343
+ expandable?: boolean;
344
+ openInTab?: boolean;
345
+ noNav?: boolean;
346
+ noReload?: boolean;
347
+ noHome?: boolean;
348
+ noPathField?: boolean;
349
+ noTabs?: boolean;
350
+ standalone?: boolean;
351
+ readonlyPath?: boolean;
352
+ }) => {
353
+ let el: HTMLElement & { files?: ArtifactFile[] };
354
+ onMount(() => { if (el) el.files = FILES; });
355
+ return (
356
+ <Frame>
357
+ <kc-artifact
358
+ ref={(e) => (el = e as HTMLElement & { files?: ArtifactFile[] })}
359
+ src={`${BASE}/index.html`}
360
+ iframe-title="Starboard artifact preview"
361
+ expandable={args.expandable || undefined}
362
+ open-in-tab={args.openInTab || undefined}
363
+ no-nav={args.noNav || undefined}
364
+ no-reload={args.noReload || undefined}
365
+ no-home={args.noHome || undefined}
366
+ no-path-field={args.noPathField || undefined}
367
+ no-tabs={args.noTabs || undefined}
368
+ standalone={args.standalone || undefined}
369
+ readonly-path={args.readonlyPath || undefined}
370
+ />
371
+ </Frame>
372
+ );
373
+ },
374
+ args: {
375
+ expandable: false,
376
+ openInTab: false,
377
+ noNav: false,
378
+ noReload: false,
379
+ noHome: false,
380
+ noPathField: false,
381
+ noTabs: false,
382
+ standalone: false,
383
+ readonlyPath: false,
384
+ },
385
+ argTypes: {
386
+ expandable: { control: 'boolean', description: 'Show the expand-to-fill button (opt-in).' },
387
+ openInTab: { control: 'boolean', name: 'open-in-tab', description: 'Show the open-in-new-tab button (opt-in).' },
388
+ noNav: { control: 'boolean', name: 'no-nav', description: 'Hide the back/forward buttons.' },
389
+ noReload: { control: 'boolean', name: 'no-reload', description: 'Hide the reload button.' },
390
+ noHome: { control: 'boolean', name: 'no-home', description: 'Hide the home button.' },
391
+ noPathField: { control: 'boolean', name: 'no-path-field', description: 'Hide the address field.' },
392
+ noTabs: { control: 'boolean', name: 'no-tabs', description: 'Hide the Preview|Code toggle.' },
393
+ standalone: { control: 'boolean', description: 'Standalone chrome (rounded + border).' },
394
+ readonlyPath: { control: 'boolean', name: 'readonly-path', description: 'Address field is visible but non-editable.' },
395
+ },
396
+ parameters: { docs: { source: { code: CONFIGURABLE_TOOLBAR_SNIPPET, language: 'html' } } },
397
+ };
398
+
399
+ const MINIMAL_SNIPPET = `<!-- Minimal: only the preview iframe, no toolbar at all. -->
400
+ <kc-artifact
401
+ src="…"
402
+ no-nav
403
+ no-reload
404
+ no-home
405
+ no-path-field
406
+ no-tabs
407
+ ></kc-artifact>`;
408
+
409
+ /**
410
+ * All five default-shown toolbar affordances suppressed — the toolbar bar
411
+ * disappears entirely (zero height), leaving just the preview iframe.
412
+ * Useful for an embedded, chrome-free artifact tile.
413
+ */
414
+ export const MinimalPreview: Story = {
415
+ name: 'Minimal (preview-only)',
416
+ render: () => (
417
+ <Frame>
418
+ <kc-artifact
419
+ src={`${BASE}/index.html`}
420
+ iframe-title="Starboard artifact preview"
421
+ no-nav
422
+ no-reload
423
+ no-home
424
+ no-path-field
425
+ no-tabs
426
+ />
427
+ </Frame>
428
+ ),
429
+ parameters: { docs: { source: { code: MINIMAL_SNIPPET, language: 'html' } } },
430
+ };
431
+
432
+ const OPEN_IN_TAB_SNIPPET = `<!-- open-in-tab: adds a button that opens the current URL in a new tab.
433
+ The button is disabled while the src is blank. -->
434
+ <kc-artifact
435
+ src="https://your-backend.example/artifacts/abc/index.html"
436
+ open-in-tab
437
+ ></kc-artifact>`;
438
+
439
+ /**
440
+ * The **open-in-tab** button (opt-in via `open-in-tab`) opens the current
441
+ * preview URL in a new browser tab (`window.open`, `noopener,noreferrer`).
442
+ * It is disabled while the URL is empty / `about:blank` and follows the live
443
+ * navigation state — clicking it after the user has navigated to a sub-page
444
+ * opens that sub-page, not the original `src`.
445
+ */
446
+ export const OpenInTab: Story = {
447
+ name: 'Open in new tab',
448
+ render: () => {
449
+ let el: HTMLElement & { files?: ArtifactFile[] };
450
+ onMount(() => { if (el) el.files = FILES; });
451
+ return (
452
+ <Frame>
453
+ <kc-artifact
454
+ ref={(e) => (el = e as HTMLElement & { files?: ArtifactFile[] })}
455
+ src={`${BASE}/index.html`}
456
+ iframe-title="Starboard artifact preview"
457
+ open-in-tab
458
+ />
459
+ </Frame>
460
+ );
461
+ },
462
+ parameters: { docs: { source: { code: OPEN_IN_TAB_SNIPPET, language: 'html' } } },
463
+ };
464
+
465
+ const STANDALONE_SNIPPET = `<!-- standalone: adds rounded corners + a border (like a card).
466
+ Suppresses the expand button even when expandable is set.
467
+ Use for an artifact rendered outside any panel layout. -->
468
+ <kc-artifact
469
+ src="…"
470
+ standalone
471
+ style="display:block;height:520px"
472
+ ></kc-artifact>`;
473
+
474
+ /**
475
+ * `standalone` adds rounded corners and a border — the "card" chrome for an
476
+ * artifact rendered outside any panel layout (e.g. as a modal or inline tile).
477
+ * In the default in-panel mode the element is square and borderless so it fits
478
+ * flush inside a `<kc-resizable>` panel without double-borders.
479
+ *
480
+ * `standalone` also suppresses the expand button (there is no enclosing resizable
481
+ * to maximize into) regardless of `expandable`.
482
+ *
483
+ * `readonly-path` keeps the address field visible and navigation-tracking but
484
+ * non-editable — useful when the consumer drives the URL programmatically.
485
+ */
486
+ export const StandaloneChrome: Story = {
487
+ name: 'Standalone chrome + read-only path',
488
+ render: () => {
489
+ let el: HTMLElement & { files?: ArtifactFile[] };
490
+ onMount(() => { if (el) el.files = FILES; });
491
+ return (
492
+ <div style={{ padding: '16px', 'max-width': '900px' }}>
493
+ <kc-artifact
494
+ ref={(e) => (el = e as HTMLElement & { files?: ArtifactFile[] })}
495
+ src={`${BASE}/index.html`}
496
+ iframe-title="Starboard artifact preview"
497
+ standalone
498
+ readonly-path
499
+ style={{ display: 'block', height: '480px' }}
500
+ />
501
+ </div>
502
+ );
503
+ },
504
+ parameters: { docs: { source: { code: STANDALONE_SNIPPET, language: 'html' } } },
505
+ };
@@ -1,3 +1,4 @@
1
+ import { createSignal, onMount, onCleanup } from 'solid-js';
1
2
  import { defineWebComponent } from './define';
2
3
  import { Artifact, type ArtifactFile, type ArtifactTab } from '../components/artifact';
3
4
 
@@ -14,6 +15,26 @@ interface Props extends Record<string, unknown> {
14
15
  sandbox?: string;
15
16
  /** Accessible title for the preview iframe. */
16
17
  iframeTitle?: string;
18
+ /** Reflects the artifact's own maximized view-state (usually driven by the protocol). */
19
+ maximized?: boolean;
20
+ /** Show the expand-to-fill button (OPT-IN). */
21
+ expandable?: boolean;
22
+ /** Show the open-in-new-tab button (OPT-IN). */
23
+ openInTab?: boolean;
24
+ /** Hide back/forward. */
25
+ noNav?: boolean;
26
+ /** Hide reload. */
27
+ noReload?: boolean;
28
+ /** Hide home. */
29
+ noHome?: boolean;
30
+ /** Hide the address field. */
31
+ noPathField?: boolean;
32
+ /** Hide the Preview|Code toggle. */
33
+ noTabs?: boolean;
34
+ /** Standalone chrome: rounded corners + border (else square, borderless in-panel). */
35
+ standalone?: boolean;
36
+ /** Show the address but make it read-only (visible, nav-tracking, non-editable). */
37
+ readonlyPath?: boolean;
17
38
  }
18
39
 
19
40
  interface Events extends Record<string, unknown> {
@@ -23,6 +44,8 @@ interface Events extends Record<string, unknown> {
23
44
  tabchange: { tab: ArtifactTab };
24
45
  /** Fired when a file is selected. `detail.path`. */
25
46
  fileselect: { path: string };
47
+ /** Artifact's own maximize button toggled (consumer-observable; non-bubbling). */
48
+ maximizechange: { maximized: boolean };
26
49
  }
27
50
 
28
51
  /**
@@ -40,33 +63,75 @@ defineWebComponent<Props, Events>('kc-artifact', {
40
63
  activeFile: undefined,
41
64
  sandbox: 'allow-scripts allow-forms',
42
65
  iframeTitle: undefined,
43
- }, (props, { dispatch }) => (
44
- <>
45
- {/* The artifact fills its container; the internal column flex (toolbar
46
- flex-shrink:0, body flex:1/min-height:0) is in the Solid component. Wrap
47
- in a definite `1fr` grid cell (NOT :host) so the facade's sibling
48
- portal-mount div can't steal a grid track — see resizable.tsx. */}
49
- <style>{':host{display:block;height:100%;min-height:0}'}</style>
50
- <div
51
- style={{
52
- display: 'grid',
53
- 'grid-template-rows': 'minmax(0, 1fr)',
54
- 'grid-template-columns': 'minmax(0, 1fr)',
55
- height: '100%',
56
- 'min-height': '0',
57
- }}
58
- >
59
- <Artifact
60
- src={props.src}
61
- files={props.files}
62
- tab={props.tab}
63
- activeFile={props.activeFile}
64
- sandbox={props.sandbox}
65
- iframeTitle={props.iframeTitle}
66
- onNavigate={(url) => dispatch('navigate', { url })}
67
- onTabChange={(tab) => dispatch('tabchange', { tab })}
68
- onFileSelect={(path) => dispatch('fileselect', { path })}
69
- />
70
- </div>
71
- </>
72
- ));
66
+ maximized: false,
67
+ expandable: false,
68
+ openInTab: false,
69
+ noNav: false,
70
+ noReload: false,
71
+ noHome: false,
72
+ noPathField: false,
73
+ noTabs: false,
74
+ standalone: false,
75
+ readonlyPath: false,
76
+ }, (props, { element, dispatch, flag }) => {
77
+ const [maximized, setMaximized] = createSignal(flag('maximized'));
78
+
79
+ const onMaximizeChange = (next: boolean) => {
80
+ setMaximized(next);
81
+ // 1) The PROTOCOL intent — raw, bubbling + composed (NOT via dispatch()).
82
+ element.dispatchEvent(
83
+ new CustomEvent('kc-maximize-intent', { detail: { requested: next }, bubbles: true, composed: true }),
84
+ );
85
+ // 2) The PUBLIC observable event (non-bubbling, on the host).
86
+ dispatch('maximizechange', { maximized: next });
87
+ };
88
+
89
+ // Authoritative reconcile: the resizable tells us the effective state.
90
+ onMount(() => {
91
+ const onState = (e: Event) => setMaximized((e as CustomEvent<{ maximized: boolean }>).detail.maximized);
92
+ element.addEventListener('kc-maximize-state', onState);
93
+ onCleanup(() => element.removeEventListener('kc-maximize-state', onState));
94
+ });
95
+
96
+ return (
97
+ <>
98
+ {/* The artifact fills its container; the internal column flex (toolbar
99
+ flex-shrink:0, body flex:1/min-height:0) is in the Solid component. Wrap
100
+ in a definite `1fr` grid cell (NOT :host) so the facade's sibling
101
+ portal-mount div can't steal a grid track — see resizable.tsx. */}
102
+ <style>{':host{display:block;height:100%;min-height:0}'}</style>
103
+ <div
104
+ style={{
105
+ display: 'grid',
106
+ 'grid-template-rows': 'minmax(0, 1fr)',
107
+ 'grid-template-columns': 'minmax(0, 1fr)',
108
+ height: '100%',
109
+ 'min-height': '0',
110
+ }}
111
+ >
112
+ <Artifact
113
+ src={props.src}
114
+ files={props.files}
115
+ tab={props.tab}
116
+ activeFile={props.activeFile}
117
+ sandbox={props.sandbox}
118
+ iframeTitle={props.iframeTitle}
119
+ maximized={maximized()}
120
+ expandable={flag('expandable')}
121
+ openInTab={flag('openInTab')}
122
+ showNav={!flag('noNav')}
123
+ showReload={!flag('noReload')}
124
+ showHome={!flag('noHome')}
125
+ showPathField={!flag('noPathField')}
126
+ showTabs={!flag('noTabs')}
127
+ standalone={flag('standalone')}
128
+ readonlyPath={flag('readonlyPath')}
129
+ onMaximizeChange={onMaximizeChange}
130
+ onNavigate={(url) => dispatch('navigate', { url })}
131
+ onTabChange={(tab) => dispatch('tabchange', { tab })}
132
+ onFileSelect={(path) => dispatch('fileselect', { path })}
133
+ />
134
+ </div>
135
+ </>
136
+ );
137
+ });
@@ -0,0 +1,54 @@
1
+ // src/elements/cards.stories.tsx
2
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
3
+ import { createSignal, onMount } from 'solid-js';
4
+ import './register'; // registers all kc-* incl. kc-cards
5
+ import type { CardEnvelope, CardEvent } from '../primitives/card-contract';
6
+
7
+ declare module 'solid-js' {
8
+ // eslint-disable-next-line @typescript-eslint/no-namespace
9
+ namespace JSX {
10
+ interface IntrinsicElements { 'kc-cards': JSX.HTMLAttributes<HTMLElement>; }
11
+ }
12
+ }
13
+
14
+ const CARDS: CardEnvelope[] = [
15
+ { type: 'confirm', id: 'deploy', title: 'Deploy to production?',
16
+ data: { body: 'Apply 3 migrations and deploy?', tone: 'warning',
17
+ actions: [{ id: 'go', label: 'Deploy', style: 'primary', default: true }, { id: 'no', label: 'Cancel' }] } },
18
+ { type: 'task-list', id: 'plan', title: 'Pick the steps',
19
+ data: { tasks: [{ id: 'a', label: 'Run tests', checked: true }, { id: 'b', label: 'Tag release' }], confirmLabel: 'Run selected' } },
20
+ { type: 'link', id: 'doc', data: { url: 'https://kitn.dev', title: 'kitn.dev', description: 'Generative UI cards', domain: 'kitn.dev' } },
21
+ ];
22
+
23
+ /** A host renders a stream of envelopes via <kc-cards> and handles events via `.policy`. */
24
+ function Demo() {
25
+ let el: HTMLElement | undefined;
26
+ const [log, setLog] = createSignal<string[]>([]);
27
+ onMount(() => {
28
+ if (!el) return;
29
+ (el as unknown as { cards: CardEnvelope[] }).cards = CARDS;
30
+ (el as unknown as { policy: unknown }).policy = {
31
+ onAction: (id: string, action: string) => setLog((l) => [`action: ${id} → ${action}`, ...l]),
32
+ onSubmitData: (id: string, data: unknown) => setLog((l) => [`submit-data: ${id} → ${JSON.stringify(data)}`, ...l]),
33
+ onOpen: (url: string) => setLog((l) => [`open: ${url}`, ...l]),
34
+ onError: (id: string, msg: string) => setLog((l) => [`error: ${id} → ${msg}`, ...l]),
35
+ };
36
+ });
37
+ return (
38
+ <div style={{ display: 'grid', 'grid-template-columns': '1fr 18rem', gap: '1rem', padding: '1rem' }}>
39
+ <kc-cards ref={(e) => (el = e as HTMLElement)} />
40
+ <pre style={{ 'font-size': '12px', background: '#f4f4f5', padding: '8px', 'border-radius': '6px', 'min-height': '6rem' }}>
41
+ {log().join('\n') || 'events appear here…'}
42
+ </pre>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ const meta = {
48
+ title: 'Generative UI/SDK/kc-cards',
49
+ parameters: { layout: 'fullscreen' },
50
+ } satisfies Meta;
51
+ export default meta;
52
+ type Story = StoryObj;
53
+
54
+ export const CardStream: Story = { render: () => <Demo /> };
@@ -0,0 +1,91 @@
1
+ // src/elements/cards.tsx
2
+ // <kc-cards> — the web-component list dispatcher. Renders one child kc-* element per
3
+ // envelope (by type→tag), propagates its theme, and routes children's bubbling
4
+ // `kc-card` events through an optional `policy`. The raw events keep bubbling past
5
+ // <kc-cards> (composed) so document-level listeners still work. Unknown types render
6
+ // the Solid CardFallback inline and emit a contract `error`.
7
+ import { For, Show, createEffect, onCleanup, onMount, type JSX } from 'solid-js';
8
+ import { Dynamic } from 'solid-js/web';
9
+ import { defineWebComponent } from './define';
10
+ import type { CardEnvelope, CardEvent, CardPolicy } from '../primitives/card-contract';
11
+ import { CARD_EVENT_NAME, emitCardEvent, routeCardEvent } from '../primitives/card-routing';
12
+ import { mergeCardTags } from '../primitives/card-registry';
13
+ import { CardFallback } from '../components/card-fallback';
14
+ // Register the built-in child card elements so that importing <kc-cards> is self-contained.
15
+ import './form';
16
+ import './confirm-card';
17
+ import './task-list-card';
18
+ import './link-card';
19
+ import './embed';
20
+
21
+ interface Props extends Record<string, unknown> {
22
+ /** The stream of card envelopes to render. Set as a JS PROPERTY: `el.cards = [...]`. */
23
+ cards?: CardEnvelope[];
24
+ /** Optional type→tag overrides/additions (merged over the built-ins). Property: `el.types`.
25
+ * Typed as a plain string map (not the `CardTagMap` alias) so the generated React
26
+ * wrapper inlines it instead of emitting an unresolved named type. */
27
+ types?: Record<string, string>;
28
+ /** Optional CardPolicy handling child events. Property: `el.policy`. */
29
+ policy?: CardPolicy;
30
+ }
31
+
32
+ /** A single resolved child: a known kc-* tag (props set imperatively) or the fallback. */
33
+ function CardSlot(props: { envelope: CardEnvelope; tag?: string; theme: string; emit: (e: CardEvent) => void }): JSX.Element {
34
+ let ref: HTMLElement | undefined;
35
+ // Set object/string props as DOM properties on the custom element (reactive).
36
+ createEffect(() => {
37
+ if (!ref) return;
38
+ (ref as unknown as { data: unknown }).data = props.envelope.data;
39
+ (ref as unknown as { cardId: string }).cardId = props.envelope.id;
40
+ if (props.envelope.title != null) (ref as unknown as { heading: string }).heading = props.envelope.title;
41
+ ref.setAttribute('theme', props.theme);
42
+ });
43
+ // Hoist the unknown-type error emit to onMount to avoid side-effect-in-JSX lint issue
44
+ // and to ensure exactly one error fires.
45
+ onMount(() => {
46
+ if (!props.tag) {
47
+ props.emit({ kind: 'error', cardId: props.envelope.id, message: `Unsupported card type: ${props.envelope.type}` });
48
+ }
49
+ });
50
+ return (
51
+ <Show
52
+ when={props.tag}
53
+ fallback={<CardFallback type={props.envelope.type} cardId={props.envelope.id} />}
54
+ >
55
+ {(tag) => <Dynamic component={tag()} ref={ref} />}
56
+ </Show>
57
+ );
58
+ }
59
+
60
+ defineWebComponent<Props>(
61
+ 'kc-cards',
62
+ { cards: undefined, types: undefined, policy: undefined },
63
+ (props, { element }) => {
64
+ // Route children's bubbling kc-card events through the policy. Attached to the host
65
+ // element so composed events from each child's shadow root are caught as they bubble.
66
+ // The handler reads `props.policy` at EVENT time (not mount time) so setting
67
+ // `el.policy` after the element is in the DOM — the standard host pattern — works.
68
+ onMount(() => {
69
+ const handler = (e: Event) =>
70
+ routeCardEvent(props.policy ?? {}, (e as CustomEvent<CardEvent>).detail);
71
+ element.addEventListener(CARD_EVENT_NAME, handler as EventListener);
72
+ onCleanup(() => element.removeEventListener(CARD_EVENT_NAME, handler as EventListener));
73
+ });
74
+ const theme = () => (element.getAttribute('theme') ?? 'auto');
75
+ const tags = () => mergeCardTags(props.types);
76
+ return (
77
+ <div class="flex flex-col gap-3">
78
+ <For each={props.cards ?? []}>
79
+ {(env) => (
80
+ <CardSlot
81
+ envelope={env}
82
+ tag={tags()[env.type]}
83
+ theme={theme()}
84
+ emit={(e) => emitCardEvent(element, e)}
85
+ />
86
+ )}
87
+ </For>
88
+ </div>
89
+ );
90
+ },
91
+ );
@@ -32,6 +32,10 @@ defineWebComponent<Props, Events>('kc-checkpoint', {
32
32
  <Checkpoint>
33
33
  <CheckpointTrigger
34
34
  tooltip={props.tooltip}
35
+ // Icon-only (no visible label) needs an accessible name: prefer the
36
+ // tooltip text, else a sensible default. With a visible label, the text
37
+ // is the name — leave aria-label unset so it isn't duplicated.
38
+ aria-label={props.label ? undefined : (props.tooltip ?? 'Checkpoint')}
35
39
  variant={props.variant}
36
40
  size={props.size}
37
41
  onClick={() => dispatch('select')}