@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
@@ -0,0 +1,30 @@
1
+ import { BUTTON_CSS } from './css.js';
2
+ import { h } from './dom.js';
3
+ import { toTab } from './options.js';
4
+ export function defineButton(getHost) {
5
+ return class CrawlSpaceButton extends HTMLElement {
6
+ root;
7
+ label = document.createTextNode('');
8
+ constructor() {
9
+ super();
10
+ this.root = this.attachShadow({ mode: 'open', delegatesFocus: true });
11
+ const button = h('button', { type: 'button', part: 'button' }, h('slot', {}, this.label));
12
+ button.addEventListener('click', () => getHost().open(toTab(this.getAttribute('tab'))));
13
+ this.root.append(button);
14
+ }
15
+ connectedCallback() {
16
+ getHost().connected.add(this);
17
+ this.refresh();
18
+ }
19
+ disconnectedCallback() {
20
+ getHost().connected.delete(this);
21
+ }
22
+ refresh() {
23
+ const host = getHost();
24
+ host.styles.apply(this.root, BUTTON_CSS);
25
+ this.label.data = host.options.title;
26
+ if (this.hasAttribute('floating'))
27
+ this.dataset.position = host.options.floating ?? 'bottom-right';
28
+ }
29
+ };
30
+ }
@@ -0,0 +1,68 @@
1
+ import { sanitizeText } from './sanitize.js';
2
+ export const CONTEXT_LIMITS = {
3
+ maxEntries: 12,
4
+ maxKeyLength: 40,
5
+ maxValueLength: 200,
6
+ };
7
+ const BROWSERS = [
8
+ ['Edge', /Edg\/(\d+)/],
9
+ ['Firefox', /Firefox\/(\d+)/],
10
+ ['Chrome', /Chrome\/(\d+)/],
11
+ ['Safari', /Version\/(\d+).*Safari/],
12
+ ];
13
+ const SYSTEMS = [
14
+ ['Windows', /Windows/],
15
+ ['iOS', /iPhone|iPad|iPod/],
16
+ ['Android', /Android/],
17
+ ['ChromeOS', /CrOS/],
18
+ ['macOS', /Macintosh|Mac OS X/],
19
+ ['Linux', /Linux/],
20
+ ];
21
+ function browserName(userAgent) {
22
+ for (const [name, pattern] of BROWSERS) {
23
+ const match = pattern.exec(userAgent);
24
+ if (match)
25
+ return `${name} ${match[1]}`;
26
+ }
27
+ return 'Other';
28
+ }
29
+ function systemName(userAgent) {
30
+ return SYSTEMS.find(([, pattern]) => pattern.test(userAgent))?.[0] ?? 'Other';
31
+ }
32
+ export function collectContext(options) {
33
+ const raw = [['Version', options.version, false]];
34
+ if (typeof location !== 'undefined') {
35
+ const path = location.pathname;
36
+ raw.push(['Page', options.scrubPath ? options.scrubPath(path) : path, false]);
37
+ }
38
+ if (typeof navigator !== 'undefined') {
39
+ raw.push(['Browser', browserName(navigator.userAgent), false], ['OS', systemName(navigator.userAgent), false]);
40
+ }
41
+ const extras = options.getContext?.();
42
+ if (typeof extras === 'object' && extras !== null) {
43
+ for (const [label, item] of Object.entries(extras)) {
44
+ if (typeof item === 'object' && item !== null) {
45
+ const detail = item;
46
+ raw.push([label, detail.value, detail.editable === true]);
47
+ }
48
+ else {
49
+ raw.push([label, item, false]);
50
+ }
51
+ }
52
+ }
53
+ const entries = [];
54
+ const seen = new Set();
55
+ for (const [rawLabel, rawValue, editable] of raw) {
56
+ if (entries.length === CONTEXT_LIMITS.maxEntries)
57
+ break;
58
+ if (typeof rawValue !== 'string')
59
+ continue;
60
+ const label = sanitizeText(rawLabel, CONTEXT_LIMITS.maxKeyLength);
61
+ const value = sanitizeText(rawValue, CONTEXT_LIMITS.maxValueLength);
62
+ if (label === '' || value === '' || seen.has(label))
63
+ continue;
64
+ seen.add(label);
65
+ entries.push({ label, value, editable });
66
+ }
67
+ return entries;
68
+ }
@@ -0,0 +1,73 @@
1
+ import { createApiClient } from './api.js';
2
+ import { defineButton } from './button.js';
3
+ import { defineDialog } from './dialog.js';
4
+ import { resolveOptions, toTab } from './options.js';
5
+ import { createStyles } from './styles.js';
6
+ const KEY = Symbol.for('@bro-code/crawl-space');
7
+ const NOOP = { open: () => undefined, close: () => undefined };
8
+ function getState() {
9
+ const state = window[KEY];
10
+ if (!state)
11
+ throw new Error('[crawl-space] createCrawlSpace has not been called.');
12
+ return state;
13
+ }
14
+ function whenBody(run) {
15
+ if (document.body)
16
+ run(document.body);
17
+ else
18
+ document.addEventListener('DOMContentLoaded', () => run(document.body), { once: true });
19
+ }
20
+ function syncFloating(state) {
21
+ if (state.options.floating && !state.floating) {
22
+ const button = document.createElement('crawl-space-button');
23
+ button.setAttribute('floating', '');
24
+ state.floating = button;
25
+ whenBody((body) => body.append(button));
26
+ }
27
+ else if (!state.options.floating && state.floating) {
28
+ state.floating.remove();
29
+ state.floating = null;
30
+ }
31
+ }
32
+ function openDialog(state, tab) {
33
+ state.dialog ??= document.createElement('crawl-space-dialog');
34
+ if (!state.dialog.isConnected)
35
+ document.body.append(state.dialog);
36
+ state.dialog.showTab(tab);
37
+ }
38
+ export function createCrawlSpace(options) {
39
+ if (typeof window === 'undefined' || typeof customElements === 'undefined')
40
+ return NOOP;
41
+ const resolved = resolveOptions(options);
42
+ const target = window;
43
+ const existing = target[KEY];
44
+ if (existing) {
45
+ existing.options = resolved;
46
+ existing.api = createApiClient(resolved);
47
+ existing.styles.setColors(resolved.colors);
48
+ for (const element of existing.connected)
49
+ element.refresh();
50
+ syncFloating(existing);
51
+ return existing.controller;
52
+ }
53
+ const state = {
54
+ options: resolved,
55
+ api: createApiClient(resolved),
56
+ styles: createStyles(resolved.colors),
57
+ connected: new Set(),
58
+ dialog: null,
59
+ floating: null,
60
+ open: (tab) => openDialog(state, tab),
61
+ controller: {
62
+ open: (tab) => openDialog(state, toTab(tab)),
63
+ close: () => state.dialog?.hide(),
64
+ },
65
+ };
66
+ target[KEY] = state;
67
+ if (!customElements.get('crawl-space-dialog'))
68
+ customElements.define('crawl-space-dialog', defineDialog(getState));
69
+ if (!customElements.get('crawl-space-button'))
70
+ customElements.define('crawl-space-button', defineButton(getState));
71
+ syncFloating(state);
72
+ return state.controller;
73
+ }
@@ -0,0 +1,101 @@
1
+ export const BUTTON_CSS = `
2
+ :host { display: inline-block; }
3
+ :host([hidden]) { display: none; }
4
+ :host([floating]) { position: fixed; z-index: 2147483000; }
5
+ :host([floating][data-position='bottom-right']) { right: 16px; bottom: 16px; }
6
+ :host([floating][data-position='bottom-left']) { left: 16px; bottom: 16px; }
7
+ :host([floating][data-position='top-right']) { right: 16px; top: 16px; }
8
+ :host([floating][data-position='top-left']) { left: 16px; top: 16px; }
9
+ button {
10
+ padding: 9px 16px;
11
+ border: none;
12
+ border-radius: 8px;
13
+ background: var(--cs-accent);
14
+ color: var(--cs-accent-text);
15
+ font: 600 14px/1.2 var(--cs-font);
16
+ cursor: pointer;
17
+ }
18
+ :host([floating]) button { padding: 11px 18px; border-radius: 999px; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); }
19
+ button:hover { filter: brightness(1.1); }
20
+ button:focus-visible { outline: 2px solid var(--cs-text); outline-offset: 2px; }
21
+ `;
22
+ export const DIALOG_CSS = `
23
+ :host { all: initial; }
24
+ *, *::before, *::after { box-sizing: border-box; }
25
+ dialog {
26
+ width: min(560px, calc(100vw - 32px));
27
+ max-height: min(720px, calc(100vh - 32px));
28
+ padding: 0;
29
+ border: 1px solid var(--cs-border);
30
+ border-radius: 12px;
31
+ background: var(--cs-background);
32
+ color: var(--cs-text);
33
+ font: 14px/1.45 var(--cs-font);
34
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
35
+ overflow: hidden;
36
+ }
37
+ dialog[open] { display: flex; flex-direction: column; }
38
+ dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
39
+ button, input, textarea { font: inherit; color: inherit; }
40
+ button { cursor: pointer; }
41
+ button:disabled { cursor: default; opacity: 0.6; }
42
+ :focus-visible { outline: 2px solid var(--cs-accent); outline-offset: 2px; }
43
+ header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 12px 0 20px; }
44
+ h2 { margin: 0; font-size: 18px; font-weight: 600; }
45
+ .close { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: none; border-radius: 8px; background: none; color: var(--cs-muted); }
46
+ .close:hover { background: var(--cs-surface); color: var(--cs-text); }
47
+ .close svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
48
+ [role='tablist'] { display: flex; gap: 4px; padding: 8px 14px 0; border-bottom: 1px solid var(--cs-border); }
49
+ [role='tab'] { margin-bottom: -1px; padding: 9px 10px; border: none; border-bottom: 2px solid transparent; background: none; color: var(--cs-muted); font-weight: 600; }
50
+ [role='tab']:hover { color: var(--cs-text); }
51
+ [role='tab'][aria-selected='true'] { border-bottom-color: var(--cs-accent); color: var(--cs-text); }
52
+ [role='tabpanel'] { flex: 1 1 auto; min-height: 0; padding: 16px 20px 20px; overflow-y: auto; }
53
+ [role='tabpanel'][hidden] { display: none; }
54
+ .hidden-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
55
+ .kinds { display: inline-flex; gap: 3px; margin: 0 0 14px; padding: 3px; border: 1px solid var(--cs-border); border-radius: 8px; }
56
+ .kinds label { position: relative; }
57
+ .kinds input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
58
+ .kinds span { display: block; padding: 5px 14px; border-radius: 6px; color: var(--cs-muted); font-weight: 600; }
59
+ .kinds input:checked + span { background: var(--cs-accent); color: var(--cs-accent-text); }
60
+ .kinds input:focus-visible + span { outline: 2px solid var(--cs-accent); outline-offset: 2px; }
61
+ .field-label { display: block; margin-bottom: 6px; font-weight: 600; }
62
+ 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; }
63
+ textarea:focus-visible { outline-offset: 0; }
64
+ .counter { margin-top: 4px; color: var(--cs-muted); font-size: 12px; text-align: right; }
65
+ .details { margin-top: 12px; padding: 10px 12px; border: 1px solid var(--cs-border); border-radius: 8px; background: var(--cs-surface); }
66
+ .details label { display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer; }
67
+ .details input { width: 16px; height: 16px; margin: 0; accent-color: var(--cs-accent); }
68
+ .details ul { margin: 8px 0 0; padding: 0; list-style: none; color: var(--cs-muted); font-size: 12px; }
69
+ .details ul.off { opacity: 0.45; }
70
+ .details li { overflow-wrap: anywhere; }
71
+ .details .label { color: var(--cs-text); }
72
+ .details li.editable { margin: 2px 0; }
73
+ .details li.editable label { gap: 4px; font-weight: inherit; cursor: auto; }
74
+ .details li.editable .label { flex: none; }
75
+ .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; }
76
+ .details li.editable input:focus-visible { outline: 2px solid var(--cs-accent); outline-offset: 1px; }
77
+ .details li.editable input:disabled { cursor: not-allowed; }
78
+ .actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 16px; }
79
+ .message { flex: 1; margin: 0; font-size: 13px; }
80
+ .error { color: var(--cs-error); }
81
+ .success { color: var(--cs-success); }
82
+ .primary { padding: 9px 18px; border: none; border-radius: 8px; background: var(--cs-accent); color: var(--cs-accent-text); font-weight: 600; }
83
+ .primary:hover:not(:disabled) { filter: brightness(1.1); }
84
+ .secondary { padding: 6px 12px; border: 1px solid var(--cs-border); border-radius: 8px; background: none; }
85
+ section + section { margin-top: 18px; }
86
+ h3 { margin: 0 0 8px; color: var(--cs-muted); font-size: 12px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; }
87
+ .list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
88
+ .item { padding: 10px 12px; border: 1px solid var(--cs-border); border-radius: 8px; background: var(--cs-surface); }
89
+ .meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-bottom: 6px; color: var(--cs-muted); font-size: 12px; }
90
+ .badge { padding: 1px 8px; border: 1px solid var(--cs-border); border-radius: 999px; color: var(--cs-text); }
91
+ .badge.in_progress { border-color: var(--cs-accent); color: var(--cs-accent); }
92
+ .badge.resolved { border-color: var(--cs-success); color: var(--cs-success); }
93
+ .text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
94
+ .answer { margin-top: 8px; padding-left: 10px; border-left: 2px solid var(--cs-accent); }
95
+ .answer-label { color: var(--cs-muted); font-size: 12px; font-weight: 600; }
96
+ .note { margin: 0; color: var(--cs-muted); }
97
+ .failed { display: flex; align-items: center; gap: 12px; }
98
+ details { border: 1px solid var(--cs-border); border-radius: 8px; background: var(--cs-surface); }
99
+ summary { padding: 10px 12px; font-weight: 600; cursor: pointer; }
100
+ details .text { padding: 0 12px 12px; }
101
+ `;
@@ -0,0 +1,103 @@
1
+ import { DIALOG_CSS } from './css.js';
2
+ import { h } from './dom.js';
3
+ import { createActivePanel, createQaPanel } from './lists.js';
4
+ import { createSubmitPanel } from './submit.js';
5
+ const TABS = [
6
+ ['submit', 'Submit'],
7
+ ['active', 'Active'],
8
+ ['qa', 'Q&A'],
9
+ ];
10
+ function closeIcon() {
11
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
12
+ svg.setAttribute('viewBox', '0 0 16 16');
13
+ svg.setAttribute('aria-hidden', 'true');
14
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
15
+ path.setAttribute('d', 'M3 3l10 10M13 3L3 13');
16
+ svg.append(path);
17
+ return svg;
18
+ }
19
+ export function defineDialog(getHost) {
20
+ return class CrawlSpaceDialog extends HTMLElement {
21
+ root;
22
+ dialog;
23
+ heading = h('h2', { id: 'cs-title' });
24
+ tabs = new Map();
25
+ panels = new Map();
26
+ current = 'submit';
27
+ constructor() {
28
+ super();
29
+ const host = getHost();
30
+ this.root = this.attachShadow({ mode: 'open' });
31
+ const close = h('button', { type: 'button', class: 'close', 'aria-label': 'Close' }, closeIcon());
32
+ close.addEventListener('click', () => this.hide());
33
+ const tablist = h('div', { role: 'tablist', 'aria-labelledby': 'cs-title' });
34
+ tablist.addEventListener('keydown', (event) => this.onTabKey(event));
35
+ this.dialog = h('dialog', { 'aria-labelledby': 'cs-title' }, h('header', {}, this.heading, close), tablist);
36
+ const panels = { submit: createSubmitPanel(host), active: createActivePanel(host), qa: createQaPanel(host) };
37
+ for (const [tab, label] of TABS) {
38
+ const button = h('button', { type: 'button', role: 'tab', id: `cs-tab-${tab}`, 'aria-controls': `cs-panel-${tab}` }, label);
39
+ button.addEventListener('click', () => this.select(tab));
40
+ tablist.append(button);
41
+ this.tabs.set(tab, button);
42
+ const element = h('div', { role: 'tabpanel', id: `cs-panel-${tab}`, 'aria-labelledby': `cs-tab-${tab}`, tabindex: '0' }, panels[tab].element);
43
+ this.dialog.append(element);
44
+ this.panels.set(tab, { panel: panels[tab], element });
45
+ }
46
+ this.dialog.addEventListener('close', () => getHost().options.onOpenChange?.(false));
47
+ this.root.append(this.dialog);
48
+ }
49
+ connectedCallback() {
50
+ getHost().connected.add(this);
51
+ this.refresh();
52
+ }
53
+ disconnectedCallback() {
54
+ getHost().connected.delete(this);
55
+ }
56
+ refresh() {
57
+ const host = getHost();
58
+ host.styles.apply(this.root, DIALOG_CSS);
59
+ this.heading.textContent = host.options.title;
60
+ for (const { panel } of this.panels.values())
61
+ panel.refresh?.();
62
+ }
63
+ showTab(tab) {
64
+ this.select(tab);
65
+ if (this.dialog.open)
66
+ return;
67
+ this.dialog.showModal();
68
+ this.tabs.get(tab)?.focus();
69
+ getHost().options.onOpenChange?.(true);
70
+ }
71
+ hide() {
72
+ if (this.dialog.open)
73
+ this.dialog.close();
74
+ }
75
+ select(tab) {
76
+ this.current = tab;
77
+ for (const [name, button] of this.tabs) {
78
+ const selected = name === tab;
79
+ button.setAttribute('aria-selected', String(selected));
80
+ button.tabIndex = selected ? 0 : -1;
81
+ }
82
+ for (const [name, { element }] of this.panels)
83
+ element.hidden = name !== tab;
84
+ this.panels.get(tab)?.panel.show();
85
+ }
86
+ onTabKey(event) {
87
+ const order = TABS.map(([tab]) => tab);
88
+ const index = order.indexOf(this.current);
89
+ const moves = {
90
+ ArrowRight: (index + 1) % order.length,
91
+ ArrowLeft: (index + order.length - 1) % order.length,
92
+ Home: 0,
93
+ End: order.length - 1,
94
+ };
95
+ const next = moves[event.key];
96
+ if (next === undefined)
97
+ return;
98
+ event.preventDefault();
99
+ this.select(order[next]);
100
+ this.tabs.get(order[next])?.focus();
101
+ }
102
+ };
103
+ }
@@ -0,0 +1,14 @@
1
+ export function h(tag, attributes = {}, ...children) {
2
+ const element = document.createElement(tag);
3
+ for (const [name, value] of Object.entries(attributes)) {
4
+ if (value === true)
5
+ element.setAttribute(name, '');
6
+ else if (value !== false)
7
+ element.setAttribute(name, value);
8
+ }
9
+ for (const child of children) {
10
+ if (child !== null && child !== undefined && child !== false)
11
+ element.append(child);
12
+ }
13
+ return element;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export { createCrawlSpace } from './crawl-space.js';
2
+ export { sanitizeText } from './sanitize.js';
@@ -0,0 +1,63 @@
1
+ import { h } from './dom.js';
2
+ const TYPE_LABELS = { bug: 'Bug', question: 'Question' };
3
+ const STATUS_LABELS = { new: 'New', in_progress: 'In progress', resolved: 'Resolved', dismissed: 'Dismissed' };
4
+ function note(text) {
5
+ return h('p', { class: 'note' }, text);
6
+ }
7
+ function dateOf(iso) {
8
+ const label = new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
9
+ return h('time', { datetime: iso }, label);
10
+ }
11
+ function reportItem(report, own) {
12
+ return h('li', { class: 'item' }, h('div', { class: 'meta' }, h('span', { class: 'badge' }, TYPE_LABELS[report.type]), own && h('span', { class: `badge ${report.status}` }, STATUS_LABELS[report.status]), dateOf(report.createdAt)), h('p', { class: 'text' }, report.description), own &&
13
+ report.answer !== null &&
14
+ h('div', { class: 'answer' }, h('div', { class: 'answer-label' }, 'Answer'), h('p', { class: 'text' }, report.answer)));
15
+ }
16
+ function section(title, content) {
17
+ return h('section', {}, h('h3', {}, title), content);
18
+ }
19
+ function renderReports(reports) {
20
+ const sections = [];
21
+ if (reports.mine.length > 0) {
22
+ sections.push(section('Your reports', h('ul', { class: 'list' }, ...reports.mine.map((r) => reportItem(r, true)))));
23
+ }
24
+ sections.push(section('Being worked on', reports.active.length > 0
25
+ ? h('ul', { class: 'list' }, ...reports.active.map((r) => reportItem(r, false)))
26
+ : note('Nothing is being worked on right now.')));
27
+ return sections;
28
+ }
29
+ function renderQa(items) {
30
+ if (items.length === 0)
31
+ return [note('No questions have been answered yet.')];
32
+ const entries = items.map((item) => h('li', {}, h('details', {}, h('summary', {}, item.question), h('p', { class: 'text' }, item.answer))));
33
+ return [h('ul', { class: 'list' }, ...entries)];
34
+ }
35
+ function listPanel(load, render) {
36
+ const element = h('div');
37
+ let latest = 0;
38
+ function show() {
39
+ const request = ++latest;
40
+ element.setAttribute('aria-busy', 'true');
41
+ element.replaceChildren(note('Loading...'));
42
+ void load().then((result) => {
43
+ if (request !== latest)
44
+ return;
45
+ element.removeAttribute('aria-busy');
46
+ if (result.ok) {
47
+ element.replaceChildren(...render(result.value));
48
+ return;
49
+ }
50
+ const retry = h('button', { type: 'button', class: 'secondary' }, 'Try again');
51
+ retry.addEventListener('click', show);
52
+ const text = result.reason === 'rate-limited' ? 'Too many requests. Please try again in a minute.' : 'This could not be loaded.';
53
+ element.replaceChildren(h('div', { class: 'failed' }, h('p', { class: 'message error' }, text), retry));
54
+ });
55
+ }
56
+ return { element, show };
57
+ }
58
+ export function createActivePanel(host) {
59
+ return listPanel(() => host.api.getReports(), renderReports);
60
+ }
61
+ export function createQaPanel(host) {
62
+ return listPanel(() => host.api.getQa(), renderQa);
63
+ }
@@ -0,0 +1,76 @@
1
+ import { resolveColors } from './themes.js';
2
+ const POSITIONS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
3
+ const TABS = ['submit', 'active', 'qa'];
4
+ function warn(message) {
5
+ console.warn(`[crawl-space] ${message}`);
6
+ }
7
+ function isRecord(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
10
+ function text(name, value, fallback) {
11
+ if (value === undefined)
12
+ return fallback;
13
+ if (typeof value === 'string' && value.trim() !== '')
14
+ return value;
15
+ warn(`"${name}" must be a non-empty string; using "${fallback}".`);
16
+ return fallback;
17
+ }
18
+ function fn(name, value) {
19
+ if (value === undefined || typeof value === 'function')
20
+ return value;
21
+ warn(`"${name}" must be a function; ignoring it.`);
22
+ return undefined;
23
+ }
24
+ function floating(value) {
25
+ if (value === undefined)
26
+ return null;
27
+ if (!isRecord(value)) {
28
+ warn('"floating" must be an object; no floating button.');
29
+ return null;
30
+ }
31
+ const position = value.position ?? 'bottom-right';
32
+ if (POSITIONS.includes(position))
33
+ return position;
34
+ warn(`Unknown floating position "${String(position)}"; using "bottom-right".`);
35
+ return 'bottom-right';
36
+ }
37
+ export function toTab(value) {
38
+ if (TABS.includes(value))
39
+ return value;
40
+ if (value !== undefined && value !== null)
41
+ warn(`Unknown tab "${String(value)}"; using "submit".`);
42
+ return 'submit';
43
+ }
44
+ export function resolveOptions(options) {
45
+ if (!isRecord(options) || typeof options.api !== 'string' || options.api.trim() === '') {
46
+ throw new TypeError('[crawl-space] "api" must be a non-empty string.');
47
+ }
48
+ let paths = {};
49
+ if (isRecord(options.paths))
50
+ paths = options.paths;
51
+ else if (options.paths !== undefined)
52
+ warn('"paths" must be an object; using the default paths.');
53
+ let maxLength = 2000;
54
+ if (Number.isInteger(options.maxLength) && options.maxLength > 0)
55
+ maxLength = options.maxLength;
56
+ else if (options.maxLength !== undefined)
57
+ warn(`"maxLength" must be a positive whole number; using ${maxLength}.`);
58
+ let version;
59
+ if (typeof options.version === 'string')
60
+ version = options.version;
61
+ else if (options.version !== undefined)
62
+ warn('"version" must be a string; ignoring it.');
63
+ return {
64
+ api: options.api,
65
+ paths: { reports: text('paths.reports', paths.reports, '/reports'), qa: text('paths.qa', paths.qa, '/qa') },
66
+ colors: resolveColors(options),
67
+ floating: floating(options.floating),
68
+ title: text('title', options.title, 'Help & bug reports'),
69
+ maxLength,
70
+ version,
71
+ getToken: fn('getToken', options.getToken),
72
+ scrubPath: fn('scrubPath', options.scrubPath),
73
+ getContext: fn('getContext', options.getContext),
74
+ onOpenChange: fn('onOpenChange', options.onOpenChange),
75
+ };
76
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,18 @@
1
+ const INVISIBLE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b-\u200d\u2060\ufeff\u202a-\u202e\u2066-\u2069]/g;
2
+ const BLANK_RUN = /\n(?:[ \t]*\n){3,}/g;
3
+ export function sanitizeText(text, maxLength) {
4
+ let clean = text
5
+ .normalize('NFC')
6
+ .replace(/\r\n/g, '\n')
7
+ .replace(INVISIBLE, '')
8
+ .replace(BLANK_RUN, '\n\n')
9
+ .trim();
10
+ if (clean.length > maxLength) {
11
+ let end = Math.max(0, maxLength);
12
+ const last = clean.charCodeAt(end - 1);
13
+ if (last >= 0xd800 && last <= 0xdbff)
14
+ end -= 1;
15
+ clean = clean.slice(0, end).trimEnd();
16
+ }
17
+ return clean;
18
+ }
@@ -0,0 +1,65 @@
1
+ const PROPERTIES = {
2
+ background: '--cs-background',
3
+ surface: '--cs-surface',
4
+ text: '--cs-text',
5
+ muted: '--cs-muted',
6
+ border: '--cs-border',
7
+ accent: '--cs-accent',
8
+ accentText: '--cs-accent-text',
9
+ error: '--cs-error',
10
+ success: '--cs-success',
11
+ font: '--cs-font',
12
+ };
13
+ export function themeCss(colors) {
14
+ const lines = Object.keys(PROPERTIES).map((key) => `${PROPERTIES[key]}: ${colors[key]};`);
15
+ return `:host { ${lines.join(' ')} }`;
16
+ }
17
+ function supportsConstructable() {
18
+ return (typeof CSSStyleSheet !== 'undefined' &&
19
+ 'replaceSync' in CSSStyleSheet.prototype &&
20
+ typeof ShadowRoot !== 'undefined' &&
21
+ 'adoptedStyleSheets' in ShadowRoot.prototype);
22
+ }
23
+ function sheetFor(css) {
24
+ const sheet = new CSSStyleSheet();
25
+ sheet.replaceSync(css);
26
+ return sheet;
27
+ }
28
+ function styleIn(root, name) {
29
+ let style = root.querySelector(`style[data-cs="${name}"]`);
30
+ if (!style) {
31
+ style = document.createElement('style');
32
+ style.dataset.cs = name;
33
+ root.prepend(style);
34
+ }
35
+ return style;
36
+ }
37
+ export function createStyles(colors) {
38
+ if (supportsConstructable()) {
39
+ const theme = sheetFor(themeCss(colors));
40
+ const sheets = new Map();
41
+ return {
42
+ apply(root, css) {
43
+ let sheet = sheets.get(css);
44
+ if (!sheet) {
45
+ sheet = sheetFor(css);
46
+ sheets.set(css, sheet);
47
+ }
48
+ root.adoptedStyleSheets = [sheet, theme];
49
+ },
50
+ setColors(next) {
51
+ theme.replaceSync(themeCss(next));
52
+ },
53
+ };
54
+ }
55
+ let theme = themeCss(colors);
56
+ return {
57
+ apply(root, css) {
58
+ styleIn(root, 'theme').textContent = theme;
59
+ styleIn(root, 'base').textContent = css;
60
+ },
61
+ setColors(next) {
62
+ theme = themeCss(next);
63
+ },
64
+ };
65
+ }