@marshalliqiu/loupe 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -75,6 +75,18 @@ loupe spec spec/guest-checkout.html # on Execute: write the companion spec (gue
75
75
 
76
76
  > Use `npx @marshalliqiu/loupe new <file>` to map out what we discussed before building it.
77
77
 
78
+ **Skill (slash command).** Install the Loupe skill in the [Agent Skills](https://github.com/anthropics/skills) format with `npx skills` — it teaches your agent the full grill → lock → before/after workflow and, in Claude Code, exposes it as `/loupe`:
79
+
80
+ ```sh
81
+ npx skills add jerry5789k1/loupe --skill loupe
82
+ ```
83
+
84
+ The skill runs the CLI on demand via `npx -y @marshalliqiu/loupe`, so nothing else needs to be installed. Then:
85
+
86
+ ```
87
+ /loupe let's inspect the guest-checkout change before building it
88
+ ```
89
+
78
90
  **Session hook.** Want Loupe's ambient context — including your live open sessions — fed into every agent session instead of loading on demand? Install globally and opt into the hook:
79
91
 
80
92
  ```sh
package/dist/cli.mjs CHANGED
@@ -801,6 +801,7 @@ function defaultPort() {
801
801
 
802
802
  // src/scaffold.js
803
803
  var MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.esm.min.mjs";
804
+ var SVG_PAN_ZOOM_CDN = "https://esm.sh/svg-pan-zoom@3.6.2";
804
805
  function escapeHtml(value) {
805
806
  return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
806
807
  }
@@ -1201,8 +1202,18 @@ ${decisionSection()}`;
1201
1202
 
1202
1203
  .loupe-card { background: var(--surface); border: 1px solid var(--hair); border-radius: var(--radius); padding: 22px; }
1203
1204
 
1204
- .loupe-diagram { display: flex; justify-content: center; min-width: 0; overflow-x: auto; }
1205
- .loupe-diagram .mermaid { min-width: 0; }
1205
+ /* A pannable/zoomable viewport: the diagram renders at full size inside a fixed
1206
+ frame so it never shrinks to fit the card. Drag to pan, \u2318/Ctrl+wheel to zoom. */
1207
+ .loupe-diagram { position: relative; height: clamp(320px, 52vh, 580px); min-width: 0; overflow: hidden; border: 1px solid var(--hair); border-radius: var(--radius); background: var(--surface); cursor: grab; user-select: none; }
1208
+ .loupe-diagram:active { cursor: grabbing; }
1209
+ /* gesture hint \u2014 purely informational, never intercepts clicks */
1210
+ .loupe-diagram-hint { position: absolute; top: 8px; right: 10px; z-index: 2; pointer-events: none; font-size: 11px; color: var(--ink-3); background: rgba(255,255,255,0.82); border: 1px solid var(--hair); border-radius: 999px; padding: 2px 9px; }
1211
+ /* margin:0 \u2014 <pre>'s default 1em top/bottom margin (~26px) otherwise overflows the
1212
+ fixed-height frame and trips the layout audit's clipped-text check. */
1213
+ .loupe-diagram .mermaid { width: 100%; height: 100%; min-width: 0; margin: 0; padding: 0; }
1214
+ /* display:block removes the inline-element baseline descender gap (~6px) that
1215
+ otherwise overflows the frame and re-trips the layout audit. */
1216
+ .loupe-diagram svg { display: block; width: 100% !important; height: 100% !important; max-width: none !important; }
1206
1217
  .loupe-legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; font-size: 13px; color: var(--ink-2); }
1207
1218
  .loupe-legend span { display: inline-flex; align-items: center; gap: 7px; }
1208
1219
  .loupe-sw { width: 13px; height: 13px; border-radius: 4px; display: inline-block; }
@@ -1277,12 +1288,67 @@ ${lenses}
1277
1288
 
1278
1289
  <script type="module">
1279
1290
  import mermaid from "${MERMAID_CDN}";
