Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# StudyFlow

## 视觉设计样板

当前的「自然沉浸与正念成长」视觉样板通过以下地址访问:

```text
http://127.0.0.1:5173/?design-preview=1
```

样板使用本地假数据,不读取或写入 StudyFlow 的 IndexedDB。Today、Plan、Focus、Meditation 和成长阶段仅用于设计确认;正式界面会在样板确认后分阶段接入。

StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形成第一阶段的 `Plan → Execute → Record`:先安排学习任务,再用正计时或番茄钟执行,并把实际投入和结果保存为可审计的本地记录。

## 主要功能
Expand Down
38 changes: 38 additions & 0 deletions docs/design-preview/ASSET_PROMPTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 原创背景资产生成记录

生成方式:Codex 内置 `imagegen`,`photorealistic-natural` 模式。植物与花朵没有使用图片生成,均为项目内可维护的 SVG。

## Focus 雾林

输出:`src/assets/nature/focus-forest.webp`

```text
Use case: photorealistic-natural
Asset type: full-screen desktop web app Focus background
Primary request: an original quiet misty forest scene for a study focus timer, with softly blurred leaves and fine branches framing the scene and calm atmospheric depth
Scene/backdrop: early morning woodland with light fog, natural foliage, no buildings
Style/medium: photorealistic natural photography with subtle film texture, refined but not glossy
Composition/framing: wide landscape composition; generous uncluttered negative space through the center for an overlaid plant illustration, timer, and controls; foliage may frame the outer edges but must not create a strong focal object
Lighting/mood: soft diffused dawn light, meditative, quiet, restrained, low contrast
Color palette: deep ink green #10241E, moss green #294C40, muted sage #6F9280, small touches of warm gray; low saturation
Constraints: background image only; no UI; no people; no animals; no text; no logos; no watermark; suitable for a dark translucent overlay; original composition
Avoid: neon green, bright sun, fantasy glow, dramatic landscape, colorful flowers, high contrast, busy detail, obvious stock-photo polish
```

## Meditation 晨雾水面

输出:`src/assets/nature/meditation-water.webp`

```text
Use case: photorealistic-natural
Asset type: full-screen desktop web app Meditation background
Primary request: an original tranquil waterside dawn scene for a meditation timer, with still water, delicate mist, soft reeds and barely visible distant foliage
Scene/backdrop: quiet natural pond or slow lake at first light, no structures
Style/medium: photorealistic natural photography with subtle film texture, refined and understated
Composition/framing: wide landscape composition; calm uncluttered negative space through the center for an overlaid breathing halo, flower illustration, timer, and controls; subtle natural framing only at far edges
Lighting/mood: diffused pale morning light through mist, serene, contemplative, soft and low contrast
Color palette: muted warm gray, deep ink green #10241E, sage #6F9280, soft stone and a restrained trace of warm gold #D7B77A; low saturation
Materials/textures: gentle water reflection, realistic fine mist, soft organic reeds
Constraints: background image only; no UI; no people; no animals; no flowers as focal subjects; no text; no logos; no watermark; suitable for a dark translucent overlay; original composition
Avoid: vivid sunrise, orange sky, neon teal, dramatic mountains, fantasy glow, colorful flowers, busy detail, high contrast, obvious stock-photo polish
```
117 changes: 117 additions & 0 deletions docs/design-preview/DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# StudyFlow「自然沉浸与正念成长」视觉设计系统

状态:视觉方向已确认,第二阶段已将设计 token 与 Today、Plan、Focus 接入正式页面。该目录中的 `?design-preview=1` 截图仍使用假数据;成长记录、今日花园和 Meditation 尚未接入正式数据。

## 1. 设计原则

StudyFlow 是一个安静、自然、克制、有呼吸感的学习与正念空间。

- 信息优先:任务标题、计时和主要操作始终比装饰更清晰。
- 自然但不拟物:自然照片只建立氛围,成长植物使用柔和分层 SVG,不模仿 Forest 的游戏视觉。
- 成长但不奖惩:植物记录真实投入,不枯萎、不用金币、连续签到或强烈庆祝制造压力。
- 沉浸但可退出:Focus 与 Meditation 可以降低视觉噪音,但暂停、结束和返回始终清晰可见。

## 2. Design tokens

