Enhance UI components and improve user notifications

- Updated App.vue to include a toast notification system for new messages, enhancing user engagement.
- Modified SplashScreen.vue to streamline the intro text display with improved typing effects.
- Added Montserrat font styles in style.css for better typography across the application.
- Improved controller navigation in useControllerNav.ts to support enhanced focus management and sound feedback.
- Updated routing logic in index.ts to redirect authenticated users from the login page to the home page.
- Enhanced the Login.vue view with transition effects for a smoother user experience during login and setup processes.
This commit is contained in:
Dorian
2026-02-17 19:19:54 +00:00
parent 1073d9fd2c
commit 1b05b5b8f1
24 changed files with 2038 additions and 470 deletions
+228 -25
View File
@@ -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()
}
}
+199
View File
@@ -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)
}
}
@@ -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
}
+70
View File
@@ -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
}
}
}