From f565cfc67049ad42d2b5227c8d03a11f466a85fe Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 06:27:42 +0000 Subject: [PATCH] Unifisert ContextHeader: innstillinger, fargevelger, slett, tema-modul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/lib/components/ContextHeader.svelte | 831 ++++++++++------ frontend/src/lib/workspace/theme.ts | 171 ++++ frontend/src/routes/+page.svelte | 884 +----------------- 3 files changed, 746 insertions(+), 1140 deletions(-) create mode 100644 frontend/src/lib/workspace/theme.ts diff --git a/frontend/src/lib/components/ContextHeader.svelte b/frontend/src/lib/components/ContextHeader.svelte index 4746402..dfaf69f 100644 --- a/frontend/src/lib/components/ContextHeader.svelte +++ b/frontend/src/lib/components/ContextHeader.svelte @@ -1,63 +1,78 @@ - +
- + {#if !isPersonalWorkspace} + + {/if}
@@ -189,43 +312,117 @@ bind:this={searchInput} bind:value={searchQuery} type="text" - placeholder="Søk samlinger..." + placeholder="Søk arbeidsflater..." class="context-selector-search-input" onclick={(e) => e.stopPropagation()} />
+ {#if !searchQuery.trim()} - {/if} - {#each filteredCollections as node (node.id)} - - {:else} -
- {searchQuery ? 'Ingen treff' : 'Ingen samlinger'} -
- {/each} + {/if} + + + {#if myCollections.length > 0} +
Mine flater
+ {#each myCollections as node (node.id)} + {#if deletingId === node.id} +
+

+ Slett «{node.title || 'Uten tittel'}»? + {#if deleteChildCount > 0} +
{deleteChildCount} noder tilhører denne flaten. + {/if} +

+
+ + +
+
+ {:else} +
+ {#if renamingId === node.id} + e.stopPropagation()} + /> + {:else} + + + + {/if} +
+ {/if} + {/each} + {/if} + + + {#if sharedCollections.length > 0} +
Delte flater
+ {#each sharedCollections as node (node.id)} + + {/each} + {/if} + + {#if myCollections.length === 0 && sharedCollections.length === 0 && searchQuery.trim()} +
Ingen treff
+ {/if} +
+ +
{/if}
- +
+ + {#if settingsOpen} +
+ +
Tema
+
+ {#each THEME_PRESETS as preset (preset.name)} + + {/each} +
+ + +
+
Bakgrunn
+
+ setThemeColor('bg', 'hue', +e.currentTarget.value)} /> + setThemeColor('bg', 'saturation', +e.currentTarget.value)} /> + +
+
+
+
Overflate
+
+ setThemeColor('surface', 'hue', +e.currentTarget.value)} /> + setThemeColor('surface', 'saturation', +e.currentTarget.value)} /> + +
+
+
+
Aksent
+
+ setThemeColor('accent', 'hue', +e.currentTarget.value)} /> + setThemeColor('accent', 'saturation', +e.currentTarget.value)} /> + +
+
+ +
+ {#if $page.data.session?.user} +
{$page.data.session.user.name}
+ {/if} + +
+ {/if} +
diff --git a/frontend/src/lib/workspace/theme.ts b/frontend/src/lib/workspace/theme.ts new file mode 100644 index 0000000..3689537 --- /dev/null +++ b/frontend/src/lib/workspace/theme.ts @@ -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): ThemeConfig | null { + const prefs = meta.preferences as Record | undefined; + const theme = prefs?.theme as Record | 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 { + 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); +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2f0b359..b243ced 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,10 +1,16 @@ - -
- -
-
-
-
- - - {#if selectorOpen} -
- -
- {#if myCollections.length > 0} -
Mine flater
- {#each myCollections as node (node.id)} -
- {#if renamingId === node.id} - e.stopPropagation()} - /> - {:else} - - - {/if} -
- {/each} - {/if} - {#if sharedCollections.length > 0} -
Delte flater
- {#each sharedCollections as node (node.id)} - - {/each} - {/if} - {#if myCollections.length === 0 && sharedCollections.length === 0} -
- {searchQuery ? 'Ingen treff' : 'Ingen arbeidsflater'} -
- {/if} -
- -
- {/if} -
-
- -
-
- - - {#if toolMenuOpen} -
-
Legg til panel
- {#each availableTools as tool (tool.key)} - - {/each} -
- {/if} -
- - {#if connected} - - {:else} - - {/if} - -
- - - {#if settingsOpen} -
-
Tema
- - - -
- {#if $page.data.session?.user} -
{$page.data.session.user.name}
- {/if} - -
- {/if} -
-
-
-
+ {#if workspaceLoading} @@ -862,281 +519,6 @@ 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 */ /* ================================================================= */ @@ -1285,216 +667,10 @@ 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 */ /* ================================================================= */ @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 { gap: 6px; }