f2871319cb
Lint & Test / test (push) Failing after 48s
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in automations, guard WebSocket JSON.parse, validate ADB address input, seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors. Code quality: add Pydantic models for brightness/power endpoints, fix thread safety and name uniqueness in DeviceStore, immutable update pattern, split 6 oversized files into 16 focused modules, enable TypeScript strictNullChecks (741→102 errors), type state variables, add dom-utils helper, migrate 3 modules from inline onclick to event delegation, ProcessorDependencies dataclass. Performance: async store saves, health endpoint log level, command palette debounce, optimized entity-events comparison, fix service worker precache list. Testing: expand from 45 to 293 passing tests — add store tests (141), route tests (25), core logic tests (42), E2E flow tests (33), organize into tests/api/, tests/storage/, tests/core/, tests/e2e/. DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage build with non-root user and health check, docker-compose improvements, version bump to 0.2.0. Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76), create contexts/server-operations.md, fix .js→.ts references, fix env var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and .env.example.
460 lines
18 KiB
TypeScript
460 lines
18 KiB
TypeScript
/**
|
||
* Gradient stop editor — canvas preview, draggable markers, stop list, presets.
|
||
*
|
||
* Extracted from color-strips.js. Self-contained module that manages
|
||
* gradient stops state and renders into the CSS editor modal DOM.
|
||
*/
|
||
|
||
import { t } from '../core/i18n.ts';
|
||
|
||
/* ── Types ─────────────────────────────────────────────────────── */
|
||
|
||
interface GradientStop {
|
||
position: number;
|
||
color: number[];
|
||
colorRight: number[] | null;
|
||
}
|
||
|
||
interface GradientDragState {
|
||
idx: number;
|
||
trackRect: DOMRect;
|
||
}
|
||
|
||
interface GradientPresetStop {
|
||
position: number;
|
||
color: number[];
|
||
color_right?: number[];
|
||
}
|
||
|
||
interface CustomPreset {
|
||
name: string;
|
||
stops: GradientPresetStop[];
|
||
}
|
||
|
||
/* ── Color conversion utilities ───────────────────────────────── */
|
||
|
||
export function rgbArrayToHex(rgb: number[]): string {
|
||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
|
||
export function hexToRgbArray(hex: string): number[] {
|
||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||
}
|
||
|
||
/* ── State ────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Internal state: array of stop objects.
|
||
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||
*/
|
||
let _gradientStops: GradientStop[] = [];
|
||
let _gradientSelectedIdx: number = -1;
|
||
let _gradientDragging: GradientDragState | null = null;
|
||
let _gradientOnChange: (() => void) | null = null;
|
||
|
||
/** Set a callback that fires whenever stops change. */
|
||
export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; }
|
||
|
||
/** Read-only accessor for save/dirty-check from the parent module. */
|
||
export function getGradientStops(): GradientStop[] {
|
||
return _gradientStops;
|
||
}
|
||
|
||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||
|
||
function _gradientInterpolate(stops: GradientStop[], pos: number): number[] {
|
||
if (!stops.length) return [128, 128, 128];
|
||
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||
|
||
if (pos <= sorted[0].position) return sorted[0].color.slice();
|
||
|
||
const last = sorted[sorted.length - 1];
|
||
if (pos >= last.position) return (last.colorRight || last.color).slice();
|
||
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const a = sorted[i];
|
||
const b = sorted[i + 1];
|
||
if (a.position <= pos && pos <= b.position) {
|
||
const span = b.position - a.position;
|
||
const t2 = span > 0 ? (pos - a.position) / span : 0;
|
||
const lc = a.colorRight || a.color;
|
||
const rc = b.color;
|
||
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
|
||
}
|
||
}
|
||
return [128, 128, 128];
|
||
}
|
||
|
||
/* ── Init ─────────────────────────────────────────────────────── */
|
||
|
||
export function gradientInit(stops: GradientPresetStop[]): void {
|
||
_gradientStops = stops.map(s => ({
|
||
position: parseFloat(String(s.position ?? 0)),
|
||
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||
}));
|
||
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
|
||
_gradientDragging = null;
|
||
_gradientSetupTrackClick();
|
||
gradientRenderAll();
|
||
}
|
||
|
||
/* ── Presets ──────────────────────────────────────────────────── */
|
||
|
||
export const GRADIENT_PRESETS = {
|
||
rainbow: [
|
||
{ position: 0.0, color: [255, 0, 0] },
|
||
{ position: 0.17, color: [255, 165, 0] },
|
||
{ position: 0.33, color: [255, 255, 0] },
|
||
{ position: 0.5, color: [0, 255, 0] },
|
||
{ position: 0.67, color: [0, 100, 255] },
|
||
{ position: 0.83, color: [75, 0, 130] },
|
||
{ position: 1.0, color: [148, 0, 211] },
|
||
],
|
||
sunset: [
|
||
{ position: 0.0, color: [255, 60, 0] },
|
||
{ position: 0.3, color: [255, 120, 20] },
|
||
{ position: 0.6, color: [200, 40, 80] },
|
||
{ position: 0.8, color: [120, 20, 120] },
|
||
{ position: 1.0, color: [40, 10, 60] },
|
||
],
|
||
ocean: [
|
||
{ position: 0.0, color: [0, 10, 40] },
|
||
{ position: 0.3, color: [0, 60, 120] },
|
||
{ position: 0.6, color: [0, 140, 180] },
|
||
{ position: 0.8, color: [100, 220, 240] },
|
||
{ position: 1.0, color: [200, 240, 255] },
|
||
],
|
||
forest: [
|
||
{ position: 0.0, color: [0, 40, 0] },
|
||
{ position: 0.3, color: [0, 100, 20] },
|
||
{ position: 0.6, color: [60, 180, 30] },
|
||
{ position: 0.8, color: [140, 220, 50] },
|
||
{ position: 1.0, color: [220, 255, 80] },
|
||
],
|
||
fire: [
|
||
{ position: 0.0, color: [0, 0, 0] },
|
||
{ position: 0.25, color: [80, 0, 0] },
|
||
{ position: 0.5, color: [255, 40, 0] },
|
||
{ position: 0.75, color: [255, 160, 0] },
|
||
{ position: 1.0, color: [255, 255, 60] },
|
||
],
|
||
lava: [
|
||
{ position: 0.0, color: [0, 0, 0] },
|
||
{ position: 0.3, color: [120, 0, 0] },
|
||
{ position: 0.6, color: [255, 60, 0] },
|
||
{ position: 0.8, color: [255, 160, 40] },
|
||
{ position: 1.0, color: [255, 255, 120] },
|
||
],
|
||
aurora: [
|
||
{ position: 0.0, color: [0, 20, 40] },
|
||
{ position: 0.25, color: [0, 200, 100] },
|
||
{ position: 0.5, color: [0, 100, 200] },
|
||
{ position: 0.75, color: [120, 0, 200] },
|
||
{ position: 1.0, color: [0, 200, 140] },
|
||
],
|
||
ice: [
|
||
{ position: 0.0, color: [255, 255, 255] },
|
||
{ position: 0.3, color: [180, 220, 255] },
|
||
{ position: 0.6, color: [80, 160, 255] },
|
||
{ position: 0.85, color: [20, 60, 180] },
|
||
{ position: 1.0, color: [10, 20, 80] },
|
||
],
|
||
warm: [
|
||
{ position: 0.0, color: [255, 255, 80] },
|
||
{ position: 0.33, color: [255, 160, 0] },
|
||
{ position: 0.67, color: [255, 60, 0] },
|
||
{ position: 1.0, color: [160, 0, 0] },
|
||
],
|
||
cool: [
|
||
{ position: 0.0, color: [0, 255, 200] },
|
||
{ position: 0.33, color: [0, 120, 255] },
|
||
{ position: 0.67, color: [60, 0, 255] },
|
||
{ position: 1.0, color: [120, 0, 180] },
|
||
],
|
||
neon: [
|
||
{ position: 0.0, color: [255, 0, 200] },
|
||
{ position: 0.25, color: [0, 255, 255] },
|
||
{ position: 0.5, color: [0, 255, 50] },
|
||
{ position: 0.75, color: [255, 255, 0] },
|
||
{ position: 1.0, color: [255, 0, 100] },
|
||
],
|
||
pastel: [
|
||
{ position: 0.0, color: [255, 180, 180] },
|
||
{ position: 0.2, color: [255, 220, 160] },
|
||
{ position: 0.4, color: [255, 255, 180] },
|
||
{ position: 0.6, color: [180, 255, 200] },
|
||
{ position: 0.8, color: [180, 200, 255] },
|
||
{ position: 1.0, color: [220, 180, 255] },
|
||
],
|
||
};
|
||
|
||
/**
|
||
* Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
|
||
*/
|
||
export function gradientPresetStripHTML(stops: GradientPresetStop[], w: number = 80, h: number = 16): string {
|
||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||
}
|
||
|
||
export function applyGradientPreset(key: string): void {
|
||
if (!key || !GRADIENT_PRESETS[key]) return;
|
||
gradientInit(GRADIENT_PRESETS[key]);
|
||
}
|
||
|
||
/* ── Render ───────────────────────────────────────────────────── */
|
||
|
||
export function gradientRenderAll(): void {
|
||
_gradientRenderCanvas();
|
||
_gradientRenderMarkers();
|
||
_gradientRenderStopList();
|
||
if (_gradientOnChange) _gradientOnChange();
|
||
}
|
||
|
||
function _gradientRenderCanvas(): void {
|
||
const canvas = document.getElementById('gradient-canvas') as HTMLCanvasElement | null;
|
||
if (!canvas) return;
|
||
|
||
// Sync canvas pixel width to its CSS display width
|
||
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||
if (canvas.width !== W) canvas.width = W;
|
||
|
||
const ctx = canvas.getContext('2d')!;
|
||
const H = canvas.height;
|
||
const imgData = ctx.createImageData(W, H);
|
||
|
||
for (let x = 0; x < W; x++) {
|
||
const pos = W > 1 ? x / (W - 1) : 0;
|
||
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
|
||
for (let y = 0; y < H; y++) {
|
||
const idx = (y * W + x) * 4;
|
||
imgData.data[idx] = r;
|
||
imgData.data[idx + 1] = g;
|
||
imgData.data[idx + 2] = b;
|
||
imgData.data[idx + 3] = 255;
|
||
}
|
||
}
|
||
ctx.putImageData(imgData, 0, 0);
|
||
}
|
||
|
||
function _gradientRenderMarkers(): void {
|
||
const track = document.getElementById('gradient-markers-track');
|
||
if (!track) return;
|
||
track.innerHTML = '';
|
||
|
||
_gradientStops.forEach((stop, idx) => {
|
||
const marker = document.createElement('div');
|
||
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||
marker.style.left = `${stop.position * 100}%`;
|
||
marker.style.background = rgbArrayToHex(stop.color);
|
||
marker.title = `${(stop.position * 100).toFixed(0)}%`;
|
||
|
||
marker.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_gradientSelectedIdx = idx;
|
||
_gradientStartDrag(e, idx);
|
||
_gradientRenderMarkers();
|
||
_gradientRenderStopList();
|
||
});
|
||
|
||
track.appendChild(marker);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Update the selected stop index and reflect it via CSS classes only —
|
||
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||
*/
|
||
function _gradientSelectStop(idx: number): void {
|
||
_gradientSelectedIdx = idx;
|
||
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||
}
|
||
|
||
function _gradientRenderStopList(): void {
|
||
const list = document.getElementById('gradient-stops-list');
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
|
||
_gradientStops.forEach((stop, idx) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||
|
||
const hasBidir = !!stop.colorRight;
|
||
const rightColor = stop.colorRight || stop.color;
|
||
|
||
row.innerHTML = `
|
||
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||
title="Left color">
|
||
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||
<span class="gradient-stop-spacer"></span>
|
||
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||
`;
|
||
|
||
// Select row on mousedown — CSS-only update so child click events are not interrupted
|
||
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||
|
||
// Position
|
||
const posInput = row.querySelector('.gradient-stop-pos');
|
||
posInput.addEventListener('change', (e) => {
|
||
const target = e.target as HTMLInputElement;
|
||
const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0));
|
||
target.value = val.toFixed(2);
|
||
_gradientStops[idx].position = val;
|
||
gradientRenderAll();
|
||
});
|
||
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||
|
||
// Left color
|
||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||
const val = (e.target as HTMLInputElement).value;
|
||
_gradientStops[idx].color = hexToRgbArray(val);
|
||
const markers = document.querySelectorAll('.gradient-marker');
|
||
if (markers[idx]) (markers[idx] as HTMLElement).style.background = val;
|
||
_gradientRenderCanvas();
|
||
});
|
||
|
||
// Bidirectional toggle
|
||
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||
? null
|
||
: [..._gradientStops[idx].color];
|
||
_gradientRenderStopList();
|
||
_gradientRenderCanvas();
|
||
});
|
||
|
||
// Right color
|
||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||
_gradientStops[idx].colorRight = hexToRgbArray((e.target as HTMLInputElement).value);
|
||
_gradientRenderCanvas();
|
||
});
|
||
|
||
// Remove
|
||
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (_gradientStops.length > 2) {
|
||
_gradientStops.splice(idx, 1);
|
||
if (_gradientSelectedIdx >= _gradientStops.length) {
|
||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||
}
|
||
gradientRenderAll();
|
||
}
|
||
});
|
||
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||
|
||
export function gradientAddStop(position?: number): void {
|
||
if (position === undefined) {
|
||
// Find the largest gap between adjacent stops and place in the middle
|
||
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||
let maxGap = 0, gapMid = 0.5;
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const gap = sorted[i + 1].position - sorted[i].position;
|
||
if (gap > maxGap) {
|
||
maxGap = gap;
|
||
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
|
||
}
|
||
}
|
||
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
|
||
}
|
||
position = Math.min(1, Math.max(0, position));
|
||
const color = _gradientInterpolate(_gradientStops, position);
|
||
_gradientStops.push({ position, color, colorRight: null });
|
||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||
gradientRenderAll();
|
||
}
|
||
|
||
/* ── Drag ─────────────────────────────────────────────────────── */
|
||
|
||
function _gradientStartDrag(e: MouseEvent, idx: number): void {
|
||
const track = document.getElementById('gradient-markers-track');
|
||
if (!track) return;
|
||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||
|
||
const onMove = (me: MouseEvent): void => {
|
||
if (!_gradientDragging) return;
|
||
const { trackRect } = _gradientDragging;
|
||
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
|
||
gradientRenderAll();
|
||
};
|
||
|
||
const onUp = (): void => {
|
||
_gradientDragging = null;
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
};
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
/* ── Custom presets (localStorage) ───────────────────────────── */
|
||
|
||
const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets';
|
||
|
||
/** Load custom presets from localStorage. Returns an array of { name, stops }. */
|
||
export function loadCustomGradientPresets(): CustomPreset[] {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]');
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Save the current gradient stops as a named custom preset. */
|
||
export function saveCurrentAsCustomPreset(name: string): void {
|
||
if (!name) return;
|
||
const stops = _gradientStops.map(s => ({
|
||
position: s.position,
|
||
color: [...s.color],
|
||
...(s.colorRight ? { color_right: [...s.colorRight] } : {}),
|
||
}));
|
||
const presets = loadCustomGradientPresets();
|
||
// Replace if same name exists
|
||
const idx = presets.findIndex(p => p.name === name);
|
||
if (idx >= 0) presets[idx] = { name, stops };
|
||
else presets.push({ name, stops });
|
||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||
}
|
||
|
||
/** Delete a custom preset by name. */
|
||
export function deleteCustomGradientPreset(name: string): void {
|
||
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
|
||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||
}
|
||
|
||
/* ── Track click → add stop ───────────────────────────────────── */
|
||
|
||
function _gradientSetupTrackClick(): void {
|
||
const track = document.getElementById('gradient-markers-track');
|
||
if (!track || (track as any)._gradientClickBound) return;
|
||
(track as any)._gradientClickBound = true;
|
||
|
||
track.addEventListener('click', (e) => {
|
||
if (_gradientDragging) return;
|
||
const rect = track.getBoundingClientRect();
|
||
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||
// Ignore clicks very close to an existing marker
|
||
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
|
||
if (!tooClose) {
|
||
gradientAddStop(Math.round(pos * 100) / 100);
|
||
}
|
||
});
|
||
}
|