feat: refresh demo seed and login device sessions
No files matched your search
@@ -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,
|
||||
}));
|
||||
};
|
||||
@@ -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\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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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。
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "127.0.0.1-video",
|
||||
"name": "EdgeEver"
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 457 KiB |
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "edgeever-promo",
|
||||
"name": "edgeever-promo",
|
||||
"createdAt": "2026-07-20T02:55:40.295Z"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||