;
+
+function load(): RecencyMap {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw) as RecencyMap;
+ } catch { /* ignore corrupt data */ }
+ return {};
+}
+
+function save(map: RecencyMap) {
+ // Prune to MAX_ENTRIES, keeping highest-scored
+ const entries = Object.entries(map);
+ if (entries.length > MAX_ENTRIES) {
+ const now = Date.now();
+ entries.sort((a, b) => score(b[1], now) - score(a[1], now));
+ const pruned: RecencyMap = {};
+ for (let i = 0; i < MAX_ENTRIES; i++) {
+ pruned[entries[i][0]] = entries[i][1];
+ }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
+ } else {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
+ }
+}
+
+/** Score combines frequency with time decay (halves every 7 days). */
+function score(visit: NodeVisit, now: number): number {
+ const daysSince = (now - visit.lastVisit) / (1000 * 60 * 60 * 24);
+ const decay = Math.pow(0.5, daysSince / 7);
+ return visit.count * decay;
+}
+
+/** Record a visit to a node. Call when navigating to a collection. */
+export function recordVisit(nodeId: string): void {
+ const map = load();
+ const existing = map[nodeId];
+ map[nodeId] = {
+ count: (existing?.count ?? 0) + 1,
+ lastVisit: Date.now(),
+ };
+ save(map);
+}
+
+/** Get node IDs sorted by score (most relevant first). */
+export function getRankedNodeIds(): string[] {
+ const map = load();
+ const now = Date.now();
+ return Object.entries(map)
+ .map(([id, visit]) => ({ id, score: score(visit, now) }))
+ .sort((a, b) => b.score - a.score)
+ .map(e => e.id);
+}
diff --git a/frontend/src/routes/collection/[id]/+page.svelte b/frontend/src/routes/collection/[id]/+page.svelte
index ce03068..6537db3 100644
--- a/frontend/src/routes/collection/[id]/+page.svelte
+++ b/frontend/src/routes/collection/[id]/+page.svelte
@@ -14,9 +14,13 @@
type PanelLayout,
type WorkspaceLayout,
getPanelInfo,
+ generateDefaultLayout,
resolveLayout,
} from '$lib/workspace/types.js';
+ // Context header
+ import ContextHeader from '$lib/components/ContextHeader.svelte';
+
// Trait components
import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
@@ -193,6 +197,35 @@
persistLayout();
}
+ /** Handle adding a new panel from the tool menu */
+ function handleAddPanel(trait: string) {
+ // Don't add duplicate panels
+ if (layout.panels.some(p => p.trait === trait)) return;
+
+ const info = getPanelInfo(trait);
+ // Place below existing panels
+ 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();
+ }
+
+ /** Active trait keys in the current layout (for tool menu) */
+ const activeLayoutTraits = $derived(layout.panels.map(p => p.trait));
+
// =========================================================================
// Mobile detection + tab state
// =========================================================================
@@ -216,36 +249,18 @@
-
-
+
+ { showTraitAdmin = !showTraitAdmin; }}
+ {showTraitAdmin}
+ onAddPanel={handleAddPanel}
+ activeTraits={activeLayoutTraits}
+ />
{#if showTraitAdmin && accessToken}
@@ -412,93 +427,6 @@
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;
@@ -643,18 +571,6 @@
/* 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;
diff --git a/tasks.md b/tasks.md
index 4e891c8..1590127 100644
--- a/tasks.md
+++ b/tasks.md
@@ -215,8 +215,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.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.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".
-- [~] 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".
- > Påbegynt: 2026-03-18T07:33
+- [x] 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.6 Personlig flate: brukerens standard arbeidsflate (node_kind: 'workspace'). Vises når ikke koblet til en annen node. Persistent layout.