# Frontend Rules & Conventions

**Read this file when working on frontend tasks** (HTML, CSS, JS, locales, templates).

## CSS Custom Properties (Variables)

Defined in `server/src/ledgrab/static/css/base.css`.

**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.

### Global (`:root`)
| Variable | Value | Usage |
|---|---|---|
| `--primary-color` | `#4CAF50` | **Accent/brand color** — borders, highlights, active states |
| `--primary-hover` | `#5cb860` | Hover state for primary elements |
| `--primary-contrast` | `#ffffff` | Text on primary background |
| `--danger-color` | `#f44336` | Destructive actions, errors |
| `--warning-color` | `#ff9800` | Warnings |
| `--info-color` | `#2196F3` | Informational highlights |

### Theme-specific (`[data-theme="dark"]` / `[data-theme="light"]`)
| Variable | Dark | Light | Usage |
|---|---|---|---|
| `--bg-color` | `#1a1a1a` | `#f5f5f5` | Page background |
| `--bg-secondary` | `#242424` | `#eee` | Secondary background |
| `--card-bg` | `#2d2d2d` | `#ffffff` | Card/panel background |
| `--text-color` | `#e0e0e0` | `#333333` | Primary text |
| `--text-secondary` | `#999` | `#666` | Secondary text |
| `--text-muted` | `#777` | `#999` | Muted/disabled text |
| `--border-color` | `#404040` | `#e0e0e0` | Borders, dividers |
| `--primary-text-color` | `#66bb6a` | `#3d8b40` | Primary-colored text |
| `--success-color` | `#28a745` | `#2e7d32` | Success indicators |
| `--shadow-color` | `rgba(0,0,0,0.3)` | `rgba(0,0,0,0.12)` | Box shadows |

## Icons — No Emoji (IMPORTANT)

**NEVER use emoji characters (`🔗`, `📋`, `🔍`, etc.) in buttons, labels, or card metadata.** Always use SVG icons from `core/icons.ts` (which wraps Lucide icon paths from `core/icon-paths.ts`).

