@icen.ai/ui 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +2 -2
  2. package/dist/base.css +11 -6
  3. package/dist/behaviors/ai-branch.mjs +13 -0
  4. package/dist/behaviors/ai-chat.mjs +13 -0
  5. package/dist/behaviors/ai-composer.mjs +13 -0
  6. package/dist/behaviors/ai-core.mjs +13 -0
  7. package/dist/behaviors/ai-diff.mjs +13 -0
  8. package/dist/behaviors/ai-feedback.mjs +13 -0
  9. package/dist/behaviors/ai-panel.mjs +13 -0
  10. package/dist/behaviors/ai-provider.mjs +13 -0
  11. package/dist/behaviors/ai-threads.mjs +13 -0
  12. package/dist/behaviors/ai-tool.mjs +242 -0
  13. package/dist/behaviors/ai-tools.mjs +242 -0
  14. package/dist/behaviors/autocomplete.mjs +13 -0
  15. package/dist/behaviors/back-top.mjs +13 -0
  16. package/dist/behaviors/carousel.mjs +13 -0
  17. package/dist/behaviors/cascader.mjs +13 -0
  18. package/dist/behaviors/charts.d.mts +12 -1
  19. package/dist/behaviors/charts.mjs +242 -0
  20. package/dist/behaviors/code-block.mjs +13 -0
  21. package/dist/behaviors/context-menu.mjs +13 -0
  22. package/dist/behaviors/controls.mjs +13 -0
  23. package/dist/behaviors/copy.mjs +13 -0
  24. package/dist/behaviors/datatable.mjs +13 -0
  25. package/dist/behaviors/date-picker.mjs +13 -0
  26. package/dist/behaviors/form-validate.mjs +13 -0
  27. package/dist/behaviors/i18n.d.mts +13 -0
  28. package/dist/behaviors/i18n.mjs +26 -0
  29. package/dist/behaviors/input.mjs +13 -0
  30. package/dist/behaviors/kb-access.mjs +13 -0
  31. package/dist/behaviors/kb-acl.mjs +13 -0
  32. package/dist/behaviors/kb-answer.mjs +242 -0
  33. package/dist/behaviors/kb-audit.mjs +13 -0
  34. package/dist/behaviors/kb-canvas.mjs +13 -0
  35. package/dist/behaviors/kb-chain.mjs +13 -0
  36. package/dist/behaviors/kb-checkpoint.mjs +13 -0
  37. package/dist/behaviors/kb-chunks.mjs +13 -0
  38. package/dist/behaviors/kb-citation.mjs +13 -0
  39. package/dist/behaviors/kb-clarify.mjs +13 -0
  40. package/dist/behaviors/kb-conflict.mjs +13 -0
  41. package/dist/behaviors/kb-connector.mjs +13 -0
  42. package/dist/behaviors/kb-core.mjs +13 -0
  43. package/dist/behaviors/kb-eval.mjs +13 -0
  44. package/dist/behaviors/kb-explain.mjs +13 -0
  45. package/dist/behaviors/kb-filter.mjs +13 -0
  46. package/dist/behaviors/kb-gap.mjs +13 -0
  47. package/dist/behaviors/kb-hittest.mjs +13 -0
  48. package/dist/behaviors/kb-metadata.mjs +13 -0
  49. package/dist/behaviors/kb-passage.mjs +13 -0
  50. package/dist/behaviors/kb-pipeline.mjs +13 -0
  51. package/dist/behaviors/kb-qa.mjs +13 -0
  52. package/dist/behaviors/kb-rerank.mjs +13 -0
  53. package/dist/behaviors/kb-retrieval.mjs +13 -0
  54. package/dist/behaviors/kb-review.mjs +13 -0
  55. package/dist/behaviors/kb-sandbox.mjs +13 -0
  56. package/dist/behaviors/kb-segment.mjs +13 -0
  57. package/dist/behaviors/kb-select.mjs +13 -0
  58. package/dist/behaviors/kb-sources.mjs +13 -0
  59. package/dist/behaviors/kb-sql.mjs +13 -0
  60. package/dist/behaviors/kb-trace.mjs +13 -0
  61. package/dist/behaviors/kb-visibility.mjs +13 -0
  62. package/dist/behaviors/kb-who-can.mjs +13 -0
  63. package/dist/behaviors/mentions.mjs +13 -0
  64. package/dist/behaviors/modal.mjs +13 -0
  65. package/dist/behaviors/motion.d.mts +16 -0
  66. package/dist/behaviors/motion.mjs +39 -0
  67. package/dist/behaviors/notification.mjs +13 -0
  68. package/dist/behaviors/panel.mjs +13 -0
  69. package/dist/behaviors/select.mjs +13 -0
  70. package/dist/behaviors/suggestions.mjs +13 -0
  71. package/dist/behaviors/tag-input.mjs +13 -0
  72. package/dist/behaviors/theme-switcher.mjs +13 -0
  73. package/dist/behaviors/toast.mjs +13 -0
  74. package/dist/behaviors/tree-select.mjs +13 -0
  75. package/dist/behaviors/upload.mjs +13 -0
  76. package/dist/components/charts.css +29 -0
  77. package/dist/components/material.css +1 -1
  78. package/dist/index.d.mts +1 -1
  79. package/dist/index.mjs +257 -1
  80. package/dist/react.mjs +242 -0
  81. package/dist/registry.json +3 -1
  82. package/dist/tokens/design.css +15 -9
  83. package/dist/tokens/dtcg.json +3594 -738
  84. package/dist/tokens/figma.variables.json +1705 -157
  85. package/dist/tokens/motion.css +51 -0
  86. package/dist/tokens/pixel.css +7 -0
  87. package/dist/tokens/tokens-studio.json +3007 -679
  88. package/dist/tokens/typography.css +33 -0
  89. package/dist/tokens.css +107 -10
  90. package/dist/ui.css +147 -16
  91. package/dist/vue.mjs +242 -0
  92. package/docs/spec/charts-scale-a11y.md +115 -0
  93. package/docs/spec/motion-typography.md +105 -0
  94. package/package.json +2 -1
