From 86abc53d1502f94538296287976ba844ebea1e39 Mon Sep 17 00:00:00 2001 From: "PC-JASPER\\jasper" Date: Fri, 11 Sep 2026 10:48:55 +0200 Subject: [PATCH 1/5] changed launchpad card to match the quick start examples. --- src/routes/+page.svelte | 118 +++++++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 49 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a9a24fa..4e5acbb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -111,41 +111,41 @@
-
-

Query Workbench

-

Write queries, filter data and search millions of records.

- -
-
- Query Workbench + Query Workbench +
+

Query Workbench

+

Write queries, filter data and search millions of records.

+
+ +
@@ -222,44 +222,64 @@ .launchpad-cards { display: grid; gap: 1rem; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + // The screenshot reaches the card edge, so the card holds no padding of + // its own. The body below it supplies the padding instead. :global(.card) { padding: 0; overflow: hidden; + height: 100%; max-width: 32.7%; } + // `Card` puts every child inside this wrapper. The wrapper must fill the + // card, else `flex` on the body below it has nothing to divide. :global(.card-content) { - padding: 0; - flex-grow: 1; display: flex; - flex-direction: row; + flex-direction: column; + flex: 1; + } - :global(.content) { - padding: 1rem; - flex: 0 0 50%; - display: flex; - flex-direction: column; + :global(.shot) { + display: block; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + object-fit: cover; + object-position: center; + border-bottom: 1px solid var(--card-border); + background-color: var(--muted); + } - :global(p) { - flex-grow: 1; - } + :global(.body) { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + // The card stretches to the tallest of the row. This pushes the + // actions of every card to the same edge. + flex: 1; + + :global(h3) { + margin: 0; } - :global(.image) { - padding-top: 3rem; - padding-bottom: 1rem; - :global(img) { - border-radius: 0.5rem 0 0 0.5rem; - border: 1px solid lightgray; - border-right: 0; - object-fit: cover; - object-position: left; - display: block; - aspect-ratio: 1/0.8; - } + + :global(.description) { + margin: 0; + // The descriptions differ in length. This keeps the action rows of + // the cards on one line. + flex: 1; } } + + :global(.actions) { + display: flex; + flex-direction: row; + gap: 0.5rem; + width: 100%; + margin-top: 0.25rem; + } } } From bb0532e10c2c87e5101c895154e8311e21fe6769 Mon Sep 17 00:00:00 2001 From: "PC-JASPER\\jasper" Date: Fri, 11 Sep 2026 10:59:15 +0200 Subject: [PATCH 2/5] launchpad card same styling as quick start cards --- src/routes/+page.svelte | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4e5acbb..f6cdeea 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,6 +7,7 @@ import BeaconNodeStatus from '@/components/BeaconNodeStatus.svelte'; import HowItWorks from '@/components/home/HowItWorks.svelte'; import QuickStartExamples from '@/components/home/QuickStartExamples.svelte'; + import BracesIcon from '@lucide/svelte/icons/braces'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; import Button from '@/components/buttons/Button.svelte'; @@ -116,7 +117,10 @@

Query Workbench

Write queries, filter data and search millions of records.

- +
@@ -279,6 +283,10 @@ gap: 0.5rem; width: 100%; margin-top: 0.25rem; + + > :global(.btn) { + flex: 1; + } } } } From 68dbace069a94b57877266e2012ef2bdc1d48a9d Mon Sep 17 00:00:00 2001 From: "PC-JASPER\\jasper" Date: Fri, 11 Sep 2026 15:29:53 +0200 Subject: [PATCH 3/5] using beacon studio for the first time through a shared query link no longer throws a warning and redirects to the home page but keeps the user waiting for the public nodes to be fetched and builds the query. --- AGENTS.md | 7 +++ .../components/query-builder/QueryActions.ts | 3 ++ .../query-builder/QueryBuilder.svelte | 4 ++ .../QueryBuilderNodeSelector.svelte | 13 +++++ .../query-builder/QueryWorkbenchPanes.svelte | 1 + .../query-builder/QueryWorkspace.svelte.ts | 34 +++++++++++-- src/lib/components/sidebar/AppSidebar.svelte | 50 ++++++++++++++++--- src/lib/services/open-nodes-import.ts | 45 ++++++++++++++++- src/lib/stores/toasts.ts | 5 +- src/routes/+layout.svelte | 7 +-- 10 files changed, 148 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 023867c..09f062c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,13 @@ This file is a quick operational guide for coding agents working in this reposit - `src/lib/stores/opfs-arrow-cache.ts` (OPFS tier under the query store: raw compressed Arrow IPC bytes, survives reloads/restarts) - `src/lib/stores/query-history.ts` (persisted log of executed queries; recorded by `queryStore.ensure()`, consumed by `queries/query-history`) - `src/lib/services/beacon-node.ts` (the single owner of the Beacon node list and the selection; read `$nodes` / `$currentNode` in a component, and write only through its actions) + - A child `onMount` runs before the layout `onMount`. State that a page reads at mount + therefore cannot come from `+layout.svelte` `onMount`. The public node list is fetched + there, so a first-ever visit has no node list when a page mounts. A page that names a + node by URL (a share link) must wait for `openNodesSettled` / `whenOpenNodesSettled()` + (`services/open-nodes-import.ts`) before it reports that node as missing. `AppSidebar` + blocks a route that needs a node, and waits for the same flag, so a first visit is not + sent home before the public nodes land. - `src/lib/stores/settings.ts` (persisted user settings; the `/settings` page builds its form from `SETTING_DEFINITIONS`. Read a value with `getSettings()` at the point of use, or `$settings` in a component. Never read it at module load.) - `src/lib/stores/toasts.ts` (global toasts) - Heavy data operations (off-main-thread, one shared worker via `getArrowWorker()`): diff --git a/src/lib/components/query-builder/QueryActions.ts b/src/lib/components/query-builder/QueryActions.ts index d890fad..8cecbff 100644 --- a/src/lib/components/query-builder/QueryActions.ts +++ b/src/lib/components/query-builder/QueryActions.ts @@ -106,6 +106,9 @@ export function getDefaultQueryActions(workspace: QueryWorkspace): QueryActions message: `Add the Beacon node ${missing} to run this query.`, type: 'warning' }); + } else if (!workspace.nodesReady) { + // The public list is still on its way, so the node can still arrive. + addToast({ message: 'Wait for the Beacon nodes to load.', type: 'warning' }); } else { addToast({ message: 'Pick a Beacon node for this query first.', type: 'warning' }); } diff --git a/src/lib/components/query-builder/QueryBuilder.svelte b/src/lib/components/query-builder/QueryBuilder.svelte index cbd1b85..b67f1a3 100644 --- a/src/lib/components/query-builder/QueryBuilder.svelte +++ b/src/lib/components/query-builder/QueryBuilder.svelte @@ -18,6 +18,7 @@ let { node, missingNodeUrl = null, + nodesReady = true, onNodeChange, onSeedMismatch, initialDraft = null, @@ -41,6 +42,8 @@ node: BeaconNode | null; /** The URL of a node that the node list does not hold, or null. */ missingNodeUrl?: string | null; + /** False while the app still reads the public node list. */ + nodesReady?: boolean; /** Called with the node the user picked in the first step. */ onNodeChange: (node: BeaconNode) => void; /** @@ -209,6 +212,7 @@ diff --git a/src/lib/components/query-builder/QueryBuilderNodeSelector.svelte b/src/lib/components/query-builder/QueryBuilderNodeSelector.svelte index e962fd1..4736597 100644 --- a/src/lib/components/query-builder/QueryBuilderNodeSelector.svelte +++ b/src/lib/components/query-builder/QueryBuilderNodeSelector.svelte @@ -29,12 +29,15 @@ let { selected = null, missingUrl = null, + nodesReady = true, onPick }: { /** The node of the query now, or null while it has none. */ selected?: BeaconNode | null; /** The URL of a node that the list does not hold, or null. */ missingUrl?: string | null; + /** False while the app still reads the public node list. */ + nodesReady?: boolean; /** Called with the node the user picked. */ onPick: (node: BeaconNode) => void; } = $props(); @@ -95,6 +98,10 @@ +{#if !nodesReady && $nodes.length === 0} +

Load Beacon nodes...

+{/if} + {#if missingUrl}
@@ -177,6 +184,12 @@ } } + p.node-loading { + margin: 1rem 0 0; + font-size: 0.875rem; + color: var(--muted-foreground); + } + .missing-node { display: flex; align-items: center; diff --git a/src/lib/components/query-builder/QueryWorkbenchPanes.svelte b/src/lib/components/query-builder/QueryWorkbenchPanes.svelte index 7fdba88..a6b0789 100644 --- a/src/lib/components/query-builder/QueryWorkbenchPanes.svelte +++ b/src/lib/components/query-builder/QueryWorkbenchPanes.svelte @@ -126,6 +126,7 @@ ([]); + /** True after the public list settles. Both node warnings wait for it. */ + private nodesSettled = $state(false); + /** * The blocks whose node the app guessed. A share link of an older app version * carries no `?node=`, so the block falls back to the default node. That @@ -120,7 +125,7 @@ export class QueryWorkspace { */ private guessedNode = $state>({}); - /** Releases the two store subscriptions. See {@link destroy}. */ + /** Releases the store subscriptions. See {@link destroy}. */ private unsubscribe: () => void; constructor() { @@ -132,9 +137,14 @@ export class QueryWorkspace { this.nodeList = list; }); + const stopSettled = openNodesSettled.subscribe((value) => { + this.nodesSettled = value; + }); + this.unsubscribe = () => { stopBlocks(); stopNodes(); + stopSettled(); }; this.restoreSelection(); @@ -194,9 +204,14 @@ export class QueryWorkspace { private warnMissingNode(url: string | null): void { if (!url) return; - addToast({ - type: 'warning', - message: `This query needs the Beacon node ${url}. Add it to run the query.` + // The public list can still add this node. Ask again after it settles. + void whenOpenNodesSettled().then(() => { + if (resolveRef(nodeRefFromUrl(url))) return; + + addToast({ + type: 'warning', + message: `This query needs the Beacon node ${url}. Add it to run the query.` + }); }); } @@ -242,6 +257,10 @@ export class QueryWorkspace { missingNodeUrlFor(block: StoredQuery | null): string | null { if (!hasNodeRef(block?.node)) return null; if (this.nodeFor(block)) return null; + + // The public list can still add this node. Claim nothing until it settles. + if (!this.nodesSettled) return null; + return block?.node.url || null; } @@ -250,6 +269,11 @@ export class QueryWorkspace { return this.missingNodeUrlFor(this.activeBlock); } + /** True after the public list settles. See {@link missingNodeUrlFor}. */ + get nodesReady(): boolean { + return this.nodesSettled; + } + /** * Put a node on a block. * diff --git a/src/lib/components/sidebar/AppSidebar.svelte b/src/lib/components/sidebar/AppSidebar.svelte index 1fbdb6b..5e70c28 100644 --- a/src/lib/components/sidebar/AppSidebar.svelte +++ b/src/lib/components/sidebar/AppSidebar.svelte @@ -2,6 +2,7 @@ // Node service import { currentNode, nodes, selectFirstIfNone } from '@/services/beacon-node'; import { ensureFresh } from '@/services/beacon-node-connect'; + import { openNodesSettled } from '@/services/open-nodes-import'; import logo from '$lib/assets/logo-gradient.svg'; // Svelte lifecycle and navigation @@ -9,7 +10,7 @@ import { goto, beforeNavigate, afterNavigate } from '$app/navigation'; import { resolve } from '$app/paths'; import { page } from '$app/state'; - import { addToast } from '@/stores/toasts'; + import { addToast, dismissToast } from '@/stores/toasts'; // Icons import EyeIcon from '@lucide/svelte/icons/eye'; @@ -150,6 +151,8 @@ // record holds its own node. Block the navigation instead of following it, so // the user stays on the page they came from and sees why the target failed. beforeNavigate((navigation) => { + // The public list can still add a node. Let the move through until it lands. + if (!$openNodesSettled) return; if ($nodes.length > 0) return; if (!needsNode(navigation.to?.route.id)) return; @@ -172,15 +175,46 @@ // and the builder asks for one where it is missing. selectFirstIfNone(); - // `beforeNavigate` never runs for the page a session opens on. A direct - // load of a blocked route (a bookmark, a refresh, a shared link) has no - // prior page to stay on, so send it home instead. - if ($nodes.length === 0 && needsNode(page.route.id)) { - warnNoNode(); - goto(resolve('/')); + return () => mobileQuery.removeEventListener('change', onMobileChange); + }); + + // `beforeNavigate` never runs for the page a session opens on. A direct load of + // a blocked route (a bookmark, a refresh, a shared link) has no prior page to + // stay on, so send it home instead. A first visit has no saved node, so this + // waits for the public list before the app gives up on the page. + $effect(() => { + if (!$openNodesSettled) return; + if ($nodes.length > 0) return; + if (!needsNode(page.route.id)) return; + + warnNoNode(); + goto(resolve('/')); + }); + + // The id of the open "please wait" toast. A plain let, so the effect below + // does not re-run on its own write. + let waitToastId: number | null = null; + + // Name the wait. The app reads the public node list on a first visit only. + $effect(() => { + if ($openNodesSettled) { + if (waitToastId !== null) { + dismissToast(waitToastId); + waitToastId = null; + } + return; } - return () => mobileQuery.removeEventListener('change', onMobileChange); + if (waitToastId !== null) return; + if ($nodes.length > 0) return; + if (!needsNode(page.route.id)) return; + + waitToastId = addToast({ + type: 'info', + timeout: 0, + message: + 'Beacon Studio reads the public Beacon nodes. This happens on the first visit only. Your query starts when they arrive.' + }); }); // Close the overlay sidebar after navigating on mobile diff --git a/src/lib/services/open-nodes-import.ts b/src/lib/services/open-nodes-import.ts index d9d1c4d..e72c8b1 100644 --- a/src/lib/services/open-nodes-import.ts +++ b/src/lib/services/open-nodes-import.ts @@ -13,16 +13,17 @@ */ import { persisted } from 'svelte-local-storage-store'; -import { get } from 'svelte/store'; +import { get, readonly, writable, type Readable } from 'svelte/store'; import { addNode, findByUrl, getCurrentNode, + getNodes, migrateKey, normalizeUrl, selectNode } from './beacon-node'; -import { getOpenNodes, type OpenNode } from './open-nodes'; +import { getOpenNodes, loadOpenNodes, type OpenNode } from './open-nodes'; /** The key of the normalized URLs that the app imported. */ const IMPORTED_KEY = 'imported-open-node-urls'; @@ -32,6 +33,46 @@ migrateKey('imported-open-instance-urls', IMPORTED_KEY); const importedUrlsStore = persisted(IMPORTED_KEY, []); +// A saved node list means the first visit is over. Nothing to wait for. +const hasNodes = getNodes().length > 0; + +const settledStore = writable(hasNodes); + +/** + * True after the public list lands, or after its fetch fails. + * + * A share link names its node by URL. On a first visit the saved list is empty, + * so that name resolves to nothing until the public list lands. A page reads + * this store to hold back a "node not found" message until the app can tell. + */ +export const openNodesSettled: Readable = readonly(settledStore); + +let settle: () => void = () => {}; + +const settledPromise = hasNodes + ? Promise.resolve() + : new Promise((done) => { + settle = done; + }); + +/** {@link openNodesSettled} for a one-shot caller, such as a toast. */ +export function whenOpenNodesSettled(): Promise { + return settledPromise; +} + +/** + * Reads the public list, imports it, and reports the list as settled. A failed + * fetch also settles the list, so a caller never waits forever. + */ +export async function syncOpenNodes(): Promise { + try { + return importOpenNodes(await loadOpenNodes()); + } finally { + settledStore.set(true); + settle(); + } +} + /** * Adds every public node that the app did not import before. The function * returns the number of nodes it added. diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts index b8ccc24..de26a6b 100644 --- a/src/lib/stores/toasts.ts +++ b/src/lib/stores/toasts.ts @@ -2,7 +2,8 @@ import { writable } from "svelte/store"; export const toasts = writable([]); -export function addToast(toast: Partial) { +/** Adds a toast and returns its id, for a caller that dismisses its own toast. */ +export function addToast(toast: Partial): number { // Create a unique ID so we can easily find/remove it // if it is dismissible/has a timeout. const id = Math.floor(Math.random() * 10000); @@ -25,6 +26,8 @@ export function addToast(toast: Partial) { // If toast is dismissible, dismiss it after "timeout" amount of time. if (toastData.timeout) setTimeout(() => dismissToast(id), toastData.timeout); + + return id; }; export const dismissToast = (id: number) => { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1dc5eaa..c88c535 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,10 +2,9 @@ import AppSidebar from '@/components/sidebar/AppSidebar.svelte'; import Toasts from '@/components/toasts/Toasts.svelte'; import { checkAllNodes, startHealthMonitor } from '@/services/beacon-node-connect'; - import { loadOpenNodes } from '@/services/open-nodes'; import { loadHomeExamples } from '@/data/home-examples'; import { FRESH_MS } from '@/services/beacon-node-health'; - import { importOpenNodes } from '@/services/open-nodes-import'; + import { syncOpenNodes } from '@/services/open-nodes-import'; import { onMount } from 'svelte'; import '../app.scss'; import '../tailwind.css'; @@ -14,9 +13,7 @@ onMount(() => { // A failure leaves the list empty, so the import then adds nothing. The // monitor sweeps before the fetch answers, so the new nodes need a check. - void loadOpenNodes() - .then(importOpenNodes) - .then(() => checkAllNodes(FRESH_MS)); + void syncOpenNodes().then(() => checkAllNodes(FRESH_MS)); void loadHomeExamples(); From 840bb28bd23d978cdbc54afb2705a6836e41bf9a Mon Sep 17 00:00:00 2001 From: "PC-JASPER\\jasper" Date: Mon, 14 Sep 2026 16:05:34 +0200 Subject: [PATCH 4/5] Added `more about beacon` button to side bar bottom --- src/lib/components/sidebar/AppSidebar.svelte | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/components/sidebar/AppSidebar.svelte b/src/lib/components/sidebar/AppSidebar.svelte index 5e70c28..bc5f63a 100644 --- a/src/lib/components/sidebar/AppSidebar.svelte +++ b/src/lib/components/sidebar/AppSidebar.svelte @@ -16,6 +16,7 @@ import EyeIcon from '@lucide/svelte/icons/eye'; import TextSearchIcon from '@lucide/svelte/icons/text-search'; import BookOpenIcon from '@lucide/svelte/icons/book-open'; + import InfoIcon from '@lucide/svelte/icons/info'; import Table2Icon from '@lucide/svelte/icons/table-2'; import LinkIcon from '@lucide/svelte/icons/link-2'; import CpuIcon from '@lucide/svelte/icons/cpu'; @@ -101,6 +102,12 @@ ]; const footer: MenuItem[] = [ + { + title: 'More about Studio', + url: 'https://beacon-datalake.org/ecosystem/beacon-studio', + icon: InfoIcon, + target: '_blank' + }, { title: 'Documentation', url: 'https://maris-development.github.io/beacon/', From 432f8381d57aea0391000e1691db8b51c01d7268 Mon Sep 17 00:00:00 2001 From: "PC-JASPER\\jasper" Date: Mon, 14 Sep 2026 16:57:36 +0200 Subject: [PATCH 5/5] fix: filters on float params were applied as strings --- .../components/query-builder/ParameterFilter.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/components/query-builder/ParameterFilter.svelte b/src/lib/components/query-builder/ParameterFilter.svelte index 69a64cd..7feaeff 100644 --- a/src/lib/components/query-builder/ParameterFilter.svelte +++ b/src/lib/components/query-builder/ParameterFilter.svelte @@ -51,7 +51,7 @@ {:else if filter.type === 'greater_than_numeric'}
- +
{:else if filter.type === 'greater_than_string'} @@ -69,7 +69,7 @@ {:else if filter.type === 'greater_than_or_equals_numeric'}
- +
{:else if filter.type === 'greater_than_or_equals_string'} @@ -87,7 +87,7 @@ {:else if filter.type === 'equals_numeric'}
- +
{:else if filter.type === 'equals_string'} @@ -105,7 +105,7 @@ {:else if filter.type === 'not_equals_numeric'}
- +
{:else if filter.type === 'not_equals_string'} @@ -123,7 +123,7 @@ {:else if filter.type === 'less_than_numeric'}
- +
{:else if filter.type === 'less_than_string'} @@ -142,7 +142,7 @@ {:else if filter.type === 'less_than_or_equals_numeric'}
- +
{:else if filter.type === 'less_than_or_equals_string'}