`, the 5 env-var reference
+ tables, the links row, and the `.copy-btn` client `
diff --git a/src/components/studio/Explorer.astro b/src/components/studio/Explorer.astro
index bab7ef9..c27de96 100644
--- a/src/components/studio/Explorer.astro
+++ b/src/components/studio/Explorer.astro
@@ -5,10 +5,8 @@ interface Props {
active?: string;
idPrefix?: string; // to keep ids unique between desktop sidebar & mobile drawer
showConnectionsLabel?: boolean;
- standalone?: boolean;
}
-const { active = 'home', idPrefix = 'exp', showConnectionsLabel = true, standalone = false } = Astro.props;
-const linkBase = standalone ? '/' : '';
+const { active = 'home', idPrefix = 'exp', showConnectionsLabel = true } = Astro.props;
---
-
+
diff --git a/src/components/studio/StudioShell.astro b/src/components/studio/StudioShell.astro
index b328a2d..efecfad 100644
--- a/src/components/studio/StudioShell.astro
+++ b/src/components/studio/StudioShell.astro
@@ -1,6 +1,6 @@
---
// src/components/studio/StudioShell.astro
-// The shared IDE chrome. index.astro and deploy.astro both render through this.
+// Shared IDE chrome for the home page and every [section] page.
import TopBar from './TopBar.astro';
import MobileTopBar from './MobileTopBar.astro';
import Explorer from './Explorer.astro';
@@ -20,12 +20,12 @@ const { active = 'home', standalone = false } = Astro.props;
data-initial-active={active}
data-standalone={standalone ? '' : undefined}
>
-
-
+
+
-
-
-
+
+
-
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
index fb022c5..ade53dc 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -2,28 +2,12 @@
import Layout from '../layouts/Layout.astro';
import StudioShell from '../components/studio/StudioShell.astro';
import SectionShell from '../components/studio/SectionShell.astro';
-import { sectionById } from '../data/sections';
-
import HomeSection from '../components/sections/HomeSection.astro';
-import FeaturesSection from '../components/sections/FeaturesSection.astro';
-import DatabasesSection from '../components/sections/DatabasesSection.astro';
-import CompareSection from '../components/sections/CompareSection.astro';
-import TechStackSection from '../components/sections/TechStackSection.astro';
-import GetStartedSection from '../components/sections/GetStartedSection.astro';
-import FaqSection from '../components/sections/FaqSection.astro';
-import DeploySection from '../components/sections/DeploySection.astro';
-
-const s = sectionById;
+import { sectionById } from '../data/sections';
+const home = sectionById['home'];
---
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/src/scripts/studio.ts b/src/scripts/studio.ts
index 0746973..8a9de0a 100644
--- a/src/scripts/studio.ts
+++ b/src/scripts/studio.ts
@@ -7,86 +7,52 @@ import { filterItems, fuzzyMatch } from './lib/filter';
/**
* Studio interaction layer (progressive enhancement).
* - Adds `.js` to the shell → desktop switches to viewport-locked single-view.
- * - Explorer selection swaps the active section (desktop) / scrolls (mobile).
- * - URL hash routing keeps deep links + back/forward working.
- * - Mobile drawer open/close; explorer column (schema) expand/collapse.
+ * - Explorer links are real URLs; navigating renders exactly one section per page.
+ * - `syncActive()` highlights the active Explorer row + updates StatusBar from the URL.
+ * - `astro:page-load` lifecycle re-runs `syncActive()` on every navigation.
*/
const studio = document.querySelector('[data-studio]');
-const standalone = !!studio && studio.hasAttribute('data-standalone');
-const initialActive = studio?.dataset.initialActive || 'home';
-const isDesktop = () => window.matchMedia('(min-width: 1024px)').matches;
-const ids = new Set(sections.map((s) => s.id));
-
-function setActive(id: string, opts: { scroll?: boolean } = {}) {
- if (!ids.has(id)) id = initialActive;
- // Focused/standalone pages render a single section; if the requested one
- // isn't in the DOM, keep the current view instead of blanking it.
- if (!document.querySelector(`[data-section="${id}"]`)) return;
- const meta = sectionById[id];
-
- // Toggle sections (desktop locked mode uses .is-active; CSS ignores it on mobile)
- document.querySelectorAll('[data-section]').forEach((el) => {
- el.classList.toggle('is-active', el.dataset.section === id);
- });
- // Reset the active result pane scroll to top on desktop swap
- if (isDesktop()) {
- const pane = document.querySelector(`[data-section="${id}"] .studio-results`);
- pane?.scrollTo({ top: 0 });
- }
+/* ---- Slug ↔ id helpers ---- */
+const slugToId = Object.fromEntries(sections.map((s) => [s.slug, s.id]));
+const href = (slug: string) => (slug === '' ? '/' : `/${slug}`);
+
+function currentId(): string {
+ const seg = location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0] ?? '';
+ return slugToId[seg] ?? 'home';
+}
- // Explorer active highlight (side + drawer) — single .active class, styled in CSS
+function syncActive() {
+ const id = currentId();
+ const meta = sectionById[id] ?? sectionById['home'];
document.querySelectorAll('[data-section-link]').forEach((a) => {
const on = a.dataset.sectionLink === id;
a.closest('.exp-row')?.classList.toggle('active', on);
a.setAttribute('aria-current', on ? 'true' : 'false');
});
-
- // Status bar
- const tableEl = document.querySelector('[data-statusbar-table]');
- const rowsEl = document.querySelector('[data-statusbar-rows]');
- if (tableEl) tableEl.textContent = meta.table;
- if (rowsEl) rowsEl.textContent = String(meta.rows);
-
- if (opts.scroll && !isDesktop()) {
- document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
-}
-
-function currentHash(): string {
- return (location.hash || `#${initialActive}`).slice(1);
-}
-
-function onLinkClick(e: Event, id: string) {
- if (standalone) { closeDrawer(); return; } // links are /#id → let the browser navigate home
- if (isDesktop()) {
- e.preventDefault();
- if (location.hash !== `#${id}`) history.pushState(null, '', `#${id}`);
- setActive(id);
- } else {
- // mobile: let the anchor scroll naturally, just sync state + close drawer
- setActive(id);
- closeDrawer();
- }
+ const t = document.querySelector('[data-statusbar-table]');
+ const r = document.querySelector('[data-statusbar-rows]');
+ if (t) t.textContent = meta.table;
+ if (r) r.textContent = String(meta.rows);
}
/* ---- Mobile drawer ---- */
-const drawerRoot = document.querySelector('[data-drawer-root]');
-const drawerPanel = document.querySelector('[data-drawer-panel]');
-const drawerOpenBtn = document.querySelector('[data-drawer-open]');
-
function openDrawer() {
+ const drawerRoot = document.querySelector('[data-drawer-root]');
+ const drawerPanel = document.querySelector('[data-drawer-panel]');
if (!drawerRoot || !drawerPanel) return;
drawerRoot.classList.remove('hidden');
requestAnimationFrame(() => drawerPanel.classList.remove('-translate-x-full'));
- drawerOpenBtn?.setAttribute('aria-expanded', 'true');
+ document.querySelector('[data-drawer-open]')?.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
+ const drawerRoot = document.querySelector('[data-drawer-root]');
+ const drawerPanel = document.querySelector('[data-drawer-panel]');
if (!drawerRoot || !drawerPanel) return;
drawerPanel.classList.add('-translate-x-full');
- drawerOpenBtn?.setAttribute('aria-expanded', 'false');
+ document.querySelector('[data-drawer-open]')?.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
setTimeout(() => drawerRoot.classList.add('hidden'), 200);
}
@@ -94,7 +60,7 @@ function closeDrawer() {
/* ---- Explain panel toggle ---- */
function toggleExplain(trigger: HTMLElement) {
const section = trigger.closest('[data-section]');
- const id = section?.dataset.section ?? currentHash();
+ const id = section?.dataset.section ?? currentId();
const panel = document.querySelector(`[data-explain="${id}"]`);
if (!panel) return;
const open = panel.hasAttribute('hidden');
@@ -194,7 +160,7 @@ const studioConsole = {
};
function runQuery() {
- const id = currentHash();
+ const id = currentId();
const meta = sectionById[id] ?? sectionById['home'];
const pane = document.querySelector(`[data-section="${id}"] .studio-results`);
const allowMotion = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -208,11 +174,11 @@ function runQuery() {
}
async function copyLink() {
- const id = currentHash();
- const url = `${location.origin}${location.pathname}#${id}`;
+ const id = currentId();
+ const url = location.origin + href(sectionById[id]?.slug ?? '');
try {
await navigator.clipboard.writeText(url);
- studioConsole.ok(`copied link to #${id}`);
+ studioConsole.ok(`copied link to ${url}`);
} catch {
studioConsole.notice(`copy this: ${url}`);
}
@@ -231,7 +197,7 @@ function downloadBlob(content: string, filename: string, mime: string) {
}
function exportSection() {
- const id = currentHash();
+ const id = currentId();
const payloadEl = document.querySelector(`[data-export-payload="${id}"]`);
if (!payloadEl?.textContent) { studioConsole.notice('nothing to export from this view'); return; }
let rows: Row[];
@@ -248,30 +214,6 @@ function exportSection() {
studioConsole.ok(`exported ${filename} (${rows.length} rows)`);
}
-/* ---- Explorer search live-filter ---- */
-function wireExplorerSearch() {
- document.querySelectorAll('[data-explorer-search]').forEach((input) => {
- const scope = input.closest('[data-explorer-root]');
- if (!scope) return;
- const items = [...scope.querySelectorAll('[data-explorer-item]')];
- const apply = () => {
- const q = input.value;
- items.forEach((li) => {
- const name = li.dataset.explorerItem ?? '';
- li.hidden = fuzzyMatch(q, name) === null;
- });
- };
- input.addEventListener('input', apply);
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- const first = items.find((li) => !li.hidden);
- const id = first?.dataset.explorerItem;
- if (id) { location.hash = `#${id}`; input.blur(); }
- }
- });
- });
-}
-
/* ---- Command palette ---- */
interface PaletteItem { label: string; hint: string; run: () => void; }
@@ -279,10 +221,7 @@ function paletteItems(): PaletteItem[] {
const jumps: PaletteItem[] = sections.map((s) => ({
label: `Jump to ${s.table}`,
hint: `${s.rows} rows`,
- run: () => {
- if (standalone) location.href = `/#${s.id}`;
- else location.hash = `#${s.id}`;
- },
+ run: () => { location.href = href(s.slug); },
}));
const actions: PaletteItem[] = [
{ label: 'Copy link to current section', hint: 'clipboard', run: () => copyLink() },
@@ -362,79 +301,127 @@ function closePalette() {
paletteLastFocus?.focus();
}
-function init() {
- if (studio) studio.classList.add('js');
+/* ---- Delegated action click handler ---- */
+function onActionClick(e: Event) {
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+
+ // Chrome delegations: palette-close, drawer open/close, explorer column toggles
+ if (target.closest('[data-palette-close]')) { e.preventDefault(); closePalette(); return; }
+ if (target.closest('[data-drawer-open]')) { e.preventDefault(); openDrawer(); return; }
+ if (target.closest('[data-drawer-close]')) { e.preventDefault(); closeDrawer(); return; }
+ const toggleBtn = target.closest('[data-explorer-toggle]');
+ if (toggleBtn) {
+ e.preventDefault();
+ const id = toggleBtn.dataset.explorerToggle!;
+ toggleColumns(id, toggleBtn);
+ return;
+ }
- // Wire explorer links
- document.querySelectorAll('[data-section-link]').forEach((a) => {
- a.addEventListener('click', (e) => onLinkClick(e, a.dataset.sectionLink!));
- });
- // Wire column toggles
- document.querySelectorAll('[data-explorer-toggle]').forEach((btn) => {
- btn.addEventListener('click', () => toggleColumns(btn.dataset.explorerToggle!, btn));
- });
- // Drawer
- drawerOpenBtn?.addEventListener('click', openDrawer);
- document.querySelectorAll('[data-drawer-close]').forEach((el) =>
- el.addEventListener('click', closeDrawer),
- );
+ const el = target.closest('[data-action]');
+ if (!el) return;
+ const action = el.dataset.action;
+ if (action === 'notice') {
+ e.preventDefault();
+ const msg = NOTICES[el.dataset.notice ?? ''];
+ if (msg) studioConsole.push(msg);
+ }
+ if (action === 'explain') { e.preventDefault(); toggleExplain(el); }
+ if (action === 'run') { e.preventDefault(); runQuery(); }
+ if (action === 'copy-link') { e.preventDefault(); copyLink(); }
+ if (action === 'export') { e.preventDefault(); exportSection(); }
+ if (action === 'palette') { e.preventDefault(); openPalette(); }
+ if (action === 'results') {
+ e.preventDefault();
+ const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ document.querySelector(`[data-section="${currentId()}"] .studio-results`)?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
+ }
+}
- // Single delegated handler for all chrome controls.
- document.addEventListener('click', (e) => {
- const el = (e.target as HTMLElement).closest('[data-action]');
- if (!el) return;
- const action = el.dataset.action;
- if (action === 'notice') {
- e.preventDefault();
- const msg = NOTICES[el.dataset.notice ?? ''];
- if (msg) studioConsole.push(msg);
- }
- if (action === 'explain') { e.preventDefault(); toggleExplain(el); }
- if (action === 'run') { e.preventDefault(); runQuery(); }
- if (action === 'copy-link') { e.preventDefault(); copyLink(); }
- if (action === 'export') { e.preventDefault(); exportSection(); }
- if (action === 'palette') { e.preventDefault(); openPalette(); }
- if (action === 'results') {
- e.preventDefault();
- const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- document.querySelector(`[data-section="${currentHash()}"] .studio-results`)?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
- }
+/* ---- Keyboard handler ---- */
+function onKeydown(e: KeyboardEvent) {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); return; }
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); openPalette(); return; }
+ const root = document.querySelector('[data-palette-root]');
+ if (!root || root.hasAttribute('hidden')) return;
+ if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
+ else if (e.key === 'Tab') { e.preventDefault(); (document.querySelector('[data-palette-input]') as HTMLElement | null)?.focus(); }
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ if (paletteFiltered.length === 0) { e.preventDefault(); return; }
+ if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(Math.min(paletteHighlight + 1, paletteFiltered.length - 1)); }
+ else { e.preventDefault(); setHighlight(Math.max(paletteHighlight - 1, 0)); }
+ }
+ else if (e.key === 'Enter') { e.preventDefault(); paletteFiltered[paletteHighlight]?.run(); closePalette(); }
+}
+
+/* ---- Explorer search filter helper ---- */
+function filterExplorer(input: HTMLInputElement) {
+ const scope = input.closest('[data-explorer-root]');
+ if (!scope) return;
+ const q = input.value;
+ scope.querySelectorAll('[data-explorer-item]').forEach((li) => {
+ const name = li.dataset.explorerItem ?? '';
+ li.hidden = fuzzyMatch(q, name) === null;
});
+}
- // Hash routing — hashchange covers in-page link nav; popstate covers
- // browser back/forward after our pushState() desktop swaps.
- window.addEventListener('hashchange', () => setActive(currentHash()));
- window.addEventListener('popstate', () => setActive(currentHash()));
+/* ---- Lifecycle ---- */
+let wiredOnce = false;
+
+function wireOnce() {
+ if (wiredOnce) return;
+ wiredOnce = true;
+ studio?.classList.add('js');
+ document.addEventListener('click', onActionClick);
+ window.addEventListener('keydown', onKeydown);
+
+ document.addEventListener('astro:before-swap', () => {
+ // force-close the persisted drawer so it doesn't carry over to the next page
+ const root = document.querySelector('[data-drawer-root]');
+ const panel = document.querySelector('[data-drawer-panel]');
+ panel?.classList.add('-translate-x-full');
+ root?.classList.add('hidden');
+ document.body.style.overflow = '';
+ document.querySelector('[data-drawer-open]')?.setAttribute('aria-expanded', 'false');
+ });
- window.addEventListener('keydown', (e) => {
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); }
+ // Delegated input: explorer search filter + palette input
+ document.addEventListener('input', (e) => {
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+ const searchInput = target.closest('[data-explorer-search]');
+ if (searchInput) { filterExplorer(searchInput); return; }
+ const paletteInput = target.closest('[data-palette-input]');
+ if (paletteInput) { renderPalette(paletteInput.value); }
});
- // Palette open/close + keyboard
- document.querySelector('[data-palette-close]')?.addEventListener('click', closePalette);
- const paletteInput = document.querySelector('[data-palette-input]');
- paletteInput?.addEventListener('input', () => renderPalette(paletteInput.value));
- window.addEventListener('keydown', (e) => {
- if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); openPalette(); return; }
- const root = document.querySelector('[data-palette-root]');
- if (!root || root.hasAttribute('hidden')) return;
- if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
- else if (e.key === 'Tab') { e.preventDefault(); (document.querySelector('[data-palette-input]') as HTMLElement | null)?.focus(); }
- else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
- if (paletteFiltered.length === 0) { e.preventDefault(); return; }
- if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(Math.min(paletteHighlight + 1, paletteFiltered.length - 1)); }
- else { e.preventDefault(); setHighlight(Math.max(paletteHighlight - 1, 0)); }
+ // Delegated keydown: explorer search Enter-to-jump
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Enter') return;
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+ const searchInput = target.closest('[data-explorer-search]');
+ if (!searchInput) return;
+ const scope = searchInput.closest('[data-explorer-root]');
+ if (!scope) return;
+ const first = scope.querySelector('[data-explorer-item]:not([hidden])');
+ const id = first?.dataset.explorerItem;
+ if (id) {
+ const slug = sectionById[id]?.slug ?? id;
+ location.href = href(slug);
+ searchInput.blur();
}
- else if (e.key === 'Enter') { e.preventDefault(); paletteFiltered[paletteHighlight]?.run(); closePalette(); }
});
-
- wireExplorerSearch();
-
- setActive(currentHash());
}
-if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', init);
-} else {
- init();
+function onPage() {
+ document.querySelector('[data-studio]')?.classList.add('js');
+ syncActive();
}
+
+function start() { wireOnce(); onPage(); }
+
+if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
+else start();
+
+document.addEventListener('astro:page-load', () => { wireOnce(); onPage(); });
diff --git a/src/styles/global.css b/src/styles/global.css
index 628807e..2990791 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -97,7 +97,7 @@
Studio layout — progressive enhancement
• Default (no-JS, any width): sections stack & the page scrolls.
• JS + desktop (.studio.js @ lg): viewport-locked IDE, single
- active section swaps in the result pane.
+ section per page is always shown (no swap).
============================================================ */
.studio-section { display: block; }
.caret-icon { display: inline-block; transition: transform 0.15s ease; }
@@ -128,8 +128,7 @@ details[open] > summary .faq-sign::before { content: '−'; }
@media (min-width: 1024px) {
.studio.js { height: 100vh; height: 100dvh; overflow: hidden; }
.studio.js .studio-pane { min-height: 0; overflow: hidden; }
- .studio.js .studio-section { display: none; height: 100%; min-height: 0; }
- .studio.js .studio-section.is-active { display: flex; flex-direction: column; }
+ .studio.js .studio-section { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.studio.js .studio-results { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
}