1291
+ // useMaxWidth:true makes mermaid emit a viewBox, so each SVG scales to fit its frame
1292
+ // immediately (no transient natural-size overflow before svg-pan-zoom's async CDN
1293
+ // import resolves \u2014 that overflow tripped the layout audit). The tall .loupe-diagram
1294
+ // frame + svg-pan-zoom fit then enlarge the diagram to fill the frame, so it stays big.
1280
1295
  mermaid.initialize({ startOnLoad: false, theme: "base", securityLevel: "loose", flowchart: { useMaxWidth: true } });
1281
1296
  try {
1282
1297
  await mermaid.run();
1283
1298
  } catch (e) {
1284
1299
  console.error("Loupe: mermaid render failed", e);
1285
1300
  }
1301
+ // Make every rendered diagram pannable/zoomable. Loaded after mermaid so the SVGs
1302
+ // exist; isolated in its own try so a CDN/offline failure leaves diagrams static but intact.
1303
+ try {
1304
+ const svgPanZoom = (await import("${SVG_PAN_ZOOM_CDN}")).default;
1305
+ for (const svg of document.querySelectorAll(".loupe-diagram .mermaid svg")) {
1306
+ svg.style.maxWidth = "none";
1307
+ svg.style.width = "100%";
1308
+ svg.style.height = "100%";
1309
+ const pz = svgPanZoom(svg, {
1310
+ zoomEnabled: true,
1311
+ controlIconsEnabled: true, // discoverable + / \u2212 / reset(fit) buttons
1312
+ fit: true,
1313
+ center: true,
1314
+ minZoom: 0.2,
1315
+ maxZoom: 12,
1316
+ dblClickZoomEnabled: false, // leave double-click free; pan is drag, annotate is click
1317
+ // A plain wheel must scroll the PAGE, not zoom the diagram \u2014 otherwise the page
1318
+ // scroll gets hijacked whenever the cursor is over a diagram (the reported bug).
1319
+ mouseWheelZoomEnabled: false,
1320
+ // Do NOT preventDefault on mouse events: Loupe's annotation picker listens for
1321
+ // click (capture phase); the default true swallows that click. false keeps
1322
+ // drag-to-pan AND click-to-annotate working.
1323
+ preventMouseEventsDefault: false,
1324
+ });
1325
+ const frame = svg.closest(".loupe-diagram");
1326
+ // Zoom is intentional only: \u2318/Ctrl + wheel zooms at the cursor; a plain wheel is
1327
+ // ignored here so the page scrolls normally. (The +/\u2212/reset buttons also zoom.)
1328
+ frame.addEventListener(
1329
+ "wheel",
1330
+ (e) => {
1331
+ if (!(e.ctrlKey || e.metaKey)) return; // plain scroll \u2192 let the page handle it
1332
+ e.preventDefault();
1333
+ const rect = svg.getBoundingClientRect();
1334
+ // gentle step per wheel tick \u2014 wheels/trackpads fire many events, so a small
1335
+ // factor keeps zoom controllable instead of jumping.
1336
+ const step = 1.06;
1337
+ pz.zoomAtPointBy(e.deltaY < 0 ? step : 1 / step, { x: e.clientX - rect.left, y: e.clientY - rect.top });
1338
+ },
1339
+ { passive: false },
1340
+ );
1341
+ // discoverability hint, pointer-events:none so it never blocks a click
1342
+ if (!frame.querySelector(".loupe-diagram-hint")) {
1343
+ const hint = document.createElement("div");
1344
+ hint.className = "loupe-diagram-hint";
1345
+ hint.textContent = "drag to pan \xB7 \u2318/Ctrl + scroll to zoom";
1346
+ frame.appendChild(hint);
1347
+ }
1348
+ }
1349
+ } catch (e) {
1350
+ console.error("Loupe: pan/zoom init failed (diagrams stay static)", e);
1351
+ }
1286
1352
  </script>
1287
1353
 
1288
1354
  <script>
