@avivbenhamo/polly-kit 0.1.0 → 0.3.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -10
  3. package/dist/adapters/encoding.d.ts +7 -0
  4. package/dist/adapters/encoding.js +46 -0
  5. package/dist/adapters/undici.d.ts +36 -0
  6. package/dist/adapters/undici.js +231 -0
  7. package/dist/browser.js +5 -19
  8. package/dist/defaults.d.ts +40 -0
  9. package/dist/defaults.js +40 -0
  10. package/dist/node.d.ts +56 -0
  11. package/dist/node.js +108 -0
  12. package/dist/studio/body.d.ts +28 -0
  13. package/dist/studio/body.js +66 -0
  14. package/dist/studio/browser.d.ts +22 -0
  15. package/dist/studio/browser.js +24 -0
  16. package/dist/studio/client.bundle.js +290 -0
  17. package/dist/studio/edit.d.ts +42 -0
  18. package/dist/studio/edit.js +90 -0
  19. package/dist/studio/etag.d.ts +6 -0
  20. package/dist/studio/etag.js +14 -0
  21. package/dist/studio/har.d.ts +19 -0
  22. package/dist/studio/har.js +36 -0
  23. package/dist/studio/headers.d.ts +31 -0
  24. package/dist/studio/headers.js +59 -0
  25. package/dist/studio/index.d.ts +9 -0
  26. package/dist/studio/index.js +5 -0
  27. package/dist/studio/server.d.ts +35 -0
  28. package/dist/studio/server.js +262 -0
  29. package/dist/studio/stores/apply.d.ts +6 -0
  30. package/dist/studio/stores/apply.js +20 -0
  31. package/dist/studio/stores/fs-store.d.ts +70 -0
  32. package/dist/studio/stores/fs-store.js +186 -0
  33. package/dist/studio/stores/http-store.d.ts +30 -0
  34. package/dist/studio/stores/http-store.js +67 -0
  35. package/dist/studio/stores/local-storage-store.d.ts +25 -0
  36. package/dist/studio/stores/local-storage-store.js +109 -0
  37. package/dist/studio/stores/types.d.ts +45 -0
  38. package/dist/studio/stores/types.js +7 -0
  39. package/dist/studio/types.d.ts +92 -0
  40. package/dist/studio/types.js +7 -0
  41. package/dist/studio/ui/app.d.ts +12 -0
  42. package/dist/studio/ui/app.js +180 -0
  43. package/dist/studio/ui/client.d.ts +2 -0
  44. package/dist/studio/ui/client.js +6 -0
  45. package/dist/studio/ui/page.d.ts +53 -0
  46. package/dist/studio/ui/page.js +360 -0
  47. package/dist/studio/ui/render.d.ts +25 -0
  48. package/dist/studio/ui/render.js +171 -0
  49. package/dist/vite.js +1 -1
  50. package/package.json +49 -7
