fix(ui): companion popup waits for sustained calm; connection banner stops crying wolf
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m36s

Two "unpolished" moments, worst on the demo:

- CompanionIntroOverlay gated on a point-in-time check of the reveal
  cinematic flag. On a cold cache the entrance video can start buffering
  after the 5s base delay, so the flag was still false when sampled and
  the popup cut into the cinematic anyway. It now shows only after the
  scene has been continuously calm for the full grace window.

- ConnectionBanner's flat 2.5s debounce fired on every tab-return and on
  first dashboard paint: a dead WebSocket is the NORMAL state right then
  (browsers kill background-tab sockets; first paint races the initial
  connect), and reconnects routinely exceed 2.5s over real links. Those
  moments now get a 10s runway (15s window after load/resume); genuine
  mid-session drops keep the 2.5s response. On the demo the blip banner
  is suppressed entirely — it runs against a local mock, so "Connection
  lost" there is pure noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-16 17:42:57 -04:00
parent 547f674ac8
commit 1a3170f1c3
2 changed files with 52 additions and 17 deletions

View File

@ -75,7 +75,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO } from '@/composables/useDemoIntro'
import { useLoginTransitionStore } from '@/stores/loginTransition'
@ -101,6 +101,8 @@ const loginTransition = useLoginTransitionStore()
const BASE_DELAY_MS = 5000
const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null
onMounted(() => {
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
@ -111,21 +113,29 @@ onMounted(() => {
}
})
onUnmounted(() => {
if (calmTicker) clearInterval(calmTicker)
})
function maybeShow() {
if (loginTransition.introCinematicPlaying) {
// First-visit reveal is still running hold until it finishes.
const stop = watch(
() => loginTransition.introCinematicPlaying,
(playing) => {
if (!playing) {
stop()
setTimeout(() => { visible.value = true }, POST_INTRO_GRACE_MS)
}
},
)
return
}
visible.value = true
// Show only after the scene has been CONTINUOUSLY calm (no reveal
// cinematic) for the full grace window. The previous point-in-time check
// raced a slow-starting reveal on a cold cache the entrance video can
// begin buffering after the 5s base delay, so the flag was still false
// when sampled and the popup cut straight into the cinematic.
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
calmTicker = setInterval(() => {
if (loginTransition.introCinematicPlaying) {
calmSince = null
return
}
if (calmSince === null) calmSince = Date.now()
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
if (calmTicker) clearInterval(calmTicker)
calmTicker = null
visible.value = true
}
}, 250)
}
watch(visible, async (isVisible) => {

View File

@ -40,6 +40,7 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { IS_DEMO } from '@/composables/useDemoIntro'
const store = useAppStore()
@ -62,8 +63,23 @@ const hasConnIssue = computed(
)
const SHOW_DELAY_MS = 2500
// Right after the page loads or the tab returns to the foreground, a dead
// WebSocket is the NORMAL state (browsers kill sockets in background tabs;
// first paint races the initial connect). Reconnecting takes longer than
// the steady-state grace on real links radio wake-up, TLS, proxies so
// the 2.5s window made every tab-return flash "Connection lost" on a
// perfectly healthy node. Give those moments a much longer runway; keep
// the short window for genuine mid-session drops.
const RESUME_GRACE_WINDOW_MS = 15000
const RESUME_SHOW_DELAY_MS = 10000
const showConnIssue = ref(false)
let pendingTimer: ReturnType<typeof setTimeout> | null = null
let lastResumeAt = Date.now() // mount counts as a resume (initial connect)
function onVisibilityResume() {
if (!document.hidden) lastResumeAt = Date.now()
}
document.addEventListener('visibilitychange', onVisibilityResume)
function clearTimer() {
if (pendingTimer) {
@ -76,11 +92,17 @@ watch(
hasConnIssue,
(issue) => {
clearTimer()
// The demo runs against a local mock a connection banner there is
// meaningless noise on what should be a flawless showcase.
if (IS_DEMO) return
if (issue) {
const delay = Date.now() - lastResumeAt < RESUME_GRACE_WINDOW_MS
? RESUME_SHOW_DELAY_MS
: SHOW_DELAY_MS
pendingTimer = setTimeout(() => {
showConnIssue.value = true
pendingTimer = null
}, SHOW_DELAY_MS)
}, delay)
} else {
// Recovered before the grace window elapsed hide at once.
showConnIssue.value = false
@ -89,7 +111,10 @@ watch(
{ immediate: true }
)
onUnmounted(clearTimer)
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', onVisibilityResume)
})
// Debounced visual states the template renders.
const showReconnecting = computed(