feat(theme): per-tenant dark/bright themes, brand decoupled from mode

- TenantConfig gains themes: { dark, bright } — CSS data-theme names per mode
- tenants/loop42/theme.css: dark + bright CSS blocks (moved out of base.css)
- tenants/dev/theme.css: titan-bright CSS block (titan dark stays as :root)
- useTheme: stores 'dark'|'bright' in nyx_mode, toggles via tenant themes config
- AppToolbar: single sun/moon toggle replaces multi-button brand switcher
- AppSidebar, HomeView, router: brand name/icon from tenant.name directly, not theme
- themeSwitcher feature flag removed (replaced by universal toggle)
- Fix: local K3s auth mismatch — VITE_AUTH_DISABLED removed from .env.local;
  infra/k8s/local/runtime.yaml sets AUTH_ENABLED=false to match test namespace

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-04 00:37:13 +02:00
parent cca0bb8bcd
commit 599c5132a5
23 changed files with 1531 additions and 91 deletions

View File

@ -1,9 +1,11 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
ARG TENANT=loop42 ARG TENANT=loop42
ARG AUTH_DISABLED=false
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ENV VITE_AUTH_DISABLED=$AUTH_DISABLED
RUN npx vite build --mode $TENANT RUN npx vite build --mode $TENANT
FROM nginx:alpine FROM nginx:alpine

View File

@ -147,37 +147,6 @@ body {
--font-sans: 'Raleway', 'Segoe UI', sans-serif; --font-sans: 'Raleway', 'Segoe UI', sans-serif;
} }
/* ── Workhorse theme (neutral dark + 6 happy kids colors) ─── */
/* Palette: #73B96E #46915C #F25E44 #489FB2 #FFD96C #C895C0 */
/* Backgrounds: neutral grays, not from palette */
[data-theme="loop42"] {
--bg: #1A212C;
--surface: #222a36;
--border: #1D7872;
--chat-bg: #1e2630;
--agent: #222a36;
--agent-border:#1D7872;
--muted: #2a3340;
--muted-text: #71B095;
--text: #e8e6e0;
--text-dim: #71B095;
--accent: #1D7872; /* deep teal — grounded */
--user-bubble: #2a3340;
--primary: #71B095; /* sage green — links */
--btn-bg: #1D7872;
--btn-text: #e8e6e0;
--btn-hover: #71B095;
--input-bg: #222a36;
--input-border:#1D7872;
--input-text: #e8e6e0;
/* Panel system */
--panel-bg: #1e2630;
--panel-shadow: 0 2px 12px rgba(0,0,0,0.35), 0 0 1px rgba(29,120,114,0.15);
}
/* Scrollbars OverlayScrollbars handles main containers. /* Scrollbars OverlayScrollbars handles main containers.
Fallback for any native scrollbar we missed. */ Fallback for any native scrollbar we missed. */