@@ -1782,6 +1848,7 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
1782
1848
  if (!(el instanceof Element) || isLavishUi(el)) return;
1783
1849
  if (isIntentionalHorizontalScroller(el)) return;
1784
1850
  elements.push(el);
1851
+ if (el.tagName && el.tagName.toLowerCase() === "svg") return;
1785
1852
  for (const child of el.children) walk(child);
1786
1853
  }
1787
1854
  if (document.body) walk(document.body);
@@ -1981,13 +2048,13 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
1981
2048
  document.documentElement.appendChild(host);
1982
2049
  shadow = host.attachShadow({ mode: "open" });
1983
2050
  const style = document.createElement("style");
1984
- style.textContent = `:host{all:initial;position:fixed;z-index:2147483647;left:0;top:0;color-scheme:dark;--ink-900:#0f1115;--ink-800:#11141a;--ink-700:#171a21;--ink-600:#1c212b;--steel-700:#2a2f3a;--steel-600:#303745;--steel-500:#3c4557;--steel-400:#8c96aa;--steel-300:#aeb6c6;--steel-200:#b9c0cf;--steel-100:#d8deea;--cream-50:#fffbf3;--cream-100:#f7f3ea;--cream-200:#e8e1cf;--brass-500:#f4c95d;--brass-400:#ffd877;--brass-ink:#17130a;--bg:var(--ink-900);--bg-panel:var(--ink-800);--bg-elevated:var(--ink-600);--fg:var(--cream-100);--fg-faint:var(--steel-300);--border:var(--steel-600);--accent:#f4c95d;--accent-hover:#ffd877;--font-sans:Geist,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--radius-md:10px;--radius-xl:14px;--shadow-floating:0 20px 70px rgba(0,0,0,.35);font-family:var(--font-sans)}*{box-sizing:border-box}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.lavish-text-highlight{position:fixed;pointer-events:none;background:rgba(244,201,93,.28);border-radius:2px;box-shadow:0 0 0 1px rgba(244,201,93,.45)}.lavish-annotation-card{position:fixed;width:min(320px,calc(100vw - 24px));padding:12px;border-radius:var(--radius-xl);background:var(--bg-panel);color:var(--fg);border:1px solid var(--accent);box-shadow:var(--shadow-floating);font:14px/1.4 var(--font-sans)}.lavish-heading{font-weight:700;margin-bottom:6px}.lavish-annotation-card textarea{width:100%;min-height:86px;resize:vertical;border-radius:var(--radius-md);border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:9px;font:inherit;font-family:var(--font-sans)}.lavish-annotation-card textarea::placeholder{color:var(--fg-faint)}.lavish-annotation-card .lavish-hint{margin-top:6px;font-size:11px;color:var(--fg-faint)}.lavish-annotation-card .lavish-row{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}.lavish-annotation-card button{border:0;border-radius:var(--radius-md);padding:8px 10px;font-family:var(--font-sans);font-size:13px;font-weight:700;cursor:pointer}.lavish-annotation-card button:active{opacity:.85}.lavish-annotation-card .lavish-send{background:var(--accent);color:var(--brass-ink)}.lavish-annotation-card .lavish-send:hover{background:var(--accent-hover)}.lavish-annotation-card .lavish-cancel{background:var(--steel-700);color:var(--fg)}`;
2051
+ style.textContent = `:host{all:initial;position:fixed;z-index:2147483647;left:0;top:0;color-scheme:dark;--ink-900:#0f1115;--ink-800:#11141a;--ink-700:#171a21;--ink-600:#1c212b;--steel-700:#2a2f3a;--steel-600:#303745;--steel-500:#3c4557;--steel-400:#8c96aa;--steel-300:#aeb6c6;--steel-200:#b9c0cf;--steel-100:#d8deea;--cream-50:#fffbf3;--cream-100:#f7f3ea;--cream-200:#e8e1cf;--brass-500:#f4c95d;--brass-400:#ffd877;--brass-ink:#17130a;--bg:var(--ink-900);--bg-panel:var(--ink-800);--bg-elevated:var(--ink-600);--fg:var(--cream-100);--fg-faint:var(--steel-300);--border:var(--steel-600);--accent:#f4c95d;--accent-hover:#ffd877;--font-sans:Geist,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--radius-md:10px;--radius-xl:14px;--shadow-floating:0 20px 70px rgba(0,0,0,.35);font-family:var(--font-sans)}*{box-sizing:border-box}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.lavish-text-highlight{position:fixed;pointer-events:none;background:rgba(244,201,93,.28);border-radius:2px;box-shadow:0 0 0 1px rgba(244,201,93,.45)}.lavish-annotation-card{position:fixed;width:min(320px,calc(100vw - 24px));padding:12px;border-radius:var(--radius-xl);background:var(--bg-panel);color:var(--fg);border:1px solid var(--accent);box-shadow:var(--shadow-floating);font:14px/1.4 var(--font-sans)}.lavish-heading{font-weight:700;margin-bottom:6px}.lavish-annotation-card textarea{width:100%;min-height:86px;resize:vertical;border-radius:var(--radius-md);border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:9px;font:inherit;font-family:var(--font-sans)}.lavish-annotation-card textarea::placeholder{color:var(--fg-faint)}.lavish-annotation-card .lavish-hint{margin-top:6px;font-size:11px;color:var(--fg-faint)}.lavish-annotation-card .lavish-row{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}.lavish-annotation-card button{border:0;border-radius:var(--radius-md);padding:8px 10px;font-family:var(--font-sans);font-size:13px;font-weight:700;cursor:pointer}.lavish-annotation-card button:active{opacity:.85}.lavish-annotation-card .lavish-send{background:var(--accent);color:var(--brass-ink)}.lavish-annotation-card .lavish-send:hover{background:var(--accent-hover)}.lavish-annotation-card .lavish-cancel{background:var(--steel-700);color:var(--fg)}.lavish-select-pill{position:fixed;display:flex;align-items:center;gap:10px;max-width:min(340px,calc(100vw - 24px));padding:5px 6px 5px 12px;border-radius:999px;background:var(--bg-panel);color:var(--fg);border:1px solid var(--accent);box-shadow:var(--shadow-floating);font:13px/1.3 var(--font-sans)}.lavish-select-pill .lavish-pill-label{display:flex;align-items:center;gap:7px;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.lavish-select-pill .lavish-pill-tag{flex:none;font-family:var(--font-mono);font-size:11px;color:var(--accent)}.lavish-select-pill .lavish-pill-text{overflow:hidden;text-overflow:ellipsis;color:var(--fg);font-weight:700}.lavish-select-pill .lavish-pill-add{flex:none;border:0;border-radius:999px;padding:6px 12px;background:var(--accent);color:var(--brass-ink);font:inherit;font-weight:700;cursor:pointer}.lavish-select-pill .lavish-pill-add:hover{background:var(--accent-hover)}`;
1985
2052
  shadow.appendChild(style);