| 用途 | Token | 值 |
| --- | --- | --- |
| 浅色主背景 | `--sf-mist` | `#F3F5F1` |
| 暖色辅助背景 | `--sf-warm` | `#F5F1E9` |
| 主文字 | `--sf-ink` | `#18231E` |
| 品牌鼠尾草绿 | `--sf-sage` | `#6F9280` |
| 柔光绿 | `--sf-sage-soft` | `#9AB9A8` |
| 深色沉浸背景 | `--sf-forest` | `#10241E` |
| 深色辅助层 | `--sf-moss` | `#294C40` |
| 到时与超时提示 | `--sf-gold` | `#D7B77A` |

圆角分为四级:控件 `12px`、任务卡 `18px`、主面板 `24px`、沉浸控制区 `32px`。不要为了“更圆”随意增加新级别。

## 3. 毛玻璃与背景边界

允许使用毛玻璃:

- Today 的主统计与今日花园
- Plan 的筛选工具栏
- 全局活动计时栏
- 弹窗
- Focus 与 Meditation 的沉浸控制区

不使用或只使用近实色:

- 任务卡和四象限内的密集内容
- 表单输入、选择器和长文本
- History 的记录列表
- 需要连续阅读的说明文字

浅色玻璃为约 62% 白色与 `18px` blur;深色玻璃为约 48% 黑绿与 `24px` blur。无 `backdrop-filter` 时分别降级为 `#F7F8F5` 与 `#18332A` 实色。

自然照片只用于 Focus、Meditation 和少量冥想入口 Hero。Today 与 Plan 使用抽象雾化渐变,避免装饰干扰优先级判断。

## 4. 代表页面

### Today

- 第一屏左侧显示计划、完成、剩余、实际专注和进行中的会话。
- 右侧“今日花园”直接显示最近植物,正式接入时最多 8 株。
- 任务列表保持近实色与高对比度。
- 移动端改为单列,主操作收成圆角图标按钮。

### Plan

- 四象限与列表切换、分类/状态/日期筛选保持现有能力。
- 筛选只使用一层玻璃;象限使用低饱和红、蓝、琥珀、绿。
- 象限任务卡保持近实色,不叠加多层透明效果。

### Focus

- 本地雾林背景上覆盖深绿渐变,保证中心文字对比。
- 植物、任务、计时和控制区沿同一视觉轴排列。
- 运行、暂停和超时都有明确文字;暖金只用于到时/超时提醒。
- 植物动画不遮挡计时,`prefers-reduced-motion` 下停止非必要动画。

### Meditation

- 本地晨雾水面背景比 Focus 更柔和、更开放。
- 设置、呼吸、冥想、复盘是四个连续但可跳过的状态。
- 呼吸光圈只有缓慢缩放和文字提示,不依赖声音表达阶段。
- 感受选择不使用“好/坏”评价,不强迫填写备注。

## 5. 成长系统

学习生成树,冥想生成花。五阶段为:

1. `<10%`:种子
2. `10–34%`:嫩芽
3. `35–64%`:幼苗
4. `65–99%`:小树或花苞
5. `≥100%`:成熟树或盛开

植物阶段跟随有效时间与目标快照动态计算。提前结束或未完成不会枯萎;成熟后超时只增加少量叶片或柔光。低饱和变体应由会话 ID 稳定生成,备份与刷新后不能改变。

## 6. Meditation 操作流程

```text
Today / 侧边栏 / 快速开始
→ 选择定时或自由计时
→ 选择意图
→ 选择呼吸引导
→ 呼吸引导(可跳过)
→ 冥想计时(暂停 / 恢复 / 提前结束)
→ 定时结束响铃
→ 超时正计时,直到主动结束
→ 五档感受与可选备注
→ 保存或跳过复盘并保存
→ 生成冥想花,并进入 History 与今日花园
```

呼吸引导阶段不计入核心冥想时长。正式实现必须复用现有通知、音量、多标签同步、刷新恢复与休眠确认能力,并保证全局只有一个学习或冥想活动会话。

## 7. 实施门禁

当前已完成设计确认样板和第 1 项正式视觉改造,后续依次实施:

1. ~~正式视觉 token 与 Today / Plan / Focus 改造~~
2. IndexedDB version 3、备份 version 3、成长记录与今日花园
3. Meditation 数据层、完整流程、History 类型隔离

每阶段单独验收,禁止在视觉改造阶段顺带修改正式数据模型。
Binary file added docs/design-preview/screenshots/focus-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design-preview/screenshots/focus-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design-preview/screenshots/plan-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design-preview/screenshots/plan-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design-preview/screenshots/today-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions e2e/design-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";

