@bro-code/crawl-space 0.0.0-stage → 2026.0.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -2
  3. package/dist/cjs/api.js +90 -0
  4. package/dist/cjs/button.js +33 -0
  5. package/dist/cjs/context.js +72 -0
  6. package/dist/cjs/crawl-space.js +76 -0
  7. package/dist/cjs/css.js +104 -0
  8. package/dist/cjs/dialog.js +106 -0
  9. package/dist/cjs/dom.js +17 -0
  10. package/dist/cjs/host.js +2 -0
  11. package/dist/cjs/index.js +7 -0
  12. package/dist/cjs/lists.js +67 -0
  13. package/dist/cjs/options.js +80 -0
  14. package/dist/cjs/package.json +3 -0
  15. package/dist/cjs/sanitize.js +21 -0
  16. package/dist/cjs/styles.js +69 -0
  17. package/dist/cjs/submit.js +96 -0
  18. package/dist/cjs/themes.js +89 -0
  19. package/dist/cjs/types.js +2 -0
  20. package/dist/esm/api.js +86 -0
  21. package/dist/esm/button.js +30 -0
  22. package/dist/esm/context.js +68 -0
  23. package/dist/esm/crawl-space.js +73 -0
  24. package/dist/esm/css.js +101 -0
  25. package/dist/esm/dialog.js +103 -0
  26. package/dist/esm/dom.js +14 -0
  27. package/dist/esm/host.js +1 -0
  28. package/dist/esm/index.js +2 -0
  29. package/dist/esm/lists.js +63 -0
  30. package/dist/esm/options.js +76 -0
  31. package/dist/esm/package.json +3 -0
  32. package/dist/esm/sanitize.js +18 -0
  33. package/dist/esm/styles.js +65 -0
  34. package/dist/esm/submit.js +93 -0
  35. package/dist/esm/themes.js +85 -0
  36. package/dist/esm/types.js +1 -0
  37. package/dist/types/api.d.ts +20 -0
  38. package/dist/types/button.d.ts +8 -0
  39. package/dist/types/context.d.ts +15 -0
  40. package/dist/types/crawl-space.d.ts +11 -0
  41. package/dist/types/css.d.ts +2 -0
  42. package/dist/types/dialog.d.ts +13 -0
  43. package/dist/types/dom.d.ts +6 -0
  44. package/dist/types/host.d.ts +17 -0
  45. package/dist/types/index.d.ts +3 -0
  46. package/dist/types/lists.d.ts +9 -0
  47. package/dist/types/options.d.ts +23 -0
  48. package/dist/types/sanitize.d.ts +7 -0
  49. package/dist/types/styles.d.ts +14 -0
  50. package/dist/types/submit.d.ts +4 -0
  51. package/dist/types/themes.d.ts +5 -0
  52. package/dist/types/types.d.ts +112 -0
  53. package/package.json +60 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bro Code Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,249 @@
1
- # Temporary Holding Version
1
+ # @bro-code/crawl-space
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@bro-code/crawl-space.svg)](https://www.npmjs.com/package/@bro-code/crawl-space)
4
+ [![license](https://img.shields.io/npm/l/@bro-code/crawl-space.svg)](./LICENSE)
5
+
6
+ A help desk for any web app (bug reports, active issues, Q&A) as framework-free custom elements, themed, against a documented HTTP API.
7
+
8
+ One call gives an app a dialog with three tabs:
9
+
10
+ - **Submit**: a bug or a question, with the details that help (version, page, browser, OS and anything the app adds), shown before sending under an "Include these details" checkbox the user can untick.
11
+ - **Active**: the signed-in user's own reports first, in every status and with the developer's answer, then everyone's reports being worked on.
12
+ - **Q&A**: published questions and their answers.
13
+
14
+ The elements live in a shadow DOM, so the page's CSS doesn't reach them and theirs doesn't leak out. It works with any library or none, has no runtime dependencies, and does nothing when imported. Without a `window` (server rendering), `createCrawlSpace` does nothing and returns a controller whose methods do nothing.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @bro-code/crawl-space
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Keep the settings in the app's `bro.config.json`:
25
+
26
+ ```json
27
+ {
28
+ "crawlSpace": {
29
+ "api": "http://localhost:8080/api/crawl-space/my-app",
30
+ "theme": "brocode"
31
+ }
32
+ }
33
+ ```
34
+
35
+ A browser can't read that file at runtime, so import the section and pass it in, with the parts only code can supply (TypeScript needs `resolveJsonModule`, which `moduleResolution: "bundler"` turns on; Vite keeps only the imported section in the bundle):
36
+
37
+ ```ts
38
+ import { createCrawlSpace } from '@bro-code/crawl-space';
39
+ import { crawlSpace } from '../bro.config.json';
40
+
41
+ const help = createCrawlSpace({
42
+ ...crawlSpace,
43
+ // The API's address can come from the app's env instead.
44
+ api: import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/crawl-space/my-app` : crawlSpace.api,
45
+ getToken: () => localStorage.getItem('token'),
46
+ version: '1.4.0',
47
+ });
48
+ ```
49
+
50
+ A CommonJS build is included too: `const { createCrawlSpace } = require('@bro-code/crawl-space');`.
51
+
52
+ Call it once, at startup. A second call (a dev server's hot reload) replaces the options and returns the same controller.
53
+
54
+ There are three ways to open it:
55
+
56
+ ```ts
57
+ help.open(); // the Submit tab
58
+ help.open('qa'); // or 'active'
59
+ help.close();
60
+ ```
61
+
62
+ ```html
63
+ <!-- Anywhere in a template; its text is the label (the title when empty). -->
64
+ <crawl-space-button tab="active">What's being fixed?</crawl-space-button>
65
+ ```
66
+
67
+ ```json
68
+ { "crawlSpace": { "api": "...", "floating": { "position": "bottom-right" } } }
69
+ ```
70
+
71
+ `floating` adds a button fixed in a corner of the page. `<crawl-space-button floating>` does the same in a template.
72
+
73
+ ## Options
74
+
75
+ ### In `bro.config.json` (`CrawlSpaceConfig`)
76
+
77
+ | Field | Default | |
78
+ |---|---|---|
79
+ | `api` | | Required. The API's base URL; the routes below go under it. |
80
+ | `paths.reports` | `/reports` | The reports route. |
81
+ | `paths.qa` | `/qa` | The Q&A route. |
82
+ | `theme` | `default` | `default`, `brocode`, `rombie`, `taut-wad` or `custom`. See [Themes](#themes). |
83
+ | `colors` | | With `theme: 'custom'`, any of the ten theme values over `default`'s. |
84
+ | `floating.position` | `bottom-right` | Adds the floating button: `bottom-right`, `bottom-left`, `top-right` or `top-left`. |
85
+ | `title` | `Help & bug reports` | The dialog's title and the buttons' default label. |
86
+ | `maxLength` | `2000` | The longest description accepted. |
87
+
88
+ ### In code only (`CrawlSpaceOptions`)
89
+
90
+ | Option | |
91
+ |---|---|
92
+ | `getToken` | `() => string \| null \| Promise<string \| null>`. The signed-in user's token, sent as `Authorization: Bearer <token>`; null when signed out. |
93
+ | `version` | The app's version, sent as a detail. |
94
+ | `scrubPath` | `(path) => string`. Takes anything private out of the page path (a room code, a token) before it is shown or sent. |
95
+ | `getContext` | `() => Record<string, string \| CrawlSpaceDetail>`. More details, label to value, shown and sent after the built-in ones. A `{ value, editable: true }` detail is shown in a field the user can change or clear. |
96
+ | `onOpenChange` | `(open) => void`, called when the dialog opens and closes (to pause an app's own keyboard or gamepad handling, say). |
97
+
98
+ An invalid `api` throws a `TypeError`. Anything else that's invalid falls back to its default with a `console.warn`.
99
+
100
+ ### The details
101
+
102
+ In order: Version, Page (`location.pathname` only, never the query or hash, through `scrubPath`), Browser (name and major version), OS (name only), then `getContext()`'s entries. Each label and value is cleaned with `sanitizeText`; empty ones and repeated labels are left out. brocodetech accepts up to 12 entries, labels up to 40 characters and values up to 200, and the package keeps to that.
103
+
104
+ Only the app's own details can be editable, so the user can correct or remove something the app guessed (which game they were playing, say) while the built-in four stay as collected:
105
+
106
+ ```ts
107
+ getContext: () => ({
108
+ Console: { value: 'NES', editable: true },
109
+ 'Last game': { value: lastGameTitle, editable: true },
110
+ }),
111
+ ```
112
+
113
+ The field is sent as it reads when the report goes, cleaned the same way; a field left empty leaves that detail out. Unticking "Include these details" disables the fields and sends none of the details. An edit lasts while the dialog switches tabs, as long as the app's value for it stays the same; a sent report puts the app's values back.
114
+
115
+ ## Themes
116
+
117
+ | | `default` | `brocode` | `rombie` | `taut-wad` |
118
+ |---|---|---|---|---|
119
+ | `background` | `#121417` | `#181818` | `#0a0a12` | `#181818` |
120
+ | `surface` | `#1c1f24` | `#222222` | `#15141f` | `#222222` |
121
+ | `text` | `#e8eaed` | `#ffffff` | `#f1eef9` | `#ffffff` |
122
+ | `muted` | `#9aa0a6` | `#b0b0b0` | `#9a90bd` | `#b0b0b0` |
123
+ | `border` | `#33373d` | `#3c3c3c` | `#302c47` | `#3c3c3c` |
124
+ | `accent` | `#5b9cff` | `#7b9981` | `#ff2d78` | `#7b9981` |
125
+ | `accentText` | `#0b1220` | `#ffffff` | `#12060d` | `#ffffff` |
126
+ | `error` | `#ff6b6b` | `#ff5252` | `#ff5c5c` | `#ff5252` |
127
+ | `success` | `#6fcf97` | `#96be4d` | `#00e5ff` | `#96be4d` |
128
+ | `font` | `system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | `Roboto, sans-serif` | `system-ui, 'Segoe UI', Roboto, sans-serif` | `Roboto, sans-serif` |
129
+
130
+ `custom` starts from `default`:
131
+
132
+ ```json
133
+ { "crawlSpace": { "api": "...", "theme": "custom", "colors": { "accent": "#e91e63", "font": "Georgia, serif" } } }
134
+ ```
135
+
136
+ Each color is checked with `CSS.supports('color', ...)` and the font with `CSS.supports('font-family', ...)`; a missing or invalid one keeps `default`'s, with a warning for the invalid ones. An unknown theme is `default`, with a warning.
137
+
138
+ ## Restyling
139
+
140
+ The theme is set as custom properties on each element, and a page's own rules win over them:
141
+
142
+ ```css
143
+ crawl-space-button,
144
+ crawl-space-dialog {
145
+ --cs-accent: #e91e63;
146
+ }
147
+ ```
148
+
149
+ The properties are `--cs-background`, `--cs-surface`, `--cs-text`, `--cs-muted`, `--cs-border`, `--cs-accent`, `--cs-accent-text`, `--cs-error`, `--cs-success` and `--cs-font`. The button itself is a part:
150
+
151
+ ```css
152
+ crawl-space-button::part(button) {
153
+ border-radius: 0;
154
+ text-transform: uppercase;
155
+ }
156
+ ```
157
+
158
+ ## Setup per library
159
+
160
+ The elements are plain custom elements. Tell a framework's template compiler they aren't its components:
161
+
162
+ **Vue** (`vite.config.ts`):
163
+
164
+ ```ts
165
+ vue({
166
+ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('crawl-space-') } },
167
+ });
168
+ ```
169
+
170
+ **Angular**: add `CUSTOM_ELEMENTS_SCHEMA` to the `schemas` of each standalone component (or module) whose template uses `<crawl-space-button>`.
171
+
172
+ **React 19** renders custom elements as they are; declare the button for JSX once, in a `.d.ts`:
173
+
174
+ ```ts
175
+ import type { DetailedHTMLProps, HTMLAttributes } from 'react';
176
+
177
+ declare module 'react' {
178
+ namespace JSX {
179
+ interface IntrinsicElements {
180
+ 'crawl-space-button': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
181
+ tab?: 'submit' | 'active' | 'qa';
182
+ floating?: boolean;
183
+ };
184
+ }
185
+ }
186
+ }
187
+ ```
188
+
189
+ ## HTTP API
190
+
191
+ Any server can back it. brocodetech's `/api/crawl-space/:app` routes are the reference implementation. Every request sends `Accept: application/json`, and `Authorization: Bearer <token>` when `getToken` returns a token.
192
+
193
+ ### `POST {api}{paths.reports}`
194
+
195
+ Sends a report.
196
+
197
+ ```json
198
+ { "type": "bug", "description": "The save is gone", "context": { "Version": "1.4.0", "Page": "/play" } }
199
+ ```
200
+
201
+ - `type` is `bug` or `question`. `description` is cleaned with `sanitizeText` and at most `maxLength` long. `context` is left out when the user unticks the details.
202
+ - `201` with `{ "id": 12 }` on success. `429` tells the user to try again in a minute; any other status is a failure, and the text is kept so they can resend.
203
+
204
+ ### `GET {api}{paths.reports}`
205
+
206
+ ```json
207
+ { "mine": [Report], "active": [Report] }
208
+ ```
209
+
210
+ - `mine` is the signed-in user's reports in every status (brocodetech leaves out ones resolved or dismissed more than 30 days ago), empty when there's no token. Its `answer` is shown.
211
+ - `active` is everyone else's reports being worked on.
212
+
213
+ A `Report` is `{ id, type, status, description, answer, createdAt, updatedAt }`: `id` a number or string, `status` one of `new`, `in_progress`, `resolved` and `dismissed`, `answer` a string or null, and the dates ISO 8601.
214
+
215
+ ### `GET {api}{paths.qa}`
216
+
217
+ An array of `{ id, question, answer, updatedAt }`.
218
+
219
+ ### What the client trusts
220
+
221
+ Responses are checked: a malformed item is dropped, fields the contract doesn't name are ignored, and a malformed body is a failed load. Everything from the API and from users is rendered as text, never markup. The server stays the authority: it should validate and limit what it accepts, limit how often an address can post, and never put a reporter's account, email or details in a public response.
222
+
223
+ An API on another origin than the site must allow the site's origin in CORS, for `GET` and `POST` with the `Content-Type` and `Authorization` headers.
224
+
225
+ ## API
226
+
227
+ | Export | |
228
+ |---|---|
229
+ | `createCrawlSpace(options)` | Sets everything up and returns `{ open(tab?), close() }`. |
230
+ | `sanitizeText(text, maxLength)` | Cleans text a user typed: NFC, no control, zero-width or bidi characters, `\n` line ends, runs of three or more blank lines collapsed to one, trimmed, and at most `maxLength` UTF-16 units (never ending in half of a surrogate pair). |
231
+ | Types | `CrawlSpaceConfig`, `CrawlSpaceOptions`, `CrawlSpaceController`, `CrawlSpaceTab`, `CrawlSpaceTheme`, `CrawlSpaceColors`, `CrawlSpacePosition`, `CrawlSpaceDetail`, `CrawlSpaceContextEntry`, `CrawlSpaceSubmission`, `CrawlSpaceReport`, `CrawlSpaceReports`, `CrawlSpaceReportType`, `CrawlSpaceReportStatus`, `CrawlSpaceQaItem`. |
232
+
233
+ The dialog is a native modal `<dialog>`: it sits above everything on the page, Escape closes it, focus stays inside while it's open and goes back to what opened it. The arrow keys, Home and End move between the tabs.
234
+
235
+ ## License
236
+
237
+ MIT - see [LICENSE](./LICENSE).
238
+
239
+ ## Changelog
240
+
241
+ ### [2026.0.0] - 2026-11-04
242
+
243
+ #### Added
244
+ - Initial release.
245
+ - `createCrawlSpace(options)`, with `open(tab?)` and `close()`, the `<crawl-space-button>` element and the floating button.
246
+ - The Submit, Active and Q&A tabs, against the documented HTTP API.
247
+ - Details from `getContext` the user can change or clear before sending (`{ value, editable: true }`).
248
+ - The `default`, `brocode`, `rombie`, `taut-wad` and `custom` themes, the `--cs-*` properties and `::part(button)`.
249
+ - `sanitizeText(text, maxLength)`.
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.joinUrl = joinUrl;
4
+ exports.createApiClient = createApiClient;
5
+ const TYPES = ['bug', 'question'];
6
+ const STATUSES = ['new', 'in_progress', 'resolved', 'dismissed'];
7
+ const FAILED = { ok: false, reason: 'failed' };
8
+ function joinUrl(base, path) {
9
+ return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
10
+ }
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ function isId(value) {
15
+ return (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value !== '');
16
+ }
17
+ function isDate(value) {
18
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value));
19
+ }
20
+ function isOneOf(list, value) {
21
+ return list.includes(value);
22
+ }
23
+ function toReport(item) {
24
+ if (!isRecord(item))
25
+ return null;
26
+ const { id, type, status, description, answer, createdAt, updatedAt } = item;
27
+ if (!isId(id) || !isOneOf(TYPES, type) || !isOneOf(STATUSES, status) || typeof description !== 'string')
28
+ return null;
29
+ if (answer != null && typeof answer !== 'string')
30
+ return null;
31
+ if (!isDate(createdAt) || !isDate(updatedAt))
32
+ return null;
33
+ return { id, type, status, description, answer: answer ?? null, createdAt, updatedAt };
34
+ }
35
+ function toQaItem(item) {
36
+ if (!isRecord(item))
37
+ return null;
38
+ const { id, question, answer, updatedAt } = item;
39
+ if (!isId(id) || typeof question !== 'string' || typeof answer !== 'string' || !isDate(updatedAt))
40
+ return null;
41
+ return { id, question, answer, updatedAt };
42
+ }
43
+ function listOf(value, toItem) {
44
+ if (!Array.isArray(value))
45
+ return null;
46
+ return value.map(toItem).filter((item) => item !== null);
47
+ }
48
+ function toReports(body) {
49
+ if (!isRecord(body))
50
+ return null;
51
+ const mine = listOf(body.mine, toReport);
52
+ const active = listOf(body.active, toReport);
53
+ return mine && active ? { mine, active } : null;
54
+ }
55
+ function toCreated(body) {
56
+ return isRecord(body) && isId(body.id) ? { id: body.id } : null;
57
+ }
58
+ function createApiClient(options) {
59
+ const reportsUrl = joinUrl(options.api, options.paths?.reports ?? '/reports');
60
+ const qaUrl = joinUrl(options.api, options.paths?.qa ?? '/qa');
61
+ async function request(url, parse, body) {
62
+ try {
63
+ const headers = { Accept: 'application/json' };
64
+ if (body !== undefined)
65
+ headers['Content-Type'] = 'application/json';
66
+ const token = await options.getToken?.();
67
+ if (typeof token === 'string' && token !== '')
68
+ headers.Authorization = `Bearer ${token}`;
69
+ const response = await fetch(url, {
70
+ method: body === undefined ? 'GET' : 'POST',
71
+ headers,
72
+ body: body === undefined ? undefined : JSON.stringify(body),
73
+ });
74
+ if (response.status === 429)
75
+ return { ok: false, reason: 'rate-limited' };
76
+ if (!response.ok)
77
+ return FAILED;
78
+ const value = parse(await response.json());
79
+ return value === null ? FAILED : { ok: true, value };
80
+ }
81
+ catch {
82
+ return FAILED;
83
+ }
84
+ }
85
+ return {
86
+ submitReport: (submission) => request(reportsUrl, toCreated, submission),
87
+ getReports: () => request(reportsUrl, toReports),
88
+ getQa: () => request(qaUrl, (body) => listOf(body, toQaItem)),
89
+ };
90
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defineButton = defineButton;
4
+ const css_js_1 = require("./css.js");
5
+ const dom_js_1 = require("./dom.js");
6
+ const options_js_1 = require("./options.js");
7
+ function defineButton(getHost) {
8
+ return class CrawlSpaceButton extends HTMLElement {
9
+ root;
10
+ label = document.createTextNode('');
11
+ constructor() {
12
+ super();
13
+ this.root = this.attachShadow({ mode: 'open', delegatesFocus: true });
14
+ const button = (0, dom_js_1.h)('button', { type: 'button', part: 'button' }, (0, dom_js_1.h)('slot', {}, this.label));
15
+ button.addEventListener('click', () => getHost().open((0, options_js_1.toTab)(this.getAttribute('tab'))));
16
+ this.root.append(button);
17
+ }
18
+ connectedCallback() {
19
+ getHost().connected.add(this);
20
+ this.refresh();
21
+ }
22
+ disconnectedCallback() {
23
+ getHost().connected.delete(this);
24
+ }
25
+ refresh() {
26
+ const host = getHost();
27
+ host.styles.apply(this.root, css_js_1.BUTTON_CSS);
28
+ this.label.data = host.options.title;
29
+ if (this.hasAttribute('floating'))
30
+ this.dataset.position = host.options.floating ?? 'bottom-right';
31
+ }
32
+ };
33
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONTEXT_LIMITS = void 0;
4
+ exports.collectContext = collectContext;
5
+ const sanitize_js_1 = require("./sanitize.js");
6
+ exports.CONTEXT_LIMITS = {
7
+ maxEntries: 12,
8
+ maxKeyLength: 40,
9
+ maxValueLength: 200,
10
+ };
11
+ const BROWSERS = [
12
+ ['Edge', /Edg\/(\d+)/],
13
+ ['Firefox', /Firefox\/(\d+)/],
14
+ ['Chrome', /Chrome\/(\d+)/],
15
+ ['Safari', /Version\/(\d+).*Safari/],
16
+ ];
17
+ const SYSTEMS = [
18
+ ['Windows', /Windows/],
19
+ ['iOS', /iPhone|iPad|iPod/],
20
+ ['Android', /Android/],
21
+ ['ChromeOS', /CrOS/],
22
+ ['macOS', /Macintosh|Mac OS X/],
23
+ ['Linux', /Linux/],
24
+ ];
25
+ function browserName(userAgent) {
26
+ for (const [name, pattern] of BROWSERS) {
27
+ const match = pattern.exec(userAgent);
28
+ if (match)
29
+ return `${name} ${match[1]}`;
30
+ }
31
+ return 'Other';
32
+ }
33
+ function systemName(userAgent) {
34
+ return SYSTEMS.find(([, pattern]) => pattern.test(userAgent))?.[0] ?? 'Other';
35
+ }
36
+ function collectContext(options) {
37
+ const raw = [['Version', options.version, false]];
38
+ if (typeof location !== 'undefined') {
39
+ const path = location.pathname;
40
+ raw.push(['Page', options.scrubPath ? options.scrubPath(path) : path, false]);
41
+ }
42
+ if (typeof navigator !== 'undefined') {
43
+ raw.push(['Browser', browserName(navigator.userAgent), false], ['OS', systemName(navigator.userAgent), false]);
44
+ }
45
+ const extras = options.getContext?.();
46
+ if (typeof extras === 'object' && extras !== null) {
47
+ for (const [label, item] of Object.entries(extras)) {
48
+ if (typeof item === 'object' && item !== null) {
49
+ const detail = item;
50
+ raw.push([label, detail.value, detail.editable === true]);
51
+ }
52
+ else {
53
+ raw.push([label, item, false]);
54
+ }
55
+ }
56
+ }
57
+ const entries = [];
58
+ const seen = new Set();
59
+ for (const [rawLabel, rawValue, editable] of raw) {
60
+ if (entries.length === exports.CONTEXT_LIMITS.maxEntries)
61
+ break;
62
+ if (typeof rawValue !== 'string')
63
+ continue;
64
+ const label = (0, sanitize_js_1.sanitizeText)(rawLabel, exports.CONTEXT_LIMITS.maxKeyLength);
65
+ const value = (0, sanitize_js_1.sanitizeText)(rawValue, exports.CONTEXT_LIMITS.maxValueLength);
66
+ if (label === '' || value === '' || seen.has(label))
67
+ continue;
68
+ seen.add(label);
69
+ entries.push({ label, value, editable });
70
+ }
71
+ return entries;
72
+ }
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createCrawlSpace = createCrawlSpace;
4
+ const api_js_1 = require("./api.js");
5
+ const button_js_1 = require("./button.js");
6
+ const dialog_js_1 = require("./dialog.js");
7
+ const options_js_1 = require("./options.js");
8
+ const styles_js_1 = require("./styles.js");
9
+ const KEY = Symbol.for('@bro-code/crawl-space');
10
+ const NOOP = { open: () => undefined, close: () => undefined };
11
+ function getState() {
12
+ const state = window[KEY];
13
+ if (!state)
14
+ throw new Error('[crawl-space] createCrawlSpace has not been called.');
15
+ return state;
16
+ }
17
+ function whenBody(run) {
18
+ if (document.body)
19
+ run(document.body);
20
+ else
21
+ document.addEventListener('DOMContentLoaded', () => run(document.body), { once: true });
22
+ }
23
+ function syncFloating(state) {
24
+ if (state.options.floating && !state.floating) {
25
+ const button = document.createElement('crawl-space-button');
26
+ button.setAttribute('floating', '');
27
+ state.floating = button;
28
+ whenBody((body) => body.append(button));
29
+ }
30
+ else if (!state.options.floating && state.floating) {
31
+ state.floating.remove();
32
+ state.floating = null;
33
+ }
34
+ }
35
+ function openDialog(state, tab) {
36
+ state.dialog ??= document.createElement('crawl-space-dialog');
37
+ if (!state.dialog.isConnected)
38
+ document.body.append(state.dialog);
39
+ state.dialog.showTab(tab);
40
+ }
41
+ function createCrawlSpace(options) {
42
+ if (typeof window === 'undefined' || typeof customElements === 'undefined')
43
+ return NOOP;
44
+ const resolved = (0, options_js_1.resolveOptions)(options);
45
+ const target = window;
46
+ const existing = target[KEY];
47
+ if (existing) {
48
+ existing.options = resolved;
49
+ existing.api = (0, api_js_1.createApiClient)(resolved);
50
+ existing.styles.setColors(resolved.colors);
51
+ for (const element of existing.connected)
52
+ element.refresh();
53
+ syncFloating(existing);
54
+ return existing.controller;
55
+ }
56
+ const state = {
57
+ options: resolved,
58
+ api: (0, api_js_1.createApiClient)(resolved),
59
+ styles: (0, styles_js_1.createStyles)(resolved.colors),
60
+ connected: new Set(),
61
+ dialog: null,
62
+ floating: null,
63
+ open: (tab) => openDialog(state, tab),
64
+ controller: {
65
+ open: (tab) => openDialog(state, (0, options_js_1.toTab)(tab)),
66
+ close: () => state.dialog?.hide(),
67
+ },
68
+ };
69
+ target[KEY] = state;
70
+ if (!customElements.get('crawl-space-dialog'))
71
+ customElements.define('crawl-space-dialog', (0, dialog_js_1.defineDialog)(getState));
72
+ if (!customElements.get('crawl-space-button'))
73
+ customElements.define('crawl-space-button', (0, button_js_1.defineButton)(getState));
74
+ syncFloating(state);
75
+ return state.controller;
76
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DIALOG_CSS = exports.BUTTON_CSS = void 0;
4
+ exports.BUTTON_CSS = `
5
+ :host { display: inline-block; }
6
+ :host([hidden]) { display: none; }
7
+ :host([floating]) { position: fixed; z-index: 2147483000; }
8
+ :host([floating][data-position='bottom-right']) { right: 16px; bottom: 16px; }
9
+ :host([floating][data-position='bottom-left']) { left: 16px; bottom: 16px; }
10
+ :host([floating][data-position='top-right']) { right: 16px; top: 16px; }
11
+ :host([floating][data-position='top-left']) { left: 16px; top: 16px; }
12
+ button {
13
+ padding: 9px 16px;
14
+ border: none;
15
+ border-radius: 8px;
16
+ background: var(--cs-accent);
17
+ color: var(--cs-accent-text);
18
+ font: 600 14px/1.2 var(--cs-font);
19
+ cursor: pointer;
20
+ }
21
+ :host([floating]) button { padding: 11px 18px; border-radius: 999px; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); }
22
+ button:hover { filter: brightness(1.1); }
23
+ button:focus-visible { outline: 2px solid var(--cs-text); outline-offset: 2px; }
24
+ `;
25
+ exports.DIALOG_CSS = `
26
+ :host { all: initial; }
27
+ *, *::before, *::after { box-sizing: border-box; }
28
+ dialog {
29
+ width: min(560px, calc(100vw - 32px));
30
+ max-height: min(720px, calc(100vh - 32px));
31
+ padding: 0;
32
+ border: 1px solid var(--cs-border);
33
+ border-radius: 12px;
34
+ background: var(--cs-background);
35
+ color: var(--cs-text);
36
+ font: 14px/1.45 var(--cs-font);
37
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
38
+ overflow: hidden;
39
+ }
40
+ dialog[open] { display: flex; flex-direction: column; }
41
+ dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
42
+ button, input, textarea { font: inherit; color: inherit; }
43
+ button { cursor: pointer; }
44
+ button:disabled { cursor: default; opacity: 0.6; }
45
+ :focus-visible { outline: 2px solid var(--cs-accent); outline-offset: 2px; }
46
+ header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 12px 0 20px; }
47
+ h2 { margin: 0; font-size: 18px; font-weight: 600; }
48
+ .close { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: none; border-radius: 8px; background: none; color: var(--cs-muted); }
49
+ .close:hover { background: var(--cs-surface); color: var(--cs-text); }
50
+ .close svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
51
+ [role='tablist'] { display: flex; gap: 4px; padding: 8px 14px 0; border-bottom: 1px solid var(--cs-border); }
52
+ [role='tab'] { margin-bottom: -1px; padding: 9px 10px; border: none; border-bottom: 2px solid transparent; background: none; color: var(--cs-muted); font-weight: 600; }
53
+ [role='tab']:hover { color: var(--cs-text); }
54
+ [role='tab'][aria-selected='true'] { border-bottom-color: var(--cs-accent); color: var(--cs-text); }
55
+ [role='tabpanel'] { flex: 1 1 auto; min-height: 0; padding: 16px 20px 20px; overflow-y: auto; }
56
+ [role='tabpanel'][hidden] { display: none; }
57
+ .hidden-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
58
+ .kinds { display: inline-flex; gap: 3px; margin: 0 0 14px; padding: 3px; border: 1px solid var(--cs-border); border-radius: 8px; }
59
+ .kinds label { position: relative; }
60
+ .kinds input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
61
+ .kinds span { display: block; padding: 5px 14px; border-radius: 6px; color: var(--cs-muted); font-weight: 600; }
62
+ .kinds input:checked + span { background: var(--cs-accent); color: var(--cs-accent-text); }
63
+ .kinds input:focus-visible + span { outline: 2px solid var(--cs-accent); outline-offset: 2px; }
64
+ .field-label { display: block; margin-bottom: 6px; font-weight: 600; }
65
+ textarea { display: block; width: 100%; min-height: 130px; padding: 10px 12px; border: 1px solid var(--cs-border); border-radius: 8px; background: var(--cs-surface); resize: vertical; }
66
+ textarea:focus-visible { outline-offset: 0; }
67
+ .counter { margin-top: 4px; color: var(--cs-muted); font-size: 12px; text-align: right; }
68
+ .details { margin-top: 12px; padding: 10px 12px; border: 1px solid var(--cs-border); border-radius: 8px; background: var(--cs-surface); }
69
+ .details label { display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer; }
70
+ .details input { width: 16px; height: 16px; margin: 0; accent-color: var(--cs-accent); }
71
+ .details ul { margin: 8px 0 0; padding: 0; list-style: none; color: var(--cs-muted); font-size: 12px; }
72
+ .details ul.off { opacity: 0.45; }
73
+ .details li { overflow-wrap: anywhere; }
74
+ .details .label { color: var(--cs-text); }
75
+ .details li.editable { margin: 2px 0; }
76
+ .details li.editable label { gap: 4px; font-weight: inherit; cursor: auto; }
77
+ .details li.editable .label { flex: none; }
78
+ .details li.editable input { flex: 1 1 auto; min-width: 0; width: auto; height: auto; padding: 2px 6px; border: 1px solid var(--cs-border); border-radius: 4px; background: var(--cs-background); color: var(--cs-text); font: inherit; }
79
+ .details li.editable input:focus-visible { outline: 2px solid var(--cs-accent); outline-offset: 1px; }
80
+ .details li.editable input:disabled { cursor: not-allowed; }
81
+ .actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 16px; }
82
+ .message { flex: 1; margin: 0; font-size: 13px; }
83
+ .error { color: var(--cs-error); }
84
+ .success { color: var(--cs-success); }
85
+ .primary { padding: 9px 18px; border: none; border-radius: 8px; background: var(--cs-accent); color: var(--cs-accent-text); font-weight: 600; }
86
+ .primary:hover:not(:disabled) { filter: brightness(1.1); }
87
+ .secondary { padding: 6px 12px; border: 1px solid var(--cs-border); border-radius: 8px; background: none; }
88
+ section + section { margin-top: 18px; }
89
+ h3 { margin: 0 0 8px; color: var(--cs-muted); font-size: 12px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; }
90
+ .list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
91
+ .item { padding: 10px 12px; border: 1px solid var(--cs-border); border-radius: 8px; background: var(--cs-surface); }
92
+ .meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-bottom: 6px; color: var(--cs-muted); font-size: 12px; }
93
+ .badge { padding: 1px 8px; border: 1px solid var(--cs-border); border-radius: 999px; color: var(--cs-text); }
94
+ .badge.in_progress { border-color: var(--cs-accent); color: var(--cs-accent); }
95
+ .badge.resolved { border-color: var(--cs-success); color: var(--cs-success); }
96
+ .text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
97
+ .answer { margin-top: 8px; padding-left: 10px; border-left: 2px solid var(--cs-accent); }
98
+ .answer-label { color: var(--cs-muted); font-size: 12px; font-weight: 600; }
99
+ .note { margin: 0; color: var(--cs-muted); }
100
+ .failed { display: flex; align-items: center; gap: 12px; }
101
+ details { border: 1px solid var(--cs-border); border-radius: 8px; background: var(--cs-surface); }
102
+ summary { padding: 10px 12px; font-weight: 600; cursor: pointer; }
103
+ details .text { padding: 0 12px 12px; }
104
+ `;