Added iframe block
This commit is contained in:
arkml 2026-04-18 12:10:40 +05:30 committed by GitHub
parent 1f58c1f6cb
commit acc655172d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1642 additions and 1 deletions

View file

@ -196,6 +196,17 @@ Embeds external content (YouTube videos, Figma designs, or generic links).
- \`caption\` (optional): Caption displayed below the embed
- YouTube and Figma render as iframes; generic shows a link card
### Iframe Block
Embeds an arbitrary web page or a locally-served dashboard in the note.
\`\`\`iframe
{"url": "http://localhost:3210/sites/example-dashboard/", "title": "Trend Dashboard", "height": 640}
\`\`\`
- \`url\` (required): Full URL to render. Use \`https://\` for remote sites, or \`http://localhost:3210/sites/<slug>/\` for local dashboards
- \`title\` (optional): Title shown above the iframe
- \`height\` (optional): Height in pixels. Good dashboard defaults are 480-800
- \`allow\` (optional): Custom iframe \`allow\` attribute when the page needs extra browser capabilities
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, create a local site in \`sites/<slug>/\` and use the localhost URL above
### Chart Block
Renders a chart from inline data.
\`\`\`chart
@ -220,8 +231,9 @@ Renders a styled table from structured data.
### Block Guidelines
- The JSON must be valid and on a single line (no pretty-printing)
- Insert blocks using \`workspace-editFile\` just like any other content
- When the user asks for a chart, table, or embed use blocks rather than plain Markdown tables or image links
- When the user asks for a chart, table, embed, or live dashboard use blocks rather than plain Markdown tables or image links
- When editing a note that already contains blocks, preserve them unless the user asks to change them
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`
## Best Practices

View file