- Import the constant: `import { ICON_CLONE } from '../core/icons.ts'`
- Use in template literals: `` `<button class="btn btn-icon">${ICON_CLONE}</button>` ``
- To add a new icon: copy inner SVG elements from [Lucide](https://lucide.dev) into `icon-paths.ts`, then export a named constant in `icons.ts`

Emoji render inconsistently across OS, break monochrome icon themes, and cannot be styled with CSS `color`/`stroke`.

## UI Conventions for Dialogs

### Hints

Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:

```html
<div class="form-group">
    <div class="label-row">
        <label for="my-field" data-i18n="my.label">Label:</label>
        <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
    </div>
    <small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
    <input type="text" id="my-field">
</div>
```

Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.

### Select dropdowns

Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.

### Empty/None option format

When a selector has an optional entity (e.g., sync clock, processing template, brightness source), the empty option must use the format `None (<description>)` where the description explains what happens when nothing is selected. Use i18n keys, never hardcoded `—` or bare `None`.

Examples:
- `None (no processing template)` — `t('common.none_no_cspt')`
- `None (no input source)` — `t('common.none_no_input')`
- `None (use own speed)` — `t('common.none_own_speed')`
- `None (full brightness)` — `t('color_strip.composite.brightness.none')`
- `None (device brightness)` — `t('targets.brightness_vs.none')`

For `EntitySelect` with `allowNone: true`, pass the same i18n string as `noneLabel`.

### Enhanced selectors (IconSelect & EntitySelect)

**IMPORTANT:** Always use icon grid or entity pickers instead of plain `<select>` dropdowns wherever appropriate. Plain HTML selects break the visual consistency of the UI. Any selector with a small fixed set of options (types, modes, presets, bands) should use `IconSelect`; any selector referencing dynamic entities should use `EntitySelect`.

Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:

- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.ts`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.ts` for examples.

- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.ts`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.ts` or `_lineSourceEntitySelect` in `advanced-calibration.ts` for examples.

Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.

**CRITICAL pitfall — `<option>` elements required:** `IconSelect` does NOT create `<option>` elements in the native `<select>` — it only builds a visual popup grid. The native `<select>` must already contain matching `<option value="...">` elements (either from the Jinja2 template or added via JS) **before** `.value` is set. Setting `.value` on a `<select>` with no matching `<option>` **silently fails** — the value stays empty, and all downstream logic (section switching, auto-naming, type setup) breaks with no error. **When adding a new type to any IconSelect-enhanced `<select>`, you MUST add the `<option>` in the HTML template too.**

**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection.

**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.

### Dynamic entity lists (e.g. group child devices)

When a modal needs an **ordered list of entity references** (like child devices in a group), use the `EntityPalette.pick()` pattern instead of plain `<select>` dropdowns per row. Each row should be a styled card with:

- An icon (from `getDeviceTypeIcon()` / `getXxxIcon()`) showing the entity type
- The entity name and metadata (LED count, type, etc.)
- SVG icon buttons for reorder (chevron-up/down) and remove (trash)
- A click handler that opens `EntityPalette.pick()` to change the selection
- `data-*` attributes to store the selected value (not hidden `<select>` elements)

See `_addGroupChildRow()` in `device-discovery.ts` for the reference implementation. This pattern keeps the list visually consistent with the rest of the UI and avoids plain HTML selects.

For **mode/type toggles** with 2-4 fixed options (e.g. group mode: Sequence vs Independent), use `IconSelect` with descriptive icons and labels rather than radio buttons or plain selects. See `ensureGroupModeIconSelect()` for the pattern.

### Modal dirty check (discard unsaved changes)

Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.ts`:

1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:

   ```javascript
   class MyEditorModal extends Modal {
       constructor() { super('my-modal-id'); }
       snapshotValues() {
           return {
               name: document.getElementById('my-name').value,
               // ... all form fields
           };
       }
       onForceClose() {
           // Optional: cleanup (reset flags, clear state, etc.)
       }
   }
   const myModal = new MyEditorModal();
   ```

2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
5. For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in `snapshotValues()`.

The base class handles: `isDirty()` comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.

### Card appearance

When creating or modifying entity cards (devices, targets, CSS sources, streams, audio/value sources, templates), **always reference existing cards** of the same or similar type for visual consistency. Cards should have:

- Clone (📋) and Edit (✏️) icon buttons in `.template-card-actions`
- Delete (✕) button as `.card-remove-btn`
- Property badges in `.stream-card-props` with emoji icons
- **Crosslinks**: When a card references another entity (audio source, picture source, capture template, PP template, etc.), make the property badge a clickable link using the `stream-card-link` CSS class and an `onclick` handler calling `navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue)`. Only add the link when the referenced entity is found (to avoid broken navigation). Example: `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${id}')">🎵 Name</span>`

### Modal footer buttons

Use **icon-only** buttons (✓ / ✕) matching the device settings modal pattern, **not** text buttons:

```html
<div class="modal-footer">
    <button class="btn btn-icon btn-secondary" onclick="closeMyModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
    <button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
```

### Slider value display

For range sliders, display the current value **inside the label** (not in a separate wrapper). This keeps the value visible next to the property name:

```html
<label for="my-slider"><span data-i18n="my.label">Speed:</span> <span id="my-slider-display">1.0</span></label>
...
<input type="range" id="my-slider" min="0" max="10" step="0.1" value="1.0"
    oninput="document.getElementById('my-slider-display').textContent = this.value">
```

Do **not** use a `range-with-value` wrapper div.

### Tutorials

