@avivbenhamo/polly-kit 0.7.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.
@@ -111,8 +111,13 @@ function entryDetail(entry) {
111
111
 
112
112
  <div class="actions">
113
113
  <button name="save">Save response</button>
114
+ <button name="duplicate" title="Append a copy to this request's group, replayed the next time the same request is made">Duplicate for next call</button>
114
115
  <button name="delete" class="danger">Delete entry</button>
115
116
  </div>
117
+ <p class="note">Duplicating adds a second answer to the <em>same</em> request:
118
+ Polly replays <code>_order 0</code> on the first call, this copy on the second.
119
+ It cannot become a different request \u2014 the <code>_id</code> is a hash of the
120
+ request, and an edited URL under the old hash would match nothing.</p>
116
121
  </div>
117
122
  </section>`;
118
123
  }
@@ -199,6 +204,18 @@ function mountStudio(root, store) {
199
204
  report(err);
200
205
  }
201
206
  }
207
+ async function duplicate(entryId, order) {
208
+ if (currentId === null || current === null)
209
+ return;
210
+ try {
211
+ const { har } = await store.duplicateEntry(currentId, entryId, order, current.etag);
212
+ const added = har.log.entries.filter((entry) => String(entry._id) === entryId).length - 1;
213
+ await openRecording(currentId);
214
+ openEntry(entryId, added);
215
+ } catch (err) {
216
+ report(err);
217
+ }
218
+ }
202
219
  async function remove(entryId, order) {
203
220
  if (currentId === null || current === null)
204
221
  return;
@@ -242,6 +259,8 @@ function mountStudio(root, store) {
242
259
  const order = Number(section.dataset["order"]);
243
260
  if (target.getAttribute("name") === "save")
244
261
  void save(entryId, order);
262
+ if (target.getAttribute("name") === "duplicate")
263
+ void duplicate(entryId, order);
245
264
  if (target.getAttribute("name") === "delete")
246
265
  void remove(entryId, order);
247
266
  });
@@ -309,6 +328,10 @@ var HttpStore = class {
309
328
  async patchEntry(id, entryId, order, etag, patch) {
310
329
  return await this.json("PATCH", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}`, { etag, patch });
311
330
  }
