Unifisert ContextHeader: innstillinger, fargevelger, slett, tema-modul

- ContextHeader brukes nå på både personlig flate og samlinger
- Ny theme.ts: ThemeConfig med hue+saturation per farge, presets
  (Standard, Hav, Skog, Solnedgang, Lavendel, Monokrom)
- Fargevelger med hue-stripe (regnbue) + saturation-slider + swatch
  per farge (bakgrunn, overflate, aksent)
- Slett arbeidsflate med bekreftelsesdialog og innholdstelling
- Kontekst-velger: grupper, rename, ny, slett — fungerer overalt
- +page.svelte forenklet: ~500 linjer CSS fjernet, bruker ContextHeader

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-19 06:27:42 +00:00
parent fa85d29c35
commit f565cfc670
3 changed files with 746 additions and 1140 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,171 @@
/**
* Shared theme logic for Synops workspace.
*
* Each workspace/collection can have its own theme stored in
* metadata.preferences.theme. Themes use HSL color model for
* background, surface, and accent colors.
*
* Three-layer inheritance:
* 1. Flate-spesifikt (lagret i nodens metadata)
* 2. Personlig default (fra "Min arbeidsflate")
* 3. Plattform-default (DEFAULT_THEME)
*/
/** Color definition: hue + saturation. Lightness is derived per use. */
export interface ThemeColor {
hue: number; // 0-360
saturation: number; // 0-100
}
/** Full theme configuration */
export interface ThemeConfig {
bg: ThemeColor;
surface: ThemeColor;
accent: ThemeColor;
}
/** Platform default — neutral dark with indigo accent */
export const DEFAULT_THEME: ThemeConfig = {
bg: { hue: 0, saturation: 0 },
surface: { hue: 0, saturation: 0 },
accent: { hue: 239, saturation: 70 },
};
/** Named theme presets */
export interface ThemePreset {
name: string;
theme: ThemeConfig;
}
export const THEME_PRESETS: ThemePreset[] = [
{
name: 'Standard',
theme: DEFAULT_THEME,
},
{
name: 'Hav',
theme: {
bg: { hue: 210, saturation: 15 },
surface: { hue: 210, saturation: 12 },
accent: { hue: 200, saturation: 75 },
},
},
{
name: 'Skog',
theme: {
bg: { hue: 150, saturation: 12 },
surface: { hue: 150, saturation: 10 },
accent: { hue: 142, saturation: 65 },
},
},
{
name: 'Solnedgang',
theme: {
bg: { hue: 15, saturation: 12 },
surface: { hue: 15, saturation: 10 },
accent: { hue: 25, saturation: 80 },
},
},
{
name: 'Lavendel',
theme: {
bg: { hue: 270, saturation: 10 },
surface: { hue: 270, saturation: 8 },
accent: { hue: 280, saturation: 65 },
},
},
{
name: 'Monokrom',
theme: {
bg: { hue: 0, saturation: 0 },
surface: { hue: 0, saturation: 0 },
accent: { hue: 0, saturation: 0 },
},
},
];
function hsl(hue: number, sat: number, light: number): string {
return `hsl(${hue}, ${sat}%, ${light}%)`;
}
function hsla(hue: number, sat: number, light: number, alpha: number): string {
return `hsla(${hue}, ${sat}%, ${light}%, ${alpha})`;
}
/** Apply theme to :root CSS custom properties */
export function applyTheme(config: ThemeConfig): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const { bg, surface, accent } = config;
// Background: very dark, slight tint
root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, 4));
// Surface: slightly lighter
root.style.setProperty('--color-surface', hsl(surface.hue, surface.saturation, 12));
root.style.setProperty('--color-surface-hover', hsl(surface.hue, Math.max(0, surface.saturation - 2), 15));
// Border: derived from surface
root.style.setProperty('--color-border', hsl(surface.hue, Math.max(0, surface.saturation - 3), 18));
root.style.setProperty('--color-border-hover', hsl(surface.hue, Math.max(0, surface.saturation - 3), 24));
// Accent
root.style.setProperty('--color-accent', hsl(accent.hue, accent.saturation, 60));
root.style.setProperty('--color-accent-hover', hsl(accent.hue, accent.saturation, 65));
root.style.setProperty('--color-accent-glow', hsla(accent.hue, accent.saturation, 60, 0.15));
}
/** Reset theme to platform defaults (remove inline styles) */
export function resetTheme(): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const props = [
'--color-bg', '--color-surface', '--color-surface-hover',
'--color-border', '--color-border-hover',
'--color-accent', '--color-accent-hover', '--color-accent-glow',
];
for (const prop of props) {
root.style.removeProperty(prop);
}
}
/**
* Load theme from node metadata. Backward compatible with old format
* ({ hueBg, hueSurface, hueAccent }) and new format ({ bg, surface, accent }).
*/
export function loadThemeFromMetadata(meta: Record<string, unknown>): ThemeConfig | null {
const prefs = meta.preferences as Record<string, unknown> | undefined;
const theme = prefs?.theme as Record<string, unknown> | undefined;
if (!theme) return null;
// New format
if (theme.bg && typeof theme.bg === 'object') {
return theme as unknown as ThemeConfig;
}
// Old format: { hueBg, hueSurface, hueAccent }
if (typeof theme.hueBg === 'number' || typeof theme.hueAccent === 'number') {
return {
bg: { hue: (theme.hueBg as number) ?? 0, saturation: (theme.hueBg as number) ? 10 : 0 },
surface: { hue: (theme.hueSurface as number) ?? 0, saturation: (theme.hueSurface as number) ? 8 : 0 },
accent: { hue: (theme.hueAccent as number) ?? 239, saturation: 70 },
};
}
return null;
}
/** Serialize theme for storage in node metadata */
export function themeToMetadata(config: ThemeConfig): Record<string, unknown> {
return {
bg: config.bg,
surface: config.surface,
accent: config.accent,
};
}
/** Get a CSS color string for a theme color at a given lightness */
export function themeColorCSS(color: ThemeColor, lightness: number): string {
return hsl(color.hue, color.saturation, lightness);
}

