From cd22298f8f4ba82dba75e7722c539926349574dd Mon Sep 17 00:00:00 2001 From: lsdragst Date: Tue, 23 Jun 2026 23:43:43 +0800 Subject: [PATCH 01/10] feat: add Warm Fold brand assets --- web/components/brand/BrandMark.tsx | 74 +++++++++++++++++++ .../brand/__tests__/BrandMark.test.tsx | 30 ++++++++ web/components/brand/brandPaths.ts | 8 ++ web/public/brand/brand-logo.svg | 7 ++ web/public/brand/brand-mark.svg | 6 ++ web/public/brand/favicon.svg | 4 + web/scripts/build-brand-assets.mjs | 69 +++++++++++++++++ 7 files changed, 198 insertions(+) create mode 100644 web/components/brand/BrandMark.tsx create mode 100644 web/components/brand/__tests__/BrandMark.test.tsx create mode 100644 web/components/brand/brandPaths.ts create mode 100644 web/public/brand/brand-logo.svg create mode 100644 web/public/brand/brand-mark.svg create mode 100644 web/public/brand/favicon.svg create mode 100644 web/scripts/build-brand-assets.mjs diff --git a/web/components/brand/BrandMark.tsx b/web/components/brand/BrandMark.tsx new file mode 100644 index 0000000..990210d --- /dev/null +++ b/web/components/brand/BrandMark.tsx @@ -0,0 +1,74 @@ +import type { SVGAttributes } from "react"; + +import { + FOLD_PATHS, + INK_PATHS, + LOGO_VIEWBOX, + LOGO_WIDTH, + POINTER_BAND, + POINTER_TRIANGLE, + WORDMARK_PATH, +} from "./brandPaths"; + +type BrandMarkVariant = "mark" | "full"; +type BrandMarkSize = "sm" | "md" | "lg" | number; + +export interface BrandMarkProps extends Omit, "aria-label"> { + variant?: BrandMarkVariant; + size?: BrandMarkSize; + label?: string; +} + +const MARK_SIZE = { + sm: 16, + md: 24, + lg: 32, +} as const; + +const LOGO_SIZE = { + sm: 120, + md: 148, + lg: 176, +} as const; + +function resolveSize(size: BrandMarkSize, variant: BrandMarkVariant) { + if (typeof size === "number") return size; + return variant === "full" ? LOGO_SIZE[size] : MARK_SIZE[size]; +} + +export function BrandMark({ + className, + label, + size = "md", + variant = "mark", + ...props +}: BrandMarkProps) { + const resolvedSize = resolveSize(size, variant); + const isFull = variant === "full"; + const aspectRatio = isFull ? LOGO_WIDTH / 64 : 1; + const width = resolvedSize; + const height = Math.round(resolvedSize / aspectRatio); + const accessibilityProps = label + ? ({ role: "img", "aria-label": label } as const) + : ({ "aria-hidden": true } as const); + + return ( + + + + + + {isFull ? : null} + + ); +} diff --git a/web/components/brand/__tests__/BrandMark.test.tsx b/web/components/brand/__tests__/BrandMark.test.tsx new file mode 100644 index 0000000..0495d8d --- /dev/null +++ b/web/components/brand/__tests__/BrandMark.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { BrandMark } from "../BrandMark"; + +describe("BrandMark", () => { + it("is decorative without a label", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveAttribute("aria-hidden", "true"); + expect(svg).toHaveClass("brand"); + }); + + it("exposes an accessible name when labeled", () => { + render(); + + expect(screen.getByRole("img", { name: "NoteGen home" })).toBeInTheDocument(); + }); + + it("uses currentColor for ink and the brand token for the pointer", () => { + const { container } = render(); + + expect(container.querySelector('[data-part="ink"]')).toHaveAttribute("fill", "currentColor"); + expect(container.querySelector('[data-part="pointer"]')).toHaveAttribute( + "fill", + "var(--wf-brand-coral, #B65C3A)", + ); + }); +}); diff --git a/web/components/brand/brandPaths.ts b/web/components/brand/brandPaths.ts new file mode 100644 index 0000000..d6c9f9f --- /dev/null +++ b/web/components/brand/brandPaths.ts @@ -0,0 +1,8 @@ +// Generated by scripts/build-brand-assets.mjs. +export const INK_PATHS = ["M8 6L25 14V40L8 58Z","M39 26L56 18V48L39 58Z"] as const; +export const FOLD_PATHS = ["M8 6L25 14L20 20Z","M39 26L56 18L47 31Z"] as const; +export const POINTER_BAND = "M23 19L42 36V46L23 29Z"; +export const POINTER_TRIANGLE = "M43 44L51 49L43 54Z"; +export const WORDMARK_PATH = "M82.602 47L75.610 47L75.610 46.540L77.128 46.080Q77.956 45.850 78.324 45.183Q78.692 44.516 78.692 43.596L78.692 21.470Q78.186 20.366 77.887 19.814Q77.588 19.262 77.220 18.871Q76.852 18.480 76.208 17.882L75.426 17.100L75.426 16.640L83.568 16.640L98.840 37.478L98.840 20.136Q98.840 19.216 98.541 18.480Q98.242 17.744 97.322 17.514L95.804 17.100L95.804 16.640L102.382 16.640L102.382 17.100L101.140 17.514Q100.312 17.790 100.128 18.480Q99.944 19.170 99.944 20.090L99.944 47.138L97.368 47.138L79.796 22.988L79.796 43.596Q79.796 44.516 80.072 45.160Q80.348 45.804 81.222 46.080L82.602 46.540L82.602 47M116.596 47.690Q113.100 47.690 110.524 46.241Q107.948 44.792 106.545 42.124Q105.142 39.456 105.142 35.868Q105.142 32.280 106.637 29.658Q108.132 27.036 110.731 25.610Q113.330 24.184 116.596 24.184Q119.862 24.184 122.438 25.587Q125.014 26.990 126.509 29.612Q128.004 32.234 128.004 35.868Q128.004 39.502 126.601 42.147Q125.198 44.792 122.645 46.241Q120.092 47.690 116.596 47.690M116.596 46.770Q118.206 46.770 119.172 45.850Q120.138 44.930 120.575 42.584Q121.012 40.238 121.012 35.960Q121.012 31.636 120.575 29.290Q120.138 26.944 119.172 26.024Q118.206 25.104 116.596 25.104Q114.986 25.104 113.997 26.024Q113.008 26.944 112.571 29.290Q112.134 31.636 112.134 35.960Q112.134 40.238 112.571 42.584Q113.008 44.930 113.997 45.850Q114.986 46.770 116.596 46.770M138.768 47.690Q135.824 47.690 134.191 46.218Q132.558 44.746 132.558 41.526L132.558 25.794L129.384 25.794L129.384 25.334L130.488 25.150Q131.914 24.828 132.949 24.230Q133.984 23.632 134.996 22.620L138.768 18.434L139.228 18.434L139.090 24.874L144.242 24.874L144.242 25.794L139.044 25.794L139.044 42.354Q139.044 43.964 139.757 44.792Q140.470 45.620 141.574 45.620Q142.540 45.620 143.276 45.137Q144.012 44.654 144.748 43.826L145.208 44.286Q144.288 45.850 142.678 46.770Q141.068 47.690 138.768 47.690M158.180 24.184Q161.170 24.184 163.286 25.403Q165.402 26.622 166.529 28.715Q167.656 30.808 167.656 33.476Q167.656 33.982 167.610 34.511Q167.564 35.040 167.426 35.454L153.718 35.454Q153.764 40.422 155.489 42.607Q157.214 44.792 160.618 44.792Q162.918 44.792 164.344 44.056Q165.770 43.320 167.012 41.894L167.472 42.308Q166.046 44.838 163.677 46.264Q161.308 47.690 157.996 47.690Q154.730 47.690 152.200 46.287Q149.670 44.884 148.244 42.262Q146.818 39.640 146.818 36.006Q146.818 32.234 148.474 29.589Q150.130 26.944 152.729 25.564Q155.328 24.184 158.180 24.184M158.042 25.104Q156.708 25.104 155.765 25.909Q154.822 26.714 154.293 28.761Q153.764 30.808 153.718 34.534L161.584 34.534Q162.044 29.520 161.216 27.312Q160.388 25.104 158.042 25.104M185.780 47.828Q181.088 47.828 177.523 45.873Q173.958 43.918 171.957 40.307Q169.956 36.696 169.956 31.774Q169.956 26.990 171.934 23.402Q173.912 19.814 177.661 17.813Q181.410 15.812 186.746 15.812Q189.184 15.812 191.277 16.364Q193.370 16.916 195.256 17.974L195.440 24.414L194.980 24.414L192.404 19.630Q191.806 18.480 191.300 17.974Q190.794 17.468 190.012 17.192Q189.276 16.962 188.655 16.847Q188.034 16.732 187.022 16.732Q184.170 16.732 181.985 18.250Q179.800 19.768 178.604 23.080Q177.408 26.392 177.408 31.774Q177.408 37.110 178.535 40.468Q179.662 43.826 181.732 45.367Q183.802 46.908 186.516 46.908Q186.930 46.908 187.321 46.839Q187.712 46.770 188.080 46.678Q188.954 46.540 189.230 45.942Q189.506 45.344 189.506 44.516L189.506 35.408Q189.506 33.614 187.666 33.200L185.550 32.648L185.550 32.188L198.522 32.188L198.522 32.648L197.878 32.832Q196.130 33.384 196.130 35.132L196.130 45.114Q193.738 46.448 191.277 47.138Q188.816 47.828 185.780 47.828M211.678 24.184Q214.668 24.184 216.784 25.403Q218.900 26.622 220.027 28.715Q221.154 30.808 221.154 33.476Q221.154 33.982 221.108 34.511Q221.062 35.040 220.924 35.454L207.216 35.454Q207.262 40.422 208.987 42.607Q210.712 44.792 214.116 44.792Q216.416 44.792 217.842 44.056Q219.268 43.320 220.510 41.894L220.970 42.308Q219.544 44.838 217.175 46.264Q214.806 47.690 211.494 47.690Q208.228 47.690 205.698 46.287Q203.168 44.884 201.742 42.262Q200.316 39.640 200.316 36.006Q200.316 32.234 201.972 29.589Q203.628 26.944 206.227 25.564Q208.826 24.184 211.678 24.184M211.540 25.104Q210.206 25.104 209.263 25.909Q208.320 26.714 207.791 28.761Q207.262 30.808 207.216 34.534L215.082 34.534Q215.542 29.520 214.714 27.312Q213.886 25.104 211.540 25.104M234.264 47L223.454 47L223.454 46.540L224.098 46.356Q225.708 45.896 225.708 44.102L225.708 29.704Q225.708 28.692 225.386 28.209Q225.064 27.726 224.144 27.542L223.454 27.358L223.454 26.898L231.320 24.230L231.780 24.690L232.148 27.450Q233.758 26.024 235.782 25.104Q237.806 24.184 239.784 24.184Q242.820 24.184 244.453 25.840Q246.086 27.496 246.086 30.900L246.086 44.148Q246.086 45.942 247.834 46.402L248.248 46.540L248.248 47L237.392 47L237.392 46.540L237.990 46.356Q239.600 45.850 239.600 44.102L239.600 29.428Q239.600 26.484 236.656 26.484Q234.632 26.484 232.240 28.508L232.240 44.148Q232.240 45.942 233.850 46.402L234.264 46.540"; +export const LOGO_WIDTH = 257; +export const LOGO_VIEWBOX = "0 0 257 64"; diff --git a/web/public/brand/brand-logo.svg b/web/public/brand/brand-logo.svg new file mode 100644 index 0000000..9ef8856 --- /dev/null +++ b/web/public/brand/brand-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/brand/brand-mark.svg b/web/public/brand/brand-mark.svg new file mode 100644 index 0000000..06cd29d --- /dev/null +++ b/web/public/brand/brand-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/public/brand/favicon.svg b/web/public/brand/favicon.svg new file mode 100644 index 0000000..e90f575 --- /dev/null +++ b/web/public/brand/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/scripts/build-brand-assets.mjs b/web/scripts/build-brand-assets.mjs new file mode 100644 index 0000000..9910b8e --- /dev/null +++ b/web/scripts/build-brand-assets.mjs @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import opentype from "opentype.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outDir = path.join(root, "public", "brand"); +const componentDir = path.join(root, "components", "brand"); +const fontFile = path.join( + root, + "node_modules", + "@fontsource", + "dm-serif-display", + "files", + "dm-serif-display-latin-400-normal.woff", +); + +fs.mkdirSync(outDir, { recursive: true }); +fs.mkdirSync(componentDir, { recursive: true }); + +const inkPaths = ["M8 6L25 14V40L8 58Z", "M39 26L56 18V48L39 58Z"]; +const foldPaths = ["M8 6L25 14L20 20Z", "M39 26L56 18L47 31Z"]; +const pointerBand = "M23 19L42 36V46L23 29Z"; +const pointerTriangle = "M43 44L51 49L43 54Z"; + +const font = opentype.parse(fs.readFileSync(fontFile).buffer); +const wordmark = font.getPath("NoteGen", 74, 47, 46); +const wordmarkPath = wordmark.toPathData(3); +const wordmarkBounds = wordmark.getBoundingBox(); +const logoWidth = Math.ceil(wordmarkBounds.x2 + 8); + +const markPaths = ` + + + + `; + +const svg = (viewBox, body, attrs = "") => + `${body}\n\n`; + +fs.writeFileSync(path.join(outDir, "brand-mark.svg"), svg("0 0 64 64", markPaths, 'data-min-size="16"')); +fs.writeFileSync( + path.join(outDir, "brand-logo.svg"), + svg( + `0 0 ${logoWidth} 64`, + `${markPaths}\n `, + 'data-min-width="120"', + ), +); +fs.writeFileSync( + path.join(outDir, "favicon.svg"), + svg( + "0 0 64 64", + `\n \n `, + 'data-min-size="16"', + ), +); + +const ts = + "// Generated by scripts/build-brand-assets.mjs.\n" + + `export const INK_PATHS = ${JSON.stringify(inkPaths)} as const;\n` + + `export const FOLD_PATHS = ${JSON.stringify(foldPaths)} as const;\n` + + `export const POINTER_BAND = ${JSON.stringify(pointerBand)};\n` + + `export const POINTER_TRIANGLE = ${JSON.stringify(pointerTriangle)};\n` + + `export const WORDMARK_PATH = ${JSON.stringify(wordmarkPath)};\n` + + `export const LOGO_WIDTH = ${logoWidth};\n` + + `export const LOGO_VIEWBOX = ${JSON.stringify(`0 0 ${logoWidth} 64`)};\n`; + +fs.writeFileSync(path.join(componentDir, "brandPaths.ts"), ts); From 4bfef2b7546f37b5eb99d479e5ea6d2a7ce06ef3 Mon Sep 17 00:00:00 2001 From: lsdragst Date: Tue, 23 Jun 2026 23:45:33 +0800 Subject: [PATCH 02/10] feat: add Warm Fold button primitives --- web/app/globals.css | 149 ++++++++++++++++++ web/components/ui/Button.tsx | 42 +++++ web/components/ui/IconButton.tsx | 45 ++++++ web/components/ui/__tests__/Button.test.tsx | 27 ++++ .../ui/__tests__/IconButton.test.tsx | 25 +++ 5 files changed, 288 insertions(+) create mode 100644 web/components/ui/Button.tsx create mode 100644 web/components/ui/IconButton.tsx create mode 100644 web/components/ui/__tests__/Button.test.tsx create mode 100644 web/components/ui/__tests__/IconButton.test.tsx diff --git a/web/app/globals.css b/web/app/globals.css index 6175309..83dcc1a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -259,3 +259,152 @@ html, body { } } /* === Warm Fold foundation tokens: END === */ + +/* === Warm Fold button primitives: START === */ +.wf-button, +.wf-icon-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + font-family: var(--wf-font-sans); + font-weight: 600; + cursor: pointer; + transition: + background var(--wf-motion-fast) var(--wf-ease), + border-color var(--wf-motion-fast) var(--wf-ease), + color var(--wf-motion-fast) var(--wf-ease), + transform var(--wf-motion-fast) var(--wf-ease); +} + +.wf-button { + gap: 0.5rem; + border-radius: var(--wf-radius-sm); +} + +.wf-button[data-size="sm"] { + min-height: 36px; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; +} + +.wf-button[data-size="md"] { + min-height: 44px; + padding: 0.625rem 1rem; + font-size: 0.9375rem; +} + +.wf-button[data-size="lg"] { + min-height: 48px; + padding: 0.75rem 1.25rem; + font-size: 1rem; +} + +.wf-button[data-variant="primary"] { + background: var(--wf-accent); + color: var(--wf-on-accent); +} + +.wf-button[data-variant="primary"]:hover:not(:disabled) { + background: var(--wf-accent-hover); +} + +.wf-button[data-variant="primary"]:active:not(:disabled) { + background: var(--wf-accent-active); +} + +.wf-button[data-variant="secondary"], +.wf-icon-button[data-variant="secondary"] { + background: var(--wf-surface); + border-color: var(--wf-border); + color: var(--wf-text); +} + +.wf-button[data-variant="ghost"], +.wf-icon-button[data-variant="ghost"] { + background: transparent; + color: var(--wf-text-secondary); +} + +.wf-button[data-variant="secondary"]:hover:not(:disabled), +.wf-icon-button[data-variant="secondary"]:hover:not(:disabled) { + background: var(--wf-surface-muted); + border-color: var(--wf-border-strong); +} + +.wf-button[data-variant="ghost"]:hover:not(:disabled), +.wf-icon-button[data-variant="ghost"]:hover:not(:disabled) { + background: var(--wf-surface-muted); + color: var(--wf-text); +} + +.wf-button[data-variant="danger"], +.wf-icon-button[data-variant="danger"] { + background: var(--wf-danger); + color: var(--wf-on-danger); +} + +.wf-button[data-variant="danger"]:hover:not(:disabled), +.wf-icon-button[data-variant="danger"]:hover:not(:disabled) { + background: var(--wf-danger-hover); +} + +.wf-button:focus-visible, +.wf-icon-button:focus-visible { + outline: 2px solid var(--wf-focus); + outline-offset: 3px; +} + +.wf-button:disabled, +.wf-icon-button:disabled { + background: var(--wf-disabled-bg); + border-color: var(--wf-disabled-border); + color: var(--wf-disabled-fg); + cursor: not-allowed; + box-shadow: none; +} + +.wf-button[data-loading="true"], +.wf-icon-button[data-loading="true"] { + cursor: progress; +} + +.wf-button[data-loading="true"] .wf-button__content, +.wf-icon-button[data-loading="true"] .wf-icon-button__content { + opacity: 0; +} + +.wf-button__spinner, +.wf-icon-button__spinner { + position: absolute; + width: 1em; + height: 1em; + animation: wf-spin 800ms linear infinite; +} + +.wf-icon-button { + width: 44px; + height: 44px; + padding: 0; + border-radius: var(--wf-radius-sm); +} + +.wf-icon-button[data-size="lg"] { + width: 48px; + height: 48px; +} + +@keyframes wf-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .wf-button__spinner, + .wf-icon-button__spinner { + animation: none; + } +} +/* === Warm Fold button primitives: END === */ diff --git a/web/components/ui/Button.tsx b/web/components/ui/Button.tsx new file mode 100644 index 0000000..f32300e --- /dev/null +++ b/web/components/ui/Button.tsx @@ -0,0 +1,42 @@ +import { forwardRef, type ButtonHTMLAttributes } from "react"; +import { LoaderCircle } from "lucide-react"; +import clsx from "clsx"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "ghost" | "danger"; + size?: "sm" | "md" | "lg"; + loading?: boolean; +} + +export const Button = forwardRef(function Button( + { + className, + children, + disabled, + loading = false, + size = "md", + type = "button", + variant = "primary", + ...props + }, + ref, +) { + return ( + + ); +}); + +export default Button; diff --git a/web/components/ui/IconButton.tsx b/web/components/ui/IconButton.tsx new file mode 100644 index 0000000..e60ecf7 --- /dev/null +++ b/web/components/ui/IconButton.tsx @@ -0,0 +1,45 @@ +import { forwardRef, type ButtonHTMLAttributes } from "react"; +import { LoaderCircle } from "lucide-react"; +import clsx from "clsx"; + +type NativeIconButtonProps = Omit, "aria-label">; + +export interface IconButtonProps extends NativeIconButtonProps { + "aria-label": string; + variant?: "ghost" | "secondary" | "danger"; + size?: "sm" | "md" | "lg"; + loading?: boolean; +} + +export const IconButton = forwardRef(function IconButton( + { + className, + children, + disabled, + loading = false, + size = "md", + type = "button", + variant = "ghost", + ...props + }, + ref, +) { + return ( + + ); +}); + +export default IconButton; diff --git a/web/components/ui/__tests__/Button.test.tsx b/web/components/ui/__tests__/Button.test.tsx new file mode 100644 index 0000000..65f7746 --- /dev/null +++ b/web/components/ui/__tests__/Button.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Button } from "../Button"; + +describe("Button", () => { + it("defaults to a non-submit button", () => { + render(); + + expect(screen.getByRole("button", { name: "Start" })).toHaveAttribute("type", "button"); + }); + + it("allows submit type when explicitly requested", () => { + render(); + + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("type", "submit"); + }); + + it("keeps text semantics while loading", () => { + render(); + const button = screen.getByRole("button", { name: "Saving" }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button.querySelector(".wf-button__spinner")).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/web/components/ui/__tests__/IconButton.test.tsx b/web/components/ui/__tests__/IconButton.test.tsx new file mode 100644 index 0000000..9b61877 --- /dev/null +++ b/web/components/ui/__tests__/IconButton.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { IconButton } from "../IconButton"; + +describe("IconButton", () => { + it("requires and exposes an accessible label", () => { + render(x); + + expect(screen.getByRole("button", { name: "Close" })).toHaveAttribute("type", "button"); + }); + + it("keeps the label while loading", () => { + render( + + R + , + ); + const button = screen.getByRole("button", { name: "Refresh" }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button.querySelector(".wf-icon-button__spinner")).toHaveAttribute("aria-hidden", "true"); + }); +}); From 0fe7fcf8f1c38e65dda1f943e8de66747ebcdf62 Mon Sep 17 00:00:00 2001 From: lsdragst Date: Tue, 23 Jun 2026 23:46:55 +0800 Subject: [PATCH 03/10] feat: add Warm Fold surface primitives --- web/app/globals.css | 128 ++++++++++++++++++ web/components/ui/Card.tsx | 24 ++++ web/components/ui/Chip.tsx | 16 +++ web/components/ui/Input.tsx | 24 ++++ .../ui/__tests__/surface-primitives.test.tsx | 42 ++++++ web/test/ui-types.tsx | 5 + 6 files changed, 239 insertions(+) create mode 100644 web/components/ui/Card.tsx create mode 100644 web/components/ui/Chip.tsx create mode 100644 web/components/ui/Input.tsx create mode 100644 web/components/ui/__tests__/surface-primitives.test.tsx create mode 100644 web/test/ui-types.tsx diff --git a/web/app/globals.css b/web/app/globals.css index 83dcc1a..973553e 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -408,3 +408,131 @@ html, body { } } /* === Warm Fold button primitives: END === */ + +/* === Warm Fold surface primitives: START === */ +.wf-card { + color: var(--wf-text); + border-radius: var(--wf-radius-md); + font-family: var(--wf-font-sans); +} + +.wf-card[data-variant="surface"] { + background: var(--wf-surface); + border: 1px solid var(--wf-border); + box-shadow: var(--wf-shadow-sm); +} + +.wf-card[data-variant="muted"] { + background: var(--wf-surface-muted); + border: 1px solid transparent; +} + +.wf-card[data-variant="outlined"] { + background: transparent; + border: 1px solid var(--wf-border-strong); +} + +.wf-card[data-padding="none"] { + padding: 0; +} + +.wf-card[data-padding="sm"] { + padding: 0.75rem; +} + +.wf-card[data-padding="md"] { + padding: 1rem; +} + +.wf-card[data-padding="lg"] { + padding: 1.5rem; +} + +.wf-input { + width: 100%; + border: 1px solid var(--wf-border); + border-radius: var(--wf-radius-sm); + background: var(--wf-surface); + color: var(--wf-text); + font-family: var(--wf-font-sans); + transition: + border-color var(--wf-motion-fast) var(--wf-ease), + background var(--wf-motion-fast) var(--wf-ease); +} + +.wf-input::placeholder { + color: var(--wf-text-tertiary); +} + +.wf-input[data-size="sm"] { + min-height: 36px; + padding: 0.5rem 0.625rem; + font-size: 0.8125rem; +} + +.wf-input[data-size="md"] { + min-height: 44px; + padding: 0.625rem 0.75rem; + font-size: 0.9375rem; +} + +.wf-input[data-size="lg"] { + min-height: 48px; + padding: 0.75rem 0.875rem; + font-size: 1rem; +} + +.wf-input:focus-visible { + outline: 2px solid var(--wf-focus); + outline-offset: 3px; + border-color: var(--wf-accent); +} + +.wf-input[aria-invalid="true"] { + border-color: var(--wf-danger); + background: var(--wf-danger-surface); +} + +.wf-input:disabled { + background: var(--wf-disabled-bg); + border-color: var(--wf-disabled-border); + color: var(--wf-disabled-fg); + cursor: not-allowed; +} + +.wf-chip { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: var(--wf-radius-full); + font-family: var(--wf-font-sans); + font-weight: 600; +} + +.wf-chip[data-size="sm"] { + min-height: 24px; + padding: 0.1875rem 0.5rem; + font-size: 0.75rem; +} + +.wf-chip[data-size="md"] { + min-height: 28px; + padding: 0.25rem 0.625rem; + font-size: 0.8125rem; +} + +.wf-chip[data-variant="neutral"] { + background: var(--wf-surface-muted); + color: var(--wf-text-secondary); +} + +.wf-chip[data-variant="accent"] { + background: color-mix(in srgb, var(--wf-brand-coral) 14%, var(--wf-surface)); + color: var(--wf-accent); +} + +.wf-chip[data-variant="danger"] { + background: var(--wf-danger-surface); + color: var(--wf-danger); +} +/* === Warm Fold surface primitives: END === */ diff --git a/web/components/ui/Card.tsx b/web/components/ui/Card.tsx new file mode 100644 index 0000000..8d9600e --- /dev/null +++ b/web/components/ui/Card.tsx @@ -0,0 +1,24 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import clsx from "clsx"; + +export interface CardProps extends HTMLAttributes { + variant?: "surface" | "muted" | "outlined"; + padding?: "none" | "sm" | "md" | "lg"; +} + +export const Card = forwardRef(function Card( + { className, variant = "surface", padding = "md", ...props }, + ref, +) { + return ( +
+ ); +}); + +export default Card; diff --git a/web/components/ui/Chip.tsx b/web/components/ui/Chip.tsx new file mode 100644 index 0000000..9b4a283 --- /dev/null +++ b/web/components/ui/Chip.tsx @@ -0,0 +1,16 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import clsx from "clsx"; + +export interface ChipProps extends HTMLAttributes { + variant?: "neutral" | "accent" | "danger"; + size?: "sm" | "md"; +} + +export const Chip = forwardRef(function Chip( + { className, variant = "neutral", size = "md", ...props }, + ref, +) { + return ; +}); + +export default Chip; diff --git a/web/components/ui/Input.tsx b/web/components/ui/Input.tsx new file mode 100644 index 0000000..2711aeb --- /dev/null +++ b/web/components/ui/Input.tsx @@ -0,0 +1,24 @@ +import { forwardRef, type InputHTMLAttributes } from "react"; +import clsx from "clsx"; + +export interface InputProps extends Omit, "size"> { + size?: "sm" | "md" | "lg"; + invalid?: boolean; +} + +export const Input = forwardRef(function Input( + { "aria-invalid": ariaInvalid, className, invalid = false, size = "md", ...props }, + ref, +) { + return ( + + ); +}); + +export default Input; diff --git a/web/components/ui/__tests__/surface-primitives.test.tsx b/web/components/ui/__tests__/surface-primitives.test.tsx new file mode 100644 index 0000000..379e240 --- /dev/null +++ b/web/components/ui/__tests__/surface-primitives.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Card } from "../Card"; +import { Chip } from "../Chip"; +import { Input } from "../Input"; + +describe("surface primitives", () => { + it("keeps Card semantically neutral", () => { + const { container } = render(Notes); + + expect(container.firstChild).toHaveAttribute("data-variant", "outlined"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("keeps Chip presentational", () => { + render(Course); + + expect(screen.getByText("Course").tagName).toBe("SPAN"); + }); + + it("forwards native Input relationships and exposes invalid state", () => { + render( + , + ); + const input = screen.getByRole("textbox"); + + expect(input).toHaveAttribute("name", "email"); + expect(input).toHaveAttribute("aria-describedby", "email-help"); + expect(input).toHaveAttribute("aria-errormessage", "email-error"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).not.toHaveAttribute("size"); + expect(input).toHaveAttribute("data-size", "lg"); + }); +}); diff --git a/web/test/ui-types.tsx b/web/test/ui-types.tsx new file mode 100644 index 0000000..d111d2c --- /dev/null +++ b/web/test/ui-types.tsx @@ -0,0 +1,5 @@ +import { IconButton } from "@/components/ui/IconButton"; + +x; +// @ts-expect-error IconButton requires an accessible label. +x; From 12f3e003092560173c37c5942eff427649f94ee8 Mon Sep 17 00:00:00 2001 From: lsdragst Date: Tue, 23 Jun 2026 23:48:08 +0800 Subject: [PATCH 04/10] test: validate Warm Fold foundation --- web/components/ui/index.ts | 5 +++ web/scripts/validate-warm-fold.mjs | 56 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 web/components/ui/index.ts create mode 100644 web/scripts/validate-warm-fold.mjs diff --git a/web/components/ui/index.ts b/web/components/ui/index.ts new file mode 100644 index 0000000..f852619 --- /dev/null +++ b/web/components/ui/index.ts @@ -0,0 +1,5 @@ +export { Button, type ButtonProps } from "./Button"; +export { Card, type CardProps } from "./Card"; +export { Chip, type ChipProps } from "./Chip"; +export { IconButton, type IconButtonProps } from "./IconButton"; +export { Input, type InputProps } from "./Input"; diff --git a/web/scripts/validate-warm-fold.mjs b/web/scripts/validate-warm-fold.mjs new file mode 100644 index 0000000..4b16401 --- /dev/null +++ b/web/scripts/validate-warm-fold.mjs @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { JSDOM } from "jsdom"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const css = fs.readFileSync(path.join(root, "app", "globals.css"), "utf8"); +const assets = [ + ["brand-mark.svg", 4096, 8], + ["brand-logo.svg", 18432, 80], + ["favicon.svg", 4096, 8], +]; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function selectorBlock(selector) { + const start = css.indexOf(selector); + assert(start >= 0, `Missing selector ${selector}`); + const open = css.indexOf("{", start); + let depth = 0; + + for (let index = open; index < css.length; index += 1) { + if (css[index] === "{") depth += 1; + if (css[index] === "}" && --depth === 0) return css.slice(start, index + 1); + } + + throw new Error(`Unclosed selector ${selector}`); +} + +for (const [name, maxBytes, maxPaths] of assets) { + const file = path.join(root, "public", "brand", name); + const source = fs.readFileSync(file, "utf8"); + const document = new JSDOM(source, { contentType: "image/svg+xml" }).window.document; + + assert(document.documentElement.tagName.toLowerCase() === "svg", `${name} is not SVG`); + assert(document.documentElement.hasAttribute("viewBox"), `${name} lacks viewBox`); + assert(!source.includes("
+ + + ); +} - {/* 特性区标题 */} -

- AI 赋能的学习伙伴 -

+function ProductPreview() { + const reduceMotion = useReducedMotion(); - {/* 特性 1:上传来源(左文右图) */} -
-
+ return ( +
+
+ +
- -

上传来源

-

- 粘贴 B 站 / YouTube 链接,或直接拖入本地视频文件。NoteGen 自动完成 - 语音识别、术语修正和内容理解,支持选择下载画质,长视频也能稳定处理。 +

+ Product preview

+

线性代数公开课

- + +
-
- {/* 特性 2:章节与知识点(右文左图) */} -
-
-
- +
+
+ 03:11 + 16:42
-
- -

章节与知识点

-

- 大模型按主题边界把视频拆成章节,配秒级时间戳。知识点卡片带关键帧截图、 - 重难点标记和一句话摘要,点哪条就跳回视频哪一秒。 -

+
+
+ +
+
+ {[ + ["00:00", "课程目标与学习路线"], + ["03:11", "矩阵变换的直觉"], + ["08:42", "特征值如何定位重点"], + ].map(([time, title], index) => ( + +

{time}

+

{title}

+
+ ))} +
+ +
+
+ + 笔记摘要 + +

+ 矩阵可以被理解为对空间的变换。复习时先看几何直觉,再回到公式推导。 +

+
+
+

问答

+

+ “为什么特征向量方向不变?” 已定位到 08:42 附近的讲解片段。 +

+
+
+
+ +
+ ); +} + +function ProcessCard({ + index, + step, +}: { + index: number; + step: (typeof PROCESS_STEPS)[number]; +}) { + const Icon = step.icon; + + return ( + +
+ + + + 0{index + 1} + +
+

{step.title}

+

{step.desc}

+
+
+
+

{step.meta}

+ + ); +} + +function UseCaseCard({ item }: { item: (typeof USE_CASES)[number] }) { + const Icon = item.icon; + + return ( +
+
+ ); +} + +export default function LandingPage() { + const { user } = useAuth(); + const primaryHref = user ? "/notebooks" : "/login?next=/notebooks"; + const primaryLabel = user ? "进入笔记本库" : "开始使用"; + const heroCtaLabel = user ? "进入笔记本库" : "开始记录"; + const finalCtaLabel = user ? "进入笔记本库" : "开始创建笔记"; + + return ( +
+ + +
+
+
+ + + +

+ 把视频折叠成可回看的学习笔记 +

+

+ 上传或导入视频后,NoteGen 会自动整理章节、提炼重点,并支持围绕内容继续提问。适合课程、讲座、教程和长视频学习。 +

+
+ + {heroCtaLabel} + + 查看示例 + +
+
+ + +
- {/* 特性 3:双语 + 小测(左文右图) */} -
-
-
- -

双语笔记与自测

-

- 全部笔记内容中英双语一键切换;每章末自动生成小测题和跨段术语表, - 学完即测,复习有抓手。 +

+
+
+

How It Works

+

+ 从一段视频,到一套可以复习的笔记 +

+

+ 首页只展示静态模拟流程,重点让用户理解视频、章节、笔记和问答之间的关系。

- +
+ {PROCESS_STEPS.map((step, index) => ( + + ))} +
- {/* 大家这样用 */} -
-

- 大家这样用 NoteGen -

-
- {HOW_PEOPLE_USE.map(({ icon: Icon, title, desc, tagline }) => ( -
- -

{title}

-

{desc}

-

{tagline}

+
+
+
+
+

Use Cases

+

+ 适合所有需要“看完还要记住”的内容 +

+

+ 不把首页变成工作台,只用清晰场景帮助用户判断 NoteGen 是否适合自己的学习材料。 +

+
+
+ {USE_CASES.map((item) => ( + + ))} +
+
+ + +
+ +

+ 准备把你的第一个视频变成笔记吗? +

+

+ 无需复杂设置,从一个视频开始。 +

+
+ + {finalCtaLabel} +
- ))} +
- {/* 底部 CTA + footer */} -
-

开始你的第一篇视频笔记

- - {user ? "进入我的笔记本" : "试用 NoteGen"} - -
-
- NoteGen +
+ NoteGen · - 教学视频结构化笔记 + 视频学习笔记生成工具
); From 4c37d39fa87348e2261eb5bacffb7d13e929528a Mon Sep 17 00:00:00 2001 From: lsdragst Date: Wed, 24 Jun 2026 00:47:40 +0800 Subject: [PATCH 07/10] feat: redesign Warm Fold notebook library --- web/app/notebooks/page.tsx | 588 ++++++++++++++++++++++++++----------- 1 file changed, 414 insertions(+), 174 deletions(-) diff --git a/web/app/notebooks/page.tsx b/web/app/notebooks/page.tsx index a33468f..4389dd8 100644 --- a/web/app/notebooks/page.tsx +++ b/web/app/notebooks/page.tsx @@ -2,25 +2,35 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { - BookOpen, Clapperboard, Clock, Code2, FlaskConical, - GraduationCap, Layers, Loader2, MessagesSquare, Plus, Smartphone, - Trash2, Wrench, X, + BookOpen, + Clock, + Code2, + FileVideo, + FlaskConical, + GraduationCap, + Layers, + MessageSquareText, + Plus, + Search, + Sparkles, + Trash2, + Video, + Wrench, + X, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import CreateNotePanel from "@/components/CreateNotePanel"; import NavBar from "@/components/NavBar"; import RequireAuth from "@/components/RequireAuth"; -import CreateNotePanel from "@/components/CreateNotePanel"; +import { Button, Card, Chip, IconButton, Input } from "@/components/ui"; +import { deleteNote, fetchHistory, fetchMyNotes } from "@/lib/api"; import { fetchCatalog, formatDuration } from "@/lib/notes"; -import { deleteNote, fetchMyNotes } from "@/lib/api"; -import type { CatalogItem, NoteView } from "@/lib/types"; +import type { CatalogItem, HistoryItem, NoteView } from "@/lib/types"; -/** - * 笔记本库(登录后主页,NotebookLM 应用首页的对应物)。 - * 营销 landing 在 / ;个人笔记、书签等登录后才可见(RequireAuth 包裹)。 - */ - -type Filter = "all" | "mine" | "public"; +type Filter = "mine" | "all" | "public"; interface CardItem { id: string; @@ -31,221 +41,412 @@ interface CardItem { chapters: number; uploader: string; mine: boolean; + recentAt?: number; } -/** domain 文案 → 图标 + 中间调色(中间调在明暗两套底色上都可读) */ -const DOMAIN_STYLE: Record = { - "编程教学": { icon: Code2, color: "#4285f4" }, - "考研专业课": { icon: GraduationCap, color: "#9a6bff" }, - "工具教程": { icon: Wrench, color: "#12b886" }, - "科普": { icon: FlaskConical, color: "#12b886" }, - "Vlog": { icon: Clapperboard, color: "#f59f00" }, - "时评": { icon: MessagesSquare, color: "#e64980" }, - "数码评测": { icon: Smartphone, color: "#22b8cf" }, +const FILTERS: { key: Filter; label: string }[] = [ + { key: "mine", label: "我的笔记" }, + { key: "all", label: "全部" }, + { key: "public", label: "公开示例" }, +]; + +const DOMAIN_STYLE: Record = { + 编程教学: { icon: Code2 }, + 考研专业课: { icon: GraduationCap }, + 工具教程: { icon: Wrench }, + 科普: { icon: FlaskConical }, + Vlog: { icon: Video }, + 时评: { icon: MessageSquareText }, + 数码评测: { icon: FileVideo }, }; function domainStyle(domain: string) { - return DOMAIN_STYLE[domain] ?? { icon: BookOpen, color: "#4285f4" }; + return DOMAIN_STYLE[domain] ?? { icon: BookOpen }; } -const FILTERS: { key: Filter; label: string }[] = [ - { key: "all", label: "全部" }, - { key: "mine", label: "我的" }, - { key: "public", label: "公开示例" }, -]; +function normalizeTime(value?: number | null) { + if (!value) return 0; + return value > 1_000_000_000_000 ? value : value * 1000; +} + +function formatRelativeTime(value?: number) { + if (!value) return "暂无进度"; + const diff = Date.now() - normalizeTime(value); + const minutes = Math.max(1, Math.floor(diff / 60_000)); + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days} 天前`; + return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(new Date(normalizeTime(value))); +} + +function jobStatusLabel(status: HistoryItem["status"]) { + if (status === "queued") return "排队中"; + if (status === "running") return "生成中"; + if (status === "done") return "已完成"; + if (status === "failed") return "失败"; + if (status === "interrupted") return "已中断"; + return status; +} + +function matchesSearch(item: CardItem, query: string) { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [item.title, item.domain, item.uploader].some((value) => value.toLowerCase().includes(q)); +} + +function NoteCard({ + deleting, + item, + onDelete, +}: { + deleting: boolean; + item: CardItem; + onDelete: (item: CardItem) => void; +}) { + const reduceMotion = useReducedMotion(); + const { icon: Icon } = domainStyle(item.domain); + + return ( + + + {item.mine ? ( +
+ onDelete(item)} + size="sm" + variant="danger" + > + +
+ ) : null} + + +
+ + + + {item.mine ? "我的" : "示例"} + +
+

+ {item.title} +

+

{item.uploader || "未知来源"}

+
+ + + + + {item.chunks} 段 +
+
+ + {item.domain} + + {formatRelativeTime(item.recentAt)} +
+ +
+
+ ); +} + +function EmptyState({ + filter, + onCreate, + query, +}: { + filter: Filter; + onCreate: () => void; + query: string; +}) { + const hasQuery = query.trim().length > 0; + const title = hasQuery ? "没有找到匹配的笔记" : filter === "mine" ? "还没有自己的笔记" : "暂无可显示的笔记"; + const desc = hasQuery + ? "换一个关键词,或清空搜索后再看全部内容。" + : filter === "mine" + ? "从一个课程、讲座或教程视频开始,生成第一本可复习的笔记。" + : "当前筛选下没有内容。"; + + return ( + + + ); +} function NotebooksInner() { const [pub, setPub] = useState(null); const [mine, setMine] = useState(null); - const [filter, setFilter] = useState("all"); + const [history, setHistory] = useState(null); + const [filter, setFilter] = useState("mine"); + const [query, setQuery] = useState(""); const [createOpen, setCreateOpen] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); const [delId, setDelId] = useState(null); + const [deleteError, setDeleteError] = useState(null); useEffect(() => { let alive = true; fetchCatalog() - .then(d => { if (alive) setPub(d); }) + .then((d) => { if (alive) setPub(d); }) .catch(() => { if (alive) setPub([]); }); fetchMyNotes() - .then(d => { if (alive) setMine(d); }) + .then((d) => { if (alive) setMine(d); }) .catch(() => { if (alive) setMine([]); }); + fetchHistory() + .then((d) => { if (alive) setHistory(d); }) + .catch(() => { if (alive) setHistory([]); }); return () => { alive = false; }; }, []); - // Esc 关闭新建面板 useEffect(() => { - if (!createOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setCreateOpen(false); }; + if (!createOpen && !confirmDelete) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setCreateOpen(false); + setConfirmDelete(null); + } + }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [createOpen]); + }, [createOpen, confirmDelete]); - const loading = pub === null || mine === null; + const loading = pub === null || mine === null || history === null; + + const recentByNote = useMemo(() => { + const map = new Map(); + for (const item of history ?? []) { + if (!item.note_id) continue; + const current = map.get(item.note_id) ?? 0; + map.set(item.note_id, Math.max(current, item.updated_at, item.finished_at ?? 0, item.created_at)); + } + return map; + }, [history]); const items: CardItem[] = useMemo(() => { - const mineCards: CardItem[] = (mine ?? []).map(n => ({ - id: n.id, title: n.title, domain: n.domain, duration_sec: n.duration_sec, - chunks: n.chunks, chapters: n.chapters, uploader: n.uploader, mine: true, + const mineCards: CardItem[] = (mine ?? []).map((n) => ({ + id: n.id, + title: n.title, + domain: n.domain, + duration_sec: n.duration_sec, + chunks: n.chunks, + chapters: n.chapters, + uploader: n.uploader, + mine: true, + recentAt: recentByNote.get(n.id), })); - const mineIds = new Set(mineCards.map(c => c.id)); + const mineIds = new Set(mineCards.map((c) => c.id)); const pubCards: CardItem[] = (pub ?? []) - .filter(p => !mineIds.has(p.id)) - .map(p => ({ - id: p.id, title: p.title, domain: p.domain, duration_sec: p.duration_sec, - chunks: p.chunks, chapters: p.chapters, uploader: p.uploader, mine: false, + .filter((p) => !mineIds.has(p.id)) + .map((p) => ({ + id: p.id, + title: p.title, + domain: p.domain, + duration_sec: p.duration_sec, + chunks: p.chunks, + chapters: p.chapters, + uploader: p.uploader, + mine: false, })); - if (filter === "mine") return mineCards; - if (filter === "public") return pubCards; - return [...mineCards, ...pubCards]; - }, [pub, mine, filter]); - - async function remove(id: string) { - if (!confirm("删除这篇笔记?产物文件会一并清除,不可恢复。")) return; - setDelId(id); + + const source = filter === "mine" ? mineCards : filter === "public" ? pubCards : [...mineCards, ...pubCards]; + return source + .filter((item) => matchesSearch(item, query)) + .sort((a, b) => { + if (a.mine !== b.mine) return a.mine ? -1 : 1; + return (b.recentAt ?? 0) - (a.recentAt ?? 0); + }); + }, [filter, mine, pub, query, recentByNote]); + + const latestJob = useMemo(() => { + return [...(history ?? [])].sort((a, b) => b.updated_at - a.updated_at)[0] ?? null; + }, [history]); + + async function remove(item: CardItem) { + setDelId(item.id); + setDeleteError(null); try { - await deleteNote(id); - setMine(m => (m ?? []).filter(x => x.id !== id)); + await deleteNote(item.id); + setMine((value) => (value ?? []).filter((x) => x.id !== item.id)); + setConfirmDelete(null); } catch (e) { - alert(`删除失败:${String(e)}`); + setDeleteError(e instanceof Error ? e.message : String(e)); } finally { setDelId(null); } } return ( -
+
-
- {/* 标题行 + filter chips */} -
-

我的笔记本

-
- {FILTERS.map(f => { - const active = filter === f.key; - return ( - - ); - })} +
+
+ +
+
+ + +

+ 我的笔记 +

+

+ 优先查看自己的视频笔记、最近生成进度和可复习材料;公开示例保留为参考内容。 +

+
+ +
+
+ + +

最近进度

+ {loading ? ( +
+
+
+
+ ) : latestJob ? ( +
+
+ + {jobStatusLabel(latestJob.status)} + + + {formatRelativeTime(latestJob.updated_at)} + +
+

{latestJob.source}

+
+
+
+ + 查看任务历史 + +
+ ) : ( +

+ 还没有生成任务。创建一本笔记后,这里会显示最近处理进度。 +

+ )} + +
+ +
+
+ {FILTERS.map((f) => ( + + ))}
- - {loading ? "" : `${items.length} 个笔记本`} + + + {loading ? "加载中" : `${items.length} 个笔记本`}
- {/* 卡片 grid */} -
- {/* 新建卡 */} +
{loading ? Array.from({ length: 7 }).map((_, i) => ( -
-
-
-
-
-
+ +
+
+
+
+ )) - : items.map(item => { - const { icon: Icon, color } = domainStyle(item.domain); - return ( - - {item.mine && ( - - )} - - - - -

- {item.title} -

-
- - {formatDuration(item.duration_sec)} - - - {item.chapters} 章 - - - - {item.domain} - - {item.mine && ( - - 我的 - - )} - -
- -
- ); - })} -
+ : items.map((item) => ( + { + setDeleteError(null); + setConfirmDelete(value); + }} + /> + ))} - {!loading && items.length === 0 && ( -
- {filter === "mine" - ? "还没有私有笔记。点「新建笔记本」提交一个视频试试。" - : "暂时没有可显示的笔记。"} -
- )} + {!loading && items.length === 0 ? ( + setCreateOpen(true)} query={query} /> + ) : null} +
- {/* 新建笔记本弹层 */} - {createOpen && ( + {createOpen ? ( setCreateOpen(false)} role="dialog" aria-modal="true" @@ -257,29 +458,68 @@ function NotebooksInner() { exit={{ opacity: 0, y: 16, scale: 0.98 }} transition={{ type: "spring", stiffness: 300, damping: 30 }} className="w-full max-w-xl" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} >
新建笔记本 - +
- )} + ) : null} + + {confirmDelete ? ( + setConfirmDelete(null)} + role="dialog" + aria-modal="true" + aria-labelledby="delete-note-title" + > + e.stopPropagation()}> +

+ 删除这本笔记? +

+

+ “{confirmDelete.title}” 会从你的笔记库中移除,相关产物文件也会被清理。此操作不可撤销。 +

+ {deleteError ? ( +

+ 删除失败:{deleteError} +

+ ) : null} +
+ + +
+
+
+ ) : null}
); } export default function NotebooksPage() { - return ; + return ( + + + + ); } From 9d72ae7f2322fb39ddab9ccc5673c2816ce66323 Mon Sep 17 00:00:00 2001 From: lsdragst Date: Wed, 24 Jun 2026 00:53:29 +0800 Subject: [PATCH 08/10] feat: refine Warm Fold note workspace --- web/components/NoteWorkspace.tsx | 225 ++++++++++++++++++++----------- 1 file changed, 143 insertions(+), 82 deletions(-) diff --git a/web/components/NoteWorkspace.tsx b/web/components/NoteWorkspace.tsx index 771f364..9b63244 100644 --- a/web/components/NoteWorkspace.tsx +++ b/web/components/NoteWorkspace.tsx @@ -24,6 +24,8 @@ import { useChapterProgress } from "@/lib/progress"; import { useLang, pickByLang } from "@/components/LangContext"; import { useAuth } from "@/components/AuthContext"; +type RailTab = "chapters" | "transcript"; + interface Props { noteId: string; bundle: NoteBundle; @@ -92,6 +94,111 @@ export function WorkspaceSkeleton({ backHref }: { backHref: string }) { ); } +function WorkspaceRail({ + bundle, + chaptersDone, + currentChapter, + currentTime, + lang, + onSeek, + railTab, + setRailTab, + shared, + sourceLabel, + sourceUrl, + title, + toggleChapterDone, +}: { + bundle: NoteBundle; + chaptersDone: number[]; + currentChapter: number; + currentTime: number; + lang: "zh" | "en"; + onSeek: (sec: number) => void; + railTab: RailTab; + setRailTab: (tab: RailTab) => void; + shared: boolean; + sourceLabel: string; + sourceUrl: string; + title: string; + toggleChapterDone: (idx: number) => void; +}) { + return ( + <> +

+ {lang === "en" ? "Source" : "来源"} +

+ {sourceUrl ? ( + + + + {sourceLabel} + + {bundle.meta?.uploader || title} + + + + ) : ( +
+ + + {sourceLabel} + + {bundle.meta?.uploader || title} + + +
+ )} +
+ {([ + ["chapters", lang === "en" ? "Chapters" : "章节"], + ["transcript", lang === "en" ? "Transcript" : "逐字稿"], + ] as const).map(([key, label]) => { + const active = railTab === key; + return ( + + ); + })} +
+ {railTab === "chapters" ? ( + bundle.chapters.length > 0 ? ( + + ) : ( +

+ {lang === "en" ? "No chapters." : "这篇笔记没有章节数据。"} +

+ ) + ) : ( + + )} + + ); +} + /** * 三栏笔记工作台(docs/frontend-redesign.md §3.2)。 * /notes/[id] 与 /s/[token] 共用;后者以 shared 模式渲染。 @@ -112,7 +219,7 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false const [mainInView, setMainInView] = useState(true); const [miniDismissed, setMiniDismissed] = useState(false); const [railOpen, setRailOpen] = useState(false); // ("chapters"); + const [railTab, setRailTab] = useState("chapters"); const [shareToken, setShareToken] = useState(null); const [shareCopied, setShareCopied] = useState(false); const [shareHint, setShareHint] = useState(null); @@ -266,82 +373,8 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false : (lang === "en" ? "Web link" : "网页链接")) : (lang === "en" ? "Local file" : "本地上传"); - // 左栏内容:桌面 aside 与 void) => ( - <> -

- {lang === "en" ? "Source" : "来源"} -

- {sourceUrl ? ( - - - - {sourceLabel} - - {bundle.meta?.uploader || title} - - - - ) : ( -
- - - {sourceLabel} - - {bundle.meta?.uploader || title} - - -
- )} - {/* 章节 | 逐字稿 tab 切换 */} -
- {([["chapters", lang === "en" ? "Chapters" : "章节"], - ["transcript", lang === "en" ? "Transcript" : "逐字稿"]] as const).map(([key, label]) => { - const active = railTab === key; - return ( - - ); - })} -
- {railTab === "chapters" ? ( - bundle.chapters.length > 0 ? ( - - ) : ( -

- {lang === "en" ? "No chapters." : "这篇笔记没有章节数据。"} -

- ) - ) : ( - - )} - - ); - return ( -
+
+
{/* 左栏:来源 + 垂直章节(仅 lg; - {renderRail(seek)} + {/* 中栏:笔记内容 + 问答 */} @@ -418,9 +465,9 @@ export default function NoteWorkspace({ noteId, bundle, backHref, shared = false {/* 右栏:视频 + 当前章 + 工具组( -
+
- {renderRail(seekAndCloseRail)} + )} From 1e5a2a8801295b8d5948fa268f6894b1729cebe3 Mon Sep 17 00:00:00 2001 From: lsdragst Date: Wed, 24 Jun 2026 00:54:56 +0800 Subject: [PATCH 09/10] ci: validate Warm Fold web build --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fafcf00..28f2b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,9 @@ jobs: - name: Type check run: npx tsc --noEmit + + - name: Validate Warm Fold assets + run: npm run validate:warm-fold + + - name: Next.js build + run: npm run build From 63cf89301554314040ad859127c2cabf86de08ba Mon Sep 17 00:00:00 2001 From: lsdragst Date: Wed, 24 Jun 2026 00:57:35 +0800 Subject: [PATCH 10/10] feat: apply Warm Fold navigation --- web/components/NavBar.tsx | 118 +++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 52 deletions(-) diff --git a/web/components/NavBar.tsx b/web/components/NavBar.tsx index a6dbac1..8cce153 100644 --- a/web/components/NavBar.tsx +++ b/web/components/NavBar.tsx @@ -1,85 +1,99 @@ "use client"; + import Link from "next/link"; -import { Sparkles, Layers, WifiOff, Bookmark } from "lucide-react"; -import ThemeToggle from "./ThemeToggle"; -import LangToggle from "./LangToggle"; +import { Bookmark, Layers, WifiOff } from "lucide-react"; + +import { BrandMark } from "@/components/brand/BrandMark"; +import { Button, Chip } from "@/components/ui"; +import { useBookmarksList } from "@/lib/bookmarks"; import { useAuth } from "./AuthContext"; -import UserMenu from "./UserMenu"; +import LangToggle from "./LangToggle"; import { useLang } from "./LangContext"; -import { useBookmarksList } from "@/lib/bookmarks"; +import ThemeToggle from "./ThemeToggle"; +import UserMenu from "./UserMenu"; /** - * 全站共享导航栏。粘性 glass 风格,左侧 logo + brand,右侧 ThemeToggle。 - * children 是可选的中间内容(如详情页传当前笔记标题)。 + * Shared app navigation. Uses the Warm Fold brand mark and tokens, + * while keeping route behavior unchanged for existing pages. */ export default function NavBar({ children }: { children?: React.ReactNode }) { const { user, loading, offline } = useAuth(); const { lang } = useLang(); const bmCount = useBookmarksList().length; + return ( -
-
- - - - - NoteGen +
+
+ + + {children ? ( -
{children}
+
{children}
) : (
)} - {user && - - {lang === "en" ? "Bookmarks" : "书签"} - {bmCount > 0 && ( - - {bmCount} - - )} - } + + {user ? ( + +
);