331
+ /** POST to the entry being copied: this adds a member to its group. */
332
+ async duplicateEntry(id, entryId, order, etag) {
333
+ return await this.json("POST", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
334
+ }
312
335
  async deleteEntry(id, entryId, order, etag) {
313
336
  return await this.json("DELETE", `/api/recordings/${encodeURIComponent(id)}/entries/${encodeURIComponent(entryId)}/${order}?etag=${encodeURIComponent(etag)}`);
314
337
  }
@@ -29,6 +29,28 @@ export type EditResult = {
29
29
  * wrong rather than merely stale.
30
30
  */
31
31
  export declare function applyResponsePatch(har: Har, id: string, order: number, patch: ResponsePatch): EditResult;
32
+ /**
33
+ * Copies an entry to the end of its own `_id` group — "the *next* time this same
34
+ * request happens, reply with this".
35
+ *
36
+ * `_order` is a position within a group of identical requests, not a label:
37
+ * Polly derives it at replay by counting how many earlier requests produced the
38
+ * same `_id`, starting at 0. So a recording with two members of a group answers
39
+ * the first call with `_order: 0` and the second with `_order: 1`. Duplicating
40
+ * appends a member, which is the only honest meaning "duplicate" can have here.
41
+ *
42
+ * What it deliberately is **not** is a way to make a *different* request. The
43
+ * `_id` is an md5 over the request that this package never recomputes — see
44
+ * `import.ts` — so a copy with an edited URL would carry the original's id,
45
+ * match nothing at replay, and fail silently. The copy keeps the id, and the
46
+ * caller edits the response.
47
+ *
48
+ * The clone is inserted directly after the last member of its group rather than
49
+ * appended to the file, because the list is shown in file order and a copy that
50
+ * appeared at the bottom, pages away from its original, reads as a new request
51
+ * somebody else recorded.
52
+ */
53
+ export declare function duplicateEntry(har: Har, id: string, order: number): EditResult;
32
54
  export declare function notFound(id: string, order: number): string;
33
55
  /**
34
56
  * Removes one entry and closes the gap it leaves in its `_id` group.
@@ -62,6 +62,41 @@ export function applyResponsePatch(har, id, order, patch) {
62
62
  }
63
63
  return { ok: true, har: next };
64
64
  }
65
+ /**
66
+ * Copies an entry to the end of its own `_id` group — "the *next* time this same
67
+ * request happens, reply with this".
68
+ *
69
+ * `_order` is a position within a group of identical requests, not a label:
70
+ * Polly derives it at replay by counting how many earlier requests produced the
71
+ * same `_id`, starting at 0. So a recording with two members of a group answers
72
+ * the first call with `_order: 0` and the second with `_order: 1`. Duplicating
73
+ * appends a member, which is the only honest meaning "duplicate" can have here.
74
+ *
75
+ * What it deliberately is **not** is a way to make a *different* request. The
76
+ * `_id` is an md5 over the request that this package never recomputes — see
77
+ * `import.ts` — so a copy with an edited URL would carry the original's id,
78
+ * match nothing at replay, and fail silently. The copy keeps the id, and the
79
+ * caller edits the response.
80
+ *
81
+ * The clone is inserted directly after the last member of its group rather than
82
+ * appended to the file, because the list is shown in file order and a copy that
83
+ * appeared at the bottom, pages away from its original, reads as a new request
84
+ * somebody else recorded.
85
+ */
86
+ export function duplicateEntry(har, id, order) {
87
+ const source = findEntry(har, id, order);
88
+ if (source === undefined) {
89
+ return { ok: false, error: { code: 'entry-not-found', message: notFound(id, order) } };
90
+ }
91
+ const next = structuredClone(har);
92
+ const entries = next.log.entries;
93
+ const group = entries.filter((entry) => entry._id === id);
94
+ const last = group.reduce((at, entry) => Math.max(at, entries.indexOf(entry)), -1);
95
+ const copy = structuredClone(findEntry(next, id, order));
96
+ copy._order = group.length;
97
+ entries.splice(last + 1, 0, copy);
98
+ return { ok: true, har: next };
99
+ }
65
100
  export function notFound(id, order) {
66
101
  return `no entry with _id ${id} and _order ${order} — it may have been deleted since the page loaded`;
67
102
  }
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { FsStore } from './stores/fs-store.js';
5
5
  import { StoreError } from './stores/types.js';
6
- import { applyResponsePatch, deleteEntry } from './edit.js';
6
+ import { applyResponsePatch, deleteEntry, duplicateEntry } from './edit.js';
7
7
  import { renderPage } from './ui/page.js';
8
8
  /**
9
9
  * A mountable editor for Polly recordings, in the style of swagger-ui: the app serves
@@ -131,6 +131,13 @@ async function handle(req, res, ctx) {
131
131
  await mutate(ctx, res, id, etag, (har) => deleteEntry(har, entryId, order));
132
132
  return;
133
133
  }
134
+ // POST, not PUT: this creates a member of the group rather than
135
+ // replacing one, and the URL it is posted to is the entry being copied.
136
+ if (req.method === 'POST') {
137
+ const etag = url.searchParams.get('etag') ?? '';
138
+ await mutate(ctx, res, id, etag, (har) => duplicateEntry(har, entryId, order));
139
+ return;
140
+ }
134
141
  }
135
142
  }
136
143
  sendJson(res, 404, { error: 'not found' });
@@ -3,6 +3,7 @@ import type { Har, ResponsePatch } from '../types.js';
3
3
  type Local = Pick<RecordingStore, 'read' | 'write'>;
4
4
  export declare function patchEntryVia(store: Local, id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
5
5
  export declare function deleteEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
6
+ export declare function duplicateEntryVia(store: Local, id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
6
7
  type Importable = Pick<RecordingStore, 'read' | 'write' | 'create'>;
7
8
  /**
8
9
  * The read-merge-write cycle, shared by every store that owns its own storage.
@@ -1,4 +1,4 @@
1
- import { applyResponsePatch, deleteEntry as deleteEntryIn } from '../edit.js';
1
+ import { applyResponsePatch, deleteEntry as deleteEntryIn, duplicateEntry as duplicateEntryIn, } from '../edit.js';
2
2
  import { POLLY_CREATOR_NAME } from '../har.js';
3
3
  import { mergeEntries, rejectImport } from '../import.js';
4
4
  import { guidForRecording, rejectRecordingName } from '../recording-id.js';
@@ -9,6 +9,9 @@ export function patchEntryVia(store, id, entryId, order, etag, patch) {
9
9
  export function deleteEntryVia(store, id, entryId, order, etag) {
10
10
  return applyEntryEdit(store, id, etag, (har) => deleteEntryIn(har, entryId, order));
11
11
  }
12
+ export function duplicateEntryVia(store, id, entryId, order, etag) {
13
+ return applyEntryEdit(store, id, etag, (har) => duplicateEntryIn(har, entryId, order));
14
+ }
12
15
  async function applyEntryEdit(store, id, etag, apply) {
13
16
  const read = await store.read(id);
14
17
  if (read.etag !== etag) {
@@ -49,6 +49,7 @@ export declare class FsStore implements RecordingStore {
49
49
  */
50
50
  delete(id: string): Promise<void>;
51
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>;
52
53
  deleteEntry(id: string, entryId: string, order: number, etag: string): Promise<ReadResult>;
53
54
  /**
54
55
  * `write` is read-compare-write and refuses a file that is not there, which is
@@ -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, importHarVia, 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,6 +151,9 @@ 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
  }
@@ -25,6 +25,8 @@ 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>;
29
31
  /**
30
32
  * Not on the wire protocol: creation happens inside `/api/import`, server-side,
@@ -36,6 +36,10 @@ 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
  }
@@ -19,6 +19,7 @@ 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>;
23
24
  create(id: string, har: Har): Promise<void>;
24
25
  importHar(recordingName: string, incoming: Har): Promise<ImportResult>;
@@ -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, importHarVia, 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,6 +90,9 @@ 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
  }
@@ -35,7 +35,7 @@ export interface RecordingStore {
35
35
  write(id: string, har: Har, etag: string): Promise<void>;
36
36
  delete(id: string): Promise<void>;
37
37
  /**
38
- * 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
39
39
  * hand a whole HAR back".
40
40
  *
41
41
  * They are on the interface so `mountStudio` never has to ask which store it holds.
@@ -46,6 +46,12 @@ export interface RecordingStore {
46
46
  */
47
47
  patchEntry(id: string, entryId: string, order: number, etag: string, patch: ResponsePatch): Promise<ReadResult>;
48
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>;
49
55
  /**
50
56
  * Write a recording that does not exist yet. Unlike `write` there is no etag to
51
57
  * compare, because there is nothing to compare against — and it must fail rather
@@ -131,6 +131,24 @@ export function mountStudio(root, store) {
131
131
  report(err);
132
132
  }
133
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
+ }
134
152
  async function remove(entryId, order) {
135
153
  if (currentId === null || current === null)
136
154
  return;
@@ -194,6 +212,8 @@ export function mountStudio(root, store) {
194
212
  const order = Number(section.dataset['order']);
195
213
  if (target.getAttribute('name') === 'save')
196
214
  void save(entryId, order);
215
+ if (target.getAttribute('name') === 'duplicate')
216
+ void duplicate(entryId, order);
197
217
  if (target.getAttribute('name') === 'delete')
198
218
  void remove(entryId, order);
199
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 <form id=\"import\" class=\"ps-import\">\n <label for=\"import-name\">Import a HAR from the swagger-recorder extension</label>\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 id=\"import-report\" aria-live=\"polite\"></div>\n </form>\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/* Import form ------------------------------------------------------------ */\n.polly-studio .ps-import {\n display: grid; gap: .4rem;\n padding: .7rem .75rem; margin-top: .4rem;\n border-top: 1px solid var(--ps-line);\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: start;\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-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";
@@ -4,26 +4,35 @@
4
4
  */
5
5
  export const STUDIO_MARKUP = `
6
6
  <div class="polly-studio">
7
+ <!--
8
+ Import sits beside the title, not inside the recordings list.
9
+ It makes a recording rather than picking one, so under the list it read as a
10
+ footer of that list — something you do *to* the thing above it — and it
11
+ pushed the list it was supposedly part of into a shorter and shorter box.
12
+ -->
7
13
  <header class="ps-bar">
8
14
  <div class="ps-brand">
9
15
  <h1>polly-studio</h1>
10
16
  <p class="ps-tagline">Change what a recorded request replies with, then save it back to the HAR.</p>
11
17
  </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>
18
+ <form id="import" class="ps-import">
19
+ <h2 class="ps-import-title">Import</h2>
20
+ <label for="import-name">A HAR from the swagger-recorder extension</label>
21
+ <div class="ps-import-row">
22
+ <input id="import-name" name="recordingName" placeholder="recording name, e.g. my-app" />
23
+ <input id="import-file" name="har" type="file" accept=".har,application/json" />
24
+ <button type="submit">Import</button>
25
+ </div>
26
+ <div id="import-report" aria-live="polite"></div>
27
+ </form>
15
28
  </header>
29
+ <p class="warn banner">Polly's <code>keepUnusedRequests</code> defaults to <code>false</code>:
30
+ the next record run deletes every entry it did not exercise, including the ones you edit here.
31
+ Set <code>persisterOptions.keepUnusedRequests: true</code> before editing.</p>
16
32
  <main class="ps-panes">
17
33
  <section class="ps-pane ps-pane--list">
18
34
  <h2 class="ps-pane-title">Recordings</h2>
19
35
  <nav id="recordings" class="ps-scroll">Loading…</nav>
20
- <form id="import" class="ps-import">
21
- <label for="import-name">Import a HAR from the swagger-recorder extension</label>
22
- <input id="import-name" name="recordingName" placeholder="recording name, e.g. my-app" />
23
- <input id="import-file" name="har" type="file" accept=".har,application/json" />
24
- <button type="submit">Import</button>
25
- <div id="import-report" aria-live="polite"></div>
26
- </form>
27
36
  </section>
28
37
  <section class="ps-pane ps-pane--list">
29
38
  <h2 class="ps-pane-title">Requests</h2>
@@ -122,6 +131,7 @@ export const STUDIO_STYLE = `
122
131
  --ps-ok: #0a6b3d; --ps-ok-line: #b6ddc6; --ps-ok-soft: #e8f4ed;
123
132
  --ps-warn: #875700; --ps-warn-line: #ecd3a3; --ps-warn-soft: #fbf2e1;
124
133
  --ps-bad: #b3261e; --ps-bad-line: #edbdb9; --ps-bad-soft: #fceceb;
134
+ --ps-lift: 0 1px 2px rgba(17,23,34,.05), 0 10px 22px -14px rgba(17,23,34,.35);
125
135
  --ps-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
126
136
 
127
137
  color-scheme: light;
@@ -137,8 +147,12 @@ export const STUDIO_STYLE = `
137
147
  }
138
148
 
139
149
  /* Title bar ------------------------------------------------------------ */
150
+ /* The title on the left, import on the right, and the warning on its own line
151
+ underneath: three things competing for one row left the import squeezed into
152
+ whatever the banner had not taken. */
140
153
  .polly-studio .ps-bar {
141
- display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.75rem;
154
+ display: flex; flex-wrap: wrap; align-items: flex-start;
155
+ justify-content: space-between; gap: .9rem 1.75rem;
142
156
  padding: .8rem 1.25rem;
143
157
  background: var(--ps-surface);
144
158
  border-bottom: 1px solid var(--ps-line);
@@ -147,8 +161,7 @@ export const STUDIO_STYLE = `
147
161
  .polly-studio h1 { margin: 0; font-size: 1.0625rem; font-weight: 650; letter-spacing: -.015em; }
148
162
  .polly-studio .ps-tagline { margin: 0; font-size: .8125rem; color: var(--ps-ink-soft); }
149
163
  .polly-studio .banner {
150
- flex: 1 1 26rem; min-width: 16rem; margin: 0;
151
- padding: .45rem .7rem;
164
+ margin: .9rem 1.25rem 0; padding: .45rem .7rem;
152
165
  font-size: .78rem; line-height: 1.45;
153
166
  background: var(--ps-warn-soft);
154
167
  border: 1px solid var(--ps-warn-line); border-left-width: 3px;
@@ -170,6 +183,9 @@ export const STUDIO_STYLE = `
170
183
  display: flex; flex-direction: column; min-height: 0;
171
184
  border: 1px solid var(--ps-line); border-radius: 12px;
172
185
  background: var(--ps-surface);
186
+ /* Lifted off the paper, so where one region ends and the next begins is a
187
+ shadow rather than a hairline somebody has to look for. */
188
+ box-shadow: var(--ps-lift);
173
189
  overflow: hidden;
174
190
  }
175
191
  /* The two browsers read as one surface you pick from; the editor reads as the page you
@@ -218,10 +234,24 @@ export const STUDIO_STYLE = `
218
234
  .polly-studio .ps-rec-note { margin: .1rem 0 .3rem .5rem; font-size: .75rem; line-height: 1.4; }
219
235
 
220
236
  /* Import form ------------------------------------------------------------ */
237
+ /* A card of its own in the title bar. Bounded width so a long page does not
238
+ stretch it across the screen, and wide enough that the three controls sit on
239
+ one line at the sizes this is actually used at. */
221
240
  .polly-studio .ps-import {
241
+ display: grid; gap: .35rem;
242
+ flex: 0 1 34rem; min-width: 17rem;
243
+ padding: .6rem .7rem;
244
+ background: var(--ps-sunken);
245
+ border: 1px solid var(--ps-line); border-radius: 12px;
246
+ box-shadow: var(--ps-lift);
247
+ }
248
+ .polly-studio .ps-import-title {
249
+ margin: 0; font-size: .8125rem; font-weight: 650; color: var(--ps-ink);
250
+ }
251
+ .polly-studio .ps-import-row {
222
252
  display: grid; gap: .4rem;
223
- padding: .7rem .75rem; margin-top: .4rem;
224
- border-top: 1px solid var(--ps-line);
253
+ grid-template-columns: minmax(7rem, 1fr) minmax(8rem, 1.2fr) auto;
254
+ align-items: center;
225
255
  }
226
256
  .polly-studio .ps-import label {
227
257
  font-size: .75rem; font-weight: 600; color: var(--ps-ink-soft);
@@ -241,7 +271,7 @@ export const STUDIO_STYLE = `
241
271
  .polly-studio .ps-import input[type="file"] { font-size: .75rem; padding: .3rem .4rem; }
242
272
  .polly-studio .ps-import button {
243
273
  font: inherit; font-size: .8125rem; font-weight: 600;
244
- justify-self: start;
274
+ justify-self: stretch; white-space: nowrap;
245
275
  padding: .45rem .95rem; border-radius: 8px; cursor: pointer;
246
276
  border: 1px solid transparent;
247
277
  background: var(--ps-accent); color: #fff;
@@ -406,6 +436,7 @@ export const STUDIO_STYLE = `
406
436
  .polly-studio .ps-pane--list .ps-scroll { max-height: 18rem; }
407
437
  }
408
438
  @media (max-width: 34rem) {
439
+ .polly-studio .ps-import-row { grid-template-columns: minmax(0, 1fr); }
409
440
  .polly-studio .ps-fields { grid-template-columns: minmax(0, 1fr); }
410
441
  .polly-studio .header-row { grid-template-columns: minmax(0, 1fr); }
411
442
  .polly-studio .header-row input[name^="headerName"] { border-right: 0; border-bottom: 1px solid var(--ps-line); }
@@ -192,8 +192,13 @@ function entryDetail(entry) {
192
192
 
193
193
  <div class="actions">
194
194
  <button name="save">Save response</button>
195
+ <button name="duplicate" title="Append a copy to this request's group, replayed the next time the same request is made">Duplicate for next call</button>
195
196
  <button name="delete" class="danger">Delete entry</button>
196
197
  </div>
198
+ <p class="note">Duplicating adds a second answer to the <em>same</em> request:
199
+ Polly replays <code>_order 0</code> on the first call, this copy on the second.
200
+ It cannot become a different request — the <code>_id</code> is a hash of the
201
+ request, and an edited URL under the old hash would match nothing.</p>
197
202
  </div>
198
203
  </section>`;
199
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avivbenhamo/polly-kit",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Drop-in record/replay of an app's HTTP traffic with Polly.JS, plus the polly-studio editor. Bring your own host policy.",
5
5
  "license": "MIT",
6
6
  "type": "module",