chore: sync CI/CD with upstream guide and update context docs
Some checks failed
Lint & Test / test (push) Failing after 30s
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:
@@ -1,5 +1,7 @@
|
||||
# CI/CD & Release Workflow
|
||||
|
||||
> **Reference guide:** [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) — reusable patterns for Gitea Actions, cross-build, NSIS, Docker. When modifying workflows or build scripts, consult this guide to stay in sync with established patterns.
|
||||
|
||||
## Workflows
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
@@ -59,6 +61,33 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
||||
- Display-dependent tests are skipped via `@requires_display` marker
|
||||
- Uses `python` not `python3` (Git Bash on Windows resolves `python3` to MS Store stub)
|
||||
|
||||
## Version Detection Pattern
|
||||
|
||||
Build scripts use a fallback chain: CLI argument → exact git tag → CI env var (`GITEA_REF_NAME` / `GITHUB_REF_NAME`) → hardcoded in source. Always strip leading `v` for clean version strings.
|
||||
|
||||
## NSIS Installer Best Practices
|
||||
|
||||
- **User-scoped install** (`$LOCALAPPDATA`, `RequestExecutionLevel user`) — no admin required
|
||||
- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility
|
||||
- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install
|
||||
- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/`
|
||||
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi`
|
||||
|
||||
## Hidden Launcher (VBS)
|
||||
|
||||
All shortcuts and the installer finish page use `scripts/start-hidden.vbs` instead of `.bat` to avoid console window flash. The VBS launcher must include an embedded Python fallback — installed distributions don't have Python on PATH, dev environment uses system Python.
|
||||
|
||||
## Gitea vs GitHub Actions Differences
|
||||
|
||||
| Feature | GitHub Actions | Gitea Actions |
|
||||
| ------- | -------------- | ------------- |
|
||||
| Context prefix | `github.*` | `gitea.*` |
|
||||
| Ref name | `${{ github.ref_name }}` | `${{ gitea.ref_name }}` |
|
||||
| Server URL | `${{ github.server_url }}` | `${{ gitea.server_url }}` |
|
||||
| Output vars | `$GITHUB_OUTPUT` | `$GITHUB_OUTPUT` (same) |
|
||||
| Secrets | `${{ secrets.NAME }}` | `${{ secrets.NAME }}` (same) |
|
||||
| Docker Buildx | Available | May not work (runner networking) |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Creating a release
|
||||
@@ -78,3 +107,48 @@ git push origin v0.2.0-alpha.1
|
||||
2. Add upload step in the relevant `build-*` job
|
||||
3. **Update the release description** in `create-release` job body template
|
||||
4. Test with a pre-release tag first
|
||||
|
||||
### Re-triggering a failed release workflow
|
||||
|
||||
```bash
|
||||
# Option A: Delete and re-push the same tag
|
||||
git push origin :refs/tags/v0.1.0-alpha.2
|
||||
# Delete the release in Gitea UI or via API
|
||||
git tag -f v0.1.0-alpha.2
|
||||
git push origin v0.1.0-alpha.2
|
||||
|
||||
# Option B: Just bump the version (simpler)
|
||||
git tag v0.1.0-alpha.3
|
||||
git push origin v0.1.0-alpha.3
|
||||
```
|
||||
|
||||
The `create-release` job has fallback logic — if the release already exists for a tag, it fetches and reuses the existing release ID.
|
||||
|
||||
## Local Build Testing (Windows)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- NSIS: `& "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" install NSIS.NSIS`
|
||||
- Installs to `C:\Program Files (x86)\NSIS\makensis.exe`
|
||||
|
||||
### Build steps
|
||||
|
||||
```bash
|
||||
npm ci && npm run build # frontend
|
||||
bash build-dist-windows.sh v1.0.0 # Windows dist
|
||||
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer
|
||||
```
|
||||
|
||||
### Iterating on installer only
|
||||
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot.
|
||||
|
||||
### Common issues
|
||||
|
||||
| Issue | Fix |
|
||||
| ----- | --- |
|
||||
| `zip: command not found` | Git Bash doesn't include `zip` — harmless for installer builds |
|
||||
| `Exec expects 1 parameters, got 2` | Use `MUI_FINISHPAGE_RUN_FUNCTION` instead of `MUI_FINISHPAGE_RUN_PARAMETERS` |
|
||||
| `Error opening file for writing: python\_asyncio.pyd` | Server is running — stop it before installing |
|
||||
| App doesn't start after install | VBS must use embedded Python fallback, not bare `python` |
|
||||
| `winget` not recognized | Use full path: `$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe` |
|
||||
| `dist/` has stale files | Re-run full build script — `dist/` doesn't auto-update |
|
||||
|
||||
@@ -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">×</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">×</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save">✓</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.
|
||||
|
||||
@@ -8,7 +8,7 @@ Two independent server modes with separate configs, ports, and data directories:
|
||||
|
||||
| Mode | Command | Config | Port | API Key | Data |
|
||||
| ---- | ------- | ------ | ---- | ------- | ---- |
|
||||
| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
|
||||
|
||||
Both can run simultaneously on different ports.
|
||||
|
||||
Reference in New Issue
Block a user