snagset 0.0.4 → 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.
- package/dist/vendor/types/brief.d.ts +68 -0
- package/dist/vendor/types/brief.js +121 -0
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thread as a BRIEF — the markdown an agent, a ticket or a clipboard wants:
|
|
3
|
+
* Where to look · What's wrong · Environment · Breadcrumbs · Screenshot.
|
|
4
|
+
*
|
|
5
|
+
* ONE formatter in the shared package (plan/08 §3, D93), so the dashboard's
|
|
6
|
+
* Copy button, the MCP server's `get_thread` and the server's own markdown
|
|
7
|
+
* route cannot drift from one another. Pure: a thread in, a string out, no
|
|
8
|
+
* clock, so the same thread always produces the same brief.
|
|
9
|
+
*
|
|
10
|
+
* STRUCTURAL INPUT. `BriefThread` is the intersection of what the dashboard's
|
|
11
|
+
* `Thread` and the MCP's `WireThread` both carry — nothing here imports
|
|
12
|
+
* either, which is what lets both hand their own type in unchanged.
|
|
13
|
+
*
|
|
14
|
+
* THE FENCE. Every string a reviewer could have typed — a comment body, a
|
|
15
|
+
* name, an element label, a breadcrumb message — passes through `fence`
|
|
16
|
+
* before it is written. The dashboard passes nothing (the clipboard is the
|
|
17
|
+
* studio's own), the MCP server passes `@snagset/adapters`' fence so a model
|
|
18
|
+
* reads `<untrusted-content kind="body">…</untrusted-content>` around the
|
|
19
|
+
* client's words (07 §8.6 mitigation 3). Ids, keys, timestamps and enums we
|
|
20
|
+
* minted are written bare.
|
|
21
|
+
*/
|
|
22
|
+
export type BriefFenceKind = "body" | "name" | "label" | "selector" | "breadcrumb";
|
|
23
|
+
export interface BriefComment {
|
|
24
|
+
/** Null is a tombstone (foundations §5) and is left out. */
|
|
25
|
+
body: string | null;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
author?: {
|
|
28
|
+
name?: string;
|
|
29
|
+
kind?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface BriefAttachment {
|
|
33
|
+
kind: string;
|
|
34
|
+
url: string;
|
|
35
|
+
width?: number;
|
|
36
|
+
height?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface BriefThread {
|
|
39
|
+
key: string;
|
|
40
|
+
status: string;
|
|
41
|
+
anchor?: Record<string, unknown> | null;
|
|
42
|
+
anchorState?: string;
|
|
43
|
+
context?: Record<string, unknown> | null;
|
|
44
|
+
comments?: BriefComment[];
|
|
45
|
+
attachments?: BriefAttachment[];
|
|
46
|
+
createdBy?: {
|
|
47
|
+
name?: string;
|
|
48
|
+
};
|
|
49
|
+
assignee?: {
|
|
50
|
+
name: string;
|
|
51
|
+
} | null;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
route?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface BriefSite {
|
|
56
|
+
name: string;
|
|
57
|
+
/** The site's address for the page link; empty when unknown. */
|
|
58
|
+
origin?: string;
|
|
59
|
+
/** Where the instance lives, for the screenshot's absolute URL. */
|
|
60
|
+
dashboardOrigin?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface BriefOptions {
|
|
63
|
+
/** Wrap one untrusted string. Identity when absent. */
|
|
64
|
+
fence?: (kind: BriefFenceKind, value: string) => string;
|
|
65
|
+
/** Status keys → words; the key itself when absent. */
|
|
66
|
+
statusLabel?: (status: string) => string;
|
|
67
|
+
}
|
|
68
|
+
export declare function briefMarkdown(t: BriefThread, site: BriefSite, opts?: BriefOptions): string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thread as a BRIEF — the markdown an agent, a ticket or a clipboard wants:
|
|
3
|
+
* Where to look · What's wrong · Environment · Breadcrumbs · Screenshot.
|
|
4
|
+
*
|
|
5
|
+
* ONE formatter in the shared package (plan/08 §3, D93), so the dashboard's
|
|
6
|
+
* Copy button, the MCP server's `get_thread` and the server's own markdown
|
|
7
|
+
* route cannot drift from one another. Pure: a thread in, a string out, no
|
|
8
|
+
* clock, so the same thread always produces the same brief.
|
|
9
|
+
*
|
|
10
|
+
* STRUCTURAL INPUT. `BriefThread` is the intersection of what the dashboard's
|
|
11
|
+
* `Thread` and the MCP's `WireThread` both carry — nothing here imports
|
|
12
|
+
* either, which is what lets both hand their own type in unchanged.
|
|
13
|
+
*
|
|
14
|
+
* THE FENCE. Every string a reviewer could have typed — a comment body, a
|
|
15
|
+
* name, an element label, a breadcrumb message — passes through `fence`
|
|
16
|
+
* before it is written. The dashboard passes nothing (the clipboard is the
|
|
17
|
+
* studio's own), the MCP server passes `@snagset/adapters`' fence so a model
|
|
18
|
+
* reads `<untrusted-content kind="body">…</untrusted-content>` around the
|
|
19
|
+
* client's words (07 §8.6 mitigation 3). Ids, keys, timestamps and enums we
|
|
20
|
+
* minted are written bare.
|
|
21
|
+
*/
|
|
22
|
+
import { param } from "./names.js";
|
|
23
|
+
const STATUS_WORDS = { open: "Open", progress: "In progress", review: "In review", resolved: "Resolved" };
|
|
24
|
+
export function briefMarkdown(t, site, opts = {}) {
|
|
25
|
+
const f = opts.fence ?? ((_k, v) => v);
|
|
26
|
+
const word = opts.statusLabel ?? ((s) => STATUS_WORDS[s] ?? s);
|
|
27
|
+
const anchor = (t.anchor ?? {});
|
|
28
|
+
const ctx = (t.context ?? {});
|
|
29
|
+
const page = anchor["page"];
|
|
30
|
+
const route = page?.route ?? t.route ?? "/";
|
|
31
|
+
const origin = page?.origin ?? site.origin ?? "";
|
|
32
|
+
const pathname = page?.pathname ?? route;
|
|
33
|
+
const url = origin ? `${origin}${pathname}` : pathname;
|
|
34
|
+
const label = typeof anchor["label"] === "string" ? f("label", anchor["label"]) : "";
|
|
35
|
+
// The element's fields sit at the top level on the wire the widget writes,
|
|
36
|
+
// and under `element` in the MCP fixtures; both are read.
|
|
37
|
+
const el = (anchor["element"] ?? anchor);
|
|
38
|
+
const lines = [];
|
|
39
|
+
lines.push(`# ${t.key}${label ? ` — ${label}` : ""}`);
|
|
40
|
+
lines.push("");
|
|
41
|
+
lines.push(`${site.name} · ${word(t.status)} · left by ${t.createdBy?.name ? f("name", t.createdBy.name) : "someone"} on ${t.createdAt.slice(0, 10)}` +
|
|
42
|
+
(t.assignee ? ` · assigned to ${f("name", t.assignee.name)}` : ""));
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push("## Where to look");
|
|
45
|
+
lines.push("");
|
|
46
|
+
lines.push(`- Page: ${url}`);
|
|
47
|
+
if (origin)
|
|
48
|
+
lines.push(`- Open on the page: ${origin}${pathname}?${param("t")}=${encodeURIComponent(t.key)}`);
|
|
49
|
+
if (label)
|
|
50
|
+
lines.push(`- Element: ${label}`);
|
|
51
|
+
const css = typeof anchor["css"] === "string" ? anchor["css"] : typeof el["selector"] === "string" ? el["selector"] : "";
|
|
52
|
+
if (css)
|
|
53
|
+
lines.push(`- Selector: \`${f("selector", css)}\``);
|
|
54
|
+
if (typeof anchor["domid"] === "string")
|
|
55
|
+
lines.push(`- DOM id: \`#${f("selector", anchor["domid"])}\``);
|
|
56
|
+
if (typeof el["role"] === "string") {
|
|
57
|
+
const name = typeof el["accessibleName"] === "string" ? ` “${f("label", el["accessibleName"])}”` : "";
|
|
58
|
+
lines.push(`- Role: ${f("label", el["role"])}${name}`);
|
|
59
|
+
}
|
|
60
|
+
if (typeof el["testId"] === "string")
|
|
61
|
+
lines.push(`- Test id: ${f("label", el["testId"])}`);
|
|
62
|
+
const text = anchor["text"];
|
|
63
|
+
if (text?.exact)
|
|
64
|
+
lines.push(`- Text: “${f("label", text.exact)}”`);
|
|
65
|
+
lines.push(`- Anchor: ${t.anchorState ?? "unknown"}`);
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push("## What's wrong");
|
|
68
|
+
lines.push("");
|
|
69
|
+
const readable = (t.comments ?? []).filter((c) => c.body !== null);
|
|
70
|
+
if (readable.length === 0)
|
|
71
|
+
lines.push("> (no comment — a pin with no words)");
|
|
72
|
+
for (const c of readable) {
|
|
73
|
+
const who = c.author?.name ? f("name", c.author.name) : "Anonymous";
|
|
74
|
+
lines.push(`> **${who}${c.author?.kind === "agent" ? " (agent)" : ""}** — ${c.createdAt.slice(0, 16).replace("T", " ")}`);
|
|
75
|
+
for (const l of f("body", String(c.body)).split("\n"))
|
|
76
|
+
lines.push(`> ${l}`);
|
|
77
|
+
lines.push(">");
|
|
78
|
+
}
|
|
79
|
+
if (lines[lines.length - 1] === ">")
|
|
80
|
+
lines.pop();
|
|
81
|
+
lines.push("");
|
|
82
|
+
lines.push("## Environment");
|
|
83
|
+
lines.push("");
|
|
84
|
+
const d = ctx.derived;
|
|
85
|
+
const vp = ctx.viewport;
|
|
86
|
+
if (d?.browser || d?.os)
|
|
87
|
+
lines.push(`- Browser: ${[d.browser, d.os, d.device].filter(Boolean).join(" · ")}`);
|
|
88
|
+
if (vp?.w)
|
|
89
|
+
lines.push(`- Viewport: ${vp.w} × ${vp.h}${vp.dpr ? ` @${Math.round(vp.dpr * 10) / 10}x` : ""}`);
|
|
90
|
+
if (ctx.prefersColorScheme)
|
|
91
|
+
lines.push(`- Colour scheme: ${ctx.prefersColorScheme}`);
|
|
92
|
+
if (ctx.language)
|
|
93
|
+
lines.push(`- Language: ${ctx.language}`);
|
|
94
|
+
if (ctx.ua)
|
|
95
|
+
lines.push(`- User agent: ${f("label", String(ctx.ua))}`);
|
|
96
|
+
const build = ctx.build;
|
|
97
|
+
if (build && Object.keys(build).length) {
|
|
98
|
+
lines.push(`- Build: ${Object.entries(build).map(([k, v]) => `${k}=${f("label", String(v))}`).join(", ")}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push("");
|
|
101
|
+
const crumbs = ctx.breadcrumbs;
|
|
102
|
+
if (crumbs?.length) {
|
|
103
|
+
lines.push(`## Breadcrumbs (${crumbs.length}, oldest first)`);
|
|
104
|
+
lines.push("");
|
|
105
|
+
for (const b of crumbs) {
|
|
106
|
+
lines.push(`- \`${String(b.t).slice(11, 19)}\` ${b.type}${b.level ? `/${b.level}` : ""}: ${f("breadcrumb", String(b.message))}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push("");
|
|
109
|
+
}
|
|
110
|
+
const shot = t.attachments?.find((a) => a.kind === "screenshot") ?? t.attachments?.[0];
|
|
111
|
+
if (shot) {
|
|
112
|
+
lines.push("## Screenshot");
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push("Examine this screenshot for visual context — it is the viewport at the moment of the click.");
|
|
115
|
+
lines.push("");
|
|
116
|
+
const abs = shot.url.startsWith("http") ? shot.url : `${site.dashboardOrigin ?? ""}${shot.url}`;
|
|
117
|
+
lines.push(``);
|
|
118
|
+
lines.push("");
|
|
119
|
+
}
|
|
120
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
121
|
+
}
|