# Custom UI Development Guide > Architecture note: for serious app shells and reusable client-side renderers, > see [Client-First UI Architecture](../internals/CLIENT-FIRST-UI-ARCHITECTURE.md). Build rich interactive UIs for your photons. A global named after your photon file is auto-injected into the iframe — call methods and subscribe to events directly (e.g., `kanban.onTaskMove(cb)`). --- ## Table of Contents - [Overview](#overview) - [MCP Apps Extension (SEP-1865)](#mcp-apps-extension-sep-1865) - [Platform Compatibility](#platform-compatibility) - [Window.photon API](#windowphoton-api) - [Theming](#theming) - [State Management](#state-management) - [Tool Invocation](#tool-invocation) - [Real-time Updates](#real-time-updates) - [Examples](#examples) - [Using Auto UI Renderers (photon.render)](#using-auto-ui-renderers-photonrender) - [Declarative Templates (.photon.html)](#declarative-templates-photonhtml) - [TSX Views (.tsx)](#tsx-views-tsx) --- ## Overview Photon custom UIs run in iframes and communicate with the host (BEAM, Claude Desktop, ChatGPT) via postMessage. The platform bridge automatically injects compatible APIs for: - **MCP Apps Extension (SEP-1865)** - Standard protocol for MCP UIs - **ChatGPT Apps SDK** - window.openai compatibility - **Claude Artifacts** - Theme synchronization --- ## Sandbox Constraints Photon UIs are loaded into a sandboxed `blob:` iframe so the same HTML works in **every** MCP client (Beam, Claude Desktop, ChatGPT, Cursor, future clients). Portability is the whole point — but the sandbox has real limits that matter if you try to run heavy browser features like client-side AI models, WebRTC, or WebGPU. ### What doesn't work inside the iframe - **Cross-origin `fetch()`** — the iframe origin is `null`/opaque, so many CDNs reject CORS preflight. Loading model weights from HuggingFace, jsdelivr, unpkg often fails. - **SharedArrayBuffer / threaded WASM** — requires `Cross-Origin-Isolated`, which needs COOP/COEP headers the host client does not set. Rules out WebLLM and threaded ONNX Runtime. - **WebGPU, camera, microphone** — gated by Permissions-Policy on the parent iframe; not guaranteed across clients. - **Dynamic `import()` of remote ESM / `importScripts` over http(s)** — often blocked from `blob:` contexts. - **Persistent IndexedDB / Cache Storage** — scoped to the opaque origin, so models may re-download each session. These are **host-imposed** constraints, not photon-runtime bugs. Changing them would either break portability or require every MCP client to adopt COOP/COEP, which is out of our control. ### Choosing a strategy (photon author's call) If your photon needs capabilities that bump into the sandbox, pick one of these up front: 1. **Run it on the backend (recommended default).** Do the work in a photon method using Node/Bun libraries (`onnxruntime-node`, `@xenova/transformers`, `sharp`, etc.) and return results to the UI. Works in every MCP client, model cached on disk, no sandbox friction. Trade-off: no live webcam/audio stream without round-trips. 2. **Proxy assets through a photon method.** Expose a method that returns model weights / remote resources as bytes. The UI calls it via the injected bridge instead of `fetch()`, sidestepping CORS from `blob:`. Portable, but slower first load. 3. **Inline small assets as data URIs.** For models or datasets under a few MB (face/pose detection, small classifiers, fonts), base64-embed them in the UI HTML. Zero fetches, fully portable, ugly diff. 4. **Accept single-threaded WASM.** Most detection-class models (MediaPipe Tasks, small ONNX via transformers.js) run fine single-threaded inside the sandbox. Slower than WebGPU/threads but fully portable. 5. **Beam-only enhancement.** If and only if a feature genuinely cannot work under the sandbox and is acceptable as a Beam-only feature, document that clearly in the photon's README. Do not design the core experience around it — the photon must still work in other MCP clients. **Rule of thumb:** if in doubt, do it on the backend. The `@ui` HTML is a renderer, not an application runtime. --- ## MCP Apps Extension (SEP-1865) The [MCP Apps Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) defines a standard protocol for rendering UIs in MCP-compatible clients. ### Initialization When your UI loads, it receives a `ui/initialize` message: ```json { "jsonrpc": "2.0", "method": "ui/initialize", "params": { "hostContext": { "name": "beam", "version": "1.5.0" }, "hostCapabilities": { "toolCalling": true, "resourceReading": true, "elicitation": true }, "containerDimensions": { "mode": "responsive", "width": 800, "height": 600 }, "theme": { "--color-bg": "#0d0d0d", "--color-text": "#e6e6e6" } } } ``` ### Ready Signal Your UI must signal readiness: ```javascript window.parent.postMessage({ jsonrpc: '2.0', method: 'ui/ready', params: {} }, '*'); ``` --- ## Platform Compatibility BEAM injects APIs for multiple platforms, so your UI works everywhere: | Platform | API | Auto-Injected | |----------|-----|---------------| | BEAM | `{photonName}` global (e.g., `kanban`, `chess`) | Yes | | BEAM | `photon` low-level bridge | Yes | | BEAM | `openai` (ChatGPT compat) | Yes | | ChatGPT | `openai` | Native | | Claude | postMessage | Native | --- ## Photon Bridge API Two APIs are injected into your custom UI iframe: 1. **`{photonName}`** (recommended) — A clean global named after your photon file. Call methods and subscribe to events directly: `kanban.taskMove(args)`, `kanban.onTaskMove(cb)`. 2. **`photon`** — The low-level bridge with full control over tool I/O, progress, streaming, elicitation, and state. No `window.` prefix needed — both are available as bare globals. ### Low-level bridge (`photon`) ### Properties ```typescript interface PhotonAPI { // Tool input/output readonly toolInput: Record; // Input parameters readonly toolOutput: any; // Last result readonly widgetState: any; // Persisted state // Context readonly theme: 'light' | 'dark'; readonly locale: string; readonly photon: string; // Photon name readonly method: string; // Current method readonly isChatGPT: boolean; // Running in ChatGPT? } ``` ### Methods ```typescript // State persistence (survives page reload) setWidgetState(state: any): void; // Tool invocation callTool(name: string, args: Record): Promise; invoke(name: string, args: Record): Promise; // Alias // Follow-up message sendFollowUpMessage(message: string): void; // Event subscriptions (each returns an unsubscribe function) onProgress(cb: (event: { value: number; message?: string }) => void): () => void; onStatus(cb: (event: { message: string }) => void): () => void; onStream(cb: (event: { chunk: string }) => void): () => void; onEmit(cb: (event: { emit: string; data?: any }) => void): () => void; onResult(cb: (result: any) => void): () => void; onError(cb: (error: any) => void): () => void; onThemeChange(cb: (theme: 'light' | 'dark') => void): () => void; onToolInputPartial(cb: (partial: any) => void): () => void; onToolInput(cb: (input: any) => void): () => void; onElicitation(handler: (event: any) => Promise): () => void; onTeardown(handler: () => void): () => void; // Model context update (MCP Apps Extension) updateModelContext(opts: { content?: string; structuredContent?: any }): Promise; // Toast notifications (displayed in host UI) showToast(message: string, type?: 'info' | 'success' | 'warning' | 'error', duration?: number): void; // Safe area insets (for mobile-aware layouts) readonly safeAreaInsets: { top: number; bottom: number; left: number; right: number }; ``` > **Note:** State restoration uses a DOM event, not the `window.photon` API: > `window.addEventListener('photon:state-restored', (event) => { /* event.detail contains state */ })` ### Photon global (recommended) Each photon gets a global named after the `.photon.ts` file. No `window.` prefix needed: ```typescript // For kanban.photon.ts: kanban = { // onEventName → subscribes to 'eventName' onTaskMove(cb): () => void, // subscribes to 'taskMove' event onTaskCreate(cb): () => void, // subscribes to 'taskCreate' event // ... any event name works (convention: on + PascalCase) // methodName → calls server tool taskMove(args): Promise, // calls photon.callTool('taskMove', args) taskCreate(args): Promise, // calls photon.callTool('taskCreate', args) // ... any method name works }; ``` **Usage pattern:** ```javascript // Server code: // Client code: this.emit('taskMove', data); → kanban.onTaskMove(cb) taskMove(params) { ... } → kanban.taskMove(params) ``` ### Event Subscriptions ```typescript // ═══════════════════════════════════════════════════════════════════════════ // DIRECT WINDOW API (Recommended for real-time sync) // ═══════════════════════════════════════════════════════════════════════════ // Subscribe to specific events using the direct window API // Server: this.emit('taskMove', data) // Client: kanban.onTaskMove(callback) kanban.onTaskMove((data) => { moveTaskInUI(data.taskId, data.column); }); kanban.onTaskCreate((data) => { addTaskToUI(data.task); }); // Call server methods await kanban.taskMove({ id: 'task-1', column: 'Done' }); // ═══════════════════════════════════════════════════════════════════════════ // BUILT-IN EVENT TYPES // ═══════════════════════════════════════════════════════════════════════════ // Progress updates (0-1 value) photon.onProgress((event) => { console.log(`${event.value * 100}%: ${event.message}`); }); // Status messages photon.onStatus((event) => { console.log(`Status: ${event.message}`); }); // Stream data photon.onStream((event) => { console.log(`Chunk: ${event.chunk}`); }); // All emit events (includes custom events) photon.onEmit((event) => { console.log(`Event: ${event.event}`, event.data); }); // Final result photon.onResult((result) => { console.log('Complete:', result); }); // Theme changes photon.onThemeChange((theme) => { document.body.className = theme; }); // ═══════════════════════════════════════════════════════════════════════════ // TOAST NOTIFICATIONS // ═══════════════════════════════════════════════════════════════════════════ // Show a toast in the host Beam UI (not inside the iframe) photon.showToast('Changes saved!', 'success'); photon.showToast('Upload failed', 'error', 5000); photon.showToast('Processing...', 'info', 2000); ``` --- ## Theming ### CSS Variables The host injects CSS variables for consistent theming: ```css :root { /* Background */ --color-bg: #0d0d0d; --color-bg-elevated: #1a1a1a; --color-bg-subtle: #262626; /* Text */ --color-text: #e6e6e6; --color-text-muted: #999999; --color-text-subtle: #666666; /* Accent */ --color-accent: #6366f1; --color-accent-hover: #818cf8; /* Status */ --color-success: #22c55e; --color-warning: #eab308; --color-error: #ef4444; /* Borders */ --color-border: #333333; --radius-sm: 4px; --radius-md: 8px; --radius-lg: 16px; } ``` ### Theme Detection ```typescript // Listen for theme changes photon.onThemeChange((theme) => { document.documentElement.classList.remove('light', 'dark'); document.documentElement.classList.add(theme); }); // Or use CSS @media (prefers-color-scheme: dark) { :root { /* dark theme */ } } @media (prefers-color-scheme: light) { :root { /* light theme */ } } ``` --- ## State Management ### Persisted Widget State State persists across page reloads and sessions: ```typescript // Save state photon.setWidgetState({ selectedTab: 'settings', filters: ['active', 'pending'] }); // Read current state const state = photon.widgetState; // Listen for state restoration window.addEventListener('photon:state-restored', (event) => { const state = event.detail; renderWithState(state); }); ``` ### Tool Input Access parameters passed to the tool: ```typescript // Read input const { query, limit } = photon.toolInput; // Use in UI document.getElementById('search').value = query || ''; ``` --- ## Tool Invocation ### Basic Call ```typescript try { const result = await photon.callTool('search', { query: 'typescript', limit: 10 }); console.log('Results:', result); } catch (error) { console.error('Tool failed:', error.message); } ``` ### With Loading State ```typescript const searchBtn = document.getElementById('search-btn'); searchBtn.onclick = async () => { searchBtn.disabled = true; searchBtn.textContent = 'Searching...'; try { const results = await photon.callTool('search', { query: document.getElementById('query').value }); renderResults(results); } catch (error) { showError(error.message); } finally { searchBtn.disabled = false; searchBtn.textContent = 'Search'; } }; ``` --- ## Real-time Updates ### Cross-Client Sync Photon enables real-time sync between Beam, Claude Desktop, and any MCP Apps-compatible client using standard MCP protocol. **How it works:** 1. Server emits: `this.emit('taskMove', data)` 2. Photon sends standard `ui/notifications/host-context-changed` with embedded `_photon` data 3. Claude Desktop (and other hosts) forward this standard notification 4. Photon bridge extracts `_photon` and routes to your event handlers ### Direct Window API (Recommended) The cleanest way to handle real-time events: ```typescript // Subscribe to specific events // Pattern: {photonName}.on{EventName}(callback) kanban.onTaskMove((data) => { moveTaskInUI(data.taskId, data.column); }); kanban.onTaskCreate((data) => { addTaskToUI(data.task); }); kanban.onBoardUpdate((data) => { refreshBoard(data); }); // Call methods (same pattern) await kanban.taskMove({ id: 'task-1', column: 'Done' }); ``` ### Generic Event Subscription For catching all events: ```typescript // Listen for ALL events photon.onEmit((event) => { console.log(`Event: ${event.emit}`, event.data); switch (event.emit) { case 'taskMove': moveTaskInUI(event.data.taskId, event.data.column); break; case 'taskCreate': addTaskToUI(event.data.task); break; } }); ``` ### Notify Viewing (for subscription management) Tell the host what resource you're viewing (enables ref-counted subscriptions): ```typescript window.parent.postMessage({ type: 'photon:viewing', itemId: 'my-board' }, '*'); ``` ### Progress Visualization ```html
0%
``` --- ## Sharing a UI Across Methods Multiple methods can share the same HTML template by referencing the same `@ui` asset ID. The first method tagged becomes the primary (used for app detection); all tagged methods render their results in the same UI. ```typescript /** * @ui dashboard */ export default class Analytics { /** @ui dashboard */ async overview() { return { visits: 1000, bounceRate: 0.3 }; } /** @ui dashboard */ async realtime() { return { activeUsers: 42 }; } /** @ui dashboard */ async funnel({ step }: { step: string }) { return { conversion: 0.12 }; } } ``` All three methods render inside `dashboard.html`. The UI receives whichever method's result via `onResult` and can distinguish them by shape or by inspecting the data. Pathless class-level `@ui dashboard` resolves the file by convention: 1. `ui/dashboard.photon.tsx` 2. `ui/dashboard.tsx` 3. `ui/dashboard.photon.html` 4. `ui/dashboard.html` Use `@ui dashboard ./some/path/index.html` only when the UI is outside the conventional `ui/` folder or when a prebuilt bundle needs sibling chunk serving from its own directory. --- ## Examples ### Minimal Custom UI ```html