1986
2053
  return shadow;
1987
2054
  }
1988
2055
  function closeCard() {
1989
2056
  if (shadow) {
1990
- for (const el of [...shadow.querySelectorAll(".lavish-annotation-card")]) el.remove();
2057
+ for (const el of [...shadow.querySelectorAll(".lavish-annotation-card,.lavish-select-pill")]) el.remove();
1991
2058
  }
1992
2059
  clearHighlight(hovered);
1993
2060
  clearHighlight(selected);
@@ -1995,6 +2062,49 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
1995
2062
  clearTextHighlight();
1996
2063
  selected = null;
1997
2064
  }
2065
+ function describeTarget(el) {
2066
+ const c = context(el);
2067
+ let label = c.text;
2068
+ if (!label && el && el.closest) {
2069
+ const group = el.closest("g");
2070
+ if (group) label = (group.textContent || "").trim().replace(/\s+/g, " ");
2071
+ }
2072
+ return { tag: c.tag, label: (label || "").slice(0, 60) };
2073
+ }
2074
+ function positionFloating(el, rect) {
2075
+ const pad = 8;
2076
+ const w = el.offsetWidth;
2077
+ const h = el.offsetHeight;
2078
+ const left = Math.min(Math.max(12, rect.left), window.innerWidth - w - 12);
2079
+ let top = rect.top - h - pad;
2080
+ if (top < 12) top = Math.min(rect.bottom + pad, window.innerHeight - h - 12);
2081
+ el.style.left = left + "px";
2082
+ el.style.top = top + "px";
2083
+ }
2084
+ function selectElement(target) {
2085
+ const root = ensureShadow();
2086
+ closeCard();
2087
+ selected = target;
2088
+ highlightElement(selected);
2089
+ const d = describeTarget(target);
2090
+ const pill = document.createElement("div");
2091
+ pill.className = "lavish-select-pill";
2092
+ pill.innerHTML = '<span class="lavish-pill-label"><span class="lavish-pill-tag"></span><span class="lavish-pill-text"></span></span><button class="lavish-pill-add" type="button">\u270E Add note</button>';
2093
+ const tagEl = pill.querySelector(".lavish-pill-tag");
2094
+ if (tagEl) tagEl.textContent = "<" + d.tag + ">";
2095
+ const textEl = pill.querySelector(".lavish-pill-text");
2096
+ if (textEl) {
2097
+ if (d.label) textEl.textContent = '"' + d.label + '"';
2098
+ else textEl.remove();
2099
+ }
2100
+ root.appendChild(pill);
2101
+ positionFloating(pill, target.getBoundingClientRect());
2102
+ const addButton = (
2103
+ /** @type {HTMLButtonElement | null} */
2104
+ pill.querySelector(".lavish-pill-add")
2105
+ );
2106
+ if (addButton) addButton.onclick = () => showAnnotationCard(selected);
2107
+ }
1998
2108
  function showAnnotationCard(target, options = {}) {
1999
2109
  const root = ensureShadow();
2000
2110
  closeCard();
@@ -2008,10 +2118,13 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
2008
2118
  const rect = options.range ? options.range.getBoundingClientRect() : target.getBoundingClientRect();
2009
2119
  const card = document.createElement("div");
2010
2120
  card.className = "lavish-annotation-card";
2011
- const heading = c.tag === "text" ? "Annotate text" : "Annotate &lt;" + c.tag + "&gt;";
2121
+ const label = c.tag === "text" ? "" : describeTarget(target).label;
2122
+ const headingText = c.tag === "text" ? "Annotate text" : "Annotate <" + c.tag + ">" + (label ? ' \xB7 "' + label + '"' : "");
2012
2123
  const placeholder = c.tag === "text" ? "Tell the agent what to change about this text..." : "Tell the agent what to change about this element...";
2013
- card.innerHTML = '<div class="lavish-heading">' + heading + '</div><textarea placeholder="' + placeholder + '"></textarea><div class="lavish-hint">Enter to queue &middot; ' + (/Mac|iP(hone|ad|od)/.test(navigator.platform) ? "\u2318" : "Ctrl") + '+Enter to send now</div><div class="lavish-row"><button class="lavish-cancel" type="button">Cancel</button><button class="lavish-send" type="button">Queue</button></div>';
2124
+ card.innerHTML = '<div class="lavish-heading"></div><textarea placeholder="' + placeholder + '"></textarea><div class="lavish-hint">Enter to queue &middot; ' + (/Mac|iP(hone|ad|od)/.test(navigator.platform) ? "\u2318" : "Ctrl") + '+Enter to send now</div><div class="lavish-row"><button class="lavish-cancel" type="button">Cancel</button><button class="lavish-send" type="button">Queue</button></div>';
2014
2125
  root.appendChild(card);
2126
+ const headingEl = card.querySelector(".lavish-heading");
2127
+ if (headingEl) headingEl.textContent = headingText;
2015
2128
  const left = Math.min(Math.max(12, rect.left), window.innerWidth - card.offsetWidth - 12);
2016
2129
  const top = Math.min(Math.max(12, rect.bottom + 8), window.innerHeight - card.offsetHeight - 12);
2017
2130
  card.style.left = left + "px";
@@ -2120,7 +2233,27 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
2120
2233
  ignoreNextClick = false;
2121
2234
  return;
2122
2235
  }