test.describe("隔离设计样板", () => {
test("可以浏览代表页面并且不会创建正式数据库", async ({ page }) => {
await page.goto("/?design-preview=1");

await expect(page.getByRole("heading", { name: "今天,按自己的节奏前进" })).toBeVisible();
await expect(page.getByText("今日花园")).toBeVisible();
expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(0);

await page.getByRole("button", { name: "Plan", exact: true }).first().click();
await expect(page.getByRole("heading", { name: "把精力放在真正重要的地方" })).toBeVisible();

await page.getByRole("button", { name: "Focus", exact: true }).first().click();
await page.getByRole("button", { name: "暂停", exact: true }).click();
await expect(page.getByText("已暂停 · 植物也在休息")).toBeVisible();
await page.getByRole("button", { name: "超时", exact: true }).click();
await expect(page.getByText("本轮已完成 · 超时专注")).toBeVisible();

await page.getByRole("button", { name: "Meditation", exact: true }).click();
await expect(page.getByRole("heading", { name: "此刻,你需要怎样的停留?" })).toBeVisible();
await page.getByRole("button", { name: "开始这次停留" }).click();
await expect(page.getByText("吸气")).toBeVisible();
await page.getByRole("button", { name: "冥想", exact: true }).click();
await expect(page.getByText("安静地停留")).toBeVisible();

expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(0);
});

test("手机预览使用 390×844 设计画布", async ({ page }) => {
await page.goto("/?design-preview=1&screen=today");
await page.getByRole("button", { name: "手机宽度" }).click();
const viewport = page.locator(".preview-viewport");
await expect(viewport).toHaveCSS("width", "390px");
await expect(viewport).toHaveCSS("height", "844px");
await expect(page.locator(".mobile-nav")).toBeVisible();
});
});
116 changes: 116 additions & 0 deletions e2e/visual-system.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { expect, test, type Page } from "@playwright/test";

const FIXED_TIME = new Date("2026-08-15T03:00:00.000Z");

async function createTask(page: Page, title: string, category: string, minutes: number, important: boolean, urgent: boolean) {
await page.getByRole("button", { name: "新建任务" }).click();
await page.getByLabel("任务标题").fill(title);
await page.getByLabel("所属分类").selectOption({ label: category });
await page.getByLabel("预计完成时长").fill(String(minutes));
await page.getByLabel("截止日期").fill("2026-08-15");
if (important) await page.getByRole("checkbox", { name: /^重要 / }).check();
if (urgent) await page.getByRole("checkbox", { name: /^紧急 / }).check();
await page.getByRole("button", { name: "保存" }).click();
}

test("Today、Plan、Focus 的正式视觉在桌面和手机宽度保持稳定", async ({ page }) => {
test.setTimeout(60_000);
await page.clock.install({ time: FIXED_TIME });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await page.getByRole("link", { name: "Plan" }).click();

await createTask(page, "高数:复习微分中值定理", "高数", 45, true, true);
await createTask(page, "CS50 Week 4 笔记整理", "CS50", 35, true, false);
await createTask(page, "线性代数习题 3.2", "线性代数", 30, false, true);

await expect(page).toHaveScreenshot("plan-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 });
await page.getByRole("link", { name: "Today" }).click();
await expect(page).toHaveScreenshot("today-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 });

await page.getByRole("link", { name: "Plan" }).click();
await page.getByRole("article", { name: /高数:复习微分中值定理/ }).getByRole("button", { name: "开始学习" }).click();
await page.getByLabel("本次目标(可选)").fill("完成本章定理复习与两道例题");
await page.getByRole("button", { name: "进入 Focus" }).click();
await page.clock.runFor(8 * 60 * 1000);
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 250));
await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "08:00"; });
await expect(page).toHaveScreenshot("focus-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 });

await page.setViewportSize({ width: 390, height: 844 });
await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "08:00"; });
await expect(page).toHaveScreenshot("focus-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 });
await page.getByRole("button", { name: "返回 StudyFlow" }).click();
await page.getByRole("link", { name: "Today" }).click();
await page.locator(".active-session-bar time").evaluate((element) => { element.textContent = "08:00"; });
await expect(page).toHaveScreenshot("today-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 });
await page.getByRole("link", { name: "Plan" }).click();
await page.locator(".active-session-bar time").evaluate((element) => { element.textContent = "08:00"; });
await expect(page).toHaveScreenshot("plan-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 });
});

test("正式视觉支持键盘焦点与减少动画", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await expect(page.getByRole("heading", { name: "今天,按自己的节奏前进" })).toBeVisible();
await page.keyboard.press("Tab");
const todayLink = page.getByRole("link", { name: "Today" });
await expect(todayLink).toBeFocused();
const transitionSeconds = await todayLink.evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration));
expect(transitionSeconds).toBeLessThanOrEqual(0.001);
});

