mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat: general-purpose skills with @-mention composition and user import
Lift skills from "one mode-bound skill per project" to a generic capability
the user can compose per turn:
- Daemon: scan multiple skill roots (user-skills under runtime data, then
the bundled `skills/`); user-imported skills can shadow built-ins by id.
- New `POST /api/skills/import` and `DELETE /api/skills/:id` endpoints,
with CONFLICT/BAD_REQUEST/NOT_FOUND error codes and built-in delete
protection.
- ChatRequest gains `skillIds: string[]`; the chat run concatenates each
picked skill's body (and merges craftRequires) into the system prompt
for that turn only — the project's persistent `skillId` is untouched.
- Web composer: `@` popover now lists skills alongside project files;
picks render as removable chips above the textarea and ride along with
the request as `skillIds`.
- Settings → Library: import form (name/description/triggers/body),
per-card delete for user skills, "user" origin badge.
* chore(web): drop welcome pet teaser + add ds→prompt-template mapping util
- SettingsDialog: remove the inline pet adoption teaser from the welcome
panel so the first-run modal stays focused on configuration.
- New `inferPromptTemplateCategoriesForDs(ds)` helper that maps a design
system's authored metadata to prompt-template gallery categories.
Imported by the design-system gallery wiring on a sibling branch; no
callers in this branch yet.
* feat: split skills/design-templates and add finalize-design API
Phase 0 of the skills/design-templates refactor (specs/current/
skills-and-design-templates.md):
- Move ~104 rendering catalogue entries from skills/ to design-templates/
and keep skills/ for the small set of functional skills that *do work*
on user input (utilities, briefs, packagers).
- Add design-templates/AGENTS.md and skills/AGENTS.md describing the
contract, and a brand-agnostic craft/ surface for opt-in craft rules.
- Daemon: add DESIGN_TEMPLATES_DIR / USER_DESIGN_TEMPLATES_DIR roots and
an /api/design-templates surface mirroring /api/skills. Asset/example
routes still span both registries so existing srcdoc URLs keep
resolving across the rename.
- Web: split LibrarySection into SkillsSection + DesignSystemsSection,
rename the EntryView "Examples" tab to "Templates", and update locales
+ the New-project picker accordingly.
Adds the finalize-design endpoint:
- New apps/daemon/src/finalize-design.ts and packages/contracts/src/api/
finalize.ts — one-shot synthesis of a project's transcript + active
design system + current artifact into <projectDir>/DESIGN.md via the
Anthropic Messages API. Per-project .finalize.lock mirrors the
transcript-export hygiene from PR #493; provider credentials are not
persisted by the daemon.
Other supporting changes:
- README + AGENTS.md updates to document the new directory split and
craft/ surface, plus i18n strings across 13 locales.
- Test refactors and new coverage (finalize-design, runs, sidecar
server, plus refreshed daemon integration tests).
- .gitignore: scope the *.exe ignore to /OpenDesign.exe so legitimate
vendor binaries are no longer hidden.
* fix(merge): move clinical-case-report to design-templates/
Origin/main added the clinical-case-report skill under skills/ before
the skills/design-templates split landed. Its od.mode is prototype, so
per specs/current/skills-and-design-templates.md it is a design template
and belongs alongside the other rendering catalogue entries — not under
the slimmed-down functional skills/ root. Moving it keeps the EntryView
Templates tab consistent with origin/main's intent.
* feat(skills): curated design/creative catalogue + collapsible Settings rows
Seed ~100 curated design/creative skill stubs under skills/ sourced from
awesome-claude-skills (ComposioHQ) and awesome-agent-skills (VoltAgent).
Each stub carries an od.category tag so the new filter pill row in
Settings -> Skills can group them. The seed script
(scripts/seed-curated-design-skills.ts, pnpm seed:curated-design-skills)
is idempotent: it only creates folders that don't already exist, so
hand-edited stubs are never overwritten.
- Daemon: parse and surface od.category on SkillInfo with a strict slug
normaliser; mirror the field on SkillSummary in @open-design/contracts.
Category is purely a UI hint — system-prompt composition is unchanged.
- Web: rewrite SkillsSection from a left-list / right-detail grid into a
vertical stack of collapsible rows mirroring the External MCP panel
(header always visible with name + mode/source/category pills + per-row
enable toggle; SKILL.md preview, file tree and inline edit form expand
on demand). Add a Category filter row above the list. Reorder Settings
nav so Skills + External MCP sit above the Composio/MCP cluster. Update
composer placeholder/hint across 17 locales to advertise '@ files or
skills · / for commands'.
- Docs: extend skills/AGENTS.md with the curated catalogue rules
(idempotency, category vocabulary, no upstream vendoring).
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(skills): teach localized-content + system-prompt tests about the skills/design-templates split
mrcfps blocking review on PR #955: the skills/design-templates split
(b5993385) moved ~110 SKILL.md entries out of `skills/` and into
`design-templates/`, but two repo-level tests still hard-coded the
single-root layout, so CI gates went red on the merged branch:
- `e2e/tests/localized-content.test.ts` only scanned `<repo>/skills`
while the locale `skillCopy` map keeps id-keyed entries spanning
both roots (ExamplesTab/Templates uses one lookup regardless of
origin). Teach the helper to read both `skills/` and
`design-templates/`, deduplicating ids so the union matches the
localized claim.
- `apps/daemon/tests/prompts/system.test.ts` read
`skills/live-artifact/SKILL.md`, which now lives under
`design-templates/live-artifact/`. Update the absolute path so
composeSystemPrompt's coverage of the live-artifact preamble is
exercised again.
Also enroll the curated design/creative catalogue (PR #955, ~91
stubs sourced from awesome-claude-skills / awesome-agent-skills) in
the DE / FR / RU `_SKILL_IDS_WITH_EN_FALLBACK` lists. The stubs are
English-only by design (frontmatter advertises an upstream URL); the
fallback list is exactly the place to acknowledge "we know this id
exists, English copy is fine here" so the localized-content coverage
gate passes without forcing a translation task per locale.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(skills): always quote frontmatter name so importUserSkill round-trips numeric / boolean ids
mrcfps PR #955 review: `buildSkillMarkdown` emitted `name:
${escapeYamlString(name)}` without quotes, so YAML coerced names
like `123`, `true`, `false`, or `null` into non-string scalars on
re-parse. listSkills() then read `data.name` as a number/boolean
and the import flow's follow-up `findSkillById(skills, result.id)`
missed it, falling into `/api/skills/import`'s "imported skill
could not be re-read" 500 path for those ids.
Switch the emitter to a quoted scalar (`name: "..."`) — the
double-escape already in `escapeYamlString` makes the quoted form
safe — and add a round-trip test covering `123`, `true`, `false`,
`null`, and `0` to lock in the contract.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): drop staged-skill chips when the matching @<id> token leaves the draft
mrcfps PR #955 review: `submit()` always forwarded every id in
`stagedSkills`, but that state was only mutated on picker click and
chip removal. Hand-deleting an `@<id>` token from the textarea left
the chip staged, so the request still carried `skillIds: [<id>]` and
the daemon composed a skill the prompt no longer referenced.
Sync the chips with the draft inside `handleChange()` by pruning
`stagedSkills` whenever the new value no longer contains the
`@<id>` token (using the same whitespace boundary as
`removeStagedSkill`'s strip regex). Comment explains why this
prune does not run for `staged` file attachments — users frequently
add files via the upload button without leaving an `@<path>` token,
so a symmetric prune there would erase legitimate uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(daemon): stage @-composed skills' side files alongside the active skill
codex PR #955 review: composing a per-turn `@`-picked skill into the
system prompt appended its body (with the `withSkillRootPreamble`
guidance pointing at relative paths under `<cwd>/.od-skills/<folder>/`)
but never staged the actual folder. `startChatRun` only copied
`activeSkillDir`, so when the project's primary skill was different
(or absent) the composed skill's references/, examples/, and scripts/
files lived only at their absolute repo path — agents that honour
the cwd-relative form (or that don't get `--add-dir`, e.g. Codex with
allowlisted gpt-image projects) couldn't reach them.
Thread the composed skills' dirs out of `composeDaemonSystemPrompt`
as `extraSkillDirs` and stage each one through the same
`stageActiveSkill` API used for the primary skill. Dedupe by folder
basename so a project whose primary skill is also `@`-composed isn't
copied twice. Each preamble already advertises its own folder, so the
prompt and the staged tree stay aligned without further changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): respect the Library disable toggle in the project @-mention picker
codex PR #955 review: only `EntryView` received `enabledSkills`
(filtered against `config.disabledSkills`); active projects still
got `skills={skills}` raw, so a skill the user disabled in Settings
kept appearing in the project's `@`-mention popover and could ride
along to the daemon via `skillIds`. That broke the Library toggle
for any project opened on the post-split branch.
Compute a functional-skills-only enabled subset
(`enabledFunctionalSkills`) and pass it into `<ProjectView>` instead.
Templates stay separate — design-templates are filtered through their
own `enabledDesignTemplates` memo for the Templates gallery — so
ProjectView's chat composer still only sees skills, never templates,
matching the pre-split prop surface.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(e2e): mock /api/design-templates for example-use-prompt flow
The Templates tab in EntryView fetches from /api/design-templates after
the skills/design-templates split (specs/current/skills-and-design-templates.md).
The example-use-prompt Playwright scenario only mocked /api/skills, so the
gallery card never appeared and the test timed out waiting on
example-card-warm-utility-example. Serve the same fixture summary on both
endpoints so the templates gallery renders the card the test clicks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(tools-pack): create design-templates fixture for resources test
The packaging resources copy now bundles the new design-templates tree
alongside skills (see resources.ts BUNDLED_RESOURCE_TREES). The
copyBundledResourceTrees fixture only created skills, design-systems,
craft, etc., so the recursive copy crashed with ENOENT on
design-templates before it could check the prompt-templates assertion.
Add the missing fixture directory so the test exercises the same set
of resource trees the packaged build does.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(skills): clone built-in side files into the shadow on first edit
mrcfps PR #955 review: editing a built-in skill wrote a USER_SKILLS_DIR
shadow folder that contained only a new SKILL.md. The next listSkills()
pass surfaced the shadow as the active dir, but every side-file resolver
(/api/skills/:id/files, /example, /assets/*, the system-prompt preamble,
and the per-turn cwd staging) reads through skill.dir. With nothing but
SKILL.md in the shadow, the bundled assets/, references/, scripts/, and
examples/ disappeared the moment the user hit save — a built-in like
last30days or live-artifact would break immediately after edit instead
of just having its body overridden.
Teach updateUserSkill() to take a `sourceDir` and clone every entry
except SKILL.md / dotfiles into the shadow on the very first edit. The
shadow stays self-contained, so all the resolvers keep working without
fallback bookkeeping. Subsequent edits detect the existing shadow and
skip the clone, so user tweaks under the side tree survive a re-save.
Wire `sourceDir: skill.dir` from server.ts's PUT /api/skills/:id handler
and add two regression tests:
- 'clones built-in side files into the shadow on the first edit' walks
the file tree after save and asserts assets/template.html, references/
notes.md, and scripts/helper.sh all round-trip from the built-in.
- 'preserves user-edited side files on subsequent edits' edits the
staged assets/template.html, re-saves, and confirms the user content
is still there.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(e2e): rename home tab from Examples to Templates
The Examples tab was renamed to Templates in EntryView (b5993385's
skills/design-templates split — entry.tabExamples became entry.tabTemplates
and the tab value moved from 'examples' to 'templates'), but
entry-chrome-flows still asserted the old label and testId. Update both.
* fix(skills+web): preserve template body in API mode and dir-based skill delete
Two follow-ups from PR #955 review:
1. ProjectView only received `enabledFunctionalSkills`, but
`composedSystemPrompt()` still resolved `project.skillId` through that
prop and `fetchSkill()`. Projects created from the new
`/api/design-templates` surface keep a template id in `project.skillId`,
so opening one in API mode dropped the template body from the system
prompt and the upstream request ran without the project's primary
template instructions. Now ProjectView takes a separate
`designTemplates` prop (the unfiltered template list, so a
later-disabled template still loads for projects already created from
it) and `composedSystemPrompt()` plus the metadata / `isDeck` lookups
fall back to that list, with `fetchDesignTemplate()` as the body-fetch
fallback to `fetchSkill()`. The chat composer's `@`-picker keeps
receiving only the enabled functional skills.
2. `DELETE /api/skills/:id` used `deleteUserSkill(USER_SKILLS_DIR, skill.id)`
which re-slugified the frontmatter id and removed
`<userSkillsDir>/<slug>/`. That matched the import shape but missed the
install shape — `installFromTarget` writes the folder at
`sanitizeRepoName(url)` (GitHub) or `path.basename(realpath)` (local
symlink), neither of which is guaranteed to equal the slugified
frontmatter `name`. A duplicate `app.delete('/api/skills/:id', ...)`
handler at the install routes never fired because Express resolved the
earlier registration first, leaving the install/uninstall path without
working teardown. The handler now removes `skill.dir` (the absolute
path listSkills already discovered) under a USER_SKILLS_DIR safety
check, using `lstat` + `unlinkSync` so symlinked local installs unlink
cleanly without recursing into the user's source tree. The dead
duplicate handler is removed; `deleteUserSkill` is dropped from the
server.ts import set (still exported and unit-tested in skills.ts).
Regression coverage in `apps/daemon/tests/skills-delete-route.test.ts`
pins both shapes plus the symlink-preserves-source case.
* test(daemon): point hyperframes system-prompt test at design-templates
The merge with main brought in a hyperframes system-prompt test that
reads `skills/hyperframes/SKILL.md`, but this branch's split moved
`hyperframes` into `design-templates/` (same migration as `live-artifact`
already handled above in this file). CI was failing with ENOENT on the
old path.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
1534 lines
51 KiB
HTML
1534 lines
51 KiB
HTML
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Open Design · Social Analytics</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=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
/* Totality Festival Design System — bind palette */
|
||
--surface: #121318;
|
||
--surface-dim: #121318;
|
||
--surface-bright: #38393f;
|
||
--surface-container-lowest: #0d0e13;
|
||
--surface-container-low: #1a1b21;
|
||
--surface-container: #1e1f25;
|
||
--surface-container-high: #292a2f;
|
||
--surface-container-highest: #34343a;
|
||
--on-surface: #e3e1e9;
|
||
--on-surface-variant: #d0c6ab;
|
||
--inverse-surface: #e3e1e9;
|
||
--inverse-on-surface: #2f3036;
|
||
--outline: #999077;
|
||
--outline-variant: #4d4732;
|
||
--surface-tint: #e9c400;
|
||
--primary: #fff6df;
|
||
--on-primary: #3a3000;
|
||
--primary-container: #ffd700;
|
||
--on-primary-container: #705e00;
|
||
--inverse-primary: #705d00;
|
||
--secondary: #bdf4ff;
|
||
--on-secondary: #00363d;
|
||
--secondary-container: #00e3fd;
|
||
--on-secondary-container: #00616d;
|
||
--tertiary: #fcf3ff;
|
||
--on-tertiary: #3b2754;
|
||
--tertiary-container: #e7d1ff;
|
||
--on-tertiary-container: #6b5586;
|
||
--error: #ffb4ab;
|
||
--on-error: #690005;
|
||
--error-container: #93000a;
|
||
--on-error-container: #ffdad6;
|
||
--background: #121318;
|
||
--on-background: #e3e1e9;
|
||
--surface-variant: #34343a;
|
||
|
||
/* Typography */
|
||
--font-display: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
|
||
/* Spacing */
|
||
--spacing-unit: 8px;
|
||
--gutter: 24px;
|
||
--margin-desktop: 64px;
|
||
|
||
/* Premium glass levels */
|
||
--glass-bg-level-1: rgba(52, 52, 58, 0.15);
|
||
--glass-bg-level-2: rgba(52, 52, 58, 0.25);
|
||
--glass-bg-level-3: rgba(52, 52, 58, 0.35);
|
||
--glass-border: rgba(255, 255, 255, 0.12);
|
||
--glass-border-strong: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--font-body);
|
||
background: radial-gradient(ellipse at 50% 0%, rgba(255, 215, 0, 0.08) 0%, transparent 60%),
|
||
radial-gradient(ellipse at 100% 100%, rgba(0, 227, 253, 0.06) 0%, transparent 50%),
|
||
var(--surface);
|
||
color: var(--on-surface);
|
||
line-height: 1.6;
|
||
overflow-x: hidden;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* Header */
|
||
header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
background: rgba(18, 19, 24, 0.75);
|
||
backdrop-filter: blur(32px) saturate(180%);
|
||
border-bottom: 1px solid var(--glass-border);
|
||
padding: 16px var(--margin-desktop);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.logo-block {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.logo-block img {
|
||
height: 32px;
|
||
width: auto;
|
||
filter: drop-shadow(0 0 8px rgba(0, 227, 253, 0.3));
|
||
}
|
||
|
||
.logo-label {
|
||
font-family: var(--font-display);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
.platform-switcher-inline {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-left: 24px;
|
||
}
|
||
|
||
.platform-pill {
|
||
font-family: var(--font-display);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
padding: 6px 16px;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--on-surface-variant);
|
||
border: 1px solid var(--outline-variant);
|
||
cursor: pointer;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.platform-pill.active {
|
||
background: var(--glass-bg-level-2);
|
||
color: var(--primary);
|
||
border-color: var(--primary-container);
|
||
box-shadow: 0 0 32px rgba(255, 215, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 24px;
|
||
}
|
||
|
||
.time-range-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.time-tab {
|
||
font-family: var(--font-display);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
padding: 6px 12px;
|
||
background: transparent;
|
||
color: var(--on-surface-variant);
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
.time-tab.active {
|
||
color: var(--primary);
|
||
}
|
||
|
||
.user-block {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
filter: drop-shadow(0 0 6px rgba(0, 227, 253, 0.3));
|
||
}
|
||
|
||
.user-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.user-name {
|
||
font-family: var(--font-display);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--on-surface);
|
||
}
|
||
|
||
.user-role {
|
||
font-size: 12px;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
/* Hero Strip */
|
||
.hero-strip {
|
||
position: sticky;
|
||
top: 65px;
|
||
z-index: 90;
|
||
background: rgba(18, 19, 24, 0.85);
|
||
backdrop-filter: blur(40px) saturate(180%);
|
||
border-bottom: 1px solid var(--glass-border);
|
||
padding: 32px var(--margin-desktop);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.hero-content {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 32px;
|
||
}
|
||
|
||
.hero-text {
|
||
flex: 1;
|
||
}
|
||
|
||
.hero-headline {
|
||
font-family: var(--font-display);
|
||
font-size: 48px;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
letter-spacing: -0.02em;
|
||
color: var(--primary);
|
||
margin-bottom: 8px;
|
||
text-shadow: 0 0 40px rgba(255, 215, 0, 0.3);
|
||
}
|
||
|
||
.hero-meta {
|
||
font-size: 14px;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
.hero-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.btn {
|
||
font-family: var(--font-display);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.05em;
|
||
padding: 12px 24px;
|
||
height: 48px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--primary-container);
|
||
color: var(--on-primary);
|
||
box-shadow: 0 0 32px rgba(255, 215, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
box-shadow: 0 0 48px rgba(255, 215, 0, 0.7), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: rgba(189, 244, 255, 0.08);
|
||
color: var(--secondary);
|
||
border: 1px solid var(--secondary);
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: rgba(189, 244, 255, 0.15);
|
||
box-shadow: 0 0 24px rgba(189, 244, 255, 0.2);
|
||
}
|
||
|
||
/* Ask Bar */
|
||
.ask-bar {
|
||
padding: 24px var(--margin-desktop);
|
||
background: var(--glass-bg-level-1);
|
||
backdrop-filter: blur(20px);
|
||
}
|
||
|
||
.ask-input {
|
||
width: 100%;
|
||
font-family: var(--font-body);
|
||
font-size: 16px;
|
||
padding: 16px 20px;
|
||
background: rgba(13, 14, 19, 0.6);
|
||
color: var(--on-surface);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 8px;
|
||
transition: all 0.3s ease;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.ask-input:focus {
|
||
outline: none;
|
||
border-color: var(--secondary-container);
|
||
box-shadow: 0 0 0 3px rgba(0, 227, 253, 0.15), 0 0 32px rgba(0, 227, 253, 0.2);
|
||
background: rgba(13, 14, 19, 0.8);
|
||
}
|
||
|
||
.ask-input::placeholder {
|
||
color: var(--on-surface-variant);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* Main Container */
|
||
.container {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 32px var(--margin-desktop);
|
||
}
|
||
|
||
/* Platform Switcher Cards */
|
||
.platform-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.platform-card {
|
||
background: var(--glass-bg-level-1);
|
||
backdrop-filter: blur(24px) saturate(180%);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
cursor: pointer;
|
||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.platform-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.platform-card.active::before {
|
||
opacity: 1;
|
||
}
|
||
|
||
.platform-card.active {
|
||
background: var(--glass-bg-level-2);
|
||
border-color: var(--primary-container);
|
||
box-shadow: 0 0 48px rgba(255, 215, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.platform-card:hover:not(.active) {
|
||
background: var(--glass-bg-level-2);
|
||
border-color: var(--glass-border-strong);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.platform-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.platform-icon {
|
||
font-size: 24px;
|
||
}
|
||
|
||
.platform-name {
|
||
font-family: var(--font-display);
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--on-surface);
|
||
}
|
||
|
||
.platform-handle {
|
||
font-size: 14px;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
/* KPI Row */
|
||
.kpi-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.kpi-card {
|
||
background: var(--glass-bg-level-1);
|
||
backdrop-filter: blur(24px) saturate(180%);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.kpi-card::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
|
||
}
|
||
|
||
.kpi-label {
|
||
font-family: var(--font-display);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--on-surface-variant);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.kpi-value {
|
||
font-family: var(--font-display);
|
||
font-size: 36px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
color: var(--on-surface);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.kpi-delta {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 14px;
|
||
color: var(--secondary);
|
||
filter: drop-shadow(0 0 8px rgba(189, 244, 255, 0.3));
|
||
}
|
||
|
||
.kpi-footnote {
|
||
font-size: 12px;
|
||
color: var(--on-surface-variant);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Main Grid - 3 columns */
|
||
.main-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 24px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.chart-card {
|
||
background: var(--glass-bg-level-1);
|
||
backdrop-filter: blur(24px) saturate(180%);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 12px;
|
||
padding: 32px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chart-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
|
||
}
|
||
|
||
.chart-header {
|
||
font-family: var(--font-display);
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--on-surface);
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.chart-svg {
|
||
width: 100%;
|
||
height: 280px;
|
||
}
|
||
|
||
.top-post-card {
|
||
background: var(--glass-bg-level-1);
|
||
backdrop-filter: blur(24px) saturate(180%);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.top-post-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
|
||
}
|
||
|
||
.post-header-tag {
|
||
display: inline-block;
|
||
font-family: var(--font-display);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
color: var(--on-primary-container);
|
||
background: var(--primary-container);
|
||
padding: 4px 12px;
|
||
border-radius: 999px;
|
||
margin-bottom: 16px;
|
||
box-shadow: 0 0 24px rgba(255, 215, 0, 0.4);
|
||
}
|
||
|
||
.post-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.post-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
border: 1px solid var(--glass-border);
|
||
}
|
||
|
||
.post-handle-block {
|
||
flex: 1;
|
||
}
|
||
|
||
.post-handle {
|
||
font-family: var(--font-display);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--on-surface);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.verified-badge {
|
||
color: var(--secondary-container);
|
||
font-size: 16px;
|
||
filter: drop-shadow(0 0 6px rgba(0, 227, 253, 0.5));
|
||
}
|
||
|
||
.post-timestamp {
|
||
font-size: 12px;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
.post-body {
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
color: var(--on-surface);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.post-media {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16/9;
|
||
background: linear-gradient(135deg, rgba(255, 215, 0, 0.12) 0%, rgba(18, 19, 24, 0.9) 100%);
|
||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||
border-radius: 8px;
|
||
margin-bottom: 12px;
|
||
box-shadow: 0 0 24px rgba(255, 215, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.post-media svg {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.post-media-caption {
|
||
position: absolute;
|
||
left: 12px;
|
||
bottom: 10px;
|
||
font-family: var(--font-display);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--primary);
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.post-engagement {
|
||
display: flex;
|
||
gap: 16px;
|
||
font-size: 13px;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
/* Geo Map Card */
|
||
.geo-card {
|
||
background: var(--glass-bg-level-1);
|
||
backdrop-filter: blur(24px) saturate(180%);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 12px;
|
||
padding: 32px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.geo-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
|
||
}
|
||
|
||
.geo-map {
|
||
width: 100%;
|
||
height: 200px;
|
||
position: relative;
|
||
}
|
||
|
||
.geo-dot {
|
||
position: absolute;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--primary-container);
|
||
box-shadow: 0 0 16px rgba(255, 215, 0, 0.6);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { transform: scale(1); opacity: 1; }
|
||
50% { transform: scale(1.3); opacity: 0.7; }
|
||
}
|
||
|
||
.geo-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 12px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.geo-stat {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.geo-stat-label {
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
.geo-stat-value {
|
||
font-family: var(--font-display);
|
||
font-weight: 600;
|
||
color: var(--on-surface);
|
||
}
|
||
|
||
/* GitHub Contributors */
|
||
.contributors-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 12px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.contributor-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.contributor-avatar {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
border: 2px solid var(--glass-border);
|
||
box-shadow: 0 0 16px rgba(0, 227, 253, 0.2);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.contributor-avatar:hover {
|
||
border-color: var(--secondary);
|
||
box-shadow: 0 0 24px rgba(0, 227, 253, 0.4);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.contributor-name {
|
||
font-size: 11px;
|
||
color: var(--on-surface-variant);
|
||
text-align: center;
|
||
}
|
||
|
||
.contributor-commits {
|
||
font-size: 10px;
|
||
color: var(--on-surface-variant);
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* Lower Grid */
|
||
.lower-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 24px;
|
||
}
|
||
|
||
.trending-card, .comments-card {
|
||
background: var(--glass-bg-level-1);
|
||
backdrop-filter: blur(24px) saturate(180%);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.trending-card::before, .comments-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
|
||
}
|
||
|
||
.section-title {
|
||
font-family: var(--font-display);
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--on-surface);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.trending-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid rgba(153, 144, 119, 0.15);
|
||
}
|
||
|
||
.trending-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.trending-name {
|
||
font-family: var(--font-display);
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: var(--on-surface);
|
||
}
|
||
|
||
.trending-count {
|
||
font-size: 13px;
|
||
color: var(--on-surface-variant);
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.sparkline {
|
||
width: 60px;
|
||
height: 20px;
|
||
}
|
||
|
||
.comment-item {
|
||
padding: 16px 0;
|
||
border-bottom: 1px solid rgba(153, 144, 119, 0.15);
|
||
}
|
||
|
||
.comment-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.comment-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.comment-avatar {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: var(--glass-bg-level-2);
|
||
border: 1px solid var(--glass-border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.comment-handle {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--on-surface);
|
||
}
|
||
|
||
.comment-time {
|
||
font-size: 12px;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
.comment-body {
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--on-surface-variant);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.comment-engagement {
|
||
font-size: 12px;
|
||
color: var(--on-surface-variant);
|
||
}
|
||
|
||
/* GitHub specific styles */
|
||
.github-chart-explosive {
|
||
position: relative;
|
||
}
|
||
|
||
.milestone-marker {
|
||
position: absolute;
|
||
font-size: 11px;
|
||
font-family: var(--font-display);
|
||
font-weight: 600;
|
||
color: var(--primary);
|
||
background: rgba(255, 215, 0, 0.15);
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--primary-container);
|
||
box-shadow: 0 0 16px rgba(255, 215, 0, 0.3);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!--
|
||
Global SVG defs — every avatar / brand mark on the page references
|
||
the same teal-to-mint gradient. Defining it once here avoids per-SVG
|
||
duplication and ensures consistent rendering when the page is
|
||
captured headlessly.
|
||
-->
|
||
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
|
||
<defs>
|
||
<linearGradient id="brandRing" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" stop-color="#7CFFB4"/>
|
||
<stop offset="100%" stop-color="#1FE3D6"/>
|
||
</linearGradient>
|
||
</defs>
|
||
</svg>
|
||
|
||
<!-- Header -->
|
||
<header data-od-id="header">
|
||
<div class="header-left">
|
||
<div class="logo-block">
|
||
<svg class="brand-mark" viewBox="0 0 64 64" width="32" height="32" aria-label="Open Design">
|
||
<circle cx="32" cy="32" r="28" fill="#1c1c20" stroke="url(#brandRing)" stroke-width="3"/>
|
||
<path d="M22 22 L46 32 L26 36 L22 46 Z" fill="none" stroke="url(#brandRing)" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/>
|
||
</svg>
|
||
<span class="logo-label">ANALYTICS</span>
|
||
</div>
|
||
<div class="platform-switcher-inline">
|
||
<button class="platform-pill active" data-platform="x">X</button>
|
||
<button class="platform-pill" data-platform="github">GitHub</button>
|
||
</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="time-range-tabs">
|
||
<button class="time-tab active">7D</button>
|
||
<button class="time-tab">30D</button>
|
||
<button class="time-tab">90D</button>
|
||
<button class="time-tab">YTD</button>
|
||
</div>
|
||
<div class="user-block">
|
||
<svg class="user-avatar" viewBox="0 0 64 64" aria-label="Open Design avatar">
|
||
<circle cx="32" cy="32" r="30" fill="#1c1c20" stroke="url(#brandRing)" stroke-width="2.5"/>
|
||
<path d="M22 22 L46 32 L26 36 L22 46 Z" fill="none" stroke="url(#brandRing)" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>
|
||
</svg>
|
||
<div class="user-info">
|
||
<div class="user-name">Open Design</div>
|
||
<div class="user-role">Design agent · by Nexu Labs</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Hero Strip -->
|
||
<section class="hero-strip" data-od-id="hero">
|
||
<div class="hero-content">
|
||
<div class="hero-text">
|
||
<h1 class="hero-headline" id="hero-headline">Open Design shipped 9 posts on X this week.</h1>
|
||
<p class="hero-meta" id="hero-meta">Engagement up 0.6% vs. last week · Top thread reached 38K accounts</p>
|
||
</div>
|
||
<div class="hero-actions">
|
||
<button class="btn btn-secondary">Export report</button>
|
||
<button class="btn btn-primary" id="hero-cta">New post →</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Ask Bar -->
|
||
<section class="ask-bar" data-od-id="ask">
|
||
<input
|
||
type="text"
|
||
class="ask-input"
|
||
id="ask-input"
|
||
placeholder="Ask across your posts — 'top performing last week', 'comments from verified accounts'..."
|
||
>
|
||
</section>
|
||
|
||
<!-- Main Container -->
|
||
<div class="container">
|
||
<!-- Platform Cards -->
|
||
<div class="platform-cards" data-od-id="platform-switcher">
|
||
<div class="platform-card active" data-platform-card="x">
|
||
<div class="platform-card-header">
|
||
<span class="platform-icon">𝕏</span>
|
||
<div>
|
||
<div class="platform-name">X (Twitter)</div>
|
||
<div class="platform-handle">@opendesignhq</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="platform-card" data-platform-card="github">
|
||
<div class="platform-card-header">
|
||
<span class="platform-icon">⚙</span>
|
||
<div>
|
||
<div class="platform-name">GitHub</div>
|
||
<div class="platform-handle">nexu-io/open-design</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KPI Row -->
|
||
<div class="kpi-row" data-od-id="kpis">
|
||
<div class="kpi-card">
|
||
<div class="kpi-label" id="kpi-1-label">Followers</div>
|
||
<div class="kpi-value" id="kpi-1-value">14,287</div>
|
||
<div class="kpi-delta">↗ <span id="kpi-1-delta">+2.3%</span></div>
|
||
<div class="kpi-footnote" id="kpi-1-footnote">vs. 13,968 last week</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-label" id="kpi-2-label">Engagement Rate</div>
|
||
<div class="kpi-value" id="kpi-2-value">4.80%</div>
|
||
<div class="kpi-delta">↗ <span id="kpi-2-delta">+0.4%</span></div>
|
||
<div class="kpi-footnote" id="kpi-2-footnote">vs. 4.4% last week</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-label" id="kpi-3-label">Likes (7D)</div>
|
||
<div class="kpi-value" id="kpi-3-value">1,842</div>
|
||
<div class="kpi-delta">↗ <span id="kpi-3-delta">+18%</span></div>
|
||
<div class="kpi-footnote" id="kpi-3-footnote">+280 vs. last week</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-label" id="kpi-4-label">Reposts (7D)</div>
|
||
<div class="kpi-value" id="kpi-4-value">487</div>
|
||
<div class="kpi-delta">↗ <span id="kpi-4-delta">+22%</span></div>
|
||
<div class="kpi-footnote" id="kpi-4-footnote">+88 vs. last week</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Grid (3 columns) -->
|
||
<div class="main-grid">
|
||
<!-- Growth Chart -->
|
||
<div class="chart-card" data-od-id="growth-chart">
|
||
<h2 class="chart-header" id="chart-title">Follower Growth · 30D</h2>
|
||
<div class="github-chart-explosive">
|
||
<svg class="chart-svg" id="chart-svg" viewBox="0 0 600 280"></svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Geo Distribution -->
|
||
<div class="geo-card" data-od-id="geo-distribution">
|
||
<h2 class="chart-header">Audience Geography</h2>
|
||
<div class="geo-map">
|
||
<svg width="100%" height="200" viewBox="0 0 400 200" preserveAspectRatio="xMidYMid meet">
|
||
<!-- Simplified world map: filled silhouettes for instant recognition -->
|
||
<g fill="rgba(208, 198, 171, 0.18)" stroke="rgba(208, 198, 171, 0.45)" stroke-width="0.6" stroke-linejoin="round">
|
||
<!-- North America -->
|
||
<path d="M 38,52 L 56,46 L 78,44 L 96,50 L 108,58 L 112,72 L 102,86 L 90,92 L 78,90 L 70,98 L 58,104 L 48,98 L 40,84 L 34,68 Z"/>
|
||
<!-- Central America strip -->
|
||
<path d="M 90,92 L 100,100 L 108,112 L 102,118 L 94,114 L 88,104 Z"/>
|
||
<!-- South America -->
|
||
<path d="M 110,118 L 122,120 L 130,134 L 134,150 L 130,164 L 122,176 L 114,168 L 110,150 L 108,134 Z"/>
|
||
<!-- Greenland -->
|
||
<path d="M 134,40 L 146,38 L 154,46 L 150,56 L 138,54 Z"/>
|
||
<!-- Europe -->
|
||
<path d="M 178,52 L 196,48 L 212,52 L 220,60 L 216,72 L 204,76 L 190,72 L 180,66 Z"/>
|
||
<!-- Africa -->
|
||
<path d="M 196,82 L 218,80 L 230,90 L 234,108 L 230,128 L 220,142 L 208,148 L 196,140 L 188,124 L 186,104 L 192,90 Z"/>
|
||
<!-- Middle East / South Asia bridge -->
|
||
<path d="M 230,80 L 248,76 L 256,86 L 252,96 L 240,98 L 230,92 Z"/>
|
||
<!-- Asia (main mass) -->
|
||
<path d="M 220,52 L 248,46 L 280,44 L 308,48 L 326,58 L 332,72 L 322,84 L 304,88 L 286,84 L 268,82 L 250,76 L 232,68 L 222,60 Z"/>
|
||
<!-- Southeast Asia islands -->
|
||
<path d="M 296,98 L 308,100 L 318,108 L 312,116 L 300,114 Z"/>
|
||
<!-- Australia -->
|
||
<path d="M 308,138 L 332,136 L 346,144 L 344,156 L 328,162 L 314,158 L 306,150 Z"/>
|
||
<!-- Japan -->
|
||
<path d="M 332,68 L 340,72 L 338,82 L 332,80 Z"/>
|
||
<!-- UK / Ireland -->
|
||
<path d="M 178,60 L 184,58 L 184,66 L 178,66 Z"/>
|
||
<!-- New Zealand -->
|
||
<path d="M 358,164 L 366,168 L 364,176 L 358,172 Z"/>
|
||
</g>
|
||
|
||
<!-- Audience cluster dots, anchored on real continents -->
|
||
<circle cx="78" cy="72" r="6" class="geo-dot" style="animation-delay: 0s"/> <!-- USA -->
|
||
<circle cx="200" cy="62" r="8" class="geo-dot" style="animation-delay: 0.3s"/> <!-- Europe -->
|
||
<circle cx="276" cy="62" r="5" class="geo-dot" style="animation-delay: 0.6s"/> <!-- China -->
|
||
<circle cx="334" cy="74" r="4" class="geo-dot" style="animation-delay: 0.9s"/> <!-- Japan -->
|
||
<circle cx="120" cy="146" r="5" class="geo-dot" style="animation-delay: 1.2s"/> <!-- Brazil -->
|
||
<circle cx="328" cy="148" r="4" class="geo-dot" style="animation-delay: 1.5s"/> <!-- Australia -->
|
||
</svg>
|
||
</div>
|
||
<div class="geo-stats">
|
||
<div class="geo-stat">
|
||
<div class="geo-stat-label">North America</div>
|
||
<div class="geo-stat-value">38%</div>
|
||
</div>
|
||
<div class="geo-stat">
|
||
<div class="geo-stat-label">Europe</div>
|
||
<div class="geo-stat-value">31%</div>
|
||
</div>
|
||
<div class="geo-stat">
|
||
<div class="geo-stat-label">Asia-Pacific</div>
|
||
<div class="geo-stat-value">22%</div>
|
||
</div>
|
||
<div class="geo-stat">
|
||
<div class="geo-stat-label">Other</div>
|
||
<div class="geo-stat-value">9%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Top Post / Contributors -->
|
||
<div class="top-post-card" data-od-id="top-post">
|
||
<div id="post-section">
|
||
<div class="post-header-tag" id="post-tag">Click-through rate 5.8%</div>
|
||
<div class="post-meta">
|
||
<svg class="post-avatar" viewBox="0 0 64 64" aria-label="Open Design avatar">
|
||
<circle cx="32" cy="32" r="30" fill="#1c1c20" stroke="url(#brandRing)" stroke-width="2.5"/>
|
||
<path d="M22 22 L46 32 L26 36 L22 46 Z" fill="none" stroke="url(#brandRing)" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>
|
||
</svg>
|
||
<div class="post-handle-block">
|
||
<div class="post-handle">
|
||
@opendesignhq
|
||
<span class="verified-badge">✓</span>
|
||
</div>
|
||
<div class="post-timestamp">4h</div>
|
||
</div>
|
||
</div>
|
||
<div class="post-body" id="post-body">
|
||
Live artifacts just shipped. Paste a DESIGN.md, get a dashboard that actually ticks. Video below.
|
||
</div>
|
||
<div class="post-media" aria-label="Video thumbnail">
|
||
<svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice">
|
||
<defs>
|
||
<radialGradient id="postMediaGlow" cx="50%" cy="50%" r="55%">
|
||
<stop offset="0%" stop-color="rgba(255,215,0,0.55)"/>
|
||
<stop offset="60%" stop-color="rgba(255,215,0,0.15)"/>
|
||
<stop offset="100%" stop-color="rgba(18,19,24,0)"/>
|
||
</radialGradient>
|
||
<linearGradient id="postMediaLine" x1="0%" y1="100%" x2="100%" y2="0%">
|
||
<stop offset="0%" stop-color="rgba(189,244,255,0.0)"/>
|
||
<stop offset="50%" stop-color="rgba(189,244,255,0.65)"/>
|
||
<stop offset="100%" stop-color="rgba(255,215,0,0.95)"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="320" height="180" fill="rgba(13,14,19,0.85)"/>
|
||
<circle cx="160" cy="92" r="80" fill="url(#postMediaGlow)"/>
|
||
<!-- Faint chart curve evoking the live dashboard inside the video -->
|
||
<path d="M 8,148 C 60,140 90,128 120,114 S 180,84 220,68 S 280,38 312,30"
|
||
fill="none" stroke="url(#postMediaLine)" stroke-width="2.5" stroke-linecap="round"/>
|
||
<!-- Three pulse dots along the curve, suggesting live data -->
|
||
<circle cx="120" cy="114" r="3.5" fill="rgba(189,244,255,0.9)"/>
|
||
<circle cx="220" cy="68" r="4" fill="rgba(255,215,0,0.95)"/>
|
||
<circle cx="312" cy="30" r="3.5" fill="rgba(255,215,0,0.9)"/>
|
||
<!-- Play icon centered -->
|
||
<g transform="translate(146,80)">
|
||
<circle cx="14" cy="14" r="14" fill="rgba(18,19,24,0.6)" stroke="rgba(255,215,0,0.9)" stroke-width="1.5"/>
|
||
<path d="M 11,8 L 11,20 L 21,14 Z" fill="rgba(255,215,0,0.95)"/>
|
||
</g>
|
||
</svg>
|
||
<span class="post-media-caption">Live · 0:22</span>
|
||
</div>
|
||
<div class="post-engagement" id="post-engagement">
|
||
<span>❤ <span id="post-likes">312</span></span>
|
||
<span>🔁 <span id="post-reposts">48</span></span>
|
||
<span>💬 <span id="post-comments">22</span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="contributors-section" style="display: none;">
|
||
<h3 class="section-title">Top Contributors</h3>
|
||
<div class="contributors-grid" id="contributors-grid"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lower Grid -->
|
||
<div class="lower-grid">
|
||
<!-- Trending -->
|
||
<div class="trending-card" data-od-id="trending">
|
||
<h3 class="section-title" id="trending-title">Trending in AI × Design</h3>
|
||
<div id="trending-list">
|
||
<div class="trending-row">
|
||
<div>
|
||
<span class="trending-name">design.md</span>
|
||
<span class="trending-count">142 posts</span>
|
||
</div>
|
||
<svg class="sparkline" viewBox="0 0 60 20">
|
||
<polyline points="0,15 10,12 20,14 30,10 40,8 50,5 60,3" fill="none" stroke="var(--primary)" stroke-width="2"/>
|
||
</svg>
|
||
</div>
|
||
<div class="trending-row">
|
||
<div>
|
||
<span class="trending-name">claude design</span>
|
||
<span class="trending-count">98 posts</span>
|
||
</div>
|
||
<svg class="sparkline" viewBox="0 0 60 20">
|
||
<polyline points="0,18 10,16 20,14 30,12 40,9 50,7 60,4" fill="none" stroke="var(--primary)" stroke-width="2"/>
|
||
</svg>
|
||
</div>
|
||
<div class="trending-row">
|
||
<div>
|
||
<span class="trending-name">live artifacts</span>
|
||
<span class="trending-count">87 posts</span>
|
||
</div>
|
||
<svg class="sparkline" viewBox="0 0 60 20">
|
||
<polyline points="0,16 10,15 20,13 30,11 40,10 50,6 60,2" fill="none" stroke="var(--primary)" stroke-width="2"/>
|
||
</svg>
|
||
</div>
|
||
<div class="trending-row">
|
||
<div>
|
||
<span class="trending-name">vibecoding</span>
|
||
<span class="trending-count">72 posts</span>
|
||
</div>
|
||
<svg class="sparkline" viewBox="0 0 60 20">
|
||
<polyline points="0,17 10,16 20,15 30,13 40,11 50,8 60,5" fill="none" stroke="var(--primary)" stroke-width="2"/>
|
||
</svg>
|
||
</div>
|
||
<div class="trending-row">
|
||
<div>
|
||
<span class="trending-name">agent os</span>
|
||
<span class="trending-count">64 posts</span>
|
||
</div>
|
||
<svg class="sparkline" viewBox="0 0 60 20">
|
||
<polyline points="0,18 10,17 20,16 30,14 40,12 50,10 60,7" fill="none" stroke="var(--primary)" stroke-width="2"/>
|
||
</svg>
|
||
</div>
|
||
<div class="trending-row">
|
||
<div>
|
||
<span class="trending-name">dogfooding</span>
|
||
<span class="trending-count">58 posts</span>
|
||
</div>
|
||
<svg class="sparkline" viewBox="0 0 60 20">
|
||
<polyline points="0,19 10,18 20,17 30,15 40,14 50,11 60,8" fill="none" stroke="var(--primary)" stroke-width="2"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Top Comments -->
|
||
<div class="comments-card" data-od-id="top-comments">
|
||
<h3 class="section-title" id="comments-title">Top Comments · This Week</h3>
|
||
<div id="comments-list">
|
||
<div class="comment-item">
|
||
<div class="comment-meta">
|
||
<div class="comment-avatar">JD</div>
|
||
<span class="comment-handle">@designerjames</span>
|
||
<span class="verified-badge">✓</span>
|
||
<span class="comment-time">2d</span>
|
||
</div>
|
||
<div class="comment-body">This is insane. The annotation dots on the chart especially — that's the level of craft I want from design tools.</div>
|
||
<div class="comment-engagement">❤ 87 · 💬 12</div>
|
||
</div>
|
||
<div class="comment-item">
|
||
<div class="comment-meta">
|
||
<div class="comment-avatar">SK</div>
|
||
<span class="comment-handle">@sarahkim_design</span>
|
||
<span class="comment-time">3d</span>
|
||
</div>
|
||
<div class="comment-body">Finally — a design system tool that doesn't feel like it was built by engineers for engineers. The glass aesthetic is 🔥</div>
|
||
<div class="comment-engagement">❤ 64 · 💬 8</div>
|
||
</div>
|
||
<div class="comment-item">
|
||
<div class="comment-meta">
|
||
<div class="comment-avatar">MC</div>
|
||
<span class="comment-handle">@mikecode</span>
|
||
<span class="verified-badge">✓</span>
|
||
<span class="comment-time">5d</span>
|
||
</div>
|
||
<div class="comment-body">Tried the live artifacts preview today. Went from DESIGN.md → working dashboard in 90 seconds. This changes everything.</div>
|
||
<div class="comment-engagement">❤ 112 · 💬 24</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Real GitHub contributors — only verified avatar IDs to avoid 404s
|
||
const githubContributors = [
|
||
{ login: "pftom", avatar: "https://avatars.githubusercontent.com/u/26423749?v=4", contributions: 26 },
|
||
{ login: "lefarcen", avatar: "https://avatars.githubusercontent.com/u/20859779?v=4", contributions: 18 },
|
||
{ login: "Sid-Qin", avatar: "https://avatars.githubusercontent.com/u/201593046?v=4", contributions: 17 },
|
||
{ login: "nettee", avatar: "https://avatars.githubusercontent.com/u/3953668?v=4", contributions: 10 },
|
||
{ login: "alchemistklk", avatar: "https://avatars.githubusercontent.com/u/56862773?v=4", contributions: 8 }
|
||
];
|
||
|
||
// Platform data
|
||
const platformData = {
|
||
x: {
|
||
heroHeadline: "Open Design shipped 9 posts on X this week.",
|
||
heroMeta: "Engagement up 0.6% vs. last week · Top thread reached 38K accounts",
|
||
heroCta: "New post →",
|
||
askPlaceholder: "Ask across your posts — 'top performing last week', 'comments from verified accounts'...",
|
||
kpis: [
|
||
{ label: "Followers", value: 14287, format: "int", delta: "+2.3%", footnote: "vs. 13,968 last week" },
|
||
{ label: "Engagement Rate", value: 4.8, format: "percent", delta: "+0.4%", footnote: "vs. 4.4% last week" },
|
||
{ label: "Likes (7D)", value: 1842, format: "int", delta: "+18%", footnote: "+280 vs. last week" },
|
||
{ label: "Reposts (7D)", value: 487, format: "int", delta: "+22%", footnote: "+88 vs. last week" }
|
||
],
|
||
chartTitle: "Follower Growth · 30D",
|
||
chartType: "smooth",
|
||
chartAnnotations: [
|
||
{ x: 180, y: 120, label: "Thread: live artifacts +412" },
|
||
{ x: 480, y: 60, label: "Launch week +1.1K" }
|
||
],
|
||
postTag: "Click-through rate 5.8%",
|
||
postBody: "Live artifacts just shipped. Paste a DESIGN.md, get a dashboard that actually ticks. Video below.",
|
||
postEngagement: { likes: 312, reposts: 48, comments: 22 },
|
||
trendingTitle: "Trending in AI × Design",
|
||
commentsTitle: "Top Comments · This Week"
|
||
},
|
||
github: {
|
||
heroHeadline: "Open Design hit 30K stars on GitHub — 9 days in.",
|
||
heroMeta: "Explosive growth: 3 days → 10K, 9 days → 30K · 127 contributors total",
|
||
heroCta: "New release →",
|
||
askPlaceholder: "Ask across your repo — 'stale PRs', 'issues tagged good-first-issue'...",
|
||
kpis: [
|
||
{ label: "Stars", value: 30124, format: "int", delta: "+3,284%", footnote: "9 days since launch" },
|
||
{ label: "Forks", value: 2847, format: "int", delta: "+412%", footnote: "vs. 556 at day 3" },
|
||
{ label: "Pull Requests (7D)", value: 38, format: "int", delta: "+156%", footnote: "18 merged this week" },
|
||
{ label: "Contributors", value: 127, format: "int", delta: "+217%", footnote: "vs. 40 at day 3" }
|
||
],
|
||
chartTitle: "Star Growth · Explosive Trajectory (9D)",
|
||
chartType: "explosive",
|
||
chartAnnotations: [
|
||
{ x: 180, y: 180, label: "Day 3 · 10K stars" },
|
||
{ x: 540, y: 20, label: "Day 9 · 30K stars" }
|
||
],
|
||
postTag: "127 contributors · 30K stars",
|
||
postBody: "From 0 → 30K stars in 9 days. This is the fastest-growing design tool in GitHub history.",
|
||
postEngagement: { likes: 842, reposts: 217, comments: 94 },
|
||
trendingTitle: "Top Issues & Discussions",
|
||
commentsTitle: "Top Issue Replies · This Week"
|
||
}
|
||
};
|
||
|
||
let activePlatform = 'x';
|
||
let chartBaseData = [];
|
||
|
||
// Generate chart data based on platform
|
||
function generateChartData(platform) {
|
||
if (platform === 'x') {
|
||
// Smooth follower growth
|
||
return Array.from({ length: 30 }, (_, i) => ({
|
||
x: i * 20,
|
||
y: 200 - (i * 4) + Math.sin(i / 3) * 20
|
||
}));
|
||
} else {
|
||
// Explosive GitHub star growth (9 days, steep curve)
|
||
return [
|
||
{ x: 0, y: 250 }, // Day 0
|
||
{ x: 60, y: 240 }, // Day 1
|
||
{ x: 120, y: 220 }, // Day 2
|
||
{ x: 180, y: 180 }, // Day 3: 10K milestone
|
||
{ x: 240, y: 140 }, // Day 4
|
||
{ x: 300, y: 110 }, // Day 5
|
||
{ x: 360, y: 80 }, // Day 6
|
||
{ x: 420, y: 60 }, // Day 7
|
||
{ x: 480, y: 40 }, // Day 8
|
||
{ x: 540, y: 20 } // Day 9: 30K milestone
|
||
];
|
||
}
|
||
}
|
||
|
||
// Initialize chart
|
||
function renderChart() {
|
||
const svg = document.getElementById('chart-svg');
|
||
svg.innerHTML = '';
|
||
const data = platformData[activePlatform];
|
||
chartBaseData = generateChartData(activePlatform);
|
||
|
||
// Background grid
|
||
for (let i = 0; i <= 5; i++) {
|
||
const y = i * 56;
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||
line.setAttribute('x1', '0');
|
||
line.setAttribute('y1', y);
|
||
line.setAttribute('x2', '600');
|
||
line.setAttribute('y2', y);
|
||
line.setAttribute('stroke', 'var(--outline-variant)');
|
||
line.setAttribute('stroke-width', '0.5');
|
||
line.setAttribute('opacity', '0.3');
|
||
svg.appendChild(line);
|
||
}
|
||
|
||
// Area fill
|
||
const areaPoints = chartBaseData.map(d => `${d.x},${d.y}`).join(' ');
|
||
const area = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||
area.setAttribute('points', `0,280 ${areaPoints} 600,280`);
|
||
area.setAttribute('fill', 'rgba(255, 215, 0, 0.12)');
|
||
svg.appendChild(area);
|
||
|
||
// Line
|
||
const linePoints = chartBaseData.map(d => `${d.x},${d.y}`).join(' ');
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
||
line.setAttribute('points', linePoints);
|
||
line.setAttribute('fill', 'none');
|
||
line.setAttribute('stroke', 'var(--primary-container)');
|
||
line.setAttribute('stroke-width', activePlatform === 'github' ? '4' : '3');
|
||
line.setAttribute('filter', 'drop-shadow(0 0 8px rgba(255, 215, 0, 0.4))');
|
||
line.id = 'chart-line';
|
||
svg.appendChild(line);
|
||
|
||
// Annotations — place label above the dot when there's room overhead,
|
||
// otherwise place it below so the text never collides with the curve.
|
||
data.chartAnnotations.forEach(ann => {
|
||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||
circle.setAttribute('cx', ann.x);
|
||
circle.setAttribute('cy', ann.y);
|
||
circle.setAttribute('r', '7');
|
||
circle.setAttribute('fill', 'var(--primary-container)');
|
||
circle.setAttribute('stroke', 'var(--surface)');
|
||
circle.setAttribute('stroke-width', '3');
|
||
circle.setAttribute('filter', 'drop-shadow(0 0 12px rgba(255, 215, 0, 0.6))');
|
||
svg.appendChild(circle);
|
||
|
||
// Anchor near edges so the label doesn't get cropped on the right side.
|
||
const anchor = ann.x > 540 ? 'end' : (ann.x < 60 ? 'start' : 'middle');
|
||
// Below the dot when the dot is too high to leave label room.
|
||
const placeBelow = ann.y < 40;
|
||
const labelY = placeBelow ? ann.y + 22 : ann.y - 14;
|
||
|
||
// Soft pill background behind the text so it stays legible across the
|
||
// gold gradient + chart line, without using a heavy rect.
|
||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
text.setAttribute('x', ann.x);
|
||
text.setAttribute('y', labelY);
|
||
text.setAttribute('fill', 'var(--primary)');
|
||
text.setAttribute('font-size', '12');
|
||
text.setAttribute('font-family', 'var(--font-display)');
|
||
text.setAttribute('font-weight', '600');
|
||
text.setAttribute('text-anchor', anchor);
|
||
text.setAttribute('paint-order', 'stroke');
|
||
text.setAttribute('stroke', 'rgba(18, 19, 24, 0.85)');
|
||
text.setAttribute('stroke-width', '4');
|
||
text.setAttribute('stroke-linejoin', 'round');
|
||
text.setAttribute('filter', 'drop-shadow(0 0 8px rgba(255, 215, 0, 0.35))');
|
||
text.textContent = ann.label;
|
||
svg.appendChild(text);
|
||
});
|
||
}
|
||
|
||
// Render contributors
|
||
function renderContributors() {
|
||
const grid = document.getElementById('contributors-grid');
|
||
grid.innerHTML = '';
|
||
githubContributors.forEach(contributor => {
|
||
const item = document.createElement('div');
|
||
item.className = 'contributor-item';
|
||
item.innerHTML = `
|
||
<img src="${contributor.avatar}" alt="${contributor.login}" class="contributor-avatar"
|
||
onerror="this.style.display='none'; this.nextElementSibling.style.fontWeight='600';">
|
||
<div class="contributor-name">${contributor.login}</div>
|
||
<div class="contributor-commits">${contributor.contributions} commits</div>
|
||
`;
|
||
grid.appendChild(item);
|
||
});
|
||
}
|
||
|
||
// Switch platform
|
||
function switchPlatform(platform) {
|
||
activePlatform = platform;
|
||
const data = platformData[platform];
|
||
|
||
// Update hero
|
||
document.getElementById('hero-headline').textContent = data.heroHeadline;
|
||
document.getElementById('hero-meta').textContent = data.heroMeta;
|
||
document.getElementById('hero-cta').textContent = data.heroCta;
|
||
|
||
// Update ask bar
|
||
document.getElementById('ask-input').placeholder = data.askPlaceholder;
|
||
|
||
// Update KPIs
|
||
data.kpis.forEach((kpi, i) => {
|
||
document.getElementById(`kpi-${i + 1}-label`).textContent = kpi.label;
|
||
document.getElementById(`kpi-${i + 1}-value`).textContent = formatKpi(kpi);
|
||
document.getElementById(`kpi-${i + 1}-delta`).textContent = kpi.delta;
|
||
document.getElementById(`kpi-${i + 1}-footnote`).textContent = kpi.footnote;
|
||
});
|
||
|
||
// Update chart
|
||
document.getElementById('chart-title').textContent = data.chartTitle;
|
||
renderChart();
|
||
|
||
// Toggle between post and contributors
|
||
if (platform === 'github') {
|
||
document.getElementById('post-section').style.display = 'none';
|
||
document.getElementById('contributors-section').style.display = 'block';
|
||
renderContributors();
|
||
} else {
|
||
document.getElementById('post-section').style.display = 'block';
|
||
document.getElementById('contributors-section').style.display = 'none';
|
||
}
|
||
|
||
// Update top post/stats
|
||
document.getElementById('post-tag').textContent = data.postTag;
|
||
document.getElementById('post-body').textContent = data.postBody;
|
||
document.getElementById('post-likes').textContent = data.postEngagement.likes;
|
||
document.getElementById('post-reposts').textContent = data.postEngagement.reposts;
|
||
document.getElementById('post-comments').textContent = data.postEngagement.comments;
|
||
|
||
// Update section titles
|
||
document.getElementById('trending-title').textContent = data.trendingTitle;
|
||
document.getElementById('comments-title').textContent = data.commentsTitle;
|
||
|
||
// Update active states
|
||
document.querySelectorAll('.platform-pill').forEach(pill => {
|
||
pill.classList.toggle('active', pill.dataset.platform === platform);
|
||
});
|
||
document.querySelectorAll('.platform-card').forEach(card => {
|
||
card.classList.toggle('active', card.dataset.platformCard === platform);
|
||
});
|
||
}
|
||
|
||
// Format a KPI value for display, based on its `format` flag.
|
||
function formatKpi(kpi) {
|
||
if (kpi.format === 'percent') return kpi.value.toFixed(2) + '%';
|
||
if (typeof kpi.value === 'number') return kpi.value.toLocaleString();
|
||
return String(kpi.value);
|
||
}
|
||
|
||
// Live updates — every 2 seconds, nudge each numeric KPI by a small,
|
||
// realistic delta. Counters tick up; the engagement rate jitters by a
|
||
// fraction of a percent so it visibly breathes without drifting absurdly.
|
||
function animateNumbers() {
|
||
const data = platformData[activePlatform];
|
||
data.kpis.forEach((kpi, i) => {
|
||
const valueEl = document.getElementById(`kpi-${i + 1}-value`);
|
||
if (kpi.format === 'percent') {
|
||
// Random walk in ±0.04% range, clamped to a believable band.
|
||
kpi.value += (Math.random() - 0.5) * 0.08;
|
||
kpi.value = Math.max(3.5, Math.min(6.0, kpi.value));
|
||
valueEl.textContent = formatKpi(kpi);
|
||
} else if (typeof kpi.value === 'number') {
|
||
const delta = Math.floor(Math.random() * (activePlatform === 'github' ? 8 : 3)) + 1;
|
||
kpi.value += delta;
|
||
valueEl.textContent = formatKpi(kpi);
|
||
}
|
||
});
|
||
|
||
// Update post engagement
|
||
const postData = data.postEngagement;
|
||
postData.likes += Math.floor(Math.random() * (activePlatform === 'github' ? 4 : 2));
|
||
postData.reposts += Math.random() > 0.7 ? 1 : 0;
|
||
postData.comments += Math.random() > 0.8 ? 1 : 0;
|
||
document.getElementById('post-likes').textContent = postData.likes;
|
||
document.getElementById('post-reposts').textContent = postData.reposts;
|
||
document.getElementById('post-comments').textContent = postData.comments;
|
||
|
||
// Breathe the chart
|
||
if (activePlatform === 'x') {
|
||
const lastPoint = chartBaseData[chartBaseData.length - 1];
|
||
lastPoint.y += (Math.random() - 0.5) * 4;
|
||
lastPoint.y = Math.max(40, Math.min(200, lastPoint.y));
|
||
|
||
const linePoints = chartBaseData.map(d => `${d.x},${d.y}`).join(' ');
|
||
const line = document.getElementById('chart-line');
|
||
if (line) {
|
||
line.setAttribute('points', linePoints);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Event listeners
|
||
document.querySelectorAll('.platform-pill').forEach(pill => {
|
||
pill.addEventListener('click', () => switchPlatform(pill.dataset.platform));
|
||
});
|
||
|
||
document.querySelectorAll('.platform-card').forEach(card => {
|
||
card.addEventListener('click', () => switchPlatform(card.dataset.platformCard));
|
||
});
|
||
|
||
// Initialize
|
||
renderChart();
|
||
setInterval(animateNumbers, 2000);
|
||
</script>
|
||
</body>
|
||
</html>
|