Compare commits

...
7 Commits
Author SHA1 Message Date
archipelagoandClaude Fable 5 ced95a60d1 feat(wallet): Lightning gets the arrival screen; copy buttons unified
Demo images / Build & push demo images (push) Successful in 3m27s
- lnd.createinvoice now returns r_hash_hex; new lnd.invoicestatus RPC
  looks the invoice up (SETTLED + amt_paid_sat). E2E-verified on this
  box: real invoice minted, status polls settled:false until paid.
- Receive modal: Lightning polls settlement every 3s and flips to the
  on-chain-style success view — straight to the green check + amount
  (no broadcast step; settlement is final). Raw bolt11 text removed:
  QR + CopyButton only. State fully reset per open/close.
- CopyButton is now the wallet's only copy affordance: the ark-address
  and ecash-token holdouts swapped in, their ad-hoc handlers deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:43:30 -04:00
archipelagoandClaude Fable 5 a0bd9e53f8 feat(settings): CA generation from the UI; Routstr panel beside the API key
Demo images / Build & push demo images (push) Successful in 3m36s
WebUI RULE (operator, 2026-08-14): never point users at a terminal. The
certificate section told users to run setup-node-ca.sh by hand — it now
has a Generate button backed by system.node-ca.generate, which runs the
idempotent script server-side (live-tested: generated and /ca.crt serves).
Routstr budget panel moves directly under the Claude API key card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:19:05 -04:00
archipelagoandClaude Fable 5 6137762786 feat(settings): Routstr AI budget panel — the integration's missing switch
Demo images / Build & push demo images (push) Successful in 3m29s
The Routstr backend (Cashu-paid inference fallback, shipped 1.7.127) was
fully wired but permanently dormant: its D-05 gate requires an
operator-set sats allowance and nothing in the UI ever called
assistant.budget-get/set — default 0 meant never selected. New Settings
panel (below AI Data Access): allowance/spent/remaining, set-allowance
with 0-disables semantics, enabled/off badge. Backend untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:57:53 -04:00
archipelagoandClaude Fable 5 8d1fda29fa feat(federation): peer requests take the stage — clustered and faced
Demo images / Build & push demo images (push) Successful in 3m10s
Requests were spread evenly around the orbit, so they could sit BEHIND
the globe: the blinking call-to-action was invisible and the chart read
as mis-scaled until the user hand-rotated. Now requests cluster tightly
at one stage angle (spacing shrinks as count grows) and, when a NEW
request arrives, the camera steers to face the cluster front-and-center
(depth ∝ sin(angle−rotY); front = angle+π/2) — arrival only, so a user
who rotates away isn't fought. Static/reduced-motion paths snap+render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 07:36:38 -04:00
archipelago 135fb5650b chore: release v1.8.3-alpha
Demo images / Build & push demo images (push) Successful in 3m43s
2026-08-14 06:34:47 -04:00
archipelagoandClaude Fable 5 1de4a0943e docs(changelog): curate v1.8.3-alpha notes + What's New block
Demo images / Build & push demo images (push) Successful in 3m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 05:13:43 -04:00
archipelagoandClaude Fable 5 b5e33784e6 fix(ui): kiosk map animates again + paints on resize; gloss scoped; icons get intrinsic size
Demo images / Build & push demo images (push) Successful in 3m42s
- NetworkMap3D: kiosks keep static PLACEMENT (the rAF-fragile intro was
  the blank-screen cause) but re-attach the half-rate ticker — the calm
  orbit is back; and measure() now renders explicitly when no ticker runs,
  so resizes repaint instead of leaving a stale/blank/mis-scaled
  projection (also fixes reduced-motion users on any screen).
- Gloss v3 scoped to .logo-gloss opt-in (screensaver, intro, login,
  splash tap-logo) — it had leaked onto every logo-gradient-border user,
  including the dashboard header, via AnimatedLogo's default border.
- normalize-app-icon.py output now carries intrinsic 512x512 dimensions:
  a viewBox-only SVG collapses to nothing in auto-sized tiles (the
  'transparent icon in My Apps' report); both app icons regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 05:11:54 -04:00
