synops/frontend/src/routes/workspace/+page.svelte
vegard e8a1a80652 Valider fase 22: STDB-migrering fullført, ingen rester i aktiv kode
Validering av fase 22 (SpacetimeDB-migrering) bekrefter:

1. WebSocket-sanntid fungerer:
   - maskinrommet lytter på PG NOTIFY-kanaler (node_changed, edge_changed,
     access_changed, mixer_channel_changed)
   - Enrichment av events med fulle rader fra PG
   - Broadcast via tokio::broadcast til WebSocket-klienter
   - Tilgangskontroll filtrerer events per bruker
   - Frontend kobler til /ws med JWT, mottar initial_sync + inkrementelle events

2. PG LISTEN/NOTIFY-triggere verifisert i database:
   - 4 notify-funksjoner: notify_node_change, notify_edge_change,
     notify_access_change, notify_mixer_channel_change
   - 4 triggere: nodes_notify, edges_notify, node_access_notify,
     mixer_channels_notify

3. Ingen STDB-rester i aktiv kode/konfig:
   - maskinrommet/src/: rent
   - Cargo.toml: ingen spacetimedb-avhengigheter
   - docker-compose.yml: ingen spacetimedb-tjeneste
   - Caddyfile: ingen spacetimedb-proxy
   - Eneste funn: frontend/src/lib/spacetime/ katalognavn —
     omdøpt til frontend/src/lib/realtime/ (32 filer oppdatert)
   - Historiske referanser i docs/arkiv og scripts/synops.md er OK
2026-03-18 16:31:16 +00:00

