④段整套同步(六节 + P/S/F/J + 模板)+ open-design 移到技能根

一、④段整套同步(用户选候选 A)
- 新增 `references/stage-proto-doc/assets/产品文档模板.html`(16 KB/247 行,从备份拷、转 LF)。
- ④段 SKILL.md 换成备份 15:28 那版,并按模板口径改齐:
  · 「产品文档框架」从「四块+一附录」改成**六节** —— 页面结构/功能清单/使用场景/操作流程/
    跨页与界面状态/待确认与偏离;与模板同构,⛔ 不从空白页重写结构。
  · 编号从 R/S/F 改成 **P 页面/S 场景/F 功能/J 二级功能**,各自唯一、按展示顺序通排。
  · 保留旧版有价值的要求:每项功能补齐 做什么/入口在哪/输入边界/异常与恢复;重点功能三处标红;
    横向流程图每步写清页面/功能/操作/完成判据。
  · 完成标准「七节」→「六节」,自查清单同步换掉「需求」那批条目,补模板与配图两条。
  · 路径 `designs/<项目>/` → `docs/pm/<项目>/ui/`(2 处)。
  · 补回本机那条「每份产出文档开头都要有一张图」的引用(指向主 skill,段内不复述)。
- 主 skill 同步:四段结构表④行、④段沿革、画图小节(④段画页面结构图 + 用户路径)。

二、open-design 移到技能根(用户选候选 A)
- `git mv product-planning/references/stage-delivery/assets/open-design → open-design/`(4178 文件)。
- `.gitignore` 白名单加 `!/open-design/` —— 否则移出去就不再纳入版本控制。
- 引用改 11 处:路径形态 `assets/open-design/...` → `<技能根>/open-design/...`;
  「随段携带的自带件」改称「同根技能」;「路径怎么找」那条改成自带件(段内相对路径)与
  同根技能(`<技能根>/<技能名>/`)两类分开写。
- open-design 自己的 ATTRIBUTION.md 两处同步(它现在与 oil-ui-pro 同层)。
- 副作用(有益):命名闸门读数由 2063 降到 225 —— 此前它把 open-design 里 919 个第三方 md
  当成规则一起扫了;现在只扫本包 23 个 md。已确认④段的 H1 与 4a/4b/4c 子步仍在检查范围内。

三、验收
- check_naming.py --ws E:/ProgramData/AIProject/contentm_agent = 通过 225 / 失败 0 / 提示 27。
- 活文件里 `assets/open-design` 残留 0;工作区 CRLF 文件 0。
This commit is contained in:
admin committed 2026-10-15 21:17:28 +08:00
1 parent fa0a9026c8
commit 5afeb6b78a
4184 files changed
+326 -70

No files matched your search

