From 4266b1f2baf9a00340ef8a7b34afd32c63179f47 Mon Sep 17 00:00:00 2001 From: Zachary Lowden Date: Sun, 1 Nov 2026 01:07:25 -0600 Subject: [PATCH 1/2] send queue: list only what is pending, and show a message's whole text on tap The Rendered view's queue listed the five most recently finished messages beside the open ones, so sent and cancelled messages piled up and each had to be deleted by hand. PGStore.List now returns the open rows (queued, sending) and the failed rows not yet retried, bounded by FailedShown (the open bound, 20). A sent or cancelled message leaves the list at once; a failure stays until it is retried or deleted, so it never vanishes unseen; a failure already retried is hidden, since its retry row carries it. The Delete route still deletes any finished message. A row's preview is cut at 200 bytes and clamped to two lines. It is now a button (popovertarget, 44px, aria-label) opening a top-layer popover with the whole text and a Close, so it works without script and moves nothing. Every listed row, failed ones included, now carries its whole text. The list's poll skips a redraw while ANY popover in the list is open, so it does not redraw an open message away. Tests: internal/sendqueue TestListShowsOnlyWhatStillNeedsSomething and TestListBoundsTheFailures (red against origin/main's store.go); the UI's TestATapOnAPreviewOpensTheWholeMessage and the API's TestEveryListedRowCarriesItsWholeText (red against origin/main's ui/send_queue.go and api/send_queue.go); existing tests and e2e specs that waited for a sent or cancelled row now wait for the row to leave the list. A new send-queue e2e taps a long queued message, sees its tail, checks no layout shift and that it stays open across a poll (red on origin/main; red with the poll's old popover selector). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/sending-and-step-up.md | 22 ++-- e2e/tests/attention.spec.ts | 2 +- e2e/tests/send-queue.spec.ts | 110 +++++++++++++---- internal/api/send_queue.go | 10 +- internal/api/send_queue_delete_pg_test.go | 42 +++++-- internal/api/send_queue_list_test.go | 50 +++++++- internal/api/send_queue_now_pg_test.go | 14 ++- internal/api/send_queue_test.go | 5 + internal/api/session_routes_pg_test.go | 26 +++- internal/sendqueue/store.go | 22 ++-- internal/sendqueue/store_list_pg_test.go | 112 ++++++++++++++++++ internal/sendqueue/store_retry_pg_test.go | 5 +- internal/ui/send_queue.go | 92 ++++++++++++--- internal/ui/send_queue_full_test.go | 137 ++++++++++++++++++++++ web/static/app.css | 2 +- 15 files changed, 565 insertions(+), 86 deletions(-) create mode 100644 internal/sendqueue/store_list_pg_test.go create mode 100644 internal/ui/send_queue_full_test.go diff --git a/docs/sending-and-step-up.md b/docs/sending-and-step-up.md index e2f8096ff..bc09101e4 100644 --- a/docs/sending-and-step-up.md +++ b/docs/sending-and-step-up.md @@ -131,8 +131,13 @@ dispatched agents and the chief panel have no Queue. status without its time. - 🔴 **A usage limit holds the queue.** See [A usage limit holds the queue](#a-usage-limit-holds-the-queue) below. -- **At most 20** messages can wait per window. Finished ones are shown for a - while and deleted after 7 days, or sooner with their **Delete** (below). +- **At most 20** messages can wait per window. The list shows only what still + needs something: messages queued or being sent, and failed ones you have not + retried or deleted (the 20 newest). A sent or cancelled message leaves the + list at once; a failed one stays until you **Retry** or **Delete** it, so a + failure never disappears unseen. Finished messages are deleted after 7 days. + A tap on a message in the list shows its whole text (the list shows the first + two lines). - **Never twice.** A message is claimed (`queued` → `sending`) before it is sent and never put back once anything may have reached the window. (The one way back to `queued` is the Send-wins case above, where nothing was pasted.) The @@ -284,12 +289,13 @@ must be fresh like a queue post's. A message that may already have been sent asks you to confirm first. A message your chief routed has no Retry: its failed route goes back to the chief instead. -A finished message (sent, failed, expired or cancelled) has a **Delete**, which -removes it from the list on every device at once (`POST /send-queue/{id}/delete`). -A message still queued has no Delete (Cancel it first), and neither has one -being sent: the send in progress still holds it. Deleting a sent message does -not let the next one in early: the next message still waits for the turn the -deleted one started. Deleting a failed message your chief routed is fine: its +A failed message (expired included) has a **Delete**, which removes it from the +list on every device at once (`POST /send-queue/{id}/delete`). The route deletes +any finished message (sent, failed or cancelled), though the list draws only +failed ones. A message still queued has no Delete (Cancel it first), and +neither has one being sent: the send in progress still holds it. Deleting a +sent message does not let the next one in early: the next message still waits +for the turn the deleted one started. Deleting a failed message your chief routed is fine: its route is not affected, and if the route times out, the message still goes back to the chief. diff --git a/e2e/tests/attention.spec.ts b/e2e/tests/attention.spec.ts index 019447d77..9615bd877 100644 --- a/e2e/tests/attention.spec.ts +++ b/e2e/tests/attention.spec.ts @@ -378,7 +378,7 @@ test('a queued message answers the ask, and its Cancel says the answer stays', a await row.getByRole('button', { name: 'More actions for this queued message' }).click(); await row.getByRole('menuitem', { name: 'Cancel this queued message' }).click(); await expect(page.locator('#mv-note')).toHaveText(/^Cancelled — but this message had already answered .+'s question, and that answer was delivered: it can't be un-sent\.$/); - await expect(row).toHaveAttribute('data-queue-state', 'cancelled'); + await expect(row, 'cancelled: it leaves the list').toHaveCount(0); }); // ANSWERED ON THE ASKING SESSION'S OWN VIEW (attention_asker.go): the diff --git a/e2e/tests/send-queue.spec.ts b/e2e/tests/send-queue.spec.ts index ec034b17c..684553a05 100644 --- a/e2e/tests/send-queue.spec.ts +++ b/e2e/tests/send-queue.spec.ts @@ -214,11 +214,12 @@ test.describe('phone 390×844 touch', () => { busy(false); await expect.poll(pasted, { message: `the first message is sent once idle (log:\n${mirrorLog})`, timeout: 30_000 }).toEqual([`${pane} first queued`]); - // Another device sees the queue: the first sent, the second waiting. + // Another device sees the queue: the first sent (so no longer listed: only + // what is still pending is), the second waiting. const other = await otherDevice(browser); await openView(other.page, newHost); - await expect(row(other.page, 'first queued')).toHaveAttribute('data-queue-state', 'sent'); await expect(row(other.page, 'second queued')).toHaveAttribute('data-queue-state', 'queued'); + await expect(row(other.page, 'first queued'), 'a sent message is not listed').toHaveCount(0); // Still idle — since BEFORE the first was sent: its turn has not ended, // so the second waits. The server says so, then holds it for as long as a // wrong send would take. @@ -235,7 +236,10 @@ test.describe('phone 390×844 touch', () => { `${pane} first queued`, `${pane} second queued`, ]); - await expect(row(other.page, 'second queued'), 'the list updates live').toHaveAttribute('data-queue-state', 'sent', { timeout: 20_000 }); + // The list updates live: the sent message leaves it, and with nothing + // pending the list is empty — the old rows do not linger. + await expect(row(other.page, 'second queued'), 'the list updates live').toHaveCount(0, { timeout: 20_000 }); + await expect(rows(other.page), 'nothing pending: an empty list').toHaveCount(0); await other.ctx.close(); }); @@ -250,9 +254,9 @@ test.describe('phone 390×844 touch', () => { const r = row(other.page, 'cancel me please'); await expect(r).toHaveAttribute('data-queue-state', 'queued'); await cancelRow(r, true); - await expect(r).toHaveAttribute('data-queue-state', 'cancelled'); + await expect(r, 'a cancelled message leaves the list').toHaveCount(0); // The first device's list follows (it polls while anything is open). - await expect(row(page, 'cancel me please'), 'the first device sees the cancel').toHaveAttribute('data-queue-state', 'cancelled', { timeout: 20_000 }); + await expect(row(page, 'cancel me please'), 'the first device sees the cancel').toHaveCount(0, { timeout: 20_000 }); // Idle: had the cancel not held, the cancelled message would be the head and // go first. A message queued after it is the observable — it is sent, and @@ -309,7 +313,7 @@ test.describe('desktop 1280×800', () => { await expect.poll(pasted, { message: 'Send pastes at once, busy or not' }).toEqual([`${pane} desktop sent now`]); // Tidy: the queued one is cancelled, so no later run sees it sent. await cancelRow(row(page, 'desktop queued')); - await expect(row(page, 'desktop queued')).toHaveAttribute('data-queue-state', 'cancelled'); + await expect(row(page, 'desktop queued'), 'cancelled: no longer listed').toHaveCount(0); busy(false); }); @@ -415,10 +419,12 @@ test.describe('desktop 1280×800', () => { await busyAndSeen(); await failed.getByRole('button', { name: 'Queue this failed message again' }).click(); await expect(rowIn(page, text, 'queued'), 'Retry queues it again').toHaveCount(1, { timeout: 20_000 }); - await expect(failed.getByRole('button', { name: /Queue this/ }), 'a retried message offers no second Retry').toHaveCount(0); + // A retried failure leaves the list (its retry row carries it), so it + // offers no second Retry. + await expect(failed, 'a retried message is no longer listed as failed').toHaveCount(0); busy(false); await expect.poll(pasted, { message: `the retried message is sent (log:\n${mirrorLog})`, timeout: 30_000 }).toEqual([`${pane} ${text}`]); - await expect(rowIn(page, text, 'sent')).toHaveCount(1, { timeout: 20_000 }); + await expect(row(page, text), 'sent: it leaves the list').toHaveCount(0, { timeout: 20_000 }); }); // 🔴 A MESSAGE PAST ITS 7 DAYS EXPIRES EVEN WHILE ITS WINDOW NEVER GOES IDLE: @@ -595,8 +601,9 @@ test.describe('desktop 1280×800', () => { await expect(rowIn(page, text, 'queued'), 'an accepted Retry queues it again').toHaveCount(1, { timeout: 20_000 }); expect(posts, 'CONTROL: the accepted Retry posted once, saying it was confirmed').toEqual(['confirm=1']); // Tidy: cancel the copy, so no later run sees it sent. + await expect(rowIn(page, text, 'failed'), 'the retried failure leaves the list').toHaveCount(0); await cancelRow(rowIn(page, text, 'queued')); - await expect(rowIn(page, text, 'cancelled')).toHaveCount(1); + await expect(row(page, text), 'cancelled: it leaves the list').toHaveCount(0); busy(false); }); @@ -615,7 +622,7 @@ test.describe('desktop 1280×800', () => { await expect(row(page, 'queued without a submitter')).toHaveAttribute('data-queue-state', 'queued'); expect(pasted(), 'nothing was sent now').toEqual([]); await cancelRow(row(page, 'queued without a submitter')); - await expect(row(page, 'queued without a submitter')).toHaveAttribute('data-queue-state', 'cancelled'); + await expect(row(page, 'queued without a submitter'), 'cancelled: it leaves the list').toHaveCount(0); busy(false); }); }); @@ -639,7 +646,7 @@ test.describe('statuses', () => { // The turn ends with a background shell still running: "shell", a newer time. setStatus('shell'); await expect.poll(pasted, { message: `sent once Claude Code reports "shell" (log:\n${mirrorLog})`, timeout: 30_000 }).toEqual([`${pane} ${text}`]); - await expect(rowIn(page, text, 'sent')).toHaveCount(1, { timeout: 20_000 }); + await expect(row(page, text), 'sent: it leaves the list').toHaveCount(0, { timeout: 20_000 }); await busyAndSeen(); }); }); @@ -666,7 +673,7 @@ test.describe('queued row menu, phone 390×844 touch', () => { `${pane} ${second}`, ]); await expect(page.locator('#mv-note')).toHaveText('Sent.'); - await expect(rowIn(page, second, 'sent'), 'the row shows it sent').toHaveCount(1, { timeout: 20_000 }); + await expect(row(page, second), 'sent: it leaves the list').toHaveCount(0, { timeout: 20_000 }); await expect(rowIn(page, first, 'queued'), 'the other stays queued').toHaveCount(1); await holdWindow(); expect(pasted(), 'the other is not sent while busy').toEqual([`${pane} ${second}`]); @@ -691,10 +698,73 @@ test.describe('queued row menu, phone 390×844 touch', () => { expect(await page.evaluate(() => navigator.clipboard.readText()), 'the clipboard holds the whole message').toBe(text); await expect(moreOf(r), 'the menu closed').toHaveAttribute('aria-expanded', 'false'); await cancelRow(r, true); - await expect(rowIn(page, 'copy me ' + stamp, 'cancelled')).toHaveCount(1); + await expect(row(page, 'copy me ' + stamp), 'cancelled: it leaves the list').toHaveCount(0); expect(pasted().some((l) => l.includes('copy me ' + stamp)), 'a copy sends nothing').toBe(false); }); + // 🔴 THE WHOLE MESSAGE: a queued row's preview is cut (200 bytes) and clamped + // (two lines); a tap on it shows the whole text in a popover — the tail the + // preview cannot show included — moving nothing on the page, and the list's + // poll does not redraw it away while it is open. On origin/main the preview + // is plain text with nothing to tap, so this fails at the button. + test('a tap on a long queued message shows the whole of it, moves nothing, and stays open across the poll', async ({ page }) => { + await busyAndSeen(); + await openView(page, newHost); + const tail = 'and the tail end ' + stamp; + const text = 'whole message ' + stamp + ' ' + 'consectetur adipiscing elit '.repeat(10) + tail; + await queue(page, text, true); + const r = rowIn(page, 'whole message ' + stamp, 'queued'); + await expect(r).toBeVisible(); + await expect(r.locator('[data-queue-full-open]'), 'the preview does not show the tail').not.toContainText(tail); + // As the menu test: the row drawn and painted before shifts are counted. + await page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(() => res())))); + await page.evaluate(() => { + const w = window as unknown as { __shift: number; __shifts: string[] }; + w.__shift = 0; + w.__shifts = []; + new PerformanceObserver((l) => { + for (const e of l.getEntries() as unknown as { value: number; sources: { node?: Node | null }[] }[]) { + const nodes = (e.sources || []).map((x) => x.node).filter((n): n is Node => !!n); + const ours = nodes.some((n) => { + const el = n instanceof Element ? n : n.parentElement; + return !!el && !!el.closest('#mv-compose, [data-queue-full]'); + }); + if (ours) w.__shift += e.value; + w.__shifts.push(`${e.value.toFixed(4)} ours=${ours} ` + nodes.map((n) => (n instanceof Element ? n.id || n.tagName : n.nodeName)).join(',')); + } + }).observe({ type: 'layout-shift', buffered: false }); + }); + const before = await r.boundingBox(); + const open = r.getByRole('button', { name: /^Show the whole message/ }); + const ob = await open.boundingBox(); + expect(ob && ob.height >= 44, `the preview is a 44px target: ${JSON.stringify(ob)}`).toBe(true); + await open.tap(); + const full = r.getByRole('dialog', { name: 'The whole message' }); + await expect(full).toBeVisible(); + await expect(full, 'the whole text, its tail included').toContainText(tail); + const fb = await full.boundingBox(); + const vw = page.viewportSize()!.width; + expect(fb && fb.x >= 0 && fb.x + fb.width <= vw, `the popover is on screen: ${JSON.stringify(fb)}`).toBe(true); + // Open across more than one poll (3 s while anything is queued): not + // redrawn away. + await page.waitForTimeout(4_000); + await expect(full, 'still open after a poll').toBeVisible(); + await full.getByRole('button', { name: 'Close' }).tap(); + await expect(full).toBeHidden(); + expect(await r.boundingBox(), 'the row did not move').toEqual(before); + // Enter on the focused preview opens it too; Escape closes it. + await open.focus(); + await page.keyboard.press('Enter'); + await expect(full).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(full).toBeHidden(); + await page.waitForTimeout(300); + expect(await page.evaluate(() => (window as unknown as { __shift: number }).__shift), 'no layout shift opening or closing it: ' + + JSON.stringify(await page.evaluate(() => (window as unknown as { __shifts: string[] }).__shifts))).toBeLessThan(0.001); + await cancelRow(r, true); + await expect(row(page, 'whole message ' + stamp), 'cancelled: it leaves the list').toHaveCount(0); + }); + test('the menu fits the phone, moves nothing, and closes on a tap outside', async ({ page }) => { await busyAndSeen(); await openView(page, newHost); @@ -755,7 +825,7 @@ test.describe('queued row menu, phone 390×844 touch', () => { expect(await page.evaluate(() => (window as unknown as { __shift: number }).__shift), 'no layout shift opening or closing it: ' + JSON.stringify(await page.evaluate(() => (window as unknown as { __shifts: string[] }).__shifts))).toBeLessThan(0.001); await cancelRow(r, true); - await expect(rowIn(page, text, 'cancelled')).toHaveCount(1); + await expect(row(page, text), 'cancelled: it leaves the list').toHaveCount(0); }); }); @@ -793,7 +863,7 @@ test.describe('queued row menu, desktop 1280×800 keyboard', () => { await page.keyboard.press('End'); await expect(itemOf(r, 'Cancel this queued message')).toBeFocused(); await page.keyboard.press('Enter'); - await expect(rowIn(page, text, 'cancelled')).toHaveCount(1, { timeout: 20_000 }); + await expect(row(page, text), 'cancelled: it leaves the list').toHaveCount(0, { timeout: 20_000 }); expect(pasted().some((l) => l.includes(text)), 'a cancelled message is not sent').toBe(false); }); @@ -827,7 +897,7 @@ test.describe('queued row menu, desktop 1280×800 keyboard', () => { await moreOf(c).click(); await itemOf(c, 'Cancel this queued message').click(); await expect(page.locator('#mv-note')).toContainText('Cancelled'); - await expect(rowIn(page, cancelText, 'cancelled')).toHaveCount(1); + await expect(row(page, cancelText), 'cancelled: it leaves the list').toHaveCount(0); await ctx.close(); }); }); @@ -893,7 +963,7 @@ for (const vp of [ // The second waits for the edited one's turn, then follows it. setStatus('idle'); await expect.poll(pasted, { message: 'then the one behind it', timeout: 30_000 }).toEqual([`${pane} ${edited}`, `${pane} ${second}`]); - await expect(rowIn(page, edited, 'sent'), 'the list shows the edited text sent').toHaveCount(1, { timeout: 20_000 }); + await expect(row(page, edited), 'sent: the edited message leaves the list').toHaveCount(0, { timeout: 20_000 }); await busyAndSeen(); }); @@ -1049,7 +1119,7 @@ for (const vp of [ await page.goto(origin + viewPath(newHost)); await expect(page.locator('#mv-input'), 'a queued draft is not restored').toHaveValue(''); await cancelRow(rowIn(page, queued, 'queued'), vp.touch); - await expect(rowIn(page, queued, 'cancelled')).toHaveCount(1); + await expect(row(page, queued), 'cancelled: it leaves the list').toHaveCount(0); } finally { writeFileAtomic(windowsFile, before); } @@ -1258,7 +1328,7 @@ test.describe('a usage limit, phone 390×844 touch', () => { await expect(clock).toContainText(month); expect(pasted(), 'held: nothing is sent').toEqual([]); await cancelRow(rowIn(page, text, 'queued'), true); - await expect(rowIn(page, text, 'cancelled')).toHaveCount(1); + await expect(row(page, text), 'cancelled: it leaves the list').toHaveCount(0); answered('the limit has lifted'); await busyAndSeen(); }); @@ -1376,7 +1446,7 @@ test.describe('a usage limit, phone 390×844 touch', () => { await page.setViewportSize({ width: 390, height: 844 }); // Tidy: cancel it, and leave the session answered for the specs after. await cancelRow(rowIn(page, text, 'queued'), true); - await expect(rowIn(page, text, 'cancelled')).toHaveCount(1); + await expect(row(page, text), 'cancelled: it leaves the list').toHaveCount(0); answered('the limit has lifted'); await busyAndSeen(); }); diff --git a/internal/api/send_queue.go b/internal/api/send_queue.go index feec5e5cb..b091845b3 100644 --- a/internal/api/send_queue.go +++ b/internal/api/send_queue.go @@ -390,11 +390,11 @@ func (s *Server) sendQueueListView(ctx context.Context, t sendqueue.Target, edit // A cut through a character leaves invalid bytes; they are dropped. preview = strings.ToValidUTF8(preview[:sendQueuePreview], "") + "…" } - body := "" - if it.State == sendqueue.Queued { - body = it.Body // Copy message copies all of it, not the preview. - } - row := ui.SendQueueItem{ID: it.ID, State: string(it.State), Preview: preview, Body: body, + // The whole text rides in every row: a tap on the preview shows it, and + // a queued row's Copy message copies it (the preview is cut). Every row + // listed is the caller's own (row-level security), so this is text the + // reader already wrote or was routed. + row := ui.SendQueueItem{ID: it.ID, State: string(it.State), Preview: preview, Body: it.Body, Detail: it.Detail, Cancellable: it.State == sendqueue.Queued, Answered: answered[it.ID], Retryable: sendQueueRetryable(it), Deletable: it.State.Finished(), MayHaveDelivered: it.MayHaveDelivered, Editable: it.State == sendqueue.Queued && it.RouteID == "" && it.ChiefNudgeID == 0, Held: it.State == sendqueue.Queued && it.Held, diff --git a/internal/api/send_queue_delete_pg_test.go b/internal/api/send_queue_delete_pg_test.go index fb13cf367..f97e05ae4 100644 --- a/internal/api/send_queue_delete_pg_test.go +++ b/internal/api/send_queue_delete_pg_test.go @@ -18,8 +18,8 @@ import ( // THE SEND QUEUE'S DELETE (POST /send-queue/{id}/delete), through the real // mux and a real Postgres (row-level security included): a FINISHED message — -// sent, failed (expired included), cancelled — is deleted and leaves the -// list; a queued one is refused (Cancel it), and so is one being sent (its +// sent, failed (expired included), cancelled — is deleted (the list draws a +// Delete only on a failed one: it does not draw the others at all); a queued one is refused (Cancel it), and so is one being sent (its // claim still holds the row); another user's is 404 and untouched. // // RED AT origin/main (0405925): the route is not registered, so every Delete @@ -151,13 +151,20 @@ func TestAnotherOwnersDeleteIsNotFound(t *testing.T) { } } -// TestTheListDrawsDeleteOnFinishedRowsOnly: through GET /send-queue, the -// finished rows (failed, cancelled, sent) each draw a Delete posting to their -// own route, and the queued row draws none. -func TestTheListDrawsDeleteOnFinishedRowsOnly(t *testing.T) { +// TestTheListDrawsDeleteOnAnUnhandledFailureOnly: through GET /send-queue, a +// failed row draws a Delete posting to its own route; a sent and a cancelled +// row are not drawn at all (they are done — the list used to keep them, and +// each had to be deleted by hand), nor a failed row already retried (its retry +// row is drawn in its place); and the queued rows draw no Delete. +func TestTheListDrawsDeleteOnAnUnhandledFailureOnly(t *testing.T) { f := newSQFixture(t) f.mirror.agentState = &mirror.AgentState{Status: mirror.StatusBusy, Since: time.Now().UnixMilli()} failed := f.expired(t, "a failed one") + retried := f.expired(t, "a failed one retried") + again, err := f.queue.Retry(asQueueUser(1, time.Now()), retried.ID, time.Now(), 0) + if err != nil { + t.Fatal(err) + } cancelled := f.enqueue(t, "a cancelled one", time.Hour, 0) if _, err := f.queue.Cancel(asQueueUser(1, time.Now()), cancelled.ID, time.Now()); err != nil { t.Fatal(err) @@ -165,16 +172,25 @@ func TestTheListDrawsDeleteOnFinishedRowsOnly(t *testing.T) { sent := f.sent(t, "a sent one") queued := f.enqueue(t, "a queued one", time.Hour, 0) _, page := mirrorGet(t, f.srv, f.ts, ui.SendQueuePath+"?host=ws&session=main&window=%401") - for _, id := range []int64{failed.ID, cancelled.ID, sent.ID} { - if !strings.Contains(page, `action="`+ui.SendQueueDeletePath(id)+`"`) { - t.Fatalf("finished row %d draws no Delete: %s", id, page) + if !strings.Contains(page, `action="`+ui.SendQueueDeletePath(failed.ID)+`"`) { + t.Fatalf("the failed row draws no Delete: %s", page) + } + for _, id := range []int64{retried.ID, cancelled.ID, sent.ID} { + if strings.Contains(page, `data-queue-id="`+strconv.FormatInt(id, 10)+`"`) { + t.Fatalf("handled row %d is drawn: %s", id, page) } } - if strings.Contains(page, ui.SendQueueDeletePath(queued.ID)) { - t.Fatalf("the queued row draws a Delete: %s", page) + // CONTROL: the open rows are drawn — the retry's and the queued one. + for _, id := range []int64{again.ID, queued.ID} { + if !strings.Contains(page, `data-queue-id="`+strconv.FormatInt(id, 10)+`"`) { + t.Fatalf("open row %d is not drawn: %s", id, page) + } + if strings.Contains(page, ui.SendQueueDeletePath(id)) { + t.Fatalf("open row %d draws a Delete: %s", id, page) + } } - if got := strings.Count(page, `aria-label="`+ui.SendQueueDeleteLabel+`"`); got != 3 { - t.Fatalf("%d Delete buttons; want 3 (one per finished row): %s", got, page) + if got := strings.Count(page, `aria-label="`+ui.SendQueueDeleteLabel+`"`); got != 1 { + t.Fatalf("%d Delete buttons; want 1 (the unhandled failure's): %s", got, page) } } diff --git a/internal/api/send_queue_list_test.go b/internal/api/send_queue_list_test.go index a5910d009..54610bbbe 100644 --- a/internal/api/send_queue_list_test.go +++ b/internal/api/send_queue_list_test.go @@ -79,11 +79,45 @@ func TestAFailedRowDrawsRetryOnce(t *testing.T) { } } -// TestDeleteIsDrawnOnExactlyTheFinishedStates: through the real list route, a -// row in each state the schema allows (0019's send_queue_state_check) draws a -// Delete exactly when it is finished — sent, failed, cancelled — and never -// when it is queued (that is Cancel) or being sent. -func TestDeleteIsDrawnOnExactlyTheFinishedStates(t *testing.T) { +// TestEveryListedRowCarriesItsWholeText: through the real list route, the +// server cuts each preview at sendQueuePreview bytes, and a queued, a sending +// and a failed row each carry the WHOLE text in the popover a tap on the +// preview opens (ui.SendQueueFullID) — the tail past the cut included. +func TestEveryListedRowCarriesItsWholeText(t *testing.T) { + q := &memQueue{} + s, ts := queueServer(t, queueMirror(), q, &turnGateway{}) + states := []sendqueue.State{sendqueue.Queued, sendqueue.Sending, sendqueue.Failed} + for _, st := range states { + body := strings.Repeat(string(st)+" filler ", 40) + "tail of the " + string(st) + " one" + it, err := q.Enqueue(asQueueUser(1, time.Now()), sendqueue.Item{Target: queueTarget, Body: body, StepUpAt: time.Now()}) + if err != nil { + t.Fatal(err) + } + q.mu.Lock() + q.items[it.ID-1].State = st + q.mu.Unlock() + } + _, page := mirrorGet(t, s, ts, ui.SendQueuePath+"?"+url.Values{"host": {"ws"}, "session": {"main"}, "window": {"@1"}}.Encode()) + for i, st := range states { + id := ui.SendQueueFullID(int64(i + 1)) + at := strings.Index(page, `id="`+id+`"`) + if at < 0 { + t.Fatalf("the %s row has no #%s: %s", st, id, page) + } + end := strings.Index(page[at:], "") + if end < 0 || !strings.Contains(page[at:at+end], "tail of the "+string(st)+" one") { + t.Fatalf("the %s row's popover lacks the tail: %s", st, page[at:]) + } + } +} + +// TestDeleteIsDrawnOnAFailedRowOnly:through the real list route, with a row +// in each state the schema allows (0019's send_queue_state_check), the failed +// row draws a Delete, and the queued (that is Cancel) and sending rows do not. +// The sent and cancelled rows are not drawn at all: the store does not list +// them (memQueue as PGStore.List: internal/sendqueue's +// TestListShowsOnlyWhatStillNeedsSomething pins the real one). +func TestDeleteIsDrawnOnAFailedRowOnly(t *testing.T) { q := &memQueue{} s, ts := queueServer(t, queueMirror(), q, &turnGateway{}) states := []sendqueue.State{sendqueue.Queued, sendqueue.Sending, sendqueue.Sent, sendqueue.Failed, sendqueue.Cancelled} @@ -97,12 +131,16 @@ func TestDeleteIsDrawnOnExactlyTheFinishedStates(t *testing.T) { q.mu.Unlock() } _, page := mirrorGet(t, s, ts, ui.SendQueuePath+"?"+url.Values{"host": {"ws"}, "session": {"main"}, "window": {"@1"}}.Encode()) - want := map[sendqueue.State]bool{sendqueue.Sent: true, sendqueue.Failed: true, sendqueue.Cancelled: true} + want := map[sendqueue.State]bool{sendqueue.Failed: true} for i, st := range states { drawn := strings.Contains(page, `action="`+ui.SendQueueDeletePath(int64(i+1))+`"`) if drawn != want[st] { t.Errorf("a %s row draws Delete: %v; want %v", st, drawn, want[st]) } + listed := strings.Contains(page, "row "+string(st)) + if wantListed := st.Open() || st == sendqueue.Failed; listed != wantListed { + t.Errorf("a %s row is drawn: %v; want %v", st, listed, wantListed) + } } if !strings.Contains(page, "row sending") { t.Fatalf("CONTROL: the list drew no rows: %s", page) diff --git a/internal/api/send_queue_now_pg_test.go b/internal/api/send_queue_now_pg_test.go index 76f7d396f..4ce89a009 100644 --- a/internal/api/send_queue_now_pg_test.go +++ b/internal/api/send_queue_now_pg_test.go @@ -234,14 +234,16 @@ func TestSendNowIsRefusedWhereASendIsAndWhereTheDispatchersDeliveryIs(t *testing // TestTheListDrawsSendNowAndCopyOnQueuedRowsOnly: a queued row's ⋯ menu holds // Send now (a real form posting to its route), Copy message (carrying the -// whole message, not the preview) and Cancel; a finished row has no menu and +// whole message, not the preview) and Cancel; a failed row has no menu and // keeps its own buttons. func TestTheListDrawsSendNowAndCopyOnQueuedRowsOnly(t *testing.T) { f := newSQFixture(t) f.mirror.agentState = &mirror.AgentState{Status: mirror.StatusBusy, Since: time.Now().UnixMilli()} + // First: expired() runs an idle dispatcher pass, which would send a row + // queued before it. + failed := f.expired(t, "already failed") long := strings.Repeat("a long queued message ", 20) + "THE END" q := f.enqueue(t, long, time.Minute, 0) - sent := f.sent(t, "already sent") _, page := mirrorGet(t, f.srv, f.ts, ui.SendQueuePath+"?"+url.Values{"host": {"ws"}, "session": {"main"}, "window": {"@1"}}.Encode()) rowOf := func(id int64) string { i := strings.Index(page, `data-queue-id="`+strconv.FormatInt(id, 10)+`"`) @@ -251,7 +253,7 @@ func TestTheListDrawsSendNowAndCopyOnQueuedRowsOnly(t *testing.T) { j := strings.Index(page[i:], "") return page[i : i+j] } - qr, sr := rowOf(q.ID), rowOf(sent.ID) + qr, sr := rowOf(q.ID), rowOf(failed.ID) for _, want := range []string{`aria-haspopup="menu"`, `action="` + sendNowPath(q.ID) + `"`, `action="` + ui.SendQueueCancelPath(q.ID) + `"`, `data-queue-copy`, "THE END"} { if !strings.Contains(qr, want) { @@ -260,10 +262,10 @@ func TestTheListDrawsSendNowAndCopyOnQueuedRowsOnly(t *testing.T) { } for _, not := range []string{`aria-haspopup="menu"`, "send-now", "data-queue-copy"} { if strings.Contains(sr, not) { - t.Errorf("the sent row has %q: %s", not, sr) + t.Errorf("the failed row has %q: %s", not, sr) } } - if !strings.Contains(sr, `action="`+ui.SendQueueDeletePath(sent.ID)+`"`) { - t.Errorf("CONTROL: the sent row lost its Delete: %s", sr) + if !strings.Contains(sr, `action="`+ui.SendQueueDeletePath(failed.ID)+`"`) { + t.Errorf("CONTROL: the failed row lost its Delete: %s", sr) } } diff --git a/internal/api/send_queue_test.go b/internal/api/send_queue_test.go index 020497122..b6df94067 100644 --- a/internal/api/send_queue_test.go +++ b/internal/api/send_queue_test.go @@ -63,6 +63,11 @@ func (m *memQueue) List(ctx context.Context, t sendqueue.Target) ([]sendqueue.It defer m.mu.Unlock() var out []sendqueue.Item for _, it := range m.items { + // What PGStore.List lists: the open rows and the unretried failures + // (order aside: these are in queue order). + if !it.State.Open() && (it.State != sendqueue.Failed || it.RetriedAs != 0) { + continue + } if it.Owner == queueUser(ctx) && it.Target == t { out = append(out, it) } diff --git a/internal/api/session_routes_pg_test.go b/internal/api/session_routes_pg_test.go index 0731f20c2..460b7a294 100644 --- a/internal/api/session_routes_pg_test.go +++ b/internal/api/session_routes_pg_test.go @@ -158,10 +158,34 @@ func routeArgs(m plugins.Message, sub string) string { // queued is the owner's send-queue rows for the routed window. func (f *routeFixture) queued(t *testing.T) []sendqueue.Item { t.Helper() - items, err := f.queue.List(f.asOwner, sendqueue.Target{Host: "tower", Session: "work", Window: "@4"}) + // Every row the owner has for the window, oldest first, read as the owner + // (row-level security applies) — NOT List, which leaves out what is done + // (sent, cancelled, retried), which is what these tests assert on. + rows, err := f.pool.Query(f.asOwner, `SELECT id FROM send_queue + WHERE mirror_host = 'tower' AND mirror_session = 'work' AND mirror_window = '@4' ORDER BY id`) if err != nil { t.Fatal(err) } + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + var items []sendqueue.Item + for _, id := range ids { + it, err := f.queue.Get(f.asOwner, id) + if err != nil { + t.Fatal(err) + } + items = append(items, it) + } return items } diff --git a/internal/sendqueue/store.go b/internal/sendqueue/store.go index bb60c62a9..e9bd3c737 100644 --- a/internal/sendqueue/store.go +++ b/internal/sendqueue/store.go @@ -199,9 +199,14 @@ const MaxOpenPerTarget = 20 // MaxDetail bounds an outcome's detail (0019's send_queue_detail_cap). const MaxDetail = 1024 -// HistoryShown is how many finished messages List returns beside the open -// ones: the recent outcomes a composer shows under its queue. -const HistoryShown = 5 +// FailedShown bounds the failed messages List returns beside the open ones: +// the most recent FailedShown failures not yet retried, newest first. A sent +// or cancelled message is never listed — it has been handled — but a failure +// stays until its owner Retries or Deletes it, so it does not vanish unseen. +// The bound only keeps a target that fails again and again from drawing an +// unbounded list; it is the open bound's size, so a whole queue that failed +// is shown whole. +const FailedShown = MaxOpenPerTarget var ( // ErrFull refuses a message past MaxOpenPerTarget. @@ -403,8 +408,11 @@ func (s *PGStore) Retry(ctx context.Context, id int64, stepUpAt time.Time, after return out, nil } -// List is the caller's messages for t: every open one, oldest first, then -// the HistoryShown most recently finished, newest first. +// List is the caller's messages for t that still need something: every open +// one (queued or being sent), oldest first, then the FailedShown most recently +// failed ones that were not retried, newest first. A sent or cancelled message +// is not listed, nor a failed one already retried (its retry row, a new +// message of its own, carries it). func (s *PGStore) List(ctx context.Context, t Target) ([]Item, error) { if err := t.Valid(); err != nil { return nil, err @@ -412,8 +420,8 @@ func (s *PGStore) List(ctx context.Context, t Target) ([]Item, error) { where, args := targetWhere(t, 1) rows, err := s.pool.Query(ctx, `(SELECT `+cols+`, 0 AS part FROM send_queue WHERE state IN ('queued', 'sending') AND `+where+`) UNION ALL - (SELECT `+cols+`, 1 AS part FROM send_queue WHERE state NOT IN ('queued', 'sending') AND `+where+` - ORDER BY finished_at DESC, id DESC LIMIT `+fmt.Sprint(HistoryShown)+`) + (SELECT `+cols+`, 1 AS part FROM send_queue WHERE state = 'failed' AND retried_as IS NULL AND `+where+` + ORDER BY finished_at DESC, id DESC LIMIT `+fmt.Sprint(FailedShown)+`) ORDER BY part, id`, args...) if err != nil { return nil, fmt.Errorf("sendqueue: list: %w", err) diff --git a/internal/sendqueue/store_list_pg_test.go b/internal/sendqueue/store_list_pg_test.go new file mode 100644 index 000000000..31a0d1fe3 --- /dev/null +++ b/internal/sendqueue/store_list_pg_test.go @@ -0,0 +1,112 @@ +package sendqueue + +import ( + "context" + "fmt" + "strconv" + "strings" + "testing" + "time" +) + +// listed is items as "state:body#id", in List's order. +func listed(items []Item) string { + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, string(it.State)+":"+it.Body+"#"+strconv.FormatInt(it.ID, 10)) + } + return strings.Join(out, " ") +} + +// TestListShowsOnlyWhatStillNeedsSomething: List draws what is queued or being +// sent, and a failure nobody has handled yet — never a sent or a cancelled +// message (they are done; the operator had to Delete each by hand), nor a +// failure already retried (its retry row carries it). Every row's body is its +// own, so a row in the wrong part, or a missing one, names itself. +func TestListShowsOnlyWhatStillNeedsSomething(t *testing.T) { + s, _ := pgStore(t) + ctx := context.Background() + + sent := mustEnqueue(t, s, 2, mirrorItem("alpha sent")) + if _, ok, err := s.Claim(ctx, sent.ID, time.Now(), claimFor("t", time.Now())); err != nil || !ok { + t.Fatal(ok, err) + } + if err := s.Finish(ctx, sent.ID, "t", Outcome{Sent: true, Detail: "Sent."}, time.Now()); err != nil { + t.Fatal(err) + } + cancelled := mustEnqueue(t, s, 2, mirrorItem("bravo cancelled")) + if _, err := s.Cancel(as(2), cancelled.ID, time.Now()); err != nil { + t.Fatal(err) + } + retried := mustEnqueue(t, s, 2, mirrorItem("charlie failed then retried")) + failRow(t, s, retried.ID, false) + again, err := s.Retry(as(2), retried.ID, time.Now(), 0) + if err != nil { + t.Fatal(err) + } + failed := mustEnqueue(t, s, 2, mirrorItem("delta failed")) + failed2 := mustEnqueue(t, s, 2, mirrorItem("echo failed later")) + failRow(t, s, failed.ID, true) + failRow(t, s, failed2.ID, false) + sending := mustEnqueue(t, s, 2, mirrorItem("foxtrot sending")) + if _, ok, err := s.Claim(ctx, sending.ID, time.Now(), claimFor("live", time.Now())); err != nil || !ok { + t.Fatal(ok, err) + } + queued := mustEnqueue(t, s, 2, mirrorItem("golf queued")) + // Another owner's failure at the same address, and the caller's own at + // another window: neither is the caller's list for win. + theirs := mustEnqueue(t, s, 3, mirrorItem("hotel user 3 failed")) + failRow(t, s, theirs.ID, false) + elsewhere := mirrorItem("india another window failed") + elsewhere.Target.Window = "@8" + failRow(t, s, mustEnqueue(t, s, 2, elsewhere).ID, false) + + items, err := s.List(as(2), win) + if err != nil { + t.Fatal(err) + } + // Open oldest first (the retry row was queued before the sending one), + // then the unretried failures newest first. + want := strings.Join([]string{ + "queued:charlie failed then retried#" + strconv.FormatInt(again.ID, 10), + "sending:foxtrot sending#" + strconv.FormatInt(sending.ID, 10), + "queued:golf queued#" + strconv.FormatInt(queued.ID, 10), + "failed:echo failed later#" + strconv.FormatInt(failed2.ID, 10), + "failed:delta failed#" + strconv.FormatInt(failed.ID, 10), + }, " ") + if got := listed(items); got != want { + t.Fatalf("user 2's list:\n got %s\nwant %s", got, want) + } + // CONTROL: the hidden rows are still there (List hides; it deletes nothing). + for _, id := range []int64{sent.ID, cancelled.ID, retried.ID} { + if _, err := s.Get(as(2), id); err != nil { + t.Fatalf("hidden row %d is gone: %v", id, err) + } + } +} + +// TestListBoundsTheFailures: a target that failed more than FailedShown times +// lists the FailedShown newest failures, newest first, and its open rows +// whatever the bound. +func TestListBoundsTheFailures(t *testing.T) { + s, _ := pgStore(t) + n := FailedShown + 3 + for i := range n { + it := mustEnqueue(t, s, 2, mirrorItem(fmt.Sprintf("failure %02d", i))) + failRow(t, s, it.ID, false) + } + open := mustEnqueue(t, s, 2, mirrorItem("still queued")) + items, err := s.List(as(2), win) + if err != nil { + t.Fatal(err) + } + if len(items) != FailedShown+1 || items[0].ID != open.ID { + t.Fatalf("list of %d failures and 1 queued: %d rows, first %s; want %d, the queued one first", + n, len(items), listed(items[:1]), FailedShown+1) + } + for i, it := range items[1:] { + if want := fmt.Sprintf("failure %02d", n-1-i); it.State != Failed || it.Body != want { + t.Fatalf("failure row %d: %s:%s; want failed:%s", i, it.State, it.Body, want) + } + } +} diff --git a/internal/sendqueue/store_retry_pg_test.go b/internal/sendqueue/store_retry_pg_test.go index c77d1369e..7e4e589c1 100644 --- a/internal/sendqueue/store_retry_pg_test.go +++ b/internal/sendqueue/store_retry_pg_test.go @@ -64,8 +64,9 @@ func TestRetryQueuesAFailedMessageAgainOnce(t *testing.T) { if err != nil { t.Fatal(err) } - if b := bodies(items); len(b) != 2 || b[0] != "queued:try me again" || b[1] != "failed:try me again" { - t.Fatalf("user 2's list: %v; want the retry queued and the failed row", b) + // The retried failure is not listed: its retry row carries it. + if b := bodies(items); len(b) != 1 || b[0] != "queued:try me again" || items[0].ID != n.ID { + t.Fatalf("user 2's list: %v; want the retry queued, and the retried failure not listed", b) } // A Retry needs the retrying sign-in's time. other := mustEnqueue(t, s, 2, Item{Target: win, Body: "no time", StepUpAt: time.Now()}) diff --git a/internal/ui/send_queue.go b/internal/ui/send_queue.go index 469f6113f..f94a08a13 100644 --- a/internal/ui/send_queue.go +++ b/internal/ui/send_queue.go @@ -14,8 +14,10 @@ import ( // "Queue for when idle" item behind Send's caret (composer_menu.go), and the // window's queue under the status line — what is waiting, what // it is waiting for, a ⋯ menu for each message not yet being sent (Send now, -// Copy message, Cancel: sendQueueRowMenu), and the last few outcomes, each -// with a Delete. +// Copy message, Cancel: sendQueueRowMenu), and each failure not yet handled, +// with a Retry and a Delete. A sent or cancelled message leaves the list +// (sendqueue.PGStore.List); a failed one stays until it is retried or +// deleted. A tap on any row's preview shows its whole text (sendQueuePreview). // // 🔴 THE QUEUE IS THE SERVER'S, NOT THE PAGE'S. Queue posts the message to // SendQueuePath and the server sends it when the target has been idle long @@ -106,8 +108,9 @@ type SendQueueItem struct { State string // Preview is the start of the message (the owner's own text). Preview string - // Body is the whole message, for a queued one's Copy message; "" for the - // others, which have no Copy. + // Body is the whole message: what a tap on the preview shows + // (sendQueuePreview), and a queued one's Copy message. "" draws the + // preview alone, with nothing to open. Body string // Detail is the outcome in words, for a finished one. Detail string @@ -118,9 +121,10 @@ type SendQueueItem struct { Answered string // Retryable: it failed and has not been retried; drawn with a Retry. Retryable bool - // Deletable: it is finished (sent, failed, cancelled), so its owner may - // remove it from the list; drawn with a Delete. Never a queued message - // (that is Cancel) or one being sent. + // Deletable: it is finished, so its owner may remove it from the list; + // drawn with a Delete. The list holds only failed ones among the finished + // (sendqueue.PGStore.List). Never a queued message (that is Cancel) or one + // being sent. Deletable bool // MayHaveDelivered: muster stopped while sending it, so it may have // reached the window. Its Retry warns, and asks once before it queues it. @@ -259,7 +263,7 @@ func SendQueueList(v SendQueueListView) g.Node { Class("flex items-start gap-2 py-1"), Span(Class("mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold "+tone), g.Text(label)), Div(Class("min-w-0 flex-1"), - P(Class("line-clamp-2 text-xs text-fg [overflow-wrap:anywhere]"), g.Text(it.Preview)), + sendQueuePreview(it), g.If(it.Detail != "" && it.State != "queued" && it.State != "sending", P(Class("text-[11px] text-muted [overflow-wrap:anywhere]"), g.Text(it.Detail))), g.If(it.Answered != "", P(g.Attr("data-queue-answered", ""), Class("text-[11px] text-muted [overflow-wrap:anywhere]"), @@ -300,6 +304,61 @@ func SendQueueList(v SendQueueListView) g.Node { ) } +// SendQueueFullLabel is the start of what a screen reader names a row's +// preview, which a tap opens whole (sendQueuePreview). +const SendQueueFullLabel = "Show the whole message" + +// SendQueueFullID is the popover holding message id's whole text. +func SendQueueFullID(id int64) string { return "mv-qf-" + strconv.FormatInt(id, 10) } + +// sendQueuePreviewClass is a row's preview: at most two lines. +const sendQueuePreviewClass = "line-clamp-2 text-xs text-fg [overflow-wrap:anywhere]" + +// sendQueuePreview is a row's preview — the start of the message, clamped to +// two lines — and, when the row carries the whole text (Body), a tap on it +// shows all of it: the preview is a button opening a popover with the whole +// message (and a Close). +// +// IT WORKS WITHOUT SCRIPT: the button opens the popover itself +// (popovertarget), which closes on Escape, on a tap outside and on its Close. +// Enter and Space are the button's own. +// +// 🔴 OUT OF FLOW, like the row's ⋯ menu: the popover is in the top layer, so +// opening it moves nothing on the page (the preview stays clamped), and the +// list's poll does not redraw the rows while it is open (sendQueueScript's +// menuOpen counts every popover in the list). +func sendQueuePreview(it SendQueueItem) g.Node { + if it.Body == "" { + return P(Class(sendQueuePreviewClass), g.Text(it.Preview)) + } + full := SendQueueFullID(it.ID) + return g.Group([]g.Node{ + Button( + Type("button"), + g.Attr("popovertarget", full), + g.Attr("aria-haspopup", "dialog"), + g.Attr("aria-controls", full), + g.Attr("aria-label", SendQueueFullLabel+": "+it.Preview), + g.Attr("data-queue-full-open", ""), + Class("block min-h-[44px] w-full cursor-pointer rounded-lg text-left hover:bg-s2 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus"), + Span(Class(sendQueuePreviewClass), g.Text(it.Preview)), + ), + Div( + ID(full), + g.Attr("popover", ""), + g.Attr("role", "dialog"), + g.Attr("aria-label", "The whole message"), + g.Attr("data-queue-full", ""), + Class("m-auto max-h-[70dvh] w-[min(90vw,32rem)] overflow-y-auto rounded-xl border-0 bg-s1 p-3 text-fg shadow-lg ring-1 ring-line"), + P(g.Attr("data-queue-full-text", ""), Class("whitespace-pre-wrap text-sm [overflow-wrap:anywhere]"), g.Text(it.Body)), + Div(Class("mt-2 flex justify-end"), + Button(Type("button"), g.Attr("popovertarget", full), g.Attr("popovertargetaction", "hide"), + Class("press min-h-[44px] rounded-lg px-3 text-sm font-semibold text-fg2 ring-1 ring-inset ring-line transition hover:bg-s2"), + g.Text("Close"))), + ), + }) +} + // sendQueueWaiting is the line saying what the next message waits for. It // RESERVES two lines whatever it says, so the queue going from "working" (one // line) to held on a usage limit (two, at a phone's width) does not grow the @@ -610,10 +669,10 @@ func sendQueueBox(q *ComposerQueue, off bool) g.Node { // sendQueueScript keeps #mv-queue current: re-read every 3 s while anything is // open there, every 15 s otherwise, at once on a Queue (the composer's script -// dispatches mv-queued) and when the page is shown again — never while a row's -// menu is open; it sends each Send now, Cancel, Retry and Delete without -// leaving the page; it copies a queued message; and it drives the rows' ⋯ -// menus (sendQueueRowMenu). +// dispatches mv-queued) and when the page is shown again — never while one of +// its popovers (a row's menu, a row's whole message) is open; it sends each +// Send now, Cancel, Retry and Delete without leaving the page; it copies a +// queued message; and it drives the rows' ⋯ menus (sendQueueRowMenu). func sendQueueScript() g.Node { return Script(g.Raw(` (function () { @@ -629,11 +688,12 @@ func sendQueueScript() g.Node { clearTimeout(timer); timer = setTimeout(load, box.querySelector('[data-active]') ? 3000 : 15000); } - // menuOpen: a row's ⋯ menu is open. The list is not redrawn under it (a - // redraw would close it, and drop the focus inside it); the next load after - // it closes catches up. + // menuOpen: a popover of the list is open — a row's ⋯ menu, a row's whole + // message (sendQueuePreview), the whole waiting line. The list is not + // redrawn under it (a redraw would close it, and drop the focus inside it); + // the next load after it closes catches up. function menuOpen() { - var ms = box.querySelectorAll('[data-queue-menu] [popover], #mv-queue-waiting-full'); + var ms = box.querySelectorAll('[popover]'); for (var i = 0; i < ms.length; i++) { try { if (ms[i].matches(':popover-open')) return true; } catch (e) { return false; } } diff --git a/internal/ui/send_queue_full_test.go b/internal/ui/send_queue_full_test.go new file mode 100644 index 000000000..4025668f2 --- /dev/null +++ b/internal/ui/send_queue_full_test.go @@ -0,0 +1,137 @@ +package ui + +import ( + "bytes" + "strings" + "testing" + + "golang.org/x/net/html" +) + +// THE WHOLE MESSAGE (sendQueuePreview): a row's preview is cut (the server's +// 200 bytes) and clamped (two lines), so a tap on it opens the whole text in a +// popover — a native popovertarget button, so it works without script. + +// queueDoc renders v's list and parses it. +func queueDoc(t *testing.T, v SendQueueListView) *html.Node { + t.Helper() + var b bytes.Buffer + if err := SendQueueList(v).Render(&b); err != nil { + t.Fatal(err) + } + doc, err := html.Parse(&b) + if err != nil { + t.Fatal(err) + } + return doc +} + +func sqAttr(n *html.Node, key string) (string, bool) { + for _, a := range n.Attr { + if a.Key == key { + return a.Val, true + } + } + return "", false +} + +// findNode is the first node under n for which ok holds, or nil. +func sqFind(n *html.Node, ok func(*html.Node) bool) *html.Node { + if ok(n) { + return n + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if f := sqFind(c, ok); f != nil { + return f + } + } + return nil +} + +func sqText(n *html.Node) string { + var b strings.Builder + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.TextNode { + b.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return b.String() +} + +// TestATapOnAPreviewOpensTheWholeMessage: a queued, a sending and a failed +// row whose text is longer than its preview each draw the preview as a button +// (popovertarget, aria-label, 44px) opening a popover that holds the WHOLE +// text — its tail, which the preview does not — with a Close. A row with no +// whole text draws the preview alone, with nothing to open. +func TestATapOnAPreviewOpensTheWholeMessage(t *testing.T) { + const tail = "kilo-tango-tail-0731" + for i, state := range []string{"queued", "sending", "failed"} { + id := int64(41 + i) + body := strings.Repeat(state+" words ", 40) + tail + preview := body[:200] + "…" + it := SendQueueItem{ID: id, State: state, Preview: preview, Body: body, Cancellable: state == "queued", + Retryable: state == "failed", Deletable: state == "failed"} + doc := queueDoc(t, SendQueueListView{Items: []SendQueueItem{it}}) + full := SendQueueFullID(id) + pop := sqFind(doc, func(n *html.Node) bool { v, _ := sqAttr(n, "id"); return n.Type == html.ElementNode && v == full }) + if pop == nil { + t.Fatalf("%s row: no #%s", state, full) + } + if _, ok := sqAttr(pop, "popover"); !ok { + t.Errorf("%s row: #%s is not a popover", state, full) + } + if got := sqText(sqFind(pop, func(n *html.Node) bool { _, ok := sqAttr(n, "data-queue-full-text"); return ok })); got != body { + t.Errorf("%s row: the popover's text is %q; want the whole message", state, got) + } + if closer := sqFind(pop, func(n *html.Node) bool { + a, _ := sqAttr(n, "popovertargetaction") + v, _ := sqAttr(n, "popovertarget") + return n.Data == "button" && a == "hide" && v == full + }); closer == nil { + t.Errorf("%s row: the popover has no Close", state) + } + open := sqFind(doc, func(n *html.Node) bool { + v, _ := sqAttr(n, "popovertarget") + _, hide := sqAttr(n, "popovertargetaction") + return n.Data == "button" && v == full && !hide + }) + if open == nil { + t.Fatalf("%s row: no button opens #%s", state, full) + } + label, _ := sqAttr(open, "aria-label") + cls, _ := sqAttr(open, "class") + typ, _ := sqAttr(open, "type") + if !strings.HasPrefix(label, SendQueueFullLabel) || !strings.Contains(cls, "min-h-[44px]") || typ != "button" { + t.Errorf("%s row: the opener is %q class %q type %q", state, label, cls, typ) + } + if strings.Contains(sqText(open), tail) || !strings.Contains(sqText(open), preview) { + t.Errorf("%s row: the opener shows %q; want the preview, not the tail", state, sqText(open)) + } + } + // No whole text: the preview alone. + var b bytes.Buffer + if err := SendQueueList(SendQueueListView{Items: []SendQueueItem{{ID: 7, State: "failed", Preview: "short"}}}).Render(&b); err != nil { + t.Fatal(err) + } + if p := b.String(); strings.Contains(p, "popovertarget") || strings.Contains(p, SendQueueFullID(7)) || !strings.Contains(p, "short") { + t.Fatalf("a row with no whole text: %s", p) + } +} + +// TestThePollDoesNotRedrawUnderAnOpenWholeMessage: the list's script skips a +// redraw while ANY popover in the list is open — a row's whole message +// included, not only the ⋯ menus and the waiting line. +func TestThePollDoesNotRedrawUnderAnOpenWholeMessage(t *testing.T) { + var b bytes.Buffer + if err := sendQueueScript().Render(&b); err != nil { + t.Fatal(err) + } + if !strings.Contains(b.String(), "var ms = box.querySelectorAll('[popover]');") { + t.Fatalf("menuOpen does not count every popover of the list:\n%s", b.String()) + } +} diff --git a/web/static/app.css b/web/static/app.css index 7475b9dff..32efb2f52 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.dark,:root{color-scheme:dark;--mu-bg:17 15 28;--mu-s1:25 22 42;--mu-s2:34 30 55;--mu-s3:45 40 70;--mu-line:47 42 72;--mu-edge:110 103 144;--mu-text:238 235 247;--mu-text2:195 189 217;--mu-muted:154 147 181;--mu-accent:228 181 86;--mu-on-accent:30 22 6;--mu-focus:242 203 120;--mu-danger:209 59 69;--mu-on-danger:255 255 255;--mu-st-open-fg:185 179 211;--mu-st-open-bg:36 32 58;--mu-st-progress-fg:143 184 255;--mu-st-progress-bg:27 37 71;--mu-st-review-fg:215 166 255;--mu-st-review-bg:46 31 69;--mu-st-complete-fg:127 214 164;--mu-st-complete-bg:22 52 40;--mu-st-error-fg:255 143 143;--mu-st-error-bg:61 26 34;--mu-st-warning-fg:255 174 94;--mu-st-warning-bg:58 38 21;--mu-st-running-fg:95 224 192;--mu-st-running-bg:18 51 47;--mu-idle-0:184 187 38;--mu-idle-1:152 151 26;--mu-idle-2:104 157 106;--mu-idle-3:215 153 33;--mu-idle-4:214 93 14;--mu-idle-5:204 36 29;--mu-idle-6:177 98 134;--mu-idle-7:102 92 84}:root:not(.dark){color-scheme:light;--mu-bg:246 244 251;--mu-s1:255 255 255;--mu-s2:238 235 246;--mu-s3:227 223 239;--mu-line:220 215 234;--mu-edge:125 118 153;--mu-text:26 22 48;--mu-text2:68 62 94;--mu-muted:98 92 125;--mu-accent:131 93 15;--mu-on-accent:255 255 255;--mu-focus:107 76 14;--mu-danger:180 38 48;--mu-on-danger:255 255 255;--mu-st-open-fg:79 72 112;--mu-st-open-bg:236 233 245;--mu-st-progress-fg:31 79 176;--mu-st-progress-bg:227 236 255;--mu-st-review-fg:116 40 181;--mu-st-review-bg:243 232 255;--mu-st-complete-fg:28 107 63;--mu-st-complete-bg:226 245 233;--mu-st-error-fg:180 35 47;--mu-st-error-bg:253 231 232;--mu-st-warning-fg:154 75 0;--mu-st-warning-bg:255 238 219;--mu-st-running-fg:13 107 90;--mu-st-running-bg:221 245 239;--mu-idle-0:184 187 38;--mu-idle-1:152 151 26;--mu-idle-2:104 157 106;--mu-idle-3:215 153 33;--mu-idle-4:214 93 14;--mu-idle-5:204 36 29;--mu-idle-6:177 98 134;--mu-idle-7:102 92 84}:root{--glyph-knock:rgb(var(--mu-s1))}[data-identity]{--id-bd:var(--id-bd-d);--id-tt:var(--id-tt-d)}:root:not(.dark) [data-identity]{--id-bd:var(--id-bd-l);--id-tt:var(--id-tt-l)}[hidden]{display:none!important}@media (max-width:639px){input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=file]),select,textarea{font-size:16px!important;min-height:44px!important}input[type=file]{font-size:16px!important;min-height:44px}summary{min-height:44px}}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.card-enter{animation:card-in .28s cubic-bezier(.16,1,.3,1) both,card-highlight 1.4s ease-out .12s both}.press{transition:transform 80ms ease;touch-action:manipulation}.press:active{transform:scale(.97)}.selecting .card-link:after{pointer-events:none}#session-drawer:focus-visible{position:fixed;top:.75rem;right:.75rem;z-index:60;width:2.75rem;height:2.75rem;margin:0;padding:0;overflow:visible;clip:auto;clip-path:none;white-space:normal;border-radius:.5rem;outline:2px solid rgb(var(--mu-focus));outline-offset:2px}#sidebar-root .sidebar-panel{transform:translateX(-100%);visibility:hidden;transition-property:transform,visibility}#sidebar-root .sidebar-backdrop{opacity:0;pointer-events:none}#sidebar-root[data-open=true] .sidebar-panel{transform:translateX(0);visibility:visible}#sidebar-root[data-open=true] .sidebar-backdrop{opacity:1;pointer-events:auto}@media (min-width:1024px){#sidebar-root:not([data-overlay]) .sidebar-panel{transform:translateX(0);visibility:visible}#sidebar-root:not([data-overlay]) .sidebar-backdrop{display:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-x-2{left:.5rem;right:.5rem}.inset-y-0{top:0;bottom:0}.-right-1{right:-.25rem}.-top-1{top:-.25rem}.bottom-0{bottom:0}.bottom-2\.5{bottom:.625rem}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.bottom-\[calc\(1\.5rem\+env\(safe-area-inset-bottom\)\)\]{bottom:calc(1.5rem + env(safe-area-inset-bottom))}.bottom-\[calc\(6rem\+env\(safe-area-inset-bottom\)\)\]{bottom:calc(6rem + env(safe-area-inset-bottom))}.bottom-auto{bottom:auto}.bottom-full{bottom:100%}.left-0{left:0}.left-1\.5{left:.375rem}.left-1\/2{left:50%}.left-\[calc\(1\.5rem\+env\(safe-area-inset-left\)\)\]{left:calc(1.5rem + env(safe-area-inset-left))}.right-0{right:0}.right-3{right:.75rem}.right-\[calc\(1\.5rem\+env\(safe-area-inset-right\)\)\]{right:calc(1.5rem + env(safe-area-inset-right))}.right-\[calc\(env\(safe-area-inset-right\)\+0\.75rem\)\]{right:calc(env(safe-area-inset-right) + .75rem)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:.5rem}.top-\[calc\(env\(safe-area-inset-top\)\+0\.5rem\)\]{top:calc(env(safe-area-inset-top) + .5rem)}.top-\[max\(0\.5rem\2c 10vh\)\]{top:max(.5rem,10vh)}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[35\]{z-index:35}.m-0{margin:0}.m-auto{margin:auto}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-auto{margin-left:auto;margin-right:auto}.my-0{margin-top:0;margin-bottom:0}.my-0\.5{margin-top:.125rem;margin-bottom:.125rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.-ml-1{margin-left:-.25rem}.-mr-0\.5{margin-right:-.125rem}.-mt-2{margin-top:-.5rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-12{margin-top:3rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.\!block{display:block!important}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[2\.8125rem\]{height:2.8125rem}.h-\[70dvh\]{height:70dvh}.h-\[calc\(2\.8125rem\+env\(safe-area-inset-bottom\)\)\]{height:calc(2.8125rem + env(safe-area-inset-bottom))}.h-\[var\(--mv-vh\2c 100dvh\)\]{height:var(--mv-vh,100dvh)}.h-dvh{height:100dvh}.h-full{height:100%}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-56{max-height:14rem}.max-h-8{max-height:2rem}.max-h-\[45vh\]{max-height:45vh}.max-h-\[60dvh\]{max-height:60dvh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85dvh\]{max-height:85dvh}.max-h-\[min\(24rem\2c 60vh\)\]{max-height:min(24rem,60vh)}.min-h-0{min-height:0}.min-h-11{min-height:2.75rem}.min-h-\[2\.25rem\]{min-height:2.25rem}.min-h-\[2lh\]{min-height:2lh}.min-h-\[32px\]{min-height:32px}.min-h-\[36px\]{min-height:36px}.min-h-\[4\.25rem\]{min-height:4.25rem}.min-h-\[40px\]{min-height:40px}.min-h-\[44px\]{min-height:44px}.min-h-\[48px\]{min-height:48px}.min-h-\[calc\(100dvh-6rem\)\]{min-height:calc(100dvh - 6rem)}.min-h-\[calc\(2\.75rem\+1px\+env\(safe-area-inset-bottom\)\)\]{min-height:calc(2.75rem + 1px + env(safe-area-inset-bottom))}.min-h-dvh{min-height:100dvh}.w-1\.5{width:.375rem}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-2\/3{width:66.666667%}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-\[22rem\]{width:22rem}.w-\[4\.5rem\]{width:4.5rem}.w-\[6\.25rem\]{width:6.25rem}.w-\[9\.75rem\]{width:9.75rem}.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-\[min\(28rem\2c calc\(100vw-1rem\)\)\]{width:min(28rem,calc(100vw - 1rem))}.w-\[min\(36rem\2c calc\(100vw-1rem\)\)\]{width:min(36rem,calc(100vw - 1rem))}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.5rem\]{min-width:1.5rem}.min-w-\[11rem\]{min-width:11rem}.min-w-\[12rem\]{min-width:12rem}.min-w-\[14rem\]{min-width:14rem}.min-w-\[36px\]{min-width:36px}.min-w-\[44px\]{min-width:44px}.min-w-\[6rem\]{min-width:6rem}.max-w-2xl{max-width:42rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[110rem\]{max-width:110rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[150rem\]{max-width:150rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[45\%\]{max-width:45%}.max-w-\[50\%\]{max-width:50%}.max-w-\[70ch\]{max-width:70ch}.max-w-\[85\%\]{max-width:85%}.max-w-\[85vw\]{max-width:85vw}.max-w-\[92\%\]{max-width:92%}.max-w-\[calc\(100vw-16px\)\]{max-width:calc(100vw - 16px)}.max-w-\[calc\(100vw-3rem\)\]{max-width:calc(100vw - 3rem)}.max-w-\[min\(90vw\2c 28rem\)\]{max-width:min(90vw,28rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-bottom-left{transform-origin:bottom left}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-full{--tw-translate-x:-100%}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.translate-x-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x:0px}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-2{--tw-translate-y:0.5rem}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.scale-95,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-col-resize{cursor:col-resize}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-none{resize:none}.resize-y{resize:vertical}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-1{-moz-column-gap:.25rem;column-gap:.25rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-line>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(var(--mu-line)/var(--tw-divide-opacity,1))}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.self-center{align-self:center}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.overscroll-contain{overscroll-behavior:contain}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-clip{text-overflow:clip}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-balance{text-wrap:balance}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-l-xl{border-top-left-radius:.75rem;border-bottom-left-radius:.75rem}.rounded-r-full{border-top-right-radius:9999px;border-bottom-right-radius:9999px}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.rounded-r-xl{border-top-right-radius:.75rem;border-bottom-right-radius:.75rem}.rounded-t-2xl{border-top-left-radius:1rem;border-top-right-radius:1rem}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-accent\/30{border-color:rgb(var(--mu-accent)/.3)}.border-accent\/40{border-color:rgb(var(--mu-accent)/.4)}.border-accent\/60{border-color:rgb(var(--mu-accent)/.6)}.border-edge{--tw-border-opacity:1;border-color:rgb(var(--mu-edge)/var(--tw-border-opacity,1))}.border-line{--tw-border-opacity:1;border-color:rgb(var(--mu-line)/var(--tw-border-opacity,1))}.border-on-accent\/30{border-color:rgb(var(--mu-on-accent)/.3)}.border-st-warning-fg\/40{border-color:rgb(var(--mu-st-warning-fg)/.4)}.border-transparent{border-color:transparent}.border-l-\[rgb\(var\(--id-bd\)\)\]{border-left-color:rgb(var(--id-bd))}.border-l-transparent{border-left-color:transparent}.bg-accent{--tw-bg-opacity:1;background-color:rgb(var(--mu-accent)/var(--tw-bg-opacity,1))}.bg-accent\/10{background-color:rgb(var(--mu-accent)/.1)}.bg-accent\/15{background-color:rgb(var(--mu-accent)/.15)}.bg-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-bg)/var(--tw-bg-opacity,1))}.bg-bg\/40{background-color:rgb(var(--mu-bg)/.4)}.bg-bg\/50{background-color:rgb(var(--mu-bg)/.5)}.bg-bg\/60{background-color:rgb(var(--mu-bg)/.6)}.bg-bg\/80{background-color:rgb(var(--mu-bg)/.8)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-current{background-color:currentColor}.bg-danger{--tw-bg-opacity:1;background-color:rgb(var(--mu-danger)/var(--tw-bg-opacity,1))}.bg-danger\/15{background-color:rgb(var(--mu-danger)/.15)}.bg-idle-0{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-0)/var(--tw-bg-opacity,1))}.bg-idle-1{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-1)/var(--tw-bg-opacity,1))}.bg-idle-2{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-2)/var(--tw-bg-opacity,1))}.bg-idle-3{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-3)/var(--tw-bg-opacity,1))}.bg-idle-4{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-4)/var(--tw-bg-opacity,1))}.bg-idle-5{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-5)/var(--tw-bg-opacity,1))}.bg-idle-6{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-6)/var(--tw-bg-opacity,1))}.bg-idle-7{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-7)/var(--tw-bg-opacity,1))}.bg-muted{--tw-bg-opacity:1;background-color:rgb(var(--mu-muted)/var(--tw-bg-opacity,1))}.bg-s1{--tw-bg-opacity:1;background-color:rgb(var(--mu-s1)/var(--tw-bg-opacity,1))}.bg-s1\/40{background-color:rgb(var(--mu-s1)/.4)}.bg-s1\/50{background-color:rgb(var(--mu-s1)/.5)}.bg-s1\/60{background-color:rgb(var(--mu-s1)/.6)}.bg-s1\/70{background-color:rgb(var(--mu-s1)/.7)}.bg-s2{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.bg-s2\/80{background-color:rgb(var(--mu-s2)/.8)}.bg-s3{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.bg-s3\/50{background-color:rgb(var(--mu-s3)/.5)}.bg-s3\/60{background-color:rgb(var(--mu-s3)/.6)}.bg-st-complete-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-complete-bg)/var(--tw-bg-opacity,1))}.bg-st-error-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-error-bg)/var(--tw-bg-opacity,1))}.bg-st-open-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-open-bg)/var(--tw-bg-opacity,1))}.bg-st-progress-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-progress-bg)/var(--tw-bg-opacity,1))}.bg-st-review-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-review-bg)/var(--tw-bg-opacity,1))}.bg-st-review-bg\/40{background-color:rgb(var(--mu-st-review-bg)/.4)}.bg-st-running-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-running-bg)/var(--tw-bg-opacity,1))}.bg-st-warning-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-warning-bg)/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.from-bg{--tw-gradient-from:rgb(var(--mu-bg)/1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--mu-bg)/0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-bg\/80{--tw-gradient-to:rgb(var(--mu-bg)/0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),rgb(var(--mu-bg)/0.8) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-28{padding-bottom:7rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[2\.8125rem\]{padding-bottom:2.8125rem}.pb-\[calc\(1\.25rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(1.25rem + env(safe-area-inset-bottom))}.pb-\[calc\(2\.8125rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(2.8125rem + env(safe-area-inset-bottom))}.pb-\[calc\(4rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(4rem + env(safe-area-inset-bottom))}.pb-\[calc\(7\.5rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(7.5rem + env(safe-area-inset-bottom))}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pb-\[max\(0\.5rem\2c env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.5rem,env(safe-area-inset-bottom))}.pb-\[max\(0\.75rem\2c env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.75rem,env(safe-area-inset-bottom))}.pb-\[var\(--mv-compose-h\2c 4\.5rem\)\]{padding-bottom:var(--mv-compose-h,4.5rem)}.pb-\[var\(--mv-compose-h\2c 7rem\)\]{padding-bottom:var(--mv-compose-h,7rem)}.pl-11{padding-left:2.75rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-5{padding-left:1.25rem}.pl-72{padding-left:18rem}.pl-9{padding-left:2.25rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-28{padding-right:7rem}.pt-0{padding-top:0}.pt-1{padding-top:.25rem}.pt-10{padding-top:2.5rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[0\.7rem\]{font-size:.7rem}.text-\[0\.85em\]{font-size:.85em}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-5{line-height:1.25rem}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-accent{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.text-current{color:currentColor}.text-fg{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.text-fg2{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.text-muted{--tw-text-opacity:1;color:rgb(var(--mu-muted)/var(--tw-text-opacity,1))}.text-on-accent{--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.text-on-danger{--tw-text-opacity:1;color:rgb(var(--mu-on-danger)/var(--tw-text-opacity,1))}.text-st-complete-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-complete-fg)/var(--tw-text-opacity,1))}.text-st-error-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-error-fg)/var(--tw-text-opacity,1))}.text-st-open-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-open-fg)/var(--tw-text-opacity,1))}.text-st-progress-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-progress-fg)/var(--tw-text-opacity,1))}.text-st-review-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-review-fg)/var(--tw-text-opacity,1))}.text-st-running-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-running-fg)/var(--tw-text-opacity,1))}.text-st-warning-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-warning-fg)/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-accent\/40{text-decoration-color:rgb(var(--mu-accent)/.4)}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-\[0_-16px_16px_-4px_rgb\(var\(--mu-s1\)\)\]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_-16px_16px_-4px_rgb\(var\(--mu-s1\)\)\]{--tw-shadow:0 -16px 16px -4px rgb(var(--mu-s1));--tw-shadow-colored:0 -16px 16px -4px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-accent\/20{--tw-shadow-color:rgb(var(--mu-accent)/0.2);--tw-shadow:var(--tw-shadow-colored)}.shadow-accent\/30{--tw-shadow-color:rgb(var(--mu-accent)/0.3);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/20{--tw-shadow-color:rgba(0,0,0,.2);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/30{--tw-shadow-color:rgba(0,0,0,.3);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/40{--tw-shadow-color:rgba(0,0,0,.4);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/50{--tw-shadow-color:rgba(0,0,0,.5);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-inset{--tw-ring-inset:inset}.ring-accent\/20{--tw-ring-color:rgb(var(--mu-accent)/0.2)}.ring-accent\/30{--tw-ring-color:rgb(var(--mu-accent)/0.3)}.ring-accent\/40{--tw-ring-color:rgb(var(--mu-accent)/0.4)}.ring-accent\/50{--tw-ring-color:rgb(var(--mu-accent)/0.5)}.ring-bg{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-bg)/var(--tw-ring-opacity,1))}.ring-black\/20{--tw-ring-color:rgba(0,0,0,.2)}.ring-danger\/40{--tw-ring-color:rgb(var(--mu-danger)/0.4)}.ring-edge{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-edge)/var(--tw-ring-opacity,1))}.ring-line{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-line)/var(--tw-ring-opacity,1))}.ring-st-complete-fg\/40{--tw-ring-color:rgb(var(--mu-st-complete-fg)/0.4)}.ring-st-error-fg\/40{--tw-ring-color:rgb(var(--mu-st-error-fg)/0.4)}.ring-st-open-fg\/40{--tw-ring-color:rgb(var(--mu-st-open-fg)/0.4)}.ring-st-progress-fg\/40{--tw-ring-color:rgb(var(--mu-st-progress-fg)/0.4)}.ring-st-review-fg\/40{--tw-ring-color:rgb(var(--mu-st-review-fg)/0.4)}.ring-st-running-fg\/40{--tw-ring-color:rgb(var(--mu-st-running-fg)/0.4)}.ring-st-warning-fg\/40{--tw-ring-color:rgb(var(--mu-st-warning-fg)/0.4)}.blur{--tw-blur:blur(8px)}.blur,.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)}.backdrop-blur,.backdrop-blur-sm{-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)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-filter{-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,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[i\:j\]{i:j}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.\[scrollbar-width\:none\]{scrollbar-width:none}@keyframes card-in{0%{opacity:0;transform:translateY(12px) scale(.985)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes card-out{0%{opacity:1;transform:translateY(0) scale(1);max-height:60rem}to{opacity:0;transform:translateY(-6px) scale(.985);max-height:0}}@keyframes card-highlight{0%{box-shadow:0 0 0 0 rgb(var(--mu-accent)/0)}25%{box-shadow:0 0 0 2px rgb(var(--mu-accent)/.5)}to{box-shadow:0 0 0 0 rgb(var(--mu-accent)/0)}}@media (prefers-reduced-motion:reduce){.card-enter,.htmx-swapping{animation:none}.press{transition:none}.sidebar-backdrop,.sidebar-panel{transition:none!important}}[data-clamp]{overflow:hidden;flex-shrink:0;transition:max-height .22s cubic-bezier(.2,.8,.2,1)}[data-clamp=closed]{cursor:pointer;-webkit-mask-image:linear-gradient(180deg,#000 calc(100% - 2.5rem),transparent);mask-image:linear-gradient(180deg,#000 calc(100% - 2.5rem),transparent)}@media (prefers-reduced-motion:reduce){[data-clamp]{transition:none}}[data-flash]{animation:card-highlight 1.4s ease-out both}@media (prefers-reduced-motion:reduce){[data-flash]{animation:none;box-shadow:0 0 0 2px rgb(var(--mu-accent)/.5)}}.composer-fit{max-height:var(--composer-max-h,40dvh);overflow-y:auto}@supports (field-sizing:content){.composer-fit{field-sizing:content}}[data-tool-group][open]>[data-tool-group-body]{animation:tool-group-open .18s cubic-bezier(.2,.8,.2,1) both}[data-tool-group-body]>[data-tool-row]{max-width:100%}@keyframes tool-group-open{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}[data-tool-group]>summary [data-tool-group-chevron]{transition:transform .18s ease}[data-tool-group][open]>summary [data-tool-group-chevron]{transform:rotate(90deg)}[data-tool-ctx]:not([hidden])+*{margin-left:0}@media (prefers-reduced-motion:reduce){[data-tool-group][open]>[data-tool-group-body]{animation:none}[data-tool-group]>summary [data-tool-group-chevron]{transition:none}}#switcher-search,#switcher-views{opacity:0;transition:opacity .16s cubic-bezier(.2,.8,.2,1),transform .16s cubic-bezier(.2,.8,.2,1),overlay allow-discrete .16s,display allow-discrete .16s}#switcher-views{transform:translateY(8px) scale(.96);transform-origin:bottom left}#switcher-search{transform:translateY(-8px) scale(.98);transform-origin:top center}#switcher-search:popover-open,#switcher-views:popover-open{opacity:1;transform:none}@starting-style{#switcher-views:popover-open{opacity:0;transform:translateY(8px) scale(.96)}#switcher-search:popover-open{opacity:0;transform:translateY(-8px) scale(.98)}}@media (prefers-reduced-motion:reduce){#switcher-search,#switcher-views{transition:none}}body:has(#agent-status:is([data-agent-status=running],[data-agent-status=provisioning])) #agent-actions [data-lifecycle=idle],body:not(:has(#agent-status:is([data-agent-status=running],[data-agent-status=provisioning]))) #agent-actions [data-lifecycle=live]{display:none}.marker\:content-\[\'\'\] ::marker{--tw-content:"";content:var(--tw-content)}.marker\:content-\[\'\'\]::marker{--tw-content:"";content:var(--tw-content)}.selection\:bg-accent\/30 ::-moz-selection{background-color:rgb(var(--mu-accent)/.3)}.selection\:bg-accent\/30 ::selection{background-color:rgb(var(--mu-accent)/.3)}.selection\:bg-accent\/30::-moz-selection{background-color:rgb(var(--mu-accent)/.3)}.selection\:bg-accent\/30::selection{background-color:rgb(var(--mu-accent)/.3)}.file\:mr-3::file-selector-button{margin-right:.75rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-s2::file-selector-button{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-1\.5::file-selector-button{padding-top:.375rem;padding-bottom:.375rem}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.file\:text-fg::file-selector-button{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.placeholder\:text-fg2::-moz-placeholder{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.placeholder\:text-fg2::placeholder{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.placeholder\:text-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(var(--mu-muted)/var(--tw-text-opacity,1))}.placeholder\:text-muted::placeholder{--tw-text-opacity:1;color:rgb(var(--mu-muted)/var(--tw-text-opacity,1))}.backdrop\:bg-black\/40::backdrop{background-color:rgba(0,0,0,.4)}.backdrop\:bg-black\/60::backdrop{background-color:rgba(0,0,0,.6)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:inset-0:after{content:var(--tw-content);inset:0}.after\:inset-x-0:after{content:var(--tw-content);left:0;right:0}.after\:top-1\/2:after{content:var(--tw-content);top:50%}.after\:z-10:after{content:var(--tw-content);z-index:10}.after\:h-11:after{content:var(--tw-content);height:2.75rem}.after\:-translate-y-1\/2:after{content:var(--tw-content);--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.empty\:hidden:empty{display:none}.hover\:bg-accent\/10:hover{background-color:rgb(var(--mu-accent)/.1)}.hover\:bg-accent\/15:hover{background-color:rgb(var(--mu-accent)/.15)}.hover\:bg-accent\/25:hover{background-color:rgb(var(--mu-accent)/.25)}.hover\:bg-accent\/40:hover{background-color:rgb(var(--mu-accent)/.4)}.hover\:bg-accent\/90:hover{background-color:rgb(var(--mu-accent)/.9)}.hover\:bg-danger\/90:hover{background-color:rgb(var(--mu-danger)/.9)}.hover\:bg-s1\/80:hover{background-color:rgb(var(--mu-s1)/.8)}.hover\:bg-s2:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.hover\:bg-s2\/60:hover{background-color:rgb(var(--mu-s2)/.6)}.hover\:bg-s2\/80:hover{background-color:rgb(var(--mu-s2)/.8)}.hover\:bg-s3:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.hover\:bg-s3\/80:hover{background-color:rgb(var(--mu-s3)/.8)}.hover\:bg-st-error-bg:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-error-bg)/var(--tw-bg-opacity,1))}.hover\:bg-st-review-bg:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-review-bg)/var(--tw-bg-opacity,1))}.hover\:bg-st-running-bg:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-running-bg)/var(--tw-bg-opacity,1))}.hover\:text-accent:hover{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.hover\:text-fg:hover{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.hover\:text-st-error-fg:hover{--tw-text-opacity:1;color:rgb(var(--mu-st-error-fg)/var(--tw-text-opacity,1))}.hover\:text-st-running-fg:hover{--tw-text-opacity:1;color:rgb(var(--mu-st-running-fg)/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:file\:bg-s3::file-selector-button:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.focus\:bg-accent\/60:focus{background-color:rgb(var(--mu-accent)/.6)}.focus\:bg-s2:focus{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-focus:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-focus)/var(--tw-ring-opacity,1))}.focus-visible\:bg-s2:focus-visible{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-inset:focus-visible{--tw-ring-inset:inset}.focus-visible\:ring-focus:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-focus)/var(--tw-ring-opacity,1))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.focus-visible\:ring-offset-bg:focus-visible{--tw-ring-offset-color:rgb(var(--mu-bg)/1)}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95}.active\:scale-95:active,.active\:scale-\[0\.98\]:active{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x:0.98;--tw-scale-y:0.98}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:after\:hidden:disabled:after{content:var(--tw-content);display:none}.group\/pw[open] .group-open\/pw\:mb-2{margin-bottom:.5rem}.group\/adv[open] .group-open\/adv\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/code[open] .group-open\/code\:rotate-90,.group\/idle[open] .group-open\/idle\:rotate-90{--tw-rotate:90deg}.group\/code[open] .group-open\/code\:rotate-90,.group\/idle[open] .group-open\/idle\:rotate-90,.group\/pw[open] .group-open\/pw\:rotate-180{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/pw[open] .group-open\/pw\:rotate-180{--tw-rotate:180deg}.group\/s[open] .group-open\/s\:rotate-90{--tw-rotate:90deg}.group[open] .group-open\:rotate-180,.group\/s[open] .group-open\/s\:rotate-90{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group[open] .group-open\:rotate-180{--tw-rotate:180deg}.group:hover .group-hover\:bg-accent\/25{background-color:rgb(var(--mu-accent)/.25)}.group:hover .group-hover\:bg-accent\/90{background-color:rgb(var(--mu-accent)/.9)}.group:hover .group-hover\:bg-s2{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.group:hover .group-hover\:bg-s3{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.group:hover .group-hover\:text-fg2{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.peer:checked~.peer-checked\:block{display:block}.peer:checked~.peer-checked\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.has-\[\:disabled\]\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-\[\:checked\]\:bg-accent:has(:checked){--tw-bg-opacity:1;background-color:rgb(var(--mu-accent)/var(--tw-bg-opacity,1))}.has-\[\:checked\]\:text-on-accent:has(:checked){--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.has-\[\:disabled\]\:opacity-50:has(:disabled){opacity:.5}.has-\[\:focus-visible\]\:ring-2:has(:focus-visible){--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.has-\[\:focus-visible\]\:ring-focus:has(:focus-visible){--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-focus)/var(--tw-ring-opacity,1))}.aria-checked\:text-accent[aria-checked=true]{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.aria-disabled\:cursor-wait[aria-disabled=true]{cursor:wait}.aria-disabled\:opacity-60[aria-disabled=true]{opacity:.6}.aria-expanded\:bg-s2[aria-expanded=true]{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.aria-expanded\:bg-s3[aria-expanded=true]{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.aria-pressed\:bg-accent[aria-pressed=true]{--tw-bg-opacity:1;background-color:rgb(var(--mu-accent)/var(--tw-bg-opacity,1))}.aria-pressed\:text-on-accent[aria-pressed=true]{--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.aria-selected\:border-accent[aria-selected=true]{--tw-border-opacity:1;border-color:rgb(var(--mu-accent)/var(--tw-border-opacity,1))}.aria-selected\:text-fg[aria-selected=true]{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.group[aria-checked=true] .group-aria-checked\:visible{visibility:visible}.group[aria-expanded=true] .group-aria-expanded\:bg-s3{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.group[aria-expanded=true] .group-aria-expanded\:text-fg{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.group[aria-pressed=true] .group-aria-pressed\:bg-accent\/15{background-color:rgb(var(--mu-accent)/.15)}.group[aria-pressed=true] .group-aria-pressed\:text-accent{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.group[aria-pressed=true] .group-aria-pressed\:ring-accent\/40{--tw-ring-color:rgb(var(--mu-accent)/0.4)}.data-\[active\=true\]\:bg-accent\/15[data-active=true]{background-color:rgb(var(--mu-accent)/.15)}.data-\[state\=recording\]\:bg-danger[data-state=recording]{--tw-bg-opacity:1;background-color:rgb(var(--mu-danger)/var(--tw-bg-opacity,1))}.data-\[active\=true\]\:text-fg[data-active=true]{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.data-\[state\=recording\]\:text-on-accent[data-state=recording]{--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.data-\[state\=recording\]\:ring-0[data-state=recording]{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.group[data-state=busy] .group-data-\[state\=busy\]\:block,.group[data-state=recording] .group-data-\[state\=recording\]\:block{display:block}.group[data-state=busy] .group-data-\[state\=busy\]\:hidden,.group[data-state=recording] .group-data-\[state\=recording\]\:hidden{display:none}@supports ((-webkit-backdrop-filter:var(--tw )) or (backdrop-filter:var(--tw ))){.supports-\[backdrop-filter\]\:bg-bg\/60{background-color:rgb(var(--mu-bg)/.6)}}@media (prefers-reduced-motion:no-preference){@keyframes ping{75%,to{transform:scale(2);opacity:0}}.motion-safe\:animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}}@media not all and (min-width:640px){.max-sm\:hidden{display:none}.max-sm\:h-11{height:2.75rem}.max-sm\:w-11{width:2.75rem}.max-sm\:justify-center{justify-content:center}.max-sm\:px-0{padding-left:0;padding-right:0}}@media (min-width:640px){.sm\:not-sr-only{position:static;width:auto;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal}.sm\:m-auto{margin:auto}.sm\:inline{display:inline}.sm\:hidden{display:none}.sm\:w-\[28rem\]{width:28rem}.sm\:items-center{align-items:center}.sm\:rounded-2xl{border-radius:1rem}.sm\:pb-5{padding-bottom:1.25rem}}@media (min-width:1024px){.lg\:hidden{display:none}.lg\:max-w-5xl{max-width:64rem}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:pl-72{padding-left:18rem}}@media (min-width:1280px){.xl\:max-w-6xl{max-width:72rem}}@media (min-width:1536px){.\32xl\:max-w-\[110rem\]{max-width:110rem}}@media (min-width:2560px){.min-\[2560px\]\:max-w-\[150rem\]{max-width:150rem}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker,.\[\&\:\:-webkit-scrollbar\]\:hidden::-webkit-scrollbar{display:none}.\[\&\:not\(\[hidden\]\)\]\:flex:not([hidden]){display:flex}.\[\&\:popover-open\]\:flex:popover-open{display:flex}.\[\&\:popover-open\]\:grid:popover-open{display:grid}.\[\&\>\*\]\:pointer-events-auto>*{pointer-events:auto}.\[\&\[data-cg-unlaid\]\>\*\]\:invisible[data-cg-unlaid]>*,.\[\&\[data-cg-unlaid\]\]\:invisible[data-cg-unlaid],.\[\&\[data-compose-pending\]\]\:invisible[data-compose-pending],.\[\&\[data-switcher-pending\]\]\:invisible[data-switcher-pending]{visibility:hidden}.\[\&\[hidden\]\]\:hidden[hidden]{display:none}.\[\&\[open\]\]\:flex[open]{display:flex}.\[\&_\[data-task-title\]\]\:text-fg2 [data-task-title]{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))} \ No newline at end of file +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.dark,:root{color-scheme:dark;--mu-bg:17 15 28;--mu-s1:25 22 42;--mu-s2:34 30 55;--mu-s3:45 40 70;--mu-line:47 42 72;--mu-edge:110 103 144;--mu-text:238 235 247;--mu-text2:195 189 217;--mu-muted:154 147 181;--mu-accent:228 181 86;--mu-on-accent:30 22 6;--mu-focus:242 203 120;--mu-danger:209 59 69;--mu-on-danger:255 255 255;--mu-st-open-fg:185 179 211;--mu-st-open-bg:36 32 58;--mu-st-progress-fg:143 184 255;--mu-st-progress-bg:27 37 71;--mu-st-review-fg:215 166 255;--mu-st-review-bg:46 31 69;--mu-st-complete-fg:127 214 164;--mu-st-complete-bg:22 52 40;--mu-st-error-fg:255 143 143;--mu-st-error-bg:61 26 34;--mu-st-warning-fg:255 174 94;--mu-st-warning-bg:58 38 21;--mu-st-running-fg:95 224 192;--mu-st-running-bg:18 51 47;--mu-idle-0:184 187 38;--mu-idle-1:152 151 26;--mu-idle-2:104 157 106;--mu-idle-3:215 153 33;--mu-idle-4:214 93 14;--mu-idle-5:204 36 29;--mu-idle-6:177 98 134;--mu-idle-7:102 92 84}:root:not(.dark){color-scheme:light;--mu-bg:246 244 251;--mu-s1:255 255 255;--mu-s2:238 235 246;--mu-s3:227 223 239;--mu-line:220 215 234;--mu-edge:125 118 153;--mu-text:26 22 48;--mu-text2:68 62 94;--mu-muted:98 92 125;--mu-accent:131 93 15;--mu-on-accent:255 255 255;--mu-focus:107 76 14;--mu-danger:180 38 48;--mu-on-danger:255 255 255;--mu-st-open-fg:79 72 112;--mu-st-open-bg:236 233 245;--mu-st-progress-fg:31 79 176;--mu-st-progress-bg:227 236 255;--mu-st-review-fg:116 40 181;--mu-st-review-bg:243 232 255;--mu-st-complete-fg:28 107 63;--mu-st-complete-bg:226 245 233;--mu-st-error-fg:180 35 47;--mu-st-error-bg:253 231 232;--mu-st-warning-fg:154 75 0;--mu-st-warning-bg:255 238 219;--mu-st-running-fg:13 107 90;--mu-st-running-bg:221 245 239;--mu-idle-0:184 187 38;--mu-idle-1:152 151 26;--mu-idle-2:104 157 106;--mu-idle-3:215 153 33;--mu-idle-4:214 93 14;--mu-idle-5:204 36 29;--mu-idle-6:177 98 134;--mu-idle-7:102 92 84}:root{--glyph-knock:rgb(var(--mu-s1))}[data-identity]{--id-bd:var(--id-bd-d);--id-tt:var(--id-tt-d)}:root:not(.dark) [data-identity]{--id-bd:var(--id-bd-l);--id-tt:var(--id-tt-l)}[hidden]{display:none!important}@media (max-width:639px){input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=file]),select,textarea{font-size:16px!important;min-height:44px!important}input[type=file]{font-size:16px!important;min-height:44px}summary{min-height:44px}}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.card-enter{animation:card-in .28s cubic-bezier(.16,1,.3,1) both,card-highlight 1.4s ease-out .12s both}.press{transition:transform 80ms ease;touch-action:manipulation}.press:active{transform:scale(.97)}.selecting .card-link:after{pointer-events:none}#session-drawer:focus-visible{position:fixed;top:.75rem;right:.75rem;z-index:60;width:2.75rem;height:2.75rem;margin:0;padding:0;overflow:visible;clip:auto;clip-path:none;white-space:normal;border-radius:.5rem;outline:2px solid rgb(var(--mu-focus));outline-offset:2px}#sidebar-root .sidebar-panel{transform:translateX(-100%);visibility:hidden;transition-property:transform,visibility}#sidebar-root .sidebar-backdrop{opacity:0;pointer-events:none}#sidebar-root[data-open=true] .sidebar-panel{transform:translateX(0);visibility:visible}#sidebar-root[data-open=true] .sidebar-backdrop{opacity:1;pointer-events:auto}@media (min-width:1024px){#sidebar-root:not([data-overlay]) .sidebar-panel{transform:translateX(0);visibility:visible}#sidebar-root:not([data-overlay]) .sidebar-backdrop{display:none}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-x-2{left:.5rem;right:.5rem}.inset-y-0{top:0;bottom:0}.-right-1{right:-.25rem}.-top-1{top:-.25rem}.bottom-0{bottom:0}.bottom-2\.5{bottom:.625rem}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.bottom-\[calc\(1\.5rem\+env\(safe-area-inset-bottom\)\)\]{bottom:calc(1.5rem + env(safe-area-inset-bottom))}.bottom-\[calc\(6rem\+env\(safe-area-inset-bottom\)\)\]{bottom:calc(6rem + env(safe-area-inset-bottom))}.bottom-auto{bottom:auto}.bottom-full{bottom:100%}.left-0{left:0}.left-1\.5{left:.375rem}.left-1\/2{left:50%}.left-\[calc\(1\.5rem\+env\(safe-area-inset-left\)\)\]{left:calc(1.5rem + env(safe-area-inset-left))}.right-0{right:0}.right-3{right:.75rem}.right-\[calc\(1\.5rem\+env\(safe-area-inset-right\)\)\]{right:calc(1.5rem + env(safe-area-inset-right))}.right-\[calc\(env\(safe-area-inset-right\)\+0\.75rem\)\]{right:calc(env(safe-area-inset-right) + .75rem)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:.5rem}.top-\[calc\(env\(safe-area-inset-top\)\+0\.5rem\)\]{top:calc(env(safe-area-inset-top) + .5rem)}.top-\[max\(0\.5rem\2c 10vh\)\]{top:max(.5rem,10vh)}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[35\]{z-index:35}.m-0{margin:0}.m-auto{margin:auto}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-auto{margin-left:auto;margin-right:auto}.my-0{margin-top:0;margin-bottom:0}.my-0\.5{margin-top:.125rem;margin-bottom:.125rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.-ml-1{margin-left:-.25rem}.-mr-0\.5{margin-right:-.125rem}.-mt-2{margin-top:-.5rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-auto{margin-right:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-12{margin-top:3rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.\!block{display:block!important}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[2\.8125rem\]{height:2.8125rem}.h-\[70dvh\]{height:70dvh}.h-\[calc\(2\.8125rem\+env\(safe-area-inset-bottom\)\)\]{height:calc(2.8125rem + env(safe-area-inset-bottom))}.h-\[var\(--mv-vh\2c 100dvh\)\]{height:var(--mv-vh,100dvh)}.h-dvh{height:100dvh}.h-full{height:100%}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-56{max-height:14rem}.max-h-8{max-height:2rem}.max-h-\[45vh\]{max-height:45vh}.max-h-\[60dvh\]{max-height:60dvh}.max-h-\[70dvh\]{max-height:70dvh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85dvh\]{max-height:85dvh}.max-h-\[min\(24rem\2c 60vh\)\]{max-height:min(24rem,60vh)}.min-h-0{min-height:0}.min-h-11{min-height:2.75rem}.min-h-\[2\.25rem\]{min-height:2.25rem}.min-h-\[2lh\]{min-height:2lh}.min-h-\[32px\]{min-height:32px}.min-h-\[36px\]{min-height:36px}.min-h-\[4\.25rem\]{min-height:4.25rem}.min-h-\[40px\]{min-height:40px}.min-h-\[44px\]{min-height:44px}.min-h-\[48px\]{min-height:48px}.min-h-\[calc\(100dvh-6rem\)\]{min-height:calc(100dvh - 6rem)}.min-h-\[calc\(2\.75rem\+1px\+env\(safe-area-inset-bottom\)\)\]{min-height:calc(2.75rem + 1px + env(safe-area-inset-bottom))}.min-h-dvh{min-height:100dvh}.w-1\.5{width:.375rem}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-2\/3{width:66.666667%}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-\[22rem\]{width:22rem}.w-\[4\.5rem\]{width:4.5rem}.w-\[6\.25rem\]{width:6.25rem}.w-\[9\.75rem\]{width:9.75rem}.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-\[min\(28rem\2c calc\(100vw-1rem\)\)\]{width:min(28rem,calc(100vw - 1rem))}.w-\[min\(36rem\2c calc\(100vw-1rem\)\)\]{width:min(36rem,calc(100vw - 1rem))}.w-\[min\(90vw\2c 32rem\)\]{width:min(90vw,32rem)}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.5rem\]{min-width:1.5rem}.min-w-\[11rem\]{min-width:11rem}.min-w-\[12rem\]{min-width:12rem}.min-w-\[14rem\]{min-width:14rem}.min-w-\[36px\]{min-width:36px}.min-w-\[44px\]{min-width:44px}.min-w-\[6rem\]{min-width:6rem}.max-w-2xl{max-width:42rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[110rem\]{max-width:110rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[150rem\]{max-width:150rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[45\%\]{max-width:45%}.max-w-\[50\%\]{max-width:50%}.max-w-\[70ch\]{max-width:70ch}.max-w-\[85\%\]{max-width:85%}.max-w-\[85vw\]{max-width:85vw}.max-w-\[92\%\]{max-width:92%}.max-w-\[calc\(100vw-16px\)\]{max-width:calc(100vw - 16px)}.max-w-\[calc\(100vw-3rem\)\]{max-width:calc(100vw - 3rem)}.max-w-\[min\(90vw\2c 28rem\)\]{max-width:min(90vw,28rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.origin-bottom-left{transform-origin:bottom left}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-full{--tw-translate-x:-100%}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.translate-x-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x:0px}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-2{--tw-translate-y:0.5rem}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.scale-95,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-col-resize{cursor:col-resize}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-none{resize:none}.resize-y{resize:vertical}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-1{-moz-column-gap:.25rem;column-gap:.25rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-line>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(var(--mu-line)/var(--tw-divide-opacity,1))}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.self-center{align-self:center}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.overscroll-contain{overscroll-behavior:contain}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-clip{text-overflow:clip}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-balance{text-wrap:balance}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-l-xl{border-top-left-radius:.75rem;border-bottom-left-radius:.75rem}.rounded-r-full{border-top-right-radius:9999px;border-bottom-right-radius:9999px}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.rounded-r-xl{border-top-right-radius:.75rem;border-bottom-right-radius:.75rem}.rounded-t-2xl{border-top-left-radius:1rem;border-top-right-radius:1rem}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-accent\/30{border-color:rgb(var(--mu-accent)/.3)}.border-accent\/40{border-color:rgb(var(--mu-accent)/.4)}.border-accent\/60{border-color:rgb(var(--mu-accent)/.6)}.border-edge{--tw-border-opacity:1;border-color:rgb(var(--mu-edge)/var(--tw-border-opacity,1))}.border-line{--tw-border-opacity:1;border-color:rgb(var(--mu-line)/var(--tw-border-opacity,1))}.border-on-accent\/30{border-color:rgb(var(--mu-on-accent)/.3)}.border-st-warning-fg\/40{border-color:rgb(var(--mu-st-warning-fg)/.4)}.border-transparent{border-color:transparent}.border-l-\[rgb\(var\(--id-bd\)\)\]{border-left-color:rgb(var(--id-bd))}.border-l-transparent{border-left-color:transparent}.bg-accent{--tw-bg-opacity:1;background-color:rgb(var(--mu-accent)/var(--tw-bg-opacity,1))}.bg-accent\/10{background-color:rgb(var(--mu-accent)/.1)}.bg-accent\/15{background-color:rgb(var(--mu-accent)/.15)}.bg-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-bg)/var(--tw-bg-opacity,1))}.bg-bg\/40{background-color:rgb(var(--mu-bg)/.4)}.bg-bg\/50{background-color:rgb(var(--mu-bg)/.5)}.bg-bg\/60{background-color:rgb(var(--mu-bg)/.6)}.bg-bg\/80{background-color:rgb(var(--mu-bg)/.8)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-current{background-color:currentColor}.bg-danger{--tw-bg-opacity:1;background-color:rgb(var(--mu-danger)/var(--tw-bg-opacity,1))}.bg-danger\/15{background-color:rgb(var(--mu-danger)/.15)}.bg-idle-0{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-0)/var(--tw-bg-opacity,1))}.bg-idle-1{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-1)/var(--tw-bg-opacity,1))}.bg-idle-2{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-2)/var(--tw-bg-opacity,1))}.bg-idle-3{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-3)/var(--tw-bg-opacity,1))}.bg-idle-4{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-4)/var(--tw-bg-opacity,1))}.bg-idle-5{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-5)/var(--tw-bg-opacity,1))}.bg-idle-6{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-6)/var(--tw-bg-opacity,1))}.bg-idle-7{--tw-bg-opacity:1;background-color:rgb(var(--mu-idle-7)/var(--tw-bg-opacity,1))}.bg-muted{--tw-bg-opacity:1;background-color:rgb(var(--mu-muted)/var(--tw-bg-opacity,1))}.bg-s1{--tw-bg-opacity:1;background-color:rgb(var(--mu-s1)/var(--tw-bg-opacity,1))}.bg-s1\/40{background-color:rgb(var(--mu-s1)/.4)}.bg-s1\/50{background-color:rgb(var(--mu-s1)/.5)}.bg-s1\/60{background-color:rgb(var(--mu-s1)/.6)}.bg-s1\/70{background-color:rgb(var(--mu-s1)/.7)}.bg-s2{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.bg-s2\/80{background-color:rgb(var(--mu-s2)/.8)}.bg-s3{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.bg-s3\/50{background-color:rgb(var(--mu-s3)/.5)}.bg-s3\/60{background-color:rgb(var(--mu-s3)/.6)}.bg-st-complete-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-complete-bg)/var(--tw-bg-opacity,1))}.bg-st-error-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-error-bg)/var(--tw-bg-opacity,1))}.bg-st-open-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-open-bg)/var(--tw-bg-opacity,1))}.bg-st-progress-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-progress-bg)/var(--tw-bg-opacity,1))}.bg-st-review-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-review-bg)/var(--tw-bg-opacity,1))}.bg-st-review-bg\/40{background-color:rgb(var(--mu-st-review-bg)/.4)}.bg-st-running-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-running-bg)/var(--tw-bg-opacity,1))}.bg-st-warning-bg{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-warning-bg)/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.from-bg{--tw-gradient-from:rgb(var(--mu-bg)/1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--mu-bg)/0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-bg\/80{--tw-gradient-to:rgb(var(--mu-bg)/0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),rgb(var(--mu-bg)/0.8) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-28{padding-bottom:7rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[2\.8125rem\]{padding-bottom:2.8125rem}.pb-\[calc\(1\.25rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(1.25rem + env(safe-area-inset-bottom))}.pb-\[calc\(2\.8125rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(2.8125rem + env(safe-area-inset-bottom))}.pb-\[calc\(4rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(4rem + env(safe-area-inset-bottom))}.pb-\[calc\(7\.5rem\+env\(safe-area-inset-bottom\)\)\]{padding-bottom:calc(7.5rem + env(safe-area-inset-bottom))}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pb-\[max\(0\.5rem\2c env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.5rem,env(safe-area-inset-bottom))}.pb-\[max\(0\.75rem\2c env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.75rem,env(safe-area-inset-bottom))}.pb-\[var\(--mv-compose-h\2c 4\.5rem\)\]{padding-bottom:var(--mv-compose-h,4.5rem)}.pb-\[var\(--mv-compose-h\2c 7rem\)\]{padding-bottom:var(--mv-compose-h,7rem)}.pl-11{padding-left:2.75rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.pl-5{padding-left:1.25rem}.pl-72{padding-left:18rem}.pl-9{padding-left:2.25rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-28{padding-right:7rem}.pt-0{padding-top:0}.pt-1{padding-top:.25rem}.pt-10{padding-top:2.5rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[0\.7rem\]{font-size:.7rem}.text-\[0\.85em\]{font-size:.85em}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-5{line-height:1.25rem}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-accent{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.text-current{color:currentColor}.text-fg{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.text-fg2{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.text-muted{--tw-text-opacity:1;color:rgb(var(--mu-muted)/var(--tw-text-opacity,1))}.text-on-accent{--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.text-on-danger{--tw-text-opacity:1;color:rgb(var(--mu-on-danger)/var(--tw-text-opacity,1))}.text-st-complete-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-complete-fg)/var(--tw-text-opacity,1))}.text-st-error-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-error-fg)/var(--tw-text-opacity,1))}.text-st-open-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-open-fg)/var(--tw-text-opacity,1))}.text-st-progress-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-progress-fg)/var(--tw-text-opacity,1))}.text-st-review-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-review-fg)/var(--tw-text-opacity,1))}.text-st-running-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-running-fg)/var(--tw-text-opacity,1))}.text-st-warning-fg{--tw-text-opacity:1;color:rgb(var(--mu-st-warning-fg)/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-accent\/40{text-decoration-color:rgb(var(--mu-accent)/.4)}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-\[0_-16px_16px_-4px_rgb\(var\(--mu-s1\)\)\]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_-16px_16px_-4px_rgb\(var\(--mu-s1\)\)\]{--tw-shadow:0 -16px 16px -4px rgb(var(--mu-s1));--tw-shadow-colored:0 -16px 16px -4px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-accent\/20{--tw-shadow-color:rgb(var(--mu-accent)/0.2);--tw-shadow:var(--tw-shadow-colored)}.shadow-accent\/30{--tw-shadow-color:rgb(var(--mu-accent)/0.3);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/20{--tw-shadow-color:rgba(0,0,0,.2);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/30{--tw-shadow-color:rgba(0,0,0,.3);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/40{--tw-shadow-color:rgba(0,0,0,.4);--tw-shadow:var(--tw-shadow-colored)}.shadow-black\/50{--tw-shadow-color:rgba(0,0,0,.5);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-inset{--tw-ring-inset:inset}.ring-accent\/20{--tw-ring-color:rgb(var(--mu-accent)/0.2)}.ring-accent\/30{--tw-ring-color:rgb(var(--mu-accent)/0.3)}.ring-accent\/40{--tw-ring-color:rgb(var(--mu-accent)/0.4)}.ring-accent\/50{--tw-ring-color:rgb(var(--mu-accent)/0.5)}.ring-bg{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-bg)/var(--tw-ring-opacity,1))}.ring-black\/20{--tw-ring-color:rgba(0,0,0,.2)}.ring-danger\/40{--tw-ring-color:rgb(var(--mu-danger)/0.4)}.ring-edge{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-edge)/var(--tw-ring-opacity,1))}.ring-line{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-line)/var(--tw-ring-opacity,1))}.ring-st-complete-fg\/40{--tw-ring-color:rgb(var(--mu-st-complete-fg)/0.4)}.ring-st-error-fg\/40{--tw-ring-color:rgb(var(--mu-st-error-fg)/0.4)}.ring-st-open-fg\/40{--tw-ring-color:rgb(var(--mu-st-open-fg)/0.4)}.ring-st-progress-fg\/40{--tw-ring-color:rgb(var(--mu-st-progress-fg)/0.4)}.ring-st-review-fg\/40{--tw-ring-color:rgb(var(--mu-st-review-fg)/0.4)}.ring-st-running-fg\/40{--tw-ring-color:rgb(var(--mu-st-running-fg)/0.4)}.ring-st-warning-fg\/40{--tw-ring-color:rgb(var(--mu-st-warning-fg)/0.4)}.blur{--tw-blur:blur(8px)}.blur,.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)}.backdrop-blur,.backdrop-blur-sm{-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)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-filter{-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,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[i\:j\]{i:j}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.\[scrollbar-width\:none\]{scrollbar-width:none}@keyframes card-in{0%{opacity:0;transform:translateY(12px) scale(.985)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes card-out{0%{opacity:1;transform:translateY(0) scale(1);max-height:60rem}to{opacity:0;transform:translateY(-6px) scale(.985);max-height:0}}@keyframes card-highlight{0%{box-shadow:0 0 0 0 rgb(var(--mu-accent)/0)}25%{box-shadow:0 0 0 2px rgb(var(--mu-accent)/.5)}to{box-shadow:0 0 0 0 rgb(var(--mu-accent)/0)}}@media (prefers-reduced-motion:reduce){.card-enter,.htmx-swapping{animation:none}.press{transition:none}.sidebar-backdrop,.sidebar-panel{transition:none!important}}[data-clamp]{overflow:hidden;flex-shrink:0;transition:max-height .22s cubic-bezier(.2,.8,.2,1)}[data-clamp=closed]{cursor:pointer;-webkit-mask-image:linear-gradient(180deg,#000 calc(100% - 2.5rem),transparent);mask-image:linear-gradient(180deg,#000 calc(100% - 2.5rem),transparent)}@media (prefers-reduced-motion:reduce){[data-clamp]{transition:none}}[data-flash]{animation:card-highlight 1.4s ease-out both}@media (prefers-reduced-motion:reduce){[data-flash]{animation:none;box-shadow:0 0 0 2px rgb(var(--mu-accent)/.5)}}.composer-fit{max-height:var(--composer-max-h,40dvh);overflow-y:auto}@supports (field-sizing:content){.composer-fit{field-sizing:content}}[data-tool-group][open]>[data-tool-group-body]{animation:tool-group-open .18s cubic-bezier(.2,.8,.2,1) both}[data-tool-group-body]>[data-tool-row]{max-width:100%}@keyframes tool-group-open{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}[data-tool-group]>summary [data-tool-group-chevron]{transition:transform .18s ease}[data-tool-group][open]>summary [data-tool-group-chevron]{transform:rotate(90deg)}[data-tool-ctx]:not([hidden])+*{margin-left:0}@media (prefers-reduced-motion:reduce){[data-tool-group][open]>[data-tool-group-body]{animation:none}[data-tool-group]>summary [data-tool-group-chevron]{transition:none}}#switcher-search,#switcher-views{opacity:0;transition:opacity .16s cubic-bezier(.2,.8,.2,1),transform .16s cubic-bezier(.2,.8,.2,1),overlay allow-discrete .16s,display allow-discrete .16s}#switcher-views{transform:translateY(8px) scale(.96);transform-origin:bottom left}#switcher-search{transform:translateY(-8px) scale(.98);transform-origin:top center}#switcher-search:popover-open,#switcher-views:popover-open{opacity:1;transform:none}@starting-style{#switcher-views:popover-open{opacity:0;transform:translateY(8px) scale(.96)}#switcher-search:popover-open{opacity:0;transform:translateY(-8px) scale(.98)}}@media (prefers-reduced-motion:reduce){#switcher-search,#switcher-views{transition:none}}body:has(#agent-status:is([data-agent-status=running],[data-agent-status=provisioning])) #agent-actions [data-lifecycle=idle],body:not(:has(#agent-status:is([data-agent-status=running],[data-agent-status=provisioning]))) #agent-actions [data-lifecycle=live]{display:none}.marker\:content-\[\'\'\] ::marker{--tw-content:"";content:var(--tw-content)}.marker\:content-\[\'\'\]::marker{--tw-content:"";content:var(--tw-content)}.selection\:bg-accent\/30 ::-moz-selection{background-color:rgb(var(--mu-accent)/.3)}.selection\:bg-accent\/30 ::selection{background-color:rgb(var(--mu-accent)/.3)}.selection\:bg-accent\/30::-moz-selection{background-color:rgb(var(--mu-accent)/.3)}.selection\:bg-accent\/30::selection{background-color:rgb(var(--mu-accent)/.3)}.file\:mr-3::file-selector-button{margin-right:.75rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-s2::file-selector-button{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-1\.5::file-selector-button{padding-top:.375rem;padding-bottom:.375rem}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.file\:text-fg::file-selector-button{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.placeholder\:text-fg2::-moz-placeholder{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.placeholder\:text-fg2::placeholder{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.placeholder\:text-muted::-moz-placeholder{--tw-text-opacity:1;color:rgb(var(--mu-muted)/var(--tw-text-opacity,1))}.placeholder\:text-muted::placeholder{--tw-text-opacity:1;color:rgb(var(--mu-muted)/var(--tw-text-opacity,1))}.backdrop\:bg-black\/40::backdrop{background-color:rgba(0,0,0,.4)}.backdrop\:bg-black\/60::backdrop{background-color:rgba(0,0,0,.6)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:inset-0:after{content:var(--tw-content);inset:0}.after\:inset-x-0:after{content:var(--tw-content);left:0;right:0}.after\:top-1\/2:after{content:var(--tw-content);top:50%}.after\:z-10:after{content:var(--tw-content);z-index:10}.after\:h-11:after{content:var(--tw-content);height:2.75rem}.after\:-translate-y-1\/2:after{content:var(--tw-content);--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.empty\:hidden:empty{display:none}.hover\:bg-accent\/10:hover{background-color:rgb(var(--mu-accent)/.1)}.hover\:bg-accent\/15:hover{background-color:rgb(var(--mu-accent)/.15)}.hover\:bg-accent\/25:hover{background-color:rgb(var(--mu-accent)/.25)}.hover\:bg-accent\/40:hover{background-color:rgb(var(--mu-accent)/.4)}.hover\:bg-accent\/90:hover{background-color:rgb(var(--mu-accent)/.9)}.hover\:bg-danger\/90:hover{background-color:rgb(var(--mu-danger)/.9)}.hover\:bg-s1\/80:hover{background-color:rgb(var(--mu-s1)/.8)}.hover\:bg-s2:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.hover\:bg-s2\/60:hover{background-color:rgb(var(--mu-s2)/.6)}.hover\:bg-s2\/80:hover{background-color:rgb(var(--mu-s2)/.8)}.hover\:bg-s3:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.hover\:bg-s3\/80:hover{background-color:rgb(var(--mu-s3)/.8)}.hover\:bg-st-error-bg:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-error-bg)/var(--tw-bg-opacity,1))}.hover\:bg-st-review-bg:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-review-bg)/var(--tw-bg-opacity,1))}.hover\:bg-st-running-bg:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-st-running-bg)/var(--tw-bg-opacity,1))}.hover\:text-accent:hover{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.hover\:text-fg:hover{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.hover\:text-st-error-fg:hover{--tw-text-opacity:1;color:rgb(var(--mu-st-error-fg)/var(--tw-text-opacity,1))}.hover\:text-st-running-fg:hover{--tw-text-opacity:1;color:rgb(var(--mu-st-running-fg)/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:file\:bg-s3::file-selector-button:hover{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.focus\:bg-accent\/60:focus{background-color:rgb(var(--mu-accent)/.6)}.focus\:bg-s2:focus{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-focus:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-focus)/var(--tw-ring-opacity,1))}.focus-visible\:bg-s2:focus-visible{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-inset:focus-visible{--tw-ring-inset:inset}.focus-visible\:ring-focus:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-focus)/var(--tw-ring-opacity,1))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.focus-visible\:ring-offset-bg:focus-visible{--tw-ring-offset-color:rgb(var(--mu-bg)/1)}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95}.active\:scale-95:active,.active\:scale-\[0\.98\]:active{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.active\:scale-\[0\.98\]:active{--tw-scale-x:0.98;--tw-scale-y:0.98}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:after\:hidden:disabled:after{content:var(--tw-content);display:none}.group\/pw[open] .group-open\/pw\:mb-2{margin-bottom:.5rem}.group\/adv[open] .group-open\/adv\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/code[open] .group-open\/code\:rotate-90,.group\/idle[open] .group-open\/idle\:rotate-90{--tw-rotate:90deg}.group\/code[open] .group-open\/code\:rotate-90,.group\/idle[open] .group-open\/idle\:rotate-90,.group\/pw[open] .group-open\/pw\:rotate-180{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/pw[open] .group-open\/pw\:rotate-180{--tw-rotate:180deg}.group\/s[open] .group-open\/s\:rotate-90{--tw-rotate:90deg}.group[open] .group-open\:rotate-180,.group\/s[open] .group-open\/s\:rotate-90{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group[open] .group-open\:rotate-180{--tw-rotate:180deg}.group:hover .group-hover\:bg-accent\/25{background-color:rgb(var(--mu-accent)/.25)}.group:hover .group-hover\:bg-accent\/90{background-color:rgb(var(--mu-accent)/.9)}.group:hover .group-hover\:bg-s2{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.group:hover .group-hover\:bg-s3{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.group:hover .group-hover\:text-fg2{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))}.peer:checked~.peer-checked\:block{display:block}.peer:checked~.peer-checked\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.has-\[\:disabled\]\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-\[\:checked\]\:bg-accent:has(:checked){--tw-bg-opacity:1;background-color:rgb(var(--mu-accent)/var(--tw-bg-opacity,1))}.has-\[\:checked\]\:text-on-accent:has(:checked){--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.has-\[\:disabled\]\:opacity-50:has(:disabled){opacity:.5}.has-\[\:focus-visible\]\:ring-2:has(:focus-visible){--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.has-\[\:focus-visible\]\:ring-focus:has(:focus-visible){--tw-ring-opacity:1;--tw-ring-color:rgb(var(--mu-focus)/var(--tw-ring-opacity,1))}.aria-checked\:text-accent[aria-checked=true]{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.aria-disabled\:cursor-wait[aria-disabled=true]{cursor:wait}.aria-disabled\:opacity-60[aria-disabled=true]{opacity:.6}.aria-expanded\:bg-s2[aria-expanded=true]{--tw-bg-opacity:1;background-color:rgb(var(--mu-s2)/var(--tw-bg-opacity,1))}.aria-expanded\:bg-s3[aria-expanded=true]{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.aria-pressed\:bg-accent[aria-pressed=true]{--tw-bg-opacity:1;background-color:rgb(var(--mu-accent)/var(--tw-bg-opacity,1))}.aria-pressed\:text-on-accent[aria-pressed=true]{--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.aria-selected\:border-accent[aria-selected=true]{--tw-border-opacity:1;border-color:rgb(var(--mu-accent)/var(--tw-border-opacity,1))}.aria-selected\:text-fg[aria-selected=true]{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.group[aria-checked=true] .group-aria-checked\:visible{visibility:visible}.group[aria-expanded=true] .group-aria-expanded\:bg-s3{--tw-bg-opacity:1;background-color:rgb(var(--mu-s3)/var(--tw-bg-opacity,1))}.group[aria-expanded=true] .group-aria-expanded\:text-fg{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.group[aria-pressed=true] .group-aria-pressed\:bg-accent\/15{background-color:rgb(var(--mu-accent)/.15)}.group[aria-pressed=true] .group-aria-pressed\:text-accent{--tw-text-opacity:1;color:rgb(var(--mu-accent)/var(--tw-text-opacity,1))}.group[aria-pressed=true] .group-aria-pressed\:ring-accent\/40{--tw-ring-color:rgb(var(--mu-accent)/0.4)}.data-\[active\=true\]\:bg-accent\/15[data-active=true]{background-color:rgb(var(--mu-accent)/.15)}.data-\[state\=recording\]\:bg-danger[data-state=recording]{--tw-bg-opacity:1;background-color:rgb(var(--mu-danger)/var(--tw-bg-opacity,1))}.data-\[active\=true\]\:text-fg[data-active=true]{--tw-text-opacity:1;color:rgb(var(--mu-text)/var(--tw-text-opacity,1))}.data-\[state\=recording\]\:text-on-accent[data-state=recording]{--tw-text-opacity:1;color:rgb(var(--mu-on-accent)/var(--tw-text-opacity,1))}.data-\[state\=recording\]\:ring-0[data-state=recording]{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.group[data-state=busy] .group-data-\[state\=busy\]\:block,.group[data-state=recording] .group-data-\[state\=recording\]\:block{display:block}.group[data-state=busy] .group-data-\[state\=busy\]\:hidden,.group[data-state=recording] .group-data-\[state\=recording\]\:hidden{display:none}@supports ((-webkit-backdrop-filter:var(--tw )) or (backdrop-filter:var(--tw ))){.supports-\[backdrop-filter\]\:bg-bg\/60{background-color:rgb(var(--mu-bg)/.6)}}@media (prefers-reduced-motion:no-preference){@keyframes ping{75%,to{transform:scale(2);opacity:0}}.motion-safe\:animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}}@media not all and (min-width:640px){.max-sm\:hidden{display:none}.max-sm\:h-11{height:2.75rem}.max-sm\:w-11{width:2.75rem}.max-sm\:justify-center{justify-content:center}.max-sm\:px-0{padding-left:0;padding-right:0}}@media (min-width:640px){.sm\:not-sr-only{position:static;width:auto;height:auto;padding:0;margin:0;overflow:visible;clip:auto;white-space:normal}.sm\:m-auto{margin:auto}.sm\:inline{display:inline}.sm\:hidden{display:none}.sm\:w-\[28rem\]{width:28rem}.sm\:items-center{align-items:center}.sm\:rounded-2xl{border-radius:1rem}.sm\:pb-5{padding-bottom:1.25rem}}@media (min-width:1024px){.lg\:hidden{display:none}.lg\:max-w-5xl{max-width:64rem}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:pl-72{padding-left:18rem}}@media (min-width:1280px){.xl\:max-w-6xl{max-width:72rem}}@media (min-width:1536px){.\32xl\:max-w-\[110rem\]{max-width:110rem}}@media (min-width:2560px){.min-\[2560px\]\:max-w-\[150rem\]{max-width:150rem}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker,.\[\&\:\:-webkit-scrollbar\]\:hidden::-webkit-scrollbar{display:none}.\[\&\:not\(\[hidden\]\)\]\:flex:not([hidden]){display:flex}.\[\&\:popover-open\]\:flex:popover-open{display:flex}.\[\&\:popover-open\]\:grid:popover-open{display:grid}.\[\&\>\*\]\:pointer-events-auto>*{pointer-events:auto}.\[\&\[data-cg-unlaid\]\>\*\]\:invisible[data-cg-unlaid]>*,.\[\&\[data-cg-unlaid\]\]\:invisible[data-cg-unlaid],.\[\&\[data-compose-pending\]\]\:invisible[data-compose-pending],.\[\&\[data-switcher-pending\]\]\:invisible[data-switcher-pending]{visibility:hidden}.\[\&\[hidden\]\]\:hidden[hidden]{display:none}.\[\&\[open\]\]\:flex[open]{display:flex}.\[\&_\[data-task-title\]\]\:text-fg2 [data-task-title]{--tw-text-opacity:1;color:rgb(var(--mu-text2)/var(--tw-text-opacity,1))} \ No newline at end of file From 870d185d5ee6a83554db0838eadf770a78fedc8e Mon Sep 17 00:00:00 2001 From: Zachary Lowden Date: Sun, 1 Nov 2026 10:00:37 -0600 Subject: [PATCH 2/2] send queue: hide failures handed back to the chief; pin FailedShown Audit round 1 on #391: - Y1: a failed routed message (route_id) or chief nudge (chief_nudge_id) has no Retry and its outcome goes back to the chief, so under the new filter it sat in the list until deleted by hand. List now leaves both out (both columns are NULL on a person's own row); the in-memory fake in internal/api matches. TestListLeavesOutAFailureHandedBackToTheChief: red before the filter change, green after. The e2e specs that made a failure with sqfix 'route' to exercise Delete now use an expired message (a person's own failure); the routed one now asserts it leaves the list. - Y2: TestFailedShownCoversAWholeQueue pins FailedShown >= MaxOpenPerTarget (20), and TestListBoundsTheFailures expects a literal 21 rows. The FailedShown=5 mutant is red in both. - G4: the docs and the store comment no longer say a failure "never disappears": it is listed until retried, deleted, or pruned 7 days after it failed, only the newest 20, and routed/nudge failures not at all. - G5: Copy message reads the row's popover text; the duplicate template is gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/sending-and-step-up.md | 17 ++++---- e2e/tests/send-queue.spec.ts | 41 ++++++++++---------- internal/api/send_queue_test.go | 7 ++-- internal/sendqueue/store.go | 28 ++++++++------ internal/sendqueue/store_list_pg_test.go | 49 ++++++++++++++++++++++-- internal/ui/send_queue.go | 19 +++++---- 6 files changed, 106 insertions(+), 55 deletions(-) diff --git a/docs/sending-and-step-up.md b/docs/sending-and-step-up.md index bc09101e4..88d0849ab 100644 --- a/docs/sending-and-step-up.md +++ b/docs/sending-and-step-up.md @@ -132,10 +132,13 @@ dispatched agents and the chief panel have no Queue. - 🔴 **A usage limit holds the queue.** See [A usage limit holds the queue](#a-usage-limit-holds-the-queue) below. - **At most 20** messages can wait per window. The list shows only what still - needs something: messages queued or being sent, and failed ones you have not - retried or deleted (the 20 newest). A sent or cancelled message leaves the - list at once; a failed one stays until you **Retry** or **Delete** it, so a - failure never disappears unseen. Finished messages are deleted after 7 days. + needs something: messages queued or being sent, and your own failed messages + that you have not retried or deleted. A sent or cancelled message leaves the + list at once. A failed message of your own stays until you **Retry** or + **Delete** it, or until it is deleted 7 days after it failed (every finished + message is); only the 20 newest are listed. A failed message your chief + routed, or a chief nudge that failed, is not listed: its outcome goes back to + the chief, which handles it. A tap on a message in the list shows its whole text (the list shows the first two lines). - **Never twice.** A message is claimed (`queued` → `sending`) before it is sent @@ -295,9 +298,9 @@ any finished message (sent, failed or cancelled), though the list draws only failed ones. A message still queued has no Delete (Cancel it first), and neither has one being sent: the send in progress still holds it. Deleting a sent message does not let the next one in early: the next message still waits -for the turn the deleted one started. Deleting a failed message your chief routed is fine: its -route is not affected, and if the route times out, the message still goes back -to the chief. +for the turn the deleted one started. A failed message your chief routed is not +listed, but deleting one through the route is fine: its route is not affected, +and if the route times out, the message still goes back to the chief. 🔴 **A queued message does not outlive the account that queued it.** When a message is sent, the server checks the account again, from the database: if it diff --git a/e2e/tests/send-queue.spec.ts b/e2e/tests/send-queue.spec.ts index 684553a05..18039de57 100644 --- a/e2e/tests/send-queue.spec.ts +++ b/e2e/tests/send-queue.spec.ts @@ -447,11 +447,13 @@ test.describe('desktop 1280×800', () => { expect(pasted(), 'an expired message is not pasted').toEqual([]); }); - // 🔴 A ROUTED MESSAGE THAT FAILED OFFERS NO RETRY: Retry would queue it again - // as the person's own, with no route and no session binding, past the gate - // its route is. A failed route goes back to the chief instead. The test - // above is the control: a person's failed row draws the same button. - test('a routed message that failed offers no Retry', async ({ page }) => { + // 🔴 A ROUTED MESSAGE THAT FAILED IS HANDED BACK, NOT LISTED: it has no Retry + // (Retry would queue it again as the person's own, with no route and no + // session binding, past the gate its route is) — its failed route goes back + // to the chief, which is its handling, so it leaves the list rather than + // waiting there for a Delete. The test above is the control: a person's own + // failure stays, with its Retry. + test('a routed message that failed leaves the list and is not pasted', async ({ page }) => { await busyAndSeen(); fs.rmSync(tr + '.pasted', { force: true }); await openView(page, newHost); @@ -460,18 +462,18 @@ test.describe('desktop 1280×800', () => { await expect(rowIn(page, text, 'queued')).toHaveCount(1); sqfix('route', newHost, text); busy(false); - const failed = rowIn(page, text, 'failed'); - await expect(failed, `the route gate fails it (log:\n${mirrorLog})`).toContainText('its route could not be read', { timeout: 30_000 }); + await expect(row(page, text), `the route gate fails it, and it leaves the list (log:\n${mirrorLog})`).toHaveCount(0, { timeout: 30_000 }); + await holdWindow(); expect(pasted(), 'a routed message whose route is gone is not pasted').toEqual([]); - await expect(failed.getByRole('button', { name: /Queue this/ }), 'a failed routed row offers no Retry').toHaveCount(0); + await expect(row(page, text), 'and it does not come back').toHaveCount(0); await busyAndSeen(); }); - // 🔴 A FINISHED MESSAGE CAN BE DELETED — the operator's case: a failed - // message the chief routed has no Retry, and used to sit in the list until it - // was pruned. Its Delete removes the row without a page reload, and another - // device's list no longer has it. A queued message has no Delete (that is - // Cancel). On origin/main there is no Delete, so this fails at the button. + // 🔴 A FAILED MESSAGE CAN BE DELETED: a person's own failure stays in the + // list until it is retried or deleted. Its Delete removes the row without a + // page reload, and another device's list no longer has it. A queued message + // has no Delete (that is Cancel). On origin/main there is no Delete, so this + // fails at the button. test('a failed message has Delete, and Delete removes it without a reload, on every device', async ({ page, browser }) => { await busyAndSeen(); fs.rmSync(tr + '.pasted', { force: true }); @@ -481,11 +483,10 @@ test.describe('desktop 1280×800', () => { const queuedRow = rowIn(page, text, 'queued'); await expect(queuedRow).toHaveCount(1); await expect(queuedRow.getByRole('button', { name: 'Delete this message' }), 'a queued message has no Delete').toHaveCount(0); - sqfix('route', newHost, text); - busy(false); + // Expired while busy: the dispatcher's own pass fails it, nothing pasted. + sqfix('age', newHost, text, String(7 * 24 * 3600 + 60)); const failed = rowIn(page, text, 'failed'); - await expect(failed, `the route gate fails it (log:\n${mirrorLog})`).toContainText('its route could not be read', { timeout: 30_000 }); - await expect(failed.getByRole('button', { name: /Queue this/ }), 'no Retry: the operator case').toHaveCount(0); + await expect(failed, `the dispatcher expires it (log:\n${mirrorLog})`).toContainText('queued more than 7 days ago', { timeout: 30_000 }); const other = await otherDevice(browser); await openView(other.page, newHost); @@ -521,10 +522,10 @@ test.describe('desktop 1280×800', () => { const text = 'deleted mid-read ' + stamp; await queue(page, text, false); await expect(rowIn(page, text, 'queued')).toHaveCount(1); - sqfix('route', newHost, text); - busy(false); + // Expired while busy: the dispatcher's own pass fails it. + sqfix('age', newHost, text, String(7 * 24 * 3600 + 60)); const failed = rowIn(page, text, 'failed'); - await expect(failed, `the route gate fails it (log:\n${mirrorLog})`).toContainText('its route could not be read', { timeout: 30_000 }); + await expect(failed, `the dispatcher expires it (log:\n${mirrorLog})`).toContainText('queued more than 7 days ago', { timeout: 30_000 }); // Hold the NEXT list read: read the real list now (the row in it), answer // only once released. diff --git a/internal/api/send_queue_test.go b/internal/api/send_queue_test.go index b6df94067..00e615fc7 100644 --- a/internal/api/send_queue_test.go +++ b/internal/api/send_queue_test.go @@ -63,9 +63,10 @@ func (m *memQueue) List(ctx context.Context, t sendqueue.Target) ([]sendqueue.It defer m.mu.Unlock() var out []sendqueue.Item for _, it := range m.items { - // What PGStore.List lists: the open rows and the unretried failures - // (order aside: these are in queue order). - if !it.State.Open() && (it.State != sendqueue.Failed || it.RetriedAs != 0) { + // What PGStore.List lists: the open rows and the person's own + // unretried failures — no routed message, no chief nudge (order aside: + // these are in queue order). + if !it.State.Open() && (it.State != sendqueue.Failed || it.RetriedAs != 0 || it.RouteID != "" || it.ChiefNudgeID != 0) { continue } if it.Owner == queueUser(ctx) && it.Target == t { diff --git a/internal/sendqueue/store.go b/internal/sendqueue/store.go index e9bd3c737..9de320af3 100644 --- a/internal/sendqueue/store.go +++ b/internal/sendqueue/store.go @@ -200,12 +200,14 @@ const MaxOpenPerTarget = 20 const MaxDetail = 1024 // FailedShown bounds the failed messages List returns beside the open ones: -// the most recent FailedShown failures not yet retried, newest first. A sent -// or cancelled message is never listed — it has been handled — but a failure -// stays until its owner Retries or Deletes it, so it does not vanish unseen. -// The bound only keeps a target that fails again and again from drawing an -// unbounded list; it is the open bound's size, so a whole queue that failed -// is shown whole. +// the FailedShown most recent of a person's own failures not yet retried, +// newest first. A sent or cancelled message is never listed — it has been +// handled — nor a routed message or a chief nudge that failed (its outcome +// goes back to the chief, which is its handling). A person's own failure is +// listed until they Retry or Delete it, or Prune deletes it Retention after it +// failed, and only the newest FailedShown of them; an older one beyond that is +// not shown. The bound is the open bound's size (TestFailedShownCoversAWholeQueue), +// so a whole queue that failed is shown whole. const FailedShown = MaxOpenPerTarget var ( @@ -410,9 +412,10 @@ func (s *PGStore) Retry(ctx context.Context, id int64, stepUpAt time.Time, after // List is the caller's messages for t that still need something: every open // one (queued or being sent), oldest first, then the FailedShown most recently -// failed ones that were not retried, newest first. A sent or cancelled message -// is not listed, nor a failed one already retried (its retry row, a new -// message of its own, carries it). +// failed of the person's own (no route, no chief nudge) that were not +// retried, newest first. A sent or cancelled message is not listed, nor a +// failed one already retried (its retry row, a new message of its own, carries +// it), nor a failed routed message or chief nudge (handed back to the chief). func (s *PGStore) List(ctx context.Context, t Target) ([]Item, error) { if err := t.Valid(); err != nil { return nil, err @@ -420,7 +423,8 @@ func (s *PGStore) List(ctx context.Context, t Target) ([]Item, error) { where, args := targetWhere(t, 1) rows, err := s.pool.Query(ctx, `(SELECT `+cols+`, 0 AS part FROM send_queue WHERE state IN ('queued', 'sending') AND `+where+`) UNION ALL - (SELECT `+cols+`, 1 AS part FROM send_queue WHERE state = 'failed' AND retried_as IS NULL AND `+where+` + (SELECT `+cols+`, 1 AS part FROM send_queue WHERE state = 'failed' AND retried_as IS NULL + AND route_id IS NULL AND chief_nudge_id IS NULL AND `+where+` ORDER BY finished_at DESC, id DESC LIMIT `+fmt.Sprint(FailedShown)+`) ORDER BY part, id`, args...) if err != nil { @@ -880,8 +884,8 @@ func (s *PGStore) Reject(ctx context.Context, id int64, detail string) (bool, er return tag.RowsAffected() == 1, nil } -// Retention is how long a finished message is kept (its outcome is shown -// under the composer) before Prune deletes it. +// Retention is how long a finished message is kept (a person's own failure is +// listed under the composer, List) before Prune deletes it. const Retention = 7 * 24 * time.Hour // Prune deletes every message that finished before before. diff --git a/internal/sendqueue/store_list_pg_test.go b/internal/sendqueue/store_list_pg_test.go index 31a0d1fe3..e6b3d08ba 100644 --- a/internal/sendqueue/store_list_pg_test.go +++ b/internal/sendqueue/store_list_pg_test.go @@ -85,6 +85,47 @@ func TestListShowsOnlyWhatStillNeedsSomething(t *testing.T) { } } +// TestListLeavesOutAFailureHandedBackToTheChief: a failed ROUTED row and a +// failed CHIEF NUDGE have no Retry here — their outcome goes back to the +// chief, which is their handling — so neither is listed (left in, each sat in +// the list until deleted by hand, the backlog this list exists to avoid). A +// person's own failure, beside them, is. +func TestListLeavesOutAFailureHandedBackToTheChief(t *testing.T) { + s, _ := pgStore(t) + routed := mustEnqueue(t, s, 2, Item{Target: win, Body: "lima routed", StepUpAt: time.Now(), RouteID: "rt_0123456789abcdef01234567"}) + failRow(t, s, routed.ID, false) + nudge := mustEnqueue(t, s, 2, Item{Target: win, Body: "mike chief nudge", StepUpAt: time.Now(), ChiefNudgeID: 4242}) + failRow(t, s, nudge.ID, false) + own := mustEnqueue(t, s, 2, mirrorItem("november own failure")) + failRow(t, s, own.ID, false) + items, err := s.List(as(2), win) + if err != nil { + t.Fatal(err) + } + if got, want := listed(items), "failed:november own failure#"+strconv.FormatInt(own.ID, 10); got != want { + t.Fatalf("user 2's list:\n got %s\nwant %s", got, want) + } + // CONTROL: the fixture really made them a routed and a nudge failure. + for _, c := range []struct { + id int64 + check func(Item) bool + }{{routed.ID, func(it Item) bool { return it.RouteID != "" }}, {nudge.ID, func(it Item) bool { return it.ChiefNudgeID == 4242 }}} { + if it, err := s.Get(as(2), c.id); err != nil || it.State != Failed || !c.check(it) { + t.Fatalf("fixture row %d: %+v %v", c.id, it, err) + } + } +} + +// TestFailedShownCoversAWholeQueue pins the bound itself: a whole queue that +// failed (MaxOpenPerTarget messages, literally 20) is listed whole. The bound +// test below derives its fixture from FailedShown, so a smaller FailedShown +// would pass it. +func TestFailedShownCoversAWholeQueue(t *testing.T) { + if MaxOpenPerTarget != 20 || FailedShown < MaxOpenPerTarget { + t.Fatalf("FailedShown = %d, MaxOpenPerTarget = %d; want at least 20, so a whole failed queue is shown", FailedShown, MaxOpenPerTarget) + } +} + // TestListBoundsTheFailures: a target that failed more than FailedShown times // lists the FailedShown newest failures, newest first, and its open rows // whatever the bound. @@ -100,9 +141,11 @@ func TestListBoundsTheFailures(t *testing.T) { if err != nil { t.Fatal(err) } - if len(items) != FailedShown+1 || items[0].ID != open.ID { - t.Fatalf("list of %d failures and 1 queued: %d rows, first %s; want %d, the queued one first", - n, len(items), listed(items[:1]), FailedShown+1) + // 21: the literal, not FailedShown+1 — the expectation is not derived from + // the constant it checks. + if len(items) != 21 || items[0].ID != open.ID { + t.Fatalf("list of %d failures and 1 queued: %d rows, first %s; want 21, the queued one first", + n, len(items), listed(items[:1])) } for i, it := range items[1:] { if want := fmt.Sprintf("failure %02d", n-1-i); it.State != Failed || it.Body != want { diff --git a/internal/ui/send_queue.go b/internal/ui/send_queue.go index f94a08a13..a38321711 100644 --- a/internal/ui/send_queue.go +++ b/internal/ui/send_queue.go @@ -14,10 +14,11 @@ import ( // "Queue for when idle" item behind Send's caret (composer_menu.go), and the // window's queue under the status line — what is waiting, what // it is waiting for, a ⋯ menu for each message not yet being sent (Send now, -// Copy message, Cancel: sendQueueRowMenu), and each failure not yet handled, -// with a Retry and a Delete. A sent or cancelled message leaves the list -// (sendqueue.PGStore.List); a failed one stays until it is retried or -// deleted. A tap on any row's preview shows its whole text (sendQueuePreview). +// Copy message, Cancel: sendQueueRowMenu), and each of the person's own +// failures not yet handled, with a Retry and a Delete. A sent or cancelled +// message leaves the list (sendqueue.PGStore.List), and so does a failed +// routed message or chief nudge (it goes back to the chief); a person's own +// failure stays until it is retried, deleted, or pruned. A tap on any row's preview shows its whole text (sendQueuePreview). // // 🔴 THE QUEUE IS THE SERVER'S, NOT THE PAGE'S. Queue posts the message to // SendQueuePath and the server sends it when the target has been idle long @@ -505,7 +506,7 @@ const sendQueueMenuItemClass = "press flex min-h-[44px] w-full items-center roun // opens itself (popovertarget), which closes on Escape and on a tap outside // with no script at all, and Send now and Cancel are each a real form. Copy // message needs the clipboard, so it is drawn hidden and the list's script -// shows it; the whole message rides in the row (a template), because the +// shows it; the whole message rides in the row (sendQueuePreview's popover), because the // preview is cut. sendQueueScript adds the menu-button keyboard pattern, // places the menu beside its button and keeps aria-expanded true. // @@ -551,8 +552,6 @@ func sendQueueRowMenu(it SendQueueItem) g.Node { Button(Type("submit"), g.Attr("role", "menuitem"), g.Attr("aria-label", SendQueueCancelLabel), Class(sendQueueMenuItemClass+" text-st-error-fg"), g.Text("Cancel"))), ), - // The whole message, for Copy message: inert, never rendered. - Template(g.Attr("data-queue-body", ""), g.Text(it.Body)), ) } @@ -895,7 +894,7 @@ func sendQueueScript() g.Node { if (m) { try { m.hidePopover(); } catch (err) {} } } - // Copy message: the WHOLE message (the row's template; the preview is cut), + // Copy message: the WHOLE message (the row's popover text; the preview is cut), // to the clipboard, then a brief "Copied." Where the async clipboard is // refused (no secure context), a selected textarea and execCommand. function copyText(text) { @@ -919,8 +918,8 @@ func sendQueueScript() g.Node { var c = e.target && e.target.closest ? e.target.closest('[data-queue-copy]') : null; if (!c) return; var row = c.closest('[data-queue-id]'); - var tpl = row ? row.querySelector('template[data-queue-body]') : null; - var text = tpl ? (tpl.content ? tpl.content.textContent : tpl.textContent) : ''; + var whole = row ? row.querySelector('[data-queue-full-text]') : null; + var text = whole ? whole.textContent : ''; closeMenu(c); copyText(text).then(function () { noteOK('Copied.'); }, function () { note('Not copied: this browser did not allow it.'); }); });