package/dist/vue.mjs CHANGED
@@ -351,6 +351,19 @@ var ICEN_LOCALE_ZH_CN = {
351
351
  "charts.noData": "\u6682\u65E0\u6570\u636E",
352
352
  "charts.resetZoom": "\u91CD\u7F6E\u7F29\u653E",
353
353
  "charts.sampled": "\u5DF2\u62BD\u6837 {p0}\u2192{p1} \u70B9",
354
+ "charts.a11y.keyboardHint": "\u65B9\u5411\u952E\u9010\u70B9\u6D4F\u89C8\uFF0CHome/End \u8DF3\u9996\u5C3E\uFF0CEsc \u91CD\u7F6E\u7F29\u653E",
355
+ "charts.a11y.max": "\u6700\u5927 {p0}\uFF08{p1}\uFF09",
356
+ "charts.a11y.min": "\u6700\u5C0F {p0}\uFF08{p1}\uFF09",
357
+ "charts.a11y.point": "{p0}\uFF1A{p1}\uFF0C\u7B2C {p2}/{p3} \u70B9",
358
+ "charts.a11y.pointSeries": "{p0} \xB7 {p1}\uFF1A{p2}\uFF0C\u7B2C {p3}/{p4} \u70B9",
359
+ "charts.a11y.range": "\u7C7B\u76EE {p0} \u81F3 {p1}",
360
+ "charts.a11y.scatterRange": "x \u8303\u56F4 {p0}\u2013{p1}\uFF0Cy \u8303\u56F4 {p2}\u2013{p3}",
361
+ "charts.a11y.seriesCount": "{p0} \u4E2A\u7CFB\u5217",
362
+ "charts.a11y.tableCaption": "{p0} \u6570\u636E\u8868",
363
+ "charts.a11y.trend": "\u9996\u7CFB\u5217\u8D8B\u52BF{p0}",
364
+ "charts.a11y.trendDown": "\u4E0B\u964D",
365
+ "charts.a11y.trendFlat": "\u6301\u5E73",
366
+ "charts.a11y.trendUp": "\u4E0A\u5347",
354
367
  "charts.type.area": "\u9762\u79EF\u56FE",
355
368
  "charts.type.donut": "\u73AF\u5F62\u56FE",
356
369
  "charts.type.gauge": "\u4EEA\u8868\u76D8",
@@ -2243,6 +2256,14 @@ function normalizeChartSpec(raw) {
2243
2256
  }
2244
2257
  if (s.zoom === "x" || s.zoom === "brush") spec.zoom = s.zoom;
2245
2258
  if (typeof s.loading === "boolean") spec.loading = s.loading;
2259
+ if (s.a11y && typeof s.a11y === "object") {
2260
+ const a = s.a11y;
2261
+ const a11y = {};
2262
+ if (typeof a.summary === "boolean") a11y.summary = a.summary;
2263
+ if (typeof a.table === "boolean") a11y.table = a.table;
2264
+ if (typeof a.keyboard === "boolean") a11y.keyboard = a.keyboard;
2265
+ if (Object.keys(a11y).length > 0) spec.a11y = a11y;
2266
+ }
2246
2267
  if (Array.isArray(s.marks)) {
2247
2268
  const marks = [];
2248
2269
  for (const raw2 of s.marks) {
@@ -2598,6 +2619,81 @@ function paintVBarChartMarks(root, view, spec, marks, formatValue, warned) {
2598
2619
  function chartAriaLabel(title, typeName) {
2599
2620
  return title ? `${typeName}\uFF1A${title}` : typeName;
2600
2621
  }
2622
+ function summarizeChart(view, type, fmt2) {
2623
+ const series = view.series && view.series.length > 0 ? view.series : [{ name: "", values: view.values ?? [] }];
2624
+ const parts = [];
2625
+ if (series.length > 1) parts.push(t("charts.a11y.seriesCount", { p0: series.length }));
2626
+ if (type === "scatter") {
2627
+ const pts = view.points ?? [];
2628
+ if (pts.length > 0) {
2629
+ const xs = pts.map((p) => p.x);
2630
+ const ys = pts.map((p) => p.y);
2631
+ parts.push(t("charts.a11y.scatterRange", { p0: fmt2(Math.min(...xs)), p1: fmt2(Math.max(...xs)), p2: fmt2(Math.min(...ys)), p3: fmt2(Math.max(...ys)) }));
2632
+ }
2633
+ return parts.join("\uFF1B");
2634
+ }
2635
+ const labels = view.labels ?? [];
2636
+ if (labels.length > 1) parts.push(t("charts.a11y.range", { p0: labels[0], p1: labels[labels.length - 1] }));
2637
+ let min = Infinity;
2638
+ let max = -Infinity;
2639
+ let minAt = "";
2640
+ let maxAt = "";
2641
+ for (const s of series) {
2642
+ s.values.forEach((v, i) => {
2643
+ if (v < min) {
2644
+ min = v;
2645
+ minAt = labels[i] ?? String(i + 1);
2646
+ }
2647
+ if (v > max) {
2648
+ max = v;
2649
+ maxAt = labels[i] ?? String(i + 1);
2650
+ }
2651
+ });
2652
+ }
2653
+ if (Number.isFinite(min) && Number.isFinite(max)) {
2654
+ parts.push(t("charts.a11y.min", { p0: fmt2(min), p1: minAt }));
2655
+ parts.push(t("charts.a11y.max", { p0: fmt2(max), p1: maxAt }));
2656
+ }
2657
+ const first = series[0]?.values ?? [];
2658
+ if (first.length > 1) {
2659
+ const d = first[first.length - 1] - first[0];
2660
+ const key = Math.abs(d) < 1e-9 ? "charts.a11y.trendFlat" : d > 0 ? "charts.a11y.trendUp" : "charts.a11y.trendDown";
2661
+ parts.push(t("charts.a11y.trend", { p0: t(key) }));
2662
+ }
2663
+ return parts.join("\uFF1B");
2664
+ }
2665
+ function buildChartTable(view, spec, type, fmt2) {
2666
+ const table = document.createElement("table");
2667
+ table.className = "icen-chart-table";
2668
+ const caption = document.createElement("caption");
2669
+ caption.textContent = t("charts.a11y.tableCaption", { p0: spec.title ?? String(type) });
2670
+ table.appendChild(caption);
2671
+ const addRow = (target, cells, head) => {
2672
+ const tr = document.createElement("tr");
2673
+ for (const c of cells) {
2674
+ const cell = document.createElement(head ? "th" : "td");
2675
+ if (head) cell.scope = target.tagName === "THEAD" ? "col" : "row";
2676
+ cell.textContent = c;
2677
+ tr.appendChild(cell);
2678
+ }
2679
+ target.appendChild(tr);
2680
+ };
2681
+ const thead = table.createTHead();
2682
+ const tbody = table.createTBody();
2683
+ if (type === "scatter") {
2684
+ addRow(thead, ["x", "y"], true);
2685
+ for (const p of view.points ?? []) addRow(tbody, [fmt2(p.x), fmt2(p.y)], false);
2686
+ return table;
2687
+ }
2688
+ const series = view.series && view.series.length > 0 ? view.series : [{ name: "", values: view.values ?? [] }];
2689
+ const labels = view.labels ?? [];
2690
+ addRow(thead, ["", ...series.map((s, i) => s.name || String(i + 1))], true);
2691
+ const n = Math.max(labels.length, ...series.map((s) => s.values.length));
2692
+ for (let i = 0; i < n; i++) {
2693
+ addRow(tbody, [labels[i] ?? String(i + 1), ...series.map((s) => fmt2(s.values[i] ?? 0))], false);
2694
+ }
2695
+ return table;
2696
+ }
2601
2697
  function svgDimsOf(svg) {
2602
2698
  const vb = (svg.getAttribute("viewBox") ?? "").split(/\s+/).map(Number);
2603
2699
  const w = Number.isFinite(vb[2]) && (vb[2] ?? 0) > 0 ? vb[2] : CHART_LINE_W;
@@ -2938,6 +3034,7 @@ function renderChart(el, raw) {
2938
3034
  if (!isZoomableType(t2) || fullLength() < 2) return;
2939
3035
  const plot = t2 === "vbar" ? root.querySelector(".icen-chart-vbar") : root.querySelector(".icen-chart-line svg");
2940
3036
  if (!plot) return;
3037
+ plot.style.touchAction = current2.zoom === "x" ? "pan-y" : "none";
2941
3038
  const on = (type, fn) => {
2942
3039
  plot.addEventListener(type, fn);
2943
3040
  };
@@ -2993,6 +3090,149 @@ function renderChart(el, raw) {
2993
3090
  render();
2994
3091
  });
2995
3092
  };
3093
+ const KB_TYPES = /* @__PURE__ */ new Set(["line", "area", "vbar", "hbar", "scatter"]);
3094
+ const kbCursor = { si: 0, i: 0, active: false };
3095
+ let liveRegion = null;
3096
+ let a11ySeq = 0;
3097
+ const a11yOf = () => ({
3098
+ summary: current2.a11y?.summary !== false,
3099
+ table: current2.a11y?.table === true,
3100
+ keyboard: current2.a11y?.keyboard !== false
3101
+ });
3102
+ const seriesViewOf = (view) => view.series && view.series.length > 0 ? view.series : [{ name: "", values: view.values ?? [] }];
3103
+ const kbPointCount = (view) => typeOf() === "scatter" ? view.points?.length ?? 0 : Math.max(0, ...seriesViewOf(view).map((s) => s.values.length), view.labels?.length ?? 0);
3104
+ const kbCursorEl = () => {
3105
+ const t2 = typeOf();
3106
+ const view = viewOf();
3107
+ const series = seriesViewOf(view);
3108
+ const n = kbPointCount(view);
3109
+ if (n < 1) return null;
3110
+ const si = clampNum(kbCursor.si, 0, Math.max(0, series.length - 1));
3111
+ const i = clampNum(kbCursor.i, 0, n - 1);
3112
+ if (t2 === "line" || t2 === "area") {
3113
+ const svg = root.querySelector(".icen-chart-line svg");
3114
+ if (!svg) return null;
3115
+ const dots = svg.querySelectorAll("circle");
3116
+ let base = 0;
3117
+ for (let k = 0; k < si; k++) base += Math.min(series[k].values.length, n);
3118
+ return dots.item(base + i) ?? null;
3119
+ }
3120
+ if (t2 === "scatter") return root.querySelector(".icen-chart-scatter svg")?.querySelectorAll("circle").item(i) ?? null;
3121
+ if (t2 === "vbar") return root.querySelectorAll(".icen-chart-vbar-bar").item(series.length > 1 ? i * series.length + si : i) ?? null;
3122
+ if (t2 === "hbar") return root.querySelectorAll(".icen-chart-hbar-bar").item(i) ?? null;
3123
+ return null;
3124
+ };
3125
+ const announceKb = (text) => {
3126
+ if (!liveRegion || !liveRegion.isConnected) {
3127
+ liveRegion = document.createElement("div");
3128
+ liveRegion.className = "icen-chart-live";
3129
+ liveRegion.setAttribute("aria-live", "polite");
3130
+ root.appendChild(liveRegion);
3131
+ }
3132
+ liveRegion.textContent = "";
3133
+ liveRegion.textContent = text;
3134
+ };
3135
+ const applyKbCursor = () => {
3136
+ for (const el2 of root.querySelectorAll(".icen-chart-kb-focus")) el2.classList.remove("icen-chart-kb-focus");
3137
+ if (!kbCursor.active) return;
3138
+ kbCursorEl()?.classList.add("icen-chart-kb-focus");
3139
+ };
3140
+ const announceCursor = () => {
3141
+ const view = viewOf();
3142
+ const series = seriesViewOf(view);
3143
+ const n = kbPointCount(view);
3144
+ if (n < 1) return;
3145
+ const si = clampNum(kbCursor.si, 0, Math.max(0, series.length - 1));
3146
+ const i = clampNum(kbCursor.i, 0, Math.max(0, n - 1));
3147
+ const fmt2 = chartFormatValue(current2.format);
3148
+ if (typeOf() === "scatter") {
3149
+ const p = view.points?.[i];
3150
+ if (!p) return;
3151
+ announceKb(t("charts.a11y.point", { p0: `x ${fmt2(p.x)}`, p1: `y ${fmt2(p.y)}`, p2: i + 1, p3: n }));
3152
+ return;
3153
+ }
3154
+ const label = view.labels?.[i] ?? String(i + 1);
3155
+ const value = series[si]?.values[i] ?? 0;
3156
+ if (series.length > 1) announceKb(t("charts.a11y.pointSeries", { p0: series[si].name || String(si + 1), p1: label, p2: fmt2(value), p3: i + 1, p4: n }));
3157
+ else announceKb(t("charts.a11y.point", { p0: label, p1: fmt2(value), p2: i + 1, p3: n }));
3158
+ };
3159
+ const onKbKeydown = (e) => {
3160
+ const conf = a11yOf();
3161
+ if (!conf.keyboard || !KB_TYPES.has(typeOf())) return;
3162
+ const g = root.querySelector("[data-icen-chart-kb]");
3163
+ if (!g || e.target !== g) return;
3164
+ if (e.key === "Escape") {
3165
+ if (zoomWindow) {
3166
+ zoomWindow = null;
3167
+ kbCursor.active = false;
3168
+ render();
3169
+ }
3170
+ return;
3171
+ }
3172
+ const view = viewOf();
3173
+ const series = seriesViewOf(view);
3174
+ const n = kbPointCount(view);
3175
+ if (n < 1) return;
3176
+ const siMax = Math.max(0, series.length - 1);
3177
+ let moved = true;
3178
+ switch (e.key) {
3179
+ case "ArrowRight":
3180
+ kbCursor.i = clampNum(kbCursor.i + 1, 0, n - 1);
3181
+ break;
3182
+ case "ArrowLeft":
3183
+ kbCursor.i = clampNum(kbCursor.i - 1, 0, n - 1);
3184
+ break;
3185
+ case "ArrowDown":
3186
+ kbCursor.si = clampNum(kbCursor.si + 1, 0, siMax);
3187
+ break;
3188
+ case "ArrowUp":
3189
+ kbCursor.si = clampNum(kbCursor.si - 1, 0, siMax);
3190
+ break;
3191
+ case "Home":
3192
+ kbCursor.i = 0;
3193
+ break;
3194
+ case "End":
3195
+ kbCursor.i = n - 1;
3196
+ break;
3197
+ default:
3198
+ moved = false;
3199
+ }
3200
+ if (!moved) return;
3201
+ e.preventDefault();
3202
+ kbCursor.active = true;
3203
+ applyKbCursor();
3204
+ announceCursor();
3205
+ };
3206
+ root.addEventListener("keydown", onKbKeydown);
3207
+ const applyChartA11yPlus = (view, spec, type, formatValue) => {
3208
+ const conf = a11yOf();
3209
+ const graphic = root.querySelector('[role="img"]');
3210
+ if (!graphic) return;
3211
+ const body = root.querySelector(".icen-chart-body") ?? graphic.parentElement ?? root;
3212
+ const summaryParts = [];
3213
+ if (conf.summary) summaryParts.push(summarizeChart(view, type, formatValue));
3214
+ if (conf.keyboard && KB_TYPES.has(type)) summaryParts.push(t("charts.a11y.keyboardHint"));
3215
+ const summaryText = summaryParts.filter(Boolean).join("\uFF1B");
3216
+ if (summaryText) {
3217
+ const p = document.createElement("p");
3218
+ p.className = "icen-chart-summary";
3219
+ p.id = `icen-chart-a11y-${++a11ySeq}`;
3220
+ p.textContent = summaryText;
3221
+ body.appendChild(p);
3222
+ graphic.setAttribute("aria-describedby", p.id);
3223
+ } else {
3224
+ graphic.removeAttribute("aria-describedby");
3225
+ }
3226
+ if (conf.keyboard && KB_TYPES.has(type)) {
3227
+ graphic.setAttribute("tabindex", "0");
3228
+ graphic.setAttribute("data-icen-chart-kb", "");
3229
+ } else {
3230
+ graphic.removeAttribute("tabindex");
3231
+ graphic.removeAttribute("data-icen-chart-kb");
3232
+ }
3233
+ if (conf.table) body.appendChild(buildChartTable(view, spec, type, formatValue));
3234
+ applyKbCursor();
3235
+ };
2996
3236
  const render = () => {
2997
3237
  if (!current2.type) current2 = { ...current2, type: inferChartType(current2) };
2998
3238
  const spec = current2;
@@ -3073,6 +3313,7 @@ function renderChart(el, raw) {
3073
3313
  }
3074
3314
  }
3075
3315
  if (loading) return;
3316
+ applyChartA11yPlus(view, spec, type, formatValue);
3076
3317
  paintChartMarks(root, view, spec, formatValue, warnedMarks);
3077
3318
  bindZoomGesture();
3078
3319
  };
@@ -3154,6 +3395,7 @@ function renderChart(el, raw) {
3154
3395
  releaseFluidWidth(root);
3155
3396
  hiddenLegendKeys.delete(root);
3156
3397
  zoomDragCleanup?.();
3398
+ root.removeEventListener("keydown", onKbKeydown);
3157
3399
  root.textContent = "";
3158
3400
  root.classList.remove("is-loading");
3159
3401
  unbindEvents();
@@ -0,0 +1,115 @@
1
+ # 图表:数据规模、无障碍与手势(spec)
2
+
3
+ 面向实现者与评审。现状事实源:`src/behaviors/charts.ts`(统一 ChartSpec + renderChart)、`src/components/charts.css`。本文件给出规模分级、渲染策略、读屏语义、键盘与手势模型、性能预算与测试策略。已落地项标注 ✅(含验证方式),接口草案标注 🔵(未实现,类型即合同)。
4
+
5
+ ## 1. 数据规模分级与渲染策略
6
+
7
+ | 级别 | 规模 | 渲染策略 | 现状 |
8
+ | --- | --- | --- | --- |
9
+ | S | ≤1e3 点 | 纯 SVG/DOM 直渲,无抽样 | ✅ 默认路径 |
10
+ | M | 1e3–1e5 | SVG + LTTB 降采样(默认阈值 1000,`spec.downsample`)| ✅ `lttb()` 纯函数 + `downsampleView`;抽样时头部行提示「已抽样 N→M 点」(诚实呈现,不静默) |
11
+ | L | 1e5–1e6 | LTTB + 视窗(zoom/append 窗口切片后才对视图抽样)| ✅ zoom 窗与 `append(maxPoints)` 头部淘汰已落地 |
12
+ | XL | ≥1e6 | 分层混合:Canvas raster 层 + SVG 交互层(热点/游标/焦点环)| 🔵 接口草案见 §7;当前 SVG 上限实测见 §6 |
13
+
14
+ 降采样算法选择:**LTTB**(保形状特征,极值不丢)为默认;min-max 分桶适合密度热力预览但不保峰形,作为 🔵 `sampling.method: 'minmax'` 草案保留;时序流式场景用 `append` 的窗口淘汰而非重抽样。
15
+
16
+ 渲染器选择原则:SVG 保交互与 a11y(每点是真实节点,可挂 title/焦点);Canvas 只在 XL 级引入且必须叠加 SVG 交互层——读屏语义与键盘游标不因渲染器切换而退步。
17
+
18
+ ## 2. 长列表 / 表格 / 树的虚拟滚动
19
+
20
+ | 组件 | 现状 | 方案 |
21
+ | --- | --- | --- |
22
+ | datatable | ✅ `virtual: true` 固定行高窗口化(`rowHeight` 可调,与分页互斥) | 万级行流畅;动态行高 🔵(测量缓存 + 预估高度 + 滚动锚定,见下) |
23
+ | table | 静态展示组件 | 数据量大时用 datatable;table 不引入虚拟化(职责分离) |
24
+ | tree | 无虚拟化 | 🔵 同 datatable 窗口化;展开/收起时以「首个可见节点的 key + 偏移」做滚动锚定 |
25
+
26
+ 动态行高草案(🔵):`virtual: { estimatedRowHeight: number; measure?: true }`——IntersectionObserver-free,渲染后测量真实高并写回缓存,滚动锚定 = 记录锚点 key 与其在行内的偏移,重渲后恢复。滚动锚定同样适用于 prepend(聊天/日志流)。
27
+
28
+ ## 3. 屏幕阅读器语义
29
+
30
+ 每个图形的语义三层(✅ 已落地,`tests/dom/charts-a11y.test.ts` 7 项):
31
+
32
+ 1. **可访问名**:`role="img"` + `aria-label` = 标题(图型)或图型(既有行为,axe 债已清)。
33
+ 2. **自动摘要**:sr-only `<p>` 经 `aria-describedby` 挂载——系列数、类目范围、最值及其类目、首系列趋势(升/降/平);scatter 播报 x/y 范围。`spec.a11y.summary: false` 可关。
34
+ 3. **数据表替代视图**:`spec.a11y.table: true` 追加 sr-only `<table>`(caption + thead 系列名 + tbody 类目/值行;scatter 为 x/y 两列)。读屏器按表格语义逐格导航,复制导出同源。
35
+
36
+ 各图型语义矩阵:
37
+
38
+ | 图型 | role/label | 摘要 | 键盘游标 |
39
+ | --- | --- | --- | --- |
40
+ | line/area | img + describedby ✅ | ✅ 范围/最值/趋势 | ✅ 点级(系列×点) |
41
+ | vbar/hbar | DOM 容器 img + describedby ✅ | ✅ | ✅ 柱条级 |
42
+ | scatter | img + describedby ✅ | ✅ x/y 范围 | ✅ 点级 |
43
+ | donut/radar | img ✅ | ✅ 最值 | 🔵(段导航) |
44
+ | heatmap/calendar | aria-label 网格 ✅ | ✅ 最值 | 🔵(格导航) |
45
+ | gauge/sparkline | img ✅ | ✅ 单值退化 | —(单值无导航必要) |
46
+ | graph/map | img ✅ | 🔵 节点/边计数 | 🔵 |
47
+
48
+ **不只靠颜色**:多系列折线的区分 = 颜色 + 图例文本 + tooltip/播报中的系列名 + 标记点形状(marks);键盘游标与播报完全不依赖颜色。纹理/虚线族(stroke-dasharray 轮换)作为 🔵 增强项保留(打印/单色场景)。
49
+
50
+ ## 4. 键盘导航模型与 live region
51
+
52
+ ✅ 已落地(`spec.a11y.keyboard` 默认开):
53
+
54
+ - 图形可聚焦(`tabindex=0` + `:focus-visible` 环);**←/→** 逐点、**↑/↓** 切系列(多系列)、**Home/End** 跳首尾、**Esc** 重置缩放窗。
55
+ - 游标视觉:SVG 点圆放大加粗(`r` 几何属性 + stroke-width;不支持 `r` 的浏览器退化为描边),DOM 柱条用 accent 轮廓环。
56
+ - 播报:单个 `aria-live="polite"` region 随图表存活,播报「系列 · 类目:值,第 i/n 点」;先清空再写入保证同值重报。
57
+ - 与 tooltip/事件族正交:键盘导航不触发 hover 事件,避免双通道噪音。
58
+
59
+ ## 5. 交互手势
60
+
61
+ 现状(✅):`spec.zoom: 'x'|'brush'` 指针拖选缩放(统一 pointer 事件,位移 <5px 视为点击;pointercancel 按 pointerup 收口);头部 chip 与双击 plot 复原;`icen:chart-zoom {chart,from,to}` 广播。
62
+
63
+ 本分支补齐(✅):**触屏冲突**——zoom 启用时 plot 的 `touch-action`:`'x'` 为 `pan-y`(垂直留给页面滚动,水平拦拖选),`'brush'` 为 `none`(自由框选接管)。惯性不做(类目语义图表的惯性滚动会造成定位歧义,有意拒绝)。
64
+
65
+ 键盘等价:方向键游标 = 平移/浏览等价;Esc = 重置等价;双指缩放的键盘等价 🔵(`+`/`-` 以游标为中心缩放,接口预留)。
66
+
67
+ 触屏替代:tooltip 已有点击语义;游标类在触屏上不显示(无焦点)。
68
+
69
+ ## 6. 性能预算与基准
70
+
71
+ 预算(验收口径,桌面中端机 Chrome;首绘 = renderChart 同步返回 + 一帧):
72
+
73
+ | 指标 | 预算 | 量级依据 |
74
+ | --- | --- | --- |
75
+ | LTTB 1e6→1000 | <10ms JS | 实测 5.25ms(bun,本机) |
76
+ | normalizeChartSpec 1e5 类目 | <10ms JS | 实测 3.27ms |
77
+ | renderChart line 1e4(抽样开) | <100ms JS(happy-dom 口径) | 实测 63.9ms;浏览器 DOM 低于此 |
78
+ | 交互帧(游标/图例切换) | <16ms | 类切换 + 单点重绘 |
79
+ | 内存 | 视图点数 ≤ 抽样阈值 + 窗口 | LTTB 输出 ≤1000 点 |
80
+
81
+ 基准方法:`bun scripts/bench-charts.mjs`(可重复,口径写在脚本头注释:JS 侧耗时,不含绘制/合成;绘制预算由 e2e 视觉舱与人工 DevTools Performance 抽样验收)。数字为量级参考,随硬件变——评审看量级与回归趋势,不比绝对值。
82
+
83
+ ## 7. 接口草案(🔵 未实现,类型即合同)
84
+
85
+ ```ts
86
+ /** XL 级(≥1e6)分层渲染:Canvas raster + SVG 交互层。a11y 层语义不因渲染器切换而退步。 */
87
+ interface ChartCanvasRenderer {
88
+ /** 绘制视窗内点云(OffscreenCanvas 可迁移 Worker) */
89
+ paint(view: ChartSpec, window: { from: number; to: number }): void;
90
+ /** 命中测试交给 SVG 交互层(分桶索引),本层只管画 */
91
+ destroy(): void;
92
+ }
93
+
94
+ /** Worker 降采样池:大数据 LTTB 移出主线程;返回 Promise,不阻塞首绘 */
95
+ interface ChartSamplingWorker {
96
+ sample(points: readonly { x: number; y: number }[], threshold: number): Promise<readonly number[]>;
97
+ terminate(): void;
98
+ }
99
+
100
+ /** 声音化(可选增强,默认关):逐点音高映射值域,键盘游标移动时发声 */
101
+ interface ChartSonifyOptions {
102
+ enabled: boolean; // 默认 false;开启需用户动作(音频上下文策略)
103
+ rangeHz?: [number, number]; // 默认 [220, 880]
104
+ }
105
+ ```
106
+
107
+ 落地顺序建议:Worker 降采样 > Canvas 层 > sonification。每一层都必须先过 a11y 回归(摘要/表格/键盘游标不降级)。
108
+
109
+ ## 8. 测试策略
110
+
111
+ - ✅ DOM 测试 `tests/dom/charts-a11y.test.ts`:摘要内容、describedby 接线、数据表结构、键盘全键位、边界 clamp、update 后游标保持、destroy 解绑。
112
+ - ✅ 既有 axe 基线(`tests/e2e/a11y.pw.ts` + a11y-baseline.json)覆盖组件页;本层新增的 sr-only 节点不改变像素(视觉基线无漂移)。
113
+ - ✅ LTTB 单元测试(`tests/unit/charts-lttb.test.ts`)。
114
+ - 手测矩阵(发布前人工):NVDA + Chrome(摘要/表格/游标播报)、VoiceOver + Safari(macOS/iOS 各一)、键盘-only 全流程(焦点可见、无键盘陷阱)、200% 缩放与 Windows 高对比(forced-colors 已有滚动条/焦点环兜底)。
115
+ - 视觉回归:游标类只在键盘聚焦时出现,默认截图不含;`a11y.table` 的 sr-only 表格不渲染进位图。
@@ -0,0 +1,105 @@
1
+ # 动效令牌与排版尺度(spec)
2
+
3
+ 面向实现者与评审。令牌定义在 `src/tokens/motion.css` 与 `src/tokens/typography.css`;运行时口径在 `src/behaviors/motion.ts`;DTCG 导出经 `scripts/dtcg.mjs`(`dist/tokens/dtcg.json`)。组件只消费令牌与语义别名,不写死时长、曲线、字号。
4
+
5
+ ## 1. 动效令牌
6
+
7
+ ### 1.1 时长阶梯
8
+
9
+ | 令牌 | 值 | 场景 | 反例 |
10
+ | --- | --- | --- | --- |
11
+ | `--icen-motion-duration-instant` | 0ms | 像素原语全量;状态复位 | 用它"省掉"必要的过渡反馈 |
12
+ | `--icen-motion-duration-fast` | 110ms | hover / press / 焦点环微反馈 | 浮层进出场(太短促) |
13
+ | `--icen-motion-duration-base` | 180ms | 展开收起、淡入淡出、勾选 | 页面级转场 |
14
+ | `--icen-motion-duration-slow` | 260ms | 浮层进出、modal、面板滑入 | 高频 hover 反馈(拖沓) |
15
+ | `--icen-motion-duration-deliberate` | 420ms | 页面级转场、强调入场 | 常规控件(必须克制,>420ms 需评审) |
16
+
17
+ 消费口径:组件读语义别名 `--icen-duration-fast/normal/slow/deliberate`(design.css 映射 = 基值 × `--icen-design-motion`);`instant` 同样有别名。新组件可直接读 `--icen-motion-duration-*` 基值(不缩放,用于设计工具对齐场景)。
18
+
19
+ ### 1.2 缓动集
20
+
21
+ | 令牌 | 值 | 场景 | 反例 |
22
+ | --- | --- | --- | --- |
23
+ | `--icen-motion-ease-standard` | cubic-bezier(.45,0,.25,1) | 持续类状态变化(颜色、宽度) | 入场(缺加速初速感) |
24
+ | `--icen-motion-ease-enter` | cubic-bezier(.16,1,.3,1) | 入场:快出慢归 | 离场(会显犹豫) |
25
+ | `--icen-motion-ease-exit` | cubic-bezier(.4,0,1,1) | 离场:快进加速 | 入场(像被弹开) |
26
+ | `--icen-motion-ease-emphasized` | cubic-bezier(.22,.8,.24,1) | 强调:长减速弧(hero、落板) | 高频微交互 |
27
+ | `--icen-motion-ease-spring` | linear() 阻尼弹簧近似(过冲 ≈8%);不支持 linear() 的浏览器回退 emphasized | 抽屉、卡片回弹 | 表单控件状态变化(过冲多余) |
28
+
29
+ 回退声明在 `motion.css` 的 `@supports (transition-timing-function: linear(0,1))` 块——可降级增强,不 polyfill。`linear()` / `steps()` 超出 DTCG 经典版标量,导出时明示跳过(`dtcg.json` 的 `ai.icen.skipped`)。
30
+
31
+ ### 1.3 幅度与编排
32
+
33
+ | 令牌 | 值 | 场景 | 反例 |
34
+ | --- | --- | --- | --- |
35
+ | `--icen-motion-shift-sm` | 2px | 行内微移(列表项 hover) | 弹层入场(幅度不足) |
36
+ | `--icen-motion-shift-md` | 6px | 浮层/气泡入场位移 | 抽屉 |
37
+ | `--icen-motion-shift-lg` | 12px | 抽屉/面板入场位移 | 行内微移(晃眼) |
38
+ | `--icen-motion-scale-enter` | .96 | 弹层入场起始缩放 | 大于 .96 的夸张缩放入场 |
39
+ | `--icen-motion-scale-press` | .985 | 按下形变 | 持续缩放循环 |
40
+ | `--icen-motion-stagger-step` | 24ms | 列表/卡片瀑布入场相邻延迟 | 超过 40ms 的排队(显卡顿) |
41
+ | `--icen-motion-stagger-count` | 5 | 级联上限,超出同拍 | 全列表逐项延迟(长尾等待) |
42
+
43
+ ### 1.4 与预设、原语的联动
44
+
45
+ - 配色预设(themes.json 六套)不动效:动效与色彩正交。
46
+ - 原语:pixel 原语把 `--icen-motion-ease-*` 全部改写为 `steps(1, end)`,且 `--icen-design-motion: 0` 使时长归零(点阵逐帧美学);chinese / soviet 沿用现代动效曲线,仅在其自有块覆盖排版与几何。
47
+ - 明暗:不动效。
48
+
49
+ ## 2. reduced-motion 策略
50
+
51
+ 降级链(实现于 `base.css`,运行时口径 `behaviors/motion.ts`):
52
+
53
+ 1. **默认遵循系统**:`@media (prefers-reduced-motion: reduce)` 时 `--icen-design-motion: 0` → 全部 `--icen-duration-*` 过渡瞬时完成;循环/环境动画(spinner、skeleton、脉冲)单次瞬时;平滑滚动关闭。(Chromium 实测:reduce 下 0s 被钳制为 0.01ms 序列化,语义等同瞬时,保证 transitionend 派发。)
54
+ 2. **手动覆盖**:`<html data-icen-motion="reduce">` 无需系统设置即可降级;`"full"` 在系统降级时显式恢复(演示机、用户显式选择)。属性优先级高于系统偏好。
55
+ 3. **降级而非移除**:状态变化仍然可见,只是瞬时完成;一次性的状态表达保留。组件编写约定:入场动画应同时含 opacity 与 transform——顺畅时是"移动+淡入",降级时是瞬时呈现,不会出现"什么都没发生"。
56
+ 4. **JS 同口径**:`icenMotionReduced()` 读取与 CSS 相同的判定(属性 > 系统);`onIcenMotionChange(cb)` 统一订阅 matchMedia 与属性改写,返回销毁函数;`setIcenMotion(mode|null)` 写覆盖。JS 驱动的视差、autoplay、canvas 循环必须走此口径停止运动、保留状态。
57
+
58
+ 反模式(守卫/评审拦截):组件内写死 `transition-duration` 毫秒值;用 `!important` 自创降级块;JS 动画只读 matchMedia 不看手动覆盖。
59
+
60
+ ## 3. 排版尺度
61
+
62
+ ### 3.1 内容排版尺度(typography.css)
63
+
64
+ 模块比例 1.250(大三度),`clamp()` 流体插值;面向内容/文档排版。控件字号仍由 `--icen-density-font-size-*` 驱动——**两套尺度解耦:密度档只压缩控件,不挤压阅读**。
65
+
66
+ | 令牌 | 区间(min→max) | 场景 |
67
+ | --- | --- | --- |
68
+ | `--icen-type-xs` | 11→12px | 角标、时间戳 |
69
+ | `--icen-type-sm` | 12.5→14.4px | 辅助说明、表注 |
70
+ | `--icen-type-md` | 14→16px | 正文基准 |
71
+ | `--icen-type-lg` | 16→20px | 小节标题、导语 |
72
+ | `--icen-type-xl` | 20→25px | 页标题 |
73
+ | `--icen-type-2xl` | 25→31.25px | 区块主标 |
74
+ | `--icen-type-3xl` | 31.25→39px | hero(克制使用) |
75
+
76
+ 行高:`--icen-leading-tight` 1.3(大标题)/ `-base` 1.6(正文,CJK 混排安全下限)/ `-loose` 1.8(长文)。字距:`--icen-tracking-tight` -.02em(大号拉丁标题)/ `-base` 0 / `-wide` .04em(眉标)。字重:`--icen-weight-regular/medium/semibold/bold` = 400/500/600/700。
77
+
78
+ ### 3.2 CJK 与拉丁混排
79
+
80
+ - 字体栈:`--icen-font-cjk` 默认跟随标题字体,栈内含 PingFang SC / Hiragino Sans GB / Microsoft YaHei(design.css);像素原语整体替换为点阵字体。
81
+ - 标点挤压:`text-spacing-trim: trim-start` 渐进增强(Chrome 123+,`@supports` 守卫;不支持即浏览器默认,不 polyfill)。
82
+ - 数字:数据密集区用 `font-variant-numeric: lining-nums tabular-nums`(`.icen-numeric` / `.icen-font-math` 已消费;新增 `--icen-feature-data` 令牌供新组件)。
83
+ - CJK 字距一律 0(`--icen-tracking-tight` 仅拉丁大标题;不对 CJK 正文加字距)。
84
+
85
+ ### 3.3 紧凑密度
86
+
87
+ `data-icen-density="compact|comfortable"` 只作用于 `--icen-density-*`(控件高度/间距/控件字号,style-profiles.css),不影响 `--icen-type-*` 内容尺度。数据密集界面应开密度档而不是缩小正文字号。
88
+
89
+ ## 4. DTCG 导出
90
+
91
+ `bun run dtcg` 导出时长(duration)、缓动(cubicBezier)、位移(dimension)、缩放/行高/字重/级联数(number)各族;clamp()、`linear()`、`steps()`、font-feature-settings 字符串超出经典版标量,逐条记录在 `ai.icen.skipped` 并注明原因——不猜不静默。
92
+
93
+ ## 5. 文档页信息结构(tokens 页扩展建议)
94
+
95
+ 1. 动效族:时长阶梯演示条(同曲线五档并排播放)→ 缓动曲线卡(SVG 曲线 + 用途/反例)→ 幅度尺(shift/scale 对照)→ stagger 演示列表。
96
+ 2. 降级演示:开关 `data-icen-motion="reduce|full|系统"`(调 `setIcenMotion`),同一块动画区实时对照。
97
+ 3. 排版族:尺度阶梯表(实际渲染字号 + 区间)→ 行高/字距对照 → CJK 混排样张 → 密度档对照(控件 vs 内容)。
98
+ 4. 每令牌一行:名称 / 值 / 一句话场景 / 反例(与本 spec 表格同源)。
99
+
100
+ ## 验收
101
+
102
+ - `bun run guard` 全绿(token 引用、meta、契约快照含新增变量与 motion.ts 导出)。
103
+ - `bun test tests/unit/motion.test.ts` 5/5:覆盖优先级、订阅销毁、非法值回退。
104
+ - `bun scripts/dtcg.mjs` 导出 motion-duration/motion-ease 等族,linear()/clamp() 记入 skipped。
105
+ - reduced-motion e2e:系统 reduce 下 `--icen-duration-fast` 计算值 0ms;`data-icen-motion="full"` 恢复 110ms;手动 reduce 无需系统设置。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icen.ai/ui",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Icen Design System — 设计 tokens(6 主题 × 4 原语 × 明暗)+ 无框架组件 CSS/行为 JS + AI 原生组件族(7 态状态机/多模态/定价审计)+ 图表通用层(ChartSpec 纯 JSON)+ AI 工具体系",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,6 +62,7 @@
62
62
  "dtcg": "bun scripts/dtcg.mjs",
63
63
  "design-tokens": "bun scripts/design-tokens.mjs",
64
64
  "guard": "bun scripts/guard/index.ts",
65
+ "meta": "bun scripts/meta.mjs",
65
66
  "contracts": "bun scripts/contracts.ts --check",
66
67
  "contracts:update": "bun scripts/contracts.ts --write",
67
68
  "contracts:diff": "bun scripts/contracts.ts --diff",