My Custom UI



  


```

### React Integration

```tsx
import { useEffect, useState } from 'react';

declare global {
  interface Window {
    photon: {
      toolInput: Record;
      widgetState: any;
      setWidgetState: (state: any) => void;
      callTool: (name: string, args: any) => Promise;
      onProgress: (cb: (e: any) => void) => () => void;
      onEmit: (cb: (e: { emit: string; data?: any }) => void) => () => void;
      onResult: (cb: (r: any) => void) => () => void;
      onError: (cb: (err: any) => void) => () => void;
      onThemeChange: (cb: (theme: 'light' | 'dark') => void) => () => void;
      theme: 'light' | 'dark';
    };
    // Direct window API (e.g., window.kanban)
    [photonName: string]: any;
  }
}

export function usePhoton() {
  const [input] = useState(() => window.photon.toolInput);
  const [state, setState] = useState(() => window.photon.widgetState || {});
  const [theme] = useState(() => window.photon.theme);

  const updateState = (newState: any) => {
    setState(newState);
    window.photon.setWidgetState(newState);
  };

  return { input, state, updateState, theme, callTool: window.photon.callTool };
}

// Hook for real-time emit events
export function usePhotonEmit(callback: (event: { emit: string; data?: any }) => void) {
  useEffect(() => {
    const unsubscribe = window.photon.onEmit(callback);
    return unsubscribe;
  }, [callback]);
}