@@ -0,0 +1,180 @@
1
+ import { renderEntryDetail, renderEntryRow, renderRecordingList } from './render.js';
2
+ import { StoreError } from '../stores/types.js';
3
+ /**
4
+ * The page's behaviour, written against `RecordingStore` and nothing else. Under the
5
+ * Node mount the store is an `HttpStore`; in a browser-only app it is a
6
+ * `LocalStorageStore`. Nothing below can tell the difference — no branch on which store
7
+ * it holds, which is why `patchEntry` and `deleteEntry` are on the interface rather than
8
+ * being assembled here out of `read` and `write`.
9
+ *
10
+ * Every mutation re-reads afterwards rather than trusting the local copy: the etag that
11
+ * the next save depends on comes from the store, never from here.
12
+ */
13
+ export function mountStudio(root, store) {
14
+ const recordings = required(root, '#recordings');
15
+ const entries = required(root, '#entries');
16
+ const detail = required(root, '#detail');
17
+ let currentId = null;
18
+ let current = null;
19
+ // `.catch(report)` rather than a bare `void`: an unawaited rejection here would be an
20
+ // unhandled rejection the page never shows, and the user would be left staring at
21
+ // whatever placeholder markup the page shipped with — see Task 11 review, Finding 1.
22
+ refreshRecordings().catch(report);
23
+ async function refreshRecordings() {
24
+ recordings.innerHTML = renderRecordingList(await store.list());
25
+ }
26
+ async function openRecording(id) {
27
+ // `currentId` moves only once the read has actually succeeded. Setting it before
28
+ // the `await` would leave a failed open pointing `currentId` at a recording whose
29
+ // `har`/`etag` were never fetched, while `current` still held the previous one — a
30
+ // state no other code path expects, and one a subsequent Save could act on.
31
+ const read = await store.read(id);
32
+ currentId = id;
33
+ current = { har: read.har, etag: read.etag };
34
+ entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join('')}</ul>`;
35
+ detail.innerHTML = '';
36
+ }
37
+ /**
38
+ * `entryId` always arrives as a string, because it came off `dataset`. `e._id` is
39
+ * whatever the file says — `isHarShaped` (`src/har.ts`) checks only that `log.entries`
40
+ * is an array — so a HAR with a numeric `_id` renders a row, emits `data-id="12345"`,
41
+ * and then fails `12345 === '12345'`. Comparing the string forms makes the click
42
+ * resolve; reporting the miss means the cases that still do not resolve say so instead
43
+ * of blanking the pane, which is indistinguishable from a dead button.
44
+ */
45
+ function openEntry(entryId, order) {
46
+ const entry = current?.har.log.entries.find((e) => String(e._id) === entryId && e._order === order);
47
+ if (entry === undefined) {
48
+ detail.innerHTML = '';
49
+ report(new Error(`no entry with _id ${entryId} and _order ${order} in this recording`));
50
+ return;
51
+ }
52
+ detail.innerHTML = renderEntryDetail(entry);
53
+ }
54
+ async function save(entryId, order) {
55
+ if (currentId === null || current === null)
56
+ return;
57
+ try {
58
+ // Reading the form back can itself fail — an out-of-range status, say — so it
59
+ // shares this try/catch rather than running ahead of it unguarded.
60
+ const patch = readPatch(detail, entryId, order, current.har);
61
+ await store.patchEntry(currentId, entryId, order, current.etag, patch);
62
+ await openRecording(currentId);
63
+ openEntry(entryId, order);
64
+ }
65
+ catch (err) {
66
+ report(err);
67
+ }
68
+ }
69
+ async function remove(entryId, order) {
70
+ if (currentId === null || current === null)
71
+ return;
72
+ try {
73
+ await store.deleteEntry(currentId, entryId, order, current.etag);
74
+ await openRecording(currentId);
75
+ }
76
+ catch (err) {
77
+ report(err);
78
+ }
79
+ }
80
+ function report(err) {
81
+ const message = err instanceof StoreError && err.code === 'conflict'
82
+ ? 'the recording changed underneath — reload and retry'
83
+ : err instanceof Error
84
+ ? err.message
85
+ : String(err);
86
+ // `document.createElement` would reach past `root` for a document `mountStudio`
87
+ // was never given — harmless today since the Node mount and the browser mount
88
+ // both happen to run in one document, but `root` exists precisely so this
89
+ // function need not assume that. `detail`'s own document is always the right one.
90
+ const banner = detail.ownerDocument.createElement('p');
91
+ banner.className = 'problem';
92
+ banner.textContent = message;
93
+ detail.prepend(banner);
94
+ }
95
+ root.addEventListener('click', (event) => {
96
+ const target = event.target?.closest('button');
97
+ if (target === null || target === undefined)
98
+ return;
99
+ if (target.classList.contains('recording')) {
100
+ openRecording(target.dataset['id']).catch(report);
101
+ return;
102
+ }
103
+ if (target.classList.contains('entry')) {
104
+ // The one synchronous call in this listener, and so the one that `.catch(report)`
105
+ // cannot cover. Without this it was also the only unguarded one: a throw out of
106
+ // `renderEntryDetail` became an uncaught exception with no banner and a detail
107
+ // pane still showing the previously opened entry — the reader has no way to tell
108
+ // the click did nothing from the click having worked. `renderEntryDetail` now
109
+ // guards itself per entry (`src/ui/render.ts`), which makes this the second line
110
+ // of defence rather than the only one; it stays because "every failure in this
111
+ // listener reports" should not depend on which function happens to be total.
112
+ try {
113
+ openEntry(target.dataset['id'], Number(target.dataset['order']));
114
+ }
115
+ catch (err) {
116
+ report(err);
117
+ }
118
+ return;
119
+ }
120
+ const section = target.closest('.detail');
121
+ if (section === null)
122
+ return;
123
+ const entryId = section.dataset['id'];
124
+ const order = Number(section.dataset['order']);
125
+ if (target.getAttribute('name') === 'save')
126
+ void save(entryId, order);
127
+ if (target.getAttribute('name') === 'delete')
128
+ void remove(entryId, order);
129
+ });
130
+ }
131
+ /** `root.querySelector` returns `Element | null`; this is the "or throw" version. */
132
+ function required(root, selector) {
133
+ const el = root.querySelector(selector);
134
+ if (el === null) {
135
+ throw new Error(`polly-studio: mountStudio could not find "${selector}" in the given root`);
136
+ }
137
+ return el;
138
+ }
139
+ /**
140
+ * Reads the form back into a patch. `bodyText` is omitted for a binary entry — there is
141
+ * no textarea for one — so a save on a binary body changes status and headers and
142
+ * leaves the recorded bytes alone.
143
+ *
144
+ * A cleared `status` input reports `.value === ''`, not `undefined` — `Number('')` is
145
+ * `0`, and nothing downstream (`applyResponsePatch` in `edit.ts`) range checks it, so an
146
+ * unguarded read would silently persist `response.status = 0`. `''` is treated as "no
147
+ * status change requested", the same as the field being absent; a value that parses but
148
+ * falls outside 100–599 is treated as a bad edit and reported the way a rejected save
149
+ * already is, through `report`, via the throw `save` catches.
150
+ */
151
+ function readPatch(root, entryId, order, har) {
152
+ const value = (name) => root.querySelector(`[name="${name}"]`)?.value;
153
+ const headers = [];
154
+ for (let index = 0;; index++) {
155
+ const name = value(`headerName${index}`);
156
+ if (name === undefined)
157
+ break;
158
+ const existing = har.log.entries
159
+ // String-compared for the same reason as `openEntry`: `entryId` came off `dataset`.
160
+ .find((e) => String(e._id) === entryId && e._order === order)
161
+ ?.response.headers[index];
162
+ headers.push({
163
+ ...(existing?._fromType === 'array' ? { _fromType: 'array' } : {}),
164
+ name,
165
+ value: value(`headerValue${index}`) ?? '',
166
+ });
167
+ }
168
+ const rawStatus = value('status');
169
+ const status = rawStatus === undefined || rawStatus === '' ? undefined : Number(rawStatus);
170
+ if (status !== undefined && (!Number.isFinite(status) || status < 100 || status > 599)) {
171
+ throw new Error(`status must be a number between 100 and 599, got "${rawStatus}"`);
172
+ }
173
+ const bodyText = value('bodyText');
174
+ return {
175
+ ...(status === undefined ? {} : { status }),
176
+ ...(value('statusText') === undefined ? {} : { statusText: value('statusText') }),
177
+ headers,
178
+ ...(bodyText === undefined ? {} : { bodyText }),
179
+ };
180
+ }
@@ -0,0 +1,2 @@
1
+ /** The entry point the served page imports. */
2
+ export declare function start(basePath: string): void;
@@ -0,0 +1,6 @@
1
+ import { mountStudio } from './app.js';
2
+ import { HttpStore } from '../stores/http-store.js';
3
+ /** The entry point the served page imports. */
4
+ export function start(basePath) {
5
+ mountStudio(document, new HttpStore(basePath));
6
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The body, shared with the browser-only mount in Task 12 so the two cannot drift into
3
+ * two different editors.
4
+ */
5
+ export declare const STUDIO_MARKUP = "\n<div class=\"polly-studio\">\n<header class=\"ps-bar\">\n <div class=\"ps-brand\">\n <h1>polly-studio</h1>\n <p class=\"ps-tagline\">Change what a recorded request replies with, then save it back to the HAR.</p>\n </div>\n <p class=\"warn banner\">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:\n the next record run deletes every entry it did not exercise, including the ones you edit here.\n Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>\n</header>\n<main class=\"ps-panes\">\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Recordings</h2>\n <nav id=\"recordings\" class=\"ps-scroll\">Loading\u2026</nav>\n </section>\n <section class=\"ps-pane ps-pane--list\">\n <h2 class=\"ps-pane-title\">Requests</h2>\n <div id=\"entries\" class=\"ps-scroll\"></div>\n </section>\n <section class=\"ps-pane ps-pane--editor\">\n <h2 class=\"ps-pane-title\">Recorded exchange</h2>\n <div id=\"detail\" class=\"ps-scroll\"></div>\n </section>\n</main>\n</div>";
6
+ /**
7
+ * The HTML shell.
8
+ *
9
+ * `clientScript` is the bundled `client.ts`, inlined rather than linked. The handler has
10
+ * no way to know what URL a browser would have to request for it: mounted with
11
+ * `app.use('/__polly', ...)`, Express strips the prefix before the handler sees the
12
+ * request, so nothing on the server side still knows the page is at `/__polly`.
13
+ *
14
+ * `location.pathname` does know, and it is evaluated in the browser, so the API base is
15
+ * derived there. The same expression is correct when the handler is mounted at the root,
16
+ * behind a prefix, or reached with a trailing slash.
17
+ *
18
+ * `clientScript` is split on a literal `</script`, not because a recording can reach
19
+ * this string — it can't; this is build-time bundle text, not runtime HAR data — but
20
+ * because Task 12's browser mount imports `STUDIO_MARKUP`/`STUDIO_STYLE` from this same
21
+ * file, which means `page.ts` itself can end up bundled into the very `clientScript`
22
+ * this function inlines. HTML's script-end-tag detection is lexical, not JS-aware, so an
23
+ * un-split `</script` anywhere in that text — in code, a string, or a comment — would
24
+ * truncate the tag regardless of a bundler's own escaping. The replacement is lossless:
25
+ * `<\/script` is identical JavaScript to `</script`, just not a tag boundary to the HTML
26
+ * parser.
27
+ *
28
+ * The trailer's regex is written `/\\/+$/` — doubled backslash — and must stay that way.
29
+ * It sits inside a template literal, so the literal consumes one backslash and the page
30
+ * receives `/\/+$/`, the escaped forward slash the browser needs. Written the way it
31
+ * reads as code, `/\/+$/`, the emitted text is `//+$/`: `//` opens a line comment, the
32
+ * closing paren is swallowed, and the browser reports `SyntaxError: Unexpected end of
33
+ * input` before anything runs. That is the bug this doubling fixes, and it was
34
+ * introduced by checking the expression as code rather than as template text. The
35
+ * `vm.Script` test in `test/render.test.ts` pins the emitted form.
36
+ */
37
+ export declare function renderPage(clientScript: string): string;
38
+ /**
39
+ * Rules that only make sense when this package owns the whole document, which is true of
40
+ * the served page and false of `mountPollyStudio` (`src/browser.ts`), where the same
41
+ * `STUDIO_STYLE` is injected into somebody else's app. Splitting them is what lets
42
+ * `STUDIO_STYLE` below be entirely scoped: a host page keeps its own body background,
43
+ * type and box-sizing, and the editor still fills the viewport when it is the page.
44
+ */
45
+ export declare const PAGE_STYLE = "\nhtml { color-scheme: light; }\nhtml, body { height: 100%; }\nbody { margin: 0; background: #eaeef5; }\n";
46
+ /**
47
+ * Every selector is scoped under `.polly-studio`, the wrapper `STUDIO_MARKUP` opens with.
48
+ * `src/browser.ts` injects this stylesheet with `innerHTML` into an arbitrary element of
49
+ * a host application, where it is document-global regardless — the previous `:root`/`body`
50
+ * rules restyled the host app's own type and margins as a side effect of mounting an
51
+ * editor. Scoping is the only thing preventing that, so new rules need the prefix too.
52
+ */
53
+ export declare const STUDIO_STYLE = "\n.polly-studio, .polly-studio *, .polly-studio *::before, .polly-studio *::after { box-sizing: border-box; }\n.polly-studio {\n --ps-paper: #eaeef5;\n --ps-surface: #ffffff;\n --ps-sunken: #f1f5fb;\n --ps-ink: #111722;\n --ps-ink-soft: #4d5867;\n --ps-ink-faint: #838e9d;\n --ps-line: #dce3ed;\n --ps-line-strong: #c3ccda;\n --ps-accent: #3d2fbd;\n --ps-accent-deep: #2e2298;\n --ps-accent-line: #c8c2f0;\n --ps-accent-soft: #eeecfb;\n --ps-ok: #0a6b3d; --ps-ok-line: #b6ddc6; --ps-ok-soft: #e8f4ed;\n --ps-warn: #875700; --ps-warn-line: #ecd3a3; --ps-warn-soft: #fbf2e1;\n --ps-bad: #b3261e; --ps-bad-line: #edbdb9; --ps-bad-soft: #fceceb;\n --ps-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n\n color-scheme: light;\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 15px;\n line-height: 1.5;\n color: var(--ps-ink);\n background: var(--ps-paper);\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 34rem;\n}\n\n/* Title bar ------------------------------------------------------------ */\n.polly-studio .ps-bar {\n display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.75rem;\n padding: .8rem 1.25rem;\n background: var(--ps-surface);\n border-bottom: 1px solid var(--ps-line);\n}\n.polly-studio .ps-brand { display: flex; align-items: baseline; flex-wrap: wrap; gap: .1rem .75rem; }\n.polly-studio h1 { margin: 0; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.015em; }\n.polly-studio .ps-tagline { margin: 0; font-size: .8125rem; color: var(--ps-ink-soft); }\n.polly-studio .banner {\n flex: 1 1 26rem; min-width: 16rem; margin: 0;\n padding: .45rem .7rem;\n font-size: .78rem; line-height: 1.45;\n background: var(--ps-warn-soft);\n border: 1px solid var(--ps-warn-line); border-left-width: 3px;\n border-radius: 0 7px 7px 0;\n}\n.polly-studio code {\n font-family: var(--ps-mono); font-size: .92em;\n background: rgba(17,23,34,.05); border-radius: 4px; padding: 0 .22em;\n}\n\n/* Panes ---------------------------------------------------------------- */\n.polly-studio .ps-panes {\n flex: 1; min-height: 0;\n display: grid; gap: .9rem;\n grid-template-columns: 17rem 24rem minmax(0, 1fr);\n padding: .9rem 1.25rem 1.25rem;\n}\n.polly-studio .ps-pane {\n display: flex; flex-direction: column; min-height: 0;\n border: 1px solid var(--ps-line); border-radius: 12px;\n background: var(--ps-surface);\n overflow: hidden;\n}\n/* The two browsers read as one surface you pick from; the editor reads as the page you\n work on. Same border, different floor \u2014 not three identical cards. */\n.polly-studio .ps-pane--list { background: var(--ps-sunken); }\n.polly-studio .ps-pane-title {\n margin: 0; padding: .5rem .75rem;\n font-size: .8125rem; font-weight: 600; color: var(--ps-ink-soft);\n background: var(--ps-surface);\n border-bottom: 1px solid var(--ps-line);\n}\n.polly-studio .ps-pane--editor .ps-pane-title { box-shadow: inset 3px 0 0 var(--ps-accent); }\n.polly-studio .ps-scroll { flex: 1; min-height: 0; overflow: auto; padding: .5rem; }\n.polly-studio .ps-pane--editor .ps-scroll { padding: .9rem 1rem 1.25rem; }\n\n/* Recording list ------------------------------------------------------- */\n.polly-studio ul { list-style: none; margin: 0; padding: 0; }\n.polly-studio .recordings, .polly-studio #entries ul { display: grid; gap: .3rem; }\n/* Each row is its own bordered card rather than a hover-only highlight: a recorded URL\n wraps to three or four lines often enough that without one, two rows read as one. */\n.polly-studio button.recording, .polly-studio button.entry {\n width: 100%; text-align: left; font: inherit;\n color: var(--ps-ink); background: var(--ps-surface);\n border: 1px solid var(--ps-line); border-radius: 8px;\n padding: .4rem .5rem; cursor: pointer;\n transition: background-color .12s ease, border-color .12s ease;\n}\n.polly-studio button.recording:hover, .polly-studio button.entry:hover {\n border-color: var(--ps-line-strong);\n}\n/* :focus, not :focus-visible \u2014 a click selects, and the selection has to be visible\n whether it came from the mouse or the keyboard. app.ts keeps no selected state. */\n.polly-studio button.recording:focus, .polly-studio button.entry:focus {\n outline: none; background: var(--ps-accent-soft); border-color: var(--ps-accent-line);\n}\n.polly-studio button.recording:focus-visible, .polly-studio button.entry:focus-visible {\n outline: 2px solid var(--ps-accent); outline-offset: 1px;\n}\n.polly-studio button.recording { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem; }\n.polly-studio .ps-rec-name { font-family: var(--ps-mono); font-size: .8125rem; overflow-wrap: anywhere; }\n.polly-studio .count {\n font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-soft);\n background: var(--ps-paper); border: 1px solid var(--ps-line);\n border-radius: 999px; padding: 0 .42rem;\n}\n.polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }\n\n/* Request list --------------------------------------------------------- */\n.polly-studio button.entry {\n display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;\n align-items: baseline; gap: .1rem .5rem;\n}\n.polly-studio .method {\n font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;\n letter-spacing: .02em; color: var(--ps-ink-soft); min-width: 2.6rem;\n}\n.polly-studio .status {\n font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;\n border: 1px solid; border-radius: 5px; padding: 0 .3rem;\n}\n.polly-studio .status.s2 { color: var(--ps-ok); background: var(--ps-ok-soft); border-color: var(--ps-ok-line); }\n.polly-studio .status.s3 { color: var(--ps-warn); background: var(--ps-warn-soft); border-color: var(--ps-warn-line); }\n.polly-studio .status.s4, .polly-studio .status.s5 { color: var(--ps-bad); background: var(--ps-bad-soft); border-color: var(--ps-bad-line); }\n.polly-studio .url { font-family: var(--ps-mono); font-size: .78rem; overflow-wrap: anywhere; }\n.polly-studio .order { font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-faint); }\n\n/* Detail --------------------------------------------------------------- */\n.polly-studio .detail { display: grid; gap: .85rem; }\n.polly-studio .ps-block {\n border: 1px solid var(--ps-line); border-radius: 10px;\n padding: .85rem .95rem 1rem;\n}\n.polly-studio .ps-block--request { background: var(--ps-sunken); }\n.polly-studio .detail h2 {\n margin: 0 0 .45rem; font-size: .9375rem; font-weight: 650; letter-spacing: -.01em;\n display: flex; align-items: baseline; gap: .5rem;\n}\n.polly-studio .detail h2 small {\n font-size: .6875rem; font-weight: 500; color: var(--ps-ink-soft);\n background: var(--ps-paper); border: 1px solid var(--ps-line);\n border-radius: 999px; padding: .05rem .45rem;\n}\n.polly-studio .note { margin: 0 0 .75rem; font-size: .78rem; line-height: 1.5; color: var(--ps-ink-soft); max-width: 68ch; }\n.polly-studio dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .3rem .9rem; margin: 0; font-size: .8125rem; }\n.polly-studio dt { font-weight: 600; color: var(--ps-ink-soft); }\n.polly-studio dd { margin: 0; font-family: var(--ps-mono); overflow-wrap: anywhere; }\n.polly-studio .ps-group-title {\n margin: 1rem 0 .4rem; font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);\n}\n\n/* Fields --------------------------------------------------------------- */\n.polly-studio .ps-fields { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: .55rem; }\n.polly-studio label.ps-field { display: grid; gap: .25rem; margin: 0; }\n.polly-studio .ps-field-label { font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft); }\n.polly-studio .ps-input, .polly-studio textarea {\n width: 100%; min-width: 0;\n font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);\n background: var(--ps-surface);\n border: 1px solid var(--ps-line-strong); border-radius: 8px;\n padding: .4rem .55rem;\n transition: border-color .12s ease, box-shadow .12s ease;\n}\n.polly-studio .ps-input:hover, .polly-studio textarea:hover, .polly-studio .header-row:hover { border-color: #a9b5c6; }\n.polly-studio .ps-input:focus, .polly-studio textarea:focus {\n outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);\n}\n.polly-studio textarea { display: block; line-height: 1.55; resize: vertical; min-height: 11rem; tab-size: 2; }\n\n/* A header is one name:value pair, so it is one control split by a hairline, not two\n inputs that happen to sit side by side. */\n.polly-studio .headers { display: grid; gap: .35rem; }\n.polly-studio .header-row {\n display: grid; grid-template-columns: minmax(6rem, 13rem) minmax(0, 1fr);\n background: var(--ps-surface);\n border: 1px solid var(--ps-line-strong); border-radius: 8px;\n overflow: hidden;\n transition: border-color .12s ease, box-shadow .12s ease;\n}\n.polly-studio .header-row:focus-within { border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft); }\n.polly-studio .header-row input {\n width: 100%; min-width: 0;\n font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);\n background: transparent; border: 0; padding: .38rem .55rem;\n}\n.polly-studio .header-row input:focus { outline: none; }\n.polly-studio .header-row input[name^=\"headerName\"] {\n color: var(--ps-accent-deep); font-weight: 600;\n background: var(--ps-sunken); border-right: 1px solid var(--ps-line);\n}\n\n/* Actions -------------------------------------------------------------- */\n.polly-studio .actions {\n display: flex; flex-wrap: wrap; gap: .5rem;\n margin-top: 1rem; padding-top: .9rem;\n border-top: 1px solid var(--ps-line);\n}\n.polly-studio .actions button {\n font: inherit; font-size: .8125rem; font-weight: 600;\n padding: .45rem .95rem; border-radius: 8px; cursor: pointer;\n border: 1px solid transparent;\n transition: background-color .12s ease, border-color .12s ease;\n}\n.polly-studio .actions button[name=\"save\"] { background: var(--ps-accent); color: #fff; }\n.polly-studio .actions button[name=\"save\"]:hover { background: var(--ps-accent-deep); }\n.polly-studio .actions button.danger { background: var(--ps-surface); color: var(--ps-bad); border-color: var(--ps-bad-line); }\n.polly-studio .actions button.danger:hover { background: var(--ps-bad-soft); }\n.polly-studio .actions button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }\n\n/* Notices and empty states --------------------------------------------- */\n.polly-studio .warn { color: var(--ps-warn); }\n.polly-studio .problem { color: var(--ps-bad); }\n.polly-studio .ps-notice {\n margin: 0; padding: .55rem .7rem; font-size: .8125rem; line-height: 1.5;\n background: var(--ps-warn-soft); border: 1px solid var(--ps-warn-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n/* report() prepends a bare <p class=\"problem\"> here; it is the only alert the page has. */\n.polly-studio #detail > .problem {\n margin: 0 0 .85rem; padding: .55rem .7rem; font-size: .8125rem;\n background: var(--ps-bad-soft); border: 1px solid var(--ps-bad-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n.polly-studio section.problem { margin: 0; }\n.polly-studio li.problem { padding: .4rem .5rem; font-size: .8125rem; }\n.polly-studio .empty {\n margin: 0; padding: 1.5rem .75rem; text-align: center;\n font-size: .8125rem; line-height: 1.55; color: var(--ps-ink-faint);\n}\n/* The same class inside the editor is a field standing in for a control, not a pane with\n nothing in it \u2014 centring it there would read as a broken row between two left-aligned\n groups. */\n.polly-studio .ps-block .empty { padding: .3rem 0 .1rem; text-align: left; }\n/* The two panes app.ts blanks rather than filling with a placeholder. ::before keeps the\n hint out of the markup, so nothing here has to be re-added after every innerHTML wipe. */\n.polly-studio #entries:empty::before, .polly-studio #detail:empty::before {\n display: block; padding: 1.75rem .75rem; text-align: center;\n font-size: .8125rem; color: var(--ps-ink-faint);\n}\n.polly-studio #entries:empty::before { content: \"Pick a recording to see the requests it captured.\"; }\n.polly-studio #detail:empty::before { content: \"Pick a request to edit the response it replays.\"; }\n\n@media (max-width: 72rem) {\n .polly-studio { height: auto; }\n .polly-studio .ps-panes { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .ps-pane--list .ps-scroll { max-height: 18rem; }\n}\n@media (max-width: 34rem) {\n .polly-studio .ps-fields { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .header-row { grid-template-columns: minmax(0, 1fr); }\n .polly-studio .header-row input[name^=\"headerName\"] { border-right: 0; border-bottom: 1px solid var(--ps-line); }\n}\n@media (prefers-reduced-motion: reduce) {\n .polly-studio * { transition: none !important; }\n}\n";
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The body, shared with the browser-only mount in Task 12 so the two cannot drift into
3
+ * two different editors.
4
+ */
5
+ export const STUDIO_MARKUP = `
6
+ <div class="polly-studio">
7
+ <header class="ps-bar">
8
+ <div class="ps-brand">
9
+ <h1>polly-studio</h1>
10
+ <p class="ps-tagline">Change what a recorded request replies with, then save it back to the HAR.</p>
11
+ </div>
12
+ <p class="warn banner">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:
13
+ the next record run deletes every entry it did not exercise, including the ones you edit here.
14
+ Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>
15
+ </header>
16
+ <main class="ps-panes">
17
+ <section class="ps-pane ps-pane--list">
18
+ <h2 class="ps-pane-title">Recordings</h2>
19
+ <nav id="recordings" class="ps-scroll">Loading…</nav>
20
+ </section>
21
+ <section class="ps-pane ps-pane--list">
22
+ <h2 class="ps-pane-title">Requests</h2>
23
+ <div id="entries" class="ps-scroll"></div>
24
+ </section>
25
+ <section class="ps-pane ps-pane--editor">
26
+ <h2 class="ps-pane-title">Recorded exchange</h2>
27
+ <div id="detail" class="ps-scroll"></div>
28
+ </section>
29
+ </main>
30
+ </div>`;
31
+ /**
32
+ * The HTML shell.
33
+ *
34
+ * `clientScript` is the bundled `client.ts`, inlined rather than linked. The handler has
35
+ * no way to know what URL a browser would have to request for it: mounted with
36
+ * `app.use('/__polly', ...)`, Express strips the prefix before the handler sees the
37
+ * request, so nothing on the server side still knows the page is at `/__polly`.
38
+ *
39
+ * `location.pathname` does know, and it is evaluated in the browser, so the API base is
40
+ * derived there. The same expression is correct when the handler is mounted at the root,
41
+ * behind a prefix, or reached with a trailing slash.
42
+ *
43
+ * `clientScript` is split on a literal `</script`, not because a recording can reach
44
+ * this string — it can't; this is build-time bundle text, not runtime HAR data — but
45
+ * because Task 12's browser mount imports `STUDIO_MARKUP`/`STUDIO_STYLE` from this same
46
+ * file, which means `page.ts` itself can end up bundled into the very `clientScript`
47
+ * this function inlines. HTML's script-end-tag detection is lexical, not JS-aware, so an
48
+ * un-split `</script` anywhere in that text — in code, a string, or a comment — would
49
+ * truncate the tag regardless of a bundler's own escaping. The replacement is lossless:
50
+ * `<\/script` is identical JavaScript to `</script`, just not a tag boundary to the HTML
51
+ * parser.
52
+ *
53
+ * The trailer's regex is written `/\\/+$/` — doubled backslash — and must stay that way.
54
+ * It sits inside a template literal, so the literal consumes one backslash and the page
55
+ * receives `/\/+$/`, the escaped forward slash the browser needs. Written the way it
56
+ * reads as code, `/\/+$/`, the emitted text is `//+$/`: `//` opens a line comment, the
57
+ * closing paren is swallowed, and the browser reports `SyntaxError: Unexpected end of
58
+ * input` before anything runs. That is the bug this doubling fixes, and it was
59
+ * introduced by checking the expression as code rather than as template text. The
60
+ * `vm.Script` test in `test/render.test.ts` pins the emitted form.
61
+ */
62
+ export function renderPage(clientScript) {
63
+ const safeScript = clientScript.replaceAll('</script', '<\\/script');
64
+ return `<!doctype html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="utf-8">
68
+ <meta name="viewport" content="width=device-width, initial-scale=1">
69
+ <title>polly-studio</title>
70
+ <style>${PAGE_STYLE}${STUDIO_STYLE}</style>
71
+ </head>
72
+ <body>
73
+ ${STUDIO_MARKUP}
74
+ <script type="module">
75
+ ${safeScript}
76
+ start(location.pathname.replace(/\\/+$/, ''))
77
+ </script>
78
+ </body>
79
+ </html>`;
80
+ }
81
+ /**
82
+ * Rules that only make sense when this package owns the whole document, which is true of
83
+ * the served page and false of `mountPollyStudio` (`src/browser.ts`), where the same
84
+ * `STUDIO_STYLE` is injected into somebody else's app. Splitting them is what lets
85
+ * `STUDIO_STYLE` below be entirely scoped: a host page keeps its own body background,
86
+ * type and box-sizing, and the editor still fills the viewport when it is the page.
87
+ */
88
+ export const PAGE_STYLE = `
89
+ html { color-scheme: light; }
90
+ html, body { height: 100%; }
91
+ body { margin: 0; background: #eaeef5; }
92
+ `;
93
+ /**
94
+ * Every selector is scoped under `.polly-studio`, the wrapper `STUDIO_MARKUP` opens with.
95
+ * `src/browser.ts` injects this stylesheet with `innerHTML` into an arbitrary element of
96
+ * a host application, where it is document-global regardless — the previous `:root`/`body`
97
+ * rules restyled the host app's own type and margins as a side effect of mounting an
98
+ * editor. Scoping is the only thing preventing that, so new rules need the prefix too.
99
+ */
100
+ export const STUDIO_STYLE = `
101
+ .polly-studio, .polly-studio *, .polly-studio *::before, .polly-studio *::after { box-sizing: border-box; }
102
+ .polly-studio {
103
+ --ps-paper: #eaeef5;
104
+ --ps-surface: #ffffff;
105
+ --ps-sunken: #f1f5fb;
106
+ --ps-ink: #111722;
107
+ --ps-ink-soft: #4d5867;
108
+ --ps-ink-faint: #838e9d;
109
+ --ps-line: #dce3ed;
110
+ --ps-line-strong: #c3ccda;
111
+ --ps-accent: #3d2fbd;
112
+ --ps-accent-deep: #2e2298;
113
+ --ps-accent-line: #c8c2f0;
114
+ --ps-accent-soft: #eeecfb;
115
+ --ps-ok: #0a6b3d; --ps-ok-line: #b6ddc6; --ps-ok-soft: #e8f4ed;
116
+ --ps-warn: #875700; --ps-warn-line: #ecd3a3; --ps-warn-soft: #fbf2e1;
117
+ --ps-bad: #b3261e; --ps-bad-line: #edbdb9; --ps-bad-soft: #fceceb;
118
+ --ps-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
119
+
120
+ color-scheme: light;
121
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
122
+ font-size: 15px;
123
+ line-height: 1.5;
124
+ color: var(--ps-ink);
125
+ background: var(--ps-paper);
126
+ display: flex;
127
+ flex-direction: column;
128
+ height: 100%;
129
+ min-height: 34rem;
130
+ }
131
+
132
+ /* Title bar ------------------------------------------------------------ */
133
+ .polly-studio .ps-bar {
134
+ display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.75rem;
135
+ padding: .8rem 1.25rem;
136
+ background: var(--ps-surface);
137
+ border-bottom: 1px solid var(--ps-line);
138
+ }
139
+ .polly-studio .ps-brand { display: flex; align-items: baseline; flex-wrap: wrap; gap: .1rem .75rem; }
140
+ .polly-studio h1 { margin: 0; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.015em; }
141
+ .polly-studio .ps-tagline { margin: 0; font-size: .8125rem; color: var(--ps-ink-soft); }
142
+ .polly-studio .banner {
143
+ flex: 1 1 26rem; min-width: 16rem; margin: 0;
144
+ padding: .45rem .7rem;
145
+ font-size: .78rem; line-height: 1.45;
146
+ background: var(--ps-warn-soft);
147
+ border: 1px solid var(--ps-warn-line); border-left-width: 3px;
148
+ border-radius: 0 7px 7px 0;
149
+ }
150
+ .polly-studio code {
151
+ font-family: var(--ps-mono); font-size: .92em;
152
+ background: rgba(17,23,34,.05); border-radius: 4px; padding: 0 .22em;
153
+ }
154
+
155
+ /* Panes ---------------------------------------------------------------- */
156
+ .polly-studio .ps-panes {
157
+ flex: 1; min-height: 0;
158
+ display: grid; gap: .9rem;
159
+ grid-template-columns: 17rem 24rem minmax(0, 1fr);
160
+ padding: .9rem 1.25rem 1.25rem;
161
+ }
162
+ .polly-studio .ps-pane {
163
+ display: flex; flex-direction: column; min-height: 0;
164
+ border: 1px solid var(--ps-line); border-radius: 12px;
165
+ background: var(--ps-surface);
166
+ overflow: hidden;
167
+ }
168
+ /* The two browsers read as one surface you pick from; the editor reads as the page you
169
+ work on. Same border, different floor — not three identical cards. */
170
+ .polly-studio .ps-pane--list { background: var(--ps-sunken); }
171
+ .polly-studio .ps-pane-title {
172
+ margin: 0; padding: .5rem .75rem;
173
+ font-size: .8125rem; font-weight: 600; color: var(--ps-ink-soft);
174
+ background: var(--ps-surface);
175
+ border-bottom: 1px solid var(--ps-line);
176
+ }
177
+ .polly-studio .ps-pane--editor .ps-pane-title { box-shadow: inset 3px 0 0 var(--ps-accent); }
178
+ .polly-studio .ps-scroll { flex: 1; min-height: 0; overflow: auto; padding: .5rem; }
179
+ .polly-studio .ps-pane--editor .ps-scroll { padding: .9rem 1rem 1.25rem; }
180
+
181
+ /* Recording list ------------------------------------------------------- */
182
+ .polly-studio ul { list-style: none; margin: 0; padding: 0; }
183
+ .polly-studio .recordings, .polly-studio #entries ul { display: grid; gap: .3rem; }
184
+ /* Each row is its own bordered card rather than a hover-only highlight: a recorded URL
185
+ wraps to three or four lines often enough that without one, two rows read as one. */
186
+ .polly-studio button.recording, .polly-studio button.entry {
187
+ width: 100%; text-align: left; font: inherit;
188
+ color: var(--ps-ink); background: var(--ps-surface);
189
+ border: 1px solid var(--ps-line); border-radius: 8px;
190
+ padding: .4rem .5rem; cursor: pointer;
191
+ transition: background-color .12s ease, border-color .12s ease;
192
+ }
193
+ .polly-studio button.recording:hover, .polly-studio button.entry:hover {
194
+ border-color: var(--ps-line-strong);
195
+ }
196
+ /* :focus, not :focus-visible — a click selects, and the selection has to be visible
197
+ whether it came from the mouse or the keyboard. app.ts keeps no selected state. */
198
+ .polly-studio button.recording:focus, .polly-studio button.entry:focus {
199
+ outline: none; background: var(--ps-accent-soft); border-color: var(--ps-accent-line);
200
+ }
201
+ .polly-studio button.recording:focus-visible, .polly-studio button.entry:focus-visible {
202
+ outline: 2px solid var(--ps-accent); outline-offset: 1px;
203
+ }
204
+ .polly-studio button.recording { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .5rem; }
205
+ .polly-studio .ps-rec-name { font-family: var(--ps-mono); font-size: .8125rem; overflow-wrap: anywhere; }
206
+ .polly-studio .count {
207
+ font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-soft);
208
+ background: var(--ps-paper); border: 1px solid var(--ps-line);
209
+ border-radius: 999px; padding: 0 .42rem;
210
+ }
211
+ .polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }
212
+
213
+ /* Request list --------------------------------------------------------- */
214
+ .polly-studio button.entry {
215
+ display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;
216
+ align-items: baseline; gap: .1rem .5rem;
217
+ }
218
+ .polly-studio .method {
219
+ font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;
220
+ letter-spacing: .02em; color: var(--ps-ink-soft); min-width: 2.6rem;
221
+ }
222
+ .polly-studio .status {
223
+ font-family: var(--ps-mono); font-size: .6875rem; font-weight: 700;
224
+ border: 1px solid; border-radius: 5px; padding: 0 .3rem;
225
+ }
226
+ .polly-studio .status.s2 { color: var(--ps-ok); background: var(--ps-ok-soft); border-color: var(--ps-ok-line); }
227
+ .polly-studio .status.s3 { color: var(--ps-warn); background: var(--ps-warn-soft); border-color: var(--ps-warn-line); }
228
+ .polly-studio .status.s4, .polly-studio .status.s5 { color: var(--ps-bad); background: var(--ps-bad-soft); border-color: var(--ps-bad-line); }
229
+ .polly-studio .url { font-family: var(--ps-mono); font-size: .78rem; overflow-wrap: anywhere; }
230
+ .polly-studio .order { font-family: var(--ps-mono); font-size: .6875rem; color: var(--ps-ink-faint); }
231
+
232
+ /* Detail --------------------------------------------------------------- */
233
+ .polly-studio .detail { display: grid; gap: .85rem; }
234
+ .polly-studio .ps-block {
235
+ border: 1px solid var(--ps-line); border-radius: 10px;
236
+ padding: .85rem .95rem 1rem;
237
+ }
238
+ .polly-studio .ps-block--request { background: var(--ps-sunken); }
239
+ .polly-studio .detail h2 {
240
+ margin: 0 0 .45rem; font-size: .9375rem; font-weight: 650; letter-spacing: -.01em;
241
+ display: flex; align-items: baseline; gap: .5rem;
242
+ }
243
+ .polly-studio .detail h2 small {
244
+ font-size: .6875rem; font-weight: 500; color: var(--ps-ink-soft);
245
+ background: var(--ps-paper); border: 1px solid var(--ps-line);
246
+ border-radius: 999px; padding: .05rem .45rem;
247
+ }
248
+ .polly-studio .note { margin: 0 0 .75rem; font-size: .78rem; line-height: 1.5; color: var(--ps-ink-soft); max-width: 68ch; }
249
+ .polly-studio dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .3rem .9rem; margin: 0; font-size: .8125rem; }
250
+ .polly-studio dt { font-weight: 600; color: var(--ps-ink-soft); }
251
+ .polly-studio dd { margin: 0; font-family: var(--ps-mono); overflow-wrap: anywhere; }
252
+ .polly-studio .ps-group-title {
253
+ margin: 1rem 0 .4rem; font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);
254
+ }
255
+
256
+ /* Fields --------------------------------------------------------------- */
257
+ .polly-studio .ps-fields { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: .55rem; }
258
+ .polly-studio label.ps-field { display: grid; gap: .25rem; margin: 0; }
259
+ .polly-studio .ps-field-label { font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft); }
260
+ .polly-studio .ps-input, .polly-studio textarea {
261
+ width: 100%; min-width: 0;
262
+ font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);
263
+ background: var(--ps-surface);
264
+ border: 1px solid var(--ps-line-strong); border-radius: 8px;
265
+ padding: .4rem .55rem;
266
+ transition: border-color .12s ease, box-shadow .12s ease;
267
+ }
268
+ .polly-studio .ps-input:hover, .polly-studio textarea:hover, .polly-studio .header-row:hover { border-color: #a9b5c6; }
269
+ .polly-studio .ps-input:focus, .polly-studio textarea:focus {
270
+ outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);
271
+ }
272
+ .polly-studio textarea { display: block; line-height: 1.55; resize: vertical; min-height: 11rem; tab-size: 2; }
273
+
274
+ /* A header is one name:value pair, so it is one control split by a hairline, not two
275
+ inputs that happen to sit side by side. */
276
+ .polly-studio .headers { display: grid; gap: .35rem; }
277
+ .polly-studio .header-row {
278
+ display: grid; grid-template-columns: minmax(6rem, 13rem) minmax(0, 1fr);
279
+ background: var(--ps-surface);
280
+ border: 1px solid var(--ps-line-strong); border-radius: 8px;
281
+ overflow: hidden;
282
+ transition: border-color .12s ease, box-shadow .12s ease;
283
+ }
284
+ .polly-studio .header-row:focus-within { border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft); }
285
+ .polly-studio .header-row input {
286
+ width: 100%; min-width: 0;
287
+ font-family: var(--ps-mono); font-size: .8125rem; color: var(--ps-ink);
288
+ background: transparent; border: 0; padding: .38rem .55rem;
289
+ }
290
+ .polly-studio .header-row input:focus { outline: none; }
291
+ .polly-studio .header-row input[name^="headerName"] {
292
+ color: var(--ps-accent-deep); font-weight: 600;
293
+ background: var(--ps-sunken); border-right: 1px solid var(--ps-line);
294
+ }
295
+
296
+ /* Actions -------------------------------------------------------------- */
297
+ .polly-studio .actions {
298
+ display: flex; flex-wrap: wrap; gap: .5rem;
299
+ margin-top: 1rem; padding-top: .9rem;
300
+ border-top: 1px solid var(--ps-line);
301
+ }
302
+ .polly-studio .actions button {
303
+ font: inherit; font-size: .8125rem; font-weight: 600;
304
+ padding: .45rem .95rem; border-radius: 8px; cursor: pointer;
305
+ border: 1px solid transparent;
306
+ transition: background-color .12s ease, border-color .12s ease;
307
+ }
308
+ .polly-studio .actions button[name="save"] { background: var(--ps-accent); color: #fff; }
309
+ .polly-studio .actions button[name="save"]:hover { background: var(--ps-accent-deep); }
310
+ .polly-studio .actions button.danger { background: var(--ps-surface); color: var(--ps-bad); border-color: var(--ps-bad-line); }
311
+ .polly-studio .actions button.danger:hover { background: var(--ps-bad-soft); }
312
+ .polly-studio .actions button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
313
+
314
+ /* Notices and empty states --------------------------------------------- */
315
+ .polly-studio .warn { color: var(--ps-warn); }
316
+ .polly-studio .problem { color: var(--ps-bad); }
317
+ .polly-studio .ps-notice {
318
+ margin: 0; padding: .55rem .7rem; font-size: .8125rem; line-height: 1.5;
319
+ background: var(--ps-warn-soft); border: 1px solid var(--ps-warn-line);
320
+ border-left-width: 3px; border-radius: 0 8px 8px 0;
321
+ }
322
+ /* report() prepends a bare <p class="problem"> here; it is the only alert the page has. */
323
+ .polly-studio #detail > .problem {
324
+ margin: 0 0 .85rem; padding: .55rem .7rem; font-size: .8125rem;
325
+ background: var(--ps-bad-soft); border: 1px solid var(--ps-bad-line);
326
+ border-left-width: 3px; border-radius: 0 8px 8px 0;
327
+ }
328
+ .polly-studio section.problem { margin: 0; }
329
+ .polly-studio li.problem { padding: .4rem .5rem; font-size: .8125rem; }
330
+ .polly-studio .empty {
331
+ margin: 0; padding: 1.5rem .75rem; text-align: center;
332
+ font-size: .8125rem; line-height: 1.55; color: var(--ps-ink-faint);
333
+ }
334
+ /* The same class inside the editor is a field standing in for a control, not a pane with
335
+ nothing in it — centring it there would read as a broken row between two left-aligned
336
+ groups. */
337
+ .polly-studio .ps-block .empty { padding: .3rem 0 .1rem; text-align: left; }
338
+ /* The two panes app.ts blanks rather than filling with a placeholder. ::before keeps the
339
+ hint out of the markup, so nothing here has to be re-added after every innerHTML wipe. */
340
+ .polly-studio #entries:empty::before, .polly-studio #detail:empty::before {
341
+ display: block; padding: 1.75rem .75rem; text-align: center;
342
+ font-size: .8125rem; color: var(--ps-ink-faint);
343
+ }
344
+ .polly-studio #entries:empty::before { content: "Pick a recording to see the requests it captured."; }
345
+ .polly-studio #detail:empty::before { content: "Pick a request to edit the response it replays."; }
346
+
347
+ @media (max-width: 72rem) {
348
+ .polly-studio { height: auto; }
349
+ .polly-studio .ps-panes { grid-template-columns: minmax(0, 1fr); }
350
+ .polly-studio .ps-pane--list .ps-scroll { max-height: 18rem; }
351
+ }
352
+ @media (max-width: 34rem) {
353
+ .polly-studio .ps-fields { grid-template-columns: minmax(0, 1fr); }
354
+ .polly-studio .header-row { grid-template-columns: minmax(0, 1fr); }
355
+ .polly-studio .header-row input[name^="headerName"] { border-right: 0; border-bottom: 1px solid var(--ps-line); }
356
+ }
357
+ @media (prefers-reduced-motion: reduce) {
358
+ .polly-studio * { transition: none !important; }
359
+ }
360
+ `;
@@ -0,0 +1,25 @@
1
+ import type { RecordingSummary } from '../stores/types.js';
2
+ import type { HarEntry } from '../types.js';
3
+ /**
4
+ * Rendering is string-in, string-out and lives apart from the DOM so it can be tested
5
+ * without a browser. Recorded bodies are attacker-shaped by nature — they came off the
6
+ * network — so everything interpolated goes through `escapeHtml`.
7
+ *
8
+ * The parameter is not `string`, and the body opens with `String(value)`. `parseHar`'s
9
+ * `isHarShaped` (`src/har.ts`) is a shape check, not a schema validator: it confirms
10
+ * `log.entries` is an array and casts the rest of the parsed JSON to `Har`, so a field
11
+ * this module's types call `string` can be a number, `null` or `undefined` on disk — and
12
+ * `.replaceAll` does not exist on any of those. Design spec §7 requires the UI to open
13
+ * exactly the recordings Polly would reject, so that is an expected input.
14
+ *
15
+ * It is a union of the JSON primitives rather than `unknown`, which is the narrowest
16
+ * type that accepts everything a malformed HAR can put in a text field while still
17
+ * failing the build on `escapeHtml(someObject)` — a call that is always a mistake, and
18
+ * that `unknown` would have accepted silently as `[object Object]`. Callers holding an
19
+ * `unknown` say `String(value)` themselves, which makes the coercion visible at the site
20
+ * that chose it.
21
+ */
22
+ export declare function escapeHtml(value: string | number | boolean | null | undefined): string;
23
+ export declare function renderRecordingList(recordings: RecordingSummary[]): string;
24
+ export declare function renderEntryRow(entry: HarEntry): string;
25
+ export declare function renderEntryDetail(entry: HarEntry): string;