diff --git a/intro-typing.mp3 b/intro-typing.mp3 new file mode 100644 index 00000000..fbddbfb2 Binary files /dev/null and b/intro-typing.mp3 differ diff --git a/loop-start.mp3 b/loop-start.mp3 new file mode 100644 index 00000000..17ad6a34 Binary files /dev/null and b/loop-start.mp3 differ diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index 395a4589..042d248d 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.8432ene9gn8" + "revision": "0.3i6g4o6lft4" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/public/assets/audio/arrows.mp3 b/neode-ui/public/assets/audio/arrows.mp3 new file mode 100644 index 00000000..d8c6ae75 Binary files /dev/null and b/neode-ui/public/assets/audio/arrows.mp3 differ diff --git a/neode-ui/public/assets/audio/cosmic-updrift.mp3 b/neode-ui/public/assets/audio/cosmic-updrift.mp3 new file mode 100644 index 00000000..e8cbb0cd Binary files /dev/null and b/neode-ui/public/assets/audio/cosmic-updrift.mp3 differ diff --git a/neode-ui/public/assets/audio/enter.mp3 b/neode-ui/public/assets/audio/enter.mp3 new file mode 100644 index 00000000..93240f75 Binary files /dev/null and b/neode-ui/public/assets/audio/enter.mp3 differ diff --git a/neode-ui/public/assets/audio/intro-typing.mp3 b/neode-ui/public/assets/audio/intro-typing.mp3 new file mode 100644 index 00000000..f34a3283 Binary files /dev/null and b/neode-ui/public/assets/audio/intro-typing.mp3 differ diff --git a/neode-ui/public/assets/audio/typing.mp3 b/neode-ui/public/assets/audio/typing.mp3 new file mode 100644 index 00000000..f34a3283 Binary files /dev/null and b/neode-ui/public/assets/audio/typing.mp3 differ diff --git a/neode-ui/public/assets/audio/winning-is-invisible.mp3 b/neode-ui/public/assets/audio/winning-is-invisible.mp3 new file mode 100644 index 00000000..bd5fdf19 Binary files /dev/null and b/neode-ui/public/assets/audio/winning-is-invisible.mp3 differ diff --git a/neode-ui/public/assets/audio/woosh.mp3 b/neode-ui/public/assets/audio/woosh.mp3 new file mode 100644 index 00000000..d17218ef Binary files /dev/null and b/neode-ui/public/assets/audio/woosh.mp3 differ diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 0b61dd01..de4a8b0f 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -20,11 +20,35 @@ + + + + +
+
+
+ + + +
+
+

New message

+

{{ toastMessage.text }}

+

Click to view

