@standardagents/code-plugin-sdk 1.0.0-alpha.0 → 1.0.0-alpha.10-rows.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.
package/src/runtime.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { LIMITS, PluginError, ensure, jsonBytes, validateManifest, ANCHORS, identifier, object } from './manifest.mjs'
2
2
  import { RpcPeer, authorize, realClock } from './protocol.mjs'
3
+ import { validateView } from './view.mjs'
4
+ import { validateRowLayout } from './row-layout.mjs'
3
5
 
4
6
  const always = Object.freeze({ kind: 'always' })
5
7
  const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
@@ -9,6 +11,43 @@ function boundedText(value, label, limit = 512) {
9
11
  !/[\u0000-\u001f\u007f]/.test(value), 'invalid_payload', `Invalid ${label}`)
10
12
  return value
11
13
  }
14
+ const HOVER_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
15
+ function validatePassiveText(value, limit = LIMITS.canvasHoverBytes, label = 'canvas hover') {
16
+ if (typeof value === 'string') {
17
+ ensure(value.trim().length > 0 && !HOVER_FORBIDDEN.test(value),
18
+ 'invalid_payload', `Invalid ${label} text`)
19
+ ensure(Buffer.byteLength(value) <= limit,
20
+ 'payload_too_large', `${label} text exceeds ${limit} bytes`)
21
+ return
22
+ }
23
+ ensure(Array.isArray(value), 'invalid_payload', `Invalid ${label} spans`)
24
+ ensure(value.length > 0, 'invalid_payload', `${label} spans must contain text`)
25
+ ensure(value.length <= 32, 'payload_too_large', `${label} spans exceed 32 spans`)
26
+ let text = ''
27
+ for (const span of value) {
28
+ ensure(object(span), 'invalid_payload', `Invalid ${label} span`)
29
+ ensure(typeof span.text === 'string' && !HOVER_FORBIDDEN.test(span.text),
30
+ 'invalid_payload', `Invalid ${label} span text`)
31
+ for (const field of ['foreground', 'background']) {
32
+ if (span[field] !== undefined) {
33
+ ensure(typeof span[field] === 'string' && span[field].trim().length > 0 &&
34
+ !HOVER_FORBIDDEN.test(span[field]), 'invalid_payload', `Invalid ${label} span ${field}`)
35
+ ensure(Buffer.byteLength(span[field]) <= 64,
36
+ 'payload_too_large', `${label} span ${field} exceeds 64 bytes`)
37
+ }
38
+ }
39
+ for (const field of ['bold', 'italic', 'underline']) {
40
+ ensure(span[field] === undefined || typeof span[field] === 'boolean',
41
+ 'invalid_payload', `Invalid ${label} span ${field}`)
42
+ }
43
+ ensure(span.actionId === undefined, 'invalid_payload', `${label} spans cannot contain actionId`)
44
+ text += span.text
45
+ }
46
+ ensure(text.trim().length > 0 && !HOVER_FORBIDDEN.test(text),
47
+ 'invalid_payload', `Invalid ${label} text`)
48
+ ensure(Buffer.byteLength(text) <= limit,
49
+ 'payload_too_large', `${label} text exceeds ${limit} bytes`)
50
+ }
12
51
  function validateEntity(entity) {
13
52
  ensure(object(entity) && entityKinds.includes(entity.kind), 'invalid_payload', 'Invalid registration entity')
14
53
  boundedText(entity.id, 'entity id', 128)
@@ -40,14 +79,49 @@ function validateCondition(condition) {
40
79
  (condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
41
80
  return structuredClone(condition)
42
81
  }
43
- function validateContent(content) {
82
+ const CONTENT_KINDS = ['rows', 'text', 'badge', 'canvas', 'view']
83
+ /** Mirrors PluginSurfaceKind::accepts: canvas suits every drawn kind, view suits cards, panels and sections. */
84
+ export function acceptsContent(kind, content) {
85
+ if (['menu', 'command', 'key', 'link'].includes(kind)) return false
86
+ if (kind === 'badge') return content.kind === 'badge'
87
+ if (content.kind === 'badge') return false
88
+ return content.kind !== 'view' || ['section', 'card', 'panel'].includes(kind)
89
+ }
90
+ function validateContent(content, declaration, manifest) {
91
+ const { kind } = declaration
92
+ ensure(object(content) && CONTENT_KINDS.includes(content.kind), 'invalid_payload', 'Invalid surface content')
93
+ ensure(acceptsContent(kind, content), 'invalid_payload', `A ${kind} contribution cannot show ${content.kind} content`)
94
+ // The view walk bounds depth before serialization visits the tree.
95
+ if (content.kind === 'view') {
96
+ validateView(content.root, { kind: declaration.kind, manifest })
97
+ if (content.title !== undefined) {
98
+ ensure(kind === 'card' && Array.isArray(content.title), 'invalid_payload', 'Styled titles belong to card views')
99
+ validatePassiveText(content.title, 512, 'card title')
100
+ }
101
+ }
102
+ if (content.kind === 'rows') {
103
+ for (const row of content.rows ?? []) if (row.layout !== undefined) {
104
+ ensure(['pane.header', 'pane.footer'].includes(declaration.anchor), 'invalid_payload', 'Row layouts require a pane header or footer')
105
+ validateRowLayout(row.layout)
106
+ }
107
+ }
44
108
  jsonBytes(content)
45
- ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
46
109
  if (content.kind === 'canvas') {
47
- const { columns, rows, shade = 0 } = content.canvas ?? {}
110
+ const { columns, rows, shade = 0, hover, themeColors } = content.canvas ?? {}
48
111
  ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
49
112
  Number.isSafeInteger(rows) && rows > 0 && rows <= LIMITS.canvasRows &&
50
113
  Number.isFinite(shade) && shade >= 0 && shade <= 1, 'invalid_payload', 'Invalid canvas dimensions or shade')
114
+ if (themeColors !== undefined) {
115
+ ensure(object(themeColors) && Object.keys(themeColors).length <= 32, 'invalid_payload', 'Invalid canvas theme colors')
116
+ for (const [index, recipe] of Object.entries(themeColors)) {
117
+ ensure(/^(0|[1-9][0-9]{0,2})$/.test(index) && Number(index) <= 255 && object(recipe) &&
118
+ Object.keys(recipe).every(key=>['source','mix','opacity'].includes(key)) &&
119
+ ['source','mix'].every(key=>recipe[key] === undefined || (Number.isInteger(recipe[key]) && recipe[key]>=0 && recipe[key]<16)) &&
120
+ Number.isFinite(recipe.opacity) && recipe.opacity>=0 && recipe.opacity<=1,
121
+ 'invalid_payload', 'Invalid canvas theme color recipe')
122
+ }
123
+ }
124
+ if (hover !== undefined) validatePassiveText(hover)
51
125
  }
52
126
  }
53
127
 
@@ -102,7 +176,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
102
176
  const replace = content => {
103
177
  active()
104
178
  ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
105
- if (content !== null) validateContent(content)
179
+ if (content !== null) validateContent(content, declaration, manifest)
106
180
  peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
107
181
  }
108
182
  const publisher = { replace, clear: () => replace(null), dispose() {
@@ -292,7 +366,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
292
366
  const method = op => (args, options) => request(op, args, options)
293
367
  const context = Object.freeze({
294
368
  manifest, producer: peer.producer, signal: lifetime.signal, request,
295
- ...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
369
+ ...Object.fromEntries(['section', 'card', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
296
370
  (id, entity) => publish(kind, id, entity).publisher])),
297
371
  canvas(id, spec, entity) {
298
372
  const { publisher, key } = publish(null, id, entity)
@@ -321,7 +395,8 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
321
395
  secrets: Object.freeze({ get: (name, options) => request('secret.get', { name }, options) }),
322
396
  config: Object.freeze({ get: options => request('config.get', {}, options) }),
323
397
  state: Object.freeze({ get: (key, options) => request('state.get', { key }, options),
324
- set: (key, value, options) => request('state.set', { key, value }, options) }),
398
+ set: (key, value, options) => request('state.set', { key, value }, options),
399
+ keys: options => request('state.keys', {}, options) }),
325
400
  context: Object.freeze({ get: options => request('context.get', {}, options) }),
326
401
  popover: Object.freeze({ open: method('popover.open') }),
327
402
  health: Object.freeze({ set: method('health.set') }),
package/src/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions } from './index.js';
1
+ import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions, EntityRef, SurfaceContent } from './index.js';
2
2
  import type { Envelope } from './internal.js';
3
3
  export interface Harness {
4
4
  context: PluginContext;
@@ -11,6 +11,8 @@ export interface Harness {
11
11
  emit(kind: string, name: string, event: Json, options?: RequestOptions): Promise<Json[]>;
12
12
  visibility(conditions: Condition[]): Promise<Json>;
13
13
  receive(frame: Envelope): void;
14
+ /** The current content of a published contribution, or undefined when it is clear. */
15
+ surface(id: string, entity?: EntityRef): SurfaceContent | undefined;
14
16
  drainTrace(): unknown[];
15
17
  readonly resources: { timers: number; subscriptions: number; surfaces: number; disposed: boolean };
16
18
  dispose(): Promise<void>;
package/src/testing.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRuntime } from './runtime.mjs'
2
2
  import { RpcPeer } from './protocol.mjs'
3
- import { PluginError, ensure, LIMITS, validateManifest } from './manifest.mjs'
3
+ import { PluginError, ensure, jsonBytes, LIMITS, validateManifest } from './manifest.mjs'
4
4
 
5
5
  /** Explicit time and fixture-owned responses; this harness starts no threads or subprocesses. */
6
6
  export function createHarness({ manifest: input, machineId = 'test-machine', epoch = '1', handlers = {}, now = 0 } = {}) {
@@ -9,7 +9,7 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
9
9
  const trace = []
10
10
  const surfaces = new Map()
11
11
  const subscriptions = new Map()
12
- const localState = new Map()
12
+ const accountState = new Map()
13
13
  const timers = new Map()
14
14
  let timerId = 0
15
15
  let disposed = false
@@ -36,12 +36,16 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
36
36
  switch (operation.op) {
37
37
  case 'subscription.add': subscriptions.set(args.id, structuredClone(args)); return null
38
38
  case 'subscription.remove': subscriptions.delete(args.id); return null
39
- case 'state.get': return structuredClone(localState.get(args.key) ?? null)
39
+ case 'state.get': return structuredClone(accountState.get(args.key) ?? null)
40
40
  case 'state.set':
41
- ensure(localState.has(args.key) || localState.size < LIMITS.contributions, 'queue_full', 'Harness state is full')
42
- localState.set(args.key, structuredClone(args.value)); return null
41
+ // The product stores these values in the account: 256 keys per
42
+ // plugin, 64 KiB per value.
43
+ ensure(accountState.has(args.key) || accountState.size < LIMITS.stateKeys, 'queue_full', 'Harness state holds 256 keys for this plugin')
44
+ jsonBytes(args.value, LIMITS.stateValueBytes)
45
+ accountState.set(args.key, structuredClone(args.value)); return null
46
+ case 'state.keys': return [...accountState.keys()].sort()
43
47
  case 'config.get': return {}
44
- case 'context.get': return { machineId }
48
+ case 'context.get': return { accountId: 'test-account', machineId, build: null, fleet: null, projects: [], panes: [], complete: true }
45
49
  case 'health.set': return null
46
50
  default: throw new PluginError('missing_fixture', `Provide a harness handler for ${operation.op}`)
47
51
  }
@@ -88,6 +92,13 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
88
92
  },
89
93
  visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
90
94
  receive: frame => runtime.receive(frame),
95
+ /** The current content of a published contribution, or undefined when it is clear. */
96
+ surface(id, entity) {
97
+ const declaration = manifest.contributions.find(item => item.id === id)
98
+ if (!declaration) return undefined
99
+ const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity } : {}) }
100
+ return structuredClone(surfaces.get(JSON.stringify(key))?.content)
101
+ },
91
102
  drainTrace() { return trace.splice(0) },
