@stina/extension-api 0.52.0 → 0.56.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.
@@ -430,6 +430,290 @@ interface ListProps extends ExtensionComponentData {
430
430
  /** Child components to render as list items. Supports iteration. */
431
431
  children: ExtensionComponentChildren;
432
432
  }
433
+ /**
434
+ * A sky the DSL knows how to draw.
435
+ *
436
+ * A closed list rather than free text: the drawing has to be chosen from it, and
437
+ * an unrecognised string would leave a hole where the icon goes. Providers name
438
+ * conditions in wildly different vocabularies, so the caller maps theirs onto
439
+ * this list and passes the provider's own words as `conditionLabel` — the enum
440
+ * picks the picture, the label is what gets read.
441
+ */
442
+ type WeatherCondition = 'clear' | 'partly-cloudy' | 'cloudy' | 'overcast' | 'fog' | 'light-rain' | 'rain' | 'heavy-rain' | 'sleet' | 'light-snow' | 'snow' | 'heavy-snow' | 'thunderstorm' | 'hail' | 'windy';
443
+ /** Wind, as shown by the weather components. */
444
+ interface WeatherWind {
445
+ speed: number;
446
+ /** Written after the speed. Defaults to `m/s`. */
447
+ unit?: string;
448
+ /** Compass direction as the caller words it, e.g. `NV` or `sydväst`. */
449
+ direction?: string;
450
+ /** Gust speed, in the same unit. Shown next to the sustained speed when present. */
451
+ gust?: number;
452
+ }
453
+ /** The extension API properties for the WeatherNow component. */
454
+ interface WeatherNowProps extends ExtensionComponentData {
455
+ component: 'WeatherNow';
456
+ /** Where the reading is from. */
457
+ place: string;
458
+ condition: WeatherCondition;
459
+ /** The condition in the user's own language. Falls back to the host's wording for `condition`. */
460
+ conditionLabel?: string;
461
+ temperature: number;
462
+ /** What it feels like, when that differs enough to be worth saying. */
463
+ feelsLike?: number;
464
+ /** Written after every temperature. Defaults to `°`. */
465
+ unit?: string;
466
+ wind?: WeatherWind;
467
+ /** Draws the night variant of the icon. Only affects conditions that have one. */
468
+ night?: boolean;
469
+ /** One line of context under the numbers, e.g. "Regn från tretiden". */
470
+ summary?: string;
471
+ }
472
+ /**
473
+ * One step of a forecast.
474
+ *
475
+ * Deliberately the same shape whether the steps are hours or days — the only
476
+ * difference is what `label` says and whether there are two temperatures. A
477
+ * separate hourly component would have been the same component twice.
478
+ */
479
+ interface WeatherForecastStep {
480
+ /** Short: `14`, `Tors`, `Nu`. */
481
+ label: string;
482
+ condition: WeatherCondition;
483
+ conditionLabel?: string;
484
+ /** The warmer of the two, or the only one for a single-temperature step. */
485
+ high: number;
486
+ /** The colder one. Absent when the step has a single temperature. */
487
+ low?: number;
488
+ night?: boolean;
489
+ /** Chance of precipitation as a percentage, 0–100. */
490
+ precipitation?: number;
491
+ /**
492
+ * The step the reader is at — this hour, today. Drawn larger than its
493
+ * neighbours, so the eye lands on it before reading the row.
494
+ *
495
+ * Same word as `TimelineEntry.current`, and the same meaning: mark one, and
496
+ * only one. Marking several is marking none.
497
+ */
498
+ current?: boolean;
499
+ }
500
+ /** The extension API properties for the WeatherForecast component. */
501
+ interface WeatherForecastProps extends ExtensionComponentData {
502
+ component: 'WeatherForecast';
503
+ /** Heading above the row, e.g. "Kommande timmar". */
504
+ title?: string;
505
+ /** Icon shown to the left of the title. */
506
+ icon?: HugeIconName;
507
+ /** Written after every temperature. Defaults to `°`. */
508
+ unit?: string;
509
+ steps: WeatherForecastStep[];
510
+ }
511
+ /** How a chart draws its series. */
512
+ type ChartKind = 'line' | 'bar' | 'area';
513
+ /**
514
+ * One series of a chart.
515
+ *
516
+ * `points` lines up with the chart's `labels` by position. A `null` is a gap in
517
+ * the data and is drawn as one — a missing reading joined up as if it were a
518
+ * value is a lie the chart would tell on the caller's behalf.
519
+ */
520
+ interface ChartSeries {
521
+ /** Named in the legend. Required in practice once there is more than one series. */
522
+ name?: string;
523
+ points: Array<number | null>;
524
+ }
525
+ /**
526
+ * The extension API properties for the Chart component.
527
+ *
528
+ * There is no colour option and no second axis, on purpose. Colours come from
529
+ * the host's categorical palette in slot order, so the same series keeps the
530
+ * same colour everywhere and the whole set stays legible to colour-blind
531
+ * readers. Two measurements of different magnitude are two charts.
532
+ */
533
+ interface ChartProps extends ExtensionComponentData {
534
+ component: 'Chart';
535
+ /** Defaults to `line`. */
536
+ chart?: ChartKind;
537
+ /** At most eight. Past that the ninth would need a colour the palette does not have. */
538
+ series: ChartSeries[];
539
+ /** One per point, along the horizontal axis. */
540
+ labels?: string[];
541
+ /** Heading above the plot. With a single series this is what names it, so no legend is drawn. */
542
+ title?: string;
543
+ /** Icon shown to the left of the title. */
544
+ icon?: HugeIconName;
545
+ /** Written after every value, e.g. `°`, ` mm`, ` kr`. */
546
+ unit?: string;
547
+ /**
548
+ * Whether the value axis must include zero. Defaults to true for `bar` and
549
+ * `area`, where the fill's height is the reading, and false for `line`, where
550
+ * forcing zero can flatten the shape the chart exists to show.
551
+ */
552
+ zeroBaseline?: boolean;
553
+ /** Plot height in rem. Defaults to 9. */
554
+ height?: number;
555
+ }
556
+ /** Which way a stat tile's trend points. */
557
+ type StatTrend = 'up' | 'down' | 'flat';
558
+ /**
559
+ * The extension API properties for the StatTile component.
560
+ *
561
+ * For the single number that does not need a plot. A chart of one value is a
562
+ * chart that spends a lot of space saying very little.
563
+ */
564
+ interface StatTileProps extends ExtensionComponentData {
565
+ component: 'StatTile';
566
+ /** What the number is, e.g. "Förbrukning i går". */
567
+ label: string;
568
+ value: string | number;
569
+ /** Written after the value in smaller type, e.g. `kWh`. */
570
+ unit?: string;
571
+ /** One quiet line under the number. */
572
+ caption?: string;
573
+ icon?: HugeIconName;
574
+ /** `flat` draws no arrow. */
575
+ trend?: StatTrend;
576
+ /** The change in words, e.g. "+12 % mot förra veckan". Shown beside the arrow. */
577
+ trendLabel?: string;
578
+ /**
579
+ * Whether up is the good direction. Defaults to true. Colours the trend;
580
+ * the arrow and the label carry the same meaning without it.
581
+ */
582
+ trendIsGood?: boolean;
583
+ }
584
+ /** One row of a KeyValueList. */
585
+ interface KeyValueRow {
586
+ label: string;
587
+ value: string;
588
+ icon?: HugeIconName;
589
+ }
590
+ /**
591
+ * The extension API properties for the KeyValueList component.
592
+ *
593
+ * Facts in two columns, aligned. What a stack of `HorizontalStack`s was always
594
+ * being used for, without each caller having to get the alignment right.
595
+ */
596
+ interface KeyValueListProps extends ExtensionComponentData {
597
+ component: 'KeyValueList';
598
+ rows: KeyValueRow[];
599
+ }
600
+ /** How much a timeline entry stands out. */
601
+ type TimelineVariant = 'default' | 'accent' | 'success' | 'warning' | 'danger';
602
+ /**
603
+ * One entry on a timeline.
604
+ *
605
+ * `time` is a string, not a timestamp, because the component draws order rather
606
+ * than duration: entries sit evenly on the rail whether they are minutes or
607
+ * decades apart. That is what makes the same component work for today's
608
+ * calendar and for a project's milestones, and it means the caller writes the
609
+ * time the way it should be read — `09:00`, `Tors 14 mar`, `2019`.
610
+ */
611
+ interface TimelineEntry {
612
+ /** When it happens, worded as it should be read. */
613
+ time: string;
614
+ title: string;
615
+ /** A line under the title. */
616
+ description?: string;
617
+ icon?: HugeIconName;
618
+ /** How long it lasts, in the caller's own words: `1 h`, `3 dagar`. */
619
+ duration?: string;
620
+ /** Trailing badge, e.g. a place or a category. */
621
+ badge?: string;
622
+ /** Marks the entry the reader is at. Drawn with a filled marker and a heavier rule. */
623
+ current?: boolean;
624
+ /** Already behind us. Drawn recessive, so what is left to come reads first. */
625
+ past?: boolean;
626
+ variant?: TimelineVariant;
627
+ }
628
+ /**
629
+ * The extension API properties for the Timeline component.
630
+ *
631
+ * A vertical rail with one marker per entry. For a day's events, or for the
632
+ * order things happened in over a longer stretch.
633
+ */
634
+ interface TimelineProps extends ExtensionComponentData {
635
+ component: 'Timeline';
636
+ /** Heading above the rail, e.g. "I dag". */
637
+ title?: string;
638
+ /** Icon shown to the left of the title. */
639
+ icon?: HugeIconName;
640
+ entries: TimelineEntry[];
641
+ }
642
+ /** How much a note stands out. */
643
+ type NoteVariant = 'default' | 'accent' | 'success' | 'warning' | 'danger';
644
+ /**
645
+ * The extension API properties for the Note component.
646
+ *
647
+ * A titled block of prose: a heading, markdown, and a quiet line at the bottom.
648
+ * For the answer that *is* text but wants a shape — one of five news summaries,
649
+ * one of three things worth doing this weekend — where a single long reply would
650
+ * run them all together and the reader would lose which sentence belonged to
651
+ * which item.
652
+ *
653
+ * The body is markdown rather than components because prose is what it is for.
654
+ * A note that wants a chart in it is a `Frame` with a `Note` and a `Chart`
655
+ * inside; keeping those separate is what stops this from becoming a second,
656
+ * worse layout system.
657
+ */
658
+ interface NoteProps extends ExtensionComponentData {
659
+ component: 'Note';
660
+ title?: string;
661
+ /** Icon shown to the left of the title. */
662
+ icon?: HugeIconName;
663
+ /** Trailing badge next to the title: a source, a category, a time. */
664
+ badge?: string;
665
+ /** The body, as markdown. */
666
+ content: string;
667
+ /** One quiet line at the bottom: where it came from, when, or a caveat. */
668
+ footer?: string;
669
+ /** Colours the rail down the left edge. Defaults to none. */
670
+ variant?: NoteVariant;
671
+ }
672
+ /** Whether an event is going ahead. */
673
+ type CalendarEventStatus = 'confirmed' | 'tentative' | 'cancelled';
674
+ /**
675
+ * The extension API properties for the CalendarEvent component.
676
+ *
677
+ * One event, shown properly. It is the thing Stina most often has to say
678
+ * something *about* — a meeting starting shortly, a booking she is reminding you
679
+ * of — and a sentence carrying a time, a place and four names is a sentence
680
+ * nobody reads to the end.
681
+ *
682
+ * For a whole day, use `Timeline`. This is the single event that has the floor.
683
+ *
684
+ * `start` and `end` are timestamps rather than the worded strings `Timeline`
685
+ * takes, and that is the one place in the DSL where live computation is right:
686
+ * the card is *about* a moment, so the host works out "om tolv minuter" and
687
+ * "pågår nu" and keeps them true. A caller who wrote those as text would have
688
+ * written a card that lies the moment it is scrolled back to.
689
+ */
690
+ interface CalendarEventProps extends ExtensionComponentData {
691
+ component: 'CalendarEvent';
692
+ title: string;
693
+ /** ISO 8601, with an offset. The host formats it in the reader's own locale. */
694
+ start: string;
695
+ /** ISO 8601, with an offset. Absent for an event with no stated end. */
696
+ end?: string;
697
+ /** Draws a day rather than a clock time. */
698
+ allDay?: boolean;
699
+ location?: string;
700
+ /** Who called it. */
701
+ organizer?: string;
702
+ /** Names as the calendar has them. Shown in full up to a few, then counted. */
703
+ attendees?: string[];
704
+ /** Which calendar it sits in. Shown as a badge. */
705
+ calendar?: string;
706
+ /** Defaults to `confirmed`. */
707
+ status?: CalendarEventStatus;
708
+ /**
709
+ * How it repeats, in words: `Varje tisdag`. The recurrence rule itself is not
710
+ * something this component parses — the caller already knows what it means,
711
+ * and a component guessing at an RRULE would get the exceptions wrong.
712
+ */
713
+ recurrence?: string;
714
+ /** One short line of context. */
715
+ notes?: string;
716
+ }
433
717
 