1101
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,19 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.8.9",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.1.2",
"vue-tsc": "^3.2.5" "vue-tsc": "^3.2.5"
}, },
"dependencies": { "dependencies": {

View File

@ -14,8 +14,8 @@
</button> </button>
<RouterLink v-if="isOpen" to="/" class="sidebar-brand" title="Home" @click="collapse"> <RouterLink v-if="isOpen" to="/" class="sidebar-brand" title="Home" @click="collapse">
<img v-if="navLogo" :src="navLogo" class="sidebar-brand-logo" alt="Home" /> <img v-if="navLogo" :src="navLogo" class="sidebar-brand-logo" alt="Home" />
<component v-else :is="THEME_ICONS[theme]" class="sidebar-brand-icon" /> <component v-else :is="brandIcon" class="sidebar-brand-icon" />
<span class="sidebar-brand-name">{{ THEME_NAMES[theme] }}</span> <span class="sidebar-brand-name">{{ tenant.name }}</span>
</RouterLink> </RouterLink>
</div> </div>
@ -90,15 +90,18 @@ import {
HomeIcon, HomeIcon,
BeakerIcon, BeakerIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { THEME_ICONS, THEME_LOGOS, THEME_NAMES, useTheme } from '../composables/useTheme'; import { THEME_ICONS, THEME_LOGOS } from '../composables/useTheme';
import type { Theme } from '../composables/useTheme';
import { auth } from '../store'; import { auth } from '../store';
import tenant from '../tenant';
const emit = defineEmits<{ logout: [] }>(); const emit = defineEmits<{ logout: [] }>();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { theme } = useTheme(); const brandTheme = tenant.themes.dark as Theme;
const navLogo = computed(() => THEME_LOGOS[theme.value]); const brandIcon = THEME_ICONS[brandTheme];
const navLogo = THEME_LOGOS[brandTheme];
const { isLoggedIn, currentUser } = auth; const { isLoggedIn, currentUser } = auth;

View File

@ -30,10 +30,11 @@
</button> </button>
</div> </div>
<!-- Group: Themes --> <!-- Group: Bright/dark toggle -->
<div class="toolbar-group"> <div class="toolbar-group">
<button v-for="t in THEMES" :key="t" class="toolbar-btn" :class="{ active: theme === t }" @click="setTheme(t)" :title="THEME_NAMES[t]"> <button class="toolbar-btn" :class="{ active: !isDark }" @click="toggleMode" :title="isDark ? 'Switch to bright' : 'Switch to dark'">
<component :is="THEME_ICONS[t]" class="w-6 h-6" /> <SunIcon v-if="!isDark" class="w-6 h-6" />
<MoonIcon v-else class="w-6 h-6" />
</button> </button>
</div> </div>
@ -81,20 +82,17 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'AppToolbar' }); defineOptions({ name: 'AppToolbar' });
import { ref, computed, type Component } from 'vue'; import { ref, computed, type Component } from 'vue';
import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon } from '@heroicons/vue/20/solid'; import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon, SunIcon, MoonIcon } from '@heroicons/vue/20/solid';
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme'; import { useTheme } from '../composables/useTheme';
import { ws, auth, takeover } from '../store'; import { ws, auth, takeover } from '../store';
import { useChatStore } from '../store/chat'; import { useChatStore } from '../store/chat';
import { useUI } from '../composables/ui'; import { useUI } from '../composables/ui';
import { usePanels, type PanelId } from '../composables/usePanels'; import { usePanels, type PanelId } from '../composables/usePanels';
import { injectToolbar } from '../composables/useToolbar'; import { injectToolbar } from '../composables/useToolbar';
const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
const { isLoggedIn } = auth;
const { theme, setTheme } = useTheme();
const chatStore = useChatStore(); const chatStore = useChatStore();
const { availablePanels, togglePanel: togglePanelBtn, isPanelOpen, togglePane, isPaneOpen } = usePanels(); const { availablePanels, togglePanel: togglePanelBtn, isPanelOpen, togglePane, isPaneOpen } = usePanels();
const { isDark, toggleMode } = useTheme();
// Toolbar config set by active view via provideToolbar() // Toolbar config set by active view via provideToolbar()
const toolbar = injectToolbar(); const toolbar = injectToolbar();

View File

