@bro-code/crawl-space 0.0.0-stage → 2026.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -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 +121 -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 +118 -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,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.THEMES = void 0;
4
+ exports.resolveColors = resolveColors;
5
+ const DEFAULT = {
6
+ background: '#121417',
7
+ surface: '#1c1f24',
8
+ text: '#e8eaed',
9
+ muted: '#9aa0a6',
10
+ border: '#33373d',
11
+ accent: '#5b9cff',
12
+ accentText: '#0b1220',
13
+ error: '#ff6b6b',
14
+ success: '#6fcf97',
15
+ font: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
16
+ };
17
+ const BROCODE = {
18
+ background: '#181818',
19
+ surface: '#222222',
20
+ text: '#ffffff',
21
+ muted: '#b0b0b0',
22
+ border: '#3c3c3c',
23
+ accent: '#7b9981',
24
+ accentText: '#ffffff',
25
+ error: '#ff5252',
26
+ success: '#96be4d',
27
+ font: 'Roboto, sans-serif',
28
+ };
29
+ const ROMBIE = {
30
+ background: '#0a0a12',
31
+ surface: '#15141f',
32
+ text: '#f1eef9',
33
+ muted: '#9a90bd',
34
+ border: '#302c47',
35
+ accent: '#ff2d78',
36
+ accentText: '#12060d',
37
+ error: '#ff5c5c',
38
+ success: '#00e5ff',
39
+ font: "system-ui, 'Segoe UI', Roboto, sans-serif",
40
+ };
41
+ exports.THEMES = {
42
+ default: DEFAULT,
43
+ brocode: BROCODE,
44
+ rombie: ROMBIE,
45
+ 'taut-wad': BROCODE,
46
+ custom: DEFAULT,
47
+ };
48
+ const COLOR_KEYS = Object.keys(DEFAULT);
49
+ function isTheme(name) {
50
+ return Object.hasOwn(exports.THEMES, name);
51
+ }
52
+ function isValid(key, value) {
53
+ if (typeof value !== 'string' || value.trim() === '')
54
+ return false;
55
+ return CSS.supports(key === 'font' ? 'font-family' : 'color', value);
56
+ }
57
+ function resolveColors(config) {
58
+ const theme = config.theme ?? 'default';
59
+ if (typeof theme !== 'string' || !isTheme(theme)) {
60
+ console.warn(`[crawl-space] Unknown theme "${String(theme)}"; using "default".`);
61
+ return { ...DEFAULT };
62
+ }
63
+ if (theme !== 'custom') {
64
+ if (config.colors !== undefined) {
65
+ console.warn(`[crawl-space] "colors" is only used with theme "custom"; ignoring it.`);
66
+ }
67
+ return { ...exports.THEMES[theme] };
68
+ }
69
+ const colors = { ...DEFAULT };
70
+ const given = config.colors ?? {};
71
+ if (typeof given !== 'object' || given === null || Array.isArray(given)) {
72
+ console.warn('[crawl-space] "colors" must be an object; using the default theme.');
73
+ return colors;
74
+ }
75
+ for (const [key, value] of Object.entries(given)) {
76
+ if (value === undefined)
77
+ continue;
78
+ if (!COLOR_KEYS.includes(key)) {
79
+ console.warn(`[crawl-space] Unknown color "${key}"; ignoring it.`);
80
+ }
81
+ else if (isValid(key, value)) {
82
+ colors[key] = value;
83
+ }
84
+ else {
85
+ console.warn(`[crawl-space] Invalid ${key} "${String(value)}"; using the default theme's.`);
86
+ }
87
+ }
88
+ return colors;
89
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,86 @@
1
+ const TYPES = ['bug', 'question'];
2
+ const STATUSES = ['new', 'in_progress', 'resolved', 'dismissed'];
3
+ const FAILED = { ok: false, reason: 'failed' };
4
+ export function joinUrl(base, path) {
5
+ return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
6
+ }
7
+ function isRecord(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
10
+ function isId(value) {
11
+ return (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value !== '');
12
+ }
13
+ function isDate(value) {
14
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value));
15
+ }
16
+ function isOneOf(list, value) {
17
+ return list.includes(value);
18
+ }
19
+ function toReport(item) {
20
+ if (!isRecord(item))
21
+ return null;
22
+ const { id, type, status, description, answer, createdAt, updatedAt } = item;
23
+ if (!isId(id) || !isOneOf(TYPES, type) || !isOneOf(STATUSES, status) || typeof description !== 'string')
24
+ return null;
25
+ if (answer != null && typeof answer !== 'string')
26
+ return null;
27
+ if (!isDate(createdAt) || !isDate(updatedAt))
28
+ return null;
29
+ return { id, type, status, description, answer: answer ?? null, createdAt, updatedAt };
30
+ }
31
+ function toQaItem(item) {
32
+ if (!isRecord(item))
33
+ return null;
34
+ const { id, question, answer, updatedAt } = item;
35
+ if (!isId(id) || typeof question !== 'string' || typeof answer !== 'string' || !isDate(updatedAt))
36
+ return null;
37
+ return { id, question, answer, updatedAt };
38
+ }
39
+ function listOf(value, toItem) {
40
+ if (!Array.isArray(value))
41
+ return null;
42
+ return value.map(toItem).filter((item) => item !== null);
43
+ }
44
+ function toReports(body) {
45
+ if (!isRecord(body))
46
+ return null;
47
+ const mine = listOf(body.mine, toReport);
48
+ const active = listOf(body.active, toReport);
49
+ return mine && active ? { mine, active } : null;
50
+ }
51
+ function toCreated(body) {
52
+ return isRecord(body) && isId(body.id) ? { id: body.id } : null;
53
+ }
54
+ export function createApiClient(options) {
55
+ const reportsUrl = joinUrl(options.api, options.paths?.reports ?? '/reports');
56
+ const qaUrl = joinUrl(options.api, options.paths?.qa ?? '/qa');
57
+ async function request(url, parse, body) {
58
+ try {
59
+ const headers = { Accept: 'application/json' };
60
+ if (body !== undefined)
61
+ headers['Content-Type'] = 'application/json';
62
+ const token = await options.getToken?.();
63
+ if (typeof token === 'string' && token !== '')
64
+ headers.Authorization = `Bearer ${token}`;
65
+ const response = await fetch(url, {
66
+ method: body === undefined ? 'GET' : 'POST',
67
+ headers,
68
+ body: body === undefined ? undefined : JSON.stringify(body),
69
+ });
70
+ if (response.status === 429)
71
+ return { ok: false, reason: 'rate-limited' };
72
+ if (!response.ok)
73
+ return FAILED;
74
+ const value = parse(await response.json());
75
+ return value === null ? FAILED : { ok: true, value };
76
+ }
77
+ catch {
78
+ return FAILED;
79
+ }
80
+ }
81
+ return {
82
+ submitReport: (submission) => request(reportsUrl, toCreated, submission),
83
+ getReports: () => request(reportsUrl, toReports),
84
+ getQa: () => request(qaUrl, (body) => listOf(body, toQaItem)),
85
+ };
86
+ }
@@ -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,118 @@
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
+ @media (max-width: 599.98px) {
102
+ dialog { width: 100%; max-width: none; height: 100vh; height: 100dvh; max-height: none; margin: 0; border: none; border-radius: 0; box-shadow: none; }
103
+ }
104
+ @media (hover: none) {
105
+ header { padding: 8px 8px 0 20px; }
106
+ .close { width: 48px; height: 48px; }
107
+ .close svg { width: 20px; height: 20px; }
108
+ [role='tablist'] { gap: 8px; }
109
+ [role='tab'] { min-height: 48px; padding: 12px 14px; }
110
+ .kinds { gap: 8px; }
111
+ .kinds span { padding: 14px 18px; }
112
+ .details > label { min-height: 48px; gap: 12px; }
113
+ .details > label input { width: 24px; height: 24px; }
114
+ .primary { min-height: 48px; padding: 12px 24px; }
115
+ .secondary { min-height: 48px; padding: 12px 18px; }
116
+ summary { min-height: 48px; padding: 14px 12px; }
117
+ }
118
+ `;
@@ -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
+ }