The app has an interactive tutorial system (`static/js/features/tutorials.ts`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
- **Getting started** (header-level walkthrough of all tabs and controls)
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
- **Device card tutorial** and **Calibration tutorial** (context-specific)

When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.ts` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).

## Icons

**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.**

- Icon SVG paths are defined in `static/js/core/icon-paths.ts` (Lucide icons, 24×24 viewBox)
- Icon constants are exported from `static/js/core/icons.ts` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
- Use `_svg(path)` wrapper from `icons.ts` to create new icon constants from paths

When you need a new icon:
1. Find the Lucide icon at https://lucide.dev
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.ts` as a new export
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
4. Import and use the constant in your feature module

Common icons: `ICON_START` (play), `ICON_STOP` (power), `ICON_EDIT` (pencil), `ICON_CLONE` (copy), `ICON_TRASH` (trash), `ICON_SETTINGS` (gear), `ICON_TEST` (flask), `ICON_OK` (circle-check), `ICON_WARNING` (triangle-alert), `ICON_HELP` (circle-help), `ICON_LIST_CHECKS` (list-checks), `ICON_CIRCLE_OFF` (circle-off).

For icon-only buttons, use `btn btn-icon` CSS classes. The `.icon` class inside buttons auto-sizes to 16×16.

## Localization (i18n)

**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.ts` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).

- In JS modules: `import { t } from '../core/i18n.ts';` then `showToast(t('my.key'), 'error')`
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)

### Dynamic content and language changes

When a feature module generates HTML with baked-in `t()` calls (e.g., toolbar button titles, legend text), that content won't update when the user switches language. To handle this, listen for the `languageChanged` event and re-render:

```javascript
document.addEventListener('languageChanged', () => {
    if (_initialized) _reRender();
});
```

Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.

## API Calls (CRITICAL)

**ALWAYS use `fetchWithAuth()` from `core/api.ts` for authenticated API requests.** It auto-prepends `API_BASE` (`/api/v1`) and attaches the auth token.

- **Paths are relative to `/api/v1`** — pass `/gradients`, NOT `/api/v1/gradients`
- `fetchWithAuth('/gradients')` → `GET /api/v1/gradients` with auth header
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123`
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)

**NEVER use raw `fetch()` or `new Audio(url)` / `new Image()` for authenticated endpoints.** These bypass the auth token and will fail with 401. Always use `fetchWithAuth()` and convert to blob URLs when needed (e.g. for `<audio>`, `<img>`, or download links):

```typescript
// WRONG: no auth header — 401
const audio = new Audio(`${API_BASE}/assets/${id}/file`);

// CORRECT: fetch with auth, then create blob URL
const res = await fetchWithAuth(`/assets/${id}/file`);
const blob = await res.blob();
const audio = new Audio(URL.createObjectURL(blob));
```

The only exception is raw `fetch()` for multipart uploads where you must set `Content-Type` to let the browser handle the boundary — but still use `getHeaders()` for the auth token.

## Bundling & Development Workflow

The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.

### Files

- **Entry points:** `static/js/app.ts` (JS), `static/css/all.css` (CSS imports all individual sheets)
- **Output:** `static/dist/app.bundle.js` and `static/dist/app.bundle.css` (minified + source maps)
- **Config:** `server/esbuild.mjs`
- **HTML:** `templates/index.html` references the bundles, not individual source files

### Commands (from `server/` directory)

| Command | Description |
|---|---|
| `npm run build` | One-shot bundle + minify (~30ms) |
| `npm run watch` | Watch mode — auto-rebuilds on any JS/CSS file save |

### Development workflow

1. Run `npm run watch` in a terminal (stays running)
2. Edit source files in `static/js/` or `static/css/` as usual
3. esbuild rebuilds the bundle automatically (~30ms)
4. Refresh the browser to see changes

### Dependencies

All JS/CSS dependencies are bundled — **no CDN or external requests** at runtime:

- **Chart.js** — imported in `perf-charts.ts`, exposed as `window.Chart` for `targets.ts` and `dashboard.ts`
- **ELK.js** — imported in `graph-layout.ts` for graph auto-layout
- **Fonts** — DM Sans (400-700) and Orbitron (700) woff2 files in `static/fonts/`, declared in `css/fonts.css`

When adding a new JS dependency: `npm install <pkg>` in `server/`, then `import` it in the relevant source file. esbuild bundles it automatically.

### Notes

- The `dist/` directory is gitignored — bundles are build artifacts, run `npm run build` after clone
- Source maps are generated so browser DevTools show original source files
- The server sets `Cache-Control: no-cache` on static JS/CSS/JSON to prevent stale browser caches during development
- GZip compression middleware reduces transfer sizes by ~75%
- **Do not edit files in `static/dist/`** — they are overwritten by the build

## Chrome Browser Tools

See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.

## Duration & Numeric Formatting

### Uptime / duration values

Use `formatUptime(seconds)` from `core/ui.ts`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.

### Large numbers

