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.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// 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);
match id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",

View File

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

View File

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

View File

@ -115,7 +115,14 @@ function findNearestInDirection(
scored.sort((a, b) => {
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
}
@ -149,7 +156,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const target = e.target as HTMLElement
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') {
e.preventDefault()
const root = containerRef?.value ?? document
@ -157,12 +164,24 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const idx = all.indexOf(target as HTMLElement)
const next = idx >= 0 ? all[idx + 1] : undefined
if (next) {
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
if (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button') {
next.focus()
next.click()
} else {
next.focus()
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}
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

View File

@ -46,11 +46,12 @@
backdrop-filter: blur(12px);
}
/* Controller / keyboard navigation - soft glow only (no box outline) */
/* Controller / keyboard navigation - orange border (Archipelago brand) */
*:focus-visible {
outline: none;
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1);
transition: box-shadow 0.2s ease;
border-color: rgba(251, 146, 60, 0.8) !important;
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 */
@ -98,12 +99,12 @@ input[type="radio"]:active + * {
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 {
transform: scale(1.02);
box-shadow:
0 0 24px rgba(120, 180, 255, 0.15),
0 0 48px rgba(100, 160, 255, 0.08),
0 0 0 2px rgba(251, 146, 60, 0.7),
0 0 24px rgba(251, 146, 60, 0.2),
inset 0 0 24px rgba(255, 255, 255, 0.03);
}
@ -978,8 +979,8 @@ input[type="radio"]:active + * {
.sidebar-nav-item:focus-visible {
transform: scale(1.02) !important;
box-shadow:
0 0 24px rgba(120, 180, 255, 0.15),
0 0 48px rgba(100, 160, 255, 0.08),
0 0 0 2px rgba(251, 146, 60, 0.7),
0 0 24px rgba(251, 146, 60, 0.2),
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);
}
.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 {
@ -1480,7 +1481,7 @@ html:has(body.video-background-active)::before {
transform: translateY(0);
}
.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 {

View File

@ -74,13 +74,7 @@
</div>
<!-- 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">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<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="proceed"
:disabled="!downloaded"
@ -149,8 +143,5 @@ function proceed() {
router.push('/onboarding/verify').catch(() => {})
}
function skipForNow() {
router.push('/onboarding/verify').catch(() => {})
}
</script>

View File

@ -98,15 +98,10 @@
</div>
<!-- Action Buttons -->
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0">
<button
@click="skipForNow"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<div class="flex justify-center max-w-[600px] mx-auto flex-shrink-0">
<button
v-if="generatedDid"
ref="continueButton"
@click="proceed"
class="path-action-button path-action-button--continue"
>
@ -118,11 +113,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
const generatedDid = ref<string>('')
const nostrNpub = ref<string>('')
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(() => {
const cached = localStorage.getItem('neode_did')
const cachedNpub = localStorage.getItem('neode_nostr_npub')
@ -205,11 +211,6 @@ function proceed() {
router.push('/onboarding/identity').catch(() => {})
}
function skipForNow() {
stopTimers()
router.push('/onboarding/identity').catch(() => {})
}
function copyDid() {
if (!generatedDid.value) return
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>
<!-- 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">
<button
@click="skip"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<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="createIdentity"
:disabled="isCreating"
@ -127,7 +121,4 @@ async function createIdentity() {
}
}
function skip() {
router.push('/onboarding/backup').catch(() => {})
}
</script>

View File

@ -18,6 +18,7 @@
</p>
<button
ref="ctaButton"
@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"
>
@ -65,12 +66,20 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { rpcClient } from '@/api/rpc-client'
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() {
router.push('/onboarding/path').catch(() => {})

View File

@ -82,6 +82,7 @@
<!-- 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">
<button
ref="continueButton"
@click="proceed"
class="path-action-button path-action-button--continue"
>
@ -93,9 +94,17 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const continueButton = ref<HTMLButtonElement | null>(null)
onMounted(() => {
setTimeout(() => {
continueButton.value?.focus({ preventScroll: true })
}, 400)
})
function proceed() {
router.push('/onboarding/did').catch(() => {})

View File

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

View File

@ -48,8 +48,9 @@ echo " 5) Existing user (login screen — mock)"
echo " 6) Boot mode (simulated 25s startup — mock)"
echo " 7) Testnet stack (signet Bitcoin + LND + ThunderHub via Podman)"
echo " 8) Manual instructions"
echo " 9) Container orchestration dev (live testing on .228)"
echo ""
read -p "Enter choice [0-8]: " choice
read -p "Enter choice [0-9]: " choice
case $choice in
0)
@ -278,6 +279,13 @@ case $choice in
echo ""
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"
exit 1