Valider fase 19–20: arbeidsflaten + universell overføring bestått

Fikser funnet under validering:
- Gjør collection-prop valgfri i alle trait-komponenter slik at de
  fungerer i personlig arbeidsflate uten collection-kontekst
- Legger til null-guards for collection.id i alle derived-blokker
  og funksjoner som oppretter edges
- Fjerner microsSinceUnixEpoch-rester fra STDB-migrasjonen —
  createdAt er nå et tall (Unix µs), ikke et objekt
- Retter saveTimeout-lekkasje i collection-sida: timer ryddes nå
  ved navigasjon mellom samlinger
- Fikser TypeScript-feil i editorial (number vs string, uoppnåelig
  'scheduled'-sammenligning), studio (bigint vs number),
  RecordingTrait ($state-generics)
- Typefeil redusert fra 55 → 4 (gjenværende er pre-eksisterende
  i mixer.ts/livekit.ts, ikke fase 19-20)

Validert: Canvas pan/zoom, BlockShell, layout-persistering,
snarveier, transfer service, alle panelreworks. Frontend bygger OK.
This commit is contained in:
vegard 2026-03-18 16:03:17 +00:00
parent 7b601ead1f
commit 15dd23b873
17 changed files with 65 additions and 56 deletions

View file

@ -6,7 +6,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string; accessToken?: string;

View file

@ -9,7 +9,7 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string; accessToken?: string;
@ -45,6 +45,7 @@
const chatNodes = $derived.by(() => { const chatNodes = $derived.by(() => {
const nodes: Node[] = []; const nodes: Node[] = [];
if (!collection) return nodes;
const seen = new Set<string>(); const seen = new Set<string>();
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
@ -121,8 +122,8 @@
} }
nodes.sort((a, b) => { nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; const ta = a.createdAt ?? 0;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; const tb = b.createdAt ?? 0;
return ta > tb ? 1 : ta < tb ? -1 : 0; return ta > tb ? 1 : ta < tb ? -1 : 0;
}); });
@ -189,8 +190,8 @@
// ========================================================================= // =========================================================================
function formatTime(node: Node): string { function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return ''; if (!node.createdAt) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms); const date = new Date(ms);
const now = new Date(); const now = new Date();
const isToday = date.toDateString() === now.toDateString(); const isToday = date.toDateString() === now.toDateString();

View file