+
+
+
+
+
@@ -465,6 +511,23 @@ onMounted(() => { min-width: 0; } +/* Intro typing cursor - block style, cyan blink (matches original typing-text caret) */ +.intro-typing-caret { + display: inline-block; + width: 4px; + min-width: 4px; + height: 1.2em; + background: #00ffff; + margin-left: 2px; + vertical-align: text-bottom; + animation: intro-caret-blink 0.5s step-end infinite; +} + +@keyframes intro-caret-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + /* Ensure text wraps smoothly on mobile */ .font-mono { word-wrap: break-word; diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index 0baa6592..2c022b79 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -1,15 +1,16 @@ /** - * Controller / gamepad-style navigation for Archipelago. - * Supports Rii X8 (keyboard/d-pad) and standard gamepads. - * - Arrow keys / d-pad: navigate between focusable elements - * - Enter / A button: activate - * - Escape / B button: back - * - Game-like navigation sounds and visual feedback + * Xbox-style controller / gamepad navigation for Archipelago. + * - Left: Go to side menu only when on leftmost main content + * - Right: Go to main content (from side menu) + * - Main: spatial/grid navigation (up/down/left/right like a game) + * - Enter enters container's inner actions; actions get celebratory sound */ import { ref, onMounted, onBeforeUnmount, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' import { useControllerStore } from '@/stores/controller' import { useSpotlightStore } from '@/stores/spotlight' +import { playNavSound } from '@/composables/useNavSounds' const FOCUSABLE_SELECTOR = [ 'a[href]', @@ -19,6 +20,7 @@ const FOCUSABLE_SELECTOR = [ 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[data-controller-focus]', + '[data-controller-container]', ].join(', ') function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] { @@ -27,24 +29,94 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM ) } -function playNavSound(type: 'move' | 'select' | 'back' = 'move') { - try { - const ctx = new (window.AudioContext || (window as any).webkitAudioContext)() - const osc = ctx.createOscillator() - const gain = ctx.createGain() - osc.connect(gain) - gain.connect(ctx.destination) - gain.gain.value = 0.08 - osc.frequency.value = type === 'select' ? 880 : type === 'back' ? 220 : 440 - osc.type = 'sine' - osc.start() - osc.stop(ctx.currentTime + 0.05) - } catch { - // Audio not supported or blocked - } +function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] { + const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null + if (!container) return [] + return getFocusableElements(container) +} + +function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean { + if (!el) return false + return !!el.closest(`[data-controller-zone="${zone}"]`) +} + +function getInnerFocusables(container: HTMLElement): HTMLElement[] { + return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container')) +} + +function isInsideContainer(el: HTMLElement | null): boolean { + if (!el) return false + const container = el.closest('[data-controller-container]') + return !!container && container !== el +} + +/** Spatial navigation: find nearest focusable in direction (game-style grid) */ +function findNearestInDirection( + from: HTMLElement, + candidates: HTMLElement[], + direction: 'up' | 'down' | 'left' | 'right' +): HTMLElement | null { + const fromRect = from.getBoundingClientRect() + const fromCenterX = fromRect.left + fromRect.width / 2 + const fromCenterY = fromRect.top + fromRect.height / 2 + const threshold = 50 // px overlap allowed + + const filtered = candidates.filter((el) => { + if (el === from) return false + const r = el.getBoundingClientRect() + + switch (direction) { + case 'left': + return r.right <= fromRect.left + threshold + case 'right': + return r.left >= fromRect.right - threshold + case 'up': + return r.bottom <= fromRect.top + threshold + case 'down': + return r.top >= fromRect.bottom - threshold + default: + return false + } + }) + + if (filtered.length === 0) return null + + // Pick best: most overlap on perpendicular axis, then closest + const scored = filtered.map((el) => { + const r = el.getBoundingClientRect() + const centerX = r.left + r.width / 2 + const centerY = r.top + r.height / 2 + + let overlap: number + let dist: number + switch (direction) { + case 'left': + case 'right': + overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top)) + dist = Math.abs(centerX - fromCenterX) + break + case 'up': + case 'down': + overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left)) + dist = Math.abs(centerY - fromCenterY) + break + default: + overlap = 0 + dist = Infinity + } + return { el, overlap, dist } + }) + + scored.sort((a, b) => { + if (b.overlap !== a.overlap) return b.overlap - a.overlap + return a.dist - b.dist + }) + return scored[0]?.el ?? null } export function useControllerNav(containerRef?: { value: HTMLElement | null }) { + const route = useRoute() + const router = useRouter() const store = useControllerStore() const isControllerActive = ref(false) const gamepadCount = ref(0) @@ -69,7 +141,6 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape'] if (!navKeys.includes(e.key)) return - // Ignore when typing in inputs const target = e.target as HTMLElement if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (e.key !== 'Escape') return @@ -78,7 +149,9 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { const root = containerRef?.value ?? document const focusable = getFocusableElements(root) const currentIndex = focusable.indexOf(document.activeElement as HTMLElement) + const activeEl = document.activeElement as HTMLElement + // --- ESCAPE --- if (e.key === 'Escape') { if (useSpotlightStore().isOpen) { useSpotlightStore().close() @@ -86,21 +159,67 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { e.stopPropagation() return } + + if (isInsideContainer(activeEl)) { + const container = activeEl.closest('[data-controller-container]') as HTMLElement | null + if (container && container.tabIndex >= 0) { + playNavSound('back') + container.focus() + container.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + return + } + } + + const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path) + if (isDetailPage) { + playNavSound('back') + window.history.back() + e.preventDefault() + return + } + + const sidebarEls = getElementsInZone('sidebar') + const firstSidebar = sidebarEls[0] + if (firstSidebar && isInZone(activeEl, 'main')) { + playNavSound('back') + firstSidebar.focus() + firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + return + } + playNavSound('back') window.history.back() e.preventDefault() return } + // --- ENTER --- if (e.key === 'Enter') { if (currentIndex >= 0 && focusable[currentIndex]) { - playNavSound('select') - ;(focusable[currentIndex] as HTMLElement).click() + const el = focusable[currentIndex] as HTMLElement + + if (el.hasAttribute('data-controller-container')) { + const inner = getInnerFocusables(el) + const firstInner = inner[0] + if (firstInner) { + playNavSound('action') + firstInner.focus() + firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + return + } + } + + playNavSound('action') + el.click() } e.preventDefault() return } + // --- ARROWS --- if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { isControllerActive.value = true if (keyNavTimeout) clearTimeout(keyNavTimeout) @@ -108,9 +227,87 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { isControllerActive.value = gamepadCount.value > 0 }, 3000) + const sidebarEls = getElementsInZone('sidebar') + const mainEls = getElementsInZone('main') + const hasZones = sidebarEls.length > 0 && mainEls.length > 0 + + // Right: from sidebar → main + const firstMain = mainEls[0] + if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) { + playNavSound('move') + firstMain.focus() + firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + return + } + + // Main zone: spatial navigation (game-style grid) + if (hasZones && isInZone(activeEl, 'main')) { + const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down' + const next = findNearestInDirection(activeEl, mainEls, dir) + + if (next) { + playNavSound('move') + next.focus() + next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + return + } + + // No element in that direction: Left from leftmost → sidebar + if (e.key === 'ArrowLeft' && dir === 'left') { + const lastSidebar = sidebarEls[sidebarEls.length - 1] + if (lastSidebar) { + playNavSound('move') + lastSidebar.focus() + lastSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + return + } + } + } + + // Inside container: spatial nav among inner elements + if (isInsideContainer(activeEl)) { + const container = activeEl.closest('[data-controller-container]') as HTMLElement + if (container) { + const inner = getInnerFocusables(container) + const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down' + const next = findNearestInDirection(activeEl, inner, dir) + if (next) { + playNavSound('move') + next.focus() + next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + e.preventDefault() + return + } + } + } + + // Sidebar: linear up/down + if (isInZone(activeEl, 'sidebar')) { + const idx = sidebarEls.indexOf(activeEl) + if (idx >= 0) { + const isDown = e.key === 'ArrowDown' + const nextIdx = isDown ? Math.min(idx + 1, sidebarEls.length - 1) : Math.max(idx - 1, 0) + const next = sidebarEls[nextIdx] + if (next && next !== activeEl) { + playNavSound('move') + next.focus() + next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + if (next.tagName === 'A') { + const href = (next as HTMLAnchorElement).getAttribute?.('href') + if (href && href.startsWith('/')) router.push(href).catch(() => {}) + } + e.preventDefault() + return + } + } + } + + // Fallback: linear navigation let nextIndex = currentIndex const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight' - if (focusable.length === 0) return if (currentIndex < 0) { @@ -126,6 +323,12 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { playNavSound('move') next.focus() next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + const href = (next as HTMLAnchorElement).getAttribute?.('href') + if (href && href.startsWith('/') && next.tagName === 'A') { + router.push(href).catch(() => {}) + } + } e.preventDefault() } } diff --git a/neode-ui/src/composables/useLoginSounds.ts b/neode-ui/src/composables/useLoginSounds.ts new file mode 100644 index 00000000..016f4615 --- /dev/null +++ b/neode-ui/src/composables/useLoginSounds.ts @@ -0,0 +1,199 @@ +/** + * Login screen audio: intro loop (MP3) + transition sounds. + */ + +let audioContext: AudioContext | null = null +let introAudio: HTMLAudioElement | null = null +let introGain: GainNode | null = null + +function getContext(): AudioContext | null { + if (audioContext) return audioContext + try { + audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() + return audioContext + } catch { + return null + } +} + +function playTone( + ctx: AudioContext, + freq: number, + duration: number, + gain: number, + type: OscillatorType = 'sine', + startOffset = 0, + dest: AudioNode = ctx.destination +) { + const osc = ctx.createOscillator() + const g = ctx.createGain() + osc.connect(g) + g.connect(dest) + g.gain.setValueAtTime(0, ctx.currentTime) + g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01) + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration) + osc.frequency.value = freq + osc.type = type + osc.start(ctx.currentTime + startOffset) + osc.stop(ctx.currentTime + startOffset + duration) +} + +const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3' +const LOOP_START_URL = '/assets/audio/loop-start.mp3' + +/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in */ +export function playLoopStart() { + try { + const audio = new Audio(LOOP_START_URL) + audio.volume = 0.5 + audio.play().catch(() => {}) + } catch { + // ignore + } +} + +/** Resume audio context (call on first user interaction to unlock autoplay) */ +export function resumeAudioContext() { + const ctx = getContext() + if (ctx?.state === 'suspended') { + ctx.resume().catch(() => {}) + } +} + +/** Start intro loop - Cosmic Updrift (free royalty) */ +export function startSynthwave() { + const ctxOrNull = getContext() + if (!ctxOrNull) return + + try { + if (ctxOrNull.state === 'suspended') ctxOrNull.resume() + } catch { + return + } + + stopSynthwave() + + const audio = new Audio(INTRO_AUDIO_URL) + audio.loop = true + + const ctx = ctxOrNull + const source = ctx.createMediaElementSource(audio) + const gainNode = ctx.createGain() + gainNode.gain.value = 0.25 + source.connect(gainNode) + gainNode.connect(ctx.destination) + introGain = gainNode + introAudio = audio + + audio.play().catch(() => {}) +} + +/** Stop intro loop (call on login success) */ +export function stopSynthwave() { + if (introAudio) { + if (introGain && audioContext) { + const t = audioContext.currentTime + introGain.gain.setValueAtTime(1, t) + introGain.gain.linearRampToValueAtTime(0.001, t + 0.2) + } + setTimeout(() => { + introAudio?.pause() + introAudio = null + introGain = null + }, 220) + } +} + +/** Whoosh transition on successful login */ +export function playLoginSuccessWhoosh() { + const woosh = new Audio('/assets/audio/woosh.mp3') + woosh.volume = 0.5 + woosh.play().catch(() => {}) +} + +/** Typing sound - plays once when welcome typing starts (typing.mp3) */ +export function playTypingSound() { + const audio = new Audio('/assets/audio/typing.mp3') + audio.volume = 0.6 + audio.play().catch(() => {}) +} + +/** Intro typing - ONE sound per sentence: play when sentence starts, stop when it ends (intro-typing.mp3 from archy assets) */ +let introTypingAudio: HTMLAudioElement | null = null +const INTRO_TYPING_URL = '/assets/audio/intro-typing.mp3' + +export function playIntroTyping() { + stopIntroTyping() + introTypingAudio = new Audio(INTRO_TYPING_URL) + introTypingAudio.volume = 0.5 + introTypingAudio.loop = true + introTypingAudio.play().catch(() => {}) +} + +export function stopIntroTyping() { + if (introTypingAudio) { + introTypingAudio.pause() + introTypingAudio.currentTime = 0 + introTypingAudio = null + } +} + +/** Typing tick - for dashboard welcome typing (typing.mp3) */ +let typingTickPool: HTMLAudioElement[] = [] +const TYPING_TICK_POOL_SIZE = 5 + +function getTypingTick(): HTMLAudioElement { + if (typingTickPool.length === 0) { + for (let i = 0; i < TYPING_TICK_POOL_SIZE; i++) { + const a = new Audio('/assets/audio/typing.mp3') + a.volume = 0.4 + typingTickPool.push(a) + } + } + const a = typingTickPool.shift()! + typingTickPool.push(a) + return a +} + +export function playTypingTick() { + const a = getTypingTick() + a.currentTime = 0 + a.play().catch(() => {}) +} + +/** Gaming-style boot thud - soft impact when dashboard loads */ +export function playDashboardLoadOomph() { + const ctx = getContext() + if (!ctx) return + + try { + if (ctx.state === 'suspended') ctx.resume() + } catch { + return + } + + const t = ctx.currentTime + + // Soft layered thud - sine only, smooth attack/decay + const layers = [ + { freq: 55, dur: 0.35, gain: 0.4, attack: 0.02 }, + { freq: 82, dur: 0.28, gain: 0.25, attack: 0.03 }, + { freq: 110, dur: 0.22, gain: 0.15, attack: 0.04 }, + { freq: 165, dur: 0.18, gain: 0.1, attack: 0.05 }, + ] + + for (let i = 0; i < layers.length; i++) { + const L = layers[i]! + const osc = ctx.createOscillator() + const g = ctx.createGain() + osc.type = 'sine' + osc.frequency.value = L.freq + g.gain.setValueAtTime(0, t) + g.gain.linearRampToValueAtTime(L.gain, t + L.attack) + g.gain.exponentialRampToValueAtTime(0.001, t + L.dur) + osc.connect(g) + g.connect(ctx.destination) + osc.start(t + i * 0.01) + osc.stop(t + L.dur) + } +} diff --git a/neode-ui/src/composables/useMessageToast.ts b/neode-ui/src/composables/useMessageToast.ts index a11b843f..fb777bd7 100644 --- a/neode-ui/src/composables/useMessageToast.ts +++ b/neode-ui/src/composables/useMessageToast.ts @@ -38,6 +38,7 @@ export function useMessageToast() { show: true, text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`, } + lastMessageCount.value = msgs.length } else { lastMessageCount.value = msgs.length } diff --git a/neode-ui/src/composables/useNavSounds.ts b/neode-ui/src/composables/useNavSounds.ts new file mode 100644 index 00000000..57c1578a --- /dev/null +++ b/neode-ui/src/composables/useNavSounds.ts @@ -0,0 +1,70 @@ +/** + * Epic interface sounds for controller/keyboard navigation. + * Layered synthesis - cool, impactful, celebratory for actions. + */ + +let audioContext: AudioContext | null = null + +function getContext(): AudioContext | null { + if (audioContext) return audioContext + try { + audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() + return audioContext + } catch { + return null + } +} + +function playTone( + ctx: AudioContext, + freq: number, + duration: number, + gain: number, + type: OscillatorType = 'sine', + startOffset = 0 +) { + const osc = ctx.createOscillator() + const g = ctx.createGain() + osc.connect(g) + g.connect(ctx.destination) + g.gain.setValueAtTime(0, ctx.currentTime) + g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01) + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration) + osc.frequency.value = freq + osc.type = type + osc.start(ctx.currentTime + startOffset) + osc.stop(ctx.currentTime + startOffset + duration) +} + +export function playNavSound(type: 'move' | 'select' | 'action' | 'back' = 'move') { + if (type === 'move') { + const audio = new Audio('/assets/audio/arrows.mp3') + audio.volume = 0.5 + audio.play().catch(() => {}) + return + } + if (type === 'select' || type === 'action') { + const audio = new Audio('/assets/audio/enter.mp3') + audio.volume = 0.5 + audio.play().catch(() => {}) + return + } + + const ctx = getContext() + if (!ctx) return + + try { + if (ctx.state === 'suspended') ctx.resume() + } catch { + return + } + + switch (type) { + case 'back': { + playTone(ctx, 440, 0.06, 0.08, 'sine') + playTone(ctx, 330, 0.08, 0.05, 'sine', 0.03) + playTone(ctx, 220, 0.1, 0.04, 'triangle', 0.05) + break + } + } +} diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 914dc63e..c05e7d1e 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { nextTick } from 'vue' import { useAppStore } from '../stores/app' const router = createRouter({ @@ -55,6 +56,7 @@ const router = createRouter({ }, ], }, + { path: '/dashboard/', redirect: '/dashboard' }, { path: '/dashboard', component: () => import('../views/Dashboard.vue'), @@ -133,9 +135,9 @@ router.beforeEach(async (to, _from, next) => { // Allow all public routes (login, onboarding) without auth check if (isPublic) { - // If already authenticated and trying to access login, redirect to dashboard + // If already authenticated and trying to access login, redirect to home if (to.path === '/login' && store.isAuthenticated) { - next('/dashboard') + next({ name: 'home' }) return } next() @@ -169,5 +171,19 @@ router.beforeEach(async (to, _from, next) => { next() }) +// Focus Home nav item for gamepad when landing on dashboard home (e.g. after login) +router.afterEach((to) => { + if (to.path === '/dashboard' || to.path === '/dashboard/') { + nextTick(() => { + setTimeout(() => { + const homeLink = document.querySelector( + '[data-controller-zone="sidebar"] a[href="/dashboard"], [data-controller-zone="sidebar"] a[href="/dashboard/"]' + ) + if (homeLink) homeLink.focus() + }, 150) + }) + } +}) + export default router diff --git a/neode-ui/src/stores/loginTransition.ts b/neode-ui/src/stores/loginTransition.ts new file mode 100644 index 00000000..c85d71b4 --- /dev/null +++ b/neode-ui/src/stores/loginTransition.ts @@ -0,0 +1,32 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +/** Signals that we just logged in - Dashboard uses this for zoom + oomph */ +export const useLoginTransitionStore = defineStore('loginTransition', () => { + const justLoggedIn = ref(false) + /** Show empty welcome block until typing starts (hide static text) */ + const pendingWelcomeTyping = ref(false) + /** Trigger welcome typing on Home - set true after dashboard animation finishes */ + const startWelcomeTyping = ref(false) + + function setJustLoggedIn(value: boolean) { + justLoggedIn.value = value + } + + function setPendingWelcomeTyping(value: boolean) { + pendingWelcomeTyping.value = value + } + + function setStartWelcomeTyping(value: boolean) { + startWelcomeTyping.value = value + } + + return { + justLoggedIn, + setJustLoggedIn, + pendingWelcomeTyping, + setPendingWelcomeTyping, + startWelcomeTyping, + setStartWelcomeTyping, + } +}) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 7ab3ccd0..3481032e 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -2,10 +2,38 @@ @tailwind components; @tailwind utilities; -/* Controller / keyboard navigation - game-like focus ring */ +/* Montserrat - header font (used in neode present) */ +@font-face { + font-family: 'Montserrat'; + src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Montserrat'; + src: url('/assets/fonts/Montserrat/Montserrat-ExtraBold.ttf') format('truetype'); + font-weight: 800; + font-style: normal; +} + +/* Controller / keyboard navigation - soft glow, hover-style (not yellow button) */ *:focus-visible { - outline: 2px solid rgba(251, 191, 36, 0.8); - outline-offset: 2px; + outline: none; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.25), + 0 0 16px rgba(120, 180, 255, 0.2), + 0 0 32px rgba(100, 160, 255, 0.1); + transition: box-shadow 0.2s ease; +} + +/* Containers get a subtle inner glow when focused */ +[data-controller-container]:focus-visible { + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.3), + 0 0 24px rgba(120, 180, 255, 0.15), + 0 0 48px rgba(100, 160, 255, 0.08), + inset 0 0 24px rgba(255, 255, 255, 0.03); } /* Global glassmorphism utilities */ @@ -67,6 +95,7 @@ -webkit-backdrop-filter: blur(18px); border: 1px solid rgba(255, 255, 255, 0.18); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + border-radius: 0.75rem; } /* Toast transition */ @@ -525,6 +554,19 @@ body::after { animation: bg-glitch-scan-repeat 5s ease-out infinite; } +/* Dashboard: full viewport width, no letterboxing */ +body.dashboard-active { + overflow-x: hidden; + width: 100%; +} + +body.dashboard-active .dashboard-view .bg-perspective-container { + left: 0 !important; + right: 0 !important; + width: 100% !important; + min-width: 100% !important; +} + /* Disable glitch effect on dashboard */ .dashboard-view ~ body::before, .dashboard-view ~ body::after, diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index 906f6e48..ae3d52a4 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -1,18 +1,21 @@ + diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index 7cbb00da..1a54f2fc 100644 --- a/neode-ui/src/views/Login.vue +++ b/neode-ui/src/views/Login.vue @@ -2,7 +2,10 @@
-