synops/frontend/src/lib/components/ContextHeader.svelte
vegard dfcec6b3b0 Kontekst-velger: Hjem og Administrasjon som separate lenker
Dropdown viser begge arbeidsflater med absolutte URLer
(ws.synops.no og adm.synops.no). Navigasjon mellom subdomener
fungerer uten å miste sesjon.

Erfaringsnotat: multi-subdomain med SvelteKit — ORIGIN-fellen,
cookie-domene, CSRF, OIDC redirect URIs, sjekkliste for nye
subdomener.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 02:30:41 +00:00

1025 lines
33 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { signOut } from '@auth/sveltekit/client';
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/realtime';
import type { Node } from '$lib/realtime';
import { getRankedNodeIds, recordVisit } from '$lib/workspace/recency.js';
import { TRAIT_PANEL_INFO } from '$lib/workspace/types.js';
import { updateNode, createNode, createEdge, deleteNode, createCommunication } from '$lib/api';
import {
type ThemeConfig,
type ThemeSurface,
DEFAULT_THEME,
THEME_PRESETS,
THEME_SURFACES,
applyTheme,
presetAccentCSS,
} from '$lib/workspace/theme.js';
interface Props {
/** Collection ID (undefined = personal workspace) */
collectionId?: string;
/** Collection node (if loaded) */
collectionNode?: Node | undefined;
/** Current user's node ID */
userId: string | undefined;
/** Access token for API calls */
accessToken?: string | undefined;
/** Whether WebSocket is connected */
connected: boolean;
/** Active trait names on this collection */
traitNames?: string[];
/** Callback to toggle trait admin panel */
onToggleTraitAdmin?: () => void;
/** Whether trait admin is currently shown */
showTraitAdmin?: boolean;
/** Callback to add a panel/trait to the workspace */
onAddPanel: (trait: string) => void;
/** Traits already visible on the workspace */
activeTraits: string[];
/** Current theme config */
theme?: ThemeConfig;
/** Callback when theme changes */
onThemeChange?: (theme: ThemeConfig) => void;
/** Label for home workspace (default "Hjem") */
homeLabel?: string;
/** User's saved theme slots (up to 6) */
savedThemes?: ThemeConfig[];
/** Callback when saved themes change */
onSavedThemesChange?: (themes: ThemeConfig[]) => void;
}
let {
collectionId,
collectionNode,
userId,
accessToken,
connected,
traitNames = [],
onToggleTraitAdmin,
showTraitAdmin = false,
onAddPanel,
activeTraits,
theme = DEFAULT_THEME,
onThemeChange,
homeLabel = 'Hjem',
savedThemes = [],
onSavedThemesChange,
}: Props = $props();
const isPersonalWorkspace = $derived(!collectionId);
// =========================================================================
// Context selector
// =========================================================================
let selectorOpen = $state(false);
let searchQuery = $state('');
let searchInput = $state<HTMLInputElement | undefined>(undefined);
$effect(() => {
if (collectionId) recordVisit(collectionId);
});
const collectionNodes = $derived.by((): Node[] => {
if (!connected || !userId) return [];
const accessibleIds = new Set<string>();
for (const edge of edgeStore.bySource(userId)) {
if (!edge.system) accessibleIds.add(edge.targetId);
}
for (const id of nodeAccessStore.objectsForSubject(userId)) {
accessibleIds.add(id);
}
const collections: Node[] = [];
for (const id of accessibleIds) {
const node = nodeStore.get(id);
if (node && node.nodeKind === 'collection' && nodeVisibility(node, userId) !== '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 === userId));
const sharedCollections = $derived(filteredCollections.filter(n => n.createdBy !== userId));
// --- Rename ---
let renamingId = $state<string | undefined>(undefined);
let renameValue = $state('');
function startRename(node: Node) {
renamingId = node.id;
renameValue = node.title || '';
requestAnimationFrame(() => {
const input = document.querySelector('.context-selector-rename-input') as HTMLInputElement;
input?.focus();
input?.select();
});
}
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; }
}
// --- Create new ---
let isCreatingWorkspace = $state(false);
async function createNewWorkspace() {
if (!accessToken || !userId || 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: userId,
target_id: node_id,
edge_type: 'owner',
});
goto(`/collection/${node_id}`);
} catch (e) {
console.error('Feil ved oppretting av arbeidsflate:', e);
} finally {
isCreatingWorkspace = false;
}
}
// --- Delete ---
let deletingId = $state<string | undefined>(undefined);
let deleteChildCount = $state(0);
function startDelete(node: Node) {
deletingId = node.id;
// Count child nodes
let count = 0;
for (const edge of edgeStore.byTarget(node.id)) {
if (edge.edgeType === 'belongs_to') count++;
}
deleteChildCount = count;
}
async function confirmDelete() {
if (!deletingId || !accessToken) return;
const id = deletingId;
deletingId = undefined;
selectorOpen = false;
try {
await deleteNode(accessToken, id);
if (collectionId === id) goto('/');
} catch (e) {
console.error('Feil ved sletting:', e);
}
}
function toggleSelector() {
selectorOpen = !selectorOpen;
toolMenuOpen = false;
settingsOpen = false;
if (selectorOpen) {
searchQuery = '';
renamingId = undefined;
deletingId = undefined;
requestAnimationFrame(() => searchInput?.focus());
}
}
function selectCollection(id: string) {
selectorOpen = false;
if (id !== collectionId) 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: activeTraits.includes(key),
}));
});
function toggleToolMenu() {
toolMenuOpen = !toolMenuOpen;
selectorOpen = false;
settingsOpen = false;
}
function addTool(trait: string) {
onAddPanel(trait);
toolMenuOpen = false;
}
// --- Quick-create actions ---
let isCreatingItem = $state(false);
async function createNewChat() {
if (!accessToken || !userId || isCreatingItem) return;
isCreatingItem = true;
toolMenuOpen = false;
try {
const { node_id } = await createCommunication(accessToken, {
title: 'Ny samtale',
participants: [userId],
visibility: 'hidden',
});
goto(`/chat/${node_id}`);
} catch (e) {
console.error('Feil ved oppretting av samtale:', e);
} finally {
isCreatingItem = false;
}
}
async function createNewBoard() {
if (!accessToken || !userId || isCreatingItem) return;
isCreatingItem = true;
toolMenuOpen = false;
try {
const { node_id } = await createNode(accessToken, {
node_kind: 'collection',
title: 'Nytt brett',
visibility: 'hidden',
metadata: { traits: { kanban: {} } },
});
await createEdge(accessToken, {
source_id: userId,
target_id: node_id,
edge_type: 'owner',
});
goto(`/collection/${node_id}`);
} catch (e) {
console.error('Feil ved oppretting av brett:', e);
} finally {
isCreatingItem = false;
}
}
function createNewCollection() {
toolMenuOpen = false;
goto('/collection/new');
}
// =========================================================================
// Settings menu (theme + sign out)
// =========================================================================
let settingsOpen = $state(false);
let editingSurface = $state<ThemeSurface>('accent');
function toggleSettings() {
settingsOpen = !settingsOpen;
selectorOpen = false;
toolMenuOpen = false;
}
function updateSurface(prop: 'hue' | 'saturation' | 'lightness', value: number) {
const newTheme = {
...theme,
[editingSurface]: { ...theme[editingSurface], [prop]: value },
};
applyTheme(newTheme);
onThemeChange?.(newTheme);
}
function applyPreset(preset: ThemeConfig) {
applyTheme(preset);
onThemeChange?.(preset);
}
const MAX_SAVED = 6;
function saveCurrentTheme() {
if (savedThemes.length >= MAX_SAVED) return;
const newSaved = [...savedThemes, { ...theme }];
onSavedThemesChange?.(newSaved);
}
function deleteSavedTheme(index: number) {
const newSaved = savedThemes.filter((_, i) => i !== index);
onSavedThemesChange?.(newSaved);
}
// =========================================================================
// 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;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<header class="context-header">
<div class="context-header-inner">
<!-- Left: Back + Context selector -->
<div class="context-header-left">
{#if !isPersonalWorkspace}
<a href="/" class="context-back" title="Tilbake til arbeidsflaten">&larr;</a>
{/if}
<div class="context-selector">
<button
class="context-selector-trigger"
onclick={toggleSelector}
title="Bytt kontekst"
>
<span class="context-selector-title">
{isPersonalWorkspace ? homeLabel : (collectionNode?.title || '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">
<!-- Faste arbeidsflater (alltid øverst når ikke søker) -->
{#if !searchQuery.trim()}
<a
class="context-selector-item"
class:current={isPersonalWorkspace && homeLabel === 'Hjem'}
href="https://ws.synops.no"
onclick={() => { selectorOpen = false; }}
>
<span class="context-selector-item-title">Hjem</span>
{#if isPersonalWorkspace && homeLabel === 'Hjem'}
<span class="context-selector-item-check">&#10003;</span>
{/if}
</a>
<a
class="context-selector-item"
class:current={isPersonalWorkspace && homeLabel === 'Administrasjon'}
href="https://adm.synops.no"
onclick={() => { selectorOpen = false; }}
>
<span class="context-selector-item-title">Administrasjon</span>
{#if isPersonalWorkspace && homeLabel === 'Administrasjon'}
<span class="context-selector-item-check">&#10003;</span>
{/if}
</a>
{/if}
<!-- Mine flater -->
{#if myCollections.length > 0}
<div class="context-selector-group-label">Mine flater</div>
{#each myCollections as node (node.id)}
{#if deletingId === node.id}
<div class="context-selector-delete-confirm">
<p class="delete-confirm-text">
Slett «{node.title || 'Uten tittel'}»?
{#if deleteChildCount > 0}
<br/><span class="delete-confirm-count">{deleteChildCount} noder tilhører denne flaten.</span>
{/if}
</p>
<div class="delete-confirm-actions">
<button class="delete-confirm-btn delete-confirm-cancel" onclick={(e) => { e.stopPropagation(); deletingId = undefined; }}>Avbryt</button>
<button class="delete-confirm-btn delete-confirm-yes" onclick={(e) => { e.stopPropagation(); confirmDelete(); }}>Slett</button>
</div>
</div>
{:else}
<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"
class:current={node.id === collectionId}
onclick={() => selectCollection(node.id)}
>
<span class="context-selector-item-title">{node.title || 'Uten tittel'}</span>
{#if node.id === collectionId}
<span class="context-selector-item-check">&#10003;</span>
{/if}
</button>
<button
class="context-selector-action-btn"
onclick={(e) => { e.stopPropagation(); startRename(node); }}
title="Gi nytt navn"
>✏️</button>
<button
class="context-selector-action-btn context-selector-delete-btn"
onclick={(e) => { e.stopPropagation(); startDelete(node); }}
title="Slett"
>🗑️</button>
{/if}
</div>
{/if}
{/each}
{/if}
<!-- Delte flater -->
{#if sharedCollections.length > 0}
<div class="context-selector-group-label">Delte flater</div>
{#each sharedCollections as node (node.id)}
<button
class="context-selector-item"
class:current={node.id === collectionId}
onclick={() => selectCollection(node.id)}
>
<span class="context-selector-item-title">{node.title || 'Uten tittel'}</span>
{#if node.id === collectionId}
<span class="context-selector-item-check">&#10003;</span>
{/if}
</button>
{/each}
{/if}
{#if myCollections.length === 0 && sharedCollections.length === 0 && searchQuery.trim()}
<div class="context-selector-empty">Ingen treff</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>
<!-- Right: Tool menu + status + settings -->
<div class="context-header-right">
{#if traitNames.length > 0 && onToggleTraitAdmin}
<button
onclick={onToggleTraitAdmin}
class="context-btn {showTraitAdmin ? 'context-btn-active' : ''}"
title="Administrer egenskaper"
>
Egenskaper
</button>
{/if}
<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">Opprett</div>
<button
class="tool-menu-item"
onclick={createNewChat}
disabled={isCreatingItem}
>
<span class="tool-menu-item-icon">💬</span>
<span class="tool-menu-item-label">Ny samtale</span>
</button>
<button
class="tool-menu-item"
onclick={createNewBoard}
disabled={isCreatingItem}
>
<span class="tool-menu-item-icon">📋</span>
<span class="tool-menu-item-label">Nytt brett</span>
</button>
<button
class="tool-menu-item"
onclick={createNewCollection}
>
<span class="tool-menu-item-icon">📁</span>
<span class="tool-menu-item-label">Ny samling</span>
</button>
<div class="tool-menu-divider"></div>
<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">
<!-- Presets -->
<div class="settings-title">Tema</div>
<div class="settings-presets">
{#each THEME_PRESETS as p (p.name)}
<button
class="settings-preset"
title={p.name}
onclick={() => applyPreset(p.theme)}
>
<span class="settings-preset-swatch"
style:background={presetAccentCSS(p.theme)}
>{p.emoji}</span>
</button>
{/each}
</div>
<div class="settings-presets settings-user-themes">
{#each savedThemes as saved, i (i)}
<button
class="settings-preset settings-saved-preset"
title="Lagret tema {i + 1} (høyreklikk for å slette)"
onclick={() => applyPreset(saved)}
oncontextmenu={(e) => { e.preventDefault(); deleteSavedTheme(i); }}
>
<span class="settings-preset-swatch"
style:background={presetAccentCSS(saved)}
>{i + 1}</span>
</button>
{/each}
{#if savedThemes.length < MAX_SAVED}
<button
class="settings-preset settings-save-btn"
title="Lagre gjeldende tema"
onclick={saveCurrentTheme}
>
<span class="settings-preset-swatch settings-save-swatch">+</span>
</button>
{/if}
</div>
<!-- Surface selector + sliders -->
<div class="settings-color-group">
<select
class="settings-surface-select"
value={editingSurface}
onchange={(e) => { editingSurface = e.currentTarget.value as ThemeSurface; }}
>
{#each Object.entries(THEME_SURFACES) as [key, label] (key)}
<option value={key}>{label}</option>
{/each}
</select>
</div>
<div class="settings-color-group">
<div class="settings-color-label">Farge</div>
<input type="range" class="settings-hue-slider" min="0" max="360"
value={theme[editingSurface].hue}
oninput={(e) => updateSurface('hue', +e.currentTarget.value)} />
</div>
<div class="settings-color-group">
<div class="settings-color-label">Metning</div>
<input type="range" class="settings-sat-slider" min="0" max="100"
value={theme[editingSurface].saturation}
oninput={(e) => updateSurface('saturation', +e.currentTarget.value)} />
</div>
<div class="settings-color-group">
<div class="settings-color-label">Lyshet</div>
<input type="range" class="settings-light-slider" min="0" max="100"
value={theme[editingSurface].lightness}
oninput={(e) => updateSurface('lightness', +e.currentTarget.value)} />
</div>
<div class="settings-color-preview">
<span class="settings-preview-swatch"
style:background="hsl({theme[editingSurface].hue}, {theme[editingSurface].saturation}%, {theme[editingSurface].lightness}%)"
></span>
<span class="settings-preview-label">{THEME_SURFACES[editingSurface]}</span>
</div>
<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>
<style>
.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;
}
/* Back button */
.context-back {
font-size: 16px;
color: var(--color-text-dim, #5a5a66);
text-decoration: none;
flex-shrink: 0;
padding: 4px;
line-height: 1;
}
.context-back:hover { color: var(--color-text-muted, #8a8a96); }
/* 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: var(--color-text, #e8e8ec);
max-width: 300px;
transition: background 0.12s, border-color 0.12s;
}
.context-selector-trigger:hover {
background: var(--color-surface-hover, #242428);
border-color: var(--color-border-hover, #3a3a3e);
}
.context-selector-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.context-selector-chevron { font-size: 10px; color: var(--color-text-dim, #5a5a66); transition: transform 0.15s; flex-shrink: 0; }
.context-selector-chevron.open { transform: rotate(180deg); }
/* Dropdown */
.context-selector-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 280px;
max-width: 380px;
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.25);
z-index: 50;
overflow: hidden;
}
.context-selector-search { padding: 8px; border-bottom: 1px solid var(--color-border, #2a2a2e); }
.context-selector-search-input {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--color-border, #2a2a2e) !important;
border-radius: 6px;
font-size: 13px;
outline: none;
background: var(--color-bg, #141416) !important;
color: var(--color-text, #e8e8ec) !important;
}
.context-selector-search-input:focus { border-color: var(--color-accent, #6366f1) !important; }
.context-selector-list { max-height: 320px; 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: var(--color-text-muted, #8a8a96);
text-align: left;
transition: background 0.1s;
}
.context-selector-item:hover { background: var(--color-surface-hover, #242428); }
.context-selector-item.current { background: rgba(99, 102, 241, 0.1); color: var(--color-accent, #6366f1); font-weight: 500; }
.context-selector-item-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.context-selector-item-check { color: var(--color-accent, #6366f1); font-size: 12px; flex-shrink: 0; margin-left: 8px; }
.context-selector-empty { padding: 16px; text-align: center; font-size: 12px; color: var(--color-text-dim, #5a5a66); }
/* Groups */
.context-selector-group-label {
padding: 8px 10px 4px;
font-size: 10px;
font-weight: 600;
color: var(--color-text-dim, #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; }
/* Action buttons (rename, delete) */
.context-selector-action-btn {
flex-shrink: 0;
padding: 4px 4px;
border: none;
background: transparent;
cursor: pointer;
font-size: 11px;
opacity: 0;
transition: opacity 0.1s;
border-radius: 4px;
}
.context-selector-item-row:hover .context-selector-action-btn { opacity: 0.5; }
.context-selector-action-btn:hover { opacity: 1 !important; background: var(--color-surface-hover, #242428); }
.context-selector-delete-btn:hover { background: rgba(239, 68, 68, 0.15); }
/* Rename input */
.context-selector-rename-input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--color-accent, #6366f1) !important;
border-radius: 6px;
font-size: 13px;
outline: none;
background: var(--color-bg, #141416) !important;
color: var(--color-text, #e8e8ec) !important;
margin: 2px 4px;
}
/* Delete confirmation */
.context-selector-delete-confirm {
padding: 8px 10px;
border-radius: 6px;
background: rgba(239, 68, 68, 0.08);
margin: 2px 0;
}
.delete-confirm-text { font-size: 12px; color: var(--color-text-muted, #8a8a96); line-height: 1.4; }
.delete-confirm-count { color: var(--color-warning, #f59e0b); }
.delete-confirm-actions { display: flex; gap: 6px; margin-top: 6px; }
.delete-confirm-btn {
padding: 4px 10px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.delete-confirm-cancel { background: var(--color-surface-hover, #242428); color: var(--color-text-muted, #8a8a96); }
.delete-confirm-cancel:hover { background: var(--color-border, #2a2a2e); }
.delete-confirm-yes { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.delete-confirm-yes:hover { background: rgba(239, 68, 68, 0.35); }
/* Footer */
.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: var(--color-text-dim, #5a5a66);
transition: border-color 0.1s, color 0.1s;
}
.context-selector-new-btn:hover:not(:disabled) { border-color: var(--color-accent, #6366f1); color: var(--color-text-muted, #8a8a96); }
.context-selector-new-btn:disabled { opacity: 0.5; cursor: default; }
/* 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.25);
z-index: 50;
overflow: hidden;
padding: 4px;
}
.tool-menu-title { padding: 6px 10px 4px; font-size: 11px; font-weight: 600; color: var(--color-text-dim, #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: var(--color-text-muted, #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: var(--color-text-dim, #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: var(--color-text-dim, #5a5a66); background: var(--color-surface-hover, #242428); padding: 1px 6px; border-radius: 4px; }
.tool-menu-divider { height: 1px; background: var(--color-border, #2a2a2e); margin: 4px 0; }
/* Status */
.context-status { font-size: 8px; color: #d1d5db; }
.context-status-ok { color: #16a34a; }
/* Shared button */
.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: var(--color-text-muted, #8a8a96); transition: background 0.12s;
}
.context-btn:hover { background: var(--color-border, #3a3a3e); color: var(--color-text, #e8e8ec); }
.context-btn-active { background: var(--color-accent, #6366f1); color: white; }
.context-btn-active:hover { background: var(--color-accent-hover, #7577f5); }
/* Settings menu */
.settings-menu { position: relative; }
.settings-trigger { font-size: 14px; }
.settings-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 260px;
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.25);
z-index: 50;
padding: 12px;
}
.settings-title { font-size: 11px; font-weight: 600; color: var(--color-text-dim, #5a5a66); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
/* Presets */
.settings-presets { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.settings-preset {
padding: 2px; border: 2px solid transparent; border-radius: 8px; background: transparent;
cursor: pointer; transition: border-color 0.15s;
}
.settings-preset:hover { border-color: var(--color-accent, #6366f1); }
.settings-preset-swatch { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.settings-user-themes { margin-top: 4px; }
.settings-saved-preset .settings-preset-swatch { font-size: 11px; font-weight: 600; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
.settings-save-swatch {
background: transparent !important;
border: 2px dashed var(--color-border, #2a2a2e);
color: var(--color-text-dim, #5a5a66);
font-size: 16px;
}
.settings-save-btn:hover .settings-save-swatch {
border-color: var(--color-accent, #6366f1);
color: var(--color-text-muted, #8a8a96);
}
/* Per-color controls */
.settings-color-group { margin-bottom: 8px; }
.settings-color-label { font-size: 11px; color: var(--color-text-dim, #5a5a66); margin-bottom: 3px; }
.settings-surface-select {
width: 100%; padding: 5px 8px; border-radius: 5px; font-size: 12px;
background: var(--color-bg, #0a0a0b) !important; color: var(--color-text, #e8e8ec) !important;
border: 1px solid var(--color-border, #2a2a2e) !important; cursor: pointer;
}
.settings-color-preview { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.settings-preview-swatch { width: 24px; height: 24px; border-radius: 4px; border: 1px solid var(--color-border, #2a2a2e); flex-shrink: 0; }
.settings-preview-label { font-size: 11px; color: var(--color-text-muted, #8a8a96); }
.settings-hue-slider, .settings-sat-slider, .settings-light-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
border: none !important;
border-radius: 3px;
outline: none;
padding: 0 !important;
cursor: pointer;
}
.settings-hue-slider {
background: linear-gradient(to right,
hsl(0,80%,50%), hsl(60,80%,50%), hsl(120,80%,50%),
hsl(180,80%,50%), hsl(240,80%,50%), hsl(300,80%,50%), hsl(360,80%,50%)
) !important;
}
.settings-sat-slider {
background: linear-gradient(to right,
hsl(0,0%,40%), hsl(0,100%,50%)
) !important;
}
.settings-light-slider {
background: linear-gradient(to right,
hsl(0,0%,0%), hsl(0,0%,50%), hsl(0,0%,100%)
) !important;
}
.settings-hue-slider::-webkit-slider-thumb,
.settings-sat-slider::-webkit-slider-thumb,
.settings-light-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: white; border: 2px solid rgba(0,0,0,0.3); cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.settings-divider { height: 1px; background: var(--color-border, #2a2a2e); margin: 10px 0; }
.settings-user { font-size: 13px; color: var(--color-text-muted, #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: var(--color-text-muted, #8a8a96); text-align: left; transition: background 0.1s;
}
.settings-signout:hover { background: var(--color-surface-hover, #242428); color: var(--color-text, #e8e8ec); }
/* 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: 240px; max-width: calc(100vw - 24px); }
.tool-menu-dropdown, .settings-dropdown { max-width: calc(100vw - 24px); }
.context-header-right { gap: 6px; }
}
</style>