test("Focus 暂停、超时和三按钮状态在手机宽度保持清晰", async ({ page }) => {
await page.clock.install({ time: FIXED_TIME });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await page.getByRole("button", { name: "开始学习" }).click();
await page.getByLabel("学习名称").fill("移动端番茄视觉检查");
await page.getByRole("radio", { name: /番茄钟/ }).check();
await page.getByLabel("本次专注时长").fill("1");
await page.getByLabel("本次短休息").fill("1");
await page.getByRole("button", { name: "进入 Focus" }).click();

await page.getByRole("button", { name: "暂停" }).click();
await expect(page.locator(".focus-botanical")).toHaveClass(/paused/);
await page.getByRole("button", { name: "继续" }).click();
await page.clock.runFor(61_000);
await expect(page.getByText("超时专注 · 正计时")).toBeVisible();
await expect(page.getByRole("progressbar", { name: "当前专注阶段进度" })).toHaveAttribute("aria-valuenow", "100");
await expect(page.locator(".focus-botanical")).toHaveClass(/overtime/);

await page.clock.pauseAt(await page.evaluate(() => Date.now() + 250));
await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "00:01"; });
await expectNoButtonOverlap(page);
await expect(page).toHaveScreenshot("focus-overtime-mobile.png", { animations: "disabled", maxDiffPixels: 150 });

await page.getByRole("button", { name: "开始休息" }).click();
await page.getByRole("button", { name: "暂停" }).click();
await expect(page.locator(".focus-botanical")).toHaveClass(/paused/);
await expectNoButtonOverlap(page);
await expect(page).toHaveScreenshot("focus-break-paused-mobile.png", { animations: "disabled", maxDiffPixels: 150 });

await page.setViewportSize({ width: 320, height: 844 });
await expectNoButtonOverlap(page);
});

async function expectNoButtonOverlap(page: Page) {
const boxes = await page.locator(".focus-actions > .button").evaluateAll((buttons) => buttons.map((button) => {
const box = button.getBoundingClientRect();
return {
left: box.left,
right: box.right,
top: box.top,
bottom: box.bottom,
contentFits: button.scrollWidth <= button.clientWidth,
};
}));
expect(boxes.every((box) => box.contentFits)).toBe(true);
for (let first = 0; first < boxes.length; first += 1) {
for (let second = first + 1; second < boxes.length; second += 1) {
const a = boxes[first]; const b = boxes[second];
const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
expect(overlaps).toBe(false);
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type ReactNode } from "react";
import { BookOpen, CalendarCheck, Database, History, LayoutGrid, Play, Settings, Tags } from "lucide-react";
import { BookOpen, CalendarCheck, Database, History, LayoutGrid, Leaf, Play, Settings, Tags } from "lucide-react";
import type { Category, CreateTaskInput, Task } from "../domain/models";
import type { ExecutionSettings, FinishSessionInput, StartContext, StartSessionInput, StudyInterval, StudySession, TimerMode } from "../features/executionTypes";
import type { PomodoroSettingsSnapshot } from "../../shared/schemas/models";
Expand Down Expand Up @@ -53,7 +53,7 @@ export default function App(){
<aside className="sidebar">
<div className="brand">
<span>
<BookOpen/>
<Leaf/>
</span>
<div>
<strong>StudyFlow</strong>
Expand All @@ -72,7 +72,7 @@ export default function App(){
<button className="data-button" onClick={()=>setBackupOpen(true)}>
<Database/>数据管理</button>
</aside>
<main className="main-content">{error&&<div className="alert error" role="alert">
<main className={`main-content page-${page}`}>{error&&<div className="alert error" role="alert">
<span>{error}</span>
<button onClick={()=>setError("")}>×</button>
</div>}{notice&&<div className="toast" role="status">
Expand Down
Binary file added src/assets/nature/focus-forest.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/nature/meditation-water.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading