@snagset/server 0.0.2 → 0.0.4

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.
@@ -0,0 +1,196 @@
1
+ import { Hono } from "hono";
2
+ import { randomUUID } from "node:crypto";
3
+ import { ID_PREFIX } from "../vendor/types/names.js";
4
+ function fail(c, status, code, message, details) {
5
+ return c.json({ error: { code, message, ...(details ? { details } : {}), requestId: c.get("requestId") } }, status);
6
+ }
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ function rowId(wire, prefix) {
9
+ if (typeof wire !== "string")
10
+ return null;
11
+ const bare = wire.startsWith(prefix) ? wire.slice(prefix.length) : wire;
12
+ return UUID.test(bare) ? bare.toLowerCase() : null;
13
+ }
14
+ /** 03 §5's enum, not free text. `manual` is reported by v2 repair, never by `resolve()`. */
15
+ const STRATEGY = new Set([
16
+ "testid",
17
+ "domid",
18
+ "css",
19
+ "aria",
20
+ "text-exact",
21
+ "text-fuzzy",
22
+ "path-rect",
23
+ "none",
24
+ "manual",
25
+ ]);
26
+ /**
27
+ * foundations §14.2's ordering. `pending` is deliberately absent — it is not a
28
+ * settled state, and §6.7 rejects it: a client reporting "I have not finished
29
+ * yet" is not reporting an outcome.
30
+ */
31
+ const RANK = { orphaned: 0, approximate: 1, anchored: 2 };
32
+ const MAX_REPORTS = 50;
33
+ /** §6.7: idempotent within 60 s per (thread, build, strategy). */
34
+ const DEDUPE_MS = 60_000;
35
+ const RATE_WINDOW_MS = 60_000;
36
+ export function anchorReportRoutes(sql, projects, perMinute) {
37
+ const app = new Hono();
38
+ app.post("/api/v1/projects/:uid/anchor-reports", async (c) => {
39
+ const p = await projects.get(c.req.param("uid")).catch(() => null);
40
+ if (!p)
41
+ return fail(c, 404, "project_not_found", "No project with that uid.");
42
+ const session = c.get("session");
43
+ if (!session) {
44
+ return fail(c, 401, "unauthorized", "Open your review link, or reload the page, and try again.");
45
+ }
46
+ const body = (await c.req.json().catch(() => null));
47
+ const reports = Array.isArray(body?.reports) ? body.reports : null;
48
+ if (!reports || reports.length === 0 || reports.length > MAX_REPORTS) {
49
+ return fail(c, 400, "validation_failed", `reports must be an array of 1 to ${MAX_REPORTS} items.`, { field: "reports" });
50
+ }
51
+ // Validate EVERY report before writing ANY of them. A batch that is half
52
+ // valid should not leave half a batch in the telemetry — the client has no
53
+ // way to learn which half landed, since the success case is a bare 204.
54
+ const parsed = [];
55
+ for (const r of reports) {
56
+ const threadRow = rowId(r["threadId"], ID_PREFIX.thread);
57
+ if (!threadRow) {
58
+ return fail(c, 400, "validation_failed", "threadId must be a thread id.", { field: "threadId" });
59
+ }
60
+ const state = String(r["state"]);
61
+ if (!(state in RANK)) {
62
+ return fail(c, 400, "validation_failed", "state must be one of orphaned, approximate, anchored — 'pending' is not an outcome.", { field: "state" });
63
+ }
64
+ const strategy = String(r["strategy"]);
65
+ if (!STRATEGY.has(strategy)) {
66
+ // 422, not 400, and §6.7 says why: a telemetry table that accepts
67
+ // anything measures nothing.
68
+ return fail(c, 422, "validation_failed", `Unknown anchor strategy '${strategy}'.`, {
69
+ field: "strategy",
70
+ });
71
+ }
72
+ const confidence = Number(r["confidence"]);
73
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
74
+ return fail(c, 400, "validation_failed", "confidence must be between 0 and 1.", {
75
+ field: "confidence",
76
+ });
77
+ }
78
+ const ms = Number(r["ms"]);
79
+ parsed.push({
80
+ threadRow,
81
+ state,
82
+ confidence,
83
+ strategy,
84
+ build: typeof r["build"] === "string" && r["build"] ? r["build"] : null,
85
+ ms: Number.isFinite(ms) ? Math.round(ms) : null,
86
+ });
87
+ }
88
+ /**
89
+ * Rate limit AFTER validation, so a malformed batch still gets its 4xx
90
+ * rather than being silently swallowed by a limit it also tripped — the
91
+ * developer integrating this needs the error more than we need the saving.
92
+ */
93
+ const now = new Date();
94
+ const windowStart = new Date(now.getTime() - RATE_WINDOW_MS);
95
+ const [counter] = await sql `
96
+ INSERT INTO rate_counters (org_id, bucket_key, window_start, count, expires_at)
97
+ VALUES (${p.org_id}, ${`anch:${session.sid}`}, ${now}, ${parsed.length},
98
+ ${new Date(now.getTime() + RATE_WINDOW_MS)})
99
+ ON CONFLICT (org_id, bucket_key) DO UPDATE
100
+ SET count = CASE WHEN rate_counters.window_start < ${windowStart} THEN ${parsed.length}
101
+ ELSE rate_counters.count + ${parsed.length} END,
102
+ window_start = CASE WHEN rate_counters.window_start < ${windowStart} THEN ${now}
103
+ ELSE rate_counters.window_start END,
104
+ expires_at = ${new Date(now.getTime() + RATE_WINDOW_MS)}
105
+ RETURNING count::text
106
+ `;
107
+ // Dropped, not queued, and not an error. §12.1 rule 8.
108
+ if (Number(counter?.count ?? 0) > perMinute())
109
+ return c.body(null, 204);
110
+ await sql.begin(async (tx) => {
111
+ await tx `SELECT set_config('app.org_id', ${p.org_id}, true)`;
112
+ for (const r of parsed) {
113
+ const [thread] = await tx `
114
+ SELECT id, anchor_state, anchor_state_build, last_anchor_check, version
115
+ FROM threads WHERE id = ${r.threadRow} AND project_id = ${p.id} AND deleted_at IS NULL
116
+ `;
117
+ // Not this project's thread. Skipped rather than failing the batch:
118
+ // the 404 case §6.7 names is a caller sending someone else's id, and
119
+ // by here we have already taken the rate-limit hit for it. A deleted
120
+ // thread is the same shape and is even less worth an error.
121
+ if (!thread)
122
+ continue;
123
+ /**
124
+ * §6.7's 60-second idempotency, by natural key rather than a header. A
125
+ * re-rendering SPA that resolves the same pin four times in a second
126
+ * is not four observations; it is one, seen four times.
127
+ */
128
+ const [recent] = await tx `
129
+ SELECT count(*)::int AS n FROM anchor_events
130
+ WHERE thread_id = ${thread.id}
131
+ AND strategy = ${r.strategy}
132
+ AND build IS NOT DISTINCT FROM ${r.build}
133
+ AND at > ${new Date(now.getTime() - DEDUPE_MS)}
134
+ `;
135
+ if ((recent?.n ?? 0) > 0)
136
+ continue;
137
+ /**
138
+ * THE MONOTONIC MERGE (§6.7, foundations §14.2 normative).
139
+ *
140
+ * A new build is exactly when a pin is allowed to get worse — that is
141
+ * what keeps this monotonic rather than a one-way ratchet that could
142
+ * never report a real regression. Otherwise the maximum wins within
143
+ * the scope, and when neither side knows a build the scope is a
144
+ * rolling 24 hours.
145
+ */
146
+ const newBuild = !!r.build && r.build !== thread.anchor_state_build;
147
+ const stale = !r.build &&
148
+ !thread.anchor_state_build &&
149
+ !!thread.last_anchor_check &&
150
+ now.getTime() - new Date(thread.last_anchor_check).getTime() > 24 * 60 * 60 * 1000;
151
+ const resets = newBuild || stale;
152
+ const next = resets
153
+ ? r.state
154
+ : (RANK[r.state] ?? 0) > (RANK[thread.anchor_state] ?? 0)
155
+ ? r.state
156
+ : thread.anchor_state;
157
+ const applied = next !== thread.anchor_state || resets;
158
+ await tx `
159
+ INSERT INTO anchor_events (id, org_id, project_id, thread_id, state, confidence,
160
+ strategy, actor_kind, actor_id, build, ms, applied)
161
+ VALUES (${randomUUID()}, ${p.org_id}, ${p.id}, ${thread.id}, ${r.state}::snag_anchor_state,
162
+ ${r.confidence}, ${r.strategy}, 'runtime', NULL, ${r.build}, ${r.ms}, ${applied})
163
+ `;
164
+ /**
165
+ * `updated_at` and `version` move ONLY when the state actually
166
+ * changed. Telemetry must not wake a polling client, and it must not
167
+ * enqueue an outbox row for a non-event — a reviewer being emailed
168
+ * because somebody's browser re-measured a pin is the kind of noise
169
+ * that teaches people to filter the sender.
170
+ */
171
+ if (next !== thread.anchor_state) {
172
+ await tx `
173
+ UPDATE threads
174
+ SET anchor_state = ${next}::snag_anchor_state,
175
+ anchor_state_build = ${r.build ?? thread.anchor_state_build},
176
+ last_anchor_check = ${now},
177
+ updated_at = ${now},
178
+ version = version + 1
179
+ WHERE id = ${thread.id}
180
+ `;
181
+ }
182
+ else {
183
+ await tx `
184
+ UPDATE threads
185
+ SET last_anchor_check = ${now},
186
+ anchor_state_build = ${r.build ?? thread.anchor_state_build}
187
+ WHERE id = ${thread.id}
188
+ `;
189
+ }
190
+ }
191
+ });
192
+ return c.body(null, 204);
193
+ });
194
+ return app;
195
+ }
196
+ //# sourceMappingURL=anchorReports.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anchorReports.js","sourceRoot":"","sources":["../../src/routes/anchorReports.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 05 §9.3 — the JSON body cap, which was registered and enforced by nothing.
3
+ *
4
+ * `SNAGSET_MAX_BODY_BYTES` has been in the config registry since 08 §4 with a
5
+ * 1 MB default, cited by name in two specs, printed by `snagset env` — and read
6
+ * by no code at all. So every JSON route on this server would read whatever it
7
+ * was sent, and the only thing standing between an instance and a body large
8
+ * enough to exhaust its memory was the hosting platform's own proxy, which a
9
+ * self-hoster on their own box does not have.
10
+ *
11
+ * ── THE TWO NUMBERS, AND WHY ONLY ONE IS TUNABLE ──
12
+ *
13
+ * 05 §9.3 caps JSON at **256 KB** and says plainly it is *"not operator-
14
+ * tunable"*. 08 §4 caps binary routes at `SNAGSET_MAX_BODY_BYTES` (1 MB) and
15
+ * that one IS tunable. They are different limits for different reasons: the
16
+ * JSON cap protects the parser, and every JSON body this API accepts is small
17
+ * by construction — a comment, an anchor, a capture context whose breadcrumbs
18
+ * are already truncated server-side. An operator raising it is not configuring
19
+ * the product, they are disabling a bound the wire format does not need.
20
+ *
21
+ * Binary is different: images are ≤ 256 KB by construction, but the ceiling is
22
+ * the operator's because their proxy and their bucket are.
23
+ *
24
+ * ── WHY `Content-Length` AND NOT A COUNTING STREAM ──
25
+ *
26
+ * Rejecting on the declared length is the whole point: it costs nothing and it
27
+ * happens BEFORE the body is read. A stream that counts as it goes has already
28
+ * accepted the bytes it is measuring. A liar who declares 1 KB and sends 10 MB
29
+ * defeats the header check — and is then bounded by the platform's own limit
30
+ * and by the fact that `c.req.json()` throws on a truncated body, which is the
31
+ * same place an unbounded read would have failed anyway. The header check is
32
+ * the cheap 99%, not the security boundary; it exists so an honest client with
33
+ * a runaway payload gets a clean `413` instead of an OOM.
34
+ */
35
+ /** 05 §9.3, and deliberately not `SNAGSET_MAX_BODY_BYTES`. */
36
+ export const JSON_BODY_CAP = 256 * 1024;
37
+ export function bodyLimit(binaryBytes) {
38
+ return async (c, next) => {
39
+ const method = c.req.method;
40
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS")
41
+ return next();
42
+ const declared = Number(c.req.header("content-length") ?? "");
43
+ if (!Number.isFinite(declared) || declared <= 0)
44
+ return next();
45
+ const type = (c.req.header("content-type") ?? "").toLowerCase();
46
+ const isJson = type.includes("json") || type === "";
47
+ const cap = isJson ? JSON_BODY_CAP : binaryBytes();
48
+ if (declared > cap) {
49
+ return c.json({
50
+ error: {
51
+ code: "body_too_large",
52
+ message: `That request body is ${Math.round(declared / 1024)} KB; the limit here is ${Math.round(cap / 1024)} KB.`,
53
+ requestId: c.get("requestId"),
54
+ },
55
+ }, 413);
56
+ }
57
+ return next();
58
+ };
59
+ }
60
+ //# sourceMappingURL=bodyLimit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bodyLimit.js","sourceRoot":"","sources":["../../src/routes/bodyLimit.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,8DAA8D;AAC9D,MAAM,CAAC,MAAM,aAAa,GAAG,GAAG,GAAG,IAAI,CAAC;AAExC,MAAM,UAAU,SAAS,CAAC,WAAyB;IACjD,OAAO,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE;QACvB,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC;QAC5B,IAAI,MAAM,KAAK,KAAK,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,SAAS;YAAE,OAAO,IAAI,EAAE,CAAC;QAEjF,MAAM,QAAQ,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,gBAAgB,CAAC,IAAI,EAAE,CAAC,CAAC;QAC9D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,CAAC;YAAE,OAAO,IAAI,EAAE,CAAC;QAE/D,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QAChE,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,IAAI,KAAK,EAAE,CAAC;QACpD,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;QAEnD,IAAI,QAAQ,GAAG,GAAG,EAAE,CAAC;YACnB,OAAO,CAAC,CAAC,IAAI,CACX;gBACE,KAAK,EAAE;oBACL,IAAI,EAAE,gBAAgB;oBACtB,OAAO,EAAE,wBAAwB,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC,0BAA0B,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,MAAM;oBAClH,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;iBAC9B;aACF,EACD,GAAG,CACJ,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@snagset/server",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Snagset server: API, dashboard and widget in one package. Node and Postgres, or Vercel.",
7
7
  "license": "AGPL-3.0-only",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/snagset/snagset.git",
11
+ "directory": "apps/server"
12
+ },
8
13
  "bin": {
9
14
  "snagset": "./dist/bin.js"
10
15
  },
@@ -0,0 +1 @@
1
+ (function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))s(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function r(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(l){if(l.ep)return;l.ep=!0;const i=r(l);fetch(l.href,i)}})();var ee,C,xe,q,me,Ie,Ee,re,V,F,je,ce,se,oe,Q={},X=[],We=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,te=Array.isArray;function L(e,t){for(var r in t)e[r]=t[r];return e}function de(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function Ge(e,t,r){var s,l,i,o={};for(i in t)i=="key"?s=t[i]:i=="ref"?l=t[i]:o[i]=t[i];if(arguments.length>2&&(o.children=arguments.length>3?ee.call(arguments,2):r),typeof e=="function"&&e.defaultProps!=null)for(i in e.defaultProps)o[i]===void 0&&(o[i]=e.defaultProps[i]);return K(e,o,s,l,null)}function K(e,t,r,s,l){var i={type:e,props:t,key:r,ref:s,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:l??++xe,__i:-1,__u:0};return l==null&&C.vnode!=null&&C.vnode(i),i}function D(e){return e.children}function J(e,t){this.props=e,this.context=t}function O(e,t){if(t==null)return e.__?O(e.__,e.__i+1):null;for(var r;t<e.__k.length;t++)if((r=e.__k[t])!=null&&r.__e!=null)return r.__e;return typeof e.type=="function"?O(e):null}function ze(e){if(e.__P&&e.__d){var t=e.__v,r=t.__e,s=[],l=[],i=L({},t);i.__v=t.__v+1,C.vnode&&C.vnode(i),ue(e.__P,i,t,e.__n,e.__P.namespaceURI,32&t.__u?[r]:null,s,r??O(t),!!(32&t.__u),l),i.__v=t.__v,i.__.__k[i.__i]=i,Ue(s,i,l),t.__e=t.__=null,i.__e!=r&&Ne(i)}}function Ne(e){if((e=e.__)!=null&&e.__c!=null)return e.__e=e.__c.base=null,e.__k.some(function(t){if(t!=null&&t.__e!=null)return e.__e=e.__c.base=t.__e}),Ne(e)}function fe(e){(!e.__d&&(e.__d=!0)&&q.push(e)&&!Z.__r++||me!=C.debounceRendering)&&((me=C.debounceRendering)||Ie)(Z)}function Z(){try{for(var e,t=1;q.length;)q.length>t&&q.sort(Ee),e=q.shift(),t=q.length,ze(e)}finally{q.length=Z.__r=0}}function Re(e,t,r,s,l,i,o,c,_,d,h){var w,a,p,g,v,k,y=s&&s.__k||X,f=t.length;for(_=Ve(r,t,y,_,f),w=0;w<f;w++)(p=r.__k[w])!=null&&(a=p.__i!=-1&&y[p.__i]||Q,p.__i=w,k=ue(e,p,a,l,i,o,c,_,d,h),g=p.__e,p.ref&&a.ref!=p.ref&&(a.ref&&_e(a.ref,null,p),h.push(p.ref,p.__c||g,p)),v==null&&g!=null&&(v=g),4&p.__u?(_=De(p,_,e),a.__e&&(a.__e=null)):typeof p.type=="function"&&k!==void 0?_=k:g&&(_=g.nextSibling),p.__u&=-7);return r.__e=v,_}function Ve(e,t,r,s,l){var i,o,c,_,d,h=r.length,w=h,a=0;for(e.__k=new Array(l),i=0;i<l;i++)(o=t[i])!=null&&typeof o!="boolean"&&typeof o!="function"?(typeof o=="string"||typeof o=="number"||typeof o=="bigint"||o.constructor==String?o=e.__k[i]=K(null,o,null,null,null):te(o)?o=e.__k[i]=K(D,{children:o},null,null,null):o.constructor===void 0&&o.__b>0?o=e.__k[i]=K(o.type,o.props,o.key,o.ref?o.ref:null,o.__v):e.__k[i]=o,_=i+a,o.__=e,o.__b=e.__b+1,c=null,(d=o.__i=Ke(o,r,_,w))!=-1&&(w--,(c=r[d])&&(c.__u|=2)),c==null||c.__v==null?(d==-1&&(l>h?a--:l<h&&a++),typeof o.type!="function"&&(o.__u|=4)):d!=_&&(d==_-1?a--:d==_+1?a++:(d>_?a--:a++,o.__u|=4))):e.__k[i]=null;if(w)for(i=0;i<h;i++)(c=r[i])!=null&&(2&c.__u)==0&&(c.__e==s&&(s=O(c)),Oe(c,c));return s}function De(e,t,r){var s,l;if(typeof e.type=="function"){for(s=e.__k,l=0;s&&l<s.length;l++)s[l]&&(s[l].__=e,t=De(s[l],t,r));return t}e.__e!=t&&(t&&e.type&&!t.parentNode&&(t=O(e)),t=r.insertBefore(e.__e,t||null));do t=t&&t.nextSibling;while(t!=null&&t.nodeType==8);return t}function Ke(e,t,r,s){var l,i,o,c=e.key,_=e.type,d=t[r],h=d!=null&&(2&d.__u)==0;if(d===null&&c==null||h&&c==d.key&&_==d.type)return r;if(s>(h?1:0)){for(l=r-1,i=r+1;l>=0||i<t.length;)if((d=t[o=l>=0?l--:i++])!=null&&(2&d.__u)==0&&c==d.key&&_==d.type)return o}return-1}function ve(e,t,r){t[0]=="-"?e.setProperty(t,r??""):e[t]=r==null?"":typeof r!="number"||We.test(t)?r:r+"px"}function G(e,t,r,s,l){var i,o;e:if(t=="style")if(typeof r=="string")e.style.cssText=r;else{if(typeof s=="string"&&(e.style.cssText=s=""),s)for(t in s)r&&t in r||ve(e.style,t,"");if(r)for(t in r)s&&r[t]==s[t]||ve(e.style,t,r[t])}else if(t[0]=="o"&&t[1]=="n")i=t!=(t=t.replace(je,"$1")),o=t.toLowerCase(),t=o in e||t=="onFocusOut"||t=="onFocusIn"?o.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+i]=r,r?s?r[F]=s[F]:(r[F]=ce,e.addEventListener(t,i?oe:se,i)):e.removeEventListener(t,i?oe:se,i);else{if(l=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=r??"";break e}catch{}typeof r=="function"||(r==null||r===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&r==1?"":r))}}function ge(e){return function(t){if(this.l){var r=this.l[t.type+e];if(t[V]==null)t[V]=ce++;else if(t[V]<r[F])return;return r(C.event?C.event(t):t)}}}function ue(e,t,r,s,l,i,o,c,_,d){var h,w,a,p,g,v,k,y,f,T,N,m,P,R,u,S,A=t.type;if(t.constructor!==void 0)return null;128&r.__u&&(_=!!(32&r.__u),i=[c=t.__e=r.__e]),(h=C.__b)&&h(t);e:if(typeof A=="function"){w=o.length;try{if(f=t.props,T=A.prototype&&A.prototype.render,N=(h=A.contextType)&&s[h.__c],m=h?N?N.props.value:h.__:s,r.__c?y=(a=t.__c=r.__c).__=a.__E:(T?t.__c=a=new A(f,m):(t.__c=a=new J(f,m),a.constructor=A,a.render=Ye),N&&N.sub(a),a.state||(a.state={}),a.__n=s,p=a.__d=!0,a.__h=[],a._sb=[]),T&&a.__s==null&&(a.__s=a.state),T&&A.getDerivedStateFromProps!=null&&(a.__s==a.state&&(a.__s=L({},a.__s)),L(a.__s,A.getDerivedStateFromProps(f,a.__s))),g=a.props,v=a.state,a.__v=t,p)T&&A.getDerivedStateFromProps==null&&a.componentWillMount!=null&&a.componentWillMount(),T&&a.componentDidMount!=null&&a.__h.push(a.componentDidMount);else{if(T&&A.getDerivedStateFromProps==null&&f!==g&&a.componentWillReceiveProps!=null&&a.componentWillReceiveProps(f,m),t.__v==r.__v||!a.__e&&a.shouldComponentUpdate!=null&&a.shouldComponentUpdate(f,a.__s,m)===!1){t.__v!=r.__v&&(a.props=f,a.state=a.__s,a.__d=!1),t.__e=r.__e,t.__k=r.__k,t.__k.some(function($){$&&($.__=t)}),X.push.apply(a.__h,a._sb),a._sb=[],a.__h.length&&o.push(a),c=O(r);break e}a.componentWillUpdate!=null&&a.componentWillUpdate(f,a.__s,m),T&&a.componentDidUpdate!=null&&a.__h.push(function(){a.componentDidUpdate(g,v,k)})}if(a.context=m,a.props=f,a.__P=e,a.__e=!1,P=C.__r,R=0,T)a.state=a.__s,a.__d=!1,P&&P(t),h=a.render(a.props,a.state,a.context),X.push.apply(a.__h,a._sb),a._sb=[];else do a.__d=!1,P&&P(t),h=a.render(a.props,a.state,a.context),a.state=a.__s;while(a.__d&&++R<25);a.state=a.__s,a.getChildContext!=null&&(s=L(L({},s),a.getChildContext())),T&&!p&&a.getSnapshotBeforeUpdate!=null&&(k=a.getSnapshotBeforeUpdate(g,v)),u=h!=null&&h.type===D&&h.key==null?qe(h.props.children):h,c=Re(e,te(u)?u:[u],t,r,s,l,i,o,c,_,d),a.base=t.__e,t.__u&=-161,a.__h.length&&o.push(a),y&&(a.__E=a.__=null)}catch($){if(o.length=w,t.__v=null,_||i!=null){if($.then){for(t.__u|=_?160:128;c&&c.nodeType==8&&c.nextSibling;)c=c.nextSibling;i!=null&&(i[i.indexOf(c)]=null),t.__e=c}else if(i!=null)for(S=i.length;S--;)de(i[S])}else t.__e=r.__e;t.__k==null&&(t.__k=r.__k||[]),$.then||Le(t),C.__e($,t,r)}}else i==null&&t.__v==r.__v?(t.__k=r.__k,t.__e=r.__e):c=t.__e=Je(r.__e,t,r,s,l,i,o,_,d);return(h=C.diffed)&&h(t),128&t.__u?void 0:c}function Le(e){e&&(e.__c&&(e.__c.__e=!0),e.__k&&e.__k.some(Le))}function Ue(e,t,r){for(var s=0;s<r.length;s++)_e(r[s],r[++s],r[++s]);C.__c&&C.__c(t,e),e.some(function(l){try{e=l.__h,l.__h=[],e.some(function(i){i.call(l)})}catch(i){C.__e(i,l.__v)}})}function qe(e){return typeof e!="object"||e==null||e.__b>0?e:te(e)?e.map(qe):e.constructor!==void 0?null:L({},e)}function Je(e,t,r,s,l,i,o,c,_){var d,h,w,a,p,g,v,k=r.props||Q,y=t.props,f=t.type;if(f=="svg"?l="http://www.w3.org/2000/svg":f=="math"?l="http://www.w3.org/1998/Math/MathML":l||(l="http://www.w3.org/1999/xhtml"),i!=null){for(d=0;d<i.length;d++)if((p=i[d])&&"setAttribute"in p==!!f&&(f?p.localName==f:p.nodeType==3)){e=p,i[d]=null;break}}if(e==null){if(f==null)return document.createTextNode(y);e=document.createElementNS(l,f,y.is&&y),c&&(C.__m&&C.__m(t,i),c=!1),i=null}if(f==null)k===y||c&&e.data==y||(e.data=y);else{if(i=f=="textarea"&&y.defaultValue!=null?null:i&&ee.call(e.childNodes),!c&&i!=null)for(k={},d=0;d<e.attributes.length;d++)k[(p=e.attributes[d]).name]=p.value;for(d in k)p=k[d],d=="dangerouslySetInnerHTML"?w=p:d=="children"||d in y||d=="value"&&"defaultValue"in y||d=="checked"&&"defaultChecked"in y||G(e,d,null,p,l);for(d in y)p=y[d],d=="children"?a=p:d=="dangerouslySetInnerHTML"?h=p:d=="value"?g=p:d=="checked"?v=p:c&&typeof p!="function"||k[d]===p||G(e,d,p,k[d],l);if(h)c||w&&(h.__html==w.__html||h.__html==e.innerHTML)||(e.innerHTML=h.__html),t.__k=[];else if(w&&(e.innerHTML=""),Re(t.type=="template"?e.content:e,te(a)?a:[a],t,r,s,f=="foreignObject"?"http://www.w3.org/1999/xhtml":l,i,o,i?i[0]:r.__k&&O(r,0),c,_),i!=null)for(d=i.length;d--;)de(i[d]);c&&f!="textarea"||(d="value",f=="progress"&&g==null?e.removeAttribute("value"):g!=null&&(g!==e[d]||f=="progress"&&!g||f=="option"&&g!=k[d])&&G(e,d,g,k[d],l),d="checked",v!=null&&v!=e[d]&&G(e,d,v,k[d],l))}return e}function _e(e,t,r){try{if(typeof e=="function"){var s=typeof e.__u=="function";s&&e.__u(),s&&t==null||(e.__u=e(t))}else e.current=t}catch(l){C.__e(l,r)}}function Oe(e,t,r){var s,l;if(C.unmount&&C.unmount(e),(s=e.ref)&&(s.current&&s.current!=e.__e||_e(s,null,t)),(s=e.__c)!=null){if(s.componentWillUnmount)try{s.componentWillUnmount()}catch(i){C.__e(i,t)}s.base=s.__P=s.__n=null}if(s=e.__k)for(l=0;l<s.length;l++)s[l]&&Oe(s[l],t,r||typeof e.type!="function");r||de(e.__e),e.__c=e.__=e.__e=void 0}function Ye(e,t,r){return this.constructor(e,r)}function Qe(e,t,r){var s,l,i,o;t==document&&(t=document.documentElement),C.__&&C.__(e,t),l=(s=!1)?null:t.__k,i=[],o=[],ue(t,e=t.__k=Ge(D,null,[e]),l||Q,Q,t.namespaceURI,l?null:t.firstChild?ee.call(t.childNodes):null,i,l?l.__e:t.firstChild,s,o),Ue(i,e,o),e.props.children=null}ee=X.slice,C={__e:function(e,t,r,s){for(var l,i,o;t=t.__;)if((l=t.__c)&&!l.__)try{if((i=l.constructor)&&i.getDerivedStateFromError!=null&&(l.setState(i.getDerivedStateFromError(e)),o=l.__d),l.componentDidCatch!=null&&(l.componentDidCatch(e,s||{}),o=l.__d),o)return l.__E=l}catch(c){e=c}throw e}},xe=0,J.prototype.setState=function(e,t){var r;r=this.__s!=null&&this.__s!=this.state?this.__s:this.__s=L({},this.state),typeof e=="function"&&(e=e(L({},r),this.props)),e&&L(r,e),e!=null&&this.__v&&(t&&this._sb.push(t),fe(this))},J.prototype.forceUpdate=function(e){this.__v&&(this.__e=!0,e&&this.__h.push(e),fe(this))},J.prototype.render=D,q=[],Ie=typeof Promise=="function"?Promise.prototype.then.bind(Promise.resolve()):setTimeout,Ee=function(e,t){return e.__v.__b-t.__v.__b},Z.__r=0,re=Math.random().toString(8),V="__d"+re,F="__a"+re,je=/(PointerCapture)$|Capture$/i,ce=0,se=ge(!1),oe=ge(!0);var Xe=0;function n(e,t,r,s,l,i){t||(t={});var o,c,_=t;if("ref"in _)for(c in _={},t)c=="ref"?o=t[c]:_[c]=t[c];var d={type:e,props:_,key:r,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--Xe,__i:-1,__u:0,__source:l,__self:i};if(typeof e=="function"&&(o=e.defaultProps))for(c in o)_[c]===void 0&&(_[c]=o[c]);return C.vnode&&C.vnode(d),d}var B,x,ie,ye,W=0,He=[],E=C,be=E.__b,we=E.__r,ke=E.diffed,Se=E.__c,Ce=E.unmount,$e=E.__;function he(e,t){E.__h&&E.__h(x,e,W||t),W=0;var r=x.__H||(x.__H={__:[],__h:[]});return e>=r.__.length&&r.__.push({}),r.__[e]}function b(e){return W=1,Ze(Be,e)}function Ze(e,t,r){var s=he(B++,2);if(s.t=e,!s.__c&&(s.__=[Be(void 0,t),function(c){var _=s.__N?s.__N[0]:s.__[0],d=s.t(_,c);_!==d&&(s.__N=[d,s.__[1]],s.__c.setState({}))}],s.__c=x,!x.__f)){var l=function(c,_,d){if(!s.__c.__H)return!0;var h=!1,w=s.__c.props!==c;if(s.__c.__H.__.some(function(p){if(p.__N){h=!0;var g=p.__[0];p.__=p.__N,p.__N=void 0,g!==p.__[0]&&(w=!0)}}),i){var a=i.call(this,c,_,d);return h?a||w:a}return!h||w};x.__f=!0;var i=x.shouldComponentUpdate,o=x.componentWillUpdate;x.componentWillUpdate=function(c,_,d){if(this.__e){var h=i;i=void 0,l(c,_,d),i=h}o&&o.call(this,c,_,d)},x.shouldComponentUpdate=l}return s.__N||s.__}function M(e,t){var r=he(B++,3);!E.__s&&Fe(r.__H,t)&&(r.__=e,r.u=t,x.__H.__h.push(r))}function et(e){return W=5,pe(function(){return{current:e}},[])}function pe(e,t){var r=he(B++,7);return Fe(r.__H,t)&&(r.__=e(),r.__H=t,r.__h=e),r.__}function Me(e,t){return W=8,pe(function(){return e},t)}function tt(){for(var e;e=He.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Y),t.__h.some(le),t.__h=[]}catch(r){t.__h=[],E.__e(r,e.__v)}}}E.__b=function(e){x=null,be&&be(e)},E.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),$e&&$e(e,t)},E.__r=function(e){we&&we(e),B=0;var t=(x=e.__c).__H;t&&(ie===x?(t.__h=[],x.__h=[],t.__.some(function(r){r.__N&&(r.__=r.__N),r.u=r.__N=void 0})):(t.__h.some(Y),t.__h.some(le),t.__h=[],B=0)),ie=x},E.diffed=function(e){ke&&ke(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(He.push(t)!==1&&ye===E.requestAnimationFrame||((ye=E.requestAnimationFrame)||nt)(tt)),t.__H.__.some(function(r){r.u&&(r.__H=r.u,r.u=void 0)})),ie=x=null},E.__c=function(e,t){t.some(function(r){try{r.__h.some(Y),r.__h=r.__h.filter(function(s){return!s.__||le(s)})}catch(s){t.some(function(l){l.__h&&(l.__h=[])}),t=[],E.__e(s,r.__v)}}),Se&&Se(e,t)},E.unmount=function(e){Ce&&Ce(e);var t,r=e.__c;r&&r.__H&&(r.__H.__.some(function(s){try{Y(s)}catch(l){t=l}}),r.__H=void 0,t&&E.__e(t,r.__v))};var Te=typeof requestAnimationFrame=="function";function nt(e){var t,r=function(){clearTimeout(s),Te&&cancelAnimationFrame(t),setTimeout(e)},s=setTimeout(r,35);Te&&(t=requestAnimationFrame(r))}function Y(e){var t=x,r=e.__c;typeof r=="function"&&(e.__c=void 0,r()),x=t}function le(e){var t=x;e.__c=e.__(),x=t}function Fe(e,t){return!e||e.length!==t.length||t.some(function(r,s){return r!==e[s]})}function Be(e,t){return typeof t=="function"?t(e):t}const rt={csrf:"snag_csrf"},it=rt.csrf,st={csrf:"x-snagset-csrf"};class U extends Error{status;code;constructor(t,r){super(r.message),this.status=t,this.code=r.code}}function ot(){for(const e of document.cookie.split(";")){const t=e.indexOf("=");if(t>0&&e.slice(0,t).trim()===it)return e.slice(t+1).trim()}return""}async function j(e,t,r){const s=await fetch(t,{method:e,credentials:"same-origin",headers:{...r===void 0?{}:{"content-type":"application/json"},...e==="GET"?{}:{[st.csrf]:ot()}},...r===void 0?{}:{body:JSON.stringify(r)}});if(s.status===204)return;const l=await s.json().catch(()=>null);if(!s.ok){const i=l?.error;throw new U(s.status,i??{code:"unknown",message:`Request failed (${s.status}).`})}return l}const I={me:()=>j("GET","/api/v1/me"),claimed:()=>j("GET","/api/v1/setup").then(e=>e.claimed),setup:e=>j("POST","/api/v1/setup",e),login:(e,t)=>j("POST","/auth/login",{email:e,password:t}),logout:()=>j("POST","/auth/logout"),threads:(e,t={})=>{const r=new URLSearchParams;t.status&&r.set("status",t.status),t.route&&r.set("route",t.route),t.round&&r.set("round",String(t.round)),t.q&&r.set("q",t.q),t.since&&r.set("since",t.since);const s=r.toString();return j("GET",`/api/v1/projects/${encodeURIComponent(e)}/threads${s?`?${s}`:""}`)},createProject:(e,t)=>j("POST","/api/v1/projects",{name:e,origins:t}),setAudience:(e,t)=>j("PATCH",`/api/v1/projects/${encodeURIComponent(e)}`,{writeMode:t}),origins:e=>j("GET",`/api/v1/projects/${encodeURIComponent(e)}/origins`),addOrigin:(e,t)=>j("POST",`/api/v1/projects/${encodeURIComponent(e)}/origins`,{origin:t}),rounds:e=>j("GET",`/api/v1/projects/${encodeURIComponent(e)}/rounds`),createRound:(e,t)=>j("POST",`/api/v1/projects/${encodeURIComponent(e)}/rounds`,{name:t}),setStatus:(e,t,r)=>j("PATCH",`/api/v1/projects/${encodeURIComponent(e)}/threads/${encodeURIComponent(t)}`,{status:r}),addComment:(e,t)=>j("POST",`/api/v1/threads/${encodeURIComponent(e)}/comments`,{id:crypto.randomUUID(),body:t}),listLinks:e=>j("GET",`/api/v1/projects/${encodeURIComponent(e)}/review-links`),mintLink:(e,t)=>j("POST",`/api/v1/projects/${encodeURIComponent(e)}/review-links`,t),revokeLink:e=>j("DELETE",`/api/v1/review-links/${encodeURIComponent(e)}`)};function Ae(e){const t=new Date(e),r=Math.round((Date.now()-t.getTime())/6e4);return r<1?"just now":r<60?`${r}m ago`:r<1440?`${Math.round(r/60)}h ago`:t.toLocaleDateString(void 0,{day:"numeric",month:"short"})}function lt({context:e}){const t=e?.capture,r=e?.viewport,l=[["Viewport",r?.w?`${r.w} × ${r.h}`:null],["Browser",e?.browser??e?.ua??null],["Platform",e?.os??e?.platform??null],["Device pixel ratio",e?.dpr??null]].filter(([,c])=>c!=null&&c!==""),i=t?.category&&t.category!=="ok"&&t.category!=="full";return!l.length&&!t?null:n("section",{class:"block",children:[n("h2",{children:["Capture",n("button",{type:"button",class:"linkish",style:"float:right;font-size:12px",onClick:()=>{navigator.clipboard?.writeText(JSON.stringify(e,null,2))},children:"Copy environment"})]}),l.length>0&&n("dl",{class:"kv",children:l.map(([c,_])=>n(D,{children:[n("dt",{children:c}),n("dd",{children:String(_)})]}))}),i&&n("p",{class:"warn",style:"margin:10px 0 0",children:[n("b",{children:t.category}),t.reasons?.length?` — ${t.reasons.join("; ")}`:""]}),t?.blindSpots?.length?n("p",{class:"warn",style:"margin:6px 0 0",children:[t.blindSpots.length," blind spot",t.blindSpots.length===1?"":"s",": ",t.blindSpots.join("; ")]}):null,t?.droppedCategories?.length?n("p",{class:"warn",style:"margin:6px 0 0",children:["Dropped: ",t.droppedCategories.join(", ")]}):null]})}function at({onReply:e}){const[t,r]=b(""),[s,l]=b(!1),[i,o]=b("");return n("form",{class:"composer",onSubmit:async c=>{c.preventDefault();const _=t.trim();if(!(!_||s)){l(!0),o("");try{await e(_),r("")}catch(d){o(d instanceof U?d.message:"Could not send that reply.")}finally{l(!1)}}},children:[n("label",{class:"field",children:[n("span",{children:"Reply"}),n("textarea",{rows:3,placeholder:"Write a reply…",value:t,disabled:s,onInput:c=>r(c.target.value)})]}),i&&n("p",{class:"err",children:i}),n("button",{class:"btn btn-primary",type:"submit",disabled:s||!t.trim(),children:s?"Sending…":"Reply"})]})}function ct({thread:e,onStatus:t,onReply:r,busy:s}){const l=e.attachments?.find(c=>c.kind==="screenshot")??e.attachments?.[0],i=e.anchor?.label??"",o=e.anchor??{};return n("article",{class:"detail detail-open",children:[n("div",{class:"detail-head",children:[n("span",{class:"key",children:e.key}),n("h1",{children:i||"A comment on this page"}),n("span",{class:`status status-${e.status}`,children:ae[e.status]??e.status})]}),n("p",{style:"margin:6px 0 0;color:var(--ink-soft);font-size:13px",children:["Round ",e.roundNumber," · ",e.route||"/"," · left by ",e.createdBy.name||"someone"," ",Ae(e.createdAt)]}),l?n("figure",{class:"shot",children:[n("img",{src:l.url,alt:`Screenshot of the page when ${e.key} was left`,width:l.width,height:l.height,loading:"lazy"}),n("figcaption",{children:["captured at ",l.width??"?"," × ",l.height??"?"]})]}):n("div",{class:"no-shot",children:[n("b",{children:"No screenshot for this one."}),n("p",{style:"margin:6px 0 0",children:"Either this instance had no bucket connected when it was left, or the reviewer turned the picture off for this comment. The anchor and the page context below are unaffected."})]}),n("section",{class:"thread",children:[e.comments.length===0&&n("p",{style:"color:var(--ink-soft)",children:"This thread has no readable comments."}),e.comments.map(c=>n("div",{class:"comment",children:[n("div",{class:"comment-head",children:[n("b",{children:c.author.name||"Anonymous"}),c.author.kind==="agent"&&n("span",{class:"agent",children:"agent"}),n("span",{style:"color:var(--ink-faint)",children:Ae(c.createdAt)})]}),n("p",{children:c.body??n("i",{style:"color:var(--ink-faint)",children:"deleted"})})]},c.id))]}),n(at,{onReply:r}),n("section",{class:"block",children:[n("h2",{children:"Anchor"}),n("dl",{class:"kv",children:[n("dt",{children:"State"}),n("dd",{children:e.anchorState}),i&&n(D,{children:[n("dt",{children:"Label"}),n("dd",{children:i})]}),o.css&&n(D,{children:[n("dt",{children:"Selector"}),n("dd",{children:String(o.css)})]}),o.domid&&n(D,{children:[n("dt",{children:"DOM id"}),n("dd",{children:String(o.domid)})]}),o.page?.pathname&&n(D,{children:[n("dt",{children:"Path"}),n("dd",{children:String(o.page.pathname)})]})]})]}),n(lt,{context:e.context??{}}),n("div",{class:"actions",children:[e.status!=="resolved"?n("button",{class:"btn btn-primary",disabled:s,onClick:()=>t("resolved"),children:"Mark resolved"}):n("button",{class:"btn",disabled:s,onClick:()=>t("open"),children:"Reopen"}),e.status!=="progress"&&e.status!=="resolved"&&n("button",{class:"btn",disabled:s,onClick:()=>t("progress"),children:"In progress"}),o.page?.origin&&n("a",{class:"btn",href:`${o.page.origin}${o.page.pathname??"/"}?snag_t=${encodeURIComponent(e.key)}`,target:"_blank",rel:"noreferrer noopener",children:"Open on site ↗"})]})]})}function z(e){return e?new Date(e).toLocaleDateString(void 0,{day:"numeric",month:"short",year:"numeric"}):"—"}function dt({uid:e,rounds:t}){const[r,s]=b(null),[l,i]=b(""),[o,c]=b(null),[_,d]=b(null),[h,w]=b(""),[a,p]=b("14"),[g,v]=b(""),[k,y]=b(!1),f=()=>{s(null),I.listLinks(e).then(m=>s(m.data)).catch(m=>i(m instanceof U?m.message:"Could not load the links."))};M(()=>{c(null),i(""),f()},[e]);const T=async m=>{m.preventDefault();const P=h.trim();if(!(!P||k)){y(!0),i("");try{const R=Number(a),u=Number.isFinite(R)&&R>0?new Date(Date.now()+R*864e5).toISOString():void 0,S=await I.mintLink(e,{name:P,...u?{expiresAt:u}:{},...g?{roundId:g}:{}});c(S),w(""),p("14"),v(""),f()}catch(R){i(R instanceof U?R.message:"Could not create that link.")}finally{y(!1)}}},N=async m=>{if(confirm(`Revoke "${m.name}"? Every session minted from it is out within 10 s. This cannot be undone — minting a new link is the recovery.`)){d(m.id),i("");try{await I.revokeLink(m.id),c(P=>P?.id===m.id?null:P),f()}catch(P){i(P instanceof U?P.message:"Could not revoke that link.")}finally{d(null)}}};return n("div",{class:"links",children:[n("div",{class:"links-list",children:[n("h1",{class:"links-title",children:"Review links"}),l&&n("p",{class:"err",children:l}),r===null&&n("p",{class:"empty",children:"Loading…"}),r?.length===0&&n("div",{class:"empty",children:[n("h2",{children:"No links yet"}),n("p",{children:"Mint one on the right — once this project needs a link, it is the only way a reviewer gets in."})]}),r&&r.length>0&&n("table",{class:"link-table",children:[n("thead",{children:n("tr",{children:[n("th",{children:"Name"}),n("th",{children:"Created"}),n("th",{children:"Expires"}),n("th",{children:"Last used"}),n("th",{children:"Uses"}),n("th",{})]})}),n("tbody",{children:r.map(m=>{const P=!!m.revokedAt,R=!P&&new Date(m.expiresAt).getTime()<=Date.now(),u=m.roundId?t?.find(S=>S.id===m.roundId):null;return n("tr",{class:P||R?"link-dead":"",children:[n("td",{children:[m.name,u&&n("span",{class:"link-round",children:u.name})]}),n("td",{children:z(m.createdAt)}),n("td",{children:P?n("span",{class:"warn",children:["revoked ",z(m.revokedAt)]}):R?n("span",{class:"warn",children:"expired"}):z(m.expiresAt)}),n("td",{children:z(m.lastUsedAt)}),n("td",{children:m.useCount}),n("td",{children:!P&&n("button",{class:"btn",disabled:_===m.id,onClick:()=>void N(m),children:_===m.id?"Revoking…":"Revoke"})})]},m.id)})})]})]}),n("div",{class:"links-side",children:[o&&n("div",{class:"card minted",children:[n("h2",{children:['"',o.name,'" is ready']}),n("p",{class:"sub",children:["This is the only time the token is shown — the list on the left never carries it again. Paste it after the reviewed site's URL, e.g. ",n("code",{children:["https://yoursite.com/page",o.fragment]}),"."]}),n("div",{class:"token-box",children:[n("code",{children:o.fragment}),n("button",{class:"btn",onClick:()=>void navigator.clipboard.writeText(o.fragment).catch(()=>{}),children:"Copy"})]}),n("button",{class:"linkish",style:"margin-top:10px",onClick:()=>c(null),children:"Done"})]}),n("form",{class:"card",onSubmit:T,children:[n("h2",{children:"New link"}),n("label",{class:"field",children:[n("span",{children:"Name"}),n("input",{required:!0,placeholder:"Client review — round 2",value:h,onInput:m=>w(m.target.value)})]}),n("label",{class:"field",children:[n("span",{children:"Expires in (days, up to 90)"}),n("input",{type:"number",min:1,max:90,value:a,onInput:m=>p(m.target.value)})]}),t&&t.length>0&&n("label",{class:"field",children:[n("span",{children:"Pin to a round (optional)"}),n("select",{value:g,onChange:m=>v(m.target.value),children:[n("option",{value:"",children:"Any round"}),t.map(m=>n("option",{value:m.id,children:m.name},m.id))]})]}),n("button",{class:"btn btn-primary",type:"submit",disabled:k||!h.trim(),children:k?"Creating…":"Create link"})]})]})]})}function ut({me:e,onAdded:t,origin:r}){const[s,l]=b(""),[i,o]=b(""),[c,_]=b(!1),[d,h]=b(""),[w,a]=b(null);return n("div",{class:"sites",children:[n("section",{class:"site-add",children:[n("h2",{children:"Add a site"}),n("form",{onSubmit:async g=>{if(g.preventDefault(),!c){_(!0),h("");try{const v=await I.createProject(s.trim(),i.trim());a({uid:v.uid,name:v.name,...v.warning?{warning:v.warning}:{}}),l(""),o(""),t(v.uid)}catch(v){h(v instanceof U?v.message:"Something went wrong. Try again.")}finally{_(!1)}}},children:[n("label",{children:["Name",n("input",{value:s,placeholder:"Acme redesign",onInput:g=>l(g.target.value),required:!0})]}),n("label",{children:["Where it runs",n("input",{value:i,placeholder:"https://staging.acme.com, http://localhost:3000",onInput:g=>o(g.target.value)}),n("small",{children:["Comma-separated, with the scheme. Only these can talk to the site — a tag on any other address is ignored. ",n("code",{children:"https://*.acme.com"})," covers subdomains."]})]}),n("button",{type:"submit",disabled:c||!s.trim(),children:c?"Adding…":"Add site"}),d&&n("p",{class:"error",children:d})]})]}),w&&n(_t,{site:w,origin:r}),n("section",{class:"site-list",children:[n("h2",{children:"Your sites"}),n("ul",{children:e.projects.map(g=>n("li",{children:[n("strong",{children:g.name}),n("code",{children:g.uid}),n("span",{children:[g.threads," snag",g.threads===1?"":"s"," · ",g.unresolved," open"]})]},g.uid))})]})]})}function _t({site:e,origin:t}){const[r,s]=b([]),[l,i]=b(""),[o,c]=b(""),[_,d]=b("everyone"),[h,w]=b(""),a=v=>{const k=_;d(v),w(""),I.setAudience(e.uid,v==="everyone"?"open":"link-required").catch(()=>{d(k),w("Could not save — try again.")})},p=Me(()=>{I.origins(e.uid).then(v=>s(v.origins)).catch(()=>s([]))},[e.uid]);M(p,[p]);const g=`<script src="${t}/snag.js" data-snag-site="${e.uid}" defer><\/script>`;return n("section",{class:"site-made",children:[n("h2",{children:[e.name," is ready"]}),n("p",{children:"Paste this into the site, immediately before the closing </body> tag."}),n("div",{class:"site-audience",role:"radiogroup","aria-label":"Who sees the toolbar",children:[n("label",{children:[n("input",{type:"radio",name:"audience",checked:_==="everyone",onChange:()=>a("everyone")}),n("span",{children:[n("strong",{children:"Everyone on these addresses"})," — the toolbar just appears, and anyone can comment. For staging. Saved to the server: change it here any time, no re-paste."]})]}),n("label",{children:[n("input",{type:"radio",name:"audience",checked:_==="links",onChange:()=>a("links")}),n("span",{children:[n("strong",{children:"Only people with a review link"})," — visitors see nothing. For production, where the visitors are your client's customers."]})]}),h&&n("p",{class:"error",children:h})]}),n("pre",{children:n("code",{children:g})}),n("button",{class:"linkish",onClick:()=>{navigator.clipboard?.writeText(g)},children:"Copy"}),n("h3",{children:"Allowed origins"}),r.length?n("ul",{class:"origins",children:r.map(v=>n("li",{children:n("code",{children:v})},v))}):n("p",{class:"warn",children:e.warning??"None yet — the tag will load and every request from it will be refused. Add one below."}),n("form",{onSubmit:async v=>{v.preventDefault();const k=l.trim();if(k){c("");try{const y=await I.addOrigin(e.uid,k);s(y.origins),i("")}catch(y){c(y instanceof U?y.message:"Could not add that origin.")}}},children:[n("input",{value:l,placeholder:"https://www.acme.com",onInput:v=>i(v.target.value)}),n("button",{type:"submit",disabled:!l.trim(),children:"Allow"}),o&&n("p",{class:"error",children:o})]})]})}function ht(e,t,r=[]){const s=new Set(r),l=e.filter(i=>!s.has(i.id));for(const i of t){if(s.has(i.id))continue;const o=l.findIndex(c=>c.id===i.id);o===-1?l.push(i):l[o]=i}return l}function pt({token:e,onClaimed:t}){const[r,s]=b(""),[l,i]=b(""),[o,c]=b(""),[_,d]=b(""),[h,w]=b(!1),[a,p]=b(""),[g,v]=b(null);return g?n(mt,{site:g,onContinue:t}):n("div",{class:"setup",children:[n("h1",{children:"Set up Snagset"}),n("p",{class:"setup-lede",children:"This instance has not been claimed yet. The account you make here is the only one, until you add another."}),n("form",{onSubmit:async y=>{if(y.preventDefault(),!h){w(!0),p("");try{const f=await I.setup({token:e,orgName:r.trim()||"My studio",projectName:l.trim(),email:o.trim(),password:_});v(f.project)}catch(f){p(f instanceof U?f.message:"Something went wrong. Try again.")}finally{w(!1)}}},children:[n("label",{children:["Your email",n("input",{type:"email",value:o,autocomplete:"username",required:!0,onInput:y=>c(y.target.value)})]}),n("label",{children:["Password",n("input",{type:"password",value:_,autocomplete:"new-password",required:!0,minLength:12,onInput:y=>d(y.target.value)}),n("small",{children:"At least 12 characters. There is no reset email on a fresh install — keep it."})]}),n("label",{children:["Studio name ",n("span",{class:"setup-opt",children:"optional"}),n("input",{value:r,placeholder:"My studio",onInput:y=>s(y.target.value)})]}),n("label",{children:["First site ",n("span",{class:"setup-opt",children:"optional"}),n("input",{value:l,placeholder:"Acme redesign",onInput:y=>i(y.target.value)})]}),n("button",{type:"submit",disabled:h||!o.trim()||_.length<12,children:h?"Setting up…":"Create my account"}),a&&n("p",{class:"error",children:a})]})]})}function mt({site:e,onContinue:t}){const r=`<script src="${location.origin}/snag.js" data-snag-site="${e.uid}" defer><\/script>`;return n("div",{class:"setup",children:[n("h1",{children:[e.name," is ready"]}),n("p",{class:"setup-lede",children:"Paste this into the site you want reviewed, before the closing </body> tag."}),n("pre",{children:n("code",{children:r})}),n("div",{class:"setup-actions",children:[n("button",{type:"button",class:"linkish",onClick:()=>void navigator.clipboard?.writeText(r),children:"Copy"}),n("button",{type:"button",onClick:t,children:"Open the dashboard"})]}),n("p",{class:"setup-note",children:"The site's address has to be on its allow-list before comments can reach you — add it on the Sites screen. A tag on any other address loads and is ignored."})]})}const ft=["open","progress","review","resolved"],ae={open:"Active",progress:"In progress",review:"In review",resolved:"Resolved"};function H(){const e=new URLSearchParams(location.search);return{project:e.get("project")??"",thread:e.get("t")??"",status:e.get("status")??"",q:e.get("q")??"",round:e.get("round")??"",screen:e.get("screen")??"",claim:e.get("claim")??""}}function Pe(e){const t={...H(),...e},r=new URLSearchParams;t.project&&r.set("project",t.project),t.thread&&r.set("t",t.thread),t.status&&r.set("status",t.status),t.q&&r.set("q",t.q),t.round&&r.set("round",t.round),t.screen&&r.set("screen",t.screen);const s=`${location.pathname}${r.toString()?`?${r}`:""}`;history.pushState(null,"",s)}function vt({onIn:e}){const[t,r]=b(""),[s,l]=b(""),[i,o]=b(""),[c,_]=b(!1);return n("div",{class:"gate",children:n("form",{class:"card",onSubmit:async d=>{d.preventDefault(),_(!0),o("");try{await I.login(t,s),e()}catch(h){o(h instanceof U?h.message:"Could not reach the server.")}finally{_(!1)}},children:[n("h1",{children:"Sign in"}),n("p",{class:"sub",children:"The studio side of Snagset. Reviewers do not sign in — they open a link."}),i&&n("p",{class:"err",children:i}),n("label",{class:"field",children:[n("span",{children:"Email"}),n("input",{type:"email",autocomplete:"username",required:!0,value:t,onInput:d=>r(d.target.value)})]}),n("label",{class:"field",children:[n("span",{children:"Password"}),n("input",{type:"password",autocomplete:"current-password",required:!0,value:s,onInput:d=>l(d.target.value)})]}),n("button",{class:"btn btn-primary",disabled:c,children:c?"Signing in…":"Sign in"}),n("p",{class:"sub",style:"margin:14px 0 0",children:["Forgotten it? There is no reset email on a fresh install — set a new one from the machine that runs the server: ",n("code",{children:"snagset passwd you@example.com"})]})]})})}function gt(){const[e,t]=b(null),[r,s]=b("loading"),[l,i]=b(null),[o,c]=b(H()),[_,d]=b(null),h=et(null),[w,a]=b(!1),[p,g]=b(null),[v,k]=b(null),y=Me(async()=>{try{const u=await I.me();t(u),s("in"),!H().project&&u.projects[0]&&(Pe({project:u.projects[0].uid}),c(H()))}catch{i(await I.claimed().catch(()=>!0)),s("out")}},[]);M(()=>{y(),fetch("/api/v1/meta").then(S=>S.json()).then(g).catch(()=>{});const u=()=>c(H());return addEventListener("popstate",u),()=>removeEventListener("popstate",u)},[y]);const f=o.project;M(()=>{if(r!=="in"||!f)return;let u=!0;return k(null),I.rounds(f).then(S=>u&&k(S.data)).catch(()=>u&&k([])),()=>{u=!1}},[r,f]);const T=o.round?Number(o.round):v?.[0]?.number??null;M(()=>{if(r!=="in"||!f||T===null)return;let u=!0;return d(null),I.threads(f,{round:T,...o.status?{status:o.status}:{},...o.q?{q:o.q}:{}}).then(S=>{u&&(d(S.data),h.current=S.serverTime)}).catch(()=>u&&d([])),()=>{u=!1}},[r,f,T,o.status,o.q]),M(()=>{if(r!=="in"||!f)return;let u=0;const S=()=>{if(document.hidden||!h.current)return;const A=Date.now();A-u<3e4||(u=A,I.threads(f,{...T===null?{}:{round:T},since:h.current,...o.status?{status:o.status}:{},...o.q?{q:o.q}:{}}).then($=>{h.current=$.serverTime,!(!$.data.length&&!$.deleted?.length)&&d(ne=>ne&&ht(ne,$.data,$.deleted))}).catch(()=>{}))};return document.addEventListener("visibilitychange",S),()=>document.removeEventListener("visibilitychange",S)},[r,f,T,o.status,o.q]);const N=pe(()=>_?.find(u=>u.key===o.thread)??null,[_,o.thread]),m=u=>{Pe(u),c(H())};if(r==="loading")return n("div",{class:"empty",children:"Loading…"});if(r==="out"&&(o.claim||l===!1))return n(pt,{token:o.claim,onClaimed:()=>void y()});if(r==="out")return n(vt,{onIn:()=>void y()});const P=async u=>{if(N){a(!0);try{const S=await I.setStatus(f,N.id,u);d(A=>A?.map($=>$.id===S.id?{...$,...S}:$)??A)}finally{a(!1)}}},R=async u=>{if(!N)return;const S=await I.addComment(N.id,u);d(A=>A?.map($=>$.id===N.id?{...$,comments:[...$.comments,S],commentCount:$.commentCount+1}:$)??A)};return n("div",{class:"shell",children:[n("header",{class:"topbar",children:[n("div",{class:"brand",children:["Snagset ",n("span",{children:"studio"})]}),n("select",{value:f,onChange:u=>m({project:u.target.value,thread:""}),children:e.projects.map(u=>n("option",{value:u.uid,children:[u.name," · ",u.unresolved," open"]},u.uid))}),v&&v.length>0&&n("select",{value:String(T??""),onChange:u=>m({round:u.target.value,thread:""}),children:v.map(u=>n("option",{value:String(u.number),children:[u.name,u.closed?" (closed)":""," · ",u.unresolved," open"]},u.id))}),n("button",{class:"linkish",title:"Start a new round. The page goes clean; nothing is deleted.",onClick:async()=>{const u=(v?.[0]?.number??0)+1,S=prompt("Name for the new round",`Round ${u}`);if(S===null)return;const A=await I.createRound(f,S).catch(()=>null);if(!A)return;const $=await I.rounds(f).catch(()=>null);$&&k($.data),m({round:String(A.number),thread:""})},children:"+ Round"}),n("button",{class:"linkish","aria-pressed":o.screen==="links",onClick:()=>m({screen:o.screen==="links"?"":"links",thread:""}),children:"Links"}),n("button",{class:"linkish","aria-pressed":o.screen==="sites",onClick:()=>m({screen:o.screen==="sites"?"":"sites",thread:""}),children:"Sites"}),n("div",{class:"spacer"}),n("span",{class:"who",children:e.user.email}),n("button",{class:"linkish",onClick:async()=>{await I.logout().catch(()=>{}),s("out")},children:"Sign out"})]}),o.screen==="sites"?n(ut,{me:e,origin:location.origin,onAdded:async u=>{const S=await I.me().catch(()=>null);S&&t(S),m({project:u,thread:""})}}):o.screen==="links"?n(dt,{uid:f,rounds:v}):n(D,{children:[v&&T!==null&&n(D,{children:v.filter(u=>u.number!==T&&u.unresolved>0).map(u=>n("button",{class:"carryover",onClick:()=>m({round:String(u.number),thread:""}),children:[n("b",{children:[u.unresolved," unresolved"]})," in ",u.name," — still open, not carried over. Go there ↗"]},u.id))}),n("div",{class:"inbox",children:[n("div",{class:"list",children:[n("div",{class:"filters",children:[n("input",{type:"search",placeholder:"Search comments",value:o.q,onInput:u=>m({q:u.target.value})}),ft.map(u=>n("button",{class:"chip","aria-pressed":o.status===u,onClick:()=>m({status:o.status===u?"":u}),children:ae[u]},u))]}),n("div",{class:"rows",children:[_===null&&n("p",{class:"empty",children:"Loading…"}),_?.length===0&&n("div",{class:"empty",children:[n("h2",{children:"Nothing here"}),n("p",{children:o.q||o.status?"No comment matches that filter.":"No comments on this project yet. Paste the script tag on the site and click something."})]}),_?.map(u=>n("button",{class:"row","aria-current":u.key===o.thread,onClick:()=>m({thread:u.key}),children:[n("div",{class:"row-top",children:[n("span",{class:"key",children:u.key}),n("span",{class:`status status-${u.status}`,children:ae[u.status]})]}),n("p",{class:"row-body",children:u.comments[0]?.body??u.anchor?.label??"(no comment)"}),n("div",{class:"row-meta",children:[n("span",{children:u.route||"/"}),n("span",{children:"·"}),n("span",{children:u.createdBy.name||"someone"}),u.attachments?.length?n("span",{children:"· 📷"}):null]})]},u.id))]})]}),N?n(ct,{thread:N,onStatus:u=>void P(u),onReply:R,busy:w}):n("div",{class:"empty",children:[n("h2",{children:"Pick a comment"}),n("p",{children:"Its screenshot, the thread, the anchor and the page context all open here."})]})]})]}),n("footer",{class:"foot",children:[n("span",{children:[e.org.name," · ",p?.version??"…"]}),p?.commit&&n("span",{children:p.commit.slice(0,8)})]})]})}Qe(n(gt,{}),document.getElementById("app"));
@@ -6,7 +6,7 @@
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <link rel="icon" href="/mark.svg" type="image/svg+xml" />
8
8
  <title>Snagset</title>
9
- <script type="module" crossorigin src="/assets/index-DultqpS7.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-CATItBzT.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-DsUJ96yc.css">
11
11
  </head>
12
12
  <body>