Fullfører oppgave 19.3: Arbeidsflaten layout med Canvas + BlockShell

Skriver om /collection/[id] fra vertikal stack til spatial workspace:

- Desktop: trait-paneler vises som BlockShell-wrappers på Canvas med
  fri pan/zoom, drag-repositionering og resize
- Mobil (<768px): tab-navigasjon med ett synlig panel om gangen
- Tre-lags layout-modell: personlig (edge metadata) > node-default
  (generert fra traits) > plattform-default (grid fallback)
- Layout persisteres debounced (1s) til brukerens owner/member_of
  edge metadata via updateEdge API
- Nye traits legges automatisk til eksisterende layout
- Fjernede traits filtreres ut ved resolving

Ny fil: frontend/src/lib/workspace/types.ts
  - PanelLayout/WorkspaceLayout typer
  - TRAIT_PANEL_INFO med default størrelse/ikon per trait
  - generateDefaultLayout(): grid-arrangement fra trait-liste
  - resolveLayout(): tre-lags merging med saved layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 07:31:40 +00:00
parent 4b6310ce53
commit 0caa8eb126
3 changed files with 721 additions and 109 deletions

View file

@ -0,0 +1,148 @@
/**
* Workspace Layout Types defines panel arrangement for the spatial canvas.
*
* Tre lag:
* 1. Personlig brukerens eget arrangement (lagret i edge metadata)
* 2. Node-default samlingens traits foreslår layout
* 3. Plattform-default Synops sine standarder (fallback)
*
* Ref: docs/retninger/arbeidsflaten.md § "Tre lag"
*/
/** Position and size of a single panel on the canvas */
export interface PanelLayout {
/** Trait name this panel represents */
trait: string;
/** X position in world coordinates */
x: number;
/** Y position in world coordinates */
y: number;
/** Panel width */
width: number;
/** Panel height */
height: number;
}
/** Full workspace layout state */
export interface WorkspaceLayout {
panels: PanelLayout[];
}
/** Panel metadata: icon and display name per trait */
export interface TraitPanelInfo {
title: string;
icon: string;
defaultWidth: number;
defaultHeight: number;
}
/** Known trait panel configurations */
export const TRAIT_PANEL_INFO: Record<string, TraitPanelInfo> = {
chat: { title: 'Chat', icon: '💬', defaultWidth: 400, defaultHeight: 500 },
editor: { title: 'Artikkelverktøy', icon: '📝', defaultWidth: 600, defaultHeight: 500 },
kanban: { title: 'Kanban', icon: '📋', defaultWidth: 500, defaultHeight: 450 },
calendar: { title: 'Kalender', icon: '📅', defaultWidth: 500, defaultHeight: 400 },
podcast: { title: 'Podcast', icon: '🎙️', defaultWidth: 450, defaultHeight: 400 },
publishing: { title: 'Publisering', icon: '📤', defaultWidth: 400, defaultHeight: 350 },
rss: { title: 'RSS', icon: '📡', defaultWidth: 400, defaultHeight: 350 },
recording: { title: 'Opptak', icon: '🔴', defaultWidth: 450, defaultHeight: 400 },
transcription: { title: 'Transkripsjon', icon: '📄', defaultWidth: 500, defaultHeight: 450 },
studio: { title: 'Studio', icon: '🎛️', defaultWidth: 550, defaultHeight: 450 },
mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 },
};
/** Default info for unknown traits */
const DEFAULT_PANEL_INFO: TraitPanelInfo = {
title: 'Verktøy',
icon: '🔧',
defaultWidth: 400,
defaultHeight: 350,
};
/** Get panel info for a trait, falling back to defaults */
export function getPanelInfo(trait: string): TraitPanelInfo {
return TRAIT_PANEL_INFO[trait] ?? { ...DEFAULT_PANEL_INFO, title: trait };
}
/**
* Generate a default layout from trait names.
* Arranges panels in a grid, spaced evenly.
* This is the "plattform-default" layer.
*/
export function generateDefaultLayout(traitNames: string[]): WorkspaceLayout {
if (traitNames.length === 0) return { panels: [] };
const GAP = 30;
const panels: PanelLayout[] = [];
// Calculate grid: aim for roughly 2-3 columns
const cols = traitNames.length <= 2 ? traitNames.length : Math.min(3, Math.ceil(Math.sqrt(traitNames.length)));
let currentX = GAP;
let currentY = GAP;
let rowMaxHeight = 0;
for (let i = 0; i < traitNames.length; i++) {
const col = i % cols;
if (col === 0 && i > 0) {
// New row
currentX = GAP;
currentY += rowMaxHeight + GAP;
rowMaxHeight = 0;
}
const info = getPanelInfo(traitNames[i]);
panels.push({
trait: traitNames[i],
x: currentX,
y: currentY,
width: info.defaultWidth,
height: info.defaultHeight,
});
currentX += info.defaultWidth + GAP;
rowMaxHeight = Math.max(rowMaxHeight, info.defaultHeight);
}
return { panels };
}
/**
* Resolve layout using the three-layer model:
* 1. Personal (from edge metadata) highest priority
* 2. Node-default (from traits) if no personal layout
* 3. Platform-default (generated) fallback
*/
export function resolveLayout(
traitNames: string[],
savedLayout: WorkspaceLayout | null,
): WorkspaceLayout {
if (savedLayout && savedLayout.panels.length > 0) {
// Personal layout exists — use it, but add any new traits not in saved layout
const savedTraits = new Set(savedLayout.panels.map(p => p.trait));
const newTraits = traitNames.filter(t => !savedTraits.has(t));
if (newTraits.length === 0) {
// Filter out panels for traits that no longer exist
const activeTraits = new Set(traitNames);
return {
panels: savedLayout.panels.filter(p => activeTraits.has(p.trait)),
};
}
// Append new traits at the end
const existingPanels = savedLayout.panels.filter(p => new Set(traitNames).has(p.trait));
const maxY = Math.max(0, ...existingPanels.map(p => p.y + p.height));
const newLayout = generateDefaultLayout(newTraits);
// Offset new panels below existing ones
const offsetPanels = newLayout.panels.map(p => ({
...p,
y: p.y + maxY + 30,
}));
return { panels: [...existingPanels, ...offsetPanels] };
}
// No saved layout — generate from traits (platform default)
return generateDefaultLayout(traitNames);
}

