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
6 changes: 6 additions & 0 deletions design-system/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ This file applies to `design-system/**`. Repository-wide rules in the root `AGEN
- Design Lab may alias `@openbitfun/ui` to source only during Vite development for HMR. Its production build must consume package exports.
- `@openbitfun/ui/registry` is the source of truth for published components. Design Lab derives navigation, counts, token scopes, and detail routes from that registry; Lab-only previews or copy must never add, retain, or remove a package component.

## CSS invalidation

- Prefer component-owned classes for sibling rules in dynamic views; tag-only or universal sibling selectors can invalidate unrelated elements even under a scoped ancestor or CSS Module.
- Use layout `gap` only where spacing semantics remain equivalent. Preserve adjacency, specificity, and state overrides when narrowing selectors; `:where()` can add class constraints without raising specificity.
- Use Selector Stats for cause analysis and ordinary traces for performance comparisons. Invalidation records alone do not prove a scrolling speedup. See `src/web-ui/AGENTS.md` for generated-content guidance; do not blanket-ban sibling selectors.

## Publication boundary

- Public manifests expose only `dist/`, README, and package metadata.
Expand Down
12 changes: 12 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ overrides that text; `title=""` opts out when a surrounding native title owns th
content. An explicit enclosing `Tooltip` suppresses automatic nested tooltips.
Do not use marquee as the sole way to access information on touch surfaces.

Overflow measurement is deferred to a shared animation-frame queue per window.
Mount, content, resize, and font notifications coalesce; all queued labels read
geometry before publishing state. Unmounted labels cancel their pending work.
Overflow indicators and automatic tooltips become available after that frame,
while the complete accessible text is present immediately. This avoids forcing
layout separately inside each label's React mount effect.
Requests made during a batch survive for the next frame; cancellation also
discards unpublished results. A failing label does not abort other labels,
and its error is reported asynchronously. This scheduling contract does not
claim an overall scrolling speedup; first-frame visual behavior requires
browser validation.

Multi-line descriptions should normally wrap. Editable fields, source code,
structured paths that need to preserve their suffix, and native controls keep
their appropriate text treatment instead of receiving a blanket fade rule.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(functi
{unit && <span className={styles.unit} data-openbitfun-part="unit">{unit}</span>}
{showButtons && variant !== "compact" && (
<span className={styles.buttons} data-openbitfun-part="buttons">
<button aria-label={decrementLabel} disabled={disabled || value <= min} onClick={() => changeBy(-step)} tabIndex={-1} type="button">−</button>
<button aria-label={incrementLabel} disabled={disabled || value >= max} onClick={() => changeBy(step)} tabIndex={-1} type="button">+</button>
<button className={styles.stepButton} aria-label={decrementLabel} disabled={disabled || value <= min} onClick={() => changeBy(-step)} tabIndex={-1} type="button">−</button>
<button className={styles.stepButton} aria-label={incrementLabel} disabled={disabled || value >= max} onClick={() => changeBy(step)} tabIndex={-1} type="button">+</button>
</span>
)}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { classNames } from "../../internal/classNames";
import { TooltipTriggerContext } from "../../internal/tooltipTriggerContext";
import { Tooltip } from "../../components/Tooltip";
import styles from "./OverflowText.module.css";
import { cancelOverflowMeasurement, scheduleOverflowMeasurement } from "./overflowMeasurementQueue";

const useIsomorphicLayoutEffect = typeof window === "undefined"
? useEffect
Expand Down Expand Up @@ -86,7 +87,7 @@ export const OverflowText = forwardRef<HTMLElement, OverflowTextProps>(
assignRef(forwardedRef, element);
}, [forwardedRef]);

const updateOverflow = useCallback(() => {
const readOverflow = useCallback(() => {
const element = elementRef.current;
const content = contentRef.current ?? element;
if (!element || !content) return;
Expand All @@ -104,10 +105,17 @@ export const OverflowText = forwardRef<HTMLElement, OverflowTextProps>(
if (current.distance === distance && current.isOverflowing === isOverflowing) return;

const next = { distance, isOverflowing };
measurementRef.current = next;
setMeasurement(next);
return () => {
measurementRef.current = next;
setMeasurement(next);
};
}, [lines]);

const updateOverflow = useCallback(() => {
const view = elementRef.current?.ownerDocument.defaultView;
if (view) scheduleOverflowMeasurement(view, readOverflow);
}, [readOverflow]);

const prepareTooltip = useCallback(() => {
const element = elementRef.current;
const trigger = triggerRef.current;
Expand All @@ -130,7 +138,9 @@ export const OverflowText = forwardRef<HTMLElement, OverflowTextProps>(

useIsomorphicLayoutEffect(() => {
updateOverflow();
}, [behavior, children, lines, overflowStyle, updateOverflow]);
const view = elementRef.current?.ownerDocument.defaultView;
return () => { if (view) cancelOverflowMeasurement(view, readOverflow); };
}, [behavior, children, lines, overflowStyle, readOverflow, updateOverflow]);

useEffect(() => {
if (measurementRef.current.isOverflowing) prepareTooltip();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
type ReadMeasurement = () => (() => void) | undefined;

interface MeasurementJob {
read: ReadMeasurement;
cancelled: boolean;
}
interface MeasurementQueue {
pending: Map<ReadMeasurement, MeasurementJob>;
active: Map<ReadMeasurement, MeasurementJob>;
frame: number | null;
}

const queues = new WeakMap<Window, MeasurementQueue>();

/** Batch geometry reads before React state publication. Overflow UI may update
* on the next frame; this is a scheduling contract, not a scroll speed guarantee.
*/
export function scheduleOverflowMeasurement(view: Window, read: ReadMeasurement): void {
let queue = queues.get(view);
if (!queue) {
queue = { pending: new Map(), active: new Map(), frame: null };
queues.set(view, queue);
}
// A new request supersedes any result already read in the current batch.
const active = queue.active.get(read);
if (active) active.cancelled = true;
queue.pending.set(read, { read, cancelled: false });
if (queue.frame !== null) return;
const current = queue;
current.frame = view.requestAnimationFrame(() => {
current.frame = null;
// Detach this batch so reentrant requests survive for the next frame.
const batch = current.pending;
current.pending = new Map();
current.active = batch;
const publications: Array<{ job: MeasurementJob; publish: () => void }> = [];
const report = (error: unknown) => {
// Preserve uncaught-error reporting without aborting other labels.
view.setTimeout(() => { throw error; }, 0);
};
try {
for (const job of batch.values()) {
if (job.cancelled) continue;
try {
const publish = job.read();
if (publish) publications.push({ job, publish });
} catch (error) { report(error); }
}
for (const { job, publish } of publications) {
if (job.cancelled) continue;
try { publish(); } catch (error) { report(error); }
}
} finally {
current.active = new Map();
}
});
}

export function cancelOverflowMeasurement(view: Window, read: ReadMeasurement): void {
const queue = queues.get(view);
if (!queue) return;
const active = queue.active.get(read);
if (active) active.cancelled = true;
queue.pending.delete(read);
if (queue.pending.size === 0 && queue.frame !== null) {
view.cancelAnimationFrame(queue.frame);
queue.frame = null;
}
}
3 changes: 2 additions & 1 deletion design-system/packages/ui/tests/overflow-text.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ test("OverflowText measures real clipping for fade and marquee treatments", asyn
assert.match(source, /measurementRef\.current/);
assert.match(
source,
/useIsomorphicLayoutEffect\(\(\) => \{\s*updateOverflow\(\);\s*\}, \[behavior, children, lines, overflowStyle, updateOverflow\]\);/s,
/scheduleOverflowMeasurement\(view, readOverflow\)/,
);
assert.match(source, /cancelOverflowMeasurement\(view, readOverflow\)/);
assert.match(source, /new ResizeObserver\(updateOverflow\)/);
assert.match(source, /resizeObserver\?\.observe\(contentRef\.current\)/);
assert.match(source, /--_overflow-text-marquee-distance/);
Expand Down
7 changes: 7 additions & 0 deletions src/web-ui/AGENTS-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ Remote Connect 使用全局 GitHub 账户和官方版本化 Relay。账户控件
`i18nService.t(...)` 必须有 bootstrap namespace 覆盖。
- 遵循 `src/web-ui/LOGGING.md`:仅英文、无 emoji、结构化日志

## 动态界面的 CSS 失效范围

- 对频繁增删 DOM 的组件自有兄弟关系样式,优先使用专属类名,避免仅按标签或通配符匹配。CSS Modules 和祖先作用域不能隔离浏览器的样式失效工作。
- 现有布局与间距语义允许时使用 flex/grid 的 `gap`;不要机械地将同类相邻关系替换为 `:not(:first-child)`,也不要仅为此切换布局模式。
- 生成内容可以保留语义标签选择器。对实测热点添加渲染器专属类名;用 `:where(.owned-class)` 限定复合选择器时可保持原优先级。保留内嵌 HTML、嵌套列表、混合表格单元格及数学内容的行为。
- 改写后核对匹配集合与层叠效果。短时 Selector Stats 录制用于定位失效原因,普通录制用于验证收益;失效次数不等于可感知提升。不全面禁止兄弟选择器。

## 命令

这里只维护开发/构建入口;验证命令统一放在下方“验证”章节。
Expand Down
7 changes: 7 additions & 0 deletions src/web-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ workspace connections remain independent of Relay sign-in.
namespace coverage.
- Follow `src/web-ui/LOGGING.md`: English only, no emojis, structured logs

## CSS invalidation in dynamic views

- For component-owned sibling styles in frequently changing DOM, prefer dedicated classes over tag-only or universal sibling selectors. CSS Modules and an ancestor scope do not isolate browser invalidation work.
- Use flex/grid `gap` when the existing layout and spacing semantics permit it. Do not mechanically replace same-tag adjacency with `:not(:first-child)` or change layout mode.
- Generated content may retain semantic tag selectors. For measured hotspots, add renderer-owned classes; `:where(.owned-class)` can narrow a compound selector without increasing specificity. Preserve raw HTML, nested lists, mixed table cells, and math rendering behavior.
- Check matching and cascade equivalence when rewriting selectors. Use short Selector Stats captures to identify invalidation causes and ordinary traces to measure speedups; invalidation counts alone do not establish user-visible gains. Do not impose a blanket ban on sibling selectors.

## Commands

Keep development/build entry points here. Verification commands are maintained
Expand Down
51 changes: 18 additions & 33 deletions src/web-ui/eslint.fence.regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* `adapters/**`. Run via `pnpm vitest run`.
*/
import { describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { ESLint } from 'eslint';
import { resolve } from 'node:path';

const webUiRoot = resolve(__dirname);
Expand Down Expand Up @@ -69,43 +69,27 @@ const cases: ProbeCase[] = [
},
];