@ -7,7 +7,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string; accessToken?: string;
@ -69,6 +69,7 @@
} }
const contentItems = $derived.by((): ContentItem[] => { const contentItems = $derived.by((): ContentItem[] => {
if (!collection) return [];
const items: ContentItem[] = []; const items: ContentItem[] = [];
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
@ -78,8 +79,8 @@
} }
} }
items.sort((a, b) => { items.sort((a, b) => {
const ta = a.node.createdAt?.microsSinceUnixEpoch ?? 0n; const ta = a.node.createdAt ?? 0;
const tb = b.node.createdAt?.microsSinceUnixEpoch ?? 0n; const tb = b.node.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0; return tb > ta ? 1 : tb < ta ? -1 : 0;
}); });
return items; return items;
@ -131,8 +132,8 @@
} }
} }
nodes.sort((a, b) => { nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; const ta = a.createdAt ?? 0;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; const tb = b.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0; return tb > ta ? 1 : tb < ta ? -1 : 0;
}); });
return nodes; return nodes;
@ -217,7 +218,7 @@
let showCreateForm = $state(false); let showCreateForm = $state(false);
async function handleCreateArticle() { async function handleCreateArticle() {
if (!accessToken || !newTitle.trim() || isCreating) return; if (!accessToken || !collection || !newTitle.trim() || isCreating) return;
isCreating = true; isCreating = true;
try { try {
const { node_id } = await createNode(accessToken, { const { node_id } = await createNode(accessToken, {
@ -360,8 +361,8 @@
// ========================================================================= // =========================================================================
function formatTime(node: Node): string { function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return ''; if (!node.createdAt) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms); const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' }); return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
} }
@ -664,7 +665,7 @@
{#if publishTarget && accessToken && pubConfig} {#if publishTarget && accessToken && pubConfig}
<PublishDialog <PublishDialog
node={publishTarget} node={publishTarget}
{collection} collection={collection!}
pubConfig={pubConfig} pubConfig={pubConfig}
{accessToken} {accessToken}
onclose={() => { publishTarget = null; }} onclose={() => { publishTarget = null; }}

View file

@ -6,7 +6,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string; accessToken?: string;
@ -43,6 +43,7 @@
// ========================================================================= // =========================================================================
const columns = $derived.by(() => { const columns = $derived.by(() => {
if (!collection) return ['todo', 'in_progress', 'done'];
try { try {
const meta = JSON.parse(collection.metadata ?? '{}'); const meta = JSON.parse(collection.metadata ?? '{}');
const traitConf = meta.traits?.kanban; const traitConf = meta.traits?.kanban;
@ -163,7 +164,7 @@
e.preventDefault(); e.preventDefault();
dragOverColumn = null; dragOverColumn = null;
if (!draggedCard || !accessToken) return; if (!draggedCard || !accessToken || !collection) return;
if (draggedCard.status === targetColumn) { if (draggedCard.status === targetColumn) {
draggedCard = null; draggedCard = null;
return; return;
@ -205,7 +206,7 @@
let isCreating = $state(false); let isCreating = $state(false);
async function handleCreateCard(column: string) { async function handleCreateCard(column: string) {
if (!accessToken || !newCardTitle.trim() || isCreating) return; if (!accessToken || !collection || !newCardTitle.trim() || isCreating) return;
isCreating = true; isCreating = true;
try { try {
@ -259,8 +260,8 @@
// ========================================================================= // =========================================================================
function formatTime(node: Node): string { function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return ''; if (!node.createdAt) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms); const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' }); return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
} }

View file

@ -45,7 +45,7 @@
} from '$lib/mixer'; } from '$lib/mixer';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
accessToken?: string; accessToken?: string;
} }
@ -90,6 +90,7 @@
// Derive room_id from the collection's communication node // Derive room_id from the collection's communication node
// Pattern: "communication_{communication_node_id}" // Pattern: "communication_{communication_node_id}"
const roomId = $derived.by(() => { const roomId = $derived.by(() => {
if (!collection) return null;
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);

View file

@ -6,7 +6,7 @@
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string; accessToken?: string;
@ -17,6 +17,7 @@
/** Media nodes (episodes) belonging to this collection */ /** Media nodes (episodes) belonging to this collection */
const episodes = $derived.by(() => { const episodes = $derived.by(() => {
const nodes: Node[] = []; const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);
@ -31,8 +32,8 @@
} }
} }
nodes.sort((a, b) => { nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; const ta = a.createdAt ?? 0;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; const tb = b.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0; return tb > ta ? 1 : tb < ta ? -1 : 0;
}); });
return nodes; return nodes;

View file

@ -3,7 +3,7 @@
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
} }
@ -45,14 +45,14 @@
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
{#if requireApproval} {#if requireApproval}
<a <a
href="/editorial/{collection.id}" href="/editorial/{collection?.id ?? ''}"
class="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700" class="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
> >
Redaksjonell arbeidsflate Redaksjonell arbeidsflate
</a> </a>
{/if} {/if}
<a <a
href="/collection/{collection.id}/forside" href="/collection/{collection?.id ?? ''}/forside"
class="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700" class="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
> >
Rediger forside Rediger forside

View file

@ -20,14 +20,14 @@
} from '$lib/livekit'; } from '$lib/livekit';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
accessToken?: string; accessToken?: string;
} }
let { collection, config, accessToken }: Props = $props(); let { collection, config, accessToken }: Props = $props();
let status: RoomStatus = $state('disconnected'); let status = $state<RoomStatus>('disconnected');
let participants: LiveKitParticipant[] = $state([]); let participants: LiveKitParticipant[] = $state([]);
let localIdentity: string = $state(''); let localIdentity: string = $state('');
let error: string | null = $state(null); let error: string | null = $state(null);
@ -55,6 +55,7 @@
/** Find communication nodes linked to this collection */ /** Find communication nodes linked to this collection */
const communicationNodes = $derived.by(() => { const communicationNodes = $derived.by(() => {
const nodes: Node[] = []; const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);

View file

@ -3,7 +3,7 @@
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
} }
@ -14,6 +14,7 @@
/** Build the feed URL from publishing slug if available */ /** Build the feed URL from publishing slug if available */
const feedUrl = $derived.by(() => { const feedUrl = $derived.by(() => {
if (!collection) return '';
try { try {
const meta = JSON.parse(collection.metadata ?? '{}'); const meta = JSON.parse(collection.metadata ?? '{}');
const slug = meta.traits?.publishing?.slug; const slug = meta.traits?.publishing?.slug;

View file

@ -28,7 +28,7 @@
} }
interface Props { interface Props {
collection: Node; collection?: Node;
accessToken?: string; accessToken?: string;
isViewer?: boolean; isViewer?: boolean;
} }
@ -56,7 +56,7 @@
// Parse pad config from collection metadata // Parse pad config from collection metadata
$effect(() => { $effect(() => {
const meta = collection.metadata ? JSON.parse(collection.metadata) : {}; const meta = collection?.metadata ? JSON.parse(collection.metadata) : {};
const mixerMeta = meta?.mixer ?? {}; const mixerMeta = meta?.mixer ?? {};
const rawPads: PadConfig[] = mixerMeta?.pads ?? []; const rawPads: PadConfig[] = mixerMeta?.pads ?? [];
@ -212,10 +212,10 @@
} }
async function savePadConfigs(configs: PadConfig[]) { async function savePadConfigs(configs: PadConfig[]) {
if (!accessToken) return; if (!accessToken || !collection) return;
// Filter out empty pads for clean storage // Filter out empty pads for clean storage
const padsToSave = configs.filter(p => p.cas_hash); const padsToSave = configs.filter(p => p.cas_hash);
const meta = collection.metadata ? JSON.parse(collection.metadata) : {}; const meta = collection?.metadata ? JSON.parse(collection.metadata) : {};
const mixer = meta.mixer ?? {}; const mixer = meta.mixer ?? {};
mixer.pads = padsToSave.length > 0 ? configs : undefined; mixer.pads = padsToSave.length > 0 ? configs : undefined;
meta.mixer = mixer; meta.mixer = mixer;

View file

@ -5,7 +5,7 @@
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string; accessToken?: string;
@ -41,6 +41,7 @@
const audioNodes = $derived.by(() => { const audioNodes = $derived.by(() => {
const nodes: Node[] = []; const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);
@ -55,8 +56,8 @@
} }
} }
nodes.sort((a, b) => { nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; const ta = a.createdAt ?? 0;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; const tb = b.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0; return tb > ta ? 1 : tb < ta ? -1 : 0;
}); });
return nodes; return nodes;
@ -121,8 +122,8 @@
} }
function formatTime(node: Node): string { function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return ''; if (!node.createdAt) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms); const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' }); return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
} }

View file

@ -3,7 +3,7 @@
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
interface Props { interface Props {
collection: Node; collection?: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
} }

View file

@ -53,8 +53,8 @@
// Sort by created_at ascending (oldest first, like a chat) // Sort by created_at ascending (oldest first, like a chat)
nodes.sort((a, b) => { nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; const ta = a.createdAt ?? 0;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; const tb = b.createdAt ?? 0;
return ta > tb ? 1 : ta < tb ? -1 : 0; return ta > tb ? 1 : ta < tb ? -1 : 0;
}); });
@ -113,8 +113,8 @@
/** Format timestamp for display */ /** Format timestamp for display */
function formatTime(node: Node): string { function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return ''; if (!node.createdAt) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n); const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms); const date = new Date(ms);
const now = new Date(); const now = new Date();
const isToday = date.toDateString() === now.toDateString(); const isToday = date.toDateString() === now.toDateString();

View file

@ -127,10 +127,13 @@
} }
}); });
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
// Reset when collection changes // Reset when collection changes
$effect(() => { $effect(() => {
const _id = collectionId; const _id = collectionId;
layoutInitialized = false; layoutInitialized = false;
clearTimeout(saveTimeout);
}); });
/** Convert layout panels to CanvasObjects for the Canvas component */ /** Convert layout panels to CanvasObjects for the Canvas component */
@ -148,8 +151,6 @@
// Layout persistence // Layout persistence
// ========================================================================= // =========================================================================
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
/** Persist layout to user's edge metadata (debounced) */ /** Persist layout to user's edge metadata (debounced) */
function persistLayout() { function persistLayout() {
if (!accessToken || !userEdge) return; if (!accessToken || !userEdge) return;

View file

@ -56,7 +56,7 @@
feedback: string | null; feedback: string | null;
edgeId: string; edgeId: string;
edgeMeta: Record<string, unknown>; edgeMeta: Record<string, unknown>;
createdAt: string; createdAt: number;
discussionIds: string[]; discussionIds: string[];
} }
@ -122,7 +122,7 @@
feedback: (meta.feedback as string) ?? null, feedback: (meta.feedback as string) ?? null,
edgeId: edge.id, edgeId: edge.id,
edgeMeta: meta, edgeMeta: meta,
createdAt: node.createdAt ?? '', createdAt: node.createdAt ?? 0,
discussionIds discussionIds
}); });
} }
@ -143,9 +143,9 @@
} }
// Sort each column: newest first for pending, oldest first for others // Sort each column: newest first for pending, oldest first for others
grouped.pending.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); grouped.pending.sort((a, b) => b.createdAt - a.createdAt);
for (const col of ['in_review', 'approved', 'scheduled'] as Column[]) { for (const col of ['in_review', 'approved', 'scheduled'] as Column[]) {
grouped[col].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); grouped[col].sort((a, b) => a.createdAt - b.createdAt);
} }
return grouped; return grouped;
@ -202,12 +202,13 @@
try { try {
// Map column back to actual status for the edge // Map column back to actual status for the edge
const newStatus = targetColumn === 'scheduled' ? 'approved' : targetColumn; // Note: 'scheduled' case handled above with early return + dialog
const newStatus = targetColumn;
const newMeta: Record<string, unknown> = { ...card.edgeMeta, status: newStatus }; const newMeta: Record<string, unknown> = { ...card.edgeMeta, status: newStatus };
// If moving away from scheduled, remove publish_at // If moving away from scheduled, remove publish_at
if (card.status === 'scheduled' && targetColumn !== 'scheduled') { if (card.status === 'scheduled') {
delete newMeta.publish_at; delete newMeta.publish_at;
} }

View file

@ -74,7 +74,7 @@
// Version history: processed nodes derived from this media node // Version history: processed nodes derived from this media node
const versions = $derived.by(() => { const versions = $derived.by(() => {
if (!connected || !mediaNodeId) return []; if (!connected || !mediaNodeId) return [];
const nodes: { id: string; title: string; createdAt: bigint }[] = []; const nodes: { id: string; title: string; createdAt: number }[] = [];
for (const edge of edgeStore.byTarget(mediaNodeId)) { for (const edge of edgeStore.byTarget(mediaNodeId)) {
if (edge.edgeType !== 'derived_from') continue; if (edge.edgeType !== 'derived_from') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);
@ -82,7 +82,7 @@
nodes.push({ nodes.push({
id: node.id, id: node.id,
title: node.title ?? 'Prosessert', title: node.title ?? 'Prosessert',
createdAt: node.createdAt?.microsSinceUnixEpoch ?? 0n, createdAt: node.createdAt ?? 0,
}); });
} }
} }

View file

@ -303,8 +303,7 @@ med spesifikasjon for det som trenger en dedikert sesjon.
- [x] 23.6 Valider fase 1314 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing. - [x] 23.6 Valider fase 1314 (traits + publisering): trait-validering, pakkevelger, Tera-templates, HTML-rendering, forside, slot-håndtering, redaksjonell flyt, planlagt publisering, A/B-testing.
- [x] 23.7 Valider fase 1516 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter. - [x] 23.7 Valider fase 1516 (admin + lydmixer): systemvarsler, graceful shutdown, jobbkø-oversikt, ressursstyring, serverhelse, Web Audio mixer, delt kontroll, sound pads, EQ, stemmeeffekter.
- [x] 23.8 Valider fase 1718 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon. - [x] 23.8 Valider fase 1718 (lydstudio-utbedring + AI-verktøy): responsivt layout, FFmpeg-validering, fade/silence, AI-presets, direction-logikk, drag-and-drop integrasjon.
- [~] 23.9 Valider fase 1920 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio). - [x] 23.9 Valider fase 1920 (arbeidsflaten + universell overføring): canvas pan/zoom, BlockShell, layout-persistering, snarveier, transfer service, alle panelreworks (chat, kanban, kalender, editor, studio).
> Påbegynt: 2026-03-18T15:50
- [ ] 23.10 Valider fase 21 (CLI-verktøy): kjør hvert synops-*-verktøy, verifiser --help, --payload-json, output-format, feilhåndtering, synops-common integrasjon. - [ ] 23.10 Valider fase 21 (CLI-verktøy): kjør hvert synops-*-verktøy, verifiser --help, --payload-json, output-format, feilhåndtering, synops-common integrasjon.
- [ ] 23.11 Valider fase 22 (STDB-migrering): WebSocket-sanntid fungerer, PG LISTEN/NOTIFY-triggere, ingen STDB-rester i aktiv kode/konfig. - [ ] 23.11 Valider fase 22 (STDB-migrering): WebSocket-sanntid fungerer, PG LISTEN/NOTIFY-triggere, ingen STDB-rester i aktiv kode/konfig.