View file

@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/realtime'; import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/realtime';
import type { Node } from '$lib/realtime'; import type { Node } from '$lib/realtime';
import { fetchMyWorkspace, updateNode, createNode, createEdge } from '$lib/api'; import { fetchMyWorkspace, updateNode } from '$lib/api';
import { signOut } from '@auth/sveltekit/client'; import ContextHeader from '$lib/components/ContextHeader.svelte';
import {
type ThemeConfig,
DEFAULT_THEME,
applyTheme,
loadThemeFromMetadata,
themeToMetadata,
} from '$lib/workspace/theme.js';
// Canvas + BlockShell // Canvas + BlockShell
import Canvas from '$lib/components/canvas/Canvas.svelte'; import Canvas from '$lib/components/canvas/Canvas.svelte';
@ -20,9 +26,6 @@
PANEL_HEADER_HEIGHT, PANEL_HEADER_HEIGHT,
} from '$lib/workspace/types.js'; } from '$lib/workspace/types.js';
// Recency tracking for context selector
import { getRankedNodeIds } from '$lib/workspace/recency.js';
// Trait components (reuse from collection page) // Trait components (reuse from collection page)
import EditorTrait from '$lib/components/traits/EditorTrait.svelte'; import EditorTrait from '$lib/components/traits/EditorTrait.svelte';
import ChatTrait from '$lib/components/traits/ChatTrait.svelte'; import ChatTrait from '$lib/components/traits/ChatTrait.svelte';
@ -73,10 +76,9 @@
} else if (!layoutInitialized) { } else if (!layoutInitialized) {
layoutInitialized = true; layoutInitialized = true;
} }
// Load theme preferences // Load preferences
if (res.metadata) { if (res.metadata) {
loadThemeFromMetadata(res.metadata as Record<string, unknown>); loadThemeFromMeta(res.metadata as Record<string, unknown>);
// Load saved camera position
const meta = res.metadata as Record<string, unknown>; const meta = res.metadata as Record<string, unknown>;
const cam = meta.camera as Camera | undefined; const cam = meta.camera as Camera | undefined;
if (cam && typeof cam.x === 'number') { if (cam && typeof cam.x === 'number') {
@ -156,7 +158,7 @@
gridEnabled, gridEnabled,
preferences: { preferences: {
...(currentMeta.preferences ?? {}), ...(currentMeta.preferences ?? {}),
theme: { hueBg: themeHueBg, hueSurface: themeHueSurface, hueAccent: themeHueAccent }, theme: themeToMetadata(currentTheme),
}, },
}, },
}); });
@ -234,188 +236,21 @@
const activeLayoutTraits = $derived(layout.panels.map(p => p.trait)); const activeLayoutTraits = $derived(layout.panels.map(p => p.trait));
// ========================================================================= // =========================================================================
// Context selector (navigate to collections) // Theme
// ========================================================================= // =========================================================================
let selectorOpen = $state(false); let currentTheme = $state<ThemeConfig>(DEFAULT_THEME);
let searchQuery = $state('');
let searchInput = $state<HTMLInputElement | undefined>(undefined);
const collectionNodes = $derived.by((): Node[] => { function handleThemeChange(newTheme: ThemeConfig) {
if (!connected || !nodeId) return []; currentTheme = newTheme;
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));
});
const myCollections = $derived(filteredCollections.filter(n => n.createdBy === nodeId));
const sharedCollections = $derived(filteredCollections.filter(n => n.createdBy !== nodeId));
let renamingId = $state<string | undefined>(undefined);
let renameValue = $state('');
function startRename(node: Node) {
renamingId = node.id;
renameValue = node.title || '';
}
async function commitRename() {
if (!renamingId || !accessToken) return;
const id = renamingId;
renamingId = undefined;
if (renameValue.trim()) {
try {
await updateNode(accessToken, { node_id: id, title: renameValue.trim() });
} catch (e) {
console.error('Feil ved omdøping:', e);
}
}
}
function handleRenameKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
if (e.key === 'Escape') { renamingId = undefined; }
}
let isCreatingWorkspace = $state(false);
async function createNewWorkspace() {
if (!accessToken || !nodeId || isCreatingWorkspace) return;
isCreatingWorkspace = true;
selectorOpen = false;
try {
const { node_id } = await createNode(accessToken, {
node_kind: 'collection',
title: 'Ny arbeidsflate',
visibility: 'hidden',
metadata: {
traits: {
chat: {},
editor: {},
kanban: {},
calendar: {},
}
},
});
await createEdge(accessToken, {
source_id: nodeId,
target_id: node_id,
edge_type: 'owner',
});
goto(`/collection/${node_id}`);
} catch (e) {
console.error('Feil ved oppretting av arbeidsflate:', e);
} finally {
isCreatingWorkspace = false;
}
}
function toggleSelector() {
selectorOpen = !selectorOpen;
toolMenuOpen = false;
settingsOpen = false;
if (selectorOpen) {
searchQuery = '';
renamingId = undefined;
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;
settingsOpen = false;
}
function addTool(trait: string) {
handleAddPanel(trait);
toolMenuOpen = false;
}
// =========================================================================
// Settings menu (theme + sign out)
// =========================================================================
let settingsOpen = $state(false);
let themeHueBg = $state(0);
let themeHueSurface = $state(0);
let themeHueAccent = $state(240); // default indigo ≈ 240
function toggleSettings() {
settingsOpen = !settingsOpen;
selectorOpen = false;
toolMenuOpen = false;
}
function hslColor(hue: number, sat: number, light: number): string {
return `hsl(${hue}, ${sat}%, ${light}%)`;
}
function applyTheme() {
const root = document.documentElement;
root.style.setProperty('--color-bg', hslColor(themeHueBg, themeHueBg ? 10 : 0, 4));
root.style.setProperty('--color-surface', hslColor(themeHueSurface, themeHueSurface ? 8 : 0, 12));
root.style.setProperty('--color-surface-hover', hslColor(themeHueSurface, themeHueSurface ? 6 : 0, 15));
root.style.setProperty('--color-border', hslColor(themeHueSurface, themeHueSurface ? 5 : 0, 18));
root.style.setProperty('--color-accent', hslColor(themeHueAccent, 70, 60));
root.style.setProperty('--color-accent-hover', hslColor(themeHueAccent, 70, 65));
root.style.setProperty('--color-accent-glow', `hsla(${themeHueAccent}, 70%, 60%, 0.15)`);
persistMetadata(); persistMetadata();
} }
function loadThemeFromMetadata(meta: Record<string, unknown>) { function loadThemeFromMeta(meta: Record<string, unknown>) {
const prefs = meta.preferences as Record<string, unknown> | undefined; const loaded = loadThemeFromMetadata(meta);
const theme = prefs?.theme as Record<string, number> | undefined; if (loaded) {
if (theme) { currentTheme = loaded;
themeHueBg = theme.hueBg ?? 0; applyTheme(loaded);
themeHueSurface = theme.hueSurface ?? 0;
themeHueAccent = theme.hueAccent ?? 240;
applyTheme();
} }
} }
@ -443,27 +278,6 @@
// Click outside // 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;
}
if (settingsOpen && !target.closest('.settings-menu')) {
settingsOpen = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
selectorOpen = false;
toolMenuOpen = false;
settingsOpen = false;
}
}
/** Trait components that have dedicated implementations */ /** Trait components that have dedicated implementations */
const knownTraits = new Set([ const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing', 'editor', 'chat', 'kanban', 'podcast', 'publishing',
@ -524,173 +338,16 @@
} }
</script> </script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="workspace-page"> <div class="workspace-page">
<!-- Header --> <ContextHeader
<header class="context-header"> userId={nodeId}
<div class="context-header-inner"> accessToken={accessToken}
<div class="context-header-left"> {connected}
<div class="context-selector"> onAddPanel={handleAddPanel}
<button activeTraits={activeLayoutTraits}
class="context-selector-trigger" theme={currentTheme}
onclick={toggleSelector} onThemeChange={handleThemeChange}
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 arbeidsflater..."
class="context-selector-search-input"
onclick={(e) => e.stopPropagation()}
/> />
</div>
<div class="context-selector-list">
{#if myCollections.length > 0}
<div class="context-selector-group-label">Mine flater</div>
{#each myCollections as node (node.id)}
<div class="context-selector-item-row">
{#if renamingId === node.id}
<input
class="context-selector-rename-input"
bind:value={renameValue}
onblur={commitRename}
onkeydown={handleRenameKeydown}
onclick={(e) => e.stopPropagation()}
/>
{:else}
<button
class="context-selector-item"
onclick={() => selectCollection(node.id)}
>
<span class="context-selector-item-title">{node.title || 'Uten tittel'}</span>
</button>
<button
class="context-selector-rename-btn"
onclick={(e) => { e.stopPropagation(); startRename(node); }}
title="Gi nytt navn"
>✏️</button>
{/if}
</div>
{/each}
{/if}
{#if sharedCollections.length > 0}
<div class="context-selector-group-label">Delte flater</div>
{#each sharedCollections 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>
{/each}
{/if}
{#if myCollections.length === 0 && sharedCollections.length === 0}
<div class="context-selector-empty">
{searchQuery ? 'Ingen treff' : 'Ingen arbeidsflater'}
</div>
{/if}
</div>
<div class="context-selector-footer">
<button
class="context-selector-new-btn"
onclick={createNewWorkspace}
disabled={isCreatingWorkspace}
>
+ Ny arbeidsflate
</button>
</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 class="settings-menu">
<button
class="context-btn settings-trigger"
onclick={toggleSettings}
title="Innstillinger"
>
&#9881;
</button>
{#if settingsOpen}
<div class="settings-dropdown">
<div class="settings-title">Tema</div>
<label class="settings-slider">
<span class="settings-slider-label">Bakgrunn</span>
<input type="range" min="0" max="360" bind:value={themeHueBg} oninput={applyTheme} />
</label>
<label class="settings-slider">
<span class="settings-slider-label">Overflate</span>
<input type="range" min="0" max="360" bind:value={themeHueSurface} oninput={applyTheme} />
</label>
<label class="settings-slider">
<span class="settings-slider-label">Aksent</span>
<input type="range" min="0" max="360" bind:value={themeHueAccent} oninput={applyTheme} />
</label>
<div class="settings-divider"></div>
{#if $page.data.session?.user}
<div class="settings-user">{$page.data.session.user.name}</div>
{/if}
<button
class="settings-signout"
onclick={() => signOut()}
>
Logg ut
</button>
</div>
{/if}
</div>
</div>
</div>
</header>
<!-- Main content --> <!-- Main content -->
{#if workspaceLoading} {#if workspaceLoading}
@ -862,281 +519,6 @@
background: var(--color-bg, #0a0a0b); background: var(--color-bg, #0a0a0b);
} }
/* ================================================================= */
/* Context header (inline — personal workspace variant) */
/* ================================================================= */
.context-header {
border-bottom: 1px solid var(--color-border, #2a2a2e);
background: var(--color-surface, #1c1c20);
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: #5a5a66;
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: #e8e8ec;
max-width: 300px;
transition: background 0.12s, border-color 0.12s;
}
.context-selector-trigger:hover {
background: var(--color-surface-hover, #242428);
border-color: #e5e7eb;
}
.context-selector-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-selector-chevron {
font-size: 10px;
color: #5a5a66;
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: var(--color-surface, #1c1c20);
border: 1px solid var(--color-border, #2a2a2e);
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 var(--color-border, #2a2a2e);
border-radius: 6px;
font-size: 13px;
outline: none;
background: var(--color-bg, #141416);
}
.context-selector-search-input:focus {
border-color: var(--color-accent, #4f46e5);
background: var(--color-surface, #1c1c20);
}
.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: #8a8a96;
text-align: left;
transition: background 0.1s;
}
.context-selector-item:hover {
background: var(--color-surface-hover, #242428);
}
.context-selector-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-selector-empty {
padding: 16px;
text-align: center;
font-size: 12px;
color: #5a5a66;
}
/* ================================================================= */
/* 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: var(--color-surface, #1c1c20);
border: 1px solid var(--color-border, #2a2a2e);
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: #5a5a66;
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: #8a8a96;
text-align: left;
transition: background 0.1s;
}
.tool-menu-item:hover:not(:disabled) {
background: var(--color-surface-hover, #242428);
}
.tool-menu-item:disabled {
cursor: default;
opacity: 0.5;
}
.tool-menu-item-active {
color: #5a5a66;
}
.tool-menu-item-icon {
font-size: 15px;
flex-shrink: 0;
}
.tool-menu-item-label {
flex: 1;
}
.tool-menu-item-badge {
font-size: 10px;
color: #5a5a66;
background: var(--color-surface-hover, #242428);
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: var(--color-surface-hover, #242428);
color: #4b5563;
transition: background 0.12s;
}
.context-btn:hover {
background: #e5e7eb;
}
/* ================================================================= */ /* ================================================================= */
/* Messages */ /* Messages */
/* ================================================================= */ /* ================================================================= */
@ -1285,216 +667,10 @@
min-height: 100%; min-height: 100%;
} }
/* ================================================================= */
/* Context selector — groups, rename, new */
/* ================================================================= */
.context-selector-group-label {
padding: 8px 10px 4px;
font-size: 10px;
font-weight: 600;
color: #5a5a66;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.context-selector-item-row {
display: flex;
align-items: center;
gap: 2px;
}
.context-selector-item-row .context-selector-item {
flex: 1;
min-width: 0;
}
.context-selector-rename-btn {
flex-shrink: 0;
padding: 4px 6px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
opacity: 0;
transition: opacity 0.1s;
border-radius: 4px;
}
.context-selector-item-row:hover .context-selector-rename-btn {
opacity: 0.6;
}
.context-selector-rename-btn:hover {
opacity: 1 !important;
background: var(--color-surface-hover, #242428);
}
.context-selector-rename-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #6366f1 !important;
border-radius: 6px;
font-size: 13px;
outline: none;
background: #141416 !important;
color: #e8e8ec !important;
margin: 2px 4px;
}
.context-selector-footer {
border-top: 1px solid var(--color-border, #2a2a2e);
padding: 6px;
}
.context-selector-new-btn {
width: 100%;
padding: 8px 10px;
border: 1px dashed var(--color-border, #2a2a2e);
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #5a5a66;
transition: border-color 0.1s, color 0.1s;
}
.context-selector-new-btn:hover:not(:disabled) {
border-color: var(--color-accent, #6366f1);
color: #8a8a96;
}
.context-selector-new-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ================================================================= */
/* Settings menu */
/* ================================================================= */
.settings-menu {
position: relative;
}
.settings-trigger {
font-size: 14px;
}
.settings-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 220px;
background: var(--color-surface, #1c1c20);
background: var(--color-surface, #1c1c20);
border: 1px solid var(--color-border, #2a2a2e);
border: 1px solid var(--color-border, #2a2a2e);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
z-index: 50;
padding: 12px;
}
.settings-title {
font-size: 11px;
font-weight: 600;
color: #5a5a66;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.settings-slider {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
cursor: pointer;
}
.settings-slider-label {
font-size: 12px;
color: #8a8a96;
min-width: 65px;
}
.settings-slider input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #2a2a2e !important;
border: none !important;
border-radius: 2px;
outline: none;
padding: 0 !important;
}
.settings-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-accent, #6366f1);
cursor: pointer;
}
.settings-divider {
height: 1px;
background: #2a2a2e;
margin: 10px 0;
}
.settings-user {
font-size: 13px;
color: #8a8a96;
padding: 4px 0 8px;
}
.settings-signout {
width: 100%;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #8a8a96;
text-align: left;
transition: background 0.1s;
}
.settings-signout:hover {
background: var(--color-surface-hover, #242428);
color: #e8e8ec;
}
/* ================================================================= */ /* ================================================================= */
/* Responsive */ /* Responsive */
/* ================================================================= */ /* ================================================================= */
@media (max-width: 768px) { @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,
.settings-dropdown {
max-width: calc(100vw - 24px);
}
.context-header-right {
gap: 6px;
}
.workspace-empty-tools { .workspace-empty-tools {
gap: 6px; gap: 6px;
} }