feat: onboarding polish, splash screen, controller nav, dev script

Onboarding flow:
- Intro: improved layout and transitions
- DID: better card styling and responsiveness
- Path: added visual enhancements
- Backup/Identity/Verify: streamlined markup
- SplashScreen component added

UI:
- Controller navigation improvements (useControllerNav)
- Style.css refinements

Backend:
- Runtime package fix

Dev:
- dev-start.sh improvements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-28 13:41:52 +00:00
parent 82eeb915a3
commit 7f03e39f58
12 changed files with 92 additions and 67 deletions

View File

@ -7,7 +7,7 @@ use anyhow::{Context, Result};
/// Per-container graceful shutdown timeout in seconds. /// Per-container graceful shutdown timeout in seconds.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state, /// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// indexers 300s for index flush, databases 120s for WAL/transaction commit. /// indexers 300s for index flush, databases 120s for WAL/transaction commit.
fn stop_timeout_secs(container_name: &str) -> &'static str { pub fn stop_timeout_secs(container_name: &str) -> &'static str {
let id = container_name.strip_prefix("archy-").unwrap_or(container_name); let id = container_name.strip_prefix("archy-").unwrap_or(container_name);
match id { match id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600", "bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",

View File

@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.ld9oh2eb91o" "revision": "0.huo00jkc7v4"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@ -277,6 +277,13 @@ if (!storedSeenIntro && isOnDashboard) {
localStorage.setItem('neode_intro_seen', '1') localStorage.setItem('neode_intro_seen', '1')
} }
function handleEnterKey(e: KeyboardEvent) {
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
e.preventDefault()
handleTapToStart()
}
}
function onIntroLogoHover() { function onIntroLogoHover() {
introLogoHover.value = true introLogoHover.value = true
if (!tapStartTransitioning.value) playKeyboardTypingSound() if (!tapStartTransitioning.value) playKeyboardTypingSound()
@ -465,10 +472,13 @@ onMounted(() => {
showSplash.value = false showSplash.value = false
document.body.classList.add('splash-complete') document.body.classList.add('splash-complete')
emit('complete') emit('complete')
} else {
window.addEventListener('keydown', handleEnterKey)
} }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('keydown', handleEnterKey)
if (introTypingTimeout) { if (introTypingTimeout) {
clearTimeout(introTypingTimeout) clearTimeout(introTypingTimeout)
introTypingTimeout = null introTypingTimeout = null

View File

@ -115,7 +115,14 @@ function findNearestInDirection(
scored.sort((a, b) => { scored.sort((a, b) => {
if (b.overlap !== a.overlap) return b.overlap - a.overlap if (b.overlap !== a.overlap) return b.overlap - a.overlap
return a.dist - b.dist if (a.dist !== b.dist) return a.dist - b.dist
// Tiebreaker for up/down: prefer leftmost element in grid layouts
if (direction === 'up' || direction === 'down') {
const aLeft = a.el.getBoundingClientRect().left
const bLeft = b.el.getBoundingClientRect().left
return aLeft - bLeft
}
return 0
}) })
return scored[0]?.el ?? null return scored[0]?.el ?? null
} }
@ -149,7 +156,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// Enter in text field: blur and move to next focusable element (e.g., submit button) // Enter in text field: find next focusable — if it's a button, click it directly (submit)
if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') { if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') {
e.preventDefault() e.preventDefault()
const root = containerRef?.value ?? document const root = containerRef?.value ?? document
@ -157,12 +164,24 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const idx = all.indexOf(target as HTMLElement) const idx = all.indexOf(target as HTMLElement)
const next = idx >= 0 ? all[idx + 1] : undefined const next = idx >= 0 ? all[idx + 1] : undefined
if (next) { if (next) {
next.focus() if (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button') {
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) next.focus()
next.click()
} else {
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
} }
return return
} }
if (e.key !== 'Escape') return // Up/Down arrows: exit field and navigate to element above/below
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
;(target as HTMLElement).blur()
// Fall through to arrow key handling below
} else if (e.key !== 'Escape') {
return
}
} }
const root = containerRef?.value ?? document const root = containerRef?.value ?? document