92
103
  get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
93
104
  async dispose() {
package/src/view.mjs ADDED
@@ -0,0 +1,257 @@
1
+ import { LIMITS, ensure, identifier, jsonBytes, object } from './manifest.mjs'
2
+
3
+ // Mirrors crates/standard-protocol/src/plugin_view.rs. The host applies the
4
+ // same bounds; checking here reports a mistake at the replace() call.
5
+ // items and logLines bound the list-shaped nodes: the host lays every entry
6
+ // out again on each paint, so a long list costs a frame, not just memory.
7
+ export const VIEW_LIMITS = Object.freeze({ depth: 8, nodes: 4096, tableRows: 512, tableColumns: 12,
8
+ items: 512, logLines: 2048, cardLines: 6, actionValueBytes: 512 })
9
+ export const TONES = Object.freeze(['ok', 'info', 'warn', 'error', 'muted', 'accent', 'pending', 'bright'])
10
+ const WEIGHTS = ['normal', 'bold', 'dim']
11
+ const ALIGNS = ['start', 'center', 'end']
12
+ // C0, DEL, C1, and the bidirectional formatting characters that can reorder terminal output.
13
+ const FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
14
+
15
+ const invalid = message => ensure(false, 'invalid_payload', message)
16
+ function text(value, field) {
17
+ if (typeof value !== 'string') invalid(`Plugin view ${field} must be a string`)
18
+ ensure(!FORBIDDEN.test(value), 'invalid_payload', 'Plugin view text contains a control or bidirectional character')
19
+ }
20
+ function optionalText(value, field) { if (value !== undefined) text(value, field) }
21
+ function choice(value, choices, field) {
22
+ ensure(value === undefined || choices.includes(value), 'invalid_payload', `Invalid plugin view ${field}`)
23
+ }
24
+ function tone(value) { choice(value, TONES, 'tone') }
25
+ function integer(value, max, field) {
26
+ ensure(value === undefined || (Number.isInteger(value) && value >= 0 && value <= max), 'invalid_payload', `Invalid plugin view ${field}`)
27
+ }
28
+ function flag(value, field) { ensure(value === undefined || typeof value === 'boolean', 'invalid_payload', `Invalid plugin view ${field}`) }
29
+ function number(value) {
30
+ ensure(typeof value === 'number' && Number.isFinite(value), 'invalid_payload', 'Plugin view number is not finite')
31
+ }
32
+ function list(value, field, optional = false) {
33
+ if (optional && value === undefined) return []
34
+ ensure(Array.isArray(value), 'invalid_payload', `Plugin view ${field} must be an array`)
35
+ return value
36
+ }
37
+ function entries(value, field, optional, check) {
38
+ for (const item of list(value, field, optional)) {
39
+ ensure(object(item), 'invalid_payload', `Plugin view ${field} must contain objects`)
40
+ check(item)
41
+ }
42
+ }
43
+ function bounded(value, field, limit, optional = false) {
44
+ const values = list(value, field, optional)
45
+ ensure(values.length <= limit, 'invalid_payload', `Plugin view ${field} has more than ${limit} entries`)
46
+ return values
47
+ }
48
+ // A list-shaped node's entries, held to the bound the host applies.
49
+ function boundedEntries(value, field, check) {
50
+ entries(bounded(value, field, VIEW_LIMITS.items), field, false, check)
51
+ }
52
+ function spans(value, optional) {
53
+ entries(value, 'spans', optional, span => {
54
+ text(span.text, 'span text')
55
+ tone(span.tone)
56
+ choice(span.weight, WEIGHTS, 'weight')
57
+ flag(span.mono, 'mono')
58
+ })
59
+ }
60
+ function action(value, optional = true) {
61
+ if (optional && value === undefined) return
62
+ ensure(object(value), 'invalid_payload', 'Plugin view action must be an object')
63
+ text(value.actionId, 'action id')
64
+ optionalText(value.value, 'action value')
65
+ optionalText(value.opens, 'action opens')
66
+ }
67
+ function meter(item) {
68
+ number(item.value)
69
+ number(item.max)
70
+ tone(item.tone)
71
+ optionalText(item.label, 'label')
72
+ }
73
+ function children(node, depth, count) {
74
+ for (const child of list(node.children, 'children', true)) walk(child, depth + 1, count)
75
+ }
76
+
77
+ const NODES = {
78
+ stack(node, depth, count) { integer(node.gap, 255, 'gap'); children(node, depth, count) },
79
+ row(node, depth, count) { choice(node.align, ALIGNS, 'align'); children(node, depth, count) },
80
+ divider(node) { optionalText(node.label, 'label') },
81
+ text(node) {
82
+ optionalText(node.text, 'text')
83
+ spans(node.spans, true)
84
+ tone(node.tone)
85
+ choice(node.weight, WEIGHTS, 'weight')
86
+ flag(node.mono, 'mono')
87
+ },
88
+ badge(node) { text(node.label, 'label'); tone(node.tone) },
89
+ dot(node) { tone(node.tone) },
90
+ progress: meter,
91
+ segments(node) { boundedEntries(node.items, 'items', meter) },
92
+ card(node, depth, count) { optionalText(node.title, 'title'); tone(node.tone); children(node, depth, count) },
93
+ stat(node) {
94
+ text(node.label, 'label')
95
+ text(node.value, 'value')
96
+ tone(node.tone)
97
+ optionalText(node.hint, 'hint')
98
+ },
99
+ kv(node) {
100
+ boundedEntries(node.items, 'items', item => {
101
+ text(item.label, 'label')
102
+ text(item.value, 'value')
103
+ flag(item.mono, 'mono')
104
+ flag(item.copy, 'copy')
105
+ })
106
+ },
107
+ tabs(node) {
108
+ text(node.id, 'id')
109
+ optionalText(node.filters, 'filters')
110
+ action(node.action)
111
+ boundedEntries(node.items, 'items', item => {
112
+ text(item.id, 'id')
113
+ text(item.label, 'label')
114
+ optionalText(item.sublabel, 'sublabel')
115
+ tone(item.tone)
116
+ integer(item.count, 0xffffffff, 'count')
117
+ optionalText(item.tag, 'tag')
118
+ })
119
+ },
120
+ select(node) {
121
+ text(node.id, 'id')
122
+ text(node.label, 'label')
123
+ optionalText(node.filters, 'filters')
124
+ action(node.action)
125
+ boundedEntries(node.options, 'options', option => {
126
+ text(option.id, 'id')
127
+ text(option.label, 'label')
128
+ optionalText(option.group, 'group')
129
+ integer(option.count, 0xffffffff, 'count')
130
+ optionalText(option.tag, 'tag')
131
+ })
132
+ },
133
+ table(node, depth, count) {
134
+ const columns = list(node.columns, 'columns')
135
+ const rows = list(node.rows, 'rows', true)
136
+ ensure(columns.length <= VIEW_LIMITS.tableColumns, 'invalid_payload', 'Plugin table has more than 12 columns')
137
+ ensure(rows.length <= VIEW_LIMITS.tableRows, 'invalid_payload', 'Plugin table has more than 512 rows')
138
+ text(node.id, 'id')
139
+ entries(columns, 'columns', false, column => {
140
+ text(column.id, 'column id')
141
+ optionalText(column.label, 'column label')
142
+ if (column.width !== 'fill') integer(column.width, 0xffff, 'column width')
143
+ integer(column.maxWidth, 0xffff, 'column maxWidth')
144
+ choice(column.align, ALIGNS, 'align')
145
+ integer(column.priority, 255, 'column priority')
146
+ })
147
+ entries(rows, 'rows', false, row => {
148
+ text(row.id, 'row id')
149
+ tone(row.tone)
150
+ spans(row.note, true)
151
+ for (const tag of bounded(row.tags, 'tags', VIEW_LIMITS.items, true)) text(tag, 'tag')
152
+ action(row.action)
153
+ ensure(row.cells === undefined || object(row.cells), 'invalid_payload', 'Plugin table cells must be an object')
154
+ for (const [column, cell] of Object.entries(row.cells ?? {})) {
155
+ text(column, 'cell column')
156
+ walk(cell, depth + 1, count)
157
+ }
158
+ })
159
+ },
160
+ log(node) { for (const line of bounded(node.lines, 'lines', VIEW_LIMITS.logLines)) text(line, 'log line') },
161
+ button(node) { text(node.label, 'label'); action(node.action, false) },
162
+ }
163
+
164
+ function walk(node, depth, count) {
165
+ ensure(depth <= VIEW_LIMITS.depth, 'invalid_payload', 'Plugin view nests deeper than 8 nodes')
166
+ ensure(++count.nodes <= VIEW_LIMITS.nodes, 'invalid_payload', 'Plugin view has more than 4096 nodes')
167
+ ensure(object(node) && typeof node.type === 'string', 'invalid_payload', 'Plugin view node requires a type')
168
+ // A node type this SDK does not know draws nothing on hosts that do not know it either.
169
+ if (Object.hasOwn(NODES, node.type)) NODES[node.type](node, depth, count)
170
+ }
171
+
172
+ // The daemon checks below mirror crates/standardd/src/plugin_view_content.rs.
173
+
174
+ /** Visits the actions a viewer can activate, in the nodes the daemon searches. */
175
+ function visitActions(node, visit) {
176
+ switch (node.type) {
177
+ case 'stack': case 'row': case 'card':
178
+ for (const child of node.children ?? []) visitActions(child, visit)
179
+ break
180
+ case 'button': visit(node.action); break
181
+ case 'tabs': case 'select': if (node.action !== undefined) visit(node.action); break
182
+ case 'table':
183
+ for (const row of node.rows ?? []) {
184
+ if (row.action !== undefined) visit(row.action)
185
+ for (const cell of Object.values(row.cells ?? {})) visitActions(cell, visit)
186
+ }
187
+ break
188
+ }
189
+ }
190
+
191
+ const ONE_LINE = ['text', 'badge', 'dot', 'progress', 'segments', 'stat', 'divider']
192
+ const PANEL_ONLY = ['card', 'kv', 'tabs', 'select', 'table', 'log', 'button']
193
+ /** Cards hold one-line summaries arranged by stacks and rows; unknown nodes draw nothing. */
194
+ function cardLines(node) {
195
+ if (node.type === 'stack') {
196
+ const children = node.children ?? []
197
+ return children.reduce((total, child) => total + cardLines(child), (node.gap ?? 0) * Math.max(children.length - 1, 0))
198
+ }
199
+ if (node.type === 'row') return (node.children ?? []).reduce((tallest, child) => Math.max(tallest, cardLines(child)), 0)
200
+ if (ONE_LINE.includes(node.type)) return 1
201
+ ensure(!PANEL_ONLY.includes(node.type), 'invalid_payload', `A card view cannot contain a ${node.type} node`)
202
+ return 0
203
+ }
204
+
205
+ /**
206
+ * Throws a PluginError when a view tree breaks a protocol bound or a rule the
207
+ * serving daemon applies. `kind: 'card'` adds the card rules, and `manifest`
208
+ * requires every action `opens` to name one of its panels.
209
+ */
210
+ export function validateView(root, { kind, manifest } = {}) {
211
+ walk(root, 1, { nodes: 0 })
212
+ jsonBytes(root, LIMITS.frameBytes)
213
+ visitActions(root, action => {
214
+ ensure(identifier(action.actionId), 'invalid_payload', 'Plugin view action id must be a plugin identifier')
215
+ ensure(action.value === undefined || Buffer.byteLength(action.value) <= VIEW_LIMITS.actionValueBytes,
216
+ 'payload_too_large', 'Plugin view action value exceeds 512 bytes')
217
+ ensure(action.opens === undefined || identifier(action.opens), 'invalid_payload', 'Plugin view action opens must be a plugin identifier')
218
+ ensure(action.opens === undefined || !manifest ||
219
+ manifest.contributions.some(item => item.id === action.opens && item.kind === 'panel'),
220
+ 'invalid_payload', 'Plugin view action opens must name a declared panel')
221
+ })
222
+ if (kind === 'card') {
223
+ ensure(cardLines(root) <= VIEW_LIMITS.cardLines, 'payload_too_large', 'A card view is taller than 6 lines')
224
+ }
225
+ }
226
+
227
+ function compact(value) {
228
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined))
229
+ }
230
+ const node = (type, fields) => compact({ type, ...fields })
231
+
232
+ /** Optional builders. Each returns the plain protocol JSON for one node. */
233
+ export const ui = Object.freeze({
234
+ view: (root, { title } = {}) => compact({ kind: 'view', root, title }),
235
+ action: (actionId, { value, opens } = {}) => compact({ actionId, value, opens }),
236
+ span: (value, { tone, weight, mono } = {}) => compact({ text: value, tone, weight, mono }),
237
+ stack: (items, { gap } = {}) => node('stack', { gap, children: items }),
238
+ row: (items, { align } = {}) => node('row', { children: items, align }),
239
+ divider: label => node('divider', { label }),
240
+ /** A string sets `text`; an array of spans sets `spans`. */
241
+ text: (content, { tone, weight, mono } = {}) =>
242
+ node('text', { ...(Array.isArray(content) ? { spans: content.map(compact) } : { text: content }), tone, weight, mono }),
243
+ badge: (label, tone) => node('badge', { label, tone }),
244
+ dot: tone => node('dot', { tone }),
245
+ progress: (value, max, { tone, label } = {}) => node('progress', { value, max, tone, label }),
246
+ segments: items => node('segments', { items: items.map(compact) }),
247
+ card: (items, { title, tone } = {}) => node('card', { title, tone, children: items }),
248
+ stat: (label, value, { tone, hint } = {}) => node('stat', { label, value, tone, hint }),
249
+ kv: items => node('kv', { items: items.map(compact) }),
250
+ tabs: (id, items, { filters, action } = {}) => node('tabs', { id, items: items.map(compact), filters, action }),
251
+ select: (id, label, options, { filters, action } = {}) =>
252
+ node('select', { id, label, options: options.map(compact), filters, action }),
253
+ table: (id, columns, rows = []) => node('table', { id, columns: columns.map(compact), rows: rows.map(compact) }),
254
+ log: lines => node('log', { lines }),
255
+ /** `action` is an action object or an action id. */
256
+ button: (label, action) => node('button', { label, action: typeof action === 'string' ? { actionId: action } : action }),
257
+ })