④段整套同步(六节 + 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:
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("&", "&").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>
|
||||
@@ -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("&", "&").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>
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user