function KanbanApp() {
  const [tasks, setTasks] = useState([]);

  // Subscribe to real-time events using direct window API
  useEffect(() => {
    const kanban = window.kanban;

    const unsub1 = kanban.onTaskMove((data: any) => {
      setTasks(prev => prev.map(t =>
        t.id === data.taskId ? { ...t, column: data.column } : t
      ));
    });

    const unsub2 = kanban.onTaskCreate((data: any) => {
      setTasks(prev => [...prev, data.task]);
    });

    return () => { unsub1(); unsub2(); };
  }, []);

  const moveTask = async (taskId: string, column: string) => {
    await window.kanban.taskMove({ id: taskId, column });
  };

  return (
    
{tasks.map(task => (
{task.title} ({task.column})
))}
); } ``` --- ## Using Auto UI Renderers (photon.render) Custom UIs don't have to build everything from scratch. `photon.render()` lets you use the same format renderers that auto UI uses — tables, charts, gauges, badges, and more — inside your own layout. ### Quick Start ```javascript // 1. Get data from a method const data = await showcase.cpu(); // 2. Render it using a format photon.render(document.getElementById('gauge'), data, 'gauge'); ``` That's it. The renderer handles theming, formatting, and interactivity automatically. ### API ```typescript photon.render(container: HTMLElement, data: any, format: string, opts?: object): void ``` | Parameter | Type | Description | |-----------|------|-------------| | `container` | `HTMLElement` | DOM element to render into (innerHTML is replaced) | | `data` | `any` | Data to visualize — shape depends on format | | `format` | `string` | Format type (see table below) | | `opts` | `object?` | Optional overrides (columns, min/max, labels, etc.) | ### Available Formats | Format | Data Shape | Description | |--------|-----------|-------------| | `table` | `Array` | Sortable table with auto-detected columns | | `gauge` | `{ value, max, label?, unit? }` | SVG semicircular gauge with color gradient | | `metric` | `{ value, label?, delta?, trend? }` | Large KPI display with trend arrow | | `stat-group` | `Array<{ label, value, delta?, trend?, prefix?, suffix? }>` | Row of KPI stat cards | | `progress` | `{ value, max?, label? }` | Animated progress bar with percentage | | `chart:bar` | `Array` | Bar chart (auto-detects label/value fields) | | `chart:hbar` | `Array` | Horizontal bar chart (same shape as `chart:bar`) | | `chart:line` | `Array` | Line chart (auto-detects time series) | | `chart:pie` | `Array` | Pie chart | | `chart:area` | `Array` | Area chart (line with fill) | | `chart:donut` | `Array` | Donut chart | | `chart:radar` | `Array` | Radar chart displaying multivariate data | | `sparkline` | `Array` | Minimalist inline line chart without axes | | `ring` | `{ value, max?, label? }` | Circular progress indicator | | `timeline` | `Array<{ time, event, details? }>` | Chronological event list with dots and lines | | `alert` | `{ title?, description, variant?, icon? }` | Callout box for important information | | `badge` | `string` | Colored status badge (auto-detects variant) | | `list` | `Array<{ name, subtitle?, status? }>` | iOS-style list rows with optional badges | | `kv` / `card` | `object` | Key-value pairs in alternating rows | | `steps` / `stepper` | `Array<{ label, status, detail? }>` | Step-by-step progress indicator | | `kanban` | `{ columns: [{ title, items: [{ title, assignee?, priority? }] }] }` | Kanban board with columns and cards | | `comparison` | `{ items: [{ name, ...props }], highlight? }` | Side-by-side property comparison | | `diff` | unified diff string or `{ before, after, filename? }` | Diff viewer with added/removed highlighting | | `log` | `Array<{ level, message, timestamp?, source? }>` | Structured log viewer with level coloring | | `embed` | URL string or `{ url, title? }` | Embed an external URL in an iframe | | `heatmap` | `{ rows, cols, values }` or `[{ rowKey, colKey, value }]` | Color-intensity activity heatmap | | `calendar` | `Array<{ title, start, end?, allDay?, color? }>` | Monthly/weekly calendar view with events | | `map` | `Array<{ lat, lng, label?, popup? }>` | Interactive map with markers | | `network` / `graph` | `{ nodes: [{ id, label, group? }], edges: [{ from, to, label? }] }` | Node-edge graph diagram | | `cron` | cron string or `{ expression, description? }` | Human-readable cron expression display | | `image` | URL string, `{ src, caption? }`, or array | Single image or image list with captions | | `carousel` | `Array<{ src, caption? }>` | Horizontally scrolling image carousel | | `gallery` | `Array<{ src, caption?, full? }>` | Thumbnail grid with lightbox expand | | `masonry` | `Array<{ src, caption? }>` | Pinterest-style masonry image grid | | `hero` | `{ title, subtitle?, image?, cta?, url? }` | Full-width hero section | | `banner` | `{ message, type?, icon? }` | Dismissable notification banner | | `empty` / `empty-state` | `{ title?, description?, icon?, action? }` | Centralized empty state placeholder | | `accordion` | `Array<{ title, content }>` | Collapsible list of items | | `feed` | `Array<{ user, action, target?, timestamp?, details? }>` | Rich activity stream with avatars and details | | `tabs` | `Array<{ title, content }>` or `object` | Tabbed navigation panels | | `tree` | `object` or `Array` | Collapsible JSON-like structural tree viewer | | `datatable` | `Array` | Interactive table with search, sort, and pagination | | `quote` | `{ text, author?, source?, avatar? }` | Styled pull-quote with attribution | | `profile` | `{ name, avatar?, role?, bio?, stats? }` | User profile card with avatar and stats | | `feature-grid` | `Array<{ icon, title, description }>` | Marketing feature grid | | `invoice` / `receipt` | `{ items: [{ description, quantity, rate, amount }], total, ... }` | Itemized invoice with totals | | `markdown` | `string` | Basic markdown rendering (headings, bold, code, lists) | | `code` | `string` | Syntax-highlighted code (keywords, strings, numbers, comments) | | `json` | `any` | Pretty-printed JSON in a `
` block |

### Data Shape Examples

```javascript
// Gauge — value within a range
photon.render(el, { value: 73, max: 100, label: 'CPU Usage', unit: '%' }, 'gauge');