View File

@ -46,11 +46,12 @@
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
} }
/* Controller / keyboard navigation - soft glow only (no box outline) */ /* Controller / keyboard navigation - orange border (Archipelago brand) */
*:focus-visible { *:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1); border-color: rgba(251, 146, 60, 0.8) !important;
transition: box-shadow 0.2s ease; box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
} }
/* Mobile touch targets — ensure tappable elements meet 44px minimum */ /* Mobile touch targets — ensure tappable elements meet 44px minimum */
@ -98,12 +99,12 @@ input[type="radio"]:active + * {
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
/* Containers get subtle grow + inner glow when focused (gamepad selection) */ /* Containers get subtle grow + orange glow when focused (gamepad selection) */
[data-controller-container]:focus-visible { [data-controller-container]:focus-visible {
transform: scale(1.02); transform: scale(1.02);
box-shadow: box-shadow:
0 0 24px rgba(120, 180, 255, 0.15), 0 0 0 2px rgba(251, 146, 60, 0.7),
0 0 48px rgba(100, 160, 255, 0.08), 0 0 24px rgba(251, 146, 60, 0.2),
inset 0 0 24px rgba(255, 255, 255, 0.03); inset 0 0 24px rgba(255, 255, 255, 0.03);
} }
@ -978,8 +979,8 @@ input[type="radio"]:active + * {
.sidebar-nav-item:focus-visible { .sidebar-nav-item:focus-visible {
transform: scale(1.02) !important; transform: scale(1.02) !important;
box-shadow: box-shadow:
0 0 24px rgba(120, 180, 255, 0.15), 0 0 0 2px rgba(251, 146, 60, 0.7),
0 0 48px rgba(100, 160, 255, 0.08), 0 0 24px rgba(251, 146, 60, 0.2),
inset 0 0 24px rgba(255, 255, 255, 0.03) !important; inset 0 0 24px rgba(255, 255, 255, 0.03) !important;
} }
} }
@ -1302,7 +1303,7 @@ html:has(body.video-background-active)::before {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
.cloud-file-item:focus-visible { .cloud-file-item:focus-visible {
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1); box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25);
} }
.cloud-file-item-thumb { .cloud-file-item-thumb {
@ -1480,7 +1481,7 @@ html:has(body.video-background-active)::before {
transform: translateY(0); transform: translateY(0);
} }
.cloud-grid-card:focus-visible { .cloud-grid-card:focus-visible {
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1); box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7), 0 0 16px rgba(251, 146, 60, 0.25);
} }
.cloud-grid-card-cover { .cloud-grid-card-cover {

View File

@ -74,13 +74,7 @@
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6"> <div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<button <button
@click="proceed" @click="proceed"
:disabled="!downloaded" :disabled="!downloaded"
@ -149,8 +143,5 @@ function proceed() {
router.push('/onboarding/verify').catch(() => {}) router.push('/onboarding/verify').catch(() => {})
} }
function skipForNow() {
router.push('/onboarding/verify').catch(() => {})
}
</script> </script>

View File

@ -98,15 +98,10 @@
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0"> <div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<button <button
v-if="generatedDid" v-if="generatedDid"
ref="continueButton"
@click="proceed" @click="proceed"
class="path-action-button path-action-button--continue" class="path-action-button path-action-button--continue"
> >
@ -118,11 +113,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
const router = useRouter() const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const generatedDid = ref<string>('') const generatedDid = ref<string>('')
const nostrNpub = ref<string>('') const nostrNpub = ref<string>('')
const isGenerating = ref(false) const isGenerating = ref(false)
@ -185,6 +181,16 @@ async function fetchDid() {
} }
} }
watch(generatedDid, (did) => {
if (did) {
nextTick(() => {
setTimeout(() => {
continueButton.value?.focus({ preventScroll: true })
}, 100)
})
}
})
onMounted(() => { onMounted(() => {
const cached = localStorage.getItem('neode_did') const cached = localStorage.getItem('neode_did')
const cachedNpub = localStorage.getItem('neode_nostr_npub') const cachedNpub = localStorage.getItem('neode_nostr_npub')
@ -205,11 +211,6 @@ function proceed() {
router.push('/onboarding/identity').catch(() => {}) router.push('/onboarding/identity').catch(() => {})
} }
function skipForNow() {
stopTimers()
router.push('/onboarding/identity').catch(() => {})
}
function copyDid() { function copyDid() {
if (!generatedDid.value) return if (!generatedDid.value) return
navigator.clipboard.writeText(generatedDid.value).catch(() => {}) navigator.clipboard.writeText(generatedDid.value).catch(() => {})

View File

@ -60,13 +60,7 @@
<p v-else-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p> <p v-else-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6"> <div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
@click="skip"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<button <button
@click="createIdentity" @click="createIdentity"
:disabled="isCreating" :disabled="isCreating"
@ -127,7 +121,4 @@ async function createIdentity() {
} }
} }
function skip() {
router.push('/onboarding/backup').catch(() => {})
}
</script> </script>

View File

@ -18,6 +18,7 @@
</p> </p>
<button <button
ref="ctaButton"
@click="goToOptions" @click="goToOptions"
class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta" class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
> >
@ -65,12 +66,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue' import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
const router = useRouter() const router = useRouter()
const ctaButton = ref<HTMLButtonElement | null>(null)
onMounted(() => {
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
setTimeout(() => {
ctaButton.value?.focus({ preventScroll: true })
}, 2100)
})
function goToOptions() { function goToOptions() {
router.push('/onboarding/path').catch(() => {}) router.push('/onboarding/path').catch(() => {})

View File

@ -82,6 +82,7 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6"> <div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button <button
ref="continueButton"
@click="proceed" @click="proceed"
class="path-action-button path-action-button--continue" class="path-action-button path-action-button--continue"
> >
@ -93,9 +94,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
onMounted(() => {
setTimeout(() => {
continueButton.value?.focus({ preventScroll: true })
}, 400)
})
function proceed() { function proceed() {
router.push('/onboarding/did').catch(() => {}) router.push('/onboarding/did').catch(() => {})

View File

@ -63,13 +63,7 @@
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6"> <div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<button <button
v-if="verified" v-if="verified"
@click="proceed" @click="proceed"
@ -152,13 +146,5 @@ async function proceed() {
router.push('/onboarding/done').catch(() => {}) router.push('/onboarding/done').catch(() => {})
} }
async function skipForNow() {
try {
await completeOnboarding()
} catch {
/* localStorage fallback ensures we can proceed */
}
router.push('/onboarding/done').catch(() => {})
}
</script> </script>

View File

@ -48,8 +48,9 @@ echo " 5) Existing user (login screen — mock)"
echo " 6) Boot mode (simulated 25s startup — mock)" echo " 6) Boot mode (simulated 25s startup — mock)"
echo " 7) Testnet stack (signet Bitcoin + LND + ThunderHub via Podman)" echo " 7) Testnet stack (signet Bitcoin + LND + ThunderHub via Podman)"
echo " 8) Manual instructions" echo " 8) Manual instructions"
echo " 9) Container orchestration dev (live testing on .228)"
echo "" echo ""
read -p "Enter choice [0-8]: " choice read -p "Enter choice [0-9]: " choice
case $choice in case $choice in
0) 0)
@ -278,6 +279,13 @@ case $choice in
echo "" echo ""
echo "Access: http://localhost:8100 (password: password123)" echo "Access: http://localhost:8100 (password: password123)"
;; ;;
9)
echo ""
echo "Container Orchestration Dev (live testing on .228)"
echo "Syncs code, builds on server, runs orchestration smoke tests."
echo ""
exec "$SCRIPT_DIR/dev-container-test.sh"
;;
*) *)
echo "Invalid choice" echo "Invalid choice"
exit 1 exit 1