@rose42/t3-feedback 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +516 -0
  3. package/dist/bridge/attachments.d.ts +27 -0
  4. package/dist/bridge/attachments.js +109 -0
  5. package/dist/bridge/format.d.ts +70 -0
  6. package/dist/bridge/format.js +358 -0
  7. package/dist/bridge/http.d.ts +31 -0
  8. package/dist/bridge/http.js +169 -0
  9. package/dist/bridge/index.d.ts +13 -0
  10. package/dist/bridge/index.js +12 -0
  11. package/dist/bridge/parse.d.ts +16 -0
  12. package/dist/bridge/parse.js +82 -0
  13. package/dist/bridge/paths.d.ts +10 -0
  14. package/dist/bridge/paths.js +10 -0
  15. package/dist/bridge/probeThreads.d.ts +13 -0
  16. package/dist/bridge/probeThreads.js +66 -0
  17. package/dist/bridge/readSource.d.ts +8 -0
  18. package/dist/bridge/readSource.js +64 -0
  19. package/dist/bridge/sendFeedback.d.ts +99 -0
  20. package/dist/bridge/sendFeedback.js +417 -0
  21. package/dist/cli/bin.d.ts +2 -0
  22. package/dist/cli/bin.js +40 -0
  23. package/dist/cli/constants.d.ts +4 -0
  24. package/dist/cli/constants.js +4 -0
  25. package/dist/cli/detect.d.ts +17 -0
  26. package/dist/cli/detect.js +118 -0
  27. package/dist/cli/index.d.ts +4 -0
  28. package/dist/cli/index.js +4 -0
  29. package/dist/cli/init.d.ts +18 -0
  30. package/dist/cli/init.js +165 -0
  31. package/dist/cli/pm.d.ts +12 -0
  32. package/dist/cli/pm.js +49 -0
  33. package/dist/cli/prompt.d.ts +2 -0
  34. package/dist/cli/prompt.js +19 -0
  35. package/dist/cli/wire/next.d.ts +8 -0
  36. package/dist/cli/wire/next.js +71 -0
  37. package/dist/cli/wire/node.d.ts +3 -0
  38. package/dist/cli/wire/node.js +76 -0
  39. package/dist/cli/wire/vite.d.ts +25 -0
  40. package/dist/cli/wire/vite.js +71 -0
  41. package/dist/client/activeThreads.d.ts +23 -0
  42. package/dist/client/activeThreads.js +89 -0
  43. package/dist/client/cropScreenshot.d.ts +39 -0
  44. package/dist/client/cropScreenshot.js +212 -0
  45. package/dist/client/customPlugins.d.ts +7 -0
  46. package/dist/client/customPlugins.js +38 -0
  47. package/dist/client/draft.d.ts +33 -0
  48. package/dist/client/draft.js +222 -0
  49. package/dist/client/index.d.ts +24 -0
  50. package/dist/client/index.js +20 -0
  51. package/dist/client/mount.d.ts +210 -0
  52. package/dist/client/mount.js +2408 -0
  53. package/dist/client/openInT3.d.ts +28 -0
  54. package/dist/client/openInT3.js +81 -0
  55. package/dist/client/readClipboardImage.d.ts +2 -0
  56. package/dist/client/readClipboardImage.js +2 -0
  57. package/dist/client/reportType.d.ts +43 -0
  58. package/dist/client/reportType.js +110 -0
  59. package/dist/client/selectBurstFrames.d.ts +21 -0
  60. package/dist/client/selectBurstFrames.js +101 -0
  61. package/dist/client/sessionThread.d.ts +26 -0
  62. package/dist/client/sessionThread.js +93 -0
  63. package/dist/client/threadPicker.d.ts +20 -0
  64. package/dist/client/threadPicker.js +51 -0
  65. package/dist/client/transport.d.ts +11 -0
  66. package/dist/client/transport.js +35 -0
  67. package/dist/client/ui/commentPanelPlugin.d.ts +46 -0
  68. package/dist/client/ui/commentPanelPlugin.js +126 -0
  69. package/dist/client/ui/defaultPlugins.d.ts +19 -0
  70. package/dist/client/ui/defaultPlugins.js +48 -0
  71. package/dist/client/ui/elementTargetChip.d.ts +64 -0
  72. package/dist/client/ui/elementTargetChip.js +185 -0
  73. package/dist/client/ui/elementTargetPanelPlugin.d.ts +43 -0
  74. package/dist/client/ui/elementTargetPanelPlugin.js +167 -0
  75. package/dist/client/ui/elementTargetPanelSession.d.ts +49 -0
  76. package/dist/client/ui/elementTargetPanelSession.js +279 -0
  77. package/dist/client/ui/elementTargetUi.d.ts +36 -0
  78. package/dist/client/ui/elementTargetUi.js +105 -0
  79. package/dist/client/ui/host.d.ts +112 -0
  80. package/dist/client/ui/host.js +132 -0
  81. package/dist/client/ui/html.d.ts +22 -0
  82. package/dist/client/ui/html.js +26 -0
  83. package/dist/client/ui/labeledPluginPreview.d.ts +8 -0
  84. package/dist/client/ui/labeledPluginPreview.js +71 -0
  85. package/dist/client/ui/resolveContributions.d.ts +13 -0
  86. package/dist/client/ui/resolveContributions.js +20 -0
  87. package/dist/client/ui/screenshotPanelPlugin.d.ts +39 -0
  88. package/dist/client/ui/screenshotPanelPlugin.js +212 -0
  89. package/dist/client/ui/screenshotPanelSession.d.ts +84 -0
  90. package/dist/client/ui/screenshotPanelSession.js +445 -0
  91. package/dist/client/ui/screenshotUi.d.ts +31 -0
  92. package/dist/client/ui/screenshotUi.js +130 -0
  93. package/dist/client/ui/shortcuts.d.ts +32 -0
  94. package/dist/client/ui/shortcuts.js +40 -0
  95. package/dist/client/ui/slots.d.ts +73 -0
  96. package/dist/client/ui/slots.js +112 -0
  97. package/dist/controls.d.ts +8 -0
  98. package/dist/controls.js +16 -0
  99. package/dist/index.d.ts +3 -0
  100. package/dist/index.js +2 -0
  101. package/dist/plugin.d.ts +9 -0
  102. package/dist/plugin.js +186 -0
  103. package/dist/types.d.ts +464 -0
  104. package/dist/types.js +283 -0
  105. package/docs/devcontainer.md +160 -0
  106. package/package.json +107 -0