View file

@ -2,6 +2,20 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime'; import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import { updateEdge } 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,
resolveLayout,
} from '$lib/workspace/types.js';
// Trait components // Trait components
import EditorTrait from '$lib/components/traits/EditorTrait.svelte'; import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
@ -54,12 +68,6 @@
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer' 'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer'
]); ]);
/** Traits that have a dedicated component */
const renderedTraits = $derived(traitNames.filter(t => knownTraits.has(t)));
/** Traits without a dedicated component — shown with generic panel */
const genericTraits = $derived(traitNames.filter(t => !knownTraits.has(t)));
/** Count of child nodes */ /** Count of child nodes */
const childCount = $derived.by(() => { const childCount = $derived.by(() => {
if (!connected || !collectionId) return 0; if (!connected || !collectionId) return 0;
@ -69,32 +77,168 @@
} }
return count; return count;
}); });
// =========================================================================
// Workspace layout state
// =========================================================================
/** Find the user's edge to this collection (owner or member_of) */
const userEdge = $derived.by(() => {
if (!connected || !nodeId || !collectionId) return undefined;
for (const edge of edgeStore.bySource(nodeId)) {
if (
(edge.edgeType === 'owner' || edge.edgeType === 'member_of') &&
edge.targetId === collectionId
) {
return edge;
}
}
return undefined;
});
/** Parse saved layout from user's edge metadata */
const savedLayout = $derived.by((): WorkspaceLayout | null => {
if (!userEdge) return null;
try {
const meta = JSON.parse(userEdge.metadata ?? '{}');
if (meta.workspace_layout && Array.isArray(meta.workspace_layout.panels)) {
return meta.workspace_layout as WorkspaceLayout;
}
} catch { /* ignore */ }
return null;
});
/** Resolved layout using three-layer model */
let layout = $state<WorkspaceLayout>({ panels: [] });
let layoutInitialized = $state(false);
// Initialize layout when traits and saved layout become available
$effect(() => {
if (traitNames.length === 0) return;
// Only initialize once per collection — user moves update layout directly
if (!layoutInitialized) {
layout = resolveLayout(traitNames, savedLayout);
layoutInitialized = true;
}
});
// Reset when collection changes
$effect(() => {
const _id = collectionId;
layoutInitialized = false;
});
/** Convert layout panels to CanvasObjects for the Canvas component */
const canvasObjects = $derived<CanvasObject[]>(
layout.panels.map(p => ({
id: p.trait,
x: p.x,
y: p.y,
width: p.width,
height: p.height,
}))
);
// =========================================================================
// Layout persistence
// =========================================================================
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
/** Persist layout to user's edge metadata (debounced) */
function persistLayout() {
if (!accessToken || !userEdge) return;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
try {
const existingMeta = JSON.parse(userEdge!.metadata ?? '{}');
await updateEdge(accessToken!, {
edge_id: userEdge!.id,
metadata: {
...existingMeta,
workspace_layout: layout,
},
});
} catch (err) {
console.warn('Failed to persist workspace layout:', err);
}
}, 1000);
}
/** Handle panel move on canvas */
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();
}
}
/** Handle panel resize via BlockShell */
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();
}
}
/** Handle panel close */
function handlePanelClose(trait: string) {
layout = {
panels: layout.panels.filter(p => p.trait !== trait),
};
persistLayout();
}
// =========================================================================
// Mobile detection + tab state
// =========================================================================
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);
});
// Clamp active tab when panels change
$effect(() => {
if (activeTab >= traitNames.length && traitNames.length > 0) {
activeTab = 0;
}
});
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="workspace-page">
<!-- Header --> <!-- Header -->
<header class="border-b border-gray-200 bg-white"> <header class="workspace-header">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3"> <div class="workspace-header-inner">
<div class="flex items-center gap-3"> <div class="workspace-header-left">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a> <a href="/" class="workspace-back">&larr; Mottak</a>
<h1 class="text-lg font-semibold text-gray-900"> <h1 class="workspace-title">
{collectionNode?.title || 'Samling'} {collectionNode?.title || 'Samling'}
</h1> </h1>
</div> </div>
<div class="flex items-center gap-3"> <div class="workspace-header-right">
{#if connected} {#if connected}
<span class="text-xs text-green-600">Tilkoblet</span> <span class="workspace-status workspace-status-ok">Tilkoblet</span>
{:else} {:else}
<span class="text-xs text-gray-400">{connectionState.current}</span> <span class="workspace-status">{connectionState.current}</span>
{/if} {/if}
{#if traitNames.length > 0} {#if traitNames.length > 0}
<span class="text-xs text-gray-400">{traitNames.length} traits</span> <span class="workspace-meta">{traitNames.length} traits</span>
{/if} {/if}
<span class="text-xs text-gray-400">{childCount} noder</span> <span class="workspace-meta">{childCount} noder</span>
{#if connected && collectionNode && accessToken} {#if connected && collectionNode && accessToken}
<button <button
onclick={() => { showTraitAdmin = !showTraitAdmin; }} onclick={() => { showTraitAdmin = !showTraitAdmin; }}
class="rounded-lg px-2 py-1 text-xs font-medium transition-colors {showTraitAdmin ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}" class="workspace-btn {showTraitAdmin ? 'workspace-btn-active' : ''}"
> >
Traits Traits
</button> </button>
@ -103,10 +247,9 @@
</div> </div>
</header> </header>
<main class="mx-auto max-w-4xl px-4 py-6"> <!-- Trait admin panel (overlay) -->
<!-- Trait admin panel -->
{#if showTraitAdmin && accessToken} {#if showTraitAdmin && accessToken}
<div class="mb-6"> <div class="workspace-admin-overlay">
<TraitAdmin <TraitAdmin
{accessToken} {accessToken}
collectionId={collectionId} collectionId={collectionId}
@ -117,40 +260,51 @@
</div> </div>
{/if} {/if}
<!-- Main content area -->
{#if !connected} {#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p> <div class="workspace-message">
<p>Venter på tilkobling…</p>
</div>
{:else if !collectionNode} {:else if !collectionNode}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800"> <div class="workspace-message workspace-message-warn">
<p class="font-medium">Samling ikke funnet</p> <p class="workspace-message-title">Samling ikke funnet</p>
<p class="mt-1">Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.</p> <p>Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.</p>
<a href="/" class="mt-2 inline-block text-blue-600 hover:underline">Tilbake til mottak</a> <a href="/" class="workspace-link">Tilbake til mottak</a>
</div> </div>
{:else if traitNames.length === 0} {:else if traitNames.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-6 text-center"> <div class="workspace-message">
<p class="text-sm text-gray-500">Denne samlingen har ingen aktive traits.</p> <p>Denne samlingen har ingen aktive traits.</p>
<p class="mt-1 text-xs text-gray-400">Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.</p> <p class="workspace-message-sub">Traits bestemmer hvilke verktøy og visninger som er tilgjengelige.</p>
{#if accessToken && !showTraitAdmin} {#if accessToken && !showTraitAdmin}
<button <button
onclick={() => { showTraitAdmin = true; }} onclick={() => { showTraitAdmin = true; }}
class="mt-3 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700" class="workspace-btn-primary"
> >
Legg til traits Legg til traits
</button> </button>
{/if} {/if}
</div> </div>
{:else} {:else if isMobile}
<!-- Active traits as pills --> <!-- ============================================================= -->
<div class="mb-6 flex flex-wrap gap-2"> <!-- MOBILE: Stacked with tab navigation -->
{#each traitNames as trait (trait)} <!-- ============================================================= -->
<span class="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700"> <div class="mobile-tabs">
{trait} {#each traitNames as trait, i (trait)}
</span> <button
class="mobile-tab {activeTab === i ? 'mobile-tab-active' : ''}"
onclick={() => { activeTab = i; }}
>
{getPanelInfo(trait).icon}
<span class="mobile-tab-label">{getPanelInfo(trait).title}</span>
</button>
{/each} {/each}
</div> </div>
<!-- Trait panels --> <div class="mobile-panel">
<div class="space-y-4"> {#each traitNames as trait, i (trait)}
{#each renderedTraits as trait (trait)} {#if activeTab === i}
<div class="mobile-panel-content">
{#if knownTraits.has(trait)}
{#if trait === 'editor'} {#if trait === 'editor'}
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} /> <EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
{:else if trait === 'chat'} {:else if trait === 'chat'}
@ -174,26 +328,337 @@
{:else if trait === 'mixer'} {:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} /> <MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{/if} {/if}
{/each} {:else}
{#each genericTraits as trait (trait)}
<GenericTrait name={trait} config={traits[trait]} /> <GenericTrait name={trait} config={traits[trait]} />
{/if}
</div>
{/if}
{/each} {/each}
</div> </div>
{:else}
<!-- ============================================================= -->
<!-- DESKTOP: Spatial Canvas with BlockShell panels -->
<!-- ============================================================= -->
<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}
onResize={(w, h) => handlePanelResize(trait, w, h)}
onClose={() => handlePanelClose(trait)}
>
{#if knownTraits.has(trait)}
{#if trait === 'editor'}
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
{:else if trait === 'chat'}
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'podcast'}
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'publishing'}
<PublishingTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'rss'}
<RssTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'calendar'}
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'recording'}
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{:else if trait === 'transcription'}
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'studio'}
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{/if}
{:else}
<GenericTrait name={trait} config={traits[trait]} />
{/if}
</BlockShell>
{/snippet}
</Canvas>
</div>
{/if} {/if}
<!-- AI-verktøy panel --> <!-- AI-verktøy og ressursforbruk (vises under canvas/tabs) -->
{#if connected && accessToken} {#if connected && accessToken}
<div class="mt-6"> <div class="workspace-footer-tools">
<AiToolPanel {accessToken} userId={nodeId} /> <AiToolPanel {accessToken} userId={nodeId} />
</div> {#if collectionId}
{/if}
<!-- Ressursforbruk for denne noden (oppgave 15.9) -->
{#if accessToken && collectionId}
<div class="mt-6">
<NodeUsage nodeId={collectionId} {accessToken} /> <NodeUsage nodeId={collectionId} {accessToken} />
{/if}
</div> </div>
{/if} {/if}
</main>
</div> </div>
<style>
/* ================================================================= */
/* Page layout */
/* ================================================================= */
.workspace-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #f0f2f5;
}
/* ================================================================= */
/* Header */
/* ================================================================= */
.workspace-header {
border-bottom: 1px solid #e5e7eb;
background: white;
flex-shrink: 0;
z-index: 30;
}
.workspace-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
max-width: 100%;
}
.workspace-header-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.workspace-header-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.workspace-back {
font-size: 13px;
color: #9ca3af;
text-decoration: none;
flex-shrink: 0;
}
.workspace-back:hover {
color: #6b7280;
}
.workspace-title {
font-size: 16px;
font-weight: 600;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-status {
font-size: 11px;
color: #9ca3af;
}
.workspace-status-ok {
color: #16a34a;
}
.workspace-meta {
font-size: 11px;
color: #9ca3af;
}
.workspace-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;
}
.workspace-btn:hover {
background: #e5e7eb;
}
.workspace-btn-active {
background: #4f46e5;
color: white;
}
.workspace-btn-primary {
margin-top: 12px;
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
background: #4f46e5;
color: white;
}
.workspace-btn-primary:hover {
background: #4338ca;
}
/* ================================================================= */
/* Trait admin overlay */
/* ================================================================= */
.workspace-admin-overlay {
position: relative;
z-index: 25;
padding: 12px 16px;
background: #fafbfc;
border-bottom: 1px solid #e5e7eb;
}
/* ================================================================= */
/* Messages (loading, not found, empty) */
/* ================================================================= */
.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;
}
.workspace-message-sub {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
.workspace-link {
margin-top: 8px;
color: #2563eb;
text-decoration: none;
}
.workspace-link:hover {
text-decoration: underline;
}
/* ================================================================= */
/* Desktop: Canvas fills remaining space */
/* ================================================================= */
.workspace-canvas {
flex: 1;
min-height: 0;
}
/* ================================================================= */
/* Mobile: Tab navigation */
/* ================================================================= */
.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 (AI, resource usage) */
/* ================================================================= */
.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) {
.workspace-header-inner {
padding: 8px 12px;
}
.workspace-header-right {
gap: 8px;
}
.workspace-title {
font-size: 15px;
}
.workspace-footer-tools {
flex-direction: column;
gap: 8px;
padding: 8px 12px;
}
}
</style>

View file

@ -214,8 +214,7 @@ Ref: `docs/retninger/arbeidsflaten.md`, `docs/features/canvas_primitiv.md`
- [x] 19.1 Canvas-primitiv Svelte-komponent: pan/zoom kamera med CSS transforms, viewport culling, pointer events (mus + touch), snap-to-grid (valgfritt), fullskjermsmodus. Ref: `docs/features/canvas_primitiv.md`. - [x] 19.1 Canvas-primitiv Svelte-komponent: pan/zoom kamera med CSS transforms, viewport culling, pointer events (mus + touch), snap-to-grid (valgfritt), fullskjermsmodus. Ref: `docs/features/canvas_primitiv.md`.
- [x] 19.2 BlockShell wrapper-komponent: header med tittel + fullskjerm/resize/lukk-knapper, drag-handles for repositionering, resize-handles, drop-sone rendering (highlight ved drag-over). Responsivt (min-size, max-size). - [x] 19.2 BlockShell wrapper-komponent: header med tittel + fullskjerm/resize/lukk-knapper, drag-handles for repositionering, resize-handles, drop-sone rendering (highlight ved drag-over). Responsivt (min-size, max-size).
- [~] 19.3 Arbeidsflaten layout: skriv om `/collection/[id]` fra vertikal stack til Canvas + BlockShell. Last brukerens lagrede arrangement eller bruk defaults fra samlingens traits. Persist arrangement i bruker-edge metadata. Desktop: spatial canvas, mobil: stacked/tabs. Ref: `docs/retninger/arbeidsflaten.md` § "Tre lag". - [x] 19.3 Arbeidsflaten layout: skriv om `/collection/[id]` fra vertikal stack til Canvas + BlockShell. Last brukerens lagrede arrangement eller bruk defaults fra samlingens traits. Persist arrangement i bruker-edge metadata. Desktop: spatial canvas, mobil: stacked/tabs. Ref: `docs/retninger/arbeidsflaten.md` § "Tre lag".
> Påbegynt: 2026-03-18T07:26
- [ ] 19.4 Kontekst-header: header tilhører flaten, viser gjeldende node som nedtrekksmeny/kontekst-velger. Mest brukte noder øverst (frekvens/recency), søkbart. Verktøymeny for å instansiere nye paneler. Ref: `docs/retninger/arbeidsflaten.md` § "Kontekst-header". - [ ] 19.4 Kontekst-header: header tilhører flaten, viser gjeldende node som nedtrekksmeny/kontekst-velger. Mest brukte noder øverst (frekvens/recency), søkbart. Verktøymeny for å instansiere nye paneler. Ref: `docs/retninger/arbeidsflaten.md` § "Kontekst-header".
- [ ] 19.5 Snarveier: paneler kan minimeres til kompakt ikon/fane. Dobbeltklikk → minimer/gjenopprett. Bevarer posisjon og størrelse. Ref: `docs/retninger/arbeidsflaten.md` § "Snarveier". - [ ] 19.5 Snarveier: paneler kan minimeres til kompakt ikon/fane. Dobbeltklikk → minimer/gjenopprett. Bevarer posisjon og størrelse. Ref: `docs/retninger/arbeidsflaten.md` § "Snarveier".
- [ ] 19.6 Personlig flate: brukerens standard arbeidsflate (node_kind: 'workspace'). Vises når ikke koblet til en annen node. Persistent layout. - [ ] 19.6 Personlig flate: brukerens standard arbeidsflate (node_kind: 'workspace'). Vises når ikke koblet til en annen node. Persistent layout.