/**
* Resolve the eslint CLI entry as an absolute path and run it with `node`,
* without a shell. Going through `pnpm`/`pnpm.cmd` needed `shell: true` on
* Windows (a `.cmd` shim cannot be spawned with `shell: false`), which triggers
* Node's DEP0190 security deprecation. Running the eslint JS entry directly
* via `node` keeps `shell: false` on every platform and avoids the warning.
*/
function eslintBinPath(): string {
return resolve(webUiRoot, 'node_modules/eslint/bin/eslint.js');
}
// Exercise the real file-selected config with one ESLint instance. Spawning a
// fresh CLI for each probe made cold Node/config loading exceed the test budget
// under the full suite's worker load. Load config during module collection,
// like the other imports, and keep assertions about the actual rule outcomes.
const eslint = new ESLint({ cwd: webUiRoot });
await eslint.calculateConfigForFile(cases[0].filename);

function lintProbe(probe: ProbeCase): { hasError: boolean; output: string } {
// --stdin + --stdin-filename make the rule's path selectors see the probe as
// if it lived at that path, so the fence applies per the probe's location.
const args = [
eslintBinPath(),
'--stdin',
'--stdin-filename',
probe.filename,
];
const result = spawnSync(process.execPath, args, {
cwd: webUiRoot,
input: probe.source,
encoding: 'utf8',
shell: false,
});
const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`;
// ESLint exits non-zero and reports the restricted-imports/syntax error when
// the fence fires; a clean probe exits 0 with no error lines.
const hasError = /no-restricted-(imports|syntax)/.test(combined);
return { hasError, output: combined };
async function lintProbe(probe: ProbeCase): Promise<{ hasError: boolean; errorCount: number; output: string }> {
const results = await eslint.lintText(probe.source, { filePath: probe.filename });
const messages = results.flatMap(result => result.messages);
return {
hasError: messages.some(message => /^no-restricted-(imports|syntax)$/.test(message.ruleId ?? '')),
errorCount: results.reduce((count, result) => count + result.errorCount, 0),
output: messages.map(message => `${message.ruleId ?? 'config'}: ${message.message}`).join('\n'),
};
}

describe('adapter fence regression', () => {
for (const probe of cases) {
it(probe.name, () => {
const { hasError, output } = lintProbe(probe);
it(probe.name, async () => {
const { hasError, errorCount, output } = await lintProbe(probe);
if (probe.expectError) {
expect(
hasError,
Expand All @@ -116,6 +100,7 @@ describe('adapter fence regression', () => {
hasError,
`expected the adapter exception to allow invoke at ${probe.filename}, but the fence fired:\n${output}`,
).toBe(false);
expect(errorCount, output).toBe(0);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const error = new TauriCommandError('Command failed', {

beforeEach(async () => {
const { JSDOM } = await import('jsdom');
dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost', pretendToBeVisual: true });
vi.stubGlobal('window', dom.window);
vi.stubGlobal('document', dom.window.document);
vi.stubGlobal('localStorage', dom.window.localStorage);
Expand Down
12 changes: 8 additions & 4 deletions src/web-ui/src/features/dispatch/DispatchInstallDialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,20 @@
display: grid;
gap: 0;

// The former div + div rule invalidated unrelated transcript divs in Chromium.
// Match owned check-row classes instead. Equal-DOM trace sample after the
// metadata fix: 93 added / 1161 retained elements, style 46 -> 23ms and
// elementCount 3111 -> 1432 (single desktop run, not a performance guarantee).
> .dispatch-install-dialog__check-row + .dispatch-install-dialog__check-row {
border-top: 1px solid var(--openbitfun-color-border-subtle);
}

> div {
display: grid;
grid-template-columns: minmax(104px, auto) minmax(0, 1fr);
gap: var(--openbitfun-space-2);
padding: var(--openbitfun-space-2) 0;

+ div {
border-top: 1px solid var(--openbitfun-color-border-subtle);
}

> span {
color: var(--openbitfun-color-content-muted);
font-size: var(--openbitfun-type-label-sm-font-size);
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ export const DispatchInstallDialog: React.FC<DispatchInstallDialogProps> = ({
{probe ? (
<div className="dispatch-install-dialog__checks">
<div
className="dispatch-install-dialog__check-row"
data-state={
cliReady ? 'ok' : installPending || preparationPhase ? 'pending' : 'blocked'
}
Expand Down
21 changes: 15 additions & 6 deletions src/web-ui/src/flow_chat/components/modern/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ before reporting a defect as new.

- Deciding *that* a history boundary is worth asking about belongs to
`flowChatHistoryBoundary.ts` and reads only a visible item range and the
scroll distance to each end. Deciding whether the ask is honoured stays in the
container, which declines while follow-output owns the viewport and until the
visible range has left that boundary since the last page.
scroll distance to each end. `flowChatHistoryPager.ts` owns request eligibility;
the list feeds it reader intent and completed layout commits, and refuses asks
while opening or follow-output owns the viewport.
- A page asks for what lies past the *rendered* transcript, never past the
window the store cut. The continuous projection makes those differ, and the
window's end is then an ordinal already on screen.
Expand All @@ -124,9 +124,18 @@ before reporting a defect as new.
- The ask goes out a screenful before the boundary, so the junction lands off
screen. Do not express that lead in items: one item here is anything from a
38px user message to a 5012px model round.
- The arming latch re-arms from `historyBoundariesReached`, never from the ask.
Sharing one predicate makes a boundary the reader can never be off, and the
direction stays disarmed for the rest of the session.
- Request completion and presentation layout are separate events, in either
order. A page must pass both before another page can dispatch. Notify layout
only after measurement/prepend compensation; do not use timers as commit proof.
- After the first ask, another page requires fresh directional reader demand.
Coalesce demand received during a request; clear it when the reader reverses
or the boundary leaves the prefetch lead. Layout/resize/compensation alone
must not create demand. Do not require a prefetch to reach the physical edge.
- Bind async outcomes and pre-commit permission to request tickets. Navigation
invalidates old tickets; an old completion cannot clear or exhaust a new ask.
- Derive native reader travel through the viewport register's accounting for
synchronous writes/shifts. Exclude owned smooth navigation/follow scrolls;
unowned inertia still counts. A gesture at the physical edge needs no travel.
- A *visible* item range is `getVisibleItemRange`, never the rendered rows. The
rendered window carries overscan and reports both ends present for any
transcript short enough to render whole.
Expand Down
Loading
Loading