Scaffold the mail UI against fixtures
Server-rendered three-pane layout from the turn 8 wireframes: mailboxes
and folders, a list of individual messages grouped by state then
thread, and a thread view whose right gutter holds each received
message's state, folder and assignees. Clicking a value opens its menu;
the change applies to that message only, with an undo toast.
An in-memory store seeded from fixtures stands in for the database and
JMAP sync. Pages are maud templates, menus use Alpine (vendored), and
Gloss tokens are vendored as CSS. Users come from the
Tailscale-User-Login header; --dev picks one from a cookie instead.
Cross-site form posts are refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012UgqBzp1gasvRV2tyq3DLw
diff --git a/Cargo.lock b/Cargo.lock
index 75704f9..f8921d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -193,6 +193,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -352,6 +358,37 @@ dependencies = [
"libc",
]
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -371,8 +408,11 @@ dependencies = [
"clap",
"fs-err",
"hegeltest",
+ "jiff",
+ "maud",
"miette",
"reqwest",
+ "serde",
"tempfile",
"thiserror",
"tokio",
@@ -875,6 +915,60 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "jiff"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+dependencies = [
+ "defmt",
+ "log",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
[[package]]
name = "jni"
version = "0.22.4"
@@ -1005,6 +1099,30 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+[[package]]
+name = "maud"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
+dependencies = [
+ "axum-core",
+ "http",
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1156,6 +1274,21 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.6"
@@ -1176,6 +1309,18 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "version_check",
+]
+
[[package]]
name = "quinn"
version = "0.11.12"
@@ -1280,7 +1425,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
]
[[package]]
@@ -1326,6 +1471,8 @@ dependencies = [
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
+ "serde",
+ "serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -1379,7 +1526,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
@@ -1503,7 +1650,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -1742,7 +1889,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -1910,7 +2057,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"bytes",
"futures-util",
"http",
@@ -1928,7 +2075,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
- "bitflags",
+ "bitflags 2.13.2",
"bytes",
"http",
"http-body",
@@ -2073,6 +2220,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "walkdir"
version = "2.5.0"
diff --git a/Cargo.toml b/Cargo.toml
index 09f0187..32d110b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,7 +11,10 @@ path = "src/bin/docket/main.rs"
axum = "*"
clap = { version = "*", features = ["derive", "env"] }
fs-err = "*"
+jiff = "*"
+maud = { version = "*", features = ["axum"] }
miette = { version = "*", features = ["fancy"] }
+serde = { version = "*", features = ["derive"] }
thiserror = "*"
tokio = { version = "*", features = ["full"] }
tower = "*"
@@ -21,7 +24,7 @@ tracing-subscriber = { version = "*", features = ["env-filter"] }
[dev-dependencies]
hegeltest = "*"
-reqwest = "*"
+reqwest = { version = "*", features = ["form"] }
tempfile = "*"
[lints.clippy]
diff --git a/assets/docket.css b/assets/docket.css
new file mode 100644
index 0000000..8af1fa2
--- /dev/null
+++ b/assets/docket.css
@@ -0,0 +1,96 @@
+/* Docket's layout on top of Gloss. Signal Teal is the project accent. */
+:root { --gl-color-accent: var(--gl-accent-teal); }
+
+html, body { height: 100%; }
+body { font: 12px/1.4 var(--gl-font-text); }
+h1, h2, p { margin: 0; }
+button { font: inherit; color: inherit; }
+.mono { font: 500 9px/1 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-tertiary); white-space: nowrap; }
+.grow { flex: 1; }
+.empty { padding: 14px 12px; color: var(--gl-color-text-tertiary); }
+
+/* Three panes: mailboxes and folders, the list, the thread. */
+.app { height: 100vh; display: grid; grid-template-columns: 168px 320px minmax(0, 1fr); }
+
+/* ── sidebar ── */
+.side { background: var(--gl-color-surface-card); padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; }
+.search { margin-bottom: 10px; }
+.search input { all: unset; box-sizing: border-box; width: 100%; padding: 6px 8px; background: var(--gl-color-surface-fill); border-radius: var(--gl-radius); font-size: 11px; }
+.search input::placeholder { color: var(--gl-color-text-tertiary); }
+.nav-item { display: flex; justify-content: space-between; padding: 5px 8px; border-radius: var(--gl-radius); color: inherit; text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.nav-item:hover { background: var(--gl-color-surface-fill); color: inherit; }
+.nav-item.on { background: var(--gl-color-surface-fill-2); font-weight: 600; }
+.count { font: 10px var(--gl-font-mono); color: var(--gl-color-text-secondary); }
+.side-head { font: 500 9px/1 var(--gl-font-mono); letter-spacing: .1em; color: var(--gl-color-text-secondary); padding: 16px 8px 6px; }
+.who { padding: 6px 8px; display: flex; flex-direction: column; gap: 2px; }
+.who.ctrl { padding: 0; }
+.who-btn { all: unset; cursor: pointer; padding: 6px 8px; display: flex; flex-direction: column; gap: 2px; border-radius: var(--gl-radius); }
+.who-btn:hover { background: var(--gl-color-surface-fill); }
+.who-name { font-weight: 600; }
+.who-login { font: 10px var(--gl-font-mono); color: var(--gl-color-text-tertiary); }
+
+/* ── message list ── */
+.list { border-right: 1px solid var(--gl-color-border-hairline); display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
+.list-head { padding: 12px 12px 7px; border-bottom: 1px solid var(--gl-color-rule); display: flex; justify-content: space-between; align-items: baseline; }
+.list-head h1 { font: 500 15px var(--gl-font-text); }
+.list-head .mono { font: 10px var(--gl-font-mono); letter-spacing: 0; color: var(--gl-color-text-secondary); }
+.section-head { font: 500 9px/1 var(--gl-font-mono); letter-spacing: .1em; padding: 14px 12px 6px; border-bottom: 1px solid var(--gl-color-rule); display: flex; justify-content: space-between; }
+.section-head .sub { color: var(--gl-color-text-tertiary); }
+.group { display: flex; flex-direction: column; padding-bottom: 4px; border-bottom: 1px solid var(--gl-color-border-hairline); }
+.group-head { display: flex; justify-content: space-between; gap: 8px; padding: 9px 12px 3px 8px; }
+.subj { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.more { font: 500 9px/1.6 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-tertiary); white-space: nowrap; }
+.row { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; gap: 1px 8px; padding: 5px 12px 6px 8px; margin: 0 4px; border-radius: var(--gl-radius); color: inherit; text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.row:hover { background: var(--gl-color-surface-fill); color: inherit; }
+.row.sel { background: var(--gl-color-surface-fill); }
+.row .dot { width: 6px; height: 6px; margin-top: 5px; border-radius: 3px; }
+.row.unread .dot { background: var(--gl-color-text-primary); }
+.row .from { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.row.unread .from { font-weight: 600; }
+.row .age { font: 10px/1.7 var(--gl-font-mono); color: var(--gl-color-text-secondary); }
+.row .snip { grid-column: 1 / 3; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.row .meta { grid-column: 1 / 3; font: 500 9px/1.6 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-secondary); }
+
+/* ── thread: a ledger of equal-weight rows, values in a right gutter ── */
+.reader { position: relative; min-height: 0; overflow-y: auto; }
+.thread { padding: 16px 20px 40px; display: flex; flex-direction: column; }
+.thread-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 0 12px 8px; border-bottom: 1px solid var(--gl-color-rule); }
+.thread-head h2 { font: 500 18px/1.25 var(--gl-font-text); }
+.item { display: grid; grid-template-columns: minmax(0, 1fr) 104px; gap: 16px; padding: 12px; border-bottom: 1px solid var(--gl-color-border-hairline); }
+.item.sel { background: var(--gl-color-surface-card); }
+.item.comment { background: var(--gl-color-surface-fill); }
+.item .body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+.item .from { cursor: pointer; }
+.item .addr { color: var(--gl-color-text-tertiary); }
+.item .copies { margin-top: -3px; font-size: 11px; color: var(--gl-color-text-tertiary); }
+.item .text { line-height: 1.55; max-width: 520px; white-space: pre-line; }
+.item .text.closed { cursor: pointer; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: none; }
+.gutter { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; font: 500 9px/1.4 var(--gl-font-mono); letter-spacing: .08em; color: var(--gl-color-text-secondary); }
+.gutter .date { font-size: 10px; letter-spacing: 0; }
+
+/* A value is its own control: click it to open its menu. */
+.ctrl { position: relative; }
+.ctrl-btn, .ctrl-static { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 5px; margin-left: -5px; padding: 2px 5px; border-radius: var(--gl-radius); white-space: nowrap; }
+.ctrl-btn { cursor: pointer; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.ctrl-btn:hover, .ctrl-btn.on { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
+.ctrl-btn:focus-visible, .opt:focus-visible, .who-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
+.sq { flex: none; width: 6px; height: 6px; border-radius: 1px; }
+
+.menu { position: absolute; top: 20px; right: 0; width: 160px; z-index: 2; background: var(--gl-color-surface-card); border-radius: var(--gl-radius); box-shadow: var(--gl-shadow-float); padding: 6px; display: flex; flex-direction: column; font: 12px/1.4 var(--gl-font-text); letter-spacing: 0; color: var(--gl-color-text-secondary); }
+.menu-up { top: auto; bottom: 100%; left: 0; right: auto; }
+.opt { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--gl-radius); }
+.opt:hover { background: var(--gl-color-surface-fill); }
+.opt.cur { color: var(--gl-color-text-primary); }
+.box { flex: none; width: 8px; height: 8px; border-radius: 1px; background: var(--gl-color-surface-fill-2); }
+.box.on { background: var(--gl-color-text-primary); }
+
+.comment-box { margin: 12px 12px 0; max-width: 520px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
+.comment-box textarea { all: unset; box-sizing: border-box; width: 100%; padding: 8px 11px; border-radius: var(--gl-radius); background: var(--gl-color-surface-fill); line-height: 1.55; field-sizing: content; min-height: 2.4em; white-space: pre-wrap; }
+.comment-box textarea::placeholder { color: var(--gl-color-text-tertiary); }
+.btn { all: unset; cursor: pointer; padding: 6px 10px; border-radius: var(--gl-radius); background: var(--gl-color-accent); color: var(--gl-color-accent-fg); font: 500 9px/1 var(--gl-font-mono); letter-spacing: .08em; }
+
+/* Gloss toast: an ink slab, mono, no border. */
+.toast { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; align-items: center; gap: var(--gl-space-s); padding: 12px var(--gl-space-s); min-width: 320px; background: var(--gl-color-text-primary); color: var(--gl-color-surface-page); border-radius: var(--gl-radius); box-shadow: var(--gl-shadow-float); font: var(--gl-step--2) var(--gl-font-mono); z-index: 3; }
+.toast form { display: contents; }
+.toast-act { all: unset; cursor: pointer; text-transform: uppercase; letter-spacing: .09em; text-decoration: underline; text-underline-offset: 3px; }
+.toast-x { all: unset; cursor: pointer; opacity: .6; font-size: 16px; line-height: 1; }
diff --git a/assets/gloss.css b/assets/gloss.css
new file mode 100644
index 0000000..0ef72d7
--- /dev/null
+++ b/assets/gloss.css
@@ -0,0 +1,264 @@
+/* Gloss design tokens, vendored from the Arbitrary Definitions design system. */
+/* Gloss — color tokens. The stock is paper: #f2f1ee at the page level, with
+ surfaces going lighter rather than darker. There is a warm cast, but it is
+ held just off neutral — enough that the page is not screen-white, not
+ enough to read as cream or beige. Every neutral shares that one hue at
+ very low chroma, so the page reads as paper rather than as a tint. If a
+ surface ever looks tinted, the chroma is too high, not too low.
+ Hand-maintained; edit values directly. Palette ramps are reference
+ material. Components read the semantic aliases below, never a ramp step. */
+:root {
+ --gl-neutral-0: #f2f1ee; /* page background */
+ --gl-neutral-50: #f9f8f6; /* card / surface stock */
+ --gl-neutral-100: #e8e7e3; /* recessed fill */
+ --gl-neutral-150: #deddd8; /* recessed fill, deeper */
+ --gl-neutral-200: #cdcbc6; /* hairline */
+ --gl-neutral-300: #a9a7a1; /* border, default */
+ --gl-neutral-500: #6a6862; /* muted-faint text (4.9:1 on stock) */
+ --gl-neutral-600: #4f4d48; /* muted / secondary text */
+ --gl-neutral-800: #2b2a27; /* strong text */
+ --gl-neutral-900: #161512; /* ink — primary text, near-black */
+
+ /* dark-mode neutral ramp — same hue, inverted */
+ --gl-neutral-dark-0: #0e0e0d;
+ --gl-neutral-dark-50: #151412;
+ --gl-neutral-dark-100: #1d1c1a;
+ --gl-neutral-dark-150: #252421;
+ --gl-neutral-dark-200: #2c2b28;
+ --gl-neutral-dark-300: #45443f;
+ --gl-neutral-dark-500: #8c8a85;
+ --gl-neutral-dark-600: #a5a39e;
+ --gl-neutral-dark-800: #d9d7d3;
+ --gl-neutral-dark-900: #eae8e4;
+
+ /* Sanctioned per-project accents — swap --gl-color-accent to one of these
+ to give a project its own identity without touching anything else.
+ All five read against the near-neutral stock; pick by project, not by
+ which one is loudest. */
+ --gl-accent-clay: #9a5a3c;
+ --gl-accent-ink-blue: #2b4a78;
+ --gl-accent-ochre: #93701f;
+ --gl-accent-plum: #6a3a5c;
+ --gl-accent-teal: #3a6a63;
+
+ /* Surfaces */
+ --gl-color-surface-page: #f2f1ee;
+ --gl-color-surface-card: #f9f8f6;
+ --gl-color-surface-fill: #e8e7e3;
+ --gl-color-surface-fill-2: #deddd8;
+
+ /* Borders */
+ --gl-color-border-hairline: #cdcbc6;
+ --gl-color-border-default: #a9a7a1;
+
+ /* The section rule is ink, not a border color: it is type-weight, not chrome. */
+ --gl-color-rule: var(--gl-color-text-primary);
+
+ /* Text. Tertiary clears 4.5:1 against the page stock; it is the faintest
+ text the system allows, and it is still readable. */
+ --gl-color-text-primary: #161512;
+ --gl-color-text-secondary: #4f4d48;
+ --gl-color-text-tertiary: #6a6862;
+
+ /* Accent — one per project, held constant. Never two in one view. */
+ --gl-color-accent: #9a5a3c;
+ --gl-color-accent-fg: #ffffff;
+ --gl-color-accent-ink: color-mix(in oklch, var(--gl-color-accent) 78%, black);
+ --gl-color-accent-soft: color-mix(in oklch, var(--gl-color-accent) 12%, white);
+
+ /* Status (fixed — never swapped like the accent) */
+ --gl-color-success: #3f7a4a;
+ --gl-color-success-bg: color-mix(in oklch, var(--gl-color-success) 12%, white);
+ --gl-color-danger: #ad3a2c;
+ --gl-color-danger-bg: color-mix(in oklch, var(--gl-color-danger) 10%, white);
+}
+
+/* Dark mode is a block of repointed aliases, not a second stylesheet.
+ Two entry points: the OS preference (overridable with data-theme="light")
+ and an explicit data-theme="dark". `.theme-dark` is kept as a class-based
+ equivalent for hosts that can't set an attribute on :root.
+ No glow, no colored shadow, no neon accent: a dark theme here is the same
+ flat ink relationship with the values swapped. */
+@media (prefers-color-scheme: dark) {
+ :root {
+ color-scheme: dark;
+ --gl-color-surface-page: #0e0e0d;
+ --gl-color-surface-card: #151412;
+ --gl-color-surface-fill: #1d1c1a;
+ --gl-color-surface-fill-2: #252421;
+ --gl-color-border-hairline: #2c2b28;
+ --gl-color-border-default: #45443f;
+ --gl-color-text-primary: #eae8e4;
+ --gl-color-text-secondary: #a5a39e;
+ --gl-color-text-tertiary: #8c8a85;
+ }
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+ --gl-color-surface-page: #f2f1ee;
+ --gl-color-surface-card: #f9f8f6;
+ --gl-color-surface-fill: #e8e7e3;
+ --gl-color-surface-fill-2: #deddd8;
+ --gl-color-border-hairline: #cdcbc6;
+ --gl-color-border-default: #a9a7a1;
+ --gl-color-rule: var(--gl-color-text-primary);
+ --gl-color-text-primary: #161512;
+ --gl-color-text-secondary: #4f4d48;
+ --gl-color-text-tertiary: #6a6862;
+}
+
+:root[data-theme="dark"], .theme-dark {
+ color-scheme: dark;
+ --gl-color-surface-page: #0e0e0d;
+ --gl-color-surface-card: #151412;
+ --gl-color-surface-fill: #1d1c1a;
+ --gl-color-surface-fill-2: #252421;
+ --gl-color-border-hairline: #2c2b28;
+ --gl-color-border-default: #45443f;
+ --gl-color-text-primary: #eae8e4;
+ --gl-color-text-secondary: #a5a39e;
+ --gl-color-text-tertiary: #8c8a85;
+}
+
+/* Utopia fluid type scale — the config shared by quire, ketchup and domus:
+ viewport 320→1240, body 18→20px, ratio 1.20→1.25. Adopted verbatim. */
+:root {
+ --gl-step--2: clamp(0.7813rem, 0.7736rem + 0.0341vw, 0.8rem); /* @kind other */
+ --gl-step--1: clamp(0.9375rem, 0.9119rem + 0.1136vw, 1rem); /* @kind other */
+ --gl-step-0: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem); /* @kind other */
+ --gl-step-1: clamp(1.35rem, 1.2631rem + 0.3864vw, 1.5625rem); /* @kind other */
+ --gl-step-2: clamp(1.62rem, 1.4837rem + 0.6057vw, 1.9531rem); /* @kind other */
+ --gl-step-3: clamp(1.944rem, 1.7405rem + 0.9044vw, 2.4414rem); /* @kind other */
+ --gl-step-4: clamp(2.3328rem, 2.0387rem + 1.3072vw, 3.0518rem); /* @kind other */
+ --gl-step-5: clamp(2.7994rem, 2.384rem + 1.8461vw, 3.8147rem); /* @kind other */
+
+ /* ── families ──────────────────────────────────────────────────────
+ No webfonts for now — the system UI face reads (headings, body, item
+ names, control labels, values) and the system mono files (section
+ rules, catalog labels, tags, buttons, dates, figures, status). Mono
+ is the ledger furniture, not the whole interface. Point these two
+ tokens at real faces later and nothing else changes.
+ --gl-font-serif stays as an opt-in editorial escape hatch. */
+ --gl-font-text: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+ --gl-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ --gl-font-serif: Georgia, "Times New Roman", serif;
+ --gl-font-read: var(--gl-font-text); /* semantic alias — prose */
+ --gl-font-ui: var(--gl-font-mono); /* semantic alias — chrome */
+}
+
+body { font-family: var(--gl-font-text); font-size: var(--gl-step-0); line-height: 1.6; }
+
+/* Headings: medium, tightened. Keep measures at 65-75ch.
+ The scale steps by 1.2-1.25, which is barely a step. Skip one between a
+ heading and the text under it (h2 over body, not h3 over body) or the
+ hierarchy reads flat. */
+.type-display { font: 500 var(--gl-step-4)/1.04 var(--gl-font-text); letter-spacing: -0.035em; }
+.type-h1 { font: 500 var(--gl-step-3)/1.1 var(--gl-font-text); letter-spacing: -0.03em; }
+.type-h2 { font: 500 var(--gl-step-2)/1.18 var(--gl-font-text); letter-spacing: -0.025em; }
+.type-h3 { font: 500 var(--gl-step-1)/1.3 var(--gl-font-text); letter-spacing: -0.015em; }
+.type-body { font: 400 var(--gl-step-0)/1.6 var(--gl-font-text); }
+.type-body-sm { font: 400 var(--gl-step--1)/1.6 var(--gl-font-text); }
+
+/* ── the section rule: this system's one repeated gesture ────────────
+ Every section head is a mono label sitting on a 1px ink rule that runs
+ the full measure. Ink is what makes it the rule; the hairlines elsewhere
+ are the same weight in a lighter colour. Use it for every section head
+ and nowhere else.
+ IMPORTANT: .type-section IS the heading. It never sits above another
+ heading as a kicker or eyebrow. A tracked uppercase label stacked over a
+ display headline is the single most common generated-UI hero, and this
+ class is one careless nesting away from producing it. */
+.type-section {
+ display: block; width: 100%;
+ font: 500 var(--gl-step--2)/1 var(--gl-font-mono);
+ text-transform: uppercase; letter-spacing: 0.1em;
+ color: var(--gl-color-text-primary);
+ padding-bottom: var(--gl-space-2xs);
+ border-bottom: var(--gl-rule-w) solid var(--gl-color-rule);
+}
+
+/* Interface type: mono. Labels and meta are uppercase and tracked (catalog
+ tags); values and technical strings are sentence case. Uppercase plus
+ tracking is for three words or fewer. Running text is never uppercase and
+ never tracked past 0.05em: word shape is how people read. */
+.type-label { font: 500 var(--gl-step--2)/1 var(--gl-font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--gl-color-text-secondary); }
+.type-mono { font: 400 var(--gl-step--1)/1.5 var(--gl-font-mono); }
+/* Figures in a ledger column: mono, lining, right-aligned by the column. */
+.type-figure { font: 400 var(--gl-step--1)/1.5 var(--gl-font-mono); font-variant-numeric: tabular-nums; }
+
+/* Utopia fluid space scale — same shared config as typography.css. */
+:root {
+ --gl-space-3xs: clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem);
+ --gl-space-2xs: clamp(0.5625rem, 0.5369rem + 0.1136vw, 0.625rem);
+ --gl-space-xs: clamp(0.875rem, 0.8494rem + 0.1136vw, 0.9375rem);
+ --gl-space-s: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
+ --gl-space-m: clamp(1.6875rem, 1.6108rem + 0.3409vw, 1.875rem);
+ --gl-space-l: clamp(2.25rem, 2.1477rem + 0.4545vw, 2.5rem);
+ --gl-space-xl: clamp(3.375rem, 3.2216rem + 0.6818vw, 3.75rem);
+ --gl-space-2xl: clamp(4.5rem, 4.2955rem + 0.9091vw, 5rem);
+ --gl-space-3xl: clamp(6.75rem, 6.4432rem + 1.3636vw, 7.5rem);
+}
+
+/* One radius, 3px, everywhere. A scale of radii is a decision made three
+ times; this system makes it once. --gl-radius-full is only for true
+ pills (switch tracks). --gl-radius-sm/md/lg are kept as aliases so
+ older code keeps working — they all resolve to the same 3px. */
+:root {
+ --gl-radius: 3px;
+ --gl-radius-sm: var(--gl-radius);
+ --gl-radius-md: var(--gl-radius);
+ --gl-radius-lg: var(--gl-radius);
+ --gl-radius-full: 999px;
+
+ /* Two line weights, each with exactly one job.
+ hairline (1px, --gl-color-border-hairline) — divides ledger rows.
+ rule (1px, ink) — under section heads.
+ Both are 1px: what separates them is ink versus hairline, not weight.
+ A heavier rule reads as a chart axis; this one reads as type.
+ Four-sided borders are not part of this system. */
+ --gl-border-w: 1px;
+ --gl-rule-w: 1px;
+
+ /* Elevation is not a language here: flat is the default and there is no
+ resting shadow. --gl-shadow-float is for the one thing genuinely
+ floating above the page (dialog, menu, toast); --gl-shadow-sm exists
+ only for the switch knob. */
+ --gl-shadow-sm: 0 1px 2px rgba(24, 20, 14, 0.14);
+ --gl-shadow-float: 0 2px 6px rgba(24, 20, 14, 0.07), 0 14px 32px -14px rgba(24, 20, 14, 0.22);
+
+ --gl-ease: cubic-bezier(0.2, 0, 0, 1); /* @kind other */
+ --gl-dur-fast: 0.12s; /* @kind other */
+ --gl-dur-base: 0.16s; /* @kind other */
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --gl-dur-fast: 0ms; /* @kind other */
+ --gl-dur-base: 0ms; /* @kind other */
+ }
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+html { -webkit-text-size-adjust: none; text-size-adjust: none; }
+body { margin: 0; background: var(--gl-color-surface-page); color: var(--gl-color-text-primary); -webkit-font-smoothing: antialiased; }
+a { color: var(--gl-color-accent-ink); text-underline-offset: 2px; }
+a:hover { color: var(--gl-color-accent); }
+[x-cloak] { display: none !important; }
+
+/* ── the ledger ────────────────────────────────────────────────────────
+ Lists in this system are not stacks of cards. Content sits on ruled rows
+ that run the full width of their column, edge to edge, divided by a
+ single hairline and nothing else — no container border, no container
+ background, no gaps between rows. */
+.gl-ledger { display: flex; flex-direction: column; }
+.gl-ledger > * + * { border-top: var(--gl-border-w) solid var(--gl-color-border-hairline); }
+.gl-ledger-row {
+ display: flex; align-items: center; gap: var(--gl-space-s);
+ padding: var(--gl-space-xs) 0;
+ transition: background var(--gl-dur-fast) var(--gl-ease);
+}
+/* Hover shifts the stock one step deeper, bled slightly past the measure so
+ the row still reads as full-bleed rather than as a highlighted box. */
+.gl-ledger-row:hover { background: var(--gl-color-surface-fill); box-shadow: -8px 0 0 var(--gl-color-surface-fill), 8px 0 0 var(--gl-color-surface-fill); }
+
diff --git a/assets/vendor/alpine-3.17.4.min.js b/assets/vendor/alpine-3.17.4.min.js
new file mode 100644
index 0000000..02a9087
--- /dev/null
+++ b/assets/vendor/alpine-3.17.4.min.js
@@ -0,0 +1,21 @@
+(()=>{var bt=!1,Et=!1,B=[],vt=-1,Fe=!1,wt=!1;function gr(e){ai(e)}function xr(){wt=!0}function yr(){wt=!1,br()}function ai(e){B.includes(e)||(B.push(e),e._x_schedulerPriority!==void 0&&(Fe=!0)),br()}function ue(e){let t=B.indexOf(e);t!==-1&&t>vt&&B.splice(t,1)}function br(){if(!Et&&!bt){if(wt)return;bt=!0,queueMicrotask(ci)}}function ci(){bt=!1,Et=!0;for(let e=0;e<B.length;e++)Fe&&li(e),B[e](),vt=e;B.length=0,vt=-1,Fe=!1,Et=!1}function li(e){let t=new Map,r=B.slice(e).sort((n,i)=>fi(n,i,t));for(let n=0;n<r.length;n++)B[e+n]=r[n];Fe=!1}function fi(e,t,r){return yt(e)?yt(t)?_r(e._x_schedulerPriority.el,r)-_r(t._x_schedulerPriority.el,r)||e._x_schedulerPriority.order-t._x_schedulerPriority.order:-1:yt(t)?1:0}function yt(e){return e._x_schedulerPriority!==void 0}function _r(e,t){if(t.has(e))return t.get(e);let r=0,n=e;for(;e;)r++,e._x_teleportBack?e=e._x_teleportBack:typeof ShadowRoot=="function"&&e.parentNode instanceof ShadowRoot?e=e.parentNode.host:e=e.parentElement;return t.set(n,r),r}var R,k,H,At,ui=0,St=!0;function Er(e){St=!1,e(),St=!0}function vr(e){R=e.reactive,H=e.release,k=t=>e.effect(t,{scheduler:r=>{St?gr(r):r()}}),At=e.raw}function Tt(e){k=e}function wr(e){let t=()=>{};return[(n,i)=>{let o=i?.priority==="structural"?ui++:void 0,s=k(n);return o!==void 0&&s!==void 0&&(s._x_schedulerPriority={el:e,order:o}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(s),t=()=>{s!==void 0&&(e._x_effects.delete(s),H(s))},s},()=>{t()}]}function Ve(e,t){let r=!0,n,i,o=k(()=>{let s=e(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{t(s,c)})}n=s,i=a,r=!1});return()=>{ue(o),H(o)}}async function Sr(e){xr();try{await e(),await Promise.resolve()}finally{yr()}}var Ar=[],Tr=[],Or=[];function Nr(e){Or.push(e)}function de(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Tr.push(t))}function Be(e){Ar.push(e)}function He(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Ot(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Cr(e){for(e._x_effects?.forEach(ue);e._x_cleanups?.length;)e._x_cleanups.pop()()}var Ue=new MutationObserver(qe),Nt=!1;function we(){Ue.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),Nt=!0}function Ct(){di(),Ue.disconnect(),Nt=!1}var Z=[];function di(){let e=Ue.takeRecords();Z.push(()=>e.length>0&&qe(e));let t=Z.length;queueMicrotask(()=>{if(Z.length===t)for(;Z.length>0;)Z.shift()()})}function Rr(){for(;Z.length>0;)Z.shift()();let e=Ue.takeRecords();e.length>0&&qe(e)}function h(e){if(!Nt)return e();Ct();try{return e()}finally{we()}}var Rt=!1,$e=[];function Dr(){Rt=!0}function Mr(){Rt=!1,qe($e),$e=[]}function qe(e){if(Rt){$e=$e.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},f=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(f(),l()):f()}i.forEach((o,s)=>{Ot(s,o)}),n.forEach((o,s)=>{Ar.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Tr.forEach(s=>s(o));for(let o of t)o.isConnected&&Or.forEach(s=>s(o));t=null,r=null,n=null,i=null}function ze(e){return I(U(e))}function P(e,t,r){return e._x_dataStack=[t,...U(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function U(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?U(e.host):e.parentNode?U(e.parentNode):[]}function I(e){return new Proxy({objects:e},pi)}function kr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:kr(Object.getPrototypeOf(e),t)}var pi={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?hi:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i;for(let s of e)if(i=kr(s,t),i)break;i||(i=e[e.length-1]);let o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function hi(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function pe(e,t=()=>{}){let r=i=>typeof i=="object"&&!Array.isArray(i)&&i!==null,n=(i,o="")=>{Object.entries(Object.getOwnPropertyDescriptors(i)).forEach(([s,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="object"&&a!==null&&a.__v_skip)return;let l=o===""?s:`${o}.${s}`;typeof a=="object"&&a!==null&&a._x_interceptor?i[s]=a.initialize(e,l,s,t):r(a)&&a!==i&&!(a instanceof Element)&&n(a,l)})};return n(e)}function Ke(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o,s){return e(this.initialValue,()=>mi(n,i),a=>Dt(n,i,a),i,o,s)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a,c)=>{let l=n.initialize(o,s,a,c);return r.initialValue=l,i(o,s,a,c)}}else r.initialValue=n;return r}}function mi(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function Dt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Dt(e[t[0]],t.slice(1),r)}}var Pr={};function b(e,t){Pr[e]=t}function G(e,t){let r=_i(t);return Object.entries(Pr).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function _i(e){let[t,r]=Mt(e),n={interceptor:Ke,...t};return de(e,r),n}function Ir(e,t,r,...n){try{return r(...n)}catch(i){Y(i,e,t)}}function Y(...e){return Lr(...e)}var Lr=gi;function jr(e){Lr=e}function gi(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,t),setTimeout(()=>{throw e},0)}var he=!0;function We(e){let t=he;he=!1;let r=e();return he=t,r}function D(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return Fr(...e)}var Fr=()=>{};function Vr(e){Fr=e}var $r;function Br(e){$r=e}function Hr(e,t){let r={};G(r,e);let n=[r,...U(e)],i=typeof t=="function"?xi(n,t):bi(n,t,e);return Ir.bind(null,e,t,i)}function xi(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!he){Se(r,t,I([n,...e]),i);return}let s=t.apply(I([n,...e]),i);Se(r,s)}}var kt={};function yi(e,t){if(kt[e])return kt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return Y(s,t,e),Promise.resolve()}})();return kt[e]=o,o}function bi(e,t,r){let n=yi(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=I([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(f=>Y(f,r,t));n.finished?(Se(i,n.result,c,s,r),n.result=void 0):l.then(f=>{Se(i,f,c,s,r)}).catch(f=>Y(f,r,t)).finally(()=>n.result=void 0)}}}function Se(e,t,r,n,i){if(he&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Se(e,s,r,n)).catch(s=>Y(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function Ur(...e){return $r(...e)}function qr(e,t,r={}){let n={};G(n,e);let i=[n,...U(e)],o=I([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&he?l.apply(o,s):l}}var Lt="x-";function N(e=""){return Lt+e}function zr(e){Lt=e}var Ge={};function p(e,t){return Ge[e]=t,{before(r){if(!Ge[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=Q.indexOf(r);Q.splice(n>=0?n:Q.indexOf("DEFAULT"),0,e)}}}function Kr(e){return Object.keys(Ge).includes(e)}function ee(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=jt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(Yr((o,s)=>n[o]=s)).filter(Xr).map(vi(n,r)).sort(wi).map(o=>Ei(e,o))}function jt(e){return Array.from(e).map(Yr()).filter(t=>!Xr(t))}var Pt=!1,Ae=new Map,Wr=Symbol();function Gr(e){Pt=!0;let t=Symbol();Wr=t,Ae.set(t,[]);let r=()=>{for(;Ae.get(t).length;)Ae.get(t).shift()();Ae.delete(t)},n=()=>{Pt=!1,r()};e(r),n()}function Mt(e){let t=[],r=a=>t.push(a),[n,i]=wr(e);return t.push(i),[{Alpine:q,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:D.bind(D,e)},()=>t.forEach(a=>a())]}function Ei(e,t){let r=()=>{},n=Ge[t.type]||r,[i,o]=Mt(e);He(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),Pt?Ae.get(Wr).push(n):n())};return s.runCleanups=o,s}var Ye=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Je=e=>e;function Yr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=Jr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var Jr=[];function me(e){Jr.push(e)}function Xr({name:e}){return Zr().test(e)}var Zr=()=>new RegExp(`^${Lt}([^:^.]+)\\b`);function vi(e,t){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(Zr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var It="DEFAULT",Q=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",It,"teleport"];function wi(e,t){let r=Q.indexOf(e.type)===-1?It:e.type,n=Q.indexOf(t.type)===-1?It:t.type;return Q.indexOf(r)-Q.indexOf(n)}function L(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>L(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)L(n,t,!1),n=n.nextElementSibling}var j=!1;function w(e,t=()=>{}){return(...r)=>j?t(...r):e(...r)}function Qr(e){return(...t)=>j&&e(...t)}var en=[];function F(e){en.push(e)}function tn(e,t){en.forEach(r=>r(e,t)),j=!0,nn(()=>{S(t,(r,n)=>{n(r,()=>{})})}),j=!1}var Xe=!1;function rn(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),j=!0,Xe=!0,nn(()=>{Si(t)}),j=!1,Xe=!1}function Si(e){let t=!1;S(e,(n,i)=>{L(n,(o,s)=>{if(t&&on(o))return s();t=!0,i(o,s)})})}function nn(e){let t=k;Tt((r,n)=>{let i=t(r);return H(i),()=>{}}),e(),Tt(t)}var Ze=0;function sn(e,t){let r=e._x_deferInit;r||(r=e._x_deferInit={pending:0,ownsIgnore:!e._x_ignore,queuedAttributes:new Map},r.ownsIgnore&&(e._x_ignore=!0),Ze++),r.pending++,Promise.resolve(t).catch(n=>{try{Y(n,e)}catch(i){setTimeout(()=>{throw i},0)}}).then(()=>Ai(e,r))}function Ai(e,t){t.pending--,!(t.pending>0)&&(Rr(),!(t.pending>0)&&e._x_deferInit===t&&(delete e._x_deferInit,t.ownsIgnore&&delete e._x_ignore,Ze--,e.isConnected&&(Ti(t),S(e))))}function an(e,t){if(Ze===0)return!1;let r=A(e,n=>n._x_deferInit);return r?(cn(r._x_deferInit,e,t.map(({name:n})=>n)),!0):!1}function cn(e,t,r){let n=e.queuedAttributes.get(t);(!n||n.marker!==t._x_marker)&&(n={marker:t._x_marker,names:new Set},e.queuedAttributes.set(t,n)),r.forEach(i=>n.names.add(i))}function Ti(e){e.queuedAttributes.forEach((t,r)=>{if(!r.isConnected||!r._x_marker||r._x_marker!==t.marker)return;let n=A(r,o=>o._x_deferInit);if(n){cn(n._x_deferInit,r,Array.from(t.names));return}let i=Array.from(t.names).filter(o=>r.hasAttribute(o)).map(o=>({name:o,value:r.getAttribute(o)}));i.length!==0&&ee(r,i).forEach(o=>o())})}F((e,t)=>{Ze!==0&&(!e||e.nodeType!==1||!t||t.nodeType!==1||A(e,r=>r._x_deferInit)&&(t._x_ignore=!0))});function te(e,t,r={},n={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function T(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ln=!1;function fn(){ln&&T("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ln=!0,document.body||T("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),te(document,"alpine:init"),te(document,"alpine:initializing"),we(),Nr(t=>S(t,L)),de(t=>C(t)),Be((t,r)=>{an(t,r)||ee(t,r).forEach(n=>n())});let e=t=>!re(t.parentElement,!0);Array.from(document.querySelectorAll(pn().join(","))).filter(e).forEach(t=>{S(t)}),te(document,"alpine:initialized"),setTimeout(()=>{Ni()})}var Ft=[],un=[];function dn(){return Ft.map(e=>e())}function pn(){return Ft.concat(un).map(e=>e())}function Qe(e){Ft.push(e)}function et(e){un.push(e)}function re(e,t=!1){return A(e,r=>{if((t?pn():dn()).some(i=>r.matches(i)))return!0})}function A(e,t){if(e){if(t(e))return e;if(e._x_teleportBack)return A(e._x_teleportBack,t);if(e.parentNode instanceof ShadowRoot)return A(e.parentNode.host,t);if(e.parentElement)return A(e.parentElement,t)}}function on(e){return dn().some(t=>e.matches(t))}var hn=[];function mn(e){hn.push(e)}var Oi=1;function S(e,t=L,r=()=>{}){A(e,n=>n._x_ignore)||Gr(()=>{t(e,(n,i)=>{n._x_marker||(r(n,i),hn.forEach(o=>o(n,i)),ee(n,n.attributes).forEach(o=>o()),n._x_ignore||(n._x_marker=Oi++),n._x_ignore&&i())})})}function C(e,t=L){t(e,r=>{Cr(r),Ot(r),delete r._x_marker})}function Ni(){[["ui","dialog",["[x-dialog], [x-popover]"]],["anchor","anchor",["[x-anchor]"]],["sort","sort",["[x-sort]"]]].forEach(([t,r,n])=>{Kr(r)||n.some(i=>{if(document.querySelector(i))return T(`found "${i}", but missing ${t} plugin`),!0})})}var Vt=[],$t=!1;function _e(e=()=>{}){return queueMicrotask(()=>{$t||setTimeout(()=>{tt()})}),new Promise(t=>{Vt.push(()=>{e(),t()})})}function tt(){for($t=!1;Vt.length;)Vt.shift()()}function _n(){$t=!0}function Te(e,t){return Array.isArray(t)?gn(e,t.join(" ")):typeof t=="object"&&t!==null?Ci(e,t):typeof t=="function"?Te(e,t()):gn(e,t)}function Bt(e){return e.split(/\s/).filter(Boolean)}function gn(e,t){let r=i=>Bt(i).filter(o=>!e.classList.contains(o)).filter(Boolean),n=i=>(e.classList.add(...i),()=>{e.classList.remove(...i)});return t=t===!0?t="":t||"",n(r(t))}function Ci(e,t){let r=Object.entries(t).flatMap(([s,a])=>a?Bt(s):!1).filter(Boolean),n=Object.entries(t).flatMap(([s,a])=>a?!1:Bt(s)).filter(Boolean),i=[],o=[];return n.forEach(s=>{e.classList.contains(s)&&(e.classList.remove(s),o.push(s))}),r.forEach(s=>{e.classList.contains(s)||(e.classList.add(s),i.push(s))}),()=>{o.forEach(s=>e.classList.add(s)),i.forEach(s=>e.classList.remove(s))}}function ne(e,t){return typeof t=="object"&&t!==null?Ri(e,t):Di(e,t)}function Ri(e,t){let r={};return Object.entries(t).forEach(([n,i])=>{r[n]=e.style[n],n.startsWith("--")||(n=Mi(n)),e.style.setProperty(n,i)}),setTimeout(()=>{e.style.length===0&&e.removeAttribute("style")}),()=>{ne(e,r)}}function Di(e,t){let r=e.getAttribute("style",t);return e.setAttribute("style",t),()=>{e.setAttribute("style",r||"")}}function Mi(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function Oe(e,t=()=>{}){let r=!1;return function(){r?t.apply(this,arguments):(r=!0,e.apply(this,arguments))}}p("transition",(e,{value:t,modifiers:r,expression:n},{evaluate:i})=>{typeof n=="function"&&(n=i(n)),n!==!1&&(!n||typeof n=="boolean"?Pi(e,r,t):ki(e,n,t))});function ki(e,t,r){xn(e,Te,""),{enter:i=>{e._x_transition.enter.during=i},"enter-start":i=>{e._x_transition.enter.start=i},"enter-end":i=>{e._x_transition.enter.end=i},leave:i=>{e._x_transition.leave.during=i},"leave-start":i=>{e._x_transition.leave.start=i},"leave-end":i=>{e._x_transition.leave.end=i}}[r](t)}function Pi(e,t,r){xn(e,ne);let n=!t.includes("in")&&!t.includes("out")&&!r,i=n||t.includes("in")||["enter"].includes(r),o=n||t.includes("out")||["leave"].includes(r);t.includes("in")&&!n&&(t=t.filter((v,fe)=>fe<t.indexOf("out"))),t.includes("out")&&!n&&(t=t.filter((v,fe)=>fe>t.indexOf("out")));let s=!t.includes("opacity")&&!t.includes("scale"),a=s||t.includes("opacity"),c=s||t.includes("scale"),l=a?0:1,f=c?Ne(t,"scale",95)/100:1,u=Ne(t,"delay",0)/1e3,g=Ne(t,"origin","center"),y="opacity, transform",$=Ne(t,"duration",150)/1e3,d=Ne(t,"duration",75)/1e3,E="cubic-bezier(0.4, 0.0, 0.2, 1)";i&&(e._x_transition.enter.during={transformOrigin:g,transitionDelay:`${u}s`,transitionProperty:y,transitionDuration:`${$}s`,transitionTimingFunction:E},e._x_transition.enter.start={opacity:l,transform:`scale(${f})`},e._x_transition.enter.end={opacity:1,transform:"scale(1)"}),o&&(e._x_transition.leave.during={transformOrigin:g,transitionDelay:`${u}s`,transitionProperty:y,transitionDuration:`${d}s`,transitionTimingFunction:E},e._x_transition.leave.start={opacity:1,transform:"scale(1)"},e._x_transition.leave.end={opacity:l,transform:`scale(${f})`})}function xn(e,t,r={}){e._x_transition||(e._x_transition={enter:{during:r,start:r,end:r},leave:{during:r,start:r,end:r},in(n=()=>{},i=()=>{}){rt(e,t,{during:this.enter.during,start:this.enter.start,end:this.enter.end},n,i)},out(n=()=>{},i=()=>{}){rt(e,t,{during:this.leave.during,start:this.leave.start,end:this.leave.end},n,i)}})}window.Element.prototype._x_toggleAndCascadeWithTransitions=function(e,t,r,n){let i=document.visibilityState==="visible"?requestAnimationFrame:setTimeout,o=()=>i(r);if(t){e._x_transition&&(e._x_transition.enter||e._x_transition.leave)?e._x_transition.enter&&(Object.entries(e._x_transition.enter.during).length||Object.entries(e._x_transition.enter.start).length||Object.entries(e._x_transition.enter.end).length)?e._x_transition.in(r):o():e._x_transition?e._x_transition.in(r):o();return}e._x_hidePromise=e._x_transition?new Promise((s,a)=>{e._x_transition.out(()=>{},()=>s(n)),e._x_transitioning&&e._x_transitioning.beforeCancel(()=>a({isFromCancelledTransition:!0}))}):Promise.resolve(n),queueMicrotask(()=>{let s=yn(e);s?(s._x_hideChildren||(s._x_hideChildren=[]),s._x_hideChildren.push(e)):i(()=>{let a=c=>{let l=Promise.all([c._x_hidePromise,...(c._x_hideChildren||[]).map(a)]).then(([f])=>f?.());return delete c._x_hidePromise,delete c._x_hideChildren,l};a(e).catch(c=>{if(!c.isFromCancelledTransition)throw c})})})};function yn(e){let t=e.parentNode;if(t)return t._x_hidePromise?t:yn(t)}function rt(e,t,{during:r,start:n,end:i}={},o=()=>{},s=()=>{}){if(e._x_transitioning&&e._x_transitioning.cancel(),Object.keys(r).length===0&&Object.keys(n).length===0&&Object.keys(i).length===0){o(),s();return}let a,c,l;Ii(e,{start(){a=t(e,n)},during(){c=t(e,r)},before:o,end(){a(),l=t(e,i)},after:s,cleanup(){c(),l()}})}function Ii(e,t){let r,n,i,o=Oe(()=>{h(()=>{r=!0,n||t.before(),i||(t.end(),tt()),t.after(),e.isConnected&&t.cleanup(),delete e._x_transitioning})});e._x_transitioning={beforeCancels:[],beforeCancel(s){this.beforeCancels.push(s)},cancel:Oe(function(){for(;this.beforeCancels.length;)this.beforeCancels.shift()();o()}),finish:o},h(()=>{t.start(),t.during()}),_n(),requestAnimationFrame(()=>{if(r)return;let s=Number(getComputedStyle(e).transitionDuration.replace(/,.*/,"").replace("s",""))*1e3,a=Number(getComputedStyle(e).transitionDelay.replace(/,.*/,"").replace("s",""))*1e3;s===0&&(s=Number(getComputedStyle(e).animationDuration.replace("s",""))*1e3),h(()=>{t.before()}),n=!0,requestAnimationFrame(()=>{r||(h(()=>{t.end()}),tt(),setTimeout(e._x_transitioning.finish,s+a),i=!0)})})}function Ne(e,t,r){if(e.indexOf(t)===-1)return r;let n=e[e.indexOf(t)+1];if(!n||t==="scale"&&isNaN(n))return r;if(t==="duration"||t==="delay"){let i=n.match(/([0-9]+)ms/);if(i)return i[1]}return t==="origin"&&["top","right","left","center","bottom"].includes(e[e.indexOf(t)+2])?[n,e[e.indexOf(t)+2]].join(" "):n}function Ce(e,t,r,n=[]){switch(e._x_bindings||(e._x_bindings=R({})),e._x_bindings[t]=r,t=n.includes("camel")?Ui(t):t,t){case"value":Li(e,r);break;case"style":Fi(e,r);break;case"class":ji(e,r);break;case"selected":case"checked":Vi(e,t,r);break;default:Ht(e,t,r);break}}function Li(e,t){if(nt(e))e.attributes.value===void 0&&(e.value=t);else if(De(e))Number.isInteger(t)?e.value=t:!Array.isArray(t)&&typeof t!="boolean"&&![null,void 0].includes(t)?e.value=String(t):Array.isArray(t)?e.checked=t.some(r=>qi(r,e.value)):e.checked=!!t;else if(e.tagName==="SELECT")Hi(e,t);else if(e.tagName==="OPTION")Ht(e,"value",t);else{if(e.value===t&&(typeof t!="object"||t===null))return;e.value=t===void 0?"":t}}function ji(e,t){e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedClasses=Te(e,t)}function Fi(e,t){e._x_undoAddedStyles&&e._x_undoAddedStyles(),e._x_undoAddedStyles=ne(e,t)}function Vi(e,t,r){Ht(e,t,r),Bi(e,t,r)}function Ht(e,t,r){[null,void 0,!1].includes(r)&&Ki(t)?e.removeAttribute(t):(bn(t)&&(r=t),Wi(r)&&(r=JSON.stringify(r)),$i(e,t,r))}function $i(e,t,r){e.getAttribute(t)!=r&&e.setAttribute(t,r)}function Bi(e,t,r){e[t]!==r&&(e[t]=r)}function Hi(e,t){let r=[].concat(t).map(n=>n+"");Array.from(e.options).forEach(n=>{n.selected=r.includes(n.value)})}function Ui(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function qi(e,t){return e==t}function Re(e){return[1,"1","true","on","yes",!0].includes(e)?!0:[0,"0","false","off","no",!1].includes(e)?!1:e?Boolean(e):null}var zi=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected","shadowrootclonable","shadowrootdelegatesfocus","shadowrootserializable"]);function bn(e){return zi.has(e)}function Ki(e){return!["aria-pressed","aria-checked","aria-expanded","aria-selected"].includes(e)}function Wi(e){return typeof e=="object"&&e!==null}function En(e,t,r){return e._x_bindings&&e._x_bindings[t]!==void 0?e._x_bindings[t]:wn(e,t,r)}function vn(e,t,r,n=!0){if(e._x_bindings&&e._x_bindings[t]!==void 0)return e._x_bindings[t];if(e._x_inlineBindings&&e._x_inlineBindings[t]!==void 0){let i=e._x_inlineBindings[t];return i.extract=n,We(()=>D(e,i.expression))}return wn(e,t,r)}function wn(e,t,r){let n=e.getAttribute(t);return n===null?typeof r=="function"?r():r:n===""?!0:bn(t)?!![t,"true"].includes(n):n}function De(e){return e.type==="checkbox"||e.localName==="ui-checkbox"||e.localName==="ui-switch"}function nt(e){return e.type==="radio"||e.localName==="ui-radio"}function it(e,t){let r;return function(){let n=this,i=arguments,o=function(){r=null,e.apply(n,i)};clearTimeout(r),r=setTimeout(o,t)}}function ot(e,t){let r;return function(){let n=this,i=arguments;r||(e.apply(n,i),r=!0,setTimeout(()=>r=!1,t))}}function st({get:e,set:t},{get:r,set:n}){let i=!0,o,s,a=k(()=>{let c=e(),l=r();if(i)n(Ut(c)),i=!1;else{let f=JSON.stringify(c),u=JSON.stringify(l);f!==o?n(Ut(c)):f!==u&&t(Ut(l))}o=JSON.stringify(e()),s=JSON.stringify(r())});return()=>{ue(a),H(a)}}function Ut(e){return typeof e=="object"?JSON.parse(JSON.stringify(e)):e}function Sn(e){(Array.isArray(e)?e:[e]).forEach(r=>r(q))}var z={},An=!1;function Tn(e,t){if(An||(z=R(z),An=!0),t===void 0)return z[e];z[e]=t,typeof t=="object"&&t!==null&&t._x_interceptor?z[e]=t.initialize(z,e,e,()=>{}):pe(z[e]),typeof t=="object"&&t!==null&&t.hasOwnProperty("init")&&typeof t.init=="function"&&z[e].init()}function On(){return z}var Nn={};function Cn(e,t){let r=typeof t!="function"?()=>t:t;return e instanceof Element?qt(e,r()):(Nn[e]=r,()=>{})}function Rn(e){return Object.entries(Nn).forEach(([t,r])=>{Object.defineProperty(e,t,{get(){return(...n)=>r(...n)}})}),e}function qt(e,t,r){let n=[];for(;n.length;)n.pop()();let i=Object.entries(t).map(([s,a])=>({name:s,value:a})),o=jt(i);return i=i.map(s=>o.find(a=>a.name===s.name)?{name:`x-bind:${s.name}`,value:`"${s.value}"`}:s),ee(e,i,r).map(s=>{n.push(s.runCleanups),s()}),()=>{for(;n.length;)n.pop()()}}var Dn={};function Mn(e,t){Dn[e]=t}function kn(e,t){return Object.entries(Dn).forEach(([r,n])=>{Object.defineProperty(e,r,{get(){return(...i)=>n.bind(t)(...i)},enumerable:!1})}),e}var Gi={get reactive(){return R},get release(){return H},get effect(){return k},get raw(){return At},get transaction(){return Sr},version:"3.17.4",flushAndStopDeferringMutations:Mr,dontAutoEvaluateFunctions:We,disableEffectScheduling:Er,startObservingMutations:we,stopObservingMutations:Ct,setReactivityEngine:vr,onAttributeRemoved:He,onAttributesAdded:Be,closestDataStack:U,skipDuringClone:w,onlyDuringClone:Qr,addRootSelector:Qe,addInitSelector:et,setErrorHandler:jr,interceptClone:F,addScopeToNode:P,deferMutations:Dr,mapAttributes:me,evaluateLater:x,interceptInit:mn,initInterceptors:pe,injectMagics:G,setEvaluator:Vr,setRawEvaluator:Br,mergeProxies:I,extractProp:vn,findClosest:A,onElRemoved:de,closestRoot:re,destroyTree:C,interceptor:Ke,transition:rt,setStyles:ne,mutateDom:h,deferInit:sn,directive:p,entangle:st,throttle:ot,debounce:it,evaluate:D,evaluateRaw:Ur,initTree:S,nextTick:_e,prefixed:N,prefix:zr,plugin:Sn,magic:b,store:Tn,start:fn,clone:rn,cloneNode:tn,bound:En,$data:ze,watch:Ve,walk:L,data:Mn,bind:Cn},q=Gi;function zt(e){let t=Object.create(null);for(let r of e.split(","))t[r]=1;return r=>r in t}var Yi=Object.freeze({}),Ca=Object.freeze([]);var ge=Object.assign;var Ji=Object.prototype.hasOwnProperty,at=(e,t)=>Ji.call(e,t),xe=Array.isArray,ye=e=>Pn(e)==="[object Map]";var Xi=e=>typeof e=="string",be=e=>typeof e=="symbol",Ee=e=>e!==null&&typeof e=="object";var Zi=Object.prototype.toString,Pn=e=>Zi.call(e),Kt=e=>Pn(e).slice(8,-1);var ct=e=>Xi(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e;var lt=e=>{let t=Object.create(null);return r=>t[r]||(t[r]=e(r))},Qi=/-\w/g,Ra=lt(e=>e.replace(Qi,t=>t.slice(1).toUpperCase())),eo=/\B([A-Z])/g,Da=lt(e=>e.replace(eo,"-$1").toLowerCase()),Wt=lt(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ma=lt(e=>e?`on${Wt(e)}`:""),J=(e,t)=>!Object.is(e,t);var to="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly";var ka=zt(to+",async,autofocus,autoplay,controls,default,defer,disabled,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");function oe(e,...t){console.warn(`[Vue warn] ${e}`,...t)}var Gt;var m;var Yt=new WeakSet,dt=class{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,Gt&&(Gt.active?Gt.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Yt.has(this)&&(Yt.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||ro(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,In(this),Vn(this);let t=m,r=M;m=this,M=!0;try{return this.fn()}finally{m!==this&&oe("Active effect was not restored correctly - this is likely a Vue internal bug."),$n(this),m=t,M=r,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)lr(t);this.deps=this.depsTail=void 0,In(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Yt.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Zt(this)&&this.run()}get dirty(){return Zt(this)}},Fn=0,ke,Pe;function ro(e,t=!1){if(e.flags|=8,t){e.next=Pe,Pe=e;return}e.next=ke,ke=e}function ar(){Fn++}function cr(){if(--Fn>0)return;if(Pe){let t=Pe;for(Pe=void 0;t;){let r=t.next;t.next=void 0,t.flags&=-9,t=r}}let e;for(;ke;){let t=ke;for(ke=void 0;t;){let r=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=r}}if(e)throw e}function Vn(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function $n(e){let t,r=e.depsTail,n=r;for(;n;){let i=n.prevDep;n.version===-1?(n===r&&(r=i),lr(n),io(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=r}function Zt(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(no(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function no(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===pt)||(e.globalVersion=pt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Zt(e))))return;e.flags|=2;let t=e.dep,r=m,n=M;m=e,M=!0;try{Vn(e);let i=e.fn(e._value);(t.version===0||J(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{m=r,M=n,$n(e),e.flags&=-3}}function lr(e,t=!1){let{dep:r,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),r.subsHead===e&&(r.subsHead=i),r.subs===e&&(r.subs=n,!n&&r.computed)){r.computed.flags&=-5;for(let o=r.computed.deps;o;o=o.nextDep)lr(o,!0)}!t&&!--r.sc&&r.map&&r.map.delete(r.key)}function io(e){let{prevDep:t,nextDep:r}=e;t&&(t.nextDep=r,e.prevDep=void 0),r&&(r.prevDep=t,e.nextDep=void 0)}function Bn(e,t){e.effect instanceof dt&&(e=e.effect.fn);let r=new dt(e);t&&ge(r,t);try{r.run()}catch(i){throw r.stop(),i}let n=r.run.bind(r);return n.effect=r,n}function Hn(e){e.effect.stop()}var M=!0,Un=[];function oo(){Un.push(M),M=!1}function so(){let e=Un.pop();M=e===void 0?!0:e}function In(e){let{cleanup:t}=e;if(e.cleanup=void 0,t){let r=m;m=void 0;try{t()}finally{m=r}}}var pt=0,Qt=class{constructor(t,r){this.sub=t,this.dep=r,this.version=r.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}},er=class{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0,this.subsHead=void 0}track(t){if(!m||!M||m===this.computed)return;let r=this.activeLink;if(r===void 0||r.sub!==m)r=this.activeLink=new Qt(m,this),m.deps?(r.prevDep=m.depsTail,m.depsTail.nextDep=r,m.depsTail=r):m.deps=m.depsTail=r,qn(r);else if(r.version===-1&&(r.version=this.version,r.nextDep)){let n=r.nextDep;n.prevDep=r.prevDep,r.prevDep&&(r.prevDep.nextDep=n),r.prevDep=m.depsTail,r.nextDep=void 0,m.depsTail.nextDep=r,m.depsTail=r,m.deps===r&&(m.deps=n)}return m.onTrack&&m.onTrack(ge({effect:m},t)),r}trigger(t){this.version++,pt++,this.notify(t)}notify(t){ar();try{for(let r=this.subsHead;r;r=r.nextSub)r.sub.onTrigger&&!(r.sub.flags&8)&&r.sub.onTrigger(ge({effect:r.sub},t));for(let r=this.subs;r;r=r.prevSub)r.sub.notify()&&r.sub.dep.notify()}finally{cr()}}};function qn(e){if(e.dep.sc++,e.sub.flags&4){let t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)qn(n)}let r=e.dep.subs;r!==e&&(e.prevSub=r,r&&(r.nextSub=e)),e.dep.subsHead===void 0&&(e.dep.subsHead=e),e.dep.subs=e}}var tr=new WeakMap,ie=Symbol("Object iterate"),rr=Symbol("Map keys iterate"),Le=Symbol("Array iterate");function O(e,t,r){if(M&&m){let n=tr.get(e);n||tr.set(e,n=new Map);let i=n.get(r);i||(n.set(r,i=new er),i.map=n,i.key=r),i.track({target:e,type:t,key:r})}}function X(e,t,r,n,i,o){let s=tr.get(e);if(!s){pt++;return}let a=c=>{c&&c.trigger({target:e,type:t,key:r,newValue:n,oldValue:i,oldTarget:o})};if(ar(),t==="clear")s.forEach(a);else{let c=xe(e),l=c&&ct(r);if(c&&r==="length"){let f=Number(n);s.forEach((u,g)=>{(g==="length"||g===Le||!be(g)&&g>=f)&&a(u)})}else switch((r!==void 0||s.has(void 0))&&a(s.get(r)),l&&a(s.get(Le)),t){case"add":c?l&&a(s.get("length")):(a(s.get(ie)),ye(e)&&a(s.get(rr)));break;case"delete":c||(a(s.get(ie)),ye(e)&&a(s.get(rr)));break;case"set":ye(e)&&a(s.get(ie));break}}cr()}function ve(e){let t=_(e);return t===e?t:(O(t,"iterate",Le),ae(e)?t:t.map(ce))}function fr(e){return O(e=_(e),"iterate",Le),e}function V(e,t){return se(e)?Jn(e)?je(ce(t)):je(t):ce(t)}var ao={__proto__:null,[Symbol.iterator](){return Jt(this,Symbol.iterator,e=>V(this,e))},concat(...e){return ve(this).concat(...e.map(t=>xe(t)?ve(t):t))},entries(){return Jt(this,"entries",e=>(e[1]=V(this,e[1]),e))},every(e,t){return K(this,"every",e,t,void 0,arguments)},filter(e,t){return K(this,"filter",e,t,r=>r.map(n=>V(this,n)),arguments)},find(e,t){return K(this,"find",e,t,r=>V(this,r),arguments)},findIndex(e,t){return K(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return K(this,"findLast",e,t,r=>V(this,r),arguments)},findLastIndex(e,t){return K(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return K(this,"forEach",e,t,void 0,arguments)},includes(...e){return Xt(this,"includes",e)},indexOf(...e){return Xt(this,"indexOf",e)},join(e){return ve(this).join(e)},lastIndexOf(...e){return Xt(this,"lastIndexOf",e)},map(e,t){return K(this,"map",e,t,void 0,arguments)},pop(){return Me(this,"pop")},push(...e){return Me(this,"push",e)},reduce(e,...t){return Ln(this,"reduce",e,t)},reduceRight(e,...t){return Ln(this,"reduceRight",e,t)},shift(){return Me(this,"shift")},some(e,t){return K(this,"some",e,t,void 0,arguments)},splice(...e){return Me(this,"splice",e)},toReversed(){return ve(this).toReversed()},toSorted(e){return ve(this).toSorted(e)},toSpliced(...e){return ve(this).toSpliced(...e)},unshift(...e){return Me(this,"unshift",e)},values(){return Jt(this,"values",e=>V(this,e))}};function Jt(e,t,r){let n=fr(e),i=n[t]();return n!==e&&!ae(e)&&(i._next=i.next,i.next=()=>{let o=i._next();return o.done||(o.value=r(o.value)),o}),i}var co=Array.prototype;function K(e,t,r,n,i,o){let s=fr(e),a=s!==e&&!ae(e),c=s[t];if(c!==co[t]){let u=c.apply(e,o);return a?ce(u):u}let l=r;s!==e&&(a?l=function(u,g){return r.call(this,V(e,u),g,e)}:r.length>2&&(l=function(u,g){return r.call(this,u,g,e)}));let f=c.call(s,l,n);return a&&i?i(f):f}function Ln(e,t,r,n){let i=fr(e),o=i!==e&&!ae(e),s=r,a=!1;i!==e&&(o?(a=n.length===0,s=function(l,f,u){return a&&(a=!1,l=V(e,l)),r.call(this,l,V(e,f),u,e)}):r.length>3&&(s=function(l,f,u){return r.call(this,l,f,u,e)}));let c=i[t](s,...n);return a?V(e,c):c}function Xt(e,t,r){let n=_(e);O(n,"iterate",Le);let i=n[t](...r);return(i===-1||i===!1)&&Eo(r[0])?(r[0]=_(r[0]),n[t](...r)):i}function Me(e,t,r=[]){oo(),ar();let n=_(e)[t].apply(e,r);return cr(),so(),n}var lo=zt("__proto__,__v_isRef,__isVue"),zn=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(be));function fo(e){be(e)||(e=String(e));let t=_(this);return O(t,"has",e),t.hasOwnProperty(e)}var ht=class{constructor(t=!1,r=!1){this._isReadonly=t,this._isShallow=r}get(t,r,n){if(r==="__v_skip")return t.__v_skip;let i=this._isReadonly,o=this._isShallow;if(r==="__v_isReactive")return!i;if(r==="__v_isReadonly")return i;if(r==="__v_isShallow")return o;if(r==="__v_raw")return n===(i?o?yo:Gn:o?xo:Wn).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;let s=xe(t);if(!i){let c;if(s&&(c=ao[r]))return c;if(r==="hasOwnProperty")return fo}let a=Reflect.get(t,r,Ie(t)?t:n);if((be(r)?zn.has(r):lo(r))||(i||O(t,"get",r),o))return a;if(Ie(a)){let c=s&&ct(r)?a:a.value;return i&&Ee(c)?sr(c):c}return Ee(a)?i?sr(a):mt(a):a}},nr=class extends ht{constructor(t=!1){super(!1,t)}set(t,r,n,i){let o=t[r],s=xe(t)&&ct(r);if(!this._isShallow){let l=se(o);if(!ae(n)&&!se(n)&&(o=_(o),n=_(n)),!s&&Ie(o)&&!Ie(n))return l?(oe(`Set operation on key "${String(r)}" failed: target is readonly.`,t[r]),!0):(o.value=n,!0)}let a=s?Number(r)<t.length:at(t,r),c=Reflect.set(t,r,n,Ie(t)?t:i);return t===_(i)&&c&&(a?J(n,o)&&X(t,"set",r,n,o):X(t,"add",r,n)),c}deleteProperty(t,r){let n=at(t,r),i=t[r],o=Reflect.deleteProperty(t,r);return o&&n&&X(t,"delete",r,void 0,i),o}has(t,r){let n=Reflect.has(t,r);return(!be(r)||!zn.has(r))&&O(t,"has",r),n}ownKeys(t){return O(t,"iterate",xe(t)?"length":ie),Reflect.ownKeys(t)}},ir=class extends ht{constructor(t=!1){super(!0,t)}set(t,r){return oe(`Set operation on key "${String(r)}" failed: target is readonly.`,t),!0}deleteProperty(t,r){return oe(`Delete operation on key "${String(r)}" failed: target is readonly.`,t),!0}},uo=new nr,po=new ir;var or=e=>e,ft=e=>Reflect.getPrototypeOf(e);function ho(e,t,r){return function(...n){let i=this.__v_raw,o=_(i),s=ye(o),a=e==="entries"||e===Symbol.iterator&&s,c=e==="keys"&&s,l=i[e](...n),f=r?or:t?je:ce;return!t&&O(o,"iterate",c?rr:ie),ge(Object.create(l),{next(){let{value:u,done:g}=l.next();return g?{value:u,done:g}:{value:a?[f(u[0]),f(u[1])]:f(u),done:g}}})}}function ut(e){return function(...t){{let r=t[0]?`on key "${t[0]}" `:"";oe(`${Wt(e)} operation ${r}failed: target is readonly.`,_(this))}return e==="delete"?!1:e==="clear"?void 0:this}}function mo(e,t){let r={get(i){let o=this.__v_raw,s=_(o),a=_(i);e||(J(i,a)&&O(s,"get",i),O(s,"get",a));let{has:c}=ft(s),l=t?or:e?je:ce;if(c.call(s,i))return l(o.get(i));if(c.call(s,a))return l(o.get(a));o!==s&&o.get(i)},get size(){let i=this.__v_raw;return!e&&O(_(i),"iterate",ie),i.size},has(i){let o=this.__v_raw,s=_(o),a=_(i);return e||(J(i,a)&&O(s,"has",i),O(s,"has",a)),i===a?o.has(i):o.has(i)||o.has(a)},forEach(i,o){let s=this,a=s.__v_raw,c=_(a),l=t?or:e?je:ce;return!e&&O(c,"iterate",ie),a.forEach((f,u)=>i.call(o,l(f),l(u),s))}};return ge(r,e?{add:ut("add"),set:ut("set"),delete:ut("delete"),clear:ut("clear")}:{add(i){let o=_(this),s=ft(o),a=_(i),c=!t&&!ae(i)&&!se(i)?a:i;return s.has.call(o,c)||J(i,c)&&s.has.call(o,i)||J(a,c)&&s.has.call(o,a)||(o.add(c),X(o,"add",c,c)),this},set(i,o){!t&&!ae(o)&&!se(o)&&(o=_(o));let s=_(this),{has:a,get:c}=ft(s),l=a.call(s,i);l?jn(s,a,i):(i=_(i),l=a.call(s,i));let f=c.call(s,i);return s.set(i,o),l?J(o,f)&&X(s,"set",i,o,f):X(s,"add",i,o),this},delete(i){let o=_(this),{has:s,get:a}=ft(o),c=s.call(o,i);c?jn(o,s,i):(i=_(i),c=s.call(o,i));let l=a?a.call(o,i):void 0,f=o.delete(i);return c&&X(o,"delete",i,void 0,l),f},clear(){let i=_(this),o=i.size!==0,s=ye(i)?new Map(i):new Set(i),a=i.clear();return o&&X(i,"clear",void 0,void 0,s),a}}),["keys","values","entries",Symbol.iterator].forEach(i=>{r[i]=ho(i,e,t)}),r}function Kn(e,t){let r=mo(e,t);return(n,i,o)=>i==="__v_isReactive"?!e:i==="__v_isReadonly"?e:i==="__v_raw"?n:Reflect.get(at(r,i)&&i in n?r:n,i,o)}var _o={get:Kn(!1,!1)};var go={get:Kn(!0,!1)};function jn(e,t,r){let n=_(r);if(n!==r&&t.call(e,n)){let i=Kt(e);oe(`Reactive ${i} contains both the raw and reactive versions of the same object${i==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}var Wn=new WeakMap,xo=new WeakMap,Gn=new WeakMap,yo=new WeakMap;function bo(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function mt(e){return se(e)?e:Yn(e,!1,uo,_o,Wn)}function sr(e){return Yn(e,!0,po,go,Gn)}function Yn(e,t,r,n,i){if(!Ee(e))return oe(`value cannot be made ${t?"readonly":"reactive"}: ${String(e)}`),e;if(e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;let o=i.get(e);if(o)return o;let s=bo(Kt(e));if(s===0)return e;let a=new Proxy(e,s===2?n:r);return i.set(e,a),a}function Jn(e){return se(e)?Jn(e.__v_raw):!!(e&&e.__v_isReactive)}function se(e){return!!(e&&e.__v_isReadonly)}function ae(e){return!!(e&&e.__v_isShallow)}function Eo(e){return e?!!e.__v_raw:!1}function _(e){let t=e&&e.__v_raw;return t?_(t):e}var ce=e=>Ee(e)?mt(e):e,je=e=>Ee(e)?sr(e):e;function Ie(e){return e?e.__v_isRef===!0:!1}b("nextTick",()=>_e);b("dispatch",e=>te.bind(te,e));b("watch",(e,{evaluateLater:t,cleanup:r})=>(n,i)=>{let o=t(n),a=Ve(()=>{let c;return o(l=>c=l),c},i);r(a)});b("store",On);b("data",e=>ze(e));b("root",e=>re(e));b("refs",e=>(e._x_refs_proxy||(e._x_refs_proxy=I(vo(e))),e._x_refs_proxy));function vo(e){let t=[];return A(e,r=>{r._x_refs&&t.push(r._x_refs)}),t}var ur={};function dr(e){return ur[e]||(ur[e]=0),++ur[e]}function Xn(e,t){return A(e,r=>{if(r._x_ids&&r._x_ids[t])return!0})}function Zn(e,t){e._x_ids||(e._x_ids={}),e._x_ids[t]||(e._x_ids[t]=dr(t))}b("id",(e,{cleanup:t})=>(r,n=null)=>{let i=`${r}${n?`-${n}`:""}`;return wo(e,i,t,()=>{let o=Xn(e,r),s=o?o._x_ids[r]:dr(r);return n?`${r}-${s}-${n}`:`${r}-${s}`})});F((e,t)=>{e._x_id&&(t._x_id=e._x_id)});function wo(e,t,r,n){if(e._x_id||(e._x_id={}),e._x_id[t])return e._x_id[t];let i=n();return e._x_id[t]=i,r(()=>{delete e._x_id[t]}),i}b("el",e=>e);Qn("Focus","focus","focus");Qn("Persist","persist","persist");function Qn(e,t,r){b(t,n=>T(`You can't use [$${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}p("modelable",(e,{expression:t},{effect:r,evaluateLater:n,cleanup:i})=>{let o=n(t),s=()=>{let f;return o(u=>f=u),f},a=n(`${t} = __placeholder`),c=f=>a(()=>{},{scope:{__placeholder:f}}),l=s();c(l),queueMicrotask(()=>{if(!e._x_model)return;e._x_removeModelListeners.default();let f=e._x_model.get,u=e._x_model.setWithModifiers,g=st({get(){return f()},set(y){u(y)}},{get(){return s()},set(y){c(y)}});i(g)})});p("teleport",(e,{modifiers:t,expression:r},{cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&T("x-teleport can only be used on a <template> tag",e);let i=ei(r),o=e.content.cloneNode(!0).firstElementChild;e._x_teleport=o,o._x_teleportBack=e,e.setAttribute("data-teleport-template",!0),o.setAttribute("data-teleport-target",!0),e._x_forwardEvents&&e._x_forwardEvents.forEach(a=>{o.addEventListener(a,c=>{c.stopPropagation(),e.dispatchEvent(new c.constructor(c.type,c))})}),P(o,{},e);let s=(a,c,l)=>{l.includes("prepend")?c.parentNode.insertBefore(a,c):l.includes("append")?c.parentNode.insertBefore(a,c.nextSibling):c.appendChild(a)};h(()=>{w(()=>{s(o,i,t),S(o)})()}),e._x_teleportPutBack=()=>{let a=ei(r);h(()=>{s(e._x_teleport,a,t)})},n(()=>h(()=>{o.remove(),C(o)}))});var So=document.createElement("div");function ei(e){let t=w(()=>document.querySelector(e),()=>So)();return t||T(`Cannot find x-teleport element for selector: "${e}"`),t}var ti=()=>{};ti.inline=(e,{modifiers:t},{cleanup:r})=>{t.includes("self")?e._x_ignoreSelf=!0:e._x_ignore=!0,r(()=>{t.includes("self")?delete e._x_ignoreSelf:delete e._x_ignore})};p("ignore",ti);p("effect",w((e,{expression:t},{effect:r})=>{r(x(e,t))}));function W(e,t,r,n){let i=e,o=c=>n(c),s={},a=(c,l)=>f=>l(c,f);return r.includes("dot")&&(t=Ao(t)),r.includes("camel")&&(t=To(t)),r.includes("capture")&&(s.capture=!0),r.includes("window")&&(i=window),r.includes("document")&&(i=document),r.includes("passive")&&(s.passive=r[r.indexOf("passive")+1]!=="false"),o=pr(r,o),r.includes("prevent")&&(o=a(o,(c,l)=>{l.preventDefault(),c(l)})),r.includes("stop")&&(o=a(o,(c,l)=>{l.stopPropagation(),c(l)})),r.includes("once")&&(o=a(o,(c,l)=>{c(l),i.removeEventListener(t,o,s)})),(r.includes("away")||r.includes("outside"))&&(i=document,o=a(o,(c,l)=>{e.contains(l.target)||l.target.isConnected!==!1&&(e.offsetWidth<1&&e.offsetHeight<1||e._x_isShown!==!1&&c(l))})),r.includes("self")&&(o=a(o,(c,l)=>{l.target===e&&c(l)})),t==="submit"&&(o=a(o,(c,l)=>{l.target._x_pendingModelUpdates&&l.target._x_pendingModelUpdates.forEach(f=>f()),c(l)})),(No(t)||ni(t))&&(o=a(o,(c,l)=>{Ro(l,r)||c(l)})),i.addEventListener(t,o,s),()=>{i.removeEventListener(t,o,s)}}function pr(e,t){if(e.includes("debounce")){let r=e[e.indexOf("debounce")+1]||"invalid-wait",n=_t(r.split("ms")[0])?Number(r.split("ms")[0]):250;t=it(t,n)}if(e.includes("throttle")){let r=e[e.indexOf("throttle")+1]||"invalid-wait",n=_t(r.split("ms")[0])?Number(r.split("ms")[0]):250;t=ot(t,n)}return t}function Ao(e){return e.replace(/-/g,".")}function To(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function _t(e){return!Array.isArray(e)&&!isNaN(e)}function Oo(e){return[" ","_"].includes(e)?e:e.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/[_\s]/,"-").toLowerCase()}function No(e){return["keydown","keyup"].includes(e)}function ni(e){return["contextmenu","click","mouse"].some(t=>e.includes(t))}var Co=["window","document","prevent","stop","once","capture","self","away","outside","passive","dot","camel","preserve-scroll","blur","change","lazy","number","boolean","trim","fill","unintrusive","parent"];function Ro(e,t){let r=t.filter((o,s)=>o==="false"&&t[s-1]==="passive"?!1:!Co.includes(o));if(r.includes("debounce")){let o=r.indexOf("debounce");r.splice(o,_t((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.includes("throttle")){let o=r.indexOf("throttle");r.splice(o,_t((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.length===0||r.length===1&&ri(e.key).includes(r[0]))return!1;let i=["ctrl","shift","alt","meta","cmd","super"].filter(o=>r.includes(o));return r=r.filter(o=>!i.includes(o)),!(i.length>0&&i.filter(s=>((s==="cmd"||s==="super")&&(s="meta"),e[`${s}Key`])).length===i.length&&(ni(e.type)||ri(e.key).includes(r[0])))}function ri(e){if(!e)return[];e=Oo(e);let t={ctrl:"control",slash:"/",space:" ",spacebar:" ",cmd:"meta",esc:"escape",up:"arrow-up",down:"arrow-down",left:"arrow-left",right:"arrow-right",period:".",comma:",",equal:"=",minus:"-",underscore:"_"};return t[e]=e,Object.keys(t).map(r=>{if(t[r]===e)return r}).filter(r=>r)}p("model",(e,{modifiers:t,expression:r},{effect:n,cleanup:i})=>{let o=e;t.includes("parent")&&(o=A(e,d=>d!==e));let s=x(o,r),a;typeof r=="string"?a=x(o,`${r} = __placeholder`):typeof r=="function"&&typeof r()=="string"?a=x(o,`${r()} = __placeholder`):a=()=>{};let c=()=>{let d;return s(E=>d=E),ii(d)?d.get():d},l=d=>{let E;s(v=>E=v),ii(E)?E.set(d):a(()=>{},{scope:{__placeholder:d}})};typeof r=="string"&&e.type==="radio"&&h(()=>{e.hasAttribute("name")||e.setAttribute("name",r)});let f=t.includes("change")||t.includes("lazy"),u=t.includes("blur"),g=t.includes("enter"),y=f||u||g,$;if(j)$=()=>{};else if(y){let d=[],E=v=>l(gt(e,t,v,c()));if(f&&d.push(W(e,"change",t,E)),u&&(d.push(W(e,"blur",t,E)),e.form)){let v=e.form,fe=()=>E({target:e});v._x_pendingModelUpdates||(v._x_pendingModelUpdates=[]),v._x_pendingModelUpdates.push(fe),i(()=>{v._x_pendingModelUpdates&&v._x_pendingModelUpdates.splice(v._x_pendingModelUpdates.indexOf(fe),1)})}g&&d.push(W(e,"keydown",t,v=>{v.key==="Enter"&&E(v)})),$=()=>d.forEach(v=>v())}else{let d=e.tagName.toLowerCase()==="select"||["checkbox","radio"].includes(e.type)?"change":"input";$=W(e,d,t,E=>{l(gt(e,t,E,c()))})}if(t.includes("fill")&&([void 0,null,""].includes(c())||De(e)&&Array.isArray(c())||e.tagName.toLowerCase()==="select"&&e.multiple)&&l(gt(e,t,{target:e},c())),e._x_removeModelListeners||(e._x_removeModelListeners={}),e._x_removeModelListeners.default=$,i(()=>e._x_removeModelListeners.default()),e.form){let d=W(e.form,"reset",[],E=>{_e(()=>e._x_model&&e._x_model.set(gt(e,t,{target:e},c())))});i(()=>d())}if(e._x_model={get(){return c()},set(d){l(d)},setWithModifiers:pr(t,l)},e._x_forceModelUpdate=d=>{d===void 0&&typeof r=="string"&&r.match(/\./)&&(d=""),h(()=>{De(e)?Array.isArray(d)?e.checked=d.some(E=>E==e.value):e.checked=!!d:nt(e)?typeof d=="boolean"?e.checked=Re(e.value)===d:e.checked=e.value==d:Ce(e,"value",d)})},e.tagName==="SELECT"){let d=new MutationObserver(()=>{e._x_forceModelUpdate(c())});d.observe(e,{childList:!0}),i(()=>d.disconnect())}n(()=>{let d=c();t.includes("unintrusive")&&document.activeElement.isSameNode(e)||e._x_forceModelUpdate(d)})});function gt(e,t,r,n){return h(()=>{if(r instanceof CustomEvent&&r.detail!==void 0)return r.detail!==null&&r.detail!==void 0?r.detail:r.target.value;if(De(e))if(Array.isArray(n)){let i=null;return t.includes("number")?i=hr(r.target.value):t.includes("boolean")?i=Re(r.target.value):i=r.target.value,r.target.checked?n.includes(i)?n:n.concat([i]):n.filter(o=>!Do(o,i))}else return r.target.checked;else{if(e.tagName.toLowerCase()==="select"&&e.multiple)return t.includes("number")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return hr(o)}):t.includes("boolean")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return Re(o)}):Array.from(r.target.selectedOptions).map(i=>i.value||i.text);{let i;return nt(e)?r.target.checked?i=r.target.value:i=n:i=r.target.value,t.includes("number")?hr(i):t.includes("boolean")?Re(i):t.includes("trim")?i.trim():i}}})}function hr(e){let t=e?parseFloat(e):null;return Mo(t)?t:e}function Do(e,t){return e==t}function Mo(e){return!Array.isArray(e)&&!isNaN(e)}function ii(e){return e!==null&&typeof e=="object"&&typeof e.get=="function"&&typeof e.set=="function"}p("cloak",e=>queueMicrotask(()=>h(()=>e.removeAttribute(N("cloak")))));et(()=>`[${N("init")}]`);p("init",w((e,{expression:t},{evaluate:r})=>typeof t=="string"?!!t.trim()&&r(t,{},!1):r(t,{},!1)));p("text",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{h(()=>{e.textContent=o})})})});p("html",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{h(()=>{Array.from(e.children).forEach(s=>C(s)),e.innerHTML=o??"",e._x_ignoreSelf=!0,S(e),delete e._x_ignoreSelf})})},{priority:"structural"})});me(Ye(":",Je(N("bind:"))));var oi=(e,{value:t,modifiers:r,expression:n,original:i},{effect:o,cleanup:s})=>{if(!t){let c={};Rn(c),x(e,n)(f=>{qt(e,f,i)},{scope:c});return}if(t==="key")return ko(e,n);if(e._x_inlineBindings&&e._x_inlineBindings[t]&&e._x_inlineBindings[t].extract)return;let a=x(e,n);o(()=>a(c=>{c===void 0&&typeof n=="string"&&n.match(/\./)&&(c=""),h(()=>Ce(e,t,c,r))})),s(()=>{e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedStyles&&e._x_undoAddedStyles()})};oi.inline=(e,{value:t,modifiers:r,expression:n})=>{t&&(e._x_inlineBindings||(e._x_inlineBindings={}),e._x_inlineBindings[t]={expression:n,extract:!1})};p("bind",oi);function ko(e,t){e._x_keyExpression=t}Qe(()=>`[${N("data")}]`);var le=Symbol();p("data",(e,{expression:t},{cleanup:r})=>{if(Io(e))return;let n=e[le];if(n?.expression===t)return;t=t===""?"{}":t;let i={};G(i,e);let o={};kn(o,i);let s=D(e,t,{scope:o});(s===void 0||s===!0)&&(s={}),G(s,e);let a;if(n?.reactiveData){a=n.reactiveData,Po(a,s);let l={expression:t};e[le]=l,queueMicrotask(()=>{e[le]===l&&delete e[le]})}else a=R(s);pe(a,r);let c=P(e,a);w(()=>{a.init&&D(e,a.init)})(),r(()=>{a.destroy&&D(e,a.destroy),c();let l={reactiveData:a};e[le]=l,queueMicrotask(()=>{e[le]===l&&delete e[le]})})});function Po(e,t){Object.keys(t).forEach(r=>{let n=Object.getOwnPropertyDescriptor(t,r),i=Object.getOwnPropertyDescriptor(e,r);n.get||n.set||i?.get||i?.set?(i&&delete e[r],i||(e[r]=void 0),n.get||n.set?Object.defineProperty(e,r,n):e[r]=t[r]):e[r]=t[r]}),Object.keys(e).filter(r=>!Object.prototype.hasOwnProperty.call(t,r)).forEach(r=>delete e[r])}F((e,t)=>{e._x_dataStack&&(t._x_dataStack=e._x_dataStack,t.setAttribute("data-has-alpine-state",!0))});function Io(e){return j?Xe?!0:e.hasAttribute("data-has-alpine-state"):!1}p("show",(e,{modifiers:t,expression:r},{effect:n})=>{let i=x(e,r);e._x_doHide||(e._x_doHide=()=>{h(()=>{e.style.setProperty("display","none",t.includes("important")?"important":void 0)})}),e._x_doShow||(e._x_doShow=()=>{h(()=>{e.style.length===1&&e.style.display==="none"?e.removeAttribute("style"):e.style.removeProperty("display")})});let o=()=>{e._x_doHide(),e._x_isShown=!1},s=()=>{e._x_doShow(),e._x_isShown=!0},a=()=>setTimeout(s),c=Oe(u=>u?s():o(),u=>{typeof e._x_toggleAndCascadeWithTransitions=="function"?e._x_toggleAndCascadeWithTransitions(e,u,s,o):u?a():o()}),l,f=!0;n(()=>i(u=>{!f&&u===l||(t.includes("immediate")&&(u?a():o()),c(u),l=u,f=!1)}))});p("for",w((e,{expression:t},{effect:r,cleanup:n})=>{let i=Fo(t),o=x(e,i.items),s=x(e,e._x_keyExpression||"index");e._x_lookup=new Map,r(()=>jo(e,i,o,s),{priority:"structural"}),n(()=>{e._x_lookup.forEach(a=>h(()=>{C(a),a.remove()})),delete e._x_lookup,delete e._x_lastRenderedEl})}));function Lo(e){return t=>{Object.entries(t).forEach(([r,n])=>{e[r]=n})}}function jo(e,t,r,n){r(i=>{$o(i)&&(i=Array.from({length:i},(l,f)=>f+1)),i==null&&(i=[]),i instanceof Set&&(i=Array.from(i)),i instanceof Map&&(i=Array.from(i));let o=e._x_lookup,s=new Map;e._x_lookup=s;let a=Bo(i),c=Object.entries(i).map(([l,f])=>{a||(l=parseInt(l));let u=Vo(t,f,l,i),g;return n(y=>{typeof y=="object"&&T("x-for key cannot be an object, it must be a string or an integer",e),o.has(y)&&(s.set(y,o.get(y)),o.delete(y)),g=y},{scope:{index:l,...u}}),[g,u]});h(()=>{o.forEach(u=>{C(u),u.remove()});let l=new Set,f=e;c.forEach(([u,g])=>{if(s.has(u)){let d=s.get(u);d._x_refreshXForScope(g),f.nextElementSibling!==d&&(f.nextElementSibling&&d.replaceWith(f.nextElementSibling),f.after(d)),f=d,d._x_currentIfEl&&(d.nextElementSibling!==d._x_currentIfEl&&f.after(d._x_currentIfEl),f=d._x_currentIfEl);return}e.content.children.length>1&&T("x-for templates require a single root element, additional elements will be ignored.",e);let y=document.importNode(e.content,!0).firstElementChild,$=R(g);P(y,$,e),y._x_refreshXForScope=Lo($),s.set(u,y),l.add(y),f.after(y),f=y}),l.forEach(u=>S(u)),f!==e?e._x_lastRenderedEl=f:delete e._x_lastRenderedEl})})}function Fo(e){let t=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,r=/^\s*\(|\)\s*$/g,n=/([\s\S]*?)\b(?:in|of)\b([\s\S]*)/,i=e.match(n);if(!i)return;let o={};o.items=i[2].trim();let s=i[1].replace(r,"").trim(),a=s.match(t);return a?(o.item=s.replace(t,"").trim(),o.index=a[1].trim(),a[2]&&(o.collection=a[2].trim())):o.item=s,o}function Vo(e,t,r,n){let i={};return/^\[.*\]$/.test(e.item)&&Array.isArray(t)?e.item.replace("[","").replace("]","").split(",").map(s=>s.trim()).forEach((s,a)=>{i[s]=t[a]}):/^\{.*\}$/.test(e.item)&&!Array.isArray(t)&&typeof t=="object"?e.item.replace("{","").replace("}","").split(",").map(s=>s.trim()).forEach(s=>{i[s]=t[s]}):i[e.item]=t,e.index&&(i[e.index]=r),e.collection&&(i[e.collection]=n),i}function $o(e){return typeof e!="object"&&!isNaN(e)}function Bo(e){return typeof e=="object"&&!Array.isArray(e)}function si(){}si.inline=(e,{expression:t},{cleanup:r})=>{let n=re(e);n&&(n._x_refs||(n._x_refs={}),n._x_refs[t]=e,r(()=>delete n._x_refs[t]))};p("ref",si);p("if",w((e,{expression:t},{effect:r,cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&T("x-if can only be used on a <template> tag",e);let i=x(e,t),o=()=>{if(e._x_currentIfEl)return e._x_currentIfEl;let a=e.content.cloneNode(!0).firstElementChild;return P(a,{},e),h(()=>{e.after(a),S(a)}),e._x_currentIfEl=a,e._x_lastRenderedEl=a,e._x_undoIf=()=>{h(()=>{C(a),a.remove()}),delete e._x_currentIfEl,delete e._x_lastRenderedEl},a},s=()=>{e._x_undoIf&&(e._x_undoIf(),delete e._x_undoIf)};r(()=>i(a=>{a?o():s()}),{priority:"structural"}),n(()=>e._x_undoIf&&e._x_undoIf())}));p("id",(e,{expression:t},{evaluate:r})=>{r(t).forEach(i=>Zn(e,i))});F((e,t)=>{e._x_ids&&(t._x_ids=e._x_ids)});me(Ye("@",Je(N("on:"))));p("on",w((e,{value:t,modifiers:r,expression:n},{cleanup:i})=>{let o=n?x(e,n):()=>{};e.tagName.toLowerCase()==="template"&&(e._x_forwardEvents||(e._x_forwardEvents=[]),e._x_forwardEvents.includes(t)||e._x_forwardEvents.push(t));let s=W(e,t,r,a=>{o(()=>{},{scope:{$event:a},params:[a]})});i(()=>s())}));xt("Collapse","collapse","collapse");xt("Intersect","intersect","intersect");xt("Focus","trap","focus");xt("Mask","mask","mask");function xt(e,t,r){p(t,n=>T(`You can't use [x-${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}q.setEvaluator(Hr);q.setRawEvaluator(qr);q.setReactivityEngine({reactive:mt,effect:(e,t={})=>{let r;return r=Bn(e,{scheduler:()=>{r&&(t.scheduler?t.scheduler(r):r())}}),r},release:Hn,raw:_});var mr=q;window.Alpine=mr;queueMicrotask(()=>{mr.start()});})();
+/*! Bundled license information:
+
+@vue/shared/dist/shared.esm-bundler.js:
+ (**
+ * @vue/shared v3.5.41
+ * (c) 2018-present Yuxi (Evan) You and Vue contributors
+ * @license MIT
+ **)
+
+@vue/reactivity/dist/reactivity.esm-bundler.js:
+ (**
+ * @vue/reactivity v3.5.41
+ * (c) 2018-present Yuxi (Evan) You and Vue contributors
+ * @license MIT
+ **)
+*/
diff --git a/justfile b/justfile
index 5d2c7b2..3615649 100644
--- a/justfile
+++ b/justfile
@@ -25,5 +25,9 @@ mutants:
all: fmt clippy coverage
+# Serve the fixture-backed UI without Tailscale on http://127.0.0.1:3000.
+dev:
+ cargo run -- --dev
+
install:
cargo install --locked --path .
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index 2e40503..a3027ec 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -7,4 +7,9 @@ pub struct Config {
/// fronts it and supplies the user identity header.
#[arg(long, env = "DOCKET_BIND", default_value = "127.0.0.1:3000")]
pub bind: SocketAddr,
+
+ /// Serve without Tailscale: requests with no identity header act as a
+ /// user picked from the sidebar. For local work against fixtures only.
+ #[arg(long, env = "DOCKET_DEV")]
+ pub dev: bool,
}
diff --git a/src/bin/docket/main.rs b/src/bin/docket/main.rs
index 9cc5c0e..a8da089 100644
--- a/src/bin/docket/main.rs
+++ b/src/bin/docket/main.rs
@@ -17,7 +17,9 @@ async fn main() -> miette::Result<()> {
.init();
let config = config::Config::parse();
- let app = docket::routes::router();
+ // Fixtures stand in for the database and JMAP sync until they exist.
+ let state = docket::routes::AppState::new(docket::fixtures::store(), config.dev);
+ let app = docket::routes::router(state);
let listener = TcpListener::bind(config.bind).await.into_diagnostic()?;
tracing::info!(addr = %config.bind, "listening");
diff --git a/src/dates.rs b/src/dates.rs
new file mode 100644
index 0000000..e2ad13e
--- /dev/null
+++ b/src/dates.rs
@@ -0,0 +1,41 @@
+use jiff::Span;
+use jiff::civil::DateTime;
+
+/// A mail-client date: the time today, "Yesterday", the weekday earlier
+/// this week, and the month and day before that.
+pub fn short(now: DateTime, at: DateTime) -> String {
+ let today = now.date();
+ let day = at.date();
+ if day == today {
+ return at.strftime("%-I:%M %p").to_string();
+ }
+ if today.yesterday().is_ok_and(|y| y == day) {
+ return "Yesterday".to_owned();
+ }
+ let since_monday = i64::from(today.weekday().to_monday_zero_offset());
+ let monday = today
+ .checked_sub(Span::new().days(since_monday))
+ .unwrap_or(today);
+ if day >= monday && day < today {
+ return at.strftime("%a").to_string();
+ }
+ at.strftime("%b %-d").to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use jiff::civil::date;
+
+ #[test]
+ fn formats_relative_to_now() {
+ // Thursday.
+ let now = date(2026, 10, 1).at(12, 0, 0, 0);
+ assert_eq!(short(now, date(2026, 10, 1).at(9, 12, 0, 0)), "9:12 AM");
+ assert_eq!(short(now, date(2026, 10, 1).at(14, 5, 0, 0)), "2:05 PM");
+ assert_eq!(short(now, date(2026, 9, 30).at(9, 0, 0, 0)), "Yesterday");
+ assert_eq!(short(now, date(2026, 9, 28).at(9, 0, 0, 0)), "Mon");
+ assert_eq!(short(now, date(2026, 9, 27).at(9, 0, 0, 0)), "Sep 27");
+ assert_eq!(short(now, date(2026, 10, 2).at(9, 0, 0, 0)), "Oct 2");
+ }
+}
diff --git a/src/error.rs b/src/error.rs
index 752ee75..699bcb1 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,3 +1,5 @@
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
use miette::Diagnostic;
#[derive(Debug, thiserror::Error, Diagnostic)]
@@ -5,4 +7,52 @@ pub enum Error {
#[error("io error: {0}")]
#[diagnostic(code(docket::io))]
Io(#[from] std::io::Error),
+
+ #[error("no user identity on the request")]
+ #[diagnostic(code(docket::unauthenticated))]
+ Unauthenticated,
+
+ #[error("no such {0}")]
+ #[diagnostic(code(docket::not_found))]
+ NotFound(&'static str),
+
+ #[error("{0}")]
+ #[diagnostic(code(docket::forbidden))]
+ Forbidden(&'static str),
+
+ #[error("{0}")]
+ #[diagnostic(code(docket::bad_request))]
+ BadRequest(&'static str),
+}
+
+impl IntoResponse for Error {
+ fn into_response(self) -> Response {
+ let status = match self {
+ Error::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
+ Error::Unauthenticated => StatusCode::UNAUTHORIZED,
+ Error::NotFound(_) => StatusCode::NOT_FOUND,
+ Error::Forbidden(_) => StatusCode::FORBIDDEN,
+ Error::BadRequest(_) => StatusCode::BAD_REQUEST,
+ };
+ (status, self.to_string()).into_response()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn statuses() {
+ let cases = [
+ (Error::Io(std::io::Error::other("disk")), 500),
+ (Error::Unauthenticated, 401),
+ (Error::NotFound("thread"), 404),
+ (Error::Forbidden("read-only"), 403),
+ (Error::BadRequest("empty"), 400),
+ ];
+ for (err, status) in cases {
+ assert_eq!(err.into_response().status().as_u16(), status);
+ }
+ }
}
diff --git a/src/fixtures.rs b/src/fixtures.rs
new file mode 100644
index 0000000..6c8773c
--- /dev/null
+++ b/src/fixtures.rs
@@ -0,0 +1,337 @@
+//! Sample household mail, standing in for JMAP until sync exists. Names and
+//! messages follow the wireframes: Alex and Sam share the household account,
+//! and Eli's account is monitored read-only.
+
+use std::collections::BTreeSet;
+
+use jiff::civil::{DateTime, date};
+
+use crate::model::{Account, Comment, Kind, Message, MessageId, State, Thread, User, Values};
+use crate::store::Store;
+
+pub const WATER: MessageId = 7;
+pub const ELI_PRACTICE: MessageId = 20;
+
+/// Thursday, 1 October 2026, at noon.
+pub fn now() -> DateTime {
+ at(10, 1, 12, 0)
+}
+
+fn at(month: i8, day: i8, hour: i8, minute: i8) -> DateTime {
+ date(2026, month, day).at(hour, minute, 0, 0)
+}
+
+fn strings(xs: &[&str]) -> Vec<String> {
+ xs.iter().map(|&x| x.to_owned()).collect()
+}
+
+struct Builder {
+ store: Store,
+}
+
+impl Builder {
+ fn thread(&mut self, id: u32, account: &str, subject: &str) {
+ self.store.threads.push(Thread {
+ id,
+ account: account.to_owned(),
+ subject: subject.to_owned(),
+ });
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn recv(
+ &mut self,
+ id: MessageId,
+ thread: u32,
+ at: DateTime,
+ (from, addr): (&str, &str),
+ body: &str,
+ (state, folder, assignees): (State, Option<&str>, &[&str]),
+ cc: &[&str],
+ ) {
+ self.store.messages.push(Message {
+ id,
+ thread,
+ at,
+ cc: strings(cc),
+ bcc: Vec::new(),
+ body: body.to_owned(),
+ kind: Kind::Received {
+ from: from.to_owned(),
+ addr: addr.to_owned(),
+ values: Values {
+ state,
+ folder: folder.map(str::to_owned),
+ assignees: assignees
+ .iter()
+ .map(|&a| a.to_owned())
+ .collect::<BTreeSet<_>>(),
+ },
+ },
+ });
+ }
+
+ fn sent(
+ &mut self,
+ id: MessageId,
+ thread: u32,
+ at: DateTime,
+ (by, to): (&str, &[&str]),
+ body: &str,
+ (cc, bcc): (&[&str], &[&str]),
+ ) {
+ self.store.messages.push(Message {
+ id,
+ thread,
+ at,
+ cc: strings(cc),
+ bcc: strings(bcc),
+ body: body.to_owned(),
+ kind: Kind::Sent {
+ by: by.to_owned(),
+ to: strings(to),
+ },
+ });
+ }
+
+ fn comment(&mut self, thread: u32, author: &str, at: DateTime, text: &str) {
+ let id = u32::try_from(self.store.comments.len())
+ .unwrap_or(u32::MAX)
+ .saturating_add(1);
+ self.store.comments.push(Comment {
+ id,
+ thread,
+ author: author.to_owned(),
+ at,
+ text: text.to_owned(),
+ });
+ }
+}
+
+pub fn store() -> Store {
+ let mut b = Builder {
+ store: Store::default(),
+ };
+ b.store.now = now();
+ b.store.users = vec![
+ User {
+ slug: "alex".into(),
+ name: "Alex".into(),
+ login: "alex@example.com".into(),
+ },
+ User {
+ slug: "sam".into(),
+ name: "Sam".into(),
+ login: "sam@example.com".into(),
+ },
+ ];
+ b.store.accounts = vec![
+ Account {
+ slug: "household".into(),
+ name: "Household".into(),
+ address: "household@example.com".into(),
+ read_only: false,
+ },
+ Account {
+ slug: "eli".into(),
+ name: "Eli".into(),
+ address: "eli@example.com".into(),
+ read_only: true,
+ },
+ ];
+ b.store.folders = strings(&["Medical", "School", "House", "Finance"]);
+
+ use State::{Do, Done, Inbox, Wait, Watch};
+ let roofer = ("Northwind Roofing", "office@northwind.co");
+ let school = ("Lincoln High School", "office@lincolnhigh.org");
+
+ b.thread(1, "household", "Gutter repair estimate");
+ b.recv(
+ 1,
+ 1,
+ at(9, 20, 9, 5),
+ roofer,
+ "Estimate for gutter repair attached: $1,840, two days.",
+ (Done, Some("House"), &[]),
+ &[],
+ );
+ b.comment(
+ 1,
+ "alex",
+ at(9, 20, 19, 30),
+ "The Hendersons paid about $1,600 for theirs.",
+ );
+ b.sent(
+ 2,
+ 1,
+ at(9, 21, 8, 40),
+ ("sam", &["Northwind Roofing"]),
+ "Does this include the downspout on the north side?",
+ (&["Alex"], &["Pat Lee"]),
+ );
+ b.recv(
+ 3,
+ 1,
+ at(9, 26, 14, 10),
+ roofer,
+ "Here are the photos from the site visit.",
+ (Inbox, Some("House"), &[]),
+ &[],
+ );
+ b.recv(
+ 4,
+ 1,
+ at(9, 27, 10, 2),
+ roofer,
+ "Revised estimate attached, includes the downspout: $2,120. We can start October 14 if you confirm by the 3rd.",
+ (Inbox, Some("House"), &[]),
+ &["Sam", "Alex"],
+ );
+
+ b.thread(2, "household", "Field trip permission form");
+ b.recv(
+ 5,
+ 2,
+ at(9, 30, 15, 30),
+ school,
+ "Please return the signed permission form by Friday.",
+ (Do, Some("School"), &["sam"]),
+ &[],
+ );
+ b.recv(
+ 6,
+ 2,
+ at(10, 1, 9, 12),
+ school,
+ "A schedule update for the museum trip: buses now return at 4:15, so pickup moves to 4:30 PM at the front entrance.",
+ (Inbox, Some("School"), &["alex"]),
+ &[],
+ );
+ b.comment(2, "sam", at(10, 1, 9, 20), "You have pickup that day.");
+
+ b.thread(3, "household", "Service interruption Oct 4");
+ b.recv(
+ WATER,
+ 3,
+ at(10, 1, 11, 20),
+ ("City Water", "notices@citywater.gov"),
+ "Crews will be working on Elm St between 9 AM and 1 PM. Expect low pressure until the afternoon.",
+ (Inbox, None, &[]),
+ &[],
+ );
+
+ b.thread(4, "household", "Your September bill is ready");
+ b.recv(
+ 8,
+ 4,
+ at(9, 30, 7, 45),
+ ("PG&E", "billing@pge.com"),
+ "Amount due $142.18 by October 21.",
+ (Inbox, None, &[]),
+ &[],
+ );
+
+ b.thread(5, "household", "Your order has shipped");
+ b.recv(
+ 9,
+ 5,
+ at(9, 28, 16, 0),
+ ("Costco", "orders@costco.com"),
+ "Estimated delivery Wednesday.",
+ (Inbox, None, &[]),
+ &[],
+ );
+
+ b.thread(6, "household", "Eli: appointment confirmed");
+ b.recv(
+ 10,
+ 6,
+ at(10, 1, 8, 15),
+ ("Dr. Patel’s Office", "frontdesk@patelpeds.com"),
+ "This confirms Eli’s appointment on Thursday, October 8 at 3:00 PM. Please arrive ten minutes early.",
+ (Inbox, Some("Medical"), &["alex"]),
+ &[],
+ );
+ b.comment(6, "sam", at(10, 1, 9, 41), "Thursday at 3, I’ll take him.");
+
+ b.thread(7, "household", "Claim 4471: adjuster visit");
+ b.recv(
+ 11,
+ 7,
+ at(9, 23, 13, 0),
+ ("State Farm", "claims@statefarm.com"),
+ "Claim 4471 is open. An adjuster will contact you within five business days to schedule a visit.",
+ (Wait, Some("House"), &["alex"]),
+ &[],
+ );
+ b.sent(
+ 12,
+ 7,
+ at(9, 24, 9, 30),
+ ("alex", &["State Farm"]),
+ "Thanks. Mornings work best for us, any day next week.",
+ (&[], &[]),
+ );
+
+ b.thread(8, "household", "Exemption renewal");
+ b.recv(
+ 13,
+ 8,
+ at(9, 25, 10, 0),
+ ("County Assessor", "assessor@county.gov"),
+ "Your homeowner’s exemption must be renewed by October 31. The form is attached.",
+ (Do, Some("Finance"), &[]),
+ &[],
+ );
+
+ b.thread(9, "household", "Shipped: furnace filters");
+ b.recv(
+ 14,
+ 9,
+ at(9, 30, 18, 5),
+ ("Amazon", "shipment-tracking@amazon.com"),
+ "Your package will arrive Friday.",
+ (Watch, Some("House"), &[]),
+ &[],
+ );
+
+ b.thread(10, "household", "Your annual checkup");
+ b.recv(
+ 15,
+ 10,
+ at(9, 10, 11, 0),
+ ("Dr. Patel’s Office", "frontdesk@patelpeds.com"),
+ "Thanks for visiting. Your visit summary is available in the portal.",
+ (Done, Some("Medical"), &[]),
+ &[],
+ );
+
+ b.thread(11, "eli", "Practice moved to Thursday");
+ b.recv(
+ ELI_PRACTICE,
+ 11,
+ at(9, 29, 17, 0),
+ ("Coach Rivera", "rivera@lincolnhigh.org"),
+ "Practice moves to Thursday this week, 4 to 6 PM. Bring water.",
+ (Watch, None, &["sam"]),
+ &[],
+ );
+
+ // Everything before yesterday has been read by both of us.
+ let cutoff = at(9, 30, 0, 0);
+ let read: Vec<_> = b
+ .store
+ .messages
+ .iter()
+ .filter(|m| m.at < cutoff)
+ .map(|m| m.id)
+ .collect();
+ for id in read {
+ for user in ["alex", "sam"] {
+ b.store.mark_read(user, id);
+ }
+ }
+ b.store.mark_read("sam", 5);
+
+ b.store
+}
diff --git a/src/lib.rs b/src/lib.rs
index 3d7eead..645588a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,10 @@
+pub mod dates;
pub mod error;
+pub mod fixtures;
+pub mod lists;
+pub mod model;
pub mod routes;
+pub mod store;
+pub mod views;
pub use error::Error;
diff --git a/src/lists.rs b/src/lists.rs
new file mode 100644
index 0000000..afb49ee
--- /dev/null
+++ b/src/lists.rs
@@ -0,0 +1,417 @@
+//! Which messages a view lists, and how they group. Every list shows
+//! individual messages, grouped by thread; a group holds only the thread's
+//! messages that belong in that section.
+
+use std::collections::BTreeSet;
+
+use crate::model::{Kind, Message, State, Thread, User};
+use crate::store::Store;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum View {
+ ForMe,
+ Lane(State),
+ Folder(String),
+ Search(String),
+}
+
+impl View {
+ pub fn title(&self) -> String {
+ match self {
+ View::ForMe => "For me".to_owned(),
+ View::Lane(state) => state.name().to_owned(),
+ View::Folder(folder) => folder.clone(),
+ View::Search(_) => "Search".to_owned(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Order {
+ NewestFirst,
+ OldestFirst,
+}
+
+/// What a section already says about its rows, so rows needn't repeat it.
+#[derive(Debug, Clone, Default)]
+pub struct Context {
+ pub show_state: bool,
+ pub show_folder: bool,
+ /// Rows assigned to exactly these people leave assignees off.
+ pub implied: Option<BTreeSet<String>>,
+}
+
+#[derive(Debug)]
+pub struct Section<'a> {
+ pub head: String,
+ pub sub: String,
+ /// Compact rows drop the snippet (Watch).
+ pub compact: bool,
+ pub context: Context,
+ pub groups: Vec<Group<'a>>,
+}
+
+#[derive(Debug)]
+pub struct Group<'a> {
+ pub thread: &'a Thread,
+ /// All messages in the thread, sent ones included.
+ pub total: usize,
+ pub rows: Vec<&'a Message>,
+}
+
+impl Group<'_> {
+ /// "2 OF 4" when the group shows only part of its thread.
+ pub fn more(&self) -> Option<String> {
+ (self.rows.len() < self.total).then(|| format!("{} OF {}", self.rows.len(), self.total))
+ }
+}
+
+pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>> {
+ let received = || store.messages.iter().filter(|m| m.values().is_some());
+ let in_state = move |state| received().filter(move |m| m.state() == Some(state));
+ let me_only: BTreeSet<String> = BTreeSet::from([me.slug.clone()]);
+ let to_me = format!("→ {}", me.name.to_uppercase());
+ let them: Vec<_> = store
+ .users
+ .iter()
+ .filter(|u| u.slug != me.slug)
+ .map(|u| u.name.to_uppercase())
+ .collect();
+ let to_them = format!("→ {}", them.join(", "));
+ let folder_context = Context {
+ show_folder: true,
+ ..Context::default()
+ };
+
+ let mut out = Vec::new();
+ match view {
+ View::ForMe => {
+ for state in State::LANES {
+ let mine = in_state(state).filter(|m| m.is_assigned_to(&me.slug));
+ out.push(section(
+ store,
+ (state.name().to_uppercase(), to_me.clone()),
+ Context {
+ implied: Some(me_only.clone()),
+ ..folder_context.clone()
+ },
+ mine.collect(),
+ Order::NewestFirst,
+ ));
+ if state == State::Inbox {
+ out.push(section(
+ store,
+ (state.name().to_uppercase(), "NO ONE’S".to_owned()),
+ folder_context.clone(),
+ in_state(state).filter(|m| m.is_unassigned()).collect(),
+ Order::NewestFirst,
+ ));
+ }
+ }
+ }
+ View::Lane(state @ (State::Inbox | State::Do)) => {
+ let order = if *state == State::Do {
+ Order::OldestFirst
+ } else {
+ Order::NewestFirst
+ };
+ let mine = (to_me, String::new());
+ let nobody = ("NO ONE’S".to_owned(), String::new());
+ let theirs = (to_them, String::new());
+ let mine_msgs = in_state(*state)
+ .filter(|m| m.is_assigned_to(&me.slug))
+ .collect();
+ let nobody_msgs = in_state(*state).filter(|m| m.is_unassigned()).collect();
+ let theirs_msgs = in_state(*state)
+ .filter(|m| !m.is_unassigned() && !m.is_assigned_to(&me.slug))
+ .collect();
+ let mine = section(
+ store,
+ mine,
+ Context {
+ implied: Some(me_only),
+ ..folder_context.clone()
+ },
+ mine_msgs,
+ order,
+ );
+ let nobody = section(store, nobody, folder_context.clone(), nobody_msgs, order);
+ let theirs = section(store, theirs, folder_context, theirs_msgs, order);
+ // Inbox reads me / them / no one; Do reads mine / unassigned / theirs.
+ if *state == State::Do {
+ out.extend([mine, nobody, theirs]);
+ } else {
+ out.extend([mine, theirs, nobody]);
+ }
+ }
+ View::Lane(state) => {
+ let order = if *state == State::Watch {
+ Order::NewestFirst
+ } else {
+ Order::OldestFirst
+ };
+ let mut s = section(
+ store,
+ (String::new(), String::new()),
+ folder_context,
+ in_state(*state).collect(),
+ order,
+ );
+ s.compact = *state == State::Watch;
+ out.push(s);
+ }
+ View::Folder(folder) => {
+ for state in State::ALL {
+ out.push(section(
+ store,
+ (state.name().to_uppercase(), String::new()),
+ Context::default(),
+ in_state(state)
+ .filter(|m| m.values().and_then(|v| v.folder.as_ref()) == Some(folder))
+ .collect(),
+ if state == State::Done {
+ Order::NewestFirst
+ } else {
+ Order::OldestFirst
+ },
+ ));
+ }
+ }
+ View::Search(query) => {
+ let query = query.trim().to_lowercase();
+ if !query.is_empty() {
+ let hits = store
+ .messages
+ .iter()
+ .filter(|m| matches(store, m, &query))
+ .collect();
+ out.push(section(
+ store,
+ ("RESULTS".to_owned(), String::new()),
+ Context {
+ show_state: true,
+ ..folder_context
+ },
+ hits,
+ Order::NewestFirst,
+ ));
+ }
+ }
+ }
+ out.retain(|s| !s.groups.is_empty());
+ out
+}
+
+/// How many messages a view lists, for the sidebar.
+pub fn count(store: &Store, me: &User, view: &View) -> usize {
+ sections(store, me, view)
+ .iter()
+ .flat_map(|s| &s.groups)
+ .map(|g| g.rows.len())
+ .sum()
+}
+
+fn matches(store: &Store, m: &Message, query: &str) -> bool {
+ let subject = store.thread(m.thread).map_or("", |t| t.subject.as_str());
+ let from = match &m.kind {
+ Kind::Received { from, .. } => from.as_str(),
+ Kind::Sent { by, .. } => store.user_name(by),
+ };
+ [subject, from, m.body.as_str()]
+ .iter()
+ .any(|s| s.to_lowercase().contains(query))
+}
+
+fn section<'a>(
+ store: &'a Store,
+ (head, sub): (String, String),
+ context: Context,
+ mut msgs: Vec<&'a Message>,
+ order: Order,
+) -> Section<'a> {
+ msgs.sort_by_key(|m| (m.at, m.id));
+ let mut groups: Vec<Group<'a>> = Vec::new();
+ for m in msgs {
+ if let Some(g) = groups.iter_mut().find(|g| g.thread.id == m.thread) {
+ g.rows.push(m);
+ } else if let Some(thread) = store.thread(m.thread) {
+ groups.push(Group {
+ thread,
+ total: store.thread_messages(thread.id).len(),
+ rows: vec![m],
+ });
+ }
+ }
+ match order {
+ Order::OldestFirst => groups.sort_by_key(|g| g.rows.first().map(|m| m.at)),
+ Order::NewestFirst => {
+ groups.sort_by_key(|g| std::cmp::Reverse(g.rows.last().map(|m| m.at)));
+ }
+ }
+ Section {
+ head,
+ sub,
+ compact: false,
+ context,
+ groups,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fixtures;
+
+ fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, String, Vec<Vec<u32>>)> {
+ let me = store.user(user).unwrap();
+ sections(store, me, view)
+ .into_iter()
+ .map(|s| {
+ (
+ s.head,
+ s.sub,
+ s.groups
+ .iter()
+ .map(|g| g.rows.iter().map(|m| m.id).collect())
+ .collect(),
+ )
+ })
+ .collect()
+ }
+
+ fn heads(store: &Store, user: &str, view: &View) -> Vec<String> {
+ summary(store, user, view)
+ .into_iter()
+ .map(|(h, s, _)| format!("{h}|{s}"))
+ .collect()
+ }
+
+ #[test]
+ fn for_me_groups_by_state_then_thread() {
+ let store = fixtures::store();
+ let sam = summary(&store, "sam", &View::ForMe);
+ assert_eq!(
+ heads(&store, "sam", &View::ForMe),
+ ["INBOX|NO ONE’S", "DO|→ SAM", "WATCH|→ SAM"]
+ );
+ let (_, _, groups) = &sam[0];
+ // Newest thread first; the roofing thread shows only its two Inbox emails.
+ assert_eq!(groups[0], vec![fixtures::WATER]);
+ assert!(groups.contains(&vec![3, 4]));
+
+ assert_eq!(
+ heads(&store, "alex", &View::ForMe),
+ ["INBOX|→ ALEX", "INBOX|NO ONE’S", "WAIT|→ ALEX"]
+ );
+ }
+
+ #[test]
+ fn group_counts() {
+ let store = fixtures::store();
+ let sam = store.user("sam").unwrap();
+ let sections = sections(&store, sam, &View::ForMe);
+ let roofing = sections[0]
+ .groups
+ .iter()
+ .find(|g| g.thread.id == 1)
+ .unwrap();
+ assert_eq!(roofing.more().as_deref(), Some("2 OF 4"));
+ let water = §ions[0].groups[0];
+ assert_eq!(water.more(), None);
+ assert_eq!(count(&store, sam, &View::ForMe), 7);
+ }
+
+ #[test]
+ fn lanes() {
+ let store = fixtures::store();
+ assert_eq!(
+ heads(&store, "sam", &View::Lane(State::Inbox)),
+ ["→ ALEX|", "NO ONE’S|"]
+ );
+ assert_eq!(
+ heads(&store, "alex", &View::Lane(State::Inbox)),
+ ["→ ALEX|", "NO ONE’S|"]
+ );
+ assert_eq!(
+ heads(&store, "alex", &View::Lane(State::Do)),
+ ["NO ONE’S|", "→ SAM|"]
+ );
+ assert_eq!(
+ heads(&store, "sam", &View::Lane(State::Do)),
+ ["→ SAM|", "NO ONE’S|"]
+ );
+ let wait = summary(&store, "sam", &View::Lane(State::Wait));
+ assert_eq!(wait[0].2, vec![vec![11]]);
+
+ let me = store.user("sam").unwrap();
+ let watch = sections(&store, me, &View::Lane(State::Watch));
+ assert!(watch[0].compact);
+ assert_eq!(watch[0].groups[0].rows[0].id, 14);
+ assert!(sections(&store, me, &View::Lane(State::Done)).len() == 1);
+ }
+
+ #[test]
+ fn do_lane_orders_mine_unassigned_theirs() {
+ let mut store = fixtures::store();
+ store
+ .edit("alex", 3, crate::store::Change::State(State::Do))
+ .unwrap();
+ store
+ .edit(
+ "alex",
+ 3,
+ crate::store::Change::ToggleAssignee("alex".into()),
+ )
+ .unwrap();
+ assert_eq!(
+ heads(&store, "alex", &View::Lane(State::Do)),
+ ["→ ALEX|", "NO ONE’S|", "→ SAM|"]
+ );
+ // Inbox reads me / them / no one.
+ store
+ .edit(
+ "alex",
+ 4,
+ crate::store::Change::ToggleAssignee("sam".into()),
+ )
+ .unwrap();
+ assert_eq!(
+ heads(&store, "alex", &View::Lane(State::Inbox)),
+ ["→ ALEX|", "→ SAM|", "NO ONE’S|"]
+ );
+ }
+
+ #[test]
+ fn folders_group_by_every_state() {
+ let store = fixtures::store();
+ assert_eq!(
+ heads(&store, "sam", &View::Folder("House".into())),
+ ["INBOX|", "WAIT|", "WATCH|", "DONE|"]
+ );
+ assert_eq!(
+ heads(&store, "sam", &View::Folder("Medical".into())),
+ ["INBOX|", "DONE|"]
+ );
+ }
+
+ #[test]
+ fn search_covers_every_message() {
+ let store = fixtures::store();
+ let hits = summary(&store, "sam", &View::Search("downspout".into()));
+ assert_eq!(hits[0].2, vec![vec![2, 4]]);
+ let by_sender = summary(&store, "sam", &View::Search("ALEX".into()));
+ assert_eq!(by_sender[0].2, vec![vec![12]]);
+ let by_subject = summary(&store, "sam", &View::Search("checkup".into()));
+ assert_eq!(by_subject[0].2, vec![vec![15]]);
+ assert!(summary(&store, "sam", &View::Search(" ".into())).is_empty());
+ assert!(summary(&store, "sam", &View::Search("zzz".into())).is_empty());
+ }
+
+ #[test]
+ fn titles() {
+ assert_eq!(View::ForMe.title(), "For me");
+ assert_eq!(View::Lane(State::Do).title(), "Do");
+ assert_eq!(View::Folder("House".into()).title(), "House");
+ assert_eq!(View::Search("x".into()).title(), "Search");
+ }
+}
diff --git a/src/model.rs b/src/model.rs
new file mode 100644
index 0000000..b2f6a3c
--- /dev/null
+++ b/src/model.rs
@@ -0,0 +1,163 @@
+//! Docket's primitives. A received message carries its own state, folder,
+//! and assignees; a thread is only a grouping, and owns nothing but its
+//! comments.
+
+use std::collections::BTreeSet;
+use std::fmt;
+
+use jiff::civil::DateTime;
+
+pub type MessageId = u32;
+pub type ThreadId = u32;
+pub type CommentId = u32;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum State {
+ Inbox,
+ Do,
+ Wait,
+ Watch,
+ Done,
+}
+
+impl State {
+ pub const ALL: [State; 5] = [
+ State::Inbox,
+ State::Do,
+ State::Wait,
+ State::Watch,
+ State::Done,
+ ];
+
+ /// The states that have a lane. Done is reached through search only.
+ pub const LANES: [State; 4] = [State::Inbox, State::Do, State::Wait, State::Watch];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ State::Inbox => "Inbox",
+ State::Do => "Do",
+ State::Wait => "Wait",
+ State::Watch => "Watch",
+ State::Done => "Done",
+ }
+ }
+
+ pub fn slug(self) -> &'static str {
+ match self {
+ State::Inbox => "inbox",
+ State::Do => "do",
+ State::Wait => "wait",
+ State::Watch => "watch",
+ State::Done => "done",
+ }
+ }
+
+ pub fn from_slug(slug: &str) -> Option<State> {
+ State::ALL.into_iter().find(|s| s.slug() == slug)
+ }
+}
+
+impl fmt::Display for State {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.name())
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct User {
+ pub slug: String,
+ pub name: String,
+ /// The `Tailscale-User-Login` value that identifies this user.
+ pub login: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct Account {
+ pub slug: String,
+ pub name: String,
+ pub address: String,
+ /// Derived from the token's rights in the real app: no filing or replying.
+ pub read_only: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct Thread {
+ pub id: ThreadId,
+ pub account: String,
+ pub subject: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Values {
+ pub state: State,
+ pub folder: Option<String>,
+ /// User slugs.
+ pub assignees: BTreeSet<String>,
+}
+
+#[derive(Debug, Clone)]
+pub enum Kind {
+ Received {
+ from: String,
+ addr: String,
+ values: Values,
+ },
+ /// Sent by one of us. Sent messages carry no values.
+ Sent { by: String, to: Vec<String> },
+}
+
+#[derive(Debug, Clone)]
+pub struct Message {
+ pub id: MessageId,
+ pub thread: ThreadId,
+ pub at: DateTime,
+ pub cc: Vec<String>,
+ /// Only known for messages we sent.
+ pub bcc: Vec<String>,
+ pub body: String,
+ pub kind: Kind,
+}
+
+impl Message {
+ pub fn values(&self) -> Option<&Values> {
+ match &self.kind {
+ Kind::Received { values, .. } => Some(values),
+ Kind::Sent { .. } => None,
+ }
+ }
+
+ pub fn state(&self) -> Option<State> {
+ self.values().map(|v| v.state)
+ }
+
+ pub fn is_assigned_to(&self, user: &str) -> bool {
+ self.values().is_some_and(|v| v.assignees.contains(user))
+ }
+
+ pub fn is_unassigned(&self) -> bool {
+ self.values().is_some_and(|v| v.assignees.is_empty())
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Comment {
+ pub id: CommentId,
+ pub thread: ThreadId,
+ pub author: String,
+ pub at: DateTime,
+ pub text: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn state_slugs_round_trip() {
+ for state in State::ALL {
+ assert_eq!(State::from_slug(state.slug()), Some(state));
+ assert_eq!(state.to_string(), state.name());
+ }
+ assert_eq!(State::from_slug("read"), None);
+ }
+}
diff --git a/src/routes.rs b/src/routes.rs
index 933abae..af30bdc 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -1,13 +1,354 @@
-use axum::Router;
-use axum::routing::get;
+use std::sync::Arc;
+
+use axum::extract::{FromRequestParts, OriginalUri, Path, Query, State as Extract};
+use axum::http::header::{CONTENT_TYPE, COOKIE, HOST, ORIGIN, SET_COOKIE};
+use axum::http::request::Parts;
+use axum::http::{HeaderMap, Method};
+use axum::response::{IntoResponse, Redirect, Response};
+use axum::routing::{get, post};
+use axum::{Form, Router};
+use maud::Markup;
+use serde::Deserialize;
+use tokio::sync::RwLock;
use tower_http::trace::TraceLayer;
-pub fn router() -> Router {
- Router::new()
+use crate::Error;
+use crate::lists::View;
+use crate::model::{MessageId, State, ThreadId, User};
+use crate::store::{Change, Store};
+use crate::views::{self, Page};
+
+const IDENTITY_HEADER: &str = "Tailscale-User-Login";
+const DEV_COOKIE: &str = "docket_dev_user";
+
+#[derive(Clone)]
+pub struct AppState {
+ pub store: Arc<RwLock<Store>>,
+ /// Without Tailscale in front, fall back to a cookie-chosen user.
+ pub dev: bool,
+}
+
+impl AppState {
+ pub fn new(store: Store, dev: bool) -> Self {
+ Self {
+ store: Arc::new(RwLock::new(store)),
+ dev,
+ }
+ }
+}
+
+pub fn router(state: AppState) -> Router {
+ let mut router = Router::new()
.route("/health", get(health))
- .layer(TraceLayer::new_for_http())
+ .route("/", get(for_me))
+ .route("/{lane}", get(lane))
+ .route("/folders/{folder}", get(folder))
+ .route("/search", get(search))
+ .route("/messages/{id}/state", post(set_state))
+ .route("/messages/{id}/folder", post(set_folder))
+ .route("/messages/{id}/assignees", post(toggle_assignee))
+ .route("/threads/{id}/comments", post(add_comment))
+ .route("/undo", post(undo))
+ .route("/assets/gloss.css", get(gloss_css))
+ .route("/assets/docket.css", get(docket_css))
+ .route("/assets/alpine.js", get(alpine_js));
+ if state.dev {
+ router = router.route("/dev/user", post(dev_user));
+ }
+ router.layer(TraceLayer::new_for_http()).with_state(state)
}
async fn health() -> &'static str {
"ok"
}
+
+/// The requesting user, from Tailscale's identity header.
+struct Me(User);
+
+impl FromRequestParts<AppState> for Me {
+ type Rejection = Error;
+
+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
+ // Tailscale identifies every request from this machine, so a page on
+ // another site could post forms as us; browsers send Origin on those.
+ if parts.method == Method::POST && !same_origin(&parts.headers) {
+ return Err(Error::Forbidden("cross-site request"));
+ }
+ let store = state.store.read().await;
+ if let Some(login) = header(&parts.headers, IDENTITY_HEADER) {
+ return store
+ .user_by_login(login)
+ .cloned()
+ .map(Me)
+ .ok_or(Error::Unauthenticated);
+ }
+ if !state.dev {
+ return Err(Error::Unauthenticated);
+ }
+ let chosen = cookie(&parts.headers, DEV_COOKIE).and_then(|slug| store.user(slug));
+ chosen
+ .or_else(|| store.users.first())
+ .cloned()
+ .map(Me)
+ .ok_or(Error::Unauthenticated)
+ }
+}
+
+fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
+ headers.get(name).and_then(|v| v.to_str().ok())
+}
+
+fn same_origin(headers: &HeaderMap) -> bool {
+ let Some(origin) = header(headers, ORIGIN.as_str()) else {
+ return true;
+ };
+ let origin_host = origin.split_once("://").map(|(_, host)| host);
+ origin_host.is_some() && origin_host == header(headers, HOST.as_str())
+}
+
+fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
+ header(headers, COOKIE.as_str())?
+ .split(';')
+ .filter_map(|pair| pair.trim().split_once('='))
+ .find(|(k, _)| *k == name)
+ .map(|(_, v)| v)
+}
+
+#[derive(Deserialize)]
+struct Selection {
+ m: Option<MessageId>,
+ q: Option<String>,
+}
+
+async fn for_me(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ OriginalUri(uri): OriginalUri,
+ Query(sel): Query<Selection>,
+) -> Result<Markup, Error> {
+ render(&state, &me, &View::ForMe, sel.m, &uri.to_string()).await
+}
+
+async fn lane(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ OriginalUri(uri): OriginalUri,
+ Path(lane): Path<String>,
+ Query(sel): Query<Selection>,
+) -> Result<Markup, Error> {
+ let lane = State::from_slug(&lane)
+ .filter(|s| State::LANES.contains(s))
+ .ok_or(Error::NotFound("lane"))?;
+ render(&state, &me, &View::Lane(lane), sel.m, &uri.to_string()).await
+}
+
+async fn folder(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ OriginalUri(uri): OriginalUri,
+ Path(folder): Path<String>,
+ Query(sel): Query<Selection>,
+) -> Result<Markup, Error> {
+ if !state.store.read().await.folders.contains(&folder) {
+ return Err(Error::NotFound("folder"));
+ }
+ render(&state, &me, &View::Folder(folder), sel.m, &uri.to_string()).await
+}
+
+async fn search(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ OriginalUri(uri): OriginalUri,
+ Query(sel): Query<Selection>,
+) -> Result<Markup, Error> {
+ let view = View::Search(sel.q.unwrap_or_default());
+ render(&state, &me, &view, sel.m, &uri.to_string()).await
+}
+
+async fn render(
+ state: &AppState,
+ me: &User,
+ view: &View,
+ selected: Option<MessageId>,
+ here: &str,
+) -> Result<Markup, Error> {
+ let mut store = state.store.write().await;
+ if let Some(id) = selected {
+ let msg = store.message(id).ok_or(Error::NotFound("message"))?;
+ // Opening a thread reads the selected message and the latest one,
+ // the two that open expanded.
+ let latest = store.thread_messages(msg.thread).last().map(|m| m.id);
+ store.mark_read(&me.slug, id);
+ if let Some(latest) = latest {
+ store.mark_read(&me.slug, latest);
+ }
+ }
+ let flash = store.take_flash(&me.slug);
+ let store = &*store;
+ Ok(views::page(&Page {
+ store,
+ me,
+ view,
+ selected: selected.and_then(|id| store.message(id)),
+ flash,
+ here,
+ dev: state.dev,
+ }))
+}
+
+/// Only same-site paths, so a form can't bounce the browser elsewhere.
+fn safe_back(back: &str) -> &str {
+ if back.starts_with('/') && !back.starts_with("//") && !back.contains('\\') {
+ back
+ } else {
+ "/"
+ }
+}
+
+#[derive(Deserialize)]
+struct StateForm {
+ state: String,
+ back: String,
+}
+
+async fn set_state(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ Path(id): Path<MessageId>,
+ Form(form): Form<StateForm>,
+) -> Result<Redirect, Error> {
+ let to = State::from_slug(&form.state).ok_or(Error::BadRequest("unknown state"))?;
+ edit(&state, &me, id, Change::State(to), &form.back).await
+}
+
+#[derive(Deserialize)]
+struct FolderForm {
+ folder: String,
+ back: String,
+}
+
+async fn set_folder(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ Path(id): Path<MessageId>,
+ Form(form): Form<FolderForm>,
+) -> Result<Redirect, Error> {
+ let folder = Some(form.folder).filter(|f| !f.is_empty());
+ edit(&state, &me, id, Change::Folder(folder), &form.back).await
+}
+
+#[derive(Deserialize)]
+struct UserForm {
+ user: String,
+ back: String,
+}
+
+async fn toggle_assignee(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ Path(id): Path<MessageId>,
+ Form(form): Form<UserForm>,
+) -> Result<Redirect, Error> {
+ edit(
+ &state,
+ &me,
+ id,
+ Change::ToggleAssignee(form.user),
+ &form.back,
+ )
+ .await
+}
+
+async fn edit(
+ state: &AppState,
+ me: &User,
+ id: MessageId,
+ change: Change,
+ back: &str,
+) -> Result<Redirect, Error> {
+ state.store.write().await.edit(&me.slug, id, change)?;
+ Ok(Redirect::to(safe_back(back)))
+}
+
+#[derive(Deserialize)]
+struct CommentForm {
+ text: String,
+ back: String,
+}
+
+async fn add_comment(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ Path(thread): Path<ThreadId>,
+ Form(form): Form<CommentForm>,
+) -> Result<Redirect, Error> {
+ state
+ .store
+ .write()
+ .await
+ .add_comment(&me.slug, thread, &form.text)?;
+ Ok(Redirect::to(safe_back(&form.back)))
+}
+
+#[derive(Deserialize)]
+struct BackForm {
+ back: String,
+}
+
+async fn undo(
+ Extract(state): Extract<AppState>,
+ Me(me): Me,
+ Form(form): Form<BackForm>,
+) -> Result<Redirect, Error> {
+ state.store.write().await.undo(&me.slug)?;
+ Ok(Redirect::to(safe_back(&form.back)))
+}
+
+async fn dev_user(
+ Extract(state): Extract<AppState>,
+ headers: HeaderMap,
+ Form(form): Form<UserForm>,
+) -> Result<Response, Error> {
+ if !same_origin(&headers) {
+ return Err(Error::Forbidden("cross-site request"));
+ }
+ if state.store.read().await.user(&form.user).is_none() {
+ return Err(Error::NotFound("user"));
+ }
+ let cookie = format!("{DEV_COOKIE}={}; Path=/; SameSite=Lax", form.user);
+ Ok(([(SET_COOKIE, cookie)], Redirect::to(safe_back(&form.back))).into_response())
+}
+
+async fn gloss_css() -> impl IntoResponse {
+ (
+ [(CONTENT_TYPE, "text/css; charset=utf-8")],
+ include_str!("../assets/gloss.css"),
+ )
+}
+
+async fn docket_css() -> impl IntoResponse {
+ (
+ [(CONTENT_TYPE, "text/css; charset=utf-8")],
+ include_str!("../assets/docket.css"),
+ )
+}
+
+async fn alpine_js() -> impl IntoResponse {
+ (
+ [(CONTENT_TYPE, "text/javascript; charset=utf-8")],
+ include_str!("../assets/vendor/alpine-3.17.4.min.js"),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn back_stays_on_site() {
+ assert_eq!(safe_back("/do?m=4"), "/do?m=4");
+ assert_eq!(safe_back("//evil.example"), "/");
+ assert_eq!(safe_back("https://evil.example"), "/");
+ assert_eq!(safe_back("/\\evil.example"), "/");
+ }
+}
diff --git a/src/store.rs b/src/store.rs
new file mode 100644
index 0000000..f1b6a6d
--- /dev/null
+++ b/src/store.rs
@@ -0,0 +1,361 @@
+//! An in-memory store seeded from fixtures. It stands in for the database
+//! and JMAP sync until those exist; edits last until the process exits.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use jiff::civil::DateTime;
+
+use crate::Error;
+use crate::model::{
+ Account, Comment, Kind, Message, MessageId, State, Thread, ThreadId, User, Values,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Change {
+ State(State),
+ Folder(Option<String>),
+ ToggleAssignee(String),
+}
+
+/// A one-shot notice shown to a user after an edit.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Flash {
+ pub text: String,
+ pub undoable: bool,
+}
+
+/// An entry in a thread's chain, in time order.
+#[derive(Debug, Clone, Copy)]
+pub enum Item<'a> {
+ Message(&'a Message),
+ Comment(&'a Comment),
+}
+
+#[derive(Debug, Default)]
+pub struct Store {
+ /// The clock fixtures are written against, so ages stay stable.
+ pub now: DateTime,
+ pub users: Vec<User>,
+ pub accounts: Vec<Account>,
+ pub folders: Vec<String>,
+ pub threads: Vec<Thread>,
+ pub messages: Vec<Message>,
+ pub comments: Vec<Comment>,
+ /// (user slug, message) pairs that user has read.
+ pub read: BTreeSet<(String, MessageId)>,
+ undo: BTreeMap<String, (MessageId, Values)>,
+ flash: BTreeMap<String, Flash>,
+}
+
+impl Store {
+ pub fn user(&self, slug: &str) -> Option<&User> {
+ self.users.iter().find(|u| u.slug == slug)
+ }
+
+ pub fn user_by_login(&self, login: &str) -> Option<&User> {
+ self.users.iter().find(|u| u.login == login)
+ }
+
+ pub fn user_name<'a>(&'a self, slug: &'a str) -> &'a str {
+ self.user(slug).map_or(slug, |u| u.name.as_str())
+ }
+
+ pub fn account(&self, slug: &str) -> Option<&Account> {
+ self.accounts.iter().find(|a| a.slug == slug)
+ }
+
+ pub fn thread(&self, id: ThreadId) -> Option<&Thread> {
+ self.threads.iter().find(|t| t.id == id)
+ }
+
+ pub fn message(&self, id: MessageId) -> Option<&Message> {
+ self.messages.iter().find(|m| m.id == id)
+ }
+
+ pub fn thread_account(&self, thread: ThreadId) -> Option<&Account> {
+ self.thread(thread).and_then(|t| self.account(&t.account))
+ }
+
+ pub fn thread_messages(&self, thread: ThreadId) -> Vec<&Message> {
+ let mut msgs: Vec<_> = self
+ .messages
+ .iter()
+ .filter(|m| m.thread == thread)
+ .collect();
+ msgs.sort_by_key(|m| (m.at, m.id));
+ msgs
+ }
+
+ pub fn timeline(&self, thread: ThreadId) -> Vec<Item<'_>> {
+ let mut items: Vec<_> = self
+ .thread_messages(thread)
+ .into_iter()
+ .map(Item::Message)
+ .chain(
+ self.comments
+ .iter()
+ .filter(|c| c.thread == thread)
+ .map(Item::Comment),
+ )
+ .collect();
+ // Stable: a message sorts before a comment made at the same moment.
+ items.sort_by_key(|item| match item {
+ Item::Message(m) => m.at,
+ Item::Comment(c) => c.at,
+ });
+ items
+ }
+
+ pub fn is_unread(&self, user: &str, msg: &Message) -> bool {
+ matches!(msg.kind, Kind::Received { .. }) && !self.read.contains(&(user.to_owned(), msg.id))
+ }
+
+ pub fn mark_read(&mut self, user: &str, msg: MessageId) {
+ self.read.insert((user.to_owned(), msg));
+ }
+
+ pub fn edit(&mut self, user: &str, id: MessageId, change: Change) -> Result<(), Error> {
+ let msg = self.message(id).ok_or(Error::NotFound("message"))?;
+ let read_only = self.thread_account(msg.thread).is_some_and(|a| a.read_only);
+ let Kind::Received { values, .. } = &msg.kind else {
+ return Err(Error::BadRequest("sent messages have no values"));
+ };
+ let prev = values.clone();
+ let mut next = prev.clone();
+
+ let text = match change {
+ Change::State(state) => {
+ next.state = state;
+ format!("Moved to {state}")
+ }
+ Change::Folder(folder) => {
+ if read_only {
+ return Err(Error::Forbidden("this account is read-only"));
+ }
+ match folder {
+ Some(f) if !self.folders.contains(&f) => {
+ return Err(Error::NotFound("folder"));
+ }
+ Some(f) => {
+ let text = format!("Filed to {f}");
+ next.folder = Some(f);
+ text
+ }
+ None => {
+ next.folder = None;
+ "Removed from folder".to_owned()
+ }
+ }
+ }
+ Change::ToggleAssignee(slug) => {
+ let name = self
+ .user(&slug)
+ .ok_or(Error::NotFound("user"))?
+ .name
+ .clone();
+ if next.assignees.remove(&slug) {
+ format!("Unassigned {name}")
+ } else {
+ next.assignees.insert(slug);
+ format!("Assigned {name}")
+ }
+ }
+ };
+
+ self.set_values(id, next);
+ self.undo.insert(user.to_owned(), (id, prev));
+ self.flash.insert(
+ user.to_owned(),
+ Flash {
+ text,
+ undoable: true,
+ },
+ );
+ Ok(())
+ }
+
+ pub fn undo(&mut self, user: &str) -> Result<(), Error> {
+ let (id, prev) = self
+ .undo
+ .remove(user)
+ .ok_or(Error::BadRequest("nothing to undo"))?;
+ self.set_values(id, prev);
+ self.flash.insert(
+ user.to_owned(),
+ Flash {
+ text: "Undone".to_owned(),
+ undoable: false,
+ },
+ );
+ Ok(())
+ }
+
+ pub fn take_flash(&mut self, user: &str) -> Option<Flash> {
+ self.flash.remove(user)
+ }
+
+ pub fn add_comment(&mut self, user: &str, thread: ThreadId, text: &str) -> Result<(), Error> {
+ self.thread(thread).ok_or(Error::NotFound("thread"))?;
+ let text = text.trim();
+ if text.is_empty() {
+ return Err(Error::BadRequest("comment is empty"));
+ }
+ let id = self
+ .comments
+ .iter()
+ .map(|c| c.id)
+ .max()
+ .unwrap_or(0)
+ .saturating_add(1);
+ self.comments.push(Comment {
+ id,
+ thread,
+ author: user.to_owned(),
+ at: self.now,
+ text: text.to_owned(),
+ });
+ Ok(())
+ }
+
+ fn set_values(&mut self, id: MessageId, next: Values) {
+ let slot = self.messages.iter_mut().find(|m| m.id == id);
+ if let Some(Message {
+ kind: Kind::Received { values, .. },
+ ..
+ }) = slot
+ {
+ *values = next;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fixtures;
+
+ fn values(store: &Store, id: MessageId) -> Values {
+ store.message(id).unwrap().values().unwrap().clone()
+ }
+
+ #[test]
+ fn edits_record_undo_and_flash() {
+ let mut store = fixtures::store();
+ store.edit("sam", 4, Change::State(State::Do)).unwrap();
+ assert_eq!(values(&store, 4).state, State::Do);
+ assert_eq!(
+ store.take_flash("sam"),
+ Some(Flash {
+ text: "Moved to Do".into(),
+ undoable: true
+ })
+ );
+ assert_eq!(store.take_flash("sam"), None);
+
+ store.undo("sam").unwrap();
+ assert_eq!(values(&store, 4).state, State::Inbox);
+ assert_eq!(store.take_flash("sam").unwrap().text, "Undone");
+ assert!(matches!(store.undo("sam"), Err(Error::BadRequest(_))));
+ }
+
+ #[test]
+ fn folders() {
+ let mut store = fixtures::store();
+ store
+ .edit("sam", 4, Change::Folder(Some("Finance".into())))
+ .unwrap();
+ assert_eq!(values(&store, 4).folder.as_deref(), Some("Finance"));
+ store.edit("sam", 4, Change::Folder(None)).unwrap();
+ assert_eq!(values(&store, 4).folder, None);
+ assert_eq!(store.take_flash("sam").unwrap().text, "Removed from folder");
+ assert!(matches!(
+ store.edit("sam", 4, Change::Folder(Some("Nope".into()))),
+ Err(Error::NotFound("folder"))
+ ));
+ }
+
+ #[test]
+ fn read_only_accounts_cannot_file() {
+ let mut store = fixtures::store();
+ let eli = fixtures::ELI_PRACTICE;
+ assert!(matches!(
+ store.edit("sam", eli, Change::Folder(None)),
+ Err(Error::Forbidden(_))
+ ));
+ store.edit("sam", eli, Change::State(State::Done)).unwrap();
+ }
+
+ #[test]
+ fn assignees_toggle() {
+ let mut store = fixtures::store();
+ store
+ .edit("alex", 4, Change::ToggleAssignee("alex".into()))
+ .unwrap();
+ assert!(values(&store, 4).assignees.contains("alex"));
+ assert_eq!(store.take_flash("alex").unwrap().text, "Assigned Alex");
+ store
+ .edit("alex", 4, Change::ToggleAssignee("alex".into()))
+ .unwrap();
+ assert!(values(&store, 4).assignees.is_empty());
+ assert_eq!(store.take_flash("alex").unwrap().text, "Unassigned Alex");
+ assert!(matches!(
+ store.edit("alex", 4, Change::ToggleAssignee("eli".into())),
+ Err(Error::NotFound("user"))
+ ));
+ }
+
+ #[test]
+ fn bad_edits() {
+ let mut store = fixtures::store();
+ assert!(matches!(
+ store.edit("sam", 999, Change::State(State::Do)),
+ Err(Error::NotFound("message"))
+ ));
+ assert!(matches!(
+ store.edit("sam", 2, Change::State(State::Do)),
+ Err(Error::BadRequest(_))
+ ));
+ }
+
+ #[test]
+ fn comments() {
+ let mut store = fixtures::store();
+ let before = store.timeline(1).len();
+ store.add_comment("sam", 1, " Called them. ").unwrap();
+ let timeline = store.timeline(1);
+ assert_eq!(timeline.len(), before + 1);
+ assert!(matches!(timeline.last(), Some(Item::Comment(c)) if c.text == "Called them."));
+ assert!(matches!(
+ store.add_comment("sam", 1, " "),
+ Err(Error::BadRequest(_))
+ ));
+ assert!(matches!(
+ store.add_comment("sam", 999, "hi"),
+ Err(Error::NotFound("thread"))
+ ));
+ }
+
+ #[test]
+ fn read_tracking() {
+ let mut store = fixtures::store();
+ let msg = store.message(fixtures::WATER).unwrap().clone();
+ assert!(store.is_unread("alex", &msg));
+ store.mark_read("alex", msg.id);
+ assert!(!store.is_unread("alex", &msg));
+ assert!(store.is_unread("sam", &msg));
+ let sent = store.message(2).unwrap().clone();
+ assert!(!store.is_unread("sam", &sent));
+ }
+
+ #[test]
+ fn lookups() {
+ let store = fixtures::store();
+ assert_eq!(store.user_name("sam"), "Sam");
+ assert_eq!(store.user_name("pat"), "pat");
+ assert_eq!(
+ store.user_by_login("alex@example.com").unwrap().slug,
+ "alex"
+ );
+ assert!(store.user_by_login("nobody").is_none());
+ }
+}
diff --git a/src/views.rs b/src/views.rs
new file mode 100644
index 0000000..b8fcc87
--- /dev/null
+++ b/src/views.rs
@@ -0,0 +1,456 @@
+//! HTML for the three-pane mail layout: mailboxes and folders, the message
+//! list, and the thread. Alpine handles the menus and collapsing; every
+//! change is a plain form post.
+
+use maud::{DOCTYPE, Markup, PreEscaped, html};
+
+use crate::dates;
+use crate::lists::{self, Context, Section, View};
+use crate::model::{Comment, Kind, Message, State, User, Values};
+use crate::store::{Flash, Item, Store};
+
+pub struct Page<'a> {
+ pub store: &'a Store,
+ pub me: &'a User,
+ pub view: &'a View,
+ pub selected: Option<&'a Message>,
+ pub flash: Option<Flash>,
+ /// This page's URL, for forms to return to.
+ pub here: &'a str,
+ /// Lets the viewer switch users without Tailscale, for fixtures.
+ pub dev: bool,
+}
+
+pub fn view_path(view: &View) -> String {
+ match view {
+ View::ForMe => "/".to_owned(),
+ View::Lane(state) => format!("/{}", state.slug()),
+ View::Folder(folder) => format!("/folders/{}", encode(folder)),
+ View::Search(query) => format!("/search?q={}", encode(query)),
+ }
+}
+
+fn select_path(view: &View, id: u32) -> String {
+ let path = view_path(view);
+ let sep = if path.contains('?') { '&' } else { '?' };
+ format!("{path}{sep}m={id}")
+}
+
+/// Percent-encodes everything but unreserved characters.
+pub fn encode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for b in s.bytes() {
+ if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
+ out.push(char::from(b));
+ } else {
+ out.push_str(&format!("%{b:02X}"));
+ }
+ }
+ out
+}
+
+fn state_mark(state: State) -> &'static str {
+ match state {
+ State::Inbox => "var(--gl-color-text-tertiary)",
+ State::Do => "var(--gl-color-text-primary)",
+ State::Wait => "var(--gl-color-text-secondary)",
+ State::Watch => "var(--gl-color-accent)",
+ State::Done => "var(--gl-color-success)",
+ }
+}
+
+fn assignee_label(store: &Store, values: &Values) -> Option<String> {
+ (!values.assignees.is_empty()).then(|| {
+ let names: Vec<_> = values
+ .assignees
+ .iter()
+ .map(|a| store.user_name(a).to_uppercase())
+ .collect();
+ format!("→ {}", names.join(", "))
+ })
+}
+
+pub fn page(p: &Page<'_>) -> Markup {
+ html! {
+ (DOCTYPE)
+ html lang="en" {
+ head {
+ meta charset="utf-8";
+ meta name="viewport" content="width=device-width, initial-scale=1";
+ title { (p.view.title()) " · Docket" }
+ link rel="stylesheet" href="/assets/gloss.css";
+ link rel="stylesheet" href="/assets/docket.css";
+ script defer src="/assets/alpine.js" {}
+ }
+ body {
+ div.app {
+ (sidebar(p))
+ (list(p))
+ (reader(p))
+ }
+ @if let Some(flash) = &p.flash {
+ (toast(flash, p.here))
+ }
+ }
+ }
+ }
+}
+
+fn back(here: &str) -> Markup {
+ html! { input type="hidden" name="back" value=(here); }
+}
+
+fn sidebar(p: &Page<'_>) -> Markup {
+ let query = match p.view {
+ View::Search(q) => q.as_str(),
+ _ => "",
+ };
+ let views = std::iter::once(View::ForMe).chain(State::LANES.map(View::Lane));
+ html! {
+ nav.side {
+ form.search action="/search" method="get" {
+ input type="search" name="q" placeholder="Search mail" value=(query) aria-label="Search mail";
+ }
+ @for view in views {
+ a.nav-item.on[&view == p.view] href=(view_path(&view)) {
+ span { (view.title()) }
+ span.count { (lists::count(p.store, p.me, &view)) }
+ }
+ }
+ div.side-head { "FOLDERS" }
+ @for folder in &p.store.folders {
+ @let view = View::Folder(folder.clone());
+ a.nav-item.on[&view == p.view] href=(view_path(&view)) { span { (folder) } }
+ }
+ div.grow {}
+ (whoami(p))
+ }
+ }
+}
+
+fn whoami(p: &Page<'_>) -> Markup {
+ let who = html! {
+ span.who-name { (p.me.name) }
+ span.who-login { (p.me.login) }
+ };
+ html! {
+ @if p.dev {
+ div.who.ctrl x-data="{ open: false }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
+ button.who-btn type="button" "@click"="open = !open" { (who) }
+ form.menu.menu-up method="post" action="/dev/user" x-show="open" x-cloak {
+ (back(p.here))
+ @for user in &p.store.users {
+ button.opt.cur[user.slug == p.me.slug] type="submit" name="user" value=(user.slug) {
+ span.box.on[user.slug == p.me.slug] {}
+ (user.name)
+ }
+ }
+ }
+ }
+ } @else {
+ div.who { (who) }
+ }
+ }
+}
+
+fn list(p: &Page<'_>) -> Markup {
+ let sections = lists::sections(p.store, p.me, p.view);
+ let total: usize = sections
+ .iter()
+ .flat_map(|s| &s.groups)
+ .map(|g| g.rows.len())
+ .sum();
+ html! {
+ section.list aria-label="Messages" {
+ header.list-head {
+ h1 { (p.view.title()) }
+ span.mono { (total) }
+ }
+ @if sections.is_empty() {
+ p.empty { "Nothing here." }
+ }
+ @for section in §ions {
+ (list_section(p, section))
+ }
+ }
+ }
+}
+
+fn list_section(p: &Page<'_>, section: &Section<'_>) -> Markup {
+ html! {
+ @if !section.head.is_empty() {
+ div.section-head {
+ span { (section.head) }
+ span.sub { (section.sub) }
+ }
+ }
+ @for group in §ion.groups {
+ div.group {
+ div.group-head {
+ span.subj { (group.thread.subject) }
+ @if let Some(more) = group.more() {
+ span.more { (more) }
+ }
+ }
+ @for m in &group.rows {
+ (row(p, m, §ion.context, section.compact))
+ }
+ }
+ }
+ }
+}
+
+fn sender(store: &Store, m: &Message) -> String {
+ match &m.kind {
+ Kind::Received { from, .. } => from.clone(),
+ Kind::Sent { by, to } => format!("{} → {}", store.user_name(by), to.join(", ")),
+ }
+}
+
+fn row_meta(store: &Store, m: &Message, context: &Context) -> String {
+ let Some(values) = m.values() else {
+ return String::new();
+ };
+ let mut parts = Vec::new();
+ if context.show_state {
+ parts.push(values.state.name().to_uppercase());
+ }
+ if context.show_folder
+ && let Some(folder) = &values.folder
+ {
+ parts.push(folder.to_uppercase());
+ }
+ if context.implied.as_ref() != Some(&values.assignees)
+ && let Some(label) = assignee_label(store, values)
+ {
+ parts.push(label);
+ }
+ parts.join(" · ")
+}
+
+fn row(p: &Page<'_>, m: &Message, context: &Context, compact: bool) -> Markup {
+ let selected = p.selected.is_some_and(|s| s.id == m.id);
+ let unread = p.store.is_unread(&p.me.slug, m);
+ let meta = row_meta(p.store, m, context);
+ html! {
+ a.row.sel[selected].unread[unread] href=(select_path(p.view, m.id)) {
+ span.dot {}
+ span.from { (sender(p.store, m)) }
+ span.age { (dates::short(p.store.now, m.at)) }
+ @if !compact {
+ span.snip { (m.body) }
+ }
+ @if !meta.is_empty() {
+ span.meta { (meta) }
+ }
+ }
+ }
+}
+
+fn reader(p: &Page<'_>) -> Markup {
+ html! {
+ main.reader {
+ @if let Some(selected) = p.selected {
+ (thread(p, selected))
+ } @else {
+ p.empty { "No thread open." }
+ }
+ }
+ }
+}
+
+fn thread(p: &Page<'_>, selected: &Message) -> Markup {
+ let store = p.store;
+ let subject = store
+ .thread(selected.thread)
+ .map_or("", |t| t.subject.as_str());
+ let account = store.thread_account(selected.thread);
+ let read_only = account.is_some_and(|a| a.read_only);
+ let msgs = store.thread_messages(selected.thread);
+ let latest = msgs.last().map(|m| m.id);
+ let count = match msgs.len() {
+ 1 => "1 MESSAGE".to_owned(),
+ n => format!("{n} MESSAGES"),
+ };
+ let mut meta = vec![account.map_or(String::new(), |a| a.name.to_uppercase())];
+ if read_only {
+ meta.push("READ-ONLY".to_owned());
+ }
+ meta.push(count);
+ html! {
+ article.thread {
+ header.thread-head {
+ h2 { (subject) }
+ span.mono { (meta.join(" · ")) }
+ }
+ @for item in store.timeline(selected.thread) {
+ @match item {
+ Item::Comment(c) => (comment(store, c)),
+ Item::Message(m) => {
+ @let open = m.id == selected.id || Some(m.id) == latest;
+ (message(p, m, open, m.id == selected.id, read_only))
+ }
+ }
+ }
+ form.comment-box method="post" action=(format!("/threads/{}/comments", selected.thread))
+ x-data="{ text: '' }" {
+ (back(p.here))
+ textarea name="text" rows="1" placeholder="Add an internal comment to this thread"
+ aria-label="Internal comment" x-model="text"
+ "@keydown.enter"="if (!$event.shiftKey) { $event.preventDefault(); if (text.trim()) $el.form.requestSubmit() }" {}
+ button.btn type="submit" x-show="text.trim()" x-cloak { "COMMENT" }
+ }
+ }
+ }
+}
+
+fn comment(store: &Store, c: &Comment) -> Markup {
+ html! {
+ div.item.comment {
+ div.body {
+ b { (store.user_name(&c.author)) }
+ span.text { (c.text) }
+ }
+ div.gutter { span.date { (dates::short(store.now, c.at)) } }
+ }
+ }
+}
+
+fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, read_only: bool) -> Markup {
+ let store = p.store;
+ let (who, addr) = match &m.kind {
+ Kind::Received { from, addr, .. } => (from.clone(), format!("<{addr}>")),
+ Kind::Sent { by, to } => (
+ store.user_name(by).to_owned(),
+ format!("→ {}", to.join(", ")),
+ ),
+ };
+ let mut copies = Vec::new();
+ if !m.cc.is_empty() {
+ copies.push(format!("cc {}", m.cc.join(", ")));
+ }
+ if !m.bcc.is_empty() {
+ copies.push(format!("bcc {}", m.bcc.join(", ")));
+ }
+ let state = if open {
+ "{ open: true }"
+ } else {
+ "{ open: false }"
+ };
+ html! {
+ div.item.sel[selected] id=(format!("m{}", m.id)) x-data=(state) {
+ div.body {
+ div.from "@click"="open = !open" {
+ b { (who) } " " span.addr { (addr) }
+ }
+ @if !copies.is_empty() {
+ span.copies { (copies.join(" · ")) }
+ }
+ span.text x-show="open" x-cloak[!open] { (m.body) }
+ span.text.closed x-show="!open" x-cloak[open] "@click"="open = true" { (m.body) }
+ }
+ div.gutter {
+ span.date { (dates::short(store.now, m.at)) }
+ @if let Some(values) = m.values() {
+ (values_controls(p, m.id, values, read_only))
+ }
+ }
+ }
+ }
+}
+
+fn menu(label: Markup, action: String, here: &str, options: Markup) -> Markup {
+ html! {
+ div.ctrl x-data="{ open: false }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
+ button.ctrl-btn type="button" "@click"="open = !open" ":class"="open && 'on'" { (label) }
+ form.menu method="post" action=(action) x-show="open" x-cloak {
+ (back(here))
+ (options)
+ }
+ }
+ }
+}
+
+fn values_controls(p: &Page<'_>, id: u32, values: &Values, read_only: bool) -> Markup {
+ let state_label = html! {
+ span.sq style=(format!("background:{}", state_mark(values.state))) {}
+ (values.state.name().to_uppercase())
+ };
+ let states = html! {
+ @for state in State::ALL {
+ button.opt.cur[state == values.state] type="submit" name="state" value=(state.slug()) {
+ span.box style=(format!("background:{}", state_mark(state))) {}
+ (state.name())
+ }
+ }
+ };
+ let folder_label = html! {
+ span.sq {}
+ (values.folder.as_deref().map_or("—".to_owned(), str::to_uppercase))
+ };
+ let folders = html! {
+ button.opt.cur[values.folder.is_none()] type="submit" name="folder" value="" {
+ span.box.on[values.folder.is_none()] {} "No folder"
+ }
+ @for folder in &p.store.folders {
+ @let on = values.folder.as_ref() == Some(folder);
+ button.opt.cur[on] type="submit" name="folder" value=(folder) {
+ span.box.on[on] {} (folder)
+ }
+ }
+ };
+ let assigned_label = html! {
+ span.sq {}
+ (assignee_label(p.store, values).unwrap_or_else(|| "ASSIGN".to_owned()))
+ };
+ let people = html! {
+ @for user in &p.store.users {
+ @let on = values.assignees.contains(&user.slug);
+ button.opt.cur[on] type="submit" name="user" value=(user.slug) {
+ span.box.on[on] {} (user.name)
+ }
+ }
+ };
+ html! {
+ (menu(state_label, format!("/messages/{id}/state"), p.here, states))
+ @if read_only {
+ span.ctrl-static { (folder_label) }
+ } @else {
+ (menu(folder_label, format!("/messages/{id}/folder"), p.here, folders))
+ }
+ (menu(assigned_label, format!("/messages/{id}/assignees"), p.here, people))
+ }
+}
+
+fn toast(flash: &Flash, here: &str) -> Markup {
+ html! {
+ div.toast role="status" x-data="{ show: true }" x-init="setTimeout(() => show = false, 8000)" x-show="show" {
+ span.grow { (flash.text) }
+ @if flash.undoable {
+ form method="post" action="/undo" {
+ (back(here))
+ button.toast-act type="submit" { "UNDO" }
+ }
+ }
+ button.toast-x type="button" aria-label="Dismiss" "@click"="show = false" { (PreEscaped("×")) }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn paths() {
+ assert_eq!(view_path(&View::ForMe), "/");
+ assert_eq!(view_path(&View::Lane(State::Wait)), "/wait");
+ assert_eq!(
+ view_path(&View::Folder("Kid stuff".into())),
+ "/folders/Kid%20stuff"
+ );
+ assert_eq!(view_path(&View::Search("a&b".into())), "/search?q=a%26b");
+ assert_eq!(select_path(&View::ForMe, 4), "/?m=4");
+ assert_eq!(select_path(&View::Search("x".into()), 4), "/search?q=x&m=4");
+ }
+}
diff --git a/tests/api.rs b/tests/api.rs
index c79ff7a..a5628b1 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -1,10 +1,14 @@
use std::net::SocketAddr;
-use reqwest::StatusCode;
+use docket::routes::{AppState, router};
+use reqwest::{Client, StatusCode, redirect};
use tokio::net::TcpListener;
-async fn spawn() -> SocketAddr {
- let app = docket::routes::router();
+const SAM: &str = "sam@example.com";
+const ALEX: &str = "alex@example.com";
+
+async fn spawn_with(dev: bool) -> SocketAddr {
+ let app = router(AppState::new(docket::fixtures::store(), dev));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
@@ -13,6 +17,46 @@ async fn spawn() -> SocketAddr {
addr
}
+async fn spawn() -> SocketAddr {
+ spawn_with(false).await
+}
+
+fn client() -> Client {
+ Client::builder()
+ .redirect(redirect::Policy::none())
+ .build()
+ .unwrap()
+}
+
+async fn get(addr: SocketAddr, login: &str, path: &str) -> (StatusCode, String) {
+ let res = client()
+ .get(format!("http://{addr}{path}"))
+ .header("Tailscale-User-Login", login)
+ .send()
+ .await
+ .unwrap();
+ (res.status(), res.text().await.unwrap())
+}
+
+async fn post(
+ addr: SocketAddr,
+ login: &str,
+ path: &str,
+ form: &[(&str, &str)],
+) -> reqwest::Response {
+ client()
+ .post(format!("http://{addr}{path}"))
+ .header("Tailscale-User-Login", login)
+ .form(form)
+ .send()
+ .await
+ .unwrap()
+}
+
+fn location(res: &reqwest::Response) -> &str {
+ res.headers()["location"].to_str().unwrap()
+}
+
#[tokio::test]
async fn health_returns_ok() {
let addr = spawn().await;
@@ -20,3 +64,322 @@ async fn health_returns_ok() {
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.text().await.unwrap(), "ok");
}
+
+#[tokio::test]
+async fn requires_a_known_identity() {
+ let addr = spawn().await;
+ let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+ let (status, _) = get(addr, "eve@example.com", "/").await;
+ assert_eq!(status, StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn for_me_lists_messages_grouped_by_state_and_thread() {
+ let addr = spawn().await;
+ let (status, body) = get(addr, SAM, "/").await;
+ assert_eq!(status, StatusCode::OK);
+ assert!(body.contains("<title>For me · Docket</title>"));
+ assert!(body.contains("NO ONE’S"));
+ assert!(body.contains("Gutter repair estimate"));
+ assert!(body.contains("2 OF 4"));
+ assert!(body.contains("No thread open."));
+ // Sam's Do group carries the folder, not the assignee the section names.
+ assert!(body.contains(r#"<span class="meta">SCHOOL</span>"#));
+}
+
+#[tokio::test]
+async fn lanes_folders_and_search() {
+ let addr = spawn().await;
+ for (path, needle) in [
+ ("/inbox", "→ ALEX"),
+ ("/do", "Exemption renewal"),
+ ("/wait", "Claim 4471"),
+ ("/watch", "Shipped: furnace filters"),
+ ("/folders/Medical", "Your annual checkup"),
+ ("/search?q=downspout", "Sam → Northwind Roofing"),
+ ] {
+ let (status, body) = get(addr, SAM, path).await;
+ assert_eq!(status, StatusCode::OK, "{path}");
+ assert!(body.contains(needle), "{path} should mention {needle}");
+ }
+ let (_, body) = get(addr, SAM, "/search?q=zzz").await;
+ assert!(body.contains("Nothing here."));
+ let (_, body) = get(addr, SAM, "/search").await;
+ assert!(body.contains("Nothing here."));
+ // A search result row links back into the search.
+ let (_, body) = get(addr, SAM, "/search?q=checkup").await;
+ assert!(body.contains("/search?q=checkup&m=15"));
+ assert!(body.contains("DONE · MEDICAL"));
+
+ for path in ["/done", "/nope", "/folders/Nope", "/?m=999"] {
+ let (status, _) = get(addr, SAM, path).await;
+ assert_eq!(status, StatusCode::NOT_FOUND, "{path}");
+ }
+}
+
+#[tokio::test]
+async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
+ let addr = spawn().await;
+ let (status, body) = get(addr, SAM, "/?m=4").await;
+ assert_eq!(status, StatusCode::OK);
+ assert!(body.contains("HOUSEHOLD · 4 MESSAGES"));
+ assert!(body.contains("cc Alex · bcc Pat Lee"));
+ assert!(body.contains("cc Sam, Alex"));
+ assert!(body.contains("→ Northwind Roofing"));
+ assert!(body.contains("The Hendersons paid about $1,600"));
+ assert!(body.contains(r#"action="/messages/4/state""#));
+ assert!(body.contains(r#"action="/messages/4/folder""#));
+ assert!(body.contains(r#"action="/messages/4/assignees""#));
+ assert!(body.contains("ASSIGN"));
+ assert!(body.contains(r#"value="/?m=4""#));
+
+ let (_, body) = get(addr, SAM, "/inbox?m=8").await;
+ assert!(body.contains("HOUSEHOLD · 1 MESSAGE"));
+}
+
+#[tokio::test]
+async fn read_only_accounts_show_folder_as_plain_text() {
+ let addr = spawn().await;
+ let (_, body) = get(addr, SAM, "/watch?m=20").await;
+ assert!(body.contains("ELI · READ-ONLY · 1 MESSAGE"));
+ assert!(!body.contains(r#"action="/messages/20/folder""#));
+ assert!(body.contains(r#"action="/messages/20/state""#));
+ let res = post(
+ addr,
+ SAM,
+ "/messages/20/folder",
+ &[("folder", "School"), ("back", "/")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::FORBIDDEN);
+}
+
+#[tokio::test]
+async fn opening_a_message_marks_it_read() {
+ let addr = spawn().await;
+ let (_, body) = get(addr, ALEX, "/").await;
+ assert!(body.contains(r#"class="row unread" href="/?m=7""#));
+ get(addr, ALEX, "/?m=7").await;
+ let (_, body) = get(addr, ALEX, "/").await;
+ assert!(body.contains(r#"class="row" href="/?m=7""#));
+ // Read tracking is per person.
+ let (_, body) = get(addr, SAM, "/").await;
+ assert!(body.contains(r#"class="row unread" href="/?m=7""#));
+}
+
+#[tokio::test]
+async fn editing_values_with_undo() {
+ let addr = spawn().await;
+ let res = post(
+ addr,
+ SAM,
+ "/messages/4/state",
+ &[("state", "do"), ("back", "/?m=4")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ assert_eq!(location(&res), "/?m=4");
+
+ let (_, body) = get(addr, SAM, "/?m=4").await;
+ assert!(body.contains("Moved to Do"));
+ assert!(body.contains(r#"action="/undo""#));
+ // The toast shows once.
+ let (_, body) = get(addr, SAM, "/?m=4").await;
+ assert!(!body.contains("Moved to Do"));
+
+ let res = post(addr, SAM, "/undo", &[("back", "/?m=4")]).await;
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ let (_, body) = get(addr, SAM, "/?m=4").await;
+ assert!(body.contains("Undone"));
+ assert!(!body.contains(r#"action="/undo""#));
+ let res = post(addr, SAM, "/undo", &[("back", "/")]).await;
+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
+
+ let res = post(
+ addr,
+ SAM,
+ "/messages/4/folder",
+ &[("folder", "Finance"), ("back", "/")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ let res = post(
+ addr,
+ SAM,
+ "/messages/4/folder",
+ &[("folder", ""), ("back", "/")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ let (_, body) = get(addr, SAM, "/?m=4").await;
+ assert!(body.contains("Removed from folder"));
+
+ let res = post(
+ addr,
+ SAM,
+ "/messages/4/assignees",
+ &[("user", "alex"), ("back", "/")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ // Now it's Alex's; Sam's For me no longer lists it, Alex's does.
+ let (_, body) = get(addr, ALEX, "/").await;
+ assert!(body.contains(r#"href="/?m=4""#));
+ let (_, body) = get(addr, SAM, "/").await;
+ assert!(!body.contains(r#"href="/?m=4""#));
+
+ let res = post(
+ addr,
+ SAM,
+ "/messages/4/state",
+ &[("state", "read"), ("back", "/")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
+ let res = post(
+ addr,
+ SAM,
+ "/messages/2/state",
+ &[("state", "do"), ("back", "/")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
+ let res = post(
+ addr,
+ SAM,
+ "/messages/4/state",
+ &[("state", "do"), ("back", "https://evil.example")],
+ )
+ .await;
+ assert_eq!(location(&res), "/");
+}
+
+#[tokio::test]
+async fn comments_join_the_thread() {
+ let addr = spawn().await;
+ let res = post(
+ addr,
+ ALEX,
+ "/threads/1/comments",
+ &[("text", "I'll call them."), ("back", "/?m=4")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ let (_, body) = get(addr, SAM, "/?m=4").await;
+ assert!(body.contains("I'll call them.") || body.contains("I'll call them."));
+ let res = post(
+ addr,
+ ALEX,
+ "/threads/1/comments",
+ &[("text", " "), ("back", "/")],
+ )
+ .await;
+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
+}
+
+#[tokio::test]
+async fn cross_site_posts_are_refused() {
+ let addr = spawn_with(true).await;
+ let send = |origin: &'static str, path: &'static str| {
+ client()
+ .post(format!("http://{addr}{path}"))
+ .header("Tailscale-User-Login", SAM)
+ .header("Origin", origin)
+ .form(&[("state", "do"), ("user", "alex"), ("back", "/")])
+ .send()
+ };
+ let res = send("https://evil.example", "/messages/4/state")
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::FORBIDDEN);
+ let res = send("null", "/messages/4/state").await.unwrap();
+ assert_eq!(res.status(), StatusCode::FORBIDDEN);
+ let res = send("https://evil.example", "/dev/user").await.unwrap();
+ assert_eq!(res.status(), StatusCode::FORBIDDEN);
+ let same: &'static str = Box::leak(format!("http://{addr}").into_boxed_str());
+ let res = send(same, "/messages/4/state").await.unwrap();
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+}
+
+#[tokio::test]
+async fn dev_mode_picks_a_user_from_a_cookie() {
+ let addr = spawn_with(true).await;
+ let anon = client();
+ let body = anon
+ .get(format!("http://{addr}/"))
+ .send()
+ .await
+ .unwrap()
+ .text()
+ .await
+ .unwrap();
+ assert!(body.contains(r#"<span class="who-name">Alex</span>"#));
+ assert!(body.contains(r#"action="/dev/user""#));
+
+ let res = anon
+ .post(format!("http://{addr}/dev/user"))
+ .form(&[("user", "sam"), ("back", "/do")])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
+ assert_eq!(location(&res), "/do");
+ let cookie = res.headers()["set-cookie"].to_str().unwrap();
+ assert!(cookie.starts_with("docket_dev_user=sam;"));
+
+ let body = anon
+ .get(format!("http://{addr}/"))
+ .header("Cookie", "theme=dark; docket_dev_user=sam")
+ .send()
+ .await
+ .unwrap()
+ .text()
+ .await
+ .unwrap();
+ assert!(body.contains(r#"<span class="who-name">Sam</span>"#));
+
+ let res = anon
+ .post(format!("http://{addr}/dev/user"))
+ .form(&[("user", "eve"), ("back", "/")])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+
+ // Outside dev mode, the switch doesn't exist.
+ let prod = spawn().await;
+ let res = post(prod, SAM, "/dev/user", &[("user", "alex"), ("back", "/")]).await;
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+}
+
+#[tokio::test]
+async fn dev_mode_with_no_users_is_unauthenticated() {
+ let store = docket::store::Store::default();
+ let app = router(AppState::new(store, true));
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+ let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn serves_assets() {
+ let addr = spawn().await;
+ for (path, kind, needle) in [
+ ("/assets/gloss.css", "text/css", "--gl-accent-teal"),
+ ("/assets/docket.css", "text/css", ".app"),
+ ("/assets/alpine.js", "text/javascript", "Alpine"),
+ ] {
+ let res = reqwest::get(format!("http://{addr}{path}")).await.unwrap();
+ assert_eq!(res.status(), StatusCode::OK);
+ assert!(
+ res.headers()["content-type"]
+ .to_str()
+ .unwrap()
+ .starts_with(kind)
+ );
+ assert!(res.text().await.unwrap().contains(needle));
+ }
+}