diff --git a/cmd/server/main.go b/cmd/server/main.go
index 0930bd0f..92b536d7 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -165,7 +165,7 @@ func main() {
dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries)
- mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService)
+ mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker)
diff --git a/cmd/server/tests/filter_builder_test.go b/cmd/server/tests/filter_builder_test.go
new file mode 100644
index 00000000..653cb731
--- /dev/null
+++ b/cmd/server/tests/filter_builder_test.go
@@ -0,0 +1,718 @@
+package main
+
+// Filter rule builder integration tests (faceted plan Phase 9): the
+// composed engines behind the qf wire param — tree semantics (AND/OR/
+// negation/nesting), every user-relative field class (the "my data"
+// weld), the note-text substring pin, family lens, suggestion-under-
+// rules exactness, qf validation, and qf-over-flat precedence.
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "bookhoard/internal/database"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// qfSearch runs the search endpoint with arbitrary params (qf trees go
+// in the qf param as JSON) and returns (status, decoded rows).
+func qfSearch(t *testing.T, setup *TestServerSetup, token string, params url.Values) (int, []map[string]interface{}) {
+ t.Helper()
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return resp.StatusCode, nil
+ }
+ var rows []map[string]interface{}
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
+ return resp.StatusCode, rows
+}
+
+// qfRule is a compact rule builder for tests.
+func qfRule(field, op string, values ...string) map[string]interface{} {
+ return map[string]interface{}{"field": field, "op": op, "values": values}
+}
+
+func qfAll(children ...map[string]interface{}) string {
+ return qfGroup("all", children...)
+}
+
+func qfAny(children ...map[string]interface{}) string {
+ return qfGroup("any", children...)
+}
+
+func qfGroup(mode string, children ...map[string]interface{}) string {
+ b, _ := json.Marshal(map[string]interface{}{"mode": mode, "children": children})
+ return string(b)
+}
+
+func qfTitles(t *testing.T, rows []map[string]interface{}) []string {
+ t.Helper()
+ out := make([]string, 0, len(rows))
+ for _, r := range rows {
+ title, _ := r["title"].(string)
+ out = append(out, title)
+ }
+ return out
+}
+
+// builderFixtureLib builds one test library + four books with
+// trigram-disjoint values. Returns the library UUID and the book titles.
+func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, name string) (uuid.UUID, pgtype.UUID) {
+ t.Helper()
+ ctx := context.Background()
+ libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
+ require.NoError(t, err)
+ library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
+ Name: fmt.Sprintf("Test Builder %s %d", name, time.Now().UnixNano()),
+ Description: pgtype.Text{String: "filter builder test library", Valid: true},
+ LibraryTypeID: libType.ID,
+ CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
+ })
+ require.NoError(t, err)
+ libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
+
+ year := pgtype.Int4{Int32: 1999, Valid: true}
+ datePublished := pgtype.Date{Time: time.Date(1999, 3, 1, 0, 0, 0, 0, time.UTC), Valid: true}
+ books := []database.CreateMediaItemParams{
+ {
+ LibraryID: library.ID, Title: name + " Tide",
+ Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
+ Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true},
+ Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true},
+ Series: pgtype.Text{String: "Voss Saga", Valid: true},
+ SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
+ Language: pgtype.Text{String: "en", Valid: true},
+ DatePublished: datePublished, CopyrightYear: year,
+ FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()),
+ FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+ MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+ },
+ {
+ LibraryID: library.ID, Title: name + " Stone",
+ Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", Valid: true},
+ Tags: []string{"space"}, PageCount: pgtype.Int4{Int32: 500, Valid: true},
+ Series: pgtype.Text{String: "Voss Saga", Valid: true},
+ SeriesNumber: pgtype.Float8{Float64: 2, Valid: true},
+ FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()),
+ FileSize: pgtype.Int8{Int64: 2048, Valid: true},
+ MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+ },
+ {
+ LibraryID: library.ID, Title: name + " Fen",
+ Authors: []string{"Benjamin Quill"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
+ Tags: []string{"epic"}, PageCount: pgtype.Int4{Int32: 200, Valid: true},
+ Abridged: true,
+ FilePath: fmt.Sprintf("/tmp/builder/%s-fen-%d.epub", name, time.Now().UnixNano()),
+ FileSize: pgtype.Int8{Int64: 512, Valid: true},
+ MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+ },
+ {
+ LibraryID: library.ID, Title: name + " Wick",
+ Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true},
+ PageCount: pgtype.Int4{Int32: 700, Valid: true},
+ FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()),
+ FileSize: pgtype.Int8{Int64: 4096, Valid: true},
+ MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+ },
+ }
+ for i := range books {
+ if books[i].Title == "" {
+ books[i].Title = fmt.Sprintf("%s Book %d", name, i)
+ }
+ // created_at binds explicitly — a zero pgtype would insert NULL
+ // and the date fields (added_at within N days) would never match.
+ books[i].CreatedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
+ books[i].ImportedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
+ // Ingest populates the *_search columns (normalized lowercase);
+ // direct-create fixtures must mirror that or the *_search-side
+ // match clauses (tags via tags_search) see nothing.
+ if len(books[i].Authors) > 0 && books[i].AuthorsSearch == nil {
+ lowered := make([]string, len(books[i].Authors))
+ for j, a := range books[i].Authors {
+ lowered[j] = strings.ToLower(a)
+ }
+ books[i].AuthorsSearch = lowered
+ }
+ if len(books[i].Tags) > 0 && books[i].TagsSearch == nil {
+ lowered := make([]string, len(books[i].Tags))
+ for j, tag := range books[i].Tags {
+ lowered[j] = strings.ToLower(tag)
+ }
+ books[i].TagsSearch = lowered
+ }
+ _, err := setup.DB.CreateMediaItem(ctx, books[i])
+ require.NoError(t, err, "create %s", books[i].Title)
+ }
+ return libUUID, library.ID
+}
+
+// secondBuilderUser creates a second test user (the "other reader" for
+// the my-data weld tests) and returns (userID bytes, JWT).
+func secondBuilderUser(t *testing.T, setup *TestServerSetup) (uuid.UUID, string) {
+ t.Helper()
+ ctx := context.Background()
+ suffix := time.Now().UnixNano()
+ second, err := setup.DB.CreateUser(ctx, database.CreateUserParams{
+ Email: fmt.Sprintf("testbuilder%d@tests.bookhoard.internal", suffix),
+ Username: fmt.Sprintf("testbuilder%d", suffix),
+ PasswordHash: "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq",
+ FirstName: pgtype.Text{String: "Second", Valid: true},
+ LastName: pgtype.Text{String: "Reader", Valid: true},
+ Role: "user",
+ Timezone: pgtype.Text{String: "UTC", Valid: true},
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = setup.DB.DeleteUser(ctx, second.ID) })
+ token := loginWithCredentials(t, setup.Server, second.Email, "Test@Pass123!")
+ secondUUID, err := uuid.FromBytes(second.ID.Bytes[:])
+ require.NoError(t, err)
+ return secondUUID, token
+}
+
+func TestFilterComposedSemantics(t *testing.T) {
+ setup := setupTestServer(t)
+ t.Cleanup(func() { _ = setup.Close() })
+
+ userID := getRegularUserID(t, setup.DB)
+ libUUID, _ := builderFixtureLib(t, setup, userID, "Sem")
+
+ titles := func(qf string) []string {
+ p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
+ status, rows := qfSearch(t, setup, setup.RegularToken, p)
+ // 404 is the endpoint's pinned no-results contract — an empty set.
+ require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
+ return qfTitles(t, rows)
+ }
+
+ // Plain rule: author contains (fuzzy, per-element).
+ got := titles(qfAll(qfRule("author", "contains", "Voss")))
+ assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "author contains")
+
+ // Negation as operator: doesn't contain.
+ got = titles(qfAll(qfRule("author", "not_contains", "Voss")))
+ assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "author not_contains")
+
+ // ALL group = AND.
+ got = titles(qfAll(
+ qfRule("author", "contains", "Voss"),
+ qfRule("genre", "contains", "fantasy"),
+ ))
+ assert.Equal(t, []string{"Sem Tide"}, got, "ALL group")
+
+ // ANY group = OR.
+ got = titles(qfAny(
+ qfRule("genre", "is", "history"),
+ qfRule("abridged", "is_true"),
+ ))
+ assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "ANY group")
+
+ // Nesting: group ANDed into its parent.
+ got = titles(qfAll(
+ qfRule("author", "contains", "Voss"),
+ map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
+ qfRule("genre", "is", "history"),
+ qfRule("page_count", "at_least", "400"),
+ }},
+ ))
+ assert.Equal(t, []string{"Sem Stone"}, got, "nested ANY inside ALL")
+
+ // Negated subgroup inside ALL: author Voss AND NOT(genre fantasy).
+ got = titles(qfAll(
+ qfRule("author", "contains", "Voss"),
+ map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
+ qfRule("genre", "not_contains", "fantasy"),
+ qfRule("title", "contains", "zzz-no-match"),
+ }},
+ ))
+ assert.Equal(t, []string{"Sem Stone"}, got, "negation inside nested group")
+
+ // Array exact + negation.
+ got = titles(qfAll(qfRule("tags", "is", "epic")))
+ assert.ElementsMatch(t, []string{"Sem Tide", "Sem Fen"}, got, "tags is")
+ got = titles(qfAll(qfRule("tags", "is_not", "epic")))
+ assert.ElementsMatch(t, []string{"Sem Stone", "Sem Wick"}, got, "tags isn't")
+
+ // Numbers: between / at_most.
+ got = titles(qfAll(qfRule("page_count", "between", "250", "550")))
+ assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "page_count between")
+ got = titles(qfAll(qfRule("page_count", "at_most", "250")))
+ assert.ElementsMatch(t, []string{"Sem Fen"}, got, "page_count at_most")
+
+ // Empty operators.
+ got = titles(qfAll(qfRule("series", "empty")))
+ assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "series is empty")
+ got = titles(qfAll(qfRule("isbn", "missing")))
+ assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "isbn missing")
+
+ // Enums exact + isn't.
+ got = titles(qfAll(qfRule("age_rating", "is", "Teen")))
+ assert.Empty(t, got, "age_rating is (none set)")
+ got = titles(qfAll(qfRule("mime_type", "is", "application/epub+zip")))
+ assert.Len(t, got, 4, "mime_type is")
+
+ // Series full membership: the alternate_info series matches too.
+ ctx := context.Background()
+ _, err := setup.DBPool.Exec(ctx,
+ `UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
+ WHERE title = $1`, "Sem Fen")
+ require.NoError(t, err)
+ got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
+ assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
+ got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
+ assert.Equal(t, []string{"Sem Fen"}, got, "series exact matches alternate_info membership")
+
+ // Dates: within the last N days (all fixtures created now).
+ got = titles(qfAll(qfRule("added_at", "within_days", "7")))
+ assert.Len(t, got, 4, "added_at within the last 7 days")
+
+ // Identifiers: is / isn't (isn't includes books without one).
+ got = titles(qfAll(qfRule("isbn", "is", "1111111111111")))
+ assert.Equal(t, []string{"Sem Tide"}, got, "isbn is")
+ got = titles(qfAll(qfRule("isbn", "is_not", "1111111111111")))
+ assert.Len(t, got, 3, "isbn isn't includes books without an ISBN")
+
+ // Presence.
+ got = titles(qfAll(qfRule("cover", "missing")))
+ assert.Len(t, got, 4, "cover missing")
+
+ // Published year (flat year-range semantics: date_published falls
+ // back to copyright_year).
+ got = titles(qfAll(qfRule("published_year", "at_least", "1990")))
+ assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
+}
+
+func TestFilterUserRelativeFields(t *testing.T) {
+ setup := setupTestServer(t)
+ t.Cleanup(func() { _ = setup.Close() })
+
+ userA := getRegularUserID(t, setup.DB)
+ userBUUID, tokenB := secondBuilderUser(t, setup)
+ ctx := context.Background()
+
+ libUUID, _ := builderFixtureLib(t, setup, userA, "Mine")
+
+ // Resolve the fixture media item ids by title.
+ itemID := func(title string) pgtype.UUID {
+ var id pgtype.UUID
+ err := setup.DBPool.QueryRow(ctx,
+ `SELECT id FROM media_items WHERE title = $1`, title).Scan(&id)
+ require.NoError(t, err, "fixture item %s", title)
+ return id
+ }
+ tideID := itemID("Mine Tide")
+ stoneID := itemID("Mine Stone")
+ fenID := itemID("Mine Fen")
+ wickID := itemID("Mine Wick")
+
+ toPg := func(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: id, Valid: true} }
+ aID := toPg(userA)
+ bID := toPg(userBUUID)
+
+ // Reader A: reading Tide (50%), finished Stone, rated Tide 8,
+ // reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
+ // Stone, time on Wick (2h), collection holding Tide.
+ _, err := setup.DBPool.Exec(ctx,
+ `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
+ tideID, aID, stoneID, aID)
+ require.NoError(t, err)
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
+ tideID, bID)
+ require.NoError(t, err)
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
+ require.NoError(t, err)
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
+ require.NoError(t, err)
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
+ require.NoError(t, err)
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
+ require.NoError(t, err)
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
+ require.NoError(t, err)
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
+ aID, wickID, aID, tideID)
+ require.NoError(t, err)
+
+ var collID pgtype.UUID
+ err = setup.DBPool.QueryRow(ctx,
+ `INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
+ aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
+ require.NoError(t, err)
+
+ titlesFor := func(token string, qf string) []string {
+ p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
+ status, rows := qfSearch(t, setup, token, p)
+ // 404 = the pinned no-results contract (an empty set).
+ require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
+ return qfTitles(t, rows)
+ }
+
+ // Status: the "my data" weld — same tree, different readers.
+ assert.ElementsMatch(t, []string{"Mine Tide"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "reading"))), "A: Tide is in progress")
+ assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("status", "is", "reading"))), "B: nothing in progress")
+ assert.ElementsMatch(t, []string{"Mine Tide"},
+ titlesFor(tokenB, qfAll(qfRule("status", "is", "finished"))), "B finished Tide; A did not")
+ assert.ElementsMatch(t, []string{"Mine Fen", "Mine Wick"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "unread"))), "A unread")
+
+ // Progress percent.
+ assert.ElementsMatch(t, []string{"Mine Stone"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("progress_percent", "at_least", "60"))))
+
+ // Last read.
+ assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("last_read", "within_days", "7"))), 2)
+
+ // My rating: two worlds.
+ assert.ElementsMatch(t, []string{"Mine Tide"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("my_rating", "at_least", "5"))))
+ assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("my_rating", "at_least", "5"))), "B rated 3")
+ assert.ElementsMatch(t, []string{"Mine Fen", "Mine Stone", "Mine Wick"},
+ titlesFor(tokenB, qfAll(qfRule("my_rating", "unrated"))), "B rated only Tide")
+
+ // Review presence.
+ assert.ElementsMatch(t, []string{"Mine Fen"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("review", "has"))))
+ assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("review", "has"))))
+
+ // Notes presence + recency.
+ assert.ElementsMatch(t, []string{"Mine Tide"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("notes", "has"))))
+
+ // Note text: substring pin + the weld. A's note says "catharsis":
+ // searching "catharsis" matches, "Carthage" does NOT (substring,
+ // not fuzzy), and B — no notes — sees nothing either way.
+ assert.ElementsMatch(t, []string{"Mine Tide"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "catharsis"))))
+ assert.Empty(t, titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "Carthage"))),
+ "substring semantics: catharsis ≠ Carthage")
+ assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("note_text", "contains", "catharsis"))),
+ "my data: B's notes are the only notes in B's world")
+
+ // Highlight text.
+ assert.ElementsMatch(t, []string{"Mine Stone"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("highlight_text", "contains", "dark forest"))))
+
+ // Time spent (minutes) + pages read.
+ assert.ElementsMatch(t, []string{"Mine Wick"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("time_spent", "at_least", "60"))))
+ assert.ElementsMatch(t, []string{"Mine Wick"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("pages_read", "at_least", "100"))))
+
+ // Collections: in / in none.
+ assert.ElementsMatch(t, []string{"Mine Tide"},
+ titlesFor(setup.RegularToken, qfAll(qfRule("collection", "in_any"))))
+ assert.Len(t, titlesFor(tokenB, qfAll(qfRule("collection", "in_none"))), 4,
+ "B is in no collections")
+
+ // Library field (holding structure): the fixture library holds all
+ // four books.
+ assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("library", "is", libUUID.String()))), 4)
+}
+
+func TestFilterSuggestionsUnderRules(t *testing.T) {
+ setup := setupTestServer(t)
+ t.Cleanup(func() { _ = setup.Close() })
+
+ userID := getRegularUserID(t, setup.DB)
+ libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
+
+ // Add a second distinct author+genre pair (trigram-disjoint).
+ ctx := context.Background()
+ _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
+ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
+ Title: "Sug Dune",
+ Authors: []string{"Zephyrine Hale"},
+ Genre: pgtype.Text{String: "spacewestern", Valid: true},
+ FilePath: fmt.Sprintf("/tmp/builder/sug-dune-%d.epub", time.Now().UnixNano()),
+ FileSize: pgtype.Int8{Int64: 128, Valid: true},
+ MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+ })
+ require.NoError(t, err)
+
+ suggest := func(field, typed, qf string) (int, []map[string]interface{}) {
+ p := url.Values{"library_id": {libUUID.String()}, field: {typed}, "limit": {"20"}}
+ if qf != "" {
+ p.Set("qf", qf)
+ }
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
+ req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return resp.StatusCode, nil
+ }
+ var body struct {
+ Results []map[string]interface{} `json:"results"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ return resp.StatusCode, body.Results
+ }
+ valuesOf := func(results []map[string]interface{}) []string {
+ out := make([]string, 0, len(results))
+ for _, r := range results {
+ v, _ := r["value"].(string)
+ out = append(out, v)
+ }
+ return out
+ }
+
+ // Typed genre menu: both genres surface for their prefixes.
+ status, results := suggest("genre", "fan", "")
+ require.Equal(t, http.StatusOK, status)
+ assert.Contains(t, valuesOf(results), "fantasy")
+
+ // The two-worlds example: author contains Voss → the genre menu is
+ // HER genres only; author DOESN'T contain → her-only genres absent.
+ status, results = suggest("genre", "fan", qfAll(qfRule("author", "contains", "Voss")))
+ require.Equal(t, http.StatusOK, status)
+ assert.Contains(t, valuesOf(results), "fantasy")
+
+ // NOTE: "fan" + not_contains STILL lists fantasy — Quill's Fen is
+ // fantasy too; the genre is not author-exclusive. The clean
+ // two-worlds demo is spacewestern (only Hale's book), below.
+
+ // ...and the OTHER world's genre appears only when Voss is excluded.
+ status, results = suggest("genre", "space", qfAll(qfRule("author", "not_contains", "Voss")))
+ require.Equal(t, http.StatusOK, status)
+ assert.Contains(t, valuesOf(results), "spacewestern", "the other world's genre surfaces under negation")
+ status, results = suggest("genre", "space", qfAll(qfRule("author", "contains", "Voss")))
+ require.Equal(t, http.StatusOK, status)
+ assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered")
+
+ // Suggestions are symmetric: the genre rule constrains the AUTHOR
+ // menu just the same.
+ status, results = suggest("author", "zeph", qfAll(qfRule("genre", "is", "spacewestern")))
+ require.Equal(t, http.StatusOK, status)
+ assert.Contains(t, valuesOf(results), "Zephyrine Hale")
+ status, results = suggest("author", "amara", qfAll(qfRule("genre", "is", "spacewestern")))
+ require.Equal(t, http.StatusOK, status)
+ assert.NotContains(t, valuesOf(results), "Amara Voss", "zero-match values never offered")
+
+ // Library field menu: visibility-scoped, id values + name display
+ // (this test's fixture libraries are named "Test Builder Sug …").
+ status, results = suggest("library", "Test Builder", "")
+ require.Equal(t, http.StatusOK, status)
+ found := false
+ for _, r := range results {
+ if r["value"] == libUUID.String() {
+ found = true
+ }
+ }
+ assert.True(t, found, "the visible library is offered")
+}
+
+func TestFilterFamilyLens(t *testing.T) {
+ setup := setupTestServer(t)
+ t.Cleanup(func() { _ = setup.Close() })
+
+ userID := getRegularUserID(t, setup.DB)
+ ctx := context.Background()
+ libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
+ require.NoError(t, err)
+
+ makeLib := func(name string) (uuid.UUID, pgtype.UUID) {
+ lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
+ Name: fmt.Sprintf("Test Lens %s %d", name, time.Now().UnixNano()),
+ Description: pgtype.Text{String: "family lens test library", Valid: true},
+ LibraryTypeID: libType.ID,
+ CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
+ })
+ require.NoError(t, err)
+ id, err := uuid.FromBytes(lib.ID.Bytes[0:16])
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
+ return id, lib.ID
+ }
+ _, visibleID := makeLib("Visible")
+ hiddenUUID, hiddenID := makeLib("Hidden")
+
+ // One book in each, same author so the All-Books menu would
+ // aggregate both if the lens failed.
+ for _, lib := range []struct {
+ id pgtype.UUID
+ title string
+ }{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
+ _, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
+ LibraryID: lib.id, Title: lib.title,
+ Authors: []string{"Lens Secret Author"},
+ FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
+ FileSize: pgtype.Int8{Int64: 64, Valid: true},
+ MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+ })
+ require.NoError(t, err)
+ }
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
+ hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
+ require.NoError(t, err)
+
+ search := func(params url.Values) (int, []map[string]interface{}) {
+ return qfSearch(t, setup, setup.RegularToken, params)
+ }
+
+ // The composed search never sees the hidden library's books —
+ // even when a rule names it explicitly.
+ qfHide := qfAll(qfRule("library", "is", hiddenUUID.String()))
+ status, rows := search(url.Values{"qf": {qfHide}})
+ // 200-with-empty or the 404 no-results contract — either way: nothing.
+ assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
+ assert.Empty(t, rows, "a rule naming a hidden library matches nothing")
+
+ // Explicit out-of-set scope + a tree: 403 (never silent).
+ status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "contains", "Lens"))}, "library_id": {hiddenUUID.String()}})
+ assert.Equal(t, http.StatusForbidden, status)
+
+ // The library suggestion menu never offers hidden libraries (a
+ // real typed prefix — empty typed values skip the autocomplete
+ // branch entirely).
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+url.Values{
+ "library": {"Test Lens"}, "limit": {"50"},
+ }.Encode(), nil)
+ req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ var body struct {
+ Results []map[string]interface{} `json:"results"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ for _, r := range body.Results {
+ assert.NotEqual(t, hiddenUUID.String(), r["value"], "hidden library never offered")
+ }
+
+ // filter-count: out-of-set scope is a 403 too.
+ req, _ = http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?qf="+url.QueryEscape(qfAll(qfRule("author", "contains", "Lens")))+"&library_id="+hiddenUUID.String(), nil)
+ req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+ resp, err = http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
+}
+
+func TestFilterQFValidationAndPrecedence(t *testing.T) {
+ setup := setupTestServer(t)
+ t.Cleanup(func() { _ = setup.Close() })
+
+ userID := getRegularUserID(t, setup.DB)
+ libUUID, _ := builderFixtureLib(t, setup, userID, "Val")
+
+ search := func(params url.Values) (int, []map[string]interface{}) {
+ return qfSearch(t, setup, setup.RegularToken, params)
+ }
+
+ // Unknown field → 400.
+ status, _ := search(url.Values{"qf": {qfAll(qfRule("not_a_field", "is", "x"))}})
+ assert.Equal(t, http.StatusBadRequest, status, "unknown field is a 400")
+
+ // Unknown operator → 400.
+ status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "explodes", "x"))}})
+ assert.Equal(t, http.StatusBadRequest, status, "unknown operator is a 400")
+
+ // Operator not allowed on the field → 400.
+ status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "contains", "3"))}})
+ assert.Equal(t, http.StatusBadRequest, status, "operator not allowed on field")
+
+ // between with one value → 400.
+ status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "between", "3"))}})
+ assert.Equal(t, http.StatusBadRequest, status, "between needs two values")
+
+ // Malformed JSON → 400.
+ status, _ = search(url.Values{"qf": {"{not json"}})
+ assert.Equal(t, http.StatusBadRequest, status)
+
+ // SQL injection in a VALUE is just a value (bound parameter): no
+ // error, no match.
+ inj := qfAll(qfRule("author", "contains", "x' OR 1=1 --"))
+ status, rows := search(url.Values{"qf": {inj}})
+ assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status,
+ "injection attempt is a bound value, not SQL")
+ assert.Empty(t, rows)
+
+ // Precedence: a URL carrying BOTH qf and flat params — qf wins.
+ p := url.Values{
+ "library_id": {libUUID.String()},
+ "qf": {qfAll(qfRule("author", "contains", "Voss"))},
+ "author_filter": {"Coraline Dar"},
+ }
+ status, rows = search(p)
+ require.Equal(t, http.StatusOK, status)
+ assert.ElementsMatch(t, []string{"Val Tide", "Val Stone"}, qfTitles(t, rows),
+ "qf wins over flat on a hand-crafted URL")
+
+ // Live count endpoint: rules + scope, a true COUNT.
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?"+url.Values{
+ "qf": {qfAll(qfRule("author", "contains", "Voss"))}, "library_id": {libUUID.String()},
+ }.Encode(), nil)
+ req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ var body struct {
+ Count int `json:"count"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ assert.Equal(t, 2, body.Count, "live count is the true match count")
+
+ // Empty group → 400 (parse validation).
+ status, _ = search(url.Values{"qf": {`{"mode":"all","children":[]}`}})
+ assert.Equal(t, http.StatusBadRequest, status)
+}
+
+func TestFilterFlatEquivalence(t *testing.T) {
+ setup := setupTestServer(t)
+ t.Cleanup(func() { _ = setup.Close() })
+
+ userID := getRegularUserID(t, setup.DB)
+ libUUID, _ := builderFixtureLib(t, setup, userID, "Eqv")
+ scope := url.Values{"library_id": {libUUID.String()}}
+
+ // The tree and its flat deep link return the SAME rows (the smart
+ // serialization promise: flat links keep their semantics).
+ statusFlat, flatRows := qfSearch(t, setup, setup.RegularToken,
+ mergeParams(scope, url.Values{"author_filter": {"Voss"}}))
+ require.Equal(t, http.StatusOK, statusFlat)
+ statusTree, treeRows := qfSearch(t, setup, setup.RegularToken,
+ mergeParams(scope, url.Values{"qf": {qfAll(qfRule("author", "contains", "Voss"))}}))
+ require.Equal(t, http.StatusOK, statusTree)
+ assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows),
+ "tree and flat link return identical rows")
+
+ // Enum: exact flat vs tree is (both may 404-no-results — the empty
+ // set on both sides is still equivalence).
+ statusFlat, flatRows = qfSearch(t, setup, setup.RegularToken,
+ mergeParams(scope, url.Values{"age_rating_filter": {"Teen"}}))
+ statusTree, treeRows = qfSearch(t, setup, setup.RegularToken,
+ mergeParams(scope, url.Values{"qf": {qfAll(qfRule("age_rating", "is", "Teen"))}}))
+ require.Equal(t, statusFlat, statusTree, "same result status")
+ assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
+}
diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go
index 7836c149..7f432696 100644
--- a/cmd/server/tests/test_helpers_test.go
+++ b/cmd/server/tests/test_helpers_test.go
@@ -507,7 +507,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries)
- mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService, worker)
+ mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS
diff --git a/docs/contract_pins.md b/docs/contract_pins.md
index 3ccdc88f..6b22ca81 100644
--- a/docs/contract_pins.md
+++ b/docs/contract_pins.md
@@ -301,3 +301,44 @@ cover_image_path, has_conflict, format_group, badge`.
the standing rule forbids). The single-facet
`/api/media-items/facets?facet=X` shape still REQUIRES `library_id`
— that pin is unchanged.
+
+## 2026-10-20 — Filter rule builder: the `qf` wire param + composed engines (faceted plan Phase 9)
+
+- NEW wire param on the search endpoint family: `qf` — a JSON rule tree
+ `{mode: all|any, children: [{field, op, values} | {mode, children}]}`.
+ Fields/operators are registry-typed constants
+ (`services/filter_registry.go` — ~55 fields incl. every book column,
+ file/lifecycle facts, the three HOLDING STRUCTURES — library,
+ collections (playlists included, `excluded IS NOT TRUE` membership),
+ series (primary + `alternate_info` full membership) — and the whole
+ user-relative class reading the VIEWER's rows). Unknown fields or
+ operators, disallowed operator/field pairs, malformed values, empty
+ groups, and nesting beyond 8 are a **400** (`ErrInvalidFilterTree`),
+ never a silently-ignored token. Values are always bound pgx
+ parameters — an injection-shaped value is just a value.
+- **Precedence (pinned)**: when a request carries BOTH `qf` and flat
+ facet params, **qf wins** — the flat params are ignored by the engine.
+ Flat links keep working unchanged server-side (and a tree equivalent
+ to a flat link returns identical rows — pinned by test).
+- The composed engines (`services/filter_query.go`) run the mandatory
+ WHERE head (visibility join + archived + missing-scan — family lens)
+ for search, suggestions, and count alike; row shape is identical to
+ `SearchMediaItemsUnifiedRow`. A rule naming a hidden library matches
+ nothing; an explicit out-of-set `library_id` stays a 403 on every
+ entry point.
+- NEW endpoint `GET /api/media-items/filter-count?qf=…[&library_id=…]`
+ — the builder's live count: a true COUNT(*) of the rules + scope
+ (not the page length). Family-lens 403 applies.
+- Field-values autocomplete UPGRADED in place (same route): ANY registry
+ field key in its bare form (`?note_text=…`, `?collection=…`,
+ `?library=…`) routes to the field-values path; with a `qf` param the
+ OTHER rules bind as filters (symmetric under-filter suggestions,
+ zero-match values never offered). Without a tree, the classic six
+ (author/genre/tags/narrator/series/language) keep their static
+ queries and response shape — pinned contract, unchanged. ID-valued
+ suggestions (library/collection/added_by) add an OPTIONAL
+ `display` field to result items (`value` stays the id; `omitempty`
+ elsewhere).
+- The Phase-7 facet-panel partial stays removed; the all-facets JSON
+ endpoint and its two shapes are unchanged (they serve flat params —
+ the builder's UI does not use them).
diff --git a/internal/handlers/media.go b/internal/handlers/media.go
index 7733ad20..42b78347 100644
--- a/internal/handlers/media.go
+++ b/internal/handlers/media.go
@@ -226,11 +226,11 @@ type MediaHandler struct {
// NewMediaHandler requires the shared ProgressService — progress writes
// must funnel through SaveProgress (the LWW-seeded canonical writer);
// a nil service has nowhere to fall back to and must not be wired.
-func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
+func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
mh := &MediaHandler{
db: db,
libraryService: libraryService,
- searchService: services.NewSearchService(db),
+ searchService: services.NewSearchService(db, raw),
progressSvc: progressSvc,
annotationSvc: annotationSvc,
}
@@ -2328,30 +2328,29 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
- // Detect autocomplete queries first (author=value, genre=value, etc.)
- if author := c.QueryParam("author"); author != "" {
- return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
- }
- if genre := c.QueryParam("genre"); genre != "" {
- return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
- }
- if tags := c.QueryParam("tags"); tags != "" {
- return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
- }
- if narrator := c.QueryParam("narrator"); narrator != "" {
- return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
- }
- if series := c.QueryParam("series"); series != "" {
- return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
- }
- if language := c.QueryParam("language"); language != "" {
- return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
+ // Autocomplete/suggestion queries: ANY registry field key in its
+ // bare form (author=, genre=, note_text=, collection=, ...) routes
+ // to the field-values path — static for the classic six without a
+ // rule tree, composed under the tree otherwise (faceted plan
+ // Phase 9: same route, no new API surface).
+ for _, key := range services.FilterFieldKeys() {
+ if v := c.QueryParam(key); v != "" {
+ return mh.handleFieldValuesSearch(c, userID.ID, key, v)
+ }
}
// Handle media item search with filters
params := ParseMediaSearchParams(c)
params.UserID = userID.ID
+ // Filter rule builder: a malformed qf is a 400 (bad request), never
+ // a silently-ignored token and never a 500.
+ if params.FilterParseErr != "" {
+ return c.JSON(http.StatusBadRequest, map[string]string{
+ "error": "invalid filter: " + params.FilterParseErr,
+ })
+ }
+
c.Logger().Info("Search parameters",
"query", params.SearchQuery,
"library_id", libraryIDString(c),
@@ -2445,7 +2444,7 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
}
- return services.SearchParams{
+ params := services.SearchParams{
SearchQuery: c.QueryParam("q"),
LibraryID: libUUID,
AuthorFilter: c.QueryParam("author_filter"),
@@ -2471,6 +2470,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
Limit: limit,
Offset: offset,
}
+
+ // Filter rule builder (faceted plan Phase 9): the qf param carries
+ // the rule tree. Parse + validate HERE (the shared parser — every
+ // entry point validates identically); a failure lands in
+ // FilterParseErr for the endpoints to answer 400, never a silently
+ // ignored token. Precedence: qf present → flat facet params are
+ // ignored by the engine (qf wins).
+ if qf := c.QueryParam("qf"); qf != "" {
+ tree, err := services.ParseFilterTree(qf)
+ if err != nil {
+ params.FilterParseErr = err.Error()
+ } else {
+ params.FilterTree = tree
+ }
+ }
+ return params
}
// libraryVisible reports whether the requested library is inside the
@@ -2583,14 +2598,22 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
})
}
-// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
-// Returns distinct field values with counts and similarity scores for dropdown population
+// handleFieldValuesSearch handles autocomplete/suggestion queries
+// (author=value, genre=value, note_text=value, …). Returns distinct
+// field values with counts and similarity scores for dropdown
+// population.
//
// library_id is OPTIONAL: absent means "every library the requesting user
// can see" — the per-user family lens is enforced inside the query (the
// library_visibility join), so the All-Books view's suggestions aggregate
// the visible world. An explicit library_id outside the visible set is a
// 403 (AGENTS standing rule: never silently pass).
+//
+// Routing (faceted plan Phase 9): with a qf rule tree, or for fields
+// beyond the classic six, suggestions come from the COMPOSED engine —
+// the other rules bind as filters (symmetric: every rule's menu sees
+// every other rule; zero-match values are never offered). Without a
+// tree, the classic six keep their static queries (pinned contract).
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
var libPg pgtype.UUID
if libraryID := c.QueryParam("library_id"); libraryID != "" {
@@ -2614,27 +2637,93 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
- params := services.FieldSearchParams{
- UserID: userID,
- LibraryID: libPg,
- FieldType: fieldType,
- SearchQuery: searchQuery,
- Limit: limit,
- Offset: offset,
+ // qf: the OTHER completed rules (the client strips the rule being
+ // edited). Parse + validate here — a malformed tree is a 400.
+ var tree *services.FilterTree
+ if qf := c.QueryParam("qf"); qf != "" {
+ t, err := services.ParseFilterTree(qf)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter: " + err.Error()})
+ }
+ tree = t
}
- results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
+ // Static path: the classic six with no rule tree (unchanged
+ // contract — the tag-dropdown island and deep links still ride it).
+ staticSix := tree == nil
+ if staticSix {
+ switch fieldType {
+ case "author", "genre", "tags", "narrator", "series", "language":
+ params := services.FieldSearchParams{
+ UserID: userID,
+ LibraryID: libPg,
+ FieldType: fieldType,
+ SearchQuery: searchQuery,
+ Limit: limit,
+ Offset: offset,
+ }
+ results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
+ if err != nil {
+ c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ }
+ return c.JSON(http.StatusOK, map[string]interface{}{
+ "results": results,
+ "total": len(results),
+ })
+ }
+ }
+
+ // Composed path: registry field under the other rules.
+ results, err := mh.searchService.ComposedFieldValues(c.Request().Context(), services.ComposedSuggestParams{
+ UserID: userID,
+ LibraryID: libPg,
+ FieldKey: fieldType,
+ Typed: searchQuery,
+ Tree: tree,
+ Limit: limit,
+ })
if err != nil {
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
-
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
})
}
+// FilterCount handles GET /api/media-items/filter-count — the rule
+// builder's live match count: the composed rules + the library scope,
+// no pagination (a true COUNT(*), not the page length).
+func (mh *MediaHandler) FilterCount(c *echo.Context) error {
+ user, ok := c.Get("user").(database.Users)
+ if !ok {
+ return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
+ }
+ params := ParseMediaSearchParams(c)
+ if params.FilterParseErr != "" {
+ return c.JSON(http.StatusBadRequest, map[string]string{
+ "error": "invalid filter: " + params.FilterParseErr,
+ })
+ }
+ if params.LibraryID.Valid {
+ visible, err := mh.libraryVisible(c, params.LibraryID)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ }
+ if !visible {
+ return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
+ }
+ }
+ count, err := mh.searchService.ComposedCount(c.Request().Context(), user.ID, params.LibraryID, params.FilterTree)
+ if err != nil {
+ c.Logger().Error("filter count error", "error", err.Error())
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ }
+ return c.JSON(http.StatusOK, map[string]interface{}{"count": count})
+}
+
// getFullFilePath returns the absolute filesystem path for a media item
// Uses LibraryService for resolution (one source of truth)
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
diff --git a/internal/router/search.go b/internal/router/search.go
index 50d4518e..1984573f 100644
--- a/internal/router/search.go
+++ b/internal/router/search.go
@@ -29,6 +29,9 @@ func registerSearchRoutes(cfg *Config) {
// Facet-value listings for the filter panel (faceted-navigation
// Batch C): /api/media-items/facets?facet=X&library_id=...
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
+ // Rule-builder live count (faceted plan Phase 9): the composed
+ // tree's true match count (COUNT(*)), rules + library scope.
+ protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
}
@@ -45,6 +48,12 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
// parser fixes that too.)
params := handlers.ParseMediaSearchParams(c)
params.UserID = user.ID
+
+ // Filter rule builder: a malformed qf is a 400, never a silently
+ // ignored token.
+ if params.FilterParseErr != "" {
+ return c.HTML(http.StatusBadRequest, `
Invalid filter
`)
+ }
limit := params.Limit
offset := params.Offset
libraryID := c.QueryParam("library_id")
diff --git a/internal/services/filter_query.go b/internal/services/filter_query.go
new file mode 100644
index 00000000..19f6cb72
--- /dev/null
+++ b/internal/services/filter_query.go
@@ -0,0 +1,1060 @@
+package services
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "bookhoard/internal/database"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+// ════════════════════════════════════════════════════════════════════
+// Composed filter engines (faceted plan Phase 9): one Go clause builder
+// over the raw pool feeds the search, the suggestions, and the live
+// count. Fields/operators arrive as registry-typed constants (parsed +
+// validated in filter_registry.go); every value is a bound pgx
+// parameter — no string-concatenated SQL from user input, ever.
+//
+// Match semantics mirror the flat facet SQL exactly (word_similarity
+// 0.3 fuzzy; ANY() exact; per-element EXISTS; IS NULL / = '' for
+// empty), so a flat link and its equivalent rule tree return the same
+// rows.
+// ════════════════════════════════════════════════════════════════════
+
+// ErrInvalidFilterTree marks a qf parse/validation failure — handlers
+// map it to 400 (bad request), never 500.
+var ErrInvalidFilterTree = errors.New("invalid filter tree")
+
+// filterBuilder accumulates bound parameters and the user-relative JOINs
+// a tree's clauses need. Parameter numbering is textual-order-independent
+// (Postgres references params by number), so lazy join binding is safe.
+type filterBuilder struct {
+ args []any
+ joins []string
+ joinSeen map[string]bool
+ userIDBind string
+}
+
+func newFilterBuilder(userID pgtype.UUID) *filterBuilder {
+ b := &filterBuilder{joinSeen: map[string]bool{}}
+ b.userIDBind = b.bind(userID)
+ return b
+}
+
+func (b *filterBuilder) bind(v any) string {
+ b.args = append(b.args, v)
+ return fmt.Sprintf("$%d", len(b.args))
+}
+
+func (b *filterBuilder) needJoin(key, sql string) {
+ if !b.joinSeen[key] {
+ b.joinSeen[key] = true
+ b.joins = append(b.joins, sql)
+ }
+}
+
+// readingProgressLEFT JOIN — shared by every progress/status field.
+func (b *filterBuilder) readingProgress() {
+ b.needJoin("rp", "LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = "+b.userIDBind)
+}
+
+// myRatingLEFT JOIN — shared by the my_rating field.
+func (b *filterBuilder) myRating() {
+ b.needJoin("mr", "LEFT JOIN media_ratings mr ON mr.media_item_id = mi.id AND mr.user_id = "+b.userIDBind)
+}
+
+// filterFrom is the MANDATORY FROM head — identical to the flat
+// SearchMediaItemsUnified: visibility join (family lens), library and
+// library-type joins for the row shape.
+const filterFrom = `FROM media_items mi
+JOIN libraries l ON mi.library_id = l.id
+JOIN library_types lt ON l.library_type_id = lt.id
+LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = `
+
+// headWhere renders the mandatory WHERE head: the family lens +
+// integrity facts, plus the optional library scope (validated upstream —
+// an explicit out-of-scope library never reaches the SQL).
+func (b *filterBuilder) headWhere(libraryID pgtype.UUID) string {
+ w := "COALESCE(lv.is_visible, true) = true AND mi.archived_at IS NULL AND mi.missing_scan_count = 0"
+ if libraryID.Valid {
+ w += " AND mi.library_id = " + b.bind(libraryID)
+ }
+ return w
+}
+
+// ─── clause helpers (flat-SQL mirrors) ────────────────────────────────
+
+// fuzzyScalar contains: any value fuzzy-matches the column.
+func fuzzyScalarContains(col string, values []string, b *filterBuilder) string {
+ v := b.bind(values) + "::text[]"
+ return fmt.Sprintf(`(EXISTS (SELECT 1 FROM unnest(%s) AS f WHERE word_similarity(f, COALESCE(%s, '')) > 0.3))`, v, col)
+}
+
+// fuzzyArray contains: any value fuzzy-matches any array element.
+func fuzzyArrayContains(arrCol string, values []string, b *filterBuilder) string {
+ v := b.bind(values) + "::text[]"
+ return fmt.Sprintf(`(EXISTS (
+ SELECT 1
+ FROM unnest(%s) AS f
+ JOIN unnest(%s) AS a ON true
+ WHERE word_similarity(f, COALESCE(a, '')) > 0.3
+))`, v, arrCol)
+}
+
+// clauseScalarFuzzyFactory — contains / doesn't contain / is / isn't /
+// is empty over one text column (word_similarity 0.3, the system fuzzy).
+func clauseScalarFuzzyFactory(col string) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpContains:
+ return fuzzyScalarContains(col, r.Values, b)
+ case OpNotContains:
+ return "NOT " + fuzzyScalarContains(col, r.Values, b)
+ case OpIs:
+ if len(r.Values) == 1 {
+ return "(" + col + " = " + b.bind(r.Values[0]) + ")"
+ }
+ return "(" + col + " = ANY(" + b.bind(r.Values) + "))"
+ case OpIsNot:
+ // IS DISTINCT FROM keeps rows with a NULL column in "isn't X"
+ // (a book with no genre ISN'T genre "Fantasy").
+ if len(r.Values) == 1 {
+ return "(" + col + " IS DISTINCT FROM " + b.bind(r.Values[0]) + ")"
+ }
+ return "(NOT (" + col + " = ANY(" + b.bind(r.Values) + ")) OR " + col + " IS NULL)"
+ case OpEmpty:
+ return "(COALESCE(" + col + ", '') = '')"
+ }
+ return "TRUE"
+ }
+}
+
+// clauseArrayFuzzyFactory — per-element fuzzy over a text[] (flat A2c
+// §4.1 semantics), plus exact is/isn't (array overlap) and empty.
+func clauseArrayFuzzyFactory(arrCol string) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpContains:
+ return fuzzyArrayContains(arrCol, r.Values, b)
+ case OpNotContains:
+ return "NOT " + fuzzyArrayContains(arrCol, r.Values, b)
+ case OpIs:
+ return "(" + b.bind(r.Values) + "::text[] && " + arrCol + ")"
+ case OpIsNot:
+ return "(NOT (" + b.bind(r.Values) + "::text[] && " + arrCol + ") OR " + arrCol + " IS NULL)"
+ case OpEmpty:
+ return "(" + arrCol + " IS NULL OR cardinality(" + arrCol + ") = 0)"
+ }
+ return "TRUE"
+ }
+}
+
+// clauseAuthorArray — the author field: contains/not_contains fuzzy per
+// element (flat mirror); is/is_not exact element equality; empty.
+func clauseAuthorArray(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpContains:
+ return fuzzyArrayContains("mi.authors", r.Values, b)
+ case OpNotContains:
+ return "NOT " + fuzzyArrayContains("mi.authors", r.Values, b)
+ case OpIs:
+ return "(" + b.bind(r.Values) + "::text[] && mi.authors)"
+ case OpIsNot:
+ return "(NOT (" + b.bind(r.Values) + "::text[] && mi.authors) OR mi.authors IS NULL)"
+ case OpEmpty:
+ return "(mi.authors IS NULL OR cardinality(mi.authors) = 0)"
+ }
+ return "TRUE"
+}
+
+// clauseEnumFactory — exact enum scalars: = ANY(...) like the flat
+// exact-enum blocks.
+func clauseEnumFactory(col string) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpIs:
+ return "(" + col + " = ANY(" + b.bind(r.Values) + "::text[]))"
+ case OpIsNot:
+ return "(NOT (" + col + " = ANY(" + b.bind(r.Values) + "::text[])) OR " + col + " IS NULL)"
+ case OpEmpty:
+ return "(" + col + " IS NULL OR " + col + " = '')"
+ }
+ return "TRUE"
+ }
+}
+
+// clauseBoolFactory — presence-bound bools (NULL = unset matches
+// neither is-true nor is-false, like the flat explicit/abridged blocks).
+func clauseBoolFactory(col string) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpIsTrue:
+ return "(" + col + " = TRUE)"
+ case OpIsFalse:
+ return "(" + col + " = FALSE)"
+ }
+ return "TRUE"
+ }
+}
+
+// clausePresenceFactory — has / missing over a text column
+// (IS NULL / = '' per the plan's empty semantics).
+func clausePresenceFactory(col string) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpHas:
+ return "(" + col + " IS NOT NULL AND " + col + " <> '')"
+ case OpMissing:
+ return "(" + col + " IS NULL OR " + col + " = '')"
+ }
+ return "TRUE"
+ }
+}
+
+// clauseIdentifierFactory — exact is / isn't (IS DISTINCT FROM, so
+// "isn't this ISBN" includes books without one) + has/missing.
+func clauseIdentifierFactory(col string) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpIs:
+ return "(" + col + " = " + b.bind(r.Values[0]) + ")"
+ case OpIsNot:
+ return "(" + col + " IS DISTINCT FROM " + b.bind(r.Values[0]) + ")"
+ case OpHas:
+ return "(" + col + " IS NOT NULL AND " + col + " <> '')"
+ case OpMissing:
+ return "(" + col + " IS NULL OR " + col + " = '')"
+ }
+ return "TRUE"
+ }
+}
+
+// clauseNumberFactory — between / at least / at most / is empty over a
+// numeric column; values bind as numbers (validated + parsed earlier).
+func clauseNumberFactory(col string, parse func(string) (any, error)) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpAtLeast:
+ n, _ := parse(r.Values[0])
+ return "(" + col + " >= " + b.bind(n) + ")"
+ case OpAtMost:
+ n, _ := parse(r.Values[0])
+ return "(" + col + " <= " + b.bind(n) + ")"
+ case OpBetween:
+ a, _ := parse(r.Values[0])
+ c, _ := parse(r.Values[1])
+ return "(" + col + " BETWEEN " + b.bind(a) + " AND " + b.bind(c) + ")"
+ case OpEmpty:
+ return "(" + col + " IS NULL)"
+ }
+ return "TRUE"
+ }
+}
+
+// clauseDateFactory — within the last N days / between (end bound
+// anchored to the START of the day AFTER the end date — the end-of-day
+// arithmetic rule) / is empty.
+func clauseDateFactory(col string) func(FilterRule, *filterBuilder) string {
+ return func(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpWithinDays:
+ n, _ := parseNumberInt(r.Values[0])
+ days := int32(n.(int64))
+ return "(" + col + " >= now() - make_interval(days => " + b.bind(days) + "::int))"
+ case OpBetween:
+ return "(" + col + " >= " + b.bind(r.Values[0]) + "::date AND " + col + " < (" + b.bind(r.Values[1]) + "::date + interval '1 day'))"
+ case OpEmpty:
+ return "(" + col + " IS NULL)"
+ }
+ return "TRUE"
+ }
+}
+
+// ─── special clauses ─────────────────────────────────────────────────
+
+// clauseSeriesMembership — the book's FULL series membership: the
+// primary series column AND the additional series held in
+// alternate_info. "belongs to series X" must not miss a book whose
+// membership of X is secondary.
+func clauseSeriesMembership(r FilterRule, b *filterBuilder) string {
+ const alt = "mi.alternate_info->>'alternate_series'"
+ switch r.Op {
+ case OpContains:
+ v := b.bind(r.Values) + "::text[]"
+ return fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM unnest(%s) AS f
+ WHERE word_similarity(f, COALESCE(mi.series, '')) > 0.3
+ OR word_similarity(f, COALESCE(%s, '')) > 0.3
+))`, v, alt)
+ case OpNotContains:
+ v := b.bind(r.Values) + "::text[]"
+ return fmt.Sprintf(`(NOT EXISTS (
+ SELECT 1 FROM unnest(%s) AS f
+ WHERE word_similarity(f, COALESCE(mi.series, '')) > 0.3
+ OR word_similarity(f, COALESCE(%s, '')) > 0.3
+))`, v, alt)
+ case OpIs:
+ v := b.bind(r.Values) + "::text[]"
+ return fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM unnest(%s) AS f
+ WHERE f = mi.series OR f = %s
+))`, v, alt)
+ case OpIsNot:
+ v := b.bind(r.Values) + "::text[]"
+ return fmt.Sprintf(`(NOT EXISTS (
+ SELECT 1 FROM unnest(%s) AS f
+ WHERE f = mi.series OR f = %s
+))`, v, alt)
+ case OpEmpty:
+ return "((mi.series IS NULL OR mi.series = '') AND COALESCE(" + alt + ", '') = '')"
+ }
+ return "TRUE"
+}
+
+// clausePublishedYear — the flat year-range semantics: date_published's
+// year, falling back to copyright_year.
+func clausePublishedYear(r FilterRule, b *filterBuilder) string {
+ expr := "COALESCE(EXTRACT(YEAR FROM mi.date_published)::int, mi.copyright_year)"
+ switch r.Op {
+ case OpAtLeast:
+ n, _ := parseNumberInt(r.Values[0])
+ return "(" + expr + " >= " + b.bind(n) + ")"
+ case OpAtMost:
+ n, _ := parseNumberInt(r.Values[0])
+ return "(" + expr + " <= " + b.bind(n) + ")"
+ case OpBetween:
+ a, _ := parseNumberInt(r.Values[0])
+ c, _ := parseNumberInt(r.Values[1])
+ return "(" + expr + " BETWEEN " + b.bind(a) + " AND " + b.bind(c) + ")"
+ case OpEmpty:
+ return "(mi.date_published IS NULL AND mi.copyright_year IS NULL)"
+ }
+ return "TRUE"
+}
+
+// clauseCover — mirrors the flat has_cover block exactly.
+func clauseCover(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpHas:
+ return "(mi.cover_image_path IS NOT NULL)"
+ case OpMissing:
+ return "(mi.cover_image_path IS NULL)"
+ }
+ return "TRUE"
+}
+
+// clauseFileAvailability — the archived_at/missing_scan_count facts.
+// NOTE: the mandatory head already hides missing files from every
+// search surface, so "is missing" matches nothing today — the clause is
+// still written honestly so the semantics hold if the head ever relaxes.
+func clauseFileAvailability(r FilterRule, b *filterBuilder) string {
+ present := "(mi.archived_at IS NULL AND mi.missing_scan_count = 0)"
+ switch r.Op {
+ case OpIs:
+ if r.Values[0] == "available" {
+ return present
+ }
+ return "(NOT " + present + ")"
+ case OpIsNot:
+ if r.Values[0] == "available" {
+ return "(NOT " + present + ")"
+ }
+ return present
+ }
+ return "TRUE"
+}
+
+// clauseAddedBy — the admin who added the book.
+func clauseAddedBy(r FilterRule, b *filterBuilder) string {
+ switch r.Op {
+ case OpIs:
+ id, err := uuid.Parse(r.Values[0])
+ if err != nil {
+ return "FALSE"
+ }
+ return "(mi.added_by_admin_id = " + b.bind(id) + ")"
+ case OpHas:
+ return "(mi.added_by_admin_id IS NOT NULL)"
+ case OpMissing:
+ return "(mi.added_by_admin_id IS NULL)"
+ }
+ return "TRUE"
+}
+
+// clauseStatus — unread / in progress / finished from the viewer's
+// reading_progress.percentage (finished threshold mirrors the
+// dashboard's 0.999 hide-finished cut).
+func clauseStatus(r FilterRule, b *filterBuilder) string {
+ b.readingProgress()
+ parts := make([]string, 0, len(r.Values))
+ for _, v := range r.Values {
+ switch v {
+ case "unread":
+ parts = append(parts, "(rp.id IS NULL OR COALESCE(rp.percentage, 0) = 0)")
+ case "reading":
+ parts = append(parts, "(rp.percentage > 0 AND rp.percentage < 0.999)")
+ case "finished":
+ parts = append(parts, "(COALESCE(rp.percentage, 0) >= 0.999)")
+ }
+ }
+ if len(parts) == 0 {
+ return "TRUE"
+ }
+ return "(" + strings.Join(parts, " OR ") + ")"
+}
+
+// clauseProgressPercent — the viewer's progress as a 0-100 number.
+func clauseProgressPercent(r FilterRule, b *filterBuilder) string {
+ b.readingProgress()
+ pct := "COALESCE(rp.percentage, 0) * 100"
+ switch r.Op {
+ case OpAtLeast:
+ n, _ := parseNumberFloat(r.Values[0])
+ return "(" + pct + " >= " + b.bind(n) + ")"
+ case OpAtMost:
+ n, _ := parseNumberFloat(r.Values[0])
+ return "(" + pct + " <= " + b.bind(n) + ")"
+ case OpBetween:
+ a, _ := parseNumberFloat(r.Values[0])
+ c, _ := parseNumberFloat(r.Values[1])
+ return "(" + pct + " BETWEEN " + b.bind(a) + " AND " + b.bind(c) + ")"
+ case OpEmpty:
+ return "(rp.id IS NULL)"
+ }
+ return "TRUE"
+}
+
+// clauseLastRead — recency of the viewer's last read.
+func clauseLastRead(r FilterRule, b *filterBuilder) string {
+ b.readingProgress()
+ switch r.Op {
+ case OpWithinDays:
+ n, _ := parseNumberInt(r.Values[0])
+ days := int32(n.(int64))
+ return "(rp.last_read_at >= now() - make_interval(days => " + b.bind(days) + "::int))"
+ case OpBetween:
+ return "(rp.last_read_at >= " + b.bind(r.Values[0]) + "::date AND rp.last_read_at < (" + b.bind(r.Values[1]) + "::date + interval '1 day'))"
+ case OpEmpty:
+ return "(rp.id IS NULL)"
+ }
+ return "TRUE"
+}
+
+// clauseMyRating — the viewer's 1-10 rating.
+func clauseMyRating(r FilterRule, b *filterBuilder) string {
+ b.myRating()
+ switch r.Op {
+ case OpIs:
+ ints := make([]int64, 0, len(r.Values))
+ for _, v := range r.Values {
+ n, _ := parseNumberInt(v)
+ ints = append(ints, n.(int64))
+ }
+ return "(mr.rating = ANY(" + b.bind(ints) + "::int[]))"
+ case OpAtLeast:
+ n, _ := parseNumberInt(r.Values[0])
+ return "(mr.rating >= " + b.bind(n) + ")"
+ case OpAtMost:
+ n, _ := parseNumberInt(r.Values[0])
+ return "(mr.rating <= " + b.bind(n) + ")"
+ case OpUnrated:
+ return "(mr.id IS NULL)"
+ }
+ return "TRUE"
+}
+
+// viewerRowsExists — an EXISTS clause over one of the viewer's
+// joined-table surfaces (reviews / notes / highlights).
+func viewerRowsExists(table, textCol string, b *filterBuilder) string {
+ return fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM %s t
+ WHERE t.media_item_id = mi.id AND t.user_id = %s
+))`, table, b.userIDBind)
+}
+
+func clauseReviewPresence(r FilterRule, b *filterBuilder) string {
+ ex := viewerRowsExists("media_reviews", "review", b)
+ if r.Op == OpHas {
+ return ex
+ }
+ return "(NOT " + ex + ")"
+}
+
+// clauseNotesPresence / clauseHighlightsPresence — presence + "last one
+// within N days".
+func clauseNotesPresence(r FilterRule, b *filterBuilder) string {
+ return annotationPresence("media_notes", r, b)
+}
+
+func clauseHighlightsPresence(r FilterRule, b *filterBuilder) string {
+ return annotationPresence("media_highlights", r, b)
+}
+
+func annotationPresence(table string, r FilterRule, b *filterBuilder) string {
+ userBind := b.userIDBind
+ if r.Op == OpWithinDays {
+ n, _ := parseNumberInt(r.Values[0])
+ days := int32(n.(int64))
+ return fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM %s t
+ WHERE t.media_item_id = mi.id AND t.user_id = %s
+ AND t.created_at >= now() - make_interval(days => %s::int)
+))`, table, userBind, b.bind(days))
+ }
+ ex := fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM %s t
+ WHERE t.media_item_id = mi.id AND t.user_id = %s
+))`, table, userBind)
+ if r.Op == OpHas {
+ return ex
+ }
+ return "(NOT " + ex + ")"
+}
+
+// clauseNoteText / clauseHighlightText — the viewer's own words,
+// substring ILIKE (NOT the system fuzzy: your words deserve exact-ish),
+// a book matching when ANY of its rows for this viewer match.
+// Wildcard characters in the note text (% _) act as wildcards — your
+// own notes, your own metacharacters.
+func clauseNoteText(r FilterRule, b *filterBuilder) string {
+ return annotationTextClause("media_notes", "content", r, b)
+}
+
+func clauseHighlightText(r FilterRule, b *filterBuilder) string {
+ return annotationTextClause("media_highlights", "selection_text", r, b)
+}
+
+func annotationTextClause(table, col string, r FilterRule, b *filterBuilder) string {
+ userBind := b.userIDBind
+ if r.Op == OpEmpty {
+ return fmt.Sprintf(`(NOT EXISTS (
+ SELECT 1 FROM %s t
+ WHERE t.media_item_id = mi.id AND t.user_id = %s
+))`, table, userBind)
+ }
+ match := fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM unnest(%s) AS f
+ JOIN %s t ON t.media_item_id = mi.id AND t.user_id = %s
+ WHERE t.%s ILIKE '%%' || f || '%%'
+))`, b.bind(r.Values)+"::text[]", table, userBind, col)
+ if r.Op == OpNotContains {
+ return "(NOT " + match + ")"
+ }
+ return match
+}
+
+// clauseTimeSpent / clausePagesRead — the viewer's reading_history SUM
+// aggregates ("books I've spent 5+ hours on"). Time binds in seconds.
+func clauseTimeSpent(r FilterRule, b *filterBuilder) string {
+ sum := fmt.Sprintf(`(SELECT COALESCE(SUM(rh.time_spent_seconds), 0)
+ FROM reading_history rh
+ WHERE rh.media_item_id = mi.id AND rh.user_id = %s)`, b.userIDBind)
+ return sumNumberClause(sum, r, b, 60)
+}
+
+func clausePagesRead(r FilterRule, b *filterBuilder) string {
+ sum := fmt.Sprintf(`(SELECT COALESCE(SUM(rh.pages_read), 0)
+ FROM reading_history rh
+ WHERE rh.media_item_id = mi.id AND rh.user_id = %s)`, b.userIDBind)
+ return sumNumberClause(sum, r, b, 1)
+}
+
+// sumNumberClause compares an aggregate subquery against bound bounds.
+func sumNumberClause(sumExpr string, r FilterRule, b *filterBuilder, scale float64) string {
+ bound := func(v string) any {
+ f, err := parseNumberFloat(v)
+ if err != nil {
+ return int64(0)
+ }
+ return int64(f.(float64) * scale)
+ }
+ switch r.Op {
+ case OpAtLeast:
+ return "(" + sumExpr + " >= " + b.bind(bound(r.Values[0])) + ")"
+ case OpAtMost:
+ return "(" + sumExpr + " <= " + b.bind(bound(r.Values[0])) + ")"
+ case OpBetween:
+ return "(" + sumExpr + " BETWEEN " + b.bind(bound(r.Values[0])) + " AND " + b.bind(bound(r.Values[1])) + ")"
+ }
+ return "TRUE"
+}
+
+// clauseCollection — in / isn't in / in any / in none, mirroring the
+// collection page's own membership definition (excluded items and
+// deleted collections never count; playlists are collections).
+func clauseCollection(r FilterRule, b *filterBuilder) string {
+ userBind := b.userIDBind
+ if r.Op == OpInAny || r.Op == OpInNone {
+ ex := fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM collection_items ci
+ JOIN collections c ON c.id = ci.collection_id
+ WHERE ci.media_item_id = mi.id AND c.user_id = %s
+ AND c.deleted_at IS NULL AND ci.excluded IS NOT TRUE
+))`, userBind)
+ if r.Op == OpInAny {
+ return ex
+ }
+ return "(NOT " + ex + ")"
+ }
+ ids := make([]uuid.UUID, 0, len(r.Values))
+ for _, v := range r.Values {
+ if id, err := uuid.Parse(v); err == nil {
+ ids = append(ids, id)
+ }
+ }
+ ex := fmt.Sprintf(`(EXISTS (
+ SELECT 1 FROM collection_items ci
+ JOIN collections c ON c.id = ci.collection_id
+ WHERE ci.media_item_id = mi.id AND c.user_id = %s
+ AND c.deleted_at IS NULL AND ci.excluded IS NOT TRUE
+ AND c.id = ANY(%s)
+))`, userBind, b.bind(ids)+"::uuid[]")
+ if r.Op == OpIn {
+ return ex
+ }
+ return "(NOT " + ex + ")"
+}
+
+// clauseLibrary — the library holding-structure field. Values are
+// library IDs; visibility is enforced by the mandatory head regardless
+// (a rule naming a hidden library simply matches nothing).
+func clauseLibrary(r FilterRule, b *filterBuilder) string {
+ ids := make([]uuid.UUID, 0, len(r.Values))
+ for _, v := range r.Values {
+ if id, err := uuid.Parse(v); err == nil {
+ ids = append(ids, id)
+ }
+ }
+ if r.Op == OpIs {
+ return "(l.id = ANY(" + b.bind(ids) + "::uuid[]))"
+ }
+ return "(NOT (l.id = ANY(" + b.bind(ids) + "::uuid[])))"
+}
+
+// ─── tree → SQL ──────────────────────────────────────────────────────
+
+// buildGroupClause renders a group: children joined by the group mode,
+// each child a rule clause or a nested group. A group ANDs into its
+// parent.
+func buildGroupClause(g *FilterChild, b *filterBuilder) string {
+ parts := make([]string, 0, len(g.Children))
+ for _, c := range g.Children {
+ if c.Field == "" {
+ parts = append(parts, buildGroupClause(&c, b))
+ continue
+ }
+ parts = append(parts, buildRuleClause(FilterRule{Field: c.Field, Op: c.Op, Values: c.Values}, b))
+ }
+ if len(parts) == 0 {
+ return "TRUE"
+ }
+ if len(parts) == 1 {
+ return parts[0]
+ }
+ joiner := " AND "
+ if g.Mode == "any" {
+ joiner = " OR "
+ }
+ return "(" + strings.Join(parts, joiner) + ")"
+}
+
+func buildRuleClause(r FilterRule, b *filterBuilder) string {
+ f, ok := FilterFieldByKey(r.Field)
+ if !ok {
+ return "TRUE" // unreachable: validation ran at parse
+ }
+ return f.Clause(r, b)
+}
+
+// treeWhere builds the full rule-tree WHERE fragment ("" when the tree
+// is nil).
+func treeWhere(tree *FilterTree, b *filterBuilder) string {
+ if tree == nil || len(tree.Children) == 0 {
+ return ""
+ }
+ g := &FilterChild{Mode: tree.Mode, Children: tree.Children}
+ return " AND " + buildGroupClause(g, b)
+}
+
+// ─── the composed search ─────────────────────────────────────────────
+
+// composedSelect — the exact SELECT list of SearchMediaItemsUnified, so
+// the composed engine returns the identical row shape
+// (database.SearchMediaItemsUnifiedRow) and the grid/JSON contracts are
+// untouched. Keep in lockstep with queries.sql.
+const composedSelect = `SELECT
+ mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description,
+ mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path,
+ mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published,
+ mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count,
+ mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id,
+ mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at,
+ mi.format_group, mi.format_mimetype, mi.is_reflowable,
+ mi.has_fixed_layout, mi.total_characters, mi.chapter_count,
+ mi.entitlement_id, mi.revision_number, mi.kobo_content_id,
+ mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.narrators,
+ mi.narrators_search, mi.abridged, mi.subtitle, mi.explicit, mi.authors,
+ mi.authors_search, mi.publishers, mi.publishers_search,
+ mi.audio_metadata, mi.file_sha256, mi.opf_identifier, mi.opf_uuid,
+ mi.hash_confidence, l.name AS library_name, lt.name AS library_type_name
+`
+
+// composedSortOrder — the flat query's user-sort CASE cascade (the
+// relevance arm simplifies to the search query only; the flat query's
+// filter-similarity terms only matter when q AND filters are both set).
+func composedSortOrder(sortBind, qBind, exactBind string) string {
+ return `
+ORDER BY
+ CASE WHEN ` + qBind + ` != '' THEN GREATEST(
+ CASE WHEN ` + exactBind + ` = false THEN word_similarity(` + qBind + `, mi.title) ELSE 0 END,
+ CASE WHEN ` + exactBind + ` = false THEN word_similarity(` + qBind + `, COALESCE(mi.author, '')) ELSE 0 END,
+ word_similarity(` + qBind + `, COALESCE(mi.series, ''))
+ ) ELSE 0 END DESC,
+ CASE WHEN ` + sortBind + ` = 'title ASC' THEN mi.title ELSE '' END ASC,
+ CASE WHEN ` + sortBind + ` = 'title DESC' THEN mi.title ELSE '' END DESC,
+ CASE WHEN ` + sortBind + ` = 'author ASC' THEN COALESCE(mi.author, '') ELSE '' END ASC,
+ CASE WHEN ` + sortBind + ` = 'author DESC' THEN COALESCE(mi.author, '') ELSE '' END DESC,
+ CASE WHEN ` + sortBind + ` = 'created_at ASC' THEN mi.created_at ELSE '1970-01-01'::timestamp END ASC,
+ CASE WHEN ` + sortBind + ` = 'created_at DESC' THEN mi.created_at ELSE '1970-01-01'::timestamp END DESC,
+ CASE WHEN ` + sortBind + ` = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END ASC,
+ CASE WHEN ` + sortBind + ` = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END DESC,
+ mi.title ASC
+LIMIT `
+}
+
+// ComposedSearch runs the rule tree — same row shape as the static
+// unified search (identical response contracts).
+func (s *SearchService) ComposedSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) {
+ b := newFilterBuilder(params.UserID)
+ isExact, searchQuery := s.parseSearchQuery(params.SearchQuery)
+
+ qBind := b.bind(searchQuery)
+ exactBind := b.bind(isExact)
+ sortBind := b.bind(params.Sort)
+ libBind := b.bind(params.LibraryID) // invalid pgtype.UUID binds as NULL
+ limitBind := b.bind(params.Limit)
+ offsetBind := b.bind(params.Offset)
+
+ // Build the WHERE first — clause construction requests the
+ // user-relative JOINs lazily, and joinBlock() must render AFTER
+ // they are collected.
+ whereSQL := b.headWhere(params.LibraryID) + treeWhere(params.FilterTree, b) + `
+ AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)
+ AND (` + qBind + ` = '' OR ` + composedQueryBlock(qBind, exactBind) + `)`
+ sqlStr := composedSelect + filterFrom + b.userIDBind +
+ b.joinBlock() + `
+WHERE ` + whereSQL +
+ composedSortOrder(sortBind, qBind, exactBind) + ` ` + limitBind + ` OFFSET ` + offsetBind
+
+ rows, err := s.raw.Query(ctx, sqlStr, b.args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []database.SearchMediaItemsUnifiedRow
+ for rows.Next() {
+ var row database.SearchMediaItemsUnifiedRow
+ if err := rows.Scan(composedScanDest(&row)...); err != nil {
+ return nil, err
+ }
+ out = append(out, row)
+ }
+ return out, rows.Err()
+}
+
+// composedScanDest returns the positional scan targets for
+// composedSelect — column-for-column in SELECT order (must stay in
+// lockstep with composedSelect / the flat query's SELECT list).
+func composedScanDest(r *database.SearchMediaItemsUnifiedRow) []any {
+ return []any{
+ &r.ID, &r.LibraryID, &r.Title, &r.Author, &r.Isbn, &r.Description,
+ &r.FilePath, &r.FileSize, &r.MimeType, &r.CoverImagePath,
+ &r.Series, &r.SeriesNumber, &r.Tags, &r.Asin, &r.DatePublished,
+ &r.Publisher, &r.Contributors, &r.Language, &r.Edition, &r.PageCount,
+ &r.Genre, &r.CopyrightYear, &r.GoodreadsID, &r.OpenlibraryID,
+ &r.GoogleBooksID, &r.AddedByAdminID, &r.CreatedAt, &r.UpdatedAt,
+ &r.FormatGroup, &r.FormatMimetype, &r.IsReflowable,
+ &r.HasFixedLayout, &r.TotalCharacters, &r.ChapterCount,
+ &r.EntitlementID, &r.RevisionNumber, &r.KoboContentID,
+ &r.KoboMetadata, &r.TagsSearch, &r.ContributorsSearch, &r.Narrators,
+ &r.NarratorsSearch, &r.Abridged, &r.Subtitle, &r.Explicit, &r.Authors,
+ &r.AuthorsSearch, &r.Publishers, &r.PublishersSearch,
+ &r.AudioMetadata, &r.FileSha256, &r.OpfIdentifier, &r.OpfUuid,
+ &r.HashConfidence, &r.LibraryName, &r.LibraryTypeName,
+ }
+}
+
+// joinBlock renders the collected user-relative JOINs (lazy-requested
+// by clauses) — placed after the FROM head, before WHERE.
+func (b *filterBuilder) joinBlock() string {
+ if len(b.joins) == 0 {
+ return ""
+ }
+ return "\n" + strings.Join(b.joins, "\n")
+}
+
+// composedQueryBlock mirrors the flat query's search-query block
+// (fuzzy by default, exact with quotes).
+func composedQueryBlock(qBind, exactBind string) string {
+ return `(
+ ` + exactBind + ` = false AND (
+ word_similarity(` + qBind + `, mi.title) > 0.3 OR
+ word_similarity(` + qBind + `, COALESCE(mi.author, '')) > 0.3 OR
+ word_similarity(` + qBind + `, COALESCE(mi.series, '')) > 0.3 OR
+ EXISTS (SELECT 1 FROM unnest(mi.tags_search) AS tag WHERE word_similarity(` + qBind + `, tag) > 0.3) OR
+ EXISTS (SELECT 1 FROM unnest(mi.contributors_search) AS c WHERE word_similarity(` + qBind + `, c) > 0.3) OR
+ EXISTS (SELECT 1 FROM unnest(mi.narrators_search) AS n WHERE word_similarity(` + qBind + `, n) > 0.3)
+ ) OR
+ ` + exactBind + ` = true AND (
+ mi.title = ` + qBind + ` OR
+ COALESCE(mi.author, '') = ` + qBind + ` OR
+ COALESCE(mi.series, '') = ` + qBind + ` OR
+ ` + qBind + ` = ANY(mi.tags_search) OR
+ ` + qBind + ` = ANY(mi.contributors_search) OR
+ ` + qBind + ` = ANY(mi.narrators_search)
+ ))`
+}
+
+// ComposedCount — the live match count for the builder (rules + scope,
+// no pagination): a true COUNT(*), not the page length.
+func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {
+ b := newFilterBuilder(userID)
+ libBind := b.bind(libraryID)
+ whereSQL := b.headWhere(libraryID) + treeWhere(tree, b) + `
+ AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)`
+ sqlStr := `SELECT COUNT(*) ` + filterFrom + b.userIDBind +
+ b.joinBlock() + `
+WHERE ` + whereSQL
+
+ var n int
+ if err := s.raw.QueryRow(ctx, sqlStr, b.args...).Scan(&n); err != nil {
+ return 0, err
+ }
+ return n, nil
+}
+
+// ─── the composed suggestion engine ──────────────────────────────────
+
+// ComposedSuggestParams — one field's value menu computed under the
+// OTHER completed rules (symmetric, order-free; zero-match values are
+// never offered).
+type ComposedSuggestParams struct {
+ UserID pgtype.UUID
+ LibraryID pgtype.UUID // optional scope
+ FieldKey string
+ Typed string // the text typed so far
+ Tree *FilterTree // the other rules (nil = none)
+ Limit int
+}
+
+// ComposedFieldValues — one registry field's top values + counts under
+// the given rules. FieldValue (value/count/score/display) is the same
+// shape the static autocomplete returns.
+func (s *SearchService) ComposedFieldValues(ctx context.Context, p ComposedSuggestParams) ([]FieldValue, error) {
+ field, ok := FilterFieldByKey(p.FieldKey)
+ if !ok || field.Suggest == suggestNone {
+ return []FieldValue{}, nil
+ }
+ if p.Limit <= 0 {
+ p.Limit = 50
+ }
+
+ b := newFilterBuilder(p.UserID)
+ typedBind := b.bind(p.Typed)
+ limitBind := b.bind(p.Limit)
+ libBind := b.bind(p.LibraryID)
+
+ // The typed filter differs per source family: substring for the
+ // viewer's own words (note/highlight), name-filter for the id
+ // sources (library/collection/added_by — you find them by name),
+ // word_similarity for metadata text. userBind is bound ONLY for the
+ // viewer-scoped sources — an unused parameter is a 42P18 error.
+ var userBind string
+ var valueSource, typedFilter string
+ switch field.Suggest {
+ case suggestNoteText, suggestHighlight:
+ userBind = b.bind(p.UserID)
+ table, col := "media_notes", "content"
+ if field.Suggest == suggestHighlight {
+ table, col = "media_highlights", "selection_text"
+ }
+ valueSource = fmt.Sprintf(`CROSS JOIN LATERAL (
+ SELECT t.%s AS value
+ FROM %s t
+ WHERE t.media_item_id = mi.id AND t.user_id = %s
+) AS src`, col, table, userBind)
+ if p.Typed != "" {
+ typedFilter = " AND src.value ILIKE '%' || " + typedBind + " || '%'"
+ }
+ case suggestSeries:
+ valueSource = `CROSS JOIN LATERAL (
+ SELECT s AS value
+ FROM unnest(ARRAY[mi.series, mi.alternate_info->>'alternate_series']) AS s
+ WHERE s IS NOT NULL AND s <> ''
+) AS src`
+ typedFilter = " AND word_similarity(" + typedBind + ", src.value) > 0.3"
+ case suggestLibrary:
+ return s.composedIDSourceValues(ctx, b, p, "libraries l2", "l2.id::text", "l2.name",
+ "JOIN media_items mi ON mi.library_id = l2.id", typedBind, limitBind, libBind)
+ case suggestCollection:
+ return s.composedIDSourceValues(ctx, b, p,
+ "collections c JOIN collection_items ci ON ci.collection_id = c.id",
+ "c.id::text", "c.name",
+ "JOIN media_items mi ON ci.media_item_id = mi.id AND ci.excluded IS NOT TRUE",
+ typedBind, limitBind, libBind)
+ case suggestAddedBy:
+ return s.composedIDSourceValues(ctx, b, p,
+ "users u JOIN media_items mi ON mi.added_by_admin_id = u.id",
+ "u.id::text", "u.username", "", typedBind, limitBind, libBind)
+ default:
+ // Column / array sources: the value expr mirrors the field's
+ // match column (tags via the *_search column, like the flat
+ // autocomplete; arrays unnested per element).
+ switch field.Key {
+ case "author":
+ valueSource = "CROSS JOIN LATERAL unnest(mi.authors) AS src(value)"
+ case "narrator":
+ valueSource = "CROSS JOIN LATERAL unnest(mi.narrators) AS src(value)"
+ case "contributor":
+ valueSource = "CROSS JOIN LATERAL unnest(mi.contributors) AS src(value)"
+ case "publisher":
+ valueSource = "CROSS JOIN LATERAL unnest(mi.publishers) AS src(value)"
+ case "tags":
+ valueSource = "CROSS JOIN LATERAL unnest(mi.tags_search) AS src(value)"
+ default:
+ col := suggestColumnForField(field.Key)
+ if col == "" {
+ return []FieldValue{}, nil
+ }
+ valueSource = "CROSS JOIN LATERAL (SELECT " + col + " AS value WHERE " + col + " IS NOT NULL AND " + col + " <> '') AS src"
+ }
+ typedFilter = " AND word_similarity(" + typedBind + ", src.value) > 0.3"
+ }
+ if p.Typed == "" {
+ typedFilter = "" // no typing = the field's world, under the other rules
+ }
+
+ whereSQL := b.headWhere(p.LibraryID) + `
+ AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)` +
+ treeWhere(p.Tree, b) + `
+ AND src.value IS NOT NULL AND src.value <> ''` + typedFilter
+ sqlStr := `SELECT src.value, COUNT(*) AS count,
+ CASE WHEN ` + typedBind + ` = '' THEN 0 ELSE word_similarity(` + typedBind + `, src.value) END AS score
+ ` + filterFrom + b.userIDBind + `
+` + valueSource + b.joinBlock() + `
+WHERE ` + whereSQL + `
+GROUP BY src.value
+ORDER BY score DESC, count DESC, src.value
+LIMIT ` + limitBind
+
+ rows, err := s.raw.Query(ctx, sqlStr, b.args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := make([]FieldValue, 0, p.Limit)
+ for rows.Next() {
+ var v FieldValue
+ if err := rows.Scan(&v.Value, &v.Count, &v.Score); err != nil {
+ return nil, err
+ }
+ // Language mirrors the static autocomplete: the display NAME
+ // rides as the value (the search path normalizes names back to
+ // stored ISO codes — both here and in the composed clause).
+ if field.Key == "language" {
+ if name := LanguageDisplayName(v.Value); name != "" {
+ v.Value = name
+ }
+ }
+ out = append(out, v)
+ }
+ return out, rows.Err()
+}
+
+// composedIDSourceValues — the id-valued sources (library / collection /
+// added_by): value = id, display = name, typing filters the NAME, and
+// counts are books-in-scope. Zero-count sources drop out (INNER JOIN).
+func (s *SearchService) composedIDSourceValues(ctx context.Context, b *filterBuilder, p ComposedSuggestParams,
+ fromTables, valueExpr, displayExpr, joinMedia, typedBind, limitBind, libBind string) ([]FieldValue, error) {
+ nameFilter := ""
+ if p.Typed != "" {
+ nameFilter = " AND " + displayExpr + " ILIKE '%' || " + typedBind + " || '%'"
+ }
+ sqlStr := `SELECT ` + valueExpr + ` AS value, ` + displayExpr + ` AS display, COUNT(mi.id) AS count
+FROM ` + fromTables + `
+` + joinMedia + `
+JOIN libraries l ON mi.library_id = l.id
+LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = ` + b.userIDBind + `
+WHERE COALESCE(lv.is_visible, true) = true
+ AND mi.archived_at IS NULL AND mi.missing_scan_count = 0
+ AND (` + libBind + `::uuid IS NULL OR mi.library_id = ` + libBind + `::uuid)` + treeWhere(p.Tree, b) + nameFilter + `
+GROUP BY ` + valueExpr + `, ` + displayExpr + `
+ORDER BY count DESC, ` + displayExpr + `
+LIMIT ` + limitBind
+
+ rows, err := s.raw.Query(ctx, sqlStr, b.args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := make([]FieldValue, 0, p.Limit)
+ for rows.Next() {
+ var v FieldValue
+ if err := rows.Scan(&v.Value, &v.Display, &v.Count); err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, rows.Err()
+}
+
+// suggestColumnForField maps a field key to its scalar suggestion
+// column (fields whose menu is the column's own values).
+func suggestColumnForField(key string) string {
+ switch key {
+ case "imprint":
+ return "mi.imprint"
+ case "edition":
+ return "mi.edition"
+ case "genre":
+ return "mi.genre"
+ case "story_arc":
+ return "mi.story_arc"
+ case "age_rating":
+ return "mi.age_rating"
+ case "title":
+ return "mi.title"
+ case "subtitle":
+ return "mi.subtitle"
+ case "language":
+ return "mi.language"
+ case "format_group":
+ return "mi.format_group"
+ case "mime_type":
+ return "mi.mime_type"
+ case "manga_type":
+ return "mi.manga_type"
+ case "reading_direction":
+ return "mi.reading_direction"
+ }
+ return ""
+}
+
+// prettifyGenreValue kept out — genre suggestions ride raw values, the
+// same as the static autocomplete.
+
+// clauseLanguage — the language field normalizes its rule values
+// through the ingest-side map (display name → ISO code) at clause time,
+// exactly like the flat language_filter block. A rule built from the
+// menu ("English") matches stored codes ("en").
+func clauseLanguage(r FilterRule, b *filterBuilder) string {
+ r.Values = strings.Split(normalizeLanguageFilter(strings.Join(r.Values, ",")), ",")
+ return clauseScalarFuzzyFactory("mi.language")(r, b)
+}
diff --git a/internal/services/filter_registry.go b/internal/services/filter_registry.go
new file mode 100644
index 00000000..5fc46de2
--- /dev/null
+++ b/internal/services/filter_registry.go
@@ -0,0 +1,548 @@
+package services
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/google/uuid"
+)
+
+// ════════════════════════════════════════════════════════════════════
+// Filter rule builder — the field registry (faceted plan Phase 9).
+//
+// The mandate: EVERYTHING is filterable — every attribute on a book and
+// everything that pertains to one. This registry is the completeness
+// check: fields and operators are typed constants (the allow-list — no
+// user string ever reaches a field/operator position), every value is a
+// bound pgx parameter, and each entry carries its own match-clause
+// closure so the composed engines (search + suggestions + count) grow
+// by adding entries, not SQL.
+//
+// User-relative fields read the VIEWER's rows: the user id is
+// server-injected (never a client param) — two users get different
+// results for the same tree (the "my data" weld).
+//
+// Holding structures (library, collections, series — primary AND
+// alternate) are fields like any other; any future structure that
+// "holds" a book must join this registry.
+// ════════════════════════════════════════════════════════════════════
+
+// Filter operators (the allow-list).
+const (
+ OpContains = "contains"
+ OpNotContains = "not_contains"
+ OpIs = "is"
+ OpIsNot = "is_not"
+ OpEmpty = "empty"
+ OpIsTrue = "is_true"
+ OpIsFalse = "is_false"
+ OpBetween = "between"
+ OpAtLeast = "at_least"
+ OpAtMost = "at_most"
+ OpWithinDays = "within_days"
+ OpHas = "has"
+ OpMissing = "missing"
+ OpUnrated = "unrated"
+ OpIn = "in"
+ OpNotIn = "not_in"
+ OpInAny = "in_any"
+ OpInNone = "in_none"
+)
+
+// FilterKind — how a field's values match and what the value cell renders.
+type FilterKind string
+
+const (
+ KindScalarFuzzy FilterKind = "scalar_fuzzy" // word_similarity on a text column
+ KindArrayFuzzy FilterKind = "array_fuzzy" // word_similarity per element of a text[]
+ KindText FilterKind = "text" // fuzzy contains + exact is + empty (title-like)
+ KindEnum FilterKind = "enum" // exact scalar, = ANY(values)
+ KindBool FilterKind = "bool"
+ KindNumber FilterKind = "number"
+ KindDate FilterKind = "date"
+ KindPresence FilterKind = "presence" // has / missing
+ KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing
+ KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows)
+ KindStatus FilterKind = "status" // unread / reading / finished
+ KindLibrary FilterKind = "library"
+ KindCollection FilterKind = "collection"
+)
+
+// Suggestion sources — where a field's value menu comes from. A source
+// of suggestNone means the value cell is free input (numbers, dates,
+// bools, fixed enums).
+type SuggestSource string
+
+const (
+ suggestNone SuggestSource = ""
+ suggestAuthor SuggestSource = "author"
+ suggestNarrator SuggestSource = "narrator"
+ suggestContrib SuggestSource = "contributor"
+ suggestPublisher SuggestSource = "publisher"
+ suggestTags SuggestSource = "tags"
+ suggestColumn SuggestSource = "column" // the field's own scalar column
+ suggestSeries SuggestSource = "series" // primary + alternate_info
+ suggestNoteText SuggestSource = "note_text" // the viewer's note snippets
+ suggestHighlight SuggestSource = "highlight_text"
+ suggestLibrary SuggestSource = "library" // visibility-scoped libraries
+ suggestCollection SuggestSource = "collection" // the viewer's collections
+ suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
+)
+
+// Filter menu groups (display order for the field menu).
+const (
+ GroupPeople = "People"
+ GroupPublish = "Publishing"
+ GroupTopics = "Topics"
+ GroupBookFacts = "Book facts"
+ GroupComics = "Comics & manga"
+ GroupMyReading = "My reading"
+ GroupLibrary = "Library"
+)
+
+// FilterRule is one leaf: field + operator + values (all bound params).
+type FilterRule struct {
+ Field string
+ Op string
+ Values []string
+}
+
+// FilterTree is the qf wire shape: a group (mode + children) whose
+// children are rules or nested groups. Groups AND/OR their children
+// together; a group ANDs into its parent.
+type FilterTree struct {
+ Mode string `json:"mode"`
+ Children []FilterChild `json:"children"`
+}
+
+// FilterChild is one node: a rule (Field set) or a group (Mode set).
+type FilterChild struct {
+ Field string `json:"field,omitempty"`
+ Op string `json:"op,omitempty"`
+ Values []string `json:"values,omitempty"`
+ Mode string `json:"mode,omitempty"`
+ Children []FilterChild `json:"children,omitempty"`
+}
+
+// FilterField is one registry entry.
+type FilterField struct {
+ Key string // wire name (the qf "field" value)
+ Label string // menu label
+ Group string // menu group
+ Kind FilterKind
+ Ops []string // allowed operators, display order
+ DefaultOp string // operator a new rule starts on (field-switch reset)
+ Clause func(r FilterRule, b *filterBuilder) string
+ Suggest SuggestSource
+}
+
+// opsWithValue / opsWithoutValue drive validation.
+var opsNoValue = map[string]bool{
+ OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true,
+ OpInAny: true, OpInNone: true, OpHas: true, OpMissing: true,
+}
+
+// fixedDomains are the operator value domains that are STATIC (validated
+// here) rather than data-driven suggestions.
+var fixedDomains = map[string][]string{
+ "status": {"unread", "reading", "finished"},
+ "file_availability": {"available", "missing"},
+}
+
+// filterFields is THE registry. Order within a group = menu order.
+var filterFields = []FilterField{
+ // ── People ────────────────────────────────────────────────────
+ {Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseAuthorArray, Suggest: suggestAuthor},
+ {Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator},
+ {Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib},
+
+ // ── Publishing ────────────────────────────────────────────────
+ {Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher},
+ {Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn},
+ {Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn},
+ {Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clausePublishedYear, Suggest: suggestNone},
+
+ // ── Topics ────────────────────────────────────────────────────
+ {Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn},
+ {Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags},
+ {Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn},
+ {Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum,
+ Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
+ Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
+
+ // ── Book facts ────────────────────────────────────────────────
+ {Key: "title", Label: "Title", Group: GroupBookFacts, Kind: KindText,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn},
+ {Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn},
+ {Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone},
+ {Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence,
+ Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone},
+ {Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseLanguage, Suggest: suggestColumn},
+ {Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone},
+ {Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone},
+ {Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone},
+ {Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone},
+ {Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate,
+ Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
+ Clause: clauseDateFactory("mi.created_at"), Suggest: suggestNone},
+ {Key: "imported_at", Label: "Date imported", Group: GroupBookFacts, Kind: KindDate,
+ Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
+ Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone},
+ {Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence,
+ Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone},
+ {Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence,
+ Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
+ {Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier,
+ Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone},
+ {Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier,
+ Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone},
+ {Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.file_size", parseNumberInt), Suggest: suggestNone},
+ {Key: "file_availability", Label: "File availability", Group: GroupBookFacts, Kind: KindStatus,
+ Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
+ Clause: clauseFileAvailability, Suggest: suggestNone},
+ {Key: "explicit", Label: "Explicit", Group: GroupBookFacts, Kind: KindBool,
+ Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
+ Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
+ {Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
+ Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
+ Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
+ {Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
+ Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clauseCover, Suggest: suggestNone},
+ {Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier,
+ Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clauseAddedBy, Suggest: suggestAddedBy},
+
+ // ── Comics & manga ────────────────────────────────────────────
+ {Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy,
+ Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
+ // Full series membership: the primary series column AND the
+ // additional series held in alternate_info — a book whose
+ // membership of X is secondary must not hide.
+ Clause: clauseSeriesMembership, Suggest: suggestSeries},
+ {Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone},
+ {Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone},
+ {Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone},
+ {Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum,
+ Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
+ Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn},
+ {Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum,
+ Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
+ Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn},
+ {Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool,
+ Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
+ Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone},
+ {Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool,
+ Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
+ Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone},
+ {Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool,
+ Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
+ Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone},
+ {Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum,
+ Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
+ Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn},
+ {Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum,
+ Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
+ Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn},
+
+ // ── My reading (user-relative — the VIEWER's rows) ────────────
+ {Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus,
+ Ops: []string{OpIs}, DefaultOp: OpIs,
+ Clause: clauseStatus, Suggest: suggestNone},
+ {Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
+ Clause: clauseProgressPercent, Suggest: suggestNone},
+ {Key: "last_read", Label: "Last read", Group: GroupMyReading, Kind: KindDate,
+ Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
+ Clause: clauseLastRead, Suggest: suggestNone},
+ {Key: "my_rating", Label: "My rating", Group: GroupMyReading, Kind: KindNumber,
+ Ops: []string{OpIs, OpAtLeast, OpAtMost, OpUnrated}, DefaultOp: OpAtLeast,
+ Clause: clauseMyRating, Suggest: suggestNone},
+ {Key: "review", Label: "Has review", Group: GroupMyReading, Kind: KindPresence,
+ Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
+ Clause: clauseReviewPresence, Suggest: suggestNone},
+ {Key: "notes", Label: "Notes", Group: GroupMyReading, Kind: KindPresence,
+ Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
+ Clause: clauseNotesPresence, Suggest: suggestNone},
+ {Key: "highlights", Label: "Highlights", Group: GroupMyReading, Kind: KindPresence,
+ Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
+ Clause: clauseHighlightsPresence, Suggest: suggestNone},
+ {Key: "note_text", Label: "Note text", Group: GroupMyReading, Kind: KindNoteText,
+ // "Contains" here is substring ILIKE, NOT the system-wide fuzzy:
+ // your own words deserve exact-ish. A book matches when ANY of
+ // the viewer's notes on it match (EXISTS + bound values).
+ Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseNoteText, Suggest: suggestNoteText},
+ {Key: "highlight_text", Label: "Highlight text", Group: GroupMyReading, Kind: KindNoteText,
+ Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
+ Clause: clauseHighlightText, Suggest: suggestHighlight},
+ {Key: "time_spent", Label: "Time spent (minutes)", Group: GroupMyReading, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
+ Clause: clauseTimeSpent, Suggest: suggestNone},
+ {Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber,
+ Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
+ Clause: clausePagesRead, Suggest: suggestNone},
+ {Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
+ // Membership mirrors the collection page: excluded items and
+ // deleted collections never count; playlists are collections.
+ Ops: []string{OpIn, OpNotIn, OpInAny, OpInNone}, DefaultOp: OpIn,
+ Clause: clauseCollection, Suggest: suggestCollection},
+
+ // ── Library (holding structure + family lens) ─────────────────
+ {Key: "library", Label: "Library", Group: GroupLibrary, Kind: KindLibrary,
+ // Values are library IDs; the menu only ever offers the viewer's
+ // VISIBLE libraries (family lens — a hidden library can not even
+ // be offered).
+ Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
+ Clause: clauseLibrary, Suggest: suggestLibrary},
+}
+
+// FilterFieldByKey returns the registry entry for a wire key.
+func FilterFieldByKey(key string) (FilterField, bool) {
+ for _, f := range filterFields {
+ if f.Key == key {
+ return f, true
+ }
+ }
+ return FilterField{}, false
+}
+
+// FilterFieldsByGroup returns the registry grouped for the field menu,
+// groups in display order.
+func FilterFieldsByGroup() []struct {
+ Group string
+ Fields []FilterField
+} {
+ order := []string{GroupPeople, GroupPublish, GroupTopics, GroupBookFacts, GroupComics, GroupMyReading, GroupLibrary}
+ out := make([]struct {
+ Group string
+ Fields []FilterField
+ }, 0, len(order))
+ for _, g := range order {
+ group := struct {
+ Group string
+ Fields []FilterField
+ }{Group: g}
+ for _, f := range filterFields {
+ if f.Group == g {
+ group.Fields = append(group.Fields, f)
+ }
+ }
+ out = append(out, group)
+ }
+ return out
+}
+
+// FilterFieldKeys returns every registered key (the completeness check
+// for tests).
+func FilterFieldKeys() []string {
+ keys := make([]string, 0, len(filterFields))
+ for _, f := range filterFields {
+ keys = append(keys, f.Key)
+ }
+ return keys
+}
+
+// fieldOpsAllowed reports whether op is allowed on the field.
+func fieldOpsAllowed(f FilterField, op string) bool {
+ for _, o := range f.Ops {
+ if o == op {
+ return true
+ }
+ }
+ return false
+}
+
+// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI
+// discourages depth but the model allows a little headroom).
+const maxFilterDepth = 8
+
+// ParseFilterTree parses + validates the qf wire JSON. Validation is
+// the allow-list enforcement: unknown fields/operators and malformed
+// values are a parse error (→ 400), never a silently-ignored token.
+func ParseFilterTree(raw string) (*FilterTree, error) {
+ var t FilterTree
+ if err := json.Unmarshal([]byte(raw), &t); err != nil {
+ return nil, fmt.Errorf("invalid qf JSON: %w", err)
+ }
+ if err := validateGroup(&FilterChild{Mode: t.Mode, Children: t.Children}, 1); err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+func validateGroup(g *FilterChild, depth int) error {
+ if depth > maxFilterDepth {
+ return fmt.Errorf("filter group nesting deeper than %d", maxFilterDepth)
+ }
+ if g.Mode != "all" && g.Mode != "any" {
+ return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
+ }
+ if len(g.Children) == 0 {
+ return fmt.Errorf("filter group has no children")
+ }
+ for _, c := range g.Children {
+ if c.Field == "" && c.Mode != "" {
+ if err := validateGroup(&c, depth+1); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := validateRule(c); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// numericParsers map each numeric field's wire values to the Go value
+// bound into SQL — validation and clause-building share one parser per
+// field, so a value that passes validation always binds.
+var numericParsers = map[string]func(string) (any, error){
+ "published_year": parseNumberInt,
+ "page_count": parseNumberInt,
+ "chapter_count": parseNumberInt,
+ "total_characters": parseNumberInt,
+ "file_size": parseNumberInt,
+ "series_count": parseNumberInt,
+ "volume": parseNumberInt,
+ "my_rating": parseNumberInt,
+ "series_number": parseNumberFloat,
+ "community_rating": parseNumberFloat,
+ "progress_percent": parseNumberFloat,
+ "time_spent": parseNumberFloat,
+ "pages_read": parseNumberFloat,
+}
+
+func validateRule(c FilterChild) error {
+ f, ok := FilterFieldByKey(c.Field)
+ if !ok {
+ return fmt.Errorf("unknown filter field %q", c.Field)
+ }
+ if !fieldOpsAllowed(f, c.Op) {
+ return fmt.Errorf("operator %q not allowed on field %q", c.Op, c.Field)
+ }
+ if opsNoValue[c.Op] {
+ if len(c.Values) != 0 {
+ return fmt.Errorf("operator %q takes no values", c.Op)
+ }
+ return nil
+ }
+ if len(c.Values) == 0 {
+ return fmt.Errorf("operator %q on field %q needs a value", c.Op, c.Field)
+ }
+ if c.Op == OpBetween && len(c.Values) != 2 {
+ return fmt.Errorf("between takes exactly two values")
+ }
+ // Numeric fields: values must parse (they bind as numbers).
+ if parse, ok := numericParsers[f.Key]; ok {
+ for _, v := range c.Values {
+ if _, err := parse(v); err != nil {
+ return fmt.Errorf("field %q needs numeric values, got %q", f.Key, v)
+ }
+ }
+ }
+ // Date within_days: a positive integer day count.
+ if c.Op == OpWithinDays {
+ n, err := strconv.Atoi(c.Values[0])
+ if err != nil || n <= 0 {
+ return fmt.Errorf("within_days needs a positive day count, got %q", c.Values[0])
+ }
+ }
+ // Static domains (status, file_availability).
+ if domain, ok := fixedDomains[f.Key]; ok {
+ for _, v := range c.Values {
+ found := false
+ for _, d := range domain {
+ if d == v {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("field %q value must be one of %v, got %q", f.Key, domain, v)
+ }
+ }
+ }
+ // UUID-valued fields (collection, library, added_by "is").
+ switch f.Key {
+ case "library", "collection":
+ for _, v := range c.Values {
+ if _, err := uuid.Parse(v); err != nil {
+ return fmt.Errorf("field %q values must be UUIDs, got %q", f.Key, v)
+ }
+ }
+ case "added_by":
+ if c.Op == OpIs {
+ if _, err := uuid.Parse(c.Values[0]); err != nil {
+ return fmt.Errorf("added_by \"is\" needs a user UUID, got %q", c.Values[0])
+ }
+ }
+ }
+ return nil
+}
+
+func parseNumberInt(v string) (any, error) {
+ n, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ return n, nil
+}
+
+func parseNumberFloat(v string) (any, error) {
+ f, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return nil, err
+ }
+ return f, nil
+}
diff --git a/internal/services/filter_registry_test.go b/internal/services/filter_registry_test.go
new file mode 100644
index 00000000..3a184f00
--- /dev/null
+++ b/internal/services/filter_registry_test.go
@@ -0,0 +1,131 @@
+package services
+
+// Filter registry unit tests (faceted plan Phase 9): every entry is
+// complete (kind, operators, default operator, clause, suggestion
+// source), the mandated surfaces are present (including the three
+// holding structures), and the qf parse is a strict allow-list.
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFilterRegistryCompleteness(t *testing.T) {
+ keys := FilterFieldKeys()
+ require.NotEmpty(t, keys)
+
+ seen := map[string]bool{}
+ for _, key := range keys {
+ require.False(t, seen[key], "duplicate registry key %q", key)
+ seen[key] = true
+
+ f, ok := FilterFieldByKey(key)
+ require.True(t, ok)
+ require.NotEmpty(t, f.Label, "%s: label", key)
+ require.NotEmpty(t, f.Group, "%s: menu group", key)
+ require.NotEmpty(t, f.Kind, "%s: kind", key)
+ require.NotEmpty(t, f.Ops, "%s: operators", key)
+ require.NotNil(t, f.Clause, "%s: match clause", key)
+ require.Contains(t, f.Ops, f.DefaultOp, "%s: default operator must be allowed", key)
+ }
+
+ // The mandated surfaces — every book attribute class, the user-
+ // relative class, and the three HOLDING STRUCTURES (library,
+ // collections, series — the registry is the completeness check).
+ mandated := []string{
+ // people / publishing / topics
+ "author", "narrator", "contributor", "publisher", "imprint",
+ "edition", "published_year", "genre", "tags", "story_arc", "age_rating",
+ // reading surface (comics & manga)
+ "format_group", "mime_type", "is_reflowable", "has_fixed_layout",
+ "is_black_and_white", "manga_type", "reading_direction", "explicit", "abridged",
+ // series facts + numbers + dates + text + presence + identifiers
+ "series", "series_number", "series_count", "volume",
+ "page_count", "chapter_count", "total_characters", "file_size",
+ "community_rating", "added_at", "imported_at",
+ "title", "subtitle", "description", "summary", "language",
+ "web_url", "metadata_notes", "isbn", "asin", "cover",
+ "file_availability", "added_by",
+ // user-relative (everything that pertains to a book)
+ "status", "progress_percent", "last_read", "my_rating", "review",
+ "notes", "highlights", "note_text", "highlight_text", "time_spent",
+ "pages_read", "collection",
+ // holding structure
+ "library",
+ }
+ for _, key := range mandated {
+ assert.True(t, seen[key], "mandated filter field %q missing from the registry", key)
+ }
+}
+
+func TestFilterParseValidation(t *testing.T) {
+ valid := func(raw string) {
+ _, err := ParseFilterTree(raw)
+ require.NoError(t, err, "expected %s to parse", raw)
+ }
+ invalid := func(raw, wantContain string) {
+ _, err := ParseFilterTree(raw)
+ require.Error(t, err, "expected %s to be rejected", raw)
+ if wantContain != "" {
+ require.Contains(t, err.Error(), wantContain)
+ }
+ }
+
+ valid(`{"mode":"all","children":[{"field":"author","op":"contains","values":["Voss"]}]}`)
+ valid(`{"mode":"any","children":[
+ {"field":"author","op":"contains","values":["Voss"]},
+ {"mode":"all","children":[{"field":"genre","op":"is","values":["fantasy"]}]}]}`)
+ valid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["10","20"]}]}`)
+ valid(`{"mode":"all","children":[{"field":"cover","op":"has"}]}`)
+ valid(`{"mode":"all","children":[{"field":"status","op":"is","values":["reading","finished"]}]}`)
+ valid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["14"]}]}`)
+ valid(`{"mode":"all","children":[{"field":"my_rating","op":"unrated"}]}`)
+ valid(`{"mode":"all","children":[{"field":"collection","op":"in_any"}]}`)
+ valid(`{"mode":"all","children":[{"field":"library","op":"is","values":["` +
+ "12345678-1234-1234-1234-123456789012" + `"]}]}`)
+
+ invalid(`{not json`, "invalid qf JSON")
+ invalid(`{"mode":"all","children":[]}`, "no children")
+ invalid(`{"mode":"sometimes","children":[{"field":"author","op":"contains","values":["x"]}]}`, "mode must be all|any")
+ invalid(`{"mode":"all","children":[{"field":"not_a_field","op":"is","values":["x"]}]}`, "unknown filter field")
+ invalid(`{"mode":"all","children":[{"field":"author","op":"explodes","values":["x"]}]}`, "not allowed on field")
+ invalid(`{"mode":"all","children":[{"field":"page_count","op":"contains","values":["1"]}]}`, "not allowed on field")
+ invalid(`{"mode":"all","children":[{"field":"page_count","op":"at_least","values":["abc"]}]}`, "numeric")
+ invalid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["1"]}]}`, "exactly two")
+ invalid(`{"mode":"all","children":[{"field":"cover","op":"has","values":["x"]}]}`, "takes no values")
+ invalid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["0"]}]}`, "positive day count")
+ invalid(`{"mode":"all","children":[{"field":"status","op":"is","values":["platypus"]}]}`, "must be one of")
+ invalid(`{"mode":"all","children":[{"field":"library","op":"is","values":["not-a-uuid"]}]}`, "must be UUIDs")
+ invalid(`{"mode":"all","children":[{"field":"author","op":"contains"}]}`, "needs a value")
+}
+
+func TestFilterParseDepthCap(t *testing.T) {
+ // Nesting beyond maxFilterDepth is rejected.
+ raw := `{"mode":"all","children":[`
+ for i := 0; i < maxFilterDepth+2; i++ {
+ raw += `{"mode":"all","children":[`
+ }
+ raw += `{"field":"author","op":"contains","values":["x"]}`
+ for i := 0; i < maxFilterDepth+2; i++ {
+ raw += `]}`
+ }
+ raw += `]}`
+ _, err := ParseFilterTree(raw)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "nesting deeper")
+}
+
+func TestFilterFieldsByGroupOrdering(t *testing.T) {
+ groups := FilterFieldsByGroup()
+ require.NotEmpty(t, groups)
+ first := groups[0].Group
+ if !strings.EqualFold(first, GroupPeople) {
+ t.Fatalf("first menu group should be %q, got %q", GroupPeople, first)
+ }
+ for _, g := range groups {
+ require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
+ }
+}
diff --git a/internal/services/search.go b/internal/services/search.go
index 67136342..b09bcb65 100644
--- a/internal/services/search.go
+++ b/internal/services/search.go
@@ -14,12 +14,16 @@ import (
// SearchService handles all search and filter operations
type SearchService struct {
- db *database.Queries
+ db *database.Queries
+ raw database.DBTX // the raw pgx handle — the composed filter engines' path
}
-// NewSearchService creates a new search service instance
-func NewSearchService(db *database.Queries) *SearchService {
- return &SearchService{db: db}
+// NewSearchService creates a new search service instance. raw is the
+// same pool db wraps (database.DBTX) — the composed filter engines run
+// hand-built, allow-listed SQL with bound parameters, which static sqlc
+// queries cannot express.
+func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService {
+ return &SearchService{db: db, raw: raw}
}
// SearchParams contains parameters for unified search
@@ -52,6 +56,14 @@ type SearchParams struct {
ReadingDirectionFilter string
ExplicitFilter pgtype.Bool
AbridgedFilter pgtype.Bool
+
+ // Filter rule builder (faceted plan Phase 9). When FilterTree is
+ // set the composed engine runs INSTEAD of the flat facet params
+ // (qf wins over flat on a hand-crafted URL — precedence pinned by
+ // test); FilterParseErr carries a qf parse/validation failure for
+ // the endpoints to answer 400.
+ FilterTree *FilterTree
+ FilterParseErr string
}
// parseSearchQuery detects quoted strings for exact match search
@@ -272,11 +284,15 @@ type FieldSearchParams struct {
Offset int
}
-// FieldValue represents a single field value with metadata
+// FieldValue is a single field value with metadata. Display carries a
+// human label when the wire value is an ID (library/collection/added_by
+// suggestions) — omitted everywhere else, so the classic static shape
+// {value, count, score} is unchanged.
type FieldValue struct {
- Value string `json:"value"`
- Count int64 `json:"count"`
- Score float64 `json:"score"`
+ Value string `json:"value"`
+ Count int64 `json:"count"`
+ Score float64 `json:"score"`
+ Display string `json:"display,omitempty"`
}
// SearchFieldValues handles field-specific search for autocomplete dropdowns
@@ -399,8 +415,22 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
}
// ExecuteSearch performs search and returns results with count
-// Shared between JSON endpoint and HTML rendering
+// Shared between JSON endpoint and HTML rendering.
+// Routing: a qf filter tree → the composed engine (flat params ignored
+// — qf wins); else the static unified query. A parse/validation failure
+// surfaces as ErrInvalidFilterTree (→ 400 at the endpoints).
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
+ if params.FilterParseErr != "" {
+ return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr)
+ }
+ if params.FilterTree != nil {
+ rows, err := s.ComposedSearch(ctx, params)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return nil, 0, err
+ }
+ count := len(rows)
+ return rows, count, nil
+ }
results, err := s.SearchMediaItemsUnified(ctx, params)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ
index 34b41117..cc94451d 100644
--- a/templates/bookshelf.templ
+++ b/templates/bookshelf.templ
@@ -170,114 +170,98 @@ templ BookShelf(
}
-
-
- = 2) fetchAuthorValues($el)"
- />
-
-
-
-
-
= 2) fetchTagValues($el)"
- @keydown="onTagFilterKeydown($event)"
- @blur="hideTagDropdown()"
- />
-
-
-
-
-
-
-
-
- = 2) fetchSeriesValues($el)"
- />
-
-
-
-
- = 2) fetchNarratorValues($el)"
- />
-
-
-
-
- = 2) fetchLanguageValues($el)"
- />
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ and
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -300,7 +284,10 @@ templ BookShelf(
= 2) fetchAuthorValues($el)\">
= 2) fetchSeriesValues($el)\">
= 2) fetchNarratorValues($el)\">
= 2) fetchLanguageValues($el)\">
0\" x-cloak>
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\">
0\">
0\" x-cloak>
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -422,7 +422,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 315, Col: 59}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 302, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -445,7 +445,7 @@ func BookShelf(
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 324, Col: 126}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 311, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func BookShelf(
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 333, Col: 32}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 320, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -489,7 +489,7 @@ func BookShelf(
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 338, Col: 126}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 325, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
diff --git a/web/src/bookshelf.ts b/web/src/bookshelf.ts
index 670192b8..a7712d46 100644
--- a/web/src/bookshelf.ts
+++ b/web/src/bookshelf.ts
@@ -2,26 +2,44 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
-import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
+import type {
+ FilterGroupState,
+ FilterRuleState,
+} from "./filter_builder";
function clearFilters(): void {
// Clear all form state (reuses helper)
clearFormWithoutSubmit();
+ // Reset the rule builder tree (the builder's wire inputs are
+ // serializer-managed and already dropped by cleanupDynamicHiddenInputs)
+ builderData()?.clearBuilder();
+
// Trigger form submit with cleared filters
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
window.htmx.trigger(filterForm, "submit");
}
+// builderData returns the rule builder's Alpine data (nested x-data
+// inside the drawer) — the same DOM-lookup pattern the bookshelf
+// component uses.
+function builderData(): any | null {
+ const el = document.querySelector('[x-data*="filterBuilderExtra"]');
+ if (!el || !(el as any)._x_dataStack) return null;
+ return (el as any)._x_dataStack[0] ?? null;
+}
+
// Remove dynamically created hidden inputs from filter form
-// Preserves: limit, offset, has_cover (static/managed inputs)
+// Preserves: limit, offset (static/managed inputs). The builder's wire
+// inputs (flat facet params + qf) are serializer-managed and dropped so
+// the next serialization starts clean.
function cleanupDynamicHiddenInputs(): void {
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
// Names of inputs to preserve (static or Alpine-managed)
- const preserveNames = ["limit", "offset", "has_cover"];
+ const preserveNames = ["limit", "offset"];
// Find all hidden inputs
const hiddenInputs = filterForm.querySelectorAll('input[type="hidden"]');
@@ -43,23 +61,6 @@ function clearFormWithoutSubmit(): void {
// Reset all form fields to their HTML defaults
filterForm.reset();
- // Remove has_cover input entirely
- const hasCoverInput = document.querySelector(
- '[name="has_cover"]',
- ) as HTMLInputElement;
- if (hasCoverInput) {
- hasCoverInput.remove();
- }
-
- // Reset Alpine hasCoverState to null
- const bookshelfComponent = document.querySelector('[x-data*="bookshelf"]');
- if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
- const data = (bookshelfComponent as any)._x_dataStack[0];
- if (data && typeof data.hasCoverState !== "undefined") {
- data.hasCoverState = null;
- }
- }
-
// Remove any hidden inputs from previous filter loads
cleanupDynamicHiddenInputs();
@@ -81,12 +82,8 @@ Alpine.data("bookshelf", () => ({
showSaveModal: false,
showFiltersDropdown: false,
filtersOpen: false,
- hasCoverState: null as boolean | null,
dropdownAlign: "right" as "left" | "right",
resizeTimeout: null as number | null,
- tagSuggestions: [] as TagSuggestion[],
- showTagDropdown: false,
- tagHighlightIndex: -1,
// Active-filter chips (Batch B): Alpine renders them from this state;
// refreshed by refreshFilterChips after every grid refetch.
activeFilters: [] as FilterChip[],
@@ -107,9 +104,12 @@ Alpine.data("bookshelf", () => ({
"filter-form",
) as HTMLFormElement;
if (!filterForm) return;
- if (param === "has_cover") {
- filterForm.querySelector('[name="has_cover"]')?.remove();
- this.hasCoverState = null;
+ // Tree chips (qf mode): "rule:
" — remove that rule from the
+ // builder tree, resync the wire inputs, re-apply.
+ if (param.startsWith("rule:")) {
+ const builder = builderData();
+ const path = param.slice(5).split("-").map((n) => parseInt(n, 10));
+ builder?.removeNode(path);
window.htmx.trigger(filterForm, "submit");
return;
}
@@ -172,18 +172,6 @@ Alpine.data("bookshelf", () => ({
});
});
},
- // Cycle through has_cover states: null -> true -> false -> null
- cycleHasCover(): void {
- if (this.hasCoverState === null) {
- this.hasCoverState = true;
- } else if (this.hasCoverState === true) {
- this.hasCoverState = false;
- } else {
- this.hasCoverState = null;
- }
- // NOTE: We don't trigger form submission here
- // The user must click "Search" or press Enter to apply the filter
- },
// Handle post-HTMX swap cleanup for saved filters
afterFilterSave(): void {
// Hide empty state if it exists
@@ -257,32 +245,11 @@ Alpine.data("bookshelf", () => ({
clearFormWithoutSubmit();
// Don't clear form - just update values
- // Populate form fields from filter data
+ // Populate form fields from filter data. Every saved param lands
+ // as a (hidden) form input — the builder's hydration reads them
+ // (qf wins over flat, the pinned precedence).
Object.entries(filterData).forEach(([key, value]) => {
- if (key === "has_cover") {
- const bookshelfComponent = document.querySelector(
- '[x-data*="bookshelf"]',
- );
- if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
- const data = (bookshelfComponent as any)._x_dataStack[0];
- if (data && typeof data.hasCoverState !== "undefined") {
- // Convert string value to boolean|null
- if (value === "true") {
- data.hasCoverState = true;
- } else if (value === "false") {
- data.hasCoverState = false;
- } else {
- data.hasCoverState = null;
- }
- }
- }
- return; // Don't process has_cover like other fields
- }
-
if (!value) return; // Skip empty values
- // Special handling for has_cover - update Alpine state directly
-
- // Handle all other fields normally
const visibleField = filterForm.querySelector(
`[name="${key}"]`,
) as HTMLInputElement;
@@ -302,6 +269,9 @@ Alpine.data("bookshelf", () => ({
}
});
+ // Rehydrate the rule-builder tree from the refilled form
+ builderData()?.hydrateFromForm();
+
// Trigger HTMX to apply the filter by submitting the form
window.htmx.trigger(filterForm, "submit");
@@ -349,139 +319,6 @@ Alpine.data("bookshelf", () => ({
showToast("Error deleting filter", "error");
}
},
-
- // Autocomplete helper function
- async fetchFieldValues(
- field: string,
- search: string,
- datalistId: string,
- ): Promise {
- const token = localStorage.getItem("token");
- if (!token) {
- console.error("Not authenticated");
- return;
- }
-
- if (search.length < 2) return;
-
- const currentLibraryId = (
- document.getElementById("library-select") as HTMLSelectElement
- )?.value;
-
- const libParam = currentLibraryId ? `&library_id=${currentLibraryId}` : "";
-
- try {
- const response = await fetch(
- `/api/media-items/search?${field}=${encodeURIComponent(
- search,
- )}${libParam}&limit=50`,
- {
- headers: { Authorization: `Bearer ${token}` },
- },
- );
-
- if (!response.ok) {
- console.error("Failed to fetch field values");
- return;
- }
-
- const data = await response.json();
-
- // Update datalist via DOM manipulation
- const datalist = document.getElementById(datalistId);
- if (!datalist) {
- console.error(`Datalist ${datalistId} not found`);
- return;
- }
-
- // Clear existing options
- datalist.innerHTML = "";
-
- // Add new options
- data.results.forEach((item: { value: string; count: number }) => {
- const option = document.createElement("option");
- option.value = item.value;
- option.textContent = `${item.value} (${item.count})`;
- datalist.appendChild(option);
- });
- } catch (error) {
- console.error("Error fetching field values:", error);
- }
- },
-
- // Fetch author values for autocomplete
- async fetchAuthorValues(input: HTMLInputElement): Promise {
- await this.fetchFieldValues("author", input.value, "author-datalist");
- },
-
- // Fetch tag values for autocomplete (custom dropdown)
- async fetchTagValues(input: HTMLInputElement): Promise {
- const libraryId = (document.getElementById("library-select") as HTMLSelectElement)?.value;
- if (!libraryId || input.value.length < 2) {
- this.tagSuggestions = [];
- this.showTagDropdown = false;
- return;
- }
- const results = await searchTagSuggestions(input.value, libraryId);
- this.tagSuggestions = results;
- this.showTagDropdown = results.length > 0;
- this.tagHighlightIndex = -1;
- },
-
- selectTagSuggestion(tag: string) {
- const input = document.querySelector('input[name="tags_filter"]') as HTMLInputElement;
- if (input) input.value = tag;
- this.showTagDropdown = false;
- this.tagSuggestions = [];
- this.tagHighlightIndex = -1;
- },
-
- hideTagDropdown() {
- setTimeout(() => { this.showTagDropdown = false; this.tagHighlightIndex = -1; }, 200);
- },
-
- onTagFilterKeydown(event: KeyboardEvent) {
- if (event.key === "ArrowDown") {
- event.preventDefault();
- if (this.showTagDropdown && this.tagSuggestions.length > 0) {
- this.tagHighlightIndex = (this.tagHighlightIndex + 1) % this.tagSuggestions.length;
- }
- } else if (event.key === "ArrowUp") {
- event.preventDefault();
- if (this.showTagDropdown && this.tagSuggestions.length > 0) {
- this.tagHighlightIndex = this.tagHighlightIndex <= 0
- ? this.tagSuggestions.length - 1
- : this.tagHighlightIndex - 1;
- }
- } else if (event.key === "Enter" && this.tagHighlightIndex >= 0 && this.showTagDropdown) {
- event.preventDefault();
- this.selectTagSuggestion(this.tagSuggestions[this.tagHighlightIndex].value);
- } else if (event.key === "Escape") {
- this.showTagDropdown = false;
- this.tagSuggestions = [];
- this.tagHighlightIndex = -1;
- }
- },
-
- // // Fetch genre values for autocomplete
- // async fetchGenreValues(input: HTMLInputElement): Promise {
- // await this.fetchFieldValues("genre", input.value, "genre-datalist");
- // },
-
- // Fetch series values for autocomplete
- async fetchSeriesValues(input: HTMLInputElement): Promise {
- await this.fetchFieldValues("series", input.value, "series-datalist");
- },
-
- // Fetch narrator values for autocomplete (A2a)
- async fetchNarratorValues(input: HTMLInputElement): Promise {
- await this.fetchFieldValues("narrator", input.value, "narrator-datalist");
- },
-
- // Fetch language values for autocomplete
- async fetchLanguageValues(input: HTMLInputElement): Promise {
- await this.fetchFieldValues("language", input.value, "language-datalist");
- },
}));
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
@@ -494,6 +331,26 @@ Alpine.data("bookshelf", () => ({
function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
if (!filterForm) return false;
const params = new URLSearchParams(window.location.search);
+ // Rule-builder trees ride the qf param (faceted plan Phase 9).
+ // Precedence (pinned): when qf is present the flat params are
+ // ignored — the tree IS the query.
+ const qf = params.get("qf");
+ if (qf) {
+ const input = filterForm.querySelector(
+ '[name="qf"]',
+ ) as HTMLInputElement | null;
+ if (input) {
+ input.value = qf;
+ } else {
+ const hidden = document.createElement("input");
+ hidden.type = "hidden";
+ hidden.name = "qf";
+ hidden.value = qf;
+ filterForm.appendChild(hidden);
+ }
+ builderData()?.hydrateFromForm();
+ return true;
+ }
const filterNames = [
"author_filter",
"series_filter",
@@ -513,6 +370,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
"abridged_filter",
"year_min",
"year_max",
+ "qf",
];
let applied = false;
for (const name of filterNames) {
@@ -545,6 +403,8 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
interface FilterChip {
param: string;
label: string;
+ // Tree chips only: negated operators render red.
+ negated: boolean;
}
// prettifyGenreLabel ports templates.prettifyGenre (Batch B transform):
@@ -610,23 +470,55 @@ function refreshFilterChips(): void {
if (typeof data.activeFilters === "undefined") return;
const chips: FilterChip[] = [];
- filterForm
- .querySelectorAll(
- '[name$="_filter"], [name="year_min"], [name="year_max"]',
- )
- .forEach((input) => {
- if (input.value) {
- chips.push({
- param: input.name,
- label: facetChipLabel(input.name, input.value),
- });
- }
- });
- const hasCoverInput = filterForm.querySelector(
- '[name="has_cover"]',
+ const qfInput = filterForm.querySelector(
+ '[name="qf"]',
) as HTMLInputElement | null;
- if (hasCoverInput && hasCoverInput.value) {
- chips.push({ param: "has_cover", label: facetChipLabel("has_cover", hasCoverInput.value) });
+
+ if (qfInput && qfInput.value) {
+ // Tree mode: one chip per rule (red for negations — the chips
+ // template styles them via chip.negated). Values that are IDs
+ // (collection/library) label through the builder's id→name cache.
+ const builder = builderData();
+ try {
+ const tree = JSON.parse(qfInput.value) as FilterGroupState;
+ const walk = (group: FilterGroupState, path: number[]) => {
+ (group.children as (FilterRuleState | FilterGroupState)[]).forEach(
+ (child, idx) => {
+ const childPath = [...path, idx];
+ if ("children" in (child as FilterGroupState)) {
+ walk(child as FilterGroupState, childPath);
+ return;
+ }
+ const rule = child as FilterRuleState;
+ const label = builder
+ ? builder.chipLabelFor(rule)
+ : `${rule.field} ${rule.op}`;
+ chips.push({
+ param: "rule:" + childPath.join("-"),
+ label,
+ negated: builder?.isNegated(rule) ?? false,
+ });
+ },
+ );
+ };
+ walk(tree, []);
+ } catch {
+ // malformed qf — no tree chips
+ }
+ } else {
+ filterForm
+ .querySelectorAll(
+ '[name$="_filter"], [name="year_min"], [name="year_max"], [name="has_cover"]',
+ )
+ .forEach((input) => {
+ if (input.value) {
+ chips.push({
+ param: input.name,
+ label: facetChipLabel(input.name, input.value),
+ negated: false,
+ });
+ }
+ });
}
data.activeFilters = chips;
@@ -640,7 +532,7 @@ function refreshFilterChips(): void {
"imprint_filter", "edition_filter", "age_rating_filter",
"format_group_filter", "manga_type_filter", "reading_direction_filter",
"explicit_filter", "abridged_filter", "year_min", "year_max",
- "has_cover", "library_id",
+ "has_cover", "library_id", "qf",
]);
const next = new URLSearchParams();
url.searchParams.forEach((value, key) => {
@@ -674,8 +566,15 @@ function initBookshelf(): void {
// pre-fills the form and applies it to the first grid load. Author
// links on the detail page (and anything else) can share filtered
// views; a comma-joined value is a multi-select (server splits it).
+ // qf deep links rehydrate as the rule tree (qf wins over flat — the
+ // pinned precedence).
const urlApplied = applyURLFilters(filterForm);
+ // The builder's tree mirrors the applied wire state (URL or empty)
+ // so the drawer opens with the current rules; the wire inputs are
+ // then serializer-owned.
+ builderData()?.hydrateFromForm();
+
// Check if books were already rendered server-side
const booksGrid = document.getElementById("books-grid");
const hasServerBooks =
diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts
new file mode 100644
index 00000000..ef6aac7e
--- /dev/null
+++ b/web/src/filter_builder.ts
@@ -0,0 +1,774 @@
+// Filter rule builder (faceted plan Phase 9) — the drawer's ONLY filter
+// UI. Alpine owns the rule-tree state; templ owns the markup (the
+// sanctioned island: same pattern as the chips bar, NO innerHTML
+// templating). The server renders nothing per keystroke: value menus
+// are server JSON (field-aware, computed under the OTHER rules),
+// rendered by Alpine.
+//
+// The tree serializes to the FLAT facet params when it is simple (one
+// rule per field, flat-expressible operators — readable deep links
+// preserved), else to one compact `qf` JSON param. saved_filters store
+// either (no schema change). On a hand-crafted URL carrying both, qf
+// wins server-side.
+
+import { Alpine } from "./alpine";
+
+// ─── operator + field metadata (client mirror of
+// internal/services/filter_registry.go — labels drive the menus) ────
+
+export interface OpMeta {
+ key: string;
+ label: string;
+ negated: boolean;
+ arity: "none" | "one" | "two" | "many";
+}
+
+const op = (
+ key: string,
+ label: string,
+ negated: boolean,
+ arity: OpMeta["arity"],
+): OpMeta => ({ key, label, negated, arity });
+
+export const OPS: Record = {
+ contains: op("contains", "contains", false, "many"),
+ not_contains: op("doesn't contain", "doesn't contain", true, "many"),
+ is: op("is", "is", false, "many"),
+ is_not: op("isn't", "isn't", true, "many"),
+ empty: op("is empty", "is empty", false, "none"),
+ is_true: op("is true", "is true", false, "none"),
+ is_false: op("is false", "is false", false, "none"),
+ between: op("between", "between", false, "two"),
+ at_least: op("at least", "at least", false, "one"),
+ at_most: op("at most", "at most", false, "one"),
+ within_days: op("within the last", "within the last", false, "one"),
+ has: op("has", "has", false, "none"),
+ missing: op("missing", "missing", false, "none"),
+ unrated: op("unrated", "unrated", false, "none"),
+ in: op("in", "in", false, "many"),
+ not_in: op("isn't in", "isn't in", true, "many"),
+ in_any: op("in any", "in any", false, "none"),
+ in_none: op("in none", "in none", false, "none"),
+};
+
+export interface FieldMeta {
+ key: string;
+ label: string;
+ group: string;
+ // value kind for the cell: text (with server suggestions when
+ // suggest), number, days, id (server suggestions, id values), fixed
+ // (static option list), none.
+ valueKind: "text" | "number" | "days" | "id" | "fixed" | "none";
+ suggest: boolean;
+ options?: string[];
+ ops: string[];
+ defaultOp: string;
+}
+
+const F = (
+ key: string,
+ label: string,
+ group: string,
+ valueKind: FieldMeta["valueKind"],
+ ops: string[],
+ defaultOp: string,
+ suggest = false,
+ options?: string[],
+): FieldMeta => ({ key, label, group, valueKind, ops, defaultOp, suggest, options });
+
+const TEXT_OPS = ["contains", "not_contains", "is", "is_not", "empty"];
+const NUM_OPS = ["between", "at_least", "at_most", "empty"];
+const DATE_OPS = ["within_days", "between", "empty"];
+
+export const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [
+ {
+ group: "People",
+ fields: [
+ F("author", "Author", "People", "text", TEXT_OPS, "contains", true),
+ F("narrator", "Narrator", "People", "text", TEXT_OPS, "contains", true),
+ F("contributor", "Contributor", "People", "text", TEXT_OPS, "contains", true),
+ ],
+ },
+ {
+ group: "Publishing",
+ fields: [
+ F("publisher", "Publisher", "Publishing", "text", TEXT_OPS, "contains", true),
+ F("imprint", "Imprint", "Publishing", "text", TEXT_OPS, "contains", true),
+ F("edition", "Edition", "Publishing", "text", TEXT_OPS, "contains", true),
+ F("published_year", "Published year", "Publishing", "number", NUM_OPS, "at_least"),
+ ],
+ },
+ {
+ group: "Topics",
+ fields: [
+ F("genre", "Genre", "Topics", "text", TEXT_OPS, "contains", true),
+ F("tags", "Tags", "Topics", "text", TEXT_OPS, "contains", true),
+ F("story_arc", "Story arc", "Topics", "text", TEXT_OPS, "contains", true),
+ F("age_rating", "Age rating", "Topics", "text", ["is", "is_not", "empty"], "is", true),
+ ],
+ },
+ {
+ group: "Book facts",
+ fields: [
+ F("title", "Title", "Book facts", "text", TEXT_OPS, "contains", true),
+ F("subtitle", "Subtitle", "Book facts", "text", TEXT_OPS, "contains", true),
+ F("description", "Description", "Book facts", "text", TEXT_OPS, "contains"),
+ F("summary", "Summary", "Book facts", "none", ["has", "missing"], "has"),
+ F("language", "Language", "Book facts", "text", TEXT_OPS, "contains", true),
+ F("page_count", "Page count", "Book facts", "number", NUM_OPS, "at_least"),
+ F("chapter_count", "Chapter count", "Book facts", "number", NUM_OPS, "at_least"),
+ F("total_characters", "Total characters", "Book facts", "number", NUM_OPS, "at_least"),
+ F("community_rating", "Community rating", "Book facts", "number", NUM_OPS, "at_least"),
+ F("added_at", "Date added", "Book facts", "days", DATE_OPS, "within_days"),
+ F("imported_at", "Date imported", "Book facts", "days", DATE_OPS, "within_days"),
+ F("web_url", "Web URL", "Book facts", "none", ["has", "missing"], "has"),
+ F("metadata_notes", "Metadata notes", "Book facts", "none", ["has", "missing"], "has"),
+ F("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
+ F("asin", "ASIN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
+ F("file_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
+ F("file_availability", "File availability", "Book facts", "fixed",
+ ["is", "is_not"], "is", false, ["available", "missing"]),
+ F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"),
+ F("added_by", "Added by", "Book facts", "id", ["is", "has", "missing"], "has", true),
+ ],
+ },
+ {
+ group: "Comics & manga",
+ fields: [
+ F("series", "Series", "Comics & manga", "text", TEXT_OPS, "contains", true),
+ F("series_number", "Series number", "Comics & manga", "number", NUM_OPS, "at_least"),
+ F("series_count", "Series count", "Comics & manga", "number", NUM_OPS, "at_least"),
+ F("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
+ F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true),
+ F("mime_type", "File type (mime)", "Comics & manga", "text", ["is", "is_not"], "is", true),
+ F("is_reflowable", "Reflowable", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
+ F("has_fixed_layout", "Fixed layout", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
+ F("is_black_and_white", "Black & white", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
+ F("manga_type", "Manga type", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
+ F("reading_direction", "Reading direction", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
+ ],
+ },
+ {
+ group: "My reading",
+ fields: [
+ F("status", "Reading status", "My reading", "fixed", ["is"], "is", false,
+ ["unread", "reading", "finished"]),
+ F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"),
+ F("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
+ F("my_rating", "My rating", "My reading", "fixed",
+ ["is", "at_least", "at_most", "unrated"], "at_least", false,
+ ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
+ F("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
+ F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
+ F("highlights", "Highlights", "My reading", "none", ["has", "missing", "within_days"], "has"),
+ F("note_text", "Note text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
+ F("highlight_text", "Highlight text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
+ F("time_spent", "Time spent (minutes)", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
+ F("pages_read", "Pages read", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
+ F("collection", "In collection", "My reading", "id",
+ ["in", "not_in", "in_any", "in_none"], "in", true),
+ ],
+ },
+ {
+ group: "Library",
+ fields: [
+ F("library", "Library", "Library", "id", ["is", "is_not"], "is", true),
+ ],
+ },
+];
+
+// fieldByKey / fieldGroupsById — lookup helpers.
+const FIELD_BY_KEY = new Map();
+for (const g of FIELD_GROUPS) {
+ for (const f of g.fields) {
+ FIELD_BY_KEY.set(f.key, f);
+ }
+}
+// Grouped ops per field, precomputed in the field's op order.
+const OPS_BY_FIELD = new Map();
+for (const [, f] of FIELD_BY_KEY) {
+ OPS_BY_FIELD.set(
+ f.key,
+ f.ops.map((k) => OPS[k]).filter(Boolean),
+ );
+}
+
+// Flat-expressible fields: key → the flat wire param a `contains`/`is`
+// rule serializes to (the PINNED deep-link contract — semantics
+// byte-identical to today's params).
+const FLAT_PARAMS: Record = {
+ author: "author_filter",
+ series: "series_filter",
+ genre: "genre_filter",
+ publisher: "publisher_filter",
+ tags: "tags_filter",
+ narrator: "narrator_filter",
+ language: "language_filter",
+ story_arc: "story_arc_filter",
+ imprint: "imprint_filter",
+ edition: "edition_filter",
+ age_rating: "age_rating_filter",
+ format_group: "format_group_filter",
+ manga_type: "manga_type_filter",
+ reading_direction: "reading_direction_filter",
+};
+
+// ─── tree state types ────────────────────────────────────────────────
+
+export interface FilterRuleState {
+ field: string;
+ op: string;
+ values: string[];
+}
+
+export interface FilterGroupState {
+ mode: "all" | "any";
+ children: (FilterRuleState | FilterGroupState)[];
+}
+
+export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState =>
+ "field" in n;
+
+// flatParamNames are the wire params the flat deep-link contract pins
+// (plus the two flat extras the builder owns).
+export const FLAT_WIRE_NAMES = [
+ ...Object.values(FLAT_PARAMS),
+ "explicit_filter",
+ "abridged_filter",
+ "year_min",
+ "year_max",
+ "has_cover",
+];
+
+// opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
+function ruleToFlatParam(rule: FilterRuleState): Record | null {
+ const joined = rule.values.join(",");
+ if (!joined) return null;
+ const flat = FLAT_PARAMS[rule.field];
+ if (flat && rule.op === "contains") return { [flat]: joined };
+ if (flat && rule.op === "is" &&
+ ["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)) {
+ return { [flat]: joined };
+ }
+ if (flat && rule.op === "is" &&
+ ["story_arc", "imprint", "edition"].includes(rule.field)) {
+ return { [flat]: joined };
+ }
+ if (rule.field === "cover") {
+ if (rule.op === "has") return { has_cover: "true" };
+ if (rule.op === "missing") return { has_cover: "false" };
+ }
+ if (rule.field === "explicit" && rule.op === "is_true") return { explicit_filter: "true" };
+ if (rule.field === "explicit" && rule.op === "is_false") return { explicit_filter: "false" };
+ if (rule.field === "abridged" && rule.op === "is_true") return { abridged_filter: "true" };
+ if (rule.field === "abridged" && rule.op === "is_false") return { abridged_filter: "false" };
+ if (rule.field === "published_year" && rule.op === "at_least") return { year_min: joined };
+ if (rule.field === "published_year" && rule.op === "at_most") return { year_max: joined };
+ if (rule.field === "published_year" && rule.op === "between") {
+ return { year_min: rule.values[0], year_max: rule.values[1] };
+ }
+ return null;
+}
+
+// isFlatable — the smart-serialization rule: a single ALL group of
+// rules, one rule per field, every rule flat-expressible.
+function isFlatable(tree: FilterGroupState): boolean {
+ if (tree.mode !== "all") return false;
+ const seen = new Set();
+ for (const child of tree.children) {
+ if (!isRule(child)) return false;
+ if (seen.has(child.field)) return false;
+ seen.add(child.field);
+ if (!ruleToFlatParam(child)) return false;
+ }
+ return true;
+}
+
+// ─── row projection (flat rendering of the nested tree) ─────────────
+
+export interface BuilderRow {
+ key: string;
+ path: number[];
+ type: "rule" | "groupOpen" | "groupClose";
+ depth: number;
+ connective: "AND" | "OR";
+ isFirst: boolean;
+ rule?: FilterRuleState;
+ group?: FilterGroupState;
+}
+
+function projectRows(
+ group: FilterGroupState,
+ path: number[],
+ depth: number,
+ out: BuilderRow[],
+): void {
+ group.children.forEach((child, idx) => {
+ const childPath = [...path, idx];
+ const key = childPath.join("-");
+ if (!isRule(child)) {
+ out.push({
+ key,
+ path: childPath,
+ type: "groupOpen",
+ depth,
+ connective: group.mode === "all" ? "AND" : "OR",
+ isFirst: idx === 0,
+ group: child,
+ });
+ projectRows(child, childPath, depth + 1, out);
+ out.push({
+ key: key + "-close",
+ path: childPath,
+ type: "groupClose",
+ depth,
+ connective: group.mode === "all" ? "AND" : "OR",
+ isFirst: idx === 0,
+ group: child,
+ });
+ return;
+ }
+ out.push({
+ key,
+ path: childPath,
+ type: "rule",
+ depth,
+ connective: group.mode === "all" ? "AND" : "OR",
+ isFirst: idx === 0,
+ rule: child,
+ });
+ });
+}
+
+// ─── the Alpine component slice ──────────────────────────────────────
+
+interface Suggestion {
+ value: string;
+ count: number;
+ display?: string;
+}
+
+export function filterBuilderData() {
+ return {
+ // Rule tree (source of truth; hidden wire inputs are synced after
+ // every mutation).
+ filterTree: { mode: "all", children: [] } as FilterGroupState,
+ matchCount: null as number | null,
+ matchCountLoading: false,
+ openMenu: "" as string, // ":field" | ":op" | ":value" | ""
+ suggestions: {} as Record,
+ suggestionsOpen: "" as string, // rowKey whose value menu is open
+ idLabels: {} as Record, // id → display, from suggestion fetches
+ _suggestTimer: null as number | null,
+ _countTimer: null as number | null,
+
+ get fieldGroups() {
+ return FIELD_GROUPS;
+ },
+
+ get builderRows(): BuilderRow[] {
+ const rows: BuilderRow[] = [];
+ projectRows(this.filterTree as FilterGroupState, [], 0, rows);
+ return rows;
+ },
+
+ // ── lookups ─────────────────────────────────────────────────────
+ fieldMeta(key: string): FieldMeta | undefined {
+ return FIELD_BY_KEY.get(key);
+ },
+ fieldLabelOf(key: string): string {
+ return FIELD_BY_KEY.get(key)?.label ?? "Field";
+ },
+ opsFor(fieldKey: string): OpMeta[] {
+ return OPS_BY_FIELD.get(fieldKey) ?? [];
+ },
+ opMeta(opKey: string): OpMeta | undefined {
+ return OPS[opKey];
+ },
+ opLabelOf(rule: FilterRuleState): string {
+ const m = OPS[rule.op];
+ return m ? m.label : rule.op;
+ },
+ isNegated(rule: FilterRuleState): boolean {
+ return OPS[rule.op]?.negated ?? false;
+ },
+ valueArity(rule: FilterRuleState): OpMeta["arity"] {
+ return OPS[rule.op]?.arity ?? "many";
+ },
+ hasValueCell(rule: FilterRuleState): boolean {
+ const field = FIELD_BY_KEY.get(rule.field);
+ if (!field) return false;
+ return field.valueKind !== "none" && this.valueArity(rule) !== "none";
+ },
+
+ // ── mutations (each ends in form sync + live count refresh) ────
+ _touch(): void {
+ this.syncFormInputs();
+ this.scheduleMatchCount();
+ },
+ ensureReadyRule(): void {
+ if (this.filterTree.children.length === 0) {
+ this.filterTree.children.push({ field: "author", op: "contains", values: [] });
+ this._touch();
+ }
+ },
+ nodeAt(path: number[]): FilterRuleState | FilterGroupState | null {
+ let node: FilterRuleState | FilterGroupState = this.filterTree;
+ for (const idx of path) {
+ if (!("children" in node)) return null;
+ const next = (node as FilterGroupState).children[idx];
+ if (!next) return null;
+ node = next;
+ }
+ return node;
+ },
+ addRule(path: number[]): void {
+ const parent = this.nodeAt(path);
+ if (!parent || !("children" in parent)) return;
+ (parent as FilterGroupState).children.push({ field: "author", op: "contains", values: [] });
+ this._touch();
+ },
+ addGroup(path: number[]): void {
+ const parent = this.nodeAt(path);
+ if (!parent || !("children" in parent)) return;
+ (parent as FilterGroupState).children.push({
+ mode: "any",
+ children: [{ field: "author", op: "contains", values: [] }],
+ });
+ this._touch();
+ },
+ removeNode(path: number[]): void {
+ if (path.length === 0) return;
+ const parentPath = path.slice(0, -1);
+ const idx = path[path.length - 1];
+ const parent = this.nodeAt(parentPath);
+ if (!parent || !("children" in parent)) return;
+ (parent as FilterGroupState).children.splice(idx, 1);
+ if (this.openMenu.startsWith(path.join("-") + ":")) this.openMenu = "";
+ this._touch();
+ },
+ setGroupMode(path: number[], mode: "all" | "any"): void {
+ const node = this.nodeAt(path);
+ if (!node || !("children" in node)) return;
+ (node as FilterGroupState).mode = mode;
+ this._touch();
+ },
+ setRuleField(path: number[], key: string): void {
+ const node = this.nodeAt(path);
+ if (!node || !isRule(node)) return;
+ if (node.field === key) {
+ this.openMenu = "";
+ return;
+ }
+ // Field switch clears the value + resets the operator to the
+ // kind's default (approved behavior).
+ node.field = key;
+ const meta = FIELD_BY_KEY.get(key);
+ node.op = meta?.defaultOp ?? "contains";
+ node.values = [];
+ this.openMenu = "";
+ delete this.suggestions[path.join("-")];
+ this._touch();
+ },
+ setRuleOp(path: number[], opKey: string): void {
+ const node = this.nodeAt(path);
+ if (!node || !isRule(node)) return;
+ node.op = opKey;
+ if (this.valueArity(node) === "none") {
+ node.values = [];
+ } else if (this.valueArity(node) === "two") {
+ while (node.values.length < 2) node.values.push("");
+ } else if (node.values.length > 1) {
+ // keep the typed values; multi stays valid for many-arity
+ } else if (node.values.length === 0) {
+ node.values = [""];
+ }
+ this.openMenu = "";
+ this._touch();
+ },
+ setRuleValue(path: number[], idx: number, value: string): void {
+ const node = this.nodeAt(path);
+ if (!node || !isRule(node)) return;
+ node.values[idx] = value;
+ this._touch();
+ if (idx === 0 && FIELD_BY_KEY.get(node.field)?.suggest) {
+ this.scheduleSuggestions(path.join("-"), path, node);
+ }
+ },
+ pickSuggestion(_rowKey: string, path: number[], s: Suggestion): void {
+ const node = this.nodeAt(path);
+ if (!node || !isRule(node)) return;
+ const meta = FIELD_BY_KEY.get(node.field);
+ this.idLabels[s.value] = s.display ?? s.value;
+ if (meta?.valueKind === "id") {
+ // id-valued: toggle the id in the comma list.
+ const ids = node.values.join(",").split(",").filter(Boolean);
+ const at = ids.indexOf(s.value);
+ if (at >= 0) {
+ ids.splice(at, 1);
+ } else {
+ ids.push(s.value);
+ }
+ node.values = [ids.join(",")];
+ } else if (this.valueArity(node) === "two") {
+ node.values[0] = s.value;
+ } else {
+ node.values = [s.value];
+ }
+ this.suggestionsOpen = "";
+ this._touch();
+ },
+ clearBuilder(): void {
+ this.filterTree = { mode: "all", children: [] };
+ this.matchCount = null;
+ this.suggestions = {};
+ this.suggestionsOpen = "";
+ this.openMenu = "";
+ this._touch();
+ },
+
+ // ── menus ───────────────────────────────────────────────────────
+ toggleMenu(menuKey: string): void {
+ this.openMenu = this.openMenu === menuKey ? "" : menuKey;
+ if (this.openMenu.endsWith(":value")) {
+ this.suggestionsOpen = this.openMenu.split(":")[0];
+ }
+ },
+
+ // ── suggestions (field-aware, under the OTHER rules) ────────────
+ scheduleSuggestions(rowKey: string, path: number[], rule: FilterRuleState): void {
+ if (this._suggestTimer !== null) window.clearTimeout(this._suggestTimer);
+ this._suggestTimer = window.setTimeout(() => {
+ void this.fetchSuggestions(rowKey, path, rule);
+ }, 250);
+ },
+ async fetchSuggestions(rowKey: string, path: number[], rule: FilterRuleState): Promise {
+ const meta = FIELD_BY_KEY.get(rule.field);
+ if (!meta || !meta.suggest) return;
+ // Fixed-option fields never hit the server.
+ if (meta.valueKind === "fixed") return;
+ const typed = rule.values[0] ?? "";
+ if (typed.trim().length < 2 && meta.valueKind !== "id") return;
+ const token = localStorage.getItem("token");
+ if (!token) return;
+ // Symmetry: the tree sent EXCLUDES the rule being edited — the
+ // menu is computed under every OTHER completed rule.
+ const others = this.treeWithout(path);
+ const params = new URLSearchParams();
+ params.set(meta.valueKind === "id" ? rule.field : rule.field, typed.trim() || " ");
+ if (others) params.set("qf", JSON.stringify(others));
+ const currentLibraryId = (
+ document.getElementById("library-select") as HTMLSelectElement
+ )?.value;
+ if (currentLibraryId) params.set("library_id", currentLibraryId);
+ params.set("limit", "8");
+ try {
+ const response = await fetch(`/api/media-items/search?${params.toString()}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!response.ok) {
+ console.warn(`suggestion fetch failed (${response.status})`);
+ return;
+ }
+ const data = (await response.json()) as { results?: Suggestion[] };
+ for (const s of data.results ?? []) {
+ if (s.display) this.idLabels[s.value] = s.display;
+ }
+ this.suggestions[rowKey] = data.results ?? [];
+ this.suggestionsOpen = rowKey;
+ } catch (error) {
+ console.warn("suggestion fetch failed", error);
+ }
+ },
+ openValueMenu(rowKey: string, path: number[], rule: FilterRuleState): void {
+ const meta = FIELD_BY_KEY.get(rule.field);
+ if (meta?.valueKind === "fixed") {
+ // Static options: no fetch.
+ this.suggestions[rowKey] = (meta.options ?? []).map((v) => ({
+ value: v,
+ count: 0,
+ }));
+ this.suggestionsOpen = rowKey;
+ return;
+ }
+ if (meta?.suggest) {
+ void this.fetchSuggestions(rowKey, path, rule);
+ return;
+ }
+ this.suggestionsOpen = rowKey;
+ },
+ suggestionShown(rowKey: string): boolean {
+ return this.suggestionsOpen === rowKey && (this.suggestions[rowKey]?.length ?? 0) > 0;
+ },
+
+ // ── live count ──────────────────────────────────────────────────
+ scheduleMatchCount(): void {
+ if (this._countTimer !== null) window.clearTimeout(this._countTimer);
+ this._countTimer = window.setTimeout(() => {
+ void this.fetchMatchCount();
+ }, 350);
+ },
+ async fetchMatchCount(): Promise {
+ const token = localStorage.getItem("token");
+ if (!token) return;
+ if (this.filterTree.children.length === 0) {
+ this.matchCount = null;
+ return;
+ }
+ const params = new URLSearchParams();
+ params.set("qf", JSON.stringify(this.filterTree));
+ const currentLibraryId = (
+ document.getElementById("library-select") as HTMLSelectElement
+ )?.value;
+ if (currentLibraryId) params.set("library_id", currentLibraryId);
+ this.matchCountLoading = true;
+ try {
+ const response = await fetch(`/api/media-items/filter-count?${params.toString()}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!response.ok) {
+ this.matchCount = null;
+ return;
+ }
+ const data = (await response.json()) as { count?: number };
+ this.matchCount = data.count ?? null;
+ } catch {
+ this.matchCount = null;
+ } finally {
+ this.matchCountLoading = false;
+ }
+ },
+
+ // ── serialization + form sync ───────────────────────────────────
+ // treeWithout returns the tree with the node at `path` removed (the
+ // "other rules" for a rule's own value menu).
+ treeWithout(path: number[]): FilterGroupState | null {
+ const clone: FilterGroupState = JSON.parse(JSON.stringify(this.filterTree));
+ if (path.length === 0) return clone;
+ const parentPath = path.slice(0, -1);
+ let node: FilterGroupState = clone;
+ for (const idx of parentPath) {
+ const next = node.children[idx];
+ if (!next || isRule(next)) return clone;
+ node = next;
+ }
+ node.children.splice(path[path.length - 1], 1);
+ return clone;
+ },
+ serializeFilters():
+ | { mode: "flat"; params: Record }
+ | { mode: "qf"; qf: string } {
+ if (this.filterTree.children.length === 0) {
+ return { mode: "flat", params: {} };
+ }
+ if (isFlatable(this.filterTree as FilterGroupState)) {
+ const params: Record = {};
+ for (const child of this.filterTree.children) {
+ if (!isRule(child)) continue;
+ Object.assign(params, ruleToFlatParam(child));
+ }
+ return { mode: "flat", params };
+ }
+ return { mode: "qf", qf: JSON.stringify(this.filterTree) };
+ },
+ syncFormInputs(): void {
+ const form = document.getElementById("filter-form") as HTMLFormElement | null;
+ if (!form) return;
+ // Drop every previously managed wire input, then write the
+ // current serialization.
+ for (const name of [...FLAT_WIRE_NAMES, "qf"]) {
+ form.querySelectorAll(`input[name="${name}"]`).forEach((el) => el.remove());
+ }
+ const s = this.serializeFilters();
+ const add = (name: string, value: string) => {
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = name;
+ input.value = value;
+ form.appendChild(input);
+ };
+ if (s.mode === "qf") {
+ add("qf", s.qf);
+ return;
+ }
+ const flatParams = s.params as Record;
+ for (const [name, value] of Object.entries(flatParams)) {
+ add(name, value);
+ }
+ },
+
+ // ── hydration (URL deep links + saved filters) ──────────────────
+ // hydrateFromForm reads the form's wire inputs into the tree: a qf
+ // input wins (pinned precedence), else the flat params become one
+ // rule each. Returns true when anything was applied.
+ hydrateFromForm(): boolean {
+ const form = document.getElementById("filter-form") as HTMLFormElement | null;
+ if (!form) return false;
+ const qfInput = form.querySelector('input[name="qf"]') as HTMLInputElement | null;
+ if (qfInput && qfInput.value) {
+ try {
+ const tree = JSON.parse(qfInput.value) as FilterGroupState;
+ if (tree && Array.isArray(tree.children)) {
+ this.filterTree = tree;
+ return true;
+ }
+ } catch {
+ // malformed qf: fall through to flat mapping
+ }
+ }
+ const rules: FilterRuleState[] = [];
+ for (const [fieldKey, wire] of Object.entries(FLAT_PARAMS)) {
+ const input = form.querySelector(`input[name="${wire}"]`) as HTMLInputElement | null;
+ if (!input || !input.value) continue;
+ const meta = FIELD_BY_KEY.get(fieldKey);
+ const op = meta && meta.ops.includes("contains") ? "contains" : "is";
+ rules.push({ field: fieldKey, op, values: [input.value] });
+ }
+ const cover = form.querySelector('input[name="has_cover"]') as HTMLInputElement | null;
+ if (cover && cover.value) {
+ rules.push({ field: "cover", op: cover.value === "true" ? "has" : "missing", values: [] });
+ }
+ const explicitIn = form.querySelector('input[name="explicit_filter"]') as HTMLInputElement | null;
+ if (explicitIn && explicitIn.value) {
+ rules.push({ field: "explicit", op: explicitIn.value === "true" ? "is_true" : "is_false", values: [] });
+ }
+ const abridgedIn = form.querySelector('input[name="abridged_filter"]') as HTMLInputElement | null;
+ if (abridgedIn && abridgedIn.value) {
+ rules.push({ field: "abridged", op: abridgedIn.value === "true" ? "is_true" : "is_false", values: [] });
+ }
+ const yearMin = form.querySelector('input[name="year_min"]') as HTMLInputElement | null;
+ if (yearMin && yearMin.value) {
+ rules.push({ field: "published_year", op: "at_least", values: [yearMin.value] });
+ }
+ const yearMax = form.querySelector('input[name="year_max"]') as HTMLInputElement | null;
+ if (yearMax && yearMax.value) {
+ rules.push({ field: "published_year", op: "at_most", values: [yearMax.value] });
+ }
+ if (rules.length === 0) return false;
+ this.filterTree = { mode: "all", children: rules };
+ return true;
+ },
+
+ // chipLabelFor — one rule's chip text.
+ chipLabelFor(rule: FilterRuleState): string {
+ const fieldLabel = this.fieldLabelOf(rule.field);
+ const opLabel = this.opLabelOf(rule);
+ const meta = FIELD_BY_KEY.get(rule.field);
+ let value = "";
+ if (this.valueArity(rule) !== "none" && meta?.valueKind !== "none") {
+ const names = rule.values
+ .join(",")
+ .split(",")
+ .filter(Boolean)
+ .map((v) => this.idLabels[v] ?? v);
+ value = names.length > 0 ? ` ${names.join(", ")}` : "";
+ if (meta?.valueKind === "days") value = ` ${rule.values[0]} days`;
+ }
+ return `${fieldLabel} ${opLabel}${value}`.trim();
+ },
+ };
+}
+
+// Component registration (main.ts imports this module for the side
+// effect, same pattern as bookshelf.ts).
+Alpine.data("filterBuilderExtra", filterBuilderData);
diff --git a/web/src/main.ts b/web/src/main.ts
index ef1a0ea5..eee45369 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -8,6 +8,7 @@ import "./api-explorer";
import "./api-explorer-docs";
import "./bookPicker";
import "./bookshelf";
+import "./filter_builder";
import "./book-detail";
import "./collection-rules";
import "./collections";
diff --git a/web/src/toast.ts b/web/src/toast.ts
index 6016b9d1..2a110f80 100644
--- a/web/src/toast.ts
+++ b/web/src/toast.ts
@@ -176,12 +176,14 @@ const setupHTMXListeners = (): void => {
});
};
-// Suggestion fetches — field-value autocomplete (author=, genre=, …)
-// on the search endpoint — are background hints for typed inputs. A
-// failed hint must never interrupt the user with a toast: it logs to
-// the console only. The grid's real search (q=/sort= params) is NOT a
-// suggestion fetch and keeps its toasts.
-const suggestionFieldRe = /[?&](author|genre|tags|narrator|series|language)=/;
+// Suggestion fetches — field-value autocomplete on the search endpoint
+// (author=, genre=, note_text=, collection=, … any filter-registry
+// field key) — are background hints for typed inputs. A failed hint
+// must never interrupt the user with a toast: it logs to the console
+// only. The grid's real search (q=/sort= params) is NOT a suggestion
+// fetch and keeps its toasts.
+const suggestionFieldRe =
+ /[?&](author|narrator|contributor|publisher|imprint|edition|genre|tags|story_arc|age_rating|title|subtitle|language|format_group|mime_type|manga_type|reading_direction|series|note_text|highlight_text|collection|library|added_by)=/;
function isSuggestionFetch(input: Parameters[0]): boolean {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/media-items/search?")) return false;
diff --git a/web/static/input.css b/web/static/input.css
index d41b4e80..2b11daf0 100644
--- a/web/static/input.css
+++ b/web/static/input.css
@@ -1909,3 +1909,126 @@
background: inherit;
transform: translateX(-50%);
}
+
+ /* ---------- Filter rule builder (filter drawer, Phase 9) ----------
+ Templ owns the markup; Alpine owns the rule-tree state. Look only:
+ rows indent by group depth, negated operators/chips run red. */
+ .fb-connective {
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+ min-width: 1.6rem;
+ }
+ .fb-picker {
+ font-size: 0.8rem;
+ padding: 0.3rem 0.6rem;
+ border-radius: 0.5rem;
+ border: 1px solid var(--border);
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ cursor: pointer;
+ white-space: nowrap;
+ }
+ .fb-picker:hover {
+ background-color: var(--surface-hover);
+ }
+ .fb-picker-active {
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
+ }
+ .fb-picker-op {
+ color: var(--text-secondary);
+ }
+ .fb-negated-text {
+ color: var(--status-danger);
+ }
+ .fb-value {
+ width: 9rem;
+ font-size: 0.8rem;
+ padding: 0.3rem 0.5rem;
+ }
+ .fb-menu {
+ position: absolute;
+ z-index: 60;
+ margin-top: 0.25rem;
+ width: 15rem;
+ max-height: 16rem;
+ overflow-y: auto;
+ border-radius: 0.5rem;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
+ background-color: var(--bg-primary);
+ border: 1px solid var(--border);
+ }
+ .fb-menu-value {
+ width: 16rem;
+ left: 0;
+ }
+ .fb-menu-group {
+ font-size: 0.65rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+ padding: 0.5rem 0.75rem 0.15rem;
+ }
+ .fb-menu-item {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ text-align: left;
+ font-size: 0.8rem;
+ padding: 0.35rem 0.75rem;
+ color: var(--text-primary);
+ cursor: pointer;
+ }
+ .fb-menu-item:hover {
+ background-color: var(--surface-hover);
+ }
+ .fb-menu-item-active {
+ color: var(--accent);
+ }
+ .fb-mode-btn {
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ padding: 0.15rem 0.5rem;
+ border-radius: 9999px;
+ border: 1px solid var(--border);
+ background-color: var(--bg-primary);
+ color: var(--text-secondary);
+ cursor: pointer;
+ }
+ .fb-mode-btn.fb-mode-active {
+ background-color: var(--accent-muted);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
+ color: var(--accent);
+ }
+ .fb-mini-btn {
+ font-size: 0.7rem;
+ padding: 0.15rem 0.5rem;
+ border-radius: 0.4rem;
+ border: 1px dashed var(--border);
+ background-color: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ white-space: nowrap;
+ }
+ .fb-mini-btn:hover {
+ background-color: var(--surface-hover);
+ color: var(--text-primary);
+ }
+ .fb-mini-btn.fb-danger {
+ border-style: solid;
+ color: var(--status-danger);
+ }
+ .fb-group-end {
+ border-left: 2px solid var(--border);
+ height: 0.5rem;
+ margin-top: -0.25rem;
+ }
+ .fb-nested {
+ border-left: 2px solid var(--border);
+ padding-left: 0.6rem;
+ }
diff --git a/web/static/style.css b/web/static/style.css
index 81e834c9..33d175c1 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
-/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{color-scheme:dark;--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-vantablack{--bg-primary:#000;--bg-secondary:#1a1a1a;--text-primary:#fff;--text-secondary:#ececec;--accent:#8d8d8d;--border:#1a1a1a}.theme-last-horizon{--bg-primary:#0c0b0c;--bg-secondary:#171516;--text-primary:#fafcfb;--text-secondary:#cfd3cd;--accent:#b59790;--border:#2b2729}.theme-ethereal{--bg-primary:#060b1e;--bg-secondary:#131a3a;--text-primary:#ffcead;--text-secondary:#c9b8a6;--accent:#7d82d9;--border:#252e56}.theme-hackerman{--bg-primary:#0b0c16;--bg-secondary:#151828;--text-primary:#ddf7ff;--text-secondary:#b5c5db;--accent:#82fb9c;--border:#1f253a}.theme-matte-black{--bg-primary:#121212;--bg-secondary:#1e1e1e;--text-primary:#bebebe;--text-secondary:#8a8a8d;--accent:#e68e0d;--border:#2a2a2a}.theme-solitude{--bg-primary:#101315;--bg-secondary:#171b1e;--text-primary:#cacccc;--text-secondary:#8a9199;--accent:#798186;--border:#262d31}.theme-retro-82{--bg-primary:#05182e;--bg-secondary:#0a2540;--text-primary:#f6dcac;--text-secondary:#a7c9c6;--accent:#faa968;--border:#134e5a}.theme-osaka-jade{--bg-primary:#111c18;--bg-secondary:#23372b;--text-primary:#c1c497;--text-secondary:#81b8a8;--accent:#509475;--border:#32473b}.theme-kanagawa{--bg-primary:#1f1f28;--bg-secondary:#223249;--text-primary:#dcd7ba;--text-secondary:#c8c093;--accent:#7e9cd8;--border:#363646}.theme-miasma{--bg-primary:#222;--bg-secondary:#2c2c2c;--text-primary:#c2c2b0;--text-secondary:#8a8a7e;--accent:#78824b;--border:#383838}.theme-lumon{--bg-primary:#16242d;--bg-secondary:#1b2d40;--text-primary:#d6e2ee;--text-secondary:#4d86b0;--accent:#8bc9eb;--border:#243d56}.theme-ristretto{--bg-primary:#2c2525;--bg-secondary:#3d2f2a;--text-primary:#e6d9db;--text-secondary:#c3b7b8;--accent:#f38d70;--border:#403e41}.theme-gruvbox{--bg-primary:#282828;--bg-secondary:#3c3836;--text-primary:#d4be98;--text-secondary:#bdae93;--accent:#7daea3;--border:#504945}.theme-everforest{--bg-primary:#2d353b;--bg-secondary:#343f44;--text-primary:#d3c6aa;--text-secondary:#9da9a0;--accent:#7fbbb3;--border:#3d484d}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{color-scheme:light;--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}.theme-tokyo-night-day{color-scheme:light;--bg-primary:#e1e2e7;--bg-secondary:#fff;--text-primary:#3760bf;--text-secondary:#6172b0;--accent:#2e7de9;--border:#c4c8da}.theme-solarized-light{color-scheme:light;--bg-primary:#fdf6e3;--bg-secondary:#eee8d5;--text-primary:#586e75;--text-secondary:#93a1a1;--accent:#268bd2;--border:#ddd6c1}.theme-one-light{color-scheme:light;--bg-primary:#fafafa;--bg-secondary:#fff;--text-primary:#383a42;--text-secondary:#696c77;--accent:#4078f2;--border:#dfdfdf}.theme-ayu-light{color-scheme:light;--bg-primary:#fafafa;--bg-secondary:#f3f4f5;--text-primary:#5c6166;--text-secondary:#8a9199;--accent:#f93;--border:#e2e3e5}.theme-github-light{color-scheme:light;--bg-primary:#fff;--bg-secondary:#f6f8fa;--text-primary:#24292f;--text-secondary:#57606a;--accent:#0969da;--border:#d0d7de}.theme-gruvbox-light{color-scheme:light;--bg-primary:#fbf1c7;--bg-secondary:#f2e5bc;--text-primary:#3c3836;--text-secondary:#7c6f64;--accent:#076678;--border:#d5c4a1}.theme-everforest-light{color-scheme:light;--bg-primary:#fdf6e3;--bg-secondary:#f4f0d9;--text-primary:#5c6a72;--text-secondary:#708089;--accent:#3a94c5;--border:#d5cfaf}.theme-kanagawa-lotus{color-scheme:light;--bg-primary:#f2ecbc;--bg-secondary:#e5ddb0;--text-primary:#545464;--text-secondary:#716e61;--accent:#4d699b;--border:#d5cea3}.theme-rose-pine{color-scheme:light;--bg-primary:#faf4ed;--bg-secondary:#fffaf3;--text-primary:#575279;--text-secondary:#797593;--accent:#56949f;--border:#dfdad9}.theme-lupine{color-scheme:light;--bg-primary:#fafafa;--bg-secondary:#fff;--text-primary:#212121;--text-secondary:#757575;--accent:#3264eb;--border:#d0d0d0}.theme-flexoki-light{color-scheme:light;--bg-primary:#fffcf0;--bg-secondary:#f2efe4;--text-primary:#100f0f;--text-secondary:#6f6e69;--accent:#205ea6;--border:#cecdc3}.theme-white{color-scheme:light;--bg-primary:#fff;--bg-secondary:#f5f5f5;--text-primary:#000;--text-secondary:grey;--accent:#6e6e6e;--border:#e8e8e8}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-ayu-light,.theme-catppuccin-latte,.theme-everforest-light,.theme-flexoki-light,.theme-github-light,.theme-gruvbox-light,.theme-kanagawa-lotus,.theme-lupine,.theme-one-light,.theme-rose-pine,.theme-solarized-light,.theme-tokyo-night-day,.theme-white{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}select option{background-color:var(--bg-secondary);color:var(--text-primary)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}.mode-toggle-active{background-color:var(--accent-muted);color:var(--text-primary)}@media (min-width:1024px){.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.\!btn{display:inline-flex!important;align-items:center!important;justify-content:center!important;gap:.5rem!important;padding:.5rem 1rem!important;border-radius:.625rem!important;font-size:.875rem!important;font-weight:500!important;line-height:1.25rem!important;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease!important;cursor:pointer!important;white-space:nowrap!important}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.\!btn:active{transform:translateY(1px)!important}.btn:active{transform:translateY(1px)}.\!btn:disabled{opacity:.5!important;cursor:not-allowed!important}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.tristate-btn{background-color:var(--bg-primary);border:1px solid var(--border)}.tristate-btn.state-null{color:var(--text-secondary)}.tristate-btn.state-true{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.tristate-btn.state-false{background-color:var(--surface-hover);color:var(--text-secondary)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood] .text-secondary{color:var(--wood-text-secondary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.reader-glass{background-color:color-mix(in srgb,var(--bg-primary) 70%,transparent);backdrop-filter:blur(18px) saturate(1.4);border-color:color-mix(in srgb,var(--border) 60%,transparent)}#reader-topbar.reader-glass{border-bottom:1px solid color-mix(in srgb,var(--border) 60%,transparent);box-shadow:0 4px 18px rgba(0,0,0,.16)}#reader-bottombar.reader-glass{border-top:1px solid color-mix(in srgb,var(--border) 60%,transparent);box-shadow:0 -4px 18px rgba(0,0,0,.16)}#reader-bottombar,#reader-topbar{transition:transform .3s ease,opacity .3s ease}#reader-chrome.chrome-hidden #reader-topbar{transform:translateY(-101%)}#reader-chrome.chrome-hidden #reader-bottombar{transform:translateY(101%)}#reader-bottombar button:hover,#reader-topbar a:hover,#reader-topbar button:hover{background-color:color-mix(in srgb,currentColor 13%,transparent)}#reader-bottombar button:focus-visible,#reader-topbar a:focus-visible,#reader-topbar button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.reader-sep{background-color:color-mix(in srgb,var(--border) 80%,transparent)}.reader-select{background-color:color-mix(in srgb,var(--bg-primary) 55%,transparent);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);color:inherit}.reader-select:hover{border-color:#3b82f6}#reader-bottombar input[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:18px;background:transparent;cursor:pointer;accent-color:#3b82f6}#reader-bottombar input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:9999px;background:color-mix(in srgb,currentColor 22%,transparent)}#reader-bottombar input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;margin-top:-5px;width:14px;height:14px;border:none;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.45);-webkit-transition:transform .12s ease;transition:transform .12s ease}#reader-bottombar input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}#reader-bottombar input[type=range]::-moz-range-track{height:4px;border-radius:9999px;background:color-mix(in srgb,currentColor 22%,transparent)}#reader-bottombar input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.45)}.drawer-scrim{position:fixed;inset:0;background-color:rgba(0,0,0,.45);z-index:45}.reader-drawer{position:fixed;top:0;bottom:0;width:340px;max-width:calc(100vw - 2rem);background-color:var(--bg-secondary);z-index:50;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom)}.reader-drawer.left{left:0;border-right:1px solid var(--border)}.reader-drawer.right{right:0;border-left:1px solid var(--border)}@media (max-width:640px){.reader-drawer{width:100%;max-width:100%}}.reader-drawer-header{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-bottom:1px solid var(--border);flex-shrink:0}.reader-drawer-body{flex:1;overflow-y:auto;padding:1rem}.reader-seg{display:inline-flex;border:1px solid color-mix(in srgb,var(--border) 70%,transparent);background-color:color-mix(in srgb,var(--bg-primary) 50%,transparent);border-radius:.5rem;overflow:hidden}.reader-seg button{padding:.25rem .6rem;font-size:.75rem;line-height:1.25rem}.reader-seg button.active{background-color:#2563eb;color:#fff}.reader-seg button.\!active{background-color:#2563eb!important;color:#fff!important}.theme-swatch{height:2.25rem;border-radius:.5rem;border:1px solid var(--border);display:flex;align-items:center;justify-content:center}.theme-swatch:hover{border-color:#3b82f6}.reader-popover{position:fixed;transform:translate(-50%,calc(-100% - 10px));background-color:color-mix(in srgb,var(--bg-primary) 85%,transparent);backdrop-filter:blur(16px) saturate(1.3);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:.75rem;box-shadow:0 8px 28px rgba(0,0,0,.35);padding:.5rem .625rem;z-index:60}.color-dot{width:1.375rem;height:1.375rem;border-radius:9999px;border:2px solid rgba(0,0,0,.25);transition:transform .12s ease}.color-dot:hover{transform:scale(1.15)}.color-dot.selected{border-color:#fff;box-shadow:0 0 0 2px #3b82f6}.reader-popover-btn{padding:.3rem;border-radius:.375rem;color:inherit}.reader-popover-btn:hover{background-color:color-mix(in srgb,currentColor 13%,transparent)}.reader-popover-btn.danger:hover{background-color:rgba(153,27,27,.6)}.adjust-handle{position:fixed;width:26px;height:26px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);background:#3b82f6;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4);pointer-events:auto;cursor:grab;touch-action:none}.adjust-handle[data-which=end]{border-radius:50% 50% 0 50%;transform:rotate(45deg)}.adjust-handle[data-dragging]{cursor:grabbing;scale:1.15}.reader-note-input{width:100%;resize:vertical;padding:.5rem;border-radius:.5rem;font-size:.875rem;color:inherit;background-color:color-mix(in srgb,var(--bg-primary) 55%,transparent);border:1px solid color-mix(in srgb,var(--border) 70%,transparent)}.reader-note-input:focus{outline:2px solid #3b82f6;outline-offset:1px}.note-rich p{margin:0}.note-rich p+p{margin-top:.6em}.note-rich h1,.note-rich h2,.note-rich h3,.note-rich h4,.note-rich h5,.note-rich h6{margin:.8em 0 .4em;font-weight:600;line-height:1.3}.note-rich h1{font-size:1.25em}.note-rich h2{font-size:1.15em}.note-rich h3,.note-rich h4{font-size:1.05em}.note-rich h5,.note-rich h6{font-size:1em}.note-rich blockquote{margin:.6em 0;padding:.1em 0 .1em .75em;border-left:3px solid color-mix(in srgb,var(--text-secondary) 55%,transparent);color:var(--text-secondary)}.note-rich ol,.note-rich ul{margin:.6em 0;padding-left:1.4em}.note-rich ul{list-style:disc}.note-rich ol{list-style:decimal}.note-rich li{margin:.15em 0}.note-rich li+li{margin-top:.25em}.note-rich li>p{margin:0}.note-rich pre{margin:.6em 0;padding:.6em .75em;overflow-x:auto;border-radius:.375rem;background-color:color-mix(in srgb,var(--bg-secondary) 80%,transparent);border:1px solid color-mix(in srgb,var(--border) 70%,transparent)}.note-rich pre code{background:none;padding:0;font-size:.9em}.note-rich code{font-size:.9em;padding:.1em .3em;border-radius:.25rem;background-color:color-mix(in srgb,var(--bg-secondary) 80%,transparent)}.note-rich a{text-decoration:underline;text-underline-offset:2px}.note-rich hr{margin:.8em 0}.note-rich table{margin:.6em 0;border-collapse:collapse;font-size:.95em}.note-rich td,.note-rich th{border:1px solid var(--border);padding:.25em .5em;text-align:left}.note-rich th{font-weight:600}.reader-tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}.reader-tabs button{flex:1;padding:.5rem .25rem;font-size:.8125rem;color:var(--text-secondary);border-bottom:2px solid transparent}.reader-tabs button.active{color:var(--text-primary);border-bottom-color:#3b82f6}.reader-tabs button.\!active{color:var(--text-primary)!important;border-bottom-color:#3b82f6!important}.reader-tab-count{display:inline-block;min-width:1.25rem;margin-left:.25rem;padding:0 .25rem;border-radius:9999px;font-size:.6875rem;line-height:1.1rem;background-color:color-mix(in srgb,currentColor 13%,transparent)}.reader-hl-row{display:flex;align-items:center;gap:.25rem}.reader-tools-popover{position:absolute;bottom:100%;right:.5rem;margin-bottom:.5rem;min-width:16rem;max-width:calc(100vw - 1.5rem);max-height:min(26rem,65vh);overflow-y:auto;background-color:color-mix(in srgb,var(--bg-primary) 88%,transparent);backdrop-filter:blur(18px) saturate(1.3);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:.75rem;box-shadow:0 -6px 28px rgba(0,0,0,.35);padding:.25rem}.reader-tools-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.5rem .75rem;font-size:.875rem;border-radius:.5rem}.reader-tools-row+.reader-tools-row{border-top:1px solid color-mix(in srgb,var(--border) 45%,transparent)}.reader-tools-row:hover{background-color:color-mix(in srgb,currentColor 6%,transparent)}#reader-view::part(filter){filter:var(--fx-filter,none)}.reader-thumb-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem}.reader-thumb{position:relative;border-radius:.375rem;overflow:hidden;border:2px solid transparent;padding:0;background-color:color-mix(in srgb,currentColor 6%,transparent)}.reader-thumb:hover{border-color:color-mix(in srgb,currentColor 30%,transparent)}.reader-thumb.active{border-color:#3b82f6}.reader-thumb.\!active{border-color:#3b82f6!important}.reader-thumb-img{aspect-ratio:3/4;display:flex;align-items:center;justify-content:center;overflow:hidden}.reader-thumb-img canvas,.reader-thumb-img img{max-width:100%;max-height:100%;-o-object-fit:contain;object-fit:contain}.reader-thumb-num{position:absolute;bottom:.2rem;right:.35rem;font-size:.65rem;line-height:1;padding:.15rem .3rem;border-radius:.25rem;background-color:rgba(0,0,0,.55);color:#fff}.reader-edge-zone{position:absolute;top:0;bottom:0;width:8%;max-width:72px;min-width:44px;z-index:10;display:none;align-items:center;cursor:pointer}.reader-edge-zone.left{left:0;justify-content:flex-start;padding-left:.75rem}.reader-edge-zone.right{right:0;justify-content:flex-end;padding-right:.75rem}.reader-edge-zone:before{content:"";position:absolute;inset:0;opacity:0;transition:opacity .15s ease}.reader-edge-zone.left:before{background:linear-gradient(90deg,hsla(0,0%,100%,.08),transparent)}.reader-edge-zone.right:before{background:linear-gradient(270deg,hsla(0,0%,100%,.08),transparent)}.reader-edge-zone .reader-icon{color:#fff;filter:drop-shadow(0 1px 3px rgba(0,0,0,.6));opacity:0;transition:opacity .15s ease}.reader-edge-zone:hover:before{opacity:1}.reader-edge-zone:hover .reader-icon{opacity:.85}@media (hover:hover) and (pointer:fine){.reader-edge-zone{display:flex}}.reader-help-backdrop{position:fixed;inset:0;background-color:rgba(0,0,0,.5);z-index:70}.reader-help{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:min(34rem,calc(100vw - 2rem));max-height:min(38rem,calc(100vh - 4rem));display:flex;flex-direction:column;background-color:color-mix(in srgb,var(--bg-primary) 90%,transparent);backdrop-filter:blur(20px) saturate(1.3);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:.875rem;box-shadow:0 16px 48px rgba(0,0,0,.45);z-index:71}.reader-help-header{display:flex;align-items:center;justify-content:space-between;padding:.875rem 1.25rem;border-bottom:1px solid var(--border);flex-shrink:0}.reader-help-body{overflow-y:auto;padding:1rem 1.25rem}.reader-help-section{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-secondary);margin:1.1rem 0 .4rem}.reader-help-body .reader-help-section:first-child{margin-top:0}.help-row{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;padding:.3rem 0;font-size:.85rem}.help-row>span:first-child{color:var(--text-primary)}.help-keys{text-align:right;font-size:.8rem;white-space:nowrap}.help-keys,.help-note{color:var(--text-secondary)}.help-note{font-size:.75rem}.kbd{display:inline-block;min-width:1.4rem;text-align:center;padding:.05rem .4rem;margin:0 .1rem;border-radius:.3rem;border:solid color-mix(in srgb,var(--border) 80%,transparent);border-width:1px 1px 2px;background-color:color-mix(in srgb,currentColor 8%,transparent);font-family:ui-monospace,monospace;font-size:.72rem;line-height:1.2rem}.reader-search-bar{display:flex;align-items:center;gap:.375rem;padding:.625rem 1rem;border-bottom:1px solid var(--border);flex-shrink:0}.reader-search-status{padding:.375rem 1rem;font-size:.75rem;color:var(--text-secondary);border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent);flex-shrink:0;min-height:1.75rem}.search-result{display:block;padding:.375rem .5rem;border-radius:.375rem;font-size:.8125rem;line-height:1.35}.search-result:hover{background-color:color-mix(in srgb,currentColor 9%,transparent)}.search-result mark{background-color:rgba(255,213,79,.4);color:inherit;border-radius:2px;padding:0 1px}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.\!visible{visibility:visible!important}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.bottom-full{bottom:100%}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-1\/2{left:50%}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-6{top:1.5rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-72{max-height:18rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[70vh\]{max-height:70vh}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[190px\]{min-width:190px}.min-w-\[200px\]{min-width:200px}.min-w-\[3\.5rem\]{min-width:3.5rem}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.min-w-\[7\.5rem\]{min-width:7.5rem}.min-w-\[7rem\]{min-width:7rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-full{--tw-translate-x:-100%}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.-translate-y-2\.5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-4{--tw-translate-y:-1rem}.-translate-y-4,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-0{--tw-translate-y:0px}.translate-y-2{--tw-translate-y:0.5rem}.rotate-180,.translate-y-2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-100,.scale-95{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-1{-moz-column-gap:.25rem;column-gap:.25rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-5{-moz-column-gap:1.25rem;column-gap:1.25rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-1{row-gap:.25rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.self-stretch{align-self:stretch}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overscroll-none{overscroll-behavior:none}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.rounded-b-2xl{border-bottom-right-radius:1.25rem;border-bottom-left-radius:1.25rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.bg-cover{background-size:cover}.bg-center{background-position:50%}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-current{accent-color:currentColor}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.invert{--tw-invert:invert(100%)}.invert,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@view-transition{navigation:auto}#selection-handles{position:fixed;inset:0;pointer-events:none;z-index:55}#selection-handles .sel-handle{position:absolute;top:0;left:0;width:12px;height:24px;pointer-events:auto;touch-action:none;background:#8ab4f8;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.45);opacity:.95}#selection-handles .sel-handle:before{content:"";position:absolute;top:-4px;left:50%;width:2px;height:5px;background:inherit;transform:translateX(-50%)}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.first\:mt-0:first-child{margin-top:0}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-red-900\/60:hover{background-color:rgba(127,29,29,.6)}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group[open] .group-open\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:translate-x-5{--tw-translate-x:1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.peer:checked~.peer-checked\:bg-brand{background-color:var(--accent)}.peer:checked~.peer-checked\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.peer:checked~.peer-checked\:ring-blue-400{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:w-96{width:24rem}.sm\:max-w-none{max-width:none}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:p-5{padding:1.25rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:gap-5{gap:1.25rem}.md\:p-6{padding:1.5rem}.md\:p-8{padding:2rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-base{font-size:1rem;line-height:1.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}
\ No newline at end of file
+/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}select:where([multiple]),select:where([size]:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}input:where([type=checkbox]),input:where([type=radio]){-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input:where([type=checkbox]):checked,input:where([type=radio]):checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=checkbox]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {input:where([type=radio]):checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):checked:focus,input:where([type=checkbox]):checked:hover,input:where([type=radio]):checked:focus,input:where([type=radio]):checked:hover{border-color:transparent;background-color:currentColor}input:where([type=checkbox]):indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {input:where([type=checkbox]):indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}input:where([type=checkbox]):indeterminate:focus,input:where([type=checkbox]):indeterminate:hover{border-color:transparent;background-color:currentColor}input:where([type=file]){background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}input:where([type=file]):focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}:root{color-scheme:dark;--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42;--status-success:#9ece6a;--status-warning:#e0af68;--status-danger:#f7768e;--status-info:#7aa2f7}.theme-tokyo-night{--bg-primary:#1a1b26;--bg-secondary:#16161e;--text-primary:#c0caf5;--text-secondary:#9aa5ce;--accent:#7aa2f7;--border:#2a2e42}.theme-dracula{--bg-primary:#282a36;--bg-secondary:#21222c;--text-primary:#f8f8f2;--text-secondary:#bfbfbf;--accent:#bd93f9;--border:#3a3d4d}.theme-nord{--bg-primary:#2e3440;--bg-secondary:#3b4252;--text-primary:#eceff4;--text-secondary:#b7c0d2;--accent:#88c0d0;--border:#46577a}.theme-solarized-dark{--bg-primary:#002b36;--bg-secondary:#073642;--text-primary:#c5d3d3;--text-secondary:#93a1a1;--accent:#2aa198;--border:#1d4b58}.theme-monokai{--bg-primary:#272822;--bg-secondary:#1e1f1c;--text-primary:#f8f8f2;--text-secondary:#c8c8b8;--accent:#a6e22e;--border:#3e3d32}.theme-one-dark-pro{--bg-primary:#282c34;--bg-secondary:#21252b;--text-primary:#d7dbe0;--text-secondary:#9da5b4;--accent:#61dafb;--border:#3a3f4b}.theme-material-dark{--bg-primary:#263238;--bg-secondary:#1e282d;--text-primary:#eff;--text-secondary:#b0bec5;--accent:#80cbc4;--border:#37474f}.theme-vantablack{--bg-primary:#000;--bg-secondary:#1a1a1a;--text-primary:#fff;--text-secondary:#ececec;--accent:#8d8d8d;--border:#1a1a1a}.theme-last-horizon{--bg-primary:#0c0b0c;--bg-secondary:#171516;--text-primary:#fafcfb;--text-secondary:#cfd3cd;--accent:#b59790;--border:#2b2729}.theme-ethereal{--bg-primary:#060b1e;--bg-secondary:#131a3a;--text-primary:#ffcead;--text-secondary:#c9b8a6;--accent:#7d82d9;--border:#252e56}.theme-hackerman{--bg-primary:#0b0c16;--bg-secondary:#151828;--text-primary:#ddf7ff;--text-secondary:#b5c5db;--accent:#82fb9c;--border:#1f253a}.theme-matte-black{--bg-primary:#121212;--bg-secondary:#1e1e1e;--text-primary:#bebebe;--text-secondary:#8a8a8d;--accent:#e68e0d;--border:#2a2a2a}.theme-solitude{--bg-primary:#101315;--bg-secondary:#171b1e;--text-primary:#cacccc;--text-secondary:#8a9199;--accent:#798186;--border:#262d31}.theme-retro-82{--bg-primary:#05182e;--bg-secondary:#0a2540;--text-primary:#f6dcac;--text-secondary:#a7c9c6;--accent:#faa968;--border:#134e5a}.theme-osaka-jade{--bg-primary:#111c18;--bg-secondary:#23372b;--text-primary:#c1c497;--text-secondary:#81b8a8;--accent:#509475;--border:#32473b}.theme-kanagawa{--bg-primary:#1f1f28;--bg-secondary:#223249;--text-primary:#dcd7ba;--text-secondary:#c8c093;--accent:#7e9cd8;--border:#363646}.theme-miasma{--bg-primary:#222;--bg-secondary:#2c2c2c;--text-primary:#c2c2b0;--text-secondary:#8a8a7e;--accent:#78824b;--border:#383838}.theme-lumon{--bg-primary:#16242d;--bg-secondary:#1b2d40;--text-primary:#d6e2ee;--text-secondary:#4d86b0;--accent:#8bc9eb;--border:#243d56}.theme-ristretto{--bg-primary:#2c2525;--bg-secondary:#3d2f2a;--text-primary:#e6d9db;--text-secondary:#c3b7b8;--accent:#f38d70;--border:#403e41}.theme-gruvbox{--bg-primary:#282828;--bg-secondary:#3c3836;--text-primary:#d4be98;--text-secondary:#bdae93;--accent:#7daea3;--border:#504945}.theme-everforest{--bg-primary:#2d353b;--bg-secondary:#343f44;--text-primary:#d3c6aa;--text-secondary:#9da9a0;--accent:#7fbbb3;--border:#3d484d}.theme-catppuccin-mocha{--bg-primary:#1e1e2e;--bg-secondary:#181825;--text-primary:#cdd6f4;--text-secondary:#a6adc8;--accent:#f38ba8;--border:#313244}.theme-catppuccin-macchiato{--bg-primary:#24273a;--bg-secondary:#1e2030;--text-primary:#cad3f5;--text-secondary:#a5adcb;--accent:#c6a0f6;--border:#363a4f}.theme-catppuccin-frappe{--bg-primary:#303446;--bg-secondary:#292c3c;--text-primary:#c6d0f5;--text-secondary:#a5adce;--accent:#ca9ee6;--border:#414559}.theme-catppuccin-latte{color-scheme:light;--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}.theme-tokyo-night-day{color-scheme:light;--bg-primary:#e1e2e7;--bg-secondary:#fff;--text-primary:#3760bf;--text-secondary:#6172b0;--accent:#2e7de9;--border:#c4c8da}.theme-solarized-light{color-scheme:light;--bg-primary:#fdf6e3;--bg-secondary:#eee8d5;--text-primary:#586e75;--text-secondary:#93a1a1;--accent:#268bd2;--border:#ddd6c1}.theme-one-light{color-scheme:light;--bg-primary:#fafafa;--bg-secondary:#fff;--text-primary:#383a42;--text-secondary:#696c77;--accent:#4078f2;--border:#dfdfdf}.theme-ayu-light{color-scheme:light;--bg-primary:#fafafa;--bg-secondary:#f3f4f5;--text-primary:#5c6166;--text-secondary:#8a9199;--accent:#f93;--border:#e2e3e5}.theme-github-light{color-scheme:light;--bg-primary:#fff;--bg-secondary:#f6f8fa;--text-primary:#24292f;--text-secondary:#57606a;--accent:#0969da;--border:#d0d7de}.theme-gruvbox-light{color-scheme:light;--bg-primary:#fbf1c7;--bg-secondary:#f2e5bc;--text-primary:#3c3836;--text-secondary:#7c6f64;--accent:#076678;--border:#d5c4a1}.theme-everforest-light{color-scheme:light;--bg-primary:#fdf6e3;--bg-secondary:#f4f0d9;--text-primary:#5c6a72;--text-secondary:#708089;--accent:#3a94c5;--border:#d5cfaf}.theme-kanagawa-lotus{color-scheme:light;--bg-primary:#f2ecbc;--bg-secondary:#e5ddb0;--text-primary:#545464;--text-secondary:#716e61;--accent:#4d699b;--border:#d5cea3}.theme-rose-pine{color-scheme:light;--bg-primary:#faf4ed;--bg-secondary:#fffaf3;--text-primary:#575279;--text-secondary:#797593;--accent:#56949f;--border:#dfdad9}.theme-lupine{color-scheme:light;--bg-primary:#fafafa;--bg-secondary:#fff;--text-primary:#212121;--text-secondary:#757575;--accent:#3264eb;--border:#d0d0d0}.theme-flexoki-light{color-scheme:light;--bg-primary:#fffcf0;--bg-secondary:#f2efe4;--text-primary:#100f0f;--text-secondary:#6f6e69;--accent:#205ea6;--border:#cecdc3}.theme-white{color-scheme:light;--bg-primary:#fff;--bg-secondary:#f5f5f5;--text-primary:#000;--text-secondary:grey;--accent:#6e6e6e;--border:#e8e8e8}body{background-color:var(--bg-primary);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--surface-hover:color-mix(in srgb,var(--text-primary) 9%,transparent);--surface-overlay:rgba(0,0,0,.55);--border-strong:color-mix(in srgb,var(--text-primary) 24%,var(--border));--accent-muted:color-mix(in srgb,var(--accent) 14%,var(--bg-secondary));--shadow-card:0 1px 2px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.08);--shadow-card-hover:0 6px 16px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);--shadow-pop:0 12px 40px rgba(0,0,0,.34),0 4px 12px rgba(0,0,0,.2);--shadow-bar:0 1px 0 rgba(0,0,0,.18),0 4px 16px rgba(0,0,0,.16)}.theme-ayu-light,.theme-catppuccin-latte,.theme-everforest-light,.theme-flexoki-light,.theme-github-light,.theme-gruvbox-light,.theme-kanagawa-lotus,.theme-lupine,.theme-one-light,.theme-rose-pine,.theme-solarized-light,.theme-tokyo-night-day,.theme-white{--shadow-card:0 1px 2px rgba(76,79,105,.08),0 1px 3px rgba(76,79,105,.06);--shadow-card-hover:0 6px 16px rgba(76,79,105,.16),0 2px 6px rgba(76,79,105,.08);--shadow-pop:0 12px 40px rgba(76,79,105,.22),0 4px 12px rgba(76,79,105,.12);--shadow-bar:0 1px 0 rgba(76,79,105,.08),0 4px 16px rgba(76,79,105,.1)}select option{background-color:var(--bg-secondary);color:var(--text-primary)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[x-cloak]{display:none!important}:root{--sidebar-w:16rem;--topbar-h:4rem}.app-sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);z-index:60;background-color:var(--bg-secondary);border-right:1px solid color-mix(in srgb,var(--text-primary) 7%,transparent);display:flex;flex-direction:column;overflow-y:auto;transform:translateX(-100%);transition:transform .25s ease}.app-sidebar.is-open{transform:translateX(0)}.app-sidebar-backdrop{position:fixed;inset:0;z-index:55;background:var(--surface-overlay);opacity:0;pointer-events:none;transition:opacity .25s ease}.app-sidebar-backdrop.is-open{opacity:1;pointer-events:auto}.sidebar-panel{background-color:color-mix(in srgb,var(--text-primary) 4%,var(--bg-secondary));border:1px solid var(--border);border-radius:.75rem;padding:.375rem}.mode-toggle-active{background-color:var(--accent-muted);color:var(--text-primary)}@media (min-width:1024px){.app-sidebar{transform:none}.app-mobile-only,.app-sidebar-backdrop{display:none!important}}.app-topbar{position:fixed;top:0;left:0;right:0;z-index:40;height:var(--topbar-h);background-color:color-mix(in srgb,var(--bg-secondary) 82%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)}body:has(.app-sidebar){padding-top:var(--topbar-h)}@media (min-width:1024px){body:has(.app-sidebar){padding-left:var(--sidebar-w)}.app-topbar{left:var(--sidebar-w)}}.app-subbar{position:sticky;top:var(--topbar-h);z-index:30}*{scrollbar-color:color-mix(in srgb,var(--text-primary) 22%,transparent) transparent}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:970px){.\!container{max-width:970px!important}.container{max-width:970px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"\201C""\201D""\2018""\2019";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-top:2em;margin-bottom:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:500;font-family:inherit;color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);font-size:.875em;border-radius:.3125rem;padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding-top:.8571429em;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){width:100%;table-layout:auto;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:rgba(17,24,39,.1);--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:hsla(0,0%,100%,.1);--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.5714286em;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--bg-secondary);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-primary);color:var(--text-primary);font-weight:400}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:""}.prose-invert :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:""}.prose-invert :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;color:inherit}.prose-invert :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--bg-secondary);color:var(--text-primary)}.\!card{background-color:var(--bg-secondary)!important;border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent)!important;border-radius:1rem!important;box-shadow:var(--shadow-card)!important}.card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;box-shadow:var(--shadow-card)}.\!btn{display:inline-flex!important;align-items:center!important;justify-content:center!important;gap:.5rem!important;padding:.5rem 1rem!important;border-radius:.625rem!important;font-size:.875rem!important;font-weight:500!important;line-height:1.25rem!important;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease!important;cursor:pointer!important;white-space:nowrap!important}.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,transform .15s ease,border-color .15s ease;cursor:pointer;white-space:nowrap}.\!btn:active{transform:translateY(1px)!important}.btn:active{transform:translateY(1px)}.\!btn:disabled{opacity:.5!important;cursor:not-allowed!important}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background-color:var(--accent);color:var(--bg-primary);box-shadow:var(--shadow-card)}.btn-primary:hover{background-color:color-mix(in srgb,var(--accent) 88%,#fff);box-shadow:var(--shadow-card-hover)}.btn-secondary{background-color:transparent;color:var(--text-primary);border:1px solid var(--border-strong)}.btn-secondary:hover{background-color:var(--surface-hover);border-color:var(--accent);color:var(--text-primary)}.btn-ghost{background-color:transparent;color:var(--text-secondary)}.btn-ghost:hover{background-color:var(--surface-hover);color:var(--text-primary)}.btn-danger{background-color:var(--status-danger);color:#fff}.btn-danger:hover{background-color:color-mix(in srgb,var(--status-danger) 88%,#fff)}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border-radius:.625rem;color:var(--text-secondary);transition:background-color .15s ease,color .15s ease;cursor:pointer}.icon-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.\!input{width:100%!important;background-color:var(--bg-primary)!important;color:var(--text-primary)!important;border:1px solid var(--border)!important;border-radius:.625rem!important;padding:.5rem .75rem!important;font-size:.875rem!important;transition:border-color .15s ease,box-shadow .15s ease!important}.input{width:100%;background-color:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border);border-radius:.625rem;padding:.5rem .75rem;font-size:.875rem;transition:border-color .15s ease,box-shadow .15s ease}.\!input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.\!input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)!important}.input::-moz-placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.input::placeholder{color:color-mix(in srgb,var(--text-secondary) 75%,transparent)}.\!input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)!important;outline:none!important}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent);outline:none}.chip{gap:.375rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;background-color:var(--accent-muted);color:var(--text-primary)}.badge,.chip{display:inline-flex;align-items:center}.badge{padding:.125rem .5rem;border-radius:.375rem;font-size:.6875rem;font-weight:600;letter-spacing:.01em}.book-card{position:relative;background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:.75rem;overflow:hidden;box-shadow:var(--shadow-card);transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}.book-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-card-hover);border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}.book-card-cover{position:relative}.book-card-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.55),transparent 55%);opacity:0;transition:opacity .18s ease;pointer-events:none}.book-card:hover .book-card-cover:after{opacity:1}.book-card-action{position:absolute;top:0;left:0;right:0;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;z-index:2;transition:opacity .18s ease}.book-card:focus-within .book-card-action,.book-card:hover .book-card-action{opacity:1}.book-card-action-btn{display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:9999px;background-color:color-mix(in srgb,var(--accent) 92%,#fff);color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.4);pointer-events:auto;text-decoration:none}.book-card-meta{background-color:var(--bg-secondary);padding:.5rem .625rem}@media (hover:none){.book-card-action{opacity:1}}.brand-gradient{background-image:linear-gradient(135deg,color-mix(in srgb,var(--accent) 28%,var(--bg-secondary)),var(--bg-secondary) 62%)}.hero-backdrop{position:absolute;inset:0;z-index:0;background-size:cover;background-position:50%;filter:blur(28px) saturate(1.4);transform:scale(1.15);opacity:.5}.hero-backdrop:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,color-mix(in srgb,var(--bg-primary) 35%,transparent),var(--bg-primary) 92%)}.stat-card{background-color:var(--bg-secondary);border:1px solid color-mix(in srgb,var(--text-primary) 6%,transparent);border-radius:1rem;padding:1.25rem;box-shadow:var(--shadow-card)}.loading-spinner{display:inline-block;width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.status-pending{background-color:color-mix(in srgb,var(--status-warning) 20%,transparent);color:var(--status-warning)}.status-processing{background-color:color-mix(in srgb,var(--status-info) 20%,transparent);color:var(--status-info)}.status-completed{background-color:color-mix(in srgb,var(--status-success) 20%,transparent);color:var(--status-success)}.status-failed{background-color:color-mix(in srgb,var(--status-danger) 20%,transparent);color:var(--status-danger)}.prose pre code.hljs{background-color:transparent!important}.prose code.hljs{background-color:var(--bg-primary)!important;padding:.2em .4em}.prose pre{background-color:var(--bg-secondary)!important}.prose .hljs{background-color:transparent!important;color:var(--text-primary)!important}.bg-theme-active,.bg-wood-active{background-color:var(--bg-primary)!important;box-shadow:inset 0 0 0 2px var(--accent)}.bg-theme-inactive,.bg-wood-inactive{background-color:var(--bg-secondary)!important}.bg-wood-dark,.bg-wood-light,.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}#collections-container.bg-wood-dark,#collections-container.bg-wood-light,#collections-container.bg-wood-mahogany,body.bg-wood-dark,body.bg-wood-light,body.bg-wood-mahogany{background-attachment:fixed;background-position:50%;background-size:cover}#collections-container[data-wood=wood-light]{--wood-text-primary:#1a1a1a;--wood-text-secondary:#4a4a4a;--wood-border:#8b5a2b}#collections-container[data-wood=wood-dark]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#7a5228}#collections-container[data-wood=wood-mahogany]{--wood-text-primary:#f5f5f5;--wood-text-secondary:silver;--wood-border:#8b3a3a}#collections-container[data-wood] a,#collections-container[data-wood] button,#collections-container[data-wood] h1,#collections-container[data-wood] h2,#collections-container[data-wood] h3,#collections-container[data-wood] p,#collections-container[data-wood] span{color:var(--wood-text-primary)!important}#collections-container[data-wood] .text-secondary{color:var(--wood-text-secondary)!important}#collections-container[data-wood=wood-light] a{color:#06c!important}#collections-container[data-wood=wood-dark] a,#collections-container[data-wood=wood-mahogany] a{color:#6cf!important}#collections-container[data-wood=wood-light] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-light] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.6),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-left,#collections-container[data-wood=wood-mahogany] .carousel-nav-left{background:linear-gradient(90deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood=wood-dark] .carousel-nav-right,#collections-container[data-wood=wood-mahogany] .carousel-nav-right{background:linear-gradient(270deg,rgba(0,0,0,.3),transparent)}#collections-container[data-wood] .book-card{border:1px solid var(--wood-border)}.reader-icon{display:block;fill:none;stroke:currentColor;stroke-width:2px;stroke-linecap:round;stroke-linejoin:round}.reader-glass{background-color:color-mix(in srgb,var(--bg-primary) 70%,transparent);backdrop-filter:blur(18px) saturate(1.4);border-color:color-mix(in srgb,var(--border) 60%,transparent)}#reader-topbar.reader-glass{border-bottom:1px solid color-mix(in srgb,var(--border) 60%,transparent);box-shadow:0 4px 18px rgba(0,0,0,.16)}#reader-bottombar.reader-glass{border-top:1px solid color-mix(in srgb,var(--border) 60%,transparent);box-shadow:0 -4px 18px rgba(0,0,0,.16)}#reader-bottombar,#reader-topbar{transition:transform .3s ease,opacity .3s ease}#reader-chrome.chrome-hidden #reader-topbar{transform:translateY(-101%)}#reader-chrome.chrome-hidden #reader-bottombar{transform:translateY(101%)}#reader-bottombar button:hover,#reader-topbar a:hover,#reader-topbar button:hover{background-color:color-mix(in srgb,currentColor 13%,transparent)}#reader-bottombar button:focus-visible,#reader-topbar a:focus-visible,#reader-topbar button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.reader-sep{background-color:color-mix(in srgb,var(--border) 80%,transparent)}.reader-select{background-color:color-mix(in srgb,var(--bg-primary) 55%,transparent);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);color:inherit}.reader-select:hover{border-color:#3b82f6}#reader-bottombar input[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:18px;background:transparent;cursor:pointer;accent-color:#3b82f6}#reader-bottombar input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:9999px;background:color-mix(in srgb,currentColor 22%,transparent)}#reader-bottombar input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;margin-top:-5px;width:14px;height:14px;border:none;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.45);-webkit-transition:transform .12s ease;transition:transform .12s ease}#reader-bottombar input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}#reader-bottombar input[type=range]::-moz-range-track{height:4px;border-radius:9999px;background:color-mix(in srgb,currentColor 22%,transparent)}#reader-bottombar input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.45)}.drawer-scrim{position:fixed;inset:0;background-color:rgba(0,0,0,.45);z-index:45}.reader-drawer{position:fixed;top:0;bottom:0;width:340px;max-width:calc(100vw - 2rem);background-color:var(--bg-secondary);z-index:50;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom)}.reader-drawer.left{left:0;border-right:1px solid var(--border)}.reader-drawer.right{right:0;border-left:1px solid var(--border)}@media (max-width:640px){.reader-drawer{width:100%;max-width:100%}}.reader-drawer-header{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-bottom:1px solid var(--border);flex-shrink:0}.reader-drawer-body{flex:1;overflow-y:auto;padding:1rem}.reader-seg{display:inline-flex;border:1px solid color-mix(in srgb,var(--border) 70%,transparent);background-color:color-mix(in srgb,var(--bg-primary) 50%,transparent);border-radius:.5rem;overflow:hidden}.reader-seg button{padding:.25rem .6rem;font-size:.75rem;line-height:1.25rem}.reader-seg button.active{background-color:#2563eb;color:#fff}.reader-seg button.\!active{background-color:#2563eb!important;color:#fff!important}.theme-swatch{height:2.25rem;border-radius:.5rem;border:1px solid var(--border);display:flex;align-items:center;justify-content:center}.theme-swatch:hover{border-color:#3b82f6}.reader-popover{position:fixed;transform:translate(-50%,calc(-100% - 10px));background-color:color-mix(in srgb,var(--bg-primary) 85%,transparent);backdrop-filter:blur(16px) saturate(1.3);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:.75rem;box-shadow:0 8px 28px rgba(0,0,0,.35);padding:.5rem .625rem;z-index:60}.color-dot{width:1.375rem;height:1.375rem;border-radius:9999px;border:2px solid rgba(0,0,0,.25);transition:transform .12s ease}.color-dot:hover{transform:scale(1.15)}.color-dot.selected{border-color:#fff;box-shadow:0 0 0 2px #3b82f6}.reader-popover-btn{padding:.3rem;border-radius:.375rem;color:inherit}.reader-popover-btn:hover{background-color:color-mix(in srgb,currentColor 13%,transparent)}.reader-popover-btn.danger:hover{background-color:rgba(153,27,27,.6)}.adjust-handle{position:fixed;width:26px;height:26px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);background:#3b82f6;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4);pointer-events:auto;cursor:grab;touch-action:none}.adjust-handle[data-which=end]{border-radius:50% 50% 0 50%;transform:rotate(45deg)}.adjust-handle[data-dragging]{cursor:grabbing;scale:1.15}.reader-note-input{width:100%;resize:vertical;padding:.5rem;border-radius:.5rem;font-size:.875rem;color:inherit;background-color:color-mix(in srgb,var(--bg-primary) 55%,transparent);border:1px solid color-mix(in srgb,var(--border) 70%,transparent)}.reader-note-input:focus{outline:2px solid #3b82f6;outline-offset:1px}.note-rich p{margin:0}.note-rich p+p{margin-top:.6em}.note-rich h1,.note-rich h2,.note-rich h3,.note-rich h4,.note-rich h5,.note-rich h6{margin:.8em 0 .4em;font-weight:600;line-height:1.3}.note-rich h1{font-size:1.25em}.note-rich h2{font-size:1.15em}.note-rich h3,.note-rich h4{font-size:1.05em}.note-rich h5,.note-rich h6{font-size:1em}.note-rich blockquote{margin:.6em 0;padding:.1em 0 .1em .75em;border-left:3px solid color-mix(in srgb,var(--text-secondary) 55%,transparent);color:var(--text-secondary)}.note-rich ol,.note-rich ul{margin:.6em 0;padding-left:1.4em}.note-rich ul{list-style:disc}.note-rich ol{list-style:decimal}.note-rich li{margin:.15em 0}.note-rich li+li{margin-top:.25em}.note-rich li>p{margin:0}.note-rich pre{margin:.6em 0;padding:.6em .75em;overflow-x:auto;border-radius:.375rem;background-color:color-mix(in srgb,var(--bg-secondary) 80%,transparent);border:1px solid color-mix(in srgb,var(--border) 70%,transparent)}.note-rich pre code{background:none;padding:0;font-size:.9em}.note-rich code{font-size:.9em;padding:.1em .3em;border-radius:.25rem;background-color:color-mix(in srgb,var(--bg-secondary) 80%,transparent)}.note-rich a{text-decoration:underline;text-underline-offset:2px}.note-rich hr{margin:.8em 0}.note-rich table{margin:.6em 0;border-collapse:collapse;font-size:.95em}.note-rich td,.note-rich th{border:1px solid var(--border);padding:.25em .5em;text-align:left}.note-rich th{font-weight:600}.reader-tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}.reader-tabs button{flex:1;padding:.5rem .25rem;font-size:.8125rem;color:var(--text-secondary);border-bottom:2px solid transparent}.reader-tabs button.active{color:var(--text-primary);border-bottom-color:#3b82f6}.reader-tabs button.\!active{color:var(--text-primary)!important;border-bottom-color:#3b82f6!important}.reader-tab-count{display:inline-block;min-width:1.25rem;margin-left:.25rem;padding:0 .25rem;border-radius:9999px;font-size:.6875rem;line-height:1.1rem;background-color:color-mix(in srgb,currentColor 13%,transparent)}.reader-hl-row{display:flex;align-items:center;gap:.25rem}.reader-tools-popover{position:absolute;bottom:100%;right:.5rem;margin-bottom:.5rem;min-width:16rem;max-width:calc(100vw - 1.5rem);max-height:min(26rem,65vh);overflow-y:auto;background-color:color-mix(in srgb,var(--bg-primary) 88%,transparent);backdrop-filter:blur(18px) saturate(1.3);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:.75rem;box-shadow:0 -6px 28px rgba(0,0,0,.35);padding:.25rem}.reader-tools-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.5rem .75rem;font-size:.875rem;border-radius:.5rem}.reader-tools-row+.reader-tools-row{border-top:1px solid color-mix(in srgb,var(--border) 45%,transparent)}.reader-tools-row:hover{background-color:color-mix(in srgb,currentColor 6%,transparent)}#reader-view::part(filter){filter:var(--fx-filter,none)}.reader-thumb-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem}.reader-thumb{position:relative;border-radius:.375rem;overflow:hidden;border:2px solid transparent;padding:0;background-color:color-mix(in srgb,currentColor 6%,transparent)}.reader-thumb:hover{border-color:color-mix(in srgb,currentColor 30%,transparent)}.reader-thumb.active{border-color:#3b82f6}.reader-thumb.\!active{border-color:#3b82f6!important}.reader-thumb-img{aspect-ratio:3/4;display:flex;align-items:center;justify-content:center;overflow:hidden}.reader-thumb-img canvas,.reader-thumb-img img{max-width:100%;max-height:100%;-o-object-fit:contain;object-fit:contain}.reader-thumb-num{position:absolute;bottom:.2rem;right:.35rem;font-size:.65rem;line-height:1;padding:.15rem .3rem;border-radius:.25rem;background-color:rgba(0,0,0,.55);color:#fff}.reader-edge-zone{position:absolute;top:0;bottom:0;width:8%;max-width:72px;min-width:44px;z-index:10;display:none;align-items:center;cursor:pointer}.reader-edge-zone.left{left:0;justify-content:flex-start;padding-left:.75rem}.reader-edge-zone.right{right:0;justify-content:flex-end;padding-right:.75rem}.reader-edge-zone:before{content:"";position:absolute;inset:0;opacity:0;transition:opacity .15s ease}.reader-edge-zone.left:before{background:linear-gradient(90deg,hsla(0,0%,100%,.08),transparent)}.reader-edge-zone.right:before{background:linear-gradient(270deg,hsla(0,0%,100%,.08),transparent)}.reader-edge-zone .reader-icon{color:#fff;filter:drop-shadow(0 1px 3px rgba(0,0,0,.6));opacity:0;transition:opacity .15s ease}.reader-edge-zone:hover:before{opacity:1}.reader-edge-zone:hover .reader-icon{opacity:.85}@media (hover:hover) and (pointer:fine){.reader-edge-zone{display:flex}}.reader-help-backdrop{position:fixed;inset:0;background-color:rgba(0,0,0,.5);z-index:70}.reader-help{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:min(34rem,calc(100vw - 2rem));max-height:min(38rem,calc(100vh - 4rem));display:flex;flex-direction:column;background-color:color-mix(in srgb,var(--bg-primary) 90%,transparent);backdrop-filter:blur(20px) saturate(1.3);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:.875rem;box-shadow:0 16px 48px rgba(0,0,0,.45);z-index:71}.reader-help-header{display:flex;align-items:center;justify-content:space-between;padding:.875rem 1.25rem;border-bottom:1px solid var(--border);flex-shrink:0}.reader-help-body{overflow-y:auto;padding:1rem 1.25rem}.reader-help-section{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-secondary);margin:1.1rem 0 .4rem}.reader-help-body .reader-help-section:first-child{margin-top:0}.help-row{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;padding:.3rem 0;font-size:.85rem}.help-row>span:first-child{color:var(--text-primary)}.help-keys{text-align:right;font-size:.8rem;white-space:nowrap}.help-keys,.help-note{color:var(--text-secondary)}.help-note{font-size:.75rem}.kbd{display:inline-block;min-width:1.4rem;text-align:center;padding:.05rem .4rem;margin:0 .1rem;border-radius:.3rem;border:solid color-mix(in srgb,var(--border) 80%,transparent);border-width:1px 1px 2px;background-color:color-mix(in srgb,currentColor 8%,transparent);font-family:ui-monospace,monospace;font-size:.72rem;line-height:1.2rem}.reader-search-bar{display:flex;align-items:center;gap:.375rem;padding:.625rem 1rem;border-bottom:1px solid var(--border);flex-shrink:0}.reader-search-status{padding:.375rem 1rem;font-size:.75rem;color:var(--text-secondary);border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent);flex-shrink:0;min-height:1.75rem}.search-result{display:block;padding:.375rem .5rem;border-radius:.375rem;font-size:.8125rem;line-height:1.35}.search-result:hover{background-color:color-mix(in srgb,currentColor 9%,transparent)}.search-result mark{background-color:rgba(255,213,79,.4);color:inherit;border-radius:2px;padding:0 1px}.stacked-covers{position:relative;width:100%;aspect-ratio:2/3;overflow:hidden}.stacked-covers .cover-img{position:absolute;width:70%;height:90%;-o-object-fit:cover;object-fit:cover;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.4)}.stacked-covers.cover-count-1 .cover-0{top:5%;left:15%}.stacked-covers.cover-count-2 .cover-0{top:8%;left:5%;transform:rotate(-6deg);z-index:1}.stacked-covers.cover-count-2 .cover-1{top:2%;left:25%;transform:rotate(3deg);z-index:2}.stacked-covers.cover-count-3 .cover-0{top:12%;left:2%;transform:rotate(-8deg);z-index:1}.stacked-covers.cover-count-3 .cover-1{top:6%;left:15%;transform:rotate(-2deg);z-index:2}.stacked-covers.cover-count-3 .cover-2{top:0;left:30%;transform:rotate(4deg);z-index:3}.stacked-covers.cover-count-4 .cover-0{top:14%;left:0;width:65%;height:85%;transform:rotate(-10deg);z-index:1}.stacked-covers.cover-count-4 .cover-1{top:10%;left:12%;width:65%;height:85%;transform:rotate(-4deg);z-index:2}.stacked-covers.cover-count-4 .cover-2{top:5%;left:22%;width:65%;height:85%;transform:rotate(2deg);z-index:3}.stacked-covers.cover-count-4 .cover-3{top:0;left:32%;width:65%;height:85%;transform:rotate(6deg);z-index:4}.stacked-covers.cover-count-5 .cover-0,.stacked-covers.cover-count-6 .cover-0,.stacked-covers.cover-count-7 .cover-0{top:16%;left:-2%;width:62%;height:82%;transform:rotate(-12deg);z-index:1}.stacked-covers.cover-count-5 .cover-1,.stacked-covers.cover-count-6 .cover-1,.stacked-covers.cover-count-7 .cover-1{top:12%;left:8%;width:62%;height:82%;transform:rotate(-6deg);z-index:2}.stacked-covers.cover-count-5 .cover-2,.stacked-covers.cover-count-6 .cover-2,.stacked-covers.cover-count-7 .cover-2{top:8%;left:17%;width:62%;height:82%;transform:rotate(-1deg);z-index:3}.stacked-covers.cover-count-5 .cover-3,.stacked-covers.cover-count-6 .cover-3,.stacked-covers.cover-count-7 .cover-3{top:4%;left:26%;width:62%;height:82%;transform:rotate(4deg);z-index:4}.stacked-covers.cover-count-5 .cover-4,.stacked-covers.cover-count-6 .cover-4,.stacked-covers.cover-count-7 .cover-4{top:0;left:35%;width:62%;height:82%;transform:rotate(9deg);z-index:5}.stacked-covers.cover-count-6 .cover-5,.stacked-covers.cover-count-7 .cover-5{top:-2%;left:30%;width:60%;height:80%;transform:rotate(13deg);z-index:6}.stacked-covers.cover-count-7 .cover-6{top:-4%;left:25%;width:58%;height:78%;transform:rotate(16deg);z-index:7}.series-card{transition:transform .2s ease,box-shadow .2s ease}.series-card:hover{transform:translateY(-4px)}@keyframes skeleton-shimmer{to{transform:translateX(100%)}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.\!visible{visibility:visible!important}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.bottom-full{bottom:100%}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.left-1\/2{left:50%}.left-3{left:.75rem}.left-4{left:1rem}.right-0{right:0}.right-1{right:.25rem}.right-2{right:.5rem}.right-4{right:1rem}.right-5{right:1.25rem}.top-0{top:0}.top-0\.5{top:.125rem}.top-1{top:.25rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2{top:.5rem}.top-4{top:1rem}.top-5{top:1.25rem}.top-6{top:1.5rem}.top-\[4\.75rem\]{top:4.75rem}.top-full{top:100%}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[65\]{z-index:65}.z-\[70\]{z-index:70}.z-\[9999\]{z-index:9999}.col-span-full{grid-column:1/-1}.-m-2{margin:-.5rem}.-m-4{margin:-1rem}.m-1{margin:.25rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-3{margin-left:.75rem;margin-right:.75rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1\.5{margin-top:.375rem;margin-bottom:.375rem}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-1{-webkit-line-clamp:1}.line-clamp-1,.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.\!grid{display:grid!important}.grid{display:grid}.inline-grid{display:inline-grid}.contents{display:contents}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.aspect-\[3\/4\]{aspect-ratio:3/4}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-32{max-height:8rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-64{max-height:16rem}.max-h-72{max-height:18rem}.max-h-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.max-h-\[70vh\]{max-height:70vh}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-\[150px\]{min-height:150px}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-auto{width:auto}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[150px\]{min-width:150px}.min-w-\[190px\]{min-width:190px}.min-w-\[200px\]{min-width:200px}.min-w-\[3\.5rem\]{min-width:3.5rem}.min-w-\[300px\]{min-width:300px}.min-w-\[3rem\]{min-width:3rem}.min-w-\[4rem\]{min-width:4rem}.min-w-\[7\.5rem\]{min-width:7.5rem}.min-w-\[7rem\]{min-width:7rem}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[500px\]{max-width:500px}.max-w-\[5rem\]{max-width:5rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-full{--tw-translate-x:-100%}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.-translate-y-2\.5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-4{--tw-translate-y:-1rem}.-translate-y-4,.-translate-y-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-5{--tw-translate-y:-1.25rem}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-7{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-7{--tw-translate-x:1.75rem}.translate-x-full{--tw-translate-x:100%}.translate-x-full,.translate-y-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-0{--tw-translate-y:0px}.translate-y-2{--tw-translate-y:0.5rem}.rotate-180,.translate-y-2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-100,.scale-95{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-move{cursor:move}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.resize{resize:both}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-start{scroll-snap-align:start}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-1{-moz-column-gap:.25rem;column-gap:.25rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-5{-moz-column-gap:1.25rem;column-gap:1.25rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-1{row-gap:.25rem}.gap-y-4{row-gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.self-stretch{align-self:stretch}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overscroll-none{overscroll-behavior:none}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.875rem}.rounded-b-2xl{border-bottom-right-radius:1.25rem;border-bottom-left-radius:1.25rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-\[var\(--border\)\]{border-color:var(--border)}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-line{border-color:var(--border)}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-t-transparent{border-top-color:transparent}.bg-\[var\(--accent\)\]{background-color:var(--accent)}.bg-\[var\(--bg-secondary\)\]{background-color:var(--bg-secondary)}.bg-\[var\(--border\)\]{background-color:var(--border)}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/90{background-color:rgba(59,130,246,.9)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-500\/90{background-color:rgba(34,197,94,.9)}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/90{background-color:rgba(239,68,68,.9)}.bg-surface-hover{background-color:var(--surface-hover)}.bg-surface-raised{background-color:var(--bg-secondary)}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/20{background-color:hsla(0,0%,100%,.2)}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-l{background-image:linear-gradient(to left,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-wood-dark{background-image:url(/static/textures/wood-dark.png)}.bg-wood-light{background-image:url(/static/textures/wood-light.png)}.bg-wood-mahogany{background-image:url(/static/textures/wood-mahogany.png)}.from-gray-700{--tw-gradient-from:#374151 var(--tw-gradient-from-position);--tw-gradient-to:rgba(55,65,81,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from:#111827 var(--tw-gradient-from-position);--tw-gradient-to:rgba(17,24,39,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-900{--tw-gradient-to:#111827 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.bg-cover{background-size:cover}.bg-center{background-position:50%}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-20{padding-bottom:5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pl-1{padding-left:.25rem}.pl-10{padding-left:2.5rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-7{padding-right:1.75rem}.pr-8{padding-right:2rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-\[env\(safe-area-inset-top\)\]{padding-top:env(safe-area-inset-top)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[var\(--bg-primary\)\]{color:var(--bg-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-content-muted{color:var(--text-secondary)}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-current{accent-color:currentColor}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-black\/10{--tw-ring-color:rgba(0,0,0,.1)}.ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.invert{--tw-invert:invert(100%)}.invert,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.sepia{--tw-sepia:sepia(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}@view-transition{navigation:auto}#selection-handles{position:fixed;inset:0;pointer-events:none;z-index:55}#selection-handles .sel-handle{position:absolute;top:0;left:0;width:12px;height:24px;pointer-events:auto;touch-action:none;background:#8ab4f8;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.45);opacity:.95}#selection-handles .sel-handle:before{content:"";position:absolute;top:-4px;left:50%;width:2px;height:5px;background:inherit;transform:translateX(-50%)}.fb-connective{font-size:.65rem;font-weight:700;letter-spacing:.05em;color:var(--text-secondary);min-width:1.6rem}.fb-picker{font-size:.8rem;padding:.3rem .6rem;border-radius:.5rem;border:1px solid var(--border);background-color:var(--bg-primary);color:var(--text-primary);cursor:pointer;white-space:nowrap}.fb-picker:hover{background-color:var(--surface-hover)}.fb-picker-active{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))}.fb-picker-op{color:var(--text-secondary)}.fb-negated-text{color:var(--status-danger)}.fb-value{width:9rem;font-size:.8rem;padding:.3rem .5rem}.fb-menu{position:absolute;z-index:60;margin-top:.25rem;width:15rem;max-height:16rem;overflow-y:auto;border-radius:.5rem;box-shadow:0 8px 24px rgba(0,0,0,.25);background-color:var(--bg-primary);border:1px solid var(--border)}.fb-menu-value{width:16rem;left:0}.fb-menu-group{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);padding:.5rem .75rem .15rem}.fb-menu-item{display:flex;width:100%;justify-content:space-between;align-items:center;gap:.5rem;text-align:left;font-size:.8rem;padding:.35rem .75rem;color:var(--text-primary);cursor:pointer}.fb-menu-item:hover{background-color:var(--surface-hover)}.fb-menu-item-active{color:var(--accent)}.fb-mode-btn{font-size:.65rem;font-weight:700;letter-spacing:.05em;padding:.15rem .5rem;border-radius:9999px;border:1px solid var(--border);background-color:var(--bg-primary);color:var(--text-secondary);cursor:pointer}.fb-mode-btn.fb-mode-active{background-color:var(--accent-muted);border-color:color-mix(in srgb,var(--accent) 45%,var(--border));color:var(--accent)}.fb-mini-btn{font-size:.7rem;padding:.15rem .5rem;border-radius:.4rem;border:1px dashed var(--border);background-color:transparent;color:var(--text-secondary);cursor:pointer;white-space:nowrap}.fb-mini-btn:hover{background-color:var(--surface-hover);color:var(--text-primary)}.fb-mini-btn.fb-danger{border-style:solid;color:var(--status-danger)}.fb-group-end{height:.5rem;margin-top:-.25rem}.fb-group-end,.fb-nested{border-left:2px solid var(--border)}.fb-nested{padding-left:.6rem}@media (min-width:768px){.md\:container{width:100%}@media (min-width:640px){.md\:container{max-width:640px}}@media (min-width:768px){.md\:container{max-width:768px}}@media (min-width:970px){.md\:container{max-width:970px}}@media (min-width:1024px){.md\:container{max-width:1024px}}@media (min-width:1280px){.md\:container{max-width:1280px}}@media (min-width:1536px){.md\:container{max-width:1536px}}}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.first\:mt-0:first-child{margin-top:0}.hover\:border-opacity-50:hover{--tw-border-opacity:0.5}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-red-900\/60:hover{background-color:rgba(127,29,29,.6)}.hover\:bg-surface-hover:hover{background-color:var(--surface-hover)}.hover\:bg-white\/30:hover{background-color:hsla(0,0%,100%,.3)}.hover\:bg-opacity-50:hover{--tw-bg-opacity:0.5}.hover\:bg-opacity-80:hover{--tw-bg-opacity:0.8}.hover\:text-brand:hover{color:var(--accent)}.hover\:text-content:hover{color:var(--text-primary)}.hover\:text-green-700:hover{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-blue-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.hover\:ring-green-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.hover\:ring-purple-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.hover\:ring-red-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.hover\:ring-yellow-400:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.hover\:ring-offset-2:hover{--tw-ring-offset-width:2px}.hover\:ring-offset-transparent:hover{--tw-ring-offset-color:transparent}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group[open] .group-open\:rotate-180{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:pointer-events-auto{pointer-events:auto}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:translate-x-5{--tw-translate-x:1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.peer:checked~.peer-checked\:bg-brand{background-color:var(--accent)}.peer:checked~.peer-checked\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.peer:checked~.peer-checked\:ring-blue-400{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-20{width:5rem}.sm\:w-40{width:10rem}.sm\:w-96{width:24rem}.sm\:max-w-none{max-width:none}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-1{gap:.25rem}.sm\:truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm\:p-2{padding:.5rem}.sm\:p-5{padding:1.25rem}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-20{padding-top:5rem;padding-bottom:5rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:mx-0{margin-left:0;margin-right:0}.md\:mx-auto{margin-left:auto;margin-right:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:h-80{height:20rem}.md\:w-56{width:14rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:gap-5{gap:1.25rem}.md\:p-6{padding:1.5rem}.md\:p-8{padding:2rem}.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-base{font-size:1rem;line-height:1.5rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:sticky{position:sticky}.lg\:bottom-auto{bottom:auto}.lg\:left-auto{left:auto}.lg\:top-20{top:5rem}.lg\:z-10{z-index:10}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:hidden{display:none}.lg\:h-\[calc\(100vh-6rem\)\]{height:calc(100vh - 6rem)}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:border-r-0{border-right-width:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-36{padding-top:9rem;padding-bottom:9rem}}@media (min-width:1280px){.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}
\ No newline at end of file