@ -0,0 +1,606 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import type { Server } from 'node:http';
import chokidar, { type FSWatcher } from 'chokidar';
import express from 'express';
import { WorkDir } from '../config/config.js';
import { LOCAL_SITE_SCAFFOLD } from './templates.js';
export const LOCAL_SITES_PORT = 3210;
export const LOCAL_SITES_BASE_URL = `http://localhost:${LOCAL_SITES_PORT}`;
const LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height';
const SITE_RELOAD_MESSAGE = 'rowboat:site-changed';
const SITE_EVENTS_PATH = '__rowboat_events';
const SITE_RELOAD_DEBOUNCE_MS = 140;
const SITE_EVENTS_RETRY_MS = 1000;
const SITE_EVENTS_HEARTBEAT_MS = 15000;
const TEXT_EXTENSIONS = new Set([
'.css',
'.html',
'.js',
'.json',
'.map',
'.mjs',
'.svg',
'.txt',
'.xml',
]);
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webp': 'image/webp',
'.xml': 'application/xml; charset=utf-8',
};
const IFRAME_AUTOSIZE_BOOTSTRAP = String.raw`<script>
(() => {
const SITE_CHANGED_MESSAGE = '__ROWBOAT_SITE_CHANGED_MESSAGE__';
const SITE_EVENTS_PATH = '__ROWBOAT_SITE_EVENTS_PATH__';
let reloadRequested = false;
let reloadSource = null;
const getSiteSlug = () => {
const match = window.location.pathname.match(/^\/sites\/([^/]+)/i);
return match ? decodeURIComponent(match[1]) : null;
};
const scheduleReload = () => {
if (reloadRequested) return;
reloadRequested = true;
try {
reloadSource?.close();
} catch {
// ignore close failures
}
window.setTimeout(() => {
window.location.reload();
}, 80);
};
const connectLiveReload = () => {
const siteSlug = getSiteSlug();
if (!siteSlug || typeof EventSource === 'undefined') return;
const streamUrl = new URL('/sites/' + encodeURIComponent(siteSlug) + '/' + SITE_EVENTS_PATH, window.location.origin);
const source = new EventSource(streamUrl.toString());
reloadSource = source;
source.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data);
if (payload?.type === SITE_CHANGED_MESSAGE) {
scheduleReload();
}
} catch {
// ignore malformed payloads
}
});
window.addEventListener('beforeunload', () => {
try {
source.close();
} catch {
// ignore close failures
}
}, { once: true });
};
connectLiveReload();
if (window.parent === window || typeof window.parent?.postMessage !== 'function') return;
const MESSAGE_TYPE = '__ROWBOAT_IFRAME_HEIGHT_MESSAGE__';
const MIN_HEIGHT = 240;
let animationFrameId = 0;
let lastHeight = 0;
const applyEmbeddedStyles = () => {
const root = document.documentElement;
if (root) root.style.overflowY = 'hidden';
if (document.body) document.body.style.overflowY = 'hidden';
};
const measureHeight = () => {
const root = document.documentElement;
const body = document.body;
return Math.max(
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
root?.clientHeight ?? 0,
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
body?.clientHeight ?? 0,
);
};
const publishHeight = () => {
animationFrameId = 0;
applyEmbeddedStyles();
const nextHeight = Math.max(MIN_HEIGHT, Math.ceil(measureHeight()));
if (Math.abs(nextHeight - lastHeight) < 2) return;
lastHeight = nextHeight;
window.parent.postMessage({
type: MESSAGE_TYPE,
height: nextHeight,
href: window.location.href,
}, '*');
};
const schedulePublish = () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(publishHeight);
};
const resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(schedulePublish)
: null;
if (resizeObserver && document.documentElement) resizeObserver.observe(document.documentElement);
if (resizeObserver && document.body) resizeObserver.observe(document.body);
const mutationObserver = new MutationObserver(schedulePublish);
if (document.documentElement) {
mutationObserver.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
}
window.addEventListener('load', schedulePublish);
window.addEventListener('resize', schedulePublish);
if (document.fonts?.addEventListener) {
document.fonts.addEventListener('loadingdone', schedulePublish);
}
for (const delay of [0, 50, 150, 300, 600, 1200]) {
setTimeout(schedulePublish, delay);
}
schedulePublish();
})();
</script>`;
let localSitesServer: Server | null = null;
let startPromise: Promise<void> | null = null;
let localSitesWatcher: FSWatcher | null = null;
const siteEventClients = new Map<string, Set<express.Response>>();
const siteReloadTimers = new Map<string, NodeJS.Timeout>();
function isSafeSiteSlug(siteSlug: string): boolean {
return SITE_SLUG_RE.test(siteSlug);
}
function resolveSiteDir(siteSlug: string): string | null {
if (!isSafeSiteSlug(siteSlug)) return null;
return path.join(LOCAL_SITES_DIR, siteSlug);
}
function resolveRequestedPath(siteDir: string, requestPath: string): string | null {
const candidate = requestPath === '/' ? '/index.html' : requestPath;
const normalized = path.posix.normalize(candidate);
const relativePath = normalized.replace(/^\/+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || relativePath.includes('\0')) {
return null;
}
const absolutePath = path.resolve(siteDir, relativePath);
if (!absolutePath.startsWith(siteDir + path.sep) && absolutePath !== siteDir) {
return null;
}
return absolutePath;
}
function getRequestPath(req: express.Request): string {
const rawPath = req.url.split('?')[0] || '/';
return rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
}
function listLocalSites(): Array<{ slug: string; url: string }> {
if (!fs.existsSync(LOCAL_SITES_DIR)) return [];
return fs.readdirSync(LOCAL_SITES_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && isSafeSiteSlug(entry.name))
.map((entry) => ({
slug: entry.name,
url: `${LOCAL_SITES_BASE_URL}/sites/${entry.name}/`,
}))
.sort((a, b) => a.slug.localeCompare(b.slug));
}
function isPathInsideRoot(rootPath: string, candidatePath: string): boolean {
return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
}
async function writeIfMissing(filePath: string, content: string): Promise<void> {
try {
await fsp.access(filePath);
} catch {
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(filePath, content, 'utf8');
}
}
async function ensureLocalSiteScaffold(): Promise<void> {
await fsp.mkdir(LOCAL_SITES_DIR, { recursive: true });
await Promise.all(
Object.entries(LOCAL_SITE_SCAFFOLD).map(([relativePath, content]) =>
writeIfMissing(path.join(LOCAL_SITES_DIR, relativePath), content),
),
);
}
function injectIframeAutosizeBootstrap(html: string): string {
const bootstrap = IFRAME_AUTOSIZE_BOOTSTRAP
.replace('__ROWBOAT_IFRAME_HEIGHT_MESSAGE__', IFRAME_HEIGHT_MESSAGE)
.replace('__ROWBOAT_SITE_CHANGED_MESSAGE__', SITE_RELOAD_MESSAGE)
.replace('__ROWBOAT_SITE_EVENTS_PATH__', SITE_EVENTS_PATH)
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${bootstrap}\n</body>`)
}
return `${html}\n${bootstrap}`
}
function getSiteSlugFromAbsolutePath(absolutePath: string): string | null {
const relativePath = path.relative(LOCAL_SITES_DIR, absolutePath);
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const [siteSlug] = relativePath.split(path.sep);
return siteSlug && isSafeSiteSlug(siteSlug) ? siteSlug : null;
}
function removeSiteEventClient(siteSlug: string, res: express.Response): void {
const clients = siteEventClients.get(siteSlug);
if (!clients) return;
clients.delete(res);
if (clients.size === 0) {
siteEventClients.delete(siteSlug);
}
}
function broadcastSiteReload(siteSlug: string, changedPath: string): void {
const clients = siteEventClients.get(siteSlug);
if (!clients || clients.size === 0) return;
const payload = JSON.stringify({
type: SITE_RELOAD_MESSAGE,
siteSlug,
changedPath,
at: Date.now(),
});
for (const res of Array.from(clients)) {
try {
res.write(`data: ${payload}\n\n`);
} catch {
removeSiteEventClient(siteSlug, res);
}
}
}
function scheduleSiteReload(siteSlug: string, changedPath: string): void {
const existingTimer = siteReloadTimers.get(siteSlug);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
siteReloadTimers.delete(siteSlug);
broadcastSiteReload(siteSlug, changedPath);
}, SITE_RELOAD_DEBOUNCE_MS);
siteReloadTimers.set(siteSlug, timer);
}
async function startSiteWatcher(): Promise<void> {
if (localSitesWatcher) return;
const watcher = chokidar.watch(LOCAL_SITES_DIR, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 180,
pollInterval: 50,
},
});
watcher
.on('all', (eventName, absolutePath) => {
if (!['add', 'addDir', 'change', 'unlink', 'unlinkDir'].includes(eventName)) return;
const siteSlug = getSiteSlugFromAbsolutePath(absolutePath);
if (!siteSlug) return;
const siteRoot = path.join(LOCAL_SITES_DIR, siteSlug);
const relativePath = path.relative(siteRoot, absolutePath);
const normalizedPath = !relativePath || relativePath === '.'
? '.'
: relativePath.split(path.sep).join('/');
scheduleSiteReload(siteSlug, normalizedPath);
})
.on('error', (error: unknown) => {
console.error('[LocalSites] Watcher error:', error);
});
localSitesWatcher = watcher;
}
function handleSiteEventsRequest(req: express.Request, res: express.Response): void {
const siteSlugParam = req.params.siteSlug;
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
if (!siteSlug || !isSafeSiteSlug(siteSlug)) {
res.status(400).json({ error: 'Invalid site slug' });
return;
}
const clients = siteEventClients.get(siteSlug) ?? new Set<express.Response>();
siteEventClients.set(siteSlug, clients);
clients.add(res);
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
res.write(`retry: ${SITE_EVENTS_RETRY_MS}\n`);
res.write(`event: ready\ndata: {"ok":true}\n\n`);
const heartbeat = setInterval(() => {
try {
res.write(`: keepalive ${Date.now()}\n\n`);
} catch {
clearInterval(heartbeat);
removeSiteEventClient(siteSlug, res);
}
}, SITE_EVENTS_HEARTBEAT_MS);
const cleanup = () => {
clearInterval(heartbeat);
removeSiteEventClient(siteSlug, res);
};
req.on('close', cleanup);
res.on('close', cleanup);
}
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
const extension = path.extname(filePath).toLowerCase();
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
const stats = await fsp.stat(filePath);
res.status(200);
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Length', String(stats.size));
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
if (method === 'HEAD') {
res.end();
return;
}
if (TEXT_EXTENSIONS.has(extension)) {
let text = await fsp.readFile(filePath, 'utf8');
if (extension === '.html') {
text = injectIframeAutosizeBootstrap(text);
}
res.setHeader('Content-Length', String(Buffer.byteLength(text)));
res.end(text);
return;
}
const data = await fsp.readFile(filePath);
res.end(data);
}
async function sendSiteResponse(req: express.Request, res: express.Response): Promise<void> {
const siteSlugParam = req.params.siteSlug;
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
const siteDir = siteSlug ? resolveSiteDir(siteSlug) : null;
if (!siteDir) {
res.status(400).json({ error: 'Invalid site slug' });
return;
}
if (!fs.existsSync(siteDir) || !fs.statSync(siteDir).isDirectory()) {
res.status(404).json({ error: 'Site not found' });
return;
}
const realSitesDir = fs.realpathSync(LOCAL_SITES_DIR);
const realSiteDir = fs.realpathSync(siteDir);
if (!isPathInsideRoot(realSitesDir, realSiteDir)) {
res.status(403).json({ error: 'Site path escapes sites directory' });
return;
}
const requestedPath = resolveRequestedPath(siteDir, getRequestPath(req));
if (!requestedPath) {
res.status(400).json({ error: 'Invalid site path' });
return;
}
const requestedExt = path.extname(requestedPath);
if (fs.existsSync(requestedPath)) {
const stat = fs.statSync(requestedPath);
if (stat.isDirectory()) {
const indexPath = path.join(requestedPath, 'index.html');
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
const realIndexPath = fs.realpathSync(indexPath);
if (!isPathInsideRoot(realSiteDir, realIndexPath)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, indexPath, req.method);
return;
}
} else if (stat.isFile()) {
const realRequestedPath = fs.realpathSync(requestedPath);
if (!isPathInsideRoot(realSiteDir, realRequestedPath)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, requestedPath, req.method);
return;
}
}
if (requestedExt) {
res.status(404).json({ error: 'Asset not found' });
return;
}
const spaFallback = path.join(siteDir, 'index.html');
if (!fs.existsSync(spaFallback) || !fs.statSync(spaFallback).isFile()) {
res.status(404).json({ error: 'Site entrypoint not found' });
return;
}
const realFallback = fs.realpathSync(spaFallback);
if (!isPathInsideRoot(realSiteDir, realFallback)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, spaFallback, req.method);
}
function createLocalSitesApp(): express.Express {
const app = express();
app.get('/health', (_req, res) => {
res.json({
ok: true,
baseUrl: LOCAL_SITES_BASE_URL,
sitesDir: LOCAL_SITES_DIR,
});
});
app.get('/sites', (_req, res) => {
res.json({
sites: listLocalSites(),
});
});
app.get(`/sites/:siteSlug/${SITE_EVENTS_PATH}`, (req, res) => {
handleSiteEventsRequest(req, res);
});
app.use('/sites/:siteSlug', (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.status(405).json({ error: 'Method not allowed' });
return;
}
void sendSiteResponse(req, res).catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
});
});
return app;
}
async function startServer(): Promise<void> {
if (localSitesServer) return;
const app = createLocalSitesApp();
await new Promise<void>((resolve, reject) => {
const server = app.listen(LOCAL_SITES_PORT, 'localhost', () => {
localSitesServer = server;
console.log('[LocalSites] Server starting.');
console.log(` Sites directory: ${LOCAL_SITES_DIR}`);
console.log(` Base URL: ${LOCAL_SITES_BASE_URL}`);
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${LOCAL_SITES_PORT} is already in use.`));
return;
}
reject(error);
});
});
}
export async function init(): Promise<void> {
if (localSitesServer) return;
if (startPromise) return startPromise;
startPromise = (async () => {
try {
await ensureLocalSiteScaffold();
await startSiteWatcher();
await startServer();
} catch (error) {
await shutdown();
throw error;
}
})().finally(() => {
startPromise = null;
});
return startPromise;
}
export async function shutdown(): Promise<void> {
const watcher = localSitesWatcher;
localSitesWatcher = null;
if (watcher) {
await watcher.close();
}
for (const timer of siteReloadTimers.values()) {
clearTimeout(timer);
}
siteReloadTimers.clear();
for (const clients of siteEventClients.values()) {
for (const res of clients) {
try {
res.end();
} catch {
// ignore close failures
}
}
}
siteEventClients.clear();
const server = localSitesServer;
localSitesServer = null;
if (!server) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}

