@portel/photon-core 2.4.0 → 2.5.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 (89) hide show
  1. package/dist/asset-discovery.d.ts.map +1 -1
  2. package/dist/asset-discovery.js +2 -1
  3. package/dist/asset-discovery.js.map +1 -1
  4. package/dist/base.d.ts +6 -0
  5. package/dist/base.d.ts.map +1 -1
  6. package/dist/base.js +11 -1
  7. package/dist/base.js.map +1 -1
  8. package/dist/collections/ReactiveArray.d.ts +97 -0
  9. package/dist/collections/ReactiveArray.d.ts.map +1 -0
  10. package/dist/collections/ReactiveArray.js +158 -0
  11. package/dist/collections/ReactiveArray.js.map +1 -0
  12. package/dist/collections/ReactiveMap.d.ts +50 -0
  13. package/dist/collections/ReactiveMap.d.ts.map +1 -0
  14. package/dist/collections/ReactiveMap.js +71 -0
  15. package/dist/collections/ReactiveMap.js.map +1 -0
  16. package/dist/collections/ReactiveSet.d.ts +50 -0
  17. package/dist/collections/ReactiveSet.d.ts.map +1 -0
  18. package/dist/collections/ReactiveSet.js +71 -0
  19. package/dist/collections/ReactiveSet.js.map +1 -0
  20. package/dist/collections/index.d.ts +57 -0
  21. package/dist/collections/index.d.ts.map +1 -0
  22. package/dist/collections/index.js +59 -0
  23. package/dist/collections/index.js.map +1 -0
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +22 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/types.d.ts +2 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/ui-types/Cards.d.ts +139 -0
  31. package/dist/ui-types/Cards.d.ts.map +1 -0
  32. package/dist/ui-types/Cards.js +235 -0
  33. package/dist/ui-types/Cards.js.map +1 -0
  34. package/dist/ui-types/Chart.d.ts +136 -0
  35. package/dist/ui-types/Chart.d.ts.map +1 -0
  36. package/dist/ui-types/Chart.js +188 -0
  37. package/dist/ui-types/Chart.js.map +1 -0
  38. package/dist/ui-types/Field.d.ts +342 -0
  39. package/dist/ui-types/Field.d.ts.map +1 -0
  40. package/dist/ui-types/Field.js +200 -0
  41. package/dist/ui-types/Field.js.map +1 -0
  42. package/dist/ui-types/FieldRenderer.d.ts +32 -0
  43. package/dist/ui-types/FieldRenderer.d.ts.map +1 -0
  44. package/dist/ui-types/FieldRenderer.js +277 -0
  45. package/dist/ui-types/FieldRenderer.js.map +1 -0
  46. package/dist/ui-types/Form.d.ts +212 -0
  47. package/dist/ui-types/Form.d.ts.map +1 -0
  48. package/dist/ui-types/Form.js +278 -0
  49. package/dist/ui-types/Form.js.map +1 -0
  50. package/dist/ui-types/Progress.d.ts +130 -0
  51. package/dist/ui-types/Progress.d.ts.map +1 -0
  52. package/dist/ui-types/Progress.js +191 -0
  53. package/dist/ui-types/Progress.js.map +1 -0
  54. package/dist/ui-types/Stats.d.ts +108 -0
  55. package/dist/ui-types/Stats.d.ts.map +1 -0
  56. package/dist/ui-types/Stats.js +162 -0
  57. package/dist/ui-types/Stats.js.map +1 -0
  58. package/dist/ui-types/Table.d.ts +206 -0
  59. package/dist/ui-types/Table.d.ts.map +1 -0
  60. package/dist/ui-types/Table.js +367 -0
  61. package/dist/ui-types/Table.js.map +1 -0
  62. package/dist/ui-types/base.d.ts +17 -0
  63. package/dist/ui-types/base.d.ts.map +1 -0
  64. package/dist/ui-types/base.js +18 -0
  65. package/dist/ui-types/base.js.map +1 -0
  66. package/dist/ui-types/index.d.ts +42 -0
  67. package/dist/ui-types/index.d.ts.map +1 -0
  68. package/dist/ui-types/index.js +50 -0
  69. package/dist/ui-types/index.js.map +1 -0
  70. package/package.json +2 -2
  71. package/src/asset-discovery.ts +2 -1
  72. package/src/base.ts +13 -1
  73. package/src/collections/ReactiveArray.ts +179 -0
  74. package/src/collections/ReactiveMap.ts +81 -0
  75. package/src/collections/ReactiveSet.ts +81 -0
  76. package/src/collections/index.ts +60 -0
  77. package/src/index.ts +80 -0
  78. package/src/types.ts +2 -0
  79. package/src/ui-types/Cards.ts +286 -0
  80. package/src/ui-types/Chart.ts +239 -0
  81. package/src/ui-types/Field.ts +594 -0
  82. package/src/ui-types/FieldRenderer.ts +364 -0
  83. package/src/ui-types/Form.ts +363 -0
  84. package/src/ui-types/Progress.ts +237 -0
  85. package/src/ui-types/Stats.ts +204 -0
  86. package/src/ui-types/Table.ts +438 -0
  87. package/src/ui-types/base.ts +25 -0
  88. package/src/ui-types/index.ts +96 -0
  89. package/src/ui-types/ui-types.test.ts +444 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Stats - Purpose-driven type for key metrics/KPIs