434
718
  /**
435
719
  * What an extension can contribute to Stina
@@ -1551,7 +1835,21 @@ interface UserProfile {
1551
1835
  * User API for profile access
1552
1836
  */
1553
1837
  interface UserAPI {
1554
- getProfile(): Promise<UserProfile>;
1838
+ /**
1839
+ * Read a user's profile, including the timezone their clock times are written in.
1840
+ *
1841
+ * Pass the id of the user this work belongs to — `ctx.userId` in a background task,
1842
+ * `context.userId` in a tool call, the fired job's `userId` in a scheduler callback.
1843
+ * A host serving more than one user cannot guess which profile was meant, and answers
1844
+ * with an empty profile when the id is left out.
1845
+ *
1846
+ * This matters most for `timezone`. An extension that turns a wall-clock time into an
1847
+ * instant without it falls back to the machine's timezone, which on a server is UTC,
1848
+ * and schedules the user's 11:30 for 11:30 UTC.
1849
+ *
1850
+ * @param userId The user whose profile to read. Optional only for single-user hosts.
1851
+ */
1852
+ getProfile(userId?: string): Promise<UserProfile>;
1555
1853
  listIds(): Promise<string[]>;
1556
1854
  }
1557
1855
  /**
@@ -1932,6 +2230,20 @@ interface ToolResult {
1932
2230
  message?: string;
1933
2231
  /** Error message if failed */
