diff --git a/.cursor/rules/og-playwright.mdc b/.cursor/rules/og-playwright.mdc index 7e1dc71..d867742 100644 --- a/.cursor/rules/og-playwright.mdc +++ b/.cursor/rules/og-playwright.mdc @@ -7,7 +7,7 @@ alwaysApply: true Social preview images (1200×630 WebP) are generated by the **isolated** `website/og/` package. Playwright is intentionally **not** in the root `package.json` (CI stays lean). -## One-time human setup (Holger's machine) +## One-time human setup (Holger's machine only) From `feelyourprotocol/website/`: @@ -16,16 +16,37 @@ npm run og:setup # npm install in og/ + download Chromium (~once per machine) npm run og:check # should print: OK: Playwright Chromium is installed and launchable. ``` -Re-run `og:setup` after Playwright major upgrades or on a new machine. Browsers live in the normal OS cache (`~/Library/Caches/ms-playwright` on macOS), not in the repo. +Re-run `og:setup` after Playwright major upgrades or on a new machine. Browsers live in the normal OS cache (`~/Library/Caches/ms-playwright` on macOS), not in the repo. Successful `og:check` writes `og/.chromium-ready.json` (gitignored) so agents can tell setup already happened. -## Agent rules +**Holger has already run this.** Do not run setup again unless he explicitly asks. -1. **Never** run ad-hoc `npx playwright install`, `node -e` with playwright, or GenerateImage as a substitute for OG pipeline output. -2. **Always** use the npm scripts from `website/`: +`og/` CLIs auto-redirect Cursor's sandbox `PLAYWRIGHT_BROWSERS_PATH` to the developer's real browser cache — that is why `required_permissions: ["all"]` plus **never** running `og:setup` is the correct agent workflow. + +## Agent rules (mandatory) + +### NEVER do these + +- `npm run og:setup`, `npm run setup --prefix og`, or any `playwright install` +- Ad-hoc `npx playwright install`, sandbox browser downloads, or `GenerateImage` as a substitute for OG pipeline output +- Retry setup when capture fails — that is almost never the fix + +### ALWAYS do these + +1. Run OG/capture commands with **`required_permissions: ["all"]`** so the agent uses the developer's installed browsers — the Cursor sandbox cache does not have Chromium. +2. Use the npm scripts from `website/`: - `npm run generate:og:exploration -- ` - `npm run generate:og:topic -- ` - `npm run generate:og:all` -3. Run OG commands with **`required_permissions: ["all"]`** so the agent uses the developer's installed browsers — the Cursor sandbox cache does not have Chromium. -4. If generation fails with "Playwright Chromium is not ready", **stop and ask the user** to run `npm run og:setup` locally. Do not attempt to install browsers inside the agent sandbox. + - `npm run capture:social [-- hero timeline board]` — roadmap Twitter cards (see `roadmap/social/README.md`) +3. Optional fast check (no browser launch): `npm run og:preflight` with `required_permissions: ["all"]` + - `status: ready` → proceed with capture/generate + - `status: needs_agent_permissions` → re-run the **same** command with `required_permissions: ["all"]`, not setup + - `status: needs_human_setup` → stop and ask the user to run `npm run og:check` in their terminal + +### When capture/generate fails + +1. If the error mentions **`required_permissions`** or **`Do NOT run og:setup`**: re-run with `required_permissions: ["all"]`. +2. If it still fails after `all` permissions: ask the user to run `npm run og:check` locally and paste the output. **Do not** run setup yourself. +3. If the error is a **selector timeout** or **404 on assets**: that is a code/path bug — fix the app or `og/src/social/config.ts`, not Playwright setup. See `og/README.md` for full usage. diff --git a/.cursor/rules/tests-with-new-code.mdc b/.cursor/rules/tests-with-new-code.mdc new file mode 100644 index 0000000..f92cb54 --- /dev/null +++ b/.cursor/rules/tests-with-new-code.mdc @@ -0,0 +1,39 @@ +--- +description: Add focused tests when introducing new modules or tooling +alwaysApply: true +--- + +# Tests with new code + +When you add **new modules, scripts, or non-trivial features** (not one-line fixes or copy-only edits), include a **solid but proportionate** test layer before calling the task done. + +## What to test + +- **Pure logic** — parsers, URL builders, config, data transforms: unit tests with clear inputs/outputs. +- **Vue components** — mount with `@vue/test-utils`; stub heavy children; assert structure, links, and key copy. +- **Cross-package contracts** — one source of truth (e.g. shared ids/enums); test that consumers stay aligned. +- **Skip** — one-off scripts, pure config/docs, or UI tweaks with no testable logic. + +## Where tests live + +Mirror production code: + +``` +src/libs/foo.ts → src/libs/__tests__/foo.spec.ts +roadmap/social/src/… → roadmap/social/src/__tests__/…spec.ts +og/src/social/… → og/src/social/__tests__/…spec.ts +``` + +## Before finishing + +1. Run targeted tests: `npx vitest run path/to/__tests__/` +2. Run the **full CI suite locally**: `npm run test:unit:ci` (all `src/`, `community-token/`, `roadmap/`, `og/` specs). +3. Run `npm run lf:ci` from `feelyourprotocol/website/`. + +Prefer **few well-named tests** over exhaustive coverage — test behavior that would hurt if it regressed. + +GitHub Actions (`.github/workflows/unit.yml`) runs `npm run test:unit:ci` on every push/PR — no per-folder CI wiring; match the include globs in `vitest.config.ts`. + +## Playwright / capture pipelines + +Do **not** require Playwright in CI for screenshot tools. Unit-test parsing, paths, and component rendering; leave browser capture as a documented manual/`og:setup` step (see `.cursor/rules/og-playwright.mdc`). diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 94fc7a9..c644e2f 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -35,4 +35,4 @@ jobs: - name: Run unit tests if: github.event_name != 'pull_request' || !contains(github.event.pull_request.labels.*.name, 'skip tests') - run: npx vitest run + run: npm run test:unit:ci diff --git a/README.md b/README.md index 9302543..5a1256b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ npm run og:check See [og/README.md](./og/README.md). +### Roadmap Twitter cards + +Thread visuals (timeline, roadmap board, hero) — see [roadmap/social/README.md](./roadmap/social/README.md): + +```bash +npm run capture:social +``` + ## Deployment Production builds (`dist/website`, `dist/docs`, `dist/community-token`, `dist/roadmap`) are **not** in the repo — the server runs `npm run build:deploy` after `git pull`. See `server-config/deployment/fyp_deploy.sh`. diff --git a/og/.gitignore b/og/.gitignore index 7aa4a0b..b213d0e 100644 --- a/og/.gitignore +++ b/og/.gitignore @@ -1,2 +1,3 @@ node_modules .tmp +.chromium-ready.json diff --git a/og/README.md b/og/README.md index e9064ac..bec132f 100644 --- a/og/README.md +++ b/og/README.md @@ -23,6 +23,10 @@ Browsers are stored in the **OS user cache** (e.g. `~/Library/Caches/ms-playwrig on macOS), not in git. Root `npm install` does **not** install `og/` deps — you must run `og:setup` explicitly. +`og/` entry scripts redirect Cursor's sandbox `PLAYWRIGHT_BROWSERS_PATH` to your +real user cache automatically — agents should not need setup again after you run +`og:check` once. + ### When to re-run setup - New machine or fresh clone @@ -36,6 +40,7 @@ From `website/`: ```bash npm run generate:og:exploration -- eip-7594 npm run generate:og:topic -- scaling +npm run generate:og:roadmap npm run generate:og:all ``` @@ -50,8 +55,36 @@ public/og/manifest.json The main site's SEO layer reads `manifest.json` and falls back to `public/og/default.webp` when a specific image has not been generated yet. +## Roadmap social cards (Twitter / threads) + +Timeline, roadmap board, and hero cards for `@FeelEthereum` threads — built from +the same Vue components as the live roadmap. See +[`roadmap/social/README.md`](../roadmap/social/README.md). + +```bash +npm run capture:social +``` + +Output: `roadmap/social/out/{hero,timeline,board}.{png,webp}` + +## Tests + +```bash +npx vitest run roadmap/social/src/__tests__/ og/src/social/__tests__/ +``` + +Unit tests cover card registry, CLI parsing, paths, and Vue frame rendering (no Playwright in CI). + ## Cursor / agent note -AI agents should use the npm scripts above with full shell permissions (`all`), -not install Playwright in a sandbox. If Chromium is missing, run `og:setup` -locally — see `.cursor/rules/og-playwright.mdc`. +AI agents must **not** run `og:setup` or `playwright install`. Use the npm scripts below with full shell permissions (`all`). If Chromium is missing, ask the human to run `og:setup` — see `.cursor/rules/og-playwright.mdc`. + +Quick agent preflight (no browser launch): + +```bash +npm run og:preflight +``` + +- `status: ready` — proceed with `generate:og:*` or `capture:social` +- `status: needs_agent_permissions` — re-run with `required_permissions: ["all"]` +- `status: needs_human_setup` — ask the user to run `npm run og:check` diff --git a/og/package.json b/og/package.json index 4ba06bd..15b4717 100644 --- a/og/package.json +++ b/og/package.json @@ -10,6 +10,8 @@ "scripts": { "setup": "npm install && npx playwright install chromium", "check": "node --experimental-strip-types src/check-cli.ts", + "preflight": "node --experimental-strip-types src/preflight-cli.ts", + "capture:social:run": "node --experimental-strip-types src/social-cli.ts", "generate": "node --experimental-strip-types src/cli.ts" }, "dependencies": { diff --git a/og/src/__tests__/bootstrap-playwright-env.spec.ts b/og/src/__tests__/bootstrap-playwright-env.spec.ts new file mode 100644 index 0000000..75ae596 --- /dev/null +++ b/og/src/__tests__/bootstrap-playwright-env.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { + bootstrapPlaywrightEnv, + defaultPlaywrightCacheDir, + isCursorSandboxBrowsersPath, +} from '../bootstrap-playwright-env.ts' + +describe('bootstrap-playwright-env', () => { + it('detects Cursor sandbox browser paths', () => { + expect( + isCursorSandboxBrowsersPath( + '/var/folders/xx/cursor-sandbox-cache/abc/playwright/chromium', + ), + ).toBe(true) + expect(isCursorSandboxBrowsersPath('/Users/holger/Library/Caches/ms-playwright')).toBe(false) + }) + + it('redirects sandbox PLAYWRIGHT_BROWSERS_PATH to user cache', () => { + const sandboxPath = '/tmp/cursor-sandbox-cache/abc/playwright' + process.env.PLAYWRIGHT_BROWSERS_PATH = sandboxPath + bootstrapPlaywrightEnv() + expect(process.env.PLAYWRIGHT_BROWSERS_PATH).toBe(defaultPlaywrightCacheDir()) + delete process.env.PLAYWRIGHT_BROWSERS_PATH + }) +}) diff --git a/og/src/__tests__/chromium-status.spec.ts b/og/src/__tests__/chromium-status.spec.ts new file mode 100644 index 0000000..4484794 --- /dev/null +++ b/og/src/__tests__/chromium-status.spec.ts @@ -0,0 +1,72 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { afterEach, describe, expect, it } from 'vitest' + +import { + AGENT_PERMISSIONS_HINT, + CHROMIUM_STAMP_PATH, + formatChromiumStatus, + launchFailureStatus, + OG_PACKAGE_ROOT, + playwrightPackageRoot, + readChromiumStamp, +} from '../chromium-status-core.ts' + +describe('chromium status (CI-safe — no playwright import)', () => { + afterEach(() => { + if (existsSync(CHROMIUM_STAMP_PATH)) rmSync(CHROMIUM_STAMP_PATH) + }) + + it('playwrightPackageRoot resolves under og/', () => { + expect(playwrightPackageRoot()).toBe(`${OG_PACKAGE_ROOT}/node_modules/playwright`) + }) + + it('reads and writes chromium stamp', () => { + const stamp = { + checkedAt: '2026-06-30T12:00:00.000Z', + executablePath: '/tmp/chromium', + playwrightVersion: '1.61.1', + } + mkdirSync(OG_PACKAGE_ROOT, { recursive: true }) + writeFileSync(CHROMIUM_STAMP_PATH, JSON.stringify(stamp), 'utf8') + expect(readChromiumStamp()).toEqual(stamp) + }) +}) + +describe('formatChromiumStatus', () => { + it('never tells agents to run og:setup when permissions are the issue', () => { + const msg = formatChromiumStatus({ + kind: 'needs_agent_permissions', + executablePath: '/Users/holger/.cache/ms-playwright/chromium-1234/chrome', + stamp: { + checkedAt: '2026-06-30T12:00:00.000Z', + executablePath: '/Users/holger/.cache/ms-playwright/chromium-1234/chrome', + playwrightVersion: '1.61.1', + }, + }) + expect(msg).toContain(AGENT_PERMISSIONS_HINT) + expect(msg).not.toContain('npm run og:setup') + }) + + it('tells humans to run setup when og deps are missing', () => { + const msg = formatChromiumStatus({ kind: 'og_deps_missing' }) + expect(msg).toContain('npm run og:setup') + expect(msg).not.toContain('required_permissions') + }) +}) + +describe('launchFailureStatus', () => { + it('prefers agent permissions hint when stamp proves prior successful check', () => { + const status = launchFailureStatus('spawn EACCES', { + kind: 'ready', + executablePath: '/tmp/chromium', + stamp: { + checkedAt: '2026-06-30T12:00:00.000Z', + executablePath: '/tmp/chromium', + playwrightVersion: '1.61.1', + }, + }) + expect(status.kind).toBe('needs_agent_permissions') + expect(formatChromiumStatus(status)).toContain('required_permissions') + expect(formatChromiumStatus(status)).not.toContain('npm run og:setup') + }) +}) diff --git a/og/src/__tests__/generate-roadmap-og.spec.ts b/og/src/__tests__/generate-roadmap-og.spec.ts new file mode 100644 index 0000000..0a4c3e2 --- /dev/null +++ b/og/src/__tests__/generate-roadmap-og.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { OG_HEIGHT, OG_WIDTH, WEBSITE_ROOT } from '../config.ts' +import { + ROADMAP_OG_OUTPUT, + ROADMAP_OG_PUBLIC_DIR, + ROADMAP_OG_RENDER_HTML, +} from '../roadmap-og-paths.ts' + +describe('roadmap OG generator paths', () => { + it('render template and output resolve under roadmap/public/og', () => { + expect(ROADMAP_OG_PUBLIC_DIR).toBe(resolve(WEBSITE_ROOT, 'roadmap/public/og')) + expect(ROADMAP_OG_RENDER_HTML).toBe(resolve(ROADMAP_OG_PUBLIC_DIR, 'render.html')) + expect(ROADMAP_OG_OUTPUT).toBe(resolve(ROADMAP_OG_PUBLIC_DIR, 'default.webp')) + }) + + it('render.html declares standard OG viewport size', () => { + const html = readFileSync(ROADMAP_OG_RENDER_HTML, 'utf8') + expect(html).toContain(`width: ${OG_WIDTH}px`) + expect(html).toContain(`height: ${OG_HEIGHT}px`) + }) +}) diff --git a/og/src/__tests__/server.spec.ts b/og/src/__tests__/server.spec.ts new file mode 100644 index 0000000..11bc9e8 --- /dev/null +++ b/og/src/__tests__/server.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { resolveStaticFile } from '../server.ts' + +describe('resolveStaticFile', () => { + const root = '/tmp/fyp-social/dist' + + it('maps / to index.html', () => { + expect(resolveStaticFile(root, '/')).toBe(`${root}/index.html`) + }) + + it('maps empty path to index.html', () => { + expect(resolveStaticFile(root, '')).toBe(`${root}/index.html`) + }) + + it('maps asset paths without double root', () => { + expect(resolveStaticFile(root, '/assets/index.js')).toBe(`${root}/assets/index.js`) + }) + + it('rejects path traversal', () => { + expect(() => resolveStaticFile(root, '/../secret')).toThrow(/escapes static root/) + }) +}) diff --git a/og/src/bootstrap-playwright-env.ts b/og/src/bootstrap-playwright-env.ts new file mode 100644 index 0000000..a7139f0 --- /dev/null +++ b/og/src/bootstrap-playwright-env.ts @@ -0,0 +1,40 @@ +import { existsSync } from 'node:fs' +import { homedir, platform } from 'node:os' +import { join } from 'node:path' + +/** Playwright cache dir for the logged-in user (not Cursor's sandbox copy). */ +export function defaultPlaywrightCacheDir(home = homedir()): string { + switch (platform()) { + case 'darwin': + return join(home, 'Library/Caches/ms-playwright') + case 'win32': + return join(home, 'AppData', 'Local', 'ms-playwright') + default: + return join(home, '.cache', 'ms-playwright') + } +} + +export function isCursorSandboxBrowsersPath(path: string): boolean { + return /cursor-sandbox-cache|cursor-sandbox/i.test(path) +} + +/** + * Cursor injects PLAYWRIGHT_BROWSERS_PATH pointing at an empty sandbox cache. + * Redirect to the developer's real cache so og:check / capture use Holger's Chromium. + * Must run before any `playwright` import (side-effect import this module first). + */ +export function bootstrapPlaywrightEnv(): void { + const current = process.env.PLAYWRIGHT_BROWSERS_PATH ?? '' + const userCache = defaultPlaywrightCacheDir() + + if (current && isCursorSandboxBrowsersPath(current)) { + process.env.PLAYWRIGHT_BROWSERS_PATH = userCache + return + } + + if (!current && existsSync(userCache)) { + process.env.PLAYWRIGHT_BROWSERS_PATH = userCache + } +} + +bootstrapPlaywrightEnv() diff --git a/og/src/check-browsers.ts b/og/src/check-browsers.ts index e4dda94..8ad081a 100644 --- a/og/src/check-browsers.ts +++ b/og/src/check-browsers.ts @@ -1,14 +1,30 @@ import { chromium } from 'playwright' -const SETUP_HINT = 'Run once from website/: npm run og:setup' +import { + formatChromiumStatus, + inspectChromiumEnvironment, + launchFailureStatus, + writeChromiumStamp, +} from './chromium-status.ts' /** Fail fast with a clear message when Chromium was never installed for this package. */ export async function assertChromiumReady(): Promise { + const env = await inspectChromiumEnvironment() + + if (env.kind === 'og_deps_missing' || env.kind === 'browser_missing') { + throw new Error(formatChromiumStatus(env)) + } + + if (env.kind === 'browser_not_executable' || env.kind === 'needs_agent_permissions') { + throw new Error(formatChromiumStatus(env)) + } + let browser try { browser = await chromium.launch({ headless: true }) - } catch { - throw new Error(`Playwright Chromium is not ready.\n${SETUP_HINT}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(formatChromiumStatus(launchFailureStatus(message, env))) } finally { await browser?.close() } @@ -16,5 +32,7 @@ export async function assertChromiumReady(): Promise { export async function printChromiumStatus(): Promise { await assertChromiumReady() + const env = await inspectChromiumEnvironment() + if (env.executablePath) writeChromiumStamp(env.executablePath) console.log('OK: Playwright Chromium is installed and launchable.') } diff --git a/og/src/check-cli.ts b/og/src/check-cli.ts index 8d3c629..53a2af4 100644 --- a/og/src/check-cli.ts +++ b/og/src/check-cli.ts @@ -1,3 +1,5 @@ +import './bootstrap-playwright-env.ts' + import { printChromiumStatus } from './check-browsers.ts' printChromiumStatus().catch((err) => { diff --git a/og/src/chromium-status-core.ts b/og/src/chromium-status-core.ts new file mode 100644 index 0000000..3a9308a --- /dev/null +++ b/og/src/chromium-status-core.ts @@ -0,0 +1,116 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +export const OG_PACKAGE_ROOT = join(here, '..') +export const CHROMIUM_STAMP_PATH = join(OG_PACKAGE_ROOT, '.chromium-ready.json') + +export type ChromiumStatusKind = + | 'ready' + | 'og_deps_missing' + | 'browser_missing' + | 'browser_not_executable' + | 'needs_agent_permissions' + | 'launch_failed' + +export interface ChromiumStamp { + checkedAt: string + executablePath: string + playwrightVersion: string +} + +export interface ChromiumStatus { + kind: ChromiumStatusKind + executablePath?: string + stamp?: ChromiumStamp + detail?: string +} + +export const AGENT_PERMISSIONS_HINT = + 'Cursor agent: run OG/capture commands with required_permissions: ["all"]. Do NOT run og:setup or playwright install.' + +export const HUMAN_SETUP_HINT = 'Human (once per machine, from website/): npm run og:setup && npm run og:check' + +export function playwrightPackageRoot(): string { + return join(OG_PACKAGE_ROOT, 'node_modules', 'playwright') +} + +export function installedPlaywrightVersion(): string { + try { + const pkg = JSON.parse( + readFileSync(join(playwrightPackageRoot(), 'package.json'), 'utf8'), + ) as { version?: string } + return pkg.version ?? 'unknown' + } catch { + return 'unknown' + } +} + +export function readChromiumStamp(): ChromiumStamp | undefined { + if (!existsSync(CHROMIUM_STAMP_PATH)) return undefined + try { + return JSON.parse(readFileSync(CHROMIUM_STAMP_PATH, 'utf8')) as ChromiumStamp + } catch { + return undefined + } +} + +export function writeChromiumStamp(executablePath: string): ChromiumStamp { + const stamp: ChromiumStamp = { + checkedAt: new Date().toISOString(), + executablePath, + playwrightVersion: installedPlaywrightVersion(), + } + writeFileSync(CHROMIUM_STAMP_PATH, `${JSON.stringify(stamp, null, 2)}\n`, 'utf8') + return stamp +} + +export function formatChromiumStatus(status: ChromiumStatus): string { + switch (status.kind) { + case 'ready': + return 'OK: Playwright Chromium is installed and launchable.' + case 'og_deps_missing': + return `Playwright package is not installed in og/.\n${HUMAN_SETUP_HINT}` + case 'browser_missing': + return `Playwright Chromium browser is not downloaded.\n${HUMAN_SETUP_HINT}${ + status.executablePath ? `\nExpected: ${status.executablePath}` : '' + }` + case 'browser_not_executable': + return `Chromium exists but cannot be executed from this environment.\n${AGENT_PERMISSIONS_HINT}\nPath: ${status.executablePath}` + case 'needs_agent_permissions': + return `Chromium is installed on this machine but not reachable from the Cursor sandbox.\n${AGENT_PERMISSIONS_HINT}${ + status.stamp ? `\nLast verified: ${status.stamp.checkedAt}` : '' + }` + case 'launch_failed': + return status.detail + ? `Playwright Chromium launch failed.\n${status.detail}` + : `Playwright Chromium launch failed.\n${AGENT_PERMISSIONS_HINT}` + default: + return 'Unknown Chromium status.' + } +} + +export function launchFailureStatus(message: string, env: ChromiumStatus): ChromiumStatus { + const stamp = env.stamp ?? readChromiumStamp() + const likelySandbox = + Boolean(stamp) || + env.kind === 'needs_agent_permissions' || + env.kind === 'browser_not_executable' || + /EACCES|EPERM|sandbox|Operation not permitted/i.test(message) + + if (likelySandbox) { + return { + kind: 'needs_agent_permissions', + executablePath: env.executablePath, + stamp, + detail: `${AGENT_PERMISSIONS_HINT}\n\nLaunch error: ${message}`, + } + } + + return { + kind: 'launch_failed', + executablePath: env.executablePath, + detail: `${HUMAN_SETUP_HINT}\n\nLaunch error: ${message}`, + } +} diff --git a/og/src/chromium-status.ts b/og/src/chromium-status.ts new file mode 100644 index 0000000..f958259 --- /dev/null +++ b/og/src/chromium-status.ts @@ -0,0 +1,56 @@ +import { accessSync, constants, existsSync } from 'node:fs' + +import { + type ChromiumStatus, + playwrightPackageRoot, + readChromiumStamp, +} from './chromium-status-core.ts' + +export * from './chromium-status-core.ts' + +function canExecute(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** Filesystem-only inspection — safe in CI when og/ deps are absent (no static playwright import). */ +export async function inspectChromiumEnvironment(): Promise { + if (!existsSync(playwrightPackageRoot())) { + return { kind: 'og_deps_missing' } + } + + const { chromium } = await import('playwright') + + let executablePath: string + try { + executablePath = chromium.executablePath() + } catch (err) { + return { + kind: 'browser_missing', + detail: err instanceof Error ? err.message : String(err), + } + } + + if (!existsSync(executablePath)) { + return { kind: 'browser_missing', executablePath } + } + + if (!canExecute(executablePath)) { + const stamp = readChromiumStamp() + if (stamp) { + return { + kind: 'needs_agent_permissions', + executablePath, + stamp, + detail: 'Chromium binary exists but is not executable from this environment.', + } + } + return { kind: 'browser_not_executable', executablePath } + } + + return { kind: 'ready', executablePath, stamp: readChromiumStamp() } +} diff --git a/og/src/cli.ts b/og/src/cli.ts index b9a3779..1686899 100644 --- a/og/src/cli.ts +++ b/og/src/cli.ts @@ -1,10 +1,15 @@ +import './bootstrap-playwright-env.ts' + import { assertChromiumReady } from './check-browsers.ts' +import { OG_HEIGHT, OG_WIDTH } from './config.ts' import { generateAllOgImages, generateExplorationOg, generateTopicOg } from './generate.ts' +import { generateRoadmapOg } from './generate-roadmap-og.ts' function usage(): never { console.error(`Usage: npm run generate -- exploration e.g. npm run generate -- exploration eip-7594 npm run generate -- topic e.g. npm run generate -- topic scaling + npm run generate -- roadmap e.g. npm run generate -- roadmap npm run generate -- all`) process.exit(1) } @@ -20,6 +25,12 @@ async function main(): Promise { return } + if (command === 'roadmap') { + const outPath = await generateRoadmapOg() + console.log(`Wrote ${outPath} (${OG_WIDTH}×${OG_HEIGHT})`) + return + } + if (!id) usage() if (command === 'exploration') { diff --git a/og/src/generate-roadmap-og.ts b/og/src/generate-roadmap-og.ts new file mode 100644 index 0000000..cb889bc --- /dev/null +++ b/og/src/generate-roadmap-og.ts @@ -0,0 +1,48 @@ +import { mkdirSync } from 'node:fs' +import { chromium } from 'playwright' +import sharp from 'sharp' + +import { OG_HEIGHT, OG_WIDTH } from './config.ts' +import { ROADMAP_OG_OUTPUT, ROADMAP_OG_PUBLIC_DIR } from './roadmap-og-paths.ts' +import { startStaticServer } from './server.ts' + +export { ROADMAP_OG_OUTPUT, ROADMAP_OG_PUBLIC_DIR, ROADMAP_OG_RENDER_HTML } from './roadmap-og-paths.ts' + +/** Capture roadmap/public/og/render.html at standard OG dimensions. */ +export async function generateRoadmapOg(): Promise { + mkdirSync(ROADMAP_OG_PUBLIC_DIR, { recursive: true }) + + const server = await startStaticServer(ROADMAP_OG_PUBLIC_DIR) + const browser = await chromium.launch({ headless: true }) + + try { + const page = await browser.newPage({ + viewport: { width: OG_WIDTH, height: OG_HEIGHT }, + deviceScaleFactor: 1, + }) + await page.goto(`${server.url}/render.html`, { waitUntil: 'load', timeout: 30_000 }) + await page.evaluate(async () => { + await document.fonts.ready + }) + await page.waitForTimeout(150) + + const png = await page.screenshot({ + type: 'png', + clip: { x: 0, y: 0, width: OG_WIDTH, height: OG_HEIGHT }, + }) + + await sharp(png).webp({ quality: 92 }).toFile(ROADMAP_OG_OUTPUT) + + const meta = await sharp(ROADMAP_OG_OUTPUT).metadata() + if (meta.width !== OG_WIDTH || meta.height !== OG_HEIGHT) { + throw new Error( + `Roadmap OG has wrong dimensions: ${meta.width}×${meta.height}, expected ${OG_WIDTH}×${OG_HEIGHT}`, + ) + } + + return ROADMAP_OG_OUTPUT + } finally { + await browser.close() + await server.close() + } +} diff --git a/og/src/preflight-cli.ts b/og/src/preflight-cli.ts new file mode 100644 index 0000000..91a75eb --- /dev/null +++ b/og/src/preflight-cli.ts @@ -0,0 +1,40 @@ +import './bootstrap-playwright-env.ts' + +import { + type ChromiumStatusKind, + formatChromiumStatus, + inspectChromiumEnvironment, +} from './chromium-status.ts' + +function statusForAgent(kind: ChromiumStatusKind): string { + switch (kind) { + case 'ready': + return 'ready' + case 'og_deps_missing': + case 'browser_missing': + case 'launch_failed': + return 'needs_human_setup' + case 'browser_not_executable': + case 'needs_agent_permissions': + return 'needs_agent_permissions' + default: + return 'unknown' + } +} + +async function main(): Promise { + const env = await inspectChromiumEnvironment() + const agentStatus = statusForAgent(env.kind) + + console.log(`status: ${agentStatus}`) + console.log(formatChromiumStatus(env)) + if (env.executablePath) console.log(`executable: ${env.executablePath}`) + if (env.stamp) console.log(`last_verified: ${env.stamp.checkedAt}`) + + process.exit(agentStatus === 'ready' ? 0 : 1) +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/og/src/roadmap-og-paths.ts b/og/src/roadmap-og-paths.ts new file mode 100644 index 0000000..8cfdb65 --- /dev/null +++ b/og/src/roadmap-og-paths.ts @@ -0,0 +1,8 @@ +import { resolve } from 'node:path' + +import { WEBSITE_ROOT } from './config.ts' + +/** roadmap/public/og — render template + generated default.webp (no Playwright import). */ +export const ROADMAP_OG_PUBLIC_DIR = resolve(WEBSITE_ROOT, 'roadmap', 'public', 'og') +export const ROADMAP_OG_RENDER_HTML = resolve(ROADMAP_OG_PUBLIC_DIR, 'render.html') +export const ROADMAP_OG_OUTPUT = resolve(ROADMAP_OG_PUBLIC_DIR, 'default.webp') diff --git a/og/src/server.ts b/og/src/server.ts index 4c9045b..42c9723 100644 --- a/og/src/server.ts +++ b/og/src/server.ts @@ -5,11 +5,34 @@ import { extname, join, normalize } from 'node:path' const MIME: Record = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', +} + +/** Map a URL pathname to a file under `rootDir` (SPA-aware: `/` → index.html). */ +export function resolveStaticFile(rootDir: string, pathname: string): string { + const root = normalize(rootDir) + const slug = pathname === '/' || pathname === '' ? 'index.html' : pathname.replace(/^\//, '') + let file = normalize(join(root, slug)) + + if (!file.startsWith(root)) { + throw new Error('Path escapes static root') + } + + try { + if (statSync(file).isDirectory()) { + file = join(file, 'index.html') + } + } catch { + // Not found as-is — fall through to caller (404). + } + + return file } export async function startStaticServer(rootDir: string): Promise<{ url: string; close: () => Promise }> { @@ -18,14 +41,13 @@ export async function startStaticServer(rootDir: string): Promise<{ url: string; const server = createServer((req: IncomingMessage, res: ServerResponse) => { try { const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://local').pathname) - const safePath = normalize(join(root, pathname)) - if (!safePath.startsWith(root)) { + const file = resolveStaticFile(root, pathname) + if (!file.startsWith(root)) { res.writeHead(403) res.end('Forbidden') return } - const file = safePath.endsWith('/') ? join(safePath, 'index.html') : safePath const body = readFileSync(file) const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream' res.writeHead(200, { 'Content-Type': type, 'Content-Length': body.length }) diff --git a/og/src/social-cli.ts b/og/src/social-cli.ts new file mode 100644 index 0000000..138eb55 --- /dev/null +++ b/og/src/social-cli.ts @@ -0,0 +1,28 @@ +import './bootstrap-playwright-env.ts' + +import { captureSocialCards } from './social/capture.ts' + +function usage(): never { + console.error(`Usage: + npm run capture:social -- hero timeline board + npm run capture:social -- all + +Build preview first: npm run social:build +Preview in browser: npm run social:dev → http://localhost:5175/?card=timeline`) + process.exit(1) +} + +async function main(): Promise { + const args = process.argv.slice(2) + if (args.includes('-h') || args.includes('--help')) usage() + if (args.length === 0) { + await captureSocialCards(['all']) + return + } + await captureSocialCards(args) +} + +main().catch((err) => { + console.error(`\nSocial capture failed: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +}) diff --git a/og/src/social/__tests__/socialCapture.spec.ts b/og/src/social/__tests__/socialCapture.spec.ts new file mode 100644 index 0000000..821c68d --- /dev/null +++ b/og/src/social/__tests__/socialCapture.spec.ts @@ -0,0 +1,53 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { SOCIAL_CARD_IDS } from '../../../../roadmap/social/src/cards.ts' +import { + SOCIAL_CAPTURE_WIDTH, + SOCIAL_DIST_DIR, + SOCIAL_OUTPUT_DIR, + socialCardOutputBase, + WEBSITE_ROOT, +} from '../config.ts' +import { parseSocialCardIds } from '../parseCardIds.ts' + +describe('parseSocialCardIds', () => { + it('returns all cards for empty args', () => { + expect(parseSocialCardIds([])).toEqual([...SOCIAL_CARD_IDS]) + }) + + it('returns all cards for "all"', () => { + expect(parseSocialCardIds(['all'])).toEqual([...SOCIAL_CARD_IDS]) + }) + + it('returns a single requested card', () => { + expect(parseSocialCardIds(['timeline'])).toEqual(['timeline']) + }) + + it('returns multiple requested cards in order', () => { + expect(parseSocialCardIds(['board', 'hero'])).toEqual(['board', 'hero']) + }) + + it('throws on unknown ids', () => { + expect(() => parseSocialCardIds(['nope'])).toThrow(/Unknown card id/) + }) +}) + +describe('social config paths', () => { + it('SOCIAL_DIST_DIR resolves under website root (not og/)', () => { + expect(SOCIAL_DIST_DIR).toBe(resolve(WEBSITE_ROOT, 'roadmap/social/dist')) + expect(SOCIAL_DIST_DIR).not.toContain(`${resolve(WEBSITE_ROOT, 'og')}${resolve.sep}roadmap`) + }) + + it('SOCIAL_OUTPUT_DIR resolves under website root', () => { + expect(SOCIAL_OUTPUT_DIR).toBe(resolve(WEBSITE_ROOT, 'roadmap/social/out')) + }) + + it('socialCardOutputBase builds png/webp stems per card', () => { + expect(socialCardOutputBase('hero')).toBe(`${SOCIAL_OUTPUT_DIR}/hero`) + }) + + it('SOCIAL_CAPTURE_WIDTH is Twitter-friendly 1200px', () => { + expect(SOCIAL_CAPTURE_WIDTH).toBe(1200) + }) +}) diff --git a/og/src/social/capture.ts b/og/src/social/capture.ts new file mode 100644 index 0000000..3e88e35 --- /dev/null +++ b/og/src/social/capture.ts @@ -0,0 +1,82 @@ +import { mkdirSync } from 'node:fs' +import { chromium } from 'playwright' +import sharp from 'sharp' + +import { assertChromiumReady } from '../check-browsers.ts' +import { startStaticServer } from '../server.ts' +import type { SocialCardId } from './cardIds.ts' +import { + SOCIAL_CAPTURE_WIDTH, + SOCIAL_DIST_DIR, + SOCIAL_OUTPUT_DIR, + socialCardOutputBase, +} from './config.ts' +import { parseSocialCardIds } from './parseCardIds.ts' + +async function captureCard( + page: import('playwright').Page, + baseUrl: string, + id: SocialCardId, +): Promise<{ pngPath: string; webpPath: string }> { + await page.goto(`${baseUrl}/index.html?card=${id}&mode=capture`, { + waitUntil: 'load', + timeout: 30_000, + }) + await page.waitForSelector(`[data-social-card="${id}"]`, { state: 'visible', timeout: 15_000 }) + await page.evaluate(async () => { + await document.fonts.ready + }) + await page.waitForTimeout(200) + + const card = page.locator(`[data-social-card="${id}"]`) + const box = await card.boundingBox() + if (!box) throw new Error(`Could not measure card: ${id}`) + + const pngPath = `${socialCardOutputBase(id)}.png` + const webpPath = `${socialCardOutputBase(id)}.webp` + mkdirSync(SOCIAL_OUTPUT_DIR, { recursive: true }) + + const pngBuffer = await card.screenshot({ type: 'png' }) + + const meta = await sharp(pngBuffer).metadata() + const srcWidth = meta.width ?? SOCIAL_CAPTURE_WIDTH + const scale = SOCIAL_CAPTURE_WIDTH / srcWidth + const targetHeight = Math.round((meta.height ?? 675) * scale) + + const normalized = await sharp(pngBuffer) + .resize(SOCIAL_CAPTURE_WIDTH, targetHeight, { fit: 'inside', withoutEnlargement: false }) + .png() + .toBuffer() + + await sharp(normalized).png().toFile(pngPath) + await sharp(normalized).webp({ quality: 90 }).toFile(webpPath) + + return { pngPath, webpPath } +} + +export async function captureSocialCards(cardArgs: string[]): Promise { + await assertChromiumReady() + + const ids = parseSocialCardIds(cardArgs) + const server = await startStaticServer(SOCIAL_DIST_DIR) + const browser = await chromium.launch({ headless: true }) + + try { + const page = await browser.newPage({ + viewport: { width: SOCIAL_CAPTURE_WIDTH + 80, height: 1400 }, + deviceScaleFactor: 2, + }) + + for (const id of ids) { + console.log(`Capturing: ${id}`) + const { pngPath, webpPath } = await captureCard(page, server.url, id) + console.log(` PNG → ${pngPath}`) + console.log(` WebP → ${webpPath}`) + } + } finally { + await browser.close() + await server.close() + } + + console.log(`\nDone — ${ids.length} card(s) in ${SOCIAL_OUTPUT_DIR}`) +} diff --git a/og/src/social/cardIds.ts b/og/src/social/cardIds.ts new file mode 100644 index 0000000..8959707 --- /dev/null +++ b/og/src/social/cardIds.ts @@ -0,0 +1,9 @@ +/** + * Re-export card ids from the social preview app — single source of truth. + * Capture tooling and Vue preview must stay in sync. + */ +export { + isSocialCardId, + SOCIAL_CARD_IDS, + type SocialCardId, +} from '../../../roadmap/social/src/cards.ts' diff --git a/og/src/social/config.ts b/og/src/social/config.ts new file mode 100644 index 0000000..0a1f790 --- /dev/null +++ b/og/src/social/config.ts @@ -0,0 +1,22 @@ +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { SocialCardId } from './cardIds.ts' + +const here = dirname(fileURLToPath(import.meta.url)) + +/** website/og */ +export const PACKAGE_ROOT = resolve(here, '..', '..') +/** website/ */ +export const WEBSITE_ROOT = resolve(here, '..', '..', '..') +/** Built social preview app (run `npm run social:build` first). */ +export const SOCIAL_DIST_DIR = resolve(WEBSITE_ROOT, 'roadmap', 'social', 'dist') +/** PNG + WebP output for tweets (gitignored — copy what you need). */ +export const SOCIAL_OUTPUT_DIR = resolve(WEBSITE_ROOT, 'roadmap', 'social', 'out') + +/** Twitter-friendly width; cards are captured at natural height then normalized. */ +export const SOCIAL_CAPTURE_WIDTH = 1200 + +export function socialCardOutputBase(id: SocialCardId): string { + return join(SOCIAL_OUTPUT_DIR, id) +} diff --git a/og/src/social/parseCardIds.ts b/og/src/social/parseCardIds.ts new file mode 100644 index 0000000..b5d42fd --- /dev/null +++ b/og/src/social/parseCardIds.ts @@ -0,0 +1,13 @@ +import { SOCIAL_CARD_IDS, type SocialCardId } from './cardIds.ts' + +/** Resolve CLI args to card ids (`all` or empty → every card). */ +export function parseSocialCardIds(args: string[]): SocialCardId[] { + if (args.length === 0 || args.includes('all')) return [...SOCIAL_CARD_IDS] + const ids = args.filter((a): a is SocialCardId => + (SOCIAL_CARD_IDS as readonly string[]).includes(a), + ) + if (ids.length === 0) { + throw new Error(`Unknown card id(s). Use: ${SOCIAL_CARD_IDS.join(', ')}, or all`) + } + return ids +} diff --git a/package.json b/package.json index bfafc7c..a72cfec 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:deploy": "run-s website:build:deploy community-token:build docs:build roadmap:build", "preview": "npm run website:preview", "test:unit": "vitest", + "test:unit:ci": "vitest run", "prepare": "cypress install", "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'", "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'", @@ -23,8 +24,15 @@ "generate:og:exploration": "npm run generate --prefix og -- exploration", "generate:og:topic": "npm run generate --prefix og -- topic", "generate:og:all": "npm run generate --prefix og -- all", + "generate:og:roadmap": "npm run generate --prefix og -- roadmap", "og:setup": "npm run setup --prefix og", "og:check": "npm run check --prefix og", + "og:preflight": "npm run preflight --prefix og", + "social:dev": "vite --config vite.social.config.ts", + "social:build": "vite build --config vite.social.config.ts", + "social:preview": "vite preview --config vite.social.config.ts", + "capture:social": "run-s social:build capture:social:run", + "capture:social:run": "npm run capture:social:run --prefix og --", "website:preview": "vite preview --outDir dist/website", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", diff --git a/roadmap/.vitepress/config.ts b/roadmap/.vitepress/config.ts index 6d18266..350500e 100644 --- a/roadmap/.vitepress/config.ts +++ b/roadmap/.vitepress/config.ts @@ -33,7 +33,7 @@ export default defineConfig({ titleTemplate: ':title | Feel Your Protocol', description: ROADMAP_DESCRIPTION, /** README + scratch notes are contributor-facing only — keep them out of the built site + sitemap. */ - srcExclude: ['README.md', 'tmp.md'], + srcExclude: ['README.md', 'tmp.md', 'social/README.md'], head: [ ['script', {}, 'document.documentElement.classList.add("fyp-site-roadmap")'], ['meta', { name: 'twitter:card', content: 'summary_large_image' }], diff --git a/roadmap/README.md b/roadmap/README.md index fc32282..ae55248 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -59,17 +59,30 @@ There are no full doc versions. Instead, fast-moving sections embed a `` +3. Add/adjust tests in `roadmap/social/src/__tests__/cards.spec.ts` +4. Re-run `npm run capture:social -- your-new-id` diff --git a/roadmap/social/index.html b/roadmap/social/index.html new file mode 100644 index 0000000..63d5a5a --- /dev/null +++ b/roadmap/social/index.html @@ -0,0 +1,12 @@ + + + + + + FYP Social Cards + + +
+ + + diff --git a/roadmap/social/src/App.vue b/roadmap/social/src/App.vue new file mode 100644 index 0000000..38e8ea2 --- /dev/null +++ b/roadmap/social/src/App.vue @@ -0,0 +1,26 @@ + + + diff --git a/roadmap/social/src/__tests__/App.spec.ts b/roadmap/social/src/__tests__/App.spec.ts new file mode 100644 index 0000000..29278f3 --- /dev/null +++ b/roadmap/social/src/__tests__/App.spec.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +import App from '../App.vue' +import { SOCIAL_CARD_IDS } from '../cards.ts' + +vi.mock('../../.vitepress/theme/components/Timeline.vue', () => ({ + default: { template: '
Timeline
' }, +})) + +vi.mock('../../.vitepress/theme/components/RoadmapBoard.vue', () => ({ + default: { template: '
Board
' }, +})) + +describe('social App', () => { + beforeEach(() => { + vi.stubGlobal('location', { search: '' }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders all cards by default', () => { + const wrapper = mount(App) + for (const id of SOCIAL_CARD_IDS) { + expect(wrapper.find(`[data-social-card="${id}"]`).exists()).toBe(true) + } + }) + + it('renders only the requested card from ?card=', () => { + vi.stubGlobal('location', { search: '?card=timeline' }) + const wrapper = mount(App) + + expect(wrapper.find('[data-social-card="timeline"]').exists()).toBe(true) + expect(wrapper.find('[data-social-card="hero"]').exists()).toBe(false) + expect(wrapper.find('[data-social-card="board"]').exists()).toBe(false) + expect(wrapper.text()).toContain('Timeline') + }) + + it('adds capture mode class when ?mode=capture', () => { + vi.stubGlobal('location', { search: '?card=hero&mode=capture' }) + const wrapper = mount(App) + + expect(wrapper.find('.fyp-social-shell--capture').exists()).toBe(true) + }) +}) diff --git a/roadmap/social/src/__tests__/BoardSocialCard.spec.ts b/roadmap/social/src/__tests__/BoardSocialCard.spec.ts new file mode 100644 index 0000000..19950b9 --- /dev/null +++ b/roadmap/social/src/__tests__/BoardSocialCard.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' + +import { ROADMAP_HORIZONS, ROADMAP_TRACKS } from '../../../data/roadmap.ts' +import { SOCIAL_CARDS } from '../cards.ts' +import BoardSocialCard from '../components/BoardSocialCard.vue' + +describe('BoardSocialCard', () => { + it('renders banner with horizon and track chips plus board body', () => { + const wrapper = mount(BoardSocialCard) + + expect(wrapper.find('[data-social-card="board"]').exists()).toBe(true) + expect(wrapper.text()).toContain(SOCIAL_CARDS.board.title) + expect(wrapper.text()).toContain(SOCIAL_CARDS.board.subtitle) + expect(wrapper.findAll('.fyp-social-board__horizon')).toHaveLength(ROADMAP_HORIZONS.length) + expect(wrapper.findAll('.fyp-social-board__track')).toHaveLength(ROADMAP_TRACKS.length) + expect(wrapper.find('.fyp-roadmap').exists()).toBe(true) + expect(wrapper.text()).toContain('In progress') + expect(wrapper.text()).toContain('Planned') + }) + + it('includes gradient glow layers', () => { + const wrapper = mount(BoardSocialCard) + expect(wrapper.findAll('.fyp-social-banner__glow')).toHaveLength(2) + }) +}) diff --git a/roadmap/social/src/__tests__/HeroSocialCard.spec.ts b/roadmap/social/src/__tests__/HeroSocialCard.spec.ts new file mode 100644 index 0000000..0e99093 --- /dev/null +++ b/roadmap/social/src/__tests__/HeroSocialCard.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' + +import { SOCIAL_CARDS } from '../cards.ts' +import HeroSocialCard from '../components/HeroSocialCard.vue' +import { HERO_FEATURES } from '../heroFeatures.ts' + +describe('HeroSocialCard', () => { + it('renders docs-style hero with capture id and feature tiles', () => { + const wrapper = mount(HeroSocialCard) + + expect(wrapper.find('[data-social-card="hero"]').exists()).toBe(true) + expect(wrapper.find('.fyp-social-hero__name').text()).toBe('Feel Your Protocol') + expect(wrapper.text()).toContain(SOCIAL_CARDS.hero.title) + expect(wrapper.text()).toContain(SOCIAL_CARDS.hero.subtitle) + expect(wrapper.text()).toContain('Deterministic truth for probabilistic machines.') + expect(wrapper.findAll('.fyp-social-hero__feature')).toHaveLength(HERO_FEATURES.length) + expect(wrapper.text()).toContain('roadmap.feelyourprotocol.org') + }) + + it('includes gradient glow layers for visual depth', () => { + const wrapper = mount(HeroSocialCard) + expect(wrapper.findAll('.fyp-social-banner__glow')).toHaveLength(2) + }) +}) diff --git a/roadmap/social/src/__tests__/SocialCard.spec.ts b/roadmap/social/src/__tests__/SocialCard.spec.ts new file mode 100644 index 0000000..0149c01 --- /dev/null +++ b/roadmap/social/src/__tests__/SocialCard.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' + +import SocialCard from '../components/SocialCard.vue' + +describe('SocialCard', () => { + it('renders frame with data-social-card id', () => { + const wrapper = mount(SocialCard, { + props: { + cardId: 'timeline', + eyebrow: 'Phase 3 · Timeline', + title: 'Test title', + subtitle: 'Test subtitle', + footerHint: 'Hint', + }, + slots: { default: '

Body

' }, + }) + + expect(wrapper.find('[data-social-card="timeline"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Test title') + expect(wrapper.text()).toContain('Test subtitle') + expect(wrapper.text()).toContain('roadmap.feelyourprotocol.org') + expect(wrapper.text()).toContain('Hint') + expect(wrapper.find('.slot-content').exists()).toBe(true) + }) +}) diff --git a/roadmap/social/src/__tests__/TimelineSocialCard.spec.ts b/roadmap/social/src/__tests__/TimelineSocialCard.spec.ts new file mode 100644 index 0000000..521b26c --- /dev/null +++ b/roadmap/social/src/__tests__/TimelineSocialCard.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' + +import { TIMELINE_PHASES } from '../../../data/timeline.ts' +import { SOCIAL_CARDS } from '../cards.ts' +import TimelineSocialCard from '../components/TimelineSocialCard.vue' + +describe('TimelineSocialCard', () => { + it('renders banner with phase chips and timeline body', () => { + const wrapper = mount(TimelineSocialCard) + + expect(wrapper.find('[data-social-card="timeline"]').exists()).toBe(true) + expect(wrapper.text()).toContain(SOCIAL_CARDS.timeline.title) + expect(wrapper.text()).toContain(SOCIAL_CARDS.timeline.subtitle) + expect(wrapper.findAll('.fyp-social-timeline__phase')).toHaveLength(TIMELINE_PHASES.length) + expect(wrapper.find('.fyp-timeline').exists()).toBe(true) + expect(wrapper.text()).toContain('reached') + expect(wrapper.text()).toContain('upcoming target') + }) + + it('includes gradient glow layers', () => { + const wrapper = mount(TimelineSocialCard) + expect(wrapper.findAll('.fyp-social-banner__glow')).toHaveLength(2) + }) +}) diff --git a/roadmap/social/src/__tests__/cards.spec.ts b/roadmap/social/src/__tests__/cards.spec.ts new file mode 100644 index 0000000..c9486ec --- /dev/null +++ b/roadmap/social/src/__tests__/cards.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { isSocialCardId, SOCIAL_CARD_IDS, SOCIAL_CARDS } from '../cards.ts' + +describe('social card registry', () => { + it('defines hero, timeline, and board', () => { + expect(SOCIAL_CARD_IDS).toEqual(['hero', 'timeline', 'board']) + }) + + it('isSocialCardId narrows known ids', () => { + expect(isSocialCardId('timeline')).toBe(true) + expect(isSocialCardId('unknown')).toBe(false) + }) + + it('every card id has complete metadata', () => { + for (const id of SOCIAL_CARD_IDS) { + const meta = SOCIAL_CARDS[id] + expect(meta.id).toBe(id) + expect(meta.title.length).toBeGreaterThan(0) + expect(meta.subtitle.length).toBeGreaterThan(0) + expect(meta.eyebrow.length).toBeGreaterThan(0) + expect(meta.footerHint.length).toBeGreaterThan(0) + } + }) + + it('hero card mentions future Ethereum protocol', () => { + expect(SOCIAL_CARDS.hero.title).toMatch(/future Ethereum protocol/i) + }) +}) diff --git a/roadmap/social/src/cards.ts b/roadmap/social/src/cards.ts new file mode 100644 index 0000000..d73297f --- /dev/null +++ b/roadmap/social/src/cards.ts @@ -0,0 +1,41 @@ +/** Social card ids — single source of truth (also imported by og/src/social/cardIds.ts). */ +export const SOCIAL_CARD_IDS = ['hero', 'timeline', 'board'] as const + +export type SocialCardId = (typeof SOCIAL_CARD_IDS)[number] + +export function isSocialCardId(value: string): value is SocialCardId { + return (SOCIAL_CARD_IDS as readonly string[]).includes(value) +} + +export type SocialCardMeta = { + id: SocialCardId + title: string + subtitle: string + eyebrow: string + footerHint: string +} + +export const SOCIAL_CARDS: Record = { + hero: { + id: 'hero', + eyebrow: 'Phase 3 · Roadmap', + title: 'Building an AI pipeline for the future Ethereum protocol.', + subtitle: + 'Vision, tracks, and draft concepts toward a deterministic API & MCP server for upcoming forks, EIPs, and research.', + footerHint: 'Conceptualization — targets, not promises', + }, + timeline: { + id: 'timeline', + eyebrow: 'Phase 3 · Timeline', + title: 'Where we’ve been — and where we’re headed', + subtitle: 'Three phases: side project → funded focus → sustainable business (future-protocol API).', + footerHint: 'Filled dots = reached · hollow = upcoming targets', + }, + board: { + id: 'board', + eyebrow: 'Phase 3 · Roadmap', + title: 'Parallel tracks', + subtitle: 'Engine & API, website, infrastructure, and business — moving at different speeds.', + footerHint: 'Data-driven board — edit roadmap/data/roadmap.ts', + }, +} diff --git a/roadmap/social/src/components/BoardSocialCard.vue b/roadmap/social/src/components/BoardSocialCard.vue new file mode 100644 index 0000000..cf18b26 --- /dev/null +++ b/roadmap/social/src/components/BoardSocialCard.vue @@ -0,0 +1,79 @@ + + + diff --git a/roadmap/social/src/components/HeroSocialCard.vue b/roadmap/social/src/components/HeroSocialCard.vue new file mode 100644 index 0000000..93cdb05 --- /dev/null +++ b/roadmap/social/src/components/HeroSocialCard.vue @@ -0,0 +1,42 @@ + + + diff --git a/roadmap/social/src/components/SocialCard.vue b/roadmap/social/src/components/SocialCard.vue new file mode 100644 index 0000000..3b76a27 --- /dev/null +++ b/roadmap/social/src/components/SocialCard.vue @@ -0,0 +1,30 @@ + + + diff --git a/roadmap/social/src/components/TimelineSocialCard.vue b/roadmap/social/src/components/TimelineSocialCard.vue new file mode 100644 index 0000000..c1880b0 --- /dev/null +++ b/roadmap/social/src/components/TimelineSocialCard.vue @@ -0,0 +1,60 @@ + + + diff --git a/roadmap/social/src/heroFeatures.ts b/roadmap/social/src/heroFeatures.ts new file mode 100644 index 0000000..30d0b40 --- /dev/null +++ b/roadmap/social/src/heroFeatures.ts @@ -0,0 +1,19 @@ +/** Compact feature tiles for the hero social card (echoes roadmap/index.md). */ +export const HERO_FEATURES = [ + { + title: 'Vision & Strategy', + detail: 'Protocol↔app gap, deterministic-oracle thesis, two-legs model.', + }, + { + title: 'Roadmap & Timeline', + detail: 'Engine & API, website, infrastructure, business — in parallel.', + }, + { + title: 'Core Concepts', + detail: 'Draft Agent API, MCP delivery, x402 pay-per-use shape.', + }, + { + title: 'Monetization & Infra', + detail: 'Pricing targets, token-holder discounts, AWS hosting plan.', + }, +] as const diff --git a/roadmap/social/src/main.ts b/roadmap/social/src/main.ts new file mode 100644 index 0000000..c5f7add --- /dev/null +++ b/roadmap/social/src/main.ts @@ -0,0 +1,6 @@ +import './social.css' +import { createApp } from 'vue' + +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/roadmap/social/src/social.css b/roadmap/social/src/social.css new file mode 100644 index 0000000..88aafbb --- /dev/null +++ b/roadmap/social/src/social.css @@ -0,0 +1,732 @@ +@import '../../../shared/vitepress/fyp-tokens.css'; + +/* Roadmap palette + viz styles (subset of .vitepress/theme/custom.css) */ +:root.fyp-site-roadmap, +.fyp-site-roadmap { + --fyp-accent: var(--fyp-purple); + --vp-c-brand-1: #7c3aed; + --vp-c-brand-2: #6d28d9; + --vp-c-brand-3: #5b21b6; + --vp-c-brand-soft: rgba(124, 58, 237, 0.14); + --vp-c-bg: #faf8ff; + --vp-c-bg-soft: #f3f0ff; + --vp-c-text-1: #0f172a; + --vp-c-text-2: #475569; + --vp-c-text-3: #64748b; + --vp-c-divider: rgba(124, 58, 237, 0.12); + --vp-c-default-soft: rgba(15, 23, 42, 0.06); +} + +html, +body { + margin: 0; + padding: 0; + background: #e2e8f0; + color: var(--vp-c-text-1); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + Helvetica, + Arial, + sans-serif; +} + +body.fyp-site-roadmap { + min-height: 100vh; +} + +.fyp-social-shell { + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +} + +.fyp-social-shell--capture { + padding: 0; + background: transparent; +} + +/* --- Social card frame --- */ +.fyp-social-card { + width: 1200px; + box-sizing: border-box; + background-color: var(--vp-c-bg); + background-image: var(--fyp-dot-grid-brand); + background-size: var(--fyp-dot-size); + border: 1px solid var(--vp-c-divider); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 8px 32px rgba(124, 58, 237, 0.08); +} + +.fyp-social-card__bar { + height: 4px; + background: var(--fyp-gradient); +} + +.fyp-social-card__header { + padding: 1.35rem 1.75rem 0.85rem; + border-bottom: 1px solid var(--vp-c-divider); +} + +.fyp-social-card__eyebrow { + font-family: var(--fyp-font-mono); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--vp-c-brand-1); + margin-bottom: 0.35rem; +} + +.fyp-social-card__title { + font-family: var(--fyp-font-mono); + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.25; + margin: 0; +} + +.fyp-social-card__subtitle { + margin: 0.45rem 0 0; + font-size: 0.92rem; + line-height: 1.45; + color: var(--vp-c-text-2); + max-width: 52rem; +} + +.fyp-social-card__body { + padding: 0.5rem 1.25rem 1rem; +} + +.fyp-social-card__body .fyp-timeline, +.fyp-social-card__body .fyp-roadmap { + margin: 0.75rem 0 0.5rem; +} + +.fyp-social-card__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.75rem; + border-top: 1px solid var(--vp-c-divider); + font-family: var(--fyp-font-mono); + font-size: 0.72rem; + color: var(--vp-c-text-3); +} + +.fyp-social-card__brand { + font-weight: 700; + color: var(--vp-c-brand-1); +} + +/* --- Banner cards (hero + timeline — docs-home inspired) --- */ +.fyp-social-card--banner { + background-color: #ffffff; + background-image: var(--fyp-dot-grid-neutral); + box-shadow: + 0 12px 40px rgba(15, 23, 42, 0.08), + 0 0 0 1px rgba(124, 58, 237, 0.06); +} + +.fyp-social-banner { + position: relative; + isolation: isolate; + overflow: hidden; +} + +.fyp-social-banner--hero { + padding: 2rem 2.25rem 1.75rem; + min-height: 420px; +} + +.fyp-social-banner--timeline { + padding: 1.65rem 2.25rem 1.35rem; +} + +.fyp-social-banner__glow { + position: absolute; + border-radius: 999px; + pointer-events: none; + z-index: 0; +} + +.fyp-social-banner__glow--primary { + width: 520px; + height: 520px; + top: -220px; + right: -80px; + background: linear-gradient(-45deg, rgba(124, 58, 237, 0.5), rgba(6, 182, 212, 0.35)); + filter: blur(72px); + opacity: 0.85; +} + +.fyp-social-banner__glow--secondary { + width: 360px; + height: 360px; + bottom: -120px; + left: -60px; + background: linear-gradient(135deg, rgba(6, 182, 212, 0.35), rgba(124, 58, 237, 0.2)); + filter: blur(56px); + opacity: 0.7; +} + +.fyp-social-card--timeline .fyp-social-banner__glow--primary { + background: linear-gradient(-45deg, rgba(124, 58, 237, 0.42), rgba(6, 182, 212, 0.38)); +} + +.fyp-social-card--timeline .fyp-social-banner__glow--secondary { + top: -80px; + bottom: auto; + left: -40px; + background: linear-gradient(135deg, rgba(100, 116, 139, 0.28), rgba(124, 58, 237, 0.18)); +} + +.fyp-social-banner__content { + position: relative; + z-index: 1; +} + +.fyp-social-banner__eyebrow { + display: inline-block; + margin: 0 0 1rem; + font-family: var(--fyp-font-mono); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--vp-c-brand-1); + padding: 0.35rem 0.75rem; + border-radius: 999px; + border: 1px solid rgba(124, 58, 237, 0.22); + background: rgba(124, 58, 237, 0.06); +} + +.fyp-social-banner__headline { + margin: 0 0 0.75rem; + max-width: 52rem; + font-family: var(--fyp-font-mono); + font-size: 1.65rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.25; + color: var(--vp-c-text-1); +} + +.fyp-social-banner--timeline .fyp-social-banner__headline, +.fyp-social-banner--board .fyp-social-banner__headline { + font-size: 1.55rem; +} + +.fyp-social-banner__tagline { + margin: 0; + max-width: 54rem; + font-size: 1.05rem; + line-height: 1.55; + color: var(--vp-c-text-2); +} + +.fyp-social-card--banner .fyp-social-card__footer { + background: rgba(248, 250, 252, 0.92); + backdrop-filter: blur(4px); +} + +/* --- Hero-only banner extras --- */ +.fyp-social-hero__name { + margin: 0 0 0.65rem; + font-family: var(--fyp-font-mono); + font-size: 2.35rem; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; + background: var(--fyp-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.fyp-social-banner--hero .fyp-social-banner__tagline { + margin-bottom: 1rem; +} + +.fyp-social-hero__thesis { + margin: 0 0 1.5rem; + max-width: 44rem; + font-family: var(--fyp-font-mono); + font-size: 0.92rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.4; + color: var(--vp-c-brand-2); +} + +.fyp-social-hero__features { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.85rem; + margin: 0; + padding: 0; + list-style: none; +} + +.fyp-social-hero__feature { + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-left: 3px solid var(--fyp-purple); + padding: 0.85rem 0.95rem; + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(6px); + box-shadow: + 0 4px 14px rgba(6, 182, 212, 0.06), + 0 0 0 1px rgba(255, 255, 255, 0.6) inset; +} + +.fyp-social-hero__feature:nth-child(4n + 2) { + border-left-color: var(--fyp-cyan); +} + +.fyp-social-hero__feature:nth-child(4n + 3) { + border-left-color: #0ea5e9; +} + +.fyp-social-hero__feature:nth-child(4n + 4) { + border-left-color: var(--fyp-amber); +} + +.fyp-social-hero__feature-title { + margin: 0 0 0.35rem; + font-family: var(--fyp-font-mono); + font-size: 0.82rem; + font-weight: 700; + line-height: 1.25; + color: var(--vp-c-text-1); +} + +.fyp-social-hero__feature-detail { + margin: 0; + font-size: 0.78rem; + line-height: 1.4; + color: var(--vp-c-text-2); +} + +/* --- Timeline banner extras --- */ +.fyp-social-timeline__phases { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; + margin: 1.25rem 0 0.85rem; + padding: 0; + list-style: none; +} + +.fyp-social-timeline__phase { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.8rem 0.95rem; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(6px); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04); +} + +.fyp-social-timeline__phase-bar { + display: block; + width: 100%; + height: 4px; + border-radius: 999px; + background: var(--phase-color, var(--fyp-purple)); + margin-bottom: 0.35rem; +} + +.fyp-social-timeline__phase-label { + font-family: var(--fyp-font-mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--phase-color, var(--vp-c-text-1)); +} + +.fyp-social-timeline__phase-range { + font-size: 0.78rem; + color: var(--vp-c-text-2); +} + +.fyp-social-timeline__legend { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 0; + font-family: var(--fyp-font-mono); + font-size: 0.72rem; + color: var(--vp-c-text-3); +} + +.fyp-social-timeline__legend-item { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.fyp-social-timeline__legend-dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 999px; + flex: 0 0 auto; +} + +.fyp-social-timeline__legend-dot--done { + background: var(--fyp-purple); +} + +.fyp-social-timeline__legend-dot--upcoming { + background: #ffffff; + border: 2px solid var(--fyp-cyan); +} + +.fyp-social-timeline__body { + padding: 0.35rem 1.75rem 1.1rem; + background: rgba(255, 255, 255, 0.96); + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.fyp-social-timeline__body .fyp-timeline { + margin: 0.35rem 0 0; +} + +/* --- Board banner extras --- */ +.fyp-social-banner--board { + padding: 1.65rem 2.25rem 1.35rem; +} + +.fyp-social-card--board .fyp-social-banner__glow--primary { + background: linear-gradient(-45deg, rgba(124, 58, 237, 0.42), rgba(6, 182, 212, 0.32)); +} + +.fyp-social-card--board .fyp-social-banner__glow--secondary { + top: auto; + bottom: -100px; + right: 120px; + left: auto; + width: 320px; + height: 320px; + background: linear-gradient(135deg, rgba(245, 158, 11, 0.28), rgba(124, 58, 237, 0.16)); +} + +.fyp-social-board__banner-grid { + display: flex; + flex-direction: column; + gap: 0.85rem; + margin-top: 1.25rem; +} + +.fyp-social-board__horizons { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; + margin: 0; + padding: 0; + list-style: none; +} + +.fyp-social-board__horizon { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1rem; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(6px); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04); + border-top: 3px solid var(--fyp-purple); +} + +.fyp-social-board__horizon--2 { + border-top-color: var(--fyp-cyan); +} + +.fyp-social-board__horizon--3 { + border-top-color: var(--fyp-amber); +} + +.fyp-social-board__horizon-label { + font-family: var(--fyp-font-mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--vp-c-text-1); +} + +.fyp-social-board__tracks { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.85rem; + margin: 0; + padding: 0; + list-style: none; +} + +.fyp-social-board__track { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.7rem 0.85rem; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-left: 3px solid var(--track-accent, var(--fyp-purple)); + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(6px); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04); +} + +.fyp-social-board__track-swatch { + width: 0.75rem; + height: 0.75rem; + flex: 0 0 auto; + border-radius: 3px; + background: var(--track-accent, var(--fyp-purple)); +} + +.fyp-social-board__track-label { + font-family: var(--fyp-font-mono); + font-size: 0.72rem; + font-weight: 700; + line-height: 1.25; + color: var(--vp-c-text-1); +} + +.fyp-social-board__legend { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin: 0.85rem 0 0; +} + +.fyp-social-board__legend-item { + display: inline-flex; +} + +.fyp-social-board__status { + display: inline-block; + font-family: var(--fyp-font-mono); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.45rem; + border-radius: 4px; +} + +.fyp-social-board__status--done { + color: #047857; + background: rgba(16, 185, 129, 0.14); +} + +.fyp-social-board__status--in-progress { + color: #6d28d9; + background: rgba(124, 58, 237, 0.14); +} + +.fyp-social-board__status--planned { + color: var(--vp-c-text-2); + background: var(--vp-c-default-soft); +} + +.fyp-social-board__body { + padding: 0.35rem 1.75rem 1.1rem; + background: rgba(255, 255, 255, 0.96); + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.fyp-social-board__body .fyp-roadmap { + margin: 0.35rem 0 0; +} + +.fyp-timeline { + --track-color: var(--vp-c-divider); + overflow-x: visible; + padding: 0.25rem 0 0; +} + +.fyp-timeline__track { + position: relative; + display: flex; + min-width: 640px; + align-items: stretch; + gap: 0; +} + +.fyp-timeline__phase { + position: relative; + flex: 1 1 0; + padding: 0 0.75rem 1.5rem; + min-width: 140px; +} + +.fyp-timeline__phase-label { + font-family: var(--fyp-font-mono); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--phase-color, var(--vp-c-brand-1)); +} + +.fyp-timeline__phase-range { + font-size: 0.72rem; + color: var(--vp-c-text-3); + margin-bottom: 0.6rem; +} + +.fyp-timeline__bar { + position: relative; + height: 4px; + border-radius: 2px; + background: var(--phase-color, var(--vp-c-brand-1)); + opacity: 0.6; +} + +.fyp-timeline__events { + position: relative; + margin-top: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.fyp-timeline__event { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8rem; + line-height: 1.3; +} + +.fyp-timeline__dot { + margin-top: 0.2rem; + width: 0.7rem; + height: 0.7rem; + flex: 0 0 auto; + border-radius: 999px; + background: var(--vp-c-bg); + border: 2px solid var(--phase-color, var(--vp-c-brand-1)); +} + +.fyp-timeline__event--done .fyp-timeline__dot { + background: var(--phase-color, var(--vp-c-brand-1)); +} + +.fyp-timeline__event-date { + font-family: var(--fyp-font-mono); + font-size: 0.7rem; + color: var(--vp-c-text-3); + display: block; +} + +/* --- Roadmap board (from custom.css) --- */ +.fyp-roadmap { + overflow-x: visible; +} + +.fyp-roadmap__grid { + display: grid; + grid-template-columns: minmax(120px, 0.7fr) repeat(var(--horizon-count, 3), minmax(180px, 1fr)); + gap: 0.6rem; + min-width: 720px; +} + +.fyp-roadmap__corner { + border-bottom: 1px solid var(--vp-c-divider); +} + +.fyp-roadmap__horizon { + font-family: var(--fyp-font-mono); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--vp-c-text-2); + padding: 0 0.25rem 0.5rem; + border-bottom: 1px solid var(--vp-c-divider); +} + +.fyp-roadmap__track-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + font-size: 0.85rem; + padding-right: 0.5rem; +} + +.fyp-roadmap__track-swatch { + width: 0.8rem; + height: 0.8rem; + flex: 0 0 auto; + border-radius: 3px; + background: var(--track-accent, var(--vp-c-brand-1)); +} + +.fyp-roadmap__cell { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.fyp-roadmap__item { + border: 1px solid var(--vp-c-divider); + border-left: 3px solid var(--track-accent, var(--vp-c-brand-1)); + border-radius: 6px; + padding: 0.5rem 0.6rem; + font-size: 0.8rem; + line-height: 1.3; + background: var(--vp-c-bg-soft); +} + +.fyp-roadmap__item-title { + font-weight: 600; +} + +.fyp-roadmap__item-note { + color: var(--vp-c-text-3); + font-size: 0.72rem; + margin-top: 0.15rem; +} + +.fyp-roadmap__status { + display: inline-block; + margin-top: 0.35rem; + font-family: var(--fyp-font-mono); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.05rem 0.4rem; + border-radius: 4px; +} + +.fyp-roadmap__status--done { + color: #047857; + background: rgba(16, 185, 129, 0.14); +} + +.fyp-roadmap__status--in-progress { + color: #6d28d9; + background: rgba(124, 58, 237, 0.14); +} + +.fyp-roadmap__status--planned { + color: var(--vp-c-text-2); + background: var(--vp-c-default-soft); +} diff --git a/vite.social.config.ts b/vite.social.config.ts new file mode 100644 index 0000000..7ff515c --- /dev/null +++ b/vite.social.config.ts @@ -0,0 +1,30 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +const rootDir = fileURLToPath(new URL('./roadmap/social', import.meta.url)) +const outDir = fileURLToPath(new URL('./roadmap/social/dist', import.meta.url)) + +/** Standalone render targets for Twitter/social screenshots (Playwright capture). */ +export default defineConfig({ + root: rootDir, + plugins: [vue()], + publicDir: fileURLToPath(new URL('./roadmap/public', import.meta.url)), + build: { + outDir, + emptyOutDir: true, + }, + resolve: { + alias: { + '@social': fileURLToPath(new URL('./roadmap/social/src', import.meta.url)), + }, + }, + server: { + port: 5175, + strictPort: false, + }, + preview: { + port: 4175, + strictPort: false, + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts index 0ec704b..dbab159 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,13 @@ export default mergeConfig( test: { environment: 'jsdom', exclude: [...configDefaults.exclude, 'e2e/**'], + /** Entire repo — src, community-token, roadmap, og tooling; CI runs all via `npm run test:unit:ci`. */ + include: [ + 'src/**/*.spec.ts', + 'community-token/**/*.spec.ts', + 'roadmap/**/*.spec.ts', + 'og/**/*.spec.ts', + ], root: fileURLToPath(new URL('./', import.meta.url)), }, }),