From 65df0ef1d75325ccdbaafa3b30849d05c36bd0f2 Mon Sep 17 00:00:00 2001 From: cqh963852 Date: Thu, 8 Oct 2026 14:56:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=AE=BE=E8=AE=A1=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=AE=8C=E6=95=B4=E6=8F=90=E5=8F=96=E4=B8=BA=20@stack?= =?UTF-8?q?/design-system=20=E5=85=B1=E4=BA=AB=E5=8C=85=EF=BC=88=E7=BB=84?= =?UTF-8?q?=E4=BB=B6/tokens/=E7=B1=BB=E5=9E=8B=E5=94=AF=E4=B8=80=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=A4=84=EF=BC=8C=E5=8F=8C=E7=AB=AF=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8B=B7=E8=B4=9D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 组件参数化适配双端:MemphisCard href/morph,EntryGuide 数据注入+linkChapter - RouteLine hover 跟随(站点 translateY(-3)、连线 CSS d 平滑插值、直线变斜线)随包回到 design-lab,shelf 场景接线演示 - tokens.css 显式 @source 包内源码——经 node_modules 解析时自动扫描漏类(text-mute 等静默丢失致 4% 画面偏差,已修复并验证) - E2E 新增 hover 跟随契约(静置 L 水平、hover 变斜线、双站点 -3px、卡片同步),28/28 全绿 --- apps/design-lab/e2e/pages.spec.ts | 21 +++ apps/design-lab/package.json | 5 +- apps/design-lab/src/components/EntryGuide.tsx | 35 ----- .../design-lab/src/components/MemphisCard.tsx | 136 ------------------ apps/design-lab/src/components/RouteLine.tsx | 83 ----------- apps/design-lab/src/lib/chapters.ts | 2 +- apps/design-lab/src/lib/ui.tsx | 1 - apps/design-lab/src/pages/colors/main.tsx | 2 +- apps/design-lab/src/pages/components/App.tsx | 12 +- apps/design-lab/src/pages/components/main.tsx | 2 +- apps/design-lab/src/pages/index/main.tsx | 2 +- apps/design-lab/src/pages/shelf/App.tsx | 47 ++++-- apps/design-lab/src/pages/shelf/main.tsx | 2 +- apps/design-lab/src/pages/symbols/App.tsx | 4 +- apps/design-lab/src/pages/symbols/main.tsx | 2 +- apps/design-lab/src/styles/tokens.css | 69 --------- apps/tutorial/package.json | 1 + apps/tutorial/src/components/ChapterPage.tsx | 6 +- apps/tutorial/src/components/EntryGuide.tsx | 36 ----- apps/tutorial/src/components/Scatter.tsx | 90 ------------ apps/tutorial/src/components/Ticket.tsx | 84 ----------- apps/tutorial/src/components/shapes.tsx | 82 ----------- apps/tutorial/src/lib/chapters.ts | 4 +- apps/tutorial/src/lib/types.ts | 54 ------- apps/tutorial/src/lib/ui.tsx | 3 +- .../src/pages/design-components/App.tsx | 12 +- .../src/pages/design-examples/App.tsx | 12 +- .../tutorial/src/pages/design-symbols/App.tsx | 4 +- apps/tutorial/src/pages/index/App.tsx | 19 ++- packages/design-system/README.md | 5 + packages/design-system/package.json | 11 ++ .../src/components/EntryGuide.tsx | 54 +++++++ .../src/components/MemphisCard.tsx | 10 +- .../src/components/RouteLine.tsx | 0 .../design-system}/src/components/Scatter.tsx | 0 .../design-system}/src/components/Ticket.tsx | 0 .../design-system}/src/components/shapes.tsx | 0 packages/design-system/src/index.ts | 12 ++ .../design-system}/src/lib/types.ts | 0 .../design-system}/src/styles/tokens.css | 3 + 40 files changed, 191 insertions(+), 736 deletions(-) delete mode 100644 apps/design-lab/src/components/EntryGuide.tsx delete mode 100644 apps/design-lab/src/components/MemphisCard.tsx delete mode 100644 apps/design-lab/src/components/RouteLine.tsx delete mode 100644 apps/design-lab/src/styles/tokens.css delete mode 100644 apps/tutorial/src/components/EntryGuide.tsx delete mode 100644 apps/tutorial/src/components/Scatter.tsx delete mode 100644 apps/tutorial/src/components/Ticket.tsx delete mode 100644 apps/tutorial/src/components/shapes.tsx delete mode 100644 apps/tutorial/src/lib/types.ts create mode 100644 packages/design-system/README.md create mode 100644 packages/design-system/package.json create mode 100644 packages/design-system/src/components/EntryGuide.tsx rename {apps/tutorial => packages/design-system}/src/components/MemphisCard.tsx (93%) rename {apps/tutorial => packages/design-system}/src/components/RouteLine.tsx (100%) rename {apps/design-lab => packages/design-system}/src/components/Scatter.tsx (100%) rename {apps/design-lab => packages/design-system}/src/components/Ticket.tsx (100%) rename {apps/design-lab => packages/design-system}/src/components/shapes.tsx (100%) create mode 100644 packages/design-system/src/index.ts rename {apps/design-lab => packages/design-system}/src/lib/types.ts (100%) rename {apps/tutorial => packages/design-system}/src/styles/tokens.css (95%) diff --git a/apps/design-lab/e2e/pages.spec.ts b/apps/design-lab/e2e/pages.spec.ts index d13352b..0943701 100644 --- a/apps/design-lab/e2e/pages.spec.ts +++ b/apps/design-lab/e2e/pages.spec.ts @@ -154,6 +154,27 @@ test.describe('RouteLine 规格契约', () => { expect(z).toBe('10') }) + test('hover 卡片:线与站点跟随浮起(-3px 同步,直线变斜线)', async ({ page }) => { + const get = () => + page.evaluate(() => { + const svg = [...document.querySelectorAll('svg')].find((s) => s.getAttribute('width') === '1160') + return { + stations: [...svg.querySelectorAll('g[style]')].map((g) => (g as HTMLElement).style.transform), + d25: [...svg.querySelectorAll('path')].map((el) => el.getAttribute('d') || '').find((d) => d.startsWith('M 630')), + cardTranslate: getComputedStyle(document.querySelector('a[data-comp="course-card"][href="#ch2"]')).translate, + } + }) + const before = await get() + await page.locator('div.absolute.left-\\[380px\\]').hover() + await page.waitForTimeout(250) + const after = await get() + expect(before.d25).toBe('M 630 108 L 700 108') // 静置:几何水平(L 结构可平滑插值) + expect(after.d25).toBe('M 630 105 L 700 108') // hover 卡2:源端随站点抬升,直线变斜线 + expect(before.stations.every((t) => t === 'translateY(0px)')).toBeTruthy() + expect(after.stations.filter((t) => t === 'translateY(-3px)').length).toBe(2) // 源+目标站点跟随 + expect(after.cardTranslate).toBe('0px -3px') // 卡片本体同步浮起 + }) + test('实线端点落在水平端口(M x y H 起、H x 止)', async ({ page }) => { const d = await page.locator('svg path[stroke-width="2.5"]').first().getAttribute('d') expect(d).toMatch(/^M [\d.]+ [\d.]+ H/) diff --git a/apps/design-lab/package.json b/apps/design-lab/package.json index 6c7729f..1d306dd 100644 --- a/apps/design-lab/package.json +++ b/apps/design-lab/package.json @@ -12,7 +12,8 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "@stack/design-system": "workspace:*" }, "devDependencies": { "@playwright/test": "1.63.0", @@ -24,4 +25,4 @@ "typescript": "~5.6.2", "vite": "^6.0.0" } -} +} \ No newline at end of file diff --git a/apps/design-lab/src/components/EntryGuide.tsx b/apps/design-lab/src/components/EntryGuide.tsx deleted file mode 100644 index ca33615..0000000 --- a/apps/design-lab/src/components/EntryGuide.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** 入口梳理 —— 读者画像推荐路径:是推荐不是强制(无锁) */ -import { CHAPTERS, PROFILES } from '../lib/chapters' - -export function EntryGuide() { - return ( -
- {PROFILES.map((p) => ( -
-
- - {p.icon} - - {p.name} -
-
- {p.path.map((id, i) => ( - - {i > 0 && →} - - {id === p.entry && ['①', '②', '③'][PROFILES.indexOf(p)]} {CHAPTERS[id].title} - - - ))} -
-

{p.note}

-
- ))} -
- ) -} diff --git a/apps/design-lab/src/components/MemphisCard.tsx b/apps/design-lab/src/components/MemphisCard.tsx deleted file mode 100644 index 8f52165..0000000 --- a/apps/design-lab/src/components/MemphisCard.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** CourseCard —— 教程的入口载体(孟菲斯骨架 × 拟物纸感) - * 卡头三件套=靶子:▲目标 + ○预期结果(16:9 相框) + ●积木点列 - * 交互:hover/聚焦拿起(浮起+投影加深)并播放 GIF;desc ⇄ GIF 每 8000ms 翻转; - * hover/Tab 锁定;触屏与 prefers-reduced-motion 不翻转。 */ -import { useEffect, useRef, useState, type ReactNode } from 'react' -import type { Chapter } from '../lib/types' -import { Ring, Tri } from './shapes' - -const FLIP_MS = 8000 - -export function BrickDots({ bricks }: { bricks: Chapter['target']['bricks'] }) { - return ( - b.learned).length}/${bricks.length}`}> - {bricks.map((b, i) => ( - - ))} - - ) -} - -export function EntryBadge({ n }: { n: 1 | 2 | 3 }) { - return ( - - {'①②③'[n - 1]} - - ) -} - -export function MemphisCard({ - chapter, - entry, - compact = false, - zoneText, -}: { - chapter: Chapter - entry?: 1 | 2 | 3 - compact?: boolean - /** 静置态相框文字(可含 荧光标记):教程卡片的价值区用法示例 */ - zoneText?: ReactNode -}) { - const [gifOn, setGifOn] = useState(false) - const [locked, setLocked] = useState(false) - const boxRef = useRef(null) - - useEffect(() => { - const coarse = matchMedia('(pointer: coarse)').matches - const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches - if (coarse || reduced) return - const io = new IntersectionObserver( - ([e]) => { - if (e.isIntersecting) setGifOn(true) - else setGifOn(false) - }, - { threshold: 0.3 }, - ) - if (boxRef.current) io.observe(boxRef.current) - return () => io.disconnect() - }, []) - - useEffect(() => { - if (!gifOn) return - const t = setInterval(() => { - setFlip((f) => !f) - }, FLIP_MS) - return () => clearInterval(t) - }, [gifOn]) - - const [flip, setFlip] = useState(false) - const showGif = gifOn && (locked ? true : flip) - - return ( - setLocked(true)} - onMouseLeave={() => setLocked(false)} - onFocus={() => setLocked(true)} - onBlur={() => setLocked(false)} - aria-label={`${chapter.title}:${chapter.target.goal}`} - > - {entry && } - {/* ○ 预期结果:16:9 相框(章节撞色只进这里) */} -
- {showGif ? ( - chapter.gifUrl ? ( - {chapter.target.expectation} - ) : ( - - ▶ {chapter.target.expectation} - - ) - ) : flip ? ( - - {chapter.target.goal} - - ) : zoneText ? ( - /* 静置态(用法示例):过程文字骑在相框里,价值区短语带荧光标记 */ - {zoneText} - ) : ( - /* 静置态:章节 emoji 是相框的视觉主体(对齐设计稿解剖卡) */ - {chapter.emoji} - )} - - 预期结果{showGif ? ' · GIF' : ''} - - -
- {/* 卡头:▲ 目标 + 标题 + ● 积木点列 */} -
- - - {chapter.title} - - {!compact && } -
-
- ) -} diff --git a/apps/design-lab/src/components/RouteLine.tsx b/apps/design-lab/src/components/RouteLine.tsx deleted file mode 100644 index f1f6313..0000000 --- a/apps/design-lab/src/components/RouteLine.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** RouteLine —— 关系线:线形即含义(实=先后 虚=相关 点=同族) - * 拓扑:横平竖直 + R8 直角步进;并行不合并(4px);站点 r6 半嵌卡边 - * (圆心骑左右描边,各半在卡上);线层 z-10 置于卡之上,站点不被卡遮挡; - * 同口多线自动聚合胶囊(线止于胶囊边界)。 */ -import type { ChapterId, Edge } from '../lib/types' - -const STATION_COLORS = ['var(--color-clash2a)', 'var(--color-clash1b)', 'var(--color-clash3a)', 'var(--color-clash1a)'] - -export interface LaneNode { - id: ChapterId - borderLeft: number // 左描边 x(入口圆心贴这里,点半嵌) - right: number // 右描边 x(出口圆心贴这里,点半嵌——不接阴影) - y: number // 卡中线 y -} - -/** 水平出发 → R8 双弯直角步进 → 水平到达;同 y 时直线 */ -function stepPath(x0: number, y0: number, x1: number, y1: number, r = 8): string { - if (Math.abs(y1 - y0) < 1) return `M ${x0} ${y0} H ${x1}` - const down = y1 > y0 - const sweep1 = down ? 1 : 0 - const sweep2 = down ? 0 : 1 - const cornerX = Math.max(x0 + r, x1 - 2 * r) - return `M ${x0} ${y0} H ${cornerX} A ${r} ${r} 0 0 ${sweep1} ${cornerX + r} ${y0 + (down ? r : -r)} V ${y1 - (down ? r : -r)} A ${r} ${r} 0 0 ${sweep2} ${cornerX + 2 * r} ${y1} H ${x1}` -} - -export function RouteLine({ - edges, - nodes, - width, - height, -}: { - edges: Edge[] - nodes: Partial> - width: number - height: number -}) { - const stationColor = (i: number) => STATION_COLORS[i % STATION_COLORS.length] - - const seqEdges = edges.filter((e) => e.kind === 'seq') - const insByTo = new Map() - seqEdges.forEach((e) => insByTo.set(e.to, [...(insByTo.get(e.to) ?? []), e])) - const firstIn = new Set() - insByTo.forEach((list) => firstIn.add(list[0].to)) - - let si = 0 - return ( - - {edges.map((e, i) => { - const a = nodes[e.from] - const b = nodes[e.to] - if (!a || !b) return null - const d = stepPath(a.right, a.y, b.borderLeft, b.y) - if (e.kind === 'rel') return - if (e.kind === 'family') return - return - })} - {seqEdges.map((e) => { - const a = nodes[e.from] - const b = nodes[e.to] - if (!a || !b) return null - const ins = insByTo.get(e.to)! - const capsule = ins.length > 1 && firstIn.has(e.to) - // 源/目标站点:圆心骑卡描边,各半在卡上;目标:单线=圆点,多线=胶囊(只画一次) - return ( - - - {capsule ? ( - - ) : ins.length === 1 ? ( - - ) : null} - - ) - })} - - ) -} diff --git a/apps/design-lab/src/lib/chapters.ts b/apps/design-lab/src/lib/chapters.ts index c02842f..699b292 100644 --- a/apps/design-lab/src/lib/chapters.ts +++ b/apps/design-lab/src/lib/chapters.ts @@ -1,4 +1,4 @@ -import type { Assignment, Chapter, ChapterId, Edge, Profile } from './types' +import type { Assignment, Chapter, ChapterId, Edge, Profile } from '@stack/design-system' export const CHAPTERS: Record = { 1: { diff --git a/apps/design-lab/src/lib/ui.tsx b/apps/design-lab/src/lib/ui.tsx index 3f0c836..865161b 100644 --- a/apps/design-lab/src/lib/ui.tsx +++ b/apps/design-lab/src/lib/ui.tsx @@ -1,6 +1,5 @@ /** 页面公共骨架 */ import type { ReactNode } from 'react' -import '../styles/tokens.css' export function Page({ tag, title, desc, children }: { tag: string; title: string; desc: string; children: ReactNode }) { return ( diff --git a/apps/design-lab/src/pages/colors/main.tsx b/apps/design-lab/src/pages/colors/main.tsx index 3dc54c8..7d878af 100644 --- a/apps/design-lab/src/pages/colors/main.tsx +++ b/apps/design-lab/src/pages/colors/main.tsx @@ -1,5 +1,5 @@ +import '@stack/design-system/styles/tokens.css' import { createRoot } from 'react-dom/client' -import '../../styles/tokens.css' import { App } from './App' createRoot(document.getElementById('root')!).render() diff --git a/apps/design-lab/src/pages/components/App.tsx b/apps/design-lab/src/pages/components/App.tsx index c1aa143..dee163d 100644 --- a/apps/design-lab/src/pages/components/App.tsx +++ b/apps/design-lab/src/pages/components/App.tsx @@ -1,9 +1,9 @@ import { Page, Section } from '../../lib/ui' -import { ASSIGNMENT, CHAPTERS } from '../../lib/chapters' -import { MemphisCard } from '../../components/MemphisCard' -import { AssignBlock, Ticket } from '../../components/Ticket' -import { EntryGuide } from '../../components/EntryGuide' -import { Mark } from '../../components/shapes' +import { ASSIGNMENT, CHAPTERS, PROFILES } from '../../lib/chapters' +import { MemphisCard } from '@stack/design-system' +import { AssignBlock, Ticket } from '@stack/design-system' +import { EntryGuide } from '@stack/design-system' +import { Mark } from '@stack/design-system' export function App() { return ( @@ -33,7 +33,7 @@ export function App() {
- +
) diff --git a/apps/design-lab/src/pages/components/main.tsx b/apps/design-lab/src/pages/components/main.tsx index 3dc54c8..7d878af 100644 --- a/apps/design-lab/src/pages/components/main.tsx +++ b/apps/design-lab/src/pages/components/main.tsx @@ -1,5 +1,5 @@ +import '@stack/design-system/styles/tokens.css' import { createRoot } from 'react-dom/client' -import '../../styles/tokens.css' import { App } from './App' createRoot(document.getElementById('root')!).render() diff --git a/apps/design-lab/src/pages/index/main.tsx b/apps/design-lab/src/pages/index/main.tsx index 3dc54c8..7d878af 100644 --- a/apps/design-lab/src/pages/index/main.tsx +++ b/apps/design-lab/src/pages/index/main.tsx @@ -1,5 +1,5 @@ +import '@stack/design-system/styles/tokens.css' import { createRoot } from 'react-dom/client' -import '../../styles/tokens.css' import { App } from './App' createRoot(document.getElementById('root')!).render() diff --git a/apps/design-lab/src/pages/shelf/App.tsx b/apps/design-lab/src/pages/shelf/App.tsx index ab19df3..94a3d7e 100644 --- a/apps/design-lab/src/pages/shelf/App.tsx +++ b/apps/design-lab/src/pages/shelf/App.tsx @@ -1,13 +1,15 @@ /** 组合场景=大部分组件拿出来的真实使用,不做解释性标注: * 入口画像 → 课程卡与关系线 → 进度票券 → 指派块;形状散落于卡片附近空当。 */ import { Page } from '../../lib/ui' -import { ASSIGNMENT, CHAPTERS, EDGES } from '../../lib/chapters' -import { MemphisCard } from '../../components/MemphisCard' -import { RouteLine } from '../../components/RouteLine' -import { AssignBlock, Ticket } from '../../components/Ticket' -import { EntryGuide } from '../../components/EntryGuide' -import { Scatter } from '../../components/Scatter' -import { Cross, Ring, Tremble, Tri, Wave } from '../../components/shapes' +import { ASSIGNMENT, CHAPTERS, EDGES, PROFILES } from '../../lib/chapters' +import { useState } from 'react' +import type { ChapterId } from '@stack/design-system' +import { MemphisCard } from '@stack/design-system' +import { RouteLine } from '@stack/design-system' +import { AssignBlock, Ticket } from '@stack/design-system' +import { EntryGuide } from '@stack/design-system' +import { Scatter } from '@stack/design-system' +import { Cross, Ring, Tremble, Tri, Wave } from '@stack/design-system' /* 场景坐标(节点=卡描边真实边缘):卡1(40,110,250) 卡2(380,44,250,rot) 卡3(700,44,140) */ const nodes = { @@ -17,13 +19,14 @@ const nodes = { } as const export function App() { + const [hovered, setHovered] = useState(null) return ( - +
, ]} /> - -
-
-
+ +
setHovered(1)} + onMouseLeave={() => setHovered(null)} + > + +
+
setHovered(2)} + onMouseLeave={() => setHovered(null)} + > + +
+
setHovered(5)} + onMouseLeave={() => setHovered(null)} + > + +
diff --git a/apps/design-lab/src/pages/shelf/main.tsx b/apps/design-lab/src/pages/shelf/main.tsx index 3dc54c8..7d878af 100644 --- a/apps/design-lab/src/pages/shelf/main.tsx +++ b/apps/design-lab/src/pages/shelf/main.tsx @@ -1,5 +1,5 @@ +import '@stack/design-system/styles/tokens.css' import { createRoot } from 'react-dom/client' -import '../../styles/tokens.css' import { App } from './App' createRoot(document.getElementById('root')!).render() diff --git a/apps/design-lab/src/pages/symbols/App.tsx b/apps/design-lab/src/pages/symbols/App.tsx index 97df6df..2ebfe26 100644 --- a/apps/design-lab/src/pages/symbols/App.tsx +++ b/apps/design-lab/src/pages/symbols/App.tsx @@ -1,7 +1,7 @@ import { Page, Section } from '../../lib/ui' -import { BrickDot, Cross, Mark, Ring, Tremble, Tri, Wave } from '../../components/shapes' +import { BrickDot, Cross, Mark, Ring, Tremble, Tri, Wave } from '@stack/design-system' import { CHAPTERS } from '../../lib/chapters' -import { Ticket } from '../../components/Ticket' +import { Ticket } from '@stack/design-system' import type { ReactNode } from 'react' const SHAPES = [ diff --git a/apps/design-lab/src/pages/symbols/main.tsx b/apps/design-lab/src/pages/symbols/main.tsx index 3dc54c8..7d878af 100644 --- a/apps/design-lab/src/pages/symbols/main.tsx +++ b/apps/design-lab/src/pages/symbols/main.tsx @@ -1,5 +1,5 @@ +import '@stack/design-system/styles/tokens.css' import { createRoot } from 'react-dom/client' -import '../../styles/tokens.css' import { App } from './App' createRoot(document.getElementById('root')!).render() diff --git a/apps/design-lab/src/styles/tokens.css b/apps/design-lab/src/styles/tokens.css deleted file mode 100644 index 76061f5..0000000 --- a/apps/design-lab/src/styles/tokens.css +++ /dev/null @@ -1,69 +0,0 @@ -@import 'tailwindcss'; - -/* ===== 设计系统 tokens(孟菲斯 × 拟物卡)===== */ -@theme { - /* 撞色对(鲜艳的全部来源,相邻必撞) */ - --color-clash1a: #ff6b6b; /* coral · ch1 */ - --color-clash1b: #4ecdc4; /* teal · ch4 */ - --color-clash2a: #ffd93d; /* mustard · ch3 */ - --color-clash2b: #a78bfa; /* violet · ch6 */ - --color-clash3a: #ff8fab; /* pink · ch2 */ - --color-clash3b: #5b8def; /* blue · ch5 */ - - /* 中性阶(纸的世界) */ - --color-ink: #111111; - --color-paper: #fff9f0; - --color-mute: #8a7a5c; - --color-body: #3d3d3d; - - /* 统一硬投影——唯一投影配方,所有基础组件复用(rest / 拿起) */ - --shadow-hard: 5px 5px 0 var(--color-ink); - --shadow-hard-lift: 8px 8px 0 var(--color-ink); - /* 同配方的 drop-shadow 形态:给挖空轮廓用(票券)——box-shadow 会被 mask 裁掉 */ - --drop-shadow-hard: 5px 5px 0 var(--color-ink); - - /* 章节色别名 */ - --color-ch1: #ff6b6b; - --color-ch2: #ff8fab; - --color-ch3: #ffd93d; - --color-ch4: #4ecdc4; - --color-ch5: #5b8def; - --color-ch6: #a78bfa; -} - -/* 票券打孔(完成票专用),三件分工: - * 挖空 = 票体 mask 差集(两端 1/4 圆硬停剪切,孔穿透露出页面底色); - * 阴影 = 外层 wrapper drop-shadow-hard——drop-shadow 跟随 alpha 轮廓, - * 孔挖到哪阴影咬到哪(box-shadow 会被 mask 的 border-box 裁切整体吃掉,不可用); - * 孔缘 = .pk-arc 半圆环补 ink 边线(mask 只减形不补线),圆心骑撕线两端。 */ -.tk-pk { - --hole-x: 172.75px; /* 撕线中心:216 − 右描边 2 − stub 42 + 撕线半宽 */ -} -.ticket-pk { - -webkit-mask-image: - radial-gradient(circle 7px at var(--hole-x) 0, transparent 6px, #000 6.5px), - radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px); - -webkit-mask-composite: source-in; - mask-image: - radial-gradient(circle 7px at var(--hole-x) 0, transparent 6px, #000 6.5px), - radial-gradient(circle 7px at var(--hole-x) 100%, transparent 6px, #000 6.5px); - mask-composite: intersect; -} -/* 孔缘弧线:16px 圆环(描边带 r6–8),clip-path 只留票内一半,不进阴影轮廓 */ -.pk-arc { - position: absolute; - left: calc(var(--hole-x) - 8px); - width: 16px; - height: 16px; - border: 2px solid var(--color-ink); - border-radius: 9999px; - pointer-events: none; -} -.pk-arc-t { - top: -8px; - clip-path: inset(50% 0 0 0); -} -.pk-arc-b { - bottom: -8px; - clip-path: inset(0 0 50% 0); -} diff --git a/apps/tutorial/package.json b/apps/tutorial/package.json index 392c980..9d13eee 100644 --- a/apps/tutorial/package.json +++ b/apps/tutorial/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@stack/design-system": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/apps/tutorial/src/components/ChapterPage.tsx b/apps/tutorial/src/components/ChapterPage.tsx index 69308fe..1cf7981 100644 --- a/apps/tutorial/src/components/ChapterPage.tsx +++ b/apps/tutorial/src/components/ChapterPage.tsx @@ -6,10 +6,8 @@ import type { CSSProperties } from 'react' import { Page, Section } from '../lib/ui' import { CHAPTERS, CONTENT } from '../lib/chapters' -import type { ChapterId } from '../lib/types' -import { AssignBlock } from './Ticket' -import { BrickDots } from './MemphisCard' -import { Mark, Ring, Tri, Wave } from './shapes' +import type { ChapterId } from '@stack/design-system' +import { AssignBlock, BrickDots, Mark, Ring, Tri, Wave } from '@stack/design-system' const ALL: ChapterId[] = [1, 2, 3, 4, 5, 6] diff --git a/apps/tutorial/src/components/EntryGuide.tsx b/apps/tutorial/src/components/EntryGuide.tsx deleted file mode 100644 index 3ef8846..0000000 --- a/apps/tutorial/src/components/EntryGuide.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** 入口梳理 —— 读者画像推荐路径:是推荐不是强制(无锁) */ -import { CHAPTERS, PROFILES } from '../lib/chapters' - -export function EntryGuide() { - return ( -
- {PROFILES.map((p) => ( -
-
- - {p.icon} - - {p.name} -
- -

{p.note}

-
- ))} -
- ) -} diff --git a/apps/tutorial/src/components/Scatter.tsx b/apps/tutorial/src/components/Scatter.tsx deleted file mode 100644 index bde9a9e..0000000 --- a/apps/tutorial/src/components/Scatter.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/** 散落形状摆放——「卡片附近空当 + 随机」: - * 网格采样容器空白 → 过滤出距内容盒([data-comp])≤ near 的空当(不胡乱撒)→ - * 种子随机挑位(彼此保距)+空当内随机偏移+随机旋转。 - * 种子固定 → 每次渲染一致(视觉基线可测);容器重排(ResizeObserver)自动重算。 */ -import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' - -function mulberry32(seed: number) { - let t = seed >>> 0 - return () => { - t = (t + 0x6d2b79f5) >>> 0 - let x = Math.imul(t ^ (t >>> 15), 1 | t) - x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x - return ((x ^ (x >>> 14)) >>> 0) / 4294967296 - } -} - -type Place = { x: number; y: number; rot: number } - -export function Scatter({ - shapes, - seed = 7, - near = 150, // 空当须距某内容盒 ≤ near——只散在卡片附近 - margin = 34, // 与内容盒保持的最小间隙(≥ 最大形状半对角) - step = 30, // 采样网格密度 - minGap = 80, // 形状彼此最小间距 -}: { - shapes: ReactNode[] - seed?: number - near?: number - margin?: number - step?: number - minGap?: number -}) { - const layerRef = useRef(null) - const [places, setPlaces] = useState([]) - - useLayoutEffect(() => { - const layer = layerRef.current - const host = layer?.parentElement - if (!layer || !host) return - - const place = () => { - const hb = host.getBoundingClientRect() - const rects = [...host.querySelectorAll('[data-comp]')].map((el) => el.getBoundingClientRect()) - if (!rects.length || !hb.width) return - const dist = (x: number, y: number, r: DOMRect) => - Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom)) - const cand: { x: number; y: number }[] = [] - for (let x = 36; x < hb.width - 36; x += step) - for (let y = 36; y < hb.height - 36; y += step) { - const px = hb.left + x - const py = hb.top + y - if (rects.every((r) => dist(px, py, r) >= margin) && rects.some((r) => dist(px, py, r) <= near)) - cand.push({ x, y }) - } - const rnd = mulberry32(seed) - for (let i = cand.length - 1; i > 0; i--) { - const j = Math.floor(rnd() * (i + 1)) - ;[cand[i], cand[j]] = [cand[j], cand[i]] - } - const taken: Place[] = [] - for (const c of cand) { - if (taken.length >= shapes.length) break - if (taken.some((t) => Math.hypot(t.x - c.x, t.y - c.y) < minGap)) continue - taken.push({ x: c.x + (rnd() - 0.5) * 14, y: c.y + (rnd() - 0.5) * 14, rot: Math.round((rnd() - 0.5) * 52) }) - } - setPlaces(taken) - } - - place() - const ro = new ResizeObserver(place) - ro.observe(host) - return () => ro.disconnect() - }, [seed, near, margin, step, minGap, shapes.length]) - - return ( -
- {places.map((p, i) => ( -
- {shapes[i]} -
- ))} -
- ) -} diff --git a/apps/tutorial/src/components/Ticket.tsx b/apps/tutorial/src/components/Ticket.tsx deleted file mode 100644 index dd11741..0000000 --- a/apps/tutorial/src/components/Ticket.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** 票券 —— 只表达完成状态:完成=沿撕线打孔(半圆缺口),未开始=实线无孔。 - * 外层 wrapper 出阴影(drop-shadow 跟随挖空后的轮廓,box-shadow 会被 mask 裁掉); - * 内层票体裁切式:头部色块无独立描边,由票体 overflow-hidden 裁出单一描边圆角。 */ -import type { Chapter } from '../lib/types' - -export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) { - return ( -
-
- {/* 头部媒体块:章节色纯填充,无独立边框(由票体裁切) */} -
{chapter.emoji}
-
- {chapter.title} -
- No. {String(chapter.id).padStart(3, '0')} · {done ? '已完成(打孔)' : '未开始(无孔)'} -
-
-
- №{String(chapter.id).padStart(2, '0')} -
-
- {/* 孔缘弧线:mask 只减形不补线,弧线补上挖空边缘的 ink 描边 */} - {done && ( - <> - - - - )} -
- ) -} - -/** 指派学习块 —— 普通内容块(非票据):+ 位置/预期/场景三样 + 脱靶标记 */ -export function AssignBlock({ - title, - pos, - expect, - scenes, - miss, -}: { - title: string - pos: string - expect: string - scenes: string[] - miss: { how: string; looks: string } -}) { - return ( -
-
- - - - - {title} -
-
-
- 位置:{pos} -
-
- 预期:{expect} -
-
- 场景:{scenes.join(' / ')} -
-
-
-
- - - -
- 脱靶 · {miss.how} -

{miss.looks}

-
-
-
-
- ) -} diff --git a/apps/tutorial/src/components/shapes.tsx b/apps/tutorial/src/components/shapes.tsx deleted file mode 100644 index 998abfb..0000000 --- a/apps/tutorial/src/components/shapes.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** 七个语义形状——装饰即语义(取自 ai-era-tutorial 词汇表) */ -import type { CSSProperties, ReactNode } from 'react' - -interface ShapeProps { - size?: number - className?: string - style?: CSSProperties -} - -/** ▲ 靶子:目标,卡头打头 */ -export const Tri = ({ size = 24, className, style }: ShapeProps) => ( - - - -) - -/** ● 积木:清单点列,实心=已学 */ -export const BrickDot = ({ size = 14, on = false, className, style }: ShapeProps & { on?: boolean }) => ( - - - -) - -/** ○ 预期:可核对的结果 */ -export const Ring = ({ size = 24, className, style }: ShapeProps) => ( - - - - -) - -/** ~ 代理区:横波,交给 agent */ -export const Wave = ({ size = 40, className, style }: ShapeProps) => ( - - - -) - -/** 荧光标记 价值区:色块骑在文字背后——是标记不是形状, - * 无描边、微斜、两端比字宽(荧光笔划过的手感),过程就地写清 */ -export const Mark = ({ children, className }: { children: ReactNode; className?: string }) => ( - - {children} - -) - -/** ⌇ 脱靶:竖立颤抖——图形入口,读者追随图形进入阅读 */ -export const Tremble = ({ size = 24, className, style }: ShapeProps) => ( - - - -) - -/** + 指派学习:位置/预期/场景三样 */ -export const Cross = ({ size = 18, className, style }: ShapeProps) => ( - - - - -) - -/** 脱靶标记:图形 + 文字,无容器(图形是第一视觉) */ -export function MissMark({ how, looks }: { how: string; looks: string }) { - return ( -
- -
- 脱靶 · {how} -

{looks}

-
-
- ) -} diff --git a/apps/tutorial/src/lib/chapters.ts b/apps/tutorial/src/lib/chapters.ts index 217bb08..50c5261 100644 --- a/apps/tutorial/src/lib/chapters.ts +++ b/apps/tutorial/src/lib/chapters.ts @@ -1,5 +1,5 @@ /** 教程内容数据——大纲见 OUTLINE.md,词汇与结构对齐 ai-era-tutorial skill */ -import type { Assignment, Chapter, ChapterId, Edge, Profile } from './types' +import type { Assignment, Chapter, ChapterId, Edge, Profile } from '@stack/design-system' export const CHAPTERS: Record = { 1: { @@ -432,7 +432,7 @@ export const CONTENT: Record = { } /** 指派学习块示例数据(设计系统·组件/样例页演示用) */ -export const ASSIGNMENT: import('./types').Assignment = { +export const ASSIGNMENT: import('@stack/design-system').Assignment = { title: '指派学习 · HTTP 协议', pos: 'Web 通信的地基,旁边是 DNS(找机器)与 TCP(铺线路)', expect: '能向 agent 讲清一次请求的往返:URL → DNS → 连接 → 请求行/头/体 → 响应 → 渲染', diff --git a/apps/tutorial/src/lib/types.ts b/apps/tutorial/src/lib/types.ts deleted file mode 100644 index 4b88f5f..0000000 --- a/apps/tutorial/src/lib/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** 设计系统数据类型——对齐 ai-era-tutorial 词汇表 */ - -/** 章程序号(票号=章程序号) */ -export type ChapterId = 1 | 2 | 3 | 4 | 5 | 6 - -/** 卡头三件套之一:靶子 = ▲目标 + ○预期结果 + ●积木清单 */ -export interface Target { - /** ▲ 目标:这一章做成什么(一句话) */ - goal: string - /** ○ 预期结果:做成长什么样、怎么核对(GIF 的 alt) */ - expectation: string - /** ● 积木清单:要用到的零件——只列用到的;实心数 = 已学 */ - bricks: { name: string; learned: boolean }[] -} - -/** 章节数据(MemphisCard / Ticket 共用) */ -export interface Chapter { - id: ChapterId - title: string - emoji: string - color: string // 章节色(只进 16:9 相框与图标瓦) - target: Target - /** 预期结果 GIF(16:9 无声循环);缺省用静态占位 */ - gifUrl?: string -} - -/** 关系边——线形即含义 */ -export type EdgeKind = 'seq' | 'rel' | 'family' -/** seq 实线=先后(唯一带站点);rel 虚线=相关;family 点线=同族 */ - -export interface Edge { - from: ChapterId - to: ChapterId - kind: EdgeKind -} - -/** 入口画像(推荐路径,非强制——无锁) */ -export interface Profile { - icon: string - name: string - entry: ChapterId // ①②③ 推荐起点 - path: ChapterId[] - note: string -} - -/** 指派学习块三样:位置给坐标、预期当尺子、场景管巩固 */ -export interface Assignment { - title: string - pos: string - expect: string - scenes: string[] - /** 脱靶标记:可能性 + 长相(撞上时认出缺哪块) */ - miss: { how: string; looks: string } -} diff --git a/apps/tutorial/src/lib/ui.tsx b/apps/tutorial/src/lib/ui.tsx index cc3cc41..9fb46fa 100644 --- a/apps/tutorial/src/lib/ui.tsx +++ b/apps/tutorial/src/lib/ui.tsx @@ -1,9 +1,8 @@ /** 教程站页面骨架:站头导航跨页常驻(view-transition-name 共享元素,页间不闪烁)。 * 选中项背景色=该章色(与章头 title 横幅统一);书架/设计册选中为 ink。 */ import type { ReactNode } from 'react' -import type { ChapterId } from './types' +import type { ChapterId } from '@stack/design-system' import { CHAPTERS } from './chapters' -import '../styles/tokens.css' /** 设计系统四册(自 design-lab 迁入) */ export type DesignKey = 'design-colors' | 'design-typography' | 'design-symbols' | 'design-components' | 'design-examples' diff --git a/apps/tutorial/src/pages/design-components/App.tsx b/apps/tutorial/src/pages/design-components/App.tsx index fc0cfbe..8db8e40 100644 --- a/apps/tutorial/src/pages/design-components/App.tsx +++ b/apps/tutorial/src/pages/design-components/App.tsx @@ -1,9 +1,9 @@ import { Page, Section } from '../../lib/ui' -import { ASSIGNMENT, CHAPTERS } from '../../lib/chapters' -import { MemphisCard } from '../../components/MemphisCard' -import { AssignBlock, Ticket } from '../../components/Ticket' -import { EntryGuide } from '../../components/EntryGuide' -import { Mark } from '../../components/shapes' +import { ASSIGNMENT, CHAPTERS, PROFILES } from '../../lib/chapters' +import { MemphisCard } from '@stack/design-system' +import { AssignBlock, Ticket } from '@stack/design-system' +import { EntryGuide } from '@stack/design-system' +import { Mark } from '@stack/design-system' export function App() { return ( @@ -29,7 +29,7 @@ export function App() {
- +
) diff --git a/apps/tutorial/src/pages/design-examples/App.tsx b/apps/tutorial/src/pages/design-examples/App.tsx index 5c04bea..6a471d8 100644 --- a/apps/tutorial/src/pages/design-examples/App.tsx +++ b/apps/tutorial/src/pages/design-examples/App.tsx @@ -1,13 +1,9 @@ /** 组合场景=大部分组件拿出来的真实使用,不做解释性标注: * 入口画像 → 课程卡与关系线 → 进度票券 → 指派块;形状散落于卡片附近空当。 */ import { Page } from '../../lib/ui' -import { ASSIGNMENT, CHAPTERS, EDGES } from '../../lib/chapters' -import { MemphisCard } from '../../components/MemphisCard' -import { RouteLine } from '../../components/RouteLine' -import { AssignBlock, Ticket } from '../../components/Ticket' -import { EntryGuide } from '../../components/EntryGuide' -import { Scatter } from '../../components/Scatter' -import { Cross, Ring, Tremble, Tri, Wave } from '../../components/shapes' +import { ASSIGNMENT, CHAPTERS, EDGES, PROFILES } from '../../lib/chapters' +import { AssignBlock, EntryGuide, MemphisCard, RouteLine, Scatter, Ticket } from '@stack/design-system' +import { Cross, Ring, Tremble, Tri, Wave } from '@stack/design-system' /* 场景坐标(节点=卡描边真实边缘):卡1(40,110,250) 卡2(380,44,250,rot) 卡3(700,44,140) */ const nodes = { @@ -19,7 +15,7 @@ const nodes = { export function App() { return ( - +
- +
{GRID.map(({ id, entry }) => ( - + ))}
@@ -61,21 +58,21 @@ export function App() { onMouseEnter={() => setHovered(1)} onMouseLeave={() => setHovered(null)} > - +
setHovered(2)} onMouseLeave={() => setHovered(null)} > - +
setHovered(5)} onMouseLeave={() => setHovered(null)} > - +
diff --git a/packages/design-system/README.md b/packages/design-system/README.md new file mode 100644 index 0000000..7481c32 --- /dev/null +++ b/packages/design-system/README.md @@ -0,0 +1,5 @@ +# @stack/design-system + +孟菲斯 × 拟物卡设计系统的唯一定义处:组件(MemphisCard/RouteLine/Ticket/AssignBlock/EntryGuide/Scatter/shapes)、tokens(颜色/字体/投影/票券打孔)、类型。 + +`apps/design-lab`(规格实验场)与 `apps/tutorial`(教程站)共同引用本包——特性只改这里,双端同步获得。消费方式:workspace 依赖 + 源码直出(vite 编译 TS,无需构建步骤)。 diff --git a/packages/design-system/package.json b/packages/design-system/package.json new file mode 100644 index 0000000..8a6ac4e --- /dev/null +++ b/packages/design-system/package.json @@ -0,0 +1,11 @@ +{ + "name": "@stack/design-system", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./styles/tokens.css": "./src/styles/tokens.css" + } +} \ No newline at end of file diff --git a/packages/design-system/src/components/EntryGuide.tsx b/packages/design-system/src/components/EntryGuide.tsx new file mode 100644 index 0000000..f69fac5 --- /dev/null +++ b/packages/design-system/src/components/EntryGuide.tsx @@ -0,0 +1,54 @@ +/** 入口梳理 —— 读者画像推荐路径:是推荐不是强制(无锁)。 + * 章节数据与画像由宿主注入(design-lab 演示数据 / tutorial 真实数据)。 */ +import type { Chapter, ChapterId, Profile } from '../lib/types' + +export function EntryGuide({ + chapters, + profiles, + linkChapter = false, +}: { + chapters: Record + profiles: Profile[] + /** 路径 chip 是否链接章节页(教程站 true) */ + linkChapter?: boolean +}) { + return ( +
+ {profiles.map((p) => ( +
+
+ + {p.icon} + + {p.name} +
+
+ {p.path.map((id, i) => ( + + {i > 0 && →} + {linkChapter ? ( + + {id === p.entry && ['①', '②', '③'][profiles.indexOf(p)]} {chapters[id].title} + + ) : ( + + {id === p.entry && ['①', '②', '③'][profiles.indexOf(p)]} {chapters[id].title} + + )} + + ))} +
+

{p.note}

+
+ ))} +
+ ) +} diff --git a/apps/tutorial/src/components/MemphisCard.tsx b/packages/design-system/src/components/MemphisCard.tsx similarity index 93% rename from apps/tutorial/src/components/MemphisCard.tsx rename to packages/design-system/src/components/MemphisCard.tsx index 1d0a1a6..9a3c2b3 100644 --- a/apps/tutorial/src/components/MemphisCard.tsx +++ b/packages/design-system/src/components/MemphisCard.tsx @@ -38,12 +38,18 @@ export function MemphisCard({ entry, compact = false, zoneText, + href, + morph = false, }: { chapter: Chapter entry?: 1 | 2 | 3 compact?: boolean /** 静置态相框文字(可含 荧光标记):教程卡片的价值区用法示例 */ zoneText?: ReactNode + /** 卡片跳转目标:缺省锚点 #ch(单页演示);整页跳转传 /ch.html */ + href?: string + /** 挂 view-transition-name(跨文档共享元素变形)。同页同章多卡时勿开(重名失效) */ + morph?: boolean }) { const [gifOn, setGifOn] = useState(false) const [locked, setLocked] = useState(false) @@ -78,7 +84,7 @@ export function MemphisCard({ return (