Use `formatCompact(n)` from `core/ui.ts`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.

### Preventing layout shift

Numeric/duration values that update frequently (FPS, uptime, frame counts) **must** use fixed-width styling to prevent layout reflow:

- `font-family: var(--font-mono, monospace)` — equal-width characters
- `font-variant-numeric: tabular-nums` — equal-width digits in proportional fonts
- Fixed `width` or `min-width` on the value container
- `text-align: right` to anchor the growing edge

Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(--font-mono)`, `font-weight: 600`, `min-width: 48px`.

### FPS sparkline charts

Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.ts`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.

## Adding a New Entity Type (Full Checklist)

This section documents the complete pattern for adding a new entity type to the frontend, covering tabs, cards, modals, CRUD operations, and all required wiring.

### 1. DataCache (state.ts)

Register a cache for the new entity's API endpoint in `static/js/core/state.ts`:

```typescript
export const myEntitiesCache = new DataCache<MyEntity[]>({
    endpoint: '/my-entities',
    extractData: json => json.entities || [],
});
```

- `endpoint` is relative to `/api/v1` (fetchWithAuth prepends it)
- `extractData` unwraps the response envelope
- Subscribe to sync into a legacy variable if needed: `myEntitiesCache.subscribe(v => { _cachedMyEntities = v; });`

### 2. CardSection instance

Create a global CardSection in the feature module (e.g. `features/my-entities.ts`):

```typescript
const csMyEntities = new CardSection('my-entities', {
    titleKey: 'my_entity.section_title',   // i18n key for section header
    gridClass: 'templates-grid',           // CSS grid class
    addCardOnclick: "showMyEntityEditor()", // onclick for the "+" card
    keyAttr: 'data-my-id',                 // attribute used to match cards during reconcile
    emptyKey: 'section.empty.my_entities', // i18n key shown when no cards
    bulkActions: _myEntityBulkActions,      // optional bulk action definitions
});
```

### 3. Tab registration (streams.ts)

Add the tab entry in the `tabs` array inside `loadSourcesTab()`:

```typescript
{ key: 'my_entities', icon: ICON_MY_ENTITY, titleKey: 'streams.group.my_entities', count: myEntities.length },
```

Then add the rendering block for the tab content:

```typescript
// First load: full render
if (!csMyEntities.isMounted()) {
    const items = myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) }));
    html += csMyEntities.render(csMyEntities.applySortOrder(items));
} else {
    // Incremental update
    csMyEntities.reconcile(myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) })));
}
```

After `innerHTML` assignment, call `csMyEntities.bind()` for first mount.

### 4. Card builder function

Build cards using `wrapCard()` from `core/card-colors.ts`:

```typescript
function createMyEntityCard(entity: MyEntity): string {
    return wrapCard({
        dataAttr: 'data-my-id',
        id: entity.id,
        removeOnclick: `deleteMyEntity('${entity.id}')`,
        removeTitle: t('common.delete'),
        content: `
            <div class="card-header">
                <div class="card-title" title="${escapeHtml(entity.name)}">
                    ${ICON_MY_ENTITY} <span class="card-title-text">${escapeHtml(entity.name)}</span>
                </div>
            </div>
            <div class="stream-card-props">
                <span class="stream-card-prop">🏷️ ${escapeHtml(entity.description || '')}</span>
            </div>
            ${renderTagChips(entity.tags)}`,
        actions: `
            <button class="btn btn-icon btn-secondary" onclick="cloneMyEntity('${entity.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
            <button class="btn btn-icon btn-secondary" onclick="showMyEntityEditor('${entity.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
    });
}
```

**Required HTML classes:**
- `.template-card` — root (auto-added by wrapCard)
- `.card-header` > `.card-title` > `.card-title-text` — title with icon
- `.stream-card-props` > `.stream-card-prop` — property badges
- `.template-card-actions` — button row (auto-added by wrapCard)
- `.card-remove-btn` — delete X button (auto-added by wrapCard)

### 5. Modal HTML template

Create `templates/modals/my-entity-editor.html`:

```html
<div id="my-entity-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="my-entity-title">
    <div class="modal-content">
        <div class="modal-header">
            <h2 id="my-entity-title" data-i18n="my_entity.add">Add Entity</h2>
            <button class="modal-close-btn" onclick="closeMyEntityModal()" data-i18n-aria-label="aria.close">&times;</button>
        </div>
        <div class="modal-body">
            <input type="hidden" id="my-entity-id">
            <div id="my-entity-error" class="modal-error" style="display:none"></div>

            <div class="form-group">
                <div class="label-row">
                    <label for="my-entity-name" data-i18n="my_entity.name">Name:</label>
                    <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
                </div>
                <small class="input-hint" style="display:none" data-i18n="my_entity.name.hint">...</small>
                <input type="text" id="my-entity-name" required>
            </div>

            <!-- Type-specific fields here -->

            <div id="my-entity-tags-container"></div>
        </div>
        <div class="modal-footer">
            <button class="btn btn-icon btn-secondary" onclick="closeMyEntityModal()" title="Cancel" data-i18n-title="settings.button.cancel">&times;</button>
            <button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save">&#x2713;</button>
        </div>
    </div>
