diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..144bf1d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Generated by `npm run css`. Minified onto one line, so collapse it in +# diffs and keep it out of language statistics; review src/styles instead. +public/app.css linguist-generated=true diff --git a/.gitignore b/.gitignore index ef18e69..2ee65bc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ node_modules /e2e/playwright-report /e2e/blob-report /e2e/.cache + +# Impeccable critique snapshots are local tooling output, not source. +.impeccable/critique/ diff --git a/.impeccable/critique/2026-08-08T10-17-31Z__src-views-index-html.md b/.impeccable/critique/2026-08-08T10-17-31Z__src-views-index-html.md deleted file mode 100644 index 856b6d0..0000000 --- a/.impeccable/critique/2026-08-08T10-17-31Z__src-views-index-html.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -target: landing page -total_score: 21 -max_score: 40 -na_heuristics: -p0_count: 2 -p1_count: 2 -timestamp: 2026-08-08T10-17-31Z -slug: src-views-index-html ---- -Method: dual-agent (A: design review · B: detector + browser evidence, run isolated and in parallel) - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 2 | `Create endpoint` has no submit state: no disabled, no pending label. On a slow link the primary action is inert for seconds. | -| 2 | Match System / Real World | 3 | The lede promises "timing", but the product captures a relative `Time` timestamp, not duration or latency. "Endpoint" is system jargon for what the visitor wants: a URL to paste. | -| 3 | User Control and Freedom | 3 | One action, no traps, footer exits present: but no preview of what you get before an irreversible-feeling commit. | -| 4 | Consistency and Standards | 3 | `Common use cases` is an uppercase h2 at 0.875rem, breaking DESIGN.md's Two Uppercase Rule and its 0.75rem Label token. `hover:shadow-md` sits on non-interactive articles. | -| 5 | Error Prevention | 1 | No double-submit guard, no CSRF token on `POST /endpoint`, and no statement that the capture URL is public and unauthenticated before you point live webhook traffic at it. | -| 6 | Recognition Rather Than Recall | 1 | The entire product must be imagined. No screenshot, no sample capture, no method badge, no JSON body anywhere on the page. | -| 7 | Flexibility and Efficiency | 2 | Not n/a: PRODUCT.md names the returning regular a co-primary user who transits this page weekly. No autofocus, no Enter-to-create, no browser-local list of endpoints they made today. | -| 8 | Aesthetic and Minimalist Design | 3 | Genuinely restrained; all four anti-references avoided. But ~60% of mobile height is six cards carrying about three distinct ideas. | -| 9 | Error Recovery | 2 | Production rate limit is 125 req/min per IP; a rejected `POST /endpoint` has no designed surface on this page. | -| 10 | Help and Documentation | 1 | Not n/a: the first-timer needs four facts before clicking: free? account? how long does it live? is it public? None appear. | -| **Total** | | **21/40 (52.5%)** | **Acceptable** | - -No heuristic scored n/a. Heuristics 7 and 10 both genuinely apply: 7 because a returning power user is a stated co-primary persona who passes through this exact page, and 10 because "no docs needed" covers operating the tool, not deciding to adopt it. - -## Design Specificity Verdict - -**LLM assessment: a neighbouring dev tool could ship this unchanged.** Swap the wordmark, the button label and the six card titles and this is Webhook.site, RequestBin, Beeceptor or Pipedream. The structure is the canonical open-source-dev-tool template, centred wordmark, centred display h1 with a hard `
`, 18px lede, one filled CTA with a trailing arrow, uppercase micro-label, 2×3 bordered card grid, three grey footer links, executed cleanly and without a single deviation. - -The chassis is genuinely authored and the page obeys DESIGN.md exactly. But the chassis is the deliberately neutral part of the system, and this page uses only the neutral part: - -- **The method badge, DESIGN.md's named signature component and the one place the system licenses full-spectrum colour, appears zero times.** -- The mono/sans evidence split survives as exactly one token: `action` in card two. -- `public/logo.svg`, the periwinkle diamond named in PRODUCT.md as the brand mark, is referenced by zero templates. The page renders 0 `` elements. -- The six icons are stock Feather glyphs chosen by literal noun-matching: a clock for cron, a phone for mobile. None encodes anything about HTTP. - -The North Star is "The Arrivals Board." The front of the arrivals board shows no arrivals. - -**Deterministic scan: 0 findings, exit 0, across all four templates.** The detector did not choke on Go template syntax. But treat this as weak evidence, not a clean bill of health: these files are fragments with no `` and no stylesheet, and every visual property lives in Tailwind utility strings resolved at runtime, so the static scanner had no CSS to analyse. The mode that would have covered rendered CSS, URL scanning, failed: `puppeteer is required for URL scanning`, and **it exited 0 anyway**, so a CI job invoking URL mode would silently pass on a broken toolchain. - -**Overlay: injection succeeded and the detector ran in the page.** Console reported exactly one message: `[impeccable] No anti-patterns found.` Explicit re-runs of `impeccableDetectAsync()` and `impeccableScanAsync()` both returned `[]`. The overlay session has since been torn down, so no overlay is visible in the browser now. - -## Overall Impression - -The craft is real and the restraint is correct. What is missing is not polish, it is evidence and argument. This page describes a live stream of HTTP requests in 170 words of prose and demonstrates it nowhere, then asks for a click. Every one of PRODUCT.md's three "a competitor could not truthfully copy this" claims, no signup, open source and self-hostable, unpolluted captures: is absent from the rendered page. Word counts on the live DOM: `account` 0, `free` 0, `open source` 0, `self-host` 0, `4 hour` 0. - -The single biggest opportunity is the cheapest one on the page: render one honest static capture, a POST badge, a path, three headers, a five-line JSON body, using components that already exist, and put two sentences of trust copy under the button. - -## What's Working - -**The restraint is real and correct for this product.** All four confirmed anti-references are genuinely avoided: no gradient mesh, no tilted dashboard mockup, no testimonial carousel, no green-on-black, no mascot. Measured contrast passes everywhere: h1 at 17.04:1, body copy at 7.58:1, CTA label at 6.46:1, worst case 4.55:1. The page earns "precise, quiet, fast" instead of claiming it. - -**The hero is single-action and that shape is right.** One indigo fill, no competing CTA, no newsletter, no nav to get lost in, five tab stops on the whole page. The One Indigo Rule is honoured exactly, and the CTA's focus ring is the only authored focus style on the page: a 2px indigo-500 ring over a 2px white offset, measured and correct. For a user who is mid-debug and impatient, "click or don't" is the right decision space. - -**Sentence-level copy voice.** "point any client at it", "see exactly what it puts on the wire: headers, body, every byte", and card six's *"is the payload from your side malformed, or mine?"* are written by someone who has had that argument at 6pm on a Friday. That last line is the only sentence on the page a competitor could not have written: and it is buried at the bottom of the sixth card, below the fold on every viewport tested. - -## Priority Issues - -### [P0] The product is invisible on the page that has to sell it -**Why it matters:** PRODUCT.md's Evidence section says it plainly: *"a live capture stream is the demonstration."* The only real evidence this project has, it declines to show. Heuristic 6 and the working-memory checklist item both fail as a direct consequence, and the visitor is asked to spend a click on faith at the moment they are least patient. -**Fix:** Put one static, honest capture between the CTA and the grid: a POST badge in Bench Emerald, `/to/`, three header rows (`content-type`, `user-agent`, `stripe-signature`), five lines of pretty-printed JSON in the borrowed hljs palette. Pure markup, zero JS, on existing panel/hairline/code-well components. This fabricates nothing: it renders the product's own output, not a testimonial or a metric. -**Suggested command:** `$impeccable bolder` - -### [P0] Every positioning claim and every reassurance at the commit point is absent -**Why it matters:** The three claims are free to state, true, and unfalsifiable by a competitor. Meanwhile the CTA creates a resource that is public, unauthenticated, guessable-in-principle and dead in four hours: and states none of that before the click. Retention surfaces only afterward, on the endpoint page. The no-auth fact is never stated anywhere; card six sells shareability as a benefit and never names its corollary. -**Fix:** One line of body copy directly under `Create endpoint`: *"No account, no email, free forever. Requests are deleted after 4 hours, and anyone with the URL can read them."* Plus a footer-adjacent line: *"Open source, MIT licensed, self-hostable: a single Go binary."* Two sentences close the persuasion gap, the trust gap and the proximity failure at once. -**Suggested command:** `$impeccable clarify` - -### [P1] The page ships 42.9% of its bytes to code it cannot use, and compiles 100% of its CSS in the browser -**Why it matters:** Measured on `/`: 158,273 B transferred, 82.2% third-party, of which **67,910 B (42.9%) is dead weight**, highlight.js (43,616 B) with 0 highlight targets on the page, Alpine (16,136 B) with 0 directives, plus `endpoint.js`, `har.js`, `render-body.js`. `document.styleSheets` shows **no first-party stylesheet at all**: every style is generated at runtime by a render-blocking 271 KB third-party script that Tailwind documents as development-only. If jsDelivr is slow, the page renders unstyled. This on a project whose design system explicitly rejects webfonts as a performance commitment. Also: no `preconnect`/`dns-prefetch` for either third-party origin, and `favicon.ico` alone is 15,706 B, 56% of first-party bytes. -**Fix:** Build Tailwind at compile time into a static stylesheet served from the Go binary. Move highlight.js, Alpine, `endpoint.js`, `har.js` and `render-body.js` out of `layouts/main.html` into `endpoint.html` where they are used. -**Suggested command:** `$impeccable optimize` - -### [P1] Six cards advertise interactivity they do not have -**Why it matters:** Each `
` carries `hover:shadow-md transition` with `cursor: auto`, no href, no handler, no tabindex, no role. DESIGN.md's Shadow-Is-A-Response Rule says elevation above the resting whisper exists only as a response: and a response implies an action. Six surfaces lift under the cursor and do nothing, on a page where the visitor is hunting for what to click. Worse, the grid is a six-option decision point where every option resolves to no action, and cards one, three and five are three framings of the same activity. -**Fix:** Pick a side. Either make each card a real link that creates an endpoint and lands with a matching example pre-filled in the *Send a test request* panel, or delete `hover:shadow-md` and let them sit flat. Cut six to three either way. -**Suggested command:** `$impeccable distill` - -### [P2] Neither arrival path PRODUCT.md names is served by the page's metadata -**Why it matters:** The `` is literally `httphq`. No `og:*`, no `twitter:*`, no canonical. PRODUCT.md says the first-timer *"arrives from a search result or a colleague's link"*: a search result shows the bare word "httphq"; a Slack paste unfurls as a naked grey link with no image, title or description. `robots.txt` already allows only `/` and `/contact`, so this is the indexed surface. -**Fix:** `<title>httphq: inspect HTTP requests in real time`, the four `og:` tags, `twitter:card=summary_large_image`, and a canonical. The `og:image` is the obvious first home for `public/logo.svg`, currently used by nothing a human sees at size. -**Suggested command:** `$impeccable clarify` - -## Persona Red Flags - -**Jordan (first-timer, mid-debug, from a search result)**: `Create endpoint` is the wrong noun, Jordan wants a URL to paste into the Stripe webhook field open in the next tab, not an "endpoint". Nothing on the page says free or no-signup, and Jordan has been burned before. The lede promises "timing", which the product does not capture, a credibility debit inside the first 90 seconds. Jordan's first sight of the actual product is an empty dashed box reading "Waiting for requests…", because the landing page did nothing to establish what will fill it. - -**Riley (stress tester)**: Double-click on `Create endpoint` over a slow link fires two POSTs, no disabled state, nothing in `index.js`. The form has **no CSRF token and no confirmation step**, which Assessment B demonstrated accidentally: its automated interaction probe submitted the hero form and created a real endpoint record on the running server. Any cross-origin form post can mint endpoints. The 125 req/min rate limit has no designed error surface here. Riley reads card six, correctly infers there is no auth, and finds nothing to size the risk with. The header `` is 976px wide at 1440, clicking anywhere in the header band navigates. - -**Casey (mobile, 390×844)**: All three footer links fail the WCAG 2.2 AA 24×24 minimum: measured 119.5×17, 138×20 and 71×17, with 13.5px and 16.5px vertical gaps, under the 24px spacing exception that would otherwise rescue them. They are the only escape hatches on the page. 1.87 screens of scroll, ~900px of it the six stacked cards. `Create endpoint` is a 195px pill inside a 358px column rather than the full-width control the system's own stacking implies. No repeated or sticky CTA, so the only action is off-screen for the last 1.2 screens. - -**Sam (the integrations engineer who opens httphq three times a week, derived from PRODUCT.md's returning power user)**: Sam's entire relationship with `/` is land, click, leave, and Sam re-reads the same six cards every visit, extracting nothing. Method filtering, full-text search, HAR export and the send-a-test-request panel, the four things PRODUCT.md names as *why Sam returns*, appear on this page zero times; Sam cannot send a colleague a link to a page describing what the tool does for a regular. Sam wants the endpoint from twenty minutes ago and there is no path to it: a browser-local list of "endpoints you created here (they expire in 4 hours)" honours both the no-accounts and the ephemeral principle exactly, and its absence reads as a decision never made rather than one made and rejected. - -## Minor Observations - -- `
` duplicates the visible `