1104 lines
30 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/realtime';
import type { Node } from '$lib/realtime';
import { fetchMyWorkspace, updateNode } from '$lib/api';
// Canvas + BlockShell
import Canvas from '$lib/components/canvas/Canvas.svelte';
import type { CanvasObject } from '$lib/components/canvas/types.js';
import BlockShell from '$lib/components/blockshell/BlockShell.svelte';
// Workspace layout
import {
type PanelLayout,
type WorkspaceLayout,
getPanelInfo,
TRAIT_PANEL_INFO,
PANEL_HEADER_HEIGHT,
} from '$lib/workspace/types.js';
// Recency tracking for context selector
import { getRankedNodeIds } from '$lib/workspace/recency.js';
// Trait components (reuse from collection page)
import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
import KanbanTrait from '$lib/components/traits/KanbanTrait.svelte';
import PodcastTrait from '$lib/components/traits/PodcastTrait.svelte';
import PublishingTrait from '$lib/components/traits/PublishingTrait.svelte';
import RssTrait from '$lib/components/traits/RssTrait.svelte';
import CalendarTrait from '$lib/components/traits/CalendarTrait.svelte';
import RecordingTrait from '$lib/components/traits/RecordingTrait.svelte';
import TranscriptionTrait from '$lib/components/traits/TranscriptionTrait.svelte';
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
import { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer';
import type { BlockReceiver } from '$lib/components/blockshell/types';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
const connected = $derived(connectionState.current === 'connected');
// =========================================================================
// Workspace node (fetched from backend)
// =========================================================================
let workspaceNodeId = $state<string | undefined>(undefined);
let workspaceLoading = $state(true);
let workspaceError = $state<string | undefined>(undefined);
// Fetch or create workspace on load
$effect(() => {
if (!accessToken) return;
workspaceLoading = true;
fetchMyWorkspace(accessToken)
.then((res) => {
workspaceNodeId = res.node_id;
// Initialize layout from backend metadata if we don't have one yet
if (!layoutInitialized && res.metadata?.workspace_layout) {
const saved = res.metadata.workspace_layout as WorkspaceLayout;
if (saved.panels && Array.isArray(saved.panels) && saved.panels.length > 0) {
layout = saved;
}
layoutInitialized = true;
} else if (!layoutInitialized) {
layoutInitialized = true;
}
})
.catch((err) => {
workspaceError = err.message;
})
.finally(() => {
workspaceLoading = false;
});
});
// Also try to read layout from WS node store (for real-time sync)
const workspaceNode = $derived(
workspaceNodeId && connected ? nodeStore.get(workspaceNodeId) : undefined
);
// =========================================================================
// Layout state
// =========================================================================
let layout = $state<WorkspaceLayout>({ panels: [] });
let layoutInitialized = $state(false);
// When workspace node appears in store (after creation), load its layout
$effect(() => {
if (!workspaceNode || layoutInitialized) return;
try {
const meta = JSON.parse(workspaceNode.metadata ?? '{}');
if (meta.workspace_layout?.panels?.length > 0) {
layout = meta.workspace_layout as WorkspaceLayout;
}
} catch { /* ignore */ }
layoutInitialized = true;
});
/** Convert layout panels to CanvasObjects */
const canvasObjects = $derived<CanvasObject[]>(
layout.panels.map(p => ({
id: p.trait,
x: p.x,
y: p.y,
width: p.width,
height: p.minimized ? PANEL_HEADER_HEIGHT : p.height,
}))
);
// =========================================================================
// Layout persistence (debounced save to workspace node metadata)
// =========================================================================
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
function persistLayout() {
if (!accessToken || !workspaceNodeId) return;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
try {
// Read current metadata from node store
const currentMeta = workspaceNode
? JSON.parse(workspaceNode.metadata ?? '{}')
: {};
await updateNode(accessToken!, {
node_id: workspaceNodeId!,
metadata: {
...currentMeta,
workspace_layout: layout,
},
});
} catch (err) {
console.warn('Failed to persist workspace layout:', err);
}
}, 1000);
}
function handleObjectMove(id: string, x: number, y: number) {
const idx = layout.panels.findIndex(p => p.trait === id);
if (idx >= 0) {
layout.panels[idx] = { ...layout.panels[idx], x, y };
layout = { ...layout };
persistLayout();
}
}
function handlePanelResize(trait: string, width: number, height: number) {
const idx = layout.panels.findIndex(p => p.trait === trait);
if (idx >= 0) {
layout.panels[idx] = { ...layout.panels[idx], width, height };
layout = { ...layout };
persistLayout();
}
}
function handlePanelClose(trait: string) {
layout = {
panels: layout.panels.filter(p => p.trait !== trait),
};
persistLayout();
}
function handlePanelMinimize(trait: string, isMinimized: boolean) {
const idx = layout.panels.findIndex(p => p.trait === trait);
if (idx >= 0) {
layout.panels[idx] = { ...layout.panels[idx], minimized: isMinimized };
layout = { ...layout };
persistLayout();
}
}
function handleAddPanel(trait: string) {
if (layout.panels.some(p => p.trait === trait)) return;
const info = getPanelInfo(trait);
const maxY = layout.panels.length > 0
? Math.max(...layout.panels.map(p => p.y + p.height))
: 0;
layout = {
panels: [
...layout.panels,
{
trait,
x: 30,
y: maxY + 30,
width: info.defaultWidth,
height: info.defaultHeight,
},
],
};
persistLayout();
}
const activeLayoutTraits = $derived(layout.panels.map(p => p.trait));
// =========================================================================
// Context selector (navigate to collections)
// =========================================================================
let selectorOpen = $state(false);
let searchQuery = $state('');
let searchInput = $state<HTMLInputElement | undefined>(undefined);
const collectionNodes = $derived.by((): Node[] => {
if (!connected || !nodeId) return [];
const accessibleIds = new Set<string>();
for (const edge of edgeStore.bySource(nodeId)) {
if (!edge.system) accessibleIds.add(edge.targetId);
}
for (const id of nodeAccessStore.objectsForSubject(nodeId)) {
accessibleIds.add(id);
}
const collections: Node[] = [];
for (const id of accessibleIds) {
const node = nodeStore.get(id);
if (node && node.nodeKind === 'collection' && nodeVisibility(node, nodeId) !== 'hidden') {
collections.push(node);
}
}
const ranked = getRankedNodeIds();
const rankMap = new Map(ranked.map((id, i) => [id, i]));
collections.sort((a, b) => {
const ra = rankMap.get(a.id) ?? 999;
const rb = rankMap.get(b.id) ?? 999;
if (ra !== rb) return ra - rb;
return (a.title ?? '').localeCompare(b.title ?? '');
});
return collections;
});
const filteredCollections = $derived.by(() => {
if (!searchQuery.trim()) return collectionNodes;
const q = searchQuery.toLowerCase().trim();
return collectionNodes.filter(n => (n.title ?? '').toLowerCase().includes(q));
});
function toggleSelector() {
selectorOpen = !selectorOpen;
toolMenuOpen = false;
if (selectorOpen) {
searchQuery = '';
requestAnimationFrame(() => searchInput?.focus());
}
}
function selectCollection(id: string) {
selectorOpen = false;
goto(`/collection/${id}`);
}
// =========================================================================
// Tool menu
// =========================================================================
let toolMenuOpen = $state(false);
const availableTools = $derived.by(() => {
return Object.entries(TRAIT_PANEL_INFO).map(([key, info]) => ({
key,
...info,
active: activeLayoutTraits.includes(key),
}));
});
function toggleToolMenu() {
toolMenuOpen = !toolMenuOpen;
selectorOpen = false;
}
function addTool(trait: string) {
handleAddPanel(trait);
toolMenuOpen = false;
}
// =========================================================================
// Mobile detection
// =========================================================================
let windowWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1024);
const isMobile = $derived(windowWidth < 768);
let activeTab = $state(0);
$effect(() => {
function onResize() { windowWidth = window.innerWidth; }
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
});
$effect(() => {
if (activeTab >= layout.panels.length && layout.panels.length > 0) {
activeTab = 0;
}
});
// =========================================================================
// Click outside
// =========================================================================
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (selectorOpen && !target.closest('.context-selector')) {
selectorOpen = false;
}
if (toolMenuOpen && !target.closest('.tool-menu')) {
toolMenuOpen = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
selectorOpen = false;
toolMenuOpen = false;
}
}
/** Trait components that have dedicated implementations */
const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer'
]);
// =========================================================================
// BlockReceiver per tool type — wired into BlockShell for drop compatibility
// Ref: docs/features/universell_overfoering.md § 4
// =========================================================================
/** Traits that support BlockReceiver drops */
const RECEIVER_TRAITS = new Set(['editor', 'chat', 'kanban', 'calendar', 'studio']);
/** Cache receivers by trait name — one per type */
const receiverCache = new Map<string, BlockReceiver>();
function getReceiverForTrait(trait: string): BlockReceiver | undefined {
if (!RECEIVER_TRAITS.has(trait)) return undefined;
if (!receiverCache.has(trait)) {
const toolType = trait as import('$lib/transfer').ToolType;
receiverCache.set(trait, createBlockReceiver(toolType));
}
return receiverCache.get(trait);
}
function handlePanelDrop(trait: string, payload: DragPayload, shiftKey: boolean = false) {
const receiver = getReceiverForTrait(trait);
if (!receiver?.receive) {
console.warn(`[universell-overføring] Ingen mottaker for ${trait}`);
return;
}
const intent = receiver.receive(payload);
const mode = resolveTransferMode(
payload.sourcePanel,
trait as import('$lib/transfer').ToolType,
shiftKey,
);
// Override intent mode with centrally resolved mode
intent.mode = mode;
if (!accessToken) {
console.error('[universell-overføring] Mangler accessToken — kan ikke utføre overføring');
return;
}
console.log(`[universell-overføring] ${payload.sourcePanel}${trait} (${mode}${shiftKey ? ', shift' : ''}):`, intent);
executeTransfer(accessToken, payload, intent, shiftKey)
.then((result) => {
console.log(`[universell-overføring] Fullført:`, result);
})
.catch((err) => {
console.error(`[universell-overføring] Feil:`, err);
});
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="workspace-page">
<!-- Header -->
<header class="context-header">
<div class="context-header-inner">
<div class="context-header-left">
<a href="/" class="context-back" title="Tilbake til mottak">&larr;</a>
<div class="context-selector">
<button
class="context-selector-trigger"
onclick={toggleSelector}
title="Bytt kontekst"
>
<span class="context-selector-title">Min arbeidsflate</span>
<span class="context-selector-chevron" class:open={selectorOpen}>&#9662;</span>
</button>
{#if selectorOpen}
<div class="context-selector-dropdown">
<div class="context-selector-search">
<input
bind:this={searchInput}
bind:value={searchQuery}
type="text"
placeholder="Søk samlinger..."
class="context-selector-search-input"
onclick={(e) => e.stopPropagation()}
/>
</div>
<div class="context-selector-list">
{#each filteredCollections as node (node.id)}
<button
class="context-selector-item"
onclick={() => selectCollection(node.id)}
>
<span class="context-selector-item-title">{node.title || 'Uten tittel'}</span>
</button>
{:else}
<div class="context-selector-empty">
{searchQuery ? 'Ingen treff' : 'Ingen samlinger'}
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div class="context-header-right">
<div class="tool-menu">
<button
class="context-btn tool-menu-trigger"
onclick={toggleToolMenu}
title="Legg til verktøy-panel"
>
+ Verktøy
</button>
{#if toolMenuOpen}
<div class="tool-menu-dropdown">
<div class="tool-menu-title">Legg til panel</div>
{#each availableTools as tool (tool.key)}
<button
class="tool-menu-item"
class:tool-menu-item-active={tool.active}
onclick={() => addTool(tool.key)}
disabled={tool.active}
title={tool.active ? 'Allerede på flaten' : `Legg til ${tool.title}`}
>
<span class="tool-menu-item-icon">{tool.icon}</span>
<span class="tool-menu-item-label">{tool.title}</span>
{#if tool.active}
<span class="tool-menu-item-badge">aktiv</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{#if connected}
<span class="context-status context-status-ok" title="Tilkoblet sanntid">&#9679;</span>
{:else}
<span class="context-status" title="{connectionState.current}">&#9679;</span>
{/if}
</div>
</div>
</header>
<!-- Main content -->
{#if workspaceLoading}
<div class="workspace-message">
<p>Laster arbeidsflate...</p>
</div>
{:else if workspaceError}
<div class="workspace-message workspace-message-warn">
<p class="workspace-message-title">Kunne ikke laste arbeidsflate</p>
<p>{workspaceError}</p>
</div>
{:else if !connected}
<div class="workspace-message">
<p>Venter på tilkobling...</p>
</div>
{:else if layout.panels.length === 0}
<div class="workspace-empty">
<div class="workspace-empty-inner">
<p class="workspace-empty-title">Din personlige arbeidsflate</p>
<p class="workspace-empty-desc">
Legg til verktøy-paneler for å bygge opp arbeidsflaten din.
Arrangementet huskes mellom besøk.
</p>
<div class="workspace-empty-tools">
{#each Object.entries(TRAIT_PANEL_INFO) as [key, info] (key)}
<button
class="workspace-empty-tool"
onclick={() => handleAddPanel(key)}
>
<span class="workspace-empty-tool-icon">{info.icon}</span>
<span class="workspace-empty-tool-label">{info.title}</span>
</button>
{/each}
</div>
</div>
</div>
{:else if isMobile}
<!-- MOBILE: Stacked tabs -->
<div class="mobile-tabs">
{#each layout.panels as panel, i (panel.trait)}
<button
class="mobile-tab {activeTab === i ? 'mobile-tab-active' : ''}"
onclick={() => { activeTab = i; }}
>
{getPanelInfo(panel.trait).icon}
<span class="mobile-tab-label">{getPanelInfo(panel.trait).title}</span>
</button>
{/each}
</div>
<div class="mobile-panel">
{#each layout.panels as panel, i (panel.trait)}
{#if activeTab === i}
<div class="mobile-panel-content">
{#if knownTraits.has(panel.trait)}
{#if panel.trait === 'editor'}
<EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} />
{:else if panel.trait === 'chat'}
<ChatTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if panel.trait === 'kanban'}
<KanbanTrait collection={undefined} config={{}} userId={nodeId} />
{:else if panel.trait === 'calendar'}
<CalendarTrait collection={undefined} config={{}} userId={nodeId} />
{:else if panel.trait === 'podcast'}
<PodcastTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if panel.trait === 'publishing'}
<PublishingTrait collection={undefined} config={{}} />
{:else if panel.trait === 'rss'}
<RssTrait collection={undefined} config={{}} />
{:else if panel.trait === 'recording'}
<RecordingTrait collection={undefined} config={{}} {accessToken} />
{:else if panel.trait === 'transcription'}
<TranscriptionTrait collection={undefined} config={{}} />
{:else if panel.trait === 'studio'}
<StudioTrait collection={undefined} config={{}} userId={nodeId} />
{:else if panel.trait === 'mixer'}
<MixerTrait collection={undefined} config={{}} {accessToken} />
{/if}
{:else}
<GenericTrait name={panel.trait} config={{}} />
{/if}
</div>
{/if}
{/each}
</div>
{:else}
<!-- DESKTOP: Spatial Canvas -->
<div class="workspace-canvas">
<Canvas
objects={canvasObjects}
onObjectMove={handleObjectMove}
grid={{ enabled: false, size: 20 }}
>
{#snippet renderObject(obj)}
{@const trait = obj.id}
{@const info = getPanelInfo(trait)}
{@const panel = layout.panels.find(p => p.trait === trait)}
<BlockShell
title={info.title}
icon={info.icon}
width={panel?.width ?? obj.width}
height={panel?.height ?? obj.height}
minimized={panel?.minimized ?? false}
receiver={getReceiverForTrait(trait)}
onResize={(w, h) => handlePanelResize(trait, w, h)}
onClose={() => handlePanelClose(trait)}
onMinimizeChange={(m) => handlePanelMinimize(trait, m)}
onDrop={(payload, shiftKey) => handlePanelDrop(trait, payload, shiftKey)}
>
{#if knownTraits.has(trait)}
{#if trait === 'editor'}
<EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} />
{:else if trait === 'chat'}
<ChatTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if trait === 'kanban'}
<KanbanTrait collection={undefined} config={{}} userId={nodeId} />
{:else if trait === 'calendar'}
<CalendarTrait collection={undefined} config={{}} userId={nodeId} />
{:else if trait === 'podcast'}
<PodcastTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if trait === 'publishing'}
<PublishingTrait collection={undefined} config={{}} />
{:else if trait === 'rss'}
<RssTrait collection={undefined} config={{}} />
{:else if trait === 'recording'}
<RecordingTrait collection={undefined} config={{}} {accessToken} />
{:else if trait === 'transcription'}
<TranscriptionTrait collection={undefined} config={{}} />
{:else if trait === 'studio'}
<StudioTrait collection={undefined} config={{}} userId={nodeId} />
{:else if trait === 'mixer'}
<MixerTrait collection={undefined} config={{}} {accessToken} />
{/if}
{:else}
<GenericTrait name={trait} config={{}} />
{/if}
</BlockShell>
{/snippet}
</Canvas>
</div>
{/if}
<!-- AI-verktøy (vises under canvas) -->
{#if connected && accessToken}
<div class="workspace-footer-tools">
<AiToolPanel {accessToken} userId={nodeId} />
</div>
{/if}
</div>
<style>
.workspace-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f2f5;
}
/* ================================================================= */
/* Context header (inline — personal workspace variant) */
/* ================================================================= */
.context-header {
border-bottom: 1px solid #e5e7eb;
background: white;
flex-shrink: 0;
z-index: 30;
position: relative;
}
.context-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
max-width: 100%;
min-height: 44px;
}
.context-header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.context-header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.context-back {
font-size: 16px;
color: #9ca3af;
text-decoration: none;
flex-shrink: 0;
padding: 4px;
line-height: 1;
}
.context-back:hover {
color: #4b5563;
}
/* ================================================================= */
/* Context Selector */
/* ================================================================= */
.context-selector {
position: relative;
min-width: 0;
}
.context-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 15px;
font-weight: 600;
color: #111827;
max-width: 300px;
transition: background 0.12s, border-color 0.12s;
}
.context-selector-trigger:hover {
background: #f3f4f6;
border-color: #e5e7eb;
}
.context-selector-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-selector-chevron {
font-size: 10px;
color: #9ca3af;
transition: transform 0.15s;
flex-shrink: 0;
}
.context-selector-chevron.open {
transform: rotate(180deg);
}
.context-selector-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 260px;
max-width: 360px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 50;
overflow: hidden;
}
.context-selector-search {
padding: 8px;
border-bottom: 1px solid #f3f4f6;
}
.context-selector-search-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 13px;
outline: none;
background: #fafbfc;
}
.context-selector-search-input:focus {
border-color: #4f46e5;
background: white;
}
.context-selector-list {
max-height: 280px;
overflow-y: auto;
padding: 4px;
}
.context-selector-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #374151;
text-align: left;
transition: background 0.1s;
}
.context-selector-item:hover {
background: #f3f4f6;
}
.context-selector-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-selector-empty {
padding: 16px;
text-align: center;
font-size: 12px;
color: #9ca3af;
}
/* ================================================================= */
/* Tool Menu */
/* ================================================================= */
.tool-menu {
position: relative;
}
.tool-menu-trigger {
font-weight: 500;
}
.tool-menu-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 200px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 50;
overflow: hidden;
padding: 4px;
}
.tool-menu-title {
padding: 6px 10px 4px;
font-size: 11px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tool-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #374151;
text-align: left;
transition: background 0.1s;
}
.tool-menu-item:hover:not(:disabled) {
background: #f3f4f6;
}
.tool-menu-item:disabled {
cursor: default;
opacity: 0.5;
}
.tool-menu-item-active {
color: #9ca3af;
}
.tool-menu-item-icon {
font-size: 15px;
flex-shrink: 0;
}
.tool-menu-item-label {
flex: 1;
}
.tool-menu-item-badge {
font-size: 10px;
color: #9ca3af;
background: #f3f4f6;
padding: 1px 6px;
border-radius: 4px;
}
/* ================================================================= */
/* Status + buttons */
/* ================================================================= */
.context-status {
font-size: 8px;
color: #d1d5db;
}
.context-status-ok {
color: #16a34a;
}
.context-btn {
padding: 4px 10px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: #f3f4f6;
color: #4b5563;
transition: background 0.12s;
}
.context-btn:hover {
background: #e5e7eb;
}
/* ================================================================= */
/* Messages */
/* ================================================================= */
.workspace-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
text-align: center;
font-size: 14px;
color: #6b7280;
}
.workspace-message-warn {
color: #92400e;
background: #fffbeb;
}
.workspace-message-title {
font-weight: 600;
margin-bottom: 4px;
}
/* ================================================================= */
/* Empty state (no panels) */
/* ================================================================= */
.workspace-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
}
.workspace-empty-inner {
text-align: center;
max-width: 480px;
}
.workspace-empty-title {
font-size: 18px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
}
.workspace-empty-desc {
font-size: 14px;
color: #6b7280;
margin-bottom: 24px;
}
.workspace-empty-tools {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.workspace-empty-tool {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: white;
cursor: pointer;
font-size: 13px;
color: #374151;
transition: border-color 0.12s, box-shadow 0.12s;
}
.workspace-empty-tool:hover {
border-color: #4f46e5;
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.1);
}
.workspace-empty-tool-icon {
font-size: 16px;
}
.workspace-empty-tool-label {
font-weight: 500;
}
/* ================================================================= */
/* Canvas */
/* ================================================================= */
.workspace-canvas {
flex: 1;
min-height: 0;
}
/* ================================================================= */
/* Mobile tabs */
/* ================================================================= */
.mobile-tabs {
display: flex;
overflow-x: auto;
gap: 0;
background: white;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
-webkit-overflow-scrolling: touch;
}
.mobile-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 14px;
border: none;
background: transparent;
font-size: 13px;
color: #6b7280;
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: color 0.12s, border-color 0.12s;
}
.mobile-tab:hover {
color: #374151;
}
.mobile-tab-active {
color: #4f46e5;
border-bottom-color: #4f46e5;
font-weight: 500;
}
.mobile-tab-label {
font-size: 12px;
}
.mobile-panel {
flex: 1;
overflow: auto;
min-height: 0;
}
.mobile-panel-content {
min-height: 100%;
}
/* ================================================================= */
/* Footer tools */
/* ================================================================= */
.workspace-footer-tools {
flex-shrink: 0;
padding: 8px 16px;
background: white;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 16px;
}
/* ================================================================= */
/* Responsive */
/* ================================================================= */
@media (max-width: 768px) {
.context-header-inner {
padding: 6px 12px;
}
.context-selector-trigger {
font-size: 14px;
max-width: 200px;
}
.context-selector-dropdown {
min-width: 220px;
max-width: calc(100vw - 24px);
}
.tool-menu-dropdown {
max-width: calc(100vw - 24px);
}
.context-header-right {
gap: 6px;
}
.workspace-footer-tools {
flex-direction: column;
gap: 8px;
padding: 8px 12px;
}
.workspace-empty-tools {
gap: 6px;
}
.workspace-empty-tool {
padding: 6px 10px;
font-size: 12px;
}
}
</style>