View file

@ -0,0 +1,625 @@
export const LOCAL_SITE_SCAFFOLD: Record<string, string> = {
'README.md': `# Local Sites
Anything inside this folder is available at:
\`http://localhost:3210/sites/<slug>/\`
Examples:
- \`sites/example-dashboard/\` -> \`http://localhost:3210/sites/example-dashboard/\`
- \`sites/team-ops/\` -> \`http://localhost:3210/sites/team-ops/\`
You can embed a local site in a note with:
\`\`\`iframe
{"url":"http://localhost:3210/sites/example-dashboard/","title":"Signal Deck","height":640,"caption":"Local dashboard served from sites/example-dashboard"}
\`\`\`
Notes:
- The app serves each site with SPA-friendly routing, so client-side routers work
- Local HTML pages auto-expand inside Rowboat iframe blocks to fit their content height
- Put an \`index.html\` file at the site root
- Remote APIs still need to allow browser requests from a local page
`,
'example-dashboard/index.html': `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signal Deck</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<header class="hero">
<div>
<p class="eyebrow">Local iframe sample · external APIs</p>
<h1>Signal Deck</h1>
<p class="lede">
A locally-served dashboard designed to live inside a Rowboat note. It fetches
live signals from public APIs and stays readable at note width.
</p>
</div>
<div class="hero-status" id="hero-status">Booting dashboard...</div>
</header>
<section class="metric-grid" id="metric-grid"></section>
<section class="board">
<article class="panel">
<div class="panel-header">
<div>
<p class="panel-kicker">Hacker News</p>
<h2>Live headlines</h2>
</div>
<span class="panel-chip">public API</span>
</div>
<div class="story-list" id="story-list"></div>
</article>
<article class="panel">
<div class="panel-header">
<div>
<p class="panel-kicker">GitHub</p>
<h2>Repo pulse</h2>
</div>
<span class="panel-chip">public API</span>
</div>
<div class="repo-list" id="repo-list"></div>
</article>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
`,
'example-dashboard/styles.css': `:root {
color-scheme: dark;
--bg: #090816;
--panel: rgba(18, 16, 39, 0.88);
--panel-strong: rgba(26, 23, 54, 0.96);
--line: rgba(255, 255, 255, 0.08);
--text: #f5f7ff;
--muted: rgba(230, 235, 255, 0.68);
--cyan: #66e2ff;
--lime: #b7ff6a;
--amber: #ffcb6b;
--pink: #ff7ed1;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top, rgba(74, 51, 175, 0.28), transparent 34%),
linear-gradient(180deg, #0c0b1d 0%, var(--bg) 100%);
}
.ambient {
position: fixed;
inset: auto;
width: 320px;
height: 320px;
border-radius: 999px;
filter: blur(70px);
pointer-events: none;
opacity: 0.35;
}
.ambient-one {
top: -80px;
right: -40px;
background: rgba(102, 226, 255, 0.22);
}
.ambient-two {
bottom: -120px;
left: -60px;
background: rgba(255, 126, 209, 0.18);
}
.shell {
position: relative;
max-width: 1180px;
margin: 0 auto;
padding: 32px 24px 40px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.eyebrow,
.panel-kicker {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 11px;
color: var(--cyan);
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.05em;
}
.lede {
max-width: 620px;
margin-top: 12px;
color: var(--muted);
line-height: 1.55;
font-size: 15px;
}
.hero-status {
flex-shrink: 0;
min-width: 180px;
padding: 12px 14px;
border: 1px solid rgba(102, 226, 255, 0.18);
border-radius: 16px;
background: rgba(14, 17, 32, 0.62);
color: var(--muted);
font-size: 13px;
line-height: 1.4;
box-shadow: var(--shadow);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.metric-card,
.panel {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
var(--panel);
box-shadow: var(--shadow);
}
.metric-card {
padding: 18px;
min-height: 152px;
}
.metric-card::after,
.panel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 40%);
pointer-events: none;
}
.metric-label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.metric-value {
margin-top: 16px;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 0.95;
letter-spacing: -0.06em;
}
.metric-detail {
margin-top: 12px;
color: var(--muted);
font-size: 13px;
}
.metric-spark {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 6px;
align-items: end;
height: 40px;
margin-top: 18px;
}
.metric-spark span {
display: block;
border-radius: 999px 999px 3px 3px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.1));
}
.board {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 18px;
}
.panel {
padding: 20px;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.panel-header h2 {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.3rem;
letter-spacing: -0.04em;
}
.panel-chip {
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: var(--muted);
font-size: 12px;
}
.story-list,
.repo-list {
display: grid;
gap: 12px;
}
.story-item,
.repo-item {
position: relative;
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 18px;
background: var(--panel-strong);
}
.story-rank {
position: absolute;
top: 14px;
right: 14px;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.18);
}
.story-item a,
.repo-item a {
color: var(--text);
text-decoration: none;
}
.story-item a:hover,
.repo-item a:hover {
color: var(--cyan);
}
.story-title,
.repo-name {
padding-right: 34px;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
.story-meta,
.repo-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.story-pill,
.repo-pill {
padding: 5px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
.repo-description {
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.empty-state {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
font-size: 14px;
}
@media (max-width: 940px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.board {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.shell {
padding: 22px 14px 28px;
}
.hero {
flex-direction: column;
}
.hero-status {
width: 100%;
}
.metric-grid {
grid-template-columns: 1fr;
}
.panel,
.metric-card {
border-radius: 18px;
}
}
`,
'example-dashboard/app.js': `const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const reposConfig = [
{
slug: 'rowboatlabs/rowboat',
label: 'Rowboat',
description: 'AI coworker with memory',
},
{
slug: 'openai/openai-cookbook',
label: 'OpenAI Cookbook',
description: 'Examples and guides for building with OpenAI APIs',
},
];
const fallbackStories = [
{ id: 1, title: 'AI product launches keep getting more opinionated', score: 182, descendants: 49, by: 'analyst', url: '#' },
{ id: 2, title: 'Designing dashboards that can survive a narrow iframe', score: 141, descendants: 26, by: 'maker', url: '#' },
{ id: 3, title: 'Why local mini-apps inside notes are underrated', score: 119, descendants: 18, by: 'builder', url: '#' },
{ id: 4, title: 'Teams want live data in docs, not screenshots', score: 97, descendants: 14, by: 'operator', url: '#' },
];
const fallbackRepos = [
{ ...reposConfig[0], stars: 1280, forks: 144, issues: 28, url: 'https://github.com/rowboatlabs/rowboat' },
{ ...reposConfig[1], stars: 71600, forks: 11300, issues: 52, url: 'https://github.com/openai/openai-cookbook' },
];
const metricGrid = document.getElementById('metric-grid');
const storyList = document.getElementById('story-list');
const repoList = document.getElementById('repo-list');
const heroStatus = document.getElementById('hero-status');
async function fetchJson(url) {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error('Request failed with status ' + response.status);
}
return response.json();
}
async function loadRepos() {
try {
const repos = await Promise.all(
reposConfig.map(async (repo) => {
const data = await fetchJson('https://api.github.com/repos/' + repo.slug);
return {
...repo,
stars: data.stargazers_count,
forks: data.forks_count,
issues: data.open_issues_count,
url: data.html_url,
};
}),
);
return repos;
} catch {
return fallbackRepos;
}
}
async function loadStories() {
try {
const ids = await fetchJson('https://hacker-news.firebaseio.com/v0/topstories.json');
const stories = await Promise.all(
ids.slice(0, 4).map((id) =>
fetchJson('https://hacker-news.firebaseio.com/v0/item/' + id + '.json'),
),
);
return stories
.filter(Boolean)
.map((story) => ({
id: story.id,
title: story.title,
score: story.score || 0,
descendants: story.descendants || 0,
by: story.by || 'unknown',
url: story.url || ('https://news.ycombinator.com/item?id=' + story.id),
}));
} catch {
return fallbackStories;
}
}
function metricSpark(values) {
const max = Math.max(...values, 1);
const bars = values.map((value) => {
const height = Math.max(18, Math.round((value / max) * 40));
return '<span style="height:' + height + 'px"></span>';
});
return '<div class="metric-spark">' + bars.join('') + '</div>';
}
function renderMetrics(repos, stories) {
const leadRepo = repos[0];
const companionRepo = repos[1];
const topStory = stories[0];
const averageScore = Math.round(
stories.reduce((sum, story) => sum + story.score, 0) / Math.max(stories.length, 1),
);
const metrics = [
{
label: 'Rowboat stars',
value: formatter.format(leadRepo.stars),
detail: formatter.format(leadRepo.forks) + ' forks · ' + leadRepo.issues + ' open issues',
spark: [leadRepo.stars * 0.58, leadRepo.stars * 0.71, leadRepo.stars * 0.88, leadRepo.stars],
accent: 'var(--cyan)',
},
{
label: 'Cookbook stars',
value: formatter.format(companionRepo.stars),
detail: formatter.format(companionRepo.forks) + ' forks · ' + companionRepo.issues + ' open issues',
spark: [companionRepo.stars * 0.76, companionRepo.stars * 0.81, companionRepo.stars * 0.93, companionRepo.stars],
accent: 'var(--lime)',
},
{
label: 'Top story score',
value: formatter.format(topStory.score),
detail: topStory.descendants + ' comments · by ' + topStory.by,
spark: stories.map((story) => story.score),
accent: 'var(--amber)',
},
{
label: 'Average HN score',
value: formatter.format(averageScore),
detail: stories.length + ' live stories in this panel',
spark: stories.map((story) => story.descendants + 10),
accent: 'var(--pink)',
},
];
metricGrid.innerHTML = metrics
.map((metric) => (
'<article class="metric-card" style="box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 80px rgba(0,0,0,0.34), 0 0 0 1px color-mix(in srgb, ' + metric.accent + ' 16%, transparent);">' +
'<div class="metric-label">' + metric.label + '</div>' +
'<div class="metric-value">' + metric.value + '</div>' +
'<div class="metric-detail">' + metric.detail + '</div>' +
metricSpark(metric.spark) +
'</article>'
))
.join('');
}
function renderStories(stories) {
storyList.innerHTML = stories
.map((story, index) => (
'<article class="story-item">' +
'<div class="story-rank">0' + (index + 1) + '</div>' +
'<a class="story-title" href="' + story.url + '" target="_blank" rel="noreferrer">' + story.title + '</a>' +
'<div class="story-meta">' +
'<span class="story-pill">' + formatter.format(story.score) + ' pts</span>' +
'<span class="story-pill">' + story.descendants + ' comments</span>' +
'<span class="story-pill">by ' + story.by + '</span>' +
'</div>' +
'</article>'
))
.join('');
}
function renderRepos(repos) {
repoList.innerHTML = repos
.map((repo) => (
'<article class="repo-item">' +
'<a class="repo-name" href="' + repo.url + '" target="_blank" rel="noreferrer">' + repo.label + '</a>' +
'<p class="repo-description">' + repo.description + '</p>' +
'<div class="repo-meta">' +
'<span class="repo-pill">' + formatter.format(repo.stars) + ' stars</span>' +
'<span class="repo-pill">' + formatter.format(repo.forks) + ' forks</span>' +
'<span class="repo-pill">' + repo.issues + ' open issues</span>' +
'</div>' +
'</article>'
))
.join('');
}
function renderErrorState(message) {
metricGrid.innerHTML = '<div class="empty-state">' + message + '</div>';
storyList.innerHTML = '<div class="empty-state">No stories available.</div>';
repoList.innerHTML = '<div class="empty-state">No repositories available.</div>';
}
async function refresh() {
heroStatus.textContent = 'Refreshing live signals...';
try {
const [repos, stories] = await Promise.all([loadRepos(), loadStories()]);
if (!repos.length || !stories.length) {
renderErrorState('The sample site loaded, but the data sources returned no content.');
heroStatus.textContent = 'Loaded with empty data.';
return;
}
renderMetrics(repos, stories);
renderStories(stories);
renderRepos(repos);
heroStatus.textContent = 'Updated ' + new Date().toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
}) + ' · embedded from sites/example-dashboard';
} catch (error) {
renderErrorState('This site is running, but the live fetch failed. The local scaffold is still valid.');
heroStatus.textContent = error instanceof Error ? error.message : 'Refresh failed';
}
}
refresh();
setInterval(refresh, 120000);
`,
}