@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1402 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +774 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +28 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +67 -0
- package/src/launch-layout.ts +47 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ship perf spans to a local OTLP/HTTP trace viewer (kundi) so an interaction's phases read as a
|
|
3
|
+
* callstack. Every export is a no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set. Tracing never throws
|
|
4
|
+
* into the app: a failed POST is swallowed. Attributes are scalar-only, matching the viewer's decoder.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PhaseMark } from "./perf-timings";
|
|
8
|
+
|
|
9
|
+
export type SpanAttributeValue =
|
|
10
|
+
| { stringValue: string }
|
|
11
|
+
| { doubleValue: number }
|
|
12
|
+
| { boolValue: boolean };
|
|
13
|
+
|
|
14
|
+
export interface SpanAttribute {
|
|
15
|
+
key: string;
|
|
16
|
+
value: SpanAttributeValue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TraceSpan {
|
|
20
|
+
traceId: string;
|
|
21
|
+
spanId: string;
|
|
22
|
+
parentSpanId?: string;
|
|
23
|
+
name: string;
|
|
24
|
+
startTimeUnixNano: string;
|
|
25
|
+
endTimeUnixNano: string;
|
|
26
|
+
attributes?: readonly SpanAttribute[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function randomHex(bytes: number): string {
|
|
30
|
+
const buffer = new Uint8Array(bytes);
|
|
31
|
+
crypto.getRandomValues(buffer);
|
|
32
|
+
|
|
33
|
+
return Array.from(buffer, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A 16-byte trace id as 32 lowercase hex chars, per the OTLP wire format. */
|
|
37
|
+
export function newTraceId(): string {
|
|
38
|
+
return randomHex(16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An 8-byte span id as 16 lowercase hex chars, per the OTLP wire format. */
|
|
42
|
+
export function newSpanId(): string {
|
|
43
|
+
return randomHex(8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build the OTLP/JSON `resourceSpans` body the trace viewer's `/v1/traces` decoder reads. */
|
|
47
|
+
export function buildOtlpTracePayload(serviceName: string, spans: readonly TraceSpan[]) {
|
|
48
|
+
return {
|
|
49
|
+
resourceSpans: [
|
|
50
|
+
{
|
|
51
|
+
resource: {
|
|
52
|
+
attributes: [{ key: "service.name", value: { stringValue: serviceName } }],
|
|
53
|
+
},
|
|
54
|
+
scopeSpans: [
|
|
55
|
+
{
|
|
56
|
+
spans: spans.map((span) => ({
|
|
57
|
+
traceId: span.traceId,
|
|
58
|
+
spanId: span.spanId,
|
|
59
|
+
// the viewer reads a missing parent as root; an empty string is the same, without a conditional key
|
|
60
|
+
parentSpanId: span.parentSpanId ?? "",
|
|
61
|
+
name: span.name,
|
|
62
|
+
startTimeUnixNano: span.startTimeUnixNano,
|
|
63
|
+
endTimeUnixNano: span.endTimeUnixNano,
|
|
64
|
+
attributes: (span.attributes ?? []).map((attribute) => ({
|
|
65
|
+
key: attribute.key,
|
|
66
|
+
value: attribute.value,
|
|
67
|
+
})),
|
|
68
|
+
})),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nanosFromPerfMs(milliseconds: number): string {
|
|
77
|
+
const originNs = BigInt(Math.round(performance.timeOrigin * 1e6));
|
|
78
|
+
|
|
79
|
+
return String(originNs + BigInt(Math.round(milliseconds * 1e6)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Turn drained phase marks into a span tree: a `scope` root with one child span per phase, in order. */
|
|
83
|
+
export function phaseMarksToSpans(scope: string, marks: readonly PhaseMark[]): TraceSpan[] {
|
|
84
|
+
const totalMs = marks.reduce((sum, mark) => sum + mark.elapsedMs, 0);
|
|
85
|
+
const endMs = performance.now();
|
|
86
|
+
const startMs = endMs - totalMs;
|
|
87
|
+
const traceId = newTraceId();
|
|
88
|
+
const rootSpanId = newSpanId();
|
|
89
|
+
const root: TraceSpan = {
|
|
90
|
+
traceId,
|
|
91
|
+
spanId: rootSpanId,
|
|
92
|
+
name: `cueloop.${scope}`,
|
|
93
|
+
startTimeUnixNano: nanosFromPerfMs(startMs),
|
|
94
|
+
endTimeUnixNano: nanosFromPerfMs(endMs),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let cursorMs = startMs;
|
|
98
|
+
const children = marks.map((mark) => {
|
|
99
|
+
const phaseStartMs = cursorMs;
|
|
100
|
+
cursorMs += mark.elapsedMs;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
traceId,
|
|
104
|
+
spanId: newSpanId(),
|
|
105
|
+
parentSpanId: rootSpanId,
|
|
106
|
+
name: `cueloop.${scope}.${mark.label}`,
|
|
107
|
+
startTimeUnixNano: nanosFromPerfMs(phaseStartMs),
|
|
108
|
+
endTimeUnixNano: nanosFromPerfMs(cursorMs),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return [root, ...children];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** POST the spans to `${OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces` as OTLP/JSON; a no-op when the endpoint is unset. */
|
|
116
|
+
export async function postOtlpTrace(
|
|
117
|
+
serviceName: string,
|
|
118
|
+
spans: readonly TraceSpan[],
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
121
|
+
if (endpoint === undefined || spans.length === 0) return;
|
|
122
|
+
|
|
123
|
+
// strip a trailing slash so an endpoint like "http://host:4318/" does not become "//v1/traces"
|
|
124
|
+
const base = endpoint.replace(/\/+$/, "");
|
|
125
|
+
await fetch(`${base}/v1/traces`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "content-type": "application/json" },
|
|
128
|
+
body: JSON.stringify(buildOtlpTracePayload(serviceName, spans)),
|
|
129
|
+
}).catch(() => undefined);
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report the drained perf marks for a scope two ways from one set of instrumentation: a phase block on
|
|
3
|
+
* stderr when CUELOOP_PERF=1, and a kundi span tree when the OTLP endpoint is set. No-op when neither is on.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatPerfBlock, takePerfMarks } from "./perf-timings";
|
|
7
|
+
import { phaseMarksToSpans, postOtlpTrace } from "./otlp-trace";
|
|
8
|
+
|
|
9
|
+
const PERF_SERVICE_NAME = "cueloop-client";
|
|
10
|
+
|
|
11
|
+
export function reportPerfMarks(scope: string): void {
|
|
12
|
+
const marks = takePerfMarks();
|
|
13
|
+
if (marks.length === 0) return;
|
|
14
|
+
|
|
15
|
+
if (process.env.CUELOOP_PERF === "1") process.stderr.write(formatPerfBlock(scope, marks));
|
|
16
|
+
void postOtlpTrace(PERF_SERVICE_NAME, phaseMarksToSpans(scope, marks));
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { formatPerfBlock } from "./perf-timings";
|
|
3
|
+
|
|
4
|
+
test("formatPerfBlock renders labeled rows and a summed TOTAL", () => {
|
|
5
|
+
const block = formatPerfBlock("startup", [
|
|
6
|
+
{ label: "connect", elapsedMs: 12.34 },
|
|
7
|
+
{ label: "firstRender", elapsedMs: 7.66 },
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
expect(block).toContain("--- perf startup ---");
|
|
11
|
+
expect(block).toContain("connect 12.3ms");
|
|
12
|
+
expect(block).toContain("firstRender 7.7ms");
|
|
13
|
+
expect(block).toContain("TOTAL 20.0ms");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("formatPerfBlock totals to zero for no marks", () => {
|
|
17
|
+
expect(formatPerfBlock("startup", [])).toContain("TOTAL 0.0ms");
|
|
18
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Startup phase timing for the perf harness: records elapsed ms between marks and flushes a
|
|
2
|
+
* labeled block to stderr. Every entry point is a no-op unless the CUELOOP_PERF env var is "1". */
|
|
3
|
+
|
|
4
|
+
export interface PhaseMark {
|
|
5
|
+
label: string;
|
|
6
|
+
elapsedMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const phaseMarks: PhaseMark[] = [];
|
|
10
|
+
let lastMarkAt: number | null = null;
|
|
11
|
+
|
|
12
|
+
/** Marks record when the stderr timer (CUELOOP_PERF=1) or the kundi exporter (OTEL endpoint) is on. */
|
|
13
|
+
function perfInstrumentationEnabled(): boolean {
|
|
14
|
+
return process.env.CUELOOP_PERF === "1" || process.env.OTEL_EXPORTER_OTLP_ENDPOINT !== undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Record the elapsed ms since the previous mark (or process start) under `label`. */
|
|
18
|
+
export function perfMark(label: string): void {
|
|
19
|
+
if (!perfInstrumentationEnabled()) return;
|
|
20
|
+
const now = performance.now();
|
|
21
|
+
phaseMarks.push({ label, elapsedMs: now - (lastMarkAt ?? 0) });
|
|
22
|
+
lastMarkAt = now;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Drain the recorded marks for a reporter to format or export; empties the buffer for the next scope. */
|
|
26
|
+
export function takePerfMarks(): PhaseMark[] {
|
|
27
|
+
lastMarkAt = null;
|
|
28
|
+
|
|
29
|
+
return phaseMarks.splice(0, phaseMarks.length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render phase deltas as the stderr block: a header, one indented row per mark, a summed TOTAL. */
|
|
33
|
+
export function formatPerfBlock(scope: string, marks: readonly PhaseMark[]): string {
|
|
34
|
+
const totalMs = marks.reduce((sum, mark) => sum + mark.elapsedMs, 0);
|
|
35
|
+
const rows = marks.map((mark) => ` ${mark.label} ${mark.elapsedMs.toFixed(1)}ms`);
|
|
36
|
+
|
|
37
|
+
return [`--- perf ${scope} ---`, ...rows, ` TOTAL ${totalMs.toFixed(1)}ms`, ""].join("\n");
|
|
38
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
closePrototypeBrowser,
|
|
4
|
+
cssBoxToCell,
|
|
5
|
+
imageCellToCss,
|
|
6
|
+
setPrototypeBrowserForTest,
|
|
7
|
+
} from "./prototype-browser";
|
|
8
|
+
|
|
9
|
+
const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
|
|
10
|
+
const VIEWPORT = { width: 1280, height: 720 };
|
|
11
|
+
|
|
12
|
+
describe("imageCellToCss", () => {
|
|
13
|
+
test("maps a cell inside the image to the center of its viewport pixel band", () => {
|
|
14
|
+
// Act - the image's top-left cell (1,1)
|
|
15
|
+
const css = imageCellToCss({ x: 1, y: 1 }, IMAGE, VIEWPORT)!;
|
|
16
|
+
|
|
17
|
+
// Assert - half a cell into the first band
|
|
18
|
+
expect(css.x).toBeCloseTo((0.5 / 80) * 1280, 5);
|
|
19
|
+
expect(css.y).toBeCloseTo((0.5 / 40) * 720, 5);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a click below the image's origin lands deeper in the page", () => {
|
|
23
|
+
// Act
|
|
24
|
+
const top = imageCellToCss({ x: 40, y: 2 }, IMAGE, VIEWPORT)!;
|
|
25
|
+
const bottom = imageCellToCss({ x: 40, y: 38 }, IMAGE, VIEWPORT)!;
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(bottom.y).toBeGreaterThan(top.y);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a cell outside the image returns null", () => {
|
|
32
|
+
// Assert - the gutter left of and the row above the image
|
|
33
|
+
expect(imageCellToCss({ x: 0, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
|
|
34
|
+
expect(imageCellToCss({ x: 10, y: 0 }, IMAGE, VIEWPORT)).toBeNull();
|
|
35
|
+
expect(imageCellToCss({ x: 200, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("cssBoxToCell", () => {
|
|
40
|
+
test("maps a viewport rect back to the image cells covering it", () => {
|
|
41
|
+
// Arrange - a rect covering the right half, lower half of the page
|
|
42
|
+
const box = { x: 640, y: 360, width: 320, height: 180 };
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
const cell = cssBoxToCell(box, IMAGE, VIEWPORT);
|
|
46
|
+
|
|
47
|
+
// Assert - x at half width -> 40 cells in from the image origin (1)
|
|
48
|
+
expect(cell.column).toBe(1 + Math.floor((640 / 1280) * 80));
|
|
49
|
+
expect(cell.row).toBe(1 + Math.floor((360 / 720) * 40));
|
|
50
|
+
expect(cell.columns).toBe(Math.round((320 / 1280) * 80));
|
|
51
|
+
expect(cell.rows).toBe(Math.round((180 / 720) * 40));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a zero-size rect still reserves at least one cell", () => {
|
|
55
|
+
// Assert
|
|
56
|
+
const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
|
|
57
|
+
|
|
58
|
+
expect(cell.columns).toBe(1);
|
|
59
|
+
expect(cell.rows).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("closePrototypeBrowser", () => {
|
|
64
|
+
afterEach(() => setPrototypeBrowserForTest(null));
|
|
65
|
+
|
|
66
|
+
test("is a safe no-op when no prototype browser was ever launched", async () => {
|
|
67
|
+
// the shutdown path calls this on every quit; with no warm Chromium it must not throw or load puppeteer
|
|
68
|
+
await expect(closePrototypeBrowser()).resolves.toBeUndefined();
|
|
69
|
+
await expect(closePrototypeBrowser()).resolves.toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("closes a warm browser once without launching Chrome", async () => {
|
|
73
|
+
const close = mock(async () => {});
|
|
74
|
+
|
|
75
|
+
setPrototypeBrowserForTest({ close });
|
|
76
|
+
await closePrototypeBrowser();
|
|
77
|
+
await closePrototypeBrowser();
|
|
78
|
+
|
|
79
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Headless-Chromium backing for prototype review: render an HTML file to a PNG
|
|
4
|
+
* and resolve a click coordinate to a DOM element. puppeteer-core is imported
|
|
5
|
+
* lazily so plan/diff review and the test suite never load it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PrototypeElement {
|
|
9
|
+
/** Stable CSS selector - the annotation anchor's authority. */
|
|
10
|
+
selector: string;
|
|
11
|
+
/** Short human label for the rail card (element text, else the selector). */
|
|
12
|
+
quote: string;
|
|
13
|
+
/** Element rectangle in CSS pixels within the captured viewport. */
|
|
14
|
+
box: ElementBox;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ElementBox {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PrototypeViewport {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PrototypeRenderer {
|
|
30
|
+
readonly viewport: PrototypeViewport;
|
|
31
|
+
screenshot(): Promise<Uint8Array>;
|
|
32
|
+
elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
|
|
33
|
+
/** Scroll the page by a pixel delta; returns whether the scroll position moved. */
|
|
34
|
+
scrollBy(deltaY: number): Promise<boolean>;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LaunchOptions {
|
|
39
|
+
filePath: string;
|
|
40
|
+
viewport: PrototypeViewport;
|
|
41
|
+
/** Capture density; 1 when the viewport already matches the region's pixels. */
|
|
42
|
+
deviceScaleFactor?: number;
|
|
43
|
+
/** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
|
|
44
|
+
executablePath?: string;
|
|
45
|
+
/** The terminal's color scheme, emulated as prefers-color-scheme so a mockup
|
|
46
|
+
* with theme-aware CSS renders to match the surface it emerges into. */
|
|
47
|
+
colorScheme?: "dark" | "light";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
|
|
51
|
+
export function imageCellToCss(
|
|
52
|
+
event: { x: number; y: number },
|
|
53
|
+
image: { x: number; y: number; width: number; height: number },
|
|
54
|
+
viewport: PrototypeViewport,
|
|
55
|
+
): { x: number; y: number } | null {
|
|
56
|
+
const columnInImage = event.x - image.x;
|
|
57
|
+
const rowInImage = event.y - image.y;
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
columnInImage < 0 ||
|
|
61
|
+
rowInImage < 0 ||
|
|
62
|
+
columnInImage >= image.width ||
|
|
63
|
+
rowInImage >= image.height
|
|
64
|
+
) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
x: ((columnInImage + 0.5) / image.width) * viewport.width,
|
|
70
|
+
y: ((rowInImage + 0.5) / image.height) * viewport.height,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** CSS pixel rect -> the image cell it maps to, for anchoring overlays. */
|
|
75
|
+
export function cssBoxToCell(
|
|
76
|
+
box: ElementBox,
|
|
77
|
+
image: { x: number; y: number; width: number; height: number },
|
|
78
|
+
viewport: PrototypeViewport,
|
|
79
|
+
) {
|
|
80
|
+
const scaleColumn = image.width / viewport.width;
|
|
81
|
+
const scaleRow = image.height / viewport.height;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
column: image.x + Math.floor(box.x * scaleColumn),
|
|
85
|
+
row: image.y + Math.floor(box.y * scaleRow),
|
|
86
|
+
columns: Math.max(1, Math.round(box.width * scaleColumn)),
|
|
87
|
+
rows: Math.max(1, Math.round(box.height * scaleRow)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
|
|
92
|
+
|
|
93
|
+
let rendererFactory: PrototypeRendererFactory | null = null;
|
|
94
|
+
|
|
95
|
+
/** Override the renderer factory so tests can inject a browserless fake. */
|
|
96
|
+
export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
|
|
97
|
+
rendererFactory = factory;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The active factory - the injected fake in tests, else real headless Chromium. */
|
|
101
|
+
export function prototypeRendererFactory(): PrototypeRendererFactory {
|
|
102
|
+
return rendererFactory ?? launchPrototypeRenderer;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type PuppeteerBrowser = Awaited<ReturnType<typeof import("puppeteer-core").default.launch>>;
|
|
106
|
+
|
|
107
|
+
// One Chromium per process, kept warm and reused across prototype opens: the
|
|
108
|
+
// launch is the dominant load cost, so each open only spawns a fresh page.
|
|
109
|
+
let sharedBrowser: Promise<PuppeteerBrowser> | null = null;
|
|
110
|
+
|
|
111
|
+
/** Install only the browser lifecycle used by unit tests, without launching Chrome. */
|
|
112
|
+
export function setPrototypeBrowserForTest(browser: Pick<PuppeteerBrowser, "close"> | null): void {
|
|
113
|
+
// SAFETY: tests call only closePrototypeBrowser before resetting this seam, so close is the sole
|
|
114
|
+
// browser member the shared slot can read while the double is installed.
|
|
115
|
+
sharedBrowser = browser ? Promise.resolve(browser as PuppeteerBrowser) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function warmBrowser(executablePath: string | undefined): Promise<PuppeteerBrowser> {
|
|
119
|
+
// reuse the warm browser only while it is still connected; a crashed or
|
|
120
|
+
// disconnected Chromium is dropped so the next open relaunches instead of
|
|
121
|
+
// calling newPage on a dead process forever
|
|
122
|
+
const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
|
|
123
|
+
|
|
124
|
+
if (cached && cached.connected) return cached;
|
|
125
|
+
const puppeteer = (await import("puppeteer-core")).default;
|
|
126
|
+
const launched = puppeteer.launch({
|
|
127
|
+
executablePath: executablePath ?? chromeExecutable(),
|
|
128
|
+
headless: true,
|
|
129
|
+
args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
sharedBrowser = launched;
|
|
133
|
+
const browser = await launched.catch((error) => {
|
|
134
|
+
if (sharedBrowser === launched) sharedBrowser = null;
|
|
135
|
+
throw error;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
browser.once("disconnected", () => {
|
|
139
|
+
if (sharedBrowser === launched) sharedBrowser = null;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return browser;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Close the warm Chromium so it never orphans when cueloop exits. Called from
|
|
147
|
+
* the shutdown path (the normal quit is not a signal, so puppeteer's own signal
|
|
148
|
+
* handlers do not fire). Idempotent.
|
|
149
|
+
*/
|
|
150
|
+
export async function closePrototypeBrowser(): Promise<void> {
|
|
151
|
+
const pending = sharedBrowser;
|
|
152
|
+
|
|
153
|
+
sharedBrowser = null;
|
|
154
|
+
if (!pending) return;
|
|
155
|
+
const browser = await pending.catch(() => null);
|
|
156
|
+
|
|
157
|
+
await browser?.close().catch(() => undefined);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
|
|
161
|
+
const { pathToFileURL } = await import("node:url");
|
|
162
|
+
const browser = await warmBrowser(options.executablePath);
|
|
163
|
+
// close only the page on teardown, never the shared browser, so the next open
|
|
164
|
+
// reuses the warm Chromium
|
|
165
|
+
const page = await browser.newPage();
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await page.setViewport({
|
|
169
|
+
width: options.viewport.width,
|
|
170
|
+
height: options.viewport.height,
|
|
171
|
+
deviceScaleFactor: options.deviceScaleFactor ?? 2,
|
|
172
|
+
});
|
|
173
|
+
// match the terminal's scheme so a theme-aware mockup renders its dark or
|
|
174
|
+
// light variant to sit correctly on the surface it emerges into
|
|
175
|
+
await page.emulateMediaFeatures([
|
|
176
|
+
{ name: "prefers-color-scheme", value: options.colorScheme ?? "light" },
|
|
177
|
+
]);
|
|
178
|
+
// `load` waits for images and styles but skips networkidle0's fixed 500ms
|
|
179
|
+
// idle window, which a static local file would otherwise always pay
|
|
180
|
+
await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
|
|
181
|
+
// Render the mockup on the terminal's own surface rather than on an opaque
|
|
182
|
+
// page: strip the root background so it never paints a square box, and the
|
|
183
|
+
// capture (taken with alpha, below) lets the active terminal theme show
|
|
184
|
+
// through around the mockup's own components - the prototype emerges into
|
|
185
|
+
// whatever theme is running instead of sitting in a grey card.
|
|
186
|
+
await page.evaluate(() => {
|
|
187
|
+
for (const element of [document.documentElement, document.body]) {
|
|
188
|
+
element?.style.setProperty("background", "transparent", "important");
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await page.close().catch(() => undefined);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
viewport: options.viewport,
|
|
198
|
+
async screenshot() {
|
|
199
|
+
// omitBackground keeps the alpha from the stripped page background, so the
|
|
200
|
+
// terminal composites the mockup over its own theme surface.
|
|
201
|
+
const buffer = await page.screenshot({
|
|
202
|
+
type: "png",
|
|
203
|
+
encoding: "binary",
|
|
204
|
+
omitBackground: true,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return new Uint8Array(buffer);
|
|
208
|
+
},
|
|
209
|
+
async elementAt(cssX, cssY) {
|
|
210
|
+
return page.evaluate(elementAtScript, cssX, cssY);
|
|
211
|
+
},
|
|
212
|
+
async scrollBy(deltaY) {
|
|
213
|
+
return page.evaluate(scrollByScript, deltaY);
|
|
214
|
+
},
|
|
215
|
+
async close() {
|
|
216
|
+
await page.close().catch(() => undefined);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
|
|
222
|
+
function elementAtScript(x: number, y: number) {
|
|
223
|
+
const SEMANTIC = new Set([
|
|
224
|
+
"SECTION",
|
|
225
|
+
"ARTICLE",
|
|
226
|
+
"LI",
|
|
227
|
+
"NAV",
|
|
228
|
+
"HEADER",
|
|
229
|
+
"FOOTER",
|
|
230
|
+
"ASIDE",
|
|
231
|
+
"FORM",
|
|
232
|
+
]);
|
|
233
|
+
const isNamed = (element: Element): boolean =>
|
|
234
|
+
element.classList.length > 0 || SEMANTIC.has(element.tagName);
|
|
235
|
+
// a card wraps several children: climb to the nearest named container holding
|
|
236
|
+
// more than one child, else the nearest named ancestor, else the clicked leaf
|
|
237
|
+
const componentRoot = (start: Element): Element => {
|
|
238
|
+
let namedFallback: Element | null = null;
|
|
239
|
+
let current: Element | null = start;
|
|
240
|
+
|
|
241
|
+
while (current && current !== document.body) {
|
|
242
|
+
if (isNamed(current)) {
|
|
243
|
+
if (current.childElementCount > 1) return current;
|
|
244
|
+
namedFallback = namedFallback ?? current;
|
|
245
|
+
}
|
|
246
|
+
current = current.parentElement;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return namedFallback ?? start;
|
|
250
|
+
};
|
|
251
|
+
const selectorFor = (start: Element): string => {
|
|
252
|
+
const parts: string[] = [];
|
|
253
|
+
let current: Element | null = start;
|
|
254
|
+
|
|
255
|
+
while (current && current.nodeType === 1 && current !== document.documentElement) {
|
|
256
|
+
let part = current.tagName.toLowerCase();
|
|
257
|
+
|
|
258
|
+
if (current.id) {
|
|
259
|
+
parts.unshift(part + "#" + CSS.escape(current.id));
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
const parent: Element | null = current.parentElement;
|
|
263
|
+
|
|
264
|
+
if (parent) {
|
|
265
|
+
const currentTagName = current.tagName;
|
|
266
|
+
const twins = [...parent.children].filter((child) => child.tagName === currentTagName);
|
|
267
|
+
|
|
268
|
+
if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
|
|
269
|
+
}
|
|
270
|
+
parts.unshift(part);
|
|
271
|
+
current = current.parentElement;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return parts.join(" > ");
|
|
275
|
+
};
|
|
276
|
+
// Prefer the nearest focusable target before falling back to its component container.
|
|
277
|
+
const focusableTarget = (start: Element | null): Element | null => {
|
|
278
|
+
for (
|
|
279
|
+
let element: Element | null = start;
|
|
280
|
+
element && element !== document.body;
|
|
281
|
+
element = element.parentElement
|
|
282
|
+
) {
|
|
283
|
+
if (element instanceof HTMLElement && element.tabIndex >= 0) {
|
|
284
|
+
return element;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return null;
|
|
289
|
+
};
|
|
290
|
+
const hit = document.elementFromPoint(x, y);
|
|
291
|
+
|
|
292
|
+
if (!(hit instanceof Element)) return null;
|
|
293
|
+
const node = focusableTarget(hit) ?? componentRoot(hit);
|
|
294
|
+
const selector = selectorFor(node);
|
|
295
|
+
|
|
296
|
+
if (!selector) return null;
|
|
297
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
298
|
+
const tag = node.tagName.toLowerCase();
|
|
299
|
+
const quote = text
|
|
300
|
+
? text.slice(0, 80)
|
|
301
|
+
: node.id
|
|
302
|
+
? tag + "#" + node.id
|
|
303
|
+
: node.classList.length
|
|
304
|
+
? tag + "." + node.classList[0]
|
|
305
|
+
: tag;
|
|
306
|
+
const rect = node.getBoundingClientRect();
|
|
307
|
+
|
|
308
|
+
return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function scrollByScript(deltaY: number): boolean {
|
|
312
|
+
const before = window.scrollY;
|
|
313
|
+
|
|
314
|
+
window.scrollBy(0, deltaY);
|
|
315
|
+
|
|
316
|
+
return window.scrollY !== before;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
|
|
320
|
+
function chromeExecutable(): string {
|
|
321
|
+
if (process.platform === "darwin") {
|
|
322
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
323
|
+
}
|
|
324
|
+
if (process.platform === "win32") {
|
|
325
|
+
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return "/usr/bin/google-chrome";
|
|
329
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy boundary for the opt-in kitty pixel prototype. The pixel path pulls in the
|
|
3
|
+
* kitty image writer, the headless-browser renderer, and (on first paint) puppeteer;
|
|
4
|
+
* none of that belongs in the runtime unless the `[experimental] prototype_pixels`
|
|
5
|
+
* flag is on. Importing only the TYPE here is erased at build time, so the real
|
|
6
|
+
* module - and its dependencies - loads only when this component actually mounts,
|
|
7
|
+
* which App does exclusively for a pixel prototype. This lives outside components/
|
|
8
|
+
* on purpose: it is an app boundary, not a visual component the story catalog covers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { useEffect, useState } from "react";
|
|
12
|
+
import type { PrototypeContentViewProps } from "./components/PrototypeContentView";
|
|
13
|
+
|
|
14
|
+
type PixelPrototypeView =
|
|
15
|
+
(typeof import("./components/PrototypeContentView"))["PrototypeContentView"];
|
|
16
|
+
|
|
17
|
+
export function PrototypePixels(props: PrototypeContentViewProps): React.ReactNode {
|
|
18
|
+
const [View, setView] = useState<PixelPrototypeView | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let alive = true;
|
|
22
|
+
|
|
23
|
+
void import("./components/PrototypeContentView").then((module) => {
|
|
24
|
+
if (alive) setView(() => module.PrototypeContentView);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
alive = false;
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
if (!View) return null;
|
|
33
|
+
|
|
34
|
+
return <View {...props} />;
|
|
35
|
+
}
|