1934
2232
  error?: string;
2233
+ /**
2234
+ * Something to show the user that is not text.
2235
+ *
2236
+ * A component from the chat card profile — a chart, a weather reading, a
2237
+ * timeline. It is rendered in the conversation where the tool ran, and it is
2238
+ * *not* part of what goes back to the model: the model already knows what it
2239
+ * asked to be shown, and sending the drawing back would spend the tokens
2240
+ * twice and invite it to summarise its own picture.
2241
+ *
2242
+ * Validate with `validateChatCard` before returning one. A card that fails the
2243
+ * profile is dropped by the host rather than half-rendered, so a tool that
2244
+ * skips the check finds out from the user, which is the wrong end.
2245
+ */
2246
+ display?: ExtensionComponentData;
1935
2247
  }
1936
2248
  /**
1937
2249
  * Action implementation for UI interactions.
@@ -1959,4 +2271,4 @@ interface ActionResult {
1959
2271
  error?: string;
1960
2272
  }
1961
2273
 
1962
- export { type BackgroundTaskHealth as $, type ActionResult as A, type EventsAPI as B, type ChatMessage as C, type Disposable as D, type ExtensionContributions as E, type SchedulerAPI as F, type GetModelsOptions as G, type HugeIconName as H, type SchedulerJobRequest as I, type SchedulerSchedule as J, type UserProfile as K, type LocalizedString as L, type ModelInfo as M, type NetworkAPI as N, type ChatAPI as O, type PanelDefinition as P, type ChatInstructionMessage as Q, type ConversationPresentation as R, type SchedulerFirePayload as S, type ToolResult as T, type UserAPI as U, type VoiceSessionOptions as V, type LogAPI as W, type BackgroundWorkersAPI as X, type BackgroundTaskConfig as Y, type BackgroundTaskCallback as Z, type BackgroundTaskContext as _, type ChatOptions as a, type BackgroundRestartPolicy as a0, type Query as a1, type QueryOptions as a2, type StorageAPI as a3, type SecretsAPI as a4, type StorageCollectionConfig as a5, type StorageContributions as a6, type AIProvider as a7, type ModelCapabilities as a8, type ChatImage as a9, type HorizontalStackProps as aA, type GridProps as aB, type DividerProps as aC, type IconProps as aD, type IconButtonType as aE, type IconButtonProps as aF, type PanelAction as aG, type PanelProps as aH, type ToggleProps as aI, type CollapsibleProps as aJ, type FrameVariant as aK, type FrameProps as aL, type ListProps as aM, type PillVariant as aN, type PillProps as aO, type CheckboxProps as aP, type MarkdownProps as aQ, type TextPreviewProps as aR, type ModalProps as aS, type ConditionalGroupProps as aT, type ExecutionContext as aU, type ToolCall as aa, type VoiceTransportRequest as ab, type Tool as ac, type Action as ad, type ExtensionModule as ae, type AllowedCSSProperty as af, type ExtensionComponentStyle as ag, type ExtensionComponentData as ah, type ExtensionComponentIterator as ai, type ExtensionComponentChildren as aj, type ExtensionActionCall as ak, type ExtensionActionRef as al, type ExtensionDataSource as am, type ExtensionPanelDefinition as an, type HeaderProps as ao, type LabelProps as ap, type ParagraphProps as aq, type ButtonProps as ar, type TextInputProps as as, type PasswordInputProps as at, type NumberInputProps as au, type TextAreaProps as av, type DateTimeInputProps as aw, type SelectProps as ax, type IconPickerProps as ay, type VerticalStackProps as az, type StreamEvent as b, type VoiceSessionDescriptor as c, type ToolSettingsViewDefinition as d, type ToolSettingsView as e, type ToolSettingsListView as f, type ToolSettingsListMapping as g, type ToolSettingsComponentView as h, type ToolSettingsActionDataSource as i, type PanelView as j, type PanelComponentView as k, type PanelActionDataSource as l, type PanelUnknownView as m, type ProviderDefinition as n, type ProviderConfigView as o, type PromptContribution as p, type PromptSection as q, resolveLocalizedString as r, type ToolDefinition as s, type ToolConfirmationConfig as t, type CommandDefinition as u, type ExtensionContext as v, type SettingsAPI as w, type ProvidersAPI as x, type ToolsAPI as y, type ActionsAPI as z };
2274
+ export { type BackgroundTaskHealth as $, type ActionResult as A, type EventsAPI as B, type ChatMessage as C, type Disposable as D, type ExtensionContributions as E, type SchedulerAPI as F, type GetModelsOptions as G, type HugeIconName as H, type SchedulerJobRequest as I, type SchedulerSchedule as J, type UserProfile as K, type LocalizedString as L, type ModelInfo as M, type NetworkAPI as N, type ChatAPI as O, type PanelDefinition as P, type ChatInstructionMessage as Q, type ConversationPresentation as R, type SchedulerFirePayload as S, type ToolResult as T, type UserAPI as U, type VoiceSessionOptions as V, type LogAPI as W, type BackgroundWorkersAPI as X, type BackgroundTaskConfig as Y, type BackgroundTaskCallback as Z, type BackgroundTaskContext as _, type ChatOptions as a, type ChartProps as a$, type BackgroundRestartPolicy as a0, type Query as a1, type QueryOptions as a2, type StorageAPI as a3, type SecretsAPI as a4, type StorageCollectionConfig as a5, type StorageContributions as a6, type AIProvider as a7, type ModelCapabilities as a8, type ChatImage as a9, type HorizontalStackProps as aA, type GridProps as aB, type DividerProps as aC, type IconProps as aD, type IconButtonType as aE, type IconButtonProps as aF, type PanelAction as aG, type PanelProps as aH, type ToggleProps as aI, type CollapsibleProps as aJ, type FrameVariant as aK, type FrameProps as aL, type ListProps as aM, type PillVariant as aN, type PillProps as aO, type CheckboxProps as aP, type MarkdownProps as aQ, type TextPreviewProps as aR, type ModalProps as aS, type ConditionalGroupProps as aT, type WeatherCondition as aU, type WeatherWind as aV, type WeatherNowProps as aW, type WeatherForecastStep as aX, type WeatherForecastProps as aY, type ChartKind as aZ, type ChartSeries as a_, type ToolCall as aa, type VoiceTransportRequest as ab, type Tool as ac, type Action as ad, type ExtensionModule as ae, type AllowedCSSProperty as af, type ExtensionComponentStyle as ag, type ExtensionComponentData as ah, type ExtensionComponentIterator as ai, type ExtensionComponentChildren as aj, type ExtensionActionCall as ak, type ExtensionActionRef as al, type ExtensionDataSource as am, type ExtensionPanelDefinition as an, type HeaderProps as ao, type LabelProps as ap, type ParagraphProps as aq, type ButtonProps as ar, type TextInputProps as as, type PasswordInputProps as at, type NumberInputProps as au, type TextAreaProps as av, type DateTimeInputProps as aw, type SelectProps as ax, type IconPickerProps as ay, type VerticalStackProps as az, type StreamEvent as b, type StatTrend as b0, type StatTileProps as b1, type KeyValueRow as b2, type KeyValueListProps as b3, type TimelineVariant as b4, type TimelineEntry as b5, type TimelineProps as b6, type NoteVariant as b7, type NoteProps as b8, type CalendarEventStatus as b9, type CalendarEventProps as ba, type ExecutionContext as bb, type VoiceSessionDescriptor as c, type ToolSettingsViewDefinition as d, type ToolSettingsView as e, type ToolSettingsListView as f, type ToolSettingsListMapping as g, type ToolSettingsComponentView as h, type ToolSettingsActionDataSource as i, type PanelView as j, type PanelComponentView as k, type PanelActionDataSource as l, type PanelUnknownView as m, type ProviderDefinition as n, type ProviderConfigView as o, type PromptContribution as p, type PromptSection as q, resolveLocalizedString as r, type ToolDefinition as s, type ToolConfirmationConfig as t, type CommandDefinition as u, type ExtensionContext as v, type SettingsAPI as w, type ProvidersAPI as x, type ToolsAPI as y, type ActionsAPI as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stina/extension-api",
3
- "version": "0.52.0",
3
+ "version": "0.56.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -192,4 +192,24 @@ export type {
192
192
  TextPreviewProps,
193
193
  ModalProps,
194
194
  ConditionalGroupProps,
195
+ // Purpose-built display components
196
+ WeatherCondition,
197
+ WeatherWind,
198
+ WeatherNowProps,
199
+ WeatherForecastStep,
200
+ WeatherForecastProps,
201
+ ChartKind,
202
+ ChartSeries,
203
+ ChartProps,
204
+ StatTrend,
205
+ StatTileProps,
206
+ KeyValueRow,
207
+ KeyValueListProps,
208
+ TimelineVariant,
209
+ TimelineEntry,
210
+ TimelineProps,
211
+ NoteVariant,
212
+ NoteProps,
213
+ CalendarEventStatus,
214
+ CalendarEventProps,
195
215
  } from './types.components.js'
package/src/runtime.ts CHANGED
@@ -856,8 +856,8 @@ function buildContext(
856
856
  // Add user profile API if permitted
857
857
  if (hasPermission('user.profile.read')) {
858
858
  const userApi: UserAPI = {
859
- async getProfile(): Promise<UserProfile> {
860
- return sendRequest<UserProfile>('user.getProfile', {})
859
+ async getProfile(userId?: string): Promise<UserProfile> {
860
+ return sendRequest<UserProfile>('user.getProfile', userId ? { userId } : {})
861
861
  },
862
862
  async listIds(): Promise<string[]> {
863
863
  return sendRequest<string[]>('user.listIds', {})
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { ChatCardComponent } from './card.schema.js'
3
+ import { CHAT_CARD_COMPONENTS, describeChatCardProfile, validateChatCard } from './card.schema.js'
4
+
5
+ describe('validateChatCard', () => {
6
+ it('accepts a chart written the way the profile describes it', () => {
7
+ const result = validateChatCard({
8
+ component: 'Chart',
9
+ chart: 'line',
10
+ title: 'Temperatur',
11
+ unit: '°',
12
+ labels: ['08', '09', '10'],
13
+ series: [{ name: 'Ute', points: [3, null, 7] }],
14
+ })
15
+
16
+ expect(result.ok).toBe(true)
17
+ })
18
+
19
+ it('accepts a note with markdown and a footer', () => {
20
+ const result = validateChatCard({
21
+ component: 'Note',
22
+ title: 'Strömavbrott i norra Uppsala',
23
+ icon: 'alert-02',
24
+ badge: 'Lokalt',
25
+ variant: 'warning',
26
+ content: 'Omkring **1 400** hushåll berörs.',
27
+ footer: 'UNT · för 40 minuter sedan',
28
+ })
29
+
30
+ expect(result.ok).toBe(true)
31
+ })
32
+
33
+ it('accepts an icon beside every heading that has one', () => {
34
+ for (const card of [
35
+ { component: 'Chart', icon: 'temperature', series: [{ points: [1] }] },
36
+ { component: 'Timeline', icon: 'calendar-03', entries: [{ time: '09:00', title: 'x' }] },
37
+ {
38
+ component: 'WeatherForecast',
39
+ icon: 'sun-cloud-02',
40
+ steps: [{ label: 'Nu', condition: 'clear', high: 4, current: true }],
41
+ },
42
+ ]) {
43
+ expect(validateChatCard(card).ok).toBe(true)
44
+ }
45
+ })
46
+
47
+ it('accepts a calendar event with timestamps', () => {
48
+ const result = validateChatCard({
49
+ component: 'CalendarEvent',
50
+ title: 'Avstämning med kunden',
51
+ start: '2026-09-01T14:00:00+02:00',
52
+ end: '2026-09-01T15:00:00+02:00',
53
+ location: 'Teams',
54
+ attendees: ['Anna', 'Jonte'],
55
+ calendar: 'Jobb',
56
+ status: 'tentative',
57
+ })
58
+
59
+ expect(result.ok).toBe(true)
60
+ })
61
+
62
+ it('refuses a calendar status outside the three that exist', () => {
63
+ const result = validateChatCard({
64
+ component: 'CalendarEvent',
65
+ title: 'Möte',
66
+ start: '2026-09-01T14:00:00+02:00',
67
+ status: 'maybe',
68
+ })
69
+
70
+ expect(result.ok).toBe(false)
71
+ })
72
+
73
+ it('accepts components nested through children', () => {
74
+ const result = validateChatCard({
75
+ component: 'VerticalStack',
76
+ children: [
77
+ { component: 'Header', level: 3, title: 'I dag' },
78
+ { component: 'Timeline', entries: [{ time: '09:00', title: 'Standup', current: true }] },
79
+ ],
80
+ })
81
+
82
+ expect(result.ok).toBe(true)
83
+ })
84
+
85
+ it('spaces every layout in the card, however deep it sits', () => {
86
+ const result = validateChatCard({
87
+ component: 'VerticalStack',
88
+ children: [
89
+ {
90
+ component: 'Grid',
91
+ columns: 2,
92
+ children: [
93
+ { component: 'HorizontalStack', children: [{ component: 'Label', text: 'A' }] },
94
+ ],
95
+ },
96
+ ],
97
+ })
98
+
99
+ expect(result.ok).toBe(true)
100
+ if (!result.ok) return
101
+
102
+ const grid = (result.card['children'] as ChatCardComponent[])[0]!
103
+ const row = (grid['children'] as ChatCardComponent[])[0]!
104
+
105
+ expect(result.card['gap']).toBe(0.75)
106
+ expect(grid['gap']).toBe(0.75)
107
+ expect(row['gap']).toBe(0.75)
108
+ })
109
+
110
+ it('refuses a gap the caller set itself', () => {
111
+ // The rhythm is the profile's. A number here is a model guessing at a unit
112
+ // it was never told — 14 meaning pixels renders as fourteen rem.
113
+ const result = validateChatCard({
114
+ component: 'VerticalStack',
115
+ gap: 14,
116
+ children: [{ component: 'Label', text: 'A' }],
117
+ })
118
+
119
+ expect(result.ok).toBe(false)
120
+ })
121
+
122
+ it('leaves gap out of the vocabulary the model is given', () => {
123
+ expect(describeChatCardProfile()).not.toContain('gap')
124
+ })
125
+
126
+ describe('refuses anything the user could act on', () => {
127
+ it.each([
128
+ ['Button', { component: 'Button', text: 'Spara', onClickAction: 'save' }],
129
+ ['TextInput', { component: 'TextInput', label: 'Namn' }],
130
+ ['Modal', { component: 'Modal', title: 'Hej' }],
131
+ ['IconButton', { component: 'IconButton', icon: 'x', tooltip: 't', onClickAction: 'a' }],
132
+ ])('%s', (_name, card) => {
133
+ // A card shows; it does not offer. Anything the user might want to do
134
+ // next, they ask for in words.
135
+ expect(validateChatCard(card).ok).toBe(false)
136
+ })
137
+ })
138
+
139
+ it('refuses an interactive prop smuggled onto a component that is allowed', () => {
140
+ const result = validateChatCard({ component: 'Label', text: 'Klicka', onClickAction: 'go' })
141
+
142
+ expect(result.ok).toBe(false)
143
+ if (!result.ok) expect(result.error).toContain('onClickAction')
144
+ })
145
+
146
+ it('refuses an iterator, since a card has no data source to expand', () => {
147
+ const result = validateChatCard({
148
+ component: 'VerticalStack',
149
+ children: { each: '$rows', as: 'row', items: [{ component: 'Label', text: '$row.name' }] },
150
+ })
151
+
152
+ expect(result.ok).toBe(false)
153
+ })
154
+
155
+ it('refuses a $-prefixed value rather than rendering it as nothing', () => {
156
+ // With no scope to resolve against, "$120" would silently disappear — which
157
+ // is precisely the failure a price is least able to survive.
158
+ const result = validateChatCard({ component: 'Label', text: '$120' })
159
+
160
+ expect(result.ok).toBe(false)
161
+ if (!result.ok) expect(result.error).toContain('$')
162
+ })
163
+
164
+ it('finds a $-prefixed value however deeply it is buried', () => {
165
+ const result = validateChatCard({
166
+ component: 'VerticalStack',
167
+ children: [
168
+ { component: 'KeyValueList', rows: [{ label: 'Pris', value: '$99' }] },
169
+ ],
170
+ })
171
+
172
+ expect(result.ok).toBe(false)
173
+ })
174
+
175
+ it('allows a dollar sign that is not leading', () => {
176
+ expect(validateChatCard({ component: 'Label', text: 'USD 120 ($)' }).ok).toBe(true)
177
+ })
178
+
179
+ it('refuses a chart whose series does not line up with its labels', () => {
180
+ // The chart would render, and every reading after the short series would
181
+ // sit under the wrong label — worse than not drawing it at all.
182
+ const result = validateChatCard({
183
+ component: 'Chart',
184
+ labels: ['mån', 'tis', 'ons'],
185
+ series: [{ points: [1, 2] }],
186
+ })
187
+
188
+ expect(result.ok).toBe(false)
189
+ if (!result.ok) expect(result.error).toContain('points')
190
+ })
191
+
192
+ it('accepts a chart whose gap is written out as null', () => {
193
+ expect(
194
+ validateChatCard({
195
+ component: 'Chart',
196
+ labels: ['mån', 'tis', 'ons'],
197
+ series: [{ points: [1, null, 3] }],
198
+ }).ok
199
+ ).toBe(true)
200
+ })
201
+
202
+ it('refuses a ninth series, which would need a colour the palette lacks', () => {
203
+ const nine = Array.from({ length: 9 }, () => ({ points: [1] }))
204
+
205
+ expect(validateChatCard({ component: 'Chart', series: nine }).ok).toBe(false)
206
+ })
207
+
208
+ it('refuses a card nested past the depth limit', () => {
209
+ let card: Record<string, unknown> = { component: 'Label', text: 'djupt' }
210
+ for (let i = 0; i < 10; i++) {
211
+ card = { component: 'VerticalStack', children: [card] }
212
+ }
213
+
214
+ const result = validateChatCard(card)
215
+ expect(result.ok).toBe(false)
216
+ if (!result.ok) expect(result.error).toContain('deep')
217
+ })
218
+
219
+ it('names the component in the error when the name itself is wrong', () => {
220
+ const result = validateChatCard({ component: 'BarGraph', series: [] })
221
+
222
+ expect(result.ok).toBe(false)
223
+ if (!result.ok) expect(result.error).toContain('component')
224
+ })
225
+
226
+ it('refuses a weather condition outside the list that has icons', () => {
227
+ const result = validateChatCard({
228
+ component: 'WeatherNow',
229
+ place: 'Uppsala',
230
+ condition: 'blizzard',
231
+ temperature: -4,
232
+ })
233
+
234
+ expect(result.ok).toBe(false)
235
+ })
236
+
237
+ it('rejects things that are not components at all', () => {
238
+ expect(validateChatCard(null).ok).toBe(false)
239
+ expect(validateChatCard('Chart').ok).toBe(false)
240
+ expect(validateChatCard([{ component: 'Label', text: 'x' }]).ok).toBe(false)
241
+ })
242
+ })
243
+
244
+ describe('describeChatCardProfile', () => {
245
+ it('documents every component the profile accepts', () => {
246
+ const described = describeChatCardProfile()
247
+
248
+ // The description is what a model reads as documentation. One that has
249
+ // drifted from the validator teaches it to write cards that are rejected.
250
+ for (const name of CHAT_CARD_COMPONENTS) {
251
+ expect(described).toContain(name)
252
+ }
253
+ })
254
+
255
+ it('names no component the profile would reject', () => {
256
+ const described = describeChatCardProfile()
257
+
258
+ expect(described).not.toContain('Button')
259
+ expect(described).not.toContain('TextInput')
260
+ })
261
+
262
+ it('spells out the enums a caller has to choose from', () => {
263
+ const described = describeChatCardProfile()
264
+
265
+ expect(described).toContain('line|bar|area')
266
+ expect(described).toContain('partly-cloudy')
267
+ })
268
+
269
+ it('marks which properties are required', () => {
270
+ const described = describeChatCardProfile()
271
+ const chart = described.split('\n').find((line) => line.startsWith('Chart'))!
272
+
273
+ expect(chart).toContain('series: ')
274
+ expect(chart).toContain('chart?: ')
275
+ })
276
+ })