3
+ *
4
+ * Automatically renders as a dashboard-style stats display.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * async overview() {
9
+ * return new Stats()
10
+ * .stat('Users', 1234, { trend: '+12%', trendUp: true })
11
+ * .stat('Revenue', 50000, { format: 'currency', prefix: '$' })
12
+ * .stat('Orders', 89, { suffix: 'today' })
13
+ * .stat('Conversion', 3.2, { format: 'percent' });
14
+ * }
15
+ * ```
16
+ */
17
+
18
+ import { PhotonUIType } from './base.js';
19
+
20
+ export type StatFormat = 'number' | 'currency' | 'percent' | 'compact';
21
+
22
+ export interface StatItem {
23
+ label: string;
24
+ value: number | string;
25
+ format?: StatFormat;
26
+ prefix?: string;
27
+ suffix?: string;
28
+ trend?: string;
29
+ trendUp?: boolean;
30
+ icon?: string;
31
+ color?: string;
32
+ description?: string;
33
+ }
34
+
35
+ export interface StatsOptions {
36
+ title?: string;
37
+ columns?: 2 | 3 | 4 | 6;
38
+ compact?: boolean;
39
+ bordered?: boolean;
40
+ animated?: boolean;
41
+ }
42
+
43
+ export class Stats extends PhotonUIType {
44
+ readonly _photonType = 'stats' as const;
45
+
46
+ private _stats: StatItem[] = [];
47
+ private _options: StatsOptions = {
48
+ columns: 4,
49
+ bordered: true,
50
+ animated: true,
51
+ };
52
+
53
+ /**
54
+ * Create a new Stats display
55
+ */
56
+ constructor() {
57
+ super();
58
+ }
59
+
60
+ /**
61
+ * Add a stat
62
+ */
63
+ stat(label: string, value: number | string, options?: Partial<Omit<StatItem, 'label' | 'value'>>): this {
64
+ this._stats.push({
65
+ label,
66
+ value,
67
+ ...options,
68
+ });
69
+ return this;
70
+ }
71
+
72
+ /**
73
+ * Add a currency stat
74
+ */
75
+ currency(label: string, value: number, options?: { prefix?: string; trend?: string; trendUp?: boolean }): this {
76
+ return this.stat(label, value, {
77
+ format: 'currency',
78
+ prefix: options?.prefix ?? '$',
79
+ trend: options?.trend,
80
+ trendUp: options?.trendUp,
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Add a percentage stat
86
+ */
87
+ percent(label: string, value: number, options?: { trend?: string; trendUp?: boolean }): this {
88
+ return this.stat(label, value, {
89
+ format: 'percent',
90
+ suffix: '%',
91
+ ...options,
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Add a count stat with compact formatting (1.2K, 5M, etc.)
97
+ */
98
+ count(label: string, value: number, options?: { trend?: string; trendUp?: boolean; suffix?: string }): this {
99
+ return this.stat(label, value, {
100
+ format: 'compact',
101
+ ...options,
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Set section title
107
+ */
108
+ title(title: string): this {
109
+ this._options.title = title;
110
+ return this;
111
+ }
112
+
113
+ /**
114
+ * Set number of columns
115
+ */
116
+ columns(count: 2 | 3 | 4 | 6): this {
117
+ this._options.columns = count;
118
+ return this;
119
+ }
120
+
121
+ /**
122
+ * Use compact layout
123
+ */
124
+ compact(enabled: boolean = true): this {
125
+ this._options.compact = enabled;
126
+ return this;
127
+ }
128
+
129
+ /**
130
+ * Show borders
131
+ */
132
+ bordered(enabled: boolean = true): this {
133
+ this._options.bordered = enabled;
134
+ return this;
135
+ }
136
+
137
+ /**
138
+ * Enable count-up animation
139
+ */
140
+ animated(enabled: boolean = true): this {
141
+ this._options.animated = enabled;
142
+ return this;
143
+ }
144
+
145
+ /**
146
+ * Get stat count
147
+ */
148
+ get length(): number {
149
+ return this._stats.length;
150
+ }
151
+
152
+ toJSON() {
153
+ return {
154
+ _photonType: this._photonType,
155
+ stats: this._stats,
156
+ options: this._options,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Render as plain text for MCP clients
162
+ */
163
+ toString(): string {
164
+ const lines: string[] = [];
165
+
166
+ if (this._options.title) {
167
+ lines.push(`## ${this._options.title}`, '');
168
+ }
169
+
170
+ for (const stat of this._stats) {
171
+ let value = String(stat.value);
172
+
173
+ // Format value
174
+ if (stat.format === 'currency' || stat.prefix) {
175
+ value = (stat.prefix ?? '$') + value;
176
+ }
177
+ if (stat.format === 'percent' || stat.suffix === '%') {
178
+ value = value + '%';
179
+ } else if (stat.suffix) {
180
+ value = value + ' ' + stat.suffix;
181
+ }
182
+ if (stat.format === 'compact' && typeof stat.value === 'number') {
183
+ value = this._formatCompact(stat.value);
184
+ }
185
+
186
+ // Add trend
187
+ let line = `**${stat.label}**: ${value}`;
188
+ if (stat.trend) {
189
+ const arrow = stat.trendUp ? '↑' : stat.trendUp === false ? '↓' : '';
190
+ line += ` (${arrow}${stat.trend})`;
191
+ }
192
+
193
+ lines.push(line);
194
+ }
195
+
196
+ return lines.join('\n');
197
+ }
198
+
199
+ private _formatCompact(num: number): string {
200
+ if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M';
201
+ if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K';
202
+ return String(num);
203
+ }
204
+ }
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Table - Purpose-driven type for tabular data
3
+ *
4
+ * Automatically renders as a table UI with sorting, filtering, etc.
5
+ *
6
+ * @example Basic usage
7
+ * ```typescript
8
+ * async users() {
9
+ * return new Table()
10
+ * .column('name', 'Name', 'string')
11
+ * .column('email', 'Email', 'string')
12
+ * .rows(users);
13
+ * }
14
+ * ```
15
+ *
16
+ * @example With Field system (React Admin-style)
17
+ * ```typescript
18
+ * async products() {
19
+ * return new Table()
20
+ * .fields([
21
+ * Field.image('thumbnail', { width: 60, rounded: true }),
22
+ * Field.text('name', { link: '/products/{id}' }),
23
+ * Field.price('price', { originalSource: 'msrp', currency: 'USD' }),
24
+ * Field.rating('rating', { countSource: 'reviewCount' }),
25
+ * Field.badge('status', { colors: { active: 'green', draft: 'gray' } }),
26
+ * Field.actions([
27
+ * { label: 'Edit', method: 'edit', icon: 'pencil' },
28
+ * { label: 'Delete', method: 'delete', confirm: true },
29
+ * ]),
30
+ * ])
31
+ * .rows(products);
32
+ * }
33
+ * ```
34
+ */
35
+
36
+ import { PhotonUIType } from './base.js';
37
+ import { FieldDefinition, formatFieldLabel, Field } from './Field.js';
38
+ import { renderFieldToText } from './FieldRenderer.js';
39
+
40
+ export type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'currency' | 'link' | 'image' | 'badge';
41
+
42
+ export interface TableColumn {
43
+ key: string;
44
+ label: string;
45
+ type: ColumnType;
46
+ sortable?: boolean;
47
+ width?: string;
48
+ align?: 'left' | 'center' | 'right';
49
+ format?: string; // For date/currency formatting
50
+ }
51
+
52
+ export interface TableOptions {
53
+ title?: string;
54
+ searchable?: boolean;
55
+ sortable?: boolean;
56
+ paginated?: boolean;
57
+ pageSize?: number;
58
+ selectable?: boolean;
59
+ striped?: boolean;
60
+ compact?: boolean;
61
+ }
62
+
63
+ export class Table extends PhotonUIType {
64
+ readonly _photonType = 'table' as const;
65
+
66
+ private _columns: TableColumn[] = [];
67
+ private _fields: FieldDefinition[] = [];
68
+ private _rows: Record<string, any>[] = [];
69
+ private _options: TableOptions = {};
70
+
71
+ /**
72
+ * Create a new Table
73
+ * @param data Optional initial data (array of objects)
74
+ */
75
+ constructor(data?: Record<string, any>[]) {
76
+ super();
77
+ if (data && data.length > 0) {
78
+ this._rows = data;
79
+ // Auto-infer columns from first row
80
+ this._inferColumns(data[0]);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Add a column definition
86
+ */
87
+ column(key: string, label: string, type: ColumnType = 'string', options?: Partial<TableColumn>): this {
88
+ this._columns.push({
89
+ key,
90
+ label,
91
+ type,
92
+ sortable: this._options.sortable ?? true,
93
+ ...options,
94
+ });
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Add multiple columns at once
100
+ */
101
+ columns(cols: Array<[key: string, label: string, type?: ColumnType] | TableColumn>): this {
102
+ for (const col of cols) {
103
+ if (Array.isArray(col)) {
104
+ this.column(col[0], col[1], col[2] ?? 'string');
105
+ } else {
106
+ this._columns.push(col);
107
+ }
108
+ }
109
+ return this;
110
+ }
111
+
112
+ // ═══════════════════════════════════════════════════════════════════════════
113
+ // Field-based API (React Admin-style)
114
+ // ═══════════════════════════════════════════════════════════════════════════
115
+
116
+ /**
117
+ * Add fields using the Field system
118
+ */
119
+ fields(fieldDefs: FieldDefinition[]): this {
120
+ this._fields = fieldDefs;
121
+ return this;
122
+ }
123
+
124
+ /**
125
+ * Add a single field
126
+ */
127
+ field(fieldDef: FieldDefinition): this {
128
+ this._fields.push(fieldDef);
129
+ return this;
130
+ }
131
+
132
+ // Convenience methods for common field types
133
+
134
+ /** Add text field */
135
+ text(source: string, options?: Parameters<typeof Field.text>[1]): this {
136
+ return this.field(Field.text(source, options));
137
+ }
138
+
139
+ /** Add email field */
140
+ email(source: string, options?: Parameters<typeof Field.email>[1]): this {
141
+ return this.field(Field.email(source, options));
142
+ }
143
+
144
+ /** Add URL field */
145
+ url(source: string, options?: Parameters<typeof Field.url>[1]): this {
146
+ return this.field(Field.url(source, options));
147
+ }
148
+
149
+ /** Add phone field */
150
+ phone(source: string, options?: Parameters<typeof Field.phone>[1]): this {
151
+ return this.field(Field.phone(source, options));
152
+ }
153
+
154
+ /** Add number field */
155
+ number(source: string, options?: Parameters<typeof Field.number>[1]): this {
156
+ return this.field(Field.number(source, options));
157
+ }
158
+
159
+ /** Add currency field */
160
+ currency(source: string, options?: Parameters<typeof Field.currency>[1]): this {
161
+ return this.field(Field.currency(source, options));
162
+ }
163
+
164
+ /** Add percent field */
165
+ percent(source: string, options?: Parameters<typeof Field.percent>[1]): this {
166
+ return this.field(Field.percent(source, options));
167
+ }
168
+
169
+ /** Add date field */
170
+ date(source: string, options?: Parameters<typeof Field.date>[1]): this {
171
+ return this.field(Field.date(source, options));
172
+ }
173
+
174
+ /** Add datetime field */
175
+ datetime(source: string, options?: Parameters<typeof Field.datetime>[1]): this {
176
+ return this.field(Field.datetime(source, options));
177
+ }
178
+
179
+ /** Add boolean field */
180
+ boolean(source: string, options?: Parameters<typeof Field.boolean>[1]): this {
181
+ return this.field(Field.boolean(source, options));
182
+ }
183
+
184
+ /** Add image field */
185
+ image(source: string, options?: Parameters<typeof Field.image>[1]): this {
186
+ return this.field(Field.image(source, options));
187
+ }
188
+
189
+ /** Add avatar field */
190
+ avatar(source: string, options?: Parameters<typeof Field.avatar>[1]): this {
191
+ return this.field(Field.avatar(source, options));
192
+ }
193
+
194
+ /** Add badge field */
195
+ badge(source: string, options?: Parameters<typeof Field.badge>[1]): this {
196
+ return this.field(Field.badge(source, options));
197
+ }
198
+
199
+ /** Add tags field */
200
+ tags(source: string, options?: Parameters<typeof Field.tags>[1]): this {
201
+ return this.field(Field.tags(source, options));
202
+ }
203
+
204
+ /** Add rating field */
205
+ rating(source: string, options?: Parameters<typeof Field.rating>[1]): this {
206
+ return this.field(Field.rating(source, options));
207
+ }
208
+
209
+ /** Add price field */
210
+ price(source: string, options?: Parameters<typeof Field.price>[1]): this {
211
+ return this.field(Field.price(source, options));
212
+ }
213
+
214
+ /** Add stock field */
215
+ stock(source: string, options?: Parameters<typeof Field.stock>[1]): this {
216
+ return this.field(Field.stock(source, options));
217
+ }
218
+
219
+ /** Add user field */
220
+ user(source: string, options?: Parameters<typeof Field.user>[1]): this {
221
+ return this.field(Field.user(source, options));
222
+ }
223
+
224
+ /** Add reference field */
225
+ reference(source: string, options?: Parameters<typeof Field.reference>[1]): this {
226
+ return this.field(Field.reference(source, options));
227
+ }
228
+
229
+ /** Add actions field */
230
+ actions(items: Parameters<typeof Field.actions>[0], options?: Parameters<typeof Field.actions>[1]): this {
231
+ return this.field(Field.actions(items, options));
232
+ }
233
+
234
+ /** Add custom field */
235
+ custom(source: string, render: Parameters<typeof Field.custom>[1], options?: Parameters<typeof Field.custom>[2]): this {
236
+ return this.field(Field.custom(source, render, options));
237
+ }
238
+
239
+ // ═══════════════════════════════════════════════════════════════════════════
240
+ // Data Methods
241
+ // ═══════════════════════════════════════════════════════════════════════════
242
+
243
+ /**
244
+ * Set the table rows
245
+ */
246
+ rows(data: Record<string, any>[]): this {
247
+ this._rows = data;
248
+ // If no columns defined, infer from data
249
+ if (this._columns.length === 0 && data.length > 0) {
250
+ this._inferColumns(data[0]);
251
+ }
252
+ return this;
253
+ }
254
+
255
+ /**
256
+ * Add a single row
257
+ */
258
+ row(data: Record<string, any>): this {
259
+ this._rows.push(data);
260
+ if (this._columns.length === 0) {
261
+ this._inferColumns(data);
262
+ }
263
+ return this;
264
+ }
265
+
266
+ /**
267
+ * Set table title
268
+ */
269
+ title(title: string): this {
270
+ this._options.title = title;
271
+ return this;
272
+ }
273
+
274
+ /**
275
+ * Enable/disable search
276
+ */
277
+ searchable(enabled: boolean = true): this {
278
+ this._options.searchable = enabled;
279
+ return this;
280
+ }
281
+
282
+ /**
283
+ * Enable/disable sorting
284
+ */
285
+ sortable(enabled: boolean = true): this {
286
+ this._options.sortable = enabled;
287
+ return this;
288
+ }
289
+
290
+ /**
291
+ * Enable pagination
292
+ */
293
+ paginated(pageSize: number = 10): this {
294
+ this._options.paginated = true;
295
+ this._options.pageSize = pageSize;
296
+ return this;
297
+ }
298
+
299
+ /**
300
+ * Enable row selection
301
+ */
302
+ selectable(enabled: boolean = true): this {
303
+ this._options.selectable = enabled;
304
+ return this;
305
+ }
306
+
307
+ /**
308
+ * Use striped rows
309
+ */
310
+ striped(enabled: boolean = true): this {
311
+ this._options.striped = enabled;
312
+ return this;
313
+ }
314
+
315
+ /**
316
+ * Use compact layout
317
+ */
318
+ compact(enabled: boolean = true): this {
319
+ this._options.compact = enabled;
320
+ return this;
321
+ }
322
+
323
+ /**
324
+ * Infer columns from a data row
325
+ */
326
+ private _inferColumns(row: Record<string, any>): void {
327
+ for (const [key, value] of Object.entries(row)) {
328
+ const type = this._inferType(value);
329
+ const label = this._formatLabel(key);
330
+ this._columns.push({ key, label, type, sortable: true });
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Infer column type from value
336
+ */
337
+ private _inferType(value: any): ColumnType {
338
+ if (typeof value === 'number') return 'number';
339
+ if (typeof value === 'boolean') return 'boolean';
340
+ if (value instanceof Date) return 'date';
341
+ if (typeof value === 'string') {
342
+ if (value.match(/^\d{4}-\d{2}-\d{2}/)) return 'date';
343
+ if (value.match(/^https?:\/\//)) return 'link';
344
+ if (value.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) return 'image';
345
+ }
346
+ return 'string';
347
+ }
348
+
349
+ /**
350
+ * Format key to human-readable label
351
+ */
352
+ private _formatLabel(key: string): string {
353
+ return key
354
+ .replace(/([A-Z])/g, ' $1') // camelCase to spaces
355
+ .replace(/[_-]/g, ' ') // snake_case/kebab-case to spaces
356
+ .replace(/^\w/, c => c.toUpperCase()) // Capitalize first letter
357
+ .trim();
358
+ }
359
+
360
+ /**
361
+ * Get row count
362
+ */
363
+ get length(): number {
364
+ return this._rows.length;
365
+ }
366
+
367
+ /**
368
+ * Check if table is empty
369
+ */
370
+ get isEmpty(): boolean {
371
+ return this._rows.length === 0;
372
+ }
373
+
374
+ /**
375
+ * Check if using Field system or legacy columns
376
+ */
377
+ private get _useFields(): boolean {
378
+ return this._fields.length > 0;
379
+ }
380
+
381
+ /**
382
+ * Get effective headers for display
383
+ */
384
+ private _getHeaders(): string[] {
385
+ if (this._useFields) {
386
+ return this._fields.map(f => f.options.label ?? formatFieldLabel(f.source));
387
+ }
388
+ return this._columns.map(c => c.label);
389
+ }
390
+
391
+ /**
392
+ * Get cell values for a row
393
+ */
394
+ private _getCells(row: Record<string, any>): string[] {
395
+ if (this._useFields) {
396
+ return this._fields.map(f => renderFieldToText(f, row));
397
+ }
398
+ return this._columns.map(c => String(row[c.key] ?? ''));
399
+ }
400
+
401
+ toJSON() {
402
+ return {
403
+ _photonType: this._photonType,
404
+ columns: this._columns,
405
+ fields: this._fields,
406
+ rows: this._rows,
407
+ options: this._options,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Render as plain text/markdown for MCP clients
413
+ */
414
+ toString(): string {
415
+ if (this._rows.length === 0) {
416
+ return this._options.title ? `${this._options.title}\n\n(No data)` : '(No data)';
417
+ }
418
+
419
+ const lines: string[] = [];
420
+
421
+ if (this._options.title) {
422
+ lines.push(`## ${this._options.title}`, '');
423
+ }
424
+
425
+ // Header row
426
+ const headers = this._getHeaders();
427
+ lines.push('| ' + headers.join(' | ') + ' |');
428
+ lines.push('| ' + headers.map(() => '---').join(' | ') + ' |');
429
+
430
+ // Data rows
431
+ for (const row of this._rows) {
432
+ const cells = this._getCells(row);
433
+ lines.push('| ' + cells.join(' | ') + ' |');
434
+ }
435
+
436
+ return lines.join('\n');
437
+ }
438
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Base class for purpose-driven UI types
3
+ *
4
+ * All UI types extend this to get the _photonType discriminator
5
+ * that auto-UI uses to select the appropriate renderer.
6
+ */
7
+ export abstract class PhotonUIType {
8
+ /** Discriminator for auto-UI type detection */
9
+ abstract readonly _photonType: string;
10
+
11
+ /** Convert to JSON-serializable format */
12
+ abstract toJSON(): Record<string, any>;
13
+ }
14
+
15
+ /**
16
+ * Check if a value is a PhotonUIType
17
+ */
18
+ export function isPhotonUIType(value: unknown): value is PhotonUIType {
19
+ return (
20
+ value !== null &&
21
+ typeof value === 'object' &&
22
+ '_photonType' in value &&
23
+ typeof (value as any)._photonType === 'string'
24
+ );
25
+ }