// Metric — big number with trend
photon.render(el, { value: 14283, label: 'Active Users', delta: 842, trend: 'up' }, 'metric');

// Table — array of objects (columns auto-detected from keys)
photon.render(el, [
  { name: 'Alice', role: 'Admin', status: 'Active' },
  { name: 'Bob',   role: 'Editor', status: 'Offline' },
], 'table');

// Chart — array with string + numeric fields
photon.render(el, [
  { month: 'Jan', revenue: 12400, costs: 8200 },
  { month: 'Feb', revenue: 15800, costs: 9100 },
], 'chart:bar');

// Badge — auto-detects color from text
photon.render(el, 'Active', 'badge');    // green
photon.render(el, 'Degraded', 'badge');  // yellow
photon.render(el, 'Offline', 'badge');   // red

// Timeline — chronological events
photon.render(el, [
  { time: '2026-03-18T08:00:00Z', event: 'Deploy started', details: 'v2.4.1' },
  { time: '2026-03-18T08:05:00Z', event: 'Deploy live', details: 'All regions healthy' },
], 'timeline');
```

### Options

Some renderers accept options to override auto-detection:

```javascript
// Table — specify which columns to show
photon.render(el, data, 'table', { columns: ['name', 'status'] });

// Gauge — override min/max range
photon.render(el, { value: 4.2 }, 'gauge', { min: 0, max: 16, label: 'Memory', unit: 'GB' });

