
Live Dashboard
- 73 installs
- 83.7k repo stars
- Updated August 5, 2026
- nexu-io/open-design
Helps with ai & agent building tasks during AI-assisted development.
About
live-dashboard is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- live-dashboard
- AI & Agent Building
- AI-coding skill
Live Dashboard by the numbers
- 73 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,582 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nexu-io/open-design --skill live-dashboardAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 73 |
|---|---|
| repo stars | ★ 83.7k |
| Last updated | August 5, 2026 |
| Repository | nexu-io/open-design ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Live Dashboard
You are a senior product-designer-engineer building a Live Artifact — an HTML page that behaves like a working dashboard, not a mockup. Your output ships, not only renders.
Pre-flight (must complete before emitting any HTML)
1. Read assets/template.html — start from this skeleton verbatim. Do not rebuild the shell from scratch. Override only what the user's brief or the active DESIGN.md require. 2. Read references/layouts.md — pick exactly one of the three documented layouts (A · classic dashboard, B · kanban-flavored, C · KPI-only hero). State your choice in your reply. 3. Read references/components.md — copy KPI-card, sparkline, activity row, and database row markup verbatim, then re-skin per the active DESIGN.md. Do not invent new component shapes. 4. Read references/connectors.md — only when inputs.connector !== mock. Emit a sibling connectors.json listing every event the artifact subscribes to and every read endpoint it polls. 5. Read references/checklist.md — every P0 row must be true before you emit index.html. Quote each P0 row inline in your reply with [x] or [ ]. Do not emit while any P0 is unchecked.
Build order
1. Lock visual direction from the active DESIGN.md. Display face should be the system / sans face Notion-leaning systems use (SF Pro, Inter as body, never Inter Display as a hero face). Body 14/22. 2. Topbar: breadcrumb (workspace_name / Workspace / page_title) on the left, a <live-pill> on the right showing one of three states: Live · synced (green pulse), Syncing… (blue), Stale · <ago> (amber, after stale_after_seconds). 3. Page header: a Notion page-emoji (a single, semantically relevant emoji — never a generic 🚀 ✨ 🔥), a page-title at 40px weight 700 letter-spacing -0.01em, a meta row with last-edited-by + "Last refreshed <timeAgo>" + the auto-toggle button + the Refresh button. 4. Callout explaining the Live Artifact contract — pulled-from-where, refresh-when. One line. No marketing language. 5. KPI grid: respect inputs.kpi_count. 1px hairline grid, no shadows, no rounded internal cards. Numbers font-variant-numeric: tabular-nums, weight 600, letter-spacing -0.01em. Each KPI gets a small grey delta line (↑ 6 vs last week). 6. Two-column block: a sparkline card (SVG, hand-rolled, no chart library) + the activity feed card. Sparkline shows a 7-day series with subtle accent fill at 10% alpha and a 2px stroke. 7. Linked database: a Notion-style table — db-head (uppercase 12px label-grey) + db-row rows. Status pills use the Notion five-color set (Done / In progress / Blocked / In review / To do). Person chips use a colored 18px round avatar with two-letter initials. 8. Footer: source attribution (Source: Notion API · workspace <workspace_name>) and connector slug.
Live behavior (the part that earns the "Live" in Live Artifact)
Wire these in a single <script> block at the bottom of index.html:
init()runsrefresh({silent: true})600ms after mount — the
"refresh on open" semantic.
- The Refresh button calls
refresh({silent: false}). Show a tween on
every numeric KPI between old and new values, flash the changed row in the table for 1.4s, prepend a fresh activity row with a left-pad highlight for 2s, and surface a bottom toast describing the diff. The tween/flash hooks are already wired in assets/template.html (tweenText() + .flash + .db-row.changed + .feed-row.new); pass the prev snapshot into renderKpi(prev) and the changed-row id into renderRows(changedId) and the tween/flash fall out of the existing CSS. Do not rebuild this from scratch.
setInterval(refresh, refresh_seconds * 1000)when Auto is on.- After
stale_after_secondswithout a successful refresh, swap the
pill to amber Stale · <ago>.
- Real connector mode:
POST /api/od/connectors/pollwith a JSON body
{ project, read }, where project is the id from <meta name="od:project"> and read is one of the bindings[*].reads[].id values declared in connectors.json. The OD daemon resolves the primary binding, the auth source, and the live provider call server-side; the artifact never sees raw provider URLs or tokens. See references/connectors.md for the wire shape and the daemon resolution order. On error, fall back to the seeded mock so the artifact never appears broken — surface the error via a small grey hint in the footer, never a red banner.
Self-critique (must run before emitting)
Score the artifact on the five dimensions inherited from skills/critique/: Philosophy · Hierarchy · Detail · Function · Innovation.
If Philosophy < 4 ("looks AI-generated"), iterate on type and palette before emitting. Quote the offending element in your reply and explain the fix. Do not emit if any dimension scores below 3.
Hard nos (anti-AI-slop)
- No purple→pink gradient header.
- No emoji icon strip across the top of the page.
- No rounded card with a 4px left-border accent.
- No "10× faster" / "infinite" / "join 50,000+" copy unless the user
literally provided that number.
- No glassmorphism / backdrop-blur on KPI cards.
- No colored progress bars under KPI numbers; the delta line is enough.
- Inter is body-only. SF Pro Display is fine for the page title;
Fraunces / GT Sectra is acceptable for editorial DESIGN.md variants.
Output contract
index.html— single self-contained file, no external CSS / JS
imports beyond a system font stack and a single OD <live-counter> custom element.
connectors.json— wheninputs.connector !== mock. See
references/connectors.md for the schema.
- Both files in the project cwd. Do not write anywhere else.
<!DOCTYPE html>
<!--
OD · live-dashboard · template seed
----------------------------------------------------------------------
This is the skeleton an agent starts from. The structure is locked.
The agent is expected to:
1. Replace every {{placeholder}} with the user's brief or DESIGN.md.
2. Fill the four SEED_* arrays at the bottom (people, rows, feed,
series) with plausible mock data — this is what shows when the
connector is offline or the user picks `inputs.connector === mock`.
3. Re-skin the CSS custom-property block (`:root`) using the active
DESIGN.md tokens. Do not introduce new property names.
4. Wire the Refresh button to a real connector by replacing the
contents of `seedNextChange()` with a `poll()` call into
`/api/od/connectors/poll`. See references/connectors.md.
5. Honor `prefers-reduced-motion: reduce` everywhere.
Hard nos: see references/checklist.md (P0 rows are non-negotiable).
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="od:project" content="{{project_id}}" />
<title>{{workspace_name}} · {{page_title}}</title>
<style>
:root {
--bg: #ffffff;
--bg-soft: #f7f6f3;
--bg-hover: #efeeec;
--ink: #37352f;
--ink-2: #787774;
--ink-3: #9b9a97;
--line: #ececea;
--line-strong: #d3d1cb;
--accent: oklch(72% 0.10 var(--accent-hue, 198));
--accent-ink: oklch(45% 0.12 var(--accent-hue, 198));
--pill-done-bg: #dbeddb; --pill-done-ink: #2b593f;
--pill-progress-bg: #fdecc8; --pill-progress-ink: #976d23;
--pill-blocked-bg: #ffe2dd; --pill-blocked-ink: #b13b2c;
--pill-todo-bg: #e9e5e3; --pill-todo-ink: #5a534f;
--pill-review-bg: #d3e5ef; --pill-review-ink: #1f5b78;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
"Noto Sans", "PingFang SC", "Hiragino Sans GB", sans-serif;
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
a { color: inherit; text-decoration: none; }
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
/* sidebar */
.side { background: var(--bg-soft); border-right: 1px solid var(--line);
padding: 14px 8px; user-select: none; }
.ws { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border-radius: var(--radius-sm); cursor: pointer; }
.ws:hover { background: var(--bg-hover); }
.ws-icon { width: 22px; height: 22px; border-radius: 4px;
background: linear-gradient(135deg,#1f1f1f,#3a3a3a); color: #fff;
display: grid; place-items: center; font-weight: 600; font-size: 12px; }
.ws-name { font-weight: 600; flex: 1; font-size: 14px; }
.ws-caret { color: var(--ink-3); font-size: 12px; }
.side-search { margin: 12px 4px 6px; padding: 5px 8px; color: var(--ink-3);
font-size: 13px; display: flex; align-items: center; gap: 8px;
border-radius: var(--radius-sm); cursor: pointer; }
.side-search:hover { background: var(--bg-hover); }
.side-search kbd { margin-left: auto; font-family: inherit; font-size: 11px;
color: var(--ink-3); background: #fff; border: 1px solid var(--line);
padding: 1px 5px; border-radius: 3px; }
.side-section { margin-top: 14px; padding: 0 8px; color: var(--ink-3);
font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
.nav { margin-top: 4px; display: flex; flex-direction: column; gap: 1px; }
.nav-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px;
border-radius: var(--radius-sm); color: var(--ink); cursor: pointer; }
.nav-item:hover { background: var(--bg-hover); }
.nav-item.active { background: #e8e6e1; font-weight: 500; }
.nav-icon { width: 18px; text-align: center; }
.nav-caret { margin-left: auto; color: var(--ink-3); font-size: 10px; }
/* main */
.main { display: flex; flex-direction: column; min-width: 0; }
.topbar { height: 44px; padding: 0 16px; border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 12px;
color: var(--ink-2); font-size: 13px; }
.crumbs span:not(:last-child)::after { content: "/"; margin: 0 6px; color: var(--ink-3); }
.topbar-spacer { flex: 1; }
.pill-live { display: inline-flex; align-items: center; gap: 6px;
padding: 2px 8px 2px 6px; background: #fff;
border: 1px solid var(--line-strong); border-radius: 999px;
font-size: 12px; color: var(--ink-2); }
.pill-live .dot { width: 7px; height: 7px; border-radius: 50%;
background: #2ecc71; box-shadow: 0 0 0 0 rgba(46,204,113,.55);
animation: pulse 1.8s infinite; }
.pill-live.syncing .dot { background: var(--accent); animation: none; }
.pill-live.stale .dot { background: #e6a23c; animation: none; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(46,204,113,.55); }
70% { box-shadow: 0 0 0 7px rgba(46,204,113,0); }
100% { box-shadow: 0 0 0 0 rgba(46,204,113,0); }
}
.page { padding: 56px max(80px, 8vw) 80px; max-width: 1100px; width: 100%; align-self: center; }
.page-cover { height: 24px; }
.page-emoji { font-size: 64px; line-height: 1; margin: -4px 0 12px; }
h1.page-title { font-size: calc(40px * var(--display-scale, 1));
line-height: 1.15; font-weight: 700; letter-spacing: -0.01em;
margin: 0 0 8px; color: var(--ink); }
.page-meta { display: flex; align-items: center; gap: 14px;
color: var(--ink-2); font-size: 13px; margin-bottom: 28px; }
.page-meta .updated { display: inline-flex; align-items: center; gap: 6px; }
.page-meta .updated .who { width: 18px; height: 18px; border-radius: 50%;
background: #f1c40f; color: #5a4500;
display: inline-grid; place-items: center;
font-size: 11px; font-weight: 700; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
font-size: 13px; color: var(--ink); background: #fff;
border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
cursor: pointer; transition: background .15s ease, transform .15s ease; }
.btn:hover { background: var(--bg-hover); }
.btn .ico { width: 14px; height: 14px; display: inline-block; }
.btn .ico.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.btn.primary { background: var(--accent); color: #fff; border-color: transparent; }
.btn.primary:hover { background: var(--accent-ink); }
.btn.ghost { border-color: transparent; }
.btn.ghost:hover { background: var(--bg-hover); }
.callout { background: var(--bg-soft); border-radius: var(--radius-md);
padding: 14px 16px; display: flex; gap: 12px;
align-items: flex-start; margin: 10px 0 28px; color: var(--ink); }
.callout .emj { font-size: 18px; line-height: 1.4; }
.callout small { color: var(--ink-2); }
.kpis { display: grid; grid-template-columns: repeat(var(--kpi-count, 4), 1fr);
gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: var(--radius-md); overflow: hidden; margin: 8px 0 32px; }
.kpi { background: #fff; padding: 18px 20px;
display: flex; flex-direction: column; gap: 4px; }
.kpi .label { color: var(--ink-2); font-size: 12px; letter-spacing: .03em;
text-transform: uppercase; font-weight: 500; }
.kpi .value { font-size: 32px; font-weight: 600; letter-spacing: -0.01em;
color: var(--ink); font-variant-numeric: tabular-nums;
transition: color .25s ease; }
.kpi .value.flash { color: var(--accent-ink); }
.kpi .delta { font-size: 12px; color: var(--ink-2); }
.kpi .delta.up { color: #2b8a3e; }
.kpi .delta.down { color: #c0392b; }
.kpi .delta .arr { display: inline-block; width: 10px; }
h2.h { font-size: 18px; font-weight: 600; margin: 32px 0 8px;
letter-spacing: -0.005em; }
.h-sub { color: var(--ink-2); font-size: 13px; margin-bottom: 12px; }
.two-col { display: grid; grid-template-columns: 1.1fr 1fr; gap: 24px;
margin-bottom: 8px; }
.card { background: #fff; border: 1px solid var(--line);
border-radius: var(--radius-md); padding: 16px 18px; }
.card-title { display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: var(--ink-2); margin-bottom: 8px; }
.card-title strong { color: var(--ink); font-weight: 600; }
.spark { width: 100%; height: 120px; }
.spark-axis { color: var(--ink-3); font-size: 11px; }
.spark-fill { fill: rgba(46,170,220,.10); }
.spark-line { fill: none; stroke: var(--accent); stroke-width: 2; }
.spark-dot { fill: var(--accent); }
.spark-grid line { stroke: var(--line); stroke-dasharray: 2 3; }
.feed { display: flex; flex-direction: column; }
.feed-row { display: flex; gap: 12px; padding: 10px 4px;
border-bottom: 1px solid var(--line); }
.feed-row:last-child { border-bottom: none; }
.feed-row .av { flex: 0 0 26px; width: 26px; height: 26px; border-radius: 50%;
display: grid; place-items: center; color: #fff; font-size: 11px; font-weight: 700; }
.feed-row .body { flex: 1; min-width: 0; font-size: 13px; }
.feed-row .body .who { font-weight: 600; }
.feed-row .body .what { color: var(--ink); }
.feed-row .body .target { color: var(--accent-ink);
border-bottom: 1px dotted var(--accent); cursor: pointer; }
.feed-row .time { color: var(--ink-3); font-size: 12px; flex: 0 0 auto; }
.feed-row.new { background: rgba(46,170,220,.06); }
.feed-row.new .body .who::before { content: "•"; color: var(--accent); margin-right: 6px; }
.db { border: 1px solid var(--line); border-radius: var(--radius-md);
overflow: hidden; margin-top: 4px; background: #fff; }
.db-head, .db-row {
display: grid; grid-template-columns: 32px 2.4fr 1fr 1fr 0.9fr 0.9fr;
align-items: center; padding: 8px 12px;
border-bottom: 1px solid var(--line); font-size: 13px;
}
.db-head { background: var(--bg-soft); color: var(--ink-2);
font-size: 12px; text-transform: uppercase; letter-spacing: .03em;
font-weight: 500; }
.db-row:last-child { border-bottom: none; }
.db-row:hover { background: var(--bg-soft); }
.db-row.changed { animation: rowflash 1.4s ease; }
@keyframes rowflash {
0% { background: rgba(46,170,220,.18); }
100% { background: transparent; }
}
.db-cell.title { font-weight: 500; display: flex; align-items: center; gap: 6px; min-width: 0; }
.db-cell.title .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.db-cell .pill { display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 3px; font-size: 12px; font-weight: 500; }
.pill.done { background: var(--pill-done-bg); color: var(--pill-done-ink); }
.pill.progress { background: var(--pill-progress-bg); color: var(--pill-progress-ink); }
.pill.blocked { background: var(--pill-blocked-bg); color: var(--pill-blocked-ink); }
.pill.todo { background: var(--pill-todo-bg); color: var(--pill-todo-ink); }
.pill.review { background: var(--pill-review-bg); color: var(--pill-review-ink); }
.person { display: inline-flex; align-items: center; gap: 6px; }
.person .av { width: 18px; height: 18px; border-radius: 50%;
color: #fff; display: grid; place-items: center;
font-size: 10px; font-weight: 700; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--line);
color: var(--ink-3); font-size: 12px;
display: flex; align-items: center; gap: 8px; }
.footer .key { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--bg-soft); padding: 1px 5px;
border-radius: 3px; color: var(--ink-2); }
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #1f1f1f; color: #fff; padding: 8px 14px;
border-radius: 8px; font-size: 13px;
opacity: 0; pointer-events: none;
transition: opacity .2s ease, transform .2s ease; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
@media (max-width: 980px) {
.app { grid-template-columns: 1fr; }
.side { display: none; }
.page { padding: 32px 20px 64px; }
.kpis { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.db-head, .db-row { grid-template-columns: 24px 1.8fr 0.8fr 0.8fr; }
.db-cell.due, .db-cell.priority { display: none; }
}
</style>
</head>
<body>
<div class="app">
<!--
Sidebar nav rows are interactive — keep the keyboard semantics
(role="button" + tabindex="0") on every clickable .ws / .side-search
/ .nav-item even if the agent re-skins the visuals.
-->
<aside class="side">
<div class="ws" role="button" tabindex="0">
<div class="ws-icon">{{workspace_initial}}</div>
<div class="ws-name">{{workspace_name}}</div>
<div class="ws-caret">⌄</div>
</div>
<div class="side-search" role="button" tabindex="0"><span>🔎</span><span>Search</span><kbd>⌘K</kbd></div>
<div class="side-search" role="button" tabindex="0"><span>⌚</span><span>Updates</span></div>
<div class="side-search" role="button" tabindex="0"><span>⚙️</span><span>Settings & members</span></div>
<div class="side-section">Workspace</div>
<div class="nav">
<div class="nav-item active" role="button" tabindex="0" aria-current="page"><span class="nav-icon">📊</span><span>{{page_title}}</span></div>
<!-- AGENT: list 3-5 plausible sibling pages here ("Tasks", "Docs", "Roadmap", …); keep role="button" + tabindex="0" on every nav-item. -->
</div>
<div class="side-section">Connections</div>
<div class="nav">
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">🔌</span><span>{{connector_label}}</span></div>
</div>
</aside>
<main class="main">
<div class="topbar">
<div class="crumbs">
<span>{{workspace_name}}</span>
<span>Workspace</span>
<span>{{page_title}}</span>
</div>
<div class="topbar-spacer"></div>
<div class="pill-live" id="livePill">
<span class="dot"></span>
<span id="liveText">Live · synced</span>
</div>
</div>
<div class="page">
<div class="page-cover"></div>
<div class="page-emoji">{{page_emoji}}</div>
<h1 class="page-title">{{page_title}}</h1>
<div class="page-meta">
<div class="updated">
<span class="who">{{owner_initials}}</span>
<span>Last edited by <strong>{{owner_name}}</strong></span>
</div>
<span>·</span>
<span id="updatedAt">Last refreshed just now</span>
<span style="flex:1"></span>
<button class="btn ghost" id="autoBtn" title="Auto refresh"><span>⏱</span><span id="autoLbl">Auto · on</span></button>
<button class="btn primary" id="refreshBtn" title="Pull latest from {{connector_label}}">
<span class="ico" id="refreshIco">↻</span>
<span>Refresh from {{connector_label}}</span>
</button>
</div>
<div class="callout">
<div class="emj">💡</div>
<div>
<div><strong>This page is a Live Artifact.</strong> Numbers below are pulled from
your <strong>{{workspace_name}}</strong> {{connector_label}} via the Composio connector.</div>
<small>Refreshes on demand or when the page opens. Last 7 days only.</small>
</div>
</div>
<!-- AGENT: render <inputs.kpi_count> .kpi cells inside this grid. -->
<div class="kpis" id="kpis" style="--kpi-count: {{kpi_count}}"></div>
<div class="two-col">
<div class="card">
<div class="card-title">
<strong>Tasks created · last 7 days</strong>
<span id="sparkSum">— total</span>
</div>
<svg class="spark" viewBox="0 0 600 140" preserveAspectRatio="none">
<g class="spark-grid">
<line x1="0" y1="35" x2="600" y2="35"/>
<line x1="0" y1="70" x2="600" y2="70"/>
<line x1="0" y1="105" x2="600" y2="105"/>
</g>
<path class="spark-fill" id="sparkFill" d=""></path>
<path class="spark-line" id="sparkLine" d=""></path>
<g id="sparkDots"></g>
<g id="sparkLabels" class="spark-axis"></g>
</svg>
</div>
<div class="card">
<div class="card-title">
<strong>Recent activity</strong>
<span>From <span style="color: var(--accent-ink)">{{connector_label}}</span></span>
</div>
<div class="feed" id="feed"></div>
</div>
</div>
<h2 class="h">📋 Tasks · Active sprint</h2>
<div class="h-sub">Linked database · filtered by Status ≠ Done · sorted by Updated time</div>
<div class="db" id="db">
<div class="db-head">
<div></div><div>Name</div><div>Status</div><div>Assignee</div>
<div class="db-cell due">Due</div>
<div class="db-cell priority">Priority</div>
</div>
</div>
<div class="footer">
<span>Source: {{connector_label}} API · workspace <strong>{{workspace_slug}}</strong></span>
<span>·</span>
<span>Connector <span class="key">composio.{{connector_slug}}</span></span>
<span>·</span>
<span id="footerTime"></span>
</div>
</div>
</main>
</div>
<div class="toast" id="toast">Synced</div>
<script>
/* =====================================================================
live-dashboard runtime
---------------------------------------------------------------------
The agent fills SEED_PEOPLE / SEED_ROWS / SEED_FEED / SEED_SERIES
below. The runtime renders, refreshes (mock or real), and tracks
staleness. Replace seedNextChange() with poll() to wire a real
connector.
===================================================================== */
/* --- AGENT FILL: people, rows, feed, series ------------------------ */
const SEED_PEOPLE = [
/* { id: "SC", name: "Sarah Chen", color: "#f1c40f" }, … */
];
const SEED_ROWS = [
/* { id, icon, title, status: "todo|progress|review|blocked|done",
who: <person id>, due, prio: "High|Med|Low", updated: <ms> }, … */
];
const SEED_FEED = [
/* { who: "<full name>", action: "moved|created|commented on|updated|shared",
target: "<task title>", suffix: "<optional clause>", icon: "<emoji>",
mins: <minutes ago> }, … */
];
const SEED_SERIES = [/* 7 integers, oldest → today's bucket */];
const DAY_LABELS = ["Wed","Thu","Fri","Sat","Sun","Mon","Tue"];
const KPI_DEFS = [
/* { id: "kTotal", label: "Total tasks", get: () => total } */
];
/* --- runtime ------------------------------------------------------- */
const $ = id => document.getElementById(id);
let people = SEED_PEOPLE.slice();
let rows = SEED_ROWS.slice();
let feed = SEED_FEED.map(x => ({ ...x, when: new Date(Date.now() - x.mins * 60000), id: rid() }));
let series = SEED_SERIES.slice();
let lastRefresh = new Date();
let autoOn = JSON.parse(localStorage.live_dashboard_auto || "true");
let autoTimer = null;
let busy = false;
function rid() { return "f" + Math.random().toString(36).slice(2, 9); }
function rand(a) { return a[Math.floor(Math.random() * a.length)]; }
function timeAgo(d) {
const s = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
if (s < 5) return "just now"; if (s < 60) return s + "s ago";
const m = Math.floor(s / 60); if (m < 60) return m + " min ago";
const h = Math.floor(m / 60); if (h < 24) return h + " h ago";
return Math.floor(h / 24) + " d ago";
}
/* All data interpolations into innerHTML must go through e() — task
titles / activity strings / person names can come from a connector
payload, and unescaped innerHTML is a working XSS sink. */
function e(v) {
return String(v == null ? "" : v)
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
.replaceAll('"', """).replaceAll("'", "'");
}
/* Tween + flash hooks for the live-refresh pass — wired to .flash /
.feed-row.new / .db-row.changed in the CSS above. renderKpi(prev) is
the public entry; pass the previous KPI snapshot in to get tween +
highlight on changed values. Both helpers honor
prefers-reduced-motion: reduce — the CSS @media block already
neutralizes animations/transitions, but tweenText() schedules JS rAF
updates and flash() toggles a highlight class, so we short-circuit
them here for reduced-motion users (P0 row in checklist.md). */
function reduceMotion() {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tweenText(el, from, to, ms = 600) {
if (!el) return;
if (from === to || reduceMotion()) { el.textContent = String(to); return; }
const start = performance.now();
function step(now) {
const t = Math.min(1, (now - start) / ms);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = String(Math.round(from + (to - from) * eased));
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function flash(el) {
if (!el || reduceMotion()) return;
el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 700);
}
/* connector hook: replace this with a real poll() — see references/connectors.md */
async function seedNextChange() {
await new Promise(r => setTimeout(r, 700 + Math.random() * 600));
const candidates = rows.filter(r => r.status !== "done");
const r = candidates.length ? rand(candidates) : null;
if (r) { r.status = "done"; r.updated = Date.now(); }
series[series.length - 1] += 1 + Math.floor(Math.random() * 2);
const person = rand(people);
feed = [{
id: rid(), when: new Date(),
who: person.name, action: "moved",
target: r ? r.title : "—", suffix: "to Done", icon: "✅"
}, ...feed].slice(0, 8);
return { changedRowId: r ? r.id : null };
}
/* render: KPI / spark / feed / rows --------------------------------- */
function renderKpi(prev) {
const wrap = $("kpis");
// First render: build the cards from scratch (escaped) so KPI labels /
// deltas / values can come from any connector payload safely.
if (!wrap.children.length) {
wrap.innerHTML = KPI_DEFS.map(def => `
<div class="kpi">
<div class="label">${e(def.label)}</div>
<div class="value" id="${e(def.id)}">${e(def.get())}</div>
<div class="delta">${e(def.delta || "")}</div>
</div>`).join("");
return;
}
// Subsequent renders: tween numeric KPIs from prev → current and
// flash the cell when the value actually changed.
for (const def of KPI_DEFS) {
const cell = $(def.id);
if (!cell) continue;
const next = def.get();
const before = prev ? Number(prev[def.id]) : Number(cell.textContent);
const after = Number(next);
if (Number.isFinite(before) && Number.isFinite(after)) {
tweenText(cell, before, after);
if (before !== after) flash(cell);
} else {
cell.textContent = String(next);
}
}
}
/* Snapshot the values KPI_DEFS produced so the next renderKpi can
tween from "old" → "new". Call before mutating state. */
function snapshotKpi() {
const out = {};
for (const def of KPI_DEFS) out[def.id] = Number(def.get());
return out;
}
function renderSpark() {
const W=600,H=140,padL=24,padR=16,padT=10,padB=24;
const max=Math.max(...series,1);
const stepX=(W-padL-padR)/(series.length-1);
const pts=series.map((v,i)=>[padL+i*stepX, padT+(1-v/(max*1.15))*(H-padT-padB)]);
const line="M "+pts.map(p=>p.join(" ")).join(" L ");
const fill=line+` L ${pts.at(-1)[0]} ${H-padB} L ${pts[0][0]} ${H-padB} Z`;
$("sparkLine").setAttribute("d",line);
$("sparkFill").setAttribute("d",fill);
$("sparkDots").innerHTML = pts.map(([x,y])=>`<circle class="spark-dot" cx="${x}" cy="${y}" r="3"/>`).join("");
$("sparkLabels").innerHTML = DAY_LABELS.map((d,i)=>{const x=padL+i*stepX;return `<text x="${x}" y="${H-6}" text-anchor="middle">${d}</text>`;}).join("");
$("sparkSum").textContent = series.reduce((a,b)=>a+b,0)+" total";
}
function pcolor(name) { return (people.find(p=>p.name===name)||{}).color || "#7f8c8d"; }
const STATUS_CSS = { done: 1, progress: 1, blocked: 1, todo: 1, review: 1 };
const STATUS_LABEL = { done: "Done", progress: "In progress", blocked: "Blocked", todo: "To do", review: "In review" };
/* Connector payloads can contain attacker-controlled strings (task
titles, activity descriptors, person names). Every dynamic value
below goes through e() before it lands in innerHTML. */
function renderFeed(highlightId) {
// Drop the transient .new highlight under reduced-motion so the row
// doesn't get a coloured background pulse for users who opted out.
const effectiveHighlight = reduceMotion() ? null : highlightId;
$("feed").innerHTML = feed.slice(0,6).map(it=>{
const init = String(it.who||"").split(" ").map(s=>s[0]).join("").slice(0,2).toUpperCase();
const cls = it.id===effectiveHighlight ? "feed-row new" : "feed-row";
const suffixHtml = it.suffix ? `<span class="what"> ${e(it.suffix)}</span>` : "";
return `<div class="${cls}">
<span class="av" style="background:${e(pcolor(it.who))}">${e(init)}</span>
<div class="body"><span class="who">${e(it.who)}</span>
<span class="what"> ${e(it.action)}</span>
<span class="target"> ${e(it.icon||"")} ${e(it.target)}</span>
${suffixHtml}</div>
<div class="time">${e(timeAgo(it.when))}</div></div>`;
}).join("");
}
function personChip(id) {
const p = people.find(x=>x.id===id) || {};
return `<span class="person"><span class="av" style="background:${e(p.color||"#888")}">${e(p.id||"")}</span><span>${e(p.name||"")}</span></span>`;
}
function renderRows(changedId) {
// Same reduced-motion gate as renderFeed: skip the .changed row-flash
// class so we don't paint a transient highlight for opted-out users.
const effectiveChanged = reduceMotion() ? null : changedId;
const db=$("db"); [...db.querySelectorAll(".db-row")].forEach(n=>n.remove());
[...rows].sort((a,b)=>b.updated-a.updated).forEach(r=>{
const div=document.createElement("div");
div.className="db-row"+(r.id===effectiveChanged?" changed":"");
const statusKey = STATUS_CSS[r.status] ? r.status : "todo";
const lbl = STATUS_LABEL[statusKey];
div.innerHTML=`<div class="db-cell">${e(r.icon)}</div>
<div class="db-cell title"><span class="t">${e(r.title)}</span></div>
<div class="db-cell"><span class="pill ${statusKey}">${e(lbl)}</span></div>
<div class="db-cell">${personChip(r.who)}</div>
<div class="db-cell due">${e(r.due)}</div>
<div class="db-cell priority">${e(r.prio)}</div>`;
db.appendChild(div);
});
}
async function refresh({silent=false}={}) {
if (busy) return; busy = true;
$("refreshIco").classList.add("spin");
$("livePill").classList.add("syncing"); $("liveText").textContent="Syncing…";
// Snapshot KPIs before mutation so renderKpi(prev) can tween from
// old → new and flash on values that actually changed.
const prev = snapshotKpi();
try {
const result = await seedNextChange(); /* AGENT: replace with real poll() */
lastRefresh = new Date();
renderKpi(prev); renderSpark(); renderFeed(feed[0]?.id);
renderRows(result?.changedRowId);
if (!silent) showToast("Synced · 1 task moved, 1 activity added");
} catch (err) {
$("livePill").classList.add("stale"); $("liveText").textContent = "Stale · " + timeAgo(lastRefresh);
} finally {
busy = false; $("refreshIco").classList.remove("spin");
$("livePill").classList.remove("syncing"); updateTimes();
}
}
function showToast(msg) {
const t = $("toast"); t.textContent = msg; t.classList.add("show");
clearTimeout(showToast._t); showToast._t = setTimeout(()=>t.classList.remove("show"), 1800);
}
function updateTimes() {
$("updatedAt").textContent = "Last refreshed " + timeAgo(lastRefresh);
$("footerTime").textContent = "Last sync · " + lastRefresh.toLocaleTimeString();
const stale = (Date.now() - lastRefresh.getTime()) > ({{stale_after_seconds}} * 1000);
$("livePill").classList.toggle("stale", stale);
$("liveText").textContent = stale ? ("Stale · " + timeAgo(lastRefresh)) : "Live · synced";
}
function startAuto() { stopAuto(); autoTimer = setInterval(()=>refresh({silent:true}), {{refresh_seconds}} * 1000); }
function stopAuto() { if (autoTimer) clearInterval(autoTimer); autoTimer = null; }
// .ws / .side-search / .nav-item are styled as clickable rows but they
// are <div>, so keyboard users get nothing for free. Map Enter/Space
// onto a synthetic click for any role="button" element that is not
// already a real button/link.
document.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
if (t.getAttribute("role") !== "button") return;
if (t.matches("button, a, input, select, textarea")) return;
ev.preventDefault();
t.click();
});
document.getElementById("refreshBtn").addEventListener("click", ()=>refresh());
document.getElementById("autoBtn").addEventListener("click", () => {
autoOn = !autoOn;
document.getElementById("autoLbl").textContent = "Auto · " + (autoOn ? "on" : "off");
localStorage.live_dashboard_auto = JSON.stringify(autoOn);
autoOn ? startAuto() : stopAuto();
});
setInterval(updateTimes, 5000);
setInterval(()=>renderFeed(), 15000);
renderKpi(); renderSpark(); renderFeed(); renderRows(); updateTimes();
if (autoOn) startAuto();
setTimeout(()=>refresh({silent:true}), 600); // refresh-on-open
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Acme · Team Dashboard</title>
<style>
:root {
--bg: #ffffff;
--bg-soft: #f7f6f3;
--bg-hover: #efeeec;
--ink: #37352f;
--ink-2: #787774;
--ink-3: #9b9a97;
--line: #ececea;
--line-strong: #d3d1cb;
--accent: #2eaadc;
--accent-ink: #0b6e91;
--pill-done-bg: #dbeddb; --pill-done-ink: #2b593f;
--pill-progress-bg: #fdecc8; --pill-progress-ink: #976d23;
--pill-blocked-bg: #ffe2dd; --pill-blocked-ink: #b13b2c;
--pill-todo-bg: #e9e5e3; --pill-todo-ink: #5a534f;
--pill-review-bg: #d3e5ef; --pill-review-ink: #1f5b78;
--shadow-card: 0 1px 0 rgba(15,15,15,0.04), 0 1px 2px rgba(15,15,15,0.04);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
"Noto Sans", "PingFang SC", "Hiragino Sans GB", sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
/* ---------- App shell ---------- */
.app {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
/* ---------- Sidebar ---------- */
.side {
background: var(--bg-soft);
border-right: 1px solid var(--line);
padding: 14px 8px;
user-select: none;
}
.ws {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: var(--radius-sm);
cursor: pointer;
}
.ws:hover { background: var(--bg-hover); }
.ws-icon {
width: 22px; height: 22px; border-radius: 4px;
background: linear-gradient(135deg,#1f1f1f,#3a3a3a);
color: #fff; display: grid; place-items: center;
font-weight: 600; font-size: 12px;
}
.ws-name { font-weight: 600; flex: 1; font-size: 14px; }
.ws-caret { color: var(--ink-3); font-size: 12px; }
.side-search {
margin: 12px 4px 6px; padding: 5px 8px;
color: var(--ink-3); font-size: 13px;
display: flex; align-items: center; gap: 8px;
border-radius: var(--radius-sm); cursor: pointer;
}
.side-search:hover { background: var(--bg-hover); }
.side-search kbd {
margin-left: auto; font-family: inherit; font-size: 11px;
color: var(--ink-3); background: #fff; border: 1px solid var(--line);
padding: 1px 5px; border-radius: 3px;
}
.side-section {
margin-top: 14px; padding: 0 8px;
color: var(--ink-3); font-size: 12px; letter-spacing: .02em;
text-transform: uppercase;
}
.nav { margin-top: 4px; display: flex; flex-direction: column; gap: 1px; }
.nav-item {
display: flex; align-items: center; gap: 8px;
padding: 4px 8px; border-radius: var(--radius-sm);
color: var(--ink); cursor: pointer;
}
.nav-item:hover { background: var(--bg-hover); }
.nav-item.active { background: #e8e6e1; font-weight: 500; }
.nav-icon { width: 18px; text-align: center; }
.nav-caret { margin-left: auto; color: var(--ink-3); font-size: 10px; }
/* ---------- Main ---------- */
.main {
display: flex; flex-direction: column;
min-width: 0;
}
.topbar {
height: 44px; padding: 0 16px;
border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 12px;
color: var(--ink-2); font-size: 13px;
}
.crumbs span:not(:last-child)::after {
content: "/"; margin: 0 6px; color: var(--ink-3);
}
.topbar-spacer { flex: 1; }
.pill-live {
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 8px 2px 6px; background: #fff;
border: 1px solid var(--line-strong); border-radius: 999px;
font-size: 12px; color: var(--ink-2);
}
.pill-live .dot {
width: 7px; height: 7px; border-radius: 50%;
background: #2ecc71; box-shadow: 0 0 0 0 rgba(46,204,113,.55);
animation: pulse 1.8s infinite;
}
.pill-live.stale .dot { background: #e6a23c; animation: none; }
/* "Sample data" — no real connector is bound. Stays grey and static
so the artifact does not claim a live data source it does not have. */
.pill-live.sample {
background: var(--bg-soft); color: var(--ink-3);
border-color: var(--line);
}
.pill-live.sample .dot {
background: var(--ink-4); animation: none; box-shadow: none;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(46,204,113,.55); }
70% { box-shadow: 0 0 0 7px rgba(46,204,113,0); }
100% { box-shadow: 0 0 0 0 rgba(46,204,113,0); }
}
.page {
padding: 56px max(80px, 8vw) 80px;
max-width: 1100px;
width: 100%;
align-self: center;
}
.page-cover {
height: 24px; /* keep airy like Notion default */
}
.page-emoji {
font-size: 64px; line-height: 1;
margin: -4px 0 12px; cursor: default;
}
h1.page-title {
font-size: 40px; line-height: 1.15;
font-weight: 700; letter-spacing: -0.01em;
margin: 0 0 8px; color: var(--ink);
}
.page-meta {
display: flex; align-items: center; gap: 14px;
color: var(--ink-2); font-size: 13px; margin-bottom: 28px;
}
.page-meta .updated { display: inline-flex; align-items: center; gap: 6px; }
.page-meta .updated .who {
width: 18px; height: 18px; border-radius: 50%;
background: #f1c40f; color: #5a4500;
display: inline-grid; place-items: center;
font-size: 11px; font-weight: 700;
}
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; font-size: 13px; color: var(--ink);
background: #fff; border: 1px solid var(--line-strong);
border-radius: var(--radius-sm); cursor: pointer;
transition: background .15s ease, transform .15s ease;
}
.btn:hover { background: var(--bg-hover); }
.btn .ico { width: 14px; height: 14px; display: inline-block; }
.btn .ico.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.btn.primary { background: var(--accent); color: #fff; border-color: transparent; }
.btn.primary:hover { background: var(--accent-ink); }
.btn.ghost { border-color: transparent; }
.btn.ghost:hover { background: var(--bg-hover); }
/* ---------- Callout ---------- */
.callout {
background: var(--bg-soft);
border-radius: var(--radius-md);
padding: 14px 16px;
display: flex; gap: 12px; align-items: flex-start;
margin: 10px 0 28px;
color: var(--ink);
}
.callout .emj { font-size: 18px; line-height: 1.4; }
.callout small { color: var(--ink-2); }
/* ---------- KPI grid ---------- */
.kpis {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 1px; background: var(--line);
border: 1px solid var(--line); border-radius: var(--radius-md);
overflow: hidden; margin: 8px 0 32px;
}
.kpi {
background: #fff; padding: 18px 20px;
display: flex; flex-direction: column; gap: 4px;
}
.kpi .label {
color: var(--ink-2); font-size: 12px; letter-spacing: .03em;
text-transform: uppercase; font-weight: 500;
}
.kpi .value {
font-size: 32px; font-weight: 600; letter-spacing: -0.01em;
color: var(--ink);
font-variant-numeric: tabular-nums;
transition: color .25s ease;
}
.kpi .value.flash { color: var(--accent-ink); }
.kpi .delta { font-size: 12px; color: var(--ink-2); }
.kpi .delta.up { color: #2b8a3e; }
.kpi .delta.down { color: #c0392b; }
.kpi .delta .arr { display: inline-block; width: 10px; }
/* ---------- Section heading ---------- */
h2.h {
font-size: 18px; font-weight: 600;
margin: 32px 0 8px; letter-spacing: -0.005em;
}
.h-sub { color: var(--ink-2); font-size: 13px; margin-bottom: 12px; }
/* ---------- Two-col block ---------- */
.two-col {
display: grid; grid-template-columns: 1.1fr 1fr; gap: 24px;
margin-bottom: 8px;
}
/* Sparkline card */
.card {
background: #fff; border: 1px solid var(--line);
border-radius: var(--radius-md); padding: 16px 18px;
}
.card-title {
display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: var(--ink-2); margin-bottom: 8px;
}
.card-title strong { color: var(--ink); font-weight: 600; }
.spark { width: 100%; height: 120px; }
.spark-axis { color: var(--ink-3); font-size: 11px; }
.spark-fill { fill: rgba(46,170,220,.10); }
.spark-line { fill: none; stroke: var(--accent); stroke-width: 2; }
.spark-dot { fill: var(--accent); }
.spark-grid line { stroke: var(--line); stroke-dasharray: 2 3; }
/* Activity feed */
.feed { display: flex; flex-direction: column; }
.feed-row {
display: flex; gap: 12px; padding: 10px 4px;
border-bottom: 1px solid var(--line);
}
.feed-row:last-child { border-bottom: none; }
.feed-row .av {
flex: 0 0 26px; width: 26px; height: 26px; border-radius: 50%;
display: grid; place-items: center;
color: #fff; font-size: 11px; font-weight: 700;
}
.feed-row .body { flex: 1; min-width: 0; font-size: 13px; }
.feed-row .body .who { font-weight: 600; }
.feed-row .body .what { color: var(--ink); }
.feed-row .body .target { color: var(--accent-ink); border-bottom: 1px dotted var(--accent); cursor: pointer; }
.feed-row .time { color: var(--ink-3); font-size: 12px; flex: 0 0 auto; }
.feed-row.new { background: rgba(46,170,220,.06); }
.feed-row.new .body .who::before {
content: "•"; color: var(--accent); margin-right: 6px;
}
/* ---------- Database table ---------- */
.db {
border: 1px solid var(--line); border-radius: var(--radius-md);
overflow: hidden; margin-top: 4px;
background: #fff;
}
.db-head, .db-row {
display: grid;
grid-template-columns: 32px 2.4fr 1fr 1fr 0.9fr 0.9fr;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
font-size: 13px;
}
.db-head {
background: var(--bg-soft); color: var(--ink-2);
font-size: 12px; text-transform: uppercase; letter-spacing: .03em;
font-weight: 500;
}
.db-row:last-child { border-bottom: none; }
.db-row:hover { background: var(--bg-soft); }
.db-row.changed { animation: rowflash 1.4s ease; }
@keyframes rowflash {
0% { background: rgba(46,170,220,.18); }
100% { background: transparent; }
}
.db-cell.title { font-weight: 500; display: flex; align-items: center; gap: 6px; min-width: 0; }
.db-cell.title .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.db-cell .pill {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 3px;
font-size: 12px; font-weight: 500;
}
.pill.done { background: var(--pill-done-bg); color: var(--pill-done-ink); }
.pill.progress { background: var(--pill-progress-bg); color: var(--pill-progress-ink); }
.pill.blocked { background: var(--pill-blocked-bg); color: var(--pill-blocked-ink); }
.pill.todo { background: var(--pill-todo-bg); color: var(--pill-todo-ink); }
.pill.review { background: var(--pill-review-bg); color: var(--pill-review-ink); }
.person {
display: inline-flex; align-items: center; gap: 6px;
}
.person .av {
width: 18px; height: 18px; border-radius: 50%;
color: #fff; display: grid; place-items: center;
font-size: 10px; font-weight: 700;
}
/* ---------- Footer ---------- */
.footer {
margin-top: 32px; padding-top: 16px;
border-top: 1px solid var(--line);
color: var(--ink-3); font-size: 12px;
display: flex; align-items: center; gap: 8px;
}
.footer .key { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--bg-soft); padding: 1px 5px; border-radius: 3px; color: var(--ink-2); }
/* ---------- Toast ---------- */
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #1f1f1f; color: #fff; padding: 8px 14px;
border-radius: 8px; font-size: 13px;
opacity: 0; pointer-events: none;
transition: opacity .2s ease, transform .2s ease;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
@media (max-width: 980px) {
.app { grid-template-columns: 1fr; }
.side { display: none; }
.page { padding: 32px 20px 64px; }
.kpis { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.db-head, .db-row { grid-template-columns: 24px 1.8fr 0.8fr 0.8fr; }
.db-cell.due, .db-cell.priority { display: none; }
}
</style>
</head>
<body>
<div class="app">
<!-- ============ SIDEBAR ============ -->
<!-- Sidebar rows are interactive — every clickable .ws / .side-search /
.nav-item carries role="button" + tabindex="0" so keyboard users
can reach them. Activation handlers are wired in the script below. -->
<aside class="side">
<div class="ws" role="button" tabindex="0">
<div class="ws-icon">A</div>
<div class="ws-name">Acme Studio</div>
<div class="ws-caret">⌄</div>
</div>
<div class="side-search" role="button" tabindex="0">
<span>🔎</span><span>Search</span><kbd>⌘K</kbd>
</div>
<div class="side-search" role="button" tabindex="0">
<span>⌚</span><span>Updates</span>
</div>
<div class="side-search" role="button" tabindex="0">
<span>⚙️</span><span>Settings & members</span>
</div>
<div class="side-section">Workspace</div>
<div class="nav">
<div class="nav-item active" role="button" tabindex="0" aria-current="page">
<span class="nav-icon">📊</span><span>Team Dashboard</span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon">✅</span><span>Tasks</span>
<span class="nav-caret">›</span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon">📓</span><span>Docs</span>
<span class="nav-caret">›</span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon">🚀</span><span>Roadmap</span>
</div>
<div class="nav-item" role="button" tabindex="0">
<span class="nav-icon">🧠</span><span>Brain dump</span>
</div>
</div>
<div class="side-section">Private</div>
<div class="nav">
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">🗒️</span><span>Daily journal</span></div>
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">📥</span><span>Inbox</span></div>
</div>
<div class="side-section">Connections</div>
<div class="nav">
<div class="nav-item" role="button" tabindex="0"><span class="nav-icon">🔌</span><span>Notion · acme.so</span></div>
</div>
</aside>
<!-- ============ MAIN ============ -->
<main class="main">
<div class="topbar">
<div class="crumbs">
<span>Acme Studio</span>
<span>Workspace</span>
<span>Team Dashboard</span>
</div>
<div class="topbar-spacer"></div>
<div class="pill-live sample" id="livePill" title="No connector bound — values below are seeded sample data">
<span class="dot"></span>
<span id="liveText">Sample data</span>
</div>
</div>
<div class="page">
<div class="page-cover"></div>
<div class="page-emoji">📊</div>
<h1 class="page-title">Team Dashboard</h1>
<div class="page-meta">
<div class="updated">
<span class="who">SC</span>
<span>Last edited by <strong>Sarah Chen</strong></span>
</div>
<span>·</span>
<span id="updatedAt">Last refreshed just now</span>
<span style="flex:1"></span>
<button class="btn ghost" id="autoBtn" title="Auto refresh every 30s">
<span>⏱</span><span id="autoLbl">Auto · on</span>
</button>
<button class="btn primary" id="refreshBtn" title="Pull latest from Notion (sample mode — refreshes seeded data)">
<span class="ico" id="refreshIco">↻</span>
<span>Refresh from Notion</span>
</button>
</div>
<div class="callout">
<div class="emj">💡</div>
<div>
<div><strong>This page is a Live Artifact running on sample data.</strong>
The shape mirrors what the <strong>Acme Studio</strong> Notion workspace
would return through the Composio connector — but the numbers, names,
tasks and activity below are seeded fixtures, not a real workspace.</div>
<small>When a connector is bound, the pill switches to <em>Live · synced</em>
and the same renderer paints real data. Until then, refreshes only
tween the seeded fixture so the live behaviors stay demo-able offline.</small>
</div>
</div>
<!-- KPI -->
<div class="kpis">
<div class="kpi">
<div class="label">Total tasks</div>
<div class="value" id="kTotal">142</div>
<div class="delta up"><span class="arr">↑</span> 6 vs last week</div>
</div>
<div class="kpi">
<div class="label">Done this week</div>
<div class="value" id="kDone">23</div>
<div class="delta up"><span class="arr">↑</span> 4 vs last week</div>
</div>
<div class="kpi">
<div class="label">Active members</div>
<div class="value" id="kPeople">11<span style="color:var(--ink-3); font-size:18px; font-weight:500"> / 14</span></div>
<div class="delta">· Stable</div>
</div>
<div class="kpi">
<div class="label">Docs awaiting review</div>
<div class="value" id="kReview">7</div>
<div class="delta down"><span class="arr">↓</span> 2 vs last week</div>
</div>
</div>
<!-- Two col -->
<div class="two-col">
<div class="card">
<div class="card-title">
<strong>Tasks created · last 7 days</strong>
<span id="sparkSum">98 total</span>
</div>
<svg class="spark" viewBox="0 0 600 140" preserveAspectRatio="none" id="sparkSvg">
<!-- grid -->
<g class="spark-grid">
<line x1="0" y1="35" x2="600" y2="35"/>
<line x1="0" y1="70" x2="600" y2="70"/>
<line x1="0" y1="105" x2="600" y2="105"/>
</g>
<path class="spark-fill" id="sparkFill" d=""></path>
<path class="spark-line" id="sparkLine" d=""></path>
<g id="sparkDots"></g>
<g id="sparkLabels" class="spark-axis"></g>
</svg>
</div>
<div class="card">
<div class="card-title">
<strong>Recent activity</strong>
<span>From <span style="color:var(--accent-ink)">Notion</span></span>
</div>
<div class="feed" id="feed"></div>
</div>
</div>
<!-- Database -->
<h2 class="h">📋 Tasks · Active sprint</h2>
<div class="h-sub">Linked database · filtered by Status ≠ Done · sorted by Updated time</div>
<div class="db" id="db">
<div class="db-head">
<div></div>
<div>Name</div>
<div>Status</div>
<div>Assignee</div>
<div class="db-cell due">Due</div>
<div class="db-cell priority">Priority</div>
</div>
<!-- rows injected -->
</div>
<div class="footer">
<span>Source: Notion API · workspace <strong>acme.so</strong></span>
<span>·</span>
<span>Connector <span class="key">composio.notion</span></span>
<span>·</span>
<span id="footerTime"></span>
</div>
</div>
</main>
</div>
<div class="toast" id="toast">Synced — 3 changes from Notion</div>
<script>
(function(){
// ---------- Fake data state ----------
const PEOPLE = [
{ id: "SC", name: "Sarah Chen", color: "#f1c40f" },
{ id: "MK", name: "Mike Park", color: "#3498db" },
{ id: "LZ", name: "Lisa Zhao", color: "#e67e22" },
{ id: "DT", name: "Dave Torres", color: "#9b59b6" },
{ id: "AN", name: "Anna Nakamura",color: "#1abc9c" },
{ id: "RJ", name: "Raj Patel", color: "#e74c3c" }
];
const ROW_POOL = [
{ icon: "🚀", title: "Q3 Roadmap planning", status: "progress", who: "SC", due: "May 12", prio: "High" },
{ icon: "📐", title: "Design tokens v2 spec", status: "review", who: "LZ", due: "May 14", prio: "Med" },
{ icon: "🐛", title: "Fix mobile nav overflow on iOS", status: "blocked", who: "MK", due: "May 09", prio: "High" },
{ icon: "📊", title: "Pricing experiment readout", status: "todo", who: "DT", due: "May 18", prio: "Low" },
{ icon: "✍️", title: "Launch blog draft · Live Artifacts", status: "progress", who: "AN", due: "May 11", prio: "High" },
{ icon: "🔐", title: "Rotate Notion integration token", status: "todo", who: "RJ", due: "May 20", prio: "Med" },
{ icon: "🧪", title: "QA onboarding flow regressions", status: "review", who: "MK", due: "May 16", prio: "Med" },
{ icon: "🎨", title: "Refresh marketing OG images", status: "progress", who: "LZ", due: "May 13", prio: "Low" }
];
const ACTIVITY_TEMPLATES = [
(p, t) => ({ who: p, action: "moved", target: t, suffix: "to Done", icon: "✅" }),
(p, t) => ({ who: p, action: "created", target: t, suffix: "in Tasks", icon: "➕" }),
(p, t) => ({ who: p, action: "commented on", target: t, suffix: "", icon: "💬" }),
(p, t) => ({ who: p, action: "updated", target: t, suffix: "properties", icon: "✏️" }),
(p, t) => ({ who: p, action: "shared", target: t, suffix: "with Marketing", icon: "🔗" }),
(p, t) => ({ who: p, action: "added", target: t, suffix: "to sprint", icon: "📌" })
];
const SEED_FEED = [
{ who: "Sarah Chen", action: "moved", target: "Q3 Roadmap planning", suffix: "to In progress", icon: "🚀", mins: 3 },
{ who: "Mike Park", action: "commented on", target: "Fix mobile nav overflow on iOS", suffix: "", icon: "💬", mins: 11 },
{ who: "Lisa Zhao", action: "created", target: "Design tokens v2 spec", suffix: "in Docs", icon: "📐", mins: 26 },
{ who: "Anna Nakamura",action: "shared", target: "Launch blog draft · Live Artifacts", suffix: "with Marketing", icon: "🔗", mins: 41 },
{ who: "Dave Torres", action: "updated", target: "Pricing experiment readout", suffix: "properties", icon: "✏️", mins: 58 }
];
let kpi = { total: 142, done: 23, people: 11, peopleAll: 14, review: 7 };
let series = [8, 12, 15, 9, 18, 22, 14];
let dayLabels = ["Wed","Thu","Fri","Sat","Sun","Mon","Tue"];
let lastRefresh = new Date();
let autoOn = true;
let autoTimer = null;
// ---------- DOM refs ----------
const $ = (id) => document.getElementById(id);
const refreshBtn = $("refreshBtn");
const refreshIco = $("refreshIco");
const autoBtn = $("autoBtn");
const autoLbl = $("autoLbl");
const updatedAt = $("updatedAt");
const liveText = $("liveText");
const livePill = $("livePill");
const toast = $("toast");
const footerTime = $("footerTime");
// ---------- Helpers ----------
// Every dynamic value rendered into innerHTML below goes through e():
// task titles / activity strings / person names can come from a
// connector payload, and unescaped innerHTML is a working XSS sink.
function e(v) {
return String(v == null ? "" : v)
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
.replaceAll('"', """).replaceAll("'", "'");
}
function avatar(personId) {
const p = PEOPLE.find(x => x.id === personId);
if (!p) return "";
return `<span class="av" style="background:${e(p.color)}">${e(p.id)}</span>`;
}
function personChip(personId) {
const p = PEOPLE.find(x => x.id === personId);
if (!p) return "";
return `<span class="person">${avatar(personId)}<span>${e(p.name)}</span></span>`;
}
function timeAgo(date) {
const s = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
if (s < 5) return "just now";
if (s < 60) return s + "s ago";
const m = Math.floor(s / 60);
if (m < 60) return m + " min ago";
const h = Math.floor(m / 60);
if (h < 24) return h + " h ago";
return Math.floor(h/24) + " d ago";
}
function fmtDateTime(d) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) +
" · " + d.toLocaleDateString([], { month: "short", day: "numeric" });
}
function rand(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
// Reduced-motion gate. The CSS @media block above already neutralizes
// CSS animations/transitions, but tweenText() schedules JS rAF updates
// and flash() toggles a transient highlight class — both keep moving
// for opted-out users unless we short-circuit them here.
function reduceMotion() {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tween(el, from, to, ms = 600) {
if (!el) return;
if (from === to || reduceMotion()) {
el.firstChild ? (el.childNodes[0].nodeValue = to) : (el.textContent = to);
return;
}
const start = performance.now();
function step(now) {
const t = Math.min(1, (now - start) / ms);
const eased = 1 - Math.pow(1 - t, 3);
const v = Math.round(from + (to - from) * eased);
el.firstChild ? (el.childNodes[0].nodeValue = v) : (el.textContent = v);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// ---------- Render: KPI ----------
function renderKpi(prev) {
const flash = (el) => {
if (!el || reduceMotion()) return;
el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 700);
};
if (prev) {
tweenText($("kTotal"), prev.total, kpi.total); if (prev.total !== kpi.total) flash($("kTotal"));
tweenText($("kDone"), prev.done, kpi.done); if (prev.done !== kpi.done) flash($("kDone"));
tweenText($("kReview"), prev.review, kpi.review); if (prev.review !== kpi.review) flash($("kReview"));
// people render keeps html — numeric values are safe but pass them
// through e() anyway so connector-driven payloads can flow through
// this same path without revisiting the markup.
$("kPeople").innerHTML = e(kpi.people) + `<span style="color:var(--ink-3); font-size:18px; font-weight:500"> / ${e(kpi.peopleAll)}</span>`;
if (prev.people !== kpi.people) flash($("kPeople"));
} else {
$("kTotal").textContent = kpi.total;
$("kDone").textContent = kpi.done;
$("kReview").textContent = kpi.review;
$("kPeople").innerHTML = e(kpi.people) + `<span style="color:var(--ink-3); font-size:18px; font-weight:500"> / ${e(kpi.peopleAll)}</span>`;
}
}
function tweenText(el, from, to, ms = 600) {
if (!el) return;
if (from === to || reduceMotion()) { el.textContent = String(to); return; }
const start = performance.now();
function step(now) {
const t = Math.min(1, (now - start) / ms);
const eased = 1 - Math.pow(1 - t, 3);
const v = Math.round(from + (to - from) * eased);
el.textContent = v;
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// ---------- Render: sparkline ----------
function renderSpark() {
const W = 600, H = 140, padL = 24, padR = 16, padT = 10, padB = 24;
const max = Math.max(...series, 1);
const stepX = (W - padL - padR) / (series.length - 1);
const pts = series.map((v, i) => {
const x = padL + i * stepX;
const y = padT + (1 - v / (max * 1.15)) * (H - padT - padB);
return [x, y];
});
const linePath = "M " + pts.map(p => p.join(" ")).join(" L ");
const fillPath = linePath + ` L ${pts[pts.length-1][0]} ${H - padB} L ${pts[0][0]} ${H - padB} Z`;
$("sparkLine").setAttribute("d", linePath);
$("sparkFill").setAttribute("d", fillPath);
const dots = pts.map(([x,y]) => `<circle class="spark-dot" cx="${x}" cy="${y}" r="3"/>`).join("");
$("sparkDots").innerHTML = dots;
const labels = dayLabels.map((d, i) => {
const x = padL + i * stepX;
return `<text x="${x}" y="${H - 6}" text-anchor="middle">${d}</text>`;
}).join("");
$("sparkLabels").innerHTML = labels;
$("sparkSum").textContent = series.reduce((a,b)=>a+b,0) + " total";
}
// ---------- Render: feed ----------
let feed = SEED_FEED.map(x => ({...x, when: new Date(Date.now() - x.mins * 60 * 1000), id: cryptoRandomId() }));
function cryptoRandomId() { return "f" + Math.random().toString(36).slice(2, 9); }
function renderFeed(highlightId) {
const el = $("feed");
// Drop the transient .new highlight under reduced-motion so the row
// doesn't get a coloured background pulse for users who opted out.
const effectiveHighlight = reduceMotion() ? null : highlightId;
el.innerHTML = feed.slice(0, 6).map(item => {
const initials = String(item.who||"").split(" ").map(s => s[0]).join("").slice(0,2).toUpperCase();
const color = personColor(item.who);
const cls = item.id === effectiveHighlight ? "feed-row new" : "feed-row";
const suffixHtml = item.suffix ? `<span class="what"> ${e(item.suffix)}</span>` : "";
return `
<div class="${cls}" data-id="${e(item.id)}">
<span class="av" style="background:${e(color)}">${e(initials)}</span>
<div class="body">
<span class="who">${e(item.who)}</span>
<span class="what"> ${e(item.action)}</span>
<span class="target"> ${e(item.icon || "")} ${e(item.target)}</span>
${suffixHtml}
</div>
<div class="time" title="${e(item.when.toLocaleString())}">${e(timeAgo(item.when))}</div>
</div>`;
}).join("");
}
function personColor(name) {
const p = PEOPLE.find(x => x.name === name);
return p ? p.color : "#7f8c8d";
}
// ---------- Render: db rows ----------
let rows = ROW_POOL.slice(0, 6).map((r, i) => ({...r, id: "r"+i, updated: Date.now() - (i+1)*1800000 }));
function renderRows(changedId) {
const head = document.querySelector(".db-head");
const db = $("db");
[...db.querySelectorAll(".db-row")].forEach(n => n.remove());
const sorted = [...rows].sort((a,b) => b.updated - a.updated);
const STATUS_LABEL = { done: "Done", progress: "In progress", blocked: "Blocked", todo: "To do", review: "In review" };
// Same reduced-motion gate as renderFeed: skip the .changed
// row-flash class so we don't paint a transient highlight for
// opted-out users (the rowflash CSS animation is already neutralized
// by the prefers-reduced-motion @media block above, but the class
// would still imply intent).
const effectiveChanged = reduceMotion() ? null : changedId;
sorted.forEach(r => {
const div = document.createElement("div");
div.className = "db-row" + (r.id === effectiveChanged ? " changed" : "");
div.dataset.id = r.id;
const statusKey = STATUS_LABEL[r.status] ? r.status : "todo";
const statusLabel = STATUS_LABEL[statusKey];
div.innerHTML = `
<div class="db-cell">${e(r.icon)}</div>
<div class="db-cell title"><span class="t">${e(r.title)}</span></div>
<div class="db-cell"><span class="pill ${statusKey}">${e(statusLabel)}</span></div>
<div class="db-cell">${personChip(r.who)}</div>
<div class="db-cell due">${e(r.due)}</div>
<div class="db-cell priority">${e(r.prio)}</div>
`;
db.appendChild(div);
});
}
// ---------- Refresh logic (the live bit) ----------
let busy = false;
async function refresh({silent = false} = {}) {
if (busy) return;
busy = true;
refreshIco.classList.add("spin");
livePill.classList.remove("stale");
liveText.textContent = "Syncing…";
// Simulate latency to a Notion API call.
await new Promise(r => setTimeout(r, 700 + Math.random() * 600));
const prev = {...kpi};
const changes = [];
// Mutate state in plausible directions.
const newDone = Math.random() < 0.7 ? 1 : 0;
if (newDone) {
kpi.done += 1;
kpi.total += Math.random() < 0.5 ? 1 : 0;
// flip a row to done
const candidates = rows.filter(r => r.status !== "done");
if (candidates.length) {
const r = rand(candidates);
r.status = "done";
r.updated = Date.now();
changes.push({ kind: "row", id: r.id, row: r });
}
} else {
// a brand new task created
const rest = ROW_POOL.filter(p => !rows.some(r => r.title === p.title));
const tmpl = rest.length ? rand(rest) : rand(ROW_POOL);
const r = { ...tmpl, id: "r" + Date.now(), status: "todo", updated: Date.now() };
rows = [r, ...rows].slice(0, 8);
kpi.total += 1;
changes.push({ kind: "row", id: r.id, row: r });
}
if (Math.random() < 0.4) {
kpi.review = Math.max(0, kpi.review + (Math.random() < 0.5 ? -1 : 1));
}
if (Math.random() < 0.25) {
kpi.people = Math.min(kpi.peopleAll, Math.max(6, kpi.people + (Math.random() < 0.5 ? -1 : 1)));
}
// Series shift: today's bucket gains 1-3
series = series.slice();
series[series.length - 1] = series[series.length - 1] + 1 + Math.floor(Math.random()*2);
// Add a new feed item
const lastChange = changes[changes.length - 1];
const person = rand(PEOPLE);
const tmpl = rand(ACTIVITY_TEMPLATES);
const targetTitle = lastChange ? lastChange.row.title : rand(ROW_POOL).title;
const evt = tmpl(person.name, targetTitle);
evt.when = new Date();
evt.id = cryptoRandomId();
feed = [evt, ...feed].slice(0, 8);
lastRefresh = new Date();
busy = false;
refreshIco.classList.remove("spin");
// Only mock-mode here — keep the "Sample data" badge so the artifact
// never falsely claims a real connector is bound.
liveText.textContent = "Sample data";
livePill.classList.remove("stale");
livePill.classList.add("sample");
renderKpi(prev);
renderSpark();
renderFeed(evt.id);
renderRows(lastChange ? lastChange.id : null);
updateTimes();
if (!silent) showToast("Refreshed sample data · 1 task moved, 1 activity added");
}
function showToast(msg) {
toast.textContent = msg;
toast.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toast.classList.remove("show"), 1800);
}
function updateTimes() {
updatedAt.textContent = "Last refreshed " + timeAgo(lastRefresh);
footerTime.textContent = "Last sync · " + fmtDateTime(lastRefresh);
// Sample mode (no connector bound) is sticky — the artifact never
// claims "Live · synced" against fixture data. The pill only escalates
// to "Stale · …" if even the sample-refresh loop has not run for a
// while (e.g. the auto-refresh was paused).
if ((Date.now() - lastRefresh.getTime()) > 90 * 1000) {
livePill.classList.add("stale");
livePill.classList.remove("sample");
liveText.textContent = "Stale · " + timeAgo(lastRefresh);
} else {
livePill.classList.remove("stale");
livePill.classList.add("sample");
liveText.textContent = "Sample data";
}
}
// ---------- Keyboard activation for role=button divs ----------
// .ws / .side-search / .nav-item are styled as clickable rows but they
// are <div>, so keyboard users get nothing for free. Map Enter/Space
// onto a synthetic click and prevent the default Space scroll.
document.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
if (t.getAttribute("role") !== "button") return;
if (t.matches("button, a, input, select, textarea")) return;
ev.preventDefault();
t.click();
});
// ---------- Init ----------
function init() {
renderKpi();
renderSpark();
renderFeed();
renderRows();
updateTimes();
// "Refresh on open" — the moment the artifact mounts.
setTimeout(() => refresh({silent: true}), 600);
}
// ---------- Wire events ----------
refreshBtn.addEventListener("click", () => refresh());
autoBtn.addEventListener("click", () => {
autoOn = !autoOn;
autoLbl.textContent = "Auto · " + (autoOn ? "on" : "off");
if (autoOn) startAuto(); else stopAuto();
});
function startAuto() { stopAuto(); autoTimer = setInterval(() => refresh({silent: true}), 30 * 1000); }
function stopAuto() { if (autoTimer) clearInterval(autoTimer); autoTimer = null; }
setInterval(updateTimes, 5 * 1000);
// Tick relative timestamps in feed every 15s
setInterval(() => renderFeed(), 15 * 1000);
startAuto();
init();
})();
</script>
</body>
</html>
Pre-emit checklist (P0 must pass; do not write index.html until green)
Quote each P0 row in your reply with [x] or [ ]. Do not emit while any P0 is unchecked.
Visual integrity
- [ ] P0 — Display face. Page title uses a system / Notion-leaning
sans (SF Pro / system-ui) or an editorial serif from the active DESIGN.md. Never Inter Display, never SF Pro Display at body size, never Helvetica condensed.
- [ ] P0 — Accent restraint. The
--accenttoken appears at most
twice in the body markup (one Refresh button + one sparkline stroke is canonical). No accent on KPI numbers.
- [ ] P0 — No purple/pink gradient header. The Notion idiom is
flat. Backgrounds are #fff or var(--bg-soft).
- [ ] P0 — Body contrast. Body text vs. background ≥ 4.5:1.
Secondary text ≥ 4.0:1.
- [ ] P0 — No emoji icon strip across the top. Page-emoji is one,
semantically meaningful (📊 for ops, 📓 for docs, 🚀 for launch — never an emoji decoration row).
Honesty (the most-violated rule)
- [ ] P0 — No invented metric. Every number is either provided by
the user, by the connector, or labeled as — / Sample. Forbidden: "10× faster", "join 50,000 founders", "99.99% uptime" unless the user supplied the literal number.
- [ ] P0 — Sample-data badge. When
inputs.connector === mock,
the page must display "Sample data" in the live-pill or callout so a screenshot of this artifact is never mistaken for real ops.
- [ ] P0 — Plausible KPI ranges.
Total tasks< 500 for a small
team. Active members ≤ team size. Done this week ≤ Total.
Connector wiring (only when connector !== mock)
- [ ] P0 — `connectors.json` exists at the project root. See
connectors.md for the schema.
- [ ] P0 — Endpoints documented. Every
fetch()call inside
index.html corresponds to one entry in connectors.json.
- [ ] P0 — No hardcoded secrets. No API tokens, integration secrets,
OAuth client IDs, or workspace IDs in the markup. The OD daemon resolves these via media-config.json.
- [ ] P0 — Stale fallback. On
fetcherror or > 90s without a
successful poll, the pill swaps to amber and the previous values remain on screen. The artifact never goes blank.
Structural
- [ ] P0 — Single file.
index.htmlself-contained. No external
CSS/JS imports beyond the system font stack and one OD custom element. No CDN URLs.
- [ ] P0 — Sidebar collapses on `< 980px`. The dashboard is
mobile-readable; sidebar hides, KPI grid stacks 2-up, table drops due and priority columns.
- [ ] P0 — `prefers-reduced-motion`. All tweens and pulses are
disabled when (prefers-reduced-motion: reduce).
- [ ] P0 — `tabular-nums` on KPI values. Numeric values shouldn't
jitter horizontally during tween or auto-refresh.
Polish (P1)
- [ ] P1 — Refresh button shows a spinning ↻ for the duration of the
poll, then settles back. No layout shift.
- [ ] P1 — Activity feed: newly-inserted row gets a left-edge accent
bullet (•) and a soft accent background for 2s, then fades.
- [ ] P1 — Database row that just changed flashes the row background
with a 1.4s ease-out.
- [ ] P1 — Live-pill states: green pulse (idle), blue solid
(syncing), amber solid (stale).
- [ ] P1 — Sparkline grid lines are dashed, line-color from
--line,
stroke-dasharray 2 3. Dots are filled --accent r=3.
- [ ] P1 — Toast bottom-center, dark
#1f1f1f, 1800ms auto-dismiss.
Content
- [ ] P1 — Person chips: 18px round avatar, two-letter initials,
stable per-name color. No initials > 2 chars.
- [ ] P1 — Status pill copy uses Notion canonical labels:
"Done", "In progress", "Blocked", "In review", "To do". Case matters: "In progress" not "In-Progress".
- [ ] P1 — Footer attribution names the source platform and the
connector slug exactly: composio.notion, composio.linear, etc.
Components
Copy these markup shapes verbatim. Re-skin via the active DESIGN.md (color, typography, spacing tokens). Do not invent new component shapes — that's where AI-slop sneaks in.
All snippets assume the CSS custom properties defined in assets/template.html (--ink, --ink-2, --bg-soft, --accent, --line, --pill-*-bg/ink, …).
---
live-pill (top-right of the topbar)
<div class="pill-live" id="livePill">
<span class="dot"></span>
<span id="liveText">Live · synced</span>
</div>Three states:
| State | Class on .pill-live | Dot color | Text |
|---|---|---|---|
| Idle | (none) | green | Live · synced |
| Syncing | .syncing | accent | Syncing… |
| Stale | .stale | amber | Stale · 2 min ago |
The green dot animates a 1.8s pulse keyframe. Disable on prefers-reduced-motion.
---
kpi card (one cell of the KPI grid)
<div class="kpi">
<div class="label">Total tasks</div>
<div class="value" id="kTotal">142</div>
<div class="delta up"><span class="arr">↑</span> 6 vs last week</div>
</div>Rules:
.valueis `font-size: 32px; font-weight: 600; letter-spacing: -0.01em;
font-variant-numeric: tabular-nums`
.labelis12px uppercase letter-spacing .03emcolor--ink-2.delta.up→ green;.delta.down→ red; neutral.deltais grey- On refresh, tween
valuebetween old and new with a 600ms cubic
ease-out, and add .flash for 700ms (turns the number --accent-ink briefly).
- Never put a colored progress bar under the number.
- Never put a sparkline inside a KPI card — sparklines belong in
the two-column block.
---
sparkline (SVG, hand-rolled)
<svg class="spark" viewBox="0 0 600 140" preserveAspectRatio="none">
<g class="spark-grid">
<line x1="0" y1="35" x2="600" y2="35"/>
<line x1="0" y1="70" x2="600" y2="70"/>
<line x1="0" y1="105" x2="600" y2="105"/>
</g>
<path class="spark-fill" d=""></path>
<path class="spark-line" d=""></path>
<g id="sparkDots"></g>
<g id="sparkLabels" class="spark-axis"></g>
</svg>CSS:
.spark-fillisfill: rgba(<accent-rgb>, 0.10).spark-lineisfill: none; stroke: var(--accent); stroke-width: 2.spark-grid lineisstroke: var(--line); stroke-dasharray: 2 3.spark-dot(circles, r=3) isfill: var(--accent)
Compute paths from a length-7 series with padding 24/16/10/24 (L/R/T/B) inside the 600×140 viewBox. Today's bucket is at the right.
Do not use Chart.js / Recharts / D3 — single artifact, no external deps.
---
activity-feed row
<div class="feed-row">
<span class="av" style="background:#f1c40f">SC</span>
<div class="body">
<span class="who">Sarah Chen</span>
<span class="what"> moved</span>
<span class="target"> 🚀 Q3 Roadmap planning</span>
<span class="what"> to In progress</span>
</div>
<div class="time">3 min ago</div>
</div>- Avatar: 26px round, two-letter initials, color stable per person.
.targetgets a 1px dotted underline andcursor: pointer.- A "just inserted" row gets the
.newclass for 2s — applies a soft
rgba(<accent>, .06) background and a • bullet before the name.
- Re-render relative timestamps every 15s.
---
status pills (Notion canonical five-color set)
<span class="pill done">Done</span>
<span class="pill progress">In progress</span>
<span class="pill blocked">Blocked</span>
<span class="pill review">In review</span>
<span class="pill todo">To do</span>| Class | Background | Foreground | Notion label |
|---|---|---|---|
done | #dbeddb | #2b593f | Done |
progress | #fdecc8 | #976d23 | In progress |
blocked | #ffe2dd | #b13b2c | Blocked |
review | #d3e5ef | #1f5b78 | In review |
todo | #e9e5e3 | #5a534f | To do |
Do not introduce extra states. Map any project-specific state into one of these five.
---
linked-database row
<div class="db-row">
<div class="db-cell">📐</div>
<div class="db-cell title"><span class="t">Design tokens v2 spec</span></div>
<div class="db-cell"><span class="pill review">In review</span></div>
<div class="db-cell"><span class="person">…avatar…<span>Lisa Zhao</span></span></div>
<div class="db-cell due">May 14</div>
<div class="db-cell priority">Med</div>
</div>- 6-column grid:
32px 2.4fr 1fr 1fr 0.9fr 0.9fr .db-row.changedtriggers a 1.4s row-flash via therowflash
keyframe.
- Hover:
background: var(--bg-soft). No transform / shadow.
---
refresh button (page header, primary)
<button class="btn primary" id="refreshBtn">
<span class="ico" id="refreshIco">↻</span>
<span>Refresh from Notion</span>
</button>- Default:
background: var(--accent); color: #fff; - Hover:
background: var(--accent-ink) - During poll: add
.spinto.ico, disable button taps via the
busy flag in JS (do not rely on [disabled] so visuals stay identical).
- Label adapts to connector: "Refresh from Notion" / "Refresh from
Linear" / "Refresh from Stripe" — never just "Refresh" alone.
---
auto-toggle (page header, ghost)
<button class="btn ghost" id="autoBtn" title="Auto refresh every 30s">
<span>⏱</span><span id="autoLbl">Auto · on</span>
</button>Toggles setInterval(refresh, refresh_seconds * 1000). Persists to localStorage.live_dashboard_auto so a reload restores the state.
---
toast (bottom-center, transient)
<div class="toast" id="toast">Synced — 3 changes from Notion</div>position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); 1800ms auto-dismiss. Dark #1f1f1f background, white text. One toast at a time; subsequent calls reset the timer.
---
callout (Notion idiom)
<div class="callout">
<div class="emj">💡</div>
<div>
<div><strong>This page is a Live Artifact.</strong> Numbers below are pulled from
your <strong>Acme Studio</strong> Notion workspace via the Composio connector.</div>
<small>Refreshes on demand or when the page opens. Last 7 days only.</small>
</div>
</div>One short, declarative sentence + a smaller meta line. No marketing copy, no "Powered by", no exclamation marks.
Connectors
live-dashboard is a Live Artifact. The values it shows are not hard-coded — they are polled from a connector at runtime. The OD daemon (0.4.0+) ships a Composio connector catalog and a connectors.json contract that artifacts emit alongside index.html.
When inputs.connector === mock (or the daemon cannot resolve the configured connector), the artifact falls back to seeded sample data. This keeps screenshots, the picker preview, and offline use working.
Status — relationship to `skills/live-artifact/`.
>
The canonical, currently-shipping live-artifact contract lives in
`skills/live-artifact/SKILL.md`: it is
file-shaped (artifact.json+template.html+data.json+
provenance.json) and CLI-shaped on the agent side (the agent calls"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts {create,update} andtools connectors {list,execute} rather than HTTP). The renderer isscalar-onlyhtml_template_v1(apps/daemon/src/live-artifacts/render.ts).
>
live-dashboard is a complementary browser-runtime variant: theartifact is rendered as a single self-contained HTML page, and the
live behaviors (refresh-on-open, manual Refresh, auto-refresh, stale
pill) run in-page rather than at template-render time. Polling
therefore needs an HTTP shape, which is what the rest of this file
describes (POST /api/od/connectors/poll).>
Treat the HTTP shape below as a forward-looking proposal that
sits alongside the file/CLI contract: the daemon does not yet expose
POST /api/od/connectors/poll(apps/daemon/src/server.ts/
apps/daemon/src/live-artifacts/), so out-of-the-box the artifactrenders against the seeded sample data and the Refresh button only
tweens the fixture. When the daemon-team route lands, only
seedNextChange() in the template needs to be replaced with thepoll()helper documented here — theconnectors.jsonshape is
already a usable declarative source-of-truth that downstream tooling
(the live-artifact CLI, MCP wrappers, audit logs) can read today.
---
connectors.json schema
Emit one connectors.json at the project root next to index.html:
{
"schema": "od.connector/1",
"primary": "notion",
"freshness": {
"auto_refresh_seconds": 30,
"warn_after_seconds": 90,
"fail_after_seconds": 600
},
"bindings": {
"notion": {
"provider": "composio.notion",
"auth_ref": "media-config.json#notion.token",
"reads": [
{
"id": "tasks_active",
"endpoint": "databases.{id}.query",
"params": { "id": "${notion.tasks_db_id}",
"filter": { "property": "Status", "status": { "does_not_equal": "Done" } },
"sorts": [{ "property": "Updated", "direction": "descending" }] },
"shape": "task[]"
},
{
"id": "kpi_total",
"endpoint": "databases.{id}.query",
"params": { "id": "${notion.tasks_db_id}", "page_size": 1 },
"extract": "$.metadata.total_count",
"shape": "integer"
},
{
"id": "activity_recent",
"endpoint": "search",
"params": { "filter": { "property": "object", "value": "page" },
"sort": { "direction": "descending", "timestamp": "last_edited_time" },
"page_size": 8 },
"shape": "activity[]"
}
],
"events": [
{ "id": "task_changed", "type": "page.updated", "selector": "$.tasks_db_id" }
]
}
}
}The shape is intentionally close to Notion's REST API — the daemon's connector adapter rewrites endpoint and params into the live provider call. Other connectors (Linear, Stripe, Posthog) follow the same shape with provider-specific endpoint strings.
---
Resolution order (what the daemon does)
1. Read connectors.json from the artifact dir. 2. Look up bindings[primary].provider in the Composio catalog. 3. Resolve auth_ref against the daemon's media-config.json. The actual lookup is environment-aware (see `apps/daemon/src/media-config.ts`, configFile() — precedence high → low):
<OD_MEDIA_CONFIG_DIR>/media-config.jsonwhen that env var is set;- else
<OD_DATA_DIR>/media-config.jsonwhenOD_DATA_DIRis set
(relative paths are anchored to the active project root, $HOME and ~ shorthands are expanded);
- else
<projectRoot>/.od/media-config.jsonfor the active project.
The artifact never opens any of these paths itself — it always goes through the daemon poll endpoint, and the daemon enforces the correct lookup order. Never read tokens from the artifact. 4. For each reads[].endpoint, the daemon constructs the live HTTP request with the resolved auth and substitutes ${...} placeholders from the resolved media-config.json#<provider>.* values. 5. Cache responses for freshness.auto_refresh_seconds. The Refresh button issues an explicit poll that bypasses the cache.
---
Wiring inside index.html
The artifact does not call Composio directly. It calls the OD daemon's local proxy:
async function poll(readId) {
const res = await fetch(`/api/od/connectors/poll`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "<project_id>", read: readId })
});
if (!res.ok) throw new Error(`poll ${readId} failed: ${res.status}`);
return res.json();
}<project_id> is injected by the daemon at render time via a <meta name="od:project" content="..."> tag. The artifact reads it once on mount.
---
Fallback behavior
- On
fetcherror: keep the previously-rendered values, swap the
live-pill to amber Stale · <ago>, write a small grey hint into the footer (Source: Notion · last good poll 4 min ago).
- On
inputs.connector === mock: skippoll()entirely, use the
seedMock() function in index.html. The live-pill displays Sample data in grey with no dot animation.
- On 401/403: surface a one-time toast `Reconnect Notion in Settings →
Connectors` and stop further polls until the next manual Refresh.
---
Provider-specific cheat sheet
| Connector | provider | Shape of one row | Typical KPI |
|---|---|---|---|
| Notion | composio.notion | task = {title, status, assignee, due, prio, updated} | total tasks · done this week · members · review |
| Linear | composio.linear | issue = {title, state, assignee, priority, updated} | backlog · in progress · blocked · cycle progress |
| Stripe | composio.stripe | event = {type, amount, customer, created} | MRR · churn · new subs · refunds |
| Posthog | composio.posthog | event = {name, distinct_id, $current_url, ts} | DAU · signups · feature adoption · errors |
Do not invent per-provider shapes. If the user wants something not in this table, fall back to mock and surface a footer hint asking the user to extend the connector catalog.
Layouts
Pick exactly one layout. State your choice in your reply before emitting index.html.
All three share the same shell from assets/template.html: grid-template-columns: 240px 1fr (sidebar + main), a 44px topbar, a 56px page top padding, and a max-width of 1100px on the page column.
---
A · Classic dashboard (default)
Vertical stack inside the page column, in this order:
1. Page header (emoji + title + meta row) 2. Live-Artifact callout 3. KPI grid — 1 row × N (where N = inputs.kpi_count), 1px hairline grid 4. Two-column block — 1.1fr 1fr:
- left: Tasks created · last 7 days sparkline card
- right: Recent activity feed card
5. Linked database — Tasks · Active sprint 6. Footer
Use this when:
- The user said "team dashboard" / "ops dashboard" without further qualifier.
- The user wants a generalist Notion-like landing page for a team
workspace.
This is what example.html ships.
---
B · Kanban-flavored
Same shell, but replace the linked database (step 5) with a 3-column kanban board:
┌── To do ───────┐ ┌── In progress ─┐ ┌── In review ───┐
│ card │ │ card │ │ card │
│ card │ │ card (changed) │ │ card │
│ + add │ │ card │ │ card │
└────────────────┘ └────────────────┘ └────────────────┘Cards are 1px-bordered, 12px padding, with: emoji row + title (medium weight) + assignee chip + due date.
Use this when:
- The user explicitly said "kanban", "sprint board", "backlog view".
inputs.include_task_tableis true and the user mentioned drag /
swimlane / column.
The Live behavior stays the same: cards animate from one column to the next on refresh (250ms ease-out translate, plus a 600ms accent left-border on the moved card).
---
C · KPI-only hero
Drop the database entirely. The page is just:
1. Page header 2. Callout (slightly taller — explain the data range) 3. Hero KPI grid: 2 × 2 with 32px → 56px font-size on the numbers (use display_scale parameter) 4. The sparkline spans full width (no two-column split) 5. Activity feed becomes a single horizontal ticker (3 visible rows max, auto-scrolls every 8s) 6. Footer
Use this when:
inputs.include_task_table === false.- The user said "exec summary", "morning standup", "weekly snapshot".
- The user wants something to throw on a TV in the office.
---
Density & responsive notes (apply to all three)
- The
densityparameter controls the gap between blocks (callout →
KPIs → two-column → DB) — 8px low, 36px high. Default 18.
- Below 980px viewport, sidebar hides, the page padding drops to 20px,
KPI grid becomes 2-up, and the two-column block stacks.
- Below 640px, the linked database hides
dueandprioritycolumns
and the kanban becomes a 1-column vertical scroll.
- Honor
prefers-reduced-motion: reduceeverywhere — disable tweens,
pulses, kanban-card translate, ticker auto-scroll, and the live-pill pulse.