2123
- showAnnotationCard(event.target);
2236
+ const t = event.target;
2237
+ if (t === document.body || t === document.documentElement) {
2238
+ closeCard();
2239
+ return;
2240
+ }
2241
+ selectElement(t);
2242
+ },
2243
+ true
2244
+ );
2245
+ document.addEventListener(
2246
+ "keydown",
2247
+ (event) => {
2248
+ if (!annotationMode || !shadow) return;
2249
+ if (event.key === "Escape") {
2250
+ if (shadow.querySelector(".lavish-select-pill,.lavish-annotation-card")) closeCard();
2251
+ return;
2252
+ }
2253
+ if (event.key === "Enter" && !event.isComposing && selected && shadow.querySelector(".lavish-select-pill")) {
2254
+ event.preventDefault();
2255
+ showAnnotationCard(selected);
2256
+ }
2124
2257
  },
2125
2258
  true
2126
2259
  );
@@ -3001,7 +3134,7 @@ function initDefaultTelemetry(_init) {
3001
3134
  // src/cli.js
3002
3135
  var COMMANDS = /* @__PURE__ */ new Set(["open", "new", "spec", "poll", "end", "stop", "server", "playbook", "design", "setup"]);
3003
3136
  var DESCRIPTION = "Loupe turns a proposed change into a structured visual review surface for the spec phase, so developers grasp a change by looking and clicking instead of reading walls of text. Every Loupe artifact has the same guaranteed shape: \xA7A Current World (a map of the relevant use cases with the blast radius highlighted), \xA7B Grill (interactive cards the developer answers by clicking, each with an open field), and \xA7C Goal Vision (a before -> after of the agreed change). Run `loupe new <html-file>` to scaffold that structure, fill the marked slots, then `loupe <html-file>` to open the review and `loupe poll <html-file>` to receive the developer's grill answers and annotations.";
3004
- var VERSION = "0.1.0";
3137
+ var VERSION = "0.2.0";
3005
3138
  async function run(argv) {
3006
3139
  await ensureStateDir();
3007
3140
  const normalizedArgv = normalizeArgv(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshalliqiu/loupe",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "Inspect a change before you build it: Loupe turns a proposed change into a structured visual spec-review surface. Forked from lavish-axi.",
6
6
  "type": "module",
@@ -14,7 +14,8 @@ metadata:
14
14
  Loupe turns a proposed change into a structured visual review surface for the spec phase, so developers grasp a change by looking and clicking instead of reading walls of text. Every Loupe artifact has the same guaranteed shape: §A Current World (a map of the relevant use cases with the blast radius highlighted), §B Grill (interactive cards the developer answers by clicking, each with an open field), and §C Goal Vision (a before -> after of the agreed change). Run `loupe new <html-file>` to scaffold that structure, fill the marked slots, then `loupe <html-file>` to open the review and `loupe poll <html-file>` to receive the developer's grill answers and annotations.
15
15
 
16
16
  Loupe runs through the `loupe` CLI (a fork of lavish-axi). The commands below assume `loupe`
17
- is on your PATH; if it is not, run it from the repo as `node bin/lavish-axi.js ...`.
17
+ is on your PATH; if it is not, run it on demand with `npx -y @marshalliqiu/loupe ...` (no install
18
+ needed), or from a source checkout as `node bin/lavish-axi.js ...`.
18
19
 
19
20
  ## Request
20
21