feat: refresh demo seed and login device sessions

This commit is contained in:
tianma-if committed 2026-07-22 13:55:00 +08:00
1 parent 9b44e3689e
commit 39cde0d620
41 files changed
+5936 -44

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
import type { LoginDeviceSession } from "@edgeever/shared";
export type LoginDeviceSessionRow = {
id: string;
device_id: string | null;
user_agent: string | null;
expires_at: string;
created_at: string;
last_seen_at: string | null;
};
export const resolveSessionDeviceId = (
requestedDeviceId: string | undefined,
userAgent: string | null,
sessionId: string,
) => requestedDeviceId ?? (userAgent?.trim() ? `legacy-ua:${userAgent}` : `legacy-session:${sessionId}`);
export const groupLoginDeviceSessions = (
rows: LoginDeviceSessionRow[],
currentSessionId: string,
): LoginDeviceSession[] => {
const grouped = new Map<
string,
{ representative: LoginDeviceSessionRow; createdAt: string; lastSeenAt: string }
>();
for (const row of rows) {
const key = row.device_id ?? `session:${row.id}`;
const lastSeenAt = row.last_seen_at ?? row.created_at;
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, { representative: row, createdAt: row.created_at, lastSeenAt });
continue;
}
if (row.created_at < existing.createdAt) existing.createdAt = row.created_at;
if (lastSeenAt > existing.lastSeenAt) existing.lastSeenAt = lastSeenAt;
if (row.id === currentSessionId) existing.representative = row;
}
return [...grouped.values()].map(({ representative, createdAt, lastSeenAt }) => ({
id: representative.id,
userAgent: representative.user_agent,
isCurrent: representative.id === currentSessionId,
createdAt,
lastSeenAt,
expiresAt: representative.expires_at,
}));
};
+131 -41
View File
@@ -63,6 +63,11 @@ import {
resolveDemoPasswordHash,
shouldUpsertDemoSeedRecord,
} from "./demo-mode";
import {
groupLoginDeviceSessions,
resolveSessionDeviceId,
type LoginDeviceSessionRow,
} from "./auth-session-devices";
type Bindings = {
DB: D1Database;
@@ -399,6 +404,53 @@ const DEMO_SEED_MEMOS = [
markdown:
"## 图片笔记示例\n\n笔记正文可以直接插入图片。上传后的图片会进入 R2,正文里保存的是资源 URL,API、MCP 和前端编辑器都能读取。\n\n![EdgeEver 图片资源示例](/api/v1/resources/res_demo_gallery_image/blob)\n\n**图注:** 一张图片和它的说明、结论放在同一条笔记里,回看时就不必猜测截图来自哪里。\n\n这类笔记适合保存截图、设计稿、读书摘图和临时资料。",
},
{
id: "memo_demo_table",
notebookId: "nb_demo_features",
title: "表格编辑:功能对比",
tags: ["table", "editor", "demo"],
isPinned: true,
markdown:
"## 表格编辑:功能对比\n\n这张表适合在 Web 或移动端直接编辑。可以点击单元格修改内容,也可以继续添加行列。\n\n| 能力 | Web | 移动端 | 适合场景 |\n| --- | --- | --- | --- |\n| 创建表格 | 支持 | 支持 | 会议记录 |\n| 编辑单元格 | 支持 | 支持 | 计划跟踪 |\n| 横向滚动 | 支持 | 支持 | 对比资料 |\n\n试着把「会议记录」改成一个真实项目,体验表格在桌面和手机上的一致性。",
},
{
id: "memo_demo_character_count",
notebookId: "nb_demo_features",
title: "字符数统计:一段产品摘要",
tags: ["editor", "character-count", "demo"],
isPinned: false,
markdown:
"## 字符数统计:一段产品摘要\n\nEdgeEver 是一个开放、轻量、支持 AI Agent 的个人知识库。它用三栏工作流承载快速记录、集中整理和长期沉淀,让笔记既适合日常捕捉,也方便通过 API 和 MCP 继续加工。\n\n这条笔记故意保留一段完整文字。打开编辑器后,观察底部字符数随着输入、删除和粘贴实时变化。",
},
{
id: "memo_demo_focus_mode",
notebookId: "nb_demo_features",
title: "桌面专注模式:产品发布提纲",
tags: ["focus-mode", "desktop", "demo"],
isPinned: false,
markdown:
"## 桌面专注模式:产品发布提纲\n\n这是一条适合长文编辑的示例。桌面端点击编辑器右上角的专注模式后,笔记会展开到更宽的工作区,减少笔记本树和列表的干扰。\n\n### 发布信息\n\n- 一句话定位:把零散材料变成可持续维护的知识库\n- 核心演示:快速记录、表格整理、Mermaid 流程和版本恢复\n- 现场操作:进入专注模式,补充一段发布说明,再退出回到三栏布局\n\n专注模式适合写方案、会议纪要、读书笔记和较长的项目总结。",
},
{
id: "memo_demo_revision",
notebookId: "nb_demo_features",
title: "版本历史:会议决策记录",
tags: ["revision", "history", "demo"],
isPinned: true,
revision: 2,
markdown:
"## 版本历史:会议决策记录\n\n这条笔记预置了历史版本。打开「版本历史」后,可以查看旧版本、对比差异,并恢复选中的版本;恢复操作还会生成新的版本记录。\n\n### 当前结论\n\n- 首版先支持 Web 和移动端的表格编辑\n- Mermaid 示例覆盖流程、时序、状态和甘特图\n- 发布前用专注模式完成长文校对\n\n请先查看版本 1 的草稿,再回到当前版本,体验历史内容与最新结论之间的差异。",
},
];
const DEMO_SEED_REVISIONS = [
{
id: "rev_demo_revision_1",
memoId: "memo_demo_revision",
revision: 1,
title: "版本历史:会议决策记录",
markdown:
"## 会议决策记录\n\n- 先完成编辑器和表格能力\n- 再补充 Mermaid 图表\n- 最后统一整理发布说明\n\n这是会议初稿,后续版本补充了验证结果。",
},
];
const DEMO_SEED_RESOURCES = [
{
@@ -535,32 +587,19 @@ app.get("/api/v1/auth/sessions", async (c) => {
const now = isoNow();
const rows = await c.env.DB.prepare(
`SELECT id, user_agent, expires_at, created_at, last_seen_at
`SELECT id, device_id, user_agent, expires_at, created_at, last_seen_at
FROM sessions
WHERE user_id = ?
AND revoked_at IS NULL
AND expires_at > ?
ORDER BY COALESCE(last_seen_at, created_at) DESC
LIMIT 50`
LIMIT 200`
)
.bind(auth.actorId, now)
.all<{
id: string;
user_agent: string | null;
expires_at: string;
created_at: string;
last_seen_at: string | null;
}>();
.all<LoginDeviceSessionRow>();
return c.json({
sessions: rows.results.map((session) => ({
id: session.id,
userAgent: session.user_agent,
isCurrent: session.id === auth.sessionId,
createdAt: session.created_at,
lastSeenAt: session.last_seen_at ?? session.created_at,
expiresAt: session.expires_at,
})),
sessions: groupLoginDeviceSessions(rows.results, auth.sessionId).slice(0, 50),
});
});
@@ -598,18 +637,31 @@ app.delete("/api/v1/auth/sessions/:sessionId", async (c) => {
const now = isoNow();
const session = await c.env.DB.prepare(
`SELECT id FROM sessions
`SELECT id, device_id FROM sessions
WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > ?`
)
.bind(sessionId, auth.actorId, now)
.first<{ id: string }>();
.first<{ id: string; device_id: string | null }>();
if (!session) {
return notFound(c, "Login session not found.");
}
const currentSession = await c.env.DB.prepare(`SELECT device_id FROM sessions WHERE id = ? AND user_id = ?`)
.bind(auth.sessionId, auth.actorId)
.first<{ device_id: string | null }>();
if (session.device_id && currentSession?.device_id === session.device_id) {
return apiError(c, "current_session_cannot_be_revoked", "The current device cannot be revoked here.", 400);
}
await c.env.DB.batch([
c.env.DB.prepare(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).bind(now, session.id),
session.device_id
? c.env.DB.prepare(
`UPDATE sessions SET revoked_at = ?
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
).bind(now, auth.actorId, session.device_id)
: c.env.DB.prepare(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).bind(now, session.id),
auditStatement(c.env.DB, "user", auth.actorId, "auth.session_revoke", "session", session.id, {}),
]);
@@ -630,7 +682,7 @@ app.post("/api/v1/auth/login", zValidator("json", LoginSchema), async (c) => {
}
const workspace = await ensureUserWorkspace(c.env.DB, user.id, user.username);
const session = await createSession(c, user);
const session = await createSession(c, user, input.deviceId);
setSessionCookie(c, session.token, session.maxAge);
await c.env.DB.batch([
@@ -3792,31 +3844,28 @@ const createDefaultNotebookRows = (workspaceId: string, _now: string) => [
{ id: `${workspaceId}_personal`, name: "生活个人", slug: "personal-life", color: "#ea580c", sortOrder: 50 },
];
const createSession = async (c: AppContext, user: UserRow) => {
const createSession = async (c: AppContext, user: UserRow, requestedDeviceId?: string) => {
const token = randomToken(SESSION_TOKEN_BYTES);
const id = createId("sess");
const now = isoNow();
const maxAge = getSessionMaxAge(c.env);
const expiresAt = new Date(Date.now() + maxAge * 1000).toISOString();
const userAgent = c.req.header("User-Agent") ?? null;
const deviceId = resolveSessionDeviceId(requestedDeviceId, userAgent, id);
const ip = c.req.header("CF-Connecting-IP");
const ipHash = ip ? await sha256(ip) : null;
await c.env.DB.prepare(
`INSERT INTO sessions (
id, user_id, token_hash, user_agent, ip_hash, expires_at, created_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
id,
user.id,
await sha256(token),
c.req.header("User-Agent") ?? null,
ipHash,
expiresAt,
now,
now
)
.run();
await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE sessions SET revoked_at = ?
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL`
).bind(now, user.id, deviceId),
c.env.DB.prepare(
`INSERT INTO sessions (
id, user_id, token_hash, device_id, user_agent, ip_hash, expires_at, created_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(id, user.id, await sha256(token), deviceId, userAgent, ipHash, expiresAt, now, now),
]);
return { id, token, maxAge };
};
@@ -5443,16 +5492,25 @@ const ensureDemoSeed = async (
.prepare(
`INSERT INTO memo_contents (
memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(memo_id) DO UPDATE SET
content_json = excluded.content_json,
content_markdown = excluded.content_markdown,
content_text = excluded.content_text,
content_hash = excluded.content_hash,
revision = 0,
revision = excluded.revision,
updated_at = excluded.updated_at`
)
.bind(memo.id, JSON.stringify(contentJson), memo.markdown, contentText, contentHash, now, now),
.bind(
memo.id,
JSON.stringify(contentJson),
memo.markdown,
contentText,
contentHash,
"revision" in memo ? memo.revision : 0,
now,
now,
),
db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(memo.id),
db
.prepare(
@@ -5463,6 +5521,38 @@ const ensureDemoSeed = async (
);
}
for (const revision of DEMO_SEED_REVISIONS) {
const contentJson = markdownToDoc(revision.markdown);
const contentHash = await sha256(revision.markdown + JSON.stringify(contentJson));
statements.push(
db
.prepare(
`INSERT INTO memo_revisions (
id, memo_id, revision, title, content_json, content_markdown, content_hash,
created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'system', ?)
ON CONFLICT(id) DO UPDATE SET
memo_id = excluded.memo_id,
revision = excluded.revision,
title = excluded.title,
content_json = excluded.content_json,
content_markdown = excluded.content_markdown,
content_hash = excluded.content_hash`
)
.bind(
revision.id,
revision.memoId,
revision.revision,
revision.title,
JSON.stringify(contentJson),
revision.markdown,
contentHash,
now,
),
);
}
const existingResourceIds = options.refreshResources || overwriteExisting
? new Set<string>()
: new Set(
+13
View File
@@ -5,6 +5,17 @@ import * as SecureStore from "expo-secure-store";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
const SESSION_KEY = "edgeever.mobile.session";
const DEVICE_ID_KEY = "edgeever.mobile.device-id";
const getOrCreateDeviceId = async () => {
const existing = await SecureStore.getItemAsync(DEVICE_ID_KEY);
if (existing) return existing;
const randomPart = `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
const deviceId = `mobile-${Date.now().toString(36)}-${randomPart}`;
await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId);
return deviceId;
};
export type MobileSession = {
baseUrl: string;
@@ -67,10 +78,12 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => {
const signIn = useCallback(async (input: { baseUrl: string; username: string; password: string }) => {
const baseUrl = normalizeInstanceUrl(input.baseUrl);
const deviceId = await getOrCreateDeviceId();
const loginClient = createEdgeEverClient({ baseUrl });
const authSession = await loginClient.login({
username: input.username,
password: input.password,
deviceId,
});
if (!authSession.authenticated || !authSession.sessionToken) {
+23 -1
View File
@@ -52,6 +52,28 @@ type ListUsersResponse = { users: InstanceUser[] };
type UserResponse = { user: InstanceUser };
type ListLoginDeviceSessionsResponse = { sessions: LoginDeviceSession[] };
const WEB_DEVICE_ID_STORAGE_KEY = "edgeever.web.device-id";
const createWebDeviceId = () => {
const uuid = globalThis.crypto?.randomUUID?.();
return uuid
? `web-${uuid}`
: `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
};
const getOrCreateWebDeviceId = () => {
try {
const existing = window.localStorage.getItem(WEB_DEVICE_ID_STORAGE_KEY);
if (existing) return existing;
const deviceId = createWebDeviceId();
window.localStorage.setItem(WEB_DEVICE_ID_STORAGE_KEY, deviceId);
return deviceId;
} catch {
return createWebDeviceId();
}
};
type MemoResponse = {
memo: MemoDetail;
};
@@ -133,7 +155,7 @@ export const api = {
login: (payload: { username: string; password: string }) =>
request<AuthSession>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
body: JSON.stringify({ ...payload, deviceId: getOrCreateWebDeviceId() }),
}),
changePassword: (payload: { currentPassword: string; newPassword: string; confirmPassword: string }) =>
+6
View File
@@ -1462,6 +1462,12 @@
},
"password": {
"type": "string"
},
"deviceId": {
"type": "string",
"minLength": 16,
"maxLength": 160,
"description": "Stable client installation identifier used to replace older sessions from the same device"
}
},
"required": ["username", "password"]
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE sessions ADD COLUMN device_id TEXT;
UPDATE sessions
SET device_id = CASE
WHEN user_agent IS NULL OR trim(user_agent) = '' THEN 'legacy-session:' || id
ELSE 'legacy-ua:' || user_agent
END
WHERE device_id IS NULL;
CREATE INDEX idx_sessions_user_device
ON sessions(user_id, device_id, revoked_at, expires_at);
+2 -1
View File
@@ -1,6 +1,7 @@
import type {
ApiToken,
AuthSession,
LoginInput,
LoginDeviceSession,
InstanceUser,
CreatedApiToken,
@@ -181,7 +182,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
revokeOtherLoginDeviceSessions: () =>
request<{ ok: true }>("/api/v1/auth/sessions", { method: "DELETE" }),
login: (payload: { username: string; password: string }) =>
login: (payload: LoginInput) =>
request<AuthSession>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
+1
View File
@@ -54,6 +54,7 @@ export const MergeMemosSchema = z.object({
export const LoginSchema = z.object({
username: z.string().trim().min(1).max(80),
password: z.string().min(1).max(512),
deviceId: z.string().trim().min(16).max(160).optional(),
});
export const ChangePasswordSchema = z
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { LoginSchema } from "@edgeever/shared";
import {
groupLoginDeviceSessions,
resolveSessionDeviceId,
type LoginDeviceSessionRow,
} from "../apps/api/src/auth-session-devices";
describe("login device sessions", () => {
test("accepts an optional stable client device id", () => {
expect(LoginSchema.parse({ username: "owner", password: "secret", deviceId: "web-device-123456" }).deviceId)
.toBe("web-device-123456");
expect(LoginSchema.parse({ username: "owner", password: "secret" }).deviceId).toBeUndefined();
expect(() => LoginSchema.parse({ username: "owner", password: "secret", deviceId: "short" })).toThrow();
});
test("falls back to a stable legacy user-agent device id", () => {
expect(resolveSessionDeviceId(undefined, "EdgeEver/1.4.0", "sess_new"))
.toBe("legacy-ua:EdgeEver/1.4.0");
expect(resolveSessionDeviceId(undefined, null, "sess_new")).toBe("legacy-session:sess_new");
});
test("groups repeated sessions from the same device and preserves the current session", () => {
const rows: LoginDeviceSessionRow[] = [
{
id: "sess_latest",
device_id: "mobile-installation-1",
user_agent: "EdgeEver/1.4.0",
created_at: "2026-07-20T00:00:00.000Z",
last_seen_at: "2026-07-22T04:00:00.000Z",
expires_at: "2027-07-20T00:00:00.000Z",
},
{
id: "sess_current",
device_id: "mobile-installation-1",
user_agent: "EdgeEver/1.4.0",
created_at: "2026-07-18T00:00:00.000Z",
last_seen_at: "2026-07-21T04:00:00.000Z",
expires_at: "2027-07-18T00:00:00.000Z",
},
];
expect(groupLoginDeviceSessions(rows, "sess_current")).toEqual([
{
id: "sess_current",
userAgent: "EdgeEver/1.4.0",
isCurrent: true,
createdAt: "2026-07-18T00:00:00.000Z",
lastSeenAt: "2026-07-22T04:00:00.000Z",
expiresAt: "2027-07-18T00:00:00.000Z",
},
]);
});
});
@@ -0,0 +1,223 @@
<!--
Editorial Forest — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look. product-launch-video's
Step 2 copies it into the project as caption-skin.html; captions.mjs then fills the
three reserved holes, wraps the fragment in a <template>, and writes
compositions/captions.html:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (captions.mjs injects it from
frame.md, so the Step-2 brand overlay flows through; each var has an editorial-forest
literal fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: an oat-cream card on a single 2px green hairline border — the system's only
separator — with a 6px card radius and NO shadow (editorial-forest is flat, paper-based;
depth is color-block + hairline, never cast). Source Serif 4 weight 500, sentence case,
warm-ink glyphs. Upcoming words sit in faint ink; the current word gets a quiet
dusty-rose panel (a soft 6px-radius block, no blur — editorial restraint, not a loud
block) with ink reading on top; spoken words settle to solid ink, panel cleared. The
per-word emphasis accent is a 2px green hairline underline, never heavy.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- captions.mjs injects the project's :root tokens here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE oat-cream card — single 2px green hairline border (the system's only
separator), 6px card radius, NO shadow (flat, paper-based) */
.caption-pill {
max-width: 78%;
padding: 20px 44px 24px;
background: var(--cap-canvas, #efe7d4);
border: 2px solid var(--cap-accent, #d27e96);
border-radius: 6px;
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.1em 0.34em;
font-family: var(--font-display, "Source Serif 4"), Georgia, serif;
font-weight: 500;
font-size: clamp(40px, 4.4vw, 58px);
line-height: 1.16;
letter-spacing: -0.02em;
}
/* upcoming — faint warm ink, no panel */
.caption-word {
display: inline-block;
padding: 0 0.06em;
color: color-mix(in srgb, var(--cap-ink, #1a1a17) 42%, var(--cap-canvas, #efe7d4));
}
/* current word — a quiet dusty-rose panel (box-shadow spread = a soft 6px block w/o
layout shift; no blur — editorial restraint, not a loud block), warm ink on top */
.caption-word.is-active {
color: var(--cap-ink, #1a1a17);
background: var(--cap-accent, #d27e96);
box-shadow: 0 0 0 0.05em var(--cap-accent, #d27e96);
border-radius: 6px;
}
/* already spoken — solid warm ink, panel cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #1a1a17);
background: transparent;
box-shadow: none;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — heavier serif weight, editorial-forest's only display inflection */
.caption-word.cap-brand {
font-weight: 600;
}
/* numerals / data — the mono chrome voice, slightly recessed */
.caption-word.cap-num {
font-family: var(--font-body, "JetBrains Mono"), ui-monospace, monospace;
color: color-mix(in srgb, var(--cap-ink, #1a1a17) 72%, var(--cap-canvas, #efe7d4));
}
/* emphasis — a 2px green hairline underline (the system's only separator, never heavy) */
.caption-word.cap-cta {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--cap-accent-2, #e89cb1);
text-underline-offset: 0.1em;
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 48px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// captions.mjs injects the engine's scene-aware groups + real duration here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.985 }, { scale: 1, duration: 0.18, ease: "power1.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>
@@ -0,0 +1,20 @@
{
"version": 1,
"preferences": {
"destination": {
"value": "web-gif",
"confirmed_in": [
"edgeever-promo"
],
"updated_at": "2026-07-20T03:00:24.066Z"
},
"style_preset.product-launch-video": {
"value": "editorial-forest",
"confirmed_in": [
"edgeever-promo"
],
"updated_at": "2026-07-20T03:00:24.144Z"
}
},
"sightings": {}
}
+93
View File
@@ -0,0 +1,93 @@
# HyperFrames Composition Project
## Skills — USE THESE FIRST
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
## Commands
```bash
npm run dev # start the preview server (long-running — keep it alive in background)
npm run check # lint + runtime + layout + motion + contrast (one command)
npm run render # render to MP4
npm run publish # publish and get a shareable link
npx hyperframes lint --verbose # include info-level findings
npx hyperframes lint --json # machine-readable output for CI
npx hyperframes docs <topic> # reference docs in terminal
```
> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped.
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
> command — it will time out and the server will die, breaking the browser preview.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
```bash
npx hyperframes docs <topic>
```
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
```
https://hyperframes.heygen.com/llms.txt
```
## Project Structure
- `index.html` — main composition (root timeline)
- `compositions/` — sub-compositions referenced via `data-composition-src`
- `meta.json` — project metadata (id, name)
- `transcript.json` — whisper word-level transcript (if generated)
## Linting — ALWAYS RUN AFTER CHANGES
After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
```bash
npm run check
```
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
## Key Rules
1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control
3. Timelines must be paused and registered on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
```
4. Videos use `muted` with a separate `<audio>` element for the audio track
5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
+36
View File
@@ -0,0 +1,36 @@
---
workflow: product-launch-video
flow: automation
storyboard: yes
message: "EdgeEver 让个人笔记保持快速、开放、AI 友好,并可在 Cloudflare 上零成本自托管"
destination: web-gif
aspect: 1920x1080
language: zh-CN
audience: 重视数据自主、效率与 AI 工作流的个人知识管理用户和开源爱好者
length: 12s
angle: demo-loop
narration: no
style_preset: editorial-forest
---
## Intent
为当前 EdgeEver 项目制作一段约 12 秒、16:9、中文、静音循环的宣传 GIF。以真实产品界面为主要证据,快速传达经典三栏体验、富文本与 Markdown、Cloudflare 零成本自托管,以及 REST API / MCP 的 AI Agent 友好能力。
## Assets
- capture/assets/screens/workspace-editor.png — 真实三栏工作区与富文本/Markdown 编辑界面。
- capture/assets/screens/workspace-agent-mcp.png — 真实 REST API、OpenAPI 与 MCP 功能笔记界面。
- capture/assets/screens/workspace-agent-mcp-dark.png — 同一功能的深色模式界面。
## Customizations
- 最终交付 GIF,同时保留高质量 MP4 母版。
- 无配音、无背景音乐、无音效;节奏完全由界面运动和文字驱动。
- GIF 设计为首尾自然衔接的循环。
## Notes
- 不展示登录页,不虚构产品界面或功能。
- 保持 EdgeEver 的绿色品牌色与简洁、可信、偏开发者友好的气质。
- CTA 使用项目名与公开演示地址 demo.edgeever.org。
+93
View File
@@ -0,0 +1,93 @@
# HyperFrames Composition Project
## Skills — USE THESE FIRST
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
## Commands
```bash
npm run dev # start the preview server (long-running — keep it alive in background)
npm run check # lint + runtime + layout + motion + contrast (one command)
npm run render # render to MP4
npm run publish # publish and get a shareable link
npx hyperframes lint --verbose # include info-level findings
npx hyperframes lint --json # machine-readable output for CI
npx hyperframes docs <topic> # reference docs in terminal
```
> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped.
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
> command — it will time out and the server will die, breaking the browser preview.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
```bash
npx hyperframes docs <topic>
```
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
```
https://hyperframes.heygen.com/llms.txt
```
## Project Structure
- `index.html` — main composition (root timeline)
- `compositions/` — sub-compositions referenced via `data-composition-src`
- `meta.json` — project metadata (id, name)
- `transcript.json` — whisper word-level transcript (if generated)
## Linting — ALWAYS RUN AFTER CHANGES
After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
```bash
npm run check
```
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
## Key Rules
1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control
3. Timelines must be paused and registered on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
```
4. Videos use `muted` with a separate `<audio>` element for the audio track
5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
+83
View File
@@ -0,0 +1,83 @@
---
format: 1920x1080
message: "EdgeEver 让个人笔记保持快速、开放、AI 友好,并可在 Cloudflare 上零成本自托管"
arc: Demo Loop
audience: 重视数据自主、效率与 AI 工作流的个人知识管理用户和开源爱好者
mode: collaborative
music: none
language: zh-CN
target_duration: 12s
loop: seamless
---
## Frame 1 — 笔记本该属于你
- scene: 大字先提出“笔记,不该被订阅和平台锁住”,随后 EdgeEver 绿色名称接管画面
- voiceover: ""
- duration: 2.8s
- poster: 2.1s
- transition_in: cut
- status: outline
- src: compositions/frames/01-owned-notes.html
- type: hook
- persuasion: Pain validation + ownership promise
- beat: tension → relief
- blueprint: ticker-takeover — 用“订阅 / 平台锁定”轮换后由 EdgeEver 接管画面
- asset_candidates:
narrativeRole: 用用户最在意的数据自主开场,并在同一拍给出 EdgeEver 这一替代方案。
keyMessage: 笔记和数据应该真正属于用户。
## Frame 2 — 熟悉,却更轻快
- scene: 真实 EdgeEver 工作区作为主角,镜头依次强调笔记本树、笔记列表、富文本与 Markdown 编辑区
- voiceover: ""
- duration: 3.3s
- poster: 2.5s
- transition_in: zoom-through
- status: outline
- src: compositions/frames/02-three-pane-editor.html
- type: product_intro
- persuasion: Show-don't-tell proof
- beat: familiarity + control
- blueprint: cursor-ui-demo — 自定义光标扫过三栏界面并落在 Markdown 切换入口
- asset_candidates: assets/workspace-editor.png — 真实浅色三栏工作区、富文本正文与 Markdown 控件
narrativeRole: 用真实界面证明 EdgeEver 保留经典笔记体验,同时把编辑工作流做得直接、清晰。
keyMessage: 三栏布局、富文本和 Markdown,在同一个轻快工作区里完成。
## Frame 3 — 开放给你,也开放给 AI
- scene: 浅色与深色真实界面左右展开,中间依次落下 Cloudflare、REST API、OpenAPI、MCP 四个能力标签
- voiceover: ""
- duration: 3.3s
- poster: 2.5s
- transition_in: push-slide LEFT
- status: outline
- src: compositions/frames/03-open-ai-ready.html
- type: feature_showcase
- persuasion: Feature-to-benefit translation
- beat: confidence + power
- blueprint: comparison-split — “零成本自托管”与“AI Agent-ready”两项同等价值并列呈现
- asset_candidates: assets/workspace-agent-mcp.png — 真实浅色 AI Agent、REST API、OpenAPI 与 MCP 功能界面; assets/workspace-agent-mcp-dark.png — 同一功能的真实深色模式界面
narrativeRole: 把底层开放能力翻译成用户价值:不用交出数据,也能让 AI 直接读写和整理笔记。
keyMessage: Cloudflare 零成本自托管,REST API 与 MCP 原生连接 AI Agent。
## Frame 4 — EdgeEver
- scene: 功能标签向中心收束成 EdgeEver 字标,最终定格“开源 · 自托管 · 100% Free”和 demo.edgeever.org
- voiceover: ""
- duration: 2.6s
- poster: 2.0s
- transition_in: squeeze
- status: outline
- src: compositions/frames/04-cta.html
- type: cta
- persuasion: Risk reversal + direct action
- beat: confidence + motivation
- blueprint: kinetic-type-beats — 三个价值词逐拍落下并解析为最终品牌与演示地址
- asset_candidates:
narrativeRole: 用低门槛承诺收束全片,让观众知道产品是什么,并给出可立即访问的下一步。
keyMessage: EdgeEver 开源、自托管、个人使用 100% 免费;立即访问公开演示。
@@ -0,0 +1,24 @@
# EdgeEver
Source: http://127.0.0.1:5173
To create a video from this capture, use the `product-launch-video` skill.
## What's in This Capture
| File | Contents |
|------|----------|
| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in labeled grid — see the entire page at a glance |
| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section. |
| `extracted/tokens.json` | Design tokens: 18 colors, 1 fonts, 1 headings, 0 CTAs |
| `extracted/design-styles.json` | Computed styles from live DOM: typography hierarchy, button/card/nav styles, spacing scale, border-radius, box shadows. Primary data source for DESIGN.md. |
| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |
| `extracted/visible-text.txt` | Page text in DOM order, prefixed with HTML tag (`[h1]`, `[p]`, `[a]`). Use as context — rephrase freely. |
| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |
| `assets/svgs/contact-sheet.jpg` | SVGs rendered as thumbnails in labeled grid |
| `assets/` | Individual downloaded images, SVGs, and font files. |
## Brand Summary
- **Colors**: #F8FAFC (bg-light), #020617 (bg-dark), #F1F5F9 (bg-light), #0F172A (accent), #FFFFFF (bg-light), #10B981 (accent), #E5E7EB (bg-light), #00A82D (accent), #ECFDF5 (bg-light), #475569 (neutral)
- **Fonts**: Inter (400,500,600,700)
+24
View File
@@ -0,0 +1,24 @@
# EdgeEver
Source: http://127.0.0.1:5173
To create a video from this capture, use the `product-launch-video` skill.
## What's in This Capture
| File | Contents |
|------|----------|
| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in labeled grid — see the entire page at a glance |
| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section. |
| `extracted/tokens.json` | Design tokens: 18 colors, 1 fonts, 1 headings, 0 CTAs |
| `extracted/design-styles.json` | Computed styles from live DOM: typography hierarchy, button/card/nav styles, spacing scale, border-radius, box shadows. Primary data source for DESIGN.md. |
| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |
| `extracted/visible-text.txt` | Page text in DOM order, prefixed with HTML tag (`[h1]`, `[p]`, `[a]`). Use as context — rephrase freely. |
| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |
| `assets/svgs/contact-sheet.jpg` | SVGs rendered as thumbnails in labeled grid |
| `assets/` | Individual downloaded images, SVGs, and font files. |
## Brand Summary
- **Colors**: #F8FAFC (bg-light), #020617 (bg-dark), #F1F5F9 (bg-light), #0F172A (accent), #FFFFFF (bg-light), #10B981 (accent), #E5E7EB (bg-light), #00A82D (accent), #ECFDF5 (bg-light), #475569 (neutral)
- **Fonts**: Inter (400,500,600,700)
+24
View File
@@ -0,0 +1,24 @@
# EdgeEver
Source: http://127.0.0.1:5173
To create a video from this capture, use the `product-launch-video` skill.
## What's in This Capture
| File | Contents |
|------|----------|
| `screenshots/contact-sheet.jpg` | **View this first.** All scroll screenshots in labeled grid — see the entire page at a glance |
| `screenshots/scroll-*.png` | Individual viewport screenshots if you need detail on a specific section. |
| `extracted/tokens.json` | Design tokens: 18 colors, 1 fonts, 1 headings, 0 CTAs |
| `extracted/design-styles.json` | Computed styles from live DOM: typography hierarchy, button/card/nav styles, spacing scale, border-radius, box shadows. Primary data source for DESIGN.md. |
| `extracted/asset-descriptions.md` | One-line description of every downloaded asset. Read this for asset selection — only open individual files for safe-zone checking. |
| `extracted/visible-text.txt` | Page text in DOM order, prefixed with HTML tag (`[h1]`, `[p]`, `[a]`). Use as context — rephrase freely. |
| `assets/contact-sheet.jpg` | All downloaded images in one labeled grid. |
| `assets/svgs/contact-sheet.jpg` | SVGs rendered as thumbnails in labeled grid |
| `assets/` | Individual downloaded images, SVGs, and font files. |
## Brand Summary
- **Colors**: #F8FAFC (bg-light), #020617 (bg-dark), #F1F5F9 (bg-light), #0F172A (accent), #FFFFFF (bg-light), #10B981 (accent), #E5E7EB (bg-light), #00A82D (accent), #ECFDF5 (bg-light), #475569 (neutral)
- **Fonts**: Inter (400,500,600,700)
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-keyhole h-5.5 w-5.5" aria-hidden="true"><circle cx="12" cy="16" r="1"></circle><rect x="3" y="10" width="18" height="12" rx="2"></rect><path d="M7 10V7a5 5 0 0 1 10 0v3"></path></svg>

After

Width:  |  Height:  |  Size: 390 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-keyhole h-4 w-4 mr-1" aria-hidden="true"><circle cx="12" cy="16" r="1"></circle><rect x="3" y="10" width="18" height="12" rx="2"></rect><path d="M7 10V7a5 5 0 0 1 10 0v3"></path></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg class="shrink-0 h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.48 2 2 6.59 2 12.25c0 4.52 2.87 8.35 6.84 9.7.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.46-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.06 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.33 9.33 0 0 1 12 6.97c.85 0 1.7.12 2.5.35 1.9-1.33 2.74-1.05 2.74-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.93-2.34 4.8-4.57 5.05.36.32.68.94.68 1.9 0 1.38-.01 2.48-.01 2.82 0 .27.18.59.69.49A10.18 10.18 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"></path></svg>

After

Width:  |  Height:  |  Size: 755 B

@@ -0,0 +1,12 @@
{
"summary": {
"webAnimations": 0,
"cssDeclarations": 3,
"scrollTargets": 0,
"cdpAnimations": 0,
"canvases": 0
},
"namedAnimations": [],
"scrollTriggeredElements": 0,
"representativeAnimations": []
}
@@ -0,0 +1,12 @@
# Asset Descriptions
⚠️ GEMINI_API_KEY not set — descriptions below are catalog-derived (alt text, headings, section context, filename) instead of Vision-generated. To get richer Vision descriptions on the next capture, set GEMINI_API_KEY (or GOOGLE_API_KEY) and re-run.
The `logo-<hash>.svg` filename prefix is a structural hint (DOM said this SVG was inside a `<header>`, home-link `<a>`, or had an aria-label matching the page brand). To pick the actual brand logo without Vision, open the `logo-*` candidates in a previewer or rasterize them with `sharp` before referencing — composing a fake logo ships off-brand in the final video.
- svgs/logo-2409432b.svg — logo 2409432b
- svgs/logo-b7752207.svg — logo b7752207
- svgs/svg-1b0e8c1e.svg — svg 1b0e8c1e
- screens/workspace-editor.png — 1280×720 authenticated EdgeEver desktop workspace in light mode, showing the classic three-pane notebook/list/editor layout and a rich-text note with Markdown controls.
- screens/workspace-agent-mcp.png — 1280×720 authenticated workspace in light mode, showing the “Agent-ready: REST API 与 MCP” note, OpenAPI path, MCP endpoint, notebooks, tags, and note list.
- screens/workspace-agent-mcp-dark.png — 1280×720 authenticated workspace in dark mode, showing the same AI Agent, REST API, OpenAPI, and MCP workflow.
@@ -0,0 +1,100 @@
{
"typography": [
{
"role": "display",
"fontFamily": "Inter",
"fontSize": "20px",
"fontWeight": "700",
"lineHeight": "25px",
"letterSpacing": "-0.5px",
"color": "#0F172A",
"sampleText": "Sign in to EdgeEver"
},
{
"role": "label",
"fontFamily": "Inter",
"fontSize": "16px",
"fontWeight": "400",
"lineHeight": "24px",
"letterSpacing": "normal",
"color": "#020617",
"sampleText": "Username"
},
{
"role": "body",
"fontFamily": "Inter",
"fontSize": "12px",
"fontWeight": "500",
"lineHeight": "16px",
"letterSpacing": "0.6px",
"color": "#94A3B8",
"sampleText": "Your personal self-hosted notes workspace"
}
],
"spacing": {
"observed": [
8,
16,
32
],
"baseUnit": 8
},
"radius": [
"8px"
],
"shadows": [
{
"value": "rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 168, 45, 0.25) 0px 8px 20px -4px",
"count": 1
}
],
"buttons": [
{
"label": "Open workspace",
"background": "#10B981",
"backgroundImage": "",
"backdropFilter": "",
"color": "#FFFFFF",
"padding": "0px 12px",
"borderRadius": "8px",
"border": "0px solid rgb(16, 185, 129)",
"boxShadow": "rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 168, 45, 0.25) 0px 8px 20px -4px",
"fontSize": "14px",
"fontWeight": "600",
"height": "44px"
}
],
"cards": [],
"nav": null,
"chips": [],
"statCells": [],
"tabs": [],
"backgrounds": [
{
"value": "radial-gradient(circle at 100% 0%, rgba(0, 168, 45, 0.06), rgba(0, 0, 0, 0) 45%)",
"area": 2073600
},
{
"value": "radial-gradient(circle at 0% 100%, rgba(0, 168, 45, 0.04), rgba(0, 0, 0, 0) 55%)",
"area": 2073600
},
{
"value": "linear-gradient(to right top, rgba(236, 253, 245, 0.7), rgb(236, 253, 245), rgb(209, 250, 229))",
"area": 2073600
},
{
"value": "linear-gradient(rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98)), none",
"area": 2073600
}
],
"glass": [
{
"backdropFilter": "blur(12px)",
"background": "rgba(255, 255, 255, 0.95)",
"border": "1px solid rgba(16, 185, 129, 0.15)",
"borderRadius": "16px",
"boxShadow": "rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 168, 45, 0.08) 0px 20px 50px 0px",
"area": 154800
}
]
}
@@ -0,0 +1,9 @@
{
"files": [],
"families": [],
"unidentified": [],
"meta": {
"generatedAt": "2026-07-20T02:56:27.547Z",
"tool": "fontkit"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,163 @@
{
"title": "EdgeEver",
"description": "EdgeEver:基于 Cloudflare 全家桶自托管的开源印象笔记。",
"cssVariables": {
"--background": "80 11% 96%",
"--brand-green": "#00a82d",
"--brand-green-hover": "#008f26",
"--brand-green-muted": "#e6f6ea",
"--brand-green-soft": "#f0fbf3",
"--brand-green-border": "#99dcab",
"--brand-green-text": "#00751f",
"--editor-body-font-size": "0.9375rem",
"--border": "220 13% 91%",
"--radius": "0.5rem"
},
"fonts": [
{
"family": "Inter",
"weights": [
400,
500,
600,
700
],
"variable": false
}
],
"colors": [
"#F8FAFC",
"#020617",
"#F1F5F9",
"#0F172A",
"#FFFFFF",
"#10B981",
"#E5E7EB",
"#00A82D",
"#ECFDF5",
"#475569",
"#D1FAE5",
"#E2E8F0",
"#000000",
"#008F26",
"#E6F6EA",
"#F0FBF3",
"#99DCAB",
"#00751F"
],
"headings": [
{
"level": 1,
"text": "Sign in to EdgeEver",
"fontSize": "20px",
"fontWeight": "700",
"color": "#0F172A"
}
],
"ctas": [],
"svgs": [
{
"label": "shrink-0",
"viewBox": "0 0 24 24",
"width": 20,
"height": 20,
"isLogo": false
},
{
"label": "lucide-lock-keyhole",
"viewBox": "0 0 24 24",
"width": 24,
"height": 24,
"isLogo": true
},
{
"label": "lucide-lock-keyhole",
"viewBox": "0 0 24 24",
"width": 16,
"height": 16,
"isLogo": true
}
],
"sections": [
{
"selector": "section",
"type": "content",
"x": 760,
"y": 347,
"width": 400,
"height": 387,
"heading": "Sign in to EdgeEver",
"backgroundColor": "#FFFFFF",
"callsToAction": [
"Open workspace"
],
"text": "Sign in to EdgeEver YOUR PERSONAL SELF-HOSTED NOTES WORKSPACE Username Password Open workspace",
"layout": "centered",
"assetUrls": []
}
],
"colorStats": [
{
"hex": "#FFFFFF",
"count": 12,
"bgCount": 2,
"interactiveBg": 1,
"areaBg": 1,
"textCount": 10,
"maxArea": 154800
},
{
"hex": "#020617",
"count": 11,
"bgCount": 0,
"interactiveBg": 0,
"areaBg": 0,
"textCount": 11,
"maxArea": 0
},
{
"hex": "#10B981",
"count": 1,
"bgCount": 1,
"interactiveBg": 1,
"areaBg": 0,
"textCount": 0,
"maxArea": 14696
},
{
"hex": "#F8FAFC",
"count": 3,
"bgCount": 3,
"interactiveBg": 0,
"areaBg": 1,
"textCount": 0,
"maxArea": 2073600
},
{
"hex": "#0F172A",
"count": 3,
"bgCount": 0,
"interactiveBg": 0,
"areaBg": 0,
"textCount": 3,
"maxArea": 0
},
{
"hex": "#475569",
"count": 3,
"bgCount": 0,
"interactiveBg": 0,
"areaBg": 0,
"textCount": 3,
"maxArea": 0
}
],
"page": {
"width": 1920,
"height": 1080,
"viewport": {
"width": 1920,
"height": 1080
}
}
}
@@ -0,0 +1,13 @@
[h1] Sign in to EdgeEver
[p] Your personal self-hosted notes workspace
[span] Username
[span] Password
[button] Open workspace
[project] EdgeEver:基于 Cloudflare 全家桶自托管的开源印象笔记替代品。
[promise] Serverless、100% free、open-source、AI-native;个人使用零维护。
[feature] 经典三栏布局:笔记本树、笔记列表、主编辑器。
[feature] 富文本编辑,并可在桌面端切换 Markdown 源码与富文本视图。
[feature] 开放数据:REST API、OpenAPI schema、Remote MCP 与 AI Agent 集成。
[feature] 多端同步,支持 PWA、原生移动端和 Chrome/Edge Web Clipper。
[cta] 公开演示:https://demo.edgeever.org
+4
View File
@@ -0,0 +1,4 @@
{
"id": "127.0.0.1-video",
"name": "EdgeEver"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

+274
View File
@@ -0,0 +1,274 @@
---
version: alpha
name: Editorial Forest — Frame (video / frame layer)
description: >
Video-first companion to Editorial Forest's design.md. The unit is the frame (1920×1080). Atoms
are identical and sacred — the green/pink/cream editorial triad, Inter at weight 500
(optical-size axis) for all display + JetBrains Mono 500 uppercase chrome, flat paper depth (no
shadows), 2px hairline rules, 6/8px card radii, and the monogram circle stamp. Composition + frame
scale rewritten. Motion out of scope.
unit: the frame — 1920×1080 primary; 9:16 and 1:1 documented
principle: atoms are sacred · composition is free · numbers come from the script
colors:
green: "#00741F"
green-deep: "#005B18"
green-lite: "#009027"
pink: "#00A82D"
pink-deep: "#10B981"
cream: "#F8FAFC"
cream-2: "#ABFFC2"
ink: "#020617"
typography:
# — reading + chrome ramp —
body: { fontFamily: "Inter", cqw: 1.56, weight: 400, lineHeight: 1.38 }
body-card:{ fontFamily: "Inter", cqw: 1.35, weight: 400, lineHeight: 1.34 }
label: { fontFamily: "JetBrains Mono", px: 26, cqw: 1.35, weight: 500, tracking: "0.18em", upper: true }
caption-mono:{ fontFamily: "JetBrains Mono", px: 24, cqw: 1.25, weight: 500, tracking: "0.14em", upper: true }
# — display ramp (Inter weight 500, opsz, negative tracking) —
title-card-sm:{ fontFamily: "Inter", cqw: 2.9, weight: 500, lineHeight: 0.98, tracking: "-0.01em" }
title-card:{ fontFamily: "Inter", cqw: 3.5, weight: 500, lineHeight: 0.96, tracking: "-0.01em" }
headline:{ fontFamily: "Inter", cqw: 4.4, weight: 500, lineHeight: 1.0, tracking: "-0.02em" }
headline-xl:{ fontFamily: "Inter", cqw: 5.0, weight: 500, lineHeight: 0.96, tracking: "-0.02em" }
display:{ fontFamily: "Inter", cqw: 7.3, weight: 500, lineHeight: 1.02, tracking: "-0.02em" }
display-hero:{ fontFamily: "Inter", cqw: 11.5, weight: 500, lineHeight: 0.92, tracking: "-0.02em" }
stat-figure:{ fontFamily: "Inter", cqw: 11.5, weight: 500, lineHeight: 0.92, tracking: "-0.03em" }
stat-figure-unit:{ fontFamily: "Inter", cqw: 5.7, weight: 500, lineHeight: 0.92 }
name: { fontFamily: "Inter", cqw: 2.3, weight: 600, lineHeight: 1.0 }
spacing:
slide-pad: "5cqw"
rule: "2px"
rule-card: "2.5px"
radius-card: "6px"
radius-step: "8px"
components:
topbar:
typography: "{typography.label} (JetBrains Mono) + monogram-circle or counter"
placement: "top edge, label left, mark right"
description: "On EVERY frame — the system's spine; a frame without it reads untreated."
footline:
typography: "{typography.caption-mono} ×2, space-between"
placement: "absolute bottom edge"
description: "On cover/data/summary frames."
monogram-circle:
border: "2px solid {colors.pink}"
rounded: "50%"
size: "130px"
typography: "mono monogram"
description: "The identity stamp. Cover/summary only."
topic-tile:
backgroundColor: "{colors.green} (pink text) / {colors.pink} (green-deep) / {colors.green-lite} (pink) / {colors.cream-2} + 2px {colors.green} border (green)"
rounded: "{spacing.radius-card}"
shadow: "none"
typography: "{typography.caption-mono} ordinal + {typography.title-card-sm} + mono foot"
description: "Fills rotate; never repeat one across a grid."
step-tile:
backgroundColor: "{colors.cream} + green border / {colors.green} / {colors.pink}"
border: "2.5px solid"
rounded: "{spacing.radius-step}"
typography: "mono ordinal + {typography.title-card} + {typography.body-card} + mono marker over a top rule"
description: "Framework/process card."
kpi-block:
typography: "{typography.caption-mono} tag → {typography.stat-figure} (+{typography.stat-figure-unit}) → {typography.body}"
rule: "2px accent rule above"
description: "Oversized serif figure."
meta-dl:
borderTop: "2px solid {colors.green}"
typography: "{typography.caption-mono} dt + {typography.meta-value} dd"
description: "3-column dt/dd grid."
bar:
backgroundColor: "{colors.pink} / {colors.cream} / {colors.green}"
rounded: "3px 3px 0 0"
size: "56px wide"
typography: "mono value above"
description: "Vertical chart bar."
rule-thin:
rule: "2px solid (green on cream / pink on green / green-deep on pink)"
description: "The only separator. Never 1px, never 3px+."
---
# Editorial Forest — Frame (video / frame layer)
## Brand adaptation (READ FIRST — the frontmatter is the source of truth)
This is the **editorial-forest** preset remixed onto the captured brand. The YAML frontmatter above (colors · typography · components) is **normative and already correct — use it verbatim.** The prose below is the ORIGINAL preset's intent; read it THROUGH the frontmatter:
- **Fonts** — already set to **Inter** (display) / **Inter** (body); ignore any preset font name lingering in prose.
- **Weights** — the brand font ships `{400, 500, 600, 700}` only; every weight is clamped to these — ignore higher preset weights (e.g. 600/700) in prose.
- **Colors** — use the frontmatter hex; preset color NAMES in prose (e.g. "cobalt", "cream") mean the remapped brand values.
## Overview
Editorial Forest at frame scale is a **serif-led literary-editorial system** — the register of a
Penguin classic or a quiet annual report. One confident voice, **Inter at weight 500**
(optical-size axis engaged), carries every headline and stat up to ~12cqw; **JetBrains Mono** at
weight 500 uppercase is the editorial chrome (labels, captions, axis ticks, footlines). Three
surfaces — forest green, dusty rose, oat cream — and nothing else. Depth is **flat and paper-based**:
no shadows, no gradients; elevation is color-block contrast + 2px hairlines + border-vs-fill.
The unmistakable signature is **weight 500** (never 400 display, never 700) and the **mono/serif
role inversion** (mono for chrome, serif for body and display). Body text drops to serif weight
400 — that 500→400 step is the reading rhythm. Every frame carries a mono topbar; the monogram
circle is the identity stamp.
**Key characteristics at frame scale:**
- **Green / pink / cream triad** — two surface tones per frame is typical, three is loud.
- **Inter weight 500** (opsz, negative tracking) for all display; **serif 400** body; **JetBrains Mono 500** uppercase chrome.
- **Flat — no shadows, no gradients**; elevation is color-block + 2px hairline + border-vs-fill.
- **2px hairline rules** are the only separator (2.5px on step tiles); 6/8px card radii; the monogram circle is the only full round.
- **Topbar on every frame** (mono label + monogram/counter); footline on data/cover frames.
- **Spacious & committed** — one subject per frame in deep negative space; fewer elements at larger sizes.
## The Frame
### Frame Craft Bar
Three eyeball tests gate every frame before any structural check:
- **Squint** — one Inter moment dominates at 3–6× its neighbor; the surface block centers the eye.
- **Silence** — one subject per frame in deep negative space; the **topic-tile and step grids are the one dense exception**.
- **Restraint** — **two surface tones max** per frame (three is loud); serif **weight 500** display / **400** body; green ground reserved for gravity moments.
- **Reference** — aim at a **Penguin classic / quiet annual report / art-book spread**; failure looks like a **shadowed, multi-color web dashboard**.
- **Primary:** 1920×1080 (16:9). Display authored in **`cqw`** (`px ÷ 1920 × 100 = cqw`).
- **Vertical:** 1080×1920 (9:16). **Square:** 1080×1080 (1:1).
- **Safe area:** `slide-pad` 5cqw (the source's generous 96–140px); the topbar/footline sit inside it.
**The container law (load-bearing).** Every frame ground sets `container-type: size`; ALL
frame-relative units are `cqw`/`cqh` against it — never `vw` (the source is a fixed 1920 canvas, so
its px map cleanly: `px ÷ 1920 × 100`). Hairlines stay 2px; card radii stay 6/8px.
## Colors
Tokens identical to the source. Default ground `{colors.cream}` for content; `{colors.green}` for
cover/statement/summary gravity. **Headlines:** green on cream, cream (or pink at hero scale) on
green, green-deep on pink. **Body:** ink on cream, cream on green, green-deep on pink. Labels/rules
take the region's accent (pink on green, green on cream, green-deep on pink). Tile fills rotate
green / pink / green-lite / cream-2-with-green-border. **No fourth color family** — the triad +
near-duplicates is the whole palette; never `rgba` transparency on a surface.
## Typography
Two ramps. The **reading/chrome ramp** (Inter **400** body 1.56cqw; JetBrains Mono labels
in px) carries copy + chrome; the **display ramp** (Inter **500** `title-card-sm` 2.9cqw →
`display-hero`/`stat-figure` 11.5cqw, opsz, negative-tracked) carries every headline + figure.
- **Legibility floor:** any load-bearing line ≥ **1.4cqw**; mono px labels are chrome only.
- **Fit-to-measure:** size the headline to its length. Cap the block at **≤ 78cqw**; ≤3 words → `display-hero`; 4–6 → `headline-xl`; 7+ → `headline`. Reserve the 7.3–11.5cqw tier for statement/stat/cover.
- **Serif is weight 500 for display, 400 for body** (the rhythm), 600 only for an attribution name. Negative tracking (−0.01..−0.03em), tight line-height (0.92–1.02). **Mono uppercase, 0.08–0.18em.** No italic, no underline, no third face, no serif body at 500.
## Depth & Surface
Flat, paper-based. Elevation from:
- **Color-block contrast** — a green tile on cream reads elevated by ink-block separation.
- **2px hairline rules** — section separators in the region's accent.
- **Border vs fill** — a cream-2 tile + 2px green border reads a different elevation than a solid tile.
**Ceiling:** zero shadow (none — adding `box-shadow` shatters the paper feel), no gradient, no glow; surfaces are solid ink-on-paper.
## Shapes
- **6px** topic tiles, **8px** step tiles, **2px** legend swatch, **3px 3px 0 0** bar tops, **50%** monogram circle. No square corners, no heavy rounding.
## Components
- **topbar** (the spine) + **footline** + **monogram-circle** (identity stamp) — the chrome set.
- **topic-tile** (6px, rotating fills) / **step-tile** (8px, 2.5px border) — the catalog + framework patterns.
- **kpi-block** (oversized serif figure) / **meta-dl** (3-col, 2px rule) / **bar** / **rule-thin** — data + structure.
## Frame Treatments
> Recipe: ground · container · composes · focal · chrome · accent · silence · Fixed/Free · density.
> Topbar on every frame; one subject per frame in deep negative space.
### 1 · Cover (identity · move: oversized serif · green · left)
**Ground** `{colors.green}`, `slide-pad`. **Composes** topbar (mono label + monogram-circle), display-hero, body lede, footline. **Focal** a 2-line Inter `display-hero` in pink (one word in cream), left. **Chrome** mono topbar; mono footline. **Accent** pink display + the monogram circle. **Silence** ~50%. **Fixed** serif 500, flat, monogram 2px pink. **Free** title, which word is cream. **Density** low.
### 2 · Topic Tiles (catalog · move: rotating-fill grid · cream · the dense frame)
**Ground** `{colors.cream}`, `slide-pad`. **Composes** topbar, headline-xl, 3–4× topic-tile. **Focal** a row of 6px tiles, fills rotating green / pink / cream-2-with-border, each a mono ordinal + serif title. **Chrome** mono topbar. **Accent** the tile fills (mix 3 of 4, never repeat one). **Silence** tight — the density exception. **Fixed** 6px radius, rotating fills, no shadow. **Free** tile titles, which fills. **Density** dense-exception.
### 3 · KPI Stat (data · move: oversized figure · green)
**Ground** `{colors.green}`, `slide-pad`. **Composes** topbar, kpi-block (mono tag + 220px stat-figure + serif description), footline. **Focal** a Inter `stat-figure` (~11.5cqw, +unit) in pink over a 2px pink rule. **Chrome** mono topbar + footline. **Accent** the pink figure. **Silence** ~55%. **Fixed** serif 500 figure, 2px accent rule, flat. **Free** the figure (from script), tag, description. **Density** low.
### 4 · Statement (quote · move: display serif · cream · left)
**Ground** `{colors.cream}`, generous pad. **Composes** topbar, display, name (600), caption-mono role. **Focal** a 2-line Inter `display` (~7.3cqw) in green; a 600-weight attribution name + mono role beneath. **Chrome** mono topbar. **Accent** none — the serif carries it. **Silence** ~55%. **Fixed** serif 500 display + 600 name only, no italic. **Free** quote, name, role. **Density** low.
### 5 · Step Framework (process · move: 8px step tiles · cream/green)
**Ground** `{colors.cream}` (or green), `slide-pad`. **Composes** topbar, headline, 3–4× step-tile. **Focal** a row of 8px step tiles (2.5px border) — mono ordinal + serif 68px title + body + mono marker over a top rule; fills rotate cream-with-green-border / green / pink. **Accent** the tile fills. **Silence** moderate. **Fixed** 8px radius, 2.5px border, mono markers. **Free** steps, fills. **Density** standard.
### 6 · Chart (data · move: bars + meta · green)
**Ground** `{colors.green}`, `slide-pad`. **Composes** topbar, headline, bars + 2px axis rules, meta-dl. **Focal** 56px pink/cream/green bars on inner-edge 2px rules with mono ticks; an optional 3-col meta-dl beneath. **Chrome** mono topbar. **Accent** the bar fills. **Silence** moderate. **Fixed** 56px bars, 3px tops, 2px axes, mono ticks. **Free** values (from script), labels. **Density** standard.
## Composition Rules
### Do
- Run every display in **Inter weight 500**, opsz, negative-tracked, tight line-height; **body at 400**.
- Set every label/caption/axis/footline in **JetBrains Mono 500 uppercase, 0.08–0.18em**.
- Give every frame a **topbar** (mono label + monogram or counter); footline on data/cover.
- Pick **one dominant surface per frame** (max two tones); **rotate tile fills**, never repeat one across a grid.
- Separate sections with **2px hairlines** in the region's accent; reserve the monogram circle for cover/summary.
- Scale display aggressively (7.3–11.5cqw) for statement/stat/cover; lean left/editorial, one subject per frame.
### Don't
- No box-shadow, gradient, or glow — flat, paper-based depth only.
- No third typeface; no italic/underline; no serif body at 500; no mono at headline scale.
- No fourth color family; no `rgba` surfaces; no 1px or 4px+ rules (2px / 2.5px only).
- No two competing content blocks; don't omit the topbar.
- Don't blow a headline edge-to-edge — step the ramp down.
## Aspect-Ratio Behavior
| Treatment | 16:9 | 9:16 | 1:1 |
| -------------- | -------------------------------- | --------------------------- | ------------------------ |
| Cover | display left, monogram top-right | display top, monogram below | display, monogram corner |
| Topic Tiles | 3–4 across | stacked | 2×2 |
| KPI Stat | figure left | figure centered, taller | centered |
| Statement | quote left | quote stacked | centered |
| Step Framework | 3–4 across | stacked | 2×2 |
| Chart | bars + meta | bars taller, meta stacks | square chart |
`slide-pad` holds on the short edge; re-step display above the 1.4cqw floor. Topbar spans the top
on every ratio; the monogram may drop to a corner.
## Approved Real Entities
No real customers, logos, or vendors are defined in the source — render any such mark as a
placeholder. The monogram, footline strings, and tile counters carry per-deck identity; figures are content.
## Numerals & Claims (hard rule)
Never invent figures, KPIs, dates, or counts at frame scale. Render slots as `— figure —`,
`{metric}`, `— %`. KPI blocks, bars, and meta values especially carry placeholders until the script
supplies them. Issue numbers / counters are decorative chrome.
## Pre-Render Self-Audit
- **Squint** — one serif moment dominates; the surface block centers the eye.
- **Silence** — one subject per frame in deep negative space; only the topic-tile/step grids run dense.
- **Triad** — two surface tones max; headlines/body/labels use the correct per-surface color; no fourth family.
- **Type** — Inter **500** display (opsz, negative-tracked) / **400** body; mono uppercase 0.08–0.18em; ≥1.4cqw floor.
- **Depth** — 0 shadow, 0 gradient; 2px hairlines; 6/8px radii; monogram the only full round.
- **Chrome** — topbar present on every frame; footline on data/cover.
- **Fabrication** — every numeral traces to the script, else placeholder.
## Known Gaps
- **Motion intentionally out of scope.** frame.md specifies composition only; the source relies on `deck-stage.js` for scaling/nav, no transition spec.
- **Inter (opsz 8..60) + JetBrains Mono via Google Fonts** — the optical-size axis is critical; a non-opsz fallback flattens the size-aware letterforms. CJK: LXGW WenKai (display) / Noto Serif SC (body) / Noto Sans Mono CJK (chrome).
- **9:16 / 1:1 are guidance**; verify the floor and that two-tone discipline holds per ratio.
- Bars, hairlines, tiles, and the monogram circle are CSS-only; no external imagery is required.
- **Contrast:** green-on-pink / pink-on-green only at display scale (84px+); keep small text cream-on-green or ink-on-cream.
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets"
},
"media": {
"autoProxy": true
}
}
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, height=1080">
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
}
body {
font-family: "Inter", sans-serif;
}
code,
pre,
.monospace {
font-family: "JetBrains Mono", monospace;
}
</style>
</head>
<body>
<div data-hf-id="hf-199x" id="root" data-composition-id="main" data-start="0" data-duration="10" data-width="1920" data-height="1080">
<!--
Add your clips here. Example:
<div id="title" class="clip" data-start="0" data-duration="5" data-track-index="1"
style="font-size: 64px; color: #fff; padding: 40px">
Hello World
</div>
-->
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Example: tl.from("#title", { opacity: 0, y: -50, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"id": "edgeever-promo",
"name": "edgeever-promo",
"createdAt": "2026-07-20T02:55:40.295Z"
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "edgeever-promo",
"private": true,
"type": "module",
"scripts": {
"dev": "npx --yes hyperframes@0.7.64 preview",
"check": "npx --yes hyperframes@0.7.64 check",
"render": "npx --yes hyperframes@0.7.64 render",
"publish": "npx --yes hyperframes@0.7.64 publish"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ workers_dev = true
directory = "apps/web/dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
run_worker_first = ["/api/*", "/mcp"]
run_worker_first = ["/api/*", "/mcp", "/__scheduled"]
[[d1_databases]]
binding = "DB"