mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(skills/live-artifact): add 7 example dashboards + contract demo (#716)
* feat(skills/live-artifact): add 7 example dashboards + contract demo
Seven self-contained HTML prototypes under skills/live-artifact/examples/,
each with a distinct visual identity and built-in interactivity for video
demos:
stock-dashboard.html - Bloomberg-style trading floor (dark)
crypto-dashboard.html - DeFi/web3 cyber terminal with on-chain ribbon
crm-table-live.html - multi-dim CRM with Grid/Kanban/Gallery/Calendar
view switcher (light productivity)
monday-operator-live.html - editorial Monday-morning briefing (paper)
competitor-radar-live.html - mission-control radar with rotating sweep
and RGB threat tiers
baby-health-live.html - soft pastel parental panel
stock-portfolio-live/ - full live-artifact contract example: 102
escaped html_template_v1 bindings + 7
data-od-repeat blocks, ready to register
via 'tools live-artifacts create'
Each interactive HTML carries refresh-with-flash, view switching, AI
panel regeneration, clickable rows/cards that mutate state, and toast
notifications. Self-contained - only Google Fonts as external dep.
stock-portfolio-live/ demonstrates the daemon contract: template.html +
data.json + artifact.json + provenance.json. Refresh runners can rewrite
data.json without re-authoring the template.
* fix(skills/live-artifact): address PR #716 review feedback
- Unroll data-od-repeat blocks into indexed data.* bindings so renderHtmlTemplateV1 can interpolate them (it does not expand data-od-repeat or repeat-local aliases like {{t.label}}).
- Rename catalysts[].body to catalysts[].text to satisfy the bounded JSON validator's forbidden-key list (body is rejected case-insensitively); update template binding accordingly.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
* fix(skills/live-artifact): make stock-portfolio provenance.json contract-compliant
- generatedBy: free-form string -> "agent" (LiveArtifactProvenanceGenerator enum)
- sources[].kind -> sources[].type with LiveArtifactProvenanceSourceType enum values
(connector for brokerage/quotes connectors, derived for AI recommendation)
- Drop non-contract per-source `note` and top-level `summary`/`transformations`/
`refreshContract`/`safetyNotes` fields; preserve their content under the
contract-allowed `notes` field so the example survives schema validation.
Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)
* fix(skills/live-artifact): use strict ISO-8601 generatedAt in provenance
The daemon's `validateIsoDate` requires `Date.toISOString()` round-trip
equality, so timezone-offset notation like `2026-05-06T14:32:18-05:00`
fails validation even though it parses. Switch to the canonical UTC form
`2026-05-06T19:32:18.000Z` (same instant), which the validator accepts.
* feat(skills): surface examples/*.html as derived skill cards + Live filter
A skill that ships hand-crafted samples under examples/*.html (e.g.
live-artifact's stock dashboard, baby health monitor) now lights up one
gallery card per file instead of a single parent card whose preview can
only ever show one of them. The parent stays in the listing tagged
aggregatesExamples=true so findSkillById and Use this prompt still
resolve back to its SKILL.md body, but the Examples tab hides it so the
derived <parent>:<child> cards aren't shadowed by a duplicate preview.
Subfolder layouts (examples/<name>/template.html + data.json) are
deliberately skipped — their templates still hold {{data.x}}
placeholders that only the daemon-side renderer fills in, so showing
the raw template would render visible braces in the gallery. Ship the
baked output as examples/<name>.html alongside the folder to surface it.
Adds an examples.modeLive filter pill (translated across all 21 locales)
that selects skill.scenario === 'live', so refreshable / connector-backed
samples are easy to find without scrolling through every desktop
prototype. live-artifact's SKILL.md gains scenario: live so it (and
every derived card) lights up there.
Co-authored-by: Cursor <cursoragent@cursor.com>
* perf(web): parallelize entry-view bootstrap so each tab renders independently
Bootstrap used to wait on a single Promise.all behind a global
'Loading workspace…' placeholder, which made the slowest endpoint
(typically /api/agents on cold start, since it probes CLI versions)
gate every tab including the ones that don't need agents at all.
Splits the global bootstrapping flag into per-resource loading flags
(agentsLoading, skillsLoading, dsLoading, projectsLoading,
promptTemplatesLoading) plus a daemonConfigLoaded flag for the merged
daemon config. Each tab now blocks only on the data it actually needs:
Examples renders as soon as skills land, Design Systems on dsList,
Designs on projects+skills+designSystems, etc.
Auto-selecting the first available agent and the default design system
moves into dedicated effects gated on daemonConfigLoaded so they no
longer race ahead of the daemon-stored choice and overwrite it with a
freshly picked first-available pick.
EntryView swaps its single loading prop for skillsLoading,
designSystemsLoading, projectsLoading, promptTemplatesLoading so each
inner tab can pick the right gate without leaking the parent's coarse
state.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9ed4ea1263
commit
1d1df52f3b
35 changed files with 10488 additions and 142 deletions
|
|
@ -30,7 +30,7 @@ import {
|
||||||
spawnEnvForAgent,
|
spawnEnvForAgent,
|
||||||
} from './agents.js';
|
} from './agents.js';
|
||||||
import { migrateLegacyDataDirSync } from './legacy-data-migrator.js';
|
import { migrateLegacyDataDirSync } from './legacy-data-migrator.js';
|
||||||
import { findSkillById, listSkills } from './skills.js';
|
import { findSkillById, listSkills, splitDerivedSkillId } from './skills.js';
|
||||||
import { validateLinkedDirs } from './linked-dirs.js';
|
import { validateLinkedDirs } from './linked-dirs.js';
|
||||||
import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js';
|
import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js';
|
||||||
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
||||||
|
|
@ -2663,18 +2663,56 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
||||||
// so we resolve the actual directory via listSkills() rather than guessing.
|
// so we resolve the actual directory via listSkills() rather than guessing.
|
||||||
//
|
//
|
||||||
// Resolution order:
|
// Resolution order:
|
||||||
// 1. <skillDir>/example.html — fully-baked static example (preferred)
|
// 1. Derived id (`<parent>:<child>`):
|
||||||
// 2. <skillDir>/assets/template.html +
|
// <parentDir>/examples/<child>.html — pre-baked single-file sample.
|
||||||
|
// Subfolder layouts (e.g. live-artifact's
|
||||||
|
// `examples/<name>/template.html`) are intentionally not served:
|
||||||
|
// they still contain `{{data.x}}` placeholders that only the
|
||||||
|
// daemon-side renderer fills in, and serving the raw template
|
||||||
|
// would render visible placeholder braces in the gallery.
|
||||||
|
// 2. <skillDir>/example.html — fully-baked static example (preferred)
|
||||||
|
// 3. <skillDir>/assets/template.html +
|
||||||
// <skillDir>/assets/example-slides.html — assemble at request time
|
// <skillDir>/assets/example-slides.html — assemble at request time
|
||||||
// by replacing the `<!-- SLIDES_HERE -->` marker with the snippet
|
// by replacing the `<!-- SLIDES_HERE -->` marker with the snippet
|
||||||
// and patching the placeholder <title>. Lets a skill ship one
|
// and patching the placeholder <title>. Lets a skill ship one
|
||||||
// canonical seed plus a small content fragment, so the example
|
// canonical seed plus a small content fragment, so the example
|
||||||
// never drifts from the seed.
|
// never drifts from the seed.
|
||||||
// 3. <skillDir>/assets/template.html — raw template, no content slides
|
// 4. <skillDir>/assets/template.html — raw template, no content slides
|
||||||
// 4. <skillDir>/assets/index.html — generic fallback
|
// 5. <skillDir>/assets/index.html — generic fallback
|
||||||
|
// 6. First .html in <skillDir>/examples/ — used as a friendly fallback
|
||||||
|
// so a skill that aggregates examples (like live-artifact) still has
|
||||||
|
// a real preview on its parent card instead of returning 404.
|
||||||
app.get('/api/skills/:id/example', async (req, res) => {
|
app.get('/api/skills/:id/example', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const skills = await listSkills(SKILLS_DIR);
|
const skills = await listSkills(SKILLS_DIR);
|
||||||
|
|
||||||
|
// 1. Derived `<parent>:<child>` id — resolve straight to the matching
|
||||||
|
// file under <parentDir>/examples/. Done before findSkillById so the
|
||||||
|
// parent's normal fallback chain never accidentally serves a stale
|
||||||
|
// file when a sample is missing (we'd rather 404 explicitly).
|
||||||
|
const derived = splitDerivedSkillId(req.params.id);
|
||||||
|
if (derived) {
|
||||||
|
const parent = findSkillById(skills, derived.parentId);
|
||||||
|
if (!parent) {
|
||||||
|
return res.status(404).type('text/plain').send('skill not found');
|
||||||
|
}
|
||||||
|
const candidate = path.join(
|
||||||
|
parent.dir,
|
||||||
|
'examples',
|
||||||
|
`${derived.childKey}.html`,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
const html = await fs.promises.readFile(candidate, 'utf8');
|
||||||
|
return res
|
||||||
|
.type('text/html')
|
||||||
|
.send(rewriteSkillAssetUrls(html, parent.id));
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.type('text/plain')
|
||||||
|
.send('derived example not found');
|
||||||
|
}
|
||||||
|
|
||||||
const skill = findSkillById(skills, req.params.id);
|
const skill = findSkillById(skills, req.params.id);
|
||||||
if (!skill) {
|
if (!skill) {
|
||||||
return res.status(404).type('text/plain').send('skill not found');
|
return res.status(404).type('text/plain').send('skill not found');
|
||||||
|
|
@ -2715,11 +2753,44 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
||||||
.type('text/html')
|
.type('text/html')
|
||||||
.send(rewriteSkillAssetUrls(html, skill.id));
|
.send(rewriteSkillAssetUrls(html, skill.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Friendly fallback for skills that aggregate examples in a sibling
|
||||||
|
// `examples/` folder (e.g. live-artifact). The parent card would
|
||||||
|
// otherwise 404 even though plenty of perfectly valid samples ship
|
||||||
|
// alongside SKILL.md; pick the first .html file alphabetically so
|
||||||
|
// direct URL access (e.g. deep links) shows something representative.
|
||||||
|
// Subfolder layouts are excluded for the same reason as the derived
|
||||||
|
// resolver above — their `template.html` still has unresolved
|
||||||
|
// `{{data.x}}` placeholders.
|
||||||
|
const examplesDir = path.join(skill.dir, 'examples');
|
||||||
|
if (fs.existsSync(examplesDir)) {
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.promises.readdir(examplesDir);
|
||||||
|
} catch {
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
entries.sort();
|
||||||
|
for (const name of entries) {
|
||||||
|
if (name.startsWith('.')) continue;
|
||||||
|
if (!name.toLowerCase().endsWith('.html')) continue;
|
||||||
|
const direct = path.join(examplesDir, name);
|
||||||
|
try {
|
||||||
|
const html = await fs.promises.readFile(direct, 'utf8');
|
||||||
|
return res
|
||||||
|
.type('text/html')
|
||||||
|
.send(rewriteSkillAssetUrls(html, skill.id));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res
|
res
|
||||||
.status(404)
|
.status(404)
|
||||||
.type('text/plain')
|
.type('text/plain')
|
||||||
.send(
|
.send(
|
||||||
'no example.html, assets/template.html, or assets/index.html for this skill',
|
'no example.html, assets/template.html, assets/index.html, or examples/*.html for this skill',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).type('text/plain').send(String(err));
|
res.status(500).type('text/plain').send(String(err));
|
||||||
|
|
|
||||||
|
|
@ -58,26 +58,45 @@ export async function listSkills(skillsRoot) {
|
||||||
const hasAttachments = await dirHasAttachments(dir);
|
const hasAttachments = await dirHasAttachments(dir);
|
||||||
const mode = data.od?.mode || inferMode(body, data.description);
|
const mode = data.od?.mode || inferMode(body, data.description);
|
||||||
const surface = normalizeSurface(data.od?.surface, mode);
|
const surface = normalizeSurface(data.od?.surface, mode);
|
||||||
|
const platform = normalizePlatform(
|
||||||
|
data.od?.platform,
|
||||||
|
mode,
|
||||||
|
body,
|
||||||
|
data.description
|
||||||
|
);
|
||||||
|
const scenario = normalizeScenario(
|
||||||
|
data.od?.scenario,
|
||||||
|
body,
|
||||||
|
data.description
|
||||||
|
);
|
||||||
|
const designSystemRequired = data.od?.design_system?.requires ?? true;
|
||||||
|
const upstream =
|
||||||
|
typeof data.od?.upstream === "string" ? data.od.upstream : null;
|
||||||
|
const previewType = data.od?.preview?.type || "html";
|
||||||
|
const parentId = data.name || entry.name;
|
||||||
|
const parentBody = hasAttachments ? withSkillRootPreamble(body, dir) : body;
|
||||||
|
// Pre-compute derived examples so the parent entry can advertise
|
||||||
|
// `aggregatesExamples` in the same push. The frontend uses that
|
||||||
|
// flag to hide the parent card from the gallery (its preview would
|
||||||
|
// duplicate one of the derived cards), while the daemon keeps the
|
||||||
|
// parent in the listing so `findSkillById` still resolves it for
|
||||||
|
// system-prompt composition and id alias lookups.
|
||||||
|
const derivedExamples = await collectDerivedExamples(dir);
|
||||||
|
const aggregatesExamples = derivedExamples.length > 0;
|
||||||
out.push({
|
out.push({
|
||||||
id: data.name || entry.name,
|
id: parentId,
|
||||||
name: data.name || entry.name,
|
name: parentId,
|
||||||
description: data.description || "",
|
description: data.description || "",
|
||||||
triggers: Array.isArray(data.triggers) ? data.triggers : [],
|
triggers: Array.isArray(data.triggers) ? data.triggers : [],
|
||||||
mode,
|
mode,
|
||||||
surface,
|
surface,
|
||||||
craftRequires: normalizeCraftRequires(data.od?.craft?.requires),
|
craftRequires: normalizeCraftRequires(data.od?.craft?.requires),
|
||||||
platform: normalizePlatform(
|
platform,
|
||||||
data.od?.platform,
|
scenario,
|
||||||
mode,
|
previewType,
|
||||||
body,
|
designSystemRequired,
|
||||||
data.description
|
|
||||||
),
|
|
||||||
scenario: normalizeScenario(data.od?.scenario, body, data.description),
|
|
||||||
previewType: data.od?.preview?.type || "html",
|
|
||||||
designSystemRequired: data.od?.design_system?.requires ?? true,
|
|
||||||
defaultFor: normalizeDefaultFor(data.od?.default_for),
|
defaultFor: normalizeDefaultFor(data.od?.default_for),
|
||||||
upstream:
|
upstream,
|
||||||
typeof data.od?.upstream === "string" ? data.od.upstream : null,
|
|
||||||
featured: normalizeFeatured(data.od?.featured),
|
featured: normalizeFeatured(data.od?.featured),
|
||||||
// Optional metadata hints used by 'Use this prompt' fast-create so
|
// Optional metadata hints used by 'Use this prompt' fast-create so
|
||||||
// the resulting project mirrors the shipped example.html. Each hint
|
// the resulting project mirrors the shipped example.html. Each hint
|
||||||
|
|
@ -87,9 +106,49 @@ export async function listSkills(skillsRoot) {
|
||||||
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
|
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
|
||||||
animations: normalizeBoolHint(data.od?.animations),
|
animations: normalizeBoolHint(data.od?.animations),
|
||||||
examplePrompt: derivePrompt(data),
|
examplePrompt: derivePrompt(data),
|
||||||
body: hasAttachments ? withSkillRootPreamble(body, dir) : body,
|
aggregatesExamples,
|
||||||
|
body: parentBody,
|
||||||
dir,
|
dir,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Surface every example sitting next to a SKILL.md as its own card so
|
||||||
|
// a single skill (e.g. live-artifact) can ship a small gallery of
|
||||||
|
// hand-crafted samples without needing one SKILL.md per sample. Each
|
||||||
|
// derived card inherits the parent's mode/platform/surface/scenario
|
||||||
|
// so existing TYPE/SURFACE filters keep working; the synthetic id
|
||||||
|
// `<parent>:<child>` lets `/api/skills/:id/example` resolve straight
|
||||||
|
// to the matching HTML on disk. We deliberately do not inherit
|
||||||
|
// `featured` so derived cards never crowd the magazine row.
|
||||||
|
for (const example of derivedExamples) {
|
||||||
|
out.push({
|
||||||
|
id: `${parentId}:${example.key}`,
|
||||||
|
name: humanizeExampleName(example.key),
|
||||||
|
description: data.description || "",
|
||||||
|
triggers: Array.isArray(data.triggers) ? data.triggers : [],
|
||||||
|
mode,
|
||||||
|
surface,
|
||||||
|
craftRequires: [],
|
||||||
|
platform,
|
||||||
|
scenario,
|
||||||
|
previewType,
|
||||||
|
designSystemRequired,
|
||||||
|
defaultFor: [],
|
||||||
|
upstream,
|
||||||
|
featured: null,
|
||||||
|
fidelity: normalizeFidelity(data.od?.fidelity),
|
||||||
|
speakerNotes: normalizeBoolHint(data.od?.speaker_notes),
|
||||||
|
animations: normalizeBoolHint(data.od?.animations),
|
||||||
|
examplePrompt: derivePrompt(data),
|
||||||
|
aggregatesExamples: false,
|
||||||
|
// Inherit the parent's full SKILL.md body so 'Use this prompt'
|
||||||
|
// on a derived card seeds the agent with the same workflow the
|
||||||
|
// parent describes. Without this, picking a derived card would
|
||||||
|
// compose an empty system prompt and the agent would have no
|
||||||
|
// skill instructions.
|
||||||
|
body: parentBody,
|
||||||
|
dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip unreadable entries — this is discovery, not validation.
|
// Skip unreadable entries — this is discovery, not validation.
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +156,87 @@ export async function listSkills(skillsRoot) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discover example artifacts that live alongside SKILL.md under
|
||||||
|
// `<dir>/examples/`. Only the single-file layout is surfaced:
|
||||||
|
//
|
||||||
|
// `examples/<name>.html` — pre-baked, self-contained sample.
|
||||||
|
//
|
||||||
|
// We deliberately do not surface the subfolder layout (e.g. live-artifact's
|
||||||
|
// `examples/<name>/template.html` + `data.json`) because those templates
|
||||||
|
// still hold `{{data.x}}` placeholders that only the daemon-side renderer
|
||||||
|
// fills in. Showing the raw template would render visible placeholder
|
||||||
|
// braces in the gallery — worse than not surfacing the example at all.
|
||||||
|
// To ship a subfolder-style example, place the baked output beside the
|
||||||
|
// folder as `examples/<name>.html` (the canonical render) and keep the
|
||||||
|
// subfolder around as agent-readable source.
|
||||||
|
async function collectDerivedExamples(dir) {
|
||||||
|
const examplesDir = path.join(dir, "examples");
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = await readdir(examplesDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
if (!entry.name.toLowerCase().endsWith(".html")) continue;
|
||||||
|
const key = entry.name.replace(/\.html$/i, "");
|
||||||
|
if (!isSafeExampleKey(key)) continue;
|
||||||
|
out.push({ key });
|
||||||
|
}
|
||||||
|
// Stable order so the gallery renders the same sequence on every reload.
|
||||||
|
out.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject keys that could escape the examples folder or break the
|
||||||
|
// `<parent>:<child>` id format. Letters/digits/dash/dot/underscore only,
|
||||||
|
// and never the dotfile path-traversal patterns.
|
||||||
|
function isSafeExampleKey(key) {
|
||||||
|
if (!key || key.startsWith(".")) return false;
|
||||||
|
if (key.includes(":")) return false;
|
||||||
|
return /^[A-Za-z0-9._-]+$/.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn a basename like `stock-portfolio-live` into a title-cased label
|
||||||
|
// (`Stock Portfolio Live`) so the gallery card has a readable heading
|
||||||
|
// without forcing every example to ship its own frontmatter.
|
||||||
|
function humanizeExampleName(key) {
|
||||||
|
return key
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.split(" ")
|
||||||
|
.map((word) =>
|
||||||
|
word.length === 0
|
||||||
|
? word
|
||||||
|
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by `/api/skills/:id/example` to resolve a derived id back to its
|
||||||
|
// on-disk file. Returns null when the key is unsafe; the route checks
|
||||||
|
// `fs.existsSync` against the returned path before reading.
|
||||||
|
export function resolveDerivedExamplePath(parentDir, childKey) {
|
||||||
|
if (!isSafeExampleKey(childKey)) return null;
|
||||||
|
return path.join(parentDir, "examples", `${childKey}.html`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a `<parent>:<child>` synthetic id into its two halves. Returns
|
||||||
|
// null for non-derived ids so the caller can fall through to the regular
|
||||||
|
// listing-based lookup.
|
||||||
|
export function splitDerivedSkillId(id) {
|
||||||
|
if (typeof id !== "string") return null;
|
||||||
|
const idx = id.indexOf(":");
|
||||||
|
if (idx <= 0 || idx === id.length - 1) return null;
|
||||||
|
const parentId = id.slice(0, idx);
|
||||||
|
const childKey = id.slice(idx + 1);
|
||||||
|
if (!isSafeExampleKey(childKey)) return null;
|
||||||
|
return { parentId, childKey };
|
||||||
|
}
|
||||||
|
|
||||||
// Skills that ship side files (e.g. `assets/template.html`, `references/*.md`)
|
// Skills that ship side files (e.g. `assets/template.html`, `references/*.md`)
|
||||||
// need the agent to know where the skill lives on disk — relative paths in the
|
// need the agent to know where the skill lives on disk — relative paths in the
|
||||||
// SKILL.md body would otherwise resolve against the agent's CWD, which is the
|
// SKILL.md body would otherwise resolve against the agent's CWD, which is the
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,21 @@ export function App() {
|
||||||
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(
|
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
// Goes false once the bootstrap effect has finished its initial round of
|
// Per-resource loading flags. Each goes false the moment its own fetch
|
||||||
// fetches. The entry view uses this to show shimmer / skeleton states
|
// resolves so each entry-view tab can render as its data lands instead of
|
||||||
// instead of an "empty" page that flickers before data lands.
|
// every tab waiting on the slowest endpoint (typically `/api/agents`,
|
||||||
const [bootstrapping, setBootstrapping] = useState(true);
|
// which probes CLI versions and can take seconds on cold start). The entry
|
||||||
|
// view picks the right flag for whichever tab the user is currently on.
|
||||||
|
const [agentsLoading, setAgentsLoading] = useState(true);
|
||||||
|
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||||
|
const [dsLoading, setDsLoading] = useState(true);
|
||||||
|
const [projectsLoading, setProjectsLoading] = useState(true);
|
||||||
|
const [promptTemplatesLoading, setPromptTemplatesLoading] = useState(true);
|
||||||
|
// Goes true once the daemon-persisted config (agentId/designSystemId/etc.)
|
||||||
|
// has merged into local state. Auto-selection effects below wait on this
|
||||||
|
// so they don't race ahead of the daemon-stored choice and overwrite it
|
||||||
|
// with a freshly picked first-available agent.
|
||||||
|
const [daemonConfigLoaded, setDaemonConfigLoaded] = useState(false);
|
||||||
// Narrower flag dedicated to the Composio API key hydration. The key is
|
// Narrower flag dedicated to the Composio API key hydration. The key is
|
||||||
// persisted by the daemon (and only reflected back via apiKeyConfigured
|
// persisted by the daemon (and only reflected back via apiKeyConfigured
|
||||||
// + apiKeyTail), so after a dev-server restart there is a window where
|
// + apiKeyTail), so after a dev-server restart there is a window where
|
||||||
|
|
@ -173,100 +184,158 @@ export function App() {
|
||||||
});
|
});
|
||||||
}, [activeProjectId, activeFileName]);
|
}, [activeProjectId, activeFileName]);
|
||||||
|
|
||||||
// Bootstrap — detect daemon, load pickers, seed sensible defaults.
|
// Bootstrap — detect daemon, then fan out independent fetches so each
|
||||||
|
// entry-view tab can render the moment its own data lands. Earlier this
|
||||||
|
// was one Promise.all behind a global "Loading workspace…" placeholder,
|
||||||
|
// which made the slowest endpoint (typically `/api/agents` on cold start)
|
||||||
|
// gate every tab including the ones that don't need agents at all.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const alive = await daemonIsLive();
|
const alive = await daemonIsLive();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setDaemonLive(alive);
|
setDaemonLive(alive);
|
||||||
const [
|
|
||||||
agentList,
|
|
||||||
skillList,
|
|
||||||
dsList,
|
|
||||||
projectList,
|
|
||||||
templateList,
|
|
||||||
promptTemplateList,
|
|
||||||
versionInfo,
|
|
||||||
daemonConfig,
|
|
||||||
daemonComposioConfig,
|
|
||||||
] = await Promise.all([
|
|
||||||
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
|
|
||||||
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
|
|
||||||
alive
|
|
||||||
? fetchDesignSystems()
|
|
||||||
: Promise.resolve([] as DesignSystemSummary[]),
|
|
||||||
alive ? listProjects() : Promise.resolve([] as Project[]),
|
|
||||||
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
|
|
||||||
alive
|
|
||||||
? fetchPromptTemplates()
|
|
||||||
: Promise.resolve([] as PromptTemplateSummary[]),
|
|
||||||
alive ? fetchAppVersionInfo() : Promise.resolve(null),
|
|
||||||
alive ? fetchDaemonConfig() : Promise.resolve(null),
|
|
||||||
alive ? fetchComposioConfigFromDaemon() : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
if (cancelled) return;
|
|
||||||
setAgents(agentList);
|
|
||||||
setSkills(skillList);
|
|
||||||
setDesignSystems(dsList);
|
|
||||||
setProjects(projectList);
|
|
||||||
setTemplates(templateList);
|
|
||||||
setPromptTemplates(promptTemplateList);
|
|
||||||
setAppVersionInfo(versionInfo);
|
|
||||||
|
|
||||||
setConfig((prev) => {
|
if (!alive) {
|
||||||
// Merge daemon-persisted config — daemon values win for the fields
|
// No daemon — clear every loading flag so empty states render
|
||||||
// it tracks so that the choice survives origin/storage resets.
|
// instead of the entry view sitting on indefinite spinners.
|
||||||
const next = mergeDaemonConfig(prev, daemonConfig);
|
setAgentsLoading(false);
|
||||||
|
setSkillsLoading(false);
|
||||||
|
setDsLoading(false);
|
||||||
|
setProjectsLoading(false);
|
||||||
|
setPromptTemplatesLoading(false);
|
||||||
|
setDaemonConfigLoaded(true);
|
||||||
|
// Composio hydration also depends on the daemon. With no daemon
|
||||||
|
// we just keep whatever localStorage already held; drop the
|
||||||
|
// skeleton so the Settings → Connectors input reflects state.
|
||||||
|
setComposioConfigLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (alive) {
|
void fetchAgents().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAgents(list);
|
||||||
|
setAgentsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void fetchSkills().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSkills(list);
|
||||||
|
setSkillsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void fetchDesignSystems().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDesignSystems(list);
|
||||||
|
setDsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void listProjects().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setProjects(list);
|
||||||
|
setProjectsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void listTemplates().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTemplates(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
void fetchPromptTemplates().then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPromptTemplates(list);
|
||||||
|
setPromptTemplatesLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void fetchAppVersionInfo().then((info) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAppVersionInfo(info);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Daemon-persisted config + composio config land together so the
|
||||||
|
// welcome-modal decision and the daemon-side composio key both apply
|
||||||
|
// in one merge, avoiding a flash where local-only state is shown
|
||||||
|
// before daemon overrides it.
|
||||||
|
void Promise.all([
|
||||||
|
fetchDaemonConfig(),
|
||||||
|
fetchComposioConfigFromDaemon(),
|
||||||
|
]).then(([daemonConfig, daemonComposioConfig]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = mergeDaemonConfig(prev, daemonConfig);
|
||||||
const hasLocalComposioKey = Boolean(next.composio?.apiKey?.trim());
|
const hasLocalComposioKey = Boolean(next.composio?.apiKey?.trim());
|
||||||
if (!hasLocalComposioKey && daemonComposioConfig) {
|
if (!hasLocalComposioKey && daemonComposioConfig) {
|
||||||
next.composio = daemonComposioConfig;
|
next.composio = daemonComposioConfig;
|
||||||
}
|
}
|
||||||
if (!next.agentId) {
|
saveConfig(next);
|
||||||
const firstAvailable = agentList.find((a) => a.available);
|
if (hasAnyConfiguredProvider(next.mediaProviders)) {
|
||||||
if (firstAvailable) next.agentId = firstAvailable.id;
|
void syncMediaProvidersToDaemon(next.mediaProviders);
|
||||||
}
|
}
|
||||||
if (!next.designSystemId && dsList.length > 0) {
|
// Migrate localStorage prefs to daemon on first boot with the new
|
||||||
next.designSystemId =
|
// endpoint. If daemon already had values the merge above used
|
||||||
dsList.find((d) => d.id === 'default')?.id ?? dsList[0]!.id;
|
// them; writing back is idempotent and keeps both sides in sync.
|
||||||
}
|
|
||||||
}
|
|
||||||
saveConfig(next);
|
|
||||||
if (alive && hasAnyConfiguredProvider(next.mediaProviders)) {
|
|
||||||
void syncMediaProvidersToDaemon(next.mediaProviders);
|
|
||||||
}
|
|
||||||
// Migrate localStorage prefs to daemon on first boot with the new
|
|
||||||
// endpoint. If daemon already had values the merge above used them;
|
|
||||||
// writing back is idempotent and ensures both sides stay in sync.
|
|
||||||
if (alive) {
|
|
||||||
void syncConfigToDaemon(next);
|
void syncConfigToDaemon(next);
|
||||||
void syncComposioConfigToDaemon(next.composio);
|
void syncComposioConfigToDaemon(next.composio);
|
||||||
}
|
|
||||||
|
|
||||||
// Pop the onboarding modal only on the first run. Once the user has
|
// Pop the onboarding modal only on the first run. Once the user
|
||||||
// saved or skipped past it once, we trust their stored config and
|
// has saved or skipped past it once, we trust their stored config
|
||||||
// let them re-open Settings explicitly via the env pill.
|
// and let them re-open Settings explicitly via the env pill.
|
||||||
if (!next.onboardingCompleted) {
|
if (!next.onboardingCompleted) {
|
||||||
setSettingsWelcome(true);
|
setSettingsWelcome(true);
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
});
|
||||||
|
setDaemonConfigLoaded(true);
|
||||||
|
// Composio key hydration is part of this same daemon-config
|
||||||
|
// fetch — by the time we land here the daemon has either
|
||||||
|
// returned the saved-key shape (apiKeyConfigured + tail) or
|
||||||
|
// it errored and we kept whatever localStorage held. Either
|
||||||
|
// way it is safe to drop the skeleton.
|
||||||
|
setComposioConfigLoading(false);
|
||||||
});
|
});
|
||||||
setBootstrapping(false);
|
|
||||||
// Composio hydration is part of the same Promise.all above — by the
|
|
||||||
// time we land here either the daemon returned the saved-key shape
|
|
||||||
// (apiKeyConfigured + tail) or the daemon was offline and we kept
|
|
||||||
// whatever localStorage held. Either way it is safe to drop the
|
|
||||||
// skeleton: the input now reflects the source of truth.
|
|
||||||
setComposioConfigLoading(false);
|
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-pick the first available agent once both the daemon-stored config
|
||||||
|
// and the agents listing have landed. Splitting this out of bootstrap
|
||||||
|
// avoids racing the local-config initial value against a slow agents
|
||||||
|
// probe — by the time this runs, daemonConfig has already overlaid the
|
||||||
|
// user's previous choice, so we only fill an empty slot.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!daemonConfigLoaded || agentsLoading) return;
|
||||||
|
if (config.agentId) return;
|
||||||
|
const firstAvailable = agents.find((a) => a.available);
|
||||||
|
if (!firstAvailable) return;
|
||||||
|
setConfig((prev) => {
|
||||||
|
if (prev.agentId) return prev;
|
||||||
|
const next: AppConfig = { ...prev, agentId: firstAvailable.id };
|
||||||
|
saveConfig(next);
|
||||||
|
void syncConfigToDaemon(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [daemonConfigLoaded, agentsLoading, agents, config.agentId]);
|
||||||
|
|
||||||
|
// Auto-pick the default design system the same way — only after daemon
|
||||||
|
// config has merged so we never overwrite a daemon-stored selection.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!daemonConfigLoaded || dsLoading) return;
|
||||||
|
if (config.designSystemId) return;
|
||||||
|
if (designSystems.length === 0) return;
|
||||||
|
const id =
|
||||||
|
designSystems.find((d) => d.id === 'default')?.id ?? designSystems[0]!.id;
|
||||||
|
setConfig((prev) => {
|
||||||
|
if (prev.designSystemId) return prev;
|
||||||
|
const next: AppConfig = { ...prev, designSystemId: id };
|
||||||
|
saveConfig(next);
|
||||||
|
void syncConfigToDaemon(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [daemonConfigLoaded, dsLoading, designSystems, config.designSystemId]);
|
||||||
|
|
||||||
// One-shot self-healing migration for pets adopted before the
|
// One-shot self-healing migration for pets adopted before the
|
||||||
// overlay learned atlas-row switching. If the stored pet is a
|
// overlay learned atlas-row switching. If the stored pet is a
|
||||||
// custom / codex pet whose imageUrl is a single-row strip
|
// custom / codex pet whose imageUrl is a single-row strip
|
||||||
|
|
@ -658,7 +727,10 @@ export function App() {
|
||||||
defaultDesignSystemId={config.designSystemId}
|
defaultDesignSystemId={config.designSystemId}
|
||||||
config={config}
|
config={config}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
loading={bootstrapping}
|
skillsLoading={skillsLoading}
|
||||||
|
designSystemsLoading={dsLoading}
|
||||||
|
projectsLoading={projectsLoading}
|
||||||
|
promptTemplatesLoading={promptTemplatesLoading}
|
||||||
onCreateProject={handleCreateProject}
|
onCreateProject={handleCreateProject}
|
||||||
onImportClaudeDesign={handleImportClaudeDesign}
|
onImportClaudeDesign={handleImportClaudeDesign}
|
||||||
onImportFolder={handleImportFolder}
|
onImportFolder={handleImportFolder}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,15 @@ interface Props {
|
||||||
defaultDesignSystemId: string | null;
|
defaultDesignSystemId: string | null;
|
||||||
config: AppConfig;
|
config: AppConfig;
|
||||||
agents: AgentInfo[];
|
agents: AgentInfo[];
|
||||||
loading?: boolean;
|
// Per-resource loading flags. Each tab gates its own content on whichever
|
||||||
|
// flag matches the data it renders, so a slow `/api/agents` probe does
|
||||||
|
// not block tabs that don't need agents. Templates are not gated here —
|
||||||
|
// the sidebar 'From template' tab renders an empty state until they
|
||||||
|
// arrive (fast fetch), which keeps the prop surface narrower.
|
||||||
|
skillsLoading?: boolean;
|
||||||
|
designSystemsLoading?: boolean;
|
||||||
|
projectsLoading?: boolean;
|
||||||
|
promptTemplatesLoading?: boolean;
|
||||||
onCreateProject: (input: CreateInput & { pendingPrompt?: string }) => void;
|
onCreateProject: (input: CreateInput & { pendingPrompt?: string }) => void;
|
||||||
onImportClaudeDesign: (file: File) => Promise<void> | void;
|
onImportClaudeDesign: (file: File) => Promise<void> | void;
|
||||||
onImportFolder?: (baseDir: string) => Promise<void> | void;
|
onImportFolder?: (baseDir: string) => Promise<void> | void;
|
||||||
|
|
@ -213,7 +221,10 @@ export function EntryView({
|
||||||
defaultDesignSystemId,
|
defaultDesignSystemId,
|
||||||
config,
|
config,
|
||||||
agents,
|
agents,
|
||||||
loading = false,
|
skillsLoading = false,
|
||||||
|
designSystemsLoading = false,
|
||||||
|
projectsLoading = false,
|
||||||
|
promptTemplatesLoading = false,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
onImportClaudeDesign,
|
onImportClaudeDesign,
|
||||||
onImportFolder,
|
onImportFolder,
|
||||||
|
|
@ -469,7 +480,7 @@ export function EntryView({
|
||||||
connectors={connectors}
|
connectors={connectors}
|
||||||
connectorsLoading={connectorsLoading}
|
connectorsLoading={connectorsLoading}
|
||||||
onOpenConnectorsTab={() => onOpenSettings('composio')}
|
onOpenConnectorsTab={() => onOpenSettings('composio')}
|
||||||
loading={loading}
|
loading={skillsLoading || designSystemsLoading}
|
||||||
/>
|
/>
|
||||||
<div className="entry-side-foot">
|
<div className="entry-side-foot">
|
||||||
<button
|
<button
|
||||||
|
|
@ -565,47 +576,65 @@ export function EntryView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="entry-tab-content">
|
<div className="entry-tab-content">
|
||||||
{loading ? (
|
{topTab === 'designs' ? (
|
||||||
<CenteredLoader label={t('entry.loadingWorkspace')} />
|
// DesignsTab uses skills + designSystems for tag rendering on
|
||||||
) : (
|
// each card, so wait until projects + that metadata are present
|
||||||
<>
|
// to avoid a flash of "No projects yet" before the real list
|
||||||
{topTab === 'designs' ? (
|
// arrives.
|
||||||
<DesignsTab
|
projectsLoading || skillsLoading || designSystemsLoading ? (
|
||||||
projects={projects}
|
<CenteredLoader label={t('common.loading')} />
|
||||||
skills={skills}
|
) : (
|
||||||
designSystems={designSystems}
|
<DesignsTab
|
||||||
onOpen={onOpenProject}
|
projects={projects}
|
||||||
onOpenLiveArtifact={onOpenLiveArtifact}
|
skills={skills}
|
||||||
onDelete={onDeleteProject}
|
designSystems={designSystems}
|
||||||
/>
|
onOpen={onOpenProject}
|
||||||
) : null}
|
onOpenLiveArtifact={onOpenLiveArtifact}
|
||||||
{topTab === 'examples' ? (
|
onDelete={onDeleteProject}
|
||||||
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
|
/>
|
||||||
) : null}
|
)
|
||||||
{topTab === 'design-systems' ? (
|
) : null}
|
||||||
<DesignSystemsTab
|
{topTab === 'examples' ? (
|
||||||
systems={designSystems}
|
skillsLoading ? (
|
||||||
selectedId={defaultDesignSystemId}
|
<CenteredLoader label={t('common.loading')} />
|
||||||
onSelect={onChangeDefaultDesignSystem}
|
) : (
|
||||||
onPreview={previewDesignSystem}
|
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
|
||||||
/>
|
)
|
||||||
) : null}
|
) : null}
|
||||||
{topTab === 'image-templates' ? (
|
{topTab === 'design-systems' ? (
|
||||||
<PromptTemplatesTab
|
designSystemsLoading ? (
|
||||||
surface="image"
|
<CenteredLoader label={t('common.loading')} />
|
||||||
templates={promptTemplates}
|
) : (
|
||||||
onPreview={setPreviewPromptTemplate}
|
<DesignSystemsTab
|
||||||
/>
|
systems={designSystems}
|
||||||
) : null}
|
selectedId={defaultDesignSystemId}
|
||||||
{topTab === 'video-templates' ? (
|
onSelect={onChangeDefaultDesignSystem}
|
||||||
<PromptTemplatesTab
|
onPreview={previewDesignSystem}
|
||||||
surface="video"
|
/>
|
||||||
templates={promptTemplates}
|
)
|
||||||
onPreview={setPreviewPromptTemplate}
|
) : null}
|
||||||
/>
|
{topTab === 'image-templates' ? (
|
||||||
) : null}
|
promptTemplatesLoading ? (
|
||||||
</>
|
<CenteredLoader label={t('common.loading')} />
|
||||||
)}
|
) : (
|
||||||
|
<PromptTemplatesTab
|
||||||
|
surface="image"
|
||||||
|
templates={promptTemplates}
|
||||||
|
onPreview={setPreviewPromptTemplate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{topTab === 'video-templates' ? (
|
||||||
|
promptTemplatesLoading ? (
|
||||||
|
<CenteredLoader label={t('common.loading')} />
|
||||||
|
) : (
|
||||||
|
<PromptTemplatesTab
|
||||||
|
surface="video"
|
||||||
|
templates={promptTemplates}
|
||||||
|
onPreview={setPreviewPromptTemplate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{petRailHidden ? null : (
|
{petRailHidden ? null : (
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,14 @@ interface Props {
|
||||||
onUsePrompt: (skill: SkillSummary) => void;
|
onUsePrompt: (skill: SkillSummary) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModeFilter = 'all' | 'prototype-desktop' | 'prototype-mobile' | 'deck' | 'document' | 'orbit';
|
type ModeFilter =
|
||||||
|
| 'all'
|
||||||
|
| 'prototype-desktop'
|
||||||
|
| 'prototype-mobile'
|
||||||
|
| 'deck'
|
||||||
|
| 'document'
|
||||||
|
| 'orbit'
|
||||||
|
| 'live';
|
||||||
type SurfaceFilter = 'all' | Surface;
|
type SurfaceFilter = 'all' | Surface;
|
||||||
type ScenarioFilter = string;
|
type ScenarioFilter = string;
|
||||||
|
|
||||||
|
|
@ -38,6 +45,7 @@ const MODE_PILLS: { value: ModeFilter; labelKey: keyof Dict }[] = [
|
||||||
{ value: 'deck', labelKey: 'examples.modeDeck' },
|
{ value: 'deck', labelKey: 'examples.modeDeck' },
|
||||||
{ value: 'document', labelKey: 'examples.modeDocument' },
|
{ value: 'document', labelKey: 'examples.modeDocument' },
|
||||||
{ value: 'orbit', labelKey: 'examples.modeOrbit' },
|
{ value: 'orbit', labelKey: 'examples.modeOrbit' },
|
||||||
|
{ value: 'live', labelKey: 'examples.modeLive' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCENARIO_LABEL_KEY: Record<string, keyof Dict> = {
|
const SCENARIO_LABEL_KEY: Record<string, keyof Dict> = {
|
||||||
|
|
@ -87,6 +95,12 @@ function matchesMode(skill: SkillSummary, filter: ModeFilter): boolean {
|
||||||
return skill.mode === 'prototype' && skill.platform === 'mobile';
|
return skill.mode === 'prototype' && skill.platform === 'mobile';
|
||||||
if (filter === 'document') return skill.mode === 'template';
|
if (filter === 'document') return skill.mode === 'template';
|
||||||
if (filter === 'orbit') return skill.scenario === 'orbit';
|
if (filter === 'orbit') return skill.scenario === 'orbit';
|
||||||
|
// Live artifacts ride on the prototype mode but want their own bucket so
|
||||||
|
// refreshable / connector-backed samples are easy to find without
|
||||||
|
// scrolling through every desktop prototype. The parent live-artifact
|
||||||
|
// skill and every derived `live-artifact:<example>` card share the
|
||||||
|
// `live` scenario, so they all light up here together.
|
||||||
|
if (filter === 'live') return skill.scenario === 'live';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,8 +118,18 @@ function quotePrompt(locale: string, text: string): string {
|
||||||
return locale === 'de' ? `„${text}“` : `“${text}”`;
|
return locale === 'de' ? `„${text}“` : `“${text}”`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExamplesTab({ skills, onUsePrompt }: Props) {
|
export function ExamplesTab({ skills: rawSkills, onUsePrompt }: Props) {
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
// Skills tagged `aggregatesExamples: true` are containers whose preview
|
||||||
|
// would just duplicate one of their derived `<parent>:<child>` cards
|
||||||
|
// (e.g. live-artifact ships a sample gallery under `examples/`). Drop
|
||||||
|
// them up front so every count, filter, and rendered card downstream
|
||||||
|
// sees only the user-facing entries. The full listing is still passed
|
||||||
|
// through for `findSkillById` lookups elsewhere in the app.
|
||||||
|
const skills = useMemo(
|
||||||
|
() => rawSkills.filter((s) => !s.aggregatesExamples),
|
||||||
|
[rawSkills],
|
||||||
|
);
|
||||||
// Hold preview HTML per skill across re-renders so cards never re-flicker.
|
// Hold preview HTML per skill across re-renders so cards never re-flicker.
|
||||||
const [previews, setPreviews] = useState<Record<string, string | null>>({});
|
const [previews, setPreviews] = useState<Record<string, string | null>>({});
|
||||||
// Track per-skill fetch failures separately so the preview modal can show
|
// Track per-skill fetch failures separately so the preview modal can show
|
||||||
|
|
@ -214,6 +238,7 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
|
||||||
deck: 0,
|
deck: 0,
|
||||||
document: 0,
|
document: 0,
|
||||||
orbit: 0,
|
orbit: 0,
|
||||||
|
live: 0,
|
||||||
};
|
};
|
||||||
for (const s of surfaceScoped) {
|
for (const s of surfaceScoped) {
|
||||||
if (matchesMode(s, 'prototype-desktop')) c['prototype-desktop']++;
|
if (matchesMode(s, 'prototype-desktop')) c['prototype-desktop']++;
|
||||||
|
|
@ -221,6 +246,7 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) {
|
||||||
if (matchesMode(s, 'deck')) c.deck++;
|
if (matchesMode(s, 'deck')) c.deck++;
|
||||||
if (matchesMode(s, 'document')) c.document++;
|
if (matchesMode(s, 'document')) c.document++;
|
||||||
if (matchesMode(s, 'orbit')) c.orbit++;
|
if (matchesMode(s, 'orbit')) c.orbit++;
|
||||||
|
if (matchesMode(s, 'live')) c.live++;
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
}, [skills, surfaceFilter]);
|
}, [skills, surfaceFilter]);
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,7 @@ export const ar: Dict = {
|
||||||
'examples.modeDeck': 'شرائح',
|
'examples.modeDeck': 'شرائح',
|
||||||
'examples.modeDocument': 'مستندات وقوالب',
|
'examples.modeDocument': 'مستندات وقوالب',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'مباشر',
|
||||||
'examples.scenarioGeneral': 'عام',
|
'examples.scenarioGeneral': 'عام',
|
||||||
'examples.scenarioEngineering': 'هندسة',
|
'examples.scenarioEngineering': 'هندسة',
|
||||||
'examples.scenarioProduct': 'منتج',
|
'examples.scenarioProduct': 'منتج',
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,7 @@ export const de: Dict = {
|
||||||
'examples.modeDeck': 'Folien',
|
'examples.modeDeck': 'Folien',
|
||||||
'examples.modeDocument': 'Dokumente & Templates',
|
'examples.modeDocument': 'Dokumente & Templates',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Live',
|
||||||
'examples.scenarioGeneral': 'Allgemein',
|
'examples.scenarioGeneral': 'Allgemein',
|
||||||
'examples.scenarioEngineering': 'Engineering',
|
'examples.scenarioEngineering': 'Engineering',
|
||||||
'examples.scenarioProduct': 'Produkt',
|
'examples.scenarioProduct': 'Produkt',
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,7 @@ export const en: Dict = {
|
||||||
'examples.modeDeck': 'Slides',
|
'examples.modeDeck': 'Slides',
|
||||||
'examples.modeDocument': 'Docs & templates',
|
'examples.modeDocument': 'Docs & templates',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Live',
|
||||||
'examples.scenarioGeneral': 'General',
|
'examples.scenarioGeneral': 'General',
|
||||||
'examples.scenarioEngineering': 'Engineering',
|
'examples.scenarioEngineering': 'Engineering',
|
||||||
'examples.scenarioProduct': 'Product',
|
'examples.scenarioProduct': 'Product',
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,7 @@ export const esES: Dict = {
|
||||||
'examples.modeDeck': 'Diapositivas',
|
'examples.modeDeck': 'Diapositivas',
|
||||||
'examples.modeDocument': 'Documentos y plantillas',
|
'examples.modeDocument': 'Documentos y plantillas',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'En vivo',
|
||||||
'examples.scenarioGeneral': 'General',
|
'examples.scenarioGeneral': 'General',
|
||||||
'examples.scenarioEngineering': 'Ingeniería',
|
'examples.scenarioEngineering': 'Ingeniería',
|
||||||
'examples.scenarioProduct': 'Producto',
|
'examples.scenarioProduct': 'Producto',
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,7 @@ export const fa: Dict = {
|
||||||
'examples.modeDeck': 'اسلایدها',
|
'examples.modeDeck': 'اسلایدها',
|
||||||
'examples.modeDocument': 'اسناد و قالبها',
|
'examples.modeDocument': 'اسناد و قالبها',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'زنده',
|
||||||
'examples.scenarioGeneral': 'عمومی',
|
'examples.scenarioGeneral': 'عمومی',
|
||||||
'examples.scenarioEngineering': 'مهندسی',
|
'examples.scenarioEngineering': 'مهندسی',
|
||||||
'examples.scenarioProduct': 'محصول',
|
'examples.scenarioProduct': 'محصول',
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,7 @@ export const fr: Dict = {
|
||||||
'examples.modeDeck': 'Diaporamas',
|
'examples.modeDeck': 'Diaporamas',
|
||||||
'examples.modeDocument': 'Docs et modèles',
|
'examples.modeDocument': 'Docs et modèles',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Live',
|
||||||
'examples.scenarioGeneral': 'Général',
|
'examples.scenarioGeneral': 'Général',
|
||||||
'examples.scenarioEngineering': 'Ingénierie',
|
'examples.scenarioEngineering': 'Ingénierie',
|
||||||
'examples.scenarioProduct': 'Produit',
|
'examples.scenarioProduct': 'Produit',
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,7 @@ export const hu: Dict = {
|
||||||
'examples.modeDeck': 'Diák',
|
'examples.modeDeck': 'Diák',
|
||||||
'examples.modeDocument': 'Dokumentumok és sablonok',
|
'examples.modeDocument': 'Dokumentumok és sablonok',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Élő',
|
||||||
'examples.scenarioGeneral': 'Általános',
|
'examples.scenarioGeneral': 'Általános',
|
||||||
'examples.scenarioEngineering': 'Mérnöki',
|
'examples.scenarioEngineering': 'Mérnöki',
|
||||||
'examples.scenarioProduct': 'Termék',
|
'examples.scenarioProduct': 'Termék',
|
||||||
|
|
|
||||||
|
|
@ -511,6 +511,7 @@ export const id: Dict = {
|
||||||
'examples.modeDeck': 'Slide',
|
'examples.modeDeck': 'Slide',
|
||||||
'examples.modeDocument': 'Dokumen & templat',
|
'examples.modeDocument': 'Dokumen & templat',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Langsung',
|
||||||
'examples.scenarioGeneral': 'Umum',
|
'examples.scenarioGeneral': 'Umum',
|
||||||
'examples.scenarioEngineering': 'Engineering',
|
'examples.scenarioEngineering': 'Engineering',
|
||||||
'examples.scenarioProduct': 'Produk',
|
'examples.scenarioProduct': 'Produk',
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,7 @@ export const ja: Dict = {
|
||||||
'examples.modeDeck': 'スライド',
|
'examples.modeDeck': 'スライド',
|
||||||
'examples.modeDocument': 'ドキュメント & テンプレート',
|
'examples.modeDocument': 'ドキュメント & テンプレート',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'ライブ',
|
||||||
'examples.scenarioGeneral': '一般',
|
'examples.scenarioGeneral': '一般',
|
||||||
'examples.scenarioEngineering': 'エンジニアリング',
|
'examples.scenarioEngineering': 'エンジニアリング',
|
||||||
'examples.scenarioProduct': 'プロダクト',
|
'examples.scenarioProduct': 'プロダクト',
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,7 @@ export const ko: Dict = {
|
||||||
'examples.modeDeck': '슬라이드',
|
'examples.modeDeck': '슬라이드',
|
||||||
'examples.modeDocument': '문서 및 템플릿',
|
'examples.modeDocument': '문서 및 템플릿',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': '라이브',
|
||||||
'examples.scenarioGeneral': '일반',
|
'examples.scenarioGeneral': '일반',
|
||||||
'examples.scenarioEngineering': '엔지니어링',
|
'examples.scenarioEngineering': '엔지니어링',
|
||||||
'examples.scenarioProduct': '제품',
|
'examples.scenarioProduct': '제품',
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,7 @@ export const pl: Dict = {
|
||||||
'examples.modeDeck': 'Slajdy',
|
'examples.modeDeck': 'Slajdy',
|
||||||
'examples.modeDocument': 'Dokumenty i szablony',
|
'examples.modeDocument': 'Dokumenty i szablony',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Live',
|
||||||
'examples.scenarioGeneral': 'Ogólne',
|
'examples.scenarioGeneral': 'Ogólne',
|
||||||
'examples.scenarioEngineering': 'Inżynieria',
|
'examples.scenarioEngineering': 'Inżynieria',
|
||||||
'examples.scenarioProduct': 'Produkt',
|
'examples.scenarioProduct': 'Produkt',
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,7 @@ export const ptBR: Dict = {
|
||||||
'examples.modeDeck': 'Slides',
|
'examples.modeDeck': 'Slides',
|
||||||
'examples.modeDocument': 'Docs e templates',
|
'examples.modeDocument': 'Docs e templates',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Ao vivo',
|
||||||
'examples.scenarioGeneral': 'Geral',
|
'examples.scenarioGeneral': 'Geral',
|
||||||
'examples.scenarioEngineering': 'Engenharia',
|
'examples.scenarioEngineering': 'Engenharia',
|
||||||
'examples.scenarioProduct': 'Produto',
|
'examples.scenarioProduct': 'Produto',
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,7 @@ export const ru: Dict = {
|
||||||
'examples.modeDeck': 'Презентации',
|
'examples.modeDeck': 'Презентации',
|
||||||
'examples.modeDocument': 'Документы и шаблоны',
|
'examples.modeDocument': 'Документы и шаблоны',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Live',
|
||||||
'examples.scenarioGeneral': 'Общее',
|
'examples.scenarioGeneral': 'Общее',
|
||||||
'examples.scenarioEngineering': 'Инженерия',
|
'examples.scenarioEngineering': 'Инженерия',
|
||||||
'examples.scenarioProduct': 'Продукт',
|
'examples.scenarioProduct': 'Продукт',
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,7 @@ export const tr: Dict = {
|
||||||
'examples.modeDeck': 'Slaytlar',
|
'examples.modeDeck': 'Slaytlar',
|
||||||
'examples.modeDocument': 'Doküman & şablonlar',
|
'examples.modeDocument': 'Doküman & şablonlar',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Canlı',
|
||||||
'examples.scenarioGeneral': 'Genel',
|
'examples.scenarioGeneral': 'Genel',
|
||||||
'examples.scenarioEngineering': 'Mühendislik',
|
'examples.scenarioEngineering': 'Mühendislik',
|
||||||
'examples.scenarioProduct': 'Ürün',
|
'examples.scenarioProduct': 'Ürün',
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,7 @@ export const uk: Dict = {
|
||||||
'examples.modeDeck': 'Слайди',
|
'examples.modeDeck': 'Слайди',
|
||||||
'examples.modeDocument': 'Документи та шаблони',
|
'examples.modeDocument': 'Документи та шаблони',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': 'Live',
|
||||||
'examples.scenarioGeneral': 'Загальне',
|
'examples.scenarioGeneral': 'Загальне',
|
||||||
'examples.scenarioEngineering': 'Інженерія',
|
'examples.scenarioEngineering': 'Інженерія',
|
||||||
'examples.scenarioProduct': 'Продукт',
|
'examples.scenarioProduct': 'Продукт',
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,7 @@ export const zhCN: Dict = {
|
||||||
'examples.modeDeck': '幻灯片',
|
'examples.modeDeck': '幻灯片',
|
||||||
'examples.modeDocument': '文档与模板',
|
'examples.modeDocument': '文档与模板',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': '实时',
|
||||||
'examples.scenarioGeneral': '通用',
|
'examples.scenarioGeneral': '通用',
|
||||||
'examples.scenarioEngineering': '工程',
|
'examples.scenarioEngineering': '工程',
|
||||||
'examples.scenarioProduct': '产品',
|
'examples.scenarioProduct': '产品',
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,7 @@ export const zhTW: Dict = {
|
||||||
'examples.modeDeck': '投影片',
|
'examples.modeDeck': '投影片',
|
||||||
'examples.modeDocument': '文件與範本',
|
'examples.modeDocument': '文件與範本',
|
||||||
'examples.modeOrbit': 'Orbit',
|
'examples.modeOrbit': 'Orbit',
|
||||||
|
'examples.modeLive': '即時',
|
||||||
'examples.scenarioGeneral': '通用',
|
'examples.scenarioGeneral': '通用',
|
||||||
'examples.scenarioEngineering': '工程',
|
'examples.scenarioEngineering': '工程',
|
||||||
'examples.scenarioProduct': '產品',
|
'examples.scenarioProduct': '產品',
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,7 @@ export interface Dict {
|
||||||
'examples.modeDeck': string;
|
'examples.modeDeck': string;
|
||||||
'examples.modeDocument': string;
|
'examples.modeDocument': string;
|
||||||
'examples.modeOrbit': string;
|
'examples.modeOrbit': string;
|
||||||
|
'examples.modeLive': string;
|
||||||
'examples.scenarioGeneral': string;
|
'examples.scenarioGeneral': string;
|
||||||
'examples.scenarioEngineering': string;
|
'examples.scenarioEngineering': string;
|
||||||
'examples.scenarioProduct': string;
|
'examples.scenarioProduct': string;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ export interface SkillSummary {
|
||||||
craftRequires?: string[];
|
craftRequires?: string[];
|
||||||
hasBody: boolean;
|
hasBody: boolean;
|
||||||
examplePrompt: string;
|
examplePrompt: string;
|
||||||
|
// True when this skill exists only to group derived `<parent>:<child>`
|
||||||
|
// example cards. The Examples gallery hides such cards because their
|
||||||
|
// preview would duplicate one of the derived cards and add no extra
|
||||||
|
// information, but the entry stays in the listing so `findSkillById`
|
||||||
|
// resolves the parent for system-prompt composition and "Use this
|
||||||
|
// prompt" fast-create on a derived card still composes the parent's
|
||||||
|
// SKILL.md body.
|
||||||
|
aggregatesExamples?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillDetail extends SkillSummary {
|
export interface SkillDetail extends SkillSummary {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ triggers:
|
||||||
- "实时看板"
|
- "实时看板"
|
||||||
od:
|
od:
|
||||||
mode: prototype
|
mode: prototype
|
||||||
|
scenario: live
|
||||||
preview:
|
preview:
|
||||||
type: html
|
type: html
|
||||||
entry: index.html
|
entry: index.html
|
||||||
|
|
|
||||||
761
skills/live-artifact/examples/baby-health-live.html
Normal file
761
skills/live-artifact/examples/baby-health-live.html
Normal file
|
|
@ -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 & 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 & 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>
|
||||||
1006
skills/live-artifact/examples/competitor-radar-live.html
Normal file
1006
skills/live-artifact/examples/competitor-radar-live.html
Normal file
File diff suppressed because it is too large
Load diff
1174
skills/live-artifact/examples/crm-table-live.html
Normal file
1174
skills/live-artifact/examples/crm-table-live.html
Normal file
File diff suppressed because it is too large
Load diff
2316
skills/live-artifact/examples/crypto-dashboard.html
Normal file
2316
skills/live-artifact/examples/crypto-dashboard.html
Normal file
File diff suppressed because it is too large
Load diff
919
skills/live-artifact/examples/monday-operator-live.html
Normal file
919
skills/live-artifact/examples/monday-operator-live.html
Normal file
|
|
@ -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 > 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>
|
||||||
2246
skills/live-artifact/examples/stock-dashboard.html
Normal file
2246
skills/live-artifact/examples/stock-dashboard.html
Normal file
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
453
skills/live-artifact/examples/stock-portfolio-live/data.json
Normal file
453
skills/live-artifact/examples/stock-portfolio-live/data.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1071
skills/live-artifact/examples/stock-portfolio-live/template.html
Normal file
1071
skills/live-artifact/examples/stock-portfolio-live/template.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue