66 * 而签名动效 #4 要「生长 / 回缩」,前提是先有一个**可补间的几何量**(刻度长度)。
77 * 本件就是那个承载面:长度 = 后端计数;逐段长度 = 单卡间隔。形态是 **div 条阵列**
88 * (不是内联 svg —— `ui/icons/no-inline-svg.test.ts` 明禁新增内联 svg)。
9- * 🔴 交付边界:本件**只建静态形态**(承载面 + 数据接线 + 判据)。生长 / 回缩动效归
10- * **T30**(波 C)—— 本件交付时刻度是**静止的**,不许把 #4 说成已交付。
9+ * 🔴 交付边界(T30 已更新本条):本件仍是**静态承载面** —— 段数 / 段高只由 `due` 与
10+ * `intervals` 决定(零 GSAP、零 effect);「生长 / 回缩」由**调用方**经 `growTo` + `growRef`
11+ * 注入(编排在 `useScaleGrowth`,唯一 timeline),本件只提供落点(首段)与基调声明。
1112 * @ai -context: 数据真源(各一,且只此两处):① 段数 = `count_due_cards` 的计数(`due` prop,由调用点
1213 * 透传 —— 本件**不发 IPC、不二次查询**,全件零 `@tauri-apps` 依赖);② 逐段间隔 =
1314 * `Flashcard.intervalDays`(`intervals` prop)。🔴 **禁止自造间隔**:本件不解析
2324 * 是 R4.5 琥珀族的**唯一**来源 —— 本件不写任何琥珀字面量,段色取 `currentColor` 继承)。
2425 * 基类 `ed-surface` 与它同元素(同 `ed-text--low-confidence` 的用法);本件**不**用
2526 * `<Surface>` 原语,因为新调用点要登记进 `surfaceResidual.ts`(本任务禁碰棘轮表)。
26- * 副作用:**无**(纯展示:不读 store、不发 IPC、不写盘、无定时器、无 effect、无 GSAP)。
27+ * 副作用:**无**(纯展示:不读 store、不发 IPC、不写盘、无定时器、无 effect、无 GSAP —— 即便 `growTo`
28+ * 已给:动画由调用方的 hook 经 `growRef` 写 `transform`,本件自身不碰任何动效 API)。
2729 * 边界:① `due === 0` ⇒ 渲染**空刻度**(不落空态文案 —— 空态归调用点 `ReviewPage`);
2830 * ② `intervals[i] <= 0`(新卡 / 无复习记录)⇒ 画「无间隔」档,**不是** 0 长度(0 长度会让
2931 * 「新卡」与「这段不存在」不可分);③ `intervals` 短于 `due`(后端队列上限 200 < 全量到期数)
3032 * ⇒ 缺位段同样走「无间隔」档:「不在本轮队列里」≠「间隔为 0」,本件不替后端猜;
3133 * ④ 段数多时靠 flex 压缩与裁切,**不省略 DOM 段** —— 段数恒等于 `due`(那正是判据 V1)。
3234 * ⑤ 零裸 `<button>`(纯展示;`components/**` 在 `nativeButton` 棘轮域内)。
3335 */
34- import type { CSSProperties , ReactElement } from "react" ;
36+ import type { CSSProperties , ReactElement , Ref } from "react" ;
3537
3638export interface DueScaleProps {
3739 /** 到期卡张数(来自 count_due_cards;**唯一**的"有多少"真源) */
@@ -44,13 +46,22 @@ export interface DueScaleProps {
4446 * `review_card` 返回体的**精确值**(PB2 ①域)。两个域**不得**互相冒充。
4547 */
4648 readonly granularity ?: "day" | "exact" ;
49+ /**
50+ * **生长目标**(归一长度 0..1,T30/§8.6 #4)—— **受控**:由调用方从 `review_card` 的**精确**
51+ * `intervalDays` 换算(`useScaleGrowth` 的 `intervalToScale`)。`null` / 缺省 ⇒ **不生长**:
52+ * 刻度静止在 T21 的静态形态(本件**不**自造目标、不读 IPC)。
53+ */
54+ readonly growTo ?: number | null ;
55+ /** 生长刻度的元素句柄(`useScaleGrowth` 的 `ref`)—— 被动画的**只有首段**(本轮在评的那张卡)。 */
56+ readonly growRef ?: Ref < HTMLSpanElement > ;
4757}
4858
4959/** 「无间隔」档的段高(px)—— 新卡 / 间隔 0 / 不在本轮队列内。**视觉常量,不是数据真源**。 */
50- const TICK_NONE_PX = 6 ;
60+ export const TICK_NONE_PX = 6 ;
5161/** 按天刻度的段高区间:1 天 ⇒ 8px,`TICK_DAY_CAP` 天及以上 ⇒ 28px(单调不减 ⇒ 间隔越长刻度越长)。 */
5262const TICK_DAY_MIN_PX = 8 ;
53- const TICK_DAY_MAX_PX = 28 ;
63+ /** 满刻度段高(px)—— **归一长度的分母**(`useScaleGrowth` 的 `intervalToScale` 复用同一个数)。 */
64+ export const TICK_DAY_MAX_PX = 28 ;
5465/** 封顶天数:再长的间隔不再变高 —— 刻度要的是「可感觉」,不是「可测量」(不发明数字)。 */
5566const TICK_DAY_CAP = 30 ;
5667
@@ -59,8 +70,11 @@ function tickTier(days: number | undefined): "none" | "day" {
5970 return days === undefined || ! Number . isFinite ( days ) || days <= 0 ? "none" : "day" ;
6071}
6172
62- /** 一段刻度的**视觉长度**(px)。`days <= 0` 或未知 ⇒ 「无间隔」档的定长(**不是** 0 长度)。 */
63- function tickHeight ( days : number | undefined ) : number {
73+ /**
74+ * 一段刻度的**视觉长度**(px)。`days <= 0` 或未知 ⇒ 「无间隔」档的定长(**不是** 0 长度)。
75+ * **导出**给 `useScaleGrowth`:生长必须复用这**同一个**单调映射(两处各写一份必然会漂)。
76+ */
77+ export function tickHeight ( days : number | undefined ) : number {
6478 if ( tickTier ( days ) === "none" ) return TICK_NONE_PX ;
6579 const capped = Math . min ( days as number , TICK_DAY_CAP ) ;
6680 return Math . round ( TICK_DAY_MIN_PX + ( ( capped - 1 ) / ( TICK_DAY_CAP - 1 ) ) * ( TICK_DAY_MAX_PX - TICK_DAY_MIN_PX ) ) ;
@@ -78,7 +92,7 @@ function tickLabel(index: number, days: number | undefined, granularity: "day" |
7892 *
7993 * 刻度底轨的琥珀来自 `ed-surface--due-glow` 的 `color: var(--ed-due)`(R4.5 的唯一合法来源)。
8094 */
81- export default function DueScale ( { due, intervals, testId, granularity = "day" } : DueScaleProps ) : ReactElement {
95+ export default function DueScale ( { due, intervals, testId, granularity = "day" , growTo = null , growRef } : DueScaleProps ) : ReactElement {
8296 // 段数 = 计数(防御:非有限值 / 负数 / 小数一律按 0 段或截断处理 —— 不 panic、不造幽灵段)
8397 const count = Number . isFinite ( due ) ? Math . max ( 0 , Math . trunc ( due ) ) : 0 ;
8498 const queueMarks = Math . min ( intervals ?. length ?? 0 , count ) ;
@@ -93,19 +107,34 @@ export default function DueScale({ due, intervals, testId, granularity = "day" }
93107 flex : "1 1 0" , minWidth : 1 , maxWidth : 5 , background : "currentColor" , borderRadius : 1 ,
94108 } ;
95109 return (
96- < div data-testid = { testId } data-due = { count } data-granularity = { granularity } title = { label } role = "img" aria-label = { label } >
110+ < div
111+ data-testid = { testId }
112+ data-due = { count }
113+ data-granularity = { granularity }
114+ data-grow-to = { growTo === null ? undefined : String ( growTo ) }
115+ title = { label }
116+ role = "img"
117+ aria-label = { label }
118+ >
97119 { count > 0 && (
98120 < div className = "ed-surface ed-surface--sunken ed-surface--due-glow" style = { lane } >
99121 { Array . from ( { length : count } , ( _ , i ) => {
100122 const days = intervals ?. [ i ] ;
123+ // 生长落点 = **首段**(当前卡的那一段):它声明基调(R3.4:#4 = 读数面 ⇒ instrument)并接住
124+ // 句柄;`transformOrigin: bottom` 是**静态**样式(不参与动画 —— 否则属性集合会多出
125+ // `transform-origin`,越出 R8.4 的合成属性白名单)。
126+ const grows = i === 0 && growTo !== null ;
101127 return (
102128 < span
103129 key = { i }
130+ ref = { grows ? growRef : undefined }
104131 data-tick = { i }
105132 data-tier = { tickTier ( days ) }
133+ data-grow = { grows ? "1" : undefined }
134+ data-tone = { grows ? "instrument" : undefined }
106135 data-interval = { days === undefined ? undefined : String ( days ) }
107136 title = { tickLabel ( i , days , granularity ) }
108- style = { { ...tick , height : tickHeight ( days ) } }
137+ style = { { ...tick , height : tickHeight ( days ) , transformOrigin : grows ? "bottom" : undefined } }
109138 />
110139 ) ;
111140 } ) }
0 commit comments