</div>
```

Include it in `templates/index.html`: `{% include 'modals/my-entity-editor.html' %}`

### 6. Modal class (dirty checking)

```typescript
class MyEntityModal extends Modal {
    constructor() { super('my-entity-modal'); }

    snapshotValues() {
        return {
            name: (document.getElementById('my-entity-name') as HTMLInputElement).value,
            // ... all tracked fields, serialize complex state as JSON strings
            tags: JSON.stringify(_tagsInput ? _tagsInput.getValue() : []),
        };
    }

    onForceClose() {
        // Cleanup: destroy tag inputs, entity selects, etc.
        if (_tagsInput) { _tagsInput.destroy(); _tagsInput = null; }
    }
}
const myEntityModal = new MyEntityModal();
```

### 7. CRUD functions

**Create / Edit (unified):**

```typescript
export async function showMyEntityEditor(editId: string | null = null) {
    const titleEl = document.getElementById('my-entity-title')!;
    const idInput = document.getElementById('my-entity-id') as HTMLInputElement;
    const nameInput = document.getElementById('my-entity-name') as HTMLInputElement;

    idInput.value = '';
    nameInput.value = '';

    if (editId) {
        // Edit mode: populate from cache
        const entities = await myEntitiesCache.fetch();
        const entity = entities.find(e => e.id === editId);
        if (!entity) return;
        idInput.value = entity.id;
        nameInput.value = entity.name;
        titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.edit')}`;
    } else {
        titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.add')}`;
    }

    myEntityModal.open();
    myEntityModal.snapshot();
}
```

**Clone:** Fetch existing entity, open editor with its data but no ID (creates new):

```typescript
export async function cloneMyEntity(entityId: string) {
    const entities = await myEntitiesCache.fetch();
    const source = entities.find(e => e.id === entityId);
    if (!source) return;

    // Open editor as "create" with pre-filled data
    await showMyEntityEditor(null);
    (document.getElementById('my-entity-name') as HTMLInputElement).value = source.name + ' (Copy)';
    // ... populate other fields from source
    myEntityModal.snapshot(); // Re-snapshot after populating clone data
}
```

**Save:** POST (new) or PUT (edit) based on hidden ID field:

```typescript
export async function saveMyEntity() {
    const id = (document.getElementById('my-entity-id') as HTMLInputElement).value;
    const name = (document.getElementById('my-entity-name') as HTMLInputElement).value.trim();
    if (!name) { myEntityModal.showError(t('my_entity.error.name_required')); return; }

    const payload = { name, /* ... other fields */ };

    try {
        const url = id ? `/my-entities/${id}` : '/my-entities';
        const method = id ? 'PUT' : 'POST';
        const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
        if (!res!.ok) { const err = await res!.json(); throw new Error(err.detail); }

        showToast(id ? t('my_entity.updated') : t('my_entity.created'), 'success');
        myEntitiesCache.invalidate();
        myEntityModal.forceClose();
        if (window.loadSourcesTab) window.loadSourcesTab();
    } catch (e: any) {
        if (e.isAuth) return;
        myEntityModal.showError(e.message);
    }
}
```

**Delete:** Confirm, API call, invalidate cache, reload:

```typescript
export async function deleteMyEntity(entityId: string) {
    const ok = await showConfirm(t('my_entity.confirm_delete'));
    if (!ok) return;
    try {
        await fetchWithAuth(`/my-entities/${entityId}`, { method: 'DELETE' });
        showToast(t('my_entity.deleted'), 'success');
        myEntitiesCache.invalidate();
        if (window.loadSourcesTab) window.loadSourcesTab();
    } catch (e: any) {
        if (e.isAuth) return;
        showToast(e.message || t('my_entity.error.delete_failed'), 'error');
    }
}
```

### 8. Window exports (app.ts)

Import and expose all onclick handlers:

```typescript
import { showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity } from './features/my-entities.ts';