@@ -0,0 +1,46 @@
1
+ import type { FeedbackCapturePluginWithUi } from "./slots.js";
2
+ import type { T3FeedbackUiContext } from "./host.js";
3
+ import type { FeedbackReportType } from "../reportType.js";
4
+ import type { T3FeedbackReportTypeGlyphResolved } from "../../types.js";
5
+ export type CommentPanelCopy = {
6
+ commentPlaceholder: string;
7
+ };
8
+ export type CommentPanelApi = {
9
+ getTextarea: () => HTMLTextAreaElement | null;
10
+ getComment: () => string;
11
+ setComment: (value: string) => void;
12
+ focus: () => void;
13
+ /** Trimmed comment, or `null` when empty (shell uses this for submit gating). */
14
+ validate: () => string | null;
15
+ };
16
+ /**
17
+ * Default comment field plugin — mounts into the `comment` slot.
18
+ * Send-action keydowns stay on the shell (via {@link CommentPanelApi.getTextarea}).
19
+ */
20
+ export declare function createCommentPanelPlugin(options: {
21
+ copy: CommentPanelCopy;
22
+ }): FeedbackCapturePluginWithUi & {
23
+ getApi: () => CommentPanelApi;
24
+ installHostHooks: (host: T3FeedbackUiContext | {
25
+ registerDraftClear: T3FeedbackUiContext["registerDraftClear"];
26
+ }) => void;
27
+ };
28
+ export type ReportTypePanelCopy = {
29
+ reportTypeBugLabel: string;
30
+ reportTypeFeedbackLabel: string;
31
+ reportTypeSuggestionLabel: string;
32
+ reportTypeBugEmoji: string;
33
+ reportTypeFeedbackEmoji: string;
34
+ reportTypeSuggestionEmoji: string;
35
+ };
36
+ /**
37
+ * Report-type segmented control — mounts in `beforeComment` (order -20).
38
+ * Collects the live host selection for the envelope (no submit-time stub plugin).
39
+ */
40
+ export declare function createReportTypePanelPlugin(options: {
41
+ copy: ReportTypePanelCopy;
42
+ glyph: T3FeedbackReportTypeGlyphResolved;
43
+ iconSvg: (type: FeedbackReportType) => string;
44
+ hintFor: (type: FeedbackReportType) => string;
45
+ getReportType: () => FeedbackReportType;
46
+ }): FeedbackCapturePluginWithUi;
@@ -0,0 +1,126 @@
1
+ import { escapeHtml } from "./html.js";
2
+ /**
3
+ * Default comment field plugin — mounts into the `comment` slot.
4
+ * Send-action keydowns stay on the shell (via {@link CommentPanelApi.getTextarea}).
5
+ */
6
+ export function createCommentPanelPlugin(options) {
7
+ let textarea = null;
8
+ let live = "";
9
+ let hostHooksInstalled = false;
10
+ const api = {
11
+ getTextarea: () => textarea,
12
+ getComment: () => (textarea ? textarea.value : live),
13
+ setComment: (value) => {
14
+ live = value;
15
+ if (textarea)
16
+ textarea.value = value;
17
+ },
18
+ focus: () => textarea?.focus(),
19
+ validate: () => {
20
+ const value = api.getComment().trim();
21
+ return value || null;
22
+ },
23
+ };
24
+ const clearDraft = () => {
25
+ api.setComment("");
26
+ };
27
+ const installHostHooks = (host) => {
28
+ if (hostHooksInstalled)
29
+ return;
30
+ hostHooksInstalled = true;
31
+ host.registerDraftClear(clearDraft);
32
+ };
33
+ return {
34
+ id: "comment",
35
+ collect: () => {
36
+ const value = api.getComment().trim();
37
+ return value || undefined;
38
+ },
39
+ getApi: () => api,
40
+ installHostHooks,
41
+ ui: {
42
+ id: "builtin:comment",
43
+ slot: "comment",
44
+ order: 0,
45
+ mount: (container, ctx) => {
46
+ installHostHooks(ctx);
47
+ const saved = ctx.getDraftExtra("comment");
48
+ if (typeof saved === "string")
49
+ live = saved;
50
+ container.innerHTML = `<textarea name="comment" placeholder="${escapeHtml(options.copy.commentPlaceholder)}" required></textarea>`;
51
+ textarea = container.querySelector("textarea");
52
+ textarea.value = live;
53
+ textarea.addEventListener("input", () => {
54
+ live = textarea.value;
55
+ ctx.setDraftExtra("comment", live);
56
+ ctx.persistDraft(true);
57
+ });
58
+ queueMicrotask(() => textarea?.focus());
59
+ return () => {
60
+ live = textarea?.value ?? live;
61
+ textarea = null;
62
+ };
63
+ },
64
+ },
65
+ };
66
+ }
67
+ function reportTypeEmojiFor(type, copy) {
68
+ if (type === "feedback")
69
+ return copy.reportTypeFeedbackEmoji;
70
+ if (type === "suggestion")
71
+ return copy.reportTypeSuggestionEmoji;
72
+ return copy.reportTypeBugEmoji;
73
+ }
74
+ function reportTypeGlyphHtml(type, glyph, copy, iconSvg) {
75
+ if (glyph !== "icon") {
76
+ const emoji = typeof glyph === "object" ? glyph[type] : reportTypeEmojiFor(type, copy);
77
+ return `<span class="t3f-report-type-emoji" aria-hidden="true">${escapeHtml(emoji)}</span>`;
78
+ }
79
+ return iconSvg(type);
80
+ }
81
+ /**
82
+ * Report-type segmented control — mounts in `beforeComment` (order -20).
83
+ * Collects the live host selection for the envelope (no submit-time stub plugin).
84
+ */
85
+ export function createReportTypePanelPlugin(options) {
86
+ return {
87
+ id: "reportType",
88
+ collect: () => options.getReportType(),
89
+ ui: {
90
+ id: "builtin:reportType",
91
+ slot: "beforeComment",
92
+ order: -20,
93
+ mount: (container, ctx) => {
94
+ const { copy, glyph, iconSvg, hintFor } = options;
95
+ container.innerHTML = `
96
+ <div class="t3f-report-type" role="group" aria-label="Report type">
97
+ <div class="t3f-report-type-seg">
98
+ <button type="button" data-act="report-type" data-report-type="bug" aria-pressed="false">${reportTypeGlyphHtml("bug", glyph, copy, iconSvg)}<span>${escapeHtml(copy.reportTypeBugLabel)}</span></button>
99
+ <button type="button" data-act="report-type" data-report-type="feedback" aria-pressed="false">${reportTypeGlyphHtml("feedback", glyph, copy, iconSvg)}<span>${escapeHtml(copy.reportTypeFeedbackLabel)}</span></button>
100
+ <button type="button" data-act="report-type" data-report-type="suggestion" aria-pressed="false">${reportTypeGlyphHtml("suggestion", glyph, copy, iconSvg)}<span>${escapeHtml(copy.reportTypeSuggestionLabel)}</span></button>
101
+ </div>
102
+ <div class="t3f-report-type-hint"></div>
103
+ </div>
104
+ `;
105
+ const hint = container.querySelector(".t3f-report-type-hint");
106
+ const buttons = Array.from(container.querySelectorAll('[data-act="report-type"]'));
107
+ const sync = (type) => {
108
+ for (const btn of buttons) {
109
+ btn.setAttribute("aria-pressed", btn.dataset.reportType === type ? "true" : "false");
110
+ }
111
+ hint.textContent = hintFor(type);
112
+ };
113
+ sync(ctx.getReportType());
114
+ for (const btn of buttons) {
115
+ btn.addEventListener("click", () => {
116
+ const value = btn.dataset.reportType;
117
+ if (value !== "bug" && value !== "feedback" && value !== "suggestion")
118
+ return;
119
+ ctx.setReportType(value);
120
+ });
121
+ }
122
+ return ctx.subscribeReportType(sync);
123
+ },
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,19 @@
1
+ import type { T3FeedbackBuiltinPluginOptions } from "../../types.js";
2
+ import { type FeedbackReportType } from "../reportType.js";
3
+ import type { FeedbackCapturePluginWithUi } from "./slots.js";
4
+ /**
5
+ * Collect-only built-ins (no panel UI) as host plugins.
6
+ * `reportTypes` / page-context field filtering match the panel hints:
7
+ * full diagnostics (bug), UX reaction (feedback), lighter product input (suggestion).
8
+ */
9
+ export declare function createCollectOnlyBuiltinPlugins(options: {
10
+ pageContext: boolean;
11
+ consoleLogs: boolean;
12
+ consoleLogsMaxEntries: number;
13
+ version?: string;
14
+ getHmr?: () => number | null;
15
+ getReportType: () => FeedbackReportType;
16
+ }): FeedbackCapturePluginWithUi[];
17
+ /** Resolve UI contributions from the full plugin list (no host-only builtins array). */
18
+ export declare function resolveAllPanelUiContributions(plugins: readonly FeedbackCapturePluginWithUi[]): any[];
19
+ export type { T3FeedbackBuiltinPluginOptions };
@@ -0,0 +1,48 @@
1
+ import { collectPageContext, createConsoleLogsPlugin, createVersionPlugin, } from "@rose42/feedback-capture";
2
+ import { CONSOLE_LOGS_REPORT_TYPES, VERSION_REPORT_TYPES, pageContextForReportType, } from "../reportType.js";
3
+ import { createLabeledPluginPreviewContribution } from "./labeledPluginPreview.js";
4
+ import { pluginsNeedingLabelPreview } from "./slots.js";
5
+ /**
6
+ * Collect-only built-ins (no panel UI) as host plugins.
7
+ * `reportTypes` / page-context field filtering match the panel hints:
8
+ * full diagnostics (bug), UX reaction (feedback), lighter product input (suggestion).
9
+ */
10
+ export function createCollectOnlyBuiltinPlugins(options) {
11
+ const out = [];
12
+ if (options.pageContext) {
13
+ out.push({
14
+ id: "pageContext",
15
+ collect: () => pageContextForReportType(collectPageContext(), options.getReportType()),
16
+ });
17
+ }
18
+ if (options.consoleLogs) {
19
+ out.push({
20
+ ...createConsoleLogsPlugin({ maxEntries: options.consoleLogsMaxEntries }),
21
+ reportTypes: CONSOLE_LOGS_REPORT_TYPES,
22
+ });
23
+ }
24
+ if (options.version) {
25
+ out.push({
26
+ ...createVersionPlugin({
27
+ getInfo: () => ({
28
+ version: options.version,
29
+ hmr: options.getHmr?.() ?? null,
30
+ }),
31
+ }),
32
+ reportTypes: VERSION_REPORT_TYPES,
33
+ });
34
+ }
35
+ return out;
36
+ }
37
+ /** Resolve UI contributions from the full plugin list (no host-only builtins array). */
38
+ export function resolveAllPanelUiContributions(plugins) {
39
+ const labeled = pluginsNeedingLabelPreview(plugins);
40
+ const labelPreview = createLabeledPluginPreviewContribution(labeled);
41
+ const fromPlugins = plugins.flatMap((plugin) => {
42
+ const ui = plugin.ui;
43
+ if (!ui)
44
+ return [];
45
+ return Array.isArray(ui) ? [...ui] : [ui];
46
+ });
47
+ return [...(labelPreview ? [labelPreview] : []), ...fromPlugins];
48
+ }
@@ -0,0 +1,64 @@
1
+ import type { T3FeedbackOpenInEditorConfig } from "../../types.js";
2
+ export type ComponentLoc = {
3
+ name?: string;
4
+ fileName: string;
5
+ line: number;
6
+ column?: number;
7
+ };
8
+ export type ElementChipTarget = {
9
+ tagName: string;
10
+ name?: string;
11
+ selector: string;
12
+ component?: ComponentLoc;
13
+ leaf?: ComponentLoc;
14
+ owners?: ComponentLoc[];
15
+ };
16
+ /**
17
+ * Leaf-first hierarchy for display: leaf → primary → outer owners.
18
+ * Roles: leaf (innermost), primary (preferred focus), secondary (first outer).
19
+ */
20
+ export declare function componentHierarchy(target: ElementChipTarget): Array<{
21
+ loc: ComponentLoc;
22
+ role: "leaf" | "primary" | "secondary" | "owner";
23
+ }>;
24
+ /** `file:line` or `file:line:column` for chip text / titles. */
25
+ export declare function formatComponentLocation(loc: {
26
+ fileName: string;
27
+ line: number;
28
+ column?: number;
29
+ }): string;
30
+ /**
31
+ * Build the URL used to open a source location.
32
+ * - `urlTemplate` → navigate (IDE protocol / custom)
33
+ * - else → Vite-style GET endpoint with `?file=`
34
+ */
35
+ export declare function buildOpenInEditorUrl(loc: {
36
+ fileName: string;
37
+ line: number;
38
+ column?: number;
39
+ }, config: T3FeedbackOpenInEditorConfig): {
40
+ kind: "navigate" | "fetch";
41
+ url: string;
42
+ };
43
+ /** Open a resolved component location in the IDE (best-effort; never throws). */
44
+ export declare function openSourceInEditor(loc: {
45
+ fileName: string;
46
+ line: number;
47
+ column?: number;
48
+ }, config: T3FeedbackOpenInEditorConfig, fetchFn?: typeof fetch): Promise<void>;
49
+ /** Split a `>`-joined CSS path into segments (trim empties). */
50
+ export declare function splitCssSelectorParts(selector: string): string[];
51
+ /**
52
+ * Leaf-first breadcrumb for the ELEMENT chip (child ‹ parent ‹ …).
53
+ * Stored/agent selector stays root→leaf; this is display-only.
54
+ */
55
+ export declare function formatSelectorBreadcrumb(selector: string): string;
56
+ /** CSS selector for the ancestor `depthFromLeaf` steps above the leaf (0 = leaf). */
57
+ export declare function selectorAtAncestorDepth(selector: string, depthFromLeaf: number): string;
58
+ /** Full ELEMENT chip text (also used as native `title` when the path scrolls). */
59
+ export declare function formatElementChip(target: ElementChipTarget): string;
60
+ /** Interactive ELEMENT chip HTML (component hierarchy or inverted CSS path). */
61
+ export declare function buildTargetChipHtml(target: ElementChipTarget, options?: {
62
+ openInEditor?: T3FeedbackOpenInEditorConfig | null;
63
+ openInEditorLabel?: string;
64
+ }): string;
@@ -0,0 +1,185 @@
1
+ import { rankSourceHierarchy } from "@rose42/feedback-capture";
2
+ import { escapeHtml } from "./html.js";
3
+ function asLoc(loc) {
4
+ return loc;
5
+ }
6
+ /**
7
+ * Leaf-first hierarchy for display: leaf → primary → outer owners.
8
+ * Roles: leaf (innermost), primary (preferred focus), secondary (first outer).
9
+ */
10
+ export function componentHierarchy(target) {
11
+ const ranked = rankSourceHierarchy({
12
+ ...(target.component ? { component: asLoc(target.component) } : {}),
13
+ ...(target.leaf ? { leaf: asLoc(target.leaf) } : {}),
14
+ ...(target.owners && target.owners.length > 0 ? { owners: target.owners.map(asLoc) } : {}),
15
+ });
16
+ if (!ranked?.component && !ranked?.leaf)
17
+ return [];
18
+ const out = [];
19
+ if (ranked.leaf && ranked.component) {
20
+ out.push({ loc: ranked.leaf, role: "leaf" });
21
+ out.push({ loc: ranked.component, role: "primary" });
22
+ }
23
+ else if (ranked.component) {
24
+ out.push({ loc: ranked.component, role: "leaf" });
25
+ }
26
+ else if (ranked.leaf) {
27
+ out.push({ loc: ranked.leaf, role: "leaf" });
28
+ }
29
+ const owners = ranked.owners ?? [];
30
+ for (let i = 0; i < owners.length; i++) {
31
+ out.push({
32
+ loc: owners[i],
33
+ role: i === 0 ? "secondary" : "owner",
34
+ });
35
+ }
36
+ return out;
37
+ }
38
+ function elementChipLabel(target) {
39
+ const hierarchy = componentHierarchy(target);
40
+ if (hierarchy.length > 0) {
41
+ const parts = hierarchy.map((entry) => {
42
+ const name = entry.loc.name ?? target.tagName;
43
+ return `${name} · ${formatComponentLocation(entry.loc)}`;
44
+ });
45
+ return `<${target.tagName}> · ${parts.join(" ‹ ")}`;
46
+ }
47
+ if (target.name)
48
+ return `<${target.tagName}> · ${target.name}`;
49
+ return formatSelectorBreadcrumb(target.selector);
50
+ }
51
+ /** `file:line` or `file:line:column` for chip text / titles. */
52
+ export function formatComponentLocation(loc) {
53
+ const base = `${loc.fileName}:${loc.line}`;
54
+ return typeof loc.column === "number" && Number.isFinite(loc.column)
55
+ ? `${base}:${loc.column}`
56
+ : base;
57
+ }
58
+ /**
59
+ * Build the URL used to open a source location.
60
+ * - `urlTemplate` → navigate (IDE protocol / custom)
61
+ * - else → Vite-style GET endpoint with `?file=`
62
+ */
63
+ export function buildOpenInEditorUrl(loc, config) {
64
+ const column = typeof loc.column === "number" && Number.isFinite(loc.column)
65
+ ? Math.max(1, Math.floor(loc.column))
66
+ : 1;
67
+ const line = Math.max(1, Math.floor(loc.line));
68
+ if (config.urlTemplate) {
69
+ const url = config.urlTemplate
70
+ .replaceAll("{file}", loc.fileName)
71
+ .replaceAll("{line}", String(line))
72
+ .replaceAll("{column}", String(column));
73
+ return { kind: "navigate", url };
74
+ }
75
+ const fileParam = `${loc.fileName}:${line}:${column}`;
76
+ const sep = config.endpoint.includes("?") ? "&" : "?";
77
+ return {
78
+ kind: "fetch",
79
+ url: `${config.endpoint}${sep}file=${encodeURIComponent(fileParam)}`,
80
+ };
81
+ }
82
+ /** Open a resolved component location in the IDE (best-effort; never throws). */
83
+ export async function openSourceInEditor(loc, config, fetchFn = fetch) {
84
+ const built = buildOpenInEditorUrl(loc, config);
85
+ try {
86
+ if (built.kind === "navigate") {
87
+ window.location.href = built.url;
88
+ return;
89
+ }
90
+ await fetchFn(built.url, { method: "GET", credentials: "same-origin" });
91
+ }
92
+ catch {
93
+ /* ignore — editor may still have opened, or LAUNCH_EDITOR unset */
94
+ }
95
+ }
96
+ /** Split a `>`-joined CSS path into segments (trim empties). */
97
+ export function splitCssSelectorParts(selector) {
98
+ return selector
99
+ .split(/\s*>\s*/)
100
+ .map((part) => part.trim())
101
+ .filter(Boolean);
102
+ }
103
+ /**
104
+ * Leaf-first breadcrumb for the ELEMENT chip (child ‹ parent ‹ …).
105
+ * Stored/agent selector stays root→leaf; this is display-only.
106
+ */
107
+ export function formatSelectorBreadcrumb(selector) {
108
+ const parts = splitCssSelectorParts(selector);
109
+ if (parts.length <= 1)
110
+ return selector.trim();
111
+ return [...parts].reverse().join(" ‹ ");
112
+ }
113
+ /** CSS selector for the ancestor `depthFromLeaf` steps above the leaf (0 = leaf). */
114
+ export function selectorAtAncestorDepth(selector, depthFromLeaf) {
115
+ const parts = splitCssSelectorParts(selector);
116
+ if (parts.length === 0)
117
+ return selector;
118
+ const keep = Math.max(1, parts.length - Math.max(0, depthFromLeaf));
119
+ return parts.slice(0, keep).join(" > ");
120
+ }
121
+ /** Full ELEMENT chip text (also used as native `title` when the path scrolls). */
122
+ export function formatElementChip(target) {
123
+ // innerHTML / styles are controlled by the pick-details toggles — don't badge the chip.
124
+ return elementChipLabel(target);
125
+ }
126
+ function buildOpenableLocHtml(loc, options) {
127
+ const openConfig = options?.openInEditor;
128
+ const canOpen = Boolean(openConfig && loc.fileName);
129
+ const text = escapeHtml(formatComponentLocation(loc));
130
+ if (canOpen && openConfig) {
131
+ const openLabel = escapeHtml(options?.openInEditorLabel ?? "Open in editor");
132
+ const locAttrs = {
133
+ fileName: loc.fileName,
134
+ line: loc.line,
135
+ ...(typeof loc.column === "number" && Number.isFinite(loc.column)
136
+ ? { column: loc.column }
137
+ : {}),
138
+ };
139
+ const built = buildOpenInEditorUrl(locAttrs, openConfig);
140
+ if (built.kind === "navigate") {
141
+ return `<a class="t3f-target-file" href="${escapeHtml(built.url)}" title="${openLabel}" aria-label="${openLabel}: ${text}">${text}</a>`;
142
+ }
143
+ const columnAttr = typeof loc.column === "number" && Number.isFinite(loc.column)
144
+ ? ` data-column="${Math.max(1, Math.floor(loc.column))}"`
145
+ : "";
146
+ return `<button type="button" class="t3f-target-file" data-file="${escapeHtml(loc.fileName)}" data-line="${Math.max(1, Math.floor(loc.line))}"${columnAttr} title="${openLabel}" aria-label="${openLabel}: ${text}">${text}</button>`;
147
+ }
148
+ return `<span class="t3f-target-file-text">${text}</span>`;
149
+ }
150
+ /** Interactive ELEMENT chip HTML (component hierarchy or inverted CSS path). */
151
+ export function buildTargetChipHtml(target, options) {
152
+ const hierarchy = componentHierarchy(target);
153
+ if (hierarchy.length > 0) {
154
+ const tag = escapeHtml(`<${target.tagName}>`);
155
+ const nodes = hierarchy.map((entry, index) => {
156
+ const name = escapeHtml(entry.loc.name ?? target.name ?? target.tagName);
157
+ const roleClass = `t3f-target-role-${entry.role}`;
158
+ const nameBtn = `<button type="button" class="t3f-target-label ${roleClass}" data-depth="0" data-role="${entry.role}" title="${escapeHtml(entry.role)}">${name}</button>`;
159
+ const fileHtml = buildOpenableLocHtml(entry.loc, options);
160
+ const seg = `<span class="t3f-target-comp ${roleClass}">${nameBtn}<span class="t3f-target-comp-sep"> · </span>${fileHtml}</span>`;
161
+ if (index === 0)
162
+ return seg;
163
+ return `<span class="t3f-target-sep" aria-hidden="true">‹</span>${seg}`;
164
+ });
165
+ return `<span class="t3f-target-hierarchy"><button type="button" class="t3f-target-label" data-depth="0">${tag}</button><span class="t3f-target-comp-sep"> · </span>${nodes.join("")}</span>`;
166
+ }
167
+ if (target.name) {
168
+ const label = elementChipLabel(target);
169
+ return `<button type="button" class="t3f-target-label" data-depth="0">${escapeHtml(label)}</button>`;
170
+ }
171
+ const parts = splitCssSelectorParts(target.selector);
172
+ if (parts.length === 0) {
173
+ return `<button type="button" class="t3f-target-label" data-depth="0">${escapeHtml(target.selector)}</button>`;
174
+ }
175
+ const reversed = [...parts].reverse();
176
+ const pathHtml = reversed
177
+ .map((part, index) => {
178
+ const seg = `<button type="button" class="t3f-target-seg" data-depth="${index}">${escapeHtml(part)}</button>`;
179
+ if (index === 0)
180
+ return seg;
181
+ return `<span class="t3f-target-sep" aria-hidden="true">‹</span>${seg}`;
182
+ })
183
+ .join("");
184
+ return `<span class="t3f-target-path">${pathHtml}</span>`;
185
+ }
@@ -0,0 +1,43 @@
1
+ import { createElementTargetPlugin, type ElementTarget, type ResolveElementSource } from "@rose42/feedback-capture";
2
+ import type { T3FeedbackPickConfig, T3FeedbackShortcutConfig } from "../../types.js";
3
+ import type { T3FeedbackHostServices } from "./host.js";
4
+ import { createElementTargetUiContributions } from "./elementTargetUi.js";
5
+ import type { FeedbackCapturePluginWithUi } from "./slots.js";
6
+ import { type BoundElementTargetPanelSession, type ElementTargetPanelSessionCopy } from "./elementTargetPanelSession.js";
7
+ export type ElementTargetPanelCopy = ElementTargetPanelSessionCopy & {
8
+ elementSectionLabel: string;
9
+ pickLabel: string;
10
+ clearPickLabel: string;
11
+ includeInnerHTMLLabel: string;
12
+ stylesLabel: string;
13
+ stylesNoneLabel: string;
14
+ stylesLayoutLabel: string;
15
+ stylesComputedLabel: string;
16
+ };
17
+ export type ElementTargetPanelController = {
18
+ plugin: FeedbackCapturePluginWithUi;
19
+ elementPlugin: ReturnType<typeof createElementTargetPlugin>;
20
+ getUiApi: ReturnType<typeof createElementTargetUiContributions>["getApi"];
21
+ installHostHooks: (host: T3FeedbackHostServices) => void;
22
+ requestStartPick: () => void;
23
+ runPick: () => void;
24
+ getBoundSession: () => BoundElementTargetPanelSession | null;
25
+ getTarget: () => ElementTarget | undefined;
26
+ clear: () => void;
27
+ setTarget: (target: ElementTarget) => void;
28
+ };
29
+ /**
30
+ * Element-target panel plugin — same public surface as custom plugins
31
+ * (`ui` + `shortcuts` + `collect`). Session binds from ui.mount.
32
+ */
33
+ export declare function createElementTargetPanelPlugin(options: {
34
+ copy: ElementTargetPanelCopy;
35
+ pickConfig: T3FeedbackPickConfig;
36
+ pickShortcut: T3FeedbackShortcutConfig | null;
37
+ pickChord: string | null;
38
+ root: HTMLElement;
39
+ /** Effective afterPick (usually `"none"` when screenshot plugin is inactive). */
40
+ afterPickMode: "none" | "capture" | "crop";
41
+ resolveSource?: ResolveElementSource;
42
+ getHost: () => T3FeedbackHostServices;
43
+ }): ElementTargetPanelController;