@avivbenhamo/polly-kit 0.6.0 → 0.8.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.
@@ -1,4 +1,7 @@
1
- import { applyResponsePatch, deleteEntry as deleteEntryIn } from '../edit.js';
1
+ import { applyResponsePatch, deleteEntry as deleteEntryIn, duplicateEntry as duplicateEntryIn, } from '../edit.js';
2
+ import { POLLY_CREATOR_NAME } from '../har.js';
3
+ import { mergeEntries, rejectImport } from '../import.js';
4
+ import { guidForRecording, rejectRecordingName } from '../recording-id.js';
2
5
  import { StoreError } from './types.js';
3
6
  export function patchEntryVia(store, id, entryId, order, etag, patch) {
4
7
  return applyEntryEdit(store, id, etag, (har) => applyResponsePatch(har, entryId, order, patch));
@@ -6,6 +9,9 @@ export function patchEntryVia(store, id, entryId, order, etag, patch) {
6
9
  export function deleteEntryVia(store, id, entryId, order, etag) {
7
10
  return applyEntryEdit(store, id, etag, (har) => deleteEntryIn(har, entryId, order));
8
11
  }
12
+ export function duplicateEntryVia(store, id, entryId, order, etag) {
13
+ return applyEntryEdit(store, id, etag, (har) => duplicateEntryIn(har, entryId, order));
14
+ }
9
15
  async function applyEntryEdit(store, id, etag, apply) {
10
16
  const read = await store.read(id);
11
17
  if (read.etag !== etag) {
@@ -18,3 +24,64 @@ async function applyEntryEdit(store, id, etag, apply) {
18
24
  await store.write(id, result.har, read.etag);
19
25
  return store.read(id);
20
26
  }
27
+ /**
28
+ * The read-merge-write cycle, shared by every store that owns its own storage.
29
+ *
30
+ * A corrupt existing file is refused rather than overwritten. `read` reports
31
+ * unparseable JSON as `not-found`, which is indistinguishable from "absent" at
32
+ * this layer, so absence is established by `list` instead — the one call that
33
+ * tells a recording that cannot be parsed apart from one that was never written.
34
+ */
35
+ export async function importHarVia(store, recordingName, incoming) {
36
+ const badName = rejectRecordingName(recordingName);
37
+ if (badName !== null)
38
+ throw new StoreError('unprocessable', badName);
39
+ // Trimmed once, here, rather than left for `guidForRecording` to trim on its own:
40
+ // without this, a padded name would hash to the correct (trimmed) directory but
41
+ // `newRecording` below would still write the untrimmed name into `_recordingName`
42
+ // — a file that disagrees with its own location, and one that would then round-trip
43
+ // the padding straight back into the import form's prefill (`openRecording`, I-2b).
44
+ const name = recordingName.trim();
45
+ const rejection = rejectImport(incoming);
46
+ if (rejection !== null)
47
+ throw new StoreError('unprocessable', rejection);
48
+ const id = guidForRecording(name);
49
+ const summary = (await store.list()).find((candidate) => candidate.id === id);
50
+ if (summary !== undefined && summary.problem !== null) {
51
+ throw new StoreError('unprocessable', `recording ${id} is on disk but unreadable (${summary.problem}) — repair or delete it before importing, rather than having this overwrite it`);
52
+ }
53
+ const existing = summary === undefined ? null : await store.read(id);
54
+ const outcome = mergeEntries(existing?.har.log.entries ?? [], incoming.log.entries);
55
+ if (existing === null) {
56
+ await store.create(id, newRecording(name, outcome.entries));
57
+ }
58
+ else {
59
+ await store.write(id, { ...existing.har, log: { ...existing.har.log, entries: outcome.entries } }, existing.etag);
60
+ }
61
+ const after = await store.read(id);
62
+ return {
63
+ ...after,
64
+ id,
65
+ added: outcome.added,
66
+ replaced: outcome.replaced,
67
+ skipped: outcome.skipped,
68
+ created: existing === null,
69
+ };
70
+ }
71
+ /**
72
+ * The log shell real Polly writes, minus `browser` and `pages`: Polly emits them,
73
+ * nothing reads them, and swagger-recorder's Polly flavour already proves a
74
+ * recording without them replays.
75
+ */
76
+ function newRecording(recordingName, entries) {
77
+ return {
78
+ log: {
79
+ _recordingName: recordingName,
80
+ version: '1.2',
81
+ // Asserted on by `Persister.findRecording`. Anything else and Polly asks
82
+ // the user to delete the file.
83
+ creator: { name: POLLY_CREATOR_NAME, version: '6.0.6', comment: 'persister:rest' },
84
+ entries,
85
+ },
86
+ };
87
+ }
@@ -1,7 +1,8 @@
1
- import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
1
+ import type { RecordingStore, RecordingSummary, ReadResult, ImportResult } from './types.js';
2
2
  import type { Har, ResponsePatch } from '../types.js';
3
3
  export { StoreError } from './types.js';
4
4
  export type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
5
+ export type { ImportResult } from './types.js';
5
6
  /**
6
7
  * Polly's node-server writes with `fs.outputJsonSync(path, data, { spaces: 2 })`, which
7
8
  * routes through `jsonfile`'s `stringify` — `JSON.stringify(obj, null, 2)` plus a final
@@ -48,7 +49,16 @@ export declare class FsStore implements RecordingStore {
48
49
  */
49
50
  delete(id: string): Promise<void>;
50
51
  patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
52
+ duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
51
53
  deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
54
+ /**
55
+ * `write` is read-compare-write and refuses a file that is not there, which is
56
+ * right for an edit and wrong for a creation. `wx` is what makes this a creation
57
+ * rather than a truncation: if the file appeared since `importHarVia` looked, the
58
+ * open fails instead of discarding whatever landed.
59
+ */
60
+ create(id: string, har: Har): Promise<void>;
61
+ importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
52
62
  /**
53
63
  * Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
54
64
  * with no sanitisation, and here the id arrives off an HTTP request. Resolving and
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { parseHar } from '../har.js';
5
5
  import { etagFor } from '../etag.js';
6
6
  import { StoreError } from './types.js';
7
- import { deleteEntryVia, patchEntryVia } from './apply.js';
7
+ import { deleteEntryVia, duplicateEntryVia, importHarVia, patchEntryVia } from './apply.js';
8
8
  export { StoreError } from './types.js';
9
9
  const FILENAME = 'recording.har';
10
10
  /**
@@ -151,9 +151,34 @@ export class FsStore {
151
151
  patchEntry(id, entryId, order, etag, patch) {
152
152
  return patchEntryVia(this, id, entryId, order, etag, patch);
153
153
  }
154
+ duplicateEntry(id, entryId, order, etag) {
155
+ return duplicateEntryVia(this, id, entryId, order, etag);
156
+ }
154
157
  deleteEntry(id, entryId, order, etag) {
155
158
  return deleteEntryVia(this, id, entryId, order, etag);
156
159
  }
160
+ /**
161
+ * `write` is read-compare-write and refuses a file that is not there, which is
162
+ * right for an edit and wrong for a creation. `wx` is what makes this a creation
163
+ * rather than a truncation: if the file appeared since `importHarVia` looked, the
164
+ * open fails instead of discarding whatever landed.
165
+ */
166
+ async create(id, har) {
167
+ const path = this.pathFor(id);
168
+ await mkdir(join(path, '..'), { recursive: true });
169
+ try {
170
+ await writeFile(path, serializeHar(har), { encoding: 'utf8', flag: 'wx' });
171
+ }
172
+ catch (err) {
173
+ if (err.code === 'EEXIST') {
174
+ throw new StoreError('conflict', `recording ${id} appeared while it was being created — reload and retry`);
175
+ }
176
+ throw err;
177
+ }
178
+ }
179
+ importHar(recordingName, incoming) {
180
+ return importHarVia(this, recordingName, incoming);
181
+ }
157
182
  /**
158
183
  * Polly's own node-server does `path.join(recordingsDir, recording, 'recording.har')`
159
184
  * with no sanitisation, and here the id arrives off an HTTP request. Resolving and
@@ -1,4 +1,4 @@
1
- import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
1
+ import type { RecordingStore, RecordingSummary, ReadResult, ImportResult } from './types.js';
2
2
  import type { Har, ResponsePatch } from '../types.js';
3
3
  /**
4
4
  * The store the page uses under the Node mount. It exists so `mountStudio` can be
@@ -25,6 +25,15 @@ export declare class HttpStore implements RecordingStore {
25
25
  write(id: string, har: Har, etag: string): Promise<void>;
26
26
  delete(id: string): Promise<void>;
27
27
  patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
28
+ /** POST to the entry being copied: this adds a member to its group. */
29
+ duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
28
30
  deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
31
+ /**
32
+ * Not on the wire protocol: creation happens inside `/api/import`, server-side,
33
+ * the same way `write` happens inside PATCH. Declared so a caller holding a
34
+ * concrete HttpStore still type-checks against the interface's arity.
35
+ */
36
+ create(id: string, har: Har): Promise<void>;
37
+ importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
29
38
  private json;
30
39
  }
@@ -36,9 +36,24 @@ export class HttpStore {
36
36
  async patchEntry(id, entryId, order, etag, patch) {
37
37
  return (await this.json('PATCH', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch }));
38
38
  }
39
+ /** POST to the entry being copied: this adds a member to its group. */
40
+ async duplicateEntry(id, entryId, order, etag) {
41
+ return (await this.json('POST', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`));
42
+ }
39
43
  async deleteEntry(id, entryId, order, etag) {
40
44
  return (await this.json('DELETE', `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`));
41
45
  }
46
+ /**
47
+ * Not on the wire protocol: creation happens inside `/api/import`, server-side,
48
+ * the same way `write` happens inside PATCH. Declared so a caller holding a
49
+ * concrete HttpStore still type-checks against the interface's arity.
50
+ */
51
+ async create(id, har) {
52
+ throw new StoreError('forbidden', 'the HTTP API creates recordings through import — use importHar');
53
+ }
54
+ async importHar(recordingName, incoming) {
55
+ return (await this.json('POST', '/api/import', { recordingName, har: incoming }));
56
+ }
42
57
  async json(method, path, body) {
43
58
  const res = await fetch(`${this.basePath}${path}`, {
44
59
  method,
@@ -1,4 +1,4 @@
1
- import type { RecordingStore, RecordingSummary, ReadResult } from './types.js';
1
+ import type { RecordingStore, RecordingSummary, ReadResult, ImportResult } from './types.js';
2
2
  import type { Har, ResponsePatch } from '../types.js';
3
3
  export interface LocalStorageStoreOptions {
4
4
  key?: string;
@@ -19,7 +19,10 @@ export declare class LocalStorageStore implements RecordingStore {
19
19
  write(id: string, har: Har, etag: string): Promise<void>;
20
20
  delete(id: string): Promise<void>;
21
21
  patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
22
+ duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
22
23
  deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
24
+ create(id: string, har: Har): Promise<void>;
25
+ importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
23
26
  /** `null` means the key holds something that is not JSON, which `list` reports. */
24
27
  private db;
25
28
  }
@@ -1,7 +1,7 @@
1
1
  import { parseHar } from '../har.js';
2
2
  import { etagFor } from '../etag.js';
3
3
  import { StoreError } from './types.js';
4
- import { deleteEntryVia, patchEntryVia } from './apply.js';
4
+ import { deleteEntryVia, duplicateEntryVia, importHarVia, patchEntryVia } from './apply.js';
5
5
  /** Polly's default namespace. `persisterOptions.local-storage.key` can change it. */
6
6
  const DEFAULT_KEY = 'pollyjs';
7
7
  /**
@@ -90,9 +90,24 @@ export class LocalStorageStore {
90
90
  patchEntry(id, entryId, order, etag, patch) {
91
91
  return patchEntryVia(this, id, entryId, order, etag, patch);
92
92
  }
93
+ duplicateEntry(id, entryId, order, etag) {
94
+ return duplicateEntryVia(this, id, entryId, order, etag);
95
+ }
93
96
  deleteEntry(id, entryId, order, etag) {
94
97
  return deleteEntryVia(this, id, entryId, order, etag);
95
98
  }
99
+ async create(id, har) {
100
+ const db = this.db();
101
+ if (db === null)
102
+ throw new StoreError('unprocessable', `localStorage[${this.key}] is not valid JSON`);
103
+ if (id in db) {
104
+ throw new StoreError('conflict', `recording ${id} appeared while it was being created — reload and retry`);
105
+ }
106
+ this.storage.setItem(this.key, JSON.stringify({ ...db, [id]: har }));
107
+ }
108
+ importHar(recordingName, incoming) {
109
+ return importHarVia(this, recordingName, incoming);
110
+ }
96
111
  /** `null` means the key holds something that is not JSON, which `list` reports. */
97
112
  db() {
98
113
  const raw = this.storage.getItem(this.key);
@@ -1,4 +1,5 @@
1
1
  import type { Har, ResponsePatch } from '../types.js';
2
+ import type { ImportSkip } from '../import.js';
2
3
  export interface RecordingSummary {
3
4
  /** The recordingId. May contain slashes: Polly builds it from describe/it names. */
4
5
  id: string;
@@ -15,6 +16,14 @@ export interface ReadResult {
15
16
  etag: string;
16
17
  pollyWillReject: string | null;
17
18
  }
19
+ export interface ImportResult extends ReadResult {
20
+ /** The recordingId the name resolved to, so the UI can open what it just wrote. */
21
+ id: string;
22
+ added: number;
23
+ replaced: number;
24
+ skipped: ImportSkip[];
25
+ created: boolean;
26
+ }
18
27
  /**
19
28
  * The one interface the page renders against. Three implementations exist — the
20
29
  * filesystem, localStorage, and an HTTP client — and the page cannot tell them apart,
@@ -26,7 +35,7 @@ export interface RecordingStore {
26
35
  write(id: string, har: Har, etag: string): Promise<void>;
27
36
  delete(id: string): Promise<void>;
28
37
  /**
29
- * The two edits the UI performs, as store operations rather than as "read, apply, and
38
+ * The three edits the UI performs, as store operations rather than as "read, apply, and
30
39
  * hand a whole HAR back".
31
40
  *
32
41
  * They are on the interface so `mountStudio` never has to ask which store it holds.
@@ -37,6 +46,28 @@ export interface RecordingStore {
37
46
  */
38
47
  patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
39
48
  deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
49
+ /**
50
+ * Copy an entry to the end of its own `_id` group: the next identical request
51
+ * replays this one instead. Never a way to make a *different* request — see
52
+ * `duplicateEntry` in edit.ts.
53
+ */
54
+ duplicateEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
55
+ /**
56
+ * Write a recording that does not exist yet. Unlike `write` there is no etag to
57
+ * compare, because there is nothing to compare against — and it must fail rather
58
+ * than truncate if the file appeared since the caller last looked.
59
+ */
60
+ create(id: string, har: Har): Promise<void>;
61
+ /**
62
+ * Import a HAR from the swagger-recorder extension, creating the recording or
63
+ * merging into it.
64
+ *
65
+ * Takes a NAME, not an id: the name is what the user knows and what the app's
66
+ * Polly config holds, while the id is a hash the UI should never compute. On the
67
+ * interface for the same reason `patchEntry` and `deleteEntry` are — the
68
+ * read-merge-write cycle belongs on the server side of the network.
69
+ */
70
+ importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
40
71
  }
41
72
  export type StoreErrorCode = 'not-found' | 'conflict' | 'forbidden' | 'unprocessable';
42
73
  export declare class StoreError extends Error {
@@ -71,7 +71,9 @@ export interface HarLog {
71
71
  };
72
72
  _recordingName?: string;
73
73
  entries: HarEntry[];
74
- pages: unknown[];
74
+ /** Optional: real Polly emits it, nothing in this package reads it, and the shell
75
+ * `newRecording` (`stores/apply.ts`) writes on import omits it entirely. */
76
+ pages?: unknown[];
75
77
  browser?: {
76
78
  name: string;
77
79
  version: string;
@@ -1,4 +1,4 @@
1
- import { renderEntryDetail, renderEntryRow, renderRecordingList } from './render.js';
1
+ import { renderEntryDetail, renderEntryRow, renderImportResult, renderRecordingList, } from './render.js';
2
2
  import { StoreError } from '../stores/types.js';
3
3
  /**
4
4
  * The page's behaviour, written against `RecordingStore` and nothing else. Under the
@@ -14,6 +14,58 @@ export function mountStudio(root, store) {
14
14
  const recordings = required(root, '#recordings');
15
15
  const entries = required(root, '#entries');
16
16
  const detail = required(root, '#detail');
17
+ const importForm = root.querySelector('#import');
18
+ const importReport = root.querySelector('#import-report');
19
+ // Optional: the Node mount (`page.ts`'s `STUDIO_MARKUP`) and the browser-only mount
20
+ // (`browser.ts`, which injects that same `STUDIO_MARKUP`) both ship this form, so
21
+ // `#import` is present and `LocalStorageStore.importHar` reachable in either. The only
22
+ // root that lacks it is a test fake that predates this markup, and a missing form must
23
+ // not throw the whole page away before a single recording has been listed.
24
+ importForm?.addEventListener('submit', (event) => {
25
+ event.preventDefault();
26
+ void runImport();
27
+ });
28
+ async function runImport() {
29
+ if (importForm === null || importReport === null)
30
+ return;
31
+ // Cleared before the attempt, not just on success: a failed import must not leave
32
+ // the previous import's success line sitting next to a form that just failed — see
33
+ // Final whole-branch review, Finding I-4.
34
+ importReport.textContent = '';
35
+ try {
36
+ // The field reads live inside the try along with everything else: a missing
37
+ // `#import-name` or `#import-file` must surface the same way any other import
38
+ // failure does, not as an unhandled rejection — see Finding M-2.
39
+ const name = importForm.querySelector('#import-name').value;
40
+ const file = importForm.querySelector('#import-file').files?.[0];
41
+ if (file === undefined) {
42
+ throw new Error('choose a .har file to import');
43
+ }
44
+ // Parsed here rather than posted raw so a file that is not JSON fails with
45
+ // a message naming the file, before a 50 MB upload.
46
+ const har = JSON.parse(await file.text());
47
+ const result = await store.importHar(name, har);
48
+ importReport.innerHTML = renderImportResult(result);
49
+ try {
50
+ // A separate try: the import itself already succeeded and `importReport`
51
+ // already shows that. A failure refreshing the list or opening the result is
52
+ // a different problem — reported the normal way, rather than the outer catch
53
+ // overwriting the success line with what would read as the import having failed.
54
+ await refreshRecordings();
55
+ await openRecording(result.id);
56
+ }
57
+ catch (err) {
58
+ report(err);
59
+ }
60
+ }
61
+ catch (err) {
62
+ // Into `#import-report`, right next to the form that failed, rather than the
63
+ // distant `detail` banner `report()` uses for everything else — see Finding I-4.
64
+ // `textContent`, never `innerHTML`: this is an error message, not markup `render.ts`
65
+ // already escaped.
66
+ importReport.textContent = errorMessage(err);
67
+ }
68
+ }
17
69
  let currentId = null;
18
70
  let current = null;
19
71
  // `.catch(report)` rather than a bare `void`: an unawaited rejection here would be an
@@ -33,6 +85,19 @@ export function mountStudio(root, store) {
33
85
  current = { har: read.har, etag: read.etag };
34
86
  entries.innerHTML = `<ul>${read.har.log.entries.map(renderEntryRow).join('')}</ul>`;
35
87
  detail.innerHTML = '';
88
+ // Spec §5.5: the import name prefills from the opened recording's `_recordingName`,
89
+ // so merging is one gesture and creating is one gesture plus typing a name. This is
90
+ // also the spec's own ergonomic defence against Finding I-2 (a padded name hashing
91
+ // to a different, identical-looking directory) — prefilling from a name Polly itself
92
+ // already wrote means fewer names get typed by hand at all.
93
+ const recordingName = read.har.log._recordingName;
94
+ // `importForm?.querySelector(...)` is `undefined`, not `null`, when `importForm`
95
+ // itself is absent — `=== null` alone would miss that and then dereference
96
+ // `undefined` below, so the guard checks `importForm` directly.
97
+ const nameField = importForm === null ? null : importForm.querySelector('#import-name');
98
+ if (nameField !== null && typeof recordingName === 'string' && recordingName !== '') {
99
+ nameField.value = recordingName;
100
+ }
36
101
  }
37
102
  /**
38
103
  * `entryId` always arrives as a string, because it came off `dataset`. `e._id` is
@@ -66,6 +131,24 @@ export function mountStudio(root, store) {
66
131
  report(err);
67
132
  }
68
133
  }
134
+ /**
135
+ * The copy lands at the end of its group, and that is the one we open: the
136
+ * entry you were looking at is unchanged, and the thing you are about to edit
137
+ * is the new answer, not the old one.
138
+ */
139
+ async function duplicate(entryId, order) {
140
+ if (currentId === null || current === null)
141
+ return;
142
+ try {
143
+ const { har } = await store.duplicateEntry(currentId, entryId, order, current.etag);
144
+ const added = har.log.entries.filter((entry) => String(entry._id) === entryId).length - 1;
145
+ await openRecording(currentId);
146
+ openEntry(entryId, added);
147
+ }
148
+ catch (err) {
149
+ report(err);
150
+ }
151
+ }
69
152
  async function remove(entryId, order) {
70
153
  if (currentId === null || current === null)
71
154
  return;
@@ -77,12 +160,17 @@ export function mountStudio(root, store) {
77
160
  report(err);
78
161
  }
79
162
  }
80
- function report(err) {
81
- const message = err instanceof StoreError && err.code === 'conflict'
163
+ // Shared by `report` (the `detail`-pane banner) and `runImport`'s catch (`#import-report`):
164
+ // both want the same friendlier wording for a conflict, neither wants a raw stack string.
165
+ function errorMessage(err) {
166
+ return err instanceof StoreError && err.code === 'conflict'
82
167
  ? 'the recording changed underneath — reload and retry'
83
168
  : err instanceof Error
84
169
  ? err.message
85
170
  : String(err);
171
+ }
172
+ function report(err) {
173
+ const message = errorMessage(err);
86
174
  // `document.createElement` would reach past `root` for a document `mountStudio`
87
175
  // was never given — harmless today since the Node mount and the browser mount
88
176
  // both happen to run in one document, but `root` exists precisely so this
@@ -124,6 +212,8 @@ export function mountStudio(root, store) {
124
212
  const order = Number(section.dataset['order']);
125
213
  if (target.getAttribute('name') === 'save')
126
214
  void save(entryId, order);
215
+ if (target.getAttribute('name') === 'duplicate')
216
+ void duplicate(entryId, order);
127
217
  if (target.getAttribute('name') === 'delete')
128
218
  void remove(entryId, order);
129
219
  });
@@ -2,7 +2,7 @@
2
2
  * The body, shared with the browser-only mount in Task 12 so the two cannot drift into
3
3
  * two different editors.
4
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>";
5
+ export declare const STUDIO_MARKUP = "\n<div class=\"polly-studio\">\n<!--\n Import sits beside the title, not inside the recordings list.\n It makes a recording rather than picking one, so under the list it read as a\n footer of that list \u2014 something you do *to* the thing above it \u2014 and it\n pushed the list it was supposedly part of into a shorter and shorter box.\n-->\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 <form id=\"import\" class=\"ps-import\">\n <h2 class=\"ps-import-title\">Import</h2>\n <label for=\"import-name\">A HAR from the swagger-recorder extension</label>\n <div class=\"ps-import-row\">\n <input id=\"import-name\" name=\"recordingName\" placeholder=\"recording name, e.g. my-app\" />\n <input id=\"import-file\" name=\"har\" type=\"file\" accept=\".har,application/json\" />\n <button type=\"submit\">Import</button>\n </div>\n <div id=\"import-report\" aria-live=\"polite\"></div>\n </form>\n</header>\n<p class=\"warn banner\">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:\nthe next record run deletes every entry it did not exercise, including the ones you edit here.\nSet <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>\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
6
  /**
7
7
  * The HTML shell.
8
8
  *
@@ -50,4 +50,4 @@ export declare const PAGE_STYLE = "\nhtml { color-scheme: light; }\nhtml, body {
50
50
  * rules restyled the host app's own type and margins as a side effect of mounting an
51
51
  * editor. Scoping is the only thing preventing that, so new rules need the prefix too.
52
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";
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-lift: 0 1px 2px rgba(17,23,34,.05), 0 10px 22px -14px rgba(17,23,34,.35);\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/* The title on the left, import on the right, and the warning on its own line\n underneath: three things competing for one row left the import squeezed into\n whatever the banner had not taken. */\n.polly-studio .ps-bar {\n display: flex; flex-wrap: wrap; align-items: flex-start;\n justify-content: space-between; gap: .9rem 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 margin: .9rem 1.25rem 0; 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 /* Lifted off the paper, so where one region ends and the next begins is a\n shadow rather than a hairline somebody has to look for. */\n box-shadow: var(--ps-lift);\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/* Import form ------------------------------------------------------------ */\n/* A card of its own in the title bar. Bounded width so a long page does not\n stretch it across the screen, and wide enough that the three controls sit on\n one line at the sizes this is actually used at. */\n.polly-studio .ps-import {\n display: grid; gap: .35rem;\n flex: 0 1 34rem; min-width: 17rem;\n padding: .6rem .7rem;\n background: var(--ps-sunken);\n border: 1px solid var(--ps-line); border-radius: 12px;\n box-shadow: var(--ps-lift);\n}\n.polly-studio .ps-import-title {\n margin: 0; font-size: .8125rem; font-weight: 650; color: var(--ps-ink);\n}\n.polly-studio .ps-import-row {\n display: grid; gap: .4rem;\n grid-template-columns: minmax(7rem, 1fr) minmax(8rem, 1.2fr) auto;\n align-items: center;\n}\n.polly-studio .ps-import label {\n font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);\n}\n.polly-studio .ps-import input {\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-import input:hover { border-color: #a9b5c6; }\n.polly-studio .ps-import input:focus {\n outline: none; border-color: var(--ps-accent); box-shadow: 0 0 0 3px var(--ps-accent-soft);\n}\n.polly-studio .ps-import input[type=\"file\"] { font-size: .75rem; padding: .3rem .4rem; }\n.polly-studio .ps-import button {\n font: inherit; font-size: .8125rem; font-weight: 600;\n justify-self: stretch; white-space: nowrap;\n padding: .45rem .95rem; border-radius: 8px; cursor: pointer;\n border: 1px solid transparent;\n background: var(--ps-accent); color: #fff;\n transition: background-color .12s ease;\n}\n.polly-studio .ps-import button:hover { background: var(--ps-accent-deep); }\n.polly-studio .ps-import button:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }\n.polly-studio .ps-import-result {\n margin: .2rem 0 0; padding: .5rem .6rem; font-size: .78rem; line-height: 1.5;\n background: var(--ps-sunken); border: 1px solid var(--ps-line);\n border-left-width: 3px; border-radius: 0 8px 8px 0;\n}\n/* max-height + overflow-y: auto rather than relying on the pane's own scroll: the pane\n around this (.ps-pane) is overflow: hidden, so without its own scroll a long skip\n list would clip silently instead of growing the pane. */\n.polly-studio .ps-import-skips {\n display: grid; gap: .25rem; margin: .4rem 0 0;\n max-height: 10rem; overflow-y: auto;\n}\n.polly-studio .ps-import-skips li {\n padding-left: .5rem; border-left: 2px solid var(--ps-line-strong);\n font-size: .75rem; line-height: 1.4; color: var(--ps-ink-soft);\n}\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-import-row { grid-template-columns: minmax(0, 1fr); }\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";