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

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

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

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

No files matched your search

@@ -0,0 +1,139 @@
---
name: live-artifact
description: |
Create refreshable, auditable OpenDesign artifacts backed by connector or local data.
Trigger when the user asks for live dashboards, refreshable reports, synced views, or reusable data-backed artifacts.
triggers:
- "live artifact"
- "live dashboard"
- "refreshable dashboard"
- "live report"
- "refreshable report"
- "synced view"
- "可刷新"
- "实时看板"
od:
mode: prototype
scenario: live
preview:
type: html
entry: index.html
reload: debounce-100
design_system:
requires: true
outputs:
primary: index.html
secondary:
- template.html
- artifact.json
- data.json
- provenance.json
capabilities_required:
- shell
- file_write
---
# Live Artifact Skill
Create an OpenDesign live artifact: a project-scoped, previewable HTML artifact whose data can later be refreshed without redesigning the presentation.
## Resource map
```
live-artifact/
├── SKILL.md
└── references/
├── artifact-schema.md ← `references/artifact-schema.md`: artifact files, DTO shape, template binding rules
├── connector-policy.md ← `references/connector-policy.md`: connector safety, redaction, credential boundaries
└── refresh-contract.md ← `references/refresh-contract.md`: source metadata, refresh execution, snapshots
```
## Current status
Use the references in this directory as the source of truth for the live artifact file contract. Prefer daemon wrapper commands over raw HTTP when registering or updating live artifacts.
## When to use this skill
Use this skill when the user asks for a data-backed view that should remain useful after the first render, for example a live dashboard, refreshable report, synced status page, auditable data view, or artifact that can later be refreshed from local/project data or connectors.
Before creating files, decide whether the user actually wants a live artifact or a normal static artifact:
- Use a live artifact when the user mentions refresh, sync, recurring updates, connector-backed data, source/provenance tracking, dashboards, reports, or reusable data-backed views.
- Use a normal static artifact when the user only wants a one-off HTML/mockup/image/file and does not need refresh, source metadata, or data/provenance panels.
- If the intent is ambiguous, ask one short question: “Should this be refreshable/live, or just a static artifact?”
## Workflow
1. **Resolve scope and data source without blocking on connected connectors**
- Identify the preview goal, audience, data freshness expectations, and whether refresh should be possible later.
- If the user explicitly names a connector/source such as Notion, GitHub, Slack, or Google Drive, do not ask “where should the data come from?” before checking daemon connector tools.
- Prefer local/project sources or daemon connector tools when available.
- Do not call provider APIs directly when a daemon connector/wrapper exists.
- If connector data is needed, first list connectors with `"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact`. If the named connector is present with `status: "connected"`, choose an appropriate read-only `auto` tool from its catalog and execute it through the connector wrapper.
- For Notion specifically, a connected `notion` connector plus a user brief that names Notion is enough to start with `notion.notion_search` using a query derived from the requested artifact/topic. Use `notion.notion_fetch_database` only when the user supplied a database id or the search result clearly identifies one.
- Ask the user a data-source question only when no matching connected connector exists, multiple connected candidates fit equally well, or the requested artifact has no usable topic/query to search for. If you must ask, be specific: ask for the page/database/topic or permission to search broadly, not “where is the Notion data source?”
2. **Author the source files**
- Write `template.html` as the human-designed HTML template. The daemon hydrates it with `data.json` using the `html_template_v1` binding contract — stay inside it or hydration fails and the raw template ships with visible `{{…}}` tokens:
- Scalars: `{{data.path.to.value}}`. Paths start with `data`, dot-separated; numeric array indexes are allowed (`{{data.kpis.0.value}}`). Every binding must resolve to a single string/number, not an object or array.
- Lists: repeat one element with `data-od-repeat="item in data.items"`, then bind loop-scoped fields inside it as `{{item.label}}` (or `{{item}}` for a scalar array). One level only; `data.*` still works inside the repeat for globals.
- Do NOT hand-loop in a `<script>` (scripts are stripped from previews), and do NOT reference a bare loop variable like `{{metric.value}}` without a matching `data-od-repeat` scope — that binding is unresolvable. Nested repeats, conditionals, filters, helpers, and raw/triple-brace interpolation are unsupported.
- Write `data.json` as the canonical preview data used by those bindings — real values, not placeholders.
- Write `artifact.json` with the live artifact metadata, preview declaration, document declaration, and safe source descriptors.
- Write `provenance.json` with concise source notes, timestamps, non-sensitive connector references, and transformation notes.
- Do not author `index.html` as source. The daemon derives `index.html` from `template.html` and `data.json`.
3. **Keep data compact and preview-oriented**
- Store only normalized values needed by the preview.
- Summarize large lists, provider responses, or logs before writing them into `data.json`.
- Stay within the bounded JSON rules in `references/artifact-schema.md`.
4. **Apply safety rules before registration**
- Never store credentials, OAuth tokens, API keys, cookies, auth headers, raw provider responses, HTTP envelopes, full payloads, or secret-like fields in `artifact.json`, `data.json`, `provenance.json`, or source metadata.
- Avoid forbidden key names such as `raw`, `rawResponse`, `payload`, `body`, `headers`, `cookie`, `authorization`, `token`, `secret`, `credential`, and `password` anywhere in persisted JSON.
- Use escaped `html_template_v1` interpolation only. Raw/unescaped HTML interpolation is not allowed.
5. **Register or update through daemon wrappers**
- Use the OpenDesign daemon wrapper commands via `"$OD_NODE_BIN" "$OD_BIN"` instead of raw `curl`, bare `node`, or bare `od`:
```bash
"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json
"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts list --format compact
"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts update --artifact-id "$ARTIFACT_ID" --input artifact.json
```
- The wrapper reads injected `OD_NODE_BIN`, `OD_BIN`, `OD_DAEMON_URL`, and `OD_TOOL_TOKEN`; do not print, persist, or override token values.
- Do not include or invent `projectId`; the daemon derives project/run scope from the token.
- Use raw HTTP only for daemon development/debugging when explicitly requested.
6. **Use connector wrappers for connector data**
- Discover available connectors and tools:
```bash
"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact
```
- Execute a read-only connector tool with a JSON object input file:
```bash
"$OD_NODE_BIN" "$OD_BIN" tools connectors execute --connector "$CONNECTOR_ID" --tool "$TOOL_NAME" --input input.json
```
- Persist only the compact normalized fields needed by the preview plus non-sensitive connector references (`connectorId`, `toolName`, `accountLabel`). Never persist connector credentials, transport metadata, or raw provider output.
- Do not ask for connector secrets or duplicate setup. If `status` is `connected`, use the listed tools; if it is not connected, tell the user to connect it in the UI.
- See `references/connector-policy.md` for listing/execution and credential boundaries, and `references/refresh-contract.md` for read-only refresh source metadata.
7. **Report concise results**
- On success, return the artifact ID/title and note that `index.html` is daemon-derived.
- On validation failure, fix the source files and retry through the wrapper. Do not bypass validation.
## Required files
Every live artifact creation flow must produce these source files before registration:
- `template.html` — declared skill output and source template for the preview.
- `data.json` — compact, canonical preview data.
- `artifact.json` — create/update input for daemon validation.
- `provenance.json` — safe source and transformation summary.
`index.html` is the primary preview entry declared in frontmatter, but it is derived daemon output rather than agent-authored source.
@@ -0,0 +1,342 @@
# Clinic Console — live artifact template
A `html_template_v1` template for a friendly clinic / hospital / telemedicine
operations console. Soft-mint healthcare aesthetic: cool off-white canvas,
single mint accent, generous 18px card radii, signature diagonal-stripe
pattern fills inside KPI tiles and bar-chart bars, illustrated CSS-gradient
avatars, and one dark surface (the calendar activity popover).
## Files
```text
clinic-console/
├── template.html # html_template_v1 source — only DOM, CSS tokens, and {{data.*}} bindings
├── data.json # canonical default sample (renders straight out of the box)
└── README.md # this file — data contract + customization notes
```
## How an agent uses this template
This template is intended to be copied (or referenced) when the
[`live-artifact`](../../../SKILL.md) skill is invoked with a healthcare
operations brief such as *"clinic dashboard", "doctors schedule", "hospital
admin", "appointment console", "telemedicine ops", "诊所后台", "医院管理"*.
The agent should:
1. Copy `template.html` and `data.json` into the project's live-artifact
workspace directory as `template.html` and `data.json`.
2. Edit `data.json` to reflect the actual brand, names, schedules, and
numbers from the brief or connector source. The shape of the JSON must
match what `template.html` references (every `{{data.path}}` interpolation
below).
3. Author `artifact.json` and `provenance.json` per the live-artifact
protocol, then register the artifact through the daemon wrapper:
```bash
"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json
```
4. The daemon renders `template.html + data.json` into the preview
`index.html` automatically. The agent does **not** author `index.html`.
When the user clicks **Refresh**, the daemon re-runs the registered source,
maps results back into `data.json`, re-renders the preview, and snapshots
the change — the layout never changes; only the numbers, names, and pill
states do.
## Default sample renders out of the box
If you create a live artifact using the default `data.json` shipped here,
you get the canonical "St. Lukes Wellness" demo screen:
- Greeting: `Hey Lukmon, glad to have you back! 🙌`
- Four KPI tiles: Total doctors / Total bookings / Available rooms / Total
visitors, with mixed amber- and blue-stripe pattern footers and an inline
General/Private rooms mini-list.
- Patient overview chart with paired diagonal-stripe bars across Jan–Jul,
Mar 2025 highlighted with a mint outline.
- March 2025 mini-calendar with day 8 active (mint circle + dot) and a dark
Activity Detail popover floating below it.
- Top requested clinics donut: Dental 120 / Cardiology 249 / Surgery 165.
- Doctor schedule with three pastel pills (Available / Unavailable / Leave).
- Today's appointments list with five illustrated avatars + venue / mode
hints (`room 204`, `video call`, …).
## Default sample provenance.json
If you ship the default sample without re-sourcing the data, use:
```json
{
"generatedAt": "2026-04-29T12:00:00.000Z",
"generatedBy": "agent",
"notes": "Default sample data shipped with the clinic-console template. Replace with real clinic data before sharing externally.",
"sources": [
{ "label": "Template default sample", "type": "user_input" }
]
}
```
## Data contract
The shape below is the contract between `template.html` and `data.json`.
Every key listed is referenced by at least one `{{data.path}}` interpolation
in `template.html`. All values are scalars (string or number); the template
does not invoke any expression / helper / conditional logic — it is a
straight `html_template_v1` substitution.
### Top-level scalars
| Key | Example | Notes |
|---|---|---|
| `brand_name` | `"ST. LUKES"` | Sidebar wordmark. Keep ≤14 characters. |
| `greeting` | `"Hey Lukmon, glad to have you back! 🙌"` | Single emoji allowed at the end; no other emoji anywhere in the artifact. |
| `search_placeholder` | `"Search doctors, patients, rooms…"` | Greeting-row search input ghost text. |
| `search_shortcut` | `"⌘K"` | Right-side keycap label. |
| `secondary_action_label` | `"Export CSV"` | Greeting-row secondary button text. |
| `primary_action_label` | `"Add new"` | Greeting-row primary mint CTA text. |
### `user`
| Key | Example | Notes |
|---|---|---|
| `name` | `"Lukmon Olabode"` | Sidebar bottom row. |
| `role` | `"Admin"` | One-word role; longer roles wrap. |
| `av_class` | `"av-orange"` | One of `av-orange`, `av-pink`, `av-mint`, `av-blue`, `av-violet`, `av-amber`, `av-rose`. |
| `initial` | `"L"` | Single uppercase letter. |
### `nav_main` and `nav_management` (5 items each)
Each item shape:
| Key | Example | Notes |
|---|---|---|
| `label` | `"Dashboard"` | Nav text. |
| `active_class` | `""` or `"active"` | Set to `"active"` on exactly one nav item across both groups. |
| `count` | `""` or `"10"` | Empty string hides the count badge (CSS `:empty { display: none }`). |
> **Icons are template-locked.** Each nav slot's icon is hardcoded inside
> `template.html` (see [Icons are template-locked](#icons-are-template-locked)
> below) and is not exposed through `data.json`. The `html_template_v1`
> security validator forbids `{{data.*}}` interpolation inside URL-bearing
> attributes (`<use href>`, `<a href>`, `<img src>`, …) — and even if it
> didn't, the validator runs *before* substitution, so a malformed `data.json`
> could smuggle a `javascript:` URL past it. The reorder rule is therefore:
> if you change the meaning of a nav slot, also edit the corresponding
> `<use href="#icon-…">` literal in `template.html`.
### `pro_card`
| Key | Example | Notes |
|---|---|---|
| `tag` | `"Pro"` | Black pill in the upgrade card. Keep ≤6 characters. |
| `title` | `"Pssst!"` | Display title. |
| `body` | `"Your subscription expires in 9 days."` | One-sentence nudge. |
| `primary_label` | `"Renew"` | Mint primary action. |
| `secondary_label` | `"Cancel"` | Outlined secondary action. |
### KPI tiles `kpi_a` `kpi_b` `kpi_c` `kpi_d`
Tiles A, B, D share the **caption + pattern strip** layout. Tile C uses a
**2-row mini-list** layout instead. Every tile must have either a strip or a
mini-list — never bare.
Common keys:
| Key | Example | Notes |
|---|---|---|
| `label` | `"Total doctors"` | Tile label. |
| `value` | `"1,089"` | Big number (Plus Jakarta Sans 700). Use commas for thousands. |
| `trend_class` | `"up"` or `"down"` | Pill grammar — `up` = mint, `down` = rose. |
| `trend_label` | `"↑ 5.5%"` | Always include the arrow glyph. |
> KPI icons are also template-locked — see
> [Icons are template-locked](#icons-are-template-locked) below.
A / B / D additional keys:
| Key | Example | Notes |
|---|---|---|
| `caption` | `"An increase of 20 doctors in the last 7 days."` | One sentence answering "compared to what". |
| `strip_class` | `"stripe-amber"` | One of `stripe-amber`, `stripe-blue`, `stripe-mint`. Adjacent tiles should alternate hues. |
| `mini_stat` (B / D only) | `"1,635 today"` | Right-aligned tiny caption below the strip. |
C (`kpi_c`) additional keys:
| Key | Example |
|---|---|
| `rows` | array of 2 objects: `{ "label": "General room", "value": "100" }` |
### `chart`
| Key | Example | Notes |
|---|---|---|
| `title` | `"Patient overview"` | Card title. |
| `dropdown_label` | `"Last 6 months"` | Time-range chip text. |
| `legend_a` `legend_b` `legend_c` | `"Total patients"` etc. | Three legend captions. |
| `bars` | array of 14 objects: `{ "x": "34", "y": "148", "h": "92" }` | 7 month pairs (mint back, blue front). Bar 5 (index 5) is the highlighted month — the template adds a 2px mint stroke to bar 5 only. |
| `x_labels` | `["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]` | Seven month labels matching the seven bar pairs. |
### `calendar`
| Key | Example | Notes |
|---|---|---|
| `month_label` | `"March 2025"` | Header. |
| `dow` | `["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]` | Always 7 items. |
| `days` | array of exactly **35** objects: `{ "label": "1", "modifier": "" }` | 5 weeks × 7 days. `modifier` = `""`, `"muted"` (leading/trailing month), or `"active"` (single highlighted day, mint circle). |
### `activity`
| Key | Example | Notes |
|---|---|---|
| `title` | `"Activity Detail · Mar 8"` | Popover header — should reference the active calendar day. |
| `events` | array of exactly 3 objects: `{ "av_class": "blue", "name": "Dr. Sarah · post-op review", "time": "11:00 am" }` | Three events. `av_class` ∈ `blue`, `pink`, `violet`, `mint`, `amber`. |
| `add_label` | `"+ Add item"` | Footer link. |
### `donut`
| Key | Example | Notes |
|---|---|---|
| `title` | `"Top 3 most requested clinics"` | Card title. |
| `center_label` | `"Total patients"` | Above the center number. |
| `center_num` | `"534"` | The big tabular number in the donut hole. |
| `segment_b` | `{ "dasharray": "120 240", "dashoffset": "0" }` | SVG `stroke-dasharray` + `stroke-dashoffset` for the pink slice. Sum of arc lengths over a r=38 circle equals `2π × 38 ≈ 239`. |
| `segment_c` | `{ "dasharray": "35 240", "dashoffset": "-120" }` | Same for the mint slice. The blue background ring is the full circumference — no per-segment math needed. |
| `legend` | array of exactly 3 objects: `{ "value": "120", "label": "Dental" }` | Three entries in blue / pink / mint order. |
### `schedule`
| Key | Example | Notes |
|---|---|---|
| `title` | `"Doctors's schedule"` | Card title. |
| `stats` | array of exactly 3 objects: `{ "value": "51", "small": "Total", "label": "Available" }` | Available / Unavailable / Leave counts in a 3-column grid. |
| `list_header_label` | `"List of Doctor"` | Sortable list header label. |
| `doctors` | array of exactly 4 objects | See below. |
Each `doctors` row:
| Key | Example | Notes |
|---|---|---|
| `av_class` | `"av-blue"` | Avatar gradient. |
| `initial` | `"P"` | Single uppercase letter. |
| `name` | `"Peter Bashir"` | Real-feeling name. |
| `role` | `"Anesthesiologist"` | Specialty. |
| `status_class` | `"avail"` `"unav"` `"leave"` | Pill grammar. |
| `status_label` | `"Available"` `"Unavailable"` `"Leave"` | Pill text. |
### `appointments`
| Key | Example |
|---|---|
| `title` | `"Today's appointments"` |
| `list` | array of exactly 5 objects |
Each `list` row:
| Key | Example | Notes |
|---|---|---|
| `av_class` | `"av-pink"` | Avatar gradient. |
| `initial` | `"R"` | Single uppercase letter. |
| `name` | `"Ruth Tubonimi"` | Real-feeling name. |
| `role` | `"Gastroenterology · room 204"` | Specialty + venue / mode hint (`room N`, `video call`, `telemedicine`). |
| `date` | `"Today"` | Short date label. |
| `time` | `"09:40"` | 24h or 12h, pick one and stay consistent. |
## Icons are template-locked
`html_template_v1` forbids `{{data.*}}` interpolation inside URL-bearing
attributes such as `<use href>`, `<a href>`, `<img src>`,
`<form action>`, etc. (see
[`design-templates/live-artifact/references/artifact-schema.md`](../../../references/artifact-schema.md#html-template-v1-binding-rules)).
The renderer's security validator runs *before* `{{data.*}}` substitution, so
even a well-formed validator pass would not protect a future `data.json`
that put `javascript:alert(1)` (or any other URL value) into one of these
attributes.
This template therefore hardcodes every `<use href="#icon-…">` reference in
`template.html` itself. Each slot has a fixed icon id:
| Slot | Hardcoded icon id |
|---|---|
| Sidebar brand mark | `#icon-leaf` |
| Sidebar collapse toggle | `#icon-collapse` |
| `nav_main[0]` Dashboard | `#icon-dashboard` |
| `nav_main[1]` Message | `#icon-message` |
| `nav_main[2]` Schedule | `#icon-schedule` |
| `nav_main[3]` Notification | `#icon-bell` |
| `nav_main[4]` Transaction | `#icon-card` |
| `nav_management[0]` Doctor | `#icon-user` |
| `nav_management[1]` Medicine | `#icon-pill` |
| `nav_management[2]` Bedroom | `#icon-bed` |
| `nav_management[3]` Appointment | `#icon-check-square` |
| `nav_management[4]` Patient | `#icon-people` |
| Sidebar logout | `#icon-logout` |
| Greeting-row search | `#icon-search` |
| Greeting-row secondary CTA | `#icon-download` |
| Greeting-row primary CTA | `#icon-plus` |
| `kpi_a` glyph | `#icon-user` |
| `kpi_b` glyph | `#icon-schedule` |
| `kpi_c` glyph | `#icon-bed` |
| `kpi_d` glyph | `#icon-people` |
| Patient-overview card | `#icon-clock` |
| Time-range dropdown chevron | `#icon-chev-down` |
| Calendar prev / next | `#icon-chev-left`, `#icon-chev-right` |
| Top-clinics card | `#icon-stethoscope` |
| Doctor-schedule card | `#icon-schedule` |
| List header chevron | `#icon-chev-down` |
| Today's-appointments card | `#icon-check-square` |
If you re-purpose a slot (e.g. swap `nav_main[2] Schedule` for
`nav_main[2] Reports`), edit the corresponding `<use href="#icon-…">` literal
in `template.html` to match — the icon set inside the inline `<symbol>`
defs at the top of `template.html` already includes 21 icons covering the
common clinic / hospital / pharmacy / telemedicine vocabulary
(`#icon-dashboard`, `#icon-message`, `#icon-schedule`, `#icon-bell`,
`#icon-card`, `#icon-user`, `#icon-pill`, `#icon-bed`, `#icon-check-square`,
`#icon-people`, `#icon-leaf`, `#icon-clock`, `#icon-stethoscope`,
`#icon-search`, `#icon-download`, `#icon-plus`, `#icon-chev-left`,
`#icon-chev-right`, `#icon-chev-down`, `#icon-collapse`, `#icon-logout`).
If you need a runtime-configurable icon, add a new constrained,
non-URL-bearing mechanism (for example a `data.kpi_a.icon_class` that toggles
between a fixed list of CSS classes the template enumerates) — never
interpolate into `<use href>` directly.
## Style guarantees
The template enforces, in CSS only (no JavaScript):
- Cool off-white canvas (`#EEF2F6`), bright white surfaces, 18px card radii, 1px hairline borders.
- Mint accent (`#10B981`) restricted to five places: active sidebar nav row, primary CTA, KPI icon glyphs, success metric pill, active calendar date.
- Diagonal-stripe pattern fills (135°, 8px line + 8px gap) on KPI footer strips and inside bar-chart bars.
- Pastel-only status pills (mint / rose / amber).
- Tabular lining numerals on every numeric value (`font-feature-settings: "tnum","lnum"`).
- The dark calendar activity popover is the only dark surface in the artifact.
- Mobile reflow at ≤920px: sidebar stacks above main, KPI strip becomes 2 cols then 1 col, mid and bottom rows stack.
- No external CDN imports. Fonts use system fallback (`Plus Jakarta Sans, Inter, system-ui, sans-serif`).
## Customization tips
- **Telemedicine** variant: replace `kpi_c` (Available rooms) with `Live sessions`, swap the donut to `Top consultation types` (Video / Audio / Chat), and add `· video call` / `· audio call` venue hints in appointment rows.
- **Pharmacy** variant: replace the doctor schedule with stock levels — keep the same shape, just rename the columns to SKU / drug / stock pill.
- **Pediatric** variant: tilt the avatar palette toward `av-pink`, `av-amber`, `av-orange`, keep the active calendar day on a children's milestone.
For all variants, **do not** introduce new colors, fonts, or radii. Every visual lever is already a token in `:root{}`.
## Bounded JSON envelope
This default `data.json` is well within the live-artifact bounded JSON
constraints:
| Constraint | Limit | This sample |
|---|---|---|
| Object/array depth | 8 | 4 |
| Object keys | 100 / object | ≤20 |
| Array length | 500 | 35 (calendar.days) |
| String length | 16 KiB | <100 chars |
| Serialized size | 256 KiB | ~7 KiB |
If you scale up the bar count, calendar density, or list rows, stay well
under these limits. Refresh writes go through the same validation, so
oversized data will be rejected before persistence.
@@ -0,0 +1,165 @@
{
"brand_name": "ST. LUKES",
"greeting": "Hey Lukmon, glad to have you back! 🙌",
"search_placeholder": "Search doctors, patients, rooms…",
"search_shortcut": "⌘K",
"secondary_action_label": "Export CSV",
"primary_action_label": "Add new",
"user": {
"name": "Lukmon Olabode",
"role": "Admin",
"av_class": "av-orange",
"initial": "L"
},
"nav_main_label": "Main Menu",
"nav_main": [
{ "label": "Dashboard", "active_class": "active", "count": "" },
{ "label": "Message", "active_class": "", "count": "10" },
{ "label": "Schedule", "active_class": "", "count": "" },
{ "label": "Notification", "active_class": "", "count": "12" },
{ "label": "Transaction", "active_class": "", "count": "" }
],
"nav_management_label": "Management",
"nav_management": [
{ "label": "Doctor", "active_class": "", "count": "" },
{ "label": "Medicine", "active_class": "", "count": "" },
{ "label": "Bedroom", "active_class": "", "count": "" },
{ "label": "Appointment", "active_class": "", "count": "" },
{ "label": "Patient", "active_class": "", "count": "" }
],
"pro_card": {
"tag": "Pro",
"title": "Pssst!",
"body": "Your subscription expires in 9 days.",
"primary_label": "Renew",
"secondary_label": "Cancel"
},
"kpi_a": {
"label": "Total doctors",
"value": "1,089",
"trend_class": "down",
"trend_label": "↓ 4.2%",
"caption": "An increase of 20 doctors in the last 7 days.",
"strip_class": "stripe-amber"
},
"kpi_b": {
"label": "Total bookings",
"value": "17,610",
"trend_class": "up",
"trend_label": "↑ 5.5%",
"caption": "Last 7 days: 5,231 → 8,323 visitors.",
"strip_class": "stripe-blue",
"mini_stat": "1,635 today"
},
"kpi_c": {
"label": "Available rooms",
"value": "8,450",
"trend_class": "up",
"trend_label": "↑ 462",
"rows": [
{ "label": "General room", "value": "100" },
{ "label": "Private room", "value": "75" }
]
},
"kpi_d": {
"label": "Total visitors",
"value": "29,709",
"trend_class": "up",
"trend_label": "↑ 3.5%",
"caption": "Top 3 in-demand clinics this month.",
"strip_class": "stripe-amber",
"mini_stat": "1,070 today"
},
"chart": {
"title": "Patient overview",
"dropdown_label": "Last 6 months",
"legend_a": "Total patients",
"legend_b": "Avg. hospitalized",
"legend_c": "Avg. outpatient care",
"bars": [
{ "x": "34", "y": "148", "h": "92" },
{ "x": "58", "y": "108", "h": "132" },
{ "x": "120", "y": "124", "h": "116" },
{ "x": "144", "y": "92", "h": "148" },
{ "x": "206", "y": "96", "h": "144" },
{ "x": "230", "y": "56", "h": "184" },
{ "x": "292", "y": "156", "h": "84" },
{ "x": "316", "y": "116", "h": "124" },
{ "x": "378", "y": "140", "h": "100" },
{ "x": "402", "y": "100", "h": "140" },
{ "x": "464", "y": "120", "h": "120" },
{ "x": "488", "y": "80", "h": "160" },
{ "x": "550", "y": "160", "h": "80" },
{ "x": "574", "y": "128", "h": "112" }
],
"x_labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]
},
"calendar": {
"month_label": "March 2025",
"dow": ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"],
"days": [
{ "label": "23", "modifier": "muted" }, { "label": "24", "modifier": "muted" }, { "label": "25", "modifier": "muted" }, { "label": "26", "modifier": "muted" }, { "label": "27", "modifier": "muted" }, { "label": "28", "modifier": "muted" }, { "label": "1", "modifier": "" },
{ "label": "2", "modifier": "" }, { "label": "3", "modifier": "" }, { "label": "4", "modifier": "" }, { "label": "5", "modifier": "" }, { "label": "6", "modifier": "" }, { "label": "7", "modifier": "" }, { "label": "8", "modifier": "active" },
{ "label": "9", "modifier": "" }, { "label": "10", "modifier": "" }, { "label": "11", "modifier": "" }, { "label": "12", "modifier": "" }, { "label": "13", "modifier": "" }, { "label": "14", "modifier": "" }, { "label": "15", "modifier": "" },
{ "label": "16", "modifier": "" }, { "label": "17", "modifier": "" }, { "label": "18", "modifier": "" }, { "label": "19", "modifier": "" }, { "label": "20", "modifier": "" }, { "label": "21", "modifier": "" }, { "label": "22", "modifier": "" },
{ "label": "23", "modifier": "" }, { "label": "24", "modifier": "" }, { "label": "25", "modifier": "" }, { "label": "26", "modifier": "" }, { "label": "27", "modifier": "" }, { "label": "28", "modifier": "" }, { "label": "29", "modifier": "" }
]
},
"activity": {
"title": "Activity Detail · Mar 8",
"events": [
{ "av_class": "blue", "name": "Dr. Sarah · post-op review", "time": "11:00 am" },
{ "av_class": "pink", "name": "Dental staff meetup", "time": "3:00 pm" },
{ "av_class": "violet", "name": "Ola Muhammad intake", "time": "4:00 pm" }
],
"add_label": "+ Add item"
},
"donut": {
"title": "Top 3 most requested clinics",
"center_label": "Total patients",
"center_num": "534",
"segment_b": { "dasharray": "120 240", "dashoffset": "0" },
"segment_c": { "dasharray": "35 240", "dashoffset": "-120" },
"legend": [
{ "value": "120", "label": "Dental" },
{ "value": "249", "label": "Cardiology" },
{ "value": "165", "label": "Surgery" }
]
},
"schedule": {
"title": "Doctors's schedule",
"stats": [
{ "value": "51", "small": "Total", "label": "Available" },
{ "value": "23", "small": "Total", "label": "Unavailable" },
{ "value": "09", "small": "Total", "label": "Leave" }
],
"list_header_label": "List of Doctor",
"doctors": [
{ "av_class": "av-blue", "initial": "P", "name": "Peter Bashir", "role": "Anesthesiologist", "status_class": "avail", "status_label": "Available" },
{ "av_class": "av-violet", "initial": "D", "name": "Deborah Fagbemi", "role": "Cardiologist", "status_class": "unav", "status_label": "Unavailable" },
{ "av_class": "av-rose", "initial": "H", "name": "Hannah Diongoli", "role": "Dermatologist", "status_class": "avail", "status_label": "Available" },
{ "av_class": "av-amber", "initial": "A", "name": "Aisha Bello", "role": "Pediatrician", "status_class": "leave", "status_label": "Leave" }
]
},
"appointments": {
"title": "Today's appointments",
"list": [
{ "av_class": "av-pink", "initial": "R", "name": "Ruth Tubonimi", "role": "Gastroenterology · room 204", "date": "Today", "time": "09:40" },
{ "av_class": "av-amber", "initial": "J", "name": "Joseph Obiano", "role": "Psychiatry · video call", "date": "Today", "time": "10:25" },
{ "av_class": "av-mint", "initial": "T", "name": "Timothy Jibrin", "role": "Hematology · room 117", "date": "Today", "time": "11:00" },
{ "av_class": "av-violet", "initial": "E", "name": "Elizabeth Kanu", "role": "Ophthalmology · room 09", "date": "Today", "time": "02:15" },
{ "av_class": "av-blue", "initial": "S", "name": "Simon Garba", "role": "Otolaryngology · room 21", "date": "Today", "time": "03:40" }
]
}
}
@@ -0,0 +1,548 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{data.brand_name}} — Clinic Operations Console</title>
<style>
:root {
--canvas: #eef2f6;
--surface: #ffffff;
--surface-muted: #f8fafc;
--surface-inverse: #0f172a;
--border: #e5e7eb;
--border-strong: #d6dbe3;
--fg: #0f172a;
--fg-muted: #64748b;
--fg-tertiary: #94a3b8;
--fg-on-inverse: #f8fafc;
--accent: #10b981;
--accent-strong: #059669;
--accent-soft: #d1fae5;
--pill-up-bg: #d1fae5; --pill-up-fg: #059669;
--pill-down-bg: #fee2e2; --pill-down-fg: #dc2626;
--pill-avail-bg: #d1fae5; --pill-avail-fg: #047857;
--pill-unav-bg: #fee2e2; --pill-unav-fg: #b91c1c;
--pill-leave-bg: #fef3c7; --pill-leave-fg: #b45309;
--stripe-blue: #bfdbfe; --stripe-amber: #fde68a; --stripe-mint: #a7f3d0;
--donut-blue: #bfdbfe; --donut-pink: #fbcfe8; --donut-mint: #ccfbf1;
--legend-blue: #3b82f6; --legend-amber: #f59e0b; --legend-pink: #ec4899; --legend-mint: #2dd4bf;
--av-orange-1: #fed7aa; --av-orange-2: #f97316; --av-orange-fg: #7c2d12;
--av-pink-1: #fbcfe8; --av-pink-2: #ec4899;
--av-mint-1: #a7f3d0; --av-mint-2: #10b981;
--av-blue-1: #bfdbfe; --av-blue-2: #3b82f6;
--av-violet-1: #ddd6fe; --av-violet-2: #7c3aed;
--av-amber-1: #fde68a; --av-amber-2: #f59e0b; --av-amber-fg: #78350f;
--av-rose-1: #fecaca; --av-rose-2: #ef4444;
--av-fg-light: #ffffff;
--r-card: 18px; --r-pill: 999px; --r-md: 14px; --r-sm: 10px; --r-xs: 8px;
--shadow-card: 0 1px 2px rgba(15,23,42,.04), 0 2px 6px rgba(15,23,42,.04);
--shadow-popover: 0 12px 28px rgba(2,6,23,.32);
--font-display: 'Plus Jakarta Sans', 'SF Pro Display', system-ui, sans-serif;
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 20px;
background: var(--canvas); color: var(--fg);
font: 14px/1.5 var(--font-body);
font-feature-settings: "tnum", "lnum";
display: grid; grid-template-columns: 240px 1fr; gap: 20px;
min-height: 100vh;
}
@media (max-width: 920px) {
body { grid-template-columns: 1fr; }
aside.sidebar { position: static !important; height: auto !important; }
}
.card, aside.sidebar {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-card); box-shadow: var(--shadow-card);
}
aside.sidebar {
padding: 18px; display: flex; flex-direction: column; gap: 16px;
position: sticky; top: 20px; height: calc(100vh - 40px);
}
.brand { display: flex; align-items: center; justify-content: space-between; padding: 0 6px; }
.brand-mark { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); font-weight: 700; font-size: 17px; letter-spacing: -0.01em; }
.brand-leaf { width: 22px; height: 22px; border-radius: var(--r-pill); background: var(--accent); color: var(--surface); display: inline-flex; align-items: center; justify-content: center; }
.brand-toggle { color: var(--fg-tertiary); }
.nav-section { display: flex; flex-direction: column; gap: 2px; }
.nav-label { color: var(--fg-tertiary); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; padding: 8px 10px 4px; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: var(--r-sm); color: var(--fg-muted); font-size: 13px; }
.nav-item .ico { color: var(--fg-muted); }
.nav-item .count { margin-left: auto; background: var(--surface-muted); color: var(--fg-muted); border-radius: var(--r-pill); padding: 1px 8px; font-size: 11px; font-weight: 500; }
.nav-item .count:empty { display: none; }
.nav-item.active { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
.nav-item.active .ico { color: var(--accent-strong); }
.pro-card { margin-top: auto; background: var(--surface-muted); border-radius: var(--r-md); padding: 14px; display: flex; flex-direction: column; gap: 8px; }
.pro-tag { display: inline-flex; background: var(--fg); color: var(--surface); border-radius: var(--r-pill); padding: 2px 8px; font-size: 10px; font-weight: 600; width: fit-content; }
.pro-card h4 { margin: 0; font-size: 13px; font-weight: 600; font-family: var(--font-display); }
.pro-card p { margin: 0; font-size: 11px; color: var(--fg-muted); }
.pro-actions { display: flex; gap: 8px; margin-top: 4px; }
.pro-actions button { font-size: 11px; padding: 6px 12px; border-radius: var(--r-sm); border: 1px solid var(--border); background: var(--surface); color: var(--fg-muted); cursor: pointer; }
.pro-actions .primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
.user-row { display: flex; align-items: center; gap: 10px; padding: 10px 6px 0; border-top: 1px solid var(--border); }
.user-row .meta { display: flex; flex-direction: column; }
.user-row .name { font-size: 13px; font-weight: 600; }
.user-row .role { font-size: 11px; color: var(--fg-tertiary); }
.user-row .out { margin-left: auto; color: var(--fg-tertiary); }
.av { border-radius: var(--r-pill); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--av-fg-light); font-weight: 600; }
.av-32 { width: 32px; height: 32px; font-size: 12px; }
.av-40 { width: 40px; height: 40px; font-size: 14px; }
.av-orange { background: linear-gradient(135deg, var(--av-orange-1), var(--av-orange-2)); color: var(--av-orange-fg); }
.av-pink { background: linear-gradient(135deg, var(--av-pink-1), var(--av-pink-2)); }
.av-mint { background: linear-gradient(135deg, var(--av-mint-1), var(--av-mint-2)); }
.av-blue { background: linear-gradient(135deg, var(--av-blue-1), var(--av-blue-2)); }
.av-violet { background: linear-gradient(135deg, var(--av-violet-1), var(--av-violet-2)); }
.av-amber { background: linear-gradient(135deg, var(--av-amber-1), var(--av-amber-2)); color: var(--av-amber-fg); }
.av-rose { background: linear-gradient(135deg, var(--av-rose-1), var(--av-rose-2)); }
main { display: flex; flex-direction: column; gap: 24px; }
.greeting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.greeting { font-family: var(--font-display); font-size: 26px; font-weight: 700; letter-spacing: -0.01em; }
.actions { display: flex; align-items: center; gap: 12px; }
.search { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 9px 14px; width: 260px; color: var(--fg-tertiary); font-size: 13px; }
.search .kbd { margin-left: auto; font-size: 11px; border: 1px solid var(--border); padding: 1px 6px; border-radius: 4px; }
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: var(--r-sm); font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--surface); color: var(--fg); font-family: var(--font-body); }
.btn.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
.kpi-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
@media (max-width: 1100px) { .kpi-strip { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .kpi-strip { grid-template-columns: 1fr; } }
.kpi { padding: 18px; display: flex; flex-direction: column; gap: 12px; min-height: 150px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-card); box-shadow: var(--shadow-card); }
.kpi-head { display: flex; align-items: center; gap: 10px; }
.kpi-glyph { width: 32px; height: 32px; border-radius: var(--r-sm); background: var(--accent-soft); color: var(--accent-strong); display: inline-flex; align-items: center; justify-content: center; }
.kpi-label { font-size: 13px; color: var(--fg-muted); font-weight: 500; flex: 1; }
.kpi-menu { color: var(--fg-tertiary); }
.kpi-num-row { display: flex; align-items: baseline; gap: 10px; }
.kpi-num { font-family: var(--font-display); font-size: 30px; font-weight: 700; line-height: 1.1; letter-spacing: -0.015em; }
.pill { display: inline-flex; align-items: center; gap: 4px; border-radius: var(--r-pill); padding: 2px 8px; font-size: 11px; font-weight: 600; }
.pill.up { background: var(--pill-up-bg); color: var(--pill-up-fg); }
.pill.down { background: var(--pill-down-bg); color: var(--pill-down-fg); }
.pill.avail { background: var(--pill-avail-bg); color: var(--pill-avail-fg); font-weight: 500; }
.pill.unav { background: var(--pill-unav-bg); color: var(--pill-unav-fg); font-weight: 500; }
.pill.leave { background: var(--pill-leave-bg); color: var(--pill-leave-fg); font-weight: 500; }
.kpi-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: auto; }
.kpi-cap { font-size: 12px; color: var(--fg-tertiary); line-height: 1.4; max-width: 60%; }
.kpi-strip-pat { height: 32px; flex-shrink: 0; border-radius: var(--r-xs); }
.stripe-amber { background: repeating-linear-gradient(135deg, var(--stripe-amber) 0 8px, var(--surface) 8px 16px); width: 96px; }
.stripe-blue { background: repeating-linear-gradient(135deg, var(--stripe-blue) 0 8px, var(--surface) 8px 16px); width: 120px; }
.stripe-mint { background: repeating-linear-gradient(135deg, var(--stripe-mint) 0 8px, var(--surface) 8px 16px); width: 96px; }
.kpi-mini-stat { font-size: 11px; color: var(--fg-tertiary); margin-top: 4px; text-align: right; }
.kpi-rooms-list { display: flex; flex-direction: column; gap: 6px; margin-top: auto; }
.kpi-rooms-list .row { display: flex; justify-content: space-between; font-size: 12px; }
.kpi-rooms-list .row .lbl { color: var(--fg-muted); display: flex; align-items: center; gap: 6px; }
.kpi-rooms-list .row .lbl::before { content: ""; width: 6px; height: 6px; border-radius: var(--r-pill); background: var(--fg-tertiary); }
.kpi-rooms-list .row .val { color: var(--fg); font-weight: 600; }
.mid-row { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
@media (max-width: 1100px) { .mid-row { grid-template-columns: 1fr; } }
.card-pad { padding: 20px; }
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.card-title { display: flex; align-items: center; gap: 8px; font-family: var(--font-display); font-size: 15px; font-weight: 600; }
.card-title .ico-tile { width: 26px; height: 26px; border-radius: var(--r-sm); background: var(--surface-muted); color: var(--fg-muted); display: inline-flex; align-items: center; justify-content: center; }
.dropdown { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: var(--r-sm); padding: 6px 10px; font-size: 12px; color: var(--fg-muted); background: var(--surface); }
.legend { display: flex; gap: 16px; margin-bottom: 8px; flex-wrap: wrap; }
.legend .lg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--fg-muted); }
.legend .dot { width: 8px; height: 8px; border-radius: var(--r-pill); }
.cal-head { display: flex; align-items: center; justify-content: space-between; }
.cal-month { font-family: var(--font-display); font-size: 16px; font-weight: 600; }
.cal-nav { color: var(--fg-muted); display: inline-flex; gap: 6px; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; text-align: center; font-size: 12px; margin-top: 10px; position: relative; }
.cal-grid .dow { color: var(--fg-tertiary); font-weight: 500; padding: 6px 0; font-size: 11px; }
.cal-grid .day { height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--r-pill); }
.cal-grid .day.muted { color: var(--fg-tertiary); }
.cal-grid .day.active { background: var(--accent); color: var(--surface); font-weight: 700; position: relative; }
.cal-grid .day.active::after { content: ""; position: absolute; bottom: -7px; left: 50%; transform: translateX(-50%); width: 4px; height: 4px; border-radius: var(--r-pill); background: var(--accent); }
.activity-popover { background: var(--surface-inverse); color: var(--fg-on-inverse); border-radius: var(--r-md); padding: 14px; box-shadow: var(--shadow-popover); margin-top: 14px; }
.activity-popover h6 { margin: 0 0 10px; font-size: 12px; font-weight: 600; }
.activity-popover .ev { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.activity-popover .ev:last-of-type { border-bottom: none; }
.activity-popover .ev .av-pop { width: 22px; height: 22px; border-radius: var(--r-pill); flex-shrink: 0; }
.activity-popover .ev .av-pop.blue { background: linear-gradient(135deg, var(--av-blue-1), var(--av-blue-2)); }
.activity-popover .ev .av-pop.pink { background: linear-gradient(135deg, var(--av-pink-1), var(--av-pink-2)); }
.activity-popover .ev .av-pop.violet { background: linear-gradient(135deg, var(--av-violet-1), var(--av-violet-2)); }
.activity-popover .ev .av-pop.mint { background: linear-gradient(135deg, var(--av-mint-1), var(--av-mint-2)); }
.activity-popover .ev .av-pop.amber { background: linear-gradient(135deg, var(--av-amber-1), var(--av-amber-2)); }
.activity-popover .ev .name { flex: 1; font-size: 11px; }
.activity-popover .ev .time { font-size: 10px; color: rgba(248,250,252,0.6); }
.activity-popover .add { margin-top: 8px; font-size: 11px; color: rgba(248,250,252,0.7); }
.bottom-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
@media (max-width: 1100px) { .bottom-row { grid-template-columns: 1fr; } }
.donut-wrap { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 8px 0; }
.donut { position: relative; width: 180px; height: 180px; }
.donut .center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.donut .center .lbl { font-size: 12px; color: var(--fg-tertiary); }
.donut .center .num { font-family: var(--font-display); font-size: 30px; font-weight: 700; line-height: 1; margin-top: 2px; }
.donut-legend { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; font-size: 12px; color: var(--fg-muted); }
.donut-legend .lg { display: inline-flex; align-items: center; gap: 6px; }
.donut-legend .lg .dot { width: 8px; height: 8px; border-radius: var(--r-pill); }
.donut-legend .lg .v { color: var(--fg); font-weight: 600; margin-right: 2px; }
.sched-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; margin-bottom: 12px; }
.sched-stats .col { padding: 4px 12px; border-left: 1px solid var(--border); }
.sched-stats .col:first-child { border-left: none; padding-left: 0; }
.sched-stats .col .v { font-family: var(--font-display); font-size: 18px; font-weight: 700; line-height: 1.1; }
.sched-stats .col .v small { font-size: 11px; color: var(--fg-tertiary); font-weight: 400; margin-left: 2px; }
.sched-stats .col .lbl { font-size: 11px; color: var(--fg-tertiary); margin-top: 2px; }
.list-head { display: flex; align-items: center; gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 11px; color: var(--fg-tertiary); font-weight: 500; }
.list-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.list-row:last-child { border-bottom: none; }
.person { flex: 1; display: flex; flex-direction: column; }
.person .n { font-size: 13px; font-weight: 600; }
.person .r { font-size: 11px; color: var(--fg-tertiary); }
.pill.list { padding: 3px 10px; }
.appt-time { display: flex; flex-direction: column; align-items: flex-end; }
.appt-time .d { font-size: 11px; color: var(--fg-tertiary); }
.appt-time .t { font-size: 13px; font-weight: 600; }
.ic { width: 18px; height: 18px; stroke-width: 1.6; }
.ic-sm { width: 14px; height: 14px; stroke-width: 1.6; }
</style>
</head>
<body data-od-id="clinic-console-template">
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="icon-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="3" width="7" height="9" rx="2"/><rect x="14" y="3" width="7" height="5" rx="2"/><rect x="14" y="12" width="7" height="9" rx="2"/><rect x="3" y="16" width="7" height="5" rx="2"/></symbol>
<symbol id="icon-message" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7A8.4 8.4 0 1 1 21 11.5z"/></symbol>
<symbol id="icon-schedule" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></symbol>
<symbol id="icon-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></symbol>
<symbol id="icon-card" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
<symbol id="icon-user" viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0 1 16 0v1"/></symbol>
<symbol id="icon-pill" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M10 2v4M14 2v4M5 10h14M6 6h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zM12 13v6M9 16h6"/></symbol>
<symbol id="icon-bed" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 17h18M5 17v-5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v5"/></symbol>
<symbol id="icon-check-square" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M8 14l3 3 5-5"/></symbol>
<symbol id="icon-people" viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="9" cy="8" r="3"/><path d="M3 21v-1a6 6 0 0 1 12 0v1"/><circle cx="17" cy="6" r="2.5"/><path d="M14 14a4 4 0 0 1 7 4"/></symbol>
<symbol id="icon-leaf" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12c4-8 14-8 14 0 0 6-7 8-7 8s-7-2-7-8z"/></symbol>
<symbol id="icon-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol>
<symbol id="icon-stethoscope" viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V7a4 4 0 1 1 8 0v4"/></symbol>
<symbol id="icon-search" viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></symbol>
<symbol id="icon-download" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>
<symbol id="icon-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="icon-chev-left" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M15 18l-6-6 6-6"/></symbol>
<symbol id="icon-chev-right" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 18l6-6-6-6"/></symbol>
<symbol id="icon-chev-down" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9l6 6 6-6"/></symbol>
<symbol id="icon-collapse" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 6l-6 6 6 6M21 6l-6 6 6 6"/></symbol>
<symbol id="icon-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
</defs>
</svg>
<aside class="sidebar" data-od-id="sidebar">
<div class="brand">
<div class="brand-mark">
<span class="brand-leaf"><svg class="ic-sm"><use href="#icon-leaf"/></svg></span>
{{data.brand_name}}
</div>
<span class="brand-toggle"><svg class="ic"><use href="#icon-collapse"/></svg></span>
</div>
<div class="nav-section">
<div class="nav-label">{{data.nav_main_label}}</div>
<a class="nav-item {{data.nav_main.0.active_class}}"><svg class="ic"><use href="#icon-dashboard"/></svg>{{data.nav_main.0.label}}<span class="count">{{data.nav_main.0.count}}</span></a>
<a class="nav-item {{data.nav_main.1.active_class}}"><svg class="ic"><use href="#icon-message"/></svg>{{data.nav_main.1.label}}<span class="count">{{data.nav_main.1.count}}</span></a>
<a class="nav-item {{data.nav_main.2.active_class}}"><svg class="ic"><use href="#icon-schedule"/></svg>{{data.nav_main.2.label}}<span class="count">{{data.nav_main.2.count}}</span></a>
<a class="nav-item {{data.nav_main.3.active_class}}"><svg class="ic"><use href="#icon-bell"/></svg>{{data.nav_main.3.label}}<span class="count">{{data.nav_main.3.count}}</span></a>
<a class="nav-item {{data.nav_main.4.active_class}}"><svg class="ic"><use href="#icon-card"/></svg>{{data.nav_main.4.label}}<span class="count">{{data.nav_main.4.count}}</span></a>
</div>
<div class="nav-section">
<div class="nav-label">{{data.nav_management_label}}</div>
<a class="nav-item {{data.nav_management.0.active_class}}"><svg class="ic"><use href="#icon-user"/></svg>{{data.nav_management.0.label}}<span class="count">{{data.nav_management.0.count}}</span></a>
<a class="nav-item {{data.nav_management.1.active_class}}"><svg class="ic"><use href="#icon-pill"/></svg>{{data.nav_management.1.label}}<span class="count">{{data.nav_management.1.count}}</span></a>
<a class="nav-item {{data.nav_management.2.active_class}}"><svg class="ic"><use href="#icon-bed"/></svg>{{data.nav_management.2.label}}<span class="count">{{data.nav_management.2.count}}</span></a>
<a class="nav-item {{data.nav_management.3.active_class}}"><svg class="ic"><use href="#icon-check-square"/></svg>{{data.nav_management.3.label}}<span class="count">{{data.nav_management.3.count}}</span></a>
<a class="nav-item {{data.nav_management.4.active_class}}"><svg class="ic"><use href="#icon-people"/></svg>{{data.nav_management.4.label}}<span class="count">{{data.nav_management.4.count}}</span></a>
</div>
<div class="pro-card">
<span class="pro-tag">{{data.pro_card.tag}}</span>
<h4>{{data.pro_card.title}}</h4>
<p>{{data.pro_card.body}}</p>
<div class="pro-actions">
<button class="primary">{{data.pro_card.primary_label}}</button>
<button>{{data.pro_card.secondary_label}}</button>
</div>
</div>
<div class="user-row">
<span class="av av-32 {{data.user.av_class}}">{{data.user.initial}}</span>
<div class="meta">
<span class="name">{{data.user.name}}</span>
<span class="role">{{data.user.role}}</span>
</div>
<span class="out"><svg class="ic"><use href="#icon-logout"/></svg></span>
</div>
</aside>
<main>
<header class="greeting-row" data-od-id="greeting">
<div class="greeting">{{data.greeting}}</div>
<div class="actions">
<div class="search">
<svg class="ic-sm"><use href="#icon-search"/></svg>
{{data.search_placeholder}}
<span class="kbd">{{data.search_shortcut}}</span>
</div>
<button class="btn"><svg class="ic-sm"><use href="#icon-download"/></svg>{{data.secondary_action_label}}</button>
<button class="btn primary"><svg class="ic-sm"><use href="#icon-plus"/></svg>{{data.primary_action_label}}</button>
</div>
</header>
<section class="kpi-strip" data-od-id="kpi-strip">
<div class="kpi">
<div class="kpi-head">
<span class="kpi-glyph"><svg class="ic-sm"><use href="#icon-user"/></svg></span>
<span class="kpi-label">{{data.kpi_a.label}}</span>
<span class="kpi-menu">⋯</span>
</div>
<div class="kpi-num-row"><div class="kpi-num">{{data.kpi_a.value}}</div><span class="pill {{data.kpi_a.trend_class}}">{{data.kpi_a.trend_label}}</span></div>
<div class="kpi-foot">
<div class="kpi-cap">{{data.kpi_a.caption}}</div>
<div class="kpi-strip-pat {{data.kpi_a.strip_class}}"></div>
</div>
</div>
<div class="kpi">
<div class="kpi-head">
<span class="kpi-glyph"><svg class="ic-sm"><use href="#icon-schedule"/></svg></span>
<span class="kpi-label">{{data.kpi_b.label}}</span>
<span class="kpi-menu">⋯</span>
</div>
<div class="kpi-num-row"><div class="kpi-num">{{data.kpi_b.value}}</div><span class="pill {{data.kpi_b.trend_class}}">{{data.kpi_b.trend_label}}</span></div>
<div class="kpi-foot">
<div class="kpi-cap">{{data.kpi_b.caption}}</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;">
<div class="kpi-strip-pat {{data.kpi_b.strip_class}}"></div>
<div class="kpi-mini-stat">{{data.kpi_b.mini_stat}}</div>
</div>
</div>
</div>
<div class="kpi">
<div class="kpi-head">
<span class="kpi-glyph"><svg class="ic-sm"><use href="#icon-bed"/></svg></span>
<span class="kpi-label">{{data.kpi_c.label}}</span>
<span class="kpi-menu">⋯</span>
</div>
<div class="kpi-num-row"><div class="kpi-num">{{data.kpi_c.value}}</div><span class="pill {{data.kpi_c.trend_class}}">{{data.kpi_c.trend_label}}</span></div>
<div class="kpi-rooms-list">
<div class="row"><span class="lbl">{{data.kpi_c.rows.0.label}}</span><span class="val">{{data.kpi_c.rows.0.value}}</span></div>
<div class="row"><span class="lbl">{{data.kpi_c.rows.1.label}}</span><span class="val">{{data.kpi_c.rows.1.value}}</span></div>
</div>
</div>
<div class="kpi">
<div class="kpi-head">
<span class="kpi-glyph"><svg class="ic-sm"><use href="#icon-people"/></svg></span>
<span class="kpi-label">{{data.kpi_d.label}}</span>
<span class="kpi-menu">⋯</span>
</div>
<div class="kpi-num-row"><div class="kpi-num">{{data.kpi_d.value}}</div><span class="pill {{data.kpi_d.trend_class}}">{{data.kpi_d.trend_label}}</span></div>
<div class="kpi-foot">
<div class="kpi-cap">{{data.kpi_d.caption}}</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;">
<div class="kpi-strip-pat {{data.kpi_d.strip_class}}"></div>
<div class="kpi-mini-stat">{{data.kpi_d.mini_stat}}</div>
</div>
</div>
</div>
</section>
<section class="mid-row" data-od-id="patient-overview-and-calendar">
<div class="card card-pad">
<div class="card-head">
<div class="card-title">
<span class="ico-tile"><svg class="ic-sm"><use href="#icon-clock"/></svg></span>
{{data.chart.title}}
</div>
<div class="dropdown">{{data.chart.dropdown_label}}<svg class="ic-sm"><use href="#icon-chev-down"/></svg></div>
</div>
<div class="legend">
<span class="lg"><span class="dot" style="background: var(--accent);"></span>{{data.chart.legend_a}}</span>
<span class="lg"><span class="dot" style="background: var(--legend-blue);"></span>{{data.chart.legend_b}}</span>
<span class="lg"><span class="dot" style="background: var(--legend-amber);"></span>{{data.chart.legend_c}}</span>
</div>
<svg viewBox="0 0 600 240" preserveAspectRatio="none" style="width:100%; height:240px; display:block;">
<defs>
<pattern id="stripeBlue" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)">
<rect width="12" height="12" style="fill: var(--surface);" />
<rect width="6" height="12" style="fill: var(--stripe-blue);" />
</pattern>
<pattern id="stripeMint" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)">
<rect width="12" height="12" style="fill: var(--surface);" />
<rect width="6" height="12" style="fill: var(--stripe-mint);" opacity="0.7" />
</pattern>
</defs>
<g style="stroke: var(--border);" stroke-width="1">
<line x1="0" y1="48" x2="600" y2="48" />
<line x1="0" y1="96" x2="600" y2="96" />
<line x1="0" y1="144" x2="600" y2="144" />
<line x1="0" y1="192" x2="600" y2="192" />
</g>
<g>
<rect x="{{data.chart.bars.0.x}}" y="{{data.chart.bars.0.y}}" width="22" height="{{data.chart.bars.0.h}}" rx="6" fill="url(#stripeMint)" />
<rect x="{{data.chart.bars.1.x}}" y="{{data.chart.bars.1.y}}" width="22" height="{{data.chart.bars.1.h}}" rx="6" fill="url(#stripeBlue)" />
<rect x="{{data.chart.bars.2.x}}" y="{{data.chart.bars.2.y}}" width="22" height="{{data.chart.bars.2.h}}" rx="6" fill="url(#stripeMint)" />
<rect x="{{data.chart.bars.3.x}}" y="{{data.chart.bars.3.y}}" width="22" height="{{data.chart.bars.3.h}}" rx="6" fill="url(#stripeBlue)" />
<rect x="{{data.chart.bars.4.x}}" y="{{data.chart.bars.4.y}}" width="22" height="{{data.chart.bars.4.h}}" rx="6" fill="url(#stripeMint)" />
<rect x="{{data.chart.bars.5.x}}" y="{{data.chart.bars.5.y}}" width="22" height="{{data.chart.bars.5.h}}" rx="6" fill="url(#stripeBlue)" style="stroke: var(--accent);" stroke-width="2" />
<rect x="{{data.chart.bars.6.x}}" y="{{data.chart.bars.6.y}}" width="22" height="{{data.chart.bars.6.h}}" rx="6" fill="url(#stripeMint)" />
<rect x="{{data.chart.bars.7.x}}" y="{{data.chart.bars.7.y}}" width="22" height="{{data.chart.bars.7.h}}" rx="6" fill="url(#stripeBlue)" />
<rect x="{{data.chart.bars.8.x}}" y="{{data.chart.bars.8.y}}" width="22" height="{{data.chart.bars.8.h}}" rx="6" fill="url(#stripeMint)" />
<rect x="{{data.chart.bars.9.x}}" y="{{data.chart.bars.9.y}}" width="22" height="{{data.chart.bars.9.h}}" rx="6" fill="url(#stripeBlue)" />
<rect x="{{data.chart.bars.10.x}}" y="{{data.chart.bars.10.y}}" width="22" height="{{data.chart.bars.10.h}}" rx="6" fill="url(#stripeMint)" />
<rect x="{{data.chart.bars.11.x}}" y="{{data.chart.bars.11.y}}" width="22" height="{{data.chart.bars.11.h}}" rx="6" fill="url(#stripeBlue)" />
<rect x="{{data.chart.bars.12.x}}" y="{{data.chart.bars.12.y}}" width="22" height="{{data.chart.bars.12.h}}" rx="6" fill="url(#stripeMint)" />
<rect x="{{data.chart.bars.13.x}}" y="{{data.chart.bars.13.y}}" width="22" height="{{data.chart.bars.13.h}}" rx="6" fill="url(#stripeBlue)" />
</g>
<g style="fill: var(--fg-tertiary);" font-size="11" text-anchor="middle">
<text x="46" y="232">{{data.chart.x_labels.0}}</text>
<text x="132" y="232">{{data.chart.x_labels.1}}</text>
<text x="218" y="232">{{data.chart.x_labels.2}}</text>
<text x="304" y="232">{{data.chart.x_labels.3}}</text>
<text x="390" y="232">{{data.chart.x_labels.4}}</text>
<text x="476" y="232">{{data.chart.x_labels.5}}</text>
<text x="562" y="232">{{data.chart.x_labels.6}}</text>
</g>
</svg>
</div>
<div class="card card-pad" data-od-id="calendar">
<div class="cal-head">
<span class="cal-nav"><svg class="ic-sm"><use href="#icon-chev-left"/></svg></span>
<div class="cal-month">{{data.calendar.month_label}}</div>
<span class="cal-nav"><svg class="ic-sm"><use href="#icon-chev-right"/></svg></span>
</div>
<div class="cal-grid">
<div class="dow">{{data.calendar.dow.0}}</div><div class="dow">{{data.calendar.dow.1}}</div><div class="dow">{{data.calendar.dow.2}}</div><div class="dow">{{data.calendar.dow.3}}</div><div class="dow">{{data.calendar.dow.4}}</div><div class="dow">{{data.calendar.dow.5}}</div><div class="dow">{{data.calendar.dow.6}}</div>
<div class="day {{data.calendar.days.0.modifier}}">{{data.calendar.days.0.label}}</div><div class="day {{data.calendar.days.1.modifier}}">{{data.calendar.days.1.label}}</div><div class="day {{data.calendar.days.2.modifier}}">{{data.calendar.days.2.label}}</div><div class="day {{data.calendar.days.3.modifier}}">{{data.calendar.days.3.label}}</div><div class="day {{data.calendar.days.4.modifier}}">{{data.calendar.days.4.label}}</div><div class="day {{data.calendar.days.5.modifier}}">{{data.calendar.days.5.label}}</div><div class="day {{data.calendar.days.6.modifier}}">{{data.calendar.days.6.label}}</div>
<div class="day {{data.calendar.days.7.modifier}}">{{data.calendar.days.7.label}}</div><div class="day {{data.calendar.days.8.modifier}}">{{data.calendar.days.8.label}}</div><div class="day {{data.calendar.days.9.modifier}}">{{data.calendar.days.9.label}}</div><div class="day {{data.calendar.days.10.modifier}}">{{data.calendar.days.10.label}}</div><div class="day {{data.calendar.days.11.modifier}}">{{data.calendar.days.11.label}}</div><div class="day {{data.calendar.days.12.modifier}}">{{data.calendar.days.12.label}}</div><div class="day {{data.calendar.days.13.modifier}}">{{data.calendar.days.13.label}}</div>
<div class="day {{data.calendar.days.14.modifier}}">{{data.calendar.days.14.label}}</div><div class="day {{data.calendar.days.15.modifier}}">{{data.calendar.days.15.label}}</div><div class="day {{data.calendar.days.16.modifier}}">{{data.calendar.days.16.label}}</div><div class="day {{data.calendar.days.17.modifier}}">{{data.calendar.days.17.label}}</div><div class="day {{data.calendar.days.18.modifier}}">{{data.calendar.days.18.label}}</div><div class="day {{data.calendar.days.19.modifier}}">{{data.calendar.days.19.label}}</div><div class="day {{data.calendar.days.20.modifier}}">{{data.calendar.days.20.label}}</div>
<div class="day {{data.calendar.days.21.modifier}}">{{data.calendar.days.21.label}}</div><div class="day {{data.calendar.days.22.modifier}}">{{data.calendar.days.22.label}}</div><div class="day {{data.calendar.days.23.modifier}}">{{data.calendar.days.23.label}}</div><div class="day {{data.calendar.days.24.modifier}}">{{data.calendar.days.24.label}}</div><div class="day {{data.calendar.days.25.modifier}}">{{data.calendar.days.25.label}}</div><div class="day {{data.calendar.days.26.modifier}}">{{data.calendar.days.26.label}}</div><div class="day {{data.calendar.days.27.modifier}}">{{data.calendar.days.27.label}}</div>
<div class="day {{data.calendar.days.28.modifier}}">{{data.calendar.days.28.label}}</div><div class="day {{data.calendar.days.29.modifier}}">{{data.calendar.days.29.label}}</div><div class="day {{data.calendar.days.30.modifier}}">{{data.calendar.days.30.label}}</div><div class="day {{data.calendar.days.31.modifier}}">{{data.calendar.days.31.label}}</div><div class="day {{data.calendar.days.32.modifier}}">{{data.calendar.days.32.label}}</div><div class="day {{data.calendar.days.33.modifier}}">{{data.calendar.days.33.label}}</div><div class="day {{data.calendar.days.34.modifier}}">{{data.calendar.days.34.label}}</div>
</div>
<div class="activity-popover" data-od-id="activity-popover">
<h6>{{data.activity.title}}</h6>
<div class="ev"><span class="av-pop {{data.activity.events.0.av_class}}"></span><span class="name">{{data.activity.events.0.name}}</span><span class="time">{{data.activity.events.0.time}}</span></div>
<div class="ev"><span class="av-pop {{data.activity.events.1.av_class}}"></span><span class="name">{{data.activity.events.1.name}}</span><span class="time">{{data.activity.events.1.time}}</span></div>
<div class="ev"><span class="av-pop {{data.activity.events.2.av_class}}"></span><span class="name">{{data.activity.events.2.name}}</span><span class="time">{{data.activity.events.2.time}}</span></div>
<div class="add">{{data.activity.add_label}}</div>
</div>
</div>
</section>
<section class="bottom-row">
<div class="card card-pad" data-od-id="top-clinics">
<div class="card-head">
<div class="card-title">
<span class="ico-tile"><svg class="ic-sm"><use href="#icon-stethoscope"/></svg></span>
{{data.donut.title}}
</div>
<span class="kpi-menu">⋯</span>
</div>
<div class="donut-wrap">
<div class="donut">
<svg viewBox="0 0 100 100" style="width:100%; height:100%; transform: rotate(-90deg);">
<circle cx="50" cy="50" r="38" fill="none" stroke-width="14" style="stroke: var(--donut-blue);" />
<circle cx="50" cy="50" r="38" fill="none" stroke-width="14" style="stroke: var(--donut-pink);" stroke-dasharray="{{data.donut.segment_b.dasharray}}" stroke-dashoffset="{{data.donut.segment_b.dashoffset}}" />
<circle cx="50" cy="50" r="38" fill="none" stroke-width="14" style="stroke: var(--donut-mint);" stroke-dasharray="{{data.donut.segment_c.dasharray}}" stroke-dashoffset="{{data.donut.segment_c.dashoffset}}" />
</svg>
<div class="center">
<span class="lbl">{{data.donut.center_label}}</span>
<span class="num">{{data.donut.center_num}}</span>
</div>
</div>
<div class="donut-legend">
<div class="lg"><span class="dot" style="background: var(--legend-blue);"></span><span class="v">{{data.donut.legend.0.value}}</span> {{data.donut.legend.0.label}}</div>
<div class="lg"><span class="dot" style="background: var(--legend-pink);"></span><span class="v">{{data.donut.legend.1.value}}</span> {{data.donut.legend.1.label}}</div>
<div class="lg"><span class="dot" style="background: var(--legend-mint);"></span><span class="v">{{data.donut.legend.2.value}}</span> {{data.donut.legend.2.label}}</div>
</div>
</div>
</div>
<div class="card card-pad" data-od-id="doctor-schedule">
<div class="card-head">
<div class="card-title">
<span class="ico-tile"><svg class="ic-sm"><use href="#icon-schedule"/></svg></span>
{{data.schedule.title}}
</div>
<span class="kpi-menu">⋯</span>
</div>
<div class="sched-stats">
<div class="col"><div class="v">{{data.schedule.stats.0.value}} <small>{{data.schedule.stats.0.small}}</small></div><div class="lbl">{{data.schedule.stats.0.label}}</div></div>
<div class="col"><div class="v">{{data.schedule.stats.1.value}} <small>{{data.schedule.stats.1.small}}</small></div><div class="lbl">{{data.schedule.stats.1.label}}</div></div>
<div class="col"><div class="v">{{data.schedule.stats.2.value}} <small>{{data.schedule.stats.2.small}}</small></div><div class="lbl">{{data.schedule.stats.2.label}}</div></div>
</div>
<div class="list-head">{{data.schedule.list_header_label}}<svg class="ic-sm"><use href="#icon-chev-down"/></svg></div>
<div class="list-row">
<span class="av av-32 {{data.schedule.doctors.0.av_class}}">{{data.schedule.doctors.0.initial}}</span>
<div class="person"><span class="n">{{data.schedule.doctors.0.name}}</span><span class="r">{{data.schedule.doctors.0.role}}</span></div>
<span class="pill list {{data.schedule.doctors.0.status_class}}">{{data.schedule.doctors.0.status_label}}</span>
</div>
<div class="list-row">
<span class="av av-32 {{data.schedule.doctors.1.av_class}}">{{data.schedule.doctors.1.initial}}</span>
<div class="person"><span class="n">{{data.schedule.doctors.1.name}}</span><span class="r">{{data.schedule.doctors.1.role}}</span></div>
<span class="pill list {{data.schedule.doctors.1.status_class}}">{{data.schedule.doctors.1.status_label}}</span>
</div>
<div class="list-row">
<span class="av av-32 {{data.schedule.doctors.2.av_class}}">{{data.schedule.doctors.2.initial}}</span>
<div class="person"><span class="n">{{data.schedule.doctors.2.name}}</span><span class="r">{{data.schedule.doctors.2.role}}</span></div>
<span class="pill list {{data.schedule.doctors.2.status_class}}">{{data.schedule.doctors.2.status_label}}</span>
</div>
<div class="list-row">
<span class="av av-32 {{data.schedule.doctors.3.av_class}}">{{data.schedule.doctors.3.initial}}</span>
<div class="person"><span class="n">{{data.schedule.doctors.3.name}}</span><span class="r">{{data.schedule.doctors.3.role}}</span></div>
<span class="pill list {{data.schedule.doctors.3.status_class}}">{{data.schedule.doctors.3.status_label}}</span>
</div>
</div>
<div class="card card-pad" data-od-id="appointments">
<div class="card-head">
<div class="card-title">
<span class="ico-tile"><svg class="ic-sm"><use href="#icon-check-square"/></svg></span>
{{data.appointments.title}}
</div>
<span class="kpi-menu">⋯</span>
</div>
<div class="list-row">
<span class="av av-40 {{data.appointments.list.0.av_class}}">{{data.appointments.list.0.initial}}</span>
<div class="person"><span class="n">{{data.appointments.list.0.name}}</span><span class="r">{{data.appointments.list.0.role}}</span></div>
<div class="appt-time"><span class="d">{{data.appointments.list.0.date}}</span><span class="t">{{data.appointments.list.0.time}}</span></div>
</div>
<div class="list-row">
<span class="av av-40 {{data.appointments.list.1.av_class}}">{{data.appointments.list.1.initial}}</span>
<div class="person"><span class="n">{{data.appointments.list.1.name}}</span><span class="r">{{data.appointments.list.1.role}}</span></div>
<div class="appt-time"><span class="d">{{data.appointments.list.1.date}}</span><span class="t">{{data.appointments.list.1.time}}</span></div>
</div>
<div class="list-row">
<span class="av av-40 {{data.appointments.list.2.av_class}}">{{data.appointments.list.2.initial}}</span>
<div class="person"><span class="n">{{data.appointments.list.2.name}}</span><span class="r">{{data.appointments.list.2.role}}</span></div>
<div class="appt-time"><span class="d">{{data.appointments.list.2.date}}</span><span class="t">{{data.appointments.list.2.time}}</span></div>
</div>
<div class="list-row">
<span class="av av-40 {{data.appointments.list.3.av_class}}">{{data.appointments.list.3.initial}}</span>
<div class="person"><span class="n">{{data.appointments.list.3.name}}</span><span class="r">{{data.appointments.list.3.role}}</span></div>
<div class="appt-time"><span class="d">{{data.appointments.list.3.date}}</span><span class="t">{{data.appointments.list.3.time}}</span></div>
</div>
<div class="list-row">
<span class="av av-40 {{data.appointments.list.4.av_class}}">{{data.appointments.list.4.initial}}</span>
<div class="person"><span class="n">{{data.appointments.list.4.name}}</span><span class="r">{{data.appointments.list.4.role}}</span></div>
<div class="appt-time"><span class="d">{{data.appointments.list.4.date}}</span><span class="t">{{data.appointments.list.4.time}}</span></div>
</div>
</div>
</section>
</main>
</body>
</html>
@@ -0,0 +1,761 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mira's First Month · A quiet panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&family=Caveat:wght@500;600&display=swap" rel="stylesheet">
<style>
:root {
/* Soft, warm, parental */
--bg: #fdf9f5;
--paper: #ffffff;
--paper-tint: #faf5ef;
--paper-deep: #f4ece2;
--line: #ece2d5;
--line-strong: #d8c8b3;
--hairline: rgba(60,40,20,0.06);
--ink: #2a2520;
--ink-soft: #4d433b;
--muted: #8a7e72;
--muted-2: #b3a89a;
/* Soft pastel accents */
--peach: #f5b885;
--peach-soft: #fce4cf;
--sage: #9ec79e;
--sage-soft: #e0eddc;
--lavender: #c5b8e0;
--lavender-soft: #e8e1f5;
--sky: #b3d4e8;
--sky-soft: #def0fa;
--rose: #e8b3c2;
--rose-soft: #fadfe6;
--gold: #e8d094;
--gold-soft: #faf1d6;
/* Type */
--serif: 'Instrument Serif', Iowan, Charter, Georgia, serif;
--display: 'Instrument Serif', Iowan, Charter, Georgia, serif;
--body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--hand: 'Caveat', 'Instrument Serif', cursive;
--r: 16px; /* softer corners */
--r-pill: 999px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
radial-gradient(800px 400px at 100% -10%, rgba(245,184,133,0.15), transparent 60%),
radial-gradient(700px 400px at 0% 60%, rgba(197,184,224,0.12), transparent 60%),
radial-gradient(900px 500px at 50% 110%, rgba(158,199,158,0.10), transparent 60%),
var(--bg);
color: var(--ink);
font-family: var(--body);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
/* ─────── Layout ─────── */
main {
max-width: 880px; margin: 0 auto;
padding: 60px 32px 80px;
}
header.app {
display: flex; align-items: center; justify-content: space-between;
padding: 22px 32px;
border-bottom: 1px solid var(--hairline);
}
.word-mark {
font-family: var(--serif); font-style: italic;
font-size: 19px; letter-spacing: -0.005em;
color: var(--ink);
}
.word-mark .small {
font-family: var(--body); font-style: normal;
font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.16em;
margin-right: 10px;
}
.icon-btn {
padding: 7px 14px; font-size: 12.5px;
border-radius: var(--r-pill);
background: var(--paper); border: 1px solid var(--line);
color: var(--ink-soft); transition: all 0.15s;
display: inline-flex; gap: 8px; align-items: center;
}
.icon-btn:hover { border-color: var(--line-strong); color: var(--ink); }
.icon-btn.warm {
background: linear-gradient(180deg, #fef4e6, var(--paper));
border-color: rgba(245,184,133,0.45);
color: #8a5a2a;
}
.icon-btn .ic { width: 13px; height: 13px; }
.icon-btn.spin .ic { animation: spin 0.8s linear; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ─────── Hero ─────── */
.hero {
text-align: center;
margin-bottom: 56px;
}
.hero .pre {
font-size: 11px; color: var(--muted); text-transform: uppercase;
letter-spacing: 0.2em; margin-bottom: 18px;
}
.hero .name {
font-family: var(--serif); font-weight: 400;
font-size: 80px; line-height: 1.05;
margin: 0; letter-spacing: -0.02em;
color: var(--ink);
}
.hero .name em {
font-style: italic;
background: linear-gradient(180deg, #f5b885 0%, #c5b8e0 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.hero .age {
font-family: var(--hand);
font-size: 32px; line-height: 1;
color: var(--peach);
margin-top: 14px;
transform: rotate(-2deg);
display: inline-block;
}
.hero .meta {
margin-top: 22px;
display: inline-flex; gap: 18px;
font-size: 13px; color: var(--muted);
}
.hero .meta .v { color: var(--ink); }
.hero .meta .sep { color: var(--muted-2); }
/* ─────── Section ─────── */
section.block {
margin-bottom: 44px;
}
.block-head {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 18px;
}
.block-head h2 {
font-family: var(--serif); font-weight: 400; font-size: 30px;
margin: 0; line-height: 1; letter-spacing: -0.005em;
color: var(--ink);
}
.block-head h2 em {
font-style: italic; color: var(--peach);
margin-right: 8px; font-size: 21px; vertical-align: 4px;
}
.block-head .meta {
font-size: 11.5px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.14em;
}
.block-head .meta .src { color: var(--ink-soft); margin-right: 4px; }
.card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 28px;
box-shadow: 0 2px 0 rgba(60,40,20,0.02);
}
/* ─────── Sleep circle ─────── */
.sleep-card {
display: grid; grid-template-columns: 280px 1fr; gap: 28px; align-items: center;
}
.sleep-circle {
width: 280px; height: 280px;
margin: 0 auto;
}
.sleep-circle svg { width: 100%; height: 100%; display: block; }
.sleep-stat .lbl {
font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.14em;
}
.sleep-stat .big {
font-family: var(--serif); font-weight: 400; font-size: 56px;
line-height: 1; margin-top: 8px; letter-spacing: -0.02em;
}
.sleep-stat .delta {
margin-top: 10px; font-size: 13px; color: var(--sage);
}
.sleep-stat .delta strong { color: var(--ink); font-weight: 600; }
.sleep-stat .quote {
font-family: var(--serif); font-style: italic;
font-size: 17px; color: var(--ink-soft);
margin-top: 22px; padding-left: 14px;
border-left: 2px solid var(--lavender);
line-height: 1.5;
max-width: 36ch;
}
/* ─────── Feeding timeline ─────── */
.feed-timeline {
position: relative; padding: 28px 0 32px;
}
.feed-line {
height: 2px;
background: linear-gradient(90deg, var(--lavender), var(--peach), var(--sage));
border-radius: 2px;
position: relative;
margin: 0 14px;
}
.feed-marker {
position: absolute; top: 50%; transform: translate(-50%, -50%);
width: 14px; height: 14px; border-radius: 50%;
cursor: pointer;
transition: transform 0.15s;
}
.feed-marker:hover { transform: translate(-50%, -50%) scale(1.4); }
.feed-marker.bottle { background: var(--peach); box-shadow: 0 0 0 4px var(--peach-soft); }
.feed-marker.breast { background: var(--rose); box-shadow: 0 0 0 4px var(--rose-soft); }
.feed-marker.now {
background: var(--ink); box-shadow: 0 0 0 4px var(--paper), 0 0 0 6px var(--ink);
animation: nowBreath 2.4s ease-in-out infinite;
}
@keyframes nowBreath {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.15); }
}
.feed-tip {
position: absolute; top: -36px; transform: translate(-50%, 0);
background: var(--ink); color: var(--paper);
font-size: 11px; padding: 4px 8px; border-radius: 5px;
white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity 0.15s;
font-family: var(--body);
}
.feed-tip::after {
content: ""; position: absolute; left: 50%; top: 100%; transform: translateX(-50%);
border: 4px solid transparent; border-top-color: var(--ink);
}
.feed-marker:hover .feed-tip { opacity: 1; }
.feed-axis {
margin-top: 36px; padding: 0 14px;
display: grid; grid-template-columns: repeat(7, 1fr);
font-family: var(--body); font-size: 11px; color: var(--muted);
text-align: center;
}
.feed-legend {
margin-top: 24px;
display: flex; gap: 22px; flex-wrap: wrap;
font-size: 12px; color: var(--ink-soft);
}
.feed-legend .swatch { display: inline-flex; gap: 8px; align-items: center; }
.feed-legend .swatch::before { content: ""; width: 10px; height: 10px; border-radius: 50%; }
.feed-legend .swatch.bottle::before { background: var(--peach); box-shadow: 0 0 0 3px var(--peach-soft); }
.feed-legend .swatch.breast::before { background: var(--rose); box-shadow: 0 0 0 3px var(--rose-soft); }
.feed-legend .swatch.now::before { background: var(--ink); }
.feed-legend .v { font-family: var(--serif); font-style: italic; color: var(--ink); }
/* ─────── Weight curve ─────── */
.growth-card { padding: 28px 32px; }
.growth-card svg { width: 100%; height: 220px; display: block; margin-top: 8px; }
.growth-stats {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;
margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--hairline);
}
.growth-stats .stat .lbl { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.14em; }
.growth-stats .stat .v { font-family: var(--serif); font-size: 24px; line-height: 1; margin-top: 6px; letter-spacing: -0.01em; }
.growth-stats .stat .pctile { color: var(--sage); font-size: 11.5px; margin-top: 4px; }
/* ─────── Today reminders ─────── */
.reminders {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
}
.rem-card {
padding: 18px 20px; border-radius: 12px;
border: 1px solid var(--line);
background: var(--paper);
display: flex; flex-direction: column; gap: 8px;
cursor: pointer;
transition: all 0.15s;
}
.rem-card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(60,40,20,0.06); }
.rem-card .l-row { display: flex; gap: 10px; align-items: center; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.14em; }
.rem-card .glyph {
width: 28px; height: 28px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 14px;
}
.rem-card.feed .glyph { background: var(--peach-soft); color: #8a5a2a; }
.rem-card.nap .glyph { background: var(--lavender-soft); color: #5e4d8e; }
.rem-card.appt .glyph { background: var(--sky-soft); color: #2a5e7a; }
.rem-card.bath .glyph { background: var(--sage-soft); color: #4a6e4a; }
.rem-card .when {
font-family: var(--serif); font-size: 28px; line-height: 1; letter-spacing: -0.01em;
}
.rem-card .desc { font-size: 12.5px; color: var(--ink-soft); line-height: 1.45; }
/* ─────── AI insights ─────── */
.insight-card {
background: linear-gradient(180deg, var(--lavender-soft) 0%, var(--paper) 100%);
border-color: rgba(197,184,224,0.4);
padding: 28px 32px;
}
.insight-card .badge {
display: inline-flex; gap: 8px; align-items: center;
font-family: var(--body); font-size: 10.5px;
text-transform: uppercase; letter-spacing: 0.16em;
color: #6e5e9e;
}
.insight-card .badge .glyph {
width: 22px; height: 22px; border-radius: 50%;
background: linear-gradient(135deg, var(--lavender), var(--peach));
display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-size: 12px; font-weight: 600;
box-shadow: 0 0 12px rgba(197,184,224,0.4);
}
.insight-card h3 {
font-family: var(--serif); font-weight: 400; font-size: 28px;
line-height: 1.2; margin: 12px 0 6px; letter-spacing: -0.01em;
}
.insight-card h3 em { font-style: italic; color: #6e5e9e; }
.insight-card p {
color: var(--ink-soft); font-size: 14px; margin: 0;
line-height: 1.55; max-width: 56ch;
}
.insight-list {
margin-top: 16px; display: flex; flex-direction: column; gap: 10px;
}
.insight-list .row {
display: grid; grid-template-columns: 22px 1fr; gap: 10px;
align-items: flex-start;
}
.insight-list .check {
color: var(--sage); font-family: var(--serif); font-size: 18px; line-height: 1;
}
.insight-list .text { font-size: 13px; color: var(--ink-soft); line-height: 1.5; }
.insight-list .text strong { color: var(--ink); }
.insight-list .text .hand {
font-family: var(--hand); font-size: 16px; color: var(--peach);
transform: rotate(-1deg); display: inline-block; padding: 0 4px;
}
/* ─────── Photo strip (placeholder) ─────── */
.photo-strip {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
.photo {
aspect-ratio: 4 / 5; border-radius: 10px;
background: linear-gradient(135deg, var(--peach-soft), var(--lavender-soft));
border: 1px solid var(--line);
position: relative;
overflow: hidden;
cursor: pointer;
transition: transform 0.15s;
}
.photo:hover { transform: translateY(-2px) rotate(-1deg); }
.photo:nth-child(2) { background: linear-gradient(135deg, var(--rose-soft), var(--sky-soft)); transform: rotate(0.5deg); }
.photo:nth-child(3) { background: linear-gradient(135deg, var(--sage-soft), var(--gold-soft)); transform: rotate(-0.5deg); }
.photo:nth-child(4) { background: linear-gradient(135deg, var(--gold-soft), var(--peach-soft)); transform: rotate(0.8deg); }
.photo .cap {
position: absolute; bottom: 8px; left: 10px; right: 10px;
font-family: var(--hand); font-size: 16px;
color: var(--ink-soft); transform: rotate(-1deg);
}
.photo .day {
position: absolute; top: 8px; right: 10px;
font-family: var(--body); font-size: 10.5px; color: var(--muted);
background: rgba(255,255,255,0.7); padding: 2px 6px; border-radius: 3px;
backdrop-filter: blur(2px);
}
/* Closing line */
.closing {
text-align: center; margin-top: 40px;
font-family: var(--serif); font-style: italic;
font-size: 18px; color: var(--muted);
line-height: 1.5;
}
.closing .author {
font-family: var(--body); font-style: normal;
font-size: 11px; color: var(--muted-2);
text-transform: uppercase; letter-spacing: 0.16em;
margin-top: 8px;
}
/* Toast */
.toast-box { position: fixed; bottom: 24px; right: 24px; z-index: 200; display: flex; flex-direction: column; gap: 10px; pointer-events: none; }
.toast {
pointer-events: auto;
min-width: 280px; max-width: 380px;
padding: 14px 16px;
background: var(--paper);
border: 1px solid var(--line);
border-left: 3px solid var(--peach);
border-radius: 12px;
box-shadow: 0 16px 40px rgba(60,40,20,0.10);
font-size: 13px;
color: var(--ink);
animation: toastIn 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.toast.lavender { border-left-color: var(--lavender); }
.toast.sage { border-left-color: var(--sage); }
.toast .t-title { font-family: var(--serif); font-size: 16px; line-height: 1.2; margin-bottom: 4px; }
.toast .t-body { color: var(--ink-soft); line-height: 1.45; }
.toast .t-body strong { color: var(--ink); }
.toast.fade { opacity: 0; transform: translateX(20px); transition: all 0.25s; }
@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@media (max-width: 720px) {
main { padding: 40px 18px 60px; }
.hero .name { font-size: 56px; }
.sleep-card { grid-template-columns: 1fr; }
.sleep-circle { width: 220px; height: 220px; }
.reminders { grid-template-columns: 1fr; }
.photo-strip { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<header class="app">
<div class="word-mark">
<span class="small">a quiet panel from</span>
<em>quiver</em>
</div>
<button class="icon-btn warm" id="logBtn">
<svg class="ic" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Log a feed
</button>
</header>
<main>
<!-- ─────── Hero ─────── -->
<div class="hero">
<div class="pre">Mira's first month · 2026</div>
<h1 class="name">Mira <em>Avery</em></h1>
<div class="age">28 days &amp; counting</div>
<div class="meta">
<span><span class="v">Born</span> April 9 · 2026</span>
<span class="sep">·</span>
<span><span class="v">3.42 kg</span> · 50.8 cm</span>
<span class="sep">·</span>
<span><span class="v">Mom &amp; dad</span> have her back</span>
</div>
</div>
<!-- ─────── 1. Sleep ─────── -->
<section class="block">
<div class="block-head">
<h2><em>i.</em>How she slept last night</h2>
<span class="meta"><span class="src">Huckleberry</span>· synced 6:42 AM</span>
</div>
<div class="card sleep-card">
<div class="sleep-circle">
<svg viewBox="0 0 200 200">
<defs>
<radialGradient id="bgG" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#fff" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#c5b8e0" stop-opacity="0.06"/>
</radialGradient>
</defs>
<circle cx="100" cy="100" r="92" fill="url(#bgG)" stroke="#ece2d5" stroke-width="0.8"/>
<!-- 24h tick marks -->
<g stroke="#d8c8b3" stroke-width="0.6">
<line x1="100" y1="14" x2="100" y2="20"/>
<line x1="186" y1="100" x2="180" y2="100"/>
<line x1="100" y1="186" x2="100" y2="180"/>
<line x1="14" y1="100" x2="20" y2="100"/>
</g>
<!-- Hour labels -->
<g font-family="Inter,sans-serif" font-size="9" fill="#8a7e72">
<text x="100" y="11" text-anchor="middle">12 am</text>
<text x="194" y="103" text-anchor="end">6</text>
<text x="100" y="197" text-anchor="middle">12 pm</text>
<text x="6" y="103">6</text>
</g>
<!-- Sleep arcs (purple = night, peach = naps) -->
<!-- Night sleep 7:30 PM → 6:30 AM (with 4 brief wakes) -->
<!-- Stroke arcs at radius 76 -->
<path d="M 154.5 145.5 A 76 76 0 0 1 100 24" fill="none" stroke="#c5b8e0" stroke-width="14" stroke-linecap="round" opacity="0.85"/>
<path d="M 100 24 A 76 76 0 0 1 134.7 32.5" fill="none" stroke="#c5b8e0" stroke-width="14" stroke-linecap="round" opacity="0.5"/>
<!-- 1st morning nap 9:00–10:30 -->
<path d="M 162.4 76.6 A 76 76 0 0 1 173.1 105.4" fill="none" stroke="#f5b885" stroke-width="14" stroke-linecap="round" opacity="0.85"/>
<!-- afternoon nap 1:30–3:00 -->
<path d="M 142.1 168.6 A 76 76 0 0 1 100 176" fill="none" stroke="#f5b885" stroke-width="14" stroke-linecap="round" opacity="0.85"/>
<!-- evening nap 6:00–6:45 -->
<path d="M 24 100 A 76 76 0 0 1 32.5 65.3" fill="none" stroke="#f5b885" stroke-width="14" stroke-linecap="round" opacity="0.65"/>
<!-- Center text -->
<text x="100" y="92" text-anchor="middle" font-family="Instrument Serif,serif" font-size="34" fill="#2a2520" letter-spacing="-0.5">14h 22m</text>
<text x="100" y="115" text-anchor="middle" font-family="Inter,sans-serif" font-size="10" fill="#8a7e72" letter-spacing="0.6">total · 24h window</text>
<!-- now hand -->
<line x1="100" y1="100" x2="100" y2="62" stroke="#2a2520" stroke-width="1.5" stroke-linecap="round" transform="rotate(105 100 100)"/>
<circle cx="100" cy="100" r="3" fill="#2a2520"/>
</svg>
</div>
<div class="sleep-stat">
<div class="lbl">Last night · 7:30 PM → 6:30 AM</div>
<div class="big">9h 14m</div>
<div class="delta">3 wakes (down from 4) · longest stretch <strong>3h 12m</strong></div>
<div class="quote">"She slept 22 minutes longer per night this week. Not a streak — a rhythm."</div>
</div>
</div>
</section>
<!-- ─────── 2. Feeding ─────── -->
<section class="block">
<div class="block-head">
<h2><em>ii.</em>How she ate today</h2>
<span class="meta"><span class="src">Baby Tracker + scale</span>· last 24h</span>
</div>
<div class="card">
<div class="feed-timeline">
<div class="feed-line">
<!-- timeline runs midnight → midnight → markers as % -->
<div class="feed-marker bottle" style="left:8.3%"><span class="feed-tip">2:00 AM · 90ml bottle</span></div>
<div class="feed-marker breast" style="left:21%"><span class="feed-tip">5:00 AM · breast 18 min</span></div>
<div class="feed-marker bottle" style="left:33%"><span class="feed-tip">8:00 AM · 110ml bottle</span></div>
<div class="feed-marker breast" style="left:46%"><span class="feed-tip">11:00 AM · breast 22 min</span></div>
<div class="feed-marker bottle" style="left:58%"><span class="feed-tip">2:00 PM · 100ml bottle</span></div>
<div class="feed-marker now" style="left:60.5%"><span class="feed-tip">Now · 14:32</span></div>
<div class="feed-marker breast" style="left:71%"><span class="feed-tip">5:00 PM · breast 16 min (est.)</span></div>
<div class="feed-marker bottle" style="left:83%"><span class="feed-tip">8:00 PM · 100ml bottle (est.)</span></div>
<div class="feed-marker breast" style="left:96%"><span class="feed-tip">11:30 PM · breast (est.)</span></div>
</div>
<div class="feed-axis">
<span>12am</span><span>4am</span><span>8am</span><span>noon</span><span>4pm</span><span>8pm</span><span>12am</span>
</div>
<div class="feed-legend">
<span class="swatch bottle">Bottle <span class="v" style="margin-left:4px;">4 today · 400 ml</span></span>
<span class="swatch breast">Breast <span class="v" style="margin-left:4px;">5 today · ~110 min</span></span>
<span class="swatch now">Now</span>
</div>
</div>
</div>
</section>
<!-- ─────── 3. Weight curve ─────── -->
<section class="block">
<div class="block-head">
<h2><em>iii.</em>How she's growing</h2>
<span class="meta"><span class="src">Withings smart scale</span>· weekly</span>
</div>
<div class="card growth-card">
<svg viewBox="0 0 600 220" preserveAspectRatio="none">
<defs>
<linearGradient id="growthArea" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#9ec79e" stop-opacity="0.28"/>
<stop offset="100%" stop-color="#9ec79e" stop-opacity="0"/>
</linearGradient>
<linearGradient id="bandG" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#c5b8e0" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#c5b8e0" stop-opacity="0.04"/>
</linearGradient>
</defs>
<!-- WHO percentile band (10–90) -->
<polygon fill="url(#bandG)" points="40,170 120,154 200,138 280,122 360,108 440,96 520,84 580,76 580,140 520,150 440,160 360,168 280,176 200,182 120,188 40,194"/>
<!-- 50th percentile dashed line -->
<polyline fill="none" stroke="#c5b8e0" stroke-width="1" stroke-dasharray="3 4"
points="40,182 120,170 200,158 280,148 360,138 440,128 520,118 580,108"/>
<!-- Mira's actual weight -->
<polygon fill="url(#growthArea)" points="40,200 40,178 130,170 220,158 310,142 400,124 490,108 580,90 580,200"/>
<polyline fill="none" stroke="#9ec79e" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"
points="40,178 130,170 220,158 310,142 400,124 490,108 580,90"/>
<!-- Markers -->
<g>
<circle cx="40" cy="178" r="3.5" fill="#9ec79e"/>
<circle cx="130" cy="170" r="3.5" fill="#9ec79e"/>
<circle cx="220" cy="158" r="3.5" fill="#9ec79e"/>
<circle cx="310" cy="142" r="3.5" fill="#9ec79e"/>
<circle cx="400" cy="124" r="3.5" fill="#9ec79e"/>
<circle cx="490" cy="108" r="3.5" fill="#9ec79e"/>
<circle cx="580" cy="90" r="5" fill="#9ec79e" stroke="#fff" stroke-width="2"/>
</g>
<!-- X labels -->
<g font-family="Inter,sans-serif" font-size="10" fill="#8a7e72" text-anchor="middle">
<text x="40" y="215">birth</text>
<text x="130" y="215">wk 1</text>
<text x="220" y="215">wk 2</text>
<text x="310" y="215">wk 3</text>
<text x="400" y="215">wk 4</text>
<text x="490" y="215">wk 5</text>
<text x="580" y="215" fill="#2a2520" font-weight="600">today</text>
</g>
<!-- "now" callout -->
<text x="572" y="80" text-anchor="end" font-family="Caveat,cursive" font-size="20" fill="#9ec79e" transform="rotate(-3 572 80)">on track ✓</text>
<!-- Y axis labels -->
<g font-family="Geist Mono,monospace" font-size="9.5" fill="#8a7e72">
<text x="6" y="80">5.0</text>
<text x="6" y="120">4.5</text>
<text x="6" y="160">4.0</text>
<text x="6" y="200">3.5</text>
</g>
</svg>
<div class="growth-stats">
<div class="stat">
<div class="lbl">Weight today</div>
<div class="v">4.84 kg</div>
<div class="pctile">62nd percentile · WHO</div>
</div>
<div class="stat">
<div class="lbl">Length</div>
<div class="v">53.4 cm</div>
<div class="pctile">58th percentile · WHO</div>
</div>
<div class="stat">
<div class="lbl">Gain · 7 days</div>
<div class="v">+218 g</div>
<div class="pctile">healthy range (150–280g)</div>
</div>
</div>
</div>
</section>
<!-- ─────── 4. Today's reminders ─────── -->
<section class="block">
<div class="block-head">
<h2><em>iv.</em>Today's gentle reminders</h2>
<span class="meta">4 things, no alarms</span>
</div>
<div class="reminders">
<div class="rem-card feed" data-rem="feed">
<div class="l-row"><span class="glyph">◍</span> Next feed</div>
<div class="when">in ~38 min</div>
<div class="desc">Last feed at 2:00 PM (100 ml bottle). Suggested 100–110 ml.</div>
</div>
<div class="rem-card nap" data-rem="nap">
<div class="l-row"><span class="glyph">☾</span> Next likely nap</div>
<div class="when">3:30 — 5:00 PM</div>
<div class="desc">She tends to fade ~90 min after morning rouse. Recent pattern.</div>
</div>
<div class="rem-card appt" data-rem="appt">
<div class="l-row"><span class="glyph">✓</span> Doctor visit</div>
<div class="when">Friday 10:30 AM</div>
<div class="desc">1-month checkup with Dr. Wei. We'll bring the weight log.</div>
</div>
<div class="rem-card bath" data-rem="bath">
<div class="l-row"><span class="glyph">~</span> Bath night</div>
<div class="when">tonight, 6:30 PM</div>
<div class="desc">Every other day · grandma's lullaby works.</div>
</div>
</div>
</section>
<!-- ─────── 5. AI Insight ─────── -->
<section class="block">
<div class="block-head">
<h2><em>v.</em>What we noticed this week</h2>
<span class="meta"><span class="src">Quiver</span>· soft patterns</span>
</div>
<div class="card insight-card">
<div class="badge">
<span class="glyph">✦</span>
Quiver insights · weekly
</div>
<h3>She's settling into a <em>longer-stretch</em> sleep at night.</h3>
<p>Five small patterns matched up this week. None demand action — they just paint a picture.</p>
<div class="insight-list">
<div class="row"><span class="check">✓</span><span class="text"><strong>Longer first stretch:</strong> first sleep block grew from 2h 14m → 3h 12m on average. <span class="hand">good for you both</span></span></div>
<div class="row"><span class="check">✓</span><span class="text"><strong>Feed-then-sleep latency dropped to 14 min</strong> (was 28 min in week 2). She's calmer post-feed.</span></div>
<div class="row"><span class="check">✓</span><span class="text"><strong>Bottle preference stabilized</strong> on the slow-flow Dr. Brown's after the size-2 trial. No spit-up since Monday.</span></div>
<div class="row"><span class="check">✓</span><span class="text"><strong>62nd percentile</strong> on weight, up from 58th — within healthy range. Length is steady at 58th.</span></div>
<div class="row"><span class="check">✓</span><span class="text"><strong>Diaper count averaged 8 per day</strong> (target ≥6). Hydration is comfortable.</span></div>
</div>
</div>
</section>
<!-- ─────── 6. Photo memory ─────── -->
<section class="block">
<div class="block-head">
<h2><em>vi.</em>This week, in pictures</h2>
<span class="meta"><span class="src">iCloud Photos</span>· auto-curated</span>
</div>
<div class="photo-strip">
<div class="photo">
<div class="day">Mon</div>
<div class="cap">first finger-grip</div>
</div>
<div class="photo">
<div class="day">Wed</div>
<div class="cap">sun nap on the porch</div>
</div>
<div class="photo">
<div class="day">Thu</div>
<div class="cap">grandma met her</div>
</div>
<div class="photo">
<div class="day">Sat</div>
<div class="cap">first real smile (we think)</div>
</div>
</div>
</section>
<div class="closing">
"What you measure, you remember."<br/>
<span class="author">— quiver, a quiet panel · auto-refreshes daily at 6:30 AM</span>
</div>
</main>
<div class="toast-box" id="toastBox"></div>
<script>
(() => {
// Toast
const toastBox = document.getElementById('toastBox');
function toast(opts) {
const { kind = '', title = '', body = '' } = (typeof opts === 'string') ? { body: opts } : opts;
const node = document.createElement('div');
node.className = `toast ${kind}`;
node.innerHTML = `<div class="t-title">${title}</div><div class="t-body">${body}</div>`;
toastBox.appendChild(node);
setTimeout(() => {
node.classList.add('fade');
setTimeout(() => node.remove(), 280);
}, 4200);
}
// Reminders
const remCopy = {
feed: { kind: '', title: 'Logged a feed', body: 'Recorded · <strong>110 ml at 3:10 PM</strong> bottle. Updated next-feed estimate to 6:15 PM.' },
nap: { kind: 'lavender', title: 'Nap window noted', body: 'We\'ll send a quiet ping at <strong>3:25 PM</strong> if she\'s still awake.' },
appt: { kind: 'sage', title: 'Friday checkup', body: 'Calendar event opened · 1-month checkup with Dr. Wei · 10:30 AM.<br/>We\'ll auto-prepare the weight log.' },
bath: { kind: '', title: 'Bath time saved', body: 'Tonight at 6:30 PM. We\'ll dim the smart lights at 6:25.' }
};
document.querySelectorAll('[data-rem]').forEach(r => {
r.addEventListener('click', () => {
const k = r.dataset.rem;
if (remCopy[k]) toast(remCopy[k]);
});
});
// Log a feed (header button)
document.getElementById('logBtn').addEventListener('click', () => {
toast({ kind: 'sage', title: 'New feed logged', body: '<strong>110 ml</strong> bottle · 3:10 PM<br/>Avg gain trend: +218 g this week, on track.' });
});
// Photos
document.querySelectorAll('.photo').forEach(p => {
p.addEventListener('click', () => {
const cap = p.querySelector('.cap').textContent;
toast({ kind: 'lavender', title: 'Photo memory', body: `"${cap}" · saved to your Mira album.` });
});
});
// Feed marker click (open detail)
document.querySelectorAll('.feed-marker').forEach(m => {
m.addEventListener('click', e => {
const tip = m.querySelector('.feed-tip');
if (tip) toast({ kind: '', title: 'Feed entry', body: tip.textContent });
});
});
})();
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,919 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Monday morning briefing · Quiver</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
/* Editorial · calm Monday morning */
--paper: #f5f1ea; /* warm cream background */
--paper-elev: #fdfcf9; /* card surface */
--paper-deep: #efe9dd; /* elevation contrast */
--ink: #1c1c1f; /* deep near-black */
--ink-soft: #3b3b3f;
--muted: #6b6b66;
--muted-2: #8e8e88;
--line: #e3ddd0;
--line-strong: #cdc4b1;
--hairline: rgba(28,28,31,0.06);
/* Editorial accent palette — earthy, calm */
--espresso: #6f4e37; /* coffee */
--sage: #6e8865;
--sage-soft: #e7eee2;
--slate-blue: #4a6e8a;
--slate-soft: #e3ebf2;
--terracotta: #c8775c;
--tc-soft: #f6e4dd;
--gold: #c9a45a;
--gold-soft: #f1e7ce;
--plum: #6e4e6e;
--plum-soft: #ece2ec;
/* Type */
--serif: 'Instrument Serif', Iowan, Charter, Georgia, serif;
--display: 'Instrument Serif', Iowan, Charter, Georgia, serif;
--body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--r: 12px;
--r-pill: 999px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
/* paper grain — barely-there noise via SVG */
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.07 0 0 0 0 0.06 0 0 0 0 0.04 0 0 0 0.06 0'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>"),
var(--paper);
color: var(--ink);
font-family: var(--body);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
/* ─────── Header ─────── */
header.app {
border-bottom: 1px solid var(--line);
padding: 22px 32px 18px;
display: flex; align-items: center; justify-content: space-between;
background: var(--paper);
}
.masthead {
font-family: var(--serif);
font-size: 22px;
letter-spacing: -0.01em;
line-height: 1;
}
.masthead .small {
font-family: var(--mono); font-size: 10.5px;
color: var(--muted); text-transform: uppercase; letter-spacing: 0.16em;
margin-bottom: 6px;
}
.masthead .word {
display: inline-flex; gap: 8px; align-items: baseline;
}
.masthead .quiver {
font-family: var(--serif); font-style: italic;
color: var(--espresso);
font-size: 22px;
}
.head-right { display: flex; align-items: center; gap: 14px; }
.pill-soft {
padding: 6px 12px; font-size: 12px;
border: 1px solid var(--line); border-radius: var(--r-pill);
background: var(--paper-elev); color: var(--ink-soft);
font-family: var(--mono); letter-spacing: 0.04em;
transition: all 0.15s;
}
.pill-soft:hover { border-color: var(--line-strong); color: var(--ink); }
.pill-soft .dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: var(--sage); margin-right: 8px; vertical-align: 1px;
box-shadow: 0 0 6px var(--sage);
}
.icon-btn {
padding: 7px 14px; border-radius: var(--r-pill);
background: var(--ink); color: var(--paper);
font-size: 12px; font-weight: 500; letter-spacing: 0.01em;
border: 1px solid var(--ink);
display: inline-flex; gap: 8px; align-items: center;
transition: all 0.15s;
}
.icon-btn:hover { transform: translateY(-1px); }
.icon-btn.ghost { background: transparent; color: var(--ink); }
.icon-btn .ic { width: 13px; height: 13px; }
.icon-btn.spin .ic { animation: spin 0.8s linear; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ─────── Layout ─────── */
main {
max-width: 1220px; margin: 0 auto;
padding: 40px 32px 80px;
display: grid;
grid-template-columns: 1fr 280px;
gap: 48px;
}
/* ─────── Hero greeting ─────── */
.greeting {
margin-bottom: 36px;
border-bottom: 1px solid var(--hairline);
padding-bottom: 28px;
}
.greeting .pretitle {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--muted);
margin-bottom: 10px;
}
.greeting h1 {
font-family: var(--serif);
font-weight: 400;
font-size: 56px;
line-height: 1.05;
letter-spacing: -0.015em;
margin: 0;
color: var(--ink);
}
.greeting h1 em {
font-style: italic; color: var(--espresso);
}
.greeting .lede {
font-family: var(--serif);
font-style: italic;
font-size: 19px;
color: var(--ink-soft);
margin-top: 10px;
max-width: 64ch;
line-height: 1.5;
}
.greeting .meta-row {
margin-top: 18px;
display: flex; gap: 24px; flex-wrap: wrap;
font-family: var(--mono); font-size: 12px;
color: var(--muted);
}
.greeting .meta-row .v { color: var(--ink); margin-right: 4px; }
.greeting .meta-row .sep { color: var(--line-strong); }
/* ─────── Section style ─────── */
section.block {
margin-bottom: 40px;
}
.block > .head {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 14px;
padding-bottom: 6px;
border-bottom: 1px solid var(--hairline);
}
.block > .head h2 {
font-family: var(--serif);
font-weight: 400;
font-size: 26px;
letter-spacing: -0.005em;
line-height: 1;
margin: 0;
}
.block > .head h2 .ord {
font-family: var(--serif); font-style: italic; color: var(--espresso);
margin-right: 12px; font-size: 18px; vertical-align: 4px;
}
.block > .head .meta {
font-family: var(--mono); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.12em;
color: var(--muted);
}
.block > .head .pill-soft { padding: 4px 10px; font-size: 11px; }
.card {
background: var(--paper-elev);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 20px 22px;
box-shadow: 0 1px 0 rgba(28,28,31,0.02);
}
/* ─────── Revenue card ─────── */
.rev-card {
display: grid;
grid-template-columns: 220px 1fr;
gap: 28px;
align-items: center;
padding: 24px 28px;
}
.rev-stat .label {
font-family: var(--mono); font-size: 10.5px;
color: var(--muted); text-transform: uppercase; letter-spacing: 0.14em;
}
.rev-stat .big {
font-family: var(--serif);
font-weight: 400;
font-size: 60px;
line-height: 1;
letter-spacing: -0.02em;
margin-top: 8px;
color: var(--ink);
}
.rev-stat .delta {
margin-top: 10px; font-size: 13px;
color: var(--sage); font-weight: 500;
}
.rev-stat .delta .arr { font-family: var(--mono); }
.rev-stat .sub { color: var(--muted); font-size: 12px; margin-top: 12px; max-width: 30ch; }
.rev-chart svg { width: 100%; height: 140px; display: block; }
/* ─────── Email digest ─────── */
.digest-list { display: flex; flex-direction: column; gap: 12px; }
.digest-item {
background: var(--paper-elev);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 16px 20px;
display: grid;
grid-template-columns: 38px 1fr auto;
gap: 14px;
cursor: pointer;
transition: all 0.15s;
}
.digest-item:hover {
border-color: var(--line-strong);
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(28,28,31,0.05);
}
.digest-av {
width: 38px; height: 38px; border-radius: 50%;
color: var(--paper); font-family: var(--serif); font-size: 16px;
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.digest-meta { font-family: var(--mono); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.digest-from { color: var(--ink); font-weight: 500; }
.digest-subject {
margin-top: 4px; font-family: var(--serif); font-size: 17px; line-height: 1.35;
color: var(--ink);
}
.digest-snippet {
margin-top: 4px; color: var(--muted); font-size: 12.5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 64ch;
}
.digest-tag {
align-self: center;
font-family: var(--mono); font-size: 10px;
padding: 3px 9px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.1em;
}
.digest-tag.urgent { background: var(--tc-soft); color: var(--terracotta); }
.digest-tag.warm { background: var(--gold-soft); color: #8e6f1f; }
.digest-tag.calm { background: var(--sage-soft); color: #496938; }
.digest-tag.fyi { background: var(--slate-soft); color: var(--slate-blue); }
/* ─────── Stuck issues ─────── */
.stuck-list { display: flex; flex-direction: column; gap: 10px; }
.stuck-row {
background: var(--paper-elev);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 14px 18px;
display: grid; grid-template-columns: 1fr auto auto auto; gap: 18px;
align-items: center;
cursor: pointer;
transition: all 0.15s;
}
.stuck-row:hover { border-color: var(--line-strong); }
.stuck-row .id {
font-family: var(--mono); font-size: 11px;
color: var(--muted); margin-bottom: 4px;
letter-spacing: 0.04em;
}
.stuck-row .title { font-size: 14.5px; color: var(--ink); font-weight: 500; }
.stuck-row .who {
display: inline-flex; gap: 6px; align-items: center;
font-size: 12px; color: var(--muted);
}
.stuck-row .who .av {
width: 22px; height: 22px; border-radius: 50%;
color: #fff; font-size: 10px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
}
.stuck-row .age {
font-family: var(--mono); font-size: 11.5px;
padding: 3px 9px; border-radius: 5px;
background: var(--tc-soft); color: var(--terracotta);
font-weight: 600;
}
.stuck-row .age.warm { background: var(--gold-soft); color: #8e6f1f; }
.stuck-row .nudge {
padding: 5px 11px; font-family: var(--mono); font-size: 11px;
border-radius: 6px; border: 1px solid var(--line); background: var(--paper);
color: var(--ink-soft); font-weight: 500;
transition: all 0.15s;
}
.stuck-row .nudge:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
/* ─────── Schedule timeline ─────── */
.schedule {
display: flex; flex-direction: column; gap: 0;
padding: 0;
background: var(--paper-elev);
border: 1px solid var(--line);
border-radius: var(--r);
overflow: hidden;
}
.sched-row {
display: grid; grid-template-columns: 80px 12px 1fr auto; gap: 16px;
align-items: center;
padding: 14px 22px;
border-bottom: 1px solid var(--hairline);
transition: background 0.12s;
position: relative;
cursor: pointer;
}
.sched-row:last-child { border-bottom: 0; }
.sched-row:hover { background: var(--paper-deep); }
.sched-row .time {
font-family: var(--mono); font-size: 12px;
color: var(--ink); font-weight: 500;
}
.sched-row .time .dur { color: var(--muted); font-size: 10.5px; display: block; margin-top: 2px; }
.sched-row .dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--sage); justify-self: center;
}
.sched-row.now .dot {
background: var(--terracotta);
box-shadow: 0 0 0 4px var(--tc-soft);
animation: nowPulse 2.4s ease-out infinite;
}
@keyframes nowPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.18); }
}
.sched-row.past .dot { background: var(--muted-2); opacity: 0.55; }
.sched-row.past .title, .sched-row.past .desc { opacity: 0.55; }
.sched-row.past .title { text-decoration: line-through; text-decoration-color: var(--muted-2); }
.sched-row .title { font-size: 14.5px; color: var(--ink); font-weight: 500; }
.sched-row .desc { font-size: 12px; color: var(--muted); margin-top: 2px; }
.sched-row .source {
font-family: var(--mono); font-size: 10.5px;
padding: 2px 8px; border-radius: 4px;
color: var(--muted); border: 1px solid var(--line);
text-transform: uppercase; letter-spacing: 0.08em;
}
/* ─────── PR list ─────── */
.pr-list { display: flex; flex-direction: column; gap: 0;
background: var(--paper-elev);
border: 1px solid var(--line);
border-radius: var(--r);
overflow: hidden;
}
.pr-row {
display: grid; grid-template-columns: 1fr auto auto auto; gap: 14px;
padding: 14px 22px;
border-bottom: 1px solid var(--hairline);
align-items: center;
cursor: pointer;
transition: background 0.12s;
}
.pr-row:last-child { border-bottom: 0; }
.pr-row:hover { background: var(--paper-deep); }
.pr-row .num { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.pr-row .title { font-size: 14px; color: var(--ink); font-weight: 500; margin-top: 2px; }
.pr-row .files {
font-family: var(--mono); font-size: 11px; color: var(--muted);
background: var(--paper-deep);
padding: 3px 8px; border-radius: 4px;
}
.pr-row .age {
font-family: var(--mono); font-size: 11.5px;
padding: 3px 8px; border-radius: 5px;
color: var(--ink-soft);
}
.pr-row .age.urgent { background: var(--tc-soft); color: var(--terracotta); }
.pr-row .age.warm { background: var(--gold-soft); color: #8e6f1f; }
.pr-row .age.calm { background: var(--sage-soft); color: #496938; }
/* ─────── Wins (subtle bottom) ─────── */
.wins {
margin-top: 36px;
padding: 32px 36px;
background: linear-gradient(180deg, transparent, var(--paper-deep));
border-radius: var(--r);
border: 1px dashed var(--line-strong);
}
.wins-title {
font-family: var(--serif); font-style: italic; font-size: 28px;
color: var(--espresso); margin: 0 0 14px;
line-height: 1; letter-spacing: -0.01em;
}
.wins-list { columns: 2; column-gap: 32px; }
.wins-list .win {
break-inside: avoid;
margin-bottom: 14px;
display: grid; grid-template-columns: 18px 1fr; gap: 8px; align-items: flex-start;
}
.wins-list .checkmark {
color: var(--sage); font-family: var(--serif); font-size: 18px; line-height: 1;
}
.wins-list .text { font-size: 13.5px; color: var(--ink-soft); line-height: 1.5; }
/* ─────── Sidebar ─────── */
aside.briefing-side {
align-self: start;
position: sticky; top: 32px;
}
.first-thing {
background: var(--ink);
color: var(--paper);
border-radius: var(--r);
padding: 20px 22px;
margin-bottom: 14px;
position: relative;
overflow: hidden;
}
.first-thing::before {
content: ""; position: absolute; inset: 0;
background: radial-gradient(220px 120px at 110% -20%, rgba(255,180,120,0.12), transparent 70%);
pointer-events: none;
}
.first-thing .label {
font-family: var(--mono); font-size: 10.5px;
text-transform: uppercase; letter-spacing: 0.14em;
color: rgba(255,255,255,0.55);
}
.first-thing h3 {
font-family: var(--serif); font-size: 22px; font-weight: 400;
margin: 8px 0 8px; line-height: 1.15; color: var(--paper);
letter-spacing: -0.005em;
}
.first-thing p {
color: rgba(255,255,255,0.78); font-size: 13px; margin: 0;
line-height: 1.55;
}
.first-thing .actions {
margin-top: 16px; display: flex; gap: 8px;
}
.first-thing .a-btn {
padding: 6px 12px; font-size: 12px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.06); color: var(--paper);
font-family: var(--mono); letter-spacing: 0.04em;
transition: all 0.15s;
}
.first-thing .a-btn:hover { background: rgba(255,255,255,0.14); }
.first-thing .a-btn.primary {
background: var(--gold); color: var(--ink); border-color: var(--gold);
font-weight: 600;
}
.side-card {
background: var(--paper-elev);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 16px 18px;
margin-bottom: 14px;
}
.side-card h4 {
font-family: var(--mono); font-size: 10.5px;
text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); margin: 0 0 10px; font-weight: 500;
}
.quick-action {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 0; font-size: 13px; color: var(--ink-soft);
border-bottom: 1px dashed var(--hairline);
cursor: pointer;
transition: color 0.12s;
}
.quick-action:last-child { border-bottom: 0; }
.quick-action:hover { color: var(--ink); }
.quick-action .arr { font-family: var(--mono); color: var(--muted); }
.weather-card {
display: flex; align-items: center; gap: 14px;
padding: 14px 16px;
}
.weather-glyph {
width: 44px; height: 44px;
background: linear-gradient(180deg, #ffce72, #f0a23a);
border-radius: 50%;
box-shadow: 0 0 22px rgba(255,180,80,0.45);
flex-shrink: 0;
}
.weather-card .temp {
font-family: var(--serif); font-size: 28px; line-height: 1; letter-spacing: -0.01em;
}
.weather-card .desc { color: var(--muted); font-size: 12px; margin-top: 4px; }
/* Toast */
.toast-box {
position: fixed; bottom: 24px; right: 24px; z-index: 200;
display: flex; flex-direction: column; gap: 10px;
pointer-events: none;
}
.toast {
pointer-events: auto;
min-width: 280px; max-width: 380px;
padding: 14px 16px;
background: var(--paper-elev);
border: 1px solid var(--line);
border-left: 3px solid var(--espresso);
border-radius: 8px;
box-shadow: 0 16px 48px rgba(28,28,31,0.10);
font-size: 13px;
color: var(--ink);
animation: toastIn 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.toast.success { border-left-color: var(--sage); }
.toast.warn { border-left-color: var(--gold); }
.toast.urgent { border-left-color: var(--terracotta); }
.toast .t-title { font-family: var(--serif); font-size: 16px; line-height: 1.2; margin-bottom: 4px; }
.toast .t-body { color: var(--ink-soft); line-height: 1.45; }
.toast .t-body strong { color: var(--ink); }
.toast.fade { opacity: 0; transform: translateX(20px); transition: all 0.25s; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@media (max-width: 1080px) {
main { grid-template-columns: 1fr; }
aside.briefing-side { position: static; }
.greeting h1 { font-size: 40px; }
.rev-card { grid-template-columns: 1fr; }
.wins-list { columns: 1; }
}
</style>
</head>
<body>
<header class="app">
<div class="masthead">
<div class="small">Daily Briefing · Volume IV · Issue 18</div>
<div class="word">
<span>The Monday Memo</span>
<span class="quiver">— from quiver</span>
</div>
</div>
<div class="head-right">
<span class="pill-soft"><span class="dot"></span>5 sources connected · synced 6:42 AM</span>
<button class="icon-btn ghost" id="snoozeBtn">Snooze 1h</button>
<button class="icon-btn" id="doneBtn">
<svg class="ic" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3 3 7-7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
Mark briefing done
</button>
</div>
</header>
<main>
<div>
<!-- ─────── Greeting ─────── -->
<div class="greeting">
<div class="pretitle">Monday · May 6, 2026 · 8:42 AM</div>
<h1>Good morning, <em>PT.</em></h1>
<div class="lede">
Six things deserve your attention this morning. The rest can wait until coffee #2.
</div>
<div class="meta-row">
<span><span class="v">San Francisco</span></span><span class="sep">·</span>
<span><span class="v">62°</span> light fog, clearing 11 AM</span><span class="sep">·</span>
<span><span class="v">Sunset</span> 8:09 PM</span><span class="sep">·</span>
<span><span class="v" style="color:var(--sage)">7h 24m</span> sleep</span>
</div>
</div>
<!-- ─────── 1. Revenue ─────── -->
<section class="block">
<div class="head">
<h2><span class="ord">i.</span>This week's revenue</h2>
<span class="meta">Stripe · 7-day rolling</span>
</div>
<div class="rev-card card">
<div class="rev-stat">
<div class="label">Past 7 days</div>
<div class="big">$84,210</div>
<div class="delta"><span class="arr">▲</span> +12.4% vs prior week · +$9,316</div>
<div class="sub">Driven by 3 enterprise expansions on Wed–Thu. Two SMB churns absorbed in net new.</div>
</div>
<div class="rev-chart">
<svg viewBox="0 0 600 140" preserveAspectRatio="none">
<defs>
<linearGradient id="revG" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#6e8865" stop-opacity="0.32"/>
<stop offset="100%" stop-color="#6e8865" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- horizontal guides -->
<line x1="0" y1="40" x2="600" y2="40" stroke="rgba(28,28,31,0.06)" stroke-dasharray="2 4"/>
<line x1="0" y1="80" x2="600" y2="80" stroke="rgba(28,28,31,0.06)" stroke-dasharray="2 4"/>
<line x1="0" y1="120" x2="600" y2="120" stroke="rgba(28,28,31,0.06)" stroke-dasharray="2 4"/>
<!-- prior week ghost line -->
<polyline fill="none" stroke="rgba(28,28,31,0.32)" stroke-width="1.2" stroke-dasharray="3 4"
points="20,90 100,86 180,82 260,86 340,80 420,78 500,72 580,70"/>
<!-- this week area + line -->
<polygon fill="url(#revG)" points="20,140 20,82 100,76 180,68 260,70 340,58 420,52 500,40 580,30 580,140"/>
<polyline fill="none" stroke="#6e8865" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"
points="20,82 100,76 180,68 260,70 340,58 420,52 500,40 580,30"/>
<!-- weekday labels -->
<g font-family="Geist Mono,monospace" font-size="9.5" fill="#6b6b66" text-anchor="middle">
<text x="20" y="138">Mon</text>
<text x="100" y="138">Tue</text>
<text x="180" y="138">Wed</text>
<text x="260" y="138">Thu</text>
<text x="340" y="138">Fri</text>
<text x="420" y="138">Sat</text>
<text x="500" y="138">Sun</text>
<text x="580" y="138" fill="#1c1c1f" font-weight="600">Mon</text>
</g>
<!-- "now" marker -->
<circle cx="580" cy="30" r="3.5" fill="#1c1c1f"/>
<text x="586" y="22" font-family="Instrument Serif,serif" font-style="italic" font-size="13" fill="#1c1c1f">today</text>
</svg>
</div>
</div>
</section>
<!-- ─────── 2. Email digest ─────── -->
<section class="block">
<div class="head">
<h2><span class="ord">ii.</span>Customers who deserve a reply</h2>
<span class="meta">Gmail · curated by relevance</span>
</div>
<div class="digest-list" id="digest"></div>
</section>
<!-- ─────── 3. Stuck Linear issues ─────── -->
<section class="block">
<div class="head">
<h2><span class="ord">iii.</span>What's stuck on the team</h2>
<span class="meta">Linear · idle &gt; 48h</span>
</div>
<div class="stuck-list" id="stuck"></div>
</section>
<!-- ─────── 4. Today's schedule ─────── -->
<section class="block">
<div class="head">
<h2><span class="ord">iv.</span>Today, hour by hour</h2>
<span class="meta">Google Calendar · 6 events</span>
</div>
<div class="schedule" id="schedule"></div>
</section>
<!-- ─────── 5. PRs ─────── -->
<section class="block">
<div class="head">
<h2><span class="ord">v.</span>Pull requests waiting on you</h2>
<span class="meta">GitHub · 4 PRs</span>
</div>
<div class="pr-list" id="prList"></div>
</section>
<!-- ─────── 6. Wins ─────── -->
<div class="wins">
<div class="wins-title">Yesterday, quietly:</div>
<div class="wins-list">
<div class="win"><span class="checkmark">✓</span><span class="text"><strong>Voltage Co.</strong> went live in production at 6:14 PM. Zero rollback. Onboarding kickoff tomorrow.</span></div>
<div class="win"><span class="checkmark">✓</span><span class="text">Mira closed <strong>Atlas Cooperative</strong> ($240k) on a Sunday call. The handshake email landed at 11:47 PM.</span></div>
<div class="win"><span class="checkmark">✓</span><span class="text">QA cleared the Q2 release — <strong>0 P0s, 2 P3s</strong>. Cleanest cut in 7 quarters.</span></div>
<div class="win"><span class="checkmark">✓</span><span class="text"><strong>Nora's onboarding</strong> wrapped a week ahead. Already shipping in <code>src/agent/router</code>.</span></div>
<div class="win"><span class="checkmark">✓</span><span class="text">Stripe weekly closed at <strong>$84.2k</strong> — fourth straight week above $80k. Comfortable Q2 trajectory.</span></div>
<div class="win"><span class="checkmark">✓</span><span class="text">Sam shipped the auth migration with a <strong>54-line PR</strong>. The rollback plan was three commits.</span></div>
</div>
</div>
</div>
<!-- ─────── Sidebar ─────── -->
<aside class="briefing-side">
<div class="first-thing">
<div class="label">Your first thing today</div>
<h3>Reply to Lattice Health by 10 AM.</h3>
<p>Their legal review wraps tomorrow. A short "we're ready when you are" keeps the proposal on Friday's close list.</p>
<div class="actions">
<button class="a-btn primary" data-action="draft">Draft reply</button>
<button class="a-btn" data-action="snooze1">Snooze 1h</button>
</div>
</div>
<div class="weather-card side-card">
<div class="weather-glyph"></div>
<div>
<div class="temp">62°</div>
<div class="desc">Light fog · clearing by 11</div>
</div>
</div>
<div class="side-card">
<h4>Quick actions</h4>
<div class="quick-action" data-action="generatePlan"><span>✦ Generate next week's plan</span><span class="arr">→</span></div>
<div class="quick-action" data-action="standup"><span>Post standup digest</span><span class="arr">→</span></div>
<div class="quick-action" data-action="snooze"><span>Snooze whole briefing 1h</span><span class="arr">→</span></div>
<div class="quick-action" data-action="email"><span>Email me a copy at 7 PM</span><span class="arr">→</span></div>
</div>
<div class="side-card">
<h4>Connected sources</h4>
<div style="display:flex;flex-direction:column;gap:8px;font-size:13px;">
<div style="display:flex;justify-content:space-between;color:var(--ink-soft);">
<span>Stripe</span><span style="font-family:var(--mono);font-size:11px;color:var(--sage);">live</span>
</div>
<div style="display:flex;justify-content:space-between;color:var(--ink-soft);">
<span>Gmail</span><span style="font-family:var(--mono);font-size:11px;color:var(--sage);">live</span>
</div>
<div style="display:flex;justify-content:space-between;color:var(--ink-soft);">
<span>Linear</span><span style="font-family:var(--mono);font-size:11px;color:var(--sage);">live</span>
</div>
<div style="display:flex;justify-content:space-between;color:var(--ink-soft);">
<span>GitHub</span><span style="font-family:var(--mono);font-size:11px;color:var(--sage);">live</span>
</div>
<div style="display:flex;justify-content:space-between;color:var(--ink-soft);">
<span>Calendar</span><span style="font-family:var(--mono);font-size:11px;color:var(--sage);">live</span>
</div>
</div>
</div>
<div style="text-align:center;font-family:var(--serif);font-style:italic;color:var(--muted);font-size:14px;line-height:1.4;padding:14px 8px;">
"What gets your attention<br/>shapes your week."
<div style="font-family:var(--mono);font-style:normal;font-size:10.5px;color:var(--muted-2);margin-top:6px;letter-spacing:0.06em;">— quiver, vol. IV</div>
</div>
</aside>
</main>
<div class="toast-box" id="toastBox"></div>
<script>
(() => {
// ─────── Email digest ───────
const EMAILS = [
{ from: 'Pioneer Robotics — Sarah Chen', subject: 'Re: Pricing 2-pager and onboarding timeline', snippet: 'Quick one — the team is ready to move forward, just need the term sheet by Wednesday so legal can…', tag: 'urgent', ago: '2h', av: 'S', avBg: '#1f2937' },
{ from: 'Lattice Health — David Park', subject: 'Procurement asked for a SOC 2 letter', snippet: 'Hey, our procurement team had one last item on the list. Can you send the SOC 2 attestation letter to…', tag: 'warm', ago: '4h', av: 'D', avBg: '#0ea5e9' },
{ from: 'Foundry Group — Thomas Brun', subject: 'Loved the demo recording — sharing internally', snippet: 'Just to close the loop: I forwarded the recording to two engineering directors. They\'re penciled in for…', tag: 'calm', ago: '14h', av: 'T', avBg: '#dc2626' },
{ from: 'Ironclad Mfg — Priya Anand', subject: 'Term sheet v2 attached', snippet: 'Here\'s our redline. Two small changes on the indemnification and one substantial on the data residency…', tag: 'urgent', ago: '18h', av: 'P', avBg: '#475569' },
{ from: 'Mosaic Health — Lin Chen', subject: 'Intro to procurement (cc\'d)', snippet: 'Looping in our head of procurement. They have time this Thursday or next Monday for a first call…', tag: 'fyi', ago: '1d', av: 'L', avBg: '#7c3aed' }
];
const dgEl = document.getElementById('digest');
dgEl.innerHTML = EMAILS.map((e, i) => `
<div class="digest-item" data-i="${i}">
<span class="digest-av" style="background:${e.avBg}">${e.av}</span>
<div>
<div class="digest-meta"><span class="digest-from">${e.from}</span> · ${e.ago} ago</div>
<div class="digest-subject">${e.subject}</div>
<div class="digest-snippet">${e.snippet}</div>
</div>
<span class="digest-tag ${e.tag}">${e.tag === 'urgent' ? 'reply today' : e.tag === 'warm' ? 'this week' : e.tag === 'calm' ? 'reviewed' : 'fyi'}</span>
</div>
`).join('');
dgEl.querySelectorAll('.digest-item').forEach(item => {
const e = EMAILS[+item.dataset.i];
item.addEventListener('click', () => {
toast({ kind: e.tag === 'urgent' ? 'urgent' : 'warn', title: e.subject, body: `From <strong>${e.from}</strong> · Opening Gmail thread…` });
});
});
// ─────── Stuck issues ───────
const STUCK = [
{ id: 'ENG-1284', title: 'Fix flaky test in payment-rollback path', who: 'Mira O.', whoBg: '#f97316', age: '5d', ageClass: '' },
{ id: 'ENG-1271', title: 'Refactor session token rotation (security review feedback)', who: 'Sam D.', whoBg: '#10b981', age: '3d', ageClass: 'warm' },
{ id: 'GROW-462', title: 'Wire Mixpanel funnel to onboarding step 3', who: 'Jules K.', whoBg: '#a855f7', age: '4d', ageClass: '' },
{ id: 'ENG-1268', title: 'Migrate cron worker to durable queue', who: 'Sam D.', whoBg: '#10b981', age: '6d', ageClass: '' },
{ id: 'DESIGN-118', title: 'Settings page redesign — needs eng pairing', who: 'Nora L.', whoBg: '#0ea5e9', age: '2d', ageClass: 'warm' }
];
document.getElementById('stuck').innerHTML = STUCK.map((s, i) => `
<div class="stuck-row" data-i="${i}">
<div>
<div class="id">${s.id}</div>
<div class="title">${s.title}</div>
</div>
<span class="who"><span class="av" style="background:${s.whoBg}">${s.who[0]}</span>${s.who}</span>
<span class="age ${s.ageClass}">stuck ${s.age}</span>
<button class="nudge" data-i="${i}">Nudge</button>
</div>
`).join('');
document.querySelectorAll('.stuck-row').forEach(row => {
const s = STUCK[+row.dataset.i];
row.querySelector('.nudge').addEventListener('click', e => {
e.stopPropagation();
toast({ kind: 'success', title: 'Nudge sent', body: `Sent ${s.who} a gentle Slack DM about <strong>${s.id}</strong>.` });
});
row.addEventListener('click', () => {
toast({ title: s.id, body: `<strong>${s.title}</strong> · stuck ${s.age} · assigned ${s.who}` });
});
});
// ─────── Schedule ───────
const NOW_HOUR = 8 * 60 + 42; // 8:42 AM
const SCHED = [
{ time: '7:30 AM', dur: '30m', title: 'Morning run', desc: 'Bay-side loop · Strava', source: 'CAL', t: 7*60 + 30 },
{ time: '9:00 AM', dur: '15m', title: 'Sync with Mira', desc: 'Pipeline review · 1:1', source: 'GMEET', t: 9*60 },
{ time: '10:00 AM', dur: '45m', title: 'Lattice Health follow-up', desc: 'Review SOC 2 + draft reply', source: 'FOCUS', t: 10*60 },
{ time: '11:30 AM', dur: '30m', title: 'Eng standup', desc: 'Fri release + Q3 capacity', source: 'GMEET', t: 11*60 + 30 },
{ time: '2:00 PM', dur: '60m', title: 'Pioneer Robotics — term sheet review', desc: 'Joint with Sam · legal walks through redlines', source: 'ZOOM', t: 14*60 },
{ time: '4:30 PM', dur: '20m', title: 'PR review block', desc: 'Clear the 4 PRs in queue', source: 'FOCUS', t: 16*60 + 30 }
];
document.getElementById('schedule').innerHTML = SCHED.map((s, i) => {
const past = s.t + parseInt(s.dur) < NOW_HOUR;
const now = s.t <= NOW_HOUR && NOW_HOUR < s.t + parseInt(s.dur);
const cls = past ? 'past' : (now ? 'now' : '');
return `
<div class="sched-row ${cls}" data-i="${i}">
<div class="time">${s.time}<span class="dur">${s.dur}</span></div>
<div class="dot"></div>
<div>
<div class="title">${s.title}</div>
<div class="desc">${s.desc}</div>
</div>
<span class="source">${s.source}</span>
</div>
`;
}).join('');
document.querySelectorAll('.sched-row').forEach(r => {
const s = SCHED[+r.dataset.i];
r.addEventListener('click', () => toast({ title: s.title, body: `${s.time} · ${s.dur} · ${s.source.toLowerCase()}<br/>${s.desc}` }));
});
// ─────── PRs ───────
const PRS = [
{ num: '#1284', title: 'Add session-token rotation primitive', files: '6 files', author: 'Sam', age: 'opened 18h ago', ageClass: 'urgent' },
{ num: '#1271', title: 'Migrate cron worker to durable queue (RFC implementation)', files: '14 files', author: 'Sam', age: '2d ago', ageClass: 'urgent' },
{ num: '#1268', title: 'Mixpanel funnel hooks for onboarding step 3', files: '4 files', author: 'Jules', age: '1d ago', ageClass: 'warm' },
{ num: '#1262', title: 'Settings page redesign · scaffolding only', files: '22 files', author: 'Nora', age: '4h ago', ageClass: 'calm' }
];
document.getElementById('prList').innerHTML = PRS.map((p, i) => `
<div class="pr-row" data-i="${i}">
<div>
<span class="num">${p.num} · @${p.author}</span>
<div class="title">${p.title}</div>
</div>
<span class="files">${p.files}</span>
<span class="age ${p.ageClass}">${p.age}</span>
</div>
`).join('');
document.querySelectorAll('.pr-row').forEach(r => {
const p = PRS[+r.dataset.i];
r.addEventListener('click', () => toast({ title: p.num + ' · ' + p.title, body: `${p.files} · @${p.author} · ${p.age}<br/>Opening on GitHub…` }));
});
// ─────── Toasts ───────
const toastBox = document.getElementById('toastBox');
function toast(opts) {
const { kind = '', title = '', body = '' } = (typeof opts === 'string') ? { body: opts } : opts;
const node = document.createElement('div');
node.className = `toast ${kind}`;
node.innerHTML = `<div class="t-title">${title}</div><div class="t-body">${body}</div>`;
toastBox.appendChild(node);
setTimeout(() => {
node.classList.add('fade');
setTimeout(() => node.remove(), 280);
}, 4200);
}
// ─────── Side actions + header ───────
const map = {
draft: { kind: 'success', title: 'Reply drafted', body: 'A short "we\'re ready when you are" is in your <strong>Lattice Health</strong> Gmail draft. Send when ready.' },
snooze1: { kind: 'warn', title: 'Snoozed', body: 'Lattice Health bumped to 11 AM.' },
generatePlan: { kind: '', title: 'Plan drafted', body: 'Next week\'s plan ready in Notion · 4 priorities · 14 commitments inferred from your week.' },
standup: { kind: 'success', title: 'Standup posted', body: '#eng-standup got a 4-line digest of yesterday\'s wins + today\'s focus.' },
snooze: { kind: 'warn', title: 'Briefing snoozed', body: 'See you in an hour. We\'ll re-pull fresh data.' },
email: { kind: 'success', title: 'Email scheduled', body: 'You\'ll get a copy of this briefing in your inbox at 7 PM.' }
};
document.querySelectorAll('[data-action]').forEach(b => {
b.addEventListener('click', () => {
const k = b.dataset.action;
if (map[k]) toast(map[k]);
});
});
document.getElementById('doneBtn').addEventListener('click', () => {
toast({ kind: 'success', title: 'Briefing done', body: '6 of 6 sections reviewed. See you tomorrow at 6:42 AM.' });
});
document.getElementById('snoozeBtn').addEventListener('click', () => {
toast({ kind: 'warn', title: 'Briefing snoozed', body: 'Re-pulling at 9:42 AM.' });
});
})();
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
{
"title": "Quiver Live · Personal Portfolio",
"slug": "quiver-live-portfolio",
"pinned": true,
"preview": {
"type": "html",
"entry": "index.html"
},
"document": {
"format": "html_template_v1",
"templatePath": "template.html",
"generatedPreviewPath": "index.html",
"dataPath": "data.json",
"dataJson": {}
}
}
@@ -0,0 +1,453 @@
{
"summary": {
"brand": "quiver",
"pro": "Live",
"marketState": "NYSE Open · 14:32:18 EST",
"lastSync": "14:32:18 EST",
"totalLabel": "Total portfolio value · USD",
"accountsLabel": "All accounts (Robinhood + IBKR)",
"totalValue": "$284,521.64",
"todayPnl": "+$3,182.40",
"todayPct": "+1.13%",
"todayDirClass": "up",
"todayArrow": "▲",
"allTimePnl": "+$84,521.64",
"allTimePct": "+42.3%",
"allTimeDirClass": "up",
"allTimeArrow": "▲",
"alphaLabel": "vs S&P · YTD",
"alpha": "+18.7pp",
"areaPoints": "10,150 10,128 60,124 110,116 160,118 210,108 260,114 310,98 360,90 410,96 460,80 510,72 560,76 610,60 660,54 710,46 760,40 810,32 860,28 870,28 870,150",
"linePoints": "10,128 60,124 110,116 160,118 210,108 260,114 310,98 360,90 410,96 460,80 510,72 560,76 610,60 660,54 710,46 760,40 810,32 860,28",
"markerX": "860",
"markerY": "28",
"axisTicks": [
{ "label": "May '24" },
{ "label": "Jul" },
{ "label": "Sep" },
{ "label": "Nov" },
{ "label": "Jan '25" },
{ "label": "Mar" },
{ "label": "Today" }
]
},
"kpis": [
{
"label": "Today's P&L",
"value": "+$3,182.40",
"valueClass": "up",
"sub": "+1.13% · 8 winners / 2 losers",
"subClass": "up"
},
{
"label": "Buying power",
"value": "$12,438.21",
"valueClass": "",
"sub": "Cash + 2× margin available",
"subClass": ""
},
{
"label": "Best position · NVDA",
"value": "+187.4%",
"valueClass": "up",
"sub": "Avg cost $321.55 · Now $924.31",
"subClass": "up"
},
{
"label": "Alpha vs S&P 500 · YTD",
"value": "+18.7pp",
"valueClass": "alpha",
"sub": "Sharpe 1.42 · Beta 1.18 · Max DD −9.4%",
"subClass": ""
}
],
"featured": {
"iconText": "N",
"iconBg": "linear-gradient(135deg,#76b900,#4d8400)",
"iconFg": "#0a1502",
"symbol": "NVDA",
"name": "NVIDIA Corporation",
"sector": "Semiconductors · Mkt cap $2.27T",
"exchangeName": "NASDAQ",
"exchangeUrl": "https://www.nasdaq.com/market-activity/stocks/nvda",
"yahooUrl": "https://finance.yahoo.com/quote/NVDA",
"tradingViewUrl": "https://www.tradingview.com/symbols/NASDAQ-NVDA/",
"price": "$924.31",
"delta": "▲ +$21.74 +2.41% today",
"dirClass": "up",
"stats": [
{ "label": "Open", "value": "906.80" },
{ "label": "High", "value": "929.18" },
{ "label": "Low", "value": "902.44" },
{ "label": "Volume", "value": "38.42M" },
{ "label": "P/E", "value": "62.4" },
{ "label": "52W range", "value": "412 — 974" }
],
"areaPoints": "40,260 40,220 80,210 120,200 160,194 200,184 240,180 280,170 320,160 360,150 400,138 440,128 480,120 520,108 560,100 600,86 640,76 680,64 720,56 760,48 800,40 840,30 850,30 850,260",
"linePoints": "40,220 80,210 120,200 160,194 200,184 240,180 280,170 320,160 360,150 400,138 440,128 480,120 520,108 560,100 600,86 640,76 680,64 720,56 760,48 800,40 840,30",
"priceY": "30",
"priceLabelY": "21",
"priceLabelTextY": "34",
"priceLabelText": "924.31"
},
"ai": {
"tag": "Quiver AI · Signal #2417",
"headlineAccent": "Add to NVDA",
"headlineRest": "on Blackwell ramp + AI capex inflection.",
"sub": "Three converging catalysts and resilient channel checks. Conviction has stepped up since last week's hyperscaler guidance.",
"conviction": "87%",
"convictionWidth": "87%",
"targetLabel": "12-mo target",
"target": "$1,050",
"upside": "+13.6%",
"size": "+12 sh",
"sizeSub": "≈ $11,092 · 4.2% port",
"catalysts": [
{
"n": "01",
"title": "Blackwell shipments accelerating",
"text": "Foxconn and Wiwynn guidance both implied an Aug volume step-up. Backlog visibility extends through Q1 '26."
},
{
"n": "02",
"title": "Hyperscaler capex revised up",
"text": "MSFT, META, AMZN combined FY '25 capex now +34% YoY. AI infra is the binding allocation."
},
{
"n": "03",
"title": "Sentiment de-risked",
"text": "Short interest at 6-month high but RSI cooling from overbought. Setup mirrors the May '24 base before the +47% leg."
}
]
},
"holdingsPanel": {
"title": "Holdings · 9 positions",
"subtitle": "Live · last quote 14:32:18 EST"
},
"holdings": [
{
"symbol": "NVDA",
"companyName": "NVIDIA Corporation",
"icon": "N",
"iconBg": "linear-gradient(135deg,#76b900,#3d6800)",
"iconFg": "#0a1502",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/nvda",
"shares": "100",
"avgCost": "321.55",
"last": "924.31",
"marketValue": "$92,431",
"pnl": "+$60,276",
"pnlPct": "+187.4%",
"dirClass": "up",
"alloc": "32.5%",
"allocWidth": "32%"
},
{
"symbol": "AAPL",
"companyName": "Apple Inc.",
"icon": "A",
"iconBg": "linear-gradient(135deg,#0c0c0c,#3a3a3a)",
"iconFg": "#ffffff",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/aapl",
"shares": "220",
"avgCost": "152.10",
"last": "228.74",
"marketValue": "$50,323",
"pnl": "+$16,861",
"pnlPct": "+50.4%",
"dirClass": "up",
"alloc": "17.7%",
"allocWidth": "18%"
},
{
"symbol": "MSFT",
"companyName": "Microsoft Corp.",
"icon": "M",
"iconBg": "linear-gradient(135deg,#0078d4,#005a9e)",
"iconFg": "#ffffff",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/msft",
"shares": "85",
"avgCost": "328.40",
"last": "438.12",
"marketValue": "$37,240",
"pnl": "+$9,326",
"pnlPct": "+33.4%",
"dirClass": "up",
"alloc": "13.1%",
"allocWidth": "13%"
},
{
"symbol": "META",
"companyName": "Meta Platforms",
"icon": "M",
"iconBg": "linear-gradient(135deg,#1877f2,#0c5fc3)",
"iconFg": "#ffffff",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/meta",
"shares": "55",
"avgCost": "312.20",
"last": "596.18",
"marketValue": "$32,790",
"pnl": "+$15,619",
"pnlPct": "+91.0%",
"dirClass": "up",
"alloc": "11.5%",
"allocWidth": "11%"
},
{
"symbol": "GOOGL",
"companyName": "Alphabet Class A",
"icon": "G",
"iconBg": "linear-gradient(135deg,#4285f4,#34a853)",
"iconFg": "#ffffff",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/googl",
"shares": "160",
"avgCost": "128.90",
"last": "186.40",
"marketValue": "$29,824",
"pnl": "+$9,200",
"pnlPct": "+44.6%",
"dirClass": "up",
"alloc": "10.5%",
"allocWidth": "10%"
},
{
"symbol": "AMZN",
"companyName": "Amazon.com Inc.",
"icon": "A",
"iconBg": "linear-gradient(135deg,#ff9900,#cc7a00)",
"iconFg": "#1a0e00",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/amzn",
"shares": "90",
"avgCost": "142.50",
"last": "218.94",
"marketValue": "$19,705",
"pnl": "+$6,880",
"pnlPct": "+53.6%",
"dirClass": "up",
"alloc": "6.9%",
"allocWidth": "7%"
},
{
"symbol": "TSLA",
"companyName": "Tesla, Inc.",
"icon": "T",
"iconBg": "linear-gradient(135deg,#cc0000,#660000)",
"iconFg": "#ffffff",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/tsla",
"shares": "42",
"avgCost": "288.40",
"last": "312.55",
"marketValue": "$13,127",
"pnl": "+$1,015",
"pnlPct": "+8.4%",
"dirClass": "up",
"alloc": "4.6%",
"allocWidth": "5%"
},
{
"symbol": "AMD",
"companyName": "Advanced Micro Devices",
"icon": "A",
"iconBg": "linear-gradient(135deg,#ed1c24,#7a0000)",
"iconFg": "#ffffff",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/amd",
"shares": "52",
"avgCost": "158.90",
"last": "142.06",
"marketValue": "$7,387",
"pnl": "−$876",
"pnlPct": "−10.6%",
"dirClass": "down",
"alloc": "2.6%",
"allocWidth": "3%"
},
{
"symbol": "USD",
"companyName": "Settled cash",
"icon": "$",
"iconBg": "linear-gradient(135deg,#facc15,#a17400)",
"iconFg": "#1a1100",
"exchName": "—",
"exchUrl": "#",
"shares": "—",
"avgCost": "—",
"last": "1.00",
"marketValue": "$1,694",
"pnl": "—",
"pnlPct": "—",
"dirClass": "",
"alloc": "0.6%",
"allocWidth": "1%"
}
],
"watchlistPanel": {
"title": "Watchlist · curated",
"subtitle": "12 tickers · 14:32 EST"
},
"watchlist": [
{
"symbol": "PLTR",
"companyName": "Palantir",
"price": "$58.42",
"pct": "▲ 4.81%",
"dirClass": "up",
"sparkColor": "#22e58c",
"sparkPoints": "0,28 20,30 40,26 60,24 80,28 100,22 120,18 140,20 160,14 180,10 200,6",
"volume": "4.21M",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/pltr"
},
{
"symbol": "SHOP",
"companyName": "Shopify",
"price": "$112.84",
"pct": "▲ 2.13%",
"dirClass": "up",
"sparkColor": "#22e58c",
"sparkPoints": "0,26 20,28 40,22 60,24 80,20 100,22 120,18 140,16 160,12 180,14 200,10",
"volume": "6.84M",
"exchName": "NYSE",
"exchUrl": "https://www.nyse.com/quote/XNYS:SHOP"
},
{
"symbol": "TSM",
"companyName": "TSMC",
"price": "$208.77",
"pct": "▲ 1.62%",
"dirClass": "up",
"sparkColor": "#22e58c",
"sparkPoints": "0,24 20,22 40,26 60,20 80,18 100,20 120,16 140,14 160,12 180,10 200,8",
"volume": "12.4M",
"exchName": "NYSE",
"exchUrl": "https://www.nyse.com/quote/XNYS:TSM"
},
{
"symbol": "COIN",
"companyName": "Coinbase",
"price": "$236.10",
"pct": "▲ 3.48%",
"dirClass": "up",
"sparkColor": "#22e58c",
"sparkPoints": "0,28 20,30 40,26 60,28 80,22 100,18 120,20 140,14 160,10 180,12 200,6",
"volume": "8.92M",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/coin"
},
{
"symbol": "CRWD",
"companyName": "CrowdStrike",
"price": "$352.09",
"pct": "▼ 1.24%",
"dirClass": "down",
"sparkColor": "#ff4f6d",
"sparkPoints": "0,14 20,12 40,16 60,14 80,18 100,20 120,18 140,22 160,24 180,28 200,30",
"volume": "3.24M",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/crwd"
},
{
"symbol": "SMCI",
"companyName": "Super Micro",
"price": "$48.93",
"pct": "▲ 5.62%",
"dirClass": "up",
"sparkColor": "#22e58c",
"sparkPoints": "0,30 20,32 40,28 60,30 80,26 100,20 120,16 140,18 160,12 180,8 200,4",
"volume": "18.2M",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/smci"
},
{
"symbol": "UBER",
"companyName": "Uber Technologies",
"price": "$72.41",
"pct": "▲ 1.18%",
"dirClass": "up",
"sparkColor": "#22e58c",
"sparkPoints": "0,24 20,26 40,22 60,20 80,22 100,18 120,16 140,18 160,14 180,12 200,12",
"volume": "7.42M",
"exchName": "NYSE",
"exchUrl": "https://www.nyse.com/quote/XNYS:UBER"
},
{
"symbol": "NFLX",
"companyName": "Netflix",
"price": "$728.50",
"pct": "▼ 0.66%",
"dirClass": "down",
"sparkColor": "#ff4f6d",
"sparkPoints": "0,16 20,14 40,18 60,16 80,18 100,16 120,20 140,18 160,22 180,20 200,24",
"volume": "2.86M",
"exchName": "NASDAQ",
"exchUrl": "https://www.nasdaq.com/market-activity/stocks/nflx"
}
],
"newsPanel": {
"title": "Live signals & news",
"subtitle": "Filtered to your book"
},
"news": [
{
"time": "14:31",
"source": "AI · Quiver Research",
"headline": "▲ Hyperscaler capex tracker rolls forward — MSFT + META combined FY '25 guide now +34% YoY, reinforcing NVDA/SMCI thesis.",
"impactLabel": "+ Bull",
"impactClass": "pos"
},
{
"time": "14:18",
"source": "Bloomberg",
"headline": "Tesla cuts Model Y prices in China by 6% as BYD pressure intensifies in Q2 sales window.",
"impactLabel": "High risk",
"impactClass": "high"
},
{
"time": "13:54",
"source": "Reuters",
"headline": "FOMC minutes signal one more cut probable in Sept — yields fall 9 bps on the long end.",
"impactLabel": "+ Bull",
"impactClass": "pos"
},
{
"time": "13:30",
"source": "AI · Earnings whisper",
"headline": "CRWD whisper revised −2.1% after channel checks show enterprise renewal slippage.",
"impactLabel": "Watch",
"impactClass": "med"
},
{
"time": "12:48",
"source": "CNBC",
"headline": "Apple confirms Vision Pro 2 component orders for Q4 — 1.4M unit run rate implied.",
"impactLabel": "+ Bull",
"impactClass": "pos"
},
{
"time": "11:22",
"source": "AI · Anomaly detector",
"headline": "▲ SMCI dark-pool prints up 220% vs 30-day baseline — likely block accumulation.",
"impactLabel": "Watch",
"impactClass": "med"
}
],
"footer": {
"live": "Live data — refresh on demand",
"source": "Quotes via IEX Cloud · Robinhood + IBKR linked",
"version": "Quiver Live · v1.4.0"
}
}
@@ -0,0 +1,19 @@
{
"generatedAt": "2026-05-06T19:32:18.000Z",
"generatedBy": "agent",
"notes": "Personal trading portfolio dashboard — total value, today's P&L, featured ticker chart, holdings table, watchlist, news/signals, and an AI-generated investment recommendation. Designed to be refreshable on demand: price/holdings/news fields can be rewritten in data.json without re-authoring the template. All numbers, percentages, and SVG polyline points are pre-formatted into display-ready strings so the template stays free of formatting/expression logic; direction is encoded via an explicit dirClass field. External links (exchange, Yahoo, TradingView, per-row exchange page) are bound as plain attribute interpolations — no inline scripting. Refresh runner should write a fresh data.json that matches the template's binding shape; daemon validates against bounded JSON limits and re-derives index.html from template.html + data.json. No credentials, OAuth tokens, cookies, raw provider responses, or HTTP envelopes are persisted; forbidden keys (raw, payload, body, headers, cookie, authorization, token, secret, credential, password) are not present in data.json.",
"sources": [
{
"label": "Brokerage holdings (mocked)",
"type": "connector"
},
{
"label": "Quotes (mocked)",
"type": "connector"
},
{
"label": "AI recommendation",
"type": "derived"
}
]
}
@@ -0,0 +1,99 @@
# Live Artifact Schema Reference
Live artifacts are stored as daemon-owned project files under `.live-artifacts/<artifactId>/`. Agents author the source files, then register them through daemon tooling. The daemon assigns IDs, project scope, timestamps, run scope, and refresh status.
## Source files
| File | Owner | Purpose |
| --- | --- | --- |
| `artifact.json` | agent-authored input, daemon-validated | Artifact metadata, preview settings, document metadata, and source descriptors. Must not contain daemon-owned fields. |
| `template.html` | agent-authored | `html_template_v1` template used to render the preview. |
| `data.json` | agent-authored then refresh-runner-updated | Canonical preview data. API `document.dataJson` is only a derived cache. |
| `provenance.json` | agent-authored then refresh-runner-updated | Source summary and generation notes. |
| `index.html` | daemon-derived | Generated preview output. Do not treat as source of truth. |
## Create/update input
`artifact.json` should match `LiveArtifactCreateInput` or `LiveArtifactUpdateInput` from `packages/contracts/src/api/live-artifacts.ts`.
Allowed agent-owned top-level fields:
- `title`
- `slug`
- `sessionId`
- `pinned`
- `status`
- `preview`
- `document`
Daemon-owned fields are rejected in agent input:
- `id`
- `projectId`
- `createdAt`
- `updatedAt`
- `createdByRunId`
- `schemaVersion`
- `refreshStatus`
- `lastRefreshedAt`
## HTML document contract
MVP documents use `html_template_v1`:
```json
{
"format": "html_template_v1",
"templatePath": "template.html",
"generatedPreviewPath": "index.html",
"dataPath": "data.json",
"dataJson": {}
}
```
`template.html + data.json` is rendered by the daemon into `index.html` and the preview route.
### Binding rules
- Use escaped interpolation: `{{data.path.to.value}}`.
- Paths must start with `data` and use dot-separated keys; numeric array indexes are allowed as path segments.
- Supported structural directive: `data-od-repeat="item in data.items"` for one-level array repeats.
- Nested repeats, conditionals, filters, helper functions, partials, and expression evaluation are not supported.
- Raw HTML insertion is forbidden: no triple braces, ampersand interpolation, `data-od-html`, `data-od-raw`, or equivalent.
- Interpolation in text and ordinary attributes is HTML-escaped by default.
- Do not interpolate inside tag names, attribute names, comments, `<script>`, `<style>`, `<iframe srcdoc>`, event-handler attributes, or unsupported URL-bearing attributes.
## Bounded JSON limits
All persisted JSON values must fit the shared bounded JSON envelope:
| Limit | Value |
| --- | ---: |
| Maximum object/array depth | 8 |
| Maximum keys per object | 100 |
| Maximum array length | 500 |
| Maximum string length | 16 KiB |
| Maximum serialized JSON size | 256 KiB |
Forbidden keys anywhere in persisted JSON include `raw`, `rawResponse`, `payload`, `body`, `headers`, `cookie`, `authorization`, `token`, `secret`, `credential`, and `password`.
## Minimal static artifact input
```json
{
"title": "Release Status",
"preview": { "type": "html", "entry": "index.html" },
"document": {
"format": "html_template_v1",
"templatePath": "template.html",
"generatedPreviewPath": "index.html",
"dataPath": "data.json",
"dataJson": {
"summary": {
"title": "Release Status",
"status": "On track"
}
}
}
}
```
@@ -0,0 +1,119 @@
# Connector Policy Reference
Live artifacts may use connector or local data, but they must persist only compact, preview-oriented data and provenance. Never persist credentials or raw provider envelopes inside live artifact files.
## Connector safety model
Connector tools are classified by side effect and approval requirement:
- `read` + `auto`: eligible for agent preview and potential refresh.
- `write` + `confirm`: not refreshable; requires explicit user confirmation if exposed later.
- `destructive` + `disabled`: never refreshable.
- `unknown` + `confirm` or `disabled`: fail closed until classified.
If a tool name, scope, or description suggests write/create/update/delete/admin/send/post/manage behavior, treat it as write-capable unless the daemon catalog explicitly proves otherwise. Destructive hints must be disabled for refresh.
## Execution boundaries
- Use daemon wrapper commands or `/api/tools/connectors/*`; do not call provider APIs directly from the artifact workflow when a daemon connector exists.
- Tool endpoints require the injected `OD_TOOL_TOKEN`; do not invent or pass `projectId`.
- Agent calls and refresh-runner calls must share the same daemon connector execution service.
- Re-check connector status, allowlists, current scopes, tool safety, and refresh eligibility at execution time.
- For connector-backed refresh, saved `connectorId`, `accountLabel`, tool name, input shape, and approval policy must still match current connector state.
## Connector listing
List connectors before using connector-backed data:
```bash
"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact
```
The compact result includes each connector's `id`, display metadata, `status`, optional `accountLabel`, and callable tool summaries with `name`, `description`, `safety`, and `inputSchema`. Use this output to select a connector and tool; do not guess tool names.
Only execute tools from connectors whose status is `connected`. Local/public connectors may already be connected by the daemon; OAuth-backed connectors must be connected by the user through the UI before agent execution.
If the user already named a connector or app, treat that as the intended data source. For example, “create a Notion live artifact” means: list connectors, find `notion`, and if it is `connected`, use its read-only tools instead of asking where the Notion data comes from. Ask a follow-up only when the matching connector is missing/unconnected, when several connected matches are equally plausible, or when there is no searchable topic/page/database clue in the user’s request.
For Notion, prefer this selection order:
1. Use `notion.notion_search` with a concise query derived from the user’s requested artifact/topic.
2. Use `notion.notion_fetch_database` only when the user provided a database id or a prior search result identifies a specific database.
3. If the user simply says “Notion live artifact” with no topic, ask what Notion page/database/topic to visualize or whether to search broadly.
## Connector execution
Create a bounded JSON object input file that matches the selected tool's `inputSchema`, then execute through the wrapper:
```bash
"$OD_NODE_BIN" "$OD_BIN" tools connectors execute --connector "$CONNECTOR_ID" --tool "$TOOL_NAME" --input input.json
```
The wrapper reads `OD_NODE_BIN`, `OD_BIN`, `OD_DAEMON_URL`, and `OD_TOOL_TOKEN`, sends the request to `/api/tools/connectors/execute`, and prints compact JSON. Successful output includes `connectorId`, optional `accountLabel`, `toolName`, `safety`, `outputSummary`, redacted `output`, and daemon metadata. On failure, fix the input/schema/connection issue and retry; do not bypass connector validation with direct provider calls.
Execution is fail-closed:
- connector and tool IDs must be in the daemon catalog allowlist;
- the connector must still be connected and not disabled;
- current runtime safety must be `read` + `auto` for agent execution;
- input must match the current tool schema;
- run rate limits and total call limits apply;
- output is size-bounded and redacted before the agent receives it.
Use execution output as an intermediate source only. Normalize it into `data.json` and provenance, keeping only fields the preview needs.
## Read-only refresh rules
Connector-backed live artifact refresh is allowed only for tools that remain read-only and refresh-eligible at refresh time. A saved refresh source must include non-sensitive connector metadata and permission state, for example:
```json
{
"type": "connector_tool",
"toolName": "github.public_repo_summary",
"input": { "owner": "open-design", "repo": "open-design" },
"connector": {
"connectorId": "github_public",
"accountLabel": "public",
"toolName": "github.public_repo_summary"
},
"outputMapping": {
"dataPaths": [{ "from": "summary", "to": "repository" }],
"transform": "metric_summary"
},
"refreshPermission": "manual_refresh_granted_for_read_only"
}
```
During refresh, the daemon revalidates `connectorId`, `accountLabel`, tool name, saved input schema, and allowlist membership. If anything drifts, the refresh fails without changing the previous valid preview.
Never mark write, destructive, unknown, confirmation-required, disabled, unconnected, or schema-drifted connector tools as refreshable.
## Persistence rules
Persist only:
- compact normalized values needed by the preview in `data.json`;
- high-level provenance in `provenance.json`;
- connector references and refresh metadata in `sourceJson`.
Never persist:
- OAuth tokens, API keys, cookies, headers, authorization values, or session material;
- raw provider HTTP bodies, envelopes, payloads, or full responses;
- credential-like values under alternate names;
- connector credentials under `.live-artifacts/`.
Credential storage is daemon-controlled and outside project artifact directories. Artifacts may contain connector IDs and non-sensitive account labels only.
## Credential handling constraints
- Do not ask the user for connector secrets inside the artifact workflow.
- Do not ask the user to re-specify a data source that is already named and connected; inspect the connector catalog first.
- Do not write OAuth material, API keys, cookies, sessions, HTTP request metadata, or provider auth state into `artifact.json`, `data.json`, `provenance.json`, tile JSON, snapshots, refresh history, or `.live-artifacts/`.
- Do not include secret-like values in connector tool inputs or source metadata. If a connector requires credentials, the daemon-owned connector UI/storage must handle them outside project artifacts.
- Safe persisted connector references are limited to catalog IDs, tool names, non-sensitive account labels, selected normalized output fields, and concise provenance notes.
- If connector output contains unredacted sensitive or envelope-like fields, stop and return a validation/safety error instead of storing it.
## Output protection
Connector outputs must be bounded and redacted before returning to agents or entering artifact files. Use compact summaries and selected fields. If redaction cannot prove the result is safe, fail with a validation error instead of storing it.
@@ -0,0 +1,78 @@
# Refresh Contract Reference
Refresh updates live artifact data without redesigning the presentation. The refresh runner updates `data.json`, provenance, and audit history; it does not allow arbitrary template rewrites.
## Refreshable source metadata
Refreshable documents use `sourceJson`:
```json
{
"type": "connector_tool",
"toolName": "list_releases",
"input": {},
"connector": {
"connectorId": "github",
"accountLabel": "example/org",
"toolName": "list_releases"
},
"outputMapping": {
"dataPaths": [{ "from": "items", "to": "releases" }],
"transform": "compact_table"
},
"refreshPermission": "manual_refresh_granted_for_read_only"
}
```
Supported source types:
- `local_file`
- `daemon_tool`
- `connector_tool`
Supported output transforms:
- `identity`
- `compact_table`
- `metric_summary`
## Source execution model
- `refreshPermission` is retained for backward compatibility with older artifacts, but the refresh runner does not require a separate connector approval step.
- If a safe source descriptor exists, manual refresh executes it through daemon-owned local or connector wrappers.
- Write, destructive, unknown, disabled, unconnected, or schema-drifted connector tools should not be authored as refresh sources.
## Connector-backed refresh
Connector-backed refresh sources use the same connector execution service as agent-initiated connector calls. Do not call provider APIs directly from refresh logic or from skill-authored scripts.
Before creating a connector-backed refresh source:
1. List connectors with `"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact`.
2. If the user named a connector/source and it is connected, select that connector directly instead of asking where the source is. Then select a tool whose safety is `read` + `auto` and whose catalog metadata marks it refresh-eligible.
3. Execute once with `"$OD_NODE_BIN" "$OD_BIN" tools connectors execute --connector <id> --tool <name> --input input.json` to produce compact normalized preview data.
4. Store only non-sensitive connector references, the bounded input object, output mapping, and compatibility `refreshPermission` in `sourceJson`.
On each refresh, the daemon must re-check connector status, account label, allowlist membership, input schema, and output protection. If any check fails or output protection rejects the result, refresh fails all-or-nothing and preserves the previous valid preview.
Persisted connector refresh metadata may include `connectorId`, `toolName`, non-sensitive `accountLabel`, bounded `input`, `outputMapping`, and compatibility `refreshPermission`. It must not include credentials, auth/session material, raw provider envelopes, or unbounded provider responses.
## Commit behavior
Refresh is all-or-nothing:
1. Acquire one active refresh lock per artifact.
2. Execute each refreshable source with timeouts and current safety checks.
3. Build candidate `data.json`, provenance, and preview.
4. Validate all candidates with the same schemas used for create/update.
5. Commit only if every refreshable source succeeds.
6. Preserve the previous valid preview if any step fails.
Refresh IDs must be monotonic so stale runs cannot overwrite newer committed data.
## Audit storage
- Append compact records to `refreshes.jsonl`.
- Successful refresh snapshots live under `snapshots/<refreshId>/` and may include `data.json` and provenance.
- Failed refreshes are summarized in `refreshes.jsonl` without leaking raw provider output or credentials.
- On daemon startup, stale running refreshes should be marked failed or timed out while preserving the last valid preview.