Object.assign(window, {
    showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity,
});
```

**Critical:** Functions used in `onclick="..."` HTML attributes MUST appear in `Object.assign(window, ...)` or they will be undefined at runtime.

### 9. global.d.ts

Add the window function declarations so TypeScript doesn't complain:

```typescript
showMyEntityEditor?: (id?: string | null) => void;
cloneMyEntity?: (id: string) => void;
deleteMyEntity?: (id: string) => void;
```

### 10. i18n keys

Add keys to all three locale files (`en.json`, `ru.json`, `zh.json`):

```json
"my_entity.section_title": "My Entities",
"my_entity.add": "Add Entity",
"my_entity.edit": "Edit Entity",
"my_entity.created": "Entity created",
"my_entity.updated": "Entity updated",
"my_entity.deleted": "Entity deleted",
"my_entity.confirm_delete": "Delete this entity?",
"my_entity.error.name_required": "Name is required",
"my_entity.name": "Name:",
"my_entity.name.hint": "A descriptive name for this entity",
"section.empty.my_entities": "No entities yet. Click + to create one."
```

### 11. Cross-references

After adding the entity:

- **Backup/restore:** Add to `STORE_MAP` in `api/routes/system.py`
- **Graph editor:** Update entity maps in graph editor files (see graph-editor.md)
- **Tutorials:** Update tutorial steps if adding a new tab

### CRITICAL: Common Pitfalls (MUST READ)

These mistakes have been made repeatedly. **Check every one before considering the entity complete:**

1. **DOM ID conflicts:** If your modal reuses a shared component (e.g. gradient stop editor, color picker) that uses hardcoded `document.getElementById()` calls, **both modals exist in the DOM simultaneously**. The shared component will render into whichever element it finds first (the wrong one). Fix: add an ID prefix mechanism to the shared component, set it before init, reset it on modal close.

2. **Tags go under the name input:** The tags container `<div>` goes **inside the same `form-group` as the name `<input>`**, directly after it — NOT in a separate section or at the bottom of the modal. Look at any existing modal (css-editor.html, audio-source-editor.html, device-settings.html) for the pattern.

3. **Cache reload after save/delete/clone:** Use `cache.invalidate()` then `await loadPictureSources()` (imported directly from streams.ts). Do NOT use `window.loadSourcesTab()` without `await` — it reads the stale cache before the invalidation takes effect, so the new entity won't appear until page reload.

4. **IconSelect / EntitySelect sync:** When programmatically changing a `<select>` value (e.g. loading a preset, populating for edit), you MUST also call `.setValue(val)` (IconSelect) or `.refresh()` (EntitySelect). The native `<select>` and the visual widget are **separate** — changing one does NOT update the other.

5. **Never use `window.prompt()`:** Always use a proper Modal subclass with `snapshotValues()` for dirty checking. Prompts break the UX and have no validation, no hint text, no i18n.

6. **Never do API-only clone:** Clone should open the editor modal pre-filled with the source entity's data (name + " (Copy)"), with an empty ID field so saving creates a new entity. Do NOT call a `/clone` endpoint and refresh — the user must be able to edit before saving.

## Visual Graph Editor

See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.

**IMPORTANT:** When adding or modifying entity types, subtypes, or connection fields, the graph editor files **must** be updated in sync. The graph maintains its own maps of entity colors, labels, icons, connection rules, and cache references. See the "Keeping the graph in sync with entity types" section in `graph-editor.md` for the complete checklist.
