chore: sync CI/CD with upstream guide and update context docs
Some checks failed
Lint & Test / test (push) Failing after 30s

release.yml: add fallback for existing releases on tag re-push.
installer.nsi: add .onInit file lock check, use LaunchApp function
instead of RUN_PARAMETERS to fix NSIS quoting bug.
build-dist.ps1: copy start-hidden.vbs to dist scripts/.
start-hidden.vbs: embedded Python fallback for installed/dev envs.

Update ci-cd.md with version detection, NSIS best practices, local
build testing, Gitea vs GitHub differences, troubleshooting.
Update frontend.md with full entity type checklist and common pitfalls.
This commit is contained in:
2026-03-24 13:59:27 +03:00
parent 1111ab7355
commit fc62d5d3b1
7 changed files with 468 additions and 29 deletions

View File

@@ -76,7 +76,9 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
- **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. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
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.
**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.
@@ -190,6 +192,17 @@ document.addEventListener('languageChanged', () => {
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)
For raw `fetch()` without auth (rare), use the full path manually.
## Bundling & Development Workflow
The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.
@@ -262,6 +275,315 @@ Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(-
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.