// Chart — specify axis fields
photon.render(el, data, 'chart:line', { x: 'timestamp', y: 'temperature' });
```

### Full Dashboard Pattern

The typical pattern combines `window[photonName]` for data and `photon.render()` for visualization:

```html
``` ### Theme Awareness Renderers auto-detect dark/light mode from the host theme. Colors, borders, and text adjust automatically — no extra configuration needed. ### Lazy Loading `photon.render()` lazy-loads the renderer library on first call. Chart formats further lazy-load Chart.js from CDN. The initial call may have a brief delay; subsequent calls are instant. ### Example Photon See [render-showcase.photon.ts](https://github.com/portel-dev/photon-examples/blob/main/render-showcase.photon.ts) for a complete working example with all 11 format types rendered in a custom dashboard. --- ## Declarative Templates (.photon.html) For UIs that display method results, you can skip JavaScript entirely. Use the `.photon.html` file extension to opt into **declarative mode** — inspired by [Datastar](https://data-star.dev/)'s SSE-first hypermedia approach, but with metadata-driven auto-inference. Where Datastar uses explicit `@get('/url')` actions, photon auto-resolves method metadata from your docblock tags — format, reactivity, and refresh are inferred automatically. ### Two Modes | Extension | Mode | What happens | |-----------|------|-------------| | `dashboard.html` | **Full control** | Bridge injected, you write all JavaScript | | `dashboard.photon.html` | **Declarative** | Auto-wrapped with base CSS, data attributes bind to methods | | `dashboard.tsx` | **Component** | TSX compiled with built-in JSX runtime, bundled into HTML | | `dashboard.photon.tsx` | **Declarative + TSX** | Declarative mode with TSX components | Priority: `.photon.html` > `.photon.tsx` > `.html` > `.tsx` ### Quick Start ```html

System Monitor

``` That's it — no ``, no ``, no `