@@ -0,0 +1,202 @@
---
name: live-dashboard
description: |
Notion-style team dashboard rendered as a Live Artifact. A single-page,
self-contained HTML dashboard with KPIs, a 7-day sparkline, a real-time
activity feed and a linked-database task table — wired to Notion via the
Composio connector catalog. Refreshes on demand and when the artifact
is opened. Falls back to seeded mock data when no connector is bound,
so it works offline / in screenshots / in the picker preview.
triggers:
- "team dashboard"
- "notion dashboard"
- "live dashboard"
- "ops dashboard"
- "team workspace dashboard"
- "团队仪表盘"
- "Notion 仪表盘"
- "Live Artifact dashboard"
od:
mode: prototype
platform: desktop
scenario: operation
fidelity: high
preview:
type: html
entry: index.html
reload: debounce-100
design_system:
requires: true
sections: [color, typography, layout, components, anti-patterns]
craft:
requires: [typography, color, anti-ai-slop, motion-discipline, state-coverage]
inputs:
- name: workspace_name
type: string
required: true
- name: page_title
type: string
default: "Team Dashboard"
- name: connector
type: enum
values: [notion, linear, stripe, posthog, mock]
default: notion
- name: refresh_seconds
type: integer
default: 30
min: 10
max: 300
- name: stale_after_seconds
type: integer
default: 90
min: 30
max: 600
- name: kpi_count
type: enum
values: [2, 4, 6]
default: 4
- name: include_activity_feed
type: boolean
default: true
- name: include_task_table
type: boolean
default: true
parameters:
- name: accent_hue
type: hue
default: 198 # Notion-blue baseline
range: [0, 360]
- name: surface_warmth
type: spacing
default: 0
range: [-12, 24] # nudges sidebar / soft-bg L* in OKLch
- name: density
type: spacing
default: 18
range: [8, 36]
- name: display_scale
type: font-scale
default: 1.0
range: [0.85, 1.4]
outputs:
primary: index.html
secondary: [connectors.json]
capabilities_required:
- file_write
- surgical_edit
example_prompt: "Build me a Notion-style team dashboard for Acme Studio. KPIs: total tasks, done this week, active members, docs in review. Wire it to the Notion connector and let it refresh on demand."
---
# 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()` runs `refresh({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_seconds` without a successful refresh, swap the
pill to amber `Stale · <ago>`.
- Real connector mode: `POST /api/od/connectors/poll` with 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 `design-templates/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` — when `inputs.connector !== mock`. See
`references/connectors.md` for the schema.
- Both files in the project cwd. Do not write anywhere else.
@@ -0,0 +1,643 @@
<!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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
/* 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>
@@ -0,0 +1,946 @@
<!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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
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>
@@ -0,0 +1,83 @@
# 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 `--accent` token 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 `fetch` error 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.html` self-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.
@@ -0,0 +1,212 @@
# 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)
```html
<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)
```html
<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:
- `.value` is `font-size: 32px; font-weight: 600; letter-spacing: -0.01em;
font-variant-numeric: tabular-nums`
- `.label` is `12px uppercase letter-spacing .03em` color `--ink-2`
- `.delta.up` → green; `.delta.down` → red; neutral `.delta` is grey
- On refresh, tween `value` between 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)
```html
<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-fill` is `fill: rgba(<accent-rgb>, 0.10)`
- `.spark-line` is `fill: none; stroke: var(--accent); stroke-width: 2`
- `.spark-grid line` is `stroke: var(--line); stroke-dasharray: 2 3`
- `.spark-dot` (circles, r=3) is `fill: 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
```html
<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.
- `.target` gets a 1px dotted underline and `cursor: pointer`.
- A "just inserted" row gets the `.new` class 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)
```html
<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
```html
<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.changed` triggers a 1.4s row-flash via the `rowflash`
keyframe.
- Hover: `background: var(--bg-soft)`. No transform / shadow.
---
## refresh button (page header, primary)
```html
<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 `.spin` to `.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)
```html
<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)
```html
<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)
```html
<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.
@@ -0,0 +1,169 @@
# 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 `design-templates/live-artifact/`.**
>
> The canonical, currently-shipping live-artifact contract lives in
> [`design-templates/live-artifact/SKILL.md`](../../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}` and
> `tools connectors {list,execute}` rather than HTTP). The renderer is
> scalar-only `html_template_v1` (`apps/daemon/src/live-artifacts/render.ts`).
>
> `live-dashboard` is a **complementary** browser-runtime variant: the
> artifact 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 artifact
> renders 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 the
> `poll()` helper documented here — the `connectors.json` shape 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`:
```json
{
"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-owned media configuration. The narrow
`OD_MEDIA_CONFIG_DIR` override applies to `media-config.json` only;
otherwise the daemon must derive the file from its already-resolved
`RUNTIME_DATA_DIR`. Do not infer a path from the project cwd or restate one
here. Before changing this lookup, read root `AGENTS.md` → **Daemon data
directory contract** and
[`apps/daemon/src/media/config.ts`](../../../apps/daemon/src/media/config.ts).
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:
```js
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 `fetch` error: 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`: skip `poll()` 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.
@@ -0,0 +1,92 @@
# 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_table` is 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 `density` parameter 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 `due` and `priority` columns
and the kanban becomes a 1-column vertical scroll.
- Honor `prefers-reduced-motion: reduce` everywhere — disable tweens,
pulses, kanban-card translate, ticker auto-scroll, and the live-pill
pulse.