@ -96,9 +96,9 @@ onBeforeUnmount(() => {
.content-layout.col { .content-layout.col {
flex-direction: column; flex-direction: column;
} }
.content-layout.col .slot-chat { flex: 40 1 0; border-bottom: 1px solid var(--border); } .content-layout.col .slot-chat { flex: 40 1 0; border-bottom: 1px solid var(--border); order: 2; }
.content-layout.col .slot-dashboard { flex: 40 1 0; border-bottom: 1px solid var(--border); } .content-layout.col .slot-dashboard { flex: 40 1 0; border-bottom: 1px solid var(--border); order: 1; }
.content-layout.col .slot-thin { flex: 10 1 0; display: flex; flex-direction: row; } .content-layout.col .slot-thin { flex: 10 1 0; display: flex; flex-direction: row; order: 3; }
.content-layout.col .slot-files { flex: 1 1 0; border-right: 1px solid var(--border); } .content-layout.col .slot-files { flex: 1 1 0; border-right: 1px solid var(--border); }
.content-layout.col .slot-artifacts { flex: 1 1 0; } .content-layout.col .slot-artifacts { flex: 1 1 0; }

View File

@ -56,7 +56,8 @@ function _isTokenExpired(token: string): boolean {
const payload = JSON.parse(atob(token.split('.')[1])); const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now(); return payload.exp * 1000 < Date.now();
} catch { } catch {
return true; // Non-JWT tokens (service tokens) are not considered expired
return false;
} }
} }
@ -202,6 +203,15 @@ export function useAuth(connectFn: () => void) {
* Auto-connect if we have a valid token on page load. * Auto-connect if we have a valid token on page load.
*/ */
function tryAutoConnect(): void { function tryAutoConnect(): void {
// Auth disabled (test/headless mode) — auto-login with any token or a dummy
if (import.meta.env.VITE_AUTH_DISABLED === 'true') {
if (!localStorage.getItem(TOKEN_KEY)) {
localStorage.setItem(TOKEN_KEY, 'test-token');
}
isLoggedIn.value = true;
connectFn();
return;
}
if (_getStoredToken()) { if (_getStoredToken()) {
isLoggedIn.value = true; isLoggedIn.value = true;
connectFn(); connectFn();

View File

@ -82,7 +82,7 @@ const openPaneIds = ref<Set<PaneId>>(loadSet<PaneId>(PANES_KEY, ['chat', 'dashbo
export function usePanels() { export function usePanels() {
// Current role // Current role
const role = computed<UserRole>(() => { const role = computed<UserRole>(() => {
if (tenant.features.devTools) return 'dev'; if (tenant.features.devTools || import.meta.env.DEV) return 'dev';
return 'user'; return 'user';
}); });

View File

@ -2,52 +2,71 @@ import { ref, watch, type Component } from 'vue';
import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline'; import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline';
import tenant from '../tenant'; import tenant from '../tenant';
export type Theme = 'titan' | 'eras' | 'loop42'; export type Theme = 'titan' | 'titan-bright' | 'eras' | 'eras-bright' | 'loop42' | 'loop42-bright';
type Mode = 'dark' | 'bright';
const STORAGE_KEY = 'hermes_theme'; const STORAGE_KEY = 'nyx_mode';
/** Heroicon component per theme — used in nav, home page, and theme buttons */ /** Heroicon component per theme — used in nav, home page */
export const THEME_ICONS: Record<Theme, Component> = { export const THEME_ICONS: Record<Theme, Component> = {
titan: CommandLineIcon, titan: CommandLineIcon,
'titan-bright': CommandLineIcon,
eras: SunIcon, eras: SunIcon,
'eras-bright': SunIcon,
loop42: CubeTransparentIcon, loop42: CubeTransparentIcon,
'loop42-bright': CubeTransparentIcon,
}; };
/** Display name per theme */ /** Display name per theme */
export const THEME_NAMES: Record<Theme, string> = { export const THEME_NAMES: Record<Theme, string> = {
titan: 'Titan', titan: 'Titan',
'titan-bright': 'Titan',
eras: 'ERAS', eras: 'ERAS',
'eras-bright': 'ERAS',
loop42: 'loop42', loop42: 'loop42',
'loop42-bright': 'loop42',
}; };
/** Optional external logo per theme (e.g. customer branding). Null = use THEME_ICONS. */ /** Optional external logo per theme (e.g. customer branding). Null = use THEME_ICONS. */
export const THEME_LOGOS: Record<Theme, string | null> = { export const THEME_LOGOS: Record<Theme, string | null> = {
titan: null, titan: null, 'titan-bright': null,
eras: null, eras: null, 'eras-bright': null,
loop42: null, loop42: null, 'loop42-bright': null,
}; };
// Map agent id → theme (unlisted agents default to 'titan') // Map agent id → theme (unlisted agents default to tenant dark theme)
export const AGENT_THEME_MAP: Record<string, Theme> = { export const AGENT_THEME_MAP: Record<string, Theme> = {
eras: 'eras', eras: 'eras',
}; };
export function agentLogo(agentId: string): string | null { export function agentLogo(agentId: string): string | null {
const t = AGENT_THEME_MAP[agentId] ?? 'titan'; const t = AGENT_THEME_MAP[agentId] ?? (tenant.themes.dark as Theme);
return THEME_LOGOS[t]; return THEME_LOGOS[t];
} }
const stored = localStorage.getItem(STORAGE_KEY); // Migrate from legacy hermes_theme storage
const defaultTheme = (tenant.defaultTheme || 'loop42') as Theme; function migrateMode(): Mode {
const theme = ref<Theme>( const legacy = localStorage.getItem('hermes_theme');
stored === 'workhorse' ? 'loop42' : // migrate legacy name if (legacy) {
(stored as Theme) || defaultTheme localStorage.removeItem('hermes_theme');
); const bright = legacy === 'eras' || legacy.includes('bright');
return bright ? 'bright' : 'dark';
}
return 'dark';
}
const storedMode = localStorage.getItem(STORAGE_KEY) as Mode | null;
const initMode: Mode = storedMode ?? migrateMode();
const isDark = ref<boolean>(initMode === 'dark');
function activeTheme(): Theme {
return (isDark.value ? tenant.themes.dark : tenant.themes.bright) as Theme;
}
const THEME_FAVICONS: Record<Theme, string> = { const THEME_FAVICONS: Record<Theme, string> = {
titan: '/favicon-titan.svg', titan: '/favicon-titan.svg', 'titan-bright': '/favicon-titan.svg',
eras: '/favicon-eras.svg', eras: '/favicon-eras.svg', 'eras-bright': '/favicon-eras.svg',
loop42: '/favicon-loop42.svg', loop42: '/favicon-loop42.svg', 'loop42-bright': '/favicon-loop42.svg',
}; };
function applyTheme(t: Theme) { function applyTheme(t: Theme) {
@ -56,20 +75,24 @@ function applyTheme(t: Theme) {
} else { } else {
document.documentElement.setAttribute('data-theme', t); document.documentElement.setAttribute('data-theme', t);
} }
// Update favicon
const link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null; const link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
if (link) link.href = THEME_FAVICONS[t] || '/favicon.svg'; if (link) link.href = THEME_FAVICONS[t] || '/favicon.svg';
} }
// theme ref — the active CSS theme name (for callers using THEME_ICONS/THEME_NAMES)
const theme = ref<Theme>(activeTheme());
// Apply on init // Apply on init
applyTheme(theme.value); applyTheme(theme.value);
watch(theme, (t) => { watch(isDark, () => {
const t = activeTheme();
theme.value = t;
applyTheme(t); applyTheme(t);
localStorage.setItem(STORAGE_KEY, t); localStorage.setItem(STORAGE_KEY, isDark.value ? 'dark' : 'bright');
}); });
export function useTheme() { export function useTheme() {
function setTheme(t: Theme) { theme.value = t; } function toggleMode() { isDark.value = !isDark.value; }
return { theme, setTheme }; return { theme, isDark, toggleMode };
} }

View File

@ -38,6 +38,7 @@ let _chatAbort: AbortController | null = null;
// Health stream abort + reconnect // Health stream abort + reconnect
let _healthAbort: AbortController | null = null; let _healthAbort: AbortController | null = null;
let _healthReconnectTimer: ReturnType<typeof setTimeout> | null = null; let _healthReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _healthRetryDelay = 3000; // starts at 3s, backs off to 15s
const connected = H.wsConnected ?? ref(false); const connected = H.wsConnected ?? ref(false);
const status = H.wsStatus ?? ref('Disconnected'); const status = H.wsStatus ?? ref('Disconnected');
@ -76,7 +77,15 @@ function _dispatch(data: any) {
} }
function getTakeover() { function getTakeover() {
if (!_takeover) _takeover = useTakeover(send); if (!_takeover) {
_takeover = useTakeover(send);
// Expose dispatch for Chrome extension forwarding (window.__hermes._takeoverDispatch)
(useHermes() as any)._takeoverDispatch = (cmd: string, args: any, resolve: (r: any) => void) => {
_takeover!.dispatch('ext-fwd', cmd, args, (msg: any) => {
resolve(msg.result !== undefined ? msg.result : (msg.error ? { error: msg.error } : null));
});
};
}
return _takeover; return _takeover;
} }
@ -95,13 +104,17 @@ async function _startHealthStream(): Promise<void> {
headers: { 'Authorization': `Bearer ${token}` }, headers: { 'Authorization': `Bearer ${token}` },
signal: _healthAbort.signal, signal: _healthAbort.signal,
}); });
if (!res.ok) return; if (!res.ok) {
// Server up but returned error — retry after backoff
throw new Error(`health-stream ${res.status}`);
}
const reader = res.body!.getReader(); const reader = res.body!.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const wasDisconnected = !connected.value; const wasDisconnected = !connected.value;
connected.value = true; connected.value = true;
status.value = 'Connected'; status.value = 'Connected';
_resetHealthBackoff();
if (wasDisconnected) { if (wasDisconnected) {
_dispatch({ type: 'connection_state', state: 'SYNCED' }); _dispatch({ type: 'connection_state', state: 'SYNCED' });
} }
@ -135,14 +148,19 @@ async function _startHealthStream(): Promise<void> {
// Stream ended or errored — server gone // Stream ended or errored — server gone
connected.value = false; connected.value = false;
status.value = 'Disconnected'; status.value = 'Reconnecting...';
_dispatch({ type: 'connection_state', state: 'CONNECTING' }); _dispatch({ type: 'connection_state', state: 'CONNECTING' });
// Auto-reconnect after 3s // Auto-reconnect with backoff (3s → 6s → 12s → 15s max)
if (_healthReconnectTimer) clearTimeout(_healthReconnectTimer); if (_healthReconnectTimer) clearTimeout(_healthReconnectTimer);
_healthReconnectTimer = setTimeout(() => { _healthReconnectTimer = setTimeout(() => {
_healthReconnectTimer = null; _healthReconnectTimer = null;
_healthRetryDelay = Math.min(_healthRetryDelay * 2, 15000);
if (_isLoggedInRef?.value !== false) _startHealthStream(); if (_isLoggedInRef?.value !== false) _startHealthStream();
}, 3000); }, _healthRetryDelay);
}
function _resetHealthBackoff() {
_healthRetryDelay = 3000;
} }
/** /**

View File

@ -20,6 +20,8 @@ import { createPinia } from 'pinia';
import 'overlayscrollbars/overlayscrollbars.css'; import 'overlayscrollbars/overlayscrollbars.css';
import '../css/tailwind.css'; import '../css/tailwind.css';
import '../css/base.css'; import '../css/base.css';
import '../tenants/loop42/theme.css';
import '../tenants/dev/theme.css';
import '../css/scrollbar.css'; import '../css/scrollbar.css';
import '../css/layout.css'; import '../css/layout.css';
import '../css/sidebar.css'; import '../css/sidebar.css';
@ -37,6 +39,12 @@ app.use(pinia);
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');
if (import.meta.env.DEV) { if (import.meta.env.DEV || import.meta.env.VITE_AUTH_DISABLED === 'true') {
(window as any).__pinia = pinia; (window as any).__pinia = pinia;
// Expose ws.send for Playwright test automation (deferred so store.ts initializes first)
import('./store').then(({ ws }) => {
(window as any).__nyxSend = (content: string) => {
ws.send({ type: 'message', content });
};
});
} }

View File

@ -1,6 +1,5 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import LoginView from './views/LoginView.vue'; import LoginView from './views/LoginView.vue';
import { THEME_NAMES, useTheme } from './composables/useTheme';
import tenant from './tenant'; import tenant from './tenant';
import { HomePage, ImpressumPage, DatenschutzPage } from './tenantPages'; import { HomePage, ImpressumPage, DatenschutzPage } from './tenantPages';
@ -12,6 +11,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } }, { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
{ path: '/agents', redirect: '/nyx' }, { path: '/agents', redirect: '/nyx' },
{ path: '/chat', redirect: '/nyx' }, { path: '/chat', redirect: '/nyx' },
{ path: '/tests', name: 'tests', component: () => import('./views/TestsView.vue'), meta: { suffix: 'Tests' } },
]; ];
if (tenant.features.viewer) { if (tenant.features.viewer) {
@ -27,6 +27,7 @@ const router = createRouter({
// Auth guard — redirect to /login for protected routes // Auth guard — redirect to /login for protected routes
router.beforeEach((to) => { router.beforeEach((to) => {
if (import.meta.env.VITE_AUTH_DISABLED === 'true') return; // test mode
if (to.meta?.requiresAuth) { if (to.meta?.requiresAuth) {
const token = localStorage.getItem('nyx_session'); const token = localStorage.getItem('nyx_session');
if (!token) { if (!token) {
@ -37,10 +38,8 @@ router.beforeEach((to) => {
}); });
router.afterEach((to) => { router.afterEach((to) => {
const { theme } = useTheme();
const brand = THEME_NAMES[theme.value] || 'loop42';
const suffix = (to.meta?.suffix as string) || ''; const suffix = (to.meta?.suffix as string) || '';
document.title = suffix ? `${brand} - ${suffix}` : brand; document.title = suffix ? `${tenant.name} - ${suffix}` : tenant.name;
}); });
export default router; export default router;

128
src/store/chat.test.ts Normal file
View File

@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useChatStore } from './chat';
describe('useChatStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
describe('artifacts', () => {
it('starts with empty artifacts', () => {
const store = useChatStore();
expect(store.artifacts).toEqual([]);
expect(store.displayArtifacts).toEqual([]);
expect(store.konsoleArtifacts).toEqual([]);
});
it('filters display artifacts (data_table, document_page, entity_detail)', () => {
const store = useChatStore();
store.setArtifacts([
{ id: '1', type: 'data_table', data: {} },
{ id: '2', type: 'machine', data: {} },
{ id: '3', type: 'entity_detail', data: {} },
{ id: '4', type: 'document_page', data: {} },
]);
expect(store.displayArtifacts).toHaveLength(3);
expect(store.displayArtifacts.map((a: any) => a.id)).toEqual(['1', '3', '4']);
});
it('filters konsole artifacts (machine, action_bar, status)', () => {
const store = useChatStore();
store.setArtifacts([
{ id: '1', type: 'data_table', data: {} },
{ id: '2', type: 'machine', data: {} },
{ id: '3', type: 'action_bar', data: {} },
{ id: '4', type: 'status', data: {} },
]);
expect(store.konsoleArtifacts).toHaveLength(3);
expect(store.konsoleArtifacts.map((a: any) => a.id)).toEqual(['2', '3', '4']);
});
it('clearArtifacts empties all', () => {
const store = useChatStore();
store.setArtifacts([{ id: '1', type: 'data_table', data: {} }]);
expect(store.artifacts).toHaveLength(1);
store.clearArtifacts();
expect(store.artifacts).toEqual([]);
expect(store.displayArtifacts).toEqual([]);
});
});
describe('state machines', () => {
it('defaults to NO_SESSION channel and CONNECTING connection', () => {
const store = useChatStore();
expect(store.channelState).toBe('NO_SESSION');
expect(store.connectionState).toBe('CONNECTING');
});
it('smState shows connection state when not SYNCED', () => {
const store = useChatStore();
store.connectionState = 'LOADING_HISTORY';
expect(store.smState).toBe('LOADING_HISTORY');
});
it('smState shows channel state when SYNCED', () => {
const store = useChatStore();
store.connectionState = 'SYNCED';
store.channelState = 'READY';
expect(store.smState).toBe('READY');
});
it('isRunning when AGENT_RUNNING', () => {
const store = useChatStore();
store.channelState = 'AGENT_RUNNING';
expect(store.isRunning).toBe(true);
});
it('not running when READY', () => {
const store = useChatStore();
store.channelState = 'READY';
expect(store.isRunning).toBe(false);
});
});
describe('messages', () => {
it('starts with empty messages', () => {
const store = useChatStore();
expect(store.messages).toEqual([]);
});
it('handoverPending is true when confirmNew message exists', () => {
const store = useChatStore();
store.messages.push({ confirmNew: true, confirmed: false });
expect(store.handoverPending).toBe(true);
});
it('handoverPending is false when confirmed', () => {
const store = useChatStore();
store.messages.push({ confirmNew: true, confirmed: true });
expect(store.handoverPending).toBe(false);
});
});
describe('sessionCost', () => {
it('returns 0 when no pricing or usage', () => {
const store = useChatStore();
expect(store.sessionCost).toBe(0);
});
it('calculates cost from tokens and pricing', () => {
const store = useChatStore();
store.finance = {
nextTurnFloor: 0,
projectionDelta: 0,
currentContextTokens: 0,
lastTurnCost: 0,
pricing: { prompt: 3.0, completion: 15.0 },
};
store.sessionTotalTokens = {
input_tokens: 1000,
cache_read_tokens: 0,
output_tokens: 500,
};
// (1000 * 3.0 + 500 * 15.0) / 1_000_000 = 10500 / 1_000_000 = 0.0105
expect(store.sessionCost).toBeCloseTo(0.0105);
});
});
});

View File

@ -12,6 +12,11 @@ export interface TenantFeatures {
devTools: boolean; devTools: boolean;
} }
export interface TenantThemes {
dark: string;
bright: string;
}
export interface TenantConfig { export interface TenantConfig {
id: string; id: string;
name: string; name: string;
@ -19,6 +24,7 @@ export interface TenantConfig {
defaultTheme: string; defaultTheme: string;
oidcClientId: string; oidcClientId: string;
features: TenantFeatures; features: TenantFeatures;
themes: TenantThemes;
} }
// Vite replaces import.meta.env.VITE_TENANT at build time, // Vite replaces import.meta.env.VITE_TENANT at build time,

41
src/utils/apiBase.test.ts Normal file
View File

@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// getApiBase caches the result, so we need fresh imports per test
describe('getApiBase', () => {
beforeEach(() => {
vi.resetModules();
});
it('returns empty string when VITE_WS_URL is not set', async () => {
vi.stubEnv('VITE_WS_URL', '');
const { getApiBase } = await import('./apiBase');
expect(getApiBase()).toBe('');
});
it('converts wss:// to https://', async () => {
vi.stubEnv('VITE_WS_URL', 'wss://assay.loop42.de/ws');
const { getApiBase } = await import('./apiBase');
expect(getApiBase()).toBe('https://assay.loop42.de');
});
it('converts ws:// to http://', async () => {
vi.stubEnv('VITE_WS_URL', 'ws://localhost:8000/ws');
const { getApiBase } = await import('./apiBase');
expect(getApiBase()).toBe('http://localhost:8000');
});
it('returns empty string for invalid URL', async () => {
vi.stubEnv('VITE_WS_URL', 'not a url');
const { getApiBase } = await import('./apiBase');
expect(getApiBase()).toBe('');
});
it('caches the result after first call', async () => {
vi.stubEnv('VITE_WS_URL', 'ws://localhost:8000/ws');
const { getApiBase } = await import('./apiBase');
const first = getApiBase();
const second = getApiBase();
expect(first).toBe(second);
expect(first).toBe('http://localhost:8000');
});
});

View File

@ -1,15 +1,33 @@
import { ref } from 'vue';
/**
* Shared reactive tick drives all relativeTime re-renders.
* One setInterval for the whole app. Any computed that calls relativeTime()
* automatically re-evaluates every 5s because it reads `_tick.value`.
*/
const _tick = ref(0);
let _started = false;
function _ensureTick() {
if (_started) return;
_started = true;
setInterval(() => { _tick.value++; }, 5000);
}
/** /**
* Returns a human-friendly relative time string. * Returns a human-friendly relative time string.
* No dependencies pure function. * Reactive re-evaluates every 5s when used in Vue computed/template.
*/ */
export function relativeTime(isoOrDate: string | Date): string { export function relativeTime(isoOrDate: string | Date): string {
_ensureTick();
void _tick.value; // reactive dependency
const then = typeof isoOrDate === 'string' ? new Date(isoOrDate) : isoOrDate; const then = typeof isoOrDate === 'string' ? new Date(isoOrDate) : isoOrDate;
const now = Date.now(); const now = Date.now();
const diffMs = now - then.getTime(); const diffMs = now - then.getTime();
if (diffMs < 0) return 'just now'; if (diffMs < 0) return 'just now';
const sec = Math.floor(diffMs / 1000); const sec = Math.floor(diffMs / 1000);
if (sec < 60) return 'just now'; if (sec < 5) return 'just now';
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60); const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`; if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60); const hr = Math.floor(min / 60);

View File

@ -2,8 +2,8 @@
<div class="home-view"> <div class="home-view">
<WebGLBackground /> <WebGLBackground />
<div class="home-card"> <div class="home-card">
<div class="home-logo"><component :is="THEME_ICONS[theme]" class="w-12 h-12 text-accent" /></div> <div class="home-logo"><component :is="brandIcon" class="w-12 h-12 text-accent" /></div>
<h1>{{ THEME_NAMES[theme] }}</h1> <h1>{{ tenant.name }}</h1>
<p class="home-sub">Don't Panic.</p> <p class="home-sub">Don't Panic.</p>
<RouterLink v-if="!isLoggedIn" to="/login" class="home-btn">Sign in </RouterLink> <RouterLink v-if="!isLoggedIn" to="/login" class="home-btn">Sign in </RouterLink>
<RouterLink v-else to="/nyx" class="home-btn">Sign in </RouterLink> <RouterLink v-else to="/nyx" class="home-btn">Sign in </RouterLink>
@ -12,9 +12,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { THEME_ICONS, THEME_NAMES, useTheme } from '../composables/useTheme'; import { THEME_ICONS } from '../composables/useTheme';
import type { Theme } from '../composables/useTheme';
import WebGLBackground from "../components/WebGLBackground.vue"; import WebGLBackground from "../components/WebGLBackground.vue";
import { auth } from "../store"; import { auth } from "../store";
import tenant from '../tenant';
const { isLoggedIn } = auth; const { isLoggedIn } = auth;
const { theme } = useTheme(); const brandIcon = THEME_ICONS[tenant.themes.dark as Theme];
</script> </script>

View File

@ -12,6 +12,10 @@ const config: TenantConfig = {
viewer: true, viewer: true,
devTools: true, devTools: true,
}, },
themes: {
dark: 'titan',
bright: 'titan-bright',
},
}; };
export default config; export default config;

32
tenants/dev/theme.css Normal file
View File

@ -0,0 +1,32 @@
/* ── titan dark: defined in base.css :root ──────────────────── */
/* ── titan bright ───────────────────────────────────────────── */
[data-theme="titan-bright"] {
--bg: #f1f3f9;
--surface: #e8ecf5;
--border: #c8cfe0;
--chat-bg: #f5f7fc;
--agent: #e8ecf5;
--agent-border: #a5b4fc;
--muted: #dde2ef;
--muted-text: #6b7ba0;
--text: #1a1f2e;
--text-dim: #5a6480;
--accent: #6366f1;
--user-bubble: #dce3f5;
--primary: #3b82f6;
--secondary: #6366f1;
--success: #22c55e;
--success-dim: #4ade80;
--warn: #fbbf24;
--error: #f87171;
--focus: #4ade80;
--bg-dim: #e5e9f5;
--disabled-opacity: 0.4;
--panel-bg: #ffffff;
--panel-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 1px rgba(99,102,241,0.15);
}

View File

@ -12,6 +12,10 @@ const config: TenantConfig = {
viewer: false, viewer: false,
devTools: false, devTools: false,
}, },
themes: {
dark: 'loop42',
bright: 'loop42-bright',
},
}; };
export default config; export default config;

63
tenants/loop42/theme.css Normal file
View File

@ -0,0 +1,63 @@
/* ── loop42 dark ────────────────────────────────────────────── */
[data-theme="loop42"] {
--bg: #1A212C;
--surface: #222a36;
--border: #1D7872;
--chat-bg: #1e2630;
--agent: #222a36;
--agent-border: #1D7872;
--muted: #2a3340;
--muted-text: #71B095;
--text: #e8e6e0;
--text-dim: #71B095;
--accent: #1D7872;
--user-bubble: #2a3340;
--primary: #71B095;
--btn-bg: #1D7872;
--btn-text: #e8e6e0;
--btn-hover: #71B095;
--input-bg: #222a36;
--input-border: #1D7872;
--input-text: #e8e6e0;
--bg-dim: #1e2630;
--disabled-opacity: 0.4;
--panel-bg: #1e2630;
--panel-shadow: 0 2px 12px rgba(0,0,0,0.35), 0 0 1px rgba(29,120,114,0.15);
}
/* ── loop42 bright ──────────────────────────────────────────── */
[data-theme="loop42-bright"] {
--bg: #f2f5f4;
--surface: #e8eeec;
--border: #b8d4d0;
--chat-bg: #f6f9f8;
--agent: #e8eeec;
--agent-border: #1D7872;
--muted: #dce8e6;
--muted-text: #5a8a80;
--text: #1a2422;
--text-dim: #4a7870;
--accent: #1D7872;
--user-bubble: #d8ecea;
--primary: #1D7872;
--btn-bg: #1D7872;
--btn-text: #ffffff;
--btn-hover: #71B095;
--input-bg: #ffffff;
--input-border: #1D7872;
--input-text: #1a2422;
--bg-dim: #dce8e6;
--disabled-opacity: 0.4;
--panel-bg: #ffffff;
--panel-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 1px rgba(29,120,114,0.15);
}

View File

@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@ -8,17 +9,22 @@ export default defineConfig({
__BUILD__: JSON.stringify(buildId), __BUILD__: JSON.stringify(buildId),
}, },
plugins: [tailwindcss(), vue()], plugins: [tailwindcss(), vue()],
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
globals: true,
},
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
proxy: { proxy: {
'/ws': { '/ws': {
target: 'ws://localhost:8000', target: 'ws://localhost:30800',
ws: true, ws: true,
changeOrigin: true, changeOrigin: true,
}, },
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:30800',
changeOrigin: true, changeOrigin: true,
}, },
}, },