`: screen readers announce the name twice. Use `aria-labelledby` or drop one. -- Heading order is clean (h1 → h2 → 6× h3, no skips), `lang="en"` present, all 5 interactive elements have accessible names, all 8 SVGs correctly hidden, `rel="noopener"` present. Zero console messages and 10/10 successful requests on load. -- Only the CTA has an authored focus style; the four links fall back to Chrome's UA default blue, which is engine-specific and unrelated to the indigo system. The CTA's ring also *fades in* via `transition` rather than appearing instantly. -- LCP element is the hero lede `

`, not the `

`: the largest painted element is the supporting copy, not the headline. -- No `prefers-reduced-motion` rule exists in any loaded stylesheet while 7 elements carry `.transition`. -- Parallelism breaks on card four: five titles are verb-first (Test, Inspect, Debug, Verify, Share), and "Mobile & IoT traffic" names a channel instead of a job. -- Footer says *"A community project by Formspark"*; PRODUCT.md records Formspark as **sponsor**. Worth aligning. -- Measured performance is excellent where localhost can measure it: LCP 128ms, CLS 0, TTFB 2ms. The payload issue above is specifically the cold-cache, real-network case localhost cannot reproduce. - -## Questions to Consider - -1. The North Star is "The Arrivals Board." What is the argument for describing a live stream in six paragraphs instead of rendering one honest static capture beneath the button, from components that already exist and cost nothing to place? -2. Three claims sit in PRODUCT.md as the things no neighbouring tool could truthfully copy, and none of the three appear on this page. What is the six-card grid buying that those three sentences aren't? -3. The CTA creates a public, unauthenticated, guessable-in-principle URL that dies in four hours, and says none of that before the click. Which of those six facts would you be uncomfortable stating above the button: and what does that discomfort tell you? -4. Half this page's height exists to persuade a first-timer who "will not read documentation to get started", while the regular has read those cards fifty times. If neither audience reads the grid, who is it for? diff --git a/DESIGN.md b/DESIGN.md index d013d3a..dd7de42 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,6 +1,6 @@ --- name: httphq -description: A live arrivals board for HTTP requests: neutral chassis, one indigo control, color reserved for classifying traffic. +description: "A live arrivals board for HTTP requests. Neutral chassis, one indigo control, color reserved for classifying traffic." colors: beacon-periwinkle: "#707ee7" signal-indigo: "oklch(51.1% 0.262 276.966)" @@ -239,7 +239,7 @@ Any method outside this set falls back to the HEAD pair. - **Hairline** (`oklch(92.9% 0.013 255.508)`): the 1px seam around panels, code wells and the sticky filter rail. The primary edge in the system. - **Hairline Faint** (`oklch(96.8% 0.007 247.896)`): the internal rules between header rows and detail rows, one step quieter than a panel edge so nested structure never out-shouts the container. - **Control Edge** (`oklch(86.9% 0.022 252.894)`): the visible stroke on inputs, selects, and secondary buttons; also the dashed edge of the waiting state. -- **Quiet Ink** (`oklch(70.4% 0.04 256.788)`): placeholders and the italic "None" for an absent query string or body. +- **Quiet Ink** (`oklch(70.4% 0.04 256.788)`): input placeholders only. It is deliberately not used for an absent value: the italic "None" that marks one measured 2.63:1 on white in this step, so it carries Label Ink instead. - **Label Ink** (`oklch(55.4% 0.046 257.417)`): field labels, meta rows, timestamps, and the resting colour of every text-only control. - **Prose Ink** (`oklch(44.6% 0.043 257.281)`): descriptive sentences on the home and contact pages. - **Control Ink** (`oklch(37.2% 0.044 257.287)`): secondary button labels, the disclosure summary, and the wordmark. @@ -419,6 +419,15 @@ must never be borrowed for a disabled, errored, or drop-target surface. ## Components +The system is implemented, not only described. `src/styles/components.css` +carries the classes these entries specify (`.btn` and its variants, `.field`, +`.field-label`, `.panel`, `.kv-row`, `.icon`, `.badge`, `.code-block`, +`.empty-value`), and templates compose them rather than repeating utility +strings. Utilities stay the default for one-off composition; anything whose +tokens must not drift between call sites belongs in that file. Before adding a +variant, check whether an existing class should absorb it: eight spellings of one +button is how two nominally identical controls ended up 4px apart. + ### Buttons - **Character:** calm instruments. Sized for accuracy rather than presence; nothing asks to be admired. @@ -426,7 +435,7 @@ must never be borrowed for a disabled, errored, or drop-target surface. - **Primary:** signal indigo fill, white label, medium weight, resting whisper shadow. Two sizes only: 0.75rem/1.5rem padding at 1rem type for the page's main action, and 0.5rem/1rem at 0.875rem inside panels. - **Secondary:** white fill, control-edge stroke, control-ink label at 0.875rem medium, 0.5rem/0.75rem padding. Hovers to the board field. - **Destructive:** rose wash fill, rose edge stroke, deep rose label, 0.375rem/0.75rem padding. Hovers one wash step darker. -- **Text-only:** no fill, no border, label ink at 0.75–0.875rem, resolving to signal indigo on hover, or to alert rose when the action deletes. Used for _Copy_, _Copy request_, and per-request _Delete_. +- **Text-only:** no fill, no border, label ink at 0.875rem, resolving to signal indigo on hover, or to alert rose when the action deletes. Used for _Copy_, _Copy request_, and per-request _Delete_. It carries its own padding so the hit area clears the 24px target minimum, and the same authored focus ring as every other control: inside a card that repeats N times, falling back to the browser default multiplies the inconsistency by N. - **Hover / Focus:** fills shift one step lighter on primary, one step darker on destructive. Focus is never suppressed: `outline: none` is always paired with a 2px `focus-visible` ring in **signal indigo lit** (the lighter step, not the fill colour), with a 2px white offset ring on filled buttons, so the ring reads against the indigo it sits on. Destructive controls ring in alert rose instead. - **Disabled:** 50% opacity and `not-allowed` cursor; used on _Copy all_ when the stream is empty. @@ -437,7 +446,7 @@ must never be borrowed for a disabled, errored, or drop-target surface. - **Shadow Strategy:** resting whisper only. No surface in the product lifts on hover: elevation change is reserved for something that responds to the cursor, and every card here is static content. - **Border:** 1px hairline, always. - **Internal Padding:** 1rem below 40rem, 1.25rem above. -- **Composition:** a request article is a header rule-separated from its body, with the method badge and UUID on the left and the copy/delete actions on the right; the body is a stack of labelled regions rather than a nested set of boxes. Its key/value rows follow the flex behaviour described in Layout: 10rem label column above the breakpoint, label stacked above value below it. +- **Composition:** a request article is a header rule-separated from its body, with the method badge and the requested path on the left and the copy/delete actions on the right. The path carries the query string inline, because to the person reading it they are one thing: the URL the client addressed; the body is a stack of labelled regions rather than a nested set of boxes. Its key/value rows follow the flex behaviour described in Layout: 10rem label column above the breakpoint, label stacked above value below it. ### Inputs / Fields @@ -482,7 +491,7 @@ container never signals which path ran; only the content does. - **XML**: highlighted in place, unformatted; the payload keeps whatever whitespace it arrived with. - **Anything else**: HTML-escaped raw text, no highlighting, no reformatting. -An absent body or query string renders as an italic _None_ in quiet ink rather +An absent body or query string renders as an italic _None_ in label ink rather than an empty well, so a card with nothing in it still reads as a complete record. Escaping happens on every path: a captured body is attacker-controlled text and is never trusted as markup. diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index 1c214c4..6ad121b 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -339,7 +339,7 @@ test.describe("Endpoint screen", () => { await page.locator('[data-test="method-filter"]').selectOption("PUT"); await expect( page.locator('[data-test="copy-all-har-label"]'), - ).toContainText("Copy all (1)"); + ).toContainText("Copy shown (1)"); await page.locator('[data-test="copy-all-har"]').click(); const clipboard = await page.evaluate(() => @@ -374,7 +374,10 @@ test.describe("Endpoint screen", () => { await search.fill("Nothing-matches-this"); await expect(results).toContainText("0 results"); - await expect(requests).toContainText("Waiting for requests"); + // A stream hidden by a filter is not a stream that never arrived, so it + // must not claim to be still waiting. + await expect(requests).toContainText("No requests match this filter"); + await expect(requests).not.toContainText("Waiting for requests"); }); test("filters by header key/value via the search box", async ({ @@ -428,15 +431,60 @@ test.describe("Endpoint screen", () => { ); }); - test("delete-all clears every request", async ({ page, request }) => { + test("delete-all asks before clearing every request", async ({ + page, + request, + }) => { await post(request, endpointUrl, { data: "x" }); await post(request, endpointUrl, { data: "y" }); + await page.locator('[data-test="delete-requests"]').click(); + // Destructive and unrecoverable, so it confirms first; the requests are + // still there until the confirmation is accepted. + await expect(page.locator('[data-test="delete-confirm"]')).toBeVisible(); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="delete-cancel"]').click(); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="delete-requests"]').click(); + await page.locator('[data-test="delete-confirm-button"]').click(); await expect(page.locator('[data-test="requests"]')).toContainText( "Waiting for requests", ); }); + test("a filtered-empty stream is not reported as waiting", async ({ + page, + request, + }) => { + await post(request, endpointUrl, { data: "x" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + + await page + .locator('[data-test="search-input"]') + .fill("nothing-matches-this-term"); + await expect(page.locator('[data-test="empty-filtered"]')).toBeVisible(); + await expect(page.locator('[data-test="empty-waiting"]')).toHaveCount(0); + + await page.locator('[data-test="clear-filters"]').click(); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + }); + + test("a malformed header line is reported instead of dropped", async ({ + page, + }) => { + await page.locator('[data-test="send-toggle"]').click(); + await page + .locator('[data-test="send-headers"]') + .fill("Authorization Bearer sk_test_123"); + await page.locator('[data-test="send-submit"]').click(); + await expect(page.locator('[data-test="send-status"]')).toContainText( + "is not a header", + ); + await expect(page.locator('[data-test="request"]')).toHaveCount(0); + }); + test("delete-request removes a single card", async ({ page, request }) => { await post(request, endpointUrl, { data: "first" }); const response = await request.post(endpointUrl, { diff --git a/public/app.css b/public/app.css index 42422c4..f0eda88 100644 --- a/public/app.css +++ b/public/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-700:oklch(50.8% .118 165.612);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-900:oklch(35.9% .144 278.697);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-700:oklch(49.1% .27 292.581);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}button:not(:disabled),summary,[role=button]:not(:disabled){cursor:pointer}::selection{background-color:var(--color-indigo-100);color:var(--color-indigo-900)}:root{accent-color:var(--color-indigo-600);color-scheme:light}}@layer components{.app-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.083l3.71-3.853a.75.75 0 111.08 1.04l-4.25 4.41a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.1em;padding-right:2rem}.loading-dots:after{content:"";text-align:left;width:1.5ch;animation:1.2s steps(4,end) infinite httphq-dots;display:inline-block}}@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.z-10{z-index:10}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-9{height:calc(var(--spacing) * 9)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-9{width:calc(var(--spacing) * 9)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-rose-200{border-color:var(--color-rose-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/95{background-color:#f8fafcf2}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/95{background-color:color-mix(in oklab, var(--color-slate-50) 95%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-white{background-color:var(--color-white)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.text-\[\#005cc5\]{color:#005cc5}.text-\[\#032f62\]{color:#032f62}.text-amber-700{color:var(--color-amber-700)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-700{color:var(--color-emerald-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-rose-700{color:var(--color-rose-700)}.text-sky-700{color:var(--color-sky-700)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-all{-webkit-user-select:all;user-select:all}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:bg-indigo-500:hover{background-color:var(--color-indigo-500)}.hover\:bg-rose-100:hover{background-color:var(--color-rose-100)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-rose-600:hover{color:var(--color-rose-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-indigo-500:focus-visible{--tw-ring-color:var(--color-indigo-500)}.focus-visible\:ring-rose-500:focus-visible{--tw-ring-color:var(--color-rose-500)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:inline{display:inline}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-auto{width:auto}.sm\:max-w-\[10rem\]{max-width:10rem}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:pt-16{padding-top:calc(var(--spacing) * 16)}.sm\:pb-5{padding-bottom:calc(var(--spacing) * 5)}.sm\:pb-8{padding-bottom:calc(var(--spacing) * 8)}.sm\:pb-12{padding-bottom:calc(var(--spacing) * 12)}.sm\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.sm\:text-right{text-align:right}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}}@keyframes httphq-dots{0%{content:""}25%{content:"."}50%{content:".."}75%,to{content:"..."}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}.loading-dots:after{content:"...";animation:none}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-900:oklch(35.9% .144 278.697);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-700:oklch(49.1% .27 292.581);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}button:not(:disabled),summary,[role=button]:not(:disabled){cursor:pointer}::selection{background-color:var(--color-indigo-100);color:var(--color-indigo-900)}:root{accent-color:var(--color-indigo-600);color-scheme:light}}@layer components{.app-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.083l3.71-3.853a.75.75 0 111.08 1.04l-4.25 4.41a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.1em;padding-right:2rem}.loading-dots:after{content:"";text-align:left;width:1.5ch;animation:1.2s steps(4,end) infinite httphq-dots;display:inline-block}.focus-ring:focus{--tw-outline-style:none;outline-style:none}.focus-ring:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-indigo-500)}.btn{justify-content:center;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);display:inline-flex}.btn:focus{--tw-outline-style:none;outline-style:none}.btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-indigo-500)}.btn:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-slate-300);background-color:var(--color-white);color:var(--color-slate-700)}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-slate-50)}}.btn-danger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-rose-200);background-color:var(--color-rose-50);color:var(--color-rose-700)}@media (hover:hover){.btn-danger:hover{background-color:var(--color-rose-100)}}.btn-danger:focus-visible{--tw-ring-color:var(--color-rose-500)}.btn-primary{background-color:var(--color-indigo-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--color-white);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.btn-primary:hover{background-color:var(--color-indigo-500)}}.btn-primary:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.btn-inline{align-items:center;gap:calc(var(--spacing) * 1);border-radius:var(--radius-sm);padding-inline:calc(var(--spacing) * 1);padding-block:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-slate-500);display:inline-flex}@media (hover:hover){.btn-inline:hover{color:var(--color-indigo-600)}}.btn-inline:focus{--tw-outline-style:none;outline-style:none}.btn-inline:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-indigo-500)}@media (hover:hover){.btn-inline-danger:hover{color:var(--color-rose-600)}}.btn-inline-danger:focus-visible{--tw-ring-color:var(--color-rose-500)}.field{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-slate-300);background-color:var(--color-white);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));display:block}.field:focus{--tw-outline-style:none;outline-style:none}.field:focus-visible{border-color:var(--color-indigo-500);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-indigo-400)}.field-mono{font-family:var(--font-mono)}.field-label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-slate-500);text-transform:uppercase;display:block}.region-label{margin-bottom:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-slate-500);text-transform:uppercase}.panel{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-slate-200);background-color:var(--color-white);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.panel-flat{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-slate-200);background-color:var(--color-white)}.kv-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-slate-100);padding-block:calc(var(--spacing) * 1.5);flex-direction:column;display:flex}.kv-row:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (min-width:40rem){.kv-row{gap:calc(var(--spacing) * 3);flex-direction:row}}.kv-row-tight{padding-block:calc(var(--spacing) * 1)}.kv-key{color:var(--color-slate-500)}@media (min-width:40rem){.kv-key{width:calc(var(--spacing) * 40);flex-shrink:0}}.kv-value{min-width:calc(var(--spacing) * 0)}.icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}.badge{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);text-transform:uppercase;border-radius:.25rem;flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.code-block{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-slate-200);background-color:var(--color-slate-50);padding:calc(var(--spacing) * 3);font-family:var(--font-mono);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));border-radius:.25rem;overflow:auto}.empty-value{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-slate-500);font-style:italic}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.z-10{z-index:10}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-9{height:calc(var(--spacing) * 9)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-9{width:calc(var(--spacing) * 9)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-rose-200{border-color:var(--color-rose-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-500{background-color:var(--color-rose-500)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/95{background-color:#f8fafcf2}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/95{background-color:color-mix(in oklab, var(--color-slate-50) 95%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-white{background-color:var(--color-white)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.text-\[\#005cc5\]{color:#005cc5}.text-\[\#032f62\]{color:#032f62}.text-amber-700{color:var(--color-amber-700)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-700{color:var(--color-emerald-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-rose-700{color:var(--color-rose-700)}.text-sky-700{color:var(--color-sky-700)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-all{-webkit-user-select:all;user-select:all}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:bg-indigo-500:hover{background-color:var(--color-indigo-500)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-indigo-500:focus-visible{--tw-ring-color:var(--color-indigo-500)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}@media (min-width:40rem){.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:inline{display:inline}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-auto{width:auto}.sm\:max-w-\[10rem\]{max-width:10rem}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:pt-16{padding-top:calc(var(--spacing) * 16)}.sm\:pb-5{padding-bottom:calc(var(--spacing) * 5)}.sm\:pb-8{padding-bottom:calc(var(--spacing) * 8)}.sm\:pb-12{padding-bottom:calc(var(--spacing) * 12)}.sm\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.sm\:text-right{text-align:right}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}}@keyframes httphq-dots{0%{content:""}25%{content:"."}50%{content:".."}75%,to{content:"..."}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}.loading-dots:after{content:"...";animation:none}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} \ No newline at end of file diff --git a/public/endpoint.js b/public/endpoint.js index 00bced4..8c34021 100644 --- a/public/endpoint.js +++ b/public/endpoint.js @@ -14,6 +14,18 @@ OPTIONS: "bg-sky-50 text-sky-700", }; + // How many cards are in the DOM at once, and how many more each reveal adds. + const PAGE_SIZE = 25; + + // Relative timestamps are re-evaluated on this interval. Anything longer and + // "a few seconds ago" is still on screen a minute later. + const TICK_MS = 30_000; + + // Reconnect backoff bounds. The page is designed to sit open for hours, so a + // dropped socket is expected rather than exceptional. + const RECONNECT_MIN_MS = 1_000; + const RECONNECT_MAX_MS = 30_000; + function transformRequest(r) { return { ...r, createdAt: new Date(r.createdAt) }; } @@ -47,6 +59,15 @@ return this.requests.filter((r) => r.method === this.methodFilter); }, + get filtered() { + return !!this.methodFilter || !!this.search; + }, + + clearFilters() { + this.methodFilter = ""; + this.search = ""; + }, + fetchRequests() { if (!this.endpointId) return; const url = @@ -91,12 +112,24 @@ function endpointPageFactory() { return { METHODS, - sendForm: { method: "POST", body: "", headers: "" }, + pageSize: PAGE_SIZE, + sendForm: { method: "POST", path: "", body: "", headers: "" }, sendStatus: "", - copyLabel: "Copy", + sendFailed: false, copiedKey: null, + pendingDeleteAll: false, + announcement: "", + connection: "connecting", + renderLimit: PAGE_SIZE, + // Bumped on an interval purely so relative-time expressions that read it + // re-evaluate. Alpine has no other reason to know that wall-clock time + // passed, so without this every timestamp freezes at first render. + tick: 0, _baseTitle: document.title, _unread: 0, + _reconnectDelay: RECONNECT_MIN_MS, + _socket: null, + _closed: false, // Alpine calls this once when the component mounts. Endpoint id and // WebSocket URL come from data-* attributes on the root element so the @@ -105,16 +138,59 @@ const endpointId = this.$el.dataset.endpointId; if (!endpointId) return; const scheme = location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${scheme}//${location.host}/ws/${endpointId}`; + this._wsUrl = `${scheme}//${location.host}/ws/${endpointId}`; Alpine.store("main").setEndpoint(endpointId); - this._connectWebSocket(wsUrl); + this._connectWebSocket(); + setInterval(() => this.tick++, TICK_MS); document.addEventListener("visibilitychange", () => { if (!document.hidden) this._clearUnread(); }); + window.addEventListener("beforeunload", () => { + this._closed = true; + if (this._socket) this._socket.close(); + }); }, - _connectWebSocket(url) { - const socket = new WebSocket(url); + // Which of the three empty states applies. They are distinct on purpose: + // "nothing has arrived", "your filter hides everything" and "the + // connection is gone" are different facts and previously shared one panel + // that asserted the first regardless of which was true. + get streamState() { + if (Alpine.store("main").visibleRequests.length > 0) return "list"; + if (Alpine.store("main").filtered) return "filtered"; + return "waiting"; + }, + + get renderedRequests() { + return Alpine.store("main").visibleRequests.slice(0, this.renderLimit); + }, + + get hiddenCount() { + return Math.max( + 0, + Alpine.store("main").visibleRequests.length - this.renderLimit, + ); + }, + + showMore() { + this.renderLimit += PAGE_SIZE; + }, + + _connectWebSocket() { + if (this._closed) return; + this.connection = + this.connection === "live" ? "connecting" : this.connection; + const socket = new WebSocket(this._wsUrl); + this._socket = socket; + + socket.addEventListener("open", () => { + this.connection = "live"; + this._reconnectDelay = RECONNECT_MIN_MS; + // Traffic that landed while the socket was down is not replayed, so + // refetch to close the gap rather than silently missing it. + Alpine.store("main").fetchRequests(); + }); + socket.addEventListener("message", (payload) => { let request; try { @@ -123,14 +199,28 @@ return; } Alpine.store("main").addRequest(request); + this.announce(`${request.method} request received`); if (document.hidden) { this._unread += 1; this._renderUnread(); } }); - socket.addEventListener("open", () => console.debug("ws:open")); - socket.addEventListener("close", () => console.debug("ws:close")); - socket.addEventListener("error", () => console.debug("ws:error")); + + socket.addEventListener("close", () => this._scheduleReconnect()); + socket.addEventListener("error", () => socket.close()); + }, + + // Exponential backoff to a 30s ceiling. A page left open overnight should + // keep trying without hammering the server once it is genuinely gone. + _scheduleReconnect() { + if (this._closed) return; + this.connection = "disconnected"; + const delay = this._reconnectDelay; + this._reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS); + setTimeout(() => { + this.connection = "connecting"; + this._connectWebSocket(); + }, delay); }, _renderUnread() { @@ -160,15 +250,27 @@ }, formatTimeAgo: window.formatTimeAgo, + formatClock: window.formatClock, + formatBytes: window.formatBytes, renderBody: window.renderBody, - async copy(text) { + // Screen readers get no navigation on this page, so every change that a + // sighted user sees has to be spoken here or it does not exist. + announce(message) { + this.announcement = message; + }, + + async copy(text, key) { try { await window.copyToClipboard(text); - this.copyLabel = "Copied!"; - setTimeout(() => (this.copyLabel = "Copy"), 1500); + this.copiedKey = key; + this.announce("Copied to clipboard"); + setTimeout(() => { + if (this.copiedKey === key) this.copiedKey = null; + }, 1500); } catch (err) { console.error(err); + this.announce("Copy failed"); } }, @@ -179,29 +281,59 @@ try { await window.copyToClipboard(window.buildHarExport(requests)); this.copiedKey = key; + this.announce(`Copied ${requests.length} requests to clipboard`); setTimeout(() => { if (this.copiedKey === key) this.copiedKey = null; }, 1500); } catch (err) { console.error(err); + this.announce("Copy failed"); } }, + confirmDeleteAll() { + this.pendingDeleteAll = true; + }, + + async deleteAllConfirmed() { + const count = Alpine.store("main").requests.length; + this.pendingDeleteAll = false; + await Alpine.store("main").deleteRequests(); + this.announce(`Deleted ${count} requests`); + }, + async sendCustom() { const store = Alpine.store("main"); if (!store.endpointId) return; - const headers = window.parseHeaderLines(this.sendForm.headers); + const parsed = window.parseHeaderLines(this.sendForm.headers); + if (parsed.invalid.length) { + this.sendFailed = true; + this.sendStatus = `Line ${parsed.invalid[0].line} is not a header: "${parsed.invalid[0].text}". Use Key: Value.`; + this.announce(this.sendStatus); + return; + } + const path = this.sendForm.path.replace(/^\/+/, ""); + const target = `/to/${store.endpointId}${path ? "/" + path : ""}`; try { + this.sendFailed = false; this.sendStatus = "Sending…"; - const res = await fetch(`/to/${store.endpointId}`, { + const res = await fetch(target, { method: this.sendForm.method, - headers, + headers: parsed.headers, body: this.sendForm.body || undefined, }); - this.sendStatus = res.ok ? "Sent" : `Error: HTTP ${res.status}`; - setTimeout(() => (this.sendStatus = ""), 2000); + this.sendFailed = !res.ok; + this.sendStatus = res.ok + ? `Sent ${this.sendForm.method}` + : `The server rejected it: HTTP ${res.status}.`; + this.announce(this.sendStatus); + if (res.ok) setTimeout(() => (this.sendStatus = ""), 2000); } catch (err) { - this.sendStatus = `Error: ${err.message}`; + // The browser refuses some combinations outright, e.g. a body on GET. + // Report its reason rather than swallowing it, and keep it on screen. + this.sendFailed = true; + this.sendStatus = `Could not send: ${err.message}`; + this.announce(this.sendStatus); } }, }; diff --git a/public/index.js b/public/index.js index db181da..f670ace 100644 --- a/public/index.js +++ b/public/index.js @@ -46,19 +46,48 @@ window.copyToClipboard = async function (text) { } }; -/* Header parsing for the send-custom-request panel */ +/* Absolute clock time. This is the primary timestamp on a capture: an arrivals + board shows when something landed, and a relative age alone goes stale the + moment it is rendered. */ + +const clockFormatter = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", +}); + +window.formatClock = function (date) { + return clockFormatter.format(date); +}; + +/* Byte sizes for captured bodies. */ + +window.formatBytes = function (bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +/* Header parsing for the send-custom-request panel. + + Malformed lines are returned rather than dropped. Silently discarding a line + that is one typo away from an Authorization header, and then reporting + success, sends the user chasing an auth bug that does not exist. */ window.parseHeaderLines = function (text) { - const out = {}; - if (!text) return out; - for (const line of text.split(/\r?\n/)) { + const headers = {}; + const invalid = []; + if (!text) return { headers, invalid }; + text.split(/\r?\n/).forEach((line, i) => { const trimmed = line.trim(); - if (!trimmed) continue; + if (!trimmed) return; const idx = trimmed.indexOf(":"); - if (idx <= 0) continue; - const k = trimmed.slice(0, idx).trim(); - const v = trimmed.slice(idx + 1).trim(); - if (k) out[k] = v; - } - return out; + const key = idx > 0 ? trimmed.slice(0, idx).trim() : ""; + if (!key) { + invalid.push({ line: i + 1, text: trimmed }); + return; + } + headers[key] = trimmed.slice(idx + 1).trim(); + }); + return { headers, invalid }; }; diff --git a/public/render-body.js b/public/render-body.js index 8e40d90..7571037 100644 --- a/public/render-body.js +++ b/public/render-body.js @@ -1,6 +1,14 @@ /* Body rendering: best-effort content-type-aware display for a captured request body (JSON pretty-print, multipart/form-data part list, XML - highlighting, or escaped raw text). Loaded on every page. */ + highlighting, or escaped raw text). Loaded on the endpoint page only. */ + +/* Above this many characters the body is shown as plain escaped text. + Highlighting runs synchronously on the main thread and emits roughly one + element per token, so a large payload costs hundreds of milliseconds and tens + of thousands of nodes to colour text the reader has to scroll past anyway. + The body is still shown in full and still copyable; only the colour is + dropped. */ +const HIGHLIGHT_LIMIT = 40_000; function htmlEscape(s) { return String(s) @@ -11,12 +19,16 @@ function htmlEscape(s) { .replaceAll("'", "'"); } -function highlightPrettyJSON(value) { - const pretty = JSON.stringify(value, null, 2); - if (window.hljs && window.hljs.getLanguage("json")) { - return window.hljs.highlight(pretty, { language: "json" }).value; +function highlight(text, language) { + if (text.length > HIGHLIGHT_LIMIT) return htmlEscape(text); + if (window.hljs && window.hljs.getLanguage(language)) { + return window.hljs.highlight(text, { language }).value; } - return htmlEscape(pretty); + return htmlEscape(text); +} + +function highlightPrettyJSON(value) { + return highlight(JSON.stringify(value, null, 2), "json"); } /* Case-insensitive lookup into a headers object whose values are either a @@ -133,13 +145,8 @@ window.renderBody = function (body, headers) { // not JSON } // Heuristic: looks like XML/HTML if it starts with '<' - const trimmed = body.trimStart(); - if ( - trimmed.startsWith("<") && - window.hljs && - window.hljs.getLanguage("xml") - ) { - return window.hljs.highlight(body, { language: "xml" }).value; + if (body.trimStart().startsWith("<")) { + return highlight(body, "xml"); } return htmlEscape(body); }; diff --git a/src/styles/app.css b/src/styles/app.css index f8927bc..53c481d 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -2,7 +2,12 @@ Everything here is import and configuration only; rules live in the modules so each one stays small enough to read in full. */ -@import "tailwindcss"; +/* source(none) turns off automatic detection so only the directives below are + scanned. Left on, Tailwind walks the whole repository and mines every + non-ignored file for class-like strings, which means prose that merely + discusses a class name (DESIGN.md, a critique write-up) silently adds rules + to the shipped stylesheet, and editing .gitignore changes the build output. */ +@import "tailwindcss" source(none); @import "./base.css"; @import "./components.css"; diff --git a/src/styles/components.css b/src/styles/components.css index 9741cf0..1cf8b95 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1,5 +1,12 @@ -/* Patterns that utility classes cannot express: a native control restyled from - scratch, and a text affordance built from generated content. */ +/* Component classes for patterns that repeat across templates. Utilities stay + the default for one-off composition; anything that appears more than twice, or + whose tokens must not drift between call sites, lives here. + + Focus is one system, not two: every control uses :focus-visible so a mouse + click never paints a ring on a button, while text fields still show one + because browsers treat click-focus on them as keyboard modality. The two ring + weights are deliberate and recorded in DESIGN.md: 2px indigo-500 on controls, + 1px indigo-400 plus a border shift on fields. */ @layer components { /* Native select with the chevron drawn in, anchored right after the value. @@ -24,4 +31,108 @@ text-align: left; animation: httphq-dots 1.2s steps(4, end) infinite; } + + /* Focus ring, applied by the component classes below. Exposed separately for + the handful of elements that are not buttons or fields. */ + .focus-ring { + @apply focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500; + } + + /* Buttons. One geometry, one focus treatment; variants change colour only. */ + .btn { + @apply inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium; + @apply focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500; + @apply disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-secondary { + @apply border border-slate-300 bg-white text-slate-700 hover:bg-slate-50; + } + + .btn-danger { + @apply border border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100; + @apply focus-visible:ring-rose-500; + } + + .btn-primary { + @apply bg-indigo-600 px-4 py-2 text-white shadow-sm hover:bg-indigo-500; + @apply focus-visible:ring-offset-2; + } + + /* Text-only action inside a card. Padded to clear the 24px target minimum + without changing where the label sits. */ + .btn-inline { + @apply inline-flex items-center gap-1 rounded-sm px-1 py-1 text-sm text-slate-500; + @apply hover:text-indigo-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500; + } + + .btn-inline-danger { + @apply hover:text-rose-600 focus-visible:ring-rose-500; + } + + /* Form fields. The mono variant is for captured-data entry. */ + .field { + @apply block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm; + @apply focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-400 focus-visible:border-indigo-500; + } + + .field-mono { + @apply font-mono; + } + + .field-label { + @apply block text-xs font-medium text-slate-500 uppercase tracking-wide mb-1; + } + + /* Section label inside a card. Same token as a field label with the spacing + a region heading needs. */ + .region-label { + @apply text-xs font-medium text-slate-500 uppercase tracking-wide mb-2; + } + + /* Surfaces. panel carries the resting whisper; panel-flat is the same edge + without it, for surfaces that sit inside another panel. */ + .panel { + @apply rounded-lg border border-slate-200 bg-white shadow-sm; + } + + .panel-flat { + @apply rounded-lg border border-slate-200 bg-white; + } + + /* Key/value row. Stacks below the breakpoint so a value is never squeezed + into what is left of a fixed label column on a phone. */ + .kv-row { + @apply flex flex-col sm:flex-row sm:gap-3 py-1.5 border-b border-slate-100 last:border-b-0; + } + + .kv-row-tight { + @apply py-1; + } + + .kv-key { + @apply text-slate-500 sm:w-40 sm:shrink-0; + } + + .kv-value { + @apply min-w-0; + } + + .icon { + @apply w-4 h-4 shrink-0; + } + + .badge { + @apply inline-flex items-center justify-center rounded px-2 py-0.5 text-xs font-semibold uppercase tracking-wide shrink-0; + } + + .code-block { + @apply text-xs font-mono bg-slate-50 border border-slate-200 rounded p-3 overflow-auto; + } + + /* Absent value. slate-500 rather than slate-400: the lighter step measured + 2.63:1 on white, under the 4.5:1 floor. */ + .empty-value { + @apply text-xs text-slate-500 italic; + } } diff --git a/src/views/endpoint.html b/src/views/endpoint.html index d3ee4f7..47c18f5 100644 --- a/src/views/endpoint.html +++ b/src/views/endpoint.html @@ -5,30 +5,35 @@ x-data="endpointPage" data-endpoint-id="{{.EndpointID}}" > +

Captured requests for {{.EndpointID}}

+ + +

+ -
-
-
- Your unique URL -
+
+
+

+ Send requests to this URL +

{{.EndpointURL}}
+

+ Anyone with this URL can read every request sent to it. Requests are + deleted after 4 hours. +

-
-
+
+
- Send a test request +

+ Send a test request +

Method + Method + + -
-

+
+

@@ -138,26 +172,20 @@
- +
-

- + + Live - - - - - Requests are deleted after 4 hours

+
+
+ + +
+

+ Delete all + captured requests? This + cannot be undone. +

+
+ +
@@ -214,35 +285,49 @@
+ + + >0 results
-
- -