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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const descriptionKeys: Readonly<Record<string, MessageKey>> = {
ProminentToolCard: "component.ProminentToolCard.description",
ReadFileToolCard: "component.ReadFileToolCard.description",
ReviewSummaryToolCard: "component.ReviewSummaryToolCard.description",
RollingText: "component.RollingText.description",
RunCodeToolCard: "component.RunCodeToolCard.description",
SearchField: "component.SearchField.description",
SegmentedControl: "component.SegmentedControl.description",
Expand Down
12 changes: 12 additions & 0 deletions design-system/apps/design-lab/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export const enUSMessages = {
"component.Spinner.description": "A compact activity indicator with matrix and bar presentations.",
"component.Switch.description": "Toggles a single setting between on and off with native checkbox semantics.",
"component.TabGroup.description": "A compact single-selection tab list with optional leading icons, end actions, and automatic keyboard activation.",
"component.RollingText.description": "Vertical text replacement with coordinated width, interruption handling, and reduced-motion support. Try repeated clicks in the replacement specimen.",
"components.preview.replaceText": "Replace title",
"components.preview.rollingTextLong": "Review workspace navigation and session switching",
"detail.option.replacing": "Replacement",
"component.TerminalControlToolCard.description": "An ambient trace for interrupting or terminating a terminal session.",
"component.TodoToolCard.description": "An ambient task-progress card with compact and expandable list presentations.",
"component.Toolbar.description": "A responsive bar with independent leading, centered, trailing, and horizontally scrollable composition regions.",
Expand Down Expand Up @@ -865,6 +869,10 @@ export const zhCNMessages = {
"component.Spinner.description": "提供点阵与条形样式的紧凑活动指示器。",
"component.Switch.description": "通过原生复选框语义在开启与关闭之间切换单项设置。",
"component.TabGroup.description": "紧凑的单选标签组,支持前置图标、尾部操作与方向键自动切换。",
"component.RollingText.description": "垂直滚动替换文本,宽度同步过渡,支持连续切换与减少动态效果。可在替换示例中连续点击体验。",
"components.preview.replaceText": "替换标题",
"components.preview.rollingTextLong": "检查工作区导航与会话切换时的交互行为",
"detail.option.replacing": "替换",
"component.TerminalControlToolCard.description": "低关注终端控制轨迹,用于呈现中断或终止终端会话的结果。",
"component.TodoToolCard.description": "低关注任务进度卡片,支持紧凑摘要与可展开列表两种呈现。",
"component.Toolbar.description": "具备独立前置、居中、尾部与横向滚动区域的响应式工具栏。",
Expand Down Expand Up @@ -1635,6 +1643,10 @@ export const zhTWMessages = {
"component.Spinner.description": "提供點陣與條形樣式的緊湊活動指示器。",
"component.Switch.description": "以原生核取方塊語意在開啟與關閉之間切換單一設定。",
"component.TabGroup.description": "緊湊的單選標籤群組,支援前置圖示、尾端操作與方向鍵自動切換。",
"component.RollingText.description": "垂直捲動替換文字,寬度同步過渡,支援連續切換與減少動態效果。可在替換範例中連續點擊體驗。",
"components.preview.replaceText": "替換標題",
"components.preview.rollingTextLong": "檢查工作區導覽與工作階段切換時的互動行為",
"detail.option.replacing": "替換",
"component.TerminalControlToolCard.description": "低關注終端控制軌跡,用於呈現中斷或終止終端工作階段的結果。",
"component.TodoToolCard.description": "低關注任務進度卡片,支援緊湊摘要與可展開列表兩種呈現。",
"component.Toolbar.description": "具備獨立前置、置中、尾端與橫向捲動區域的響應式工具列。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import {
import type { ComponentMeta } from "@openbitfun/ui/registry";
import previewImage from "../assets/design-system-hero.webp";
import { IconCompositionPreview } from "../preview/IconCompositionPreview";
import { RollingTextPreview } from "../preview/RollingTextPreview";
import { NestedMenuPattern } from "./ReferencePatterns";
import { useI18n, type MessageKey } from "../i18n";
import {
Expand Down Expand Up @@ -174,6 +175,7 @@ const optionLabelKeys: Readonly<Record<string, MessageKey>> = {
chevron: "detail.option.chevron",
center: "detail.option.center",
default: "detail.option.default",
replacing: "detail.option.replacing",
disabled: "detail.option.disabled",
display: "detail.option.display",
error: "detail.option.error",
Expand Down Expand Up @@ -437,6 +439,9 @@ export function ComponentDetailPage({
: states;

const codeSample = useMemo(() => {
if (component.name === "RollingText") {
return 'import { RollingText, TabGroup } from "@openbitfun/ui";\n\n// Keep the identity stable for title edits; change it when replacing the resource.\n<RollingText transitionKey={record.id}>{record.title}</RollingText>\n\n// TabGroup owns the text slot and composes RollingText without nested clipping.\n<TabGroup\n aria-label="Views"\n items={[{ value: slotId, label: record.title, labelTransitionKey: record.id }]}\n/>\n';
}
if (component.name === "MobileActionSheet") return `import { MobileActionSheet } from "@openbitfun/ui/mobile";\n\n<MobileActionSheet\n actions={[\n { id: "rename", label: "${t("components.preview.modalSave")}" },\n { id: "delete", label: "${t("components.preview.confirmDelete")}", tone: "danger" },\n ]}\n cancelLabel="${t("components.preview.modalCancel")}"\n onAction={handleAction}\n onOpenChange={() => setOpen(false)}\n open={open}\n title="${t("components.preview.session")}"\n/>`;
if (component.name === "MobileComposer") return `import { MobileComposer } from "@openbitfun/ui/mobile";\n\n<MobileComposer\n expanded={expanded}\n leading={<AttachButton />}\n startActions={<ModelControls />}\n endActions={<SendButton />}\n>\n <textarea />\n</MobileComposer>`;
if (component.name === "MobileChoiceSheet") return `import { MobileChoiceSheet } from "@openbitfun/ui/mobile";\n\n<MobileChoiceSheet\n cancelLabel="${t("components.preview.modalCancel")}"\n onOpenChange={() => setOpen(false)}\n onSelect={setMode}\n open={open}\n options={[\n { label: "${t("components.preview.modeMinimal")}", value: "minimal" },\n { label: "${t("components.preview.modeStandard")}", value: "standard" },\n { label: "${t("components.preview.modeUltimate")}", value: "ultimate" },\n ]}\n selectedValue={mode}\n title="${t("components.preview.selectExecutionMode")}"\n/>`;
Expand Down Expand Up @@ -1886,6 +1891,10 @@ export function ComponentDetailPage({
);
}

if (component.name === "RollingText") {
return <RollingTextPreview interactive={state === "replacing"} />;
}

if (component.name === "TabGroup") {
const defaultTab = state === "unselected" ? "settings" : "welcome";
return (
Expand Down
3 changes: 3 additions & 0 deletions design-system/apps/design-lab/src/pages/ComponentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
getFlowChatPreviewDefinition,
} from "../preview/FlowChatPreviewRegistry";
import { FlowChatToolGallery } from "../preview/FlowChatToolGallery";
import { RollingTextPreview } from "../preview/RollingTextPreview";

interface ComponentsPageProps {
category?: ComponentMeta["category"];
Expand Down Expand Up @@ -646,6 +647,8 @@ function ComponentCardPreview({ component }: { component: ComponentMeta }) {
/>
</Stack>
);
case "RollingText":
return <RollingTextPreview />;
case "TabGroup":
return (
<TabGroup
Expand Down
34 changes: 34 additions & 0 deletions design-system/apps/design-lab/src/preview/RollingTextPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useState } from "react";
import { Button, RollingText, Stack, TabGroup } from "@openbitfun/ui";
import { useI18n } from "../i18n";

/** Manual, event-driven specimens of the public text and tab contracts. */
export function RollingTextPreview({ interactive = false }: { interactive?: boolean }) {
const { t } = useI18n();
const [index, setIndex] = useState(0);
const labels = [
t("components.preview.welcome"),
t("components.preview.rollingTextLong"),
t("components.preview.session"),
] as const;
const label = labels[index % labels.length] ?? labels[0];

return (
<Stack gap="3" style={{ inlineSize: 240, maxInlineSize: "100%" }}>
<RollingText transitionKey={index}>{label}</RollingText>
{interactive && (
<>
<TabGroup
aria-label={t("components.preview.tabGroupLabel")}
items={[{ value: "slot", label, labelTransitionKey: index }]}
size="sm"
style={{ maxInlineSize: "100%" }}
/>
<Button onClick={() => setIndex(value => value + 1)} size="sm">
{t("components.preview.replaceText")}
</Button>
</>
)}
</Stack>
);
}
5 changes: 5 additions & 0 deletions design-system/apps/design-lab/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3946,6 +3946,11 @@ input.lab-force-focus {
min-width: 420px;
}

.component-preview-matrix[data-state-count="2"] {
grid-template-columns: 96px repeat(2, minmax(280px, max-content));
grid-template-rows: 50px minmax(190px, 1fr);
}

.component-preview-matrix[data-state-count="3"] {
grid-template-columns: 96px repeat(3, minmax(280px, max-content));
grid-template-rows: 50px minmax(190px, 1fr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ test("preview matrices define horizontal columns for every registered state coun
source,
/\.component-preview-matrix\[data-state-count="1"\]\s*\{[^}]*grid-template-columns:\s*96px\s+minmax\(240px, 1fr\)/s,
);
assert.match(
source,
/\.component-preview-matrix\[data-state-count="2"\]\s*\{[^}]*grid-template-columns:\s*96px\s+repeat\(2, minmax\(280px, max-content\)\)/s,
);
assert.match(
source,
/\.component-preview-matrix\[data-state-count="3"\]\s*\{[^}]*grid-template-columns:\s*96px\s+repeat\(3, minmax\(280px, max-content\)\)/s,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ test("Switch is explicit and unknown components never silently become switches",
assert.doesNotMatch(preview, /data-openbitfun-component="switch"/);
});

test("RollingText exposes real standalone and TabGroup replacement specimens", () => {
const { preview, html } = render("RollingText");
assert.match(preview, /data-openbitfun-component="rolling-text"/);
assert.match(preview, /data-openbitfun-component="tab-group"/);
assert.match(preview, /替换标题/);
assert.match(html, /labelTransitionKey/);
});

test("Icon details include real mixed-icon compositions at every button size", () => {
const { preview } = render("Icon");
assert.match(preview, /component-icon-composition/);
Expand Down
5 changes: 5 additions & 0 deletions design-system/packages/design-tokens/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ Only semantic and system token names are public API. Density modes reuse the sam

`tokenCatalog` is the authoring contract for visual tools. Each entry exposes its type, CSS variable, category, description, and resolved value in every density mode. Applications normally consume `tokens`; editors consume the catalog instead of maintaining a second token list.

`motion.duration.contentSwap` (320ms) gives `RollingText` one cadence for vertical
line travel and intrinsic-width changes. It has its own token so tuning content
replacement does not change the existing hover-feedback and larger-view
transition durations. It is zeroed in the reduced-motion projection.

## Semantic typography roles

Text-bearing components consume a complete semantic role rather than assembling
Expand Down
2 changes: 2 additions & 0 deletions design-system/packages/design-tokens/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ assertRequiredTokens(systemTokens, [
"radius.3xl",
"radius.4xl",
"motion.duration.fast",
"motion.duration.contentSwap",
"focus.width",
]);

Expand Down Expand Up @@ -155,6 +156,7 @@ const css = [
" :where([data-openbitfun-design-system-root]) {\n",
" --openbitfun-motion-duration-fast: 0ms;\n",
" --openbitfun-motion-duration-normal: 0ms;\n",
" --openbitfun-motion-duration-content-swap: 0ms;\n",
" --openbitfun-motion-duration-slow: 0ms;\n",
" --openbitfun-motion-duration-loop: 0ms;\n",
" }\n",
Expand Down
4 changes: 4 additions & 0 deletions design-system/packages/design-tokens/src/system.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,10 @@
"fast": { "$value": "140ms" },
"base": { "$value": "220ms" },
"normal": { "$value": "{motion.duration.base}" },
"contentSwap": {
"$value": "320ms",
"$description": "Coordinated single-line content replacement and intrinsic-width changes."
},
"slow": { "$value": "420ms" },
"lazy": { "$value": "1s" },
"loop": { "$value": "720ms" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ test("shared system scales preserve the migrated Web UI foundation contract", ()
assert.equal(tokens["radius.3xl"], "24px");
assert.equal(tokens["motion.duration.instant"], "80ms");
assert.equal(tokens["motion.duration.slow"], "420ms");
assert.equal(tokens["motion.duration.contentSwap"], "320ms");
assert.equal(tokens["motion.easing.standard"], "cubic-bezier(0.23, 1, 0.32, 1)");
assert.equal(tokens["layer.modal"], 200);
assert.equal(tokens["layer.contextMenu"], 500);
Expand Down
41 changes: 41 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,47 @@ ellipsis rules are intentional source-code excerpts, contenteditable reference
chips/placeholders, and multiline message previews. Diagnostic/payload size caps
and persisted Appearance `textOverflow` values are data contracts, not layout rules.

## Text replacement motion

`RollingText` provides **vertical slide replacement** (also called rolling text):
the old line moves up and out while the new line enters from below. The line
positions and measured width share `motion.duration.contentSwap` (320ms) and
`motion.easing.smooth`. Initial render is static; the current accessible text
updates immediately, without a live region. The outgoing visual copy is hidden
from assistive technology.

```tsx
import { RollingText, TabGroup } from "@openbitfun/ui";

<RollingText transitionKey={record.id}>{record.title}</RollingText>

// TabGroup already owns its text slot; pass an identity instead of wrapping it.
<TabGroup
aria-label="Views"
items={[{ value: slotId, label: record.title, labelTransitionKey: record.id }]}
/>
```

Keep `transitionKey` stable for edits to the same resource. Without an explicit
key, changing the text triggers replacement. Plain strings and numbers are
supported; interactive elements, icons, and actions stay outside the rolling
line. The new title's natural width is measured within the existing layout
constraints before paint, then animated from the displayed width. No text clone
or frame-by-frame React measurement drives that animation. `OverflowText` still
owns truncation and the full-text fallback; its marquee resumes after replacement
instead of moving on two axes at once.

A running transition retains one outgoing snapshot and adopts the latest
incoming value, retargeting from the actual displayed positions and width.
There is no animation queue. Returning to the outgoing value reverses the roll.
`prefers-reduced-motion` disables replacement and cancels an in-flight roll when
the preference changes. Native animation completion owns cleanup, including
interruption and unmount; there is no timer duplicating the token duration.
Hosts without Web Animations render the current text immediately.

The **RollingText** Design Lab entry includes manual standalone and TabGroup
examples for repeated replacement and long labels.

## Mobile controls

Touch-first controls use the isolated mobile entry so compact and foldable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ComponentMeta } from "../../registry.types";

export const rollingTextMeta = {
category: "primitive",
description: "A single-line vertical text replacement with explicit identity, interruption handling, accessible text, and reduced-motion support.",
maturity: "stable",
name: "RollingText",
props: [
{ name: "children", type: "string | number" },
{ defaultValue: "children", name: "transitionKey", type: "string | number" },
{ defaultValue: "marquee", name: "behavior", type: "fade | marquee" },
{ name: "marqueeActive", type: "boolean" },
],
states: ["default", "replacing"],
tokens: [
"motion.duration.contentSwap",
"motion.easing.smooth",
"layout.overflowText.fadeExtent",
],
} as const satisfies ComponentMeta;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@layer openbitfun.components {
.root {
--_rolling-text-duration: var(--openbitfun-motion-duration-content-swap);
--_rolling-text-easing: var(--openbitfun-motion-easing-smooth);

position: relative;
display: block;
min-inline-size: 0;
max-inline-size: 100%;
overflow: hidden;
white-space: nowrap;
}

.current {
display: block;
}

.outgoing {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
}

@media (prefers-reduced-motion: reduce) {
.outgoing {
display: none;
}
}
}
Loading
Loading