dsh-better-display 0.3.8 → 0.3.9
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.
- package/CHANGELOG.md +11 -0
- package/DESIGN.md +43 -3
- package/README.en.md +9 -1
- package/README.md +9 -1
- package/docs/dependency-update-policy.md +9 -0
- package/docs/extension-points.md +172 -0
- package/docs/official-rendering-bridge.md +48 -19
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1417 -490
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +378 -144
- package/lib/types/client/AssistantNode.d.ts +20 -0
- package/lib/types/client/AssistantNode.d.ts.map +1 -0
- package/lib/types/client/AssistantNode.js +46 -0
- package/lib/types/client/AssistantNode.js.map +1 -0
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +33 -10
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
- package/lib/types/client/ChoreographedFlow.js +2 -0
- package/lib/types/client/ChoreographedFlow.js.map +1 -1
- package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
- package/lib/types/client/ClosedProcessSummary.js +3 -1
- package/lib/types/client/ClosedProcessSummary.js.map +1 -1
- package/lib/types/client/McpAppFrame.d.ts.map +1 -1
- package/lib/types/client/McpAppFrame.js +5 -11
- package/lib/types/client/McpAppFrame.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +17 -3
- package/lib/types/client/OfficialContent.d.ts.map +1 -1
- package/lib/types/client/OfficialContent.js +33 -7
- package/lib/types/client/OfficialContent.js.map +1 -1
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +43 -71
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/SettingsSection.js +0 -5
- package/lib/types/client/SettingsSection.js.map +1 -1
- package/lib/types/client/StickyLane.d.ts +12 -0
- package/lib/types/client/StickyLane.d.ts.map +1 -1
- package/lib/types/client/StickyLane.js +78 -2
- package/lib/types/client/StickyLane.js.map +1 -1
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/index.d.ts +3 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +6 -0
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/live-turn.d.ts +17 -5
- package/lib/types/client/live-turn.d.ts.map +1 -1
- package/lib/types/client/live-turn.js +106 -76
- package/lib/types/client/live-turn.js.map +1 -1
- package/lib/types/client/motion.d.ts +26 -2
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +136 -10
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +9 -0
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +22 -7
- package/lib/types/client/official-slots.js.map +1 -1
- package/lib/types/client/projection.d.ts +15 -2
- package/lib/types/client/projection.d.ts.map +1 -1
- package/lib/types/client/projection.js +16 -4
- package/lib/types/client/projection.js.map +1 -1
- package/lib/types/client/rewind-cut.d.ts +131 -0
- package/lib/types/client/rewind-cut.d.ts.map +1 -0
- package/lib/types/client/rewind-cut.js +321 -0
- package/lib/types/client/rewind-cut.js.map +1 -0
- package/lib/types/client/text-transform.d.ts +93 -0
- package/lib/types/client/text-transform.d.ts.map +1 -0
- package/lib/types/client/text-transform.js +183 -0
- package/lib/types/client/text-transform.js.map +1 -0
- package/lib/types/client/timeline.d.ts.map +1 -1
- package/lib/types/client/timeline.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +9 -2
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +42 -2
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/turn-slots.d.ts +29 -0
- package/lib/types/client/turn-slots.d.ts.map +1 -0
- package/lib/types/client/turn-slots.js +62 -0
- package/lib/types/client/turn-slots.js.map +1 -0
- package/lib/types/client/types.d.ts +18 -0
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/dsh-better-display.d.ts +4 -1
- package/lib/types/dsh-better-display.d.ts.map +1 -1
- package/lib/types/dsh-better-display.js +56 -7
- package/lib/types/dsh-better-display.js.map +1 -1
- package/lib/types/flags.d.ts +7 -0
- package/lib/types/flags.d.ts.map +1 -0
- package/lib/types/flags.js +37 -0
- package/lib/types/flags.js.map +1 -0
- package/lib/types/log.d.ts +17 -0
- package/lib/types/log.d.ts.map +1 -0
- package/lib/types/log.js +122 -0
- package/lib/types/log.js.map +1 -0
- package/lib/types/metrics.d.ts +18 -0
- package/lib/types/metrics.d.ts.map +1 -0
- package/lib/types/metrics.js +35 -0
- package/lib/types/metrics.js.map +1 -0
- package/package.json +36 -5
- package/scripts/changelog.mjs +20 -0
- package/scripts/check-agents-md.mjs +67 -0
- package/scripts/check-bundle-size.mjs +38 -0
- package/scripts/check-dead-flags.mjs +55 -0
- package/scripts/check-todo-debt.mjs +69 -0
- package/scripts/security-audit-report.mjs +92 -0
- package/scripts/verify-commentary-visible.mjs +627 -0
- package/src/client/AssistantNode.tsx +72 -0
- package/src/client/Blocks.tsx +49 -10
- package/src/client/ChoreographedFlow.tsx +3 -1
- package/src/client/ClosedProcessSummary.tsx +3 -1
- package/src/client/McpAppFrame.tsx +5 -13
- package/src/client/OfficialContent.tsx +47 -9
- package/src/client/Reader.module.css +68 -18
- package/src/client/Reader.tsx +36 -76
- package/src/client/SettingsSection.tsx +1 -7
- package/src/client/StickyLane.tsx +81 -2
- package/src/client/ToolActivity.tsx +1 -1
- package/src/client/index.tsx +8 -1
- package/src/client/live-turn.ts +100 -69
- package/src/client/motion.tsx +135 -7
- package/src/client/official-slots.tsx +23 -7
- package/src/client/projection.ts +16 -4
- package/src/client/rewind-cut.ts +340 -0
- package/src/client/text-transform.ts +265 -0
- package/src/client/timeline.ts +0 -2
- package/src/client/tool-activity.ts +49 -2
- package/src/client/turn-slots.ts +80 -0
- package/src/client/types.ts +19 -0
- package/src/dsh-better-display.ts +58 -7
- package/src/flags.ts +40 -0
- package/src/log.ts +137 -0
- package/src/metrics.ts +50 -0
- package/tests/browser-commentary.tsx +191 -0
- package/tests/chat-flow-hook.test.ts +1 -1
- package/tests/cloud-gate.test.ts +1 -1
- package/tests/context-tool-update.test.ts +1 -1
- package/tests/conversation-scroll.test.ts +1 -1
- package/tests/deliverables.test.ts +1 -1
- package/tests/entry.test.ts +1 -1
- package/tests/fixtures/reader-lane-owners.tsx +46 -0
- package/tests/fixtures/reader-official-users.tsx +64 -0
- package/tests/fixtures/reader-theme-overlap.html +80 -0
- package/tests/fixtures/reader-user-bubble.tsx +222 -0
- package/tests/flags.test.ts +66 -0
- package/tests/fold-choreography.test.ts +32 -8
- package/tests/fold-intensity.test.ts +11 -6
- package/tests/footer-precedence.test.ts +1 -1
- package/tests/history-word-regression.test.ts +1 -1
- package/tests/live-turn.test.ts +125 -54
- package/tests/log.test.ts +99 -0
- package/tests/mcp-apps.test.ts +1 -1
- package/tests/message-chrome.test.ts +1 -1
- package/tests/metrics.test.ts +52 -0
- package/tests/official-slots.test.ts +41 -2
- package/tests/open-file.test.ts +1 -1
- package/tests/pending-submission.test.ts +12 -1
- package/tests/pinned-selection.test.ts +16 -0
- package/tests/platform-media.test.ts +1 -1
- package/tests/projection.test.ts +66 -3
- package/tests/reader-glass-css.test.ts +1 -1
- package/tests/reader-official-users.test.ts +11 -1
- package/tests/reader-sticky-surface.test.ts +377 -0
- package/tests/reader-theme-lanes.test.ts +54 -0
- package/tests/reader-theme-overlap.test.ts +151 -0
- package/tests/reader-user-bubble.test.ts +137 -0
- package/tests/reasoning-follow.test.ts +1 -1
- package/tests/retiring-content.test.ts +98 -6
- package/tests/reveal.test.ts +3 -3
- package/tests/rewind-cut.test.ts +1090 -0
- package/tests/settings-copy.test.ts +1 -1
- package/tests/skill-status.test.ts +1 -1
- package/tests/stock-install.test.ts +1 -1
- package/tests/stream-buffer.test.ts +1 -1
- package/tests/styles.test.ts +30 -3
- package/tests/terminal-input-preview.test.ts +1 -1
- package/tests/text-transform.test.ts +272 -0
- package/tests/timeline.test.ts +1 -1
- package/tests/tool-clock.test.ts +1 -1
- package/tests/tool-diff.test.ts +1 -1
- package/tests/waiting-clock.test.ts +1 -1
- package/tests/word-timeline.test.ts +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { transform } from 'lightningcss';
|
|
9
|
+
import { test } from 'vitest';
|
|
10
|
+
import { OFFICIAL_SEATS, OFFICIAL_SLOTS } from '../src/client/official-slots.js';
|
|
11
|
+
|
|
12
|
+
// Width is a layout fact of the real Reader and the host user/steering renderer.
|
|
13
|
+
// This skips in CI, which has no pinned Chromium and no Harness tree.
|
|
14
|
+
const harnessPointer = join(homedir(), '.config/dshx/harness');
|
|
15
|
+
const harness = process.env.DSHX_HARNESS?.trim()
|
|
16
|
+
|| (existsSync(harnessPointer) ? readFileSync(harnessPointer, 'utf8').trim() : '');
|
|
17
|
+
const browserRuntime = join(homedir(), '.codex/playwright-runtime/runtime.mjs');
|
|
18
|
+
const skip = !harness ? 'DSH Harness source not available'
|
|
19
|
+
: !existsSync(browserRuntime) ? 'pinned Chromium runtime not available' : false;
|
|
20
|
+
|
|
21
|
+
interface Box { w: number; h: number; font: string; line: string }
|
|
22
|
+
interface Row {
|
|
23
|
+
name: string;
|
|
24
|
+
user: Box; steering: Box; pending: Box;
|
|
25
|
+
nativeUser: Box; nativeSteering: Box;
|
|
26
|
+
readerHtml: string;
|
|
27
|
+
}
|
|
28
|
+
interface BubbleResult {
|
|
29
|
+
error?: string;
|
|
30
|
+
readerSlot?: string;
|
|
31
|
+
nativeSlot?: string;
|
|
32
|
+
rows?: Row[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('reader user and steering bubbles match the host column cap', { skip, timeout: 120_000 }, async () => {
|
|
36
|
+
const root = resolve(import.meta.dirname, '..');
|
|
37
|
+
const require = createRequire(join(root, 'package.json'));
|
|
38
|
+
const { build } = createRequire(require.resolve('tsx'))('esbuild');
|
|
39
|
+
const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
|
|
40
|
+
const out = mkdtempSync(join(tmpdir(), 'reader-user-bubble-'));
|
|
41
|
+
const cssParts: string[] = [];
|
|
42
|
+
try {
|
|
43
|
+
const { launchPinnedChromium } = await import(pathToFileURL(browserRuntime).href);
|
|
44
|
+
await build({
|
|
45
|
+
entryPoints: [join(root, 'tests/fixtures/reader-user-bubble.tsx')],
|
|
46
|
+
outfile: join(out, 'fixture.js'), bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
|
|
47
|
+
alias: {
|
|
48
|
+
'@fixture/registry': join(harness, 'packages/client/ui-renderer/src/client/registry.ts'),
|
|
49
|
+
'@fixture/renderer': join(harness, 'packages/client/ui-renderer/src/client/scoped-slots.tsx'),
|
|
50
|
+
'@fixture/user-message': join(harness, 'packages/client/ui-chat/src/client/chat/MessageItem.tsx'),
|
|
51
|
+
'@deepseek-ai/dsh-client-ui-primitives': join(harness, 'packages/client/ui-primitives/src/index.ts'),
|
|
52
|
+
'@deepseek-ai/dsh-client-ui-slots': join(harness, 'packages/client/ui-slots/src/index.ts'),
|
|
53
|
+
'@deepseek-ai/dsh-client-store': join(harness, 'packages/client/store/src/index.ts'),
|
|
54
|
+
react: dirname(require.resolve('react/package.json')),
|
|
55
|
+
'react-dom': dirname(webRequire.resolve('react-dom/package.json')),
|
|
56
|
+
},
|
|
57
|
+
loader: { '.woff2': 'empty', '.woff': 'empty', '.ttf': 'empty', '.png': 'empty', '.jpg': 'empty', '.webp': 'empty' },
|
|
58
|
+
plugins: [{
|
|
59
|
+
name: 'css-modules',
|
|
60
|
+
setup(build: { onLoad: Function; onEnd: Function }) {
|
|
61
|
+
build.onLoad({ filter: /\.module\.css$/ }, (args: { path: string }) => {
|
|
62
|
+
const result = transform({ filename: args.path, code: readFileSync(args.path), cssModules: { pattern: 't[hash]_[local]' } });
|
|
63
|
+
const mapping: Record<string, string> = {};
|
|
64
|
+
for (const [key, value] of Object.entries(result.exports ?? {})) mapping[key] = value.name;
|
|
65
|
+
cssParts.push(Buffer.from(result.code).toString('utf8'));
|
|
66
|
+
return { contents: `export default ${JSON.stringify(mapping)};`, loader: 'js' };
|
|
67
|
+
});
|
|
68
|
+
build.onLoad({ filter: /\.css$/ }, (args: { path: string }) => {
|
|
69
|
+
cssParts.push(readFileSync(args.path, 'utf8'));
|
|
70
|
+
return { contents: 'export default {};', loader: 'js' };
|
|
71
|
+
});
|
|
72
|
+
build.onLoad({ filter: /\.svg$/ }, () => ({ contents: 'export default function Svg(){return null}', loader: 'js' }));
|
|
73
|
+
build.onEnd(() => { writeFileSync(join(out, 'fixture.css'), cssParts.join('\n')); });
|
|
74
|
+
},
|
|
75
|
+
}],
|
|
76
|
+
logLevel: 'silent',
|
|
77
|
+
});
|
|
78
|
+
const server = createServer((request, response) => {
|
|
79
|
+
if (request.url === '/fixture.js') {
|
|
80
|
+
response.setHeader('Content-Type', 'text/javascript');
|
|
81
|
+
response.end(readFileSync(join(out, 'fixture.js')));
|
|
82
|
+
} else if (request.url === '/fixture.css') {
|
|
83
|
+
response.setHeader('Content-Type', 'text/css');
|
|
84
|
+
response.end(readFileSync(join(out, 'fixture.css')));
|
|
85
|
+
} else {
|
|
86
|
+
response.setHeader('Content-Type', 'text/html');
|
|
87
|
+
response.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/fixture.css"><style>html,body{margin:0;font-family:"PingFang SC","Hiragino Sans GB",sans-serif}</style><div id="app"></div><script type="module" src="/fixture.js"></script>');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
await new Promise<void>(resolveListen => server.listen(0, '127.0.0.1', resolveListen));
|
|
91
|
+
let browser;
|
|
92
|
+
try {
|
|
93
|
+
browser = await launchPinnedChromium();
|
|
94
|
+
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
|
95
|
+
const errors: string[] = [];
|
|
96
|
+
page.on('pageerror', (error: Error) => errors.push(String(error)));
|
|
97
|
+
page.on('console', (message: { type(): string; text(): string }) => {
|
|
98
|
+
if (message.type() === 'error') errors.push(message.text());
|
|
99
|
+
});
|
|
100
|
+
const address = server.address();
|
|
101
|
+
assert.ok(address && typeof address === 'object');
|
|
102
|
+
await page.goto(`http://127.0.0.1:${address.port}`);
|
|
103
|
+
await page.waitForFunction(() => 'readerBubbleResult' in window, undefined, { timeout: 20000 });
|
|
104
|
+
const result = await page.evaluate(() => Reflect.get(window, 'readerBubbleResult')) as BubbleResult;
|
|
105
|
+
assert.equal(result.error, undefined, result.error);
|
|
106
|
+
assert.deepEqual(errors, []);
|
|
107
|
+
assert.equal(result.readerSlot, OFFICIAL_SEATS.nodes);
|
|
108
|
+
assert.equal(result.nativeSlot, OFFICIAL_SLOTS.nodes);
|
|
109
|
+
assert.notEqual(result.readerSlot, 'conversation.chat.node');
|
|
110
|
+
for (const row of result.rows ?? []) {
|
|
111
|
+
assert.equal(row.user.w, row.nativeUser.w, `${row.name} user width ${row.user.w} != ${row.nativeUser.w} ${row.readerHtml}`);
|
|
112
|
+
assert.equal(row.user.h, row.nativeUser.h, `${row.name} user height`);
|
|
113
|
+
assert.equal(row.steering.w, row.nativeSteering.w, `${row.name} steering width`);
|
|
114
|
+
assert.equal(row.steering.h, row.nativeSteering.h, `${row.name} steering height`);
|
|
115
|
+
assert.equal(row.pending.w, row.nativeUser.w, `${row.name} pending width`);
|
|
116
|
+
assert.equal(row.pending.h, row.nativeUser.h, `${row.name} pending height`);
|
|
117
|
+
assert.equal(row.pending.font, '14px', `${row.name} pending font`);
|
|
118
|
+
assert.equal(row.pending.line, '22px', `${row.name} pending line`);
|
|
119
|
+
assert.equal(row.nativeUser.font, '14px', `${row.name} native font`);
|
|
120
|
+
assert.equal(row.nativeUser.line, '22px', `${row.name} native line`);
|
|
121
|
+
}
|
|
122
|
+
const byName = Object.fromEntries((result.rows ?? []).map(row => [row.name, row]));
|
|
123
|
+
assert.ok(byName.short.user.h < 50, 'a four-glyph message stays on one line');
|
|
124
|
+
assert.ok(byName.short.user.w < 160, 'a four-glyph message stays content-sized');
|
|
125
|
+
assert.ok(byName.thirty.user.h < 50, 'thirty glyphs fit on one line at the default column');
|
|
126
|
+
assert.ok(Math.abs(byName.long.user.w - 748 * 0.702) < 1, 'the default cap follows 748 * 0.702');
|
|
127
|
+
assert.ok(byName['wide-axis'].user.w > 540, 'a wider content axis is not stuck at 525px');
|
|
128
|
+
assert.ok(Math.abs(byName.narrow.user.w - 480 * 0.82) < 1, 'a narrow column uses 82% of the column');
|
|
129
|
+
assert.ok(byName.short.steering.h < 50, 'a short steering message stays on one line');
|
|
130
|
+
} finally {
|
|
131
|
+
await browser?.close();
|
|
132
|
+
await new Promise<void>(resolveClose => server.close(() => resolveClose()));
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(out, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from '
|
|
2
|
+
import { test } from 'vitest';
|
|
3
3
|
import { reasoningTarget } from '../src/client/reasoning-follow.js';
|
|
4
4
|
|
|
5
5
|
test('reasoning follows ordinary growth in two-line steps without passing the tail', () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from '
|
|
2
|
+
import { test } from 'vitest';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { runInNewContext } from 'node:vm';
|
|
@@ -14,7 +14,7 @@ const compiled = ts.transpileModule(`const EASING = 'cubic-bezier(.22,1,.36,1)';
|
|
|
14
14
|
|
|
15
15
|
// Drive the component's real effect with deterministic hooks and a compositor
|
|
16
16
|
// that never finishes unless the test explicitly sends onfinish.
|
|
17
|
-
function mount() {
|
|
17
|
+
function mount(options?: { containsFocus?: boolean; hidden?: boolean }) {
|
|
18
18
|
let cursor = 0;
|
|
19
19
|
let enabled = true;
|
|
20
20
|
const hooks: unknown[] = [];
|
|
@@ -23,8 +23,22 @@ function mount() {
|
|
|
23
23
|
let timerId = 0;
|
|
24
24
|
const timers = new Map<number, { run: () => void; duration: number }>();
|
|
25
25
|
const animations: Array<{ onfinish?: () => void; cancelled: number }> = [];
|
|
26
|
+
const cutFocus = { id: 'focus-cut' };
|
|
27
|
+
const outside = { id: 'outside' };
|
|
28
|
+
const listeners = new Set<() => void>();
|
|
29
|
+
const column = {
|
|
30
|
+
id: 'reader-column',
|
|
31
|
+
tabIndex: -1,
|
|
32
|
+
focus() { page.active = column; },
|
|
33
|
+
};
|
|
34
|
+
const page = {
|
|
35
|
+
hidden: options?.hidden ?? false,
|
|
36
|
+
active: (options?.containsFocus ? cutFocus : null) as { id: string } | null,
|
|
37
|
+
};
|
|
26
38
|
const element = {
|
|
27
|
-
contains: () =>
|
|
39
|
+
contains: (node: unknown) => node === cutFocus,
|
|
40
|
+
closest: (selector: string) => selector === '[data-chat-flow]' ? column : null,
|
|
41
|
+
parentElement: null,
|
|
28
42
|
getBoundingClientRect: () => ({ height: 64 }),
|
|
29
43
|
animate: (_frames: unknown, options: { duration: number; fill: string }) => {
|
|
30
44
|
assert.equal(options.duration, 220);
|
|
@@ -45,7 +59,14 @@ function mount() {
|
|
|
45
59
|
require: createRequire(import.meta.url),
|
|
46
60
|
css: { retiringContent: 'retiring' },
|
|
47
61
|
StreamMotionContext: {},
|
|
48
|
-
|
|
62
|
+
HTMLElement: globalThis.HTMLElement,
|
|
63
|
+
Element: globalThis.Element,
|
|
64
|
+
document: {
|
|
65
|
+
get activeElement() { return page.active; },
|
|
66
|
+
get hidden() { return page.hidden; },
|
|
67
|
+
addEventListener: (type: string, listener: () => void) => { if (type === 'visibilitychange') listeners.add(listener); },
|
|
68
|
+
removeEventListener: (type: string, listener: () => void) => { if (type === 'visibilitychange') listeners.delete(listener); },
|
|
69
|
+
},
|
|
49
70
|
window: {
|
|
50
71
|
setTimeout: (run: () => void, duration: number) => { timers.set(++timerId, { run, duration }); return timerId; },
|
|
51
72
|
clearTimeout: (id: number) => { timers.delete(id); },
|
|
@@ -65,10 +86,13 @@ function mount() {
|
|
|
65
86
|
useEffect: effect,
|
|
66
87
|
});
|
|
67
88
|
assert.equal(typeof exports.RetiringContent, 'function');
|
|
89
|
+
type Rendered = { ref: { current: unknown }; props: { onBlur: (event: unknown) => void } };
|
|
90
|
+
let last: Rendered | null = null;
|
|
68
91
|
const render = (visible: boolean) => {
|
|
69
92
|
cursor = 0;
|
|
70
93
|
pending = [];
|
|
71
|
-
const result = exports.RetiringContent!({ visible, children: 'narration' }) as
|
|
94
|
+
const result = exports.RetiringContent!({ visible, children: 'narration' }) as Rendered | null;
|
|
95
|
+
last = result;
|
|
72
96
|
if (result) result.ref.current = element;
|
|
73
97
|
for (const { index, run, deps } of pending) {
|
|
74
98
|
effects.get(index)?.cleanup?.();
|
|
@@ -78,7 +102,17 @@ function mount() {
|
|
|
78
102
|
};
|
|
79
103
|
render(true);
|
|
80
104
|
return {
|
|
81
|
-
render, timers, animations,
|
|
105
|
+
render, timers, animations, listeners,
|
|
106
|
+
focusId: () => page.active?.id ?? '',
|
|
107
|
+
// Move focus out of the cut the way a real blur reports it.
|
|
108
|
+
blur: () => {
|
|
109
|
+
page.active = outside;
|
|
110
|
+
last?.props.onBlur({ currentTarget: element, relatedTarget: outside });
|
|
111
|
+
},
|
|
112
|
+
hide: () => {
|
|
113
|
+
page.hidden = true;
|
|
114
|
+
for (const listener of [...listeners]) listener();
|
|
115
|
+
},
|
|
82
116
|
disableMotion: () => { enabled = false; },
|
|
83
117
|
unmount: () => { for (const effect of effects.values()) effect.cleanup?.(); },
|
|
84
118
|
deadline: () => {
|
|
@@ -135,3 +169,61 @@ test('disabled motion retires immediately without an animation or timer', () =>
|
|
|
135
169
|
assert.equal(view.timers.size, 0);
|
|
136
170
|
view.unmount();
|
|
137
171
|
});
|
|
172
|
+
|
|
173
|
+
test('reduced motion moves focus out of cut content before it unmounts', () => {
|
|
174
|
+
const reduced = mount({ containsFocus: true });
|
|
175
|
+
reduced.disableMotion();
|
|
176
|
+
reduced.render(false);
|
|
177
|
+
assert.equal(reduced.render(false), null);
|
|
178
|
+
assert.equal(reduced.focusId(), 'reader-column');
|
|
179
|
+
assert.equal(reduced.animations.length, 0);
|
|
180
|
+
reduced.unmount();
|
|
181
|
+
|
|
182
|
+
const hidden = mount({ containsFocus: true, hidden: true });
|
|
183
|
+
hidden.render(false);
|
|
184
|
+
assert.equal(hidden.render(false), null);
|
|
185
|
+
assert.equal(hidden.focusId(), 'reader-column');
|
|
186
|
+
assert.equal(hidden.animations.length, 0);
|
|
187
|
+
hidden.unmount();
|
|
188
|
+
|
|
189
|
+
// The animated path still defers while focus is inside the cut.
|
|
190
|
+
const playing = mount({ containsFocus: true });
|
|
191
|
+
assert.ok(playing.render(false));
|
|
192
|
+
assert.equal(playing.focusId(), 'focus-cut');
|
|
193
|
+
assert.equal(playing.animations.length, 0);
|
|
194
|
+
playing.unmount();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('a page hidden while focus defers the collapse retires at once and keeps focus', () => {
|
|
198
|
+
const view = mount({ containsFocus: true });
|
|
199
|
+
assert.ok(view.render(false));
|
|
200
|
+
// The focus-held state re-runs the effect; it must keep waiting, not animate.
|
|
201
|
+
assert.ok(view.render(false));
|
|
202
|
+
assert.equal(view.focusId(), 'focus-cut');
|
|
203
|
+
assert.equal(view.animations.length, 0);
|
|
204
|
+
assert.equal(view.listeners.size, 1, 'the focus-held path still listens for a hidden page');
|
|
205
|
+
view.hide();
|
|
206
|
+
assert.equal(view.render(false), null);
|
|
207
|
+
assert.equal(view.focusId(), 'reader-column');
|
|
208
|
+
assert.equal(view.animations.length, 0);
|
|
209
|
+
assert.equal(view.timers.size, 0);
|
|
210
|
+
view.unmount();
|
|
211
|
+
assert.equal(view.listeners.size, 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('focus leaving the cut still plays the collapse, bounded by its deadline', () => {
|
|
215
|
+
const view = mount({ containsFocus: true });
|
|
216
|
+
view.render(false);
|
|
217
|
+
view.render(false);
|
|
218
|
+
assert.equal(view.animations.length, 0);
|
|
219
|
+
view.blur();
|
|
220
|
+
assert.ok(view.render(false));
|
|
221
|
+
assert.equal(view.animations.length, 1);
|
|
222
|
+
assert.equal(view.listeners.size, 1);
|
|
223
|
+
view.deadline();
|
|
224
|
+
assert.equal(view.render(false), null);
|
|
225
|
+
assert.equal(view.focusId(), 'outside');
|
|
226
|
+
view.unmount();
|
|
227
|
+
assert.equal(view.listeners.size, 0);
|
|
228
|
+
assert.equal(view.timers.size, 0);
|
|
229
|
+
});
|
package/tests/reveal.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from '
|
|
2
|
+
import { test } from 'vitest';
|
|
3
3
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
5
|
import { Socket } from 'node:net';
|
|
@@ -23,9 +23,9 @@ test('absolute parent predicate accepts POSIX, drive and UNC roots only', () =>
|
|
|
23
23
|
assert.equal(isAbsoluteWorkspacePath(dirname('docs/a.txt')), false);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
async function workspace(t: {
|
|
26
|
+
async function workspace(t: { onTestFinished: (cleanup: () => Promise<void>) => void }) {
|
|
27
27
|
const root = await mkdtemp(join(tmpdir(), 'bd-reveal-'));
|
|
28
|
-
t.
|
|
28
|
+
t.onTestFinished(() => rm(root, { recursive: true, force: true }));
|
|
29
29
|
await mkdir(join(root, 'docs'));
|
|
30
30
|
await writeFile(join(root, 'docs', 'report.md'), 'report');
|
|
31
31
|
return root;
|