26 changed files with 419 additions and 88 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## v1.8.3-alpha (2026-08-14)
- **The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.
- **The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.
- **App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.
## v1.8.2-alpha (2026-08-13)
- **An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.2-alpha"
version = "1.8.3-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.2-alpha"
version = "1.8.3-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -344,6 +344,7 @@ impl RpcHandler {
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
"bitcoin.relay-update-settings" => {
@@ -133,6 +133,7 @@ impl RpcHandler {
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
@@ -503,6 +504,7 @@ impl RpcHandler {
"ai.permissions.set" => self.handle_ai_permissions_set(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
@@ -607,9 +607,77 @@ impl RpcHandler {
.unwrap_or("")
.to_string();
// LND returns r_hash base64-encoded; the lookup endpoint the Receive
// flow polls (`lnd.invoicestatus`) wants it hex — hand the UI the
// ready-to-use form.
let r_hash_hex = {
use base64::Engine as _;
body.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default()
};
Ok(serde_json::json!({
"payment_request": payment_request,
"amount_sats": amount_sats,
"r_hash_hex": r_hash_hex,
}))
}
/// lnd.invoicestatus — is this invoice settled yet? Polled by the wallet's
/// Receive flow so a Lightning payment gets the same "money has arrived"
/// success screen as on-chain (minus the broadcast step: settlement is
/// final). Params: `{ "r_hash_hex": string }`.
pub(in crate::api::rpc) async fn handle_lnd_invoicestatus(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let r_hash_hex = params
.get("r_hash_hex")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'r_hash_hex' parameter"))?;
if r_hash_hex.len() != 64 || !r_hash_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("r_hash_hex must be 64 hex characters"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{r_hash_hex}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to query invoice")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice lookup response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Invoice lookup failed: {}", msg));
}
let settled = body
.get("state")
.and_then(|v| v.as_str())
.map(|s| s == "SETTLED")
.unwrap_or_else(|| body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false));
let amt_paid_sat = body
.get("amt_paid_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| body.get("amt_paid_sat").and_then(|v| v.as_i64()))
.unwrap_or(0);
Ok(serde_json::json!({
"settled": settled,
"amt_paid_sat": amt_paid_sat,
}))
}
@@ -1248,6 +1248,30 @@ impl RpcHandler {
}
}
/// system.node-ca.generate — run the node's (idempotent) CA setup so the
/// dashboard can offer certificate generation as a button. WebUI rule:
/// users must never be pointed at a terminal; the script reuses an
/// existing CA and only reissues the leaf, so re-running is safe.
pub(in crate::api::rpc) async fn handle_system_node_ca_generate(
&self,
) -> Result<serde_json::Value> {
let script = "/opt/archipelago/scripts/setup-node-ca.sh";
if tokio::fs::metadata(script).await.is_err() {
anyhow::bail!(
"The certificate setup script is not on this node yet — it arrives with the next update."
);
}
let status = host_sudo(&["/usr/bin/bash", script]).await?;
if !status.success() {
anyhow::bail!(
"Certificate generation failed (exit {:?}) — see the node log for detail",
status.code()
);
}
info!("Node CA generated/reissued via dashboard");
Ok(serde_json::json!({ "generated": true }))
}
/// system.kiosk-display.get — Current kiosk display preset + whether this
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.2-alpha",
"version": "1.8.3-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.2-alpha",
"version": "1.8.3-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.2-alpha",
"version": "1.8.3-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">
<!-- normalized by scripts/normalize-app-icon.py: margin=0.12 per side -->
<svg x="12.000" y="12.000" width="76.000" height="76.000" viewBox="0 0 8000 8000" preserveAspectRatio="xMidYMid meet">
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8000 8000">

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">
<!-- normalized by scripts/normalize-app-icon.py: margin=0.12 per side -->
<svg x="12.000" y="12.000" width="76.000" height="76.000" viewBox="0 0 94 94" preserveAspectRatio="xMidYMid meet">
<svg viewBox="0 0 94 94" id="vector" xmlns="http://www.w3.org/2000/svg">

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -21,11 +21,25 @@
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.memoOptional') }}</label>
<input v-model="invoiceMemo" type="text" :placeholder="t('receiveBitcoin.memoPlaceholder')" class="w-full input-glass" />
</div>
<div v-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<!-- Paid: the invoice did its job straight to the green check
(no broadcast step: Lightning settlement is final) -->
<div v-if="invoicePaid" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full flex items-center justify-center bg-green-500/15">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p class="text-lg font-semibold text-white mb-1">{{ t('receiveBitcoin.paymentConfirmed') }}</p>
<p v-if="invoicePaid.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
{{ invoicePaid.amountSats.toLocaleString() }} sats
</p>
</div>
<div v-else-if="invoiceResult" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="lightningQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ invoiceResult }}</p>
<CopyButton :value="invoiceResult" :label="t('common.copy')" class="mt-2" />
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.invoiceShareLabel') }}</p>
<CopyButton :value="invoiceResult" :label="t('common.copy')" />
</div>
</div>
@@ -147,6 +161,9 @@ watch(() => props.show, (open) => {
return
}
paymentSeen.value = null
invoicePaid.value = null
invoiceRHash.value = ''
stopWatchingInvoice()
// Blank slate on every open: a leftover amount/memo/token or a previous
// invoice quietly carrying into a new receive flow is exactly the stale-
// state class the operator flagged on the send modal (2026-08-05).
@@ -238,6 +255,46 @@ async function checkForPayment() {
onUnmounted(stopWatchingPayment)
// Lightning settlement watch the on-chain pattern minus the broadcast
// step: an invoice is either open or SETTLED (final), so the success view
// goes straight to the green check.
const invoicePaid = ref<null | { amountSats: number }>(null)
const invoiceRHash = ref('')
let invoiceTimer: ReturnType<typeof setInterval> | null = null
function stopWatchingInvoice() {
if (invoiceTimer) {
clearInterval(invoiceTimer)
invoiceTimer = null
}
}
function startWatchingInvoice() {
stopWatchingInvoice()
invoiceTimer = setInterval(() => void checkInvoice(), 3000)
}
async function checkInvoice() {
if (!props.show || !invoiceRHash.value) {
stopWatchingInvoice()
return
}
try {
const res = await rpcClient.call<{ settled: boolean; amt_paid_sat: number }>({
method: 'lnd.invoicestatus',
params: { r_hash_hex: invoiceRHash.value },
})
if (!res.settled) return
invoicePaid.value = { amountSats: res.amt_paid_sat || invoiceAmount.value }
stopWatchingInvoice()
emit('received')
} catch {
// Transient poll failure keep watching.
}
}
onUnmounted(stopWatchingInvoice)
async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') {
if (!canvas || !data) return
try {
@@ -272,11 +329,14 @@ async function receive() {
// lnd.createinvoice fail with connection-refused (FED-08 follow-up).
if (!(await lightning.requireLightningReady('receive'))) return
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({
const res = await rpcClient.call<{ payment_request: string; r_hash_hex?: string }>({
method: 'lnd.createinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
})
invoiceResult.value = res.payment_request
invoiceRHash.value = res.r_hash_hex || ''
invoicePaid.value = null
if (invoiceRHash.value) startWatchingInvoice()
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="logo-gradient-border screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
<div class="logo-gradient-border logo-gloss screensaver-logo-cycle relative w-48 h-48 sm:w-64 sm:h-64 md:w-80 md:h-80 flex items-center justify-center overflow-hidden">
<!-- Squares logo -->
<div class="screensaver-logo-squares absolute inset-[3px] flex items-center justify-center">
<AnimatedLogo size="xl" no-border fit />
+1 -5
View File
@@ -227,7 +227,7 @@
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
<CopyButton :value="ecashToken" :label="t('common.copy')" size="sm" class="mt-2" />
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
@@ -604,10 +604,6 @@ function sendAnother() {
error.value = ''
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(ecashToken, async (token) => {
if (!token) return
@@ -202,11 +202,7 @@
</div>
<div v-if="arkAddress" class="mt-2 flex items-center gap-2 p-3 bg-white/5 rounded-lg">
<span class="text-xs font-mono text-white/90 break-all flex-1">{{ arkAddress }}</span>
<button @click="copyArkAddress" class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white shrink-0" :title="arkCopied ? 'Copied' : 'Copy'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<CopyButton :value="arkAddress" icon-only class="shrink-0" />
</div>
</div>
@@ -277,6 +273,7 @@
</template>
<script setup lang="ts">
import CopyButton from '@/components/CopyButton.vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
@@ -349,7 +346,6 @@ const arkStatus = ref<ArkStatus | null>(null)
const arkBalance = ref<ArkBalance | null>(null)
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
const arkAddress = ref('')
const arkCopied = ref(false)
const loadingArk = ref(false)
const savingArk = ref(false)
const arkBoarding = ref(false)
@@ -505,22 +501,11 @@ async function fetchArkAddress(onchain: boolean) {
params: { onchain },
})
arkAddress.value = res.address
arkCopied.value = false
} catch (err: unknown) {
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
}
}
async function copyArkAddress() {
if (!arkAddress.value) return
try {
await navigator.clipboard.writeText(arkAddress.value)
arkCopied.value = true
} catch {
/* clipboard unavailable (http) — the address is selectable */
}
}
async function boardArk() {
arkBoarding.value = true
arkError.value = ''
@@ -702,11 +702,22 @@ function applyGraph() {
const reqs = [...(props.requests ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const reqRing = requestRingRadius(peerNodes.length)
const seenReqs = new Set<string>()
// Requests are a call to action: cluster them tightly at one stage angle
// (instead of spread around the orbit, where they could sit BEHIND the
// globe reading as a mis-scaled chart the user had to rotate by hand)
// and steer the camera to face them when a new one arrives.
const REQUEST_STAGE_ANGLE = 0.35
const reqSpacing = Math.min(0.5, (Math.PI * 0.9) / Math.max(reqs.length, 1))
let newRequestArrived = false
reqs.forEach((req, i) => {
seenReqs.add(req.id)
const slot = { angle: (i / reqs.length) * Math.PI * 2 + 0.35, ringRadius: reqRing }
const slot = {
angle: REQUEST_STAGE_ANGLE + (i - (reqs.length - 1) / 2) * reqSpacing,
ringRadius: reqRing,
}
const existing = reqMap.get(req.id)
if (!existing) {
newRequestArrived = true
const vis = createRequest(req, slot)
if (introPlayed) {
if (staticMode) vis.p = 1
@@ -738,6 +749,20 @@ function applyGraph() {
for (const [id, vis] of reqMap) {
if (!seenReqs.has(id)) killRequest(vis)
}
// Face the request cluster when a new one arrives. Depth for a node at
// world angle a is proportional to sin(a rotY): front-center (nearest
// to the viewer, horizontally centred) is a rotY = π/2. Only on
// ARRIVAL, so a user who rotates away afterwards isn't fought.
if (newRequestArrived && introPlayed) {
const target = nearestAngle(cam.rotY, REQUEST_STAGE_ANGLE + Math.PI / 2)
for (const t of spinTween) t.kill()
if (staticMode && !tickerAttached) {
cam.rotY = target
render()
} else {
gsap.to(cam, { rotY: target, duration: 1.1, ease: motionTokens.ease.inOut, overwrite: 'auto' })
}
}
// --- orbit guide rings ---
const desiredRings: { radius: number; kind: 'peer' | 'request' }[] = []
@@ -878,7 +903,9 @@ function tick(_time: number, deltaMS: number) {
}
function attachTicker() {
if (tickerAttached || staticMode) return
// Kiosks run staticMode for PLACEMENT (instant, intro-free) but still get
// the calm orbit half-rate via the kioskLowPower frame-skip in tick().
if (tickerAttached || (staticMode && !kioskLowPower)) return
gsap.ticker.add(tick)
tickerAttached = true
}
@@ -1179,17 +1206,19 @@ function measure() {
}
// Resize: snap to the current mode's fit (no tween tracks the drag)
applyMode(viewMode.value, false)
// No ticker (reduced-motion) means nobody repaints after this resize
// without an explicit render the map keeps its stale (possibly zero-size)
// projection: the "loads blank / wrong scale until refresh" bug.
if (built && !tickerAttached) render()
}
function initialBuild() {
// Kiosk TVs get the fully static map: no entrance animation, no ticker.
// The GSAP intro needs healthy rAF delivery to ever reach opacity 1 on
// a paint-starved kiosk it stalls and the screen reads as BLANK until a
// lucky refresh (framework-pt, 2026-08-14). Half-rate ticking (below)
// helped but any continuous SVG animation is still the single biggest
// load on the kiosk's compositor, and at TV distance the still map loses
// nothing. Everything lands in place immediately, graph updates apply
// instantly, and the 2D/3D toggle still works.
// Kiosk TVs take the static PLACEMENT path (the GSAP entrance intro needs
// healthy rAF delivery to reach opacity 1 on a paint-starved kiosk it
// stalls and the screen reads as BLANK until a lucky refresh,
// framework-pt 2026-08-14) but keep the calm orbital motion: the ticker
// re-attaches below at half rate. Fully-static-everything was tried and
// rejected it also killed the animation on screens that could carry it.
staticMode = prefersReducedMotion() || kioskLowPower
built = true
applyGraph()
@@ -1208,6 +1237,8 @@ function initialBuild() {
if (staticMode) {
introPlayed = true
playIntro() // static branch: everything lands in place
attachTicker() // no-op for reduced-motion; kiosks resume the calm orbit
render() // paint the landed state now nothing else repaints until a tick
return
}
attachTicker()
+6 -3
View File
@@ -1135,13 +1135,15 @@ html.controller-nav [data-controller-container]:focus {
lower reflection arc, bottom depth vignette, paint-black base; inset
rim lights; and a curved liquid "window streak" on ::before (under
the badge's z-1 content, above the paint). Revert: delete this block. */
.logo-gradient-border {
.logo-gradient-border.logo-gloss,
.tap-to-start-logo .logo-gradient-border {
background: none;
box-shadow: none;
padding: 0;
overflow: hidden;
}
.logo-gradient-border::after {
.logo-gradient-border.logo-gloss::after,
.tap-to-start-logo .logo-gradient-border::after {
inset: 0;
/* Dense intermediate stops + a fine turbulence grain (top layer,
overlay-blended) dither the near-black falloffs without them the
@@ -1160,7 +1162,8 @@ html.controller-nav [data-controller-container]:focus {
inset 0 0 0 1px rgba(255,242,212,0.08),
0 12px 34px rgba(0,0,0,0.65);
}
.logo-gradient-border::before {
.logo-gradient-border.logo-gloss::before,
.tap-to-start-logo .logo-gradient-border::before {
content: '';
position: absolute;
left: 8%;
+1 -1
View File
@@ -8,7 +8,7 @@
>
<!-- Logo - half in, half out of container -->
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
<div class="logo-gradient-border w-20 h-20">
<div class="logo-gradient-border logo-gloss w-20 h-20">
<AnimatedLogo no-border fit />
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="glass-card p-8 pt-16 sm:p-12 sm:pt-20 text-center relative overflow-visible onb-card">
<!-- Logo - half in, half out of container -->
<div class="absolute -top-8 sm:-top-10 left-0 right-0 flex justify-center z-10 onb-logo">
<div class="logo-gradient-border w-16 h-16 sm:w-20 sm:h-20">
<div class="logo-gradient-border logo-gloss w-16 h-16 sm:w-20 sm:h-20">
<AnimatedLogo no-border fit />
</div>
</div>
@@ -362,6 +362,18 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.3-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.3-alpha</span>
<span class="text-xs text-white/40">August 14, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**The network map on TVs: no more blank page, no more frozen page and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.</p>
<p>**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.</p>
<p>**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.</p>
</div>
</div>
<!-- v1.8.2-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -26,7 +26,30 @@ async function computeFingerprint(pem: string): Promise<string> {
.join(':')
}
onMounted(async () => {
const generating = ref(false)
const generateError = ref('')
// WebUI rule: never point a user at a terminal the backend runs the
// (idempotent) CA setup script for us.
async function generateCa() {
generating.value = true
generateError.value = ''
try {
const { rpcClient } = await import('@/api/rpc-client')
await rpcClient.call({ method: 'system.node-ca.generate', timeout: 60000 })
loading.value = true
await probe()
if (!caAvailable.value) {
generateError.value = 'Generation reported success but the certificate is not being served yet — try reloading in a few seconds.'
}
} catch (e: unknown) {
generateError.value = e instanceof Error ? e.message : 'Certificate generation failed'
} finally {
generating.value = false
}
}
async function probe() {
try {
const res = await fetch('/ca.crt', { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -48,7 +71,9 @@ onMounted(async () => {
} finally {
loading.value = false
}
})
}
onMounted(probe)
</script>
<template>
@@ -70,9 +95,13 @@ onMounted(async () => {
v-else-if="!caAvailable"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
This node has not generated a certificate authority yet. Run
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">scripts/setup-node-ca.sh</code>
on the node, then reload this page.
<p class="mb-3">This node has not generated its certificate yet.</p>
<button
:disabled="generating"
@click="generateCa"
class="px-4 py-2 glass-button rounded-lg text-sm font-semibold disabled:opacity-60"
>{{ generating ? 'Generating…' : 'Generate certificate' }}</button>
<p v-if="generateError" class="mt-2 text-xs text-orange-300/90">{{ generateError }}</p>
</div>
<div v-else class="space-y-4">
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
// Routstr prepaid budget (D-05): the assistant may only spend against an
// operator-set sats ceiling. Zero allowance = Routstr never selected. This
// panel is the ONLY UI for that ceiling; it fronts assistant.budget-get/set.
const loaded = ref(false)
const allowance = ref(0)
const spent = ref(0)
const remaining = ref(0)
const draft = ref('')
const applying = ref(false)
const error = ref('')
const savedTick = ref(false)
const active = computed(() => allowance.value > 0)
async function refresh() {
const res = await rpcClient.call<{ allowance_sats: number; spent_sats: number; remaining_sats: number }>({
method: 'assistant.budget-get',
})
allowance.value = res.allowance_sats
spent.value = res.spent_sats
remaining.value = res.remaining_sats
draft.value = String(res.allowance_sats)
}
onMounted(async () => {
try {
await refresh()
loaded.value = true
} catch { /* backend without the RPC — section hides */ }
})
async function apply() {
const parsed = Number(draft.value)
if (!Number.isInteger(parsed) || parsed < 0) {
error.value = 'Enter a whole number of sats (0 disables Routstr).'
return
}
applying.value = true
error.value = ''
try {
await rpcClient.call({ method: 'assistant.budget-set', params: { allowance_sats: parsed } })
await refresh()
savedTick.value = true
setTimeout(() => { savedTick.value = false }, 2500)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to save budget'
} finally {
applying.value = false
}
}
</script>
<template>
<div v-if="loaded" class="glass-card px-6 py-6 mb-6">
<div class="flex items-center gap-3 mb-2">
<h2 class="text-xl font-semibold text-white/96">Routstr AI Budget</h2>
<span
class="text-xs font-mono px-2 py-0.5 rounded"
:class="active ? 'bg-emerald-500/20 text-emerald-300' : 'bg-white/10 text-white/50'"
>{{ active ? 'enabled' : 'off' }}</span>
</div>
<p class="text-sm text-white/60 mb-5">
Routstr is pay-per-use AI inference, paid in sats over Cashu, used when your local
model can't take a request. It never spends without a prepaid ceiling you set here
at <span class="font-mono">0</span> it is completely disabled. The assistant stops
when the ceiling is reached; raising it widens the remainder without erasing the
spend history.
</p>
<div class="grid grid-cols-3 gap-4 mb-5 text-center">
<div class="rounded-lg bg-white/5 py-3">
<div class="text-lg font-mono text-white/90">{{ allowance.toLocaleString() }}</div>
<div class="text-xs text-white/50 mt-1">Allowance (sats)</div>
</div>
<div class="rounded-lg bg-white/5 py-3">
<div class="text-lg font-mono text-white/90">{{ spent.toLocaleString() }}</div>
<div class="text-xs text-white/50 mt-1">Spent</div>
</div>
<div class="rounded-lg bg-white/5 py-3">
<div class="text-lg font-mono" :class="remaining > 0 ? 'text-emerald-300' : 'text-white/60'">{{ remaining.toLocaleString() }}</div>
<div class="text-xs text-white/50 mt-1">Remaining</div>
</div>
</div>
<div class="flex items-end gap-3">
<div class="flex-1 max-w-xs">
<label class="block text-sm text-white/60 mb-1" for="routstr-allowance">Set allowance (sats)</label>
<input
id="routstr-allowance"
v-model="draft"
type="number"
min="0"
step="1"
inputmode="numeric"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white font-mono placeholder-white/30 focus:outline-none focus:border-white/30 transition-colors"
@keydown.enter="apply"
/>
</div>
<button
:disabled="applying || draft === String(allowance)"
@click="apply"
class="px-4 py-2 glass-button rounded-lg text-sm font-medium disabled:opacity-50"
>{{ applying ? 'Saving…' : 'Save' }}</button>
<span v-if="savedTick" class="text-sm text-emerald-300 pb-2">Saved</span>
</div>
<div v-if="error" class="mt-3 alert-error text-sm">{{ error }}</div>
</div>
</template>
@@ -3,6 +3,7 @@ import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
import KioskDisplaySection from '@/views/settings/KioskDisplaySection.vue'
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
@@ -15,6 +16,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<InterfaceModeSection />
<KioskDisplaySection />
<ClaudeAuthSection />
<RoutstrBudgetSection />
<AIDataAccessSection />
<WebhookSection />
<TelemetrySection />
+16 -16
View File
@@ -1,29 +1,29 @@
{
"changelog": [
"**An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.",
"**The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)",
"**New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically."
"**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.",
"**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.",
"**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files."
],
"components": [
{
"current_version": "1.8.2-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.2-alpha/archipelago",
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.2-alpha",
"sha256": "505a456d94a17ef336b8573e1423edc2295463e603b0af89131144297cffd5ca",
"size_bytes": 59898960
"new_version": "1.8.3-alpha",
"sha256": "23b608bfce575212edb873ded3e125505f42be0db6dc06ce5f9c1e51c232c22d",
"size_bytes": 59900696
},
{
"current_version": "1.8.2-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.2-alpha/archipelago-frontend-1.8.2-alpha.tar.gz",
"name": "archipelago-frontend-1.8.2-alpha.tar.gz",
"new_version": "1.8.2-alpha",
"sha256": "531ea4c5fc6c1116a3c09e0fc81f16acb91a86a0ad25fb4883de499713c530da",
"size_bytes": 97617354
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago-frontend-1.8.3-alpha.tar.gz",
"name": "archipelago-frontend-1.8.3-alpha.tar.gz",
"new_version": "1.8.3-alpha",
"sha256": "13ca772e9a36c266b67e6eff2d51366616cdbedeab3a05ba5eba340332d3d6d4",
"size_bytes": 97615528
}
],
"release_date": "2026-08-14",
"signature": "e2b5c85707121444aff6ca2150fe04dd7d73ef4f9a183eb962986554f34a20128f087c138250a42043801380f50f2e0aebe48869d1577d0171dfe4cf0ff0ac08",
"signature": "35d5f56ef77cda1866051a3ff06f6e09ff0a23ac7b000a1ac4cf24e04cecabff00be7f3e240fc82ec605bff80c5690c55c296a300018562940d71ffc5df26c00",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.2-alpha"
"version": "1.8.3-alpha"
}
+16 -16
View File
@@ -1,29 +1,29 @@
{
"changelog": [
"**An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.",
"**The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)",
"**New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically."
"**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.",
"**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.",
"**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files."
],
"components": [
{
"current_version": "1.8.2-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.2-alpha/archipelago",
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.2-alpha",
"sha256": "505a456d94a17ef336b8573e1423edc2295463e603b0af89131144297cffd5ca",
"size_bytes": 59898960
"new_version": "1.8.3-alpha",
"sha256": "23b608bfce575212edb873ded3e125505f42be0db6dc06ce5f9c1e51c232c22d",
"size_bytes": 59900696
},
{
"current_version": "1.8.2-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.2-alpha/archipelago-frontend-1.8.2-alpha.tar.gz",
"name": "archipelago-frontend-1.8.2-alpha.tar.gz",
"new_version": "1.8.2-alpha",
"sha256": "531ea4c5fc6c1116a3c09e0fc81f16acb91a86a0ad25fb4883de499713c530da",
"size_bytes": 97617354
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago-frontend-1.8.3-alpha.tar.gz",
"name": "archipelago-frontend-1.8.3-alpha.tar.gz",
"new_version": "1.8.3-alpha",
"sha256": "13ca772e9a36c266b67e6eff2d51366616cdbedeab3a05ba5eba340332d3d6d4",
"size_bytes": 97615528
}
],
"release_date": "2026-08-14",
"signature": "e2b5c85707121444aff6ca2150fe04dd7d73ef4f9a183eb962986554f34a20128f087c138250a42043801380f50f2e0aebe48869d1577d0171dfe4cf0ff0ac08",
"signature": "35d5f56ef77cda1866051a3ff06f6e09ff0a23ac7b000a1ac4cf24e04cecabff00be7f3e240fc82ec605bff80c5690c55c296a300018562940d71ffc5df26c00",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.2-alpha"
"version": "1.8.3-alpha"
}
+1 -1
View File
@@ -67,7 +67,7 @@ def main() -> int:
inner = re.sub(r"^\s*<\?xml[^>]*\?>\s*", "", svg)
out = (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {canvas:g} {canvas:g}">\n'
f'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 {canvas:g} {canvas:g}">\n'
f' <!-- normalized by scripts/normalize-app-icon.py: margin={args.margin:g} per side -->\n'
f' <svg x="{x:.3f}" y="{y:.3f}" width="{scale_w:.3f}" height="{scale_h:.3f}" '
f'viewBox="{inner_viewbox}